From 9372e80cec4712a499d099eb5292b96f48485151 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 18:31:31 +0200 Subject: [PATCH] =?UTF-8?q?Serve=20a=20trusted=20holder=20from=20a=20runti?= =?UTF-8?q?me=20of=20its=20own=20account,=20refuse=20it=20in=20the=20machi?= =?UTF-8?q?ne's=20runtime,=20and=20say=20while=20an=20agent=20can=20become?= =?UTF-8?q?=20root=20where=20it=20runs=20(hq=20ADR=200259=20=C2=A78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/mesh-controller/busobjects.go | 26 ++- cmd/mesh-controller/doctor.go | 5 + cmd/mesh-controller/probe_agent_root.go | 194 +++++++++++++++++++ cmd/mesh-controller/probe_agent_root_test.go | 60 ++++++ cmd/mesh-controller/seats.go | 6 + cmd/mesh-controller/seats_test.go | 12 ++ internal/broker/membership.go | 16 +- internal/broker/nats.go | 13 ++ internal/broker/seattraffic.go | 51 ++++- internal/broker/seattraffic_test.go | 78 +++++++- internal/broker/users.go | 14 +- internal/catalogue/declaration.go | 16 +- internal/catalogue/kinded_test.go | 47 ++++- internal/catalogue/manifest.go | 8 + internal/catalogue/runtime.go | 82 ++++++++ internal/catalogue/runtime_test.go | 46 +++++ internal/catalogue/seats_declared.go | 72 +++++++ internal/inventory/busrecords.go | 26 ++- 18 files changed, 747 insertions(+), 25 deletions(-) create mode 100644 cmd/mesh-controller/probe_agent_root.go create mode 100644 cmd/mesh-controller/probe_agent_root_test.go diff --git a/cmd/mesh-controller/busobjects.go b/cmd/mesh-controller/busobjects.go index 3c21d1d9..bb367a92 100644 --- a/cmd/mesh-controller/busobjects.go +++ b/cmd/mesh-controller/busobjects.go @@ -60,14 +60,14 @@ func assertBusObjects(ctx context.Context, inv *inventory.Inventory, r broker.Ra if err != nil { return nil, err } - for _, s := range trafficStreams { - if err := r.EnsureStream(s); err != nil { - return nil, fmt.Errorf("asserting the work queue %s: %w", s.Name, err) - } - } // Every one tried, and every failure named: one module's consumer the bus refuses is no reason // the modules after it in the list hear nothing (novox/hq issue 208, where this runs on each send). var failed []error + for _, s := range trafficStreams { + if err := r.EnsureStream(s); err != nil { + failed = append(failed, fmt.Errorf("the work queue %s: %w", s.Name, err)) + } + } for _, c := range trafficWorkers { if err := r.EnsureConsumer(c); err != nil { failed = append(failed, fmt.Errorf("the worker %s on %s: %w", c.Name, c.Stream, err)) @@ -159,6 +159,22 @@ func seatTrafficObjects(ctx context.Context, inv *inventory.Inventory) ([]broker return nil, nil, err } streams, workers := broker.SeatTrafficObjects(users) + // And the queue of every such seat the catalogue declares, held or not: work queues from registration, + // so what is submitted before a holder is assigned waits for it (the correctness review of 2026-10-08). + declared, err := inv.DeclaredTrafficSeats(ctx) + if err != nil { + return nil, nil, err + } + have := map[string]bool{} + for _, s := range streams { + have[s.Name] = true + } + for _, s := range broker.TrafficQueues(declared) { + if !have[s.Name] { + streams = append(streams, s) + have[s.Name] = true + } + } return streams, workers, nil } diff --git a/cmd/mesh-controller/doctor.go b/cmd/mesh-controller/doctor.go index b694dd43..c229a64d 100644 --- a/cmd/mesh-controller/doctor.go +++ b/cmd/mesh-controller/doctor.go @@ -126,6 +126,11 @@ var probeRegistry = []probe{ {ID: agentAccountProbe, Asserts: "every machine that names an agent account has it judged, on its node-engine's " + "newest statement, unable to become root without a person", From: "ADR 0266, ADR 0259 §8", Kind: kindAgentCanBecomeRoot, Phase: 1, run: probeAgentAccounts}, + // Root where the trusted parties run (novox/hq ADR 0259 §8): while an agent can become root there without a + // person, an answer proven there proves nothing. + {ID: "D-root", Asserts: "no agent can become root without a person on a machine where the router or a channel " + + "proving its sender runs: not by its own account, and not through a tool that runs its command as an account " + + "that can", From: "ADR 0259 §8", Kind: kindAgentRoot, Phase: 2, run: probeAgentRoot}, {ID: "DW", Asserts: "the watchdogs of the signals table ran within three of their intervals", From: "ADR 0227 rule 6: the watchers are watched", Kind: "watchdogs-silent", Phase: 1, run: probeWatchdogs}, // The core's health definitions (novox/hq to-be 45 §8, ADR 0236): what a core component's new build is diff --git a/cmd/mesh-controller/probe_agent_root.go b/cmd/mesh-controller/probe_agent_root.go new file mode 100644 index 00000000..b7aff456 --- /dev/null +++ b/cmd/mesh-controller/probe_agent_root.go @@ -0,0 +1,194 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "slices" + "sort" + "strings" + "time" + + "github.com/novox/mesh-controller/internal/conditions" + "github.com/novox/mesh-controller/internal/link" +) + +// The self-check's probe of who can become root where the trusted parties run (novox/hq ADR 0259 §8, rule 3). +// +// The router and every channel proving its sender run as accounts of their own, so that no agent reads what +// they hold or speaks as them. **Root on their machine undoes all of it.** So on every machine where a module +// of its own account runs, this probe asks two questions, and raises an urgent condition while either is yes: +// +// 1. can an account an agent runs as become root without a person there — passwordless sudo, or a group +// that is root by another name (docker, disk)? The agent's account is the one the coding-agent module on +// that machine names (`agent_account`), and the operator's account while it names none; +// 2. can an agent run a command it chooses, through the mesh's own tools, as an account that can — the login +// shell's `execute` runs as the machine's runtime account, which the mesh's acting tools give passwordless +// sudo? +// +// The measurement is the sudo module's on that machine (`sudo_escalation`); a machine it does not run on, or +// that does not answer, is a probe that could not run — said, never taken for "no". + +// kindAgentRoot is the condition an agent able to become root where a trusted party runs raises. +const kindAgentRoot = "agent-root" + +// The tools the probe asks, of the modules on each machine. +const ( + agentModule = "claude-code" + agentStatusTool = "claude_code_status" + sudoModule = "sudo" + sudoEscalation = "sudo_escalation" + loginShellSeat = "node-login-shell" +) + +// escalation is the sudo module's answer for one account. +type escalation struct { + Account string `json:"account"` + Root bool `json:"root_without_a_person"` + Why string `json:"why"` +} + +// agentRootFacts is what one machine says, as the probe reads it. +type agentRootFacts struct { + Machine string + Trusted []string // the modules of their own account on it + Agent string // the account agents run as there; "" when none run there + AgentNamed bool // the coding-agent module named it, rather than it being taken for the operator's + Runtime string // the account the machine's runtime runs as + LoginShell bool // the login shell seat is held there, running commands as the runtime's account + Answers map[string]escalation +} + +// agentRootObservations judges one machine's facts: an urgent condition while an agent can become root there +// without a person, one way or the other, naming which. +func agentRootObservations(f agentRootFacts) []conditions.Observation { + var ways []string + if f.Agent != "" { + if e := f.Answers[f.Agent]; e.Root { + named := "the operator's account, which agents run as while the coding-agent module names no other" + if f.AgentNamed { + named = "the account agents run as" + } + ways = append(ways, fmt.Sprintf("%s (%s) can become root without a person: %s", f.Agent, named, e.Why)) + } + } + if f.LoginShell && f.Runtime != "" { + if e := f.Answers[f.Runtime]; e.Root { + ways = append(ways, fmt.Sprintf("the login shell runs any command an agent gives it as %s, which can "+ + "become root without a person: %s", f.Runtime, e.Why)) + } + } + if len(ways) == 0 { + return nil + } + sort.Strings(f.Trusted) + return []conditions.Observation{{Scope: conditions.ScopeMachine, ID: f.Machine, Token: kindAgentRoot, + Machine: f.Machine, Kind: kindAgentRoot, Severity: conditions.Urgent, + Summary: fmt.Sprintf("an agent can become root on %s without a person, where %s run as accounts of their "+ + "own; until it cannot, the router approves nothing proven there (novox/hq ADR 0259 §8): %s", + f.Machine, strings.Join(f.Trusted, ", "), strings.Join(ways, "; ")), + Headline: "Root without you on " + f.Machine, + Needs: "choose how programs working for you on " + f.Machine + " stop becoming root without asking you.", + Explanation: "The modules that prove your answers from your phone run on " + f.Machine + ", and a program " + + "working for you there can become root without asking you, so it could answer in your name. Until " + + "that changes, answers from your phone can only acknowledge.", + Resolved: "Nothing on " + f.Machine + " becomes root without you any more"}} +} + +// probeAgentRoot is the probe: every machine a module of its own account runs on, judged. +func probeAgentRoot(ctx context.Context, d *doctor) ([]conditions.Observation, error) { + inv := d.open.inventory + entries, err := inv.Catalogued(ctx) + if err != nil { + return nil, err + } + facts := map[string]*agentRootFacts{} + agentOn, sudoOn := map[string]bool{}, map[string]bool{} + for _, e := range entries { + for _, node := range e.On { + switch { + case e.Manifest.RunsAs != "": + f := facts[node] + if f == nil { + f = &agentRootFacts{Machine: node, Answers: map[string]escalation{}} + facts[node] = f + } + f.Trusted = append(f.Trusted, e.Manifest.Module) + case e.Manifest.Module == agentModule: + agentOn[node] = true + case e.Manifest.Module == sudoModule: + sudoOn[node] = true + } + } + } + for _, e := range entries { + if e.Manifest.ClaimsSeat(loginShellSeat) { + for _, node := range e.On { + if f := facts[node]; f != nil { + f.LoginShell = true + } + } + } + } + machines := make([]string, 0, len(facts)) + for m := range facts { + machines = append(machines, m) + } + sort.Strings(machines) + var out []conditions.Observation + var unread []string + for _, m := range machines { + f := facts[m] + record, err := inv.NodeByName(ctx, m) + if err != nil { + unread = append(unread, m+": "+err.Error()) + continue + } + f.Runtime = record.Account + if agentOn[m] { + f.Agent = record.Account + if a, err := link.AskModuleToolOn(ctx, d.js.Conn(), agentModule, agentStatusTool, m, map[string]any{}, 10*time.Second); err == nil && a.Error == "" { + var status struct { + AgentAccount string `json:"agent_account"` + } + if json.Unmarshal(a.Result, &status) == nil && status.AgentAccount != "" { + f.Agent, f.AgentNamed = status.AgentAccount, true + } + } + } + if !sudoOn[m] { + unread = append(unread, m+": the sudo module is not assigned there, so who can become root is not measured") + continue + } + var accounts []string + for _, a := range []string{f.Agent, f.Runtime} { + if a != "" && !slices.Contains(accounts, a) { + accounts = append(accounts, a) + } + } + if len(accounts) == 0 { + continue + } + a, err := link.AskModuleToolOn(ctx, d.js.Conn(), sudoModule, sudoEscalation, m, map[string]any{"accounts": accounts}, 15*time.Second) + if err == nil && a.Error != "" { + err = fmt.Errorf("%s", a.Error) + } + if err != nil { + unread = append(unread, m+": "+err.Error()) + continue + } + var answers []escalation + if err := json.Unmarshal(a.Result, &answers); err != nil { + unread = append(unread, m+": the sudo module's answer could not be read: "+err.Error()) + continue + } + for _, e := range answers { + f.Answers[e.Account] = e + } + out = append(out, agentRootObservations(*f)...) + } + if len(unread) > 0 { + return out, fmt.Errorf("who can become root could not be measured: %s", strings.Join(unread, "; ")) + } + return out, nil +} diff --git a/cmd/mesh-controller/probe_agent_root_test.go b/cmd/mesh-controller/probe_agent_root_test.go new file mode 100644 index 00000000..5ca7364b --- /dev/null +++ b/cmd/mesh-controller/probe_agent_root_test.go @@ -0,0 +1,60 @@ +package main + +import ( + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/conditions" +) + +// novox/hq ADR 0259 §8, rule 3: the probe fails today — agents run as the operator's account, which has +// passwordless sudo, and the login shell runs their commands as it — and passes only once neither holds. +func TestAnAgentAbleToBecomeRootWhereTheRouterRunsIsSaid(t *testing.T) { + root := escalation{Root: true, Why: "(ALL : ALL) NOPASSWD: ALL"} + none := escalation{Why: "no rule lets it without a password"} + base := func() agentRootFacts { + return agentRootFacts{Machine: "anchor", Trusted: []string{"telegram", "messenger"}, Runtime: "ops", + Answers: map[string]escalation{}} + } + + today := base() + today.Agent, today.LoginShell = "ops", true + today.Answers["ops"] = root + got := agentRootObservations(today) + if len(got) != 1 || got[0].Severity != conditions.Urgent || got[0].Kind != kindAgentRoot || + !strings.Contains(got[0].Summary, "the operator's account") || !strings.Contains(got[0].Summary, "the login shell") { + t.Fatalf("today: %+v", got) + } + + agentsMoved := base() + agentsMoved.Agent, agentsMoved.AgentNamed, agentsMoved.LoginShell = "agents", true, true + agentsMoved.Answers["agents"], agentsMoved.Answers["ops"] = none, root + if got := agentRootObservations(agentsMoved); len(got) != 1 || strings.Contains(got[0].Summary, "agents (") || + !strings.Contains(got[0].Summary, "the login shell") { + t.Errorf("agents of their own account, the login shell still the runtime's: %+v", got) + } + + closed := base() + closed.Agent, closed.AgentNamed = "agents", true + closed.Answers["agents"], closed.Answers["ops"] = none, root + if got := agentRootObservations(closed); len(got) != 0 { + t.Errorf("agents of their own account and no login shell there: %+v", got) + } + + noAgents := base() + noAgents.Answers["ops"] = root + if got := agentRootObservations(noAgents); len(got) != 0 { + t.Errorf("no agent runs there and no login shell is held: %+v", got) + } +} + +// Its words are plain, as every condition's are (novox/hq ADR 0253). +func TestTheRootConditionIsSaidInPlainWords(t *testing.T) { + f := agentRootFacts{Machine: "anchor", Trusted: []string{"telegram"}, Runtime: "ops", Agent: "ops", + Answers: map[string]escalation{"ops": {Root: true, Why: "x"}}} + o := agentRootObservations(f)[0] + if why, ok := conditions.PlainWords(conditions.Words{Headline: o.Headline, Explanation: o.Explanation, + Needs: o.Needs, Resolved: o.Resolved}, "anchor"); !ok { + t.Errorf("not plain: %s", why) + } +} diff --git a/cmd/mesh-controller/seats.go b/cmd/mesh-controller/seats.go index 793897a6..07910266 100644 --- a/cmd/mesh-controller/seats.go +++ b/cmd/mesh-controller/seats.go @@ -135,6 +135,12 @@ func handOver(ctx context.Context, seatName, to string, adding bool) error { if !ok || nodeName == "" || module == "" { return fmt.Errorf("the new holder is named /, not %q", to) } + // A kinded bench is held once per kind, by the claims themselves (novox/hq ADR 0234 §2, ADR 0259): the + // record of who holds a seat has no kind, so a handover would name one holder for every kind. + if catalogue.KindedBenches[seatName] { + return fmt.Errorf("%s is a kinded bench: each kind is held by the module claiming it, and is not handed "+ + "over by `seat` — assign the module that claims the kind, or unassign the one that does", seatName) + } open, err := openStores(ctx) if err != nil { return err diff --git a/cmd/mesh-controller/seats_test.go b/cmd/mesh-controller/seats_test.go index 9fc61356..7db64bf8 100644 --- a/cmd/mesh-controller/seats_test.go +++ b/cmd/mesh-controller/seats_test.go @@ -1,6 +1,8 @@ package main import ( + "context" + "strings" "testing" "github.com/novox/mesh-controller/internal/catalogue" @@ -62,3 +64,13 @@ func TestAClaimOutsideTheSetIsShownNotHidden(t *testing.T) { t.Fatalf("a claim outside the set was not shown: %+v", outside) } } + +// A kinded bench is not handed over by `seat`: each kind is held by its claim (novox/hq ADR 0259). +func TestAKindedBenchIsNotHandedOver(t *testing.T) { + for _, bench := range []string{"channel", "intake"} { + err := handOver(context.Background(), bench, "anchor/telegram", false) + if err == nil || !strings.Contains(err.Error(), "is a kinded bench") { + t.Errorf("%s: %v", bench, err) + } + } +} diff --git a/internal/broker/membership.go b/internal/broker/membership.go index 1909e99e..8b4a4537 100644 --- a/internal/broker/membership.go +++ b/internal/broker/membership.go @@ -172,7 +172,7 @@ func PlacementsOf(r Records, interchangeable map[string]bool) Placements { for _, s := range d.Holds { if s.Kinded && s.Kind != "" { p.Kinds = append(p.Kinds, KindHeld{Seat: s.Name, Kind: s.Kind, Module: d.Module, Node: node, - Capabilities: s.Capabilities}) + Capabilities: placedCapabilities(s.Capabilities, d.RunsAs)}) } } } @@ -190,6 +190,20 @@ func PlacementsOf(r Records, interchangeable map[string]bool) Placements { return p } +// placedCapabilities is what a kind's claim promises, as far as its placement lets the router believe it +// (novox/hq ADR 0259 §8): `verified-sender` only from a holder that runs as an account of its own, on a bus +// account of its own — never one the machine's runtime carries as the operator's account. +func placedCapabilities(declared []string, runsAs string) []string { + var out []string + for _, c := range declared { + if c == "verified-sender" && runsAs == "" { + continue + } + out = append(out, c) + } + return out +} + func kindListed(list []KindHeld, k KindHeld) bool { for _, x := range list { if x.Seat == k.Seat && x.Kind == k.Kind && x.Module == k.Module && x.Node == k.Node { diff --git a/internal/broker/nats.go b/internal/broker/nats.go index d0481852..9252703d 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -77,6 +77,9 @@ type Seat struct { Records []string // Capabilities are what this principal's claim of a kinded bench promises (ADR 0234 §2). Capabilities []string + // DeclaredBy is the module that declares the seat. On a kinded bench it alone submits work to a kind + // and answers its proofs (novox/hq ADR 0259 §8): the router, not any user or watcher of the bench. + DeclaredBy string } // A Principal is one user of the bus. Its permissions are derived from what it declares and @@ -710,6 +713,16 @@ func PermissionsFor(p Principal) (Permissions, error) { pub = append(pub, stateGrants(stateAccess{Module: d.Module, Node: p.Node, Keeps: stateNames(d.State), PerMachine: perMachineNames(d.State), Reads: d.Reads, KeyedReads: d.KeyedReads})...) } + // **Never the traffic of a trusted holder** (novox/hq ADR 0259 §8): the machine's runtime runs as the + // operator's account, which every agent runs as, so a module saying warrants or speaking for a kind + // that proves its sender is never composed into it — refused here, naming it, whatever registration + // let through. + for _, d := range p.Carries { + if why := trustedTraffic(d); why != "" { + return Permissions{}, fmt.Errorf("%s on %s is carried by the machine's runtime, and %s: it runs "+ + "as an account of its own, never the runtime's (novox/hq ADR 0259)", d.Module, p.Node, why) + } + } // **And the seat traffic of the modules it carries** (novox/hq ADR 0259 §3): a bundle reaches the // bus only through its runtime, so the runtime is granted the union. That one module's code does // not publish under another's name or kind through it is the runtime's to keep, from the seat diff --git a/internal/broker/seattraffic.go b/internal/broker/seattraffic.go index d2ebee8c..e8f69682 100644 --- a/internal/broker/seattraffic.go +++ b/internal/broker/seattraffic.go @@ -137,7 +137,8 @@ func SeatTrafficOf(module string, holds, uses, watches []Seat) SeatTraffic { switch { case namesVerb(s.ByCaller, a): t.Publish = append(t.Publish, seatSubject(s, "accept", a+"."+module)) - case s.Kinded: + case s.Kinded && module == s.DeclaredBy: + // Work for a kind is put on its queue by the bench's own router, and by no other user. t.Publish = append(t.Publish, seatSubject(s, "accept", a+".*")) } } @@ -158,8 +159,11 @@ func SeatTrafficOf(module string, holds, uses, watches []Seat) SeatTraffic { for _, e := range w.Emits { t.Subscribe = append(t.Subscribe, seatSubject(w, "event", e+".*")) } - for _, v := range w.Proofs { - t.Answers = append(t.Answers, seatSubject(w, "proof", v+".*")) + if module == w.DeclaredBy { + // A code is answered by the bench's own router, and by no other watcher. + for _, v := range w.Proofs { + t.Answers = append(t.Answers, seatSubject(w, "proof", v+".*")) + } } } } @@ -228,15 +232,16 @@ func SeatTrafficObjects(users []Principal) ([]Stream, []Consumer) { Subjects: []string{"mesh.seat." + seat + ".accept.>"}, Retention: RetentionWorkQueue, MaxAge: 7 * 24 * 60 * 60, - Why: "work submitted to the " + seat + " seat; its holders take it, each kind its own, " + - "and it queues while nobody does", + Why: "work submitted to the " + seat + " seat; its holders take it, each kind its own, and it queues while nobody does", } consumers[w.Consumer] = Consumer{ Name: w.Consumer, Stream: w.Stream, Filters: []string{w.Filter}, AckWaitSeconds: 60, - MaxDeliver: 5, + // No bound on redelivery: a channel away for a day keeps its work, offered again later and + // later by its holder's runtime (novox/hq ADR 0259; the correctness review of 2026-10-08). + MaxDeliver: 0, Why: module + " holds " + seat + "; it pulls one ask at a time and acknowledges once it has " + "recorded it, so a crash redelivers rather than loses", } @@ -264,3 +269,37 @@ func SeatTrafficObjects(users []Principal) ([]Stream, []Consumer) { sort.Slice(cs, func(i, j int) bool { return cs[i].Name < cs[j].Name }) return ss, cs } + +// trustedTraffic is why a module's seat traffic is the trusted holder's (novox/hq ADR 0259 §8), or "": it +// says a seat's event to one caller each (a warrant), or holds a kind of a kinded bench that proves its sender. +func trustedTraffic(d Declared) string { + for _, s := range d.Holds { + for _, e := range s.Emits { + if namesVerb(s.ByCaller, e) { + return "it says " + s.Name + "'s " + e + " to one caller each" + } + } + if s.Kinded && namesVerb(s.Capabilities, "verified-sender") { + return "it holds " + s.Name + " of kind " + s.Kind + ", which proves its sender" + } + } + return "" +} + +// TrafficQueues is the work queue of every seat naming its caller or its kind that accepts work, held or not +// (novox/hq ADR 0259 §3): what is submitted before a holder is assigned waits for it. +func TrafficQueues(seats []Seat) []Stream { + var out []Stream + seen := map[string]bool{} + for _, s := range seats { + if len(s.Accepts) == 0 || !s.isNewTraffic() || seen[s.Name] { + continue + } + seen[s.Name] = true + out = append(out, Stream{Name: seatStreamName(s.Name), Subjects: []string{"mesh.seat." + s.Name + ".accept.>"}, + Retention: RetentionWorkQueue, MaxAge: 7 * 24 * 60 * 60, + Why: "work submitted to the " + s.Name + " seat; its holders take it, each kind its own, and it queues while nobody does"}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} diff --git a/internal/broker/seattraffic_test.go b/internal/broker/seattraffic_test.go index 2f8b7bbd..c5b6944f 100644 --- a/internal/broker/seattraffic_test.go +++ b/internal/broker/seattraffic_test.go @@ -13,12 +13,13 @@ func operatorChannel() Seat { } func channelSeat(kind string) Seat { - return Seat{Name: "channel", Scope: "mesh", Accepts: []string{"show", "edit", "send"}, Kinded: true, Kind: kind} + return Seat{Name: "channel", Scope: "mesh", Accepts: []string{"show", "edit", "send"}, Kinded: true, Kind: kind, + DeclaredBy: "messenger"} } func intakeSeat(kind string) Seat { return Seat{Name: "intake", Scope: "mesh", Emits: []string{"choice", "link"}, Proofs: []string{"code"}, - Kinded: true, Kind: kind} + Kinded: true, Kind: kind, DeclaredBy: "messenger"} } func allowed(patterns []string, subject string) bool { @@ -80,7 +81,7 @@ func TestOnlyTheHolderPublishesAWarrant(t *testing.T) { Nodes: []string{"anchor"}, Assigned: map[string][]Declared{"anchor": { {Module: "messenger", Holds: []Seat{operatorChannel()}, Uses: []Seat{channelSeat("")}, - Watches: []Seat{{Name: "intake", Emits: []string{"choice", "link"}, Kinded: true, Proofs: []string{"code"}}}}, + Watches: []Seat{{Name: "intake", Emits: []string{"choice", "link"}, Kinded: true, Proofs: []string{"code"}, DeclaredBy: "messenger"}}}, {Module: "mesh-delivery", Uses: []Seat{operatorChannel()}}, {Module: "telegram", Holds: []Seat{channelSeat("telegram"), intakeSeat("telegram")}}, }}, @@ -150,7 +151,7 @@ func TestAKindedHolderReachesItsOwnKindAndNoOther(t *testing.T) { func TestTheWatcherAnswersProofsAndHearsEveryKind(t *testing.T) { got := perms(t, Principal{Kind: KindModule, Node: "anchor", Module: "messenger", Uses: []Seat{channelSeat("")}, - Watches: []Seat{{Name: "intake", Emits: []string{"choice"}, Kinded: true, Proofs: []string{"code"}}}}) + Watches: []Seat{{Name: "intake", Emits: []string{"choice"}, Kinded: true, Proofs: []string{"code"}, DeclaredBy: "messenger"}}}) for _, s := range []string{"mesh.seat.intake.event.choice.telegram", "mesh.seat.intake.proof.code.desktop"} { if !allowed(got.Subscribe, s) { t.Errorf("the router does not hear %s", s) @@ -248,7 +249,7 @@ func TestTheRoutersMembershipNamesEveryKindAndItsCapabilities(t *testing.T) { desk.Capabilities = []string{"choice"} router := Declared{Module: "messenger", Holds: []Seat{operatorChannel()}, Uses: []Seat{channelSeat("")}} records := Records{Nodes: []string{"anchor", "laptop"}, Assigned: map[string][]Declared{ - "anchor": {router, {Module: "telegram", Holds: []Seat{tg}}}, + "anchor": {router, {Module: "telegram", Holds: []Seat{tg}, RunsAs: "telegram"}}, "laptop": {{Module: "desk-channel", Holds: []Seat{desk}}}, }} where := PlacementsOf(records, nil) @@ -270,3 +271,70 @@ func TestTheRoutersMembershipNamesEveryKindAndItsCapabilities(t *testing.T) { t.Error("an asker is told the channels") } } + +// novox/hq ADR 0259 §8: only the bench's own router answers its proofs and puts work on a kind's queue. +func TestOnlyTheBenchsRouterAnswersProofsAndSubmitsWork(t *testing.T) { + other := perms(t, Principal{Kind: KindModule, Node: "anchor", Module: "eavesdropper", + Uses: []Seat{channelSeat("")}, + Watches: []Seat{{Name: "intake", Emits: []string{"choice"}, Kinded: true, Proofs: []string{"code"}, DeclaredBy: "messenger"}}}) + if allowed(other.Subscribe, "mesh.seat.intake.proof.code.telegram") { + t.Error("a watcher that is not the router answers codes") + } + if allowed(other.Publish, "mesh.seat.channel.accept.show.telegram") { + t.Error("a user that is not the router puts work on a kind's queue") + } + if !allowed(other.Subscribe, "mesh.seat.intake.event.choice.telegram") { + t.Error("a watcher no longer hears the bench's events") + } +} + +// novox/hq ADR 0259 §8: the machine's runtime runs as the operator's account; it never carries a module +// that says warrants or speaks for a kind proving its sender, and such a module has its own account. +func TestTheMachinesRuntimeNeverCarriesATrustedHolder(t *testing.T) { + tg := channelSeat("telegram") + tg.Capabilities = []string{"choice", "verified-sender"} + for name, d := range map[string]Declared{ + "the router": {Module: "messenger", Holds: []Seat{operatorChannel()}}, + "a verified channel": {Module: "telegram", Holds: []Seat{tg}}, + } { + if _, err := PermissionsFor(Principal{Kind: KindNodeTools, Node: "anchor", Module: RuntimeModule, + Carries: []Declared{d}}); err == nil || !strings.Contains(err.Error(), "an account of its own") { + t.Errorf("%s was composed into the machine's runtime: %v", name, err) + } + } + desk := channelSeat("desktop") + desk.Capabilities = []string{"choice"} + if _, err := PermissionsFor(Principal{Kind: KindNodeTools, Node: "anchor", Module: RuntimeModule, + Carries: []Declared{{Module: "desk-channel", Holds: []Seat{desk}}}}); err != nil { + t.Errorf("a channel proving nothing was refused: %v", err) + } + users, err := Users(Records{Nodes: []string{"anchor"}, Assigned: map[string][]Declared{"anchor": { + {Module: RuntimeModule}, {Module: "telegram", Holds: []Seat{tg}, RunsAs: "telegram"}, + {Module: "messenger", Holds: []Seat{operatorChannel()}, RunsAs: "messenger"}, + }}}) + if err != nil { + t.Fatal(err) + } + for _, u := range users { + if u.Kind == KindNodeTools { + for _, d := range u.Carries { + if d.RunsAs != "" { + t.Errorf("the machine's runtime carries %s", d.Module) + } + } + if _, err := PermissionsFor(u); err != nil { + t.Errorf("the runtime could not be composed: %v", err) + } + } + } +} + +// novox/hq ADR 0259 §8: verified-sender reaches the router only from a holder of its own account. +func TestVerifiedSenderIsBelievedOnlyFromAHolderOfItsOwnAccount(t *testing.T) { + if got := placedCapabilities([]string{"choice", "verified-sender"}, ""); namesVerb(got, "verified-sender") { + t.Errorf("a carried holder keeps verified-sender: %v", got) + } + if got := placedCapabilities([]string{"choice", "verified-sender"}, "telegram"); !namesVerb(got, "verified-sender") { + t.Errorf("a holder of its own account lost verified-sender: %v", got) + } +} diff --git a/internal/broker/users.go b/internal/broker/users.go index e06ad472..83924b60 100644 --- a/internal/broker/users.go +++ b/internal/broker/users.go @@ -50,6 +50,9 @@ type Declared struct { // Checks are the module's own tools its health asks, each `.` (novox/hq ADR 0240, to-be // 48 §3): the machine's node-engine asks them of its own node tools, and is granted that and no more. Checks []string + // RunsAs is the account the module runs as in a runtime of its own (novox/hq ADR 0259 §8): it is never + // carried by the machine's runtime, and reaches the bus on its own account. + RunsAs string } // Records is what composing a user list needs to know about the mesh, and nothing more. @@ -120,10 +123,13 @@ func Users(r Records) ([]Principal, error) { }) } if runtimeHere { - out = append(out, Principal{ - Kind: KindNodeTools, Node: node, Module: RuntimeModule, - Carries: append([]Declared(nil), r.Assigned[node]...), - }) + var carried []Declared + for _, d := range r.Assigned[node] { + if d.RunsAs == "" { + carried = append(carried, d) + } + } + out = append(out, Principal{Kind: KindNodeTools, Node: node, Module: RuntimeModule, Carries: carried}) } } for _, node := range sortedCopy(r.Enrolling) { diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index fadab8da..7d7f1e5e 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -1101,9 +1101,23 @@ func (r Resolution) compose(with Rendering, owner map[string]string, } owner[fmt.Sprint(process["id"])] = RuntimeModule out = append(out, process) - // What each module's bundles are given is read as the account the runtime runs as. + // And a runtime of its own for each module of its own account, after it (novox/hq ADR 0259 §8). + owns, err := r.ownRuntimes(with) + if err != nil { + return nil, err + } + for _, p := range owns { + id := fmt.Sprint(p["id"]) + owner[id] = strings.TrimSuffix(id, "."+OwnRuntimeID()) + out = append(out, p) + } + // What each module's bundles are given is read as the account the runtime runs as — not what a + // module of its own account is given, which its own account reads. words := map[string]map[string]string{} for _, m := range r.Modules { + if m.RunsAs != "" { + continue + } w, err := bundleWords(m, with) if err != nil { return nil, err diff --git a/internal/catalogue/kinded_test.go b/internal/catalogue/kinded_test.go index c1fd1aa3..15b15201 100644 --- a/internal/catalogue/kinded_test.go +++ b/internal/catalogue/kinded_test.go @@ -8,7 +8,7 @@ import ( // The router of novox/hq ADR 0259: it declares the operator's seat and the two kinded benches. func router() Manifest { return Manifest{Module: "messenger", Tools: []string{"open", "history", "notify"}, - State: []StateDeclaration{{Name: "asks"}}, + State: []StateDeclaration{{Name: "asks"}}, RunsAs: "messenger", SecretsOwner: "messenger", DefinesSeats: []SeatDeclaration{ {Name: "operator-channel", Scope: ScopeMesh, Accepts: []string{"ask", "cancel"}, Emits: []string{"decided"}, ByCaller: []string{"ask", "cancel", "decided"}, Records: []string{"asks"}, @@ -98,6 +98,7 @@ func TestEachKindIsItsOwnHolderWhenResolved(t *testing.T) { func TestCapabilitiesAreTheVocabularysAndOnlyOnAKindedClaim(t *testing.T) { good := aChannel("telegram", "telegram") good.Claims[0].Capabilities = []string{"deliver", "choice", "verified-sender", "max-length:4096"} + good.RunsAs = "telegram" if got := problemsFor(t, Shelf{"messenger": router(), "telegram": good}); got != "" { t.Fatalf("the vocabulary was refused: %s", got) } @@ -114,3 +115,47 @@ func TestCapabilitiesAreTheVocabularysAndOnlyOnAKindedClaim(t *testing.T) { t.Errorf("capabilities on a seat that is not kinded stood: %s", got) } } + +// novox/hq ADR 0259 §8: a module saying warrants, or speaking for a kind that proves its sender, runs as an +// account of its own — never carried by the machine's runtime, which runs as the operator's account. +func TestATrustedHolderMustRunAsAnAccountOfItsOwn(t *testing.T) { + r := router() + r.RunsAs = "" + if got := problemsFor(t, Shelf{"messenger": r}); !strings.Contains(got, "messenger must run as an account of its own") { + t.Errorf("a router on the machine's runtime stood: %s", got) + } + tg := aChannel("telegram", "telegram") + tg.Claims[0].Capabilities = []string{"choice", "verified-sender"} + if got := problemsFor(t, Shelf{"messenger": router(), "telegram": tg}); !strings.Contains(got, "telegram must run as an account of its own") { + t.Errorf("a verified channel on the machine's runtime stood: %s", got) + } + desk := aChannel("desk-channel", "desktop") + desk.Claims[0].Capabilities = []string{"choice"} + if got := problemsFor(t, Shelf{"messenger": router(), "desk-channel": desk}); got != "" { + t.Errorf("a channel proving nothing was held to it: %s", got) + } +} + +func TestRunsAsIsAnAccountOfTheModulesOwn(t *testing.T) { + ok := Manifest{Module: "telegram", RunsAs: "telegram", SecretsOwner: "telegram", + OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/telegram/broker"}}, + Resources: []map[string]any{{"id": "account", "type": "user", "name": "telegram"}}} + if got := RunsAsProblems(ok); len(got) != 0 { + t.Fatalf("a sound runs-as was refused: %v", got) + } + for want, change := range map[string]func(*Manifest){ + "never root": func(m *Manifest) { m.RunsAs, m.SecretsOwner = "root", "root" }, + "not an account name": func(m *Manifest) { m.RunsAs = "${machine:account}" }, + "which it does not make": func(m *Manifest) { m.Resources = nil }, + "declares no own secret": func(m *Manifest) { m.OwnSecrets = nil }, + "they are the account's own": func(m *Manifest) { m.SecretsOwner = "" }, + } { + m := ok + m.Resources = append([]map[string]any(nil), ok.Resources...) + m.OwnSecrets = OwnSecrets{"broker": {Path: "/x"}} + change(&m) + if got := strings.Join(RunsAsProblems(m), "; "); !strings.Contains(got, want) { + t.Errorf("want %q, got %q", want, got) + } + } +} diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index bbae198c..842580cf 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -647,6 +647,13 @@ type Manifest struct { // cannot use. SecretsOwner string `json:"secrets-owner,omitempty"` + // RunsAs is the account this module's tools bundle runs as, in a runtime of its own on a bus account of + // its own (novox/hq ADR 0259 §8): never the machine's runtime, which runs as the operator's account and + // carries every module on the machine. The account is one the module makes (a `user` resource of that + // name), owns its secrets (`secrets-owner`), and is neither root nor the operator's. Required of a module + // that says a warrant, or speaks for a channel kind that proves its sender. + RunsAs string `json:"runs-as,omitempty"` + // Keeps is where this module wants every operator-sealed secret in the mesh written — the // vault's field, and so far nobody else's (novox/hq ADR 0085, amended). // @@ -1627,6 +1634,7 @@ func ParseManifest(raw []byte) (Manifest, error) { // prefix. Whether a seat anybody names exists, and whether a holder answers for it, are // facts about the catalogue and are checked at registration (CatalogueProblems). problems = append(problems, declaredSeatProblems(m)...) + problems = append(problems, RunsAsProblems(m)...) if m.Computed != "" && len(m.Resources) > 0 { // One or the other. A module that both ships files and has them computed would leave // nobody able to say where a given file came from. diff --git a/internal/catalogue/runtime.go b/internal/catalogue/runtime.go index 71a5c87a..d0e3bc32 100644 --- a/internal/catalogue/runtime.go +++ b/internal/catalogue/runtime.go @@ -170,6 +170,10 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) { if with.Adopted && m.Filtering != nil { continue } + if m.RunsAs != "" { + // Served by a runtime of its own, on its own account (ownRuntimes): never the machine's. + continue + } words, err := bundleWords(m, with) if err != nil { return nil, err @@ -232,6 +236,84 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) { return process, nil } +// OwnRuntimeID names the process a module of its own account is served by (novox/hq ADR 0259 §8). +func OwnRuntimeID() string { return "own-runtime" } + +// ownRuntimes are the processes the modules of their own account are served by (novox/hq ADR 0259 §8): each +// the machine's runtime program — the same build, run from the same source — serving that one module alone, +// as the module's own account, on the module's own bus credential. Never the machine's runtime, which runs +// as the operator's account and carries every module on the machine. +func (r Resolution) ownRuntimes(with Rendering) ([]map[string]any, error) { + var own []Manifest + for _, m := range r.Modules { + if m.RunsAs != "" && !(with.Adopted && m.Filtering != nil) { + own = append(own, m) + } + } + if len(own) == 0 { + return nil, nil + } + var runtime *Manifest + for i := range r.Modules { + if r.Modules[i].Module == RuntimeModule { + runtime = &r.Modules[i] + } + } + if runtime == nil || len(runtime.Bundles) != 1 || runtime.Bundles[0].Binary == "" { + return nil, fmt.Errorf("%s runs as its own account in a runtime of its own, and %s is not here to run it "+ + "from: assign %s to %s first (novox/hq ADR 0259)", own[0].Module, RuntimeModule, RuntimeModule, r.Node) + } + program := runtime.Bundles[0] + var out []map[string]any + for _, m := range own { + credential, declared := m.OwnSecrets["broker"] + if !declared { + return nil, fmt.Errorf("%s runs as its own account and declares no own secret broker", m.Module) + } + var served, restartOn []string + for _, b := range m.Bundles { + for _, load := range b.Loads { + if launcher, has := b.Launchers[load]; has { + load = launcher + } + served = append(served, m.Module+"="+BundlePath(m.Module, b.Name)+"/"+load) + } + if len(b.Loads) > 0 { + restartOn = append(restartOn, m.Module+"."+BundleID(b.Name)) + } + } + if len(served) == 0 { + return nil, fmt.Errorf("%s runs as its own account and its build produced no bundle to serve", m.Module) + } + sort.Strings(served) + restartOn = append(restartOn, m.Module+"."+NeedID("broker")) + sort.Strings(restartOn) + env := map[string]string{RuntimeToolModules: strings.Join(served, ","), RuntimeBrokerFile: credential.Path} + words, err := bundleWords(m, with) + if err != nil { + return nil, err + } + if len(words) > 0 { + body, err := json.Marshal(map[string]map[string]string{m.Module: words}) + if err != nil { + return nil, err + } + env[RuntimeToolEnv] = string(body) + } + process := map[string]any{ + "id": m.Module + "." + OwnRuntimeID(), "type": "process", "name": m.Module + "-runtime", + "source": program.Source, "digest": program.Digest, + "run": []any{"./" + program.Binary}, "env": env, "restart-on": toAny(restartOn), + "user": m.RunsAs, + } + if err := artifactsInto(process, RuntimeModule, with); err != nil { + return nil, err + } + out = append(out, process) + } + return out, nil +} + func toAny(in []string) []any { out := make([]any, 0, len(in)) for _, s := range in { diff --git a/internal/catalogue/runtime_test.go b/internal/catalogue/runtime_test.go index 5d2b9f65..cba8b954 100644 --- a/internal/catalogue/runtime_test.go +++ b/internal/catalogue/runtime_test.go @@ -457,3 +457,49 @@ func TestAGoToolsBundleIsServedByItsBinary(t *testing.T) { t.Error("a Go bundle loading a file it does not contain was admitted") } } + +// novox/hq ADR 0259 §8: a module of its own account is served by a runtime of its own — the machine's +// runtime program, as that account, on that module's own credential — and never by the machine's runtime, +// which runs as the operator's account and is given none of its words. +func TestAModuleOfItsOwnAccountIsServedByARuntimeOfItsOwn(t *testing.T) { + with := Rendering{ArtifactStore: "anchor.internal:5101", + Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}, "telegram": {"broker": "own"}}} + goRuntime := Manifest{Module: RuntimeModule, Version: "1", + OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/mesh/" + RuntimeModule + "/broker"}}, + Build: &Build{Artifacts: []Artifact{{Name: "runtime", Kind: ArtifactBundle, Language: "go", + System: "arch", From: "cmd/node-tools"}}}} + goRuntime, err := goRuntime.Resolve([]Built{{Name: "runtime", Kind: ArtifactBundle, + Reference: ArtifactStoreScheme + RuntimeModule + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}}) + if err != nil { + t.Fatal(err) + } + telegram := aToolsModule(t, "telegram", "tools/index.js") + telegram.RunsAs, telegram.SecretsOwner = "telegram", "telegram" + telegram.OwnSecrets = OwnSecrets{"broker": {Path: "/var/lib/telegram/broker"}} + out, err := Resolution{Node: "anchor", Account: "ops", + Modules: []Manifest{aToolsModule(t, "nftables", "tools/index.js"), telegram, goRuntime}}.Declaration(with) + if err != nil { + t.Fatal(err) + } + machine := fileNamed(out, RuntimeModule+"."+RuntimeProcessID()) + if served := machine["env"].(map[string]string)[RuntimeToolModules]; strings.Contains(served, "telegram") || !strings.Contains(served, "nftables") { + t.Errorf("the machine's runtime serves %q", served) + } + own := fileNamed(out, "telegram."+OwnRuntimeID()) + if own == nil { + t.Fatalf("telegram has no runtime of its own: %v", ids(out)) + } + env := own["env"].(map[string]string) + if own["user"] != "telegram" || fmt.Sprint(own["run"]) != "[./node-tools]" || + env[RuntimeBrokerFile] != "/var/lib/telegram/broker" || + env[RuntimeToolModules] != "telegram="+BundleRoot+"/telegram/tools/tools/index.js" { + t.Errorf("its own runtime: user %v run %v env %v", own["user"], own["run"], env) + } + if _, told := env[RuntimeOperatorAccount]; told { + t.Error("a runtime of a module's own account is told the operator's account") + } + // Without the machine's runtime to run it from, it is refused in words. + if _, err := (Resolution{Node: "anchor", Modules: []Manifest{telegram}}).ownRuntimes(with); err == nil { + t.Error("a module of its own account composed without a runtime program") + } +} diff --git a/internal/catalogue/seats_declared.go b/internal/catalogue/seats_declared.go index fd4b0899..1728a9bb 100644 --- a/internal/catalogue/seats_declared.go +++ b/internal/catalogue/seats_declared.go @@ -321,6 +321,16 @@ func CatalogueProblems(shelf Shelf) []string { } } } + // **A trusted holder runs as its own account** (novox/hq ADR 0259 §8): a module saying warrants, or + // speaking for a kind that proves its sender, is never carried by a machine's runtime. + for _, module := range shelfOrder(shelf) { + m := shelf[module] + if why := TrustedHolding(m, declared); why != "" && m.RunsAs == "" { + problems = append(problems, fmt.Sprintf( + "%s must run as an account of its own (runs-as): %s, and the machine's runtime runs as the "+ + "operator's account, which every agent runs as (novox/hq ADR 0259)", module, why)) + } + } // A read of a module's state that module does not keep (novox/hq ADR 0201) — said only where the // owner is on the shelf, as a consumer may be installed before its emitter. var manifests []Manifest @@ -416,3 +426,65 @@ func shelfOrder(shelf Shelf) []string { sort.Strings(out) return out } + +var accountName = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,30}$`) + +// RunsAsProblems is what one manifest's `runs-as` is held to (novox/hq ADR 0259 §8): an account of the +// module's own making — a `user` resource of that name — that owns its secrets, with a bus account of its own, +// and that is neither root nor the operator's. +func RunsAsProblems(m Manifest) []string { + if m.RunsAs == "" { + return nil + } + var problems []string + say := func(format string, a ...any) { problems = append(problems, fmt.Sprintf(format, a...)) } + switch { + case !accountName.MatchString(m.RunsAs): + say("%s runs as %q, which is not an account name of the module's own", m.Module, m.RunsAs) + return problems + case m.RunsAs == "root": + say("%s runs as root; a module of its own account runs as an account it makes, never root", m.Module) + } + made := false + for _, r := range m.Resources { + if fmt.Sprint(r["type"]) == "user" && fmt.Sprint(r["name"]) == m.RunsAs { + made = true + } + } + if !made { + say("%s runs as %s, which it does not make: a user resource named %s", m.Module, m.RunsAs, m.RunsAs) + } + if _, has := m.OwnSecrets["broker"]; !has { + say("%s runs as its own account and declares no own secret broker: its runtime reaches the bus on an "+ + "account of its own", m.Module) + } + if m.SecretsOwner != m.RunsAs { + say("%s runs as %s, and its secrets belong to %q: they are the account's own", m.Module, m.RunsAs, m.SecretsOwner) + } + return problems +} + +// TrustedHolding is why a module must run as its own account (novox/hq ADR 0259 §8), or "": it holds a seat +// whose events it says to one caller each (a warrant), or speaks for a kind of a kinded bench that proves +// its sender. Neither may be carried by the machine's runtime, which runs as the operator's account. +func TrustedHolding(m Manifest, declared map[string]SeatDeclaration) string { + for _, c := range m.Claims { + s, ok := declared[c.Name] + if !ok { + continue + } + for _, e := range s.Emits { + if s.NamedByCaller(e) { + return fmt.Sprintf("it holds %s, whose %s it says to one caller each", c.Name, e) + } + } + if s.Kinded { + for _, capability := range c.Capabilities { + if capability == "verified-sender" { + return fmt.Sprintf("it holds %s of kind %s, which proves its sender", c.Name, c.Kind) + } + } + } + } + return "" +} diff --git a/internal/inventory/busrecords.go b/internal/inventory/busrecords.go index 4af67be5..68ca4f54 100644 --- a/internal/inventory/busrecords.go +++ b/internal/inventory/busrecords.go @@ -141,7 +141,7 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio ev := strings.TrimSuffix(event, ".*") if catalogue.SeatSays(s.Emits, ev) { watches = append(watches, broker.Seat{Name: s.Name, Scope: s.Scope, Emits: []string{ev}, - Kinded: true, Proofs: s.Proofs}) + Kinded: true, Proofs: s.Proofs, DeclaredBy: declarers[s.Name]}) continue } } @@ -168,6 +168,8 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio Reads: m.Reads, // And the tools its health asks (novox/hq ADR 0240): the machine's node-engine is granted them. Checks: catalogue.HealthChecks(m), + // And whether it runs as an account of its own (novox/hq ADR 0259 §8). + RunsAs: m.RunsAs, } // Whether it can be given an account at all: delivered as its own secret named broker, so one // that declares none has nowhere to read it (novox/hq issue 195). @@ -222,7 +224,8 @@ func (i *Inventory) DeclaredBuckets(ctx context.Context) ([]broker.Bucket, error func asSeat(s catalogue.SeatDeclaration, declarer string) broker.Seat { seat := broker.Seat{Name: s.Name, Scope: s.Scope, Accepts: s.Accepts, Emits: s.Emits, - Serves: catalogue.VerbNames(s.Serves), Kinded: s.Kinded, ByCaller: s.ByCaller, Proofs: s.Proofs} + Serves: catalogue.VerbNames(s.Serves), Kinded: s.Kinded, ByCaller: s.ByCaller, Proofs: s.Proofs, + DeclaredBy: declarer} // A seat's records are its declaring module's buckets, named as the bus holds them (ADR 0259 §3). for _, r := range s.Records { if declarer != "" { @@ -300,3 +303,22 @@ func heldHere(claimed []broker.Seat, holdings []catalogue.Held, node, module str } return out } + +// DeclaredTrafficSeats is every seat any registered module declares that names its caller or its kind +// (novox/hq ADR 0259 §3), as the bus needs it: assigned or not, so its work queue exists from registration. +func (i *Inventory) DeclaredTrafficSeats(ctx context.Context) ([]broker.Seat, error) { + declared, err := i.Catalogue(ctx) + if err != nil { + return nil, fmt.Errorf("cannot read the catalogue: %w", err) + } + var out []broker.Seat + for _, m := range declared { + for _, s := range m.DefinesSeats { + seat := asSeat(s, m.Module) + if seat.Kinded || len(seat.ByCaller) > 0 { + out = append(out, seat) + } + } + } + return out, nil +}