The module is one Go binary the runtime launches: the renderer (its instruction file held byte for byte to the TypeScript one it replaces), the credentials and identity files, the licence flow of ADR 0206 and the MCP servers in state. Keeps the TypeScript module's key files, so a node moving to it keeps its key. The npm package, its tests and its build go. Both binaries were run together under the real runtime on a test bus with postgres and a stub vendor: a login was adopted by one exchange, the node bound and handed an access token, its file left with no refresh token, and no token in either state.
199 lines
5.8 KiB
Go
199 lines
5.8 KiB
Go
package main
|
|
|
|
// The agent's credentials file, and whether an offered grant may replace what it holds (novox/hq ADR 0183,
|
|
// ADR 0206, design 36 §5). Pure where it decides, so the rules are tested without a file.
|
|
//
|
|
// The file is the vendor's: `{ claudeAiOauth: { accessToken, expiresAt, refreshTokenExpiresAt?, scopes?,
|
|
// subscriptionType?, rateLimitTier? }, ... }`. A node bound to a licence never holds a refresh token, so
|
|
// the one this module writes never carries one; a refresh token found there is a person's login.
|
|
//
|
|
// The lineage rule is the predecessor's, with the incidents that earned it: a rotation of the same licence
|
|
// is applied only if newer; a grant re-issued by a login is adopted whatever its expiry; a switch to another
|
|
// licence is applied regardless, because across licences the expiries are unrelated numbers.
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
)
|
|
|
|
// Grant is what the manager hands a node: an access token and its expiries, never a refresh token.
|
|
type Grant struct {
|
|
AccessToken string `json:"accessToken"`
|
|
ExpiresAt int64 `json:"expiresAt"`
|
|
RefreshTokenExpiresAt *int64 `json:"refreshTokenExpiresAt,omitempty"`
|
|
Scopes []string `json:"scopes,omitempty"`
|
|
SubscriptionType string `json:"subscriptionType,omitempty"`
|
|
RateLimitTier string `json:"rateLimitTier,omitempty"`
|
|
}
|
|
|
|
// Decision is whether a handed grant is applied, and why not.
|
|
type Decision struct {
|
|
Apply bool
|
|
Reissued bool
|
|
Reason string // already-current | not-newer
|
|
}
|
|
|
|
// generationTolerance: two refresh-token expiries within a day are one lineage; a login starts a fresh
|
|
// window weeks away.
|
|
const generationTolerance = 24 * 60 * 60 * 1000
|
|
|
|
func sameGeneration(a, b *int64) bool {
|
|
if a == nil || b == nil {
|
|
return true
|
|
}
|
|
return math.Abs(float64(*a-*b)) <= generationTolerance
|
|
}
|
|
|
|
// DecideApply says whether an offered grant replaces the one held; switch is a move to another licence.
|
|
func DecideApply(local *Grant, offered Grant, switching bool) Decision {
|
|
if local == nil || local.AccessToken == "" {
|
|
return Decision{Apply: true}
|
|
}
|
|
if local.AccessToken == offered.AccessToken {
|
|
return Decision{Reason: "already-current"}
|
|
}
|
|
reissued := !sameGeneration(local.RefreshTokenExpiresAt, offered.RefreshTokenExpiresAt)
|
|
if !switching && !reissued && local.ExpiresAt >= offered.ExpiresAt {
|
|
return Decision{Reason: "not-newer"}
|
|
}
|
|
return Decision{Apply: true, Reissued: reissued}
|
|
}
|
|
|
|
// Credentials is the file as found, every key kept — the vendor's other keys are not this module's.
|
|
type Credentials map[string]any
|
|
|
|
func (c Credentials) oauth() map[string]any {
|
|
o, _ := c["claudeAiOauth"].(map[string]any)
|
|
return o
|
|
}
|
|
|
|
// ReadCredentials reads the file, keeping numbers as written; nil when there is none.
|
|
func ReadCredentials(path string) Credentials {
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
|
dec.UseNumber()
|
|
var c Credentials
|
|
if dec.Decode(&c) != nil {
|
|
return nil
|
|
}
|
|
return c
|
|
}
|
|
|
|
func number(v any) (int64, bool) {
|
|
switch n := v.(type) {
|
|
case json.Number:
|
|
i, err := n.Int64()
|
|
if err != nil {
|
|
f, err := n.Float64()
|
|
return int64(f), err == nil
|
|
}
|
|
return i, true
|
|
case float64:
|
|
return int64(n), true
|
|
case int64:
|
|
return n, true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// GrantOf is the grant the file holds, or nil.
|
|
func GrantOf(c Credentials) *Grant {
|
|
o := c.oauth()
|
|
at, _ := o["accessToken"].(string)
|
|
if at == "" {
|
|
return nil
|
|
}
|
|
g := &Grant{AccessToken: at}
|
|
g.ExpiresAt, _ = number(o["expiresAt"])
|
|
if v, ok := number(o["refreshTokenExpiresAt"]); ok {
|
|
g.RefreshTokenExpiresAt = &v
|
|
}
|
|
return g
|
|
}
|
|
|
|
// HoldsLogin says the file holds a refresh token — which this module never writes, so a person's login.
|
|
func HoldsLogin(c Credentials) bool {
|
|
rt, _ := c.oauth()["refreshToken"].(string)
|
|
return rt != ""
|
|
}
|
|
|
|
// RefreshTokenOf is the refresh token a login left, or "".
|
|
func RefreshTokenOf(c Credentials) string {
|
|
rt, _ := c.oauth()["refreshToken"].(string)
|
|
return rt
|
|
}
|
|
|
|
// WithGrant lays the handed grant over what is there, and deletes any refresh token.
|
|
func WithGrant(local Credentials, g Grant) Credentials {
|
|
next := Credentials{}
|
|
for k, v := range local {
|
|
next[k] = v
|
|
}
|
|
oauth := map[string]any{}
|
|
for k, v := range local.oauth() {
|
|
oauth[k] = v
|
|
}
|
|
oauth["accessToken"] = g.AccessToken
|
|
oauth["expiresAt"] = g.ExpiresAt
|
|
if g.RefreshTokenExpiresAt != nil {
|
|
oauth["refreshTokenExpiresAt"] = *g.RefreshTokenExpiresAt
|
|
}
|
|
if len(g.Scopes) > 0 {
|
|
oauth["scopes"] = g.Scopes
|
|
}
|
|
if g.SubscriptionType != "" {
|
|
oauth["subscriptionType"] = g.SubscriptionType
|
|
}
|
|
if g.RateLimitTier != "" {
|
|
oauth["rateLimitTier"] = g.RateLimitTier
|
|
}
|
|
delete(oauth, "refreshToken")
|
|
next["claudeAiOauth"] = oauth
|
|
return next
|
|
}
|
|
|
|
// ReplacedBy is the handed grant in place of the old licence's, whole — scopes and subscription included;
|
|
// only keys outside the grant stay. No refresh token survives.
|
|
func ReplacedBy(local Credentials, g Grant) Credentials {
|
|
next := Credentials{}
|
|
for k, v := range local {
|
|
if k != "claudeAiOauth" {
|
|
next[k] = v
|
|
}
|
|
}
|
|
return WithGrant(next, g)
|
|
}
|
|
|
|
// WriteCredentials writes atomically at 0600: a partial credentials file must never be read as a whole one.
|
|
func WriteCredentials(path string, c Credentials) error {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
return err
|
|
}
|
|
raw, err := indented(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmp := path + ".mesh-tmp"
|
|
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, path)
|
|
}
|
|
|
|
// indented is JSON as the agent's own files are written: two-space indent, a trailing newline, nothing
|
|
// escaped that need not be.
|
|
func indented(v any) ([]byte, error) {
|
|
var b bytes.Buffer
|
|
enc := json.NewEncoder(&b)
|
|
enc.SetEscapeHTML(false)
|
|
enc.SetIndent("", " ")
|
|
err := enc.Encode(v)
|
|
return b.Bytes(), err
|
|
}
|