claude-code in Go (operator: always Go)
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.
This commit is contained in:
@@ -0,0 +1,364 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var now = time.Now().UnixMilli()
|
||||
|
||||
func node(t *testing.T, name string) (Paths, map[string]string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
p := Paths{State: filepath.Join(root, "state"), Facts: filepath.Join(root, "state", "facts.json"),
|
||||
Settings: filepath.Join(root, "state", "settings.json"), Home: filepath.Join(root, "home"), Node: name}
|
||||
_ = os.MkdirAll(p.State, 0o700)
|
||||
_ = os.MkdirAll(filepath.Join(p.Home, ".claude"), 0o700)
|
||||
_ = os.WriteFile(p.Facts, []byte(`{"node":"`+name+`","console":"http://127.0.0.1:4270/mcp"}`), 0o600)
|
||||
_ = os.WriteFile(p.Settings, []byte(`{"role":"","mcp_servers":{}}`), 0o600)
|
||||
return p, map[string]string{}
|
||||
}
|
||||
|
||||
func writer(w map[string]string) WriteManaged {
|
||||
return func(name, content string) (string, error) { w[name] = content; return name + ": written", nil }
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func creds(t *testing.T, p Paths) map[string]any {
|
||||
t.Helper()
|
||||
var c map[string]any
|
||||
raw, _ := os.ReadFile(p.credentials())
|
||||
if err := json.Unmarshal(raw, &c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c["claudeAiOauth"].(map[string]any)
|
||||
}
|
||||
|
||||
// ---- the renderer, held to the TypeScript it replaced -------------------------------------------------
|
||||
|
||||
func TestTheRendererWritesWhatTheTypeScriptOneWrote(t *testing.T) {
|
||||
raw, err := os.ReadFile("testdata/rendered-by-typescript.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var f struct {
|
||||
Facts Facts `json:"facts"`
|
||||
Settings Settings `json:"settings"`
|
||||
Registered Servers `json:"registered"`
|
||||
WithKey map[string]string `json:"withKey"`
|
||||
Plain map[string]string `json:"plain"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
same := func(label string, got, want map[string]string) {
|
||||
if got["CLAUDE.md"] != want["CLAUDE.md"] {
|
||||
t.Errorf("%s: CLAUDE.md differs from the TypeScript's:\n--- go\n%s\n--- typescript\n%s", label, got["CLAUDE.md"], want["CLAUDE.md"])
|
||||
}
|
||||
for _, file := range []string{"managed-mcp.json", "managed-settings.json"} {
|
||||
var a, b any
|
||||
_ = json.Unmarshal([]byte(got[file]), &a)
|
||||
_ = json.Unmarshal([]byte(want[file]), &b)
|
||||
if !reflect.DeepEqual(a, b) {
|
||||
t.Errorf("%s: %s means something else:\n--- go\n%s\n--- typescript\n%s", label, file, got[file], want[file])
|
||||
}
|
||||
}
|
||||
}
|
||||
same("with an API key", Render(f.Facts, f.Settings, &Binding{Licence: "api", Kind: "api-key"}, "/state/api-key-helper", f.Registered), f.WithKey)
|
||||
same("plain", Render(f.Facts, Settings{}, nil, "/h", Servers{}), f.Plain)
|
||||
}
|
||||
|
||||
func TestASettingCannotReplaceTheMeshsOwnEntryAndABadNameIsLeftOut(t *testing.T) {
|
||||
out := Render(Facts{Node: "w", Console: "http://127.0.0.1:4270/mcp"},
|
||||
Settings{MCPServers: map[string]map[string]any{"mesh": {"type": "http", "url": "http://evil"}, "bad name": {}}}, nil, "/h", nil)
|
||||
var mcp struct {
|
||||
MCPServers map[string]map[string]any `json:"mcpServers"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(out["managed-mcp.json"]), &mcp)
|
||||
if mcp.MCPServers["mesh"]["url"] != "http://127.0.0.1:4270/mcp" || mcp.MCPServers["bad name"] != nil {
|
||||
t.Fatalf("%v", mcp.MCPServers)
|
||||
}
|
||||
if !reflect.DeepEqual(Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil), Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil)) {
|
||||
t.Fatal("rendering is not deterministic")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the credentials file -----------------------------------------------------------------------------
|
||||
|
||||
func i64(v int64) *int64 { return &v }
|
||||
|
||||
func TestTheLineageRules(t *testing.T) {
|
||||
const hour = 3_600_000
|
||||
g := func(at string, exp int64, rtExp int64) Grant {
|
||||
return Grant{AccessToken: at, ExpiresAt: exp, RefreshTokenExpiresAt: i64(rtExp)}
|
||||
}
|
||||
month := now + 30*24*hour
|
||||
if d := DecideApply(&Grant{AccessToken: "A", ExpiresAt: now + hour, RefreshTokenExpiresAt: i64(month)}, g("B", now+2*hour, month), false); !d.Apply {
|
||||
t.Fatal("a newer rotation was refused")
|
||||
}
|
||||
if d := DecideApply(&Grant{AccessToken: "new", ExpiresAt: now + 2*hour, RefreshTokenExpiresAt: i64(month)}, g("old", now+hour, month), false); d.Apply || d.Reason != "not-newer" {
|
||||
t.Fatalf("a late older rotation: %+v", d)
|
||||
}
|
||||
if d := DecideApply(&Grant{AccessToken: "A", ExpiresAt: now + 8*hour, RefreshTokenExpiresAt: i64(month)}, g("re", now+hour, now+5*24*hour), false); !d.Apply || !d.Reissued {
|
||||
t.Fatalf("a re-issued grant: %+v", d)
|
||||
}
|
||||
if d := DecideApply(&Grant{AccessToken: "A", ExpiresAt: now + 8*hour}, g("other", now+hour, month), true); !d.Apply {
|
||||
t.Fatal("a switch was refused")
|
||||
}
|
||||
if d := DecideApply(&Grant{AccessToken: "A"}, Grant{AccessToken: "A"}, true); d.Apply || d.Reason != "already-current" {
|
||||
t.Fatalf("the same token: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestALoginIsSeenAndStrippedWhenTheNodesOwnGrantIsWritten(t *testing.T) {
|
||||
p, _ := node(t, "laptop")
|
||||
writeFile(t, p.credentials(), `{"claudeAiOauth":{"accessToken":"at-login","refreshToken":"rt-login","expiresAt":1700000000000},"other":1}`)
|
||||
login := ReadCredentials(p.credentials())
|
||||
if !HoldsLogin(login) {
|
||||
t.Fatal("a login was not seen")
|
||||
}
|
||||
if err := WriteCredentials(p.credentials(), WithGrant(login, Grant{AccessToken: "at-mesh", ExpiresAt: 1, Scopes: []string{"user:inference"}})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back := ReadCredentials(p.credentials())
|
||||
raw, _ := os.ReadFile(p.credentials())
|
||||
info, _ := os.Stat(p.credentials())
|
||||
if HoldsLogin(back) || GrantOf(back).AccessToken != "at-mesh" || back["other"] == nil || strings.Contains(string(raw), "rt-login") || info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("written %s (mode %v)", raw, info.Mode())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheAccountIsReadFromTheAgentsStateFileAndNeverGuessed(t *testing.T) {
|
||||
p, _ := node(t, "laptop")
|
||||
writeFile(t, p.account(), `{"oauthAccount":{"accountUuid":"u-1","emailAddress":"a@example.org"},"other":2}`)
|
||||
if id := ReadIdentity(p.account()); id == nil || id.AccountUUID != "u-1" || id.EmailAddress != "a@example.org" {
|
||||
t.Fatalf("%+v", id)
|
||||
}
|
||||
if ReadIdentity("/nonexistent/.claude.json") != nil {
|
||||
t.Fatal("an identity from nothing")
|
||||
}
|
||||
writeFile(t, p.account(), `{}`)
|
||||
if ReadIdentity(p.account()) != nil {
|
||||
t.Fatal("an identity from an empty file")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the licence, ADR 0206 ----------------------------------------------------------------------------
|
||||
|
||||
func TestWhatANodeHoldsIsReportedWithFingerprintsAndItsAccountNeverAToken(t *testing.T) {
|
||||
p, _ := node(t, "laptop")
|
||||
writeFile(t, p.credentials(), `{"claudeAiOauth":{"accessToken":"at-secret","refreshToken":"rt-secret","expiresAt":2000,"refreshTokenExpiresAt":9000}}`)
|
||||
writeFile(t, p.account(), `{"oauthAccount":{"accountUuid":"u-1","emailAddress":"a@example.org"}}`)
|
||||
h := HoldingsOf(p)
|
||||
if h.Node != "laptop" || h.Identity.AccountUUID != "u-1" || *h.Kind != "subscription" || !h.Refresh.Present ||
|
||||
!strings.HasPrefix(*h.Refresh.Fingerprint, "sha256:") || h.Access.ExpiresAt != 2000 || h.ChangedAt == nil {
|
||||
t.Fatalf("%+v", h)
|
||||
}
|
||||
raw, _ := json.Marshal(h)
|
||||
if strings.Contains(string(raw), "at-secret") || strings.Contains(string(raw), "rt-secret") {
|
||||
t.Fatalf("a token is in the report: %s", raw)
|
||||
}
|
||||
var keys map[string]any
|
||||
_ = json.Unmarshal(raw, &keys)
|
||||
for k := range keys {
|
||||
if strings.Contains(strings.ToLower(k), "token") || strings.Contains(strings.ToLower(k), "secret") {
|
||||
t.Fatalf("a field the runtime would refuse: %s", k)
|
||||
}
|
||||
}
|
||||
// The manager reads exactly this shape.
|
||||
if _, err := time.Parse(time.RFC3339Nano, *h.ChangedAt); err != nil {
|
||||
t.Fatalf("the manager cannot read the report's time: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheGrantAnswersOnlyAWaitingLoginSealedToTheManagersKey(t *testing.T) {
|
||||
p, _ := node(t, "laptop")
|
||||
manager, _ := GenerateKeyPair()
|
||||
if a, _ := GrantFor(p, manager.PublicKey); a.Sealed != nil || a.Waiting == nil || *a.Waiting {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
writeFile(t, p.credentials(), `{"claudeAiOauth":{"accessToken":"at","refreshToken":"rt-login","expiresAt":1}}`)
|
||||
writeFile(t, p.account(), `{"oauthAccount":{"accountUuid":"u-9"}}`)
|
||||
a, err := GrantFor(p, manager.PublicKey)
|
||||
if err != nil || a.Identity.AccountUUID != "u-9" {
|
||||
t.Fatalf("%+v %v", a, err)
|
||||
}
|
||||
plain, _ := Open(*a.Sealed, manager.PrivateKey)
|
||||
if !strings.Contains(plain, `"refreshToken":"rt-login"`) {
|
||||
t.Fatalf("opened %s", plain)
|
||||
}
|
||||
raw, _ := json.Marshal(a)
|
||||
if strings.Contains(string(raw), "rt-login") {
|
||||
t.Fatal("the refresh token crossed in the clear")
|
||||
}
|
||||
}
|
||||
|
||||
// seat answers `current` as the manager does: the grant sealed to the key the node sent.
|
||||
func seat(t *testing.T, licence, token string, gen int64, asked *[]string) Ask {
|
||||
return func(address string, args any) (json.RawMessage, error) {
|
||||
*asked = append(*asked, address)
|
||||
key := args.(map[string]any)["public_key"].(string)
|
||||
g, _ := json.Marshal(Grant{AccessToken: token, ExpiresAt: now + 3_600_000})
|
||||
box, err := Seal(string(g), key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return json.Marshal(Current{Licence: licence, Kind: "subscription", Generation: gen, Sealed: &box})
|
||||
}
|
||||
}
|
||||
|
||||
func TestANewerGenerationFetchesTheTokenOnceByTheSeatsVerb(t *testing.T) {
|
||||
p, w := node(t, "laptop")
|
||||
var asked []string
|
||||
ask := seat(t, "personal", "at-1", 3, &asked)
|
||||
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3}, ask, writer(w)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(asked) != 1 || asked[0] != "seat:anthropic-licence-manager.current" || creds(t, p)["accessToken"] != "at-1" {
|
||||
t.Fatalf("asked %v, credentials %v", asked, creds(t, p))
|
||||
}
|
||||
if done, _ := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3}, ask, writer(w)); done != "" || len(asked) != 1 {
|
||||
t.Fatal("an equal generation asked again")
|
||||
}
|
||||
if HoldingsOf(p).Generation != 3 || w["managed-mcp.json"] == "" {
|
||||
t.Fatal("the generation or the managed files were not written")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheTokenANodeIsHandedReplacesALoginsGrantAndLeavesNoRefreshToken(t *testing.T) {
|
||||
p, w := node(t, "laptop")
|
||||
writeFile(t, p.credentials(), `{"claudeAiOauth":{"accessToken":"at-old","refreshToken":"rt-spent","expiresAt":`+
|
||||
strings.TrimSpace(string(mustJSON(now+7_200_000)))+`}}`)
|
||||
var asked []string
|
||||
out, err := Pull(p, seat(t, "personal", "at-new", 1, &asked), writer(w))
|
||||
if err != nil || out["applied"] != true {
|
||||
t.Fatalf("%v %v", out, err)
|
||||
}
|
||||
c := creds(t, p)
|
||||
if c["accessToken"] != "at-new" || c["refreshToken"] != nil || HoldingsOf(p).Refresh.Present {
|
||||
t.Fatalf("%v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSON(v any) []byte { b, _ := json.Marshal(v); return b }
|
||||
|
||||
// ---- MCP servers in state, ADR 0201 -------------------------------------------------------------------
|
||||
|
||||
// bus is the `servers` state as every node in a test shares it, with each node's watch.
|
||||
type bus struct {
|
||||
kept map[string]map[string]any
|
||||
watchers []func(ServerChange)
|
||||
}
|
||||
|
||||
func (b *bus) Put(key string, value any) error {
|
||||
v := value.(map[string]any)
|
||||
b.kept[key] = v
|
||||
for _, w := range b.watchers {
|
||||
w(ServerChange{Key: key, Op: "put", Value: v})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bus) Delete(key string) error {
|
||||
delete(b.kept, key)
|
||||
for _, w := range b.watchers {
|
||||
w(ServerChange{Key: key, Op: "delete"})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bus) Keys() ([]string, error) {
|
||||
var out []string
|
||||
for k := range b.kept {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// join is a node joining: its view takes the current state, then every change.
|
||||
func (b *bus) join(p Paths, w map[string]string) *ServerView {
|
||||
v := NewServerView(p)
|
||||
for k, val := range b.kept {
|
||||
_, _ = OnServerChange(v, ServerChange{Key: k, Op: "put", Value: val}, p, writer(w))
|
||||
}
|
||||
b.watchers = append(b.watchers, func(c ServerChange) { _, _ = OnServerChange(v, c, p, writer(w)) })
|
||||
return v
|
||||
}
|
||||
|
||||
func noOthers() ([]string, error) { return nil, nil }
|
||||
|
||||
func TestRegisteringHerePutsItUnderThisNodesKeyAndAsksAboutTheOthers(t *testing.T) {
|
||||
p, w := node(t, "laptop")
|
||||
b := &bus{kept: map[string]map[string]any{}}
|
||||
v := b.join(p, w)
|
||||
r, err := RegisterServer(p, Registration{Name: "search", Entry: map[string]any{"type": "http", "url": "https://s.example/mcp"}}, b, v, writer(w),
|
||||
func() ([]string, error) { return []string{"laptop", "server", "desktop"}, nil })
|
||||
if err != nil || r["here"] != "changed" || !strings.Contains(r["also"].(string), "server, desktop") || b.kept["laptop.search"] == nil {
|
||||
t.Fatalf("%v %v %v", r, err, b.kept)
|
||||
}
|
||||
if !strings.Contains(w["managed-mcp.json"], `"search"`) {
|
||||
t.Fatal("not rendered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryNodeRegistrationReachesTheOthersAndALateNodeReadsIt(t *testing.T) {
|
||||
a, wa := node(t, "laptop")
|
||||
s, ws := node(t, "server")
|
||||
b := &bus{kept: map[string]map[string]any{}}
|
||||
va := b.join(a, wa)
|
||||
b.join(s, ws)
|
||||
_, _ = RegisterServer(a, Registration{Name: "docs", Entry: map[string]any{"type": "stdio", "command": "docs-mcp"}, Nodes: []string{"all"}}, b, va, writer(wa), noOthers)
|
||||
if Registered(s)["docs"] == nil || !strings.Contains(ws["managed-mcp.json"], "docs-mcp") {
|
||||
t.Fatalf("the other node did not take it: %v", Registered(s))
|
||||
}
|
||||
late, wl := node(t, "desktop")
|
||||
b.join(late, wl)
|
||||
if Registered(late)["docs"] == nil {
|
||||
t.Fatal("a node joining later did not read the current set")
|
||||
}
|
||||
_, _ = RegisterServer(a, Registration{Name: "docs", Nodes: []string{"all"}}, b, va, writer(wa), noOthers)
|
||||
if Registered(s)["docs"] != nil || Registered(late)["docs"] != nil {
|
||||
t.Fatal("an unregistration did not reach every node")
|
||||
}
|
||||
}
|
||||
|
||||
func TestANodesOwnRegistrationOverridesTheOneForEveryNode(t *testing.T) {
|
||||
a, wa := node(t, "laptop")
|
||||
s, ws := node(t, "server")
|
||||
b := &bus{kept: map[string]map[string]any{}}
|
||||
va := b.join(a, wa)
|
||||
b.join(s, ws)
|
||||
_, _ = RegisterServer(a, Registration{Name: "x", Entry: map[string]any{"type": "http", "url": "https://all"}, Nodes: []string{"all"}}, b, va, writer(wa), noOthers)
|
||||
_, _ = RegisterServer(a, Registration{Name: "x", Entry: map[string]any{"type": "http", "url": "https://laptop"}}, b, va, writer(wa), noOthers)
|
||||
if Registered(a)["x"]["url"] != "https://laptop" || Registered(s)["x"]["url"] != "https://all" {
|
||||
t.Fatalf("%v %v", Registered(a), Registered(s))
|
||||
}
|
||||
r, _ := RegisterServer(a, Registration{Name: "x"}, b, va, writer(wa), noOthers)
|
||||
if !strings.Contains(r["still"].(string), "still applies here") || Registered(a)["x"]["url"] != "https://all" {
|
||||
t.Fatalf("%v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestABadEntryIsRefusedBeforeAnythingIsPut(t *testing.T) {
|
||||
p, w := node(t, "laptop")
|
||||
b := &bus{kept: map[string]map[string]any{}}
|
||||
v := b.join(p, w)
|
||||
r, _ := RegisterServer(p, Registration{Name: "mesh", Entry: map[string]any{"type": "http", "url": "https://x"}}, b, v, writer(w), noOthers)
|
||||
if r["registered"] != false || len(b.kept) != 0 {
|
||||
t.Fatalf("%v %v", r, b.kept)
|
||||
}
|
||||
if done, _ := OnServerChange(v, ServerChange{Key: "server.b", Op: "put", Value: map[string]any{"type": "http", "url": "https://b"}}, p, writer(w)); done != "" {
|
||||
t.Fatal("another node's key changed this one")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package main
|
||||
|
||||
// Which account the agent is logged in as (novox/hq ADR 0183): not in the token, but in the agent's own
|
||||
// state file beside the home, `~/.claude.json` → `oauthAccount`. Read to report and attribute a login;
|
||||
// written, three keys and nothing else, when a licence is switched, so the account Claude Code shows is
|
||||
// the one whose token it now holds.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Identity is an account as the agent's state file names it.
|
||||
type Identity struct {
|
||||
AccountUUID string `json:"accountUuid"`
|
||||
EmailAddress string `json:"emailAddress,omitempty"`
|
||||
OrganizationUUID string `json:"organizationUuid,omitempty"`
|
||||
}
|
||||
|
||||
func readState(path string) (map[string]any, bool) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.UseNumber()
|
||||
var m map[string]any
|
||||
if dec.Decode(&m) != nil || m == nil {
|
||||
return nil, false
|
||||
}
|
||||
return m, true
|
||||
}
|
||||
|
||||
// ReadIdentity is the account the state file names, or nil — never a guess.
|
||||
func ReadIdentity(path string) *Identity {
|
||||
m, ok := readState(path)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
a, _ := m["oauthAccount"].(map[string]any)
|
||||
uuid, _ := a["accountUuid"].(string)
|
||||
if uuid == "" {
|
||||
return nil
|
||||
}
|
||||
id := &Identity{AccountUUID: uuid}
|
||||
id.EmailAddress, _ = a["emailAddress"].(string)
|
||||
id.OrganizationUUID, _ = a["organizationUuid"].(string)
|
||||
return id
|
||||
}
|
||||
|
||||
// WriteIdentity points the state file's account at id, keeping every other key as found; answers whether
|
||||
// the file changed. A file that is there and cannot be read as an object is left alone.
|
||||
func WriteIdentity(path string, id Identity) (bool, error) {
|
||||
m, ok := readState(path)
|
||||
if !ok {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return false, nil
|
||||
}
|
||||
m = map[string]any{}
|
||||
}
|
||||
current, _ := m["oauthAccount"].(map[string]any)
|
||||
if current == nil {
|
||||
current = map[string]any{}
|
||||
}
|
||||
e, _ := current["emailAddress"].(string)
|
||||
o, _ := current["organizationUuid"].(string)
|
||||
if current["accountUuid"] == id.AccountUUID && e == id.EmailAddress && o == id.OrganizationUUID {
|
||||
return false, nil
|
||||
}
|
||||
current["accountUuid"] = id.AccountUUID
|
||||
current["emailAddress"] = id.EmailAddress
|
||||
current["organizationUuid"] = id.OrganizationUUID
|
||||
m["oauthAccount"] = current
|
||||
raw, err := indented(m)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
tmp := path + ".mesh-tmp"
|
||||
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, os.Rename(tmp, path)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package main
|
||||
|
||||
// instructionsText is the managed instruction file, generated from the TypeScript renderer it replaced so
|
||||
// the file under the agent's managed directory did not change by a byte when the module moved to Go;
|
||||
// a test holds it to that renderer's own output (testdata/rendered-by-typescript.json).
|
||||
func instructionsText(node, role string) string {
|
||||
return "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalogue, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `" +
|
||||
node +
|
||||
"`\n- **Role:** " +
|
||||
role +
|
||||
"\n- The other nodes, their roles and what runs where: ask the controller (`mesh-controller.nodes`,\n `mesh-controller.node`). Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own verbs\n are `mesh-controller.<verb>`: `status`, `plan`, `node`, `assign`, `push`, `settings`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalogue.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalogue that owns it, never on disk. If\n unsure, `mesh-controller.plan` for the node says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
// claude-code's bundle (novox/hq design 36, ADR 0183, ADR 0206): a binary the node's runtime launches over
|
||||
// stdio as the operator account (ADR 0193) and is the bus for (ADR 0198). It is given its state directory
|
||||
// and two files the mesh renders into it (ADR 0192), beside the runtime's own words.
|
||||
//
|
||||
// At start it renders the agent's managed directory, reports what this node holds as the module's
|
||||
// `holdings` state and again whenever the credentials file changes, watches the licence manager's
|
||||
// `bindings` state for this node and fetches the token when it says so, and watches the module's
|
||||
// `servers` state — every node's MCP server registrations (ADR 0201). node.go holds the logic.
|
||||
//
|
||||
// stdout is the MCP channel; everything this module says, it says on stderr.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
func say(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, "[claude-code] "+format+"\n", args...)
|
||||
}
|
||||
|
||||
// writeManaged writes one managed file as root, only when its content changed. From a staged file, never
|
||||
// /dev/stdin: a child's input may be a socket, which /dev/stdin cannot open (found on the first assignment).
|
||||
func writeManaged(name, content string) (string, error) {
|
||||
path := filepath.Join(ManagedDir, name)
|
||||
if was, err := os.ReadFile(path); err == nil && string(was) == content {
|
||||
return name + ": unchanged", nil
|
||||
}
|
||||
staged, err := os.MkdirTemp("", "claude-code-")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer os.RemoveAll(staged)
|
||||
source := filepath.Join(staged, name)
|
||||
if err := os.WriteFile(source, []byte(content), 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
args := []string{"install", "-D", "-m", "0644", source, path}
|
||||
if os.Geteuid() != 0 {
|
||||
args = append([]string{"sudo", "-n"}, args...)
|
||||
}
|
||||
if out, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil {
|
||||
return "", fmt.Errorf("%s: could not be written to %s (%s); the module writes there through the operator account's passwordless sudo",
|
||||
name, ManagedDir, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return name + ": written", nil
|
||||
}
|
||||
|
||||
// ask is a tool on the bus, through the runtime: its answer is the tool's value.
|
||||
func ask(address string, args any) (json.RawMessage, error) { return stdio.Ask(address, args) }
|
||||
|
||||
// stateOf adapts the SDK's state to what node.go asks of one.
|
||||
type stateOf struct{ s stdio.KeptState }
|
||||
|
||||
func (s stateOf) Put(key string, value any) error { _, err := s.s.Put(key, value); return err }
|
||||
func (s stateOf) Delete(key string) error { return s.s.Delete(key) }
|
||||
func (s stateOf) Keys() ([]string, error) { return s.s.Keys() }
|
||||
|
||||
// nodesRunningMe is the nodes claude-code runs on, from the controller's list of modules — for the register
|
||||
// tool's question.
|
||||
func nodesRunningMe() ([]string, error) {
|
||||
raw, err := ask("seat:mesh-controller.modules", map[string]any{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var answer struct {
|
||||
Output string `json:"output"`
|
||||
}
|
||||
text := string(raw)
|
||||
if json.Unmarshal(raw, &answer) == nil && answer.Output != "" {
|
||||
text = answer.Output
|
||||
}
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
if !strings.HasPrefix(line, "claude-code ") {
|
||||
continue
|
||||
}
|
||||
_, on, ok := strings.Cut(line, " on ")
|
||||
if !ok || strings.TrimSpace(on) == "nothing" {
|
||||
return nil, nil
|
||||
}
|
||||
var out []string
|
||||
for _, n := range strings.Split(on, ",") {
|
||||
if n = strings.TrimSpace(n); n != "" {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func fingerprintOfFile(path string) any {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return Fingerprint(string(raw))
|
||||
}
|
||||
|
||||
func status(p Paths) map[string]any {
|
||||
creds := ReadCredentials(p.credentials())
|
||||
var token any
|
||||
if g := GrantOf(creds); g != nil {
|
||||
token = map[string]any{"fingerprint": Fingerprint(g.AccessToken), "expiresAt": stamp(g.ExpiresAt), "loginWaiting": HoldsLogin(creds)}
|
||||
}
|
||||
var managed []map[string]any
|
||||
for _, f := range []string{"managed-mcp.json", "managed-settings.json", "CLAUDE.md"} {
|
||||
path := filepath.Join(ManagedDir, f)
|
||||
managed = append(managed, map[string]any{"file": path, "fingerprint": fingerprintOfFile(path)})
|
||||
}
|
||||
var licence any
|
||||
var b Binding
|
||||
if readJSON(p.binding(), &b) {
|
||||
licence = b
|
||||
}
|
||||
names := []string{}
|
||||
for n := range Registered(p) {
|
||||
names = append(names, n)
|
||||
}
|
||||
return map[string]any{"node": p.Node, "licence": licence, "token": token, "holdings": HoldingsOf(p),
|
||||
"managed": managed, "registered": names}
|
||||
}
|
||||
|
||||
func str(description string) map[string]any {
|
||||
return map[string]any{"type": "string", "description": description}
|
||||
}
|
||||
|
||||
// nodesOf reads the tools' `nodes` argument: absent is this node, "all" every node, else a list.
|
||||
func nodesOf(v any) []string {
|
||||
s, _ := v.(string)
|
||||
s = strings.TrimSpace(s)
|
||||
switch s {
|
||||
case "":
|
||||
return nil
|
||||
case "all":
|
||||
return []string{"all"}
|
||||
}
|
||||
var out []string
|
||||
for _, n := range strings.Split(s, ",") {
|
||||
if n = strings.TrimSpace(n); n != "" {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func tools(p Paths, servers ServerState, view *ServerView) []stdio.Tool {
|
||||
nodesArg := str(`more nodes: "all" for every node running claude-code, or a comma-separated list; absent is this node only`)
|
||||
return []stdio.Tool{
|
||||
{Name: "claude_code_status",
|
||||
Description: "Claude Code on this machine as the mesh configured it: the licence it holds and when its token expires, what it reports holding, the managed files, the MCP servers registered here. Fingerprints only, never a token.",
|
||||
Run: func(map[string]any) (any, error) { return status(p), nil }},
|
||||
{Name: "claude_code_render",
|
||||
Description: "Write Claude Code's managed directory now, from the mesh's facts, this module's settings and the servers registered here.",
|
||||
Run: func(map[string]any) (any, error) {
|
||||
out, err := RenderNow(p, writeManaged)
|
||||
return map[string]any{"rendered": out}, err
|
||||
}},
|
||||
{Name: "claude_code_pull",
|
||||
Description: "Ask the licence manager for this node's current token now and apply it, rather than waiting for its binding to change.",
|
||||
Run: func(map[string]any) (any, error) { return Pull(p, ask, writeManaged) }},
|
||||
{Name: "claude_code_grant",
|
||||
Description: "For the licence manager (ADR 0206): the full grant in this node's credentials file — a login made here — sealed to the public key given, with the account it belongs to. Nothing when no login is waiting. Never answers a token in the clear.",
|
||||
Input: map[string]any{"public_key": str("the manager's public key, PEM; the grant opens only with its private half")},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
key, _ := a["public_key"].(string)
|
||||
if !strings.Contains(key, "PUBLIC KEY") {
|
||||
return nil, errors.New("claude_code_grant seals to a public key, and none was given")
|
||||
}
|
||||
return GrantFor(p, key)
|
||||
}},
|
||||
{Name: "claude_code_mcp_list",
|
||||
Description: "The MCP servers registered through this module: those that apply on this node (beside the console, `mesh`, and those set in the module's settings), and every registration on the mesh, by key — `all.<server>` for every node, `<node>.<server>` for one.",
|
||||
Run: func(map[string]any) (any, error) {
|
||||
keys, err := servers.Keys()
|
||||
return map[string]any{"here": Registered(p), "everywhere": keys}, err
|
||||
}},
|
||||
{Name: "claude_code_mcp_register",
|
||||
Description: "Register an MCP server with Claude Code on this node, every node, or a list — an http/sse server by url, or a stdio server by command. Kept on the bus, so a node that joins later takes it too. Never put a secret in env or headers: the mesh refuses one.",
|
||||
Input: map[string]any{
|
||||
"name": str("the server's name: letters, digits, - and _"),
|
||||
"type": str("http, sse or stdio (default stdio when a command is given, http when a url is)"),
|
||||
"url": str("an http or sse server's url"),
|
||||
"command": str("a stdio server's program"),
|
||||
"args": map[string]any{"type": "array", "description": "a stdio server's arguments"},
|
||||
"env": map[string]any{"type": "object", "description": "a stdio server's environment"},
|
||||
"headers": map[string]any{"type": "object", "description": "an http server's headers"},
|
||||
"nodes": nodesArg,
|
||||
},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
entry := map[string]any{}
|
||||
if t, _ := a["type"].(string); t != "" {
|
||||
entry["type"] = t
|
||||
} else if _, hasURL := a["url"]; hasURL {
|
||||
entry["type"] = "http"
|
||||
} else {
|
||||
entry["type"] = "stdio"
|
||||
}
|
||||
for _, k := range []string{"url", "command", "args", "env", "headers"} {
|
||||
if v, ok := a[k]; ok {
|
||||
entry[k] = v
|
||||
}
|
||||
}
|
||||
name, _ := a["name"].(string)
|
||||
return RegisterServer(p, Registration{Name: name, Entry: entry, Nodes: nodesOf(a["nodes"])}, servers, view, writeManaged, nodesRunningMe)
|
||||
}},
|
||||
{Name: "claude_code_mcp_unregister",
|
||||
Description: "Remove an MCP server registered through this module, on this node or more.",
|
||||
Input: map[string]any{"name": str("the server's name"), "nodes": nodesArg},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
name, _ := a["name"].(string)
|
||||
return RegisterServer(p, Registration{Name: name, Nodes: nodesOf(a["nodes"])}, servers, view, writeManaged, nodesRunningMe)
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// persist asks the state again until it answers: its bucket or the bus's grant may arrive after the module.
|
||||
func persist(what string, attempt func() error, done func(refusals int)) {
|
||||
waits := []time.Duration{2 * time.Second, 5 * time.Second, 10 * time.Second, 30 * time.Second}
|
||||
for n := 0; ; n++ {
|
||||
err := attempt()
|
||||
if err == nil {
|
||||
done(n)
|
||||
return
|
||||
}
|
||||
pause := time.Minute
|
||||
if n < len(waits) {
|
||||
pause = waits[n]
|
||||
}
|
||||
say("%s not yet (%v); asking again in %s", what, err, pause)
|
||||
time.Sleep(pause)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
p, launched := PathsFrom(os.Getenv)
|
||||
if !launched {
|
||||
// Outside a launch — a build, a check — it serves nothing and says why.
|
||||
say("not launched by the runtime with this module's words; serving no tools")
|
||||
if err := stdio.Serve("", nil); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
if _, err := Keypair(p); err != nil {
|
||||
say("this module's key: %v", err)
|
||||
}
|
||||
if out, err := RenderNow(p, writeManaged); err != nil {
|
||||
say("%v", err)
|
||||
} else {
|
||||
for _, line := range out {
|
||||
if !strings.HasSuffix(line, "unchanged") {
|
||||
say("%s", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
servers := stateOf{stdio.State("servers")}
|
||||
view := NewServerView(p)
|
||||
go run(p, view)
|
||||
if err := stdio.Serve("", tools(p, servers, view)); err != nil {
|
||||
say("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// run is the module's long-running half, beside the tools (ADR 0198).
|
||||
func run(p Paths, view *ServerView) {
|
||||
// Every node's MCP servers: the whole current set first, then each change (ADR 0201).
|
||||
go persist("watching the MCP servers", func() error {
|
||||
return stdio.State("servers").Watch("", func(c stdio.StateChange) error {
|
||||
var value map[string]any
|
||||
_ = json.Unmarshal(c.Value, &value)
|
||||
if done, err := OnServerChange(view, ServerChange{Key: c.Key, Op: c.Op, Value: value}, p, writeManaged); err != nil {
|
||||
say("taking %s %s: %v", c.Op, c.Key, err) // the view took it; the next render writes it
|
||||
} else if done != "" {
|
||||
say("%s", done)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}, func(n int) { say("watching the MCP servers%s", refusals(n)) })
|
||||
|
||||
// What this node holds (ADR 0206): at start — a node already logged in is reported at once — and on
|
||||
// every change of the credentials file, polled, because the file is replaced by rename and a watch on
|
||||
// the old inode would go quiet. Fingerprints and expiries only.
|
||||
holdings := stdio.State("holdings")
|
||||
reported := ""
|
||||
report := func() {
|
||||
now := HoldingsOf(p)
|
||||
raw, _ := json.Marshal(now)
|
||||
if string(raw) == reported {
|
||||
return
|
||||
}
|
||||
persist("reporting what this node holds", func() error { _, err := holdings.Put(p.Node, now); return err }, func(int) {
|
||||
reported = string(raw)
|
||||
account := "no account"
|
||||
if now.Identity != nil && now.Identity.EmailAddress != "" {
|
||||
account = now.Identity.EmailAddress
|
||||
}
|
||||
line := "reported: " + account
|
||||
if now.Kind != nil {
|
||||
line += ", " + *now.Kind
|
||||
}
|
||||
if now.Refresh.Present {
|
||||
line += ", a login waiting"
|
||||
}
|
||||
if now.Licence != nil {
|
||||
line += fmt.Sprintf(", licence %s g%d", *now.Licence, now.Generation)
|
||||
}
|
||||
say("%s", line)
|
||||
})
|
||||
}
|
||||
|
||||
// What this node should hold (ADR 0206): the manager's `bindings` key for this node; a newer
|
||||
// generation is fetched with the seat's `current`, sealed to this module's key.
|
||||
go persist("watching this node's licence binding", func() error {
|
||||
return stdio.State(Manager+".bindings").Watch(p.Node, func(c stdio.StateChange) error {
|
||||
if c.Key != p.Node {
|
||||
return nil
|
||||
}
|
||||
var b *BindingState
|
||||
if c.Op == "put" {
|
||||
b = &BindingState{}
|
||||
if err := json.Unmarshal(c.Value, b); err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if done, err := OnBinding(p, b, ask, writeManaged); err != nil {
|
||||
say("fetching this node's token failed: %v", err)
|
||||
} else if done != "" {
|
||||
say("%s", done)
|
||||
}
|
||||
go report()
|
||||
return nil
|
||||
})
|
||||
}, func(n int) { say("watching this node's licence binding%s", refusals(n)) })
|
||||
|
||||
report()
|
||||
var last string
|
||||
for range time.Tick(5 * time.Second) {
|
||||
info, err := os.Stat(p.credentials())
|
||||
now := "absent"
|
||||
if err == nil {
|
||||
now = fmt.Sprintf("%d/%d", info.ModTime().UnixNano(), info.Size())
|
||||
}
|
||||
if now != last {
|
||||
last = now
|
||||
report()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func refusals(n int) string {
|
||||
if n == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(" (after %d refusal(s))", n)
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
package main
|
||||
|
||||
// What claude-code does on a node, written against what it is handed — a way to ask a tool on the bus, its
|
||||
// own state, a way to write a managed file — so every path is tested without a bus (novox/hq design 36,
|
||||
// ADR 0183, ADR 0201, ADR 0206).
|
||||
//
|
||||
// Over NATS, and nothing an event: what is current is state, and a secret only ever travels on a request,
|
||||
// sealed to its one recipient.
|
||||
// - What this node holds is the module's `holdings` state, one key per node: the account, the kind,
|
||||
// fingerprints and expiries — never a token. Written at start and on every change of the credentials
|
||||
// file, so the licence manager learns a login, or a node already logged in, from the state alone.
|
||||
// - The grant itself leaves only when the manager asks `claude_code_grant`, sealed to the key it gives.
|
||||
// - What this node should hold is the manager's `bindings` state; a newer generation for this node is
|
||||
// fetched with the seat's `current` verb, sealed to this module's key, and written access-token-only.
|
||||
// - An MCP server registered through this module is a key in its `servers` state — `all.<server>` for
|
||||
// every node, `<node>.<server>` for one — which every node watches.
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Seat is the licence manager's role, and Manager the module whose `bindings` state this one reads.
|
||||
const (
|
||||
Seat = "anthropic-licence-manager"
|
||||
Manager = "claude-licence-manager"
|
||||
)
|
||||
|
||||
// SeatVerb is a seat's verb as the runtime addresses it: a role, not a module.
|
||||
func SeatVerb(verb string) string { return "seat:" + Seat + "." + verb }
|
||||
|
||||
// Fingerprint names a token without being one: the first 16 hex of its SHA-256, as the manager computes it.
|
||||
func Fingerprint(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return "sha256:" + hex.EncodeToString(sum[:])[:16]
|
||||
}
|
||||
|
||||
// Paths are where this node's files are, from the module's words (ADR 0192).
|
||||
type Paths struct {
|
||||
State, Facts, Settings, Home, Node string
|
||||
}
|
||||
|
||||
// PathsFrom reads them, or answers false outside a launch.
|
||||
func PathsFrom(env func(string) string) (Paths, bool) {
|
||||
p := Paths{State: env("MESH_CLAUDE_CODE_STATE"), Facts: env("MESH_CLAUDE_CODE_FACTS"),
|
||||
Settings: env("MESH_CLAUDE_CODE_SETTINGS"), Home: env("MESH_OPERATOR_HOME"), Node: env("MESH_NODE")}
|
||||
return p, p.State != "" && p.Facts != "" && p.Settings != "" && p.Home != "" && p.Node != ""
|
||||
}
|
||||
|
||||
func (p Paths) credentials() string { return filepath.Join(p.Home, ".claude", ".credentials.json") }
|
||||
func (p Paths) account() string { return filepath.Join(p.Home, ".claude.json") }
|
||||
func (p Paths) binding() string { return filepath.Join(p.State, "licence.json") }
|
||||
func (p Paths) apiKey() string { return filepath.Join(p.State, "api-key") }
|
||||
func (p Paths) helper() string { return filepath.Join(p.State, "api-key-helper") }
|
||||
func (p Paths) registry() string { return filepath.Join(p.State, "mcp-servers.json") }
|
||||
|
||||
// Ask is a tool on the bus: its address and arguments in, its JSON answer out.
|
||||
type Ask func(address string, args any) (json.RawMessage, error)
|
||||
|
||||
// WriteManaged writes one managed file and answers what happened.
|
||||
type WriteManaged func(name, content string) (string, error)
|
||||
|
||||
func readJSON(path string, into any) bool {
|
||||
raw, err := os.ReadFile(path)
|
||||
return err == nil && json.Unmarshal(raw, into) == nil
|
||||
}
|
||||
|
||||
// Keypair is this module's own, made once in its state; the TypeScript module's files are kept, so a node
|
||||
// moving to this binary keeps the key it had.
|
||||
func Keypair(p Paths) (KeyPair, error) {
|
||||
priv, pub := filepath.Join(p.State, "key.pem"), filepath.Join(p.State, "key.pub.pem")
|
||||
if _, err := os.Stat(priv); errors.Is(err, os.ErrNotExist) {
|
||||
k, err := GenerateKeyPair()
|
||||
if err != nil {
|
||||
return KeyPair{}, err
|
||||
}
|
||||
if err := os.WriteFile(priv, []byte(k.PrivateKey), 0o600); err != nil {
|
||||
return KeyPair{}, err
|
||||
}
|
||||
if err := os.WriteFile(pub, []byte(k.PublicKey), 0o644); err != nil {
|
||||
return KeyPair{}, err
|
||||
}
|
||||
}
|
||||
a, err1 := os.ReadFile(priv)
|
||||
b, err2 := os.ReadFile(pub)
|
||||
return KeyPair{PrivateKey: string(a), PublicKey: string(b)}, errors.Join(err1, err2)
|
||||
}
|
||||
|
||||
// Registered is what applies here of the servers registered through this module.
|
||||
func Registered(p Paths) Servers {
|
||||
s := Servers{}
|
||||
readJSON(p.registry(), &s)
|
||||
return s
|
||||
}
|
||||
|
||||
// RenderNow writes the managed directory from the facts, the settings, the licence held and the servers
|
||||
// registered here.
|
||||
func RenderNow(p Paths, write WriteManaged) ([]string, error) {
|
||||
var facts Facts
|
||||
if !readJSON(p.Facts, &facts) || facts.Console == "" {
|
||||
return nil, fmt.Errorf("the mesh has not rendered %s yet; nothing to write", p.Facts)
|
||||
}
|
||||
var settings Settings
|
||||
readJSON(p.Settings, &settings)
|
||||
var binding *Binding
|
||||
var b Binding
|
||||
if readJSON(p.binding(), &b) {
|
||||
binding = &b
|
||||
}
|
||||
files := Render(facts, settings, binding, p.helper(), Registered(p))
|
||||
names := make([]string, 0, len(files))
|
||||
for n := range files {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
var out []string
|
||||
for _, n := range names {
|
||||
line, err := write(n, files[n])
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---- the licence ----------------------------------------------------------------------------------
|
||||
|
||||
// BindingState is what the manager's `bindings` state says one consumer should hold (ADR 0206).
|
||||
type BindingState struct {
|
||||
Licence string `json:"licence"`
|
||||
Kind string `json:"kind"`
|
||||
Generation int64 `json:"generation"`
|
||||
}
|
||||
|
||||
// Current is what the seat answers to `current`: the licence this node is bound to and its token, sealed.
|
||||
type Current struct {
|
||||
Licence string `json:"licence"`
|
||||
Kind string `json:"kind"`
|
||||
Generation int64 `json:"generation"`
|
||||
Sealed *SealedBox `json:"sealed"`
|
||||
Identity *Identity `json:"identity"`
|
||||
}
|
||||
|
||||
// Holdings is what this node holds, as the `holdings` state carries it (ADR 0206): enough for the manager
|
||||
// to tell a login it has not adopted from one it has, and never a token — fingerprints and expiries only.
|
||||
type Holdings struct {
|
||||
Node string `json:"node"`
|
||||
Identity *Identity `json:"identity"`
|
||||
Kind *string `json:"kind"`
|
||||
Refresh struct {
|
||||
Present bool `json:"present"`
|
||||
Fingerprint *string `json:"fingerprint"`
|
||||
ExpiresAt *int64 `json:"expiresAt"`
|
||||
} `json:"refresh"`
|
||||
Access *struct {
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
} `json:"access"`
|
||||
Licence *string `json:"licence"`
|
||||
Generation int64 `json:"generation"`
|
||||
ChangedAt *string `json:"changedAt"`
|
||||
}
|
||||
|
||||
// HoldingsOf is what this node holds now.
|
||||
func HoldingsOf(p Paths) Holdings {
|
||||
h := Holdings{Node: p.Node, Identity: ReadIdentity(p.account())}
|
||||
creds := ReadCredentials(p.credentials())
|
||||
if info, err := os.Stat(p.credentials()); err == nil {
|
||||
at := info.ModTime().UTC().Format("2006-01-02T15:04:05.000Z")
|
||||
h.ChangedAt = &at
|
||||
}
|
||||
if rt := RefreshTokenOf(creds); rt != "" {
|
||||
fp := Fingerprint(rt)
|
||||
h.Refresh.Present, h.Refresh.Fingerprint = true, &fp
|
||||
}
|
||||
if v, ok := number(creds.oauth()["refreshTokenExpiresAt"]); ok {
|
||||
h.Refresh.ExpiresAt = &v
|
||||
}
|
||||
if g := GrantOf(creds); g != nil {
|
||||
h.Access = &struct {
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
}{Fingerprint(g.AccessToken), g.ExpiresAt}
|
||||
}
|
||||
kind := ""
|
||||
if _, err := os.Stat(p.apiKey()); err == nil {
|
||||
kind = "api-key"
|
||||
} else if h.Access != nil {
|
||||
kind = "subscription"
|
||||
}
|
||||
if kind != "" {
|
||||
h.Kind = &kind
|
||||
}
|
||||
var applied Binding
|
||||
if readJSON(p.binding(), &applied) && applied.Licence != "" {
|
||||
h.Licence, h.Generation = &applied.Licence, applied.Generation
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// GrantAnswer is what `claude_code_grant` answers: a login sealed to the key given, or nothing waiting.
|
||||
type GrantAnswer struct {
|
||||
Sealed *SealedBox `json:"sealed,omitempty"`
|
||||
Identity *Identity `json:"identity,omitempty"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
Waiting *bool `json:"waiting,omitempty"`
|
||||
}
|
||||
|
||||
// GrantFor is the full grant in the credentials file sealed to the manager's key — the one time a refresh
|
||||
// token leaves this node, for the manager to adopt by refreshing it (ADR 0206). Nothing waiting when the
|
||||
// file holds no refresh token.
|
||||
func GrantFor(p Paths, managerPublicKey string) (GrantAnswer, error) {
|
||||
creds := ReadCredentials(p.credentials())
|
||||
rt := RefreshTokenOf(creds)
|
||||
if rt == "" {
|
||||
no := false
|
||||
return GrantAnswer{Waiting: &no}, nil
|
||||
}
|
||||
raw, err := json.Marshal(creds.oauth())
|
||||
if err != nil {
|
||||
return GrantAnswer{}, err
|
||||
}
|
||||
box, err := Seal(string(raw), managerPublicKey)
|
||||
if err != nil {
|
||||
return GrantAnswer{}, err
|
||||
}
|
||||
return GrantAnswer{Sealed: &box, Identity: ReadIdentity(p.account()), Fingerprint: Fingerprint(rt)}, nil
|
||||
}
|
||||
|
||||
// Pull asks the seat for this node's current token and applies it.
|
||||
func Pull(p Paths, ask Ask, write WriteManaged) (map[string]any, error) {
|
||||
keys, err := Keypair(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := ask(SeatVerb("current"), map[string]any{"consumer": p.Node, "public_key": keys.PublicKey})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var c Current
|
||||
if err := json.Unmarshal(raw, &c); err != nil || c.Sealed == nil {
|
||||
return map[string]any{"applied": false, "reason": "the seat holds no licence for this node"}, nil
|
||||
}
|
||||
return Apply(p, c, write)
|
||||
}
|
||||
|
||||
// OnBinding takes a change to this node's key in the manager's `bindings` state (ADR 0206): the token is
|
||||
// fetched when the generation is newer than the one applied. A released binding keeps the last token,
|
||||
// which lives hours, and says so.
|
||||
func OnBinding(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, error) {
|
||||
if b == nil {
|
||||
return "this node's binding was released; it keeps its last token until it expires", nil
|
||||
}
|
||||
var applied Binding
|
||||
if readJSON(p.binding(), &applied) && applied.Generation >= b.Generation {
|
||||
return "", nil
|
||||
}
|
||||
out, err := Pull(p, ask, write)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
raw, _ := json.Marshal(out)
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
// Apply applies what the seat handed over. A switch replaces the grant whole and cleans up after the old
|
||||
// licence; whatever it is, the file is written without a refresh token, so the agent here never refreshes.
|
||||
func Apply(p Paths, c Current, write WriteManaged) (map[string]any, error) {
|
||||
keys, err := Keypair(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plain, err := Open(*c.Sealed, keys.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var previous Binding
|
||||
had := readJSON(p.binding(), &previous)
|
||||
switched := !had || previous.Licence != c.Licence
|
||||
out := map[string]any{"applied": true, "licence": c.Licence, "kind": c.Kind, "switched": switched}
|
||||
if c.Kind == "api-key" {
|
||||
if err := os.WriteFile(p.apiKey(), []byte(strings.TrimSpace(plain)+"\n"), 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(p.helper(), []byte("#!/bin/sh\nexec cat '"+p.apiKey()+"'\n"), 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = os.Chmod(p.helper(), 0o700)
|
||||
} else {
|
||||
var g Grant
|
||||
if err := json.Unmarshal([]byte(plain), &g); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
local := ReadCredentials(p.credentials())
|
||||
// A login waiting here was handed to the manager first (ADR 0206): what comes back is its successor,
|
||||
// and the refresh token in the file is the one the manager just spent.
|
||||
d := DecideApply(GrantOf(local), g, switched || HoldsLogin(local))
|
||||
if d.Apply {
|
||||
next := WithGrant(local, g)
|
||||
if switched {
|
||||
next = ReplacedBy(local, g)
|
||||
}
|
||||
if err := WriteCredentials(p.credentials(), next); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
out = map[string]any{"applied": false, "licence": c.Licence, "reason": d.Reason}
|
||||
}
|
||||
// Away from the API key: it goes, with its helper.
|
||||
_ = os.Remove(p.apiKey())
|
||||
_ = os.Remove(p.helper())
|
||||
}
|
||||
if switched && c.Identity != nil && c.Identity.AccountUUID != "" {
|
||||
changed, err := WriteIdentity(p.account(), *c.Identity)
|
||||
if err == nil {
|
||||
out["account"] = map[bool]string{true: "updated", false: "unchanged"}[changed]
|
||||
}
|
||||
}
|
||||
gen := c.Generation
|
||||
if gen == 0 {
|
||||
gen = previous.Generation
|
||||
}
|
||||
raw, _ := json.Marshal(Binding{Licence: c.Licence, Kind: c.Kind, Generation: gen})
|
||||
if err := os.WriteFile(p.binding(), append(raw, '\n'), 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The key-helper comes or goes with the licence's kind.
|
||||
if rendered, err := RenderNow(p, write); err != nil {
|
||||
out["rendered"] = map[string]any{"failed": err.Error()}
|
||||
} else {
|
||||
out["rendered"] = rendered
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---- MCP servers ----------------------------------------------------------------------------------
|
||||
|
||||
// Registration is a server registered (or, with no entry, unregistered) through this module.
|
||||
type Registration struct {
|
||||
Name string
|
||||
Entry map[string]any
|
||||
// Nodes: nil for this node, ["all"] for every node running the module, or a list.
|
||||
Nodes []string
|
||||
}
|
||||
|
||||
// ServerState is the `servers` state as this module reaches it through the runtime.
|
||||
type ServerState interface {
|
||||
Put(key string, value any) error
|
||||
Delete(key string) error
|
||||
Keys() ([]string, error)
|
||||
}
|
||||
|
||||
// ServerChange is one change to the `servers` state, as a watch hands it over.
|
||||
type ServerChange struct {
|
||||
Key string
|
||||
Op string // put | delete
|
||||
Value map[string]any
|
||||
}
|
||||
|
||||
// KeyOf is the key a registration lives at: `all.<server>` for every node, `<node>.<server>` for one.
|
||||
func KeyOf(scope, name string) string { return scope + "." + name }
|
||||
|
||||
// ServerView is what this node takes from the `servers` state: the entries for every node and for this
|
||||
// one, kept in memory from the watch and written through to the module's own file whenever what applies
|
||||
// here changes, so the managed directory renders without the bus.
|
||||
type ServerView struct {
|
||||
p Paths
|
||||
mu sync.Mutex
|
||||
entries map[string]map[string]any
|
||||
}
|
||||
|
||||
// NewServerView is an empty view for this node.
|
||||
func NewServerView(p Paths) *ServerView {
|
||||
return &ServerView{p: p, entries: map[string]map[string]any{}}
|
||||
}
|
||||
|
||||
// Take takes one change, and answers whether what applies to this node changed.
|
||||
func (v *ServerView) Take(c ServerChange) bool {
|
||||
scope, name, ok := strings.Cut(c.Key, ".")
|
||||
if !ok || scope == "" || (scope != "all" && scope != v.p.Node) {
|
||||
return false
|
||||
}
|
||||
v.mu.Lock()
|
||||
if c.Op == "put" && c.Value != nil && EntryProblem(name, c.Value) == "" {
|
||||
v.entries[c.Key] = c.Value
|
||||
} else {
|
||||
delete(v.entries, c.Key)
|
||||
}
|
||||
v.mu.Unlock()
|
||||
return v.writeThrough()
|
||||
}
|
||||
|
||||
// Effective is what applies here: every node's entries, with this node's own laid over them by name.
|
||||
func (v *ServerView) Effective() Servers {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
out := Servers{}
|
||||
for _, scope := range []string{"all", v.p.Node} {
|
||||
for key, entry := range v.entries {
|
||||
if name, ok := strings.CutPrefix(key, scope+"."); ok {
|
||||
out[name] = entry
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (v *ServerView) writeThrough() bool {
|
||||
now, _ := indented(v.Effective())
|
||||
before, _ := os.ReadFile(v.p.registry())
|
||||
if string(before) == string(now) {
|
||||
return false
|
||||
}
|
||||
_ = os.WriteFile(v.p.registry(), now, 0o600)
|
||||
return true
|
||||
}
|
||||
|
||||
// OnServerChange takes a change from the watch, and renders when what applies here changed.
|
||||
func OnServerChange(v *ServerView, c ServerChange, p Paths, write WriteManaged) (string, error) {
|
||||
if !v.Take(c) {
|
||||
return "", nil
|
||||
}
|
||||
if _, err := RenderNow(p, write); err != nil {
|
||||
return "", err
|
||||
}
|
||||
what := "registered"
|
||||
if c.Op != "put" {
|
||||
what = "unregistered"
|
||||
}
|
||||
return what + " " + c.Key, nil
|
||||
}
|
||||
|
||||
// RegisterServer registers (or, with no entry, unregisters) a server: a put (or delete) per scope in the
|
||||
// `servers` state, taken into this node's view at once so the answer says what it did here; every other
|
||||
// node takes it from its watch, and a node that joins later from the current state.
|
||||
func RegisterServer(p Paths, r Registration, servers ServerState, v *ServerView, write WriteManaged,
|
||||
others func() ([]string, error)) (map[string]any, error) {
|
||||
if r.Entry != nil {
|
||||
if problem := EntryProblem(r.Name, r.Entry); problem != "" {
|
||||
return map[string]any{"registered": false, "reason": problem}, nil
|
||||
}
|
||||
}
|
||||
scopes := r.Nodes
|
||||
if len(scopes) == 0 {
|
||||
scopes = []string{p.Node}
|
||||
}
|
||||
// Compared before and after rather than read from Take: this node's own watch may hand the view the
|
||||
// same change first, and then Take here finds nothing new although this call made it.
|
||||
before, _ := json.Marshal(v.Effective())
|
||||
for _, scope := range scopes {
|
||||
key := KeyOf(scope, r.Name)
|
||||
var err error
|
||||
if r.Entry != nil {
|
||||
err = servers.Put(key, r.Entry)
|
||||
} else {
|
||||
err = servers.Delete(key)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
op := "put"
|
||||
if r.Entry == nil {
|
||||
op = "delete"
|
||||
}
|
||||
v.Take(ServerChange{Key: key, Op: op, Value: r.Entry})
|
||||
}
|
||||
after, _ := json.Marshal(v.Effective())
|
||||
changed := string(before) != string(after)
|
||||
here := false
|
||||
for _, s := range scopes {
|
||||
here = here || s == "all" || s == p.Node
|
||||
}
|
||||
verb := "registered"
|
||||
if r.Entry == nil {
|
||||
verb = "unregistered"
|
||||
}
|
||||
answer := map[string]any{verb: r.Name, "on": scopes}
|
||||
switch {
|
||||
case !here:
|
||||
answer["here"] = "not this node"
|
||||
case changed:
|
||||
answer["here"] = "changed"
|
||||
default:
|
||||
answer["here"] = "already so"
|
||||
}
|
||||
if changed {
|
||||
rendered, err := RenderNow(p, write)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answer["rendered"] = rendered
|
||||
}
|
||||
if r.Entry == nil {
|
||||
if _, still := v.Effective()[r.Name]; still {
|
||||
answer["still"] = r.Name + " still applies here from another registration (for every node, or for this one); unregister that too"
|
||||
}
|
||||
}
|
||||
if len(r.Nodes) == 0 {
|
||||
// The question the operator wanted asked: here only, or more?
|
||||
var elsewhere []string
|
||||
if nodes, err := others(); err == nil {
|
||||
for _, n := range nodes {
|
||||
if n != p.Node {
|
||||
elsewhere = append(elsewhere, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(elsewhere) > 0 {
|
||||
answer["also"] = fmt.Sprintf("claude-code also runs on %s. To %s it there too, call again with nodes: \"all\" or a list of those nodes.",
|
||||
strings.Join(elsewhere, ", "), map[bool]string{true: "register", false: "unregister"}[r.Entry != nil])
|
||||
} else {
|
||||
answer["also"] = "To do the same on every node running claude-code, call again with nodes: \"all\"."
|
||||
}
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// stamp is a time as the status answers it.
|
||||
func stamp(ms int64) string { return time.UnixMilli(ms).UTC().Format(time.RFC3339) }
|
||||
@@ -0,0 +1,121 @@
|
||||
package main
|
||||
|
||||
// What the module writes into the agent's machine-wide managed directory (novox/hq design 36 §1–§4).
|
||||
// Pure: composed from the facts the mesh rendered, the settings the operator set and the licence the node
|
||||
// holds, so what lands under /etc is tested without a machine.
|
||||
//
|
||||
// Three files, owned whole by this module:
|
||||
//
|
||||
// managed-mcp.json the tool servers every session loads: the mesh's console as `mesh`, and the
|
||||
// servers the operator declared or registered through this module. Exclusive by
|
||||
// the vendor's rule — a server not listed here does not load (operator's choice,
|
||||
// 2026-10-03).
|
||||
// managed-settings.json the mesh's keys only: the repositories' attribution convention, the claude.ai
|
||||
// connectors kept beside the managed servers, and — for an API-key licence only —
|
||||
// the key-helper. A person's preferences are theirs.
|
||||
// CLAUDE.md how a session on this mesh works, who this node is, the conventions.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ManagedDir is the agent's machine-wide managed directory.
|
||||
const ManagedDir = "/etc/claude-code"
|
||||
|
||||
const meshEntry = "mesh"
|
||||
|
||||
// Facts are what the mesh rendered for this node.
|
||||
type Facts struct {
|
||||
Node string `json:"node"`
|
||||
Console string `json:"console"`
|
||||
}
|
||||
|
||||
// Settings are the operator's, for the mesh or this node.
|
||||
type Settings struct {
|
||||
Role string `json:"role"`
|
||||
MCPServers map[string]map[string]any `json:"mcp_servers"`
|
||||
}
|
||||
|
||||
// Binding is the licence this node holds, as it was last applied.
|
||||
type Binding struct {
|
||||
Licence string `json:"licence"`
|
||||
Kind string `json:"kind"` // subscription | api-key
|
||||
Generation int64 `json:"generation,omitempty"`
|
||||
}
|
||||
|
||||
// Servers are tool server entries by name, in the vendor's `.mcp.json` shape.
|
||||
type Servers map[string]map[string]any
|
||||
|
||||
var serverName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
||||
|
||||
// EntryProblem says why the vendor's managed file would not take an entry, or "" when it would: a name of
|
||||
// letters, digits, `-` and `_`, and an http/sse server with a url or a stdio server with a command.
|
||||
func EntryProblem(name string, entry map[string]any) string {
|
||||
if !serverName.MatchString(name) {
|
||||
return fmt.Sprintf("%q is not a name the agent takes: letters, digits, - and _", name)
|
||||
}
|
||||
if name == meshEntry {
|
||||
return fmt.Sprintf("%q is the mesh's own entry", meshEntry)
|
||||
}
|
||||
kind, _ := entry["type"].(string)
|
||||
if kind == "" {
|
||||
kind = "stdio"
|
||||
}
|
||||
switch kind {
|
||||
case "http", "sse", "streamable-http":
|
||||
if u, _ := entry["url"].(string); u != "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("an %s server needs a url", kind)
|
||||
case "stdio":
|
||||
if c, _ := entry["command"].(string); c != "" {
|
||||
return ""
|
||||
}
|
||||
return "a stdio server needs a command"
|
||||
}
|
||||
return fmt.Sprintf("%q is not a server type the agent knows (http, sse, stdio)", kind)
|
||||
}
|
||||
|
||||
// jsonFile is a value as the managed files are written: two-space indent, a trailing newline, nothing
|
||||
// escaped that need not be.
|
||||
func jsonFile(v any) string {
|
||||
var b bytes.Buffer
|
||||
enc := json.NewEncoder(&b)
|
||||
enc.SetEscapeHTML(false)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(v)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Render composes the three files. registered — what was registered through this module and applies
|
||||
// here — is laid over the servers the operator set in its settings.
|
||||
func Render(facts Facts, settings Settings, binding *Binding, helperPath string, registered Servers) map[string]string {
|
||||
servers := map[string]any{}
|
||||
for _, layer := range []map[string]map[string]any{settings.MCPServers, registered} {
|
||||
for name, entry := range layer {
|
||||
if EntryProblem(name, entry) != "" {
|
||||
continue // the mesh's own entry, or one the agent would refuse
|
||||
}
|
||||
servers[name] = entry
|
||||
}
|
||||
}
|
||||
servers[meshEntry] = map[string]any{"type": "http", "url": facts.Console}
|
||||
|
||||
managed := map[string]any{"attribution": map[string]any{"commit": "", "pr": ""}, "allowAllClaudeAiMcps": true}
|
||||
if binding != nil && binding.Kind == "api-key" {
|
||||
managed["apiKeyHelper"] = helperPath
|
||||
}
|
||||
role := strings.TrimSpace(settings.Role)
|
||||
if role == "" {
|
||||
role = "not stated — set it in this module's settings for the node"
|
||||
}
|
||||
return map[string]string{
|
||||
"managed-mcp.json": jsonFile(map[string]any{"mcpServers": servers}),
|
||||
"managed-settings.json": jsonFile(managed),
|
||||
"CLAUDE.md": instructionsText(facts.Node, role),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package main
|
||||
|
||||
// Sealing to one recipient (novox/hq ADR 0183, ADR 0206): the manager seals what it hands a consumer to
|
||||
// the key that consumer sent, and a node seals a waiting login to the key the manager gives. The same box
|
||||
// the agent module's TypeScript makes and opens, byte for byte — X25519 for the agreement, HKDF-SHA256 for
|
||||
// the key, AES-256-GCM for the box — so `testdata/sealed-by-typescript.json` is opened here, and a test
|
||||
// reopens what this seals with the same derivation.
|
||||
//
|
||||
// A box is `{ v: 1, eph, iv, tag, ct }`, every field base64; `eph` is the one-time public key as SPKI DER,
|
||||
// and the key is bound to it and to the recipient's raw public key, so a box cannot be re-addressed.
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/ecdh"
|
||||
"crypto/hkdf"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// SealedBox is a value sealed to one recipient.
|
||||
type SealedBox struct {
|
||||
V int `json:"v"`
|
||||
Eph string `json:"eph"`
|
||||
IV string `json:"iv"`
|
||||
Tag string `json:"tag"`
|
||||
Ct string `json:"ct"`
|
||||
}
|
||||
|
||||
// KeyPair is a recipient's keypair as the two PEM strings it is kept and sent as.
|
||||
type KeyPair struct {
|
||||
PublicKey string `json:"publicKey"`
|
||||
PrivateKey string `json:"privateKey"`
|
||||
}
|
||||
|
||||
const sealInfo = "novox-mesh sealed box v1"
|
||||
|
||||
// GenerateKeyPair makes an X25519 keypair, PEM-encoded as the agent module's are.
|
||||
func GenerateKeyPair() (KeyPair, error) {
|
||||
priv, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return KeyPair{}, err
|
||||
}
|
||||
pubDER, err := x509.MarshalPKIXPublicKey(priv.PublicKey())
|
||||
if err != nil {
|
||||
return KeyPair{}, err
|
||||
}
|
||||
privDER, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||
if err != nil {
|
||||
return KeyPair{}, err
|
||||
}
|
||||
return KeyPair{
|
||||
PublicKey: string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER})),
|
||||
PrivateKey: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privDER})),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func publicFromPEM(p string) (*ecdh.PublicKey, error) {
|
||||
block, _ := pem.Decode([]byte(p))
|
||||
if block == nil {
|
||||
return nil, errors.New("not a PEM public key")
|
||||
}
|
||||
k, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pub, ok := k.(*ecdh.PublicKey)
|
||||
if !ok || pub.Curve() != ecdh.X25519() {
|
||||
return nil, errors.New("not an X25519 public key")
|
||||
}
|
||||
return pub, nil
|
||||
}
|
||||
|
||||
func privateFromPEM(p string) (*ecdh.PrivateKey, error) {
|
||||
block, _ := pem.Decode([]byte(p))
|
||||
if block == nil {
|
||||
return nil, errors.New("not a PEM private key")
|
||||
}
|
||||
k, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
priv, ok := k.(*ecdh.PrivateKey)
|
||||
if !ok || priv.Curve() != ecdh.X25519() {
|
||||
return nil, errors.New("not an X25519 private key")
|
||||
}
|
||||
return priv, nil
|
||||
}
|
||||
|
||||
func boxKey(secret, ephDER, recipientRaw []byte) ([]byte, error) {
|
||||
salt := append(append([]byte{}, ephDER...), recipientRaw...)
|
||||
return hkdf.Key(sha256.New, secret, salt, sealInfo, 32)
|
||||
}
|
||||
|
||||
// Seal seals plaintext to the recipient's public key.
|
||||
func Seal(plaintext, recipientPEM string) (SealedBox, error) {
|
||||
recipient, err := publicFromPEM(recipientPEM)
|
||||
if err != nil {
|
||||
return SealedBox{}, err
|
||||
}
|
||||
eph, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return SealedBox{}, err
|
||||
}
|
||||
secret, err := eph.ECDH(recipient)
|
||||
if err != nil {
|
||||
return SealedBox{}, err
|
||||
}
|
||||
ephDER, err := x509.MarshalPKIXPublicKey(eph.PublicKey())
|
||||
if err != nil {
|
||||
return SealedBox{}, err
|
||||
}
|
||||
key, err := boxKey(secret, ephDER, recipient.Bytes())
|
||||
if err != nil {
|
||||
return SealedBox{}, err
|
||||
}
|
||||
gcm, err := newGCM(key)
|
||||
if err != nil {
|
||||
return SealedBox{}, err
|
||||
}
|
||||
iv := make([]byte, 12)
|
||||
if _, err := rand.Read(iv); err != nil {
|
||||
return SealedBox{}, err
|
||||
}
|
||||
out := gcm.Seal(nil, iv, []byte(plaintext), nil)
|
||||
ct, tag := out[:len(out)-gcm.Overhead()], out[len(out)-gcm.Overhead():]
|
||||
b64 := base64.StdEncoding.EncodeToString
|
||||
return SealedBox{V: 1, Eph: b64(ephDER), IV: b64(iv), Tag: b64(tag), Ct: b64(ct)}, nil
|
||||
}
|
||||
|
||||
// Open opens a box with the recipient's private key; it fails for a box to another key or one tampered with.
|
||||
func Open(box SealedBox, privatePEM string) (string, error) {
|
||||
if box.V != 1 {
|
||||
return "", errors.New("not a sealed box this module can open")
|
||||
}
|
||||
priv, err := privateFromPEM(privatePEM)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
d := base64.StdEncoding.DecodeString
|
||||
ephDER, err := d(box.Eph)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("the box's eph: %w", err)
|
||||
}
|
||||
ephKey, err := x509.ParsePKIXPublicKey(ephDER)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
eph, ok := ephKey.(*ecdh.PublicKey)
|
||||
if !ok {
|
||||
return "", errors.New("the box's eph is not an X25519 key")
|
||||
}
|
||||
secret, err := priv.ECDH(eph)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
key, err := boxKey(secret, ephDER, priv.PublicKey().Bytes())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
iv, err1 := d(box.IV)
|
||||
tag, err2 := d(box.Tag)
|
||||
ct, err3 := d(box.Ct)
|
||||
if err := errors.Join(err1, err2, err3); err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := newGCM(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
plain, err := gcm.Open(nil, iv, append(ct, tag...), nil)
|
||||
if err != nil {
|
||||
return "", errors.New("the box does not open with this key")
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
|
||||
func newGCM(key []byte) (cipher.AEAD, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cipher.NewGCM(block)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"facts": {
|
||||
"node": "workstation",
|
||||
"console": "http://127.0.0.1:4270/mcp"
|
||||
},
|
||||
"settings": {
|
||||
"role": "the laptop",
|
||||
"mcp_servers": {
|
||||
"search": {
|
||||
"type": "http",
|
||||
"url": "https://s.example/mcp"
|
||||
},
|
||||
"docs": {
|
||||
"type": "stdio",
|
||||
"command": "docs-mcp",
|
||||
"args": [
|
||||
"--x"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"registered": {
|
||||
"anton": {
|
||||
"type": "stdio",
|
||||
"command": "node",
|
||||
"args": [
|
||||
"/a/b.js"
|
||||
],
|
||||
"env": {}
|
||||
}
|
||||
},
|
||||
"withKey": {
|
||||
"managed-mcp.json": "{\n \"mcpServers\": {\n \"anton\": {\n \"type\": \"stdio\",\n \"command\": \"node\",\n \"args\": [\n \"/a/b.js\"\n ],\n \"env\": {}\n },\n \"docs\": {\n \"type\": \"stdio\",\n \"command\": \"docs-mcp\",\n \"args\": [\n \"--x\"\n ]\n },\n \"mesh\": {\n \"type\": \"http\",\n \"url\": \"http://127.0.0.1:4270/mcp\"\n },\n \"search\": {\n \"type\": \"http\",\n \"url\": \"https://s.example/mcp\"\n }\n }\n}\n",
|
||||
"managed-settings.json": "{\n \"attribution\": {\n \"commit\": \"\",\n \"pr\": \"\"\n },\n \"allowAllClaudeAiMcps\": true,\n \"apiKeyHelper\": \"/state/api-key-helper\"\n}\n",
|
||||
"CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalogue, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** the laptop\n- The other nodes, their roles and what runs where: ask the controller (`mesh-controller.nodes`,\n `mesh-controller.node`). Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own verbs\n are `mesh-controller.<verb>`: `status`, `plan`, `node`, `assign`, `push`, `settings`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalogue.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalogue that owns it, never on disk. If\n unsure, `mesh-controller.plan` for the node says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
|
||||
},
|
||||
"plain": {
|
||||
"managed-mcp.json": "{\n \"mcpServers\": {\n \"mesh\": {\n \"type\": \"http\",\n \"url\": \"http://127.0.0.1:4270/mcp\"\n }\n }\n}\n",
|
||||
"managed-settings.json": "{\n \"attribution\": {\n \"commit\": \"\",\n \"pr\": \"\"\n },\n \"allowAllClaudeAiMcps\": true\n}\n",
|
||||
"CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalogue, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** not stated — set it in this module's settings for the node\n- The other nodes, their roles and what runs where: ask the controller (`mesh-controller.nodes`,\n `mesh-controller.node`). Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own verbs\n are `mesh-controller.<verb>`: `status`, `plan`, `node`, `assign`, `push`, `settings`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalogue.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalogue that owns it, never on disk. If\n unsure, `mesh-controller.plan` for the node says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user