Compare commits
17
Commits
5ef52011c9
...
0c828fbf96
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c828fbf96 | ||
|
|
54bbf7e76b | ||
|
|
470f621a1c | ||
|
|
5f6784d03f | ||
|
|
0383724be2 | ||
|
|
9ab0bb9aff | ||
|
|
130da939e1 | ||
|
|
9f1a96a932 | ||
|
|
926dbd7a97 | ||
|
|
db702312af | ||
|
|
d7fe5b609b | ||
|
|
b4dff64ae0 | ||
|
|
2b8a28adab | ||
|
|
20d4f1ae71 | ||
|
|
528951319c | ||
|
|
dcbbf4487a | ||
|
|
fcfbf7e69e |
@@ -0,0 +1,175 @@
|
||||
package main
|
||||
|
||||
// The account agents run as (novox/hq ADR 0266).
|
||||
//
|
||||
// On the control node every agent session ran as the operator's account, which may become root without a
|
||||
// password — so any agent there could become root without a person, and ADR 0259 §8 (an answer from the
|
||||
// operator's phone authorises an act) rests on that being false where the router and its channels run. The
|
||||
// decision: a node may name an account its agents run as, of their own and without sudo; the operator's
|
||||
// account keeps its sudo.
|
||||
//
|
||||
// - **Named at the controller's terminal only** (`node agent-account`): not a verb, not a setting, so no
|
||||
// agent can name itself another account. Empty is a real state: agents run as the operator there.
|
||||
// - **Composed** as `${machine:agent-account}`, `${machine:agent-home}` and `${machine:agent-root}` for the
|
||||
// agent's module, which declares the account with `root: never`, and as MESH_AGENT_ACCOUNT and
|
||||
// MESH_AGENT_HOME for its tools (catalogue/machine_into_files.go, runtime.go).
|
||||
// - **Judged by the machine itself.** The node-engine reads, on every look, whether an account declared
|
||||
// `root: never` can become root without a person — uid 0, a group that grants root, a sudo rule, a
|
||||
// secret of the mesh it may read — and says it as the declaring module's account verdict, marked
|
||||
// Root "never". agentConfined reads that verdict; the self-check (probe DA) raises
|
||||
// `agent-can-become-root` while it does not hold, and `node show` says it.
|
||||
//
|
||||
// A verdict not given is never a pass: an engine older than the judging, an account not yet declared, a
|
||||
// statement that says nothing of it — each is "not judged", and fails.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// kindAgentCanBecomeRoot is the condition raised while a machine's agent account can become root without
|
||||
// a person, or is not judged (ADR 0266).
|
||||
const kindAgentCanBecomeRoot = "agent-can-become-root"
|
||||
|
||||
// agentAccountProbe is the self-check's probe of it.
|
||||
const agentAccountProbe = "DA"
|
||||
|
||||
// agentConfined says whether the agents of a machine that names an agent account are confined: the
|
||||
// machine's newest statement holds a healthy account verdict, judged for root, on that account. named is
|
||||
// false for a machine that names none — agents run as the operator account there, which this does not
|
||||
// judge. why is said either way, in the mesh's words; err is a store that could not be read.
|
||||
//
|
||||
// The one judgement: `node show`, the self-check, and ADR 0259's router honouring a verified sender read
|
||||
// it here.
|
||||
func agentConfined(ctx context.Context, inv *inventory.Inventory, node string) (named, confined bool, why string, err error) {
|
||||
n, err := inv.NodeByName(ctx, node)
|
||||
if err != nil {
|
||||
return false, false, "", err
|
||||
}
|
||||
if n.AgentAccount == "" {
|
||||
return false, false, fmt.Sprintf("%s names no agent account: agents run as the operator account (%s)",
|
||||
node, orNoneKnown(n.Account)), nil
|
||||
}
|
||||
h, had, err := inv.HealthOf(ctx, node)
|
||||
if err != nil {
|
||||
return true, false, "", err
|
||||
}
|
||||
confined, why = judgedConfined(n.AgentAccount, h, had, time.Now())
|
||||
return true, confined, why, nil
|
||||
}
|
||||
|
||||
// verdictFreshFor is how old the statement holding the verdict may be, by this controller's clock. A
|
||||
// node-engine states its health on every change and at least every five minutes (mesh-host's sayAnyway), so
|
||||
// three statements missed is a node-engine stopped, or a machine away. **A stale verdict is not a pass**: an
|
||||
// agent that stopped the node-engine must not leave "cannot become root" standing from before.
|
||||
const verdictFreshFor = 15 * time.Minute
|
||||
|
||||
// judgedConfined is the judgement over one statement, without the store, at now.
|
||||
func judgedConfined(agent string, h inventory.NodeHealth, had bool, now time.Time) (bool, string) {
|
||||
if !had {
|
||||
return false, fmt.Sprintf("the agent account %s is not judged: the machine's node-engine has stated "+
|
||||
"nothing of what it runs", agent)
|
||||
}
|
||||
if age := now.Sub(h.HeardAt); age > verdictFreshFor {
|
||||
return false, fmt.Sprintf("the agent account %s is not judged: the machine's newest statement was heard at "+
|
||||
"%s, more than %d minutes ago, and a verdict that old is not a verdict on now", agent,
|
||||
h.HeardAt.Local().Format("2006-01-02 15:04"), int(verdictFreshFor.Minutes()))
|
||||
}
|
||||
if h.Contract < link.RootContract {
|
||||
return false, fmt.Sprintf("the agent account %s is not judged: the machine's node-engine is older than "+
|
||||
"the judging of an account's root (its statement's contract is %d, the judging is %d)",
|
||||
agent, h.Contract, link.RootContract)
|
||||
}
|
||||
var verdicts []inventory.ResourceHealth
|
||||
for _, r := range h.Resources {
|
||||
if r.Kind == link.KindAccount && r.Target == agent && r.Root == link.RootNever {
|
||||
verdicts = append(verdicts, r)
|
||||
}
|
||||
}
|
||||
if len(verdicts) == 0 {
|
||||
return false, fmt.Sprintf("the agent account %s is not judged: the machine's newest statement holds no "+
|
||||
"verdict on it — no module there declares it never to become root, or the declaration naming it "+
|
||||
"has not been applied", agent)
|
||||
}
|
||||
sort.Slice(verdicts, func(i, j int) bool {
|
||||
return verdicts[i].Module+verdicts[i].Resource < verdicts[j].Module+verdicts[j].Resource
|
||||
})
|
||||
for _, v := range verdicts {
|
||||
switch v.State {
|
||||
case link.StateHealthy:
|
||||
case link.StateUnhealthy:
|
||||
// The engine's own words, which start with link.ReasonRoot when it found a way to root.
|
||||
return false, fmt.Sprintf("the agent account %s %s (said by %s's %s)", agent,
|
||||
orNoneKnown(v.Reason), v.Module, v.Resource)
|
||||
default:
|
||||
return false, fmt.Sprintf("the agent account %s is not judged: %s (%s's %s, %s)", agent,
|
||||
orNoneKnown(v.Reason), v.Module, v.Resource, v.State)
|
||||
}
|
||||
}
|
||||
return true, fmt.Sprintf("the agent account %s cannot become root without a person (judged %s)", agent,
|
||||
h.SaidAt.Local().Format("2006-01-02 15:04"))
|
||||
}
|
||||
|
||||
// probeAgentAccounts is DA: every machine that names an agent account has it judged, on its node-engine's
|
||||
// newest statement, unable to become root without a person (ADR 0266).
|
||||
func probeAgentAccounts(ctx context.Context, d *doctor) ([]conditions.Observation, error) {
|
||||
inv := d.open.inventory
|
||||
nodes, err := inv.Nodes(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []conditions.Observation
|
||||
for _, n := range nodes {
|
||||
if n.AgentAccount == "" {
|
||||
continue
|
||||
}
|
||||
h, had, err := inv.HealthOf(ctx, n.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
confined, why := judgedConfined(n.AgentAccount, h, had, time.Now())
|
||||
if confined {
|
||||
continue
|
||||
}
|
||||
out = append(out, conditions.Observation{Scope: conditions.ScopeMachine, ID: n.Name, Token: "agent-root",
|
||||
Machine: n.Name, Severity: conditions.Urgent,
|
||||
Summary: fmt.Sprintf("on %s, %s (ADR 0266): an agent there may become root without a person, and "+
|
||||
"no answer from a channel authorises an act there (ADR 0259 §8)", n.Name, why),
|
||||
Said: why})
|
||||
}
|
||||
return sortedFound(out), nil
|
||||
}
|
||||
|
||||
// agentAccountLines is what `node show` says of the account agents run as.
|
||||
func agentAccountLines(ctx context.Context, inv *inventory.Inventory, n inventory.Node) []string {
|
||||
if n.AgentAccount == "" {
|
||||
return []string{fmt.Sprintf(" agents run as the operator account (%s); no agent account is named",
|
||||
orNoneKnown(n.Account))}
|
||||
}
|
||||
_, confined, why, err := agentConfined(ctx, inv, n.Name)
|
||||
if err != nil {
|
||||
return []string{fmt.Sprintf(" agents run as %s (home %s); whether it can become root could NOT be read: %v",
|
||||
n.AgentAccount, n.AgentHome(), err)}
|
||||
}
|
||||
verdict := "CAN become root, or is not judged: " + why
|
||||
if confined {
|
||||
verdict = why
|
||||
}
|
||||
return []string{fmt.Sprintf(" agents run as %s (home %s)", n.AgentAccount, n.AgentHome()),
|
||||
" " + verdict}
|
||||
}
|
||||
|
||||
// orNoneKnown is a value, or that none is known.
|
||||
func orNoneKnown(s string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return "none known"
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
snapshot "github.com/novox/mesh-controller/internal/facts"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// The agent account's judgement (novox/hq ADR 0266): confined only on a healthy account verdict judged for
|
||||
// root, on the very account; every verdict not given — no statement, an older engine, no verdict on it, a
|
||||
// verdict of unknown — is "not judged" and fails, never a pass.
|
||||
func TestAnAgentAccountIsConfinedOnlyOnAHealthyVerdictJudgedForRoot(t *testing.T) {
|
||||
at := time.Date(2026, 10, 8, 19, 21, 0, 0, time.UTC)
|
||||
verdict := func(target, root, state, reason string) inventory.ResourceHealth {
|
||||
return inventory.ResourceHealth{Module: "claude-code", Resource: "claude-code.agent-account",
|
||||
Kind: link.KindAccount, Target: target, State: state, Reason: reason, Root: root, Account: target}
|
||||
}
|
||||
statement := func(contract int, rs ...inventory.ResourceHealth) inventory.NodeHealth {
|
||||
return inventory.NodeHealth{Node: "anchor", Contract: contract, SaidAt: at, HeardAt: at, Resources: rs}
|
||||
}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
h inventory.NodeHealth
|
||||
had bool
|
||||
confined bool
|
||||
says string
|
||||
}{
|
||||
{"judged and unable", statement(link.RootContract, verdict("agent", link.RootNever, link.StateHealthy, "")),
|
||||
true, true, "cannot become root without a person"},
|
||||
{"judged and able", statement(link.RootContract, verdict("agent", link.RootNever, link.StateUnhealthy,
|
||||
link.ReasonRoot+": in the group docker, which grants root")), true, false, "in the group docker"},
|
||||
{"a verdict of unknown", statement(link.RootContract, verdict("agent", link.RootNever, link.StateUnknown,
|
||||
"sudo could not be read")), true, false, "not judged"},
|
||||
{"no statement", inventory.NodeHealth{}, false, false, "not judged"},
|
||||
{"an older engine", statement(link.ReadinessContract, verdict("agent", "", link.StateHealthy, "")),
|
||||
true, false, "older than the judging"},
|
||||
{"a verdict on groups only", statement(link.RootContract, verdict("agent", "", link.StateHealthy, "")),
|
||||
true, false, "no verdict on it"},
|
||||
{"a verdict on another account", statement(link.RootContract, verdict("ops", link.RootNever, link.StateHealthy, "")),
|
||||
true, false, "no verdict on it"},
|
||||
} {
|
||||
confined, why := judgedConfined("agent", c.h, c.had, at.Add(time.Minute))
|
||||
if confined != c.confined || !strings.Contains(why, c.says) {
|
||||
t.Errorf("%s: confined %v, %q; want %v saying %q", c.name, confined, why, c.confined, c.says)
|
||||
}
|
||||
}
|
||||
// A verdict heard longer ago than the bound is no verdict: an agent that stopped the node-engine must not
|
||||
// leave "healthy" standing.
|
||||
fresh := statement(link.RootContract, verdict("agent", link.RootNever, link.StateHealthy, ""))
|
||||
if ok, _ := judgedConfined("agent", fresh, true, at.Add(verdictFreshFor)); !ok {
|
||||
t.Error("a verdict exactly at the bound is still one")
|
||||
}
|
||||
if ok, why := judgedConfined("agent", fresh, true, at.Add(verdictFreshFor+time.Second)); ok ||
|
||||
!strings.Contains(why, "not judged") {
|
||||
t.Errorf("a stale healthy verdict passed: %q", why)
|
||||
}
|
||||
}
|
||||
|
||||
// DA raises an urgent condition, with plain words, on a machine whose agent account is not judged unable to
|
||||
// become root; a machine that names none is not its to judge.
|
||||
func TestTheSelfCheckSaysAnAgentAccountThatCanBecomeRoot(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
for _, n := range []string{"anchor", "laptop"} {
|
||||
if _, err := inv.NodeByName(ctx, n); err != nil {
|
||||
if _, err := inv.AddNode(ctx, n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := inv.SetAccount(ctx, n, "ops", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := inv.SetAgentAccount(ctx, "anchor", "agent", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := &doctor{open: open}
|
||||
found, err := probeAgentAccounts(ctx, d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found = onlyMachine(found, "anchor")
|
||||
if len(found) != 1 || found[0].Machine != "anchor" || found[0].Severity != conditions.Urgent ||
|
||||
!strings.Contains(found[0].Said, "not judged") {
|
||||
t.Fatalf("a named agent account with no verdict: %+v", found)
|
||||
}
|
||||
w := plainWordings[kindAgentCanBecomeRoot](conditions.Observation{Kind: kindAgentCanBecomeRoot, Machine: "anchor"})
|
||||
if w.Headline == "" || w.Needs == "" || w.Resolved == "" {
|
||||
t.Errorf("the condition has no plain words: %+v", w)
|
||||
}
|
||||
|
||||
healthy := inventory.ResourceHealth{Module: "claude-code", Resource: "claude-code.agent-account",
|
||||
Kind: link.KindAccount, Target: "agent", State: link.StateHealthy, Root: link.RootNever, Account: "agent"}
|
||||
if _, err := inv.RecordHealth(ctx, inventory.NodeHealth{Node: "anchor", Contract: link.RootContract,
|
||||
SaidAt: time.Now(), HeardAt: time.Now(), Resources: []inventory.ResourceHealth{healthy}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if found, err = probeAgentAccounts(ctx, d); err != nil || len(onlyMachine(found, "anchor")) != 0 {
|
||||
t.Fatalf("a judged agent account still fails: %+v %v", found, err)
|
||||
}
|
||||
if named, confined, why, err := agentConfined(ctx, inv, "anchor"); err != nil || !named || !confined {
|
||||
t.Fatalf("agentConfined on anchor: %v %v %q %v", named, confined, why, err)
|
||||
}
|
||||
if named, _, why, err := agentConfined(ctx, inv, "laptop"); err != nil || named ||
|
||||
!strings.Contains(why, "operator account") {
|
||||
t.Fatalf("agentConfined on a machine naming none: %v %q %v", named, why, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheAgentRootWordsArePlain(t *testing.T) {
|
||||
w := plainWordings[kindAgentCanBecomeRoot](conditions.Observation{Kind: kindAgentCanBecomeRoot, Machine: "anchor"})
|
||||
if why, ok := conditions.PlainWords(w, "anchor"); !ok {
|
||||
t.Fatalf("not plain: %s: %+v", why, w)
|
||||
}
|
||||
}
|
||||
|
||||
func onlyMachine(obs []conditions.Observation, machine string) []conditions.Observation {
|
||||
var out []conditions.Observation
|
||||
for _, o := range obs {
|
||||
if o.Machine == machine {
|
||||
out = append(out, o)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The snapshot a merge check composes from carries the agent account as a pseudonym (novox/hq ADR 0266),
|
||||
// so a change is judged against machines that name one, and the name never leaves.
|
||||
func TestTheFactsCarryTheAgentAccountAsAPseudonym(t *testing.T) {
|
||||
open, _ := aMeshWithSecrets(t)
|
||||
ctx := t.Context()
|
||||
if err := open.inventory.SetAccount(ctx, "anchor", "keeper", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := open.inventory.SetAgentAccount(ctx, "anchor", "warden", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, err := gatherFacts(ctx, open, "2.11.17")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := f.Encode()
|
||||
if strings.Contains(string(body), "warden") {
|
||||
t.Error("the agent account's name is in the snapshot")
|
||||
}
|
||||
m, ok := f.Machine(snapshot.Pseudonym("machine", "anchor"))
|
||||
if !ok || m.AgentAccount != snapshot.Pseudonym("account", "warden") || m.Account == m.AgentAccount {
|
||||
t.Fatalf("the anchor's agent account reads as %q (operator %q)", m.AgentAccount, m.Account)
|
||||
}
|
||||
}
|
||||
|
||||
// No verb runs a `node` command that sets something: through the generic `command` verb, `node account`,
|
||||
// `node agent-account` and every other `node` subcommand but list and show are refused, naming the terminal.
|
||||
func TestNoVerbSetsANodesAccounts(t *testing.T) {
|
||||
for _, line := range []string{
|
||||
"node agent-account novox --clear",
|
||||
"node agent-account novox ops",
|
||||
"node account novox agent",
|
||||
"node account novox",
|
||||
"node add intruder",
|
||||
"node public-domain novox --clear",
|
||||
"node",
|
||||
"node frobnicate",
|
||||
} {
|
||||
argv, err := argvFor("command", map[string]any{"command": line})
|
||||
if err == nil || !strings.Contains(err.Error(), "controller's terminal") ||
|
||||
!strings.Contains(err.Error(), "ADR 0266") {
|
||||
t.Errorf("%q ran as %v (%v); want a refusal naming the terminal", line, argv, err)
|
||||
}
|
||||
}
|
||||
for _, line := range []string{"node show novox", "node list --json", "status --json"} {
|
||||
if _, err := argvFor("command", map[string]any{"command": line}); err != nil {
|
||||
t.Errorf("%q, a read, was refused: %v", line, err)
|
||||
}
|
||||
}
|
||||
if err := terminalOnly([]string{"node", "account", "a", "b"}); err == nil {
|
||||
t.Error("the refusal is not only the command verb's")
|
||||
}
|
||||
}
|
||||
|
||||
// Naming the agent account is the controller's terminal's alone: the `node` verb only shows.
|
||||
func TestTheNodeVerbOnlyShows(t *testing.T) {
|
||||
argv, err := argvFor("node", map[string]any{"node": "anchor"})
|
||||
if err != nil || strings.Join(argv, " ") != "node show anchor" {
|
||||
t.Fatalf("the node verb runs %v (%v)", argv, err)
|
||||
}
|
||||
for _, v := range catalogue.ControllerVerbs {
|
||||
if strings.Contains(v.Name, "agent") {
|
||||
t.Errorf("a verb %q may name the agent account", v.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,9 +53,26 @@ func assertBusObjects(ctx context.Context, inv *inventory.Inventory, r broker.Ra
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// And the work queues of seats that name their caller or their kind, with each holder's worker
|
||||
// (novox/hq ADR 0259 §3): an ask queues until the router takes it, a channel's work until that kind
|
||||
// takes it.
|
||||
trafficStreams, trafficWorkers, err := seatTrafficObjects(ctx, inv)
|
||||
if err != nil {
|
||||
return nil, 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))
|
||||
}
|
||||
}
|
||||
for _, c := range consumers {
|
||||
if err := r.EnsureConsumer(c.Consumer); err != nil {
|
||||
failed = append(failed, fmt.Errorf("how %s on %s hears what it consumes: %w", c.Module, c.Node, err))
|
||||
@@ -130,6 +147,37 @@ func moduleConsumers(ctx context.Context, inv *inventory.Inventory) ([]broker.Mo
|
||||
return broker.ConsumersOf(users), nil
|
||||
}
|
||||
|
||||
// seatTrafficObjects is the work queues and workers of seats that name their caller or their kind, from
|
||||
// the records the user list is composed from.
|
||||
func seatTrafficObjects(ctx context.Context, inv *inventory.Inventory) ([]broker.Stream, []broker.Consumer, error) {
|
||||
records, err := inv.BusRecords(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
users, err := broker.Users(records)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// moduleConsumerCount is how many modules hear what they consume, for the raise's one line.
|
||||
func moduleConsumerCount(ctx context.Context, inv *inventory.Inventory) (int, error) {
|
||||
consumers, err := moduleConsumers(ctx, inv)
|
||||
|
||||
@@ -37,8 +37,6 @@ func TestAPushAnswersBeforeItSends(t *testing.T) {
|
||||
}{
|
||||
{"push", map[string]any{"node": "anchor", "why": "w"}, true},
|
||||
{"push", map[string]any{"why": "w"}, true},
|
||||
{"command", map[string]any{"command": "push anchor --why w"}, true},
|
||||
{"command", map[string]any{"command": "push --behind --why=w"}, true},
|
||||
{"command", map[string]any{"command": "builds"}, false},
|
||||
{"status", map[string]any{}, false},
|
||||
{"assign", map[string]any{"node": "anchor", "module": "m"}, false},
|
||||
|
||||
@@ -121,6 +121,16 @@ var probeRegistry = []probe{
|
||||
{ID: probeReconnectsID, Asserts: "no user of the bus had its connection dropped more than twelve times in the " +
|
||||
"last hour: the bus module's nats_closed_connections", From: "issue 327", Kind: kindBusReconnects,
|
||||
Phase: 1, run: probeReconnects},
|
||||
// The account agents run as (novox/hq ADR 0266): where a machine names one, its node-engine has judged it
|
||||
// unable to become root without a person — what ADR 0259 §8 rests an authorised answer on.
|
||||
{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
|
||||
|
||||
@@ -269,7 +269,8 @@ func gatherFacts(ctx context.Context, open *stores, busVersion string) (snapshot
|
||||
engines := map[string]bool{}
|
||||
for _, n := range nodes {
|
||||
m := snapshot.Machine{Name: scrub.Machine(n.Name), Length: len(n.Name), Adopted: n.Adopted,
|
||||
AccountHome: scrub.Text(n.AccountHome), NodeEngine: n.HostVersion, PublicDomain: domains[n.Name]}
|
||||
AccountHome: scrub.Text(n.AccountHome), NodeEngine: n.HostVersion, PublicDomain: domains[n.Name],
|
||||
AgentAccount: scrub.Account(n.AgentAccount), AgentAccountHome: scrub.Text(n.AgentAccountHome)}
|
||||
switch n.Account {
|
||||
case "", "root":
|
||||
m.Account = n.Account
|
||||
|
||||
@@ -22,10 +22,6 @@ func TestARepairByHandWithoutAReasonIsRefused(t *testing.T) {
|
||||
{"plans", map[string]any{"close": "plan-1"}},
|
||||
{"plans", map[string]any{"stop": "plan-1"}},
|
||||
{"hand-act", map[string]any{"what": "restarted the proxy", "cause": "proxy-stuck"}},
|
||||
{"command", map[string]any{"command": "push anchor"}},
|
||||
{"command", map[string]any{"command": "plans close plan-1"}},
|
||||
{"command", map[string]any{"command": "broker consumer-reset EVENTS controller"}},
|
||||
{"command", map[string]any{"command": "hand-act record restarted --cause x"}},
|
||||
} {
|
||||
argv, err := argvFor(c.verb, c.args)
|
||||
if c.verb == "plans" && err == nil {
|
||||
@@ -65,7 +61,6 @@ func TestARepairByHandCarriesItsReason(t *testing.T) {
|
||||
{"plans", map[string]any{"retry": "plan-1"}, "plans retry plan-1"},
|
||||
{"hand-act", map[string]any{"what": "restarted", "why": "hung", "cause": "proxy", "condition": "machine.a.silent"},
|
||||
"hand-act record restarted --why hung --cause proxy --condition machine.a.silent"},
|
||||
{"command", map[string]any{"command": "push anchor --why stuck"}, "push anchor --why stuck"},
|
||||
{"command", map[string]any{"command": "plans plan-1"}, "plans plan-1"},
|
||||
} {
|
||||
argv, err := argvFor(c.verb, c.args)
|
||||
|
||||
@@ -1013,6 +1013,11 @@ func raiseFromFacts(ctx context.Context, open *stores, f snapshot.Facts, shelf m
|
||||
return notes, err
|
||||
}
|
||||
}
|
||||
if m.AgentAccount != "" {
|
||||
if err := inv.SetAgentAccount(ctx, m.Name, m.AgentAccount, m.AgentAccountHome); err != nil {
|
||||
return notes, err
|
||||
}
|
||||
}
|
||||
if m.PublicDomain != "" {
|
||||
if err := inv.SetPublicDomain(ctx, m.Name, m.PublicDomain); err != nil {
|
||||
return notes, err
|
||||
|
||||
@@ -73,7 +73,7 @@ func stateHealth(ctx context.Context, inv *inventory.Inventory, k *conditions.Ke
|
||||
for _, r := range h.Resources {
|
||||
kept := inventory.ResourceHealth{Module: r.Module, Resource: r.Resource, Kind: r.Kind, Target: r.Target,
|
||||
State: r.State, Reason: r.Reason, Since: r.Since, Streak: r.Streak, Restarts: r.Restarts,
|
||||
Check: r.Check, Needs: r.Needs, Account: r.Account}
|
||||
Check: r.Check, Needs: r.Needs, Account: r.Account, Root: r.Root}
|
||||
resources = append(resources, kept)
|
||||
if r.State == link.StateUnhealthy && r.Module != "" {
|
||||
unhealthy[r.Module] = append(unhealthy[r.Module], kept)
|
||||
|
||||
@@ -100,6 +100,12 @@ func nodeCommand(ctx context.Context, args []string) error {
|
||||
"containers reaching outward. The machine reports which of its links face outside; see " +
|
||||
"`node show <name>`")
|
||||
|
||||
case "agent-account":
|
||||
// The account agents run as on this machine, when it is not the operator's (novox/hq ADR 0266). Here,
|
||||
// at the controller's terminal, and nowhere else: no verb and no setting names it, so no agent can
|
||||
// name itself another account.
|
||||
return nodeAgentAccount(ctx, inv, args[1:])
|
||||
|
||||
case "account":
|
||||
// The operator's login on this machine (novox/hq to-be 29): what a home-scoped file is
|
||||
// owned by and which account `ssh <node>` uses. Reports with no argument; sets with one;
|
||||
@@ -107,7 +113,7 @@ func nodeCommand(ctx context.Context, args []string) error {
|
||||
return nodeAccount(ctx, inv, args[1:])
|
||||
|
||||
default:
|
||||
return fmt.Errorf("node has no %q; it has add, list, show, public-domain and account", args[0])
|
||||
return fmt.Errorf("node has no %q; it has add, list, show, public-domain, account and agent-account", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +184,57 @@ func nodeAccount(ctx context.Context, inv *inventory.Inventory, positionals []st
|
||||
return nil
|
||||
}
|
||||
|
||||
const agentAccountUsage = "node agent-account <name> — what it is now; " +
|
||||
"<name> <account> [home] to name the account agents run as (home defaults to /home/<account>); " +
|
||||
"<name> --clear to have them run as the operator account again"
|
||||
|
||||
// nodeAgentAccount reports, names or clears the account agents run as on a node (novox/hq ADR 0266). Read-
|
||||
// shaped with no account, like public-domain; clearing is asked for by name.
|
||||
func nodeAgentAccount(ctx context.Context, inv *inventory.Inventory, args []string) error {
|
||||
set := flag.NewFlagSet("node agent-account", flag.ContinueOnError)
|
||||
clear := set.Bool("clear", false, "agents run as the operator account again")
|
||||
positionals, err := parseAround(set, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(positionals) == 0 || len(positionals) > 3 {
|
||||
return errors.New(agentAccountUsage)
|
||||
}
|
||||
node := positionals[0]
|
||||
switch {
|
||||
case *clear && len(positionals) > 1:
|
||||
return fmt.Errorf("name an agent account for %s or --clear, not both", node)
|
||||
case *clear:
|
||||
if err := inv.SetAgentAccount(ctx, node, "", ""); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("agents on %s run as the operator account again\n", node)
|
||||
fmt.Printf(" run `push %s` to send it; the agent account itself is kept (the mesh never deletes a login)\n", node)
|
||||
return nil
|
||||
case len(positionals) >= 2:
|
||||
home := ""
|
||||
if len(positionals) == 3 {
|
||||
home = positionals[2]
|
||||
}
|
||||
if err := inv.SetAgentAccount(ctx, node, positionals[1], home); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("agents on %s run as %s\n", node, positionals[1])
|
||||
fmt.Printf(" run `push %s` to send it; the self-check says once its node-engine has judged it "+
|
||||
"unable to become root\n", node)
|
||||
return nil
|
||||
default:
|
||||
n, err := inv.NodeByName(ctx, node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, line := range agentAccountLines(ctx, inv, n) {
|
||||
fmt.Println(strings.TrimPrefix(line, " "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
const publicDomainUsage = "node public-domain <name> — what it is now; " +
|
||||
"<name> <domain> to set it; <name> --clear to take it away"
|
||||
|
||||
@@ -592,6 +649,10 @@ func showNode(ctx context.Context, inv *inventory.Inventory, name string) error
|
||||
if err := showMode(ctx, inv, node); err != nil {
|
||||
return err
|
||||
}
|
||||
// Whom agents run as here, and whether that account can become root without a person (ADR 0266).
|
||||
for _, line := range agentAccountLines(ctx, inv, node) {
|
||||
fmt.Println(line)
|
||||
}
|
||||
|
||||
// The domain its routed names are composed under, when it has one (novox/hq ADR 0066). Shown
|
||||
// only when set: a machine that serves nothing to the outside has no domain, and saying so of
|
||||
|
||||
@@ -110,6 +110,15 @@ var plainWordings = map[string]func(conditions.Observation) words{
|
||||
"reaches it. It keeps running what it has.", m),
|
||||
Resolved: m + " can get new instructions again"}
|
||||
}),
|
||||
kindAgentCanBecomeRoot: worded(func(o conditions.Observation) words {
|
||||
m := machineOr(o, "a machine")
|
||||
return words{Headline: "Sessions on " + m + " could become root",
|
||||
Needs: "take the sessions' own account out of every group and rule that grants root; the details say which.",
|
||||
Explanation: fmt.Sprintf("Assistant sessions on %s run under an account of their own, so that none "+
|
||||
"can take over the machine without you. The machine cannot show that this holds now, so an answer "+
|
||||
"from your phone authorises nothing there until it does.", m),
|
||||
Resolved: "Sessions on " + m + " cannot become root again"}
|
||||
}),
|
||||
"own-address-banned": worded(func(o conditions.Observation) words {
|
||||
m := machineOr(o, "a machine")
|
||||
return words{Headline: m + " has banned the mesh",
|
||||
|
||||
@@ -136,7 +136,8 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso
|
||||
resolved, err := catalogue.Resolve(shelf, assigned,
|
||||
catalogue.Node{Name: nodeName, Site: site, Capabilities: capabilities,
|
||||
At: onNetwork[nodeName], PublicDomain: publicDomain,
|
||||
Account: who.Account, AccountHome: who.AccountHome}, world)
|
||||
Account: who.Account, AccountHome: who.AccountHome,
|
||||
AgentAccount: who.AgentAccount, AgentAccountHome: who.AgentAccountHome}, world)
|
||||
if err != nil {
|
||||
// The node's own set does not compose. Marked, because this is the only failure here that
|
||||
// a mesh-wide gatherer may pass over — see notResolvable.
|
||||
@@ -885,8 +886,13 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
||||
if err != nil {
|
||||
return catalogue.Rendering{}, inventory.Node{}, err
|
||||
}
|
||||
judgesRoot, err := engineJudgesRoot(ctx, inv, node)
|
||||
if err != nil {
|
||||
return catalogue.Rendering{}, inventory.Node{}, err
|
||||
}
|
||||
return catalogue.Rendering{
|
||||
ReadsHealth: readsHealth,
|
||||
JudgesRoot: judgesRoot,
|
||||
BusMembership: memberships[node],
|
||||
Settings: settings, Generators: gens, Grants: grants, Needed: needed, Foreseen: foreseen, Ports: ports,
|
||||
Certificate: certificate, Authority: authority, Mesh: private, Names: names,
|
||||
@@ -909,6 +915,16 @@ func engineReadsHealth(ctx context.Context, inv *inventory.Inventory, node strin
|
||||
return had && stated.Contract >= link.ReadinessContract, nil
|
||||
}
|
||||
|
||||
// engineJudgesRoot says whether a machine's node-engine judges a user's declared `root` (novox/hq ADR 0266),
|
||||
// by its own newest statement, for the same reason as engineReadsHealth: an older engine parses strictly.
|
||||
func engineJudgesRoot(ctx context.Context, inv *inventory.Inventory, node string) (bool, error) {
|
||||
stated, had, err := inv.HealthOf(ctx, node)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return had && stated.Contract >= link.RootContract, nil
|
||||
}
|
||||
|
||||
// zonesInTheMesh is every zone a module in the mesh declares, where the mesh placed it (novox/hq ADR
|
||||
// 0199): the zone settled from that node's settings, the node's private address, the port the
|
||||
// answering listen is published on there.
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// Who can become root where the trusted parties run (novox/hq ADR 0259 §8, as reviewed on 2026-10-09).
|
||||
//
|
||||
// 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**, and so does an agent running as the
|
||||
// operator's account there. A machine is **root-free** — an answer proven there may authorise — only when all
|
||||
// of these are measured, now, and hold:
|
||||
//
|
||||
// 1. the machine names an account agents run as (novox/hq ADR 0266), so no agent runs as the operator's
|
||||
// account, which may become root;
|
||||
// 2. its node-engine — running as root, which no agent controls — judged that account unable to become root
|
||||
// without a person, in a statement heard within the last 15 minutes (agentConfined, judgedConfined);
|
||||
// 3. the login shell's `execute` is not served there (novox/hq ADR 0268): its holder's setting withholds it
|
||||
// **and** the bus was asked and heard no `execute` answered there. `execute` runs commands as the machine's
|
||||
// runtime account, which the mesh's acting tools give passwordless sudo; that account is taken to become
|
||||
// root, always, so no measure of it is asked.
|
||||
//
|
||||
// **Nothing else is a pass.** A machine that names no agent account, an unknown machine, a store or bus that
|
||||
// could not be read, a verdict stale or absent — each is not root-free, and says why. The sudo module's own
|
||||
// measure is no longer part of this judgement: it ran in the machine's runtime, as the very account an agent
|
||||
// could become, so it could not be believed.
|
||||
//
|
||||
// The one judgement (judgeRoot) is read two ways: the self-check raises `agent-root` on every machine where
|
||||
// the router or a module of its own account runs and the judgement fails; and the `root-free` verb answers it
|
||||
// live, to the router, which honours a verified sender only on its pass. A machine holding the operator's
|
||||
// graphical session, where a messaging client's desktop app may run, is not judged here: the operator accepted
|
||||
// that gap for now (hq issue 344).
|
||||
|
||||
// kindAgentRoot is the condition a trusted party's machine that is not root-free raises.
|
||||
const kindAgentRoot = "agent-root"
|
||||
|
||||
// routerSeat is the seat the router holds: where it runs counts as a trusted party's machine.
|
||||
const routerSeat = "operator-channel"
|
||||
|
||||
const (
|
||||
loginShellSeat = "node-login-shell"
|
||||
// loginShellVerb is the seat's verb that runs a command, and the name of the setting its holder withholds
|
||||
// it by (novox/hq ADR 0268).
|
||||
loginShellVerb = "execute"
|
||||
// executeServes is the one value of that setting that serves the verb; anything else withholds it.
|
||||
executeServes = "serve"
|
||||
)
|
||||
|
||||
// loginShellServed judges whether the login shell's execute is served on a machine, failing closed (novox/hq
|
||||
// ADR 0268): served while the holder's setting there is `serve` (or the holder has no such setting and claims
|
||||
// the verb), or while the bus heard the verb answered there, or while the bus could not be asked. why says
|
||||
// which, in words.
|
||||
func loginShellServed(holder string, claims bool, setting *any, heard, asked bool) (bool, string) {
|
||||
var why []string
|
||||
switch {
|
||||
case setting != nil:
|
||||
if v, _ := (*setting).(string); v == executeServes {
|
||||
why = append(why, holder+"'s execute setting there is "+executeServes)
|
||||
}
|
||||
case claims:
|
||||
why = append(why, holder+" claims execute and has no setting that withholds it")
|
||||
}
|
||||
if heard {
|
||||
why = append(why, "the bus hears execute answered there")
|
||||
} else if !asked {
|
||||
why = append(why, "the bus could not be asked whether execute is answered there")
|
||||
}
|
||||
return len(why) > 0, strings.Join(why, "; ")
|
||||
}
|
||||
|
||||
// rootFacts is what the judgement reads of one machine.
|
||||
type rootFacts struct {
|
||||
Machine string
|
||||
// Unread is every read that failed, in words: any one is a fail.
|
||||
Unread []string
|
||||
// AgentNamed is whether the machine names an agent account; Confined whether its node-engine judged it
|
||||
// unable to become root, freshly; ConfinedWhy the judgement's words either way.
|
||||
AgentNamed bool
|
||||
Confined bool
|
||||
ConfinedWhy string
|
||||
// Execute is whether the login shell's execute is served there; ExecuteWhy why, in words.
|
||||
Execute bool
|
||||
ExecuteWhy string
|
||||
}
|
||||
|
||||
// rootVerdict is the judgement on one machine, as the root-free verb answers it.
|
||||
type rootVerdict struct {
|
||||
Machine string `json:"machine"`
|
||||
Free bool `json:"free"`
|
||||
Why string `json:"why"`
|
||||
Judged time.Time `json:"judged"`
|
||||
}
|
||||
|
||||
// judgeRoot is the one judgement: free only when nothing failed to read, an agent account is named and judged
|
||||
// confined, and execute is not served.
|
||||
func judgeRoot(f rootFacts, now time.Time) rootVerdict {
|
||||
v := rootVerdict{Machine: f.Machine, Judged: now.UTC()}
|
||||
var not []string
|
||||
if len(f.Unread) > 0 {
|
||||
not = append(not, "not measured: "+strings.Join(f.Unread, "; "))
|
||||
}
|
||||
switch {
|
||||
case f.ConfinedWhy == "":
|
||||
// Not read (said above), or nothing said of it: never a pass.
|
||||
if len(f.Unread) == 0 {
|
||||
not = append(not, "whether agents there can become root was not judged")
|
||||
}
|
||||
case !f.AgentNamed:
|
||||
not = append(not, "agents run as the operator's account there, which may become root ("+f.ConfinedWhy+")")
|
||||
case !f.Confined:
|
||||
not = append(not, f.ConfinedWhy)
|
||||
}
|
||||
if f.Execute {
|
||||
not = append(not, "the login shell runs any command an agent gives it as the machine's runtime account, "+
|
||||
"which can become root ("+orNoneKnown(f.ExecuteWhy)+")")
|
||||
}
|
||||
if len(not) > 0 {
|
||||
v.Why = strings.Join(not, "; ")
|
||||
return v
|
||||
}
|
||||
v.Free = true
|
||||
v.Why = f.ConfinedWhy + "; the login shell's execute is not served there"
|
||||
return v
|
||||
}
|
||||
|
||||
// rootReader reads the facts of machines live: the catalogue's placements, the node-engine's verdicts and the
|
||||
// bus's discovery, each once per reader.
|
||||
type rootReader struct {
|
||||
entries []inventory.Entry
|
||||
read error
|
||||
heard map[string]map[string]map[string]bool
|
||||
asked error
|
||||
// confined is agentConfined; settings the login shell holder's settings on a machine. Replaceable in a test.
|
||||
confined func(ctx context.Context, node string) (named, confined bool, why string, err error)
|
||||
settings func(ctx context.Context, node, module string) ([]catalogue.Layer, error)
|
||||
}
|
||||
|
||||
func newRootReader(ctx context.Context, inv *inventory.Inventory, conn *nats.Conn) *rootReader {
|
||||
r := &rootReader{}
|
||||
r.entries, r.read = inv.Catalogued(ctx)
|
||||
if conn == nil {
|
||||
r.asked = fmt.Errorf("this process holds no connection to the bus")
|
||||
} else {
|
||||
r.heard, r.asked = discoverSeatVerbs(ctx, conn)
|
||||
}
|
||||
r.confined = func(ctx context.Context, node string) (bool, bool, string, error) {
|
||||
return agentConfined(ctx, inv, node)
|
||||
}
|
||||
r.settings = inv.SettingsFor
|
||||
return r
|
||||
}
|
||||
|
||||
// facts reads one machine.
|
||||
func (r *rootReader) facts(ctx context.Context, machine string) rootFacts {
|
||||
f := rootFacts{Machine: machine}
|
||||
if r.read != nil {
|
||||
f.Unread = append(f.Unread, "the catalogue's placements could not be read: "+r.read.Error())
|
||||
}
|
||||
named, confined, why, err := r.confined(ctx, machine)
|
||||
if err != nil {
|
||||
f.Unread = append(f.Unread, "the account agents run as could not be read: "+err.Error())
|
||||
} else {
|
||||
f.AgentNamed, f.Confined, f.ConfinedWhy = named, confined, why
|
||||
}
|
||||
f.Execute, f.ExecuteWhy = r.executeServed(ctx, machine)
|
||||
return f
|
||||
}
|
||||
|
||||
// executeServed is whether the login shell's execute is served on a machine, failing closed: the bus not
|
||||
// asked, the placements not read, or a holder's setting not read, is served.
|
||||
func (r *rootReader) executeServed(ctx context.Context, machine string) (bool, string) {
|
||||
heard := r.heard[loginShellSeat][loginShellVerb][machine]
|
||||
asked := r.asked == nil
|
||||
var whys []string
|
||||
served := false
|
||||
holders := 0
|
||||
for _, e := range r.entries {
|
||||
if !e.Manifest.ClaimsSeat(loginShellSeat) || !slices.Contains(e.On, machine) {
|
||||
continue
|
||||
}
|
||||
holders++
|
||||
var setting *any
|
||||
if _, declared := e.Manifest.Settings[loginShellVerb]; declared {
|
||||
layers, err := r.settings(ctx, machine, e.Manifest.Module)
|
||||
if err != nil {
|
||||
served = true
|
||||
whys = append(whys, e.Manifest.Module+"'s setting there could not be read: "+err.Error())
|
||||
continue
|
||||
}
|
||||
for _, s := range catalogue.Effective(e.Manifest, layers) {
|
||||
if s.Key == loginShellVerb {
|
||||
v := s.Value
|
||||
setting = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
if s, why := loginShellServed(e.Manifest.Module, claimServes(e.Manifest, loginShellSeat, loginShellVerb),
|
||||
setting, heard, asked); s {
|
||||
served = true
|
||||
whys = append(whys, why)
|
||||
}
|
||||
}
|
||||
if holders == 0 {
|
||||
// Nobody is assigned to serve it; the bus must still hear nobody answering it.
|
||||
if s, why := loginShellServed("no holder", false, nil, heard, asked); s {
|
||||
served = true
|
||||
whys = append(whys, why)
|
||||
}
|
||||
}
|
||||
if r.read != nil {
|
||||
served = true
|
||||
whys = append(whys, "who holds the login shell there could not be read")
|
||||
}
|
||||
return served, strings.Join(whys, "; ")
|
||||
}
|
||||
|
||||
// claimServes says whether a manifest's claim of a seat names a verb among those it serves.
|
||||
func claimServes(m catalogue.Manifest, seat, verb string) bool {
|
||||
for _, c := range m.Claims {
|
||||
if c.Name == seat && slices.Contains(c.Serves, verb) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// judgeRootFree is the root-free verb's answer: each named machine judged now. It never fails: what could not
|
||||
// be read is a machine not free, saying so.
|
||||
func judgeRootFree(ctx context.Context, r *rootReader, machines []string, now time.Time) []rootVerdict {
|
||||
out := make([]rootVerdict, 0, len(machines))
|
||||
for _, m := range machines {
|
||||
out = append(out, judgeRoot(r.facts(ctx, m), now))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// trustedMachines are the machines where the router or a module of its own account runs, each with those
|
||||
// modules.
|
||||
func trustedMachines(entries []inventory.Entry) map[string][]string {
|
||||
trusted := map[string][]string{}
|
||||
for _, e := range entries {
|
||||
for _, node := range e.On {
|
||||
if e.Manifest.RunsAs != "" || e.Manifest.ClaimsSeat(routerSeat) {
|
||||
trusted[node] = append(trusted[node], e.Manifest.Module)
|
||||
}
|
||||
}
|
||||
}
|
||||
return trusted
|
||||
}
|
||||
|
||||
// agentRootObservation is the condition of a trusted party's machine that is not root-free.
|
||||
func agentRootObservation(v rootVerdict, trusted []string) conditions.Observation {
|
||||
trusted = append([]string(nil), trusted...)
|
||||
sort.Strings(trusted)
|
||||
return conditions.Observation{Scope: conditions.ScopeMachine, ID: v.Machine, Token: kindAgentRoot,
|
||||
Machine: v.Machine, Kind: kindAgentRoot, Severity: conditions.Urgent,
|
||||
Summary: fmt.Sprintf("%s is not root-free, where %s run: until it is, the router approves nothing proven "+
|
||||
"there (novox/hq ADR 0259 §8): %s", v.Machine, strings.Join(trusted, ", "), v.Why),
|
||||
Headline: "Phone answers held on " + v.Machine,
|
||||
Needs: "give the programs working for you on " + v.Machine + " an account that cannot become root.",
|
||||
Explanation: "The modules that prove your answers from your phone run on " + v.Machine + ", and the mesh " +
|
||||
"cannot show that a program working for you there is unable to become root or to act as you. Until " +
|
||||
"it can, answers from your phone can only acknowledge.",
|
||||
Resolved: "Answers from your phone can approve again on " + v.Machine}
|
||||
}
|
||||
|
||||
// probeAgentRoot is the probe: every trusted party's machine, judged by the one judgement.
|
||||
func probeAgentRoot(ctx context.Context, d *doctor) ([]conditions.Observation, error) {
|
||||
var conn *nats.Conn
|
||||
if d.js != nil {
|
||||
conn = d.js.Conn()
|
||||
}
|
||||
r := newRootReader(ctx, d.open.inventory, conn)
|
||||
if r.read != nil {
|
||||
return nil, r.read
|
||||
}
|
||||
return rootObservations(ctx, r, trustedMachines(r.entries), time.Now()), nil
|
||||
}
|
||||
|
||||
// rootObservations judges the machines and says each that fails.
|
||||
func rootObservations(ctx context.Context, r *rootReader, trusted map[string][]string, now time.Time) []conditions.Observation {
|
||||
var machines []string
|
||||
for m := range trusted {
|
||||
machines = append(machines, m)
|
||||
}
|
||||
sort.Strings(machines)
|
||||
var out []conditions.Observation
|
||||
for _, v := range judgeRootFree(ctx, r, machines, now) {
|
||||
if !v.Free {
|
||||
out = append(out, agentRootObservation(v, trusted[v.Machine]))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// rootClock is the clock the root-free verb judges by.
|
||||
var rootClock = time.Now
|
||||
|
||||
// rootFreeAnswer is the root-free verb: the named machines, each judged now by the serving controller. Only it
|
||||
// answers: a process that is not serving says so, and a caller reads that as no machine free.
|
||||
func rootFreeAnswer(ctx context.Context, machines string, now time.Time) (any, error) {
|
||||
d := doctorFrom
|
||||
if d == nil || d.open == nil || d.open.inventory == nil {
|
||||
return nil, fmt.Errorf("this controller is not serving, so it judges no machine root-free: ask again, and " +
|
||||
"the serving controller answers")
|
||||
}
|
||||
var names []string
|
||||
for _, m := range strings.Split(machines, ",") {
|
||||
if m = strings.TrimSpace(m); m != "" && !slices.Contains(names, m) {
|
||||
names = append(names, m)
|
||||
}
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return nil, fmt.Errorf("root-free judges the machines named, and none was")
|
||||
}
|
||||
var conn *nats.Conn
|
||||
if d.js != nil {
|
||||
conn = d.js.Conn()
|
||||
}
|
||||
return map[string]any{"machines": judgeRootFree(ctx, newRootReader(ctx, d.open.inventory, conn), names, now)}, nil
|
||||
}
|
||||
|
||||
// rootFreeNow is the machines judged root-free, for composing a push's memberships: only those that pass.
|
||||
func rootFreeNow(ctx context.Context, r *rootReader, machines []string, now time.Time) map[string]bool {
|
||||
free := map[string]bool{}
|
||||
for _, v := range judgeRootFree(ctx, r, machines, now) {
|
||||
if v.Free {
|
||||
free[v.Machine] = true
|
||||
}
|
||||
}
|
||||
return free
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
var rootNow = time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// A machine is root-free only on a positive measure of each thing (the review of 2026-10-09, H2/H3): every
|
||||
// read succeeded, an agent account is named and judged confined by the node-engine, execute is not served.
|
||||
func TestRootFreeIsAPositiveMeasureAndNothingElse(t *testing.T) {
|
||||
pass := rootFacts{Machine: "anchor", AgentNamed: true, Confined: true,
|
||||
ConfinedWhy: "the agent account agents cannot become root without a person (judged 2026-10-09 12:00)"}
|
||||
if v := judgeRoot(pass, rootNow); !v.Free || v.Machine != "anchor" || !v.Judged.Equal(rootNow) {
|
||||
t.Fatalf("the one pass: %+v", v)
|
||||
}
|
||||
fails := map[string]func(*rootFacts){
|
||||
"a read failed": func(f *rootFacts) { f.Unread = []string{"the store did not answer"} },
|
||||
"no agent account named": func(f *rootFacts) { f.AgentNamed, f.Confined = false, false },
|
||||
"not confined": func(f *rootFacts) { f.Confined = false },
|
||||
"nothing said of it": func(f *rootFacts) { f.ConfinedWhy = "" },
|
||||
"execute served": func(f *rootFacts) { f.Execute, f.ExecuteWhy = true, "the bus hears execute answered there" },
|
||||
"confined, but agent read": func(f *rootFacts) { f.Unread, f.ConfinedWhy = []string{"x"}, "" },
|
||||
}
|
||||
for name, mutate := range fails {
|
||||
f := pass
|
||||
mutate(&f)
|
||||
if v := judgeRoot(f, rootNow); v.Free || v.Why == "" {
|
||||
t.Errorf("%s: judged %+v", name, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The reader fails closed on every case the review named: the agent account read only where the coding-agent
|
||||
// module runs (old :225), no account to measure taken for a pass (old :246), and a measure that did not answer
|
||||
// taken for "not root" (old :114, :123).
|
||||
func TestTheRootReaderFailsClosed(t *testing.T) {
|
||||
shell := catalogue.Manifest{Module: "zsh", Claims: []catalogue.Claim{{Name: loginShellSeat, Serves: []string{"execute"}}},
|
||||
Settings: map[string]catalogue.SettingDeclaration{"execute": {Default: "withhold"}}}
|
||||
reader := func() *rootReader {
|
||||
return &rootReader{
|
||||
entries: []inventory.Entry{{Manifest: shell, On: []string{"anchor"}}},
|
||||
heard: map[string]map[string]map[string]bool{},
|
||||
confined: func(context.Context, string) (bool, bool, string, error) {
|
||||
return true, true, "the agent account agents cannot become root without a person", nil
|
||||
},
|
||||
settings: func(context.Context, string, string) ([]catalogue.Layer, error) { return nil, nil },
|
||||
}
|
||||
}
|
||||
ctx := context.Background()
|
||||
if v := judgeRootFree(ctx, reader(), []string{"anchor"}, rootNow)[0]; !v.Free {
|
||||
t.Fatalf("the control: agents confined, execute withheld and unheard, everything read: %+v", v)
|
||||
}
|
||||
cases := map[string]func(*rootReader){
|
||||
"no agent account named, no coding-agent module there": func(r *rootReader) {
|
||||
r.confined = func(context.Context, string) (bool, bool, string, error) {
|
||||
return false, false, "anchor names no agent account: agents run as the operator account (ops)", nil
|
||||
}
|
||||
},
|
||||
"the node-engine's verdict not read": func(r *rootReader) {
|
||||
r.confined = func(context.Context, string) (bool, bool, string, error) {
|
||||
return false, false, "", errors.New("the store did not answer")
|
||||
}
|
||||
},
|
||||
"a stale verdict": func(r *rootReader) {
|
||||
r.confined = func(context.Context, string) (bool, bool, string, error) {
|
||||
return true, false, "the agent account agents is not judged: the machine's newest statement was heard at …", nil
|
||||
}
|
||||
},
|
||||
"the bus not asked": func(r *rootReader) { r.asked = errors.New("no bus") },
|
||||
"the placements not read": func(r *rootReader) { r.read = errors.New("no store") },
|
||||
"the holder's setting not read": func(r *rootReader) {
|
||||
r.settings = func(context.Context, string, string) ([]catalogue.Layer, error) { return nil, errors.New("no store") }
|
||||
},
|
||||
"execute heard on the bus": func(r *rootReader) {
|
||||
r.heard = map[string]map[string]map[string]bool{loginShellSeat: {"execute": {"anchor": true}}}
|
||||
},
|
||||
"a holder that serves execute": func(r *rootReader) {
|
||||
r.entries[0].Manifest.Settings = nil
|
||||
},
|
||||
}
|
||||
for name, mutate := range cases {
|
||||
r := reader()
|
||||
mutate(r)
|
||||
if v := judgeRootFree(ctx, r, []string{"anchor"}, rootNow)[0]; v.Free {
|
||||
t.Errorf("%s: judged free: %+v", name, v)
|
||||
}
|
||||
}
|
||||
// A machine with no login shell holder at all: still the bus must hear none.
|
||||
r := reader()
|
||||
r.entries = nil
|
||||
r.heard = map[string]map[string]map[string]bool{loginShellSeat: {"execute": {"anchor": true}}}
|
||||
if v := judgeRootFree(ctx, r, []string{"anchor"}, rootNow)[0]; v.Free {
|
||||
t.Errorf("execute answered by a module nobody assigned: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// The probe says agent-root, by the same judgement, on each machine where the router or a module of its own
|
||||
// account runs and that is not root-free; urgent and in plain words; nothing where every one is free.
|
||||
func TestTheProbeSaysEachMachineThatIsNotRootFree(t *testing.T) {
|
||||
entries := []inventory.Entry{
|
||||
{Manifest: catalogue.Manifest{Module: "telegram", RunsAs: "telegram"}, On: []string{"anchor"}},
|
||||
{Manifest: catalogue.Manifest{Module: "messenger", RunsAs: "messenger",
|
||||
Claims: []catalogue.Claim{{Name: routerSeat}}}, On: []string{"anchor"}},
|
||||
{Manifest: catalogue.Manifest{Module: "xorg", Claims: []catalogue.Claim{{Name: catalogue.DisplayServerSeat}}},
|
||||
On: []string{"laptop"}},
|
||||
}
|
||||
trusted := trustedMachines(entries)
|
||||
if len(trusted["anchor"]) != 2 || len(trusted["laptop"]) != 0 {
|
||||
t.Fatalf("trusted %v", trusted)
|
||||
}
|
||||
r := &rootReader{entries: entries, heard: map[string]map[string]map[string]bool{},
|
||||
confined: func(_ context.Context, node string) (bool, bool, string, error) {
|
||||
return false, false, node + " names no agent account: agents run as the operator account (ops)", nil
|
||||
},
|
||||
settings: func(context.Context, string, string) ([]catalogue.Layer, error) { return nil, nil }}
|
||||
got := rootObservations(context.Background(), r, trusted, rootNow)
|
||||
if len(got) != 1 || got[0].Machine != "anchor" || got[0].Kind != kindAgentRoot || got[0].Severity != conditions.Urgent ||
|
||||
!strings.Contains(got[0].Summary, "messenger, telegram") || !strings.Contains(got[0].Summary, "names no agent account") {
|
||||
t.Fatalf("said %+v", got)
|
||||
}
|
||||
o := got[0]
|
||||
if why, ok := conditions.PlainWords(conditions.Words{Headline: o.Headline, Explanation: o.Explanation,
|
||||
Needs: o.Needs, Resolved: o.Resolved}, o.Machine); !ok {
|
||||
t.Errorf("not plain: %s", why)
|
||||
}
|
||||
r.confined = func(context.Context, string) (bool, bool, string, error) { return true, true, "confined", nil }
|
||||
if got := rootObservations(context.Background(), r, trusted, rootNow); len(got) != 0 {
|
||||
t.Errorf("said of a free machine: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0268: the login shell counts only where its execute is served, and fails closed.
|
||||
func TestTheLoginShellCountsOnlyWhereExecuteIsServed(t *testing.T) {
|
||||
val := func(v any) *any { return &v }
|
||||
cases := []struct {
|
||||
name string
|
||||
claims bool
|
||||
setting *any
|
||||
heard, asked bool
|
||||
served bool
|
||||
saysInTheWhys string
|
||||
}{
|
||||
{"withheld by the setting and silent on the bus", true, val("withhold"), false, true, false, ""},
|
||||
{"withheld by the setting, the machine not yet pushed", true, val("withhold"), true, true, true, "the bus hears"},
|
||||
{"the setting serves", true, val("serve"), false, true, true, "setting there is serve"},
|
||||
{"a wrong value withholds, as the holder does", true, val("Serve"), false, true, false, ""},
|
||||
{"the setting withholds, the bus could not be asked", true, val("withhold"), false, false, true, "could not be asked"},
|
||||
{"a holder with no such setting that claims execute", true, nil, false, true, true, "claims execute"},
|
||||
{"a holder with no such setting that does not claim it, heard all the same", false, nil, true, true, true, "the bus hears"},
|
||||
{"a holder with no such setting that does not claim it, silent", false, nil, false, true, false, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
served, why := loginShellServed("zsh", c.claims, c.setting, c.heard, c.asked)
|
||||
if served != c.served || (c.saysInTheWhys != "" && !strings.Contains(why, c.saysInTheWhys)) {
|
||||
t.Errorf("%s: served %v (%q), want %v saying %q", c.name, served, why, c.served, c.saysInTheWhys)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The root-free verb is the serving controller's alone, answered in its process and never as a command; a
|
||||
// controller not serving answers an error, which the router reads as no machine free.
|
||||
func TestRootFreeIsAnsweredOnlyByTheServingController(t *testing.T) {
|
||||
was := doctorFrom
|
||||
doctorFrom = nil
|
||||
t.Cleanup(func() { doctorFrom = was })
|
||||
if _, err := rootFreeAnswer(context.Background(), "anchor", rootNow); err == nil {
|
||||
t.Error("a controller not serving judged a machine")
|
||||
}
|
||||
if !inProcess["root-free"] {
|
||||
t.Error("root-free is not answered in the serving process")
|
||||
}
|
||||
if _, err := argvFor("root-free", map[string]any{"machines": "anchor"}); err == nil {
|
||||
t.Error("root-free ran as a command")
|
||||
}
|
||||
// The router names its machines as a list (its contract with this verb); one text separated by commas is
|
||||
// the same; anything else in the list is refused.
|
||||
for _, given := range []any{[]any{"anchor", "relay"}, "anchor, relay"} {
|
||||
a, err := readArguments("root-free", map[string]any{"machines": given})
|
||||
if err != nil || a.given["machines"] != "anchor,relay" && a.given["machines"] != "anchor, relay" {
|
||||
t.Errorf("root-free given %v read %v (%v)", given, a, err)
|
||||
}
|
||||
}
|
||||
if _, err := readArguments("root-free", map[string]any{"machines": []any{"anchor", 7}}); err == nil {
|
||||
t.Error("root-free took a number for a machine")
|
||||
}
|
||||
if _, err := readArguments("status", map[string]any{"machines": []any{"anchor"}}); err == nil {
|
||||
t.Error("a verb that takes no list took one")
|
||||
}
|
||||
}
|
||||
@@ -637,31 +637,8 @@ const (
|
||||
// discoverHolders asks the bus's discovery who serves what, and answers seat → machine for every
|
||||
// endpoint a seat's verb is served on.
|
||||
func discoverHolders(ctx context.Context, conn *nats.Conn) (map[string]map[string]bool, error) {
|
||||
inbox := conn.NewRespInbox()
|
||||
sub, err := conn.SubscribeSync(inbox)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = sub.Unsubscribe() }()
|
||||
if err := conn.PublishRequest("$SRV.INFO", inbox, nil); err != nil {
|
||||
return nil, fmt.Errorf("asking the bus who serves what: %w", err)
|
||||
}
|
||||
out := map[string]map[string]bool{}
|
||||
deadline := time.Now().Add(discoveryPatience)
|
||||
for time.Now().Before(deadline) {
|
||||
wait, cancel := context.WithTimeout(ctx, discoveryQuiet)
|
||||
msg, err := sub.NextMsgWithContext(wait)
|
||||
cancel()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
break
|
||||
}
|
||||
var info micro.Info
|
||||
if json.Unmarshal(msg.Data, &info) != nil {
|
||||
continue
|
||||
}
|
||||
err := discoverServices(ctx, conn, func(info micro.Info) {
|
||||
for _, e := range info.Endpoints {
|
||||
seat, node := e.Metadata["seat"], e.Metadata["node"]
|
||||
if seat == "" {
|
||||
@@ -680,8 +657,69 @@ func discoverHolders(ctx context.Context, conn *nats.Conn) (map[string]map[strin
|
||||
out[info.Name] = map[string]bool{}
|
||||
}
|
||||
out[info.Name][info.ID] = true
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// discoverSeatVerbs asks the bus's discovery the same, one level finer: seat → verb → machine, for every
|
||||
// seat verb answered (an endpoint's `tool` is its verb). A seat held where a verb is withheld (novox/hq ADR
|
||||
// 0268) shows the seat and not that verb.
|
||||
func discoverSeatVerbs(ctx context.Context, conn *nats.Conn) (map[string]map[string]map[string]bool, error) {
|
||||
out := map[string]map[string]map[string]bool{}
|
||||
err := discoverServices(ctx, conn, func(info micro.Info) {
|
||||
for _, e := range info.Endpoints {
|
||||
seat, verb, node := e.Metadata["seat"], e.Metadata["tool"], e.Metadata["node"]
|
||||
if seat == "" || verb == "" {
|
||||
continue
|
||||
}
|
||||
if node == "" {
|
||||
node = info.ID
|
||||
}
|
||||
if out[seat] == nil {
|
||||
out[seat] = map[string]map[string]bool{}
|
||||
}
|
||||
if out[seat][verb] == nil {
|
||||
out[seat][verb] = map[string]bool{}
|
||||
}
|
||||
out[seat][verb][node] = true
|
||||
}
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// discoverServices asks the bus's discovery once and hands every service's answer to visit, waiting
|
||||
// discoveryQuiet after the last and discoveryPatience at the most.
|
||||
func discoverServices(ctx context.Context, conn *nats.Conn, visit func(micro.Info)) error {
|
||||
if conn == nil {
|
||||
return errors.New("the controller holds no connection to the bus")
|
||||
}
|
||||
return out, nil
|
||||
inbox := conn.NewRespInbox()
|
||||
sub, err := conn.SubscribeSync(inbox)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = sub.Unsubscribe() }()
|
||||
if err := conn.PublishRequest("$SRV.INFO", inbox, nil); err != nil {
|
||||
return fmt.Errorf("asking the bus who serves what: %w", err)
|
||||
}
|
||||
deadline := time.Now().Add(discoveryPatience)
|
||||
for time.Now().Before(deadline) {
|
||||
wait, cancel := context.WithTimeout(ctx, discoveryQuiet)
|
||||
msg, err := sub.NextMsgWithContext(wait)
|
||||
cancel()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
break
|
||||
}
|
||||
var info micro.Info
|
||||
if json.Unmarshal(msg.Data, &info) != nil {
|
||||
continue
|
||||
}
|
||||
visit(info)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// probeArchives is D4: every archive the mesh keeps is held by its manifest in the artifact store.
|
||||
|
||||
@@ -1242,11 +1242,14 @@ func issueMemberships(ctx context.Context, open *stores, server *link.Server, se
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
where := broker.PlacementsOf(records, records.Interchangeable)
|
||||
bus, ok := server.Bus().(link.OverNATS)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
// Which machines are root-free now (novox/hq ADR 0259 §8): a channel's verified sender is composed for the
|
||||
// router only from one, beside a router on one. Judged once per push, by the root-free verb's judgement.
|
||||
records.RootFree = rootFreeNow(ctx, newRootReader(ctx, open.inventory, bus.Conn), records.Nodes, time.Now())
|
||||
where := broker.PlacementsOf(records, records.Interchangeable)
|
||||
// **Every declared state's bucket, before the memberships that name it** (novox/hq ADR 0201). The
|
||||
// raise at start asserts them too, but a module registered and assigned since would otherwise have
|
||||
// its bucket only after the control plane next restarts — found the first time a module declared
|
||||
|
||||
@@ -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 <node>/<module>, 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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,9 @@ func argvFor(verb string, args map[string]any) ([]string, error) {
|
||||
return nil, err
|
||||
}
|
||||
argv, err := a.commandLine()
|
||||
if err == nil {
|
||||
err = terminalOnly(argv)
|
||||
}
|
||||
if len(a.misread) > 0 {
|
||||
// The table and the command line disagree: the verb reads an argument no caller can see
|
||||
// in its schema, so no caller could ever pass it.
|
||||
@@ -111,6 +114,14 @@ func declaredArguments(v catalogue.Verb) (names []string, switches map[string]bo
|
||||
return names, switches
|
||||
}
|
||||
|
||||
// isList says a verb's argument is declared a list of text (catalogue's listed): given as a JSON array, it is
|
||||
// read as its items joined by commas, as the same argument given as one text would be.
|
||||
func isList(v catalogue.Verb, name string) bool {
|
||||
props, _ := v.Input["properties"].(map[string]any)
|
||||
p, _ := props[name].(map[string]any)
|
||||
return p != nil && p["type"] == "array"
|
||||
}
|
||||
|
||||
// readArguments refuses what the verb does not take, before anything is composed.
|
||||
func readArguments(verb string, args map[string]any) (*verbArguments, error) {
|
||||
v, known := controllerVerb(verb)
|
||||
@@ -147,6 +158,19 @@ func readArguments(verb string, args map[string]any) (*verbArguments, error) {
|
||||
return nil, fmt.Errorf("%s: %q is text, not true or false", verb, k)
|
||||
}
|
||||
value = fmt.Sprint(x)
|
||||
case []any:
|
||||
if !isList(v, k) {
|
||||
return nil, fmt.Errorf("%s: %q is text, and was given a list", verb, k)
|
||||
}
|
||||
items := make([]string, 0, len(x))
|
||||
for _, item := range x {
|
||||
text, ok := item.(string)
|
||||
if !ok || strings.TrimSpace(text) == "" || strings.Contains(text, ",") {
|
||||
return nil, fmt.Errorf("%s: %q is a list of names, and holds %v", verb, k, item)
|
||||
}
|
||||
items = append(items, strings.TrimSpace(text))
|
||||
}
|
||||
value = strings.Join(items, ",")
|
||||
default:
|
||||
return nil, fmt.Errorf("%s: %q is text, and was given %T", verb, k, x)
|
||||
}
|
||||
@@ -249,9 +273,14 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
||||
// settings verb is where what a verb may not set is refused, and one route is one set of words.
|
||||
// The command refuses places and accesses through any verb as well; this says so before it runs.
|
||||
if argv[0] == "settings" && slices.ContainsFunc(argv[1:], func(w string) bool { return w == "set" || w == "clear" }) {
|
||||
return nil, errors.New("settings are set and cleared through the settings verb, not the generic " +
|
||||
"command; and places and accesses only at the controller's terminal (novox/hq issue 339). " +
|
||||
"Nothing was done")
|
||||
return nil, &heldAtTheTerminal{msg: "settings are set and cleared through the settings verb, not the " +
|
||||
"generic command; and places and accesses only at the controller's terminal (novox/hq issue 339). " +
|
||||
"Nothing was done"}
|
||||
}
|
||||
// The generic verb only reads (novox/hq ADR 0266): what writes has a named verb that composes its own
|
||||
// line, or is the operator's at the controller's terminal.
|
||||
if err := commandReads(argv); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The generic verb is no way round the hand-act log (novox/hq to-be 45 §7): a repair through
|
||||
// it says why, as it would through its own verb.
|
||||
@@ -264,6 +293,8 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
||||
return nil, errors.New("tools is answered from the records, not by a command")
|
||||
case "dead-letters":
|
||||
return nil, errors.New("dead-letters is answered by the serving controller, on its own connection, not by a command")
|
||||
case "root-free":
|
||||
return nil, errors.New("root-free is judged by the serving controller, on its own connection, not by a command")
|
||||
case "status":
|
||||
return []string{"status", "--json"}, nil
|
||||
case "nodes":
|
||||
@@ -1054,6 +1085,9 @@ func seatToolHandlers() (map[string]link.ToolHandler, []string, error) {
|
||||
if verb == "dead-letters" {
|
||||
return deadLettersAnswer(ctx, a)
|
||||
}
|
||||
if verb == "root-free" {
|
||||
return rootFreeAnswer(ctx, a.given["machines"], rootClock())
|
||||
}
|
||||
if verb == "doctor" {
|
||||
// From the serving controller, which runs the self-check and hears the signals
|
||||
// (novox/hq to-be 45 §4): the last verdict at once, or a run now.
|
||||
@@ -1071,7 +1105,8 @@ func seatToolHandlers() (map[string]link.ToolHandler, []string, error) {
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, err := argvFor(verb, sampleArguments(v)); err != nil {
|
||||
var policy *heldAtTheTerminal
|
||||
if _, err := argvFor(verb, sampleArguments(v)); err != nil && !errors.As(err, &policy) {
|
||||
// **A row ahead of this binary is not a reason to go silent.**
|
||||
//
|
||||
// The row is the store's and a control plane follows it (novox/hq ADR 0154), so a verb
|
||||
@@ -1143,7 +1178,10 @@ func actsOnAPlan(args map[string]any) bool {
|
||||
var inProcess = map[string]bool{"tools": true, "calls": true, "doctor": true,
|
||||
// What a consumer gave up on, read and changed on the serving controller's own connection (novox/hq
|
||||
// issue 330).
|
||||
"dead-letters": true}
|
||||
"dead-letters": true,
|
||||
// Whether a machine is root-free, judged live on the serving controller's store and connection (novox/hq ADR
|
||||
// 0259 §8): the router asks it before an approval.
|
||||
"root-free": true}
|
||||
|
||||
// answersFirst is a command line whose caller is answered before it runs: a push, by its verb or
|
||||
// through `command`. A push sends the machine holding the bus first when its user list changed, the
|
||||
@@ -1332,3 +1370,131 @@ func seatAnnouncement(handlers map[string]link.ToolHandler) micro.Info {
|
||||
Endpoints: endpoints,
|
||||
}
|
||||
}
|
||||
|
||||
// nodeReads are the `node` subcommands a verb may run: the ones that only read.
|
||||
var nodeReads = map[string]bool{"list": true, "show": true}
|
||||
|
||||
// flagsOnly says a command line's rest names no subcommand: empty, or beginning with a flag. For a command
|
||||
// with no subcommands every word is a flag, its value or a name it reads.
|
||||
func flagsOnly(rest []string) bool { return len(rest) == 0 || strings.HasPrefix(rest[0], "-") }
|
||||
|
||||
// subIn says the rest begins with one of these subcommands.
|
||||
func subIn(rest []string, subs ...string) bool {
|
||||
return len(rest) > 0 && slices.Contains(subs, rest[0])
|
||||
}
|
||||
|
||||
// commandReadForms are the command lines the generic `command` verb may run (novox/hq ADR 0266): **an allow
|
||||
// list of the ones that only read**, judged command by command. Anything else — every command that writes a
|
||||
// record, sends, builds, issues an account or a token, sets a key, accepts, rotates, recovers or exports a
|
||||
// secret — is refused, and a command added later is refused until it is judged a read. Writing has its named
|
||||
// verbs, which compose their own lines and are judged by terminalOnly; the rest is the operator's at the
|
||||
// controller's terminal.
|
||||
var commandReadForms = map[string]func(rest []string) bool{
|
||||
"status": flagsOnly, "version": flagsOnly, "help": flagsOnly, "seats": flagsOnly, "healers": flagsOnly,
|
||||
"hand-acts": flagsOnly, "durations": flagsOnly, "collection": flagsOnly, "images": flagsOnly,
|
||||
"artifacts": flagsOnly, "data": flagsOnly, "builds": flagsOnly, "queue": flagsOnly,
|
||||
// `plan <node>` previews a node's declaration; it sends nothing.
|
||||
"plan": func([]string) bool { return true },
|
||||
// `plans` lists and `plans <id>` shows one; `plans stop|close|go` acts.
|
||||
// Judged on every word, not the first: a flag before the subcommand (`plans --json go <id>`) still acts.
|
||||
"plans": func(r []string) bool {
|
||||
return !slices.ContainsFunc(r, func(w string) bool { return slices.Contains(plansActs, w) })
|
||||
},
|
||||
// `doctor` answers the last run, `probes` and `signals` describe; `doctor run` runs.
|
||||
"doctor": func(r []string) bool { return flagsOnly(r) || subIn(r, "probes", "signals") },
|
||||
"conditions": func(r []string) bool { return flagsOnly(r) || subIn(r, "list", "show", "history") },
|
||||
"node": func(r []string) bool { return subIn(r, "list", "show") },
|
||||
"module": func(r []string) bool { return subIn(r, "list") },
|
||||
"settings": func(r []string) bool { return subIn(r, "show", "preferences") },
|
||||
"retire": func(r []string) bool { return subIn(r, "list") },
|
||||
"cleanup": func(r []string) bool { return subIn(r, "list") },
|
||||
"delivery": func(r []string) bool { return subIn(r, "plan", "walks") },
|
||||
// `bus` alone says the bus's step; `bus upgrade` takes one.
|
||||
"bus": func(r []string) bool { return len(r) == 0 },
|
||||
// `mirrors` lists; --record and --confirm keep a mirror.
|
||||
"mirrors": func(r []string) bool {
|
||||
return flagsOnly(r) && !slices.ContainsFunc(r, func(w string) bool {
|
||||
return w == "--record" || w == "-record" || strings.HasPrefix(w, "--record=") || strings.HasPrefix(w, "-record=") ||
|
||||
w == "--confirm" || w == "-confirm" || strings.HasPrefix(w, "--confirm=")
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// plansActs are the `plans` subcommands that act on a walk; no other word of a plans line is one of them.
|
||||
var plansActs = []string{"go", "stop", "close", "retry"}
|
||||
|
||||
// heldAtTheTerminal is a refusal of policy (novox/hq ADR 0266): the verb is known and served, and this line is
|
||||
// the operator's at the controller's terminal. Never read as a verb this binary is behind on.
|
||||
type heldAtTheTerminal struct{ msg string }
|
||||
|
||||
func (e *heldAtTheTerminal) Error() string { return e.msg }
|
||||
|
||||
func terminalRefusal(format string, args ...any) error {
|
||||
return &heldAtTheTerminal{fmt.Sprintf(format, args...)}
|
||||
}
|
||||
|
||||
// commandReads refuses a line the generic verb may not run, saying what it may.
|
||||
func commandReads(argv []string) error {
|
||||
if read, ok := commandReadForms[argv[0]]; ok && read(argv[1:]) {
|
||||
return nil
|
||||
}
|
||||
return terminalRefusal("%q is not a reading command, and the generic command verb only reads (novox/hq ADR 0266): "+
|
||||
"whoever may call a verb includes agents, and a line that writes, issues, sets a key or reveals a secret "+
|
||||
"would be theirs to run. Use the named verb for it, or run it at the controller's terminal. The verb may "+
|
||||
"run: %s. Nothing was done", strings.Join(argv, " "), commandReadNames())
|
||||
}
|
||||
|
||||
func commandReadNames() string {
|
||||
names := make([]string, 0, len(commandReadForms))
|
||||
for n := range commandReadForms {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return strings.Join(names, ", ") + " (each in its reading forms)"
|
||||
}
|
||||
|
||||
// terminalOnlyCommands are the commands no verb runs, whatever composed them (novox/hq ADR 0266): they set
|
||||
// the operator's key, issue a credential or a token that is answered to the caller, or accept, recover or
|
||||
// export a secret. Their answers or effects hand whoever calls them what the runtime's account holds.
|
||||
var terminalOnlyCommands = map[string]string{
|
||||
"operator": "the operator's key and credential",
|
||||
"identity": "the mesh's identity keys",
|
||||
"token": "a token a machine joins with, answered to the caller",
|
||||
"broker": "the bus's accounts",
|
||||
"api": "the controller's API keys",
|
||||
"licence": "the licences' secrets",
|
||||
}
|
||||
|
||||
// terminalOnly refuses, through any verb, a command that is the operator's at the controller's terminal
|
||||
// alone (novox/hq ADR 0266). **Every `node` subcommand that is not a read**: `node account` and
|
||||
// `node agent-account` above all. Whoever may call a verb includes agents, and an agent that named itself
|
||||
// the operator account, or cleared the agent account, would have the next send grant it root through the
|
||||
// sudo module's rule. An allow list, so a subcommand added later is refused until it is judged a read.
|
||||
func terminalOnly(argv []string) error {
|
||||
if len(argv) == 0 {
|
||||
return nil
|
||||
}
|
||||
if what, kept := terminalOnlyCommands[argv[0]]; kept {
|
||||
return terminalRefusal("%s is run at the controller's terminal only, never through a verb: it holds %s, and "+
|
||||
"whoever may call a verb includes agents (novox/hq ADR 0266). Nothing was done", argv[0], what)
|
||||
}
|
||||
// Of a secret's commands only rotation, which seals the new value to the machine that uses it.
|
||||
if argv[0] == "secret" && (len(argv) < 2 || argv[1] != "rotate") {
|
||||
return terminalRefusal("secret %s is run at the controller's terminal only, never through a verb: accepting, "+
|
||||
"recovering or exporting a secret hands it to whoever asks, and that includes agents (novox/hq ADR "+
|
||||
"0266). Nothing was done", strings.Join(argv[1:], " "))
|
||||
}
|
||||
if argv[0] != "node" {
|
||||
return nil
|
||||
}
|
||||
if len(argv) > 1 && nodeReads[argv[1]] {
|
||||
return nil
|
||||
}
|
||||
sub := "node"
|
||||
if len(argv) > 1 {
|
||||
sub += " " + argv[1]
|
||||
}
|
||||
return terminalRefusal("%s is run at the controller's terminal only, never through a verb: a node's accounts "+
|
||||
"decide who may become root on it (novox/hq ADR 0266). A verb may run node list and node show. "+
|
||||
"Nothing was done", sub)
|
||||
}
|
||||
|
||||
@@ -271,7 +271,6 @@ var accountedFlags = map[string]map[string]string{
|
||||
"builds": {"n": "=limit"},
|
||||
"plans": {"n": "=limit", "what-if": "=repository"},
|
||||
// The machine's tunnel key, named as the verb's other arguments are (novox/hq ADR 0169).
|
||||
"token issue": {"overlay-key": "=overlay_key"},
|
||||
"durations": {
|
||||
"json": "set by the verb: the answer is data",
|
||||
"all": "withheld: every measurement of a fortnight is more than a call should carry; `command` reaches it",
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
"strings"
|
||||
@@ -108,11 +109,14 @@ func TestRotateTakesAProvisionOrAnOwnSecret(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// `token` is `token issue` at a shell, with the machine's tunnel key (novox/hq ADR 0169).
|
||||
func TestTokenIssuesForAMachineAndItsTunnelKey(t *testing.T) {
|
||||
argv, err := argvFor("token", map[string]any{"new": "laptop", "overlay_key": "k", "for": "2h"})
|
||||
if err != nil || strings.Join(argv, " ") != "token issue --new laptop --overlay-key k --for 2h" {
|
||||
t.Fatalf("token: %v %v", argv, err)
|
||||
// `token` answers a joining token to its caller, and whoever may call a verb includes agents: it is the
|
||||
// controller's terminal's alone (novox/hq ADR 0266), refused through the verb whatever it is given.
|
||||
func TestTokenIsRefusedThroughAVerb(t *testing.T) {
|
||||
for _, args := range []map[string]any{{"new": "laptop", "overlay_key": "k", "for": "2h"}, {"node": "ace"}} {
|
||||
argv, err := argvFor("token", args)
|
||||
if err == nil || !strings.Contains(err.Error(), "controller's terminal only") {
|
||||
t.Fatalf("token %v: %v %v", args, argv, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,20 +241,20 @@ func TestAJSONVerbsAnswerIsItsStandardOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// `command` is the generic verb: the command line as given, split as a shell would, nothing added —
|
||||
// so an operator's `node account g14 jochen` is one call through the console rather than a shell on
|
||||
// the control node (novox/hq ADR 0154, ADR 0175).
|
||||
// `command` is the generic verb: the command line as given, split as a shell would, nothing added
|
||||
// (novox/hq ADR 0154, ADR 0175). It once carried an operator's `node account g14 jochen` too; a node's
|
||||
// accounts are the controller's terminal's alone since ADR 0266 (TestNoVerbSetsANodesAccounts).
|
||||
func TestCommandRunsTheLineAsGiven(t *testing.T) {
|
||||
argv, err := argvFor("command", map[string]any{"command": "node account g14 jochen"})
|
||||
if err != nil || strings.Join(argv, " ") != "node account g14 jochen" {
|
||||
argv, err := argvFor("command", map[string]any{"command": "node show g14"})
|
||||
if err != nil || strings.Join(argv, " ") != "node show g14" {
|
||||
t.Fatalf("a plain line: %v %v", argv, err)
|
||||
}
|
||||
argv, err = argvFor("command", map[string]any{"command": `settings show dnsmasq '{"a": "b c"}' --node ace`})
|
||||
if err != nil || len(argv) != 6 || argv[3] != `{"a": "b c"}` {
|
||||
t.Fatalf("a quoted word stays one word: %q %v", argv, err)
|
||||
}
|
||||
argv, err = argvFor("command", map[string]any{"command": `node add "the box" --adopted`})
|
||||
if err != nil || len(argv) != 4 || argv[2] != "the box" {
|
||||
argv, err = argvFor("command", map[string]any{"command": `plan "the box" --json`})
|
||||
if err != nil || len(argv) != 3 || argv[1] != "the box" {
|
||||
t.Fatalf("double quotes group: %q %v", argv, err)
|
||||
}
|
||||
if _, err := argvFor("command", map[string]any{"command": " "}); err == nil {
|
||||
@@ -355,3 +359,59 @@ func TestTheControllerAnnouncesTheVerbsItServes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The generic verb only reads (novox/hq ADR 0266): an allow list of reading forms, and every line that
|
||||
// writes, issues, sets a key or reveals a secret refused — the chain a review found ran through it: set the
|
||||
// operator's key to one the caller holds, rotate secrets sealed to it, open them.
|
||||
func TestTheCommandVerbOnlyReads(t *testing.T) {
|
||||
for _, line := range []string{
|
||||
"operator key set --replace k", "operator issue", "secret accept a b", "secret rotate a b c",
|
||||
"secret recover a", "secret export a", "token issue --new x", "identity show", "broker users",
|
||||
"api key", "licence show", "push anchor --why w", "assign novox m", "settings set m {}",
|
||||
"settings clear m", "module add f", "module check /etc", "module forget m", "module issue m --node a",
|
||||
"seat rename a b", "plans close p --why w", "plans go p", "plans retry p", "plans stop p",
|
||||
"plans --json go p", "plans -n 3 close p", "plans --what-if r retry p", "doctor run", "conditions silence c --why w",
|
||||
"retire approve x", "cleanup delete x", "delivery check", "delivery go x", "bus upgrade",
|
||||
"mirrors --record x", "mirrors --confirm", "hand-act record x --why y --cause z", "serve", "migrate",
|
||||
"prepare", "declare x", "overlay x", "facts", "merge-gate", "check-here", "build x", "rebuild x",
|
||||
"cancel x", "kill x", "clear x", "replay x", "pause", "resume", "pin a b", "unpin a", "take x",
|
||||
"converge", "adopt x", "rollout x", "upgrade x", "collect", "board", "builder", "ask x", "frobnicate",
|
||||
} {
|
||||
argv, err := argvFor("command", map[string]any{"command": line})
|
||||
var policy *heldAtTheTerminal
|
||||
if err == nil || !errors.As(err, &policy) {
|
||||
t.Errorf("%q ran as %v (%v); the generic verb only reads", line, argv, err)
|
||||
}
|
||||
}
|
||||
for _, line := range []string{
|
||||
"status --json", "version", "seats --json", "healers", "hand-acts --days 3", "durations", "collection",
|
||||
"images", "artifacts --collected", "data --machine a", "builds --log b", "queue", "plan ace --diff",
|
||||
"plans", "plans plan-1", "doctor", "doctor probes", "doctor signals", "conditions", "conditions list",
|
||||
"conditions show c", "conditions history", "node list", "node show ace", "module list",
|
||||
"settings show m", "settings preferences", "retire list", "cleanup list", "delivery plan --repository r",
|
||||
"delivery walks", "bus", "mirrors --json",
|
||||
} {
|
||||
if _, err := argvFor("command", map[string]any{"command": line}); err != nil {
|
||||
t.Errorf("%q, a read, was refused: %v", line, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What hands a caller a key, a credential or a secret is refused whichever verb composed it.
|
||||
func TestNoVerbSetsTheOperatorsKeyOrRevealsASecret(t *testing.T) {
|
||||
for _, argv := range [][]string{
|
||||
{"operator", "key", "set"}, {"operator", "issue"}, {"identity"}, {"token", "issue"}, {"broker", "users"},
|
||||
{"api"}, {"licence"}, {"secret", "export", "x"}, {"secret", "recover", "x"}, {"secret", "accept", "x"}, {"secret"},
|
||||
} {
|
||||
if err := terminalOnly(argv); err == nil {
|
||||
t.Errorf("%v passed", argv)
|
||||
}
|
||||
}
|
||||
if err := terminalOnly([]string{"secret", "rotate", "n", "m", "s"}); err != nil {
|
||||
t.Errorf("rotating seals to the machine that uses the secret, and stays a verb's: %v", err)
|
||||
}
|
||||
// A policy refusal is not a verb this binary is behind on: every verb is still served.
|
||||
if _, behind, err := seatToolHandlers(); err != nil || len(behind) != 0 {
|
||||
t.Fatalf("behind %v: %v", behind, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,4 +35,4 @@ require (
|
||||
// committed. Every build (the build agent's `go build`, the Dockerfile) compiles from vendor/ and
|
||||
// fetches nothing; go refuses to build when vendor/ and this file disagree, so a pin moved without
|
||||
// `go mod vendor` fails loudly, at once, everywhere.
|
||||
replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261007162834-56e2ebec4bac
|
||||
replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261009081005-b28d7bbcbff4
|
||||
|
||||
@@ -4,6 +4,8 @@ git.novox.be/novox/mesh-host v0.0.0-20261007120832-bdd44154ccac h1:KvnKtJ2rWeIE/
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261007120832-bdd44154ccac/go.mod h1:VlilMCRZ5yyNXg7SNigNBLr0Gt32jrGw5KSNq5JAVYs=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261007162834-56e2ebec4bac h1:yLtFS0pDCCqIE9Zx8hgXEFG9fUWzf8L9WQoKV+Amk1E=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261007162834-56e2ebec4bac/go.mod h1:VlilMCRZ5yyNXg7SNigNBLr0Gt32jrGw5KSNq5JAVYs=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261009081005-b28d7bbcbff4 h1:f4rBnKSemuN0Z9dTtRJMigIGfEs6ltFPOILJGHGab74=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261009081005-b28d7bbcbff4/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
|
||||
@@ -126,7 +126,9 @@ func ConsumerFor(p Principal) (Consumer, bool) {
|
||||
// A module that reacts to anything — a module's events or a role's (novox/hq ADR 0121). Watching
|
||||
// a role was missing here, so the one module that does it got no consumer at all: it started,
|
||||
// connected, and its graph stayed empty with nothing anywhere reporting why.
|
||||
if p.Kind != KindModule || (len(p.Consumes) == 0 && len(p.Watches) == 0) {
|
||||
// And one that hears its own answers on a seat it uses (novox/hq ADR 0259 §3): an asker's warrants.
|
||||
hearsItsOwn := len(SeatTrafficOf(p.Module, nil, p.Uses, nil).Subscribe) > 0
|
||||
if p.Kind != KindModule || (len(p.Consumes) == 0 && len(p.Watches) == 0 && !hearsItsOwn) {
|
||||
return Consumer{}, false
|
||||
}
|
||||
perms, err := PermissionsFor(p)
|
||||
|
||||
@@ -2,6 +2,7 @@ package broker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
@@ -50,6 +51,12 @@ type Membership struct {
|
||||
// and refuses, with the reason, what is not on it — the bus enforces only the union over every
|
||||
// module on the machine.
|
||||
State []StateIssued `json:"state,omitempty"`
|
||||
// SeatTraffic is what this module's code may submit, say, hear, take, ask, answer and read on seats
|
||||
// that name their caller or their kind (novox/hq ADR 0259 §3). The runtime carrying the module
|
||||
// publishes, takes and answers for it only what is listed here: the bus enforces only the union
|
||||
// over every module on the machine, so one module's code reaching another's name or kind through
|
||||
// the runtime is the runtime's to refuse.
|
||||
SeatTraffic *SeatTraffic `json:"seat-traffic,omitempty"`
|
||||
}
|
||||
|
||||
// Served is one address a tool is answered on.
|
||||
@@ -78,6 +85,8 @@ type Placements struct {
|
||||
// Interchangeable is each module whose definition says its instances are the same anywhere,
|
||||
// so the module's plain subject is issued to all of them in one queue.
|
||||
Interchangeable map[string]bool
|
||||
// Kinds is every kind held of a kinded bench, by whom and with what capabilities (ADR 0259 §5).
|
||||
Kinds []KindHeld
|
||||
}
|
||||
|
||||
// AnswersForTheModule says whether an instance of a module on one machine is issued the module's
|
||||
@@ -109,6 +118,20 @@ func MembershipFor(node string, d Declared, where Placements) Membership {
|
||||
}
|
||||
}
|
||||
m.State = stateIssuedFor(d, node)
|
||||
t := SeatTrafficOf(d.Module, d.Holds, d.Uses, d.Watches)
|
||||
for _, s := range append(append([]Seat{}, d.Uses...), d.Watches...) {
|
||||
if !s.Kinded {
|
||||
continue
|
||||
}
|
||||
for _, k := range where.Kinds {
|
||||
if k.Seat == s.Name && !kindListed(t.Kinds, k) {
|
||||
t.Kinds = append(t.Kinds, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(t.Publish)+len(t.Subscribe)+len(t.Answers)+len(t.Workers)+len(t.Records)+len(t.Kinds) > 0 {
|
||||
m.SeatTraffic = &t
|
||||
}
|
||||
if len(d.Invokes) > 0 {
|
||||
m.Reaches = map[string][]string{}
|
||||
for _, t := range d.Invokes {
|
||||
@@ -145,5 +168,67 @@ func PlacementsOf(r Records, interchangeable map[string]bool) Placements {
|
||||
for _, nodes := range p.Nodes {
|
||||
sort.Strings(nodes)
|
||||
}
|
||||
// Where the router runs: a verified sender is believed only while its machine is root-free too. A router
|
||||
// placed nowhere, or on more than one machine, frees nothing.
|
||||
var routerNodes []string
|
||||
for node, declared := range r.Assigned {
|
||||
for _, d := range declared {
|
||||
for _, s := range d.Holds {
|
||||
if s.Name == routerSeat && !slices.Contains(routerNodes, node) {
|
||||
routerNodes = append(routerNodes, node)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
routerFree := len(routerNodes) == 1 && r.RootFree[routerNodes[0]]
|
||||
for node, declared := range r.Assigned {
|
||||
for _, d := range declared {
|
||||
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: placedCapabilities(s.Capabilities, d.RunsAs, routerFree && r.RootFree[node])})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(p.Kinds, func(i, j int) bool {
|
||||
a, b := p.Kinds[i], p.Kinds[j]
|
||||
if a.Seat != b.Seat {
|
||||
return a.Seat < b.Seat
|
||||
}
|
||||
if a.Kind != b.Kind {
|
||||
return a.Kind < b.Kind
|
||||
}
|
||||
return a.Node < b.Node
|
||||
})
|
||||
return p
|
||||
}
|
||||
|
||||
// routerSeat is the seat the router of asks holds (novox/hq ADR 0259 §3).
|
||||
const routerSeat = "operator-channel"
|
||||
|
||||
// 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 — and only while
|
||||
// its machine and the router's were root-free when composed (rootFree; the review of 2026-10-09, H3). The
|
||||
// membership carrying it is composed at a push, so it can outlive a pass that later fails: the router asks
|
||||
// the controller's root-free verb again before it honours an approval, and that is the check that holds.
|
||||
func placedCapabilities(declared []string, runsAs string, rootFree bool) []string {
|
||||
var out []string
|
||||
for _, c := range declared {
|
||||
if c == "verified-sender" && (runsAs == "" || !rootFree) {
|
||||
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 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
+53
-1
@@ -63,6 +63,23 @@ type Seat struct {
|
||||
Emits []string
|
||||
Serves []string
|
||||
Versions []string // protocol versions served beside the current one; empty for v1 only
|
||||
|
||||
// Kinded says the seat is a kinded bench (novox/hq ADR 0234 §2, ADR 0259 §3): each holder claims one
|
||||
// kind, and its verbs' subjects carry it. Kind is the kind this principal's claim names, for a seat it
|
||||
// holds.
|
||||
Kinded bool
|
||||
Kind string
|
||||
// ByCaller are the accepts and emits whose last token names the calling module (ADR 0259 §3).
|
||||
ByCaller []string
|
||||
// Proofs are the seat's proof verbs: core request and reply, never on a stream (ADR 0259 §3).
|
||||
Proofs []string
|
||||
// Records are the holder's buckets, by their full name, each user reads under its own name.
|
||||
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
|
||||
@@ -530,6 +547,9 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// 2b. Events of a role it watches, under the seat's own namespace. Subscribe only: watching a
|
||||
// role is hearing what it announced, not taking part in it.
|
||||
for _, w := range p.Watches {
|
||||
if w.Kinded {
|
||||
continue // composed by SeatTrafficOf below
|
||||
}
|
||||
for _, e := range w.Emits {
|
||||
sub = append(sub, seatSubject(w, "event", e))
|
||||
}
|
||||
@@ -548,6 +568,13 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
|
||||
// 3. Seats it holds: full participation.
|
||||
for _, s := range p.Holds {
|
||||
if s.isNewTraffic() {
|
||||
// Composed by SeatTrafficOf below, worker and all; only its tools are served here.
|
||||
for _, t := range s.Serves {
|
||||
sub = append(sub, seatToolSubject(s, t, p.Node))
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Taking work from the role's queue: the worker consumer every holder shares (asked
|
||||
// about, pulled from, acknowledged), on the seat's own stream (novox/hq ADR 0190). A
|
||||
// holder pulls — asks the consumer for its next message, answered on its own inbox —
|
||||
@@ -590,7 +617,7 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// seat's inbound subject and watch other modules' traffic, nor publish its outbound
|
||||
// events and lie about outcomes (design 29 §2).
|
||||
for _, s := range p.Uses {
|
||||
for _, a := range s.Accepts {
|
||||
for _, a := range plainVerbs(s, s.Accepts) {
|
||||
pub = append(pub, seatSubject(s, "accept", a))
|
||||
}
|
||||
for _, t := range s.Serves {
|
||||
@@ -603,6 +630,12 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
pub = append(pub, stateGrants(stateAccess{Module: p.Module, Node: p.Node, Keeps: p.State,
|
||||
PerMachine: p.PerMachine, Reads: p.Reads, KeyedReads: p.KeyedReads})...)
|
||||
|
||||
// 6. Its traffic on seats that name their caller or their kind, ask proofs or keep records
|
||||
// (novox/hq ADR 0259 §3).
|
||||
tp, ts := SeatTrafficOf(p.Module, p.Holds, p.Uses, p.Watches).grants()
|
||||
pub = append(pub, tp...)
|
||||
sub = append(sub, ts...)
|
||||
|
||||
case KindNodeTools:
|
||||
// **One process serves what every module on the machine would have served for itself**
|
||||
// (novox/hq ADR 0175). Each carried module's whole tool namespace — the same grant that
|
||||
@@ -680,6 +713,25 @@ 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
|
||||
// traffic each module's membership lists.
|
||||
for _, d := range p.Carries {
|
||||
tp, ts := SeatTrafficOf(d.Module, d.Holds, d.Uses, d.Watches).grants()
|
||||
pub = append(pub, tp...)
|
||||
sub = append(sub, ts...)
|
||||
}
|
||||
sub = unique(sub)
|
||||
pub = unique(pub)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
package broker
|
||||
|
||||
import "sort"
|
||||
|
||||
// What a module may say and take on the seats it holds, uses and watches, beyond tools (novox/hq ADR
|
||||
// 0259 §3, to-be 46 §10).
|
||||
//
|
||||
// Three rules are added to the ones a seat always had, each a subject whose last token names who may
|
||||
// publish it, granted to that publisher alone — the way a node seat's event about a machine carries the
|
||||
// machine (ADR 0219):
|
||||
//
|
||||
// - **A verb named by its caller** (`by-caller`). A user of the seat submits that accept, and hears that
|
||||
// event, under its own module's name and no other: `accept.ask.<module>`, `event.decided.<module>`. The
|
||||
// holder takes every caller's accept and says the event to any caller. So an ask's asker is a fact the
|
||||
// server enforces, and a warrant reaches only the asker it is for.
|
||||
// - **A kinded bench** (ADR 0234 §2). A holder claims one kind and takes its own kind's accepts, says its
|
||||
// own kind's events and asks its own kind's proofs, and nothing of another kind; a user submits to any
|
||||
// kind. Each kind has its own worker on the seat's queue, so a holder that is away keeps its work and
|
||||
// holds up no other kind.
|
||||
// - **A proof** (`proofs`): core request and reply on `mesh.seat.<seat>.proof.<verb>.<kind>`. No stream's
|
||||
// subjects cover it, so what travels there — a code typed by the operator — is never persisted. A kinded
|
||||
// holder asks with its own kind; the modules that watch the seat answer.
|
||||
//
|
||||
// And one read: **a holder's records**, a bucket the seat names, read by each user under its own name only
|
||||
// (`$KV.<bucket>.<module>.>`), so an asker reads the state of its own asks and no other asker's.
|
||||
|
||||
// Worker is one durable consumer a holder pulls a seat's work from.
|
||||
type Worker struct {
|
||||
Stream string
|
||||
Consumer string
|
||||
Filter string
|
||||
}
|
||||
|
||||
// SeatTraffic is one module's seat traffic beyond tools. Publish and Subscribe are subject patterns, in the
|
||||
// server's wildcards; the runtime that carries the module checks a bundle's request against them, since
|
||||
// the runtime's own principal holds the union of every module it carries.
|
||||
type SeatTraffic struct {
|
||||
// Publish is what it submits (accepts of seats it uses), says (events of seats it holds) and asks
|
||||
// (proofs of seats it holds a kind of).
|
||||
Publish []string `json:"publish,omitempty"`
|
||||
// Subscribe is what it hears: events of seats it uses that are named by caller, events of seats it
|
||||
// watches, and accepts of seats it holds.
|
||||
Subscribe []string `json:"subscribe,omitempty"`
|
||||
// Answers is the proof subjects it answers, as a watcher of a kinded seat.
|
||||
Answers []string `json:"answers,omitempty"`
|
||||
// Workers are the work queues it takes from, as a holder.
|
||||
Workers []Worker `json:"workers,omitempty"`
|
||||
// Records is the direct-get subjects of the records it reads under its own name.
|
||||
Records []string `json:"records,omitempty"`
|
||||
// Kinds are the holders of every kinded bench it uses or watches, with what each promises: the one
|
||||
// account of which channel is which, and what it can carry, that the router judges an answer by. The
|
||||
// controller's, from the claims, never a channel's word (novox/hq ADR 0259 §5).
|
||||
Kinds []KindHeld `json:"kinds,omitempty"`
|
||||
}
|
||||
|
||||
// KindHeld is one kind of a kinded bench and who holds it.
|
||||
type KindHeld struct {
|
||||
Seat string `json:"seat"`
|
||||
Kind string `json:"kind"`
|
||||
Module string `json:"module"`
|
||||
Node string `json:"node"`
|
||||
Capabilities []string `json:"capabilities,omitempty"`
|
||||
}
|
||||
|
||||
// KindedBenches are the seats that may be kinded (ADR 0234 §2): making another is a decision, recorded.
|
||||
var KindedBenches = map[string]bool{"channel": true, "intake": true}
|
||||
|
||||
// WorkerName is the worker a seat's holders pull from: one for the seat, or one per kind on a kinded bench.
|
||||
func WorkerName(seat, kind string) string {
|
||||
if kind == "" {
|
||||
return "SEAT_" + upperSnake(seat) + "_worker"
|
||||
}
|
||||
return "SEAT_" + upperSnake(seat) + "_" + upperSnake(kind) + "_worker"
|
||||
}
|
||||
|
||||
func namesVerb(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SeatTrafficOf derives one module's seat traffic from the seats it holds, uses and watches. Only seats
|
||||
// carrying one of the rules above are read: every other seat is composed as it always was.
|
||||
func SeatTrafficOf(module string, holds, uses, watches []Seat) SeatTraffic {
|
||||
var t SeatTraffic
|
||||
for _, s := range holds {
|
||||
if !s.isNewTraffic() {
|
||||
continue
|
||||
}
|
||||
kind := ""
|
||||
if s.Kinded {
|
||||
kind = s.Kind
|
||||
if kind == "" || !safeSubject.MatchString(kind) {
|
||||
// A kinded claim without a usable kind is refused at registration; here it is granted
|
||||
// nothing, which is the same answer at the last place it could be asked.
|
||||
continue
|
||||
}
|
||||
}
|
||||
if len(s.Accepts) > 0 {
|
||||
stream := seatStreamName(s.Name)
|
||||
filter := "mesh.seat." + s.Name + ".accept.>"
|
||||
if kind != "" {
|
||||
filter = "mesh.seat." + s.Name + ".accept.*." + kind
|
||||
}
|
||||
t.Workers = append(t.Workers, Worker{Stream: stream, Consumer: WorkerName(s.Name, kind), Filter: filter})
|
||||
}
|
||||
for _, a := range s.Accepts {
|
||||
switch {
|
||||
case kind != "":
|
||||
t.Subscribe = append(t.Subscribe, seatSubject(s, "accept", a+"."+kind))
|
||||
case namesVerb(s.ByCaller, a):
|
||||
t.Subscribe = append(t.Subscribe, seatSubject(s, "accept", a+".*"))
|
||||
}
|
||||
}
|
||||
for _, e := range s.Emits {
|
||||
switch {
|
||||
case kind != "":
|
||||
t.Publish = append(t.Publish, seatSubject(s, "event", e+"."+kind))
|
||||
case namesVerb(s.ByCaller, e):
|
||||
t.Publish = append(t.Publish, seatSubject(s, "event", e+".*"))
|
||||
}
|
||||
}
|
||||
if kind != "" {
|
||||
for _, v := range s.Proofs {
|
||||
t.Publish = append(t.Publish, seatSubject(s, "proof", v+"."+kind))
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, s := range uses {
|
||||
if !s.isNewTraffic() {
|
||||
continue
|
||||
}
|
||||
for _, a := range s.Accepts {
|
||||
switch {
|
||||
case namesVerb(s.ByCaller, a):
|
||||
t.Publish = append(t.Publish, seatSubject(s, "accept", a+"."+module))
|
||||
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+".*"))
|
||||
}
|
||||
}
|
||||
for _, e := range s.Emits {
|
||||
if namesVerb(s.ByCaller, e) {
|
||||
t.Subscribe = append(t.Subscribe, seatSubject(s, "event", e+"."+module))
|
||||
}
|
||||
}
|
||||
for _, b := range s.Records {
|
||||
if !safeSubject.MatchString(b) {
|
||||
continue
|
||||
}
|
||||
t.Records = append(t.Records, "$JS.API.DIRECT.GET.KV_"+b+".$KV."+b+"."+module+".>")
|
||||
}
|
||||
}
|
||||
for _, w := range watches {
|
||||
if w.Kinded {
|
||||
for _, e := range w.Emits {
|
||||
t.Subscribe = append(t.Subscribe, seatSubject(w, "event", e+".*"))
|
||||
}
|
||||
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+".*"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Publish = unique(t.Publish)
|
||||
t.Subscribe = unique(t.Subscribe)
|
||||
t.Answers = unique(t.Answers)
|
||||
t.Records = unique(t.Records)
|
||||
sort.Slice(t.Workers, func(i, j int) bool { return t.Workers[i].Consumer < t.Workers[j].Consumer })
|
||||
return t
|
||||
}
|
||||
|
||||
// grants is the bus permissions seat traffic needs: the subjects themselves, and the JetStream API a
|
||||
// worker is pulled and acknowledged through and a record is read through.
|
||||
func (t SeatTraffic) grants() (pub, sub []string) {
|
||||
pub = append(pub, t.Publish...)
|
||||
sub = append(sub, t.Subscribe...)
|
||||
sub = append(sub, t.Answers...)
|
||||
for _, w := range t.Workers {
|
||||
pub = append(pub,
|
||||
"$JS.API.CONSUMER.INFO."+w.Stream+"."+w.Consumer,
|
||||
"$JS.API.CONSUMER.MSG.NEXT."+w.Stream+"."+w.Consumer,
|
||||
"$JS.ACK."+w.Stream+"."+w.Consumer+".>")
|
||||
}
|
||||
pub = append(pub, t.Records...)
|
||||
return pub, sub
|
||||
}
|
||||
|
||||
// isNewTraffic says whether a seat carries any of the rules above, so a seat that carries none is
|
||||
// composed exactly as before them.
|
||||
func (s Seat) isNewTraffic() bool {
|
||||
return s.Kinded || len(s.ByCaller) > 0 || len(s.Proofs) > 0 || len(s.Records) > 0
|
||||
}
|
||||
|
||||
// plainVerbs is a seat's accepts or emits with those the rules above compose taken out: a verb named by
|
||||
// its caller and every verb of a kinded bench are composed by SeatTrafficOf and nowhere else.
|
||||
func plainVerbs(s Seat, verbs []string) []string {
|
||||
if s.Kinded {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, v := range verbs {
|
||||
if !namesVerb(s.ByCaller, v) {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SeatTrafficObjects is the work queues and workers the seat traffic of every composed user implies
|
||||
// (novox/hq ADR 0259 §3): a queue for each seat a holder takes work from, and each holder's worker on it —
|
||||
// one per kind on a kinded bench, filtered to that kind, so the kinds never take each other's work. Only
|
||||
// seats carrying the rules above; the mesh's own seats' queues are RaiseSeats'.
|
||||
func SeatTrafficObjects(users []Principal) ([]Stream, []Consumer) {
|
||||
streams := map[string]Stream{}
|
||||
consumers := map[string]Consumer{}
|
||||
add := func(module string, holds []Seat) {
|
||||
for _, w := range SeatTrafficOf(module, holds, nil, nil).Workers {
|
||||
seat := ""
|
||||
for _, s := range holds {
|
||||
if seatStreamName(s.Name) == w.Stream {
|
||||
seat = s.Name
|
||||
}
|
||||
}
|
||||
streams[w.Stream] = Stream{
|
||||
Name: w.Stream,
|
||||
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",
|
||||
}
|
||||
consumers[w.Consumer] = Consumer{
|
||||
Name: w.Consumer,
|
||||
Stream: w.Stream,
|
||||
Filters: []string{w.Filter},
|
||||
AckWaitSeconds: 60,
|
||||
// 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",
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, p := range users {
|
||||
switch p.Kind {
|
||||
case KindModule:
|
||||
add(p.Module, p.Holds)
|
||||
case KindNodeTools:
|
||||
for _, d := range p.Carries {
|
||||
add(d.Module, d.Holds)
|
||||
}
|
||||
}
|
||||
}
|
||||
var ss []Stream
|
||||
for _, s := range streams {
|
||||
ss = append(ss, s)
|
||||
}
|
||||
sort.Slice(ss, func(i, j int) bool { return ss[i].Name < ss[j].Name })
|
||||
var cs []Consumer
|
||||
for _, c := range consumers {
|
||||
cs = append(cs, c)
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The seats of novox/hq ADR 0259 §3, as the messenger declares them.
|
||||
func operatorChannel() Seat {
|
||||
return Seat{Name: "operator-channel", Scope: "mesh", Accepts: []string{"ask", "cancel"},
|
||||
Emits: []string{"decided"}, Serves: []string{"open", "history", "notify"},
|
||||
ByCaller: []string{"ask", "cancel", "decided"}, Records: []string{"messenger_asks"}}
|
||||
}
|
||||
|
||||
func channelSeat(kind string) Seat {
|
||||
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, DeclaredBy: "messenger"}
|
||||
}
|
||||
|
||||
func allowed(patterns []string, subject string) bool {
|
||||
for _, p := range patterns {
|
||||
if subjectMatches(p, subject) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func perms(t *testing.T, p Principal) Permissions {
|
||||
t.Helper()
|
||||
got, err := PermissionsFor(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func TestAnAskerAsksAndHearsUnderItsOwnNameOnly(t *testing.T) {
|
||||
asker := Principal{Kind: KindModule, Node: "anchor", Module: "mesh-delivery", Uses: []Seat{operatorChannel()}}
|
||||
got := perms(t, asker)
|
||||
for _, s := range []string{
|
||||
"mesh.seat.operator-channel.accept.ask.mesh-delivery",
|
||||
"mesh.seat.operator-channel.accept.cancel.mesh-delivery",
|
||||
"$JS.API.DIRECT.GET.KV_messenger_asks.$KV.messenger_asks.mesh-delivery.a1",
|
||||
} {
|
||||
if !allowed(got.Publish, s) {
|
||||
t.Errorf("an asker may not publish %s", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{
|
||||
"mesh.seat.operator-channel.accept.ask.mesh-controller",
|
||||
"mesh.seat.operator-channel.accept.ask.*",
|
||||
"mesh.seat.operator-channel.event.decided.mesh-delivery",
|
||||
"$JS.API.DIRECT.GET.KV_messenger_asks.$KV.messenger_asks.mesh-controller.a1",
|
||||
"$KV.messenger_asks.mesh-delivery.a1",
|
||||
} {
|
||||
if allowed(got.Publish, s) {
|
||||
t.Errorf("an asker may publish %s, which is not its own to submit", s)
|
||||
}
|
||||
}
|
||||
if !allowed(got.Subscribe, "mesh.seat.operator-channel.event.decided.mesh-delivery") {
|
||||
t.Error("an asker does not hear its own warrants")
|
||||
}
|
||||
if allowed(got.Subscribe, "mesh.seat.operator-channel.event.decided.mesh-controller") {
|
||||
t.Error("an asker hears another asker's warrants")
|
||||
}
|
||||
// And its own consumer carries its warrants, so a restart catches up.
|
||||
c, ok := ConsumerFor(asker)
|
||||
if !ok || !allowed(c.Filters, "mesh.seat.operator-channel.event.decided.mesh-delivery") {
|
||||
t.Errorf("the asker's consumer does not carry its warrants: %v", c.Filters)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyTheHolderPublishesAWarrant(t *testing.T) {
|
||||
users, err := Users(Records{
|
||||
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"}, DeclaredBy: "messenger"}}},
|
||||
{Module: "mesh-delivery", Uses: []Seat{operatorChannel()}},
|
||||
{Module: "telegram", Holds: []Seat{channelSeat("telegram"), intakeSeat("telegram")}},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, u := range users {
|
||||
got := perms(t, u)
|
||||
says := allowed(got.Publish, "mesh.seat.operator-channel.event.decided.mesh-delivery")
|
||||
if says != (u.Module == "messenger") {
|
||||
t.Errorf("%s %s publish a warrant", u.Username(), map[bool]string{true: "may", false: "may not"}[says])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheHolderTakesEveryCallersAskThroughItsWorker(t *testing.T) {
|
||||
got := perms(t, Principal{Kind: KindModule, Node: "anchor", Module: "messenger", Holds: []Seat{operatorChannel()}})
|
||||
if !allowed(got.Subscribe, "mesh.seat.operator-channel.accept.ask.mesh-delivery") {
|
||||
t.Error("the router does not take an ask")
|
||||
}
|
||||
for _, s := range []string{
|
||||
"$JS.API.CONSUMER.MSG.NEXT.SEAT_OPERATOR_CHANNEL.SEAT_OPERATOR_CHANNEL_worker",
|
||||
"$JS.ACK.SEAT_OPERATOR_CHANNEL.SEAT_OPERATOR_CHANNEL_worker.x",
|
||||
"mesh.seat.operator-channel.event.decided.mesh-controller",
|
||||
} {
|
||||
if !allowed(got.Publish, s) {
|
||||
t.Errorf("the router may not publish %s", s)
|
||||
}
|
||||
}
|
||||
if allowed(got.Publish, "mesh.seat.operator-channel.accept.ask.messenger") {
|
||||
t.Error("the holder may ask its own seat without using it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAKindedHolderReachesItsOwnKindAndNoOther(t *testing.T) {
|
||||
got := perms(t, Principal{Kind: KindModule, Node: "anchor", Module: "telegram",
|
||||
Holds: []Seat{channelSeat("telegram"), intakeSeat("telegram")}})
|
||||
for _, s := range []string{
|
||||
"mesh.seat.intake.event.choice.telegram", "mesh.seat.intake.event.link.telegram",
|
||||
"mesh.seat.intake.proof.code.telegram",
|
||||
"$JS.API.CONSUMER.MSG.NEXT.SEAT_CHANNEL.SEAT_CHANNEL_TELEGRAM_worker",
|
||||
} {
|
||||
if !allowed(got.Publish, s) {
|
||||
t.Errorf("telegram may not publish %s", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{
|
||||
"mesh.seat.intake.event.choice.desktop", "mesh.seat.intake.proof.code.desktop",
|
||||
"mesh.seat.channel.accept.show.telegram",
|
||||
"$JS.API.CONSUMER.MSG.NEXT.SEAT_CHANNEL.SEAT_CHANNEL_DESKTOP_worker",
|
||||
"mesh.seat.operator-channel.event.decided.mesh-delivery",
|
||||
} {
|
||||
if allowed(got.Publish, s) {
|
||||
t.Errorf("telegram may publish %s", s)
|
||||
}
|
||||
}
|
||||
if !allowed(got.Subscribe, "mesh.seat.channel.accept.show.telegram") ||
|
||||
allowed(got.Subscribe, "mesh.seat.channel.accept.show.desktop") {
|
||||
t.Error("telegram does not take exactly its own kind's work")
|
||||
}
|
||||
if allowed(got.Subscribe, "mesh.seat.intake.proof.code.telegram") {
|
||||
t.Error("a channel answers its own proofs")
|
||||
}
|
||||
}
|
||||
|
||||
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"}, 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)
|
||||
}
|
||||
}
|
||||
if !allowed(got.Publish, "mesh.seat.channel.accept.show.telegram") {
|
||||
t.Error("the router cannot send a channel its work")
|
||||
}
|
||||
if allowed(got.Publish, "mesh.seat.intake.event.choice.telegram") || allowed(got.Publish, "mesh.seat.intake.proof.code.telegram") {
|
||||
t.Error("the router may say a channel's answer or proof")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoStreamKeepsAProof(t *testing.T) {
|
||||
users, _ := Users(Records{Nodes: []string{"anchor"}, Assigned: map[string][]Declared{"anchor": {
|
||||
{Module: "telegram", Holds: []Seat{channelSeat("telegram"), intakeSeat("telegram")}},
|
||||
{Module: "messenger", Holds: []Seat{operatorChannel()}},
|
||||
}}})
|
||||
streams, _ := SeatTrafficObjects(users)
|
||||
streams = append(streams, MeshStreams()...)
|
||||
for _, s := range streams {
|
||||
for _, subject := range s.Subjects {
|
||||
if subjectMatches(subject, "mesh.seat.intake.proof.code.telegram") {
|
||||
t.Errorf("%s keeps a proof (%s)", s.Name, subject)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEachKindHasAWorkerOfItsOwn(t *testing.T) {
|
||||
users, _ := Users(Records{Nodes: []string{"anchor"}, Assigned: map[string][]Declared{"anchor": {
|
||||
{Module: "telegram", Holds: []Seat{channelSeat("telegram")}},
|
||||
{Module: "desk-channel", Holds: []Seat{channelSeat("desktop")}},
|
||||
{Module: "messenger", Holds: []Seat{operatorChannel()}},
|
||||
}}})
|
||||
streams, workers := SeatTrafficObjects(users)
|
||||
names := map[string]string{}
|
||||
for _, w := range workers {
|
||||
names[w.Name] = strings.Join(w.Filters, ",")
|
||||
}
|
||||
want := map[string]string{
|
||||
"SEAT_CHANNEL_TELEGRAM_worker": "mesh.seat.channel.accept.*.telegram",
|
||||
"SEAT_CHANNEL_DESKTOP_worker": "mesh.seat.channel.accept.*.desktop",
|
||||
"SEAT_OPERATOR_CHANNEL_worker": "mesh.seat.operator-channel.accept.>",
|
||||
}
|
||||
for n, f := range want {
|
||||
if names[n] != f {
|
||||
t.Errorf("worker %s filters %q, want %q", n, names[n], f)
|
||||
}
|
||||
}
|
||||
if len(streams) != 2 {
|
||||
t.Errorf("want the queues of channel and operator-channel, got %v", streams)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRuntimeIsGrantedTheUnionAndTheMembershipEachModulesShare(t *testing.T) {
|
||||
telegram := Declared{Module: "telegram", Holds: []Seat{channelSeat("telegram"), intakeSeat("telegram")}}
|
||||
desk := Declared{Module: "desk-channel", Holds: []Seat{channelSeat("desktop"), intakeSeat("desktop")}}
|
||||
got := perms(t, Principal{Kind: KindNodeTools, Node: "anchor", Module: RuntimeModule, Carries: []Declared{telegram, desk}})
|
||||
for _, s := range []string{"mesh.seat.intake.event.choice.telegram", "mesh.seat.intake.event.choice.desktop"} {
|
||||
if !allowed(got.Publish, s) {
|
||||
t.Errorf("the runtime may not publish %s for a module it carries", s)
|
||||
}
|
||||
}
|
||||
m := MembershipFor("anchor", telegram, Placements{})
|
||||
if m.SeatTraffic == nil || !allowed(m.SeatTraffic.Publish, "mesh.seat.intake.event.choice.telegram") ||
|
||||
allowed(m.SeatTraffic.Publish, "mesh.seat.intake.event.choice.desktop") {
|
||||
t.Errorf("telegram's membership does not list exactly its own kind: %+v", m.SeatTraffic)
|
||||
}
|
||||
if plain := MembershipFor("anchor", Declared{Module: "plain"}, Placements{}); plain.SeatTraffic != nil {
|
||||
t.Error("a module with no such seat is given seat traffic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestASeatWithoutTheNewRulesIsComposedAsBefore(t *testing.T) {
|
||||
old := Seat{Name: "node-build-agent", Scope: "node", Accepts: []string{"build"}, Emits: []string{"built"}}
|
||||
got := perms(t, Principal{Kind: KindModule, Node: "anchor", Module: "builder", Holds: []Seat{old}})
|
||||
for _, s := range []string{"mesh.seat.node-build-agent.event.built",
|
||||
"$JS.API.CONSUMER.MSG.NEXT.SEAT_NODE_BUILD_AGENT.SEAT_NODE_BUILD_AGENT_worker"} {
|
||||
if !allowed(got.Publish, s) {
|
||||
t.Errorf("an old seat's holder lost %s", s)
|
||||
}
|
||||
}
|
||||
if !allowed(got.Subscribe, "mesh.seat.node-build-agent.accept.build") {
|
||||
t.Error("an old seat's holder lost its accept")
|
||||
}
|
||||
}
|
||||
|
||||
// The router learns which channel is which, and what each promises, from the controller's membership:
|
||||
// the claims, never a channel's word (ADR 0259 §5).
|
||||
func TestTheRoutersMembershipNamesEveryKindAndItsCapabilities(t *testing.T) {
|
||||
tg := channelSeat("telegram")
|
||||
tg.Capabilities = []string{"choice", "verified-sender"}
|
||||
desk := channelSeat("desktop")
|
||||
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}, RunsAs: "telegram"}},
|
||||
"laptop": {{Module: "desk-channel", Holds: []Seat{desk}}},
|
||||
}, RootFree: map[string]bool{"anchor": true}}
|
||||
where := PlacementsOf(records, nil)
|
||||
m := MembershipFor("anchor", router, where)
|
||||
if m.SeatTraffic == nil || len(m.SeatTraffic.Kinds) != 2 {
|
||||
t.Fatalf("the router is not told the kinds: %+v", m.SeatTraffic)
|
||||
}
|
||||
byKind := map[string]KindHeld{}
|
||||
for _, k := range m.SeatTraffic.Kinds {
|
||||
byKind[k.Kind] = k
|
||||
}
|
||||
if k := byKind["telegram"]; k.Module != "telegram" || k.Node != "anchor" || !namesVerb(k.Capabilities, "verified-sender") {
|
||||
t.Errorf("telegram is %+v", k)
|
||||
}
|
||||
if k := byKind["desktop"]; k.Module != "desk-channel" || namesVerb(k.Capabilities, "verified-sender") {
|
||||
t.Errorf("the desk is %+v", k)
|
||||
}
|
||||
if other := MembershipFor("anchor", Declared{Module: "mesh-delivery", Uses: []Seat{operatorChannel()}}, where); other.SeatTraffic != nil && len(other.SeatTraffic.Kinds) > 0 {
|
||||
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, on a machine
|
||||
// root-free when composed, with the router's own machine root-free too (the review of 2026-10-09, H3).
|
||||
func TestVerifiedSenderIsBelievedOnlyFromAHolderOfItsOwnAccount(t *testing.T) {
|
||||
if got := placedCapabilities([]string{"choice", "verified-sender"}, "", true); namesVerb(got, "verified-sender") {
|
||||
t.Errorf("a carried holder keeps verified-sender: %v", got)
|
||||
}
|
||||
if got := placedCapabilities([]string{"choice", "verified-sender"}, "telegram", true); !namesVerb(got, "verified-sender") {
|
||||
t.Errorf("a holder of its own account on a root-free machine lost verified-sender: %v", got)
|
||||
}
|
||||
if got := placedCapabilities([]string{"choice", "verified-sender"}, "telegram", false); namesVerb(got, "verified-sender") ||
|
||||
!namesVerb(got, "choice") {
|
||||
t.Errorf("a holder on a machine not root-free keeps verified-sender, or lost the rest: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The kinds the router is told carry verified-sender only while the channel's machine and the router's are
|
||||
// both root-free as composed; no record of a pass is no pass, and neither is a router placed nowhere.
|
||||
func TestVerifiedSenderNeedsTheChannelsAndTheRoutersMachinesRootFree(t *testing.T) {
|
||||
tg := channelSeat("telegram")
|
||||
tg.Capabilities = []string{"choice", "verified-sender"}
|
||||
router := Declared{Module: "messenger", Holds: []Seat{operatorChannel()}, RunsAs: "messenger"}
|
||||
telegram := Declared{Module: "telegram", Holds: []Seat{tg}, RunsAs: "telegram"}
|
||||
verified := func(r Records) bool {
|
||||
for _, k := range PlacementsOf(r, nil).Kinds {
|
||||
if k.Kind == "telegram" {
|
||||
return namesVerb(k.Capabilities, "verified-sender")
|
||||
}
|
||||
}
|
||||
t.Fatal("telegram not placed")
|
||||
return false
|
||||
}
|
||||
same := func(free map[string]bool) Records {
|
||||
return Records{Nodes: []string{"anchor"}, Assigned: map[string][]Declared{"anchor": {router, telegram}}, RootFree: free}
|
||||
}
|
||||
apart := func(free map[string]bool) Records {
|
||||
return Records{Nodes: []string{"anchor", "relay"},
|
||||
Assigned: map[string][]Declared{"anchor": {router}, "relay": {telegram}}, RootFree: free}
|
||||
}
|
||||
if !verified(same(map[string]bool{"anchor": true})) {
|
||||
t.Error("both on one root-free machine: verified-sender withheld")
|
||||
}
|
||||
if verified(same(nil)) {
|
||||
t.Error("no record of a pass, and verified-sender kept")
|
||||
}
|
||||
if verified(apart(map[string]bool{"relay": true})) {
|
||||
t.Error("the router's machine not root-free, and verified-sender kept")
|
||||
}
|
||||
if verified(apart(map[string]bool{"anchor": true})) {
|
||||
t.Error("the channel's machine not root-free, and verified-sender kept")
|
||||
}
|
||||
if !verified(apart(map[string]bool{"anchor": true, "relay": true})) {
|
||||
t.Error("both machines root-free: verified-sender withheld")
|
||||
}
|
||||
noRouter := Records{Nodes: []string{"relay"}, Assigned: map[string][]Declared{"relay": {telegram}},
|
||||
RootFree: map[string]bool{"relay": true}}
|
||||
if verified(noRouter) {
|
||||
t.Error("no router placed, and verified-sender kept")
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,9 @@ type Declared struct {
|
||||
// Checks are the module's own tools its health asks, each `<module>.<tool>` (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.
|
||||
@@ -67,6 +70,10 @@ type Records struct {
|
||||
// Interchangeable is each module whose definition says its instances are the same anywhere
|
||||
// (ADR 0160), which decides whether the module's plain subject is issued to every instance.
|
||||
Interchangeable map[string]bool
|
||||
// RootFree is each machine judged root-free when this was composed (novox/hq ADR 0259 §8): it names an
|
||||
// account agents run as, judged unable to become root by its node-engine, and serves no login shell
|
||||
// execute. A machine absent is not free: no record of a pass is no pass.
|
||||
RootFree map[string]bool
|
||||
}
|
||||
|
||||
// Users is every user the composed file should contain, in the order it will be written.
|
||||
@@ -120,10 +127,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) {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The account agents run as (novox/hq ADR 0266): a module names it as a machine fact — the agent account
|
||||
// where the node names one, the operator's otherwise — and asks the node-engine to judge it never to become
|
||||
// root only where it is the agents' own.
|
||||
|
||||
func TestTheAgentAccountFactFallsBackToTheOperatorAndIsNeverRootOnlyWhenItsOwn(t *testing.T) {
|
||||
facts := machineFacts(Resolution{Node: "anchor", Account: "ops"}, nil, "")
|
||||
if facts["agent-account"] != "ops" || facts["agent-home"] != "/home/ops" || facts["agent-root"] != "" {
|
||||
t.Errorf("with no agent account named, agents run as the operator: %v", facts)
|
||||
}
|
||||
facts = machineFacts(Resolution{Node: "anchor", Account: "ops", AccountHome: "/srv/ops"}, nil, "")
|
||||
if facts["agent-home"] != "/srv/ops" {
|
||||
t.Errorf("the operator's stated home is the agent's home when they are one account: %v", facts)
|
||||
}
|
||||
facts = machineFacts(Resolution{Node: "anchor", Account: "ops", AgentAccount: "agent"}, nil, "")
|
||||
if facts["agent-account"] != "agent" || facts["agent-home"] != "/home/agent" || facts["agent-root"] != RootNever {
|
||||
t.Errorf("a named agent account is the agents', never root: %v", facts)
|
||||
}
|
||||
if facts["account"] != "ops" {
|
||||
t.Errorf("the operator account is still the operator's: %v", facts)
|
||||
}
|
||||
facts = machineFacts(Resolution{Node: "anchor", AgentAccount: "agent", AgentAccountHome: "/var/lib/agent"}, nil, "")
|
||||
if facts["agent-home"] != "/var/lib/agent" || facts["agent-root"] != RootNever {
|
||||
t.Errorf("an agent account with a stated home on a machine with no operator: %v", facts)
|
||||
}
|
||||
if _, has := machineFacts(Resolution{Node: "anchor"}, nil, "")["agent-account"]; has {
|
||||
t.Error("a machine with no account at all names an agent account")
|
||||
}
|
||||
}
|
||||
|
||||
// The agent's module, in the shape the catalogue's declares it: the account, never root where it is its
|
||||
// own; its directory under that home, owned by it.
|
||||
const agentModule = `{"module": "agent", "version": "1", "resources": [
|
||||
{"id": "account", "type": "user", "name": "${machine:agent-account}", "home": "${machine:agent-home}",
|
||||
"root": "${machine:agent-root}"},
|
||||
{"id": "home", "type": "directory", "path": "${machine:agent-home}/.agent", "mode": "0700",
|
||||
"owner": "${machine:agent-account}"}
|
||||
]}`
|
||||
|
||||
func TestTheAgentAccountIsDeclaredNeverRootOnlyToAnEngineThatJudgesIt(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(agentModule))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
compose := func(r Resolution, with Rendering) (user, home map[string]any) {
|
||||
t.Helper()
|
||||
r.Node, r.Modules = "anchor", []Manifest{m}
|
||||
out, err := r.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return fileNamed(out, "agent.account"), fileNamed(out, "agent.home")
|
||||
}
|
||||
|
||||
user, home := compose(Resolution{Account: "ops", AgentAccount: "agent"}, Rendering{JudgesRoot: true})
|
||||
if user["name"] != "agent" || user[RootField] != RootNever || user["home"] != "/home/agent" {
|
||||
t.Errorf("an engine that judges root is sent the agent account never to become root: %v", user)
|
||||
}
|
||||
if home["path"] != "/home/agent/.agent" || home["owner"] != "agent" {
|
||||
t.Errorf("the agent's directory is under its own home, its own: %v", home)
|
||||
}
|
||||
|
||||
user, _ = compose(Resolution{Account: "ops", AgentAccount: "agent", AgentAccountHome: "/srv/agent"}, Rendering{JudgesRoot: true})
|
||||
if user["home"] != "/srv/agent" {
|
||||
t.Errorf("an agent account named with a home of its own is made there: %v", user)
|
||||
}
|
||||
|
||||
user, _ = compose(Resolution{Account: "ops", AgentAccount: "agent"}, Rendering{})
|
||||
if _, sent := user[RootField]; sent || user["name"] != "agent" {
|
||||
t.Errorf("an older engine, which parses strictly, is sent root: %v", user)
|
||||
}
|
||||
|
||||
user, home = compose(Resolution{Account: "ops"}, Rendering{JudgesRoot: true})
|
||||
if _, sent := user[RootField]; sent || user["name"] != "ops" {
|
||||
t.Errorf("where agents run as the operator, root asserts nothing and is not sent: %v", user)
|
||||
}
|
||||
if home["path"] != "/home/ops/.agent" || home["owner"] != "ops" {
|
||||
t.Errorf("with no agent account, the agent's directory is the operator's: %v", home)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRuntimeIsToldTheAgentAccount(t *testing.T) {
|
||||
with := Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}}
|
||||
envOf := func(r Resolution) map[string]string {
|
||||
t.Helper()
|
||||
r.Node, r.Modules = "anchor", []Manifest{aToolsModule(t, "nftables", "tools/index.js"), theRuntime(t)}
|
||||
out, err := r.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
process := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())
|
||||
if process == nil {
|
||||
t.Fatal("no runtime process was composed")
|
||||
}
|
||||
return process["env"].(map[string]string)
|
||||
}
|
||||
env := envOf(Resolution{Account: "ops", AgentAccount: "agent"})
|
||||
if env[RuntimeAgentAccount] != "agent" || env[RuntimeAgentHome] != "/home/agent" || env[RuntimeOperatorAccount] != "ops" {
|
||||
t.Errorf("the runtime is not told whom agents run as: %v", env)
|
||||
}
|
||||
env = envOf(Resolution{Account: "ops"})
|
||||
if env[RuntimeAgentAccount] != "ops" || env[RuntimeAgentHome] != "/home/ops" {
|
||||
t.Errorf("with no agent account, agents run as the operator: %v", env)
|
||||
}
|
||||
env = envOf(Resolution{})
|
||||
if _, set := env[RuntimeAgentAccount]; set {
|
||||
t.Errorf("a machine with no account names an agent account: %v", env)
|
||||
}
|
||||
if problems := bundleEnvProblems("x", Artifact{Name: "b", Kind: ArtifactBundle, Loads: []string{"x"},
|
||||
Env: map[string]string{RuntimeAgentAccount: "me"}}); len(problems) == 0 {
|
||||
t.Error("a bundle may tell the runtime whom agents run as")
|
||||
}
|
||||
}
|
||||
@@ -399,7 +399,7 @@ func versionOf(digest string) string {
|
||||
// telling the runtime what it is, which is the mesh's to say (novox/hq ADR 0192).
|
||||
var bundleEnvWords = map[string]bool{
|
||||
RuntimeToolModules: true, RuntimeBrokerFile: true, RuntimeOperatorAccount: true,
|
||||
RuntimeOperatorHome: true, RuntimeToolEnv: true,
|
||||
RuntimeOperatorHome: true, RuntimeToolEnv: true, RuntimeAgentAccount: true, RuntimeAgentHome: true,
|
||||
}
|
||||
|
||||
// bundleEnvProblems says what is wrong with what a bundle says it is given (novox/hq ADR 0192):
|
||||
|
||||
@@ -241,6 +241,31 @@ type Rendering struct {
|
||||
// refuses a field it does not know, whole — so to it the field is not sent, and what it runs is
|
||||
// judged by liveness alone.
|
||||
ReadsHealth bool
|
||||
|
||||
// JudgesRoot says this machine's node-engine judges a user's declared `root` (novox/hq ADR 0266: its
|
||||
// statement's contract is link.RootContract or later). To an older, strict engine the field is not
|
||||
// sent, and the account it names is not judged — which the self-check says, as not judged.
|
||||
JudgesRoot bool
|
||||
}
|
||||
|
||||
// RootField is a user resource's field saying the account must never become root without a person
|
||||
// (novox/hq ADR 0266).
|
||||
const RootField = "root"
|
||||
|
||||
// rootInto composes a user's `root` for the node-engine: taken away when it asserts nothing (empty — a
|
||||
// machine where agents run as the operator) or when the engine is older than the field and parses
|
||||
// strictly; kept as "never" otherwise.
|
||||
func rootInto(resource map[string]any, with Rendering) {
|
||||
if resource["type"] != "user" {
|
||||
return
|
||||
}
|
||||
value, has := resource[RootField]
|
||||
if !has {
|
||||
return
|
||||
}
|
||||
if s, _ := value.(string); s == "" || !with.JudgesRoot {
|
||||
delete(resource, RootField)
|
||||
}
|
||||
}
|
||||
|
||||
// machinePort is where a module's port lives on this machine, or the port itself when the mesh has
|
||||
@@ -981,6 +1006,9 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
// How it is ready, in the node-engine's words: its endpoint as the port this machine
|
||||
// published it on — or not sent at all to an engine older than the field (ADR 0240).
|
||||
healthInto(copied, m, with)
|
||||
// And a user's `root` (novox/hq ADR 0266): sent only when it asserts something, to an engine
|
||||
// that judges it.
|
||||
rootInto(copied, with)
|
||||
// The account's environment and every module's shell code, where this module holds the
|
||||
// seat that places them (novox/hq ADR 0203, ADR 0204). Gathered from every module on
|
||||
// the node, as the jails are, and **last of every placeholder pass**: shell code is a
|
||||
@@ -1073,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
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 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"}}, 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"},
|
||||
Serves: []Verb{{Name: "open"}, {Name: "history"}, {Name: "notify"}}},
|
||||
{Name: "channel", Scope: ScopeMesh, Kinded: true, Accepts: []string{"show", "edit", "send"}},
|
||||
{Name: "intake", Scope: ScopeMesh, Kinded: true, Emits: []string{"choice", "link"}, Proofs: []string{"code"}},
|
||||
},
|
||||
Claims: []Claim{{Name: "operator-channel", Scope: ScopeMesh}},
|
||||
Uses: []string{"channel"}}
|
||||
}
|
||||
|
||||
func aChannel(module, kind string) Manifest {
|
||||
return Manifest{Module: module, Claims: []Claim{
|
||||
{Name: "channel", Scope: ScopeMesh, Kind: kind}, {Name: "intake", Scope: ScopeMesh, Kind: kind}}}
|
||||
}
|
||||
|
||||
func TestTwoChannelsOfDifferentKindsHoldTheBenches(t *testing.T) {
|
||||
shelf := Shelf{"messenger": router(), "telegram": aChannel("telegram", "telegram"),
|
||||
"desk-channel": aChannel("desk-channel", "desktop")}
|
||||
if got := problemsFor(t, shelf); got != "" {
|
||||
t.Fatalf("two kinds were refused: %s", got)
|
||||
}
|
||||
for _, m := range shelf {
|
||||
if got := declaredSeatProblems(m); len(got) > 0 {
|
||||
t.Fatalf("%s: %v", m.Module, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestASecondClaimOfOneKindIsRefused(t *testing.T) {
|
||||
got := problemsFor(t, Shelf{"messenger": router(), "telegram": aChannel("telegram", "telegram"),
|
||||
"telegram-two": aChannel("telegram-two", "telegram")})
|
||||
if !strings.Contains(got, `of kind "telegram", which telegram already claims`) {
|
||||
t.Fatalf("a second holder of one kind stood: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAKindedBenchNeedsAKindAndNoOtherSeatTakesOne(t *testing.T) {
|
||||
got := problemsFor(t, Shelf{"messenger": router(), "nameless": aChannel("nameless", "")})
|
||||
if !strings.Contains(got, "claims the kinded bench channel and names no kind") {
|
||||
t.Fatalf("a claim without a kind stood: %s", got)
|
||||
}
|
||||
odd := Manifest{Module: "odd", Claims: []Claim{{Name: "operator-channel", Scope: ScopeMesh, Kind: "telegram"}}}
|
||||
got = problemsFor(t, Shelf{"messenger": router(), "odd": odd})
|
||||
if !strings.Contains(got, "only a kinded bench takes a kind") {
|
||||
t.Fatalf("a kind on a seat that is not kinded stood: %s", got)
|
||||
}
|
||||
dotted := problemsFor(t, Shelf{"messenger": router(), "dotted": aChannel("dotted", "a.b")})
|
||||
if !strings.Contains(dotted, "not a usable name") {
|
||||
t.Fatalf("a kind that would widen a subject stood: %s", dotted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyChannelAndIntakeAreKinded(t *testing.T) {
|
||||
m := Manifest{Module: "x", DefinesSeats: []SeatDeclaration{{Name: "pager", Kinded: true, Accepts: []string{"page"}}}}
|
||||
if got := strings.Join(declaredSeatProblems(m), "; "); !strings.Contains(got, "only channel and intake are kinded") {
|
||||
t.Fatalf("another kinded bench was declared: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheNewRulesAreHeldToWhatTheSeatSays(t *testing.T) {
|
||||
m := Manifest{Module: "x", DefinesSeats: []SeatDeclaration{{Name: "thing", Accepts: []string{"do"},
|
||||
ByCaller: []string{"undo"}, Proofs: []string{"code"}, Records: []string{"nothing"}}}}
|
||||
got := strings.Join(declaredSeatProblems(m), "; ")
|
||||
for _, want := range []string{"names thing.undo by its caller, which the seat neither accepts nor emits",
|
||||
"declares proofs on thing, which is not kinded", `read its records "nothing", which it keeps no state of`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("not refused: %q in %s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Two kinds on one machine are two holders, and one kind on two machines is a second claimant.
|
||||
func TestEachKindIsItsOwnHolderWhenResolved(t *testing.T) {
|
||||
modules := []Manifest{aChannel("telegram", "telegram"), aChannel("desk-channel", "desktop")}
|
||||
held, problems := checkClaims(modules, Node{Name: "anchor"}, nil, nil)
|
||||
if len(problems) > 0 || len(held) != 4 {
|
||||
t.Fatalf("two kinds on one machine: held %v, problems %v", held, problems)
|
||||
}
|
||||
_, problems = checkClaims([]Manifest{aChannel("telegram", "telegram")}, Node{Name: "home"}, held, nil)
|
||||
if len(problems) == 0 {
|
||||
t.Fatal("one kind held on two machines was not refused")
|
||||
}
|
||||
}
|
||||
|
||||
// A channel's capabilities come from the fixed vocabulary, and only a kinded claim carries any.
|
||||
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)
|
||||
}
|
||||
bad := aChannel("telegram", "telegram")
|
||||
bad.Claims[0].Capabilities = []string{"trusted", "max-length:lots"}
|
||||
got := problemsFor(t, Shelf{"messenger": router(), "telegram": bad})
|
||||
for _, w := range []string{`"trusted"`, `"max-length:lots"`} {
|
||||
if !strings.Contains(got, w+", which channel-capabilities/1 does not have") {
|
||||
t.Errorf("%s was not refused: %s", w, got)
|
||||
}
|
||||
}
|
||||
odd := Manifest{Module: "odd", Claims: []Claim{{Name: "operator-channel", Scope: ScopeMesh, Capabilities: []string{"deliver"}}}}
|
||||
if got := problemsFor(t, Shelf{"messenger": router(), "odd": odd}); !strings.Contains(got, "only a kinded bench's claim carries them") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,9 +78,47 @@ func machineFacts(r Resolution, names map[string]string, meshRange string) map[s
|
||||
out["account"] = r.Account
|
||||
out["account-home"] = accountHomeOf(r.Account, r.AccountHome)
|
||||
}
|
||||
// The account agents run as here, and whether it must never become root (novox/hq ADR 0266). The agent
|
||||
// account where the node names one; the operator account otherwise, so a module writing the agent's
|
||||
// home names one fact on every machine. `agent-root` is "never" only for an account of the agents' own:
|
||||
// the user resource naming it then asks the node-engine to judge it, and on a machine where agents run
|
||||
// as the operator it is empty, asserting nothing — the operator's account may become root there.
|
||||
if agent, home := r.agentAccount(); agent != "" {
|
||||
out["agent-account"] = agent
|
||||
out["agent-home"] = home
|
||||
out["agent-root"] = ""
|
||||
if r.AgentAccount != "" {
|
||||
out["agent-root"] = RootNever
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RootNever is what a user resource's `root` says of an account that must never become root without a
|
||||
// person (novox/hq ADR 0266); the node-engine judges it.
|
||||
const RootNever = "never"
|
||||
|
||||
// agentAccount is the account agents run as on this machine and its home: the agent account when the node
|
||||
// names one (novox/hq ADR 0266), else the operator account; empty when neither is known.
|
||||
func (r Resolution) agentAccount() (string, string) {
|
||||
if r.AgentAccount != "" {
|
||||
return r.AgentAccount, agentHomeOf(r.AgentAccount, r.AgentAccountHome)
|
||||
}
|
||||
if r.Account != "" {
|
||||
return r.Account, accountHomeOf(r.Account, r.AccountHome)
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// agentHomeOf is where the agent account's home is: what was stored, or /home/<account>. Never /root: the
|
||||
// agent account is never root.
|
||||
func agentHomeOf(account, home string) string {
|
||||
if home != "" {
|
||||
return home
|
||||
}
|
||||
return "/home/" + account
|
||||
}
|
||||
|
||||
// accountHomeOf is where an account's home is: what was stored, or the derived default — /root for
|
||||
// root, /home/<account> otherwise. The one place the default is written, so a fact and the store
|
||||
// cannot disagree about it.
|
||||
@@ -105,8 +143,12 @@ func machineInto(resource map[string]any, facts map[string]string, module string
|
||||
// (novox/hq to-be 29), the same reason its content names ${machine:address}. And the name a
|
||||
// `user` shape sets the login shell of, and the user a user-scoped unit or a process runs as:
|
||||
// the shell module makes the operator's account its holder's login shell, and the desktop's
|
||||
// watchers run as that account (novox/hq ADR 0176, ADR 0177) — neither can name the person.
|
||||
for _, field := range []string{"path", "owner", "content", "name", "user"} {
|
||||
// watchers run as that account (novox/hq ADR 0176, ADR 0177) — neither can name the person. And a
|
||||
// user's `root`: the agent's module declares the account agents run as with ${machine:agent-root},
|
||||
// "never" only where that account is the agents' own (novox/hq ADR 0266), and its `home`, so an account
|
||||
// the operator named with a home of its own is made there (${machine:agent-home}); the node-engine reads a
|
||||
// user's home only when it creates the account, so an existing one is never moved.
|
||||
for _, field := range []string{"path", "owner", "content", "name", "user", "root", "home"} {
|
||||
s, ok := resource[field].(string)
|
||||
if !ok {
|
||||
continue
|
||||
|
||||
@@ -64,6 +64,13 @@ type Claim struct {
|
||||
// text/template over one piece — its fields and `module` — in the tool's own grammar (novox/hq ADR
|
||||
// 0255). The data is the mesh's, the format the holder's, as a module's facts template is.
|
||||
Renders map[string]string `json:"renders,omitempty"`
|
||||
// Kind is the kind this module holds a kinded bench as (novox/hq ADR 0234 §2, ADR 0259): `telegram`,
|
||||
// `desktop`. Refused on any other seat, and a second claim of one kind is refused.
|
||||
Kind string `json:"kind,omitempty"`
|
||||
// Capabilities are what a channel of this kind promises, from the fixed vocabulary
|
||||
// channel-capabilities/1 (novox/hq ADR 0234 §2): the router judges an answer by these, read from the
|
||||
// controller's record of this claim and never from the channel.
|
||||
Capabilities []string `json:"capabilities,omitempty"`
|
||||
}
|
||||
|
||||
// ServesFor is what this claim offers a seat's protocol: the verbs it names, else the module's
|
||||
@@ -640,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).
|
||||
//
|
||||
@@ -1620,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.
|
||||
|
||||
@@ -32,6 +32,11 @@ type Node struct {
|
||||
// to-be 29). What a home-scoped file is owned by and what ${machine:account} resolves to.
|
||||
Account string
|
||||
AccountHome string
|
||||
// AgentAccount is the login agents run as here when it is not the operator's, AgentAccountHome its
|
||||
// home when not derived (novox/hq ADR 0266). What ${machine:agent-account} resolves to; empty means
|
||||
// agents run as the operator account.
|
||||
AgentAccount string
|
||||
AgentAccountHome string
|
||||
}
|
||||
|
||||
// World is what the rest of the mesh already has.
|
||||
@@ -115,6 +120,16 @@ type Held struct {
|
||||
Node string
|
||||
Module string
|
||||
Site string
|
||||
// Kind is the kind a kinded bench is held as (novox/hq ADR 0234 §2): each kind is its own holder.
|
||||
Kind string
|
||||
}
|
||||
|
||||
// heldKey is what one holder holds: the seat, and its kind on a kinded bench.
|
||||
func heldKey(claim, kind string) string {
|
||||
if kind == "" {
|
||||
return canonicalSeat(claim)
|
||||
}
|
||||
return canonicalSeat(claim) + "/" + kind
|
||||
}
|
||||
|
||||
// Resolution is what a node should run, and why.
|
||||
@@ -134,6 +149,10 @@ type Resolution struct {
|
||||
// here without a store lookup.
|
||||
Account string
|
||||
AccountHome string
|
||||
// AgentAccount and AgentAccountHome are the account agents run as here when it is not the
|
||||
// operator's, and its home (novox/hq ADR 0266); empty when agents run as the operator account.
|
||||
AgentAccount string
|
||||
AgentAccountHome string
|
||||
// Capabilities are the machine's, as its profile reported them, carried from the node so a
|
||||
// contribution placed only where the machine has something (`if-capability`, novox/hq ADR 0255)
|
||||
// is decided here without a store lookup.
|
||||
@@ -703,7 +722,8 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
||||
}
|
||||
|
||||
resolution := Resolution{Node: node.Name, At: node.At, PublicDomain: node.PublicDomain,
|
||||
Account: node.Account, AccountHome: node.AccountHome, Capabilities: node.Capabilities,
|
||||
Account: node.Account, AccountHome: node.AccountHome, AgentAccount: node.AgentAccount,
|
||||
AgentAccountHome: node.AgentAccountHome, Capabilities: node.Capabilities,
|
||||
Because: because, Needs: needs, Unhostable: unhostable, Kept: kept}
|
||||
for _, n := range providersFirst(order, catalogue) {
|
||||
resolution.Modules = append(resolution.Modules, catalogue[n])
|
||||
@@ -864,7 +884,7 @@ func checkClaims(modules []Manifest, node Node, elsewhere []Held, holdings []Hel
|
||||
// **One seat under either of its names** (novox/hq ADR 0122): a manifest registered before
|
||||
// a rename claims the former name, and one written after it the current — two claimants of
|
||||
// one seat, compared by the seat they resolve to and not by how each spelled it.
|
||||
seat := canonicalSeat(c.Name)
|
||||
seat := heldKey(c.Name, c.Kind)
|
||||
if other, taken := byScope[scope][seat]; taken {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s and %s both claim %q, and only one thing may hold it per %s",
|
||||
@@ -873,14 +893,14 @@ func checkClaims(modules []Manifest, node Node, elsewhere []Held, holdings []Hel
|
||||
}
|
||||
byScope[scope][seat] = m.Module
|
||||
held = append(held, Held{Claim: c.Name, Scope: scope, Node: node.Name,
|
||||
Module: m.Module, Site: node.Site})
|
||||
Module: m.Module, Site: node.Site, Kind: c.Kind})
|
||||
}
|
||||
}
|
||||
|
||||
// And against the rest of the mesh, for the scopes that reach past this machine.
|
||||
for _, h := range held {
|
||||
for _, e := range elsewhere {
|
||||
if e.Node == node.Name || canonicalSeat(e.Claim) != canonicalSeat(h.Claim) || e.Scope != h.Scope {
|
||||
if e.Node == node.Name || heldKey(e.Claim, e.Kind) != heldKey(h.Claim, h.Kind) || e.Scope != h.Scope {
|
||||
continue
|
||||
}
|
||||
switch h.Scope {
|
||||
|
||||
@@ -83,6 +83,11 @@ const (
|
||||
RuntimeBrokerFile = "MESH_BROKER_FILE"
|
||||
RuntimeOperatorAccount = "MESH_OPERATOR_ACCOUNT"
|
||||
RuntimeOperatorHome = "MESH_OPERATOR_HOME"
|
||||
// RuntimeAgentAccount and RuntimeAgentHome are the account agents run as on the machine and its home
|
||||
// (novox/hq ADR 0266): the agent account where the node names one, the operator account otherwise.
|
||||
// The agent's module writes the agent's home from them; absent where neither account is known.
|
||||
RuntimeAgentAccount = "MESH_AGENT_ACCOUNT"
|
||||
RuntimeAgentHome = "MESH_AGENT_HOME"
|
||||
// RuntimeToolEnv is every served module's composed environment, as JSON (novox/hq ADR 0192):
|
||||
// {"<module>": {"<word>": "<value>"}}. The runtime takes it at start, removes it from its own
|
||||
// environment and hands each module's words to that module's bundles alone. In the unit, so a
|
||||
@@ -165,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
|
||||
@@ -215,6 +224,10 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) {
|
||||
env[RuntimeOperatorHome] = accountHomeOf(r.Account, r.AccountHome)
|
||||
process["user"] = r.Account
|
||||
}
|
||||
if agent, home := r.agentAccount(); agent != "" {
|
||||
env[RuntimeAgentAccount] = agent
|
||||
env[RuntimeAgentHome] = home
|
||||
}
|
||||
// Routed through the artifact store as this network reaches it now, like everything the mesh
|
||||
// built; refused with the same words when there is no store to route through.
|
||||
if err := artifactsInto(process, RuntimeModule, with); err != nil {
|
||||
@@ -223,6 +236,94 @@ 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 {
|
||||
// Never the node's operator account, nor the account agents run as there (the review of 2026-10-09):
|
||||
// either would hand what it holds back to the very accounts it is kept from.
|
||||
switch {
|
||||
case r.Account != "" && m.RunsAs == r.Account:
|
||||
return nil, fmt.Errorf("%s runs as %s, the operator's account on %s: a module of its own account never "+
|
||||
"runs as it (novox/hq ADR 0259 §8)", m.Module, m.RunsAs, r.Node)
|
||||
case r.AgentAccount != "" && m.RunsAs == r.AgentAccount:
|
||||
return nil, fmt.Errorf("%s runs as %s, the account agents run as on %s: a module of its own account "+
|
||||
"never runs as it (novox/hq ADR 0259 §8)", m.Module, m.RunsAs, r.Node)
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -457,3 +457,75 @@ 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")
|
||||
}
|
||||
}
|
||||
|
||||
// The review of 2026-10-09 (L4): a module of its own account never runs as the node's operator account, nor
|
||||
// as the account agents run as there — either would hand what it holds back to the accounts it is kept from.
|
||||
func TestAModuleOfItsOwnAccountIsRefusedTheOperatorsAndTheAgentsAccount(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)
|
||||
}
|
||||
for _, account := range []string{"ops", "agent"} {
|
||||
telegram := aToolsModule(t, "telegram", "tools/index.js")
|
||||
telegram.RunsAs, telegram.SecretsOwner = account, account
|
||||
telegram.OwnSecrets = OwnSecrets{"broker": {Path: "/var/lib/telegram/broker"}}
|
||||
_, err := Resolution{Node: "anchor", Account: "ops", AgentAccount: "agent",
|
||||
Modules: []Manifest{telegram, goRuntime}}.ownRuntimes(with)
|
||||
if err == nil || !strings.Contains(err.Error(), account) {
|
||||
t.Errorf("telegram running as %s was composed: %v", account, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
@@ -51,6 +52,35 @@ type SeatDeclaration struct {
|
||||
// owns its own, which is why a seat is also the answer for a module that needs retention
|
||||
// its events cannot have.
|
||||
RetainSeconds int `json:"retain-seconds,omitempty"`
|
||||
|
||||
// Kinded makes the seat a kinded bench (novox/hq ADR 0234 §2, ADR 0259 §3): its holders are different
|
||||
// modules, each claiming one kind, and each verb's subject carries the kind. Only the benches in
|
||||
// KindedBenches may be kinded; making another is a decision, recorded.
|
||||
Kinded bool `json:"kinded,omitempty"`
|
||||
// ByCaller are accepts and emits whose subject's last token names the calling module (ADR 0259 §3): a
|
||||
// user submits such an accept, and hears such an event, under its own name and no other.
|
||||
ByCaller []string `json:"by-caller,omitempty"`
|
||||
// Proofs are verbs carried as core request and reply, never on a stream: what travels on them (a code
|
||||
// the operator typed) is never kept (ADR 0259 §3). On a kinded bench a holder asks with its own kind and
|
||||
// the modules watching the seat answer.
|
||||
Proofs []string `json:"proofs,omitempty"`
|
||||
// Records are state buckets of the declaring module that each user reads under its own name — the
|
||||
// keys `<user>.…` and no other (ADR 0259 §3).
|
||||
Records []string `json:"records,omitempty"`
|
||||
}
|
||||
|
||||
// KindedBenches are the seats that may be kinded (novox/hq ADR 0234 §2): `channel` sends to the operator,
|
||||
// `intake` takes what the operator answers. Another is a decision, recorded, as ADR 0223 asks of a bench.
|
||||
var KindedBenches = map[string]bool{"channel": true, "intake": true}
|
||||
|
||||
// NamedByCaller says whether one of the seat's verbs is named by its caller.
|
||||
func (s SeatDeclaration) NamedByCaller(verb string) bool {
|
||||
for _, v := range s.ByCaller {
|
||||
if v == verb {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// At is this declaration's scope, with the default applied. Mesh by default, because a seat
|
||||
@@ -132,6 +162,7 @@ func declaredSeatProblems(m Manifest) []string {
|
||||
"%s declares %s.%s, which is not a usable verb", m.Module, s.Name, v))
|
||||
}
|
||||
}
|
||||
problems = append(problems, trafficProblems(m, s)...)
|
||||
}
|
||||
|
||||
for _, u := range m.Uses {
|
||||
@@ -142,6 +173,56 @@ func declaredSeatProblems(m Manifest) []string {
|
||||
return problems
|
||||
}
|
||||
|
||||
// trafficProblems is what one declaration of the rules of ADR 0259 §3 can be judged on alone.
|
||||
func trafficProblems(m Manifest, s SeatDeclaration) []string {
|
||||
var problems []string
|
||||
if s.Kinded && !KindedBenches[s.Name] {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s declares %s as a kinded bench; only channel and intake are kinded, and another is a "+
|
||||
"decision, recorded (novox/hq ADR 0234)", m.Module, s.Name))
|
||||
}
|
||||
if s.Kinded && len(s.ByCaller) > 0 {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s declares %s kinded and names verbs by their caller; a kinded bench's subjects carry the kind",
|
||||
m.Module, s.Name))
|
||||
}
|
||||
for _, v := range s.ByCaller {
|
||||
inAccepts, inEmits := false, false
|
||||
for _, a := range s.Accepts {
|
||||
inAccepts = inAccepts || a == v
|
||||
}
|
||||
for _, e := range s.Emits {
|
||||
inEmits = inEmits || e == v
|
||||
}
|
||||
if !inAccepts && !inEmits {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s names %s.%s by its caller, which the seat neither accepts nor emits", m.Module, s.Name, v))
|
||||
}
|
||||
}
|
||||
for _, v := range s.Proofs {
|
||||
if !name.MatchString(v) || strings.Contains(v, ".") {
|
||||
problems = append(problems, fmt.Sprintf("%s declares the proof %s.%s, which is not a usable verb",
|
||||
m.Module, s.Name, v))
|
||||
}
|
||||
}
|
||||
if len(s.Proofs) > 0 && !s.Kinded {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s declares proofs on %s, which is not kinded; a proof is asked by a holder of a kind",
|
||||
m.Module, s.Name))
|
||||
}
|
||||
for _, r := range s.Records {
|
||||
kept := false
|
||||
for _, st := range m.State {
|
||||
kept = kept || st.Name == r
|
||||
}
|
||||
if !kept {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s says %s's users read its records %q, which it keeps no state of", m.Module, s.Name, r))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// A Shelf is every manifest the mesh has registered, by module name.
|
||||
type Shelf map[string]Manifest
|
||||
|
||||
@@ -182,8 +263,19 @@ func CatalogueProblems(shelf Shelf) []string {
|
||||
return ok
|
||||
}
|
||||
|
||||
// Who claims each kind of a kinded bench, so a second claim of one kind is refused (ADR 0234 §2).
|
||||
kindsTaken := map[string]string{}
|
||||
|
||||
for _, module := range shelfOrder(shelf) {
|
||||
m := shelf[module]
|
||||
for _, c := range m.Claims {
|
||||
if c.Kind != "" {
|
||||
if _, isModuleSeat := declared[c.Name]; !isModuleSeat {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s claims %s of kind %q, and only a kinded bench takes a kind", module, c.Name, c.Kind))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A `uses` naming nothing is where ADR 0110's guarantee lands under a derived set: the
|
||||
// same refusal, at the same moment, from a set nobody maintains by hand.
|
||||
@@ -214,6 +306,7 @@ func CatalogueProblems(shelf Shelf) []string {
|
||||
}
|
||||
continue
|
||||
}
|
||||
problems = append(problems, kindProblems(module, c, s, kindsTaken)...)
|
||||
if c.At() != s.At() {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s claims %s at scope %q, and %s declares it at %s",
|
||||
@@ -228,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
|
||||
@@ -239,6 +342,63 @@ func CatalogueProblems(shelf Shelf) []string {
|
||||
return problems
|
||||
}
|
||||
|
||||
// ChannelCapabilities is the fixed vocabulary `channel-capabilities/1` (novox/hq ADR 0234 §2): a word
|
||||
// outside it is refused. `max-length:<N>` takes a number.
|
||||
var ChannelCapabilities = map[string]bool{
|
||||
"deliver": true, "reaches-away": true, "loud": true, "silent": true, "edit": true,
|
||||
"reaches-when-mesh-down": true, "private": true,
|
||||
"choice": true, "reply": true, "threads": true, "operator-first": true,
|
||||
"verified-sender": true, "exact-render": true, "code-factor": true, "key-factor": true,
|
||||
}
|
||||
|
||||
var maxLength = regexp.MustCompile(`^max-length:[1-9][0-9]{0,6}$`)
|
||||
|
||||
// capabilityProblems are the words of a claim outside the vocabulary, and capabilities on a claim of a
|
||||
// seat that is not kinded.
|
||||
func capabilityProblems(module string, c Claim, kinded bool) []string {
|
||||
if len(c.Capabilities) == 0 {
|
||||
return nil
|
||||
}
|
||||
if !kinded {
|
||||
return []string{fmt.Sprintf("%s claims %s with capabilities, and only a kinded bench's claim carries them",
|
||||
module, c.Name)}
|
||||
}
|
||||
var problems []string
|
||||
for _, w := range c.Capabilities {
|
||||
if !ChannelCapabilities[w] && !maxLength.MatchString(w) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s claims %s with the capability %q, which channel-capabilities/1 does not have", module, c.Name, w))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// kindProblems is a claim judged against a declared seat's kind: a kinded bench takes one claim per kind,
|
||||
// a usable name; any other seat takes none.
|
||||
func kindProblems(module string, c Claim, s SeatDeclaration, taken map[string]string) []string {
|
||||
if problems := capabilityProblems(module, c, s.Kinded); len(problems) > 0 {
|
||||
return problems
|
||||
}
|
||||
switch {
|
||||
case !s.Kinded && c.Kind != "":
|
||||
return []string{fmt.Sprintf("%s claims %s of kind %q, and only a kinded bench takes a kind",
|
||||
module, c.Name, c.Kind)}
|
||||
case !s.Kinded:
|
||||
return nil
|
||||
case c.Kind == "":
|
||||
return []string{fmt.Sprintf("%s claims the kinded bench %s and names no kind", module, c.Name)}
|
||||
case !name.MatchString(c.Kind) || strings.Contains(c.Kind, "."):
|
||||
return []string{fmt.Sprintf("%s claims %s of kind %q, which is not a usable name", module, c.Name, c.Kind)}
|
||||
}
|
||||
key := c.Name + "/" + c.Kind
|
||||
if first, ok := taken[key]; ok && first != module {
|
||||
return []string{fmt.Sprintf("%s claims %s of kind %q, which %s already claims; a kind has one holder",
|
||||
module, c.Name, c.Kind, first)}
|
||||
}
|
||||
taken[key] = module
|
||||
return nil
|
||||
}
|
||||
|
||||
// unserved is what a seat's protocol promises and the claimant does not answer. Only the tools
|
||||
// are checked: `accepts` and `emits` are wired by the runtime from the declaration, while a tool
|
||||
// is code the module either has or has not written — under the claim's serves, or among its own.
|
||||
@@ -266,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 ""
|
||||
}
|
||||
|
||||
@@ -237,9 +237,9 @@ var ControllerVerbs = []Verb{
|
||||
"node": "the machine that runs the module",
|
||||
"module": "the module's name",
|
||||
}, []string{"node", "module"})},
|
||||
{Name: "token", Description: "Issue a one-time token for a machine to join with. Give the public half of the " +
|
||||
"tunnel key the machine made (`nox-mesh-host key`): the machine is given its address and made a peer of " +
|
||||
"the hub, and joins through the tunnel. The token is shown once, in the answer.",
|
||||
{Name: "token", Description: "Refused through a verb since novox/hq ADR 0266: the one-time token a machine " +
|
||||
"joins with is answered to its caller, and whoever may call a verb includes agents. Issue it at the " +
|
||||
"controller's terminal: `mesh-controller token issue --new <name> --overlay-key <public half>`.",
|
||||
Input: schema(map[string]string{
|
||||
"node": "a machine the mesh already has a record for",
|
||||
"new": "or the name of a machine to create the record for",
|
||||
@@ -267,10 +267,14 @@ var ControllerVerbs = []Verb{
|
||||
"list": "\"preferences\": every module's preferences — key, default and why — and the value on each " +
|
||||
"machine it is assigned to with where it comes from; module and node narrow it (novox/hq ADR 0262)",
|
||||
}, nil, "clear", "replace", "history")},
|
||||
{Name: "command", Description: "Run one command line of the controller's own, as you would type it at its " +
|
||||
"shell — `node account g14 jochen`, `node show ace`, `module list` — and answer what it printed. The " +
|
||||
"generic verb beside the named ones (novox/hq ADR 0154): everything the binary can do, without a verb " +
|
||||
"per command. Any node may call any tool (ADR 0175), so nothing is held back here.",
|
||||
{Name: "command", Description: "Run one reading command line of the controller's own, as you would type it at " +
|
||||
"its shell — `node show ace`, `module list`, `plans`, `conditions show <key>` — and answer what it " +
|
||||
"printed. The generic verb beside the named ones (novox/hq ADR 0154), and since ADR 0266 it only reads: " +
|
||||
"status, version, help, seats, healers, hand-acts, durations, collection, images, artifacts, data, builds, " +
|
||||
"queue, plan, plans (not stop/close/go), doctor (not run), conditions list/show/history, node list/show, " +
|
||||
"module list, settings show/preferences, retire list, cleanup list, delivery plan/walks, bus, mirrors (not " +
|
||||
"--record/--confirm). What writes has its named verb; what sets a key, issues a credential or a token, or " +
|
||||
"accepts, recovers or exports a secret is the controller's terminal's alone.",
|
||||
Input: schema(map[string]string{
|
||||
"command": "the command line, as the controller's binary takes it; quotes group a word with spaces",
|
||||
}, []string{"command"})},
|
||||
@@ -425,6 +429,15 @@ var ControllerVerbs = []Verb{
|
||||
"cause": "with consumer or older-than: the cause in a word (cleanup-waiting when absent)",
|
||||
}, nil, "confirm")},
|
||||
// What a consumer gave up on (novox/hq issue 330): kept in DEAD_LETTERS until a person acts on it.
|
||||
{Name: "root-free", Description: "Whether each machine named is root-free now (novox/hq ADR 0259 §8): no agent " +
|
||||
"there can become root without a person. Judged when asked, never from a condition: free only when the machine " +
|
||||
"names an account its agents run as, its node-engine judged that account unable to become root within the " +
|
||||
"last 15 minutes, and the login shell's execute is not served there. Anything else, a read that failed " +
|
||||
"included, is not free and says why. The router asks it before an answer from a channel proving its sender " +
|
||||
"may approve. Only reads.",
|
||||
Input: listed(schema(map[string]string{
|
||||
"machines": "the machines to judge, by name: a list, or one text separated by commas",
|
||||
}, []string{"machines"}), "machines")},
|
||||
{Name: "dead-letters", Description: "Every message a consumer on the bus gave up on after handing it over " +
|
||||
"as often as it may, kept in DEAD_LETTERS: whose consumer, the subject, how often it was handed over and " +
|
||||
"when it was given up, newest first. With id: that one whole, with what it said. With deliver: hand it " +
|
||||
@@ -541,6 +554,21 @@ func schema(properties map[string]string, required []string, switches ...string)
|
||||
return out
|
||||
}
|
||||
|
||||
// listed makes the named properties of a schema lists of text: a caller gives them as a JSON array (or, as
|
||||
// any argument, one text separated by commas).
|
||||
func listed(in map[string]any, names ...string) map[string]any {
|
||||
props, _ := in["properties"].(map[string]any)
|
||||
for _, n := range names {
|
||||
p, _ := props[n].(map[string]any)
|
||||
if p == nil {
|
||||
panic("a list that is not a property: " + n)
|
||||
}
|
||||
props[n] = map[string]any{"type": "array", "items": map[string]any{"type": "string"},
|
||||
"description": p["description"]}
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
// unpromised is what a claim says it serves and the seat's protocol never promised.
|
||||
func unpromised(serves []string, promised []Verb) []string {
|
||||
has := map[string]bool{}
|
||||
|
||||
@@ -131,6 +131,10 @@ type Machine struct {
|
||||
// home is when that is not the derived one.
|
||||
Account string `json:"account,omitempty"`
|
||||
AccountHome string `json:"account-home,omitempty"`
|
||||
// AgentAccount is the account agents run as there when it is not the operator's (a pseudonym), and
|
||||
// AgentAccountHome its home when not derived (novox/hq ADR 0266).
|
||||
AgentAccount string `json:"agent-account,omitempty"`
|
||||
AgentAccountHome string `json:"agent-account-home,omitempty"`
|
||||
// PublicDomain is the domain it answers for, its labels replaced.
|
||||
PublicDomain string `json:"public-domain,omitempty"`
|
||||
// Assigned is every module assigned there.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The agent account (novox/hq ADR 0266): recorded and read back with every node, its home derived when
|
||||
// not stated, cleared by an empty name — and refused when it is root, the operator's own account, or no
|
||||
// login at all, because each of those would say agents have an account of their own while they do not.
|
||||
func TestAgentAccountIsRecordedAndRefusedWhereItWouldNotConfine(t *testing.T) {
|
||||
inv := ForTest(t)
|
||||
ctx := context.Background()
|
||||
if _, err := inv.AddNode(ctx, "anchor"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.SetAccount(ctx, "anchor", "operator", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
n, err := inv.NodeByName(ctx, "anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n.AgentAccount != "" || n.AgentHome() != "" {
|
||||
t.Fatalf("a node that names none has agent account %q, home %q", n.AgentAccount, n.AgentHome())
|
||||
}
|
||||
|
||||
if err := inv.SetAgentAccount(ctx, "anchor", "agent", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n, _ = inv.NodeByName(ctx, "anchor")
|
||||
if n.AgentAccount != "agent" || n.AgentHome() != "/home/agent" {
|
||||
t.Fatalf("agent account %q, home %q; want agent, /home/agent", n.AgentAccount, n.AgentHome())
|
||||
}
|
||||
all, err := inv.Nodes(ctx)
|
||||
if err != nil || len(all) != 1 || all[0].AgentAccount != "agent" {
|
||||
t.Fatalf("the listing does not carry the agent account: %+v %v", all, err)
|
||||
}
|
||||
|
||||
if err := inv.SetAgentAccount(ctx, "anchor", "agent", "/srv/agent"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, _ = inv.NodeByName(ctx, "anchor"); n.AgentHome() != "/srv/agent" {
|
||||
t.Fatalf("the stated home is %q", n.AgentHome())
|
||||
}
|
||||
|
||||
for _, c := range []struct{ account, home, says string }{
|
||||
{"root", "", "may not run as root"},
|
||||
{"operator", "", "operator account"},
|
||||
{"Agent", "", "not a login name"},
|
||||
{"9agent", "", "not a login name"},
|
||||
{"agent", "relative", "absolute"},
|
||||
{"postgres", "", "service account"},
|
||||
{"systemd-network", "", "service account"},
|
||||
{"showcase", "", "service account"},
|
||||
{"", "/home/x", "without an agent account"},
|
||||
} {
|
||||
err := inv.SetAgentAccount(ctx, "anchor", c.account, c.home)
|
||||
if err == nil || !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%q %q: %v; want a refusal saying %q", c.account, c.home, err, c.says)
|
||||
}
|
||||
}
|
||||
if n, _ = inv.NodeByName(ctx, "anchor"); n.AgentAccount != "agent" {
|
||||
t.Fatalf("a refusal changed the record: %q", n.AgentAccount)
|
||||
}
|
||||
|
||||
// The other direction: the operator account may not be named as the agent account either.
|
||||
if err := inv.SetAccount(ctx, "anchor", "agent", ""); err == nil || !strings.Contains(err.Error(), "agent account") {
|
||||
t.Fatalf("the operator account named as the agent account: %v; want a refusal", err)
|
||||
}
|
||||
if n, _ = inv.NodeByName(ctx, "anchor"); n.Account != "operator" {
|
||||
t.Fatalf("a refusal changed the operator account: %q", n.Account)
|
||||
}
|
||||
|
||||
if err := inv.SetAgentAccount(ctx, "anchor", "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.SetAccount(ctx, "anchor", "agent", ""); err != nil {
|
||||
t.Fatalf("with the agent account cleared, the name is free: %v", err)
|
||||
}
|
||||
if err := inv.SetAccount(ctx, "anchor", "operator", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, _ = inv.NodeByName(ctx, "anchor"); n.AgentAccount != "" || n.AgentAccountHome != "" {
|
||||
t.Fatalf("clearing left %q %q", n.AgentAccount, n.AgentAccountHome)
|
||||
}
|
||||
if err := inv.SetAgentAccount(ctx, "nowhere", "agent", ""); !errors.Is(err, ErrNoSuchNode) {
|
||||
t.Fatalf("an unknown node: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -34,9 +34,12 @@ func (i *Inventory) BusRecords(ctx context.Context) (broker.Records, error) {
|
||||
// that seat promises. **Across the whole catalogue, not one manifest**: a seat is declared by
|
||||
// one module and held by another, which is the whole reason a seat exists (ADR 0118).
|
||||
seats := map[string]catalogue.SeatDeclaration{}
|
||||
// And who declared each, so a seat's records are named as the declaring module's buckets (ADR 0259).
|
||||
declarers := map[string]string{}
|
||||
for _, m := range declared {
|
||||
for _, s := range m.DefinesSeats {
|
||||
seats[s.Name] = s
|
||||
declarers[s.Name] = m.Module
|
||||
}
|
||||
}
|
||||
// And the mesh's own, which carry protocol too (novox/hq ADR 0121). Added after the modules'
|
||||
@@ -78,7 +81,7 @@ func (i *Inventory) BusRecords(ctx context.Context) (broker.Records, error) {
|
||||
"%s is assigned to %s and is not in the catalogue, so what it may say cannot "+
|
||||
"be derived", module, n.Name)
|
||||
}
|
||||
d := declaredFor(m, seats)
|
||||
d := declaredFor(m, seats, declarers)
|
||||
// And the state offered to it as a seat's holder by the modules beside it (novox/hq ADR 0255).
|
||||
// For this machine's key alone (novox/hq ADR 0260): a bar shows its own machine's draw.
|
||||
for _, sr := range catalogue.ReadsGranted(m, onMachine(declared, modules), n.Name) {
|
||||
@@ -121,7 +124,7 @@ func onMachine(declared map[string]catalogue.Manifest, modules []string) []catal
|
||||
|
||||
// declaredFor is one module's manifest as the composer needs it: what it says about itself, and the
|
||||
// protocol of every seat it holds or uses.
|
||||
func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaration) broker.Declared {
|
||||
func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaration, declarers map[string]string) broker.Declared {
|
||||
// A consumed name is a module's event unless it names a seat, and only somebody holding the seat
|
||||
// set can tell (novox/hq ADR 0121). Split here, because the composer cannot look at a name and
|
||||
// know — and a role's event read as a module's is a subscription to a namespace nobody owns.
|
||||
@@ -132,6 +135,16 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio
|
||||
if named {
|
||||
// A seat's event when the seat says it; else the event of the module of that name — a seat and
|
||||
// the module holding it may share a name (mesh-delivery, novox/hq ADR 0239).
|
||||
if s, isASeat := seats[emitter]; isASeat && s.Kinded {
|
||||
// A kinded bench's event is named `<event>` or `<event>.*` and heard from every kind; its
|
||||
// proofs are answered by whoever watches it (ADR 0259 §3).
|
||||
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, DeclaredBy: declarers[s.Name]})
|
||||
continue
|
||||
}
|
||||
}
|
||||
if s, isASeat := seats[emitter]; isASeat && catalogue.SeatSays(s.Emits, event) {
|
||||
watches = append(watches, broker.Seat{Name: s.Name, Emits: []string{event}})
|
||||
continue
|
||||
@@ -155,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).
|
||||
@@ -168,12 +183,15 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio
|
||||
// Every seat with a protocol, the mesh's own included. One that says only who does a job is
|
||||
// not here and grants nothing, which is most of them.
|
||||
if s, hasAProtocol := seats[c.Name]; hasAProtocol {
|
||||
d.Holds = append(d.Holds, asSeat(s))
|
||||
held := asSeat(s, declarers[s.Name])
|
||||
held.Kind = c.Kind
|
||||
held.Capabilities = c.Capabilities
|
||||
d.Holds = append(d.Holds, held)
|
||||
}
|
||||
}
|
||||
for _, name := range m.Uses {
|
||||
if s, declaredSomewhere := seats[name]; declaredSomewhere {
|
||||
d.Uses = append(d.Uses, asSeat(s))
|
||||
d.Uses = append(d.Uses, asSeat(s, declarers[s.Name]))
|
||||
}
|
||||
}
|
||||
return d
|
||||
@@ -204,9 +222,17 @@ func (i *Inventory) DeclaredBuckets(ctx context.Context) ([]broker.Bucket, error
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func asSeat(s catalogue.SeatDeclaration) broker.Seat {
|
||||
return broker.Seat{Name: s.Name, Scope: s.Scope, Accepts: s.Accepts, Emits: s.Emits,
|
||||
Serves: catalogue.VerbNames(s.Serves)}
|
||||
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,
|
||||
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 != "" {
|
||||
seat.Records = append(seat.Records, broker.BucketName(declarer, r))
|
||||
}
|
||||
}
|
||||
return seat
|
||||
}
|
||||
|
||||
// MeshSeats are the mesh's own seats that carry a protocol, as the bus needs them: what to make a work
|
||||
@@ -277,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
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ type ResourceHealth struct {
|
||||
// Account is the account whose own service manager runs it, or the account a resource of kind account
|
||||
// is (novox/hq ADR 0254).
|
||||
Account string `json:"account,omitempty"`
|
||||
// Root is "never" on an account verdict that judged whether the account can become root without a
|
||||
// person (novox/hq ADR 0266).
|
||||
Root string `json:"root,omitempty"`
|
||||
}
|
||||
|
||||
// NodeHealth is a machine's newest statement, as kept.
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- A node names the account its agents run as (novox/hq ADR 0266).
|
||||
--
|
||||
-- On the control node every agent session ran as the operator's account, which may become root without
|
||||
-- a password: any agent there could become root without a person. The decision is an account of the
|
||||
-- agents' own, without sudo, beside the operator's, who keeps theirs. Stated by the operator at the
|
||||
-- controller's terminal, like the operator account (migration 0036), and never by a verb or a setting,
|
||||
-- so no agent can change which account it is.
|
||||
--
|
||||
-- Empty rather than null, as the operator account is: empty is a real state, "agents run as the
|
||||
-- operator's account here" — a workstation's today. The home is stored only when it is not
|
||||
-- /home/<account>; empty means derive it.
|
||||
alter table node add column agent_account text not null default '';
|
||||
alter table node add column agent_account_home text not null default '';
|
||||
+118
-2
@@ -9,6 +9,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -69,6 +70,14 @@ type Node struct {
|
||||
Account string
|
||||
AccountHome string
|
||||
|
||||
// AgentAccount is the login agents run as on this machine when it is not the operator's — `agent`
|
||||
// on the control node (novox/hq ADR 0266): an account of their own, without sudo, so no agent there
|
||||
// can become root without a person. Empty means agents run as the operator account. Stated at the
|
||||
// controller's terminal only, never by a verb or a setting. AgentAccountHome is its home when not
|
||||
// /home/<account>; empty means derive it.
|
||||
AgentAccount string
|
||||
AgentAccountHome string
|
||||
|
||||
// HostVersion is the version of the host this machine reported running (novox/hq 04-ISSUES/087).
|
||||
// Empty when it has not said since the mesh began keeping it — which is not the same as running
|
||||
// no host, so nothing derives "behind" from an empty one.
|
||||
@@ -91,6 +100,17 @@ func (n Node) Home() string {
|
||||
}
|
||||
}
|
||||
|
||||
// AgentHome is the agent account's home, derived when not stored; empty when no agent account is named.
|
||||
func (n Node) AgentHome() string {
|
||||
if n.AgentAccount == "" {
|
||||
return ""
|
||||
}
|
||||
if n.AgentAccountHome != "" {
|
||||
return n.AgentAccountHome
|
||||
}
|
||||
return "/home/" + n.AgentAccount
|
||||
}
|
||||
|
||||
// Silent is how long since this node was last heard from, and whether it ever was.
|
||||
func (n Node) Silent() (time.Duration, bool) {
|
||||
if n.LastSeen.IsZero() {
|
||||
@@ -147,14 +167,14 @@ func (i *Inventory) AddNodeAs(ctx context.Context, name string, adopted bool) (N
|
||||
// nodeColumns and scanNode are the one reading of a node row, so every way of finding a node
|
||||
// says whether it is adopted.
|
||||
const nodeColumns = `id, name, created, last_seen, adopted, adopted_since, account, account_home,
|
||||
host_version`
|
||||
agent_account, agent_account_home, host_version`
|
||||
|
||||
func scanNode(row pgx.Row) (Node, error) {
|
||||
var n Node
|
||||
var seen, since *time.Time
|
||||
var host *string
|
||||
if err := row.Scan(&n.ID, &n.Name, &n.Created, &seen, &n.Adopted, &since,
|
||||
&n.Account, &n.AccountHome, &host); err != nil {
|
||||
&n.Account, &n.AccountHome, &n.AgentAccount, &n.AgentAccountHome, &host); err != nil {
|
||||
return Node{}, err
|
||||
}
|
||||
if host != nil {
|
||||
@@ -172,7 +192,23 @@ func scanNode(row pgx.Row) (Node, error) {
|
||||
// SetAccount records the operator account on a node — its human login — and optionally where that
|
||||
// account's home is (novox/hq to-be 29). An empty home means the mesh derives it. Clearing the
|
||||
// account (empty name) is allowed: a machine may stop having a known operator.
|
||||
//
|
||||
// **Never the node's agent account** (novox/hq ADR 0266): the operator account may become root, and the
|
||||
// agent account exists so agents cannot; naming the one as the other gives agents root. Refused here as
|
||||
// SetAgentAccount refuses the other direction.
|
||||
func (i *Inventory) SetAccount(ctx context.Context, node, account, home string) error {
|
||||
account = strings.TrimSpace(account)
|
||||
if account != "" {
|
||||
n, err := i.NodeByName(ctx, node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n.AgentAccount != "" && n.AgentAccount == account {
|
||||
return fmt.Errorf("%s is %s's agent account: the operator account may become root, and agents run as "+
|
||||
"%s so that they cannot (novox/hq ADR 0266); clear the agent account first "+
|
||||
"(node agent-account %s --clear) if the operator is to log in as it", account, node, account, node)
|
||||
}
|
||||
}
|
||||
tag, err := i.store.Pool().Exec(ctx,
|
||||
`update node set account = $1, account_home = $2 where name = $3`, account, home, node)
|
||||
if err != nil {
|
||||
@@ -184,6 +220,86 @@ func (i *Inventory) SetAccount(ctx context.Context, node, account, home string)
|
||||
return nil
|
||||
}
|
||||
|
||||
// loginName is what a login may be called: what useradd accepts by default, lower case, a letter or
|
||||
// an underscore first.
|
||||
var loginName = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}$`)
|
||||
|
||||
// SetAgentAccount records the account agents run as on a node, and optionally its home (novox/hq ADR
|
||||
// 0266). An empty account clears it: agents run as the operator account again.
|
||||
//
|
||||
// **Refused, rather than recorded and judged later:** root, which is the very thing the account exists
|
||||
// to keep agents from; the node's operator account, which may become root without a password and is
|
||||
// what agents ran as before — naming it here would say the agents have an account of their own while
|
||||
// they do not; and a name no machine would accept as a login.
|
||||
func (i *Inventory) SetAgentAccount(ctx context.Context, node, account, home string) error {
|
||||
account, home = strings.TrimSpace(account), strings.TrimSpace(home)
|
||||
if account == "" && home != "" {
|
||||
return errors.New("a home without an agent account says nothing; name the account too")
|
||||
}
|
||||
if account != "" {
|
||||
if err := AgentAccountRefusal(account, home); err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := i.NodeByName(ctx, node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n.Account != "" && n.Account == account {
|
||||
return fmt.Errorf("%s is %s's operator account: agents would run as the operator, who may become "+
|
||||
"root; clear the agent account instead (node agent-account %s --clear)", account, node, node)
|
||||
}
|
||||
}
|
||||
tag, err := i.store.Pool().Exec(ctx,
|
||||
`update node set agent_account = $1, agent_account_home = $2 where name = $3`, account, home, node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("%w: %s", ErrNoSuchNode, node)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// serviceAccounts are the system and service accounts a machine of the mesh has, or a module of the
|
||||
// catalogue declares (showcase, and the accounts ADR 0259 gives the router and the channels). The controller
|
||||
// cannot read a machine's user database, so this list is the controller's half; the node-engine's half is
|
||||
// refusing to take an existing account below the first login uid as one that must never become root.
|
||||
var serviceAccounts = map[string]bool{
|
||||
"root": true, "bin": true, "daemon": true, "sys": true, "adm": true, "nobody": true, "mail": true,
|
||||
"ftp": true, "http": true, "www-data": true, "git": true, "sshd": true, "dbus": true, "polkitd": true,
|
||||
"postgres": true, "docker": true, "nats": true, "redis": true, "uuidd": true, "dnsmasq": true,
|
||||
"avahi": true, "rtkit": true, "colord": true, "geoclue": true, "tss": true, "alpm": true, "usbmux": true,
|
||||
"showcase": true, "messenger": true, "telegram": true,
|
||||
}
|
||||
|
||||
// serviceAccount says whether a name is a system or service account: one of the list, or a name of
|
||||
// systemd's own (systemd-…).
|
||||
func serviceAccount(name string) bool {
|
||||
return serviceAccounts[name] || strings.HasPrefix(name, "systemd-")
|
||||
}
|
||||
|
||||
// AgentAccountRefusal is why an agent account cannot be named, or nil: root, a malformed login, or a
|
||||
// home that is not an absolute path.
|
||||
func AgentAccountRefusal(account, home string) error {
|
||||
if account == "root" {
|
||||
return errors.New("agents may not run as root: the agent account exists to keep them from it " +
|
||||
"(novox/hq ADR 0266)")
|
||||
}
|
||||
if !loginName.MatchString(account) {
|
||||
return fmt.Errorf("%q is not a login name: lower case letters, digits, _ and -, a letter or _ first, "+
|
||||
"at most 32", account)
|
||||
}
|
||||
if serviceAccount(account) {
|
||||
return fmt.Errorf("%q is a system or service account a machine or a module already has: the agent "+
|
||||
"account is one the mesh creates for agents alone, which nothing else runs as or owns files as "+
|
||||
"(novox/hq ADR 0266); name a new one, such as agent", account)
|
||||
}
|
||||
if home != "" && !strings.HasPrefix(home, "/") {
|
||||
return fmt.Errorf("the agent account's home %q is not an absolute path", home)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Nodes are every node record, oldest first.
|
||||
func (i *Inventory) Nodes(ctx context.Context) ([]Node, error) {
|
||||
rows, err := i.store.Pool().Query(ctx,
|
||||
|
||||
@@ -420,6 +420,20 @@ const LivenessContract = 1
|
||||
// because an older one parses strictly and would refuse the whole declaration for it.
|
||||
const ReadinessContract = 2
|
||||
|
||||
// RootContract is the statement of an engine that also judges a user's declared `root` (novox/hq ADR 0266):
|
||||
// whether an account declared never to become root without a person can — uid 0, a group that grants root,
|
||||
// a sudo rule, a secret of the mesh it may read. Only to such an engine is the field sent: an older one
|
||||
// parses strictly and would refuse the whole declaration for it.
|
||||
const RootContract = 3
|
||||
|
||||
// ReasonRoot starts the reason of an account verdict that found a way to root (ADR 0266); the node-engine's
|
||||
// own words (mesh-host internal/accounts ReasonRoot).
|
||||
const ReasonRoot = "can become root without a person"
|
||||
|
||||
// RootNever is the value of a user's `root`, and of a verdict's Root, that the account must never become
|
||||
// root without a person (ADR 0266).
|
||||
const RootNever = "never"
|
||||
|
||||
// Health is one statement of a machine's long-running resources (to-be 48 §4): in every report, as the
|
||||
// event HealthSubject between reports on each change, and again every minute while one is not healthy.
|
||||
// The node-engine's own (mesh-host internal/link Health); a test on each side holds the field names.
|
||||
@@ -550,6 +564,10 @@ type ResourceHealth struct {
|
||||
// manager, and the account itself for a resource of kind KindAccount (novox/hq ADR 0254). Empty from an
|
||||
// engine older than that, and for anything the machine's own manager or runtime runs.
|
||||
Account string `json:"account,omitempty"`
|
||||
// Root is "never" on a verdict of kind KindAccount whose account is declared never to become root
|
||||
// without a person (novox/hq ADR 0266): the engine judged that too, and a healthy verdict says it cannot.
|
||||
// Empty from an engine older than RootContract, and on every other verdict.
|
||||
Root string `json:"root,omitempty"`
|
||||
}
|
||||
|
||||
// HealthSaid is the health event's body: the machine and its statement. The machine is read from the
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
"retire",
|
||||
"cleanup",
|
||||
"dead-letters",
|
||||
"root-free",
|
||||
"data",
|
||||
"build",
|
||||
"artifacts",
|
||||
|
||||
+26
@@ -198,6 +198,12 @@ type File struct {
|
||||
// by looking rather than by knowing which field won.
|
||||
Sealed string `json:"sealed,omitempty"`
|
||||
|
||||
// Trusted is the catalogue's word on whether the settings this file's content took are trusted (novox/hq
|
||||
// issue 339). It is the controller's to act on, and a controller takes it out before it sends a declaration.
|
||||
// Accepted here, and nothing is done with it, so that a controller that passes it on — an older one, or one
|
||||
// rolled back to — never costs a node its whole declaration. Not part of the file's digest.
|
||||
Trusted *bool `json:"trusted,omitempty"`
|
||||
|
||||
// Secrets are sealed values put into Content where it says `${secret:name}`.
|
||||
//
|
||||
// **The one place a secret and a configuration meet, and it happens on the machine.** A
|
||||
@@ -383,8 +389,25 @@ type User struct {
|
||||
// has it not. On the account rather than on the unit, because it is the account's: two units of
|
||||
// one account cannot disagree about it, and undeclaring one of them must not stop the other.
|
||||
Linger *bool `json:"linger,omitempty"`
|
||||
|
||||
// Root says whether this account may become root without a person (novox/hq ADR 0266). "never"
|
||||
// is the agents' own account: the login an agent session runs as on a machine where it must not
|
||||
// reach root by itself. Empty asserts nothing, as Shell's does.
|
||||
//
|
||||
// **A statement the engine judges, never one it acts on.** The apply gives an account it creates
|
||||
// no password, no sudo rule and no group beyond those declared, as it always has, and takes none
|
||||
// away from one it finds: a sudo rule or a group granted by hand is a person's to remove, and a
|
||||
// declaration that silently stripped them would be the mesh deciding what a person's machine
|
||||
// grants. What "never" adds is the look: on every look the engine reads whether the account can
|
||||
// become root by itself — by its uid, a group that grants root, any sudo rule, or a secret the mesh
|
||||
// placed that it can read — and says it unhealthy while it can (internal/accounts), so the
|
||||
// controller can tell a machine where it holds from one where it does not.
|
||||
Root string `json:"root,omitempty"`
|
||||
}
|
||||
|
||||
// RootNever is the one value Root takes besides empty: the account never becomes root without a person.
|
||||
const RootNever = "never"
|
||||
|
||||
// Network is a named network on this machine.
|
||||
//
|
||||
// **A name and nothing else.** Not a driver, a subnet or a gateway: each of those is something a
|
||||
@@ -478,6 +501,9 @@ func (u *User) validate(where string, _ bool) []string {
|
||||
if u.Home != "" && !strings.HasPrefix(u.Home, "/") {
|
||||
problems = append(problems, where+": a home directory is an absolute path")
|
||||
}
|
||||
if u.Root != "" && u.Root != RootNever {
|
||||
problems = append(problems, fmt.Sprintf("%s: root is %q or absent, and %q is neither", where, RootNever, u.Root))
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
|
||||
Vendored
+2
-2
@@ -75,7 +75,7 @@ github.com/nats-io/nkeys
|
||||
# github.com/nats-io/nuid v1.0.1
|
||||
## explicit
|
||||
github.com/nats-io/nuid
|
||||
# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261007162834-56e2ebec4bac
|
||||
# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261009081005-b28d7bbcbff4
|
||||
## explicit; go 1.26.0
|
||||
github.com/novox/mesh-host/internal/declaration
|
||||
github.com/novox/mesh-host/validate
|
||||
@@ -133,4 +133,4 @@ golang.org/x/text/width
|
||||
# golang.org/x/time v0.15.0
|
||||
## explicit; go 1.25.0
|
||||
golang.org/x/time/rate
|
||||
# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261007162834-56e2ebec4bac
|
||||
# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261009081005-b28d7bbcbff4
|
||||
|
||||
Reference in New Issue
Block a user