The grants part knows the tool runner by what its manifest says, never by its name, and keeps a node's tool runner user as the list in force holds it while no manifest there says it yet, said in the held condition (ADR 0601 §4; the prepare's seams finding on mesh-grants's own name)

This commit is contained in:
2026-11-03 16:44:47 +01:00
parent daf178c4ca
commit b342c67b19
9 changed files with 81 additions and 26 deletions
+26 -2
View File
@@ -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)
}
})
}
@@ -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 {
@@ -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"},
@@ -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
@@ -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
@@ -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
@@ -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 {
@@ -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) {
@@ -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),