mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery failed: its walk failed: a gate on a first machine (what it carried put back), a build, a machine
The node-engine's own since starts again at every restart, so an agent that restarted the engine in a loop kept agent-can-become-root quiet for ever (the review of 2026-10-09). The controller now keeps when it first saw the verdict waiting for the setuid search (migration 0085), forgets it at the next complete verdict, and raises once the engine's own bound has passed since; the bound and the pending reason are read from mesh-host's rootsearch.
274 lines
12 KiB
Go
274 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"github.com/novox/mesh-host/rootsearch"
|
|
"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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// After every node-engine restart its search for setuid programs runs for up to its bound, and the agent account
|
|
// is not judged until it ends. DA does not raise that as urgent while the search is within its bound, counted from
|
|
// when this controller first saw it waiting — never from the engine's own "since", which a restart resets, so an
|
|
// engine restarted in a loop does not keep it quiet. The account is still not confined, and `node show` still
|
|
// says not judged; a search that failed, or a way to root found, is urgent at once.
|
|
func TestASearchStillRunningAfterARestartIsNotUrgent(t *testing.T) {
|
|
if searchQuietFor != rootsearch.Bound {
|
|
t.Fatalf("the quiet is %s and the node-engine's bound %s: they are one value", searchQuietFor, rootsearch.Bound)
|
|
}
|
|
open := aMesh(t)
|
|
ctx := t.Context()
|
|
inv := open.inventory
|
|
if _, err := inv.NodeByName(ctx, "anchor"); err != nil {
|
|
if _, err := inv.AddNode(ctx, "anchor"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := inv.SetAgentAccount(ctx, "anchor", "agent", ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
d := &doctor{open: open}
|
|
say := func(state, reason string) []conditions.Observation {
|
|
t.Helper()
|
|
// The engine's since is always now: it was just restarted.
|
|
v := inventory.ResourceHealth{Module: "claude-code", Resource: "claude-code.agent-account",
|
|
Kind: link.KindAccount, Target: "agent", State: state, Reason: reason, Root: link.RootNever,
|
|
Account: "agent", Since: time.Now()}
|
|
if _, err := inv.RecordHealth(ctx, inventory.NodeHealth{Node: "anchor", Contract: link.RootContract,
|
|
SaidAt: time.Now(), HeardAt: time.Now(), Resources: []inventory.ResourceHealth{v}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
found, err := probeAgentAccounts(ctx, d)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return onlyMachine(found, "anchor")
|
|
}
|
|
running := link.ReasonRootPending + ": the search for setuid programs, started at 19:21, has not finished yet"
|
|
healthy := func() {
|
|
t.Helper()
|
|
if found := say(link.StateHealthy, ""); len(found) != 0 {
|
|
t.Fatalf("a healthy verdict raised: %+v", found)
|
|
}
|
|
}
|
|
if found := say(link.StateUnknown, running); len(found) != 0 {
|
|
t.Fatalf("a search first seen now was raised: %+v", found)
|
|
}
|
|
if _, confined, why, _ := agentConfined(ctx, inv, "anchor"); confined || !strings.Contains(why, "not judged") {
|
|
t.Fatalf("an account whose search runs was read as confined: %q", why)
|
|
}
|
|
// The engine restarted again and again, each statement's own since fresh: the controller's clock runs on.
|
|
if _, err := inv.RootSearchPending(ctx, "anchor", time.Now().Add(-searchQuietFor-time.Minute)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
healthy() // a complete verdict forgets when the waiting began …
|
|
if _, err := inv.RootSearchPending(ctx, "anchor", time.Now().Add(-searchQuietFor-time.Minute)); err != nil {
|
|
t.Fatal(err) // … and this restart loop began past the bound
|
|
}
|
|
if found := say(link.StateUnknown, running); len(found) != 1 || found[0].Severity != conditions.Urgent {
|
|
t.Fatalf("a search pending past the bound, by the controller's clock, was not urgent: %+v", found)
|
|
}
|
|
healthy()
|
|
incomplete := rootsearch.ReasonIncomplete + ": the search for setuid programs did not finish (last tried at " +
|
|
"19:23: timeout); it is tried again later"
|
|
if found := say(link.StateUnknown, incomplete); len(found) != 1 || found[0].Severity != conditions.Urgent {
|
|
t.Fatalf("a search that did not finish was not urgent: %+v", found)
|
|
}
|
|
if found := say(link.StateUnhealthy, link.ReasonRoot+": in the group docker; "+running); len(found) != 1 ||
|
|
found[0].Severity != conditions.Urgent {
|
|
t.Fatalf("a way to root found while the search runs was not urgent: %+v", found)
|
|
}
|
|
}
|