diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index 90ffeaed..88deec86 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -599,7 +599,7 @@ func TestGrants(t *testing.T) { b := fixture() b.manifestOf("mod-z", `{"module":"mod-z","version":"1","own-secrets":{"broker":"/var/lib/z/broker"}`+row.build+`}`) b.assign("a", "mod-z") - b.manifestOf("node-tools", `{"module":"node-tools","version":"1","own-secrets":{"broker":"/var/lib/nt/broker"}}`) + b.manifestOf("node-tools", `{"module":"node-tools","version":"1","tool-runner":true,"own-secrets":{"broker":"/var/lib/nt/broker"}}`) b.assign("a", "node-tools") users := shadowOf(t, b) tools := users[slices.IndexFunc(users, func(u grants.User) bool { return u.User == "a.node-tools" })] @@ -1255,7 +1255,7 @@ func TestGrants(t *testing.T) { n.manifestOf("claude-licence-manager", string(licence)) n.assign("novox", "nats") n.assign("novox", "claude-licence-manager") - n.manifestOf("node-tools", `{"module":"node-tools","version":"1"}`) // the machine's tool runner, which carries the seat + n.manifestOf("node-tools", `{"module":"node-tools","version":"1","tool-runner":true}`) // the machine's tool runner, which carries the seat n.assign("novox", "node-tools") // The live placement names mesh-host; with its manifest's state the runner once carried it and the whole list was // refused as a second writer of the engine's health (issue 774, 2026-10-30): the shadow below now composes. @@ -2839,6 +2839,30 @@ func TestGrants(t *testing.T) { if p.Compose(); said[len(said)-1].State != asks.StateCleared { t.Errorf("the condition was not cleared once the manifest is accepted: %+v", said[len(said)-1]) } + // ADR 0601 §4: a tool runner whose manifest does not say so yet (an older build's) keeps its user as the list in force + // holds it, known by what it runs (it says the runner is alive), and the held condition says so; never composed as a + // plain module. + const runner = `{"module":"node-tools","version":"1","tool-runner":true,"own-secrets":{"broker":"/var/lib/nt/broker"}}` + b.manifestOf("node-tools", runner) + b.assign("a", "node-tools") + b.put(grants.ReadAccounts, "a.node-tools", map[string]any{"user": "a.node-tools", "hash": "$2a$10$first", "generation": 1, "fingerprint": "sha256:1"}) + p.Compose() + inForce, _, _ = p.Sealed() + ran, _, ok, _ := b.ownGet(grants.StateUserList, grants.SealedUserKey(inForce.Digest, "a.node-tools")) + if !ok || !strings.Contains(string(ran), "mesh.control.a.tools-alive") { + t.Fatalf("the list in force holds no tool runner on a: %s", ran) + } + b.manifestOf("node-tools", strings.Replace(runner, `"tool-runner":true,`, "", 1)) + b.put(grants.ReadAccounts, "a.mod-x", map[string]any{"user": "a.mod-x", "hash": "$2a$10$gen10", "generation": 10, "fingerprint": "sha256:10"}) + p.Compose() + now, _, _ := p.Sealed() + kept, _, ok, _ := b.ownGet(grants.StateUserList, grants.SealedUserKey(now.Digest, "a.node-tools")) + if !ok || string(kept) != string(ran) { + t.Errorf("the tool runner of a was composed without what it runs: %s, was %s", kept, ran) + } + if last := said[len(said)-1]; last.State != asks.StateRaised || !strings.Contains(last.Summary, "a.node-tools (stays in the list") { + t.Errorf("the kept tool runner was not said: %+v", last) + } }) } diff --git a/modules/mesh-grants/internal/buscompose/buscompose_test.go b/modules/mesh-grants/internal/buscompose/buscompose_test.go index 32953ed9..2b00cff9 100644 --- a/modules/mesh-grants/internal/buscompose/buscompose_test.go +++ b/modules/mesh-grants/internal/buscompose/buscompose_test.go @@ -129,7 +129,7 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { ByCaller: []string{"ask", "cancel", "decided"}, Records: []string{"messenger_asks"}} users, err := Users(Records{Nodes: []string{"one", "two"}, Assigned: map[string][]Declared{ "one": {{Module: "messenger", Holds: []Seat{router}, Invokes: []string{"seat:" + EngineSeat + ".root-free"}}}, - "two": {{Module: RuntimeModule}, {Module: "shop", Invokes: []string{"*"}}}}}) + "two": {{Module: "node-tools", ToolRunner: true}, {Module: "shop", Invokes: []string{"*"}}}}}) if err != nil { t.Fatal(err) } @@ -361,7 +361,7 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { {Module: "mesh-enrolment", RunsAs: "mesh-enrolment", Holds: []Seat{holder}, Invokes: calls, State: []Bucket{{Module: "mesh-enrolment", Name: "enrolments", KeyTTL: true}}}, {Module: "shop", Invokes: calls[:2]}, - {Module: RuntimeModule}, {Module: "agent-tools", Invokes: []string{"*"}}}}}) + {Module: "node-tools", ToolRunner: true}, {Module: "agent-tools", Invokes: []string{"*"}}}}}) if err != nil { t.Fatal(err) } @@ -782,7 +782,7 @@ func healthChecks(t *testing.T) { } } // Nor the runtime carrying the node's modules, which reads each one's membership from the state. - if perms, err := PermissionsFor(Principal{Kind: KindNodeTools, Node: "a", Module: RuntimeModule, + if perms, err := PermissionsFor(Principal{Kind: KindNodeTools, Node: "a", Module: "node-tools", Carries: []Declared{{Module: "restic"}}}); err != nil || MaySubscribe(perms, "mesh.assignment.a.restic") || !MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.a.restic") { t.Errorf("the runtime hears mesh.assignment or reads no membership: %v", err) @@ -926,7 +926,7 @@ func everyNodesCompositions(t *testing.T) { t.Error("the read is granted as one bucket mesh-host_composed") } // A bundle reading them (the provisioner, carried by the machine's runtime) reads them on the runtime's connection. - carried := Principal{Kind: KindNodeTools, Node: "one", Module: RuntimeModule, Nodes: []string{"one", "two"}, + carried := Principal{Kind: KindNodeTools, Node: "one", Module: "node-tools", Nodes: []string{"one", "two"}, Carries: []Declared{{Module: "mesh-provisioner", Reads: []string{delivery.ComposedRead}}}} runner, err := PermissionsFor(carried) if err != nil { diff --git a/modules/mesh-grants/internal/buscompose/creates_streams_test.go b/modules/mesh-grants/internal/buscompose/creates_streams_test.go index 35937977..7c99a208 100644 --- a/modules/mesh-grants/internal/buscompose/creates_streams_test.go +++ b/modules/mesh-grants/internal/buscompose/creates_streams_test.go @@ -21,7 +21,7 @@ func theGrantsPartCreatesOnlyDeclaredStreams(t *testing.T) { Logs: []Log{{Module: "mesh-delivery-builder", Name: "builds"}}, Streams: []ModuleStream{{Module: "mesh-delivery-builder", Name: "BUILDASKS", Events: []string{"ask"}}}}, {Module: GrantsModule, State: []Bucket{{Module: GrantsModule, Name: "assertions"}}, Reads: []string{VaultBusAccounts}}, - {Module: RuntimeModule}, + {Module: "node-tools", ToolRunner: true}, // The node-engine's manifest as a placement names it (issue 774, met twice on 2026-10-30): no account of its // own, so the runner would carry it, and its state is the node's to write alone. {Module: EngineModule, NoAccount: true, Consumes: []string{"restic.backed-up"}, diff --git a/modules/mesh-grants/internal/buscompose/nats.go b/modules/mesh-grants/internal/buscompose/nats.go index 93482882..d8314b75 100644 --- a/modules/mesh-grants/internal/buscompose/nats.go +++ b/modules/mesh-grants/internal/buscompose/nats.go @@ -58,12 +58,13 @@ const ( KindLedger Kind = "ledger" ) -// RuntimeModule is the module that IS the node's tool runtime (novox/hq ADR 0175). Where it is -// assigned, the mesh composes one runtime principal for the machine in place of that module's own, -// and the per-module containers that served tools until then stop being the way tools reach a node. -// Mirrored in the catalogue package, which the agreement test holds to the same string; one -// constant, so a rename is one edit and the two packages cannot drift. -const RuntimeModule = "node-tools" +// The node's tool runner (novox/hq ADR 0175) is the module whose manifest says it is (`tool-runner`, Declared.ToolRunner; +// ADR 0601 §4), never a name here. Where it is assigned, the mesh composes one runtime principal for the machine in place of +// that module's own, and the per-module containers that served tools until then stop being the way tools reach a node. + +// ToolsAliveSubject is what a node's tool runner says it is alive on: granted to the runner alone, so a user of the list in +// force that may publish it is that node's tool runner (ADR 0601 §4's keeping of what runs). +func ToolsAliveSubject(node string) string { return "mesh.control." + node + ".tools-alive" } // Seat is a role on the bus as a principal relates to it: the subjects it accepts, and those it // emits (novox/hq ADR 0118, design 29 §5). @@ -999,7 +1000,7 @@ func PermissionsFor(p Principal) (Permissions, error) { pub = append(pub, readGrants(GrantsMembershipsBucket, p.Node+".*")...) // And that it is there (novox/hq to-be 45 §3, S11): its own heartbeat, under its machine's // name and no other's, on core NATS like the host's. - pub = append(pub, "mesh.control."+p.Node+".tools-alive") + pub = append(pub, ToolsAliveSubject(p.Node)) // And every tool on the mesh (ADR 0175, decision 5): any node may call any tool on any // node, as the console already could — the runtime is the console's serving mode. invoked, err := invokedSubjects([]string{"*"}) @@ -1016,7 +1017,7 @@ func PermissionsFor(p Principal) (Permissions, error) { // discovery for each module and seat it carries, and the console it is asks the bus. // One service per runtime process, named for the runtime: the bus lets a principal answer each // request once, so the runtime announces everything it carries under its own name. - sub = append(sub, announcing(append([]string{RuntimeModule}, serves...)...)...) + sub = append(sub, announcing(append([]string{p.Module}, serves...)...)...) pub = append(pub, discovering()...) // **And it consumes for the modules it carries** (novox/hq ADR 0198, which changes ADR 0175's // "it consumes nothing"): a module's long-running code is a bundle this runtime launches, and diff --git a/modules/mesh-grants/internal/buscompose/users.go b/modules/mesh-grants/internal/buscompose/users.go index cbf0a027..5a667a96 100644 --- a/modules/mesh-grants/internal/buscompose/users.go +++ b/modules/mesh-grants/internal/buscompose/users.go @@ -26,6 +26,8 @@ import ( // Declared is one module on one node, as composing its authority needs it. type Declared struct { Module string + // ToolRunner says the module is the node's tool runner, as its manifest says (`tool-runner`, ADR 0601 §4). + ToolRunner bool // Objects are its own object buckets, and ReadsObjects the bus names of the object buckets of others its reads name // and their owners' grants let it read (novox/hq ADR 0592 §5, §9). Objects []ObjectBucket @@ -202,10 +204,10 @@ func Users(r Records) ([]Principal, error) { // node: its serving grants are the union of theirs. Every other module keeps its own // principal — a module still serving tools from its own container holds its own // credential until it moves, and the two serve side by side in the meantime. - runtimeHere := false + runtimeHere, runner := false, "" for _, d := range r.Assigned[node] { - if d.Module == RuntimeModule { - runtimeHere = true + if d.ToolRunner { + runtimeHere, runner = true, d.Module } } for _, d := range r.Assigned[node] { @@ -237,7 +239,7 @@ func Users(r Records) ([]Principal, error) { if len(d.Ledgers) > 0 { out = append(out, Principal{Kind: KindLedger, Node: node, Module: d.Module, Ledgers: sortedCopy(d.Ledgers)}) } - if runtimeHere && d.Module == RuntimeModule { + if runtimeHere && d.ToolRunner { continue } // **A module with nowhere to read an account is no user** (novox/hq issue 195). `module @@ -284,7 +286,7 @@ func Users(r Records) ([]Principal, error) { carried = append(carried, d) } } - out = append(out, Principal{Kind: KindNodeTools, Node: node, Module: RuntimeModule, Carries: carried}) + out = append(out, Principal{Kind: KindNodeTools, Node: node, Module: runner, Carries: carried}) } } // A node enrols through the seat mesh-enrolment alone (ADR 0587 §6, ADR 0590): while no holder serves redeem, an @@ -525,7 +527,7 @@ func CarriedByRunner(d Declared) bool { return Composed(d) && d.RunsAs == "" && // stays carried, and so does the runtime module itself. Whether the module has its own broker secret is not // the test: a module with tools and its own credential is still carried (ADR 0175). func runnerServes(d Declared) bool { - return d.Module == RuntimeModule || d.NoAccount || d.Loads || len(d.Serves) > 0 || len(d.Holds) > 0 || len(d.Waits) > 0 || len(d.Checks) > 0 + return d.ToolRunner || d.NoAccount || d.Loads || len(d.Serves) > 0 || len(d.Holds) > 0 || len(d.Waits) > 0 || len(d.Checks) > 0 } // declaredStreamsOf is the stream of every bucket, log and stream a module placed anywhere declares, once each, in name diff --git a/modules/mesh-grants/internal/buscompose/waits.go b/modules/mesh-grants/internal/buscompose/waits.go index c92300ed..1fb8616b 100644 --- a/modules/mesh-grants/internal/buscompose/waits.go +++ b/modules/mesh-grants/internal/buscompose/waits.go @@ -62,9 +62,9 @@ func waitGrants(carries []Declared) ([]string, error) { if len(d.Waits) == 0 { continue } - if d.Module != RuntimeModule { - return nil, fmt.Errorf("%s declares what a session waits on; only %s, the tool runner's module, "+ - "does (novox/hq ADR 0367 §2)", d.Module, RuntimeModule) + if !d.ToolRunner { + return nil, fmt.Errorf("%s declares what a session waits on; only the tool runner's module (its manifest says "+ + "tool-runner) does (novox/hq ADR 0367 §2)", d.Module) } for _, w := range d.Waits { s, err := WaitSubject(w) @@ -79,7 +79,7 @@ func waitGrants(carries []Declared) ([]string, error) { // waitsIssuedFor is the families a membership names: the tool runner's module's, and nobody else's. func waitsIssuedFor(d Declared) []WaitIssued { - if d.Module != RuntimeModule { + if !d.ToolRunner { return nil } var out []WaitIssued diff --git a/modules/mesh-grants/internal/grants/held.go b/modules/mesh-grants/internal/grants/held.go index 1b970744..1a283593 100644 --- a/modules/mesh-grants/internal/grants/held.go +++ b/modules/mesh-grants/internal/grants/held.go @@ -2,9 +2,12 @@ package grants import ( "fmt" + "slices" "sort" "strings" + "mesh-grants/internal/buscompose" + "git.novox.be/novox/mesh-sdk/go/asks" ) @@ -54,6 +57,28 @@ func heldModuleUsers(in Inputs, candidates []string) map[string]string { return out } +// runnerUsersKept are, by user, why it is kept: on a node where no placed module's manifest says it is the tool runner +// (`tool-runner`), the user of the list in force that is that node's tool runner, known by what runs (it may say the runner +// is alive, buscompose.ToolsAliveSubject, which the runner alone is granted), never by a module's name (ADR 0601 §4). A +// manifest that does not yet say what shared code no longer infers keeps the runner as it runs, said in the held module's +// condition, and is never composed without it. Pure. +func runnerUsersKept(recs buscompose.Records, inForce map[string]User) map[string]string { + out := map[string]string{} + for node, placed := range recs.Assigned { + if slices.ContainsFunc(placed, func(d buscompose.Declared) bool { return d.ToolRunner }) { + continue + } + alive := buscompose.ToolsAliveSubject(node) + for user, u := range inForce { + if strings.HasPrefix(user, node+".") && slices.Contains(u.Publish, alive) { + out[user] = fmt.Sprintf("no manifest placed on %s says it is the tool runner (tool-runner), so the tool runner keeps "+ + "what it runs until its manifest says so", node) + } + } + } + return out +} + // inForceGrants are the grants of each user of the list in force as the part sealed them, or nil where it kept none. func (p *Part) inForceGrants() map[string]User { if g, _, had := p.Sealed(); had && g.Digest != "" && p.Store != nil { diff --git a/modules/mesh-grants/internal/grants/part.go b/modules/mesh-grants/internal/grants/part.go index bb0a8b30..ed644c8d 100644 --- a/modules/mesh-grants/internal/grants/part.go +++ b/modules/mesh-grants/internal/grants/part.go @@ -271,6 +271,9 @@ func (p *Part) Compose() Result { candidates = append(candidates, user) } heldUsers := heldModuleUsers(in, candidates) + for user, why := range runnerUsersKept(listRecs, inForce) { + heldUsers[user] = why + } allRefused := mergedRefusals(refused, heldUsers) kept, _ := p.keptOf(allRefused) for _, user := range sortedKeys(refused) { diff --git a/modules/mesh-grants/internal/grants/records.go b/modules/mesh-grants/internal/grants/records.go index 62d42424..c93bf116 100644 --- a/modules/mesh-grants/internal/grants/records.go +++ b/modules/mesh-grants/internal/grants/records.go @@ -132,7 +132,7 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio fromModules = append(fromModules, c) } d := buscompose.Declared{ - Module: m.Module, Emits: m.EmitsAll(), Consumes: fromModules, Watches: watches, Serves: m.Tools, + Module: m.Module, ToolRunner: catalogue.IsToolRunner(m), Emits: m.EmitsAll(), Consumes: fromModules, Watches: watches, Serves: m.Tools, Invokes: m.Invokes, Waits: waitsOf(m, seats), State: bucketsOf(m), Reads: m.Reads, Logs: logsOf(m), Streams: streamsOf(m), Queues: queueOwnersOf(m), Processes: processesOf(m), Checks: catalogue.HealthChecks(m), RunsAs: m.RunsAs, Issued: issuedTools(m),