diff --git a/internal/broker/delivery_gate_grant_test.go b/internal/broker/delivery_gate_grant_test.go index 0a1db088..8466d4cf 100644 --- a/internal/broker/delivery_gate_grant_test.go +++ b/internal/broker/delivery_gate_grant_test.go @@ -67,8 +67,8 @@ func TestTheGatesBucketsAreWritableByItsMachinesToolRunner(t *testing.T) { if !canRead(runner, read, "k") { t.Errorf("novox's tool runner may not read %s, which the gate declares reading", read) } - // (The runner may write the teller's answers: the teller declares no runs-as, so the runner carries it too. See the report of issue 1326.) - if canWrite(runner, read, "k") && read != "mesh-delivery-teller_answers" { + // 1329/AC3: the runner no longer carries the teller, so it may not write the teller's answers either. + if canWrite(runner, read, "k") { t.Errorf("novox's tool runner may write %s, which the gate only reads", read) } } diff --git a/internal/broker/delivery_parts_grant_test.go b/internal/broker/delivery_parts_grant_test.go index 836a9f9a..947e69c1 100644 --- a/internal/broker/delivery_parts_grant_test.go +++ b/internal/broker/delivery_parts_grant_test.go @@ -13,8 +13,9 @@ import ( // and streams, `reads` watching another module's bucket and never writing it, `consumes` hearing the named events, // `invokes` calling the named tools, `uses` the seat operator-channel's by-caller subjects under the module's own name. // -// The three manifests are copied from mesh-delivery's main at 6a23627426c6 (modules//module.json, read -// 2026-10-19), whole, so a change to one of them there that changes its grant is a change to read here. +// The four manifests (the watchman's is the fourth, issue 1329) are copied from mesh-delivery's main at 9c15112d935a +// (modules//module.json, read 2026-10-20; the first three are unchanged since 6a23627426c6), whole, so a change +// to one of them there that changes its grant is a change to read here. const bridgeManifest = `{ "module": "mesh-delivery-forge-bridge", "version": "1", @@ -160,6 +161,69 @@ const indexerManifest = `{ } }` +const watchmanManifest = `{ + "module": "mesh-delivery-watchman", + "version": "1", + "slug": "watchman", + "reads": [ + "mesh-controller.checks-running" + ], + "state": [ + { "name": "presence", "history": 20 }, + { "name": "pulse", "key-ttl": true }, + { "name": "stuck", "history": 20 } + ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-delivery-watchman", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-delivery-watchman", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-delivery-watchman" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-delivery-watchman", + "place": ".", + "unknown": "program" + }, + { + "id": "watchman", + "type": "process", + "name": "mesh-delivery-watchman", + "artifact": "program", + "run": [ + "./mesh-delivery-watchman" + ], + "env": { + "MESH_BROKER_FILE": "${dir:state}/broker" + }, + "user": "mesh-delivery-watchman", + "health": { + "kind": "unit" + } + } + ], + "build": { + "artifacts": [ + { + "name": "program", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-delivery-watchman", + "binary": "mesh-delivery-watchman" + } + ] + } +}` + const tellerManifest = `{ "module": "mesh-delivery-teller", "version": "1", @@ -488,3 +552,87 @@ func TestNoPartsCredentialReachesBeyondItsDeclaration(t *testing.T) { } } } + +// toolServer is a module that serves a tool on its own broker credential: the runner carries it (ADR 0175). +func toolServer() Declared { + return Declared{Module: "some-tools", Serves: []string{"some_tool"}, + State: []Bucket{{Module: "some-tools", Name: "kept", History: 1}}} +} + +// 1329/AC1: composed from the manifests of mesh-delivery's main, the machine's tool runner may write no bucket of the +// forge bridge, the indexer, the teller or the watchman, publish none of their stream events nor the teller's +// operator-channel accept subjects, and pull none of their consumers; each part's own credential still may. +func TestTheToolRunnerCarriesNoneOfTheDeliverysParts(t *testing.T) { + streams := map[string]ModuleStream{"mesh-delivery-forge-bridge": {Module: "mesh-delivery-forge-bridge", Name: "FORGE"}} + all := composed(t, Records{Nodes: []string{"novox"}, Assigned: map[string][]Declared{"novox": { + {Module: RuntimeModule}, declaredOf(t, bridgeManifest, streams), declaredOf(t, indexerManifest, streams), + declaredOf(t, tellerManifest, streams), declaredOf(t, watchmanManifest, streams), + }}}) + runner := all["runner@novox"] + buckets := map[string][]string{ + "mesh-delivery-forge-bridge": {"heads"}, "mesh-delivery-indexer": {"candidates", "modules"}, + "mesh-delivery-teller": {"answers"}, "mesh-delivery-watchman": {"presence", "pulse", "stuck"}, + } + for part, names := range buckets { + own, there := all[part+"@novox"] + if !there { + t.Errorf("%s has no credential of its own", part) + continue + } + for _, name := range names { + if canWrite(runner, part+"_"+name, "some.key") { + t.Errorf("novox's tool runner may write %s_%s", part, name) + } + if !canWrite(own, part+"_"+name, "some.key") { + t.Errorf("%s may not write its own bucket %s", part, name) + } + } + } + for _, subject := range []string{ + "mesh.stream.mesh-delivery-forge-bridge.event.forge.merged", + "mesh.seat.operator-channel.accept.ask.mesh-delivery-teller", + "mesh.seat.operator-channel.accept.cancel.mesh-delivery-teller", + "mesh.seat.operator-channel.accept.condition.mesh-delivery-teller", + "$JS.API.CONSUMER.MSG.NEXT.FORGE.novox_mesh-delivery-indexer", + } { + if allowed(runner.Publish, subject) { + t.Errorf("novox's tool runner may publish %s", subject) + } + } + if !allowed(all["mesh-delivery-forge-bridge@novox"].Publish, "mesh.stream.mesh-delivery-forge-bridge.event.forge.merged") || + !allowed(all["mesh-delivery-teller@novox"].Publish, "mesh.seat.operator-channel.accept.ask.mesh-delivery-teller") || + !allowed(all["mesh-delivery-indexer@novox"].Publish, "$JS.API.CONSUMER.MSG.NEXT.FORGE.novox_mesh-delivery-indexer") { + t.Error("a part's own credential lost what its manifest grants") + } +} + +// 1329/AC2: a module with tools and a broker secret of its own stays carried by the tool runner, beside its own +// credential (ADR 0175); a module with neither tools, a seat, waits nor a check is not. +func TestTheToolRunnerStillCarriesAModuleWithToolsAndItsOwnCredential(t *testing.T) { + process := Declared{Module: "only-process", State: []Bucket{{Module: "only-process", Name: "mine", History: 1}}} + all := composed(t, Records{Nodes: []string{"novox"}, Assigned: map[string][]Declared{"novox": { + {Module: RuntimeModule}, toolServer(), process, + }}}) + runner := all["runner@novox"] + if !canWrite(runner, "some-tools_kept", "k") { + t.Error("novox's tool runner does not carry a module with tools") + } + if !canWrite(all["some-tools@novox"], "some-tools_kept", "k") { + t.Error("the module with tools lost its own credential") + } + if canWrite(runner, "only-process_mine", "k") { + t.Error("novox's tool runner carries a module that has nothing to serve") + } + if !canWrite(all["only-process@novox"], "only-process_mine", "k") { + t.Error("the process module lost its own credential") + } + for name, d := range map[string]Declared{ + "a held seat": {Module: "m", Holds: []Seat{{Name: "some-seat"}}}, + "a wait": {Module: "m", Waits: []Wait{{}}}, + "a check": {Module: "m", Checks: []string{"m.check"}}, + } { + if !runnerServes(d) { + t.Errorf("the tool runner does not serve a module with %s", name) + } + } +} diff --git a/internal/broker/modstreams_test.go b/internal/broker/modstreams_test.go index 9445a64d..a29b560f 100644 --- a/internal/broker/modstreams_test.go +++ b/internal/broker/modstreams_test.go @@ -154,7 +154,7 @@ func TestTheToolRunnerNeverHearsARestrictedStream(t *testing.T) { records := Records{Nodes: []string{"ace", "novox"}, Assigned: map[string][]Declared{ "novox": {{Module: "mesh-mail", Streams: []ModuleStream{mailStream}}, {Module: "news", Streams: []ModuleStream{newsStream}}}, "ace": {{Module: RuntimeModule}, - {Module: "carried", Hears: []Heard{mailHeard, newsHeard}, HearsAStream: true}, + {Module: "carried", NoAccount: true, Hears: []Heard{mailHeard, newsHeard}, HearsAStream: true}, {Module: "mailer", RunsAs: "mailer", Hears: []Heard{mailHeard}, HearsAStream: true}}, }} users, err := Users(records) diff --git a/internal/broker/users.go b/internal/broker/users.go index 46c01604..3612dc36 100644 --- a/internal/broker/users.go +++ b/internal/broker/users.go @@ -206,7 +206,7 @@ func Users(r Records) ([]Principal, error) { if runtimeHere { var carried []Declared for _, d := range r.Assigned[node] { - if d.RunsAs == "" { + if d.RunsAs == "" && runnerServes(d) { // Never a restricted stream for what the tool runner carries (novox/hq ADR 0376 §1). d.Hears = ComposedHears(d) carried = append(carried, d) @@ -391,3 +391,14 @@ func boundBucketsOf(r Records) []string { sort.Strings(out) return out } + +// runnerServes says the machine's tool runner has something of this module to serve (novox/hq ADR 0436 §1, +// ADR 0437 §4, issue 1329): tools or loads it answers, a seat it holds, event families a session waits on, +// or a check its health asks. A module with none of these is a process of its own on its own credential, and +// carrying it would hand every session on the machine the runner's account with that module's state, seat +// traffic and streams. A module with no account of its own (NoAccount) is spoken for by the runner, so it +// 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 || len(d.Serves) > 0 || len(d.Holds) > 0 || len(d.Waits) > 0 || len(d.Checks) > 0 +}