From 8d2497b65ef435d27d318f43536233393dd9a5a1 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 09:25:57 +0100 Subject: [PATCH] C1: the licence manager runs as its own account, its service and its tools in its own runtime (issue 1409) Run inside the machine's tool runner, the manager was issued none of its own logs and states, so the carry-over failed live ("keeps no log called usage ... it was issued: none"). It now declares runs-as claude-licence-manager, makes that account, owns what it places on its machine and its secrets, reaches the bus with its own broker secret, and invokes a node's claude_code_grant, which the tool runner's account could ask and its own could not. The whole tests play the manager's own runtime: they issue only the states, logs, asks and events its manifest names, and fail on a manifest without runs-as; a unit test holds every ask in the code to the manifest's invokes. Builds: design 39 bus-and-switch C1 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- modules/claude-licence-manager/README.md | 6 + .../cmd/claude-licence-manager/main.go | 10 +- .../claude-licence-manager/manifest_test.go | 104 ++++++++++++++++++ .../whole_integration_test.go | 44 ++++++-- modules/claude-licence-manager/module.json | 18 ++- 5 files changed, 170 insertions(+), 12 deletions(-) diff --git a/modules/claude-licence-manager/README.md b/modules/claude-licence-manager/README.md index de0b4c789..afad6f7d2 100644 --- a/modules/claude-licence-manager/README.md +++ b/modules/claude-licence-manager/README.md @@ -5,6 +5,12 @@ rotation source, its consumers moved off a licence near its limit by the manager ADR 0206, design 39 and its milestone bus-and-switch). Every grant and access token lives in the vault; this module keeps none. +It runs as an account of its own (`runs-as claude-licence-manager`, design 39 bus-and-switch C1), in a runtime of its +own, never in the machine's tool runner: its service part (the carry-over at start, the usage timer, the switch rule, +the conditions) and its tools beside it, so every state, log and secret below is issued to its own account. Its +account is issued what its manifest names: the asks under `invokes` (the vault, the operator channel, mesh-config and +a node's `claude_code_grant`), the events under `emits` and `consumes`. + ## How a licence comes to exist Nothing is configured. Every node running `claude-code` says what it holds as the event diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go index d33c6d033..7e8ddc6f4 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go @@ -1,7 +1,9 @@ -// claude-licence-manager (novox/hq ADR 0183, ADR 0206, design 39): one binary, launched by the control -// node's runtime and speaking MCP to it over stdio through the Go SDK (ADR 0193, ADR 0198). It serves the -// `anthropic-licence-manager` seat's verbs and, beside them, runs long: it watches what every node reports -// holding and adopts a login it does not hold, keeps every grant alive under a lease, and reads usage. +// claude-licence-manager (novox/hq ADR 0183, ADR 0206, design 39): one binary, launched by a runtime of its own under +// its own account (`runs-as claude-licence-manager`, design 39 bus-and-switch C1), never by the machine's tool runner, +// and speaking MCP to it over stdio through the Go SDK (ADR 0193, ADR 0198). Its service part runs long (the +// carry-over at start, the usage timer, the switch rule, the conditions): it watches what every node reports holding +// and adopts a login it does not hold, keeps every grant alive under a lease, and reads usage. Its tools part, the +// seat's verbs and the module's tools, is served beside it and acts only through that one running manager. // // stdout is the MCP channel; everything this module says, it says on stderr. package main diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manifest_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manifest_test.go index e087078ab..8916b3680 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manifest_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manifest_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "os" "path/filepath" + "regexp" + "strings" "testing" ) @@ -48,3 +50,105 @@ func TestTheManifestKeepsNoOldForm(t *testing.T) { } } } + +// issued is what the manager's own runtime is issued from its manifest (design 39 bus-and-switch C1): its account, the +// states and logs it keeps (`requires` by template), what it may ask (`invokes`) and say (`emits`). +type issued struct { + RunsAs, SecretsOwner string + OwnSecrets map[string]string + Resources []map[string]any + States, Logs map[string]bool + Invokes, Emits map[string]bool +} + +func manifestIssued(t testing.TB) issued { + t.Helper() + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil { + t.Fatal(err) + } + var m struct { + RunsAs string `json:"runs-as"` + SecretsOwner string `json:"secrets-owner"` + OwnSecrets map[string]string `json:"own-secrets"` + Requires []json.RawMessage `json:"requires"` + Resources []map[string]any `json:"resources"` + Invokes []string `json:"invokes"` + Emits []string `json:"emits"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + out := issued{RunsAs: m.RunsAs, SecretsOwner: m.SecretsOwner, OwnSecrets: m.OwnSecrets, Resources: m.Resources, + States: map[string]bool{}, Logs: map[string]bool{}, Invokes: map[string]bool{}, Emits: map[string]bool{}} + for _, r := range m.Requires { + var e struct{ Name, Storage string } + if json.Unmarshal(r, &e) != nil { + continue + } + switch e.Storage { + case "key-value": + out.States[e.Name] = true + case "stream": + out.Logs[e.Name] = true + } + } + for _, k := range m.Invokes { + out.Invokes[k] = true + } + for _, e := range m.Emits { + out.Emits[e] = true + } + return out +} + +// mayAsk is whether the manager's own account may ask key: a module tool asked on one node (`.@`) +// is invoked as `.`. +func (i issued) mayAsk(key string) bool { + k, _, _ := strings.Cut(key, "@") + return i.Invokes[k] +} + +// design 39 bus-and-switch C1: the manager runs as an account of its own, which it makes and which owns what it places +// on its machine and its secrets, reaching the bus as itself; and that account is issued every ask its code makes, +// since its runtime is not the machine's tool runner. +func TestTheManagerRunsAsItsOwnAccount(t *testing.T) { + m := manifestIssued(t) + if m.RunsAs != Publisher { + t.Fatalf("the manager runs as %q, not as its own account %s", m.RunsAs, Publisher) + } + if m.SecretsOwner != Publisher || m.OwnSecrets["broker"] == "" { + t.Errorf("its secrets belong to %q and its own broker secret is %q: both are its account's", m.SecretsOwner, + m.OwnSecrets["broker"]) + } + made := false + for _, r := range m.Resources { + switch r["type"] { + case "user": + made = made || r["name"] == Publisher + case "directory", "file": + if r["owner"] != Publisher { + t.Errorf("resource %v is owned by %v, not by the manager's account", r["id"], r["owner"]) + } + } + } + if !made { + t.Errorf("the manager does not make its account: no user resource named %s", Publisher) + } + files, _ := filepath.Glob("*.go") + asked := regexp.MustCompile(`stdio\.Ask\("([^"]+)"`) + for _, f := range files { + if strings.HasSuffix(f, "_test.go") { + continue + } + src, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + for _, k := range asked.FindAllStringSubmatch(string(src), -1) { + if !m.mayAsk(k[1]) { + t.Errorf("%s asks %s, which the manifest's invokes do not name", f, k[1]) + } + } + } +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/whole_integration_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/whole_integration_test.go index 70a3ad73f..f9c750658 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/whole_integration_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/whole_integration_test.go @@ -287,6 +287,7 @@ type mesh struct { dir string dbURL string vendor *vendor + own issued // what the manager's own runtime is issued from its manifest (C1) mu sync.Mutex said []said @@ -299,11 +300,14 @@ type mesh struct { conditions []oc.Condition } -const busStates = "licences bindings leases offered carried conditions" -const busLogs = "usage audit" - func newWholeMesh(t *testing.T, dbURL string) *mesh { t.Helper() + // The manager runs as its own account, never in the machine's tool runner, whose account is issued none of its + // logs or states (C1): this player plays that runtime of its own, issuing what the manifest names and nothing else. + own := manifestIssued(t) + if own.RunsAs != Publisher { + t.Fatalf("the manager declares runs-as %q: it would run in the machine's tool runner, not as its own account", own.RunsAs) + } ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) dir := t.TempDir() @@ -323,12 +327,12 @@ func newWholeMesh(t *testing.T, dbURL string) *mesh { t.Fatal(err) } // What the bus module creates from the manifest's templates (§1 to §9) before the manager's want moves (§50). - for _, s := range strings.Fields(busStates) { + for s := range own.States { if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: Publisher + "_" + s, History: 8}); err != nil { t.Fatal(err) } } - for _, l := range strings.Fields(busLogs) { + for l := range own.Logs { if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "LOG_" + Publisher + "_" + l, Subjects: []string{"mesh.log." + Publisher + "." + l + ".>"}}); err != nil { t.Fatal(err) @@ -340,7 +344,7 @@ func newWholeMesh(t *testing.T, dbURL string) *mesh { t.Fatal(err) } } - m := &mesh{t: t, ctx: ctx, nc: nc, js: js, bin: bin, dir: dir, dbURL: dbURL, vendor: newVendor(t), + m := &mesh{t: t, ctx: ctx, nc: nc, js: js, bin: bin, dir: dir, dbURL: dbURL, vendor: newVendor(t), own: own, news: make(chan struct{}, 1), secrets: map[string]string{}, calls: map[string][32]byte{}, callArgs: map[string]map[string]any{}, logins: map[string]FullGrant{}} sub, err := nc.Subscribe("mesh.seat.operator-channel.accept.condition.>", func(msg *nats.Msg) { @@ -521,6 +525,10 @@ func (p *started) handle(w wire) { Pattern string `json:"pattern"` } _ = json.Unmarshal(w.Params, &a) + if why := p.refused(w.Method, w.Params); why != nil { + p.answer(w.ID, nil, why) + return + } if strings.HasPrefix(w.Method, "mesh/log.") { p.log(w) return @@ -620,6 +628,28 @@ func (p *started) handle(w wire) { } } +// refused is the runtime's refusal of what the manager's own account was not issued (C1): a state or a log its +// manifest does not ask for, an ask its invokes do not name, an event its emits do not name; nil when it was issued. +func (p *started) refused(method string, params json.RawMessage) error { + var a struct { + State string `json:"state"` + Log string `json:"log"` + Key string `json:"key"` + } + _ = json.Unmarshal(params, &a) + switch { + case strings.HasPrefix(method, "mesh/state.") && !p.own.States[a.State]: + return fmt.Errorf("%s keeps no state called %q", Publisher, a.State) + case strings.HasPrefix(method, "mesh/log.") && !p.own.Logs[a.Log]: + return fmt.Errorf("%s keeps no log called %q", Publisher, a.Log) + case method == "mesh/ask" && !p.own.mayAsk(a.Key): + return fmt.Errorf("this account may not call %s", a.Key) + case method == "mesh/publish" && !p.own.Emits[a.Key]: + return fmt.Errorf("%s does not emit %s", Publisher, a.Key) + } + return nil +} + func (p *started) say(key string, body json.RawMessage) { p.mu.Lock() p.said = append(p.said, said{key: key, body: append(json.RawMessage(nil), body...)}) @@ -899,7 +929,7 @@ func (m *mesh) written() map[string]uint64 { i, _ := info.Info(m.ctx) out[s] = i.State.LastSeq } - for _, l := range strings.Fields(busLogs) { + for l := range m.own.Logs { info, err := m.js.Stream(m.ctx, "LOG_"+Publisher+"_"+l) if err != nil { m.t.Fatal(err) diff --git a/modules/claude-licence-manager/module.json b/modules/claude-licence-manager/module.json index 3c169a4df..ce212f0a8 100644 --- a/modules/claude-licence-manager/module.json +++ b/modules/claude-licence-manager/module.json @@ -2,6 +2,7 @@ "module": "claude-licence-manager", "version": "1", "slug": "licmgr", + "runs-as": "claude-licence-manager", "requires": [ "postgres-database", "secret", @@ -91,6 +92,10 @@ "grant-key": "${dir:state}/grant.key" } }, + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "claude-licence-manager", "uses": [ "operator-channel" ], @@ -98,7 +103,8 @@ "seat:operator-channel.tell", "seat:mesh-vault.licence-put", "seat:mesh-vault.get", - "seat:mesh-config.config" + "seat:mesh-config.config", + "claude-code.claude_code_grant" ], "seats": [ { @@ -232,10 +238,18 @@ } }, "resources": [ + { + "id": "account", + "type": "user", + "name": "claude-licence-manager", + "shell": "/usr/bin/nologin", + "home": "/var/lib/claude-licence-manager" + }, { "id": "state", "type": "directory", "mode": "0700", + "owner": "claude-licence-manager", "place": ".", "unknown": "program" }, @@ -244,6 +258,7 @@ "type": "file", "path": "${dir:state}/database.url", "mode": "0600", + "owner": "claude-licence-manager", "template": "files/database.url" }, { @@ -251,6 +266,7 @@ "type": "file", "path": "${dir:state}/settings.json", "mode": "0600", + "owner": "claude-licence-manager", "takes-config": [ "cadence_minutes", "floor_minutes",