nox cleanup delete without --confirm deletes nothing; the vault refuses it for its missing confirm alone and judges only a confirmed delete, and nox records the warranting act only for that one (issue 1126 review 2). Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
320 lines
17 KiB
Go
320 lines
17 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// The tests' own standard input is the runner's, which may be a pipe nobody closes: read as a terminal.
|
|
func init() { standardInput = func() (io.Reader, bool) { return nil, true } }
|
|
|
|
// asked is one call the stand-in console took: the console's tool, and for mesh_call the address and its arguments.
|
|
type asked struct {
|
|
Tool string
|
|
Address string
|
|
Args string
|
|
}
|
|
|
|
// console is a stand-in for this machine's console: it answers MCP tools/call over HTTP as node-tools does, with the
|
|
// answer the test gives per address (an error when it starts with "!"), and records what it was asked.
|
|
func console(t *testing.T, answers map[string]string) *[]asked {
|
|
t.Helper()
|
|
var got []asked
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/mcp" || r.Method != http.MethodPost {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
var req struct {
|
|
Method string `json:"method"`
|
|
Params struct {
|
|
Name string `json:"name"`
|
|
Arguments struct {
|
|
Address string `json:"address"`
|
|
Arguments map[string]any `json:"arguments"`
|
|
Query string `json:"query"`
|
|
} `json:"arguments"`
|
|
} `json:"params"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Method != "tools/call" {
|
|
t.Errorf("the console was asked %v %v", req.Method, err)
|
|
}
|
|
a := req.Params.Arguments
|
|
args, _ := json.Marshal(a.Arguments)
|
|
got = append(got, asked{req.Params.Name, a.Address, string(args)})
|
|
key := a.Address
|
|
if req.Params.Name != "mesh_call" {
|
|
key = req.Params.Name
|
|
}
|
|
text, ok := answers[key]
|
|
if !ok {
|
|
text = `{"ok":true}`
|
|
}
|
|
isError := strings.HasPrefix(text, "!")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": 1, "result": map[string]any{
|
|
"content": []any{map[string]any{"type": "text", "text": strings.TrimPrefix(text, "!")},
|
|
map[string]any{"type": "text", "text": "answered by ace"}}, "isError": isError}})
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
t.Setenv("MESH_CONSOLE", srv.URL+"/mcp")
|
|
t.Setenv("MESH_VERB", "")
|
|
return &got
|
|
}
|
|
|
|
func runLine(args ...string) (int, string, string) {
|
|
var out, errOut bytes.Buffer
|
|
code := run(context.Background(), args, &out, &errOut)
|
|
return code, out.String(), errOut.String()
|
|
}
|
|
|
|
// Every owned line calls the seat verb or module tool that owns its job, with the arguments the owner reads
|
|
// (novox/hq issue 1126; hq .work/1126/nox-map.md), and prints its answer.
|
|
func TestEachLineCallsItsOwner(t *testing.T) {
|
|
now = func() time.Time { return time.Date(2026, 11, 2, 10, 0, 0, 0, time.UTC) }
|
|
t.Cleanup(func() { now = time.Now })
|
|
for _, c := range []struct {
|
|
line string
|
|
address string
|
|
args string
|
|
}{
|
|
{"node show ace", "mesh-placement.placement", `{"node":"ace"}`},
|
|
{"node public-domain ace", "mesh-placement.placement", `{"node":"ace"}`},
|
|
{"node public-domain ace example.org --why moved", "mesh-placement.propose", `{"changes":[{"field":"public-domain","node":"ace","op":"decide","value":"example.org"}],"why":"moved"}`},
|
|
{"plan ace", "mesh-delivery-view.assigner_wants", `{"node":"ace"}`},
|
|
{"haves", "mesh-delivery-view.node_haves", `{}`},
|
|
{"composition ace plex", "ace/node-engine.composition", `{"module":"plex"}`},
|
|
{"seats", "mesh-placement.placement", `{"seats":"true"}`},
|
|
{"assign ace plex sonarr --why=media", "mesh-placement.propose", `{"changes":[{"module":"plex","node":"ace","op":"place"},{"module":"sonarr","node":"ace","op":"place"}],"why":"media"}`},
|
|
{"unassign ace plex --why gone", "mesh-placement.propose", `{"changes":[{"module":"plex","node":"ace","op":"take-off"}],"why":"gone"}`},
|
|
{"pin ace postgres-database novox postgres --why one", "mesh-placement.propose", `{"changes":[{"node":"ace","op":"pin","provider":{"module":"postgres","node":"novox"},"provision":"postgres-database"}],"why":"one"}`},
|
|
{"unpin ace postgres-database --why back", "mesh-placement.propose", `{"changes":[{"node":"ace","op":"unpin","provision":"postgres-database"}],"why":"back"}`},
|
|
{"deliveries --state merged --json", "mesh-delivery.deliveries", `{"state":"merged"}`},
|
|
{"delivery novox/mesh-sdk@dff0ce2abb21", "mesh-delivery.show", `{"id":"novox/mesh-sdk@dff0ce2abb21"}`},
|
|
{"checks novox/mesh-catalog --number 591", "mesh-delivery.checks", `{"number":"591","repository":"novox/mesh-catalog"}`},
|
|
{"try novox/mesh-catalog abc123 --level unit", "mesh-delivery-checker.try", `{"head":"abc123","level":"unit","repository":"novox/mesh-catalog"}`},
|
|
{"builds", "mesh-delivery-builder.builder_builds_since", `{"since":"2026-11-01T10:00:00Z"}`},
|
|
{"upgrade nats --policy record --why bus", "mesh-wants.upgrade", `{"module":"nats","policy":"record","why":"bus"}`},
|
|
{"bus", "mesh-wants.bus", `{}`},
|
|
{"conditions --severity urgent", "mesh-conditions.conditions", `{"severity":"urgent"}`},
|
|
{"conditions show machine.g14.root-not-free", "mesh-conditions.conditions", `{"key":"machine.g14.root-not-free"}`},
|
|
{"conditions silence k1 --for 4h --why known", "mesh-conditions.silence", `{"for":"4h","key":"k1","why":"known"}`},
|
|
{"conditions history --days 3", "mesh-conditions.conditions", `{"days":"3","history":"true"}`},
|
|
{"conditions history --key k1", "mesh-conditions.conditions_history", `{"key":"k1"}`},
|
|
{"doctor", "mesh-self-check.doctor", `{}`},
|
|
{"doctor run", "mesh-self-check.doctor", `{"run":true}`},
|
|
{"doctor probe D6", "mesh-self-check.doctor", `{"probe":"D6"}`},
|
|
{"healers --days 2", "mesh-healers.healers", `{"days":2}`},
|
|
{"hand-act record restarted the view --why stuck --cause view", "mesh-hand-acts.hand-act", `{"cause":"view","what":"restarted the view","why":"stuck"}`},
|
|
{"hand-acts --days 3", "mesh-hand-acts.hand-acts", `{"days":3}`},
|
|
{"settings show zsh --node g14", "mesh-config.config", `{"action":"list","module":"zsh","node":"g14"}`},
|
|
{"settings set zsh execute withhold --node g14 --why test", "mesh-config.config", `{"action":"set","key":"execute","module":"zsh","node":"g14","value":"withhold","why":"test"}`},
|
|
{"settings clear zsh execute --node g14 --why test", "mesh-config.config", `{"action":"clear","key":"execute","module":"zsh","node":"g14","why":"test"}`},
|
|
{"config history --key k --limit 5", "mesh-config.config", `{"action":"history","key":"k","limit":5}`},
|
|
{"operator key show", "mesh-vault.vault_status", `{}`},
|
|
// A cleanup without --confirm deletes nothing, so no hand act is recorded for it (issue 1126 review 2).
|
|
{"cleanup delete ace.plex --why dry", "mesh-vault.provisioner_delete", `{"consumer":"ace.plex","why":"dry"}`},
|
|
{"collect --confirm --why space", "mesh-artifact-sweep.collect", `{"confirm":"true","why":"space"}`},
|
|
{"images ace", "mesh-artifact-sweep.images", `{"node":"ace"}`},
|
|
{"licence use g14 work", "anthropic-licence-manager.switch", `{"consumer":"g14","licence":"work"}`},
|
|
{"module show plex", "mesh-delivery-view.indexer_module", `{"module":"plex"}`},
|
|
{"issues apostrophe bug --status open --limit 5", "issue-tracker.list", `{"limit":5,"search":"apostrophe bug","status":"open"}`},
|
|
{"issue 1126 --brief", "issue-tracker.get", `{"brief":true,"number":1126}`},
|
|
{"kb ask what holds about the merge window", "knowledge-base.ask", `{"question":"what holds about the merge window"}`},
|
|
{"kb get 587 --reasoning", "knowledge-base.get", `{"number":587,"reasoning":true}`},
|
|
{"progress 47", "knowledge-base.progress", `{"of":"design 47"}`},
|
|
{"enrol issue new1 --why laptop --expires 2h", "mesh-enrolment.issue", `{"expires":"2h","node":"new1","why":"laptop"}`},
|
|
{"token issue --new new1 --why laptop", "mesh-enrolment.issue", `{"node":"new1","why":"laptop"}`},
|
|
{"enrol tokens", "mesh-enrolment.tokens", `{}`},
|
|
{"call ace/node-service-manager.status {\"unit\":\"plex\"}", "ace/node-service-manager.status", `{"unit":"plex"}`},
|
|
{"ask mesh-catalog catalog_modules", "mesh-catalog.catalog_modules", `{}`},
|
|
} {
|
|
got := console(t, nil)
|
|
args := strings.Fields(c.line)
|
|
if strings.HasPrefix(c.line, "call ") {
|
|
args = []string{"call", "ace/node-service-manager.status", `{"unit":"plex"}`}
|
|
}
|
|
code, out, errOut := runLine(args...)
|
|
if code != 0 || len(*got) != 1 || (*got)[0].Tool != "mesh_call" || (*got)[0].Address != c.address || (*got)[0].Args != c.args {
|
|
t.Errorf("nox %s: exit %d, asked %+v, stderr %q; want %s %s", c.line, code, *got, errOut, c.address, c.args)
|
|
continue
|
|
}
|
|
if !strings.Contains(out, `"ok":true`) {
|
|
t.Errorf("nox %s printed %q, not the owner's answer", c.line, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A line the controller ran that no seat owns says so and what to use, exits 1 and calls nothing; a line nox cannot
|
|
// read says how it is written, exits 2 and calls nothing.
|
|
func TestALineWithNoOwnerOrNotReadCallsNothingAndSaysWhy(t *testing.T) {
|
|
for _, c := range []struct {
|
|
line string
|
|
code int
|
|
err string
|
|
}{
|
|
{"push ace", 1, "mesh-delivery-assigner.assigner_put"},
|
|
{"migrate", 1, "no seat or module does this now"},
|
|
{"secret accept ace plex token", 1, "mesh-vault.secret-ask"},
|
|
{"node add new1", 1, "nox enrol issue"},
|
|
{"pause", 1, "node-build-go.pause"},
|
|
{"operator key make", 1, "operator key show"},
|
|
{"assign ace plex", 2, "needs --why"},
|
|
{"issue twelve", 2, "not an issue's number"},
|
|
{"conditions --bogus x", 2, "--bogus is not a flag"},
|
|
{"settings set zsh {\"execute\":\"withhold\"} --node g14 --why x", 2, "set each key"},
|
|
{"healers --days soon", 2, "takes a number"},
|
|
{"frobnicate", 2, "is not a command"},
|
|
} {
|
|
got := console(t, nil)
|
|
args := strings.Fields(c.line)
|
|
code, _, errOut := runLine(args...)
|
|
if code != c.code || len(*got) != 0 || !strings.Contains(errOut, c.err) {
|
|
t.Errorf("nox %s: exit %d, asked %+v, stderr %q; want exit %d saying %q", c.line, code, *got, errOut, c.code, c.err)
|
|
}
|
|
if c.code == 1 && !strings.Contains(errOut, "issue 1126") {
|
|
t.Errorf("nox %s does not say the controller is unassigned: %q", c.line, errOut)
|
|
}
|
|
}
|
|
}
|
|
|
|
// What the console says is printed as said: an answer it marks an error on standard error with exit 1, a console that
|
|
// does not answer as nothing called, a verb's process refused before anything is called; nodes are one line each.
|
|
func TestAnswersRefusalsAndSilenceAreSaid(t *testing.T) {
|
|
got := console(t, map[string]string{"mesh-enrolment.tokens": "!nothing serves seat:mesh-enrolment.tokens"})
|
|
if code, out, errOut := runLine("enrol", "tokens"); code != 1 || out != "" || !strings.Contains(errOut, "nothing serves") {
|
|
t.Errorf("an error answer: exit %d, stdout %q, stderr %q", code, out, errOut)
|
|
}
|
|
placement := `{"keys":{"novox":{"placement":{"decided":{"address":"10.10.0.1","site":"hub"},"modules":[{},{}]}},` +
|
|
`"ace":{"placement":{"decided":{"address":"10.10.0.2"},"modules":[{}]}}}}`
|
|
console(t, map[string]string{"mesh-placement.placement": placement})
|
|
if code, out, _ := runLine("nodes"); code != 0 || !strings.HasPrefix(out, "ace ") ||
|
|
!strings.Contains(out, "10.10.0.1") || !strings.Contains(out, "2 modules") || !strings.HasSuffix(out, "answered by ace\n") {
|
|
t.Errorf("nodes: exit %d, %q", code, out)
|
|
}
|
|
got = console(t, map[string]string{"mesh_search": `{"matches":[]}`})
|
|
if code, out, _ := runLine("search", "restart", "a", "unit"); code != 0 || (*got)[0].Tool != "mesh_search" || !strings.Contains(out, "matches") {
|
|
t.Errorf("search: exit %d, asked %+v, %q", code, *got, out)
|
|
}
|
|
t.Setenv("MESH_CONSOLE", "http://127.0.0.1:1/mcp")
|
|
if code, _, errOut := runLine("conditions"); code != 1 || !strings.Contains(errOut, "nothing was called") {
|
|
t.Errorf("no console: exit %d, %q", code, errOut)
|
|
}
|
|
t.Setenv("MESH_VERB", "some.verb")
|
|
if code, _, errOut := runLine("conditions"); code != 1 || !strings.Contains(errOut, "a verb is never the operator") {
|
|
t.Errorf("a verb's process: exit %d, %q", code, errOut)
|
|
}
|
|
}
|
|
|
|
// The status is the delivery view's. When the view does not answer, that is said first, the conditions and the
|
|
// deliveries are shown under their own addresses, and the exit is 1: they are never passed off as the status.
|
|
func TestStatusIsTheViewsAndItsSilenceIsSaid(t *testing.T) {
|
|
got := console(t, map[string]string{"mesh-delivery-view.view_status": `{"well":true}`})
|
|
if code, out, _ := runLine("status"); code != 0 || len(*got) != 1 || !strings.Contains(out, `"well":true`) {
|
|
t.Errorf("status: exit %d, asked %+v, %q", code, *got, out)
|
|
}
|
|
got = console(t, map[string]string{"mesh-delivery-view.view_status": "!mesh-delivery-view.view_status did not answer within 30s",
|
|
"mesh-conditions.conditions": `{"conditions":[]}`, "mesh-delivery.deliveries": `{"deliveries":[]}`})
|
|
code, out, errOut := runLine("status")
|
|
if code != 1 || len(*got) != 3 || !strings.Contains(errOut, "did not answer") || !strings.Contains(errOut, "not the status") ||
|
|
!strings.Contains(out, "== mesh-conditions.conditions") || !strings.Contains(out, "== mesh-delivery.deliveries") {
|
|
t.Errorf("status, the view silent: exit %d, asked %+v, stdout %q, stderr %q", code, *got, out, errOut)
|
|
}
|
|
}
|
|
|
|
// The console's address comes from the file the module writes from its mcp-endpoint; MESH_CONSOLE overrides it; with
|
|
// neither, the port node-tools declares. An answer given as a server-sent event is read like one given as JSON.
|
|
func TestTheConsoleIsFoundAndItsAnswerRead(t *testing.T) {
|
|
t.Setenv("MESH_CONSOLE", "")
|
|
dir := t.TempDir()
|
|
before := consoleFile
|
|
consoleFile = filepath.Join(dir, "console")
|
|
t.Cleanup(func() { consoleFile = before })
|
|
if url, _ := consoleAddress(); url != defaultConsole {
|
|
t.Errorf("no file: %s", url)
|
|
}
|
|
if err := os.WriteFile(consoleFile, []byte("http://127.0.0.1:4999/mcp\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if url, from := consoleAddress(); url != "http://127.0.0.1:4999/mcp" || from != consoleFile {
|
|
t.Errorf("the file: %s from %s", url, from)
|
|
}
|
|
t.Setenv("MESH_CONSOLE", "http://127.0.0.1:5000/mcp")
|
|
if url, _ := consoleAddress(); url != "http://127.0.0.1:5000/mcp" {
|
|
t.Errorf("the variable: %s", url)
|
|
}
|
|
a, err := decodeAnswer([]byte("event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"hi\"}],\"isError\":true}}\n\n"))
|
|
if err != nil || a.Text != "hi" || !a.IsError {
|
|
t.Errorf("an event: %+v %v", a, err)
|
|
}
|
|
if _, err := decodeAnswer([]byte("<html>")); err == nil {
|
|
t.Error("a page that is no answer was read as one")
|
|
}
|
|
}
|
|
|
|
// Every command's line is in the help, and none is defined twice.
|
|
func TestEveryLineIsInTheHelpOnce(t *testing.T) {
|
|
seen := map[string]bool{}
|
|
help := usage()
|
|
for _, c := range commands {
|
|
w := strings.Join(c.words, " ")
|
|
if seen[w] {
|
|
t.Errorf("nox %s is defined twice", w)
|
|
}
|
|
seen[w] = true
|
|
if c.build != nil && !strings.Contains(help, "nox "+w+" ") {
|
|
t.Errorf("nox %s is not in the help", w)
|
|
}
|
|
if c.build == nil && c.instead == "" {
|
|
t.Errorf("nox %s has neither an owner nor an instead", w)
|
|
}
|
|
if _, dup := unowned[c.words[0]]; dup && len(c.words) == 1 {
|
|
t.Errorf("nox %s is both owned and unowned", w)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ADR 0590 §8 (issue 1126 review 1): each sensitive vault verb judges its own caller, and nox reaches the vault as its
|
|
// machine's runtime, so nox first records the hand act that warrants the line through mesh-hand-acts (the vault verb as
|
|
// its verb, what it acts on as its what) and names it as `act`; a recording refused calls the vault not at all.
|
|
func TestASensitiveLineRecordsItsHandActAndNamesIt(t *testing.T) {
|
|
recorded := `{"act":{"id":"act-7","verb":"mesh-vault.x"},"said":"recorded"}`
|
|
for _, c := range []struct{ line, address, verb, what, args string }{
|
|
{"secret export", "mesh-vault.secret_vault_export", "mesh-vault.secret_vault_export", "the vault's sealed copies", `{"act":"act-7"}`},
|
|
{"rotate --node ace --module plex --name token --why leak", "mesh-vault.rotate", "mesh-vault.rotate", "own/ace/plex/token",
|
|
`{"act":"act-7","module":"plex","name":"token","node":"ace","why":"leak"}`},
|
|
{"rotate --provider novox --provision postgres --why yearly", "mesh-vault.rotate", "mesh-vault.rotate", "provision/novox/postgres",
|
|
`{"act":"act-7","provider":"novox","provision":"postgres","why":"yearly"}`},
|
|
{"retire approve c1 c2 --why done", "mesh-vault.provisioner_retire_approve", "mesh-vault.provisioner_retire_approve", "c1 c2",
|
|
`{"act":"act-7","consumers":["c1","c2"],"why":"done"}`},
|
|
{"cleanup delete ace.plex --why done --confirm", "mesh-vault.provisioner_delete", "mesh-vault.provisioner_delete", "ace.plex",
|
|
`{"act":"act-7","confirm":"ace.plex","consumer":"ace.plex","why":"done"}`},
|
|
} {
|
|
got := console(t, map[string]string{"mesh-hand-acts.hand-act": recorded})
|
|
code, _, errOut := runLine(strings.Fields(c.line)...)
|
|
if code != 0 || len(*got) != 2 || (*got)[0].Address != "mesh-hand-acts.hand-act" || (*got)[1].Address != c.address ||
|
|
(*got)[1].Args != c.args {
|
|
t.Errorf("nox %s: exit %d, asked %+v, stderr %q", c.line, code, *got, errOut)
|
|
continue
|
|
}
|
|
var act map[string]any
|
|
_ = json.Unmarshal([]byte((*got)[0].Args), &act)
|
|
if act["verb"] != c.verb || act["what"] != c.what || act["why"] == "" || act["cause"] == "" {
|
|
t.Errorf("nox %s recorded %v", c.line, act)
|
|
}
|
|
}
|
|
got := console(t, map[string]string{"mesh-hand-acts.hand-act": "!nothing serves seat:mesh-hand-acts.hand-act"})
|
|
if code, _, errOut := runLine("secret", "export"); code != 1 || len(*got) != 1 || !strings.Contains(errOut, "nothing serves") {
|
|
t.Errorf("a refused recording: exit %d, asked %+v, %q", code, *got, errOut)
|
|
}
|
|
}
|