diff --git a/modules/claude-code/README.md b/modules/claude-code/README.md index cbdde015..89a55134 100644 --- a/modules/claude-code/README.md +++ b/modules/claude-code/README.md @@ -94,8 +94,8 @@ another node. So these tools refuse, and say why and how it is done instead: - `claude_code_hook_register`, `claude_code_settings_set`, `claude_code_permission_add`, `claude_code_permission_remove`: the operator sets hooks, permission rules and every other managed setting in this module's `managed_settings` - setting, `mesh-controller settings set claude-code '{"managed_settings": {…}}' [--node ]`, typed at the - controller's terminal. The controller refuses `managed_settings`, `mcp_servers` and `role` through any verb: a + setting, `mesh-cli settings set claude-code '{"managed_settings": {…}}' [--node ]`, typed at the + controller's terminal (`mesh-cli`, or `nox`, on the control-node; hq ADR 0272). The controller refuses `managed_settings`, `mcp_servers` and `role` through any verb: a mergeable file asks for every key its own content names; - `claude_code_mcp_register`: the operator sets tool servers in the `mcp_servers` setting the same way; - a skill, subagent or command that would run something by itself: a shell line the agent runs when it loads one @@ -117,7 +117,7 @@ decision of 2026-10-09; a surface of the operator's own may update it later). In skills, commands and subagents at the `mesh` or `node` scope are refused through the tools, and the import tool is refused. They are keys of this module's settings, each text a list of its lines, because a setting holds no line break: `instructions`, `output_styles`, `commands` and `agents` map a name to its text; `skills` maps a name to its -files, each a list of lines. For example `mesh-controller settings set claude-code '{"instructions": {"conventions": +files, each a list of lines. For example `mesh-cli settings set claude-code '{"instructions": {"conventions": ["Commit in the imperative.", "", "Test before pushing."]}}'`. They land where a registration did, laid over what was registered before; what the operator writes there is not judged, but an item that is not well formed is left out and said in the render's answer. What was registered before is still rendered and may be unregistered. @@ -156,8 +156,8 @@ A new session takes a change; a running one at `/reload-plugins`. ## Settings -Per node or for the whole mesh, at the controller's terminal alone: `mesh-controller settings set claude-code '' -[--node ]`. The controller refuses all three keys through any verb, `mesh-controller.settings` included, because +Per node or for the whole mesh, at the controller's terminal alone: `mesh-cli settings set claude-code '' +[--node ]` on the control-node (in zsh, `nox`; hq ADR 0272). The controller refuses all three keys through any verb, `mesh-controller.settings` included, because each reaches every session on the node and a verb's caller may be an agent (hq issue 340). Reading them through the verb still answers. diff --git a/modules/claude-code/cmd/claude-code/config.go b/modules/claude-code/cmd/claude-code/config.go index fb14151e..3eb1a28e 100644 --- a/modules/claude-code/cmd/claude-code/config.go +++ b/modules/claude-code/cmd/claude-code/config.go @@ -86,8 +86,8 @@ var nodeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`) // AtTheTerminal is how a hook, a permission rule or any other managed setting is set now (novox/hq issue 340): // in this module's `managed_settings` setting, at the controller's terminal, which refuses it through a verb. -const AtTheTerminal = "mesh-controller settings set claude-code '{\"managed_settings\": {...}}' [--node ], " + - "typed by the operator at the controller's terminal" +const AtTheTerminal = "mesh-cli settings set claude-code '{\"managed_settings\": {...}}' [--node ], " + + "typed by the operator at the controller's terminal: mesh-cli (nox) on the control-node (novox/hq ADR 0272)" // RunsInASession says why a kind is never registered through this module: what it holds runs in, or is obeyed // by, every session it reaches, as whoever starts that session (novox/hq issue 340). @@ -935,7 +935,7 @@ func SharedTextAtTheTerminal(kind string) string { KindOutputStyle: "output_styles", KindInstructions: "instructions"}[kind] return "a " + kind + " for every node or some reaches every session there as the mesh's own word, and this tool " + "cannot tell an agent from the operator. It is set at the controller's terminal only, under `" + key + - "` in this module's settings, its text as a list of lines: mesh-controller settings set claude-code " + + "` in this module's settings, its text as a list of lines, on the control-node: mesh-cli settings set claude-code " + "'{\"" + key + "\": {\"\": [\"line\", …]}}' [--node ] (novox/hq issue 340). The home scope, " + "one account's own directory, is still registered here. Nothing was registered" } diff --git a/modules/claude-code/cmd/claude-code/node.go b/modules/claude-code/cmd/claude-code/node.go index f17c9430..6d128e98 100644 --- a/modules/claude-code/cmd/claude-code/node.go +++ b/modules/claude-code/cmd/claude-code/node.go @@ -604,7 +604,7 @@ func OnServerChange(v *ServerView, c ServerChange, p Paths, write WriteManaged) const ServerAtTheTerminal = "a tool server is started, or handed what the agent asks it, by every session on the nodes it " + "reaches, as whoever starts that session — the operator, whose account holds root — and the tool runner is not " + "told who called it, so an agent on another node could register one. It is set at the controller's terminal " + - "only, in this module's mcp_servers setting: mesh-controller settings set claude-code '{\"mcp_servers\": {...}}' " + + "only, in this module's mcp_servers setting, on the control-node: mesh-cli settings set claude-code '{\"mcp_servers\": {...}}' " + "[--node ] (novox/hq issue 340). Nothing was registered" // RegisterServer unregisters a server: a delete per scope in the `servers` state, taken into this node's view at diff --git a/modules/claude-code/cmd/claude-code/terminal_only_test.go b/modules/claude-code/cmd/claude-code/terminal_only_test.go index 2445d5a0..b376ab73 100644 --- a/modules/claude-code/cmd/claude-code/terminal_only_test.go +++ b/modules/claude-code/cmd/claude-code/terminal_only_test.go @@ -130,7 +130,7 @@ func TestWhatASessionRunsIsRefusedThroughEveryTool(t *testing.T) { // The settings and the tool server refusals name the terminal's line. text, _ := s.call(t, "claude_code_settings_set", map[string]any{"settings": map[string]any{"model": "x"}}) - if !strings.Contains(text, "mesh-controller settings set claude-code") { + if !strings.Contains(text, "mesh-cli settings set claude-code") { t.Errorf("the refusal does not say how it is done: %s", text) } text, _ = s.call(t, "claude_code_mcp_register", map[string]any{"name": "x", "url": "https://x.example"}) diff --git a/modules/mesh-cli/README.md b/modules/mesh-cli/README.md new file mode 100644 index 00000000..6dfb4cab --- /dev/null +++ b/modules/mesh-cli/README.md @@ -0,0 +1,64 @@ +# mesh-cli + +The operator's command on every node (novox/hq ADR 0272): `mesh-cli ` asks the controller to run one +of its command lines, from wherever the operator is. In interactive zsh it is also `nox`. + +``` +nox status +nox settings show zsh --node +nox settings set zsh '{"execute":"withhold"}' --node +``` + +## What it places + +| What | Where | How | +|---|---|---| +| the program | `/usr/local/bin/mesh-cli` | the `program` bundle, unpacked by an `archive` resource: the node-engine moves the one file in, refuses to write over a file it did not place, and takes out only that file when the module goes | +| the alias `nox` | the zsh module's mesh block in `~/.zshrc` | a `shell` contribution in the `normal` slot (ADR 0204) | + +No link and no second copy. `/usr/local/bin` is on every account's `PATH` already, so the module contributes no +environment. The alias is interactive zsh only: a script, `sudo`, a cron line and any document meant to work +everywhere say `mesh-cli`. In interactive zsh the alias hides any other `nox` (Arch's `python-nox` installs +`/usr/bin/nox`); `command nox` still reaches it. + +## How it is answered + +`mesh-cli` holds no credential and reads no file of the mesh. It writes one request to the node-engine's socket +(the SDK's `go/cli`: the line, as words) and prints the one answer. + +1. The node-engine reads which account is asking from the kernel, never from the program, and asks the controller + on its own node's subject. +2. The controller judges (ADR 0272 §4): + - **the controller's terminal**: the node's operator account, on the control-node. The line runs as the + operator typed it there. + - **an ordinary call**: the node's operator account on any other node, where agents may run as that account. + It meets every refusal of the generic `command` verb, and a change made at the terminal alone is refused with + the reason. + - **refused**: any other account, root included. Run it as yourself, not under `sudo`. +3. A line still running after ten seconds is followed by its call until it ends. `mesh-cli` prints standard + output and standard error as the command wrote them, says why a line was not the terminal or was refused, says + when the output was cut to fit one bus message, and exits with the command's status. When no answer comes in + time it says the line may have run and names its call; "nothing ran" is said only of a line no controller + took. + +A process that runs for a verb (it carries `MESH_VERB`) is refused before anything is asked: a verb is never the +operator. + +## What it does not do yet + +- **No standard input.** A command that reads one gets nothing, and fails saying so. +- **No local files.** A path in a line names a file where the controller runs, read by the controller's account. + Give settings inline: `nox settings set "$(cat settings.json)" --node `. +- **No streaming.** The answer arrives when the command ends, within the controller's bound on a call (five + minutes). Interrupting `mesh-cli` stops the wait, not the command. +- **The `MESH_VERB` refusal is a courtesy.** A verb's process is refused before asking, but the gate is the + controller: it judges the account the node-engine vouches for, whatever the program says. +- **No other node is the terminal.** Phase 2 of ADR 0272 adds a node whose agents run under an account of their + own (ADR 0266), judged unable to become root. + +## Tests + +`go test ./...`: the module places exactly one file (one archive resource, one Go bundle compiled to the binary +alone); the line is asked as given and the answer printed with its exit; a reason, a refusal, a cut and an outcome not known +are said and a refusal never exits 0; a verb's process is refused before asking; an engine that does not answer +is said. diff --git a/modules/mesh-cli/cmd/mesh-cli/main.go b/modules/mesh-cli/cmd/mesh-cli/main.go new file mode 100644 index 00000000..f5019e5e --- /dev/null +++ b/modules/mesh-cli/cmd/mesh-cli/main.go @@ -0,0 +1,86 @@ +// Command mesh-cli is the operator's command on every node (novox/hq ADR 0272): `mesh-cli ` asks the +// controller to run one of its command lines, and prints what it said. In interactive zsh it is also `nox`. +// +// It holds no credential and reads no file of the mesh. It asks the node-engine on this machine over a local +// socket; the engine reads which account is asking from the kernel and asks the controller. The controller decides: +// on the control-node, from the operator's own account, the line runs as the controller's terminal; elsewhere it is +// an ordinary call, and a change made at the terminal alone is refused with the reason; from any other account +// nothing runs. +package main + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/signal" + "syscall" + + "git.novox.be/novox/mesh-sdk/go/cli" +) + +// socket is where the node-engine answers; a variable so a test can name its own. +var socket = cli.Socket + +const usage = `mesh-cli — the controller's command lines, from this node (novox/hq ADR 0272) + + mesh-cli e.g. mesh-cli status, mesh-cli settings show zsh --node + nox the same, in interactive zsh + +Run it as yourself, never under sudo: the node-engine tells the controller which account asks. +On the control-node, from its operator's account, the line runs as the controller's terminal. +Anywhere else it is an ordinary call, and a terminal-only change is refused with the reason. +'mesh-cli help' asks the controller for its own list of command lines. +` + +func main() { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + code := run(ctx, os.Args[1:], os.Stdout, os.Stderr) + stop() + os.Exit(code) +} + +func run(ctx context.Context, args []string, stdout, stderr io.Writer) int { + if len(args) == 0 || args[0] == "-h" || args[0] == "--help" { + fmt.Fprint(stderr, usage) + return 2 + } + // A verb's own process carries the verb it runs for; a verb is never a person, so it is never the terminal. + if verb := os.Getenv("MESH_VERB"); verb != "" { + fmt.Fprintf(stderr, "mesh-cli: this process runs for the verb %q, and a verb is never the operator. "+ + "Nothing was asked\n", verb) + return 1 + } + a, err := cli.Ask(ctx, socket, args) + if err != nil { + if errors.Is(ctx.Err(), context.Canceled) { + fmt.Fprintln(stderr, "mesh-cli: stopped waiting; the line may still be running on the controller") + return 130 + } + fmt.Fprintf(stderr, "mesh-cli: %v\n", err) + return 1 + } + _, _ = stdout.Write(a.Stdout) + _, _ = stderr.Write(a.Stderr) + if a.Cut { + fmt.Fprintln(stderr, "mesh-cli: the output was longer than one bus message, and was cut there") + } + if !a.Terminal && a.Why != "" { + fmt.Fprintln(stderr, "mesh-cli: "+a.Why) + } + if a.Unknown != "" { + // Asked, and what came of it not known: never said as nothing having run. + fmt.Fprintln(stderr, "mesh-cli: "+a.Unknown) + if a.Exit == 0 { + return 1 + } + } + if a.Refused != "" { + fmt.Fprintln(stderr, "mesh-cli: "+a.Refused) + if a.Exit == 0 { + return 1 + } + } + return a.Exit +} diff --git a/modules/mesh-cli/cmd/mesh-cli/main_test.go b/modules/mesh-cli/cmd/mesh-cli/main_test.go new file mode 100644 index 00000000..a38238fe --- /dev/null +++ b/modules/mesh-cli/cmd/mesh-cli/main_test.go @@ -0,0 +1,106 @@ +package main + +import ( + "bytes" + "context" + "net" + "path/filepath" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/cli" +) + +// engine answers one connection on a socket of the test's own as the node-engine would, and says what it was asked. +func engine(t *testing.T, answer cli.Answer) <-chan []string { + t.Helper() + path := filepath.Join(t.TempDir(), "cli.sock") + l, err := net.Listen("unix", path) + if err != nil { + t.Skipf("no unix socket here: %v", err) + } + t.Cleanup(func() { l.Close() }) + before := socket + socket = path + t.Cleanup(func() { socket = before }) + asked := make(chan []string, 1) + go func() { + conn, err := l.Accept() + if err != nil { + return + } + defer conn.Close() + req, err := cli.ReadRequest(conn) + if err != nil { + return + } + asked <- req.Line + _ = cli.WriteAnswer(conn, answer) + }() + return asked +} + +// The line is asked as given, what the controller printed is printed, and its exit is mesh-cli's. +func TestTheLineIsAskedAndTheAnswerIsPrinted(t *testing.T) { + t.Setenv("MESH_VERB", "") + asked := engine(t, cli.Answer{Stdout: []byte("out\n"), Stderr: []byte("err\n"), Exit: 3, Terminal: true, Why: "the terminal"}) + var out, errOut bytes.Buffer + code := run(context.Background(), []string{"settings", "set", "zsh", `{"execute":"withhold"}`}, &out, &errOut) + if line := <-asked; strings.Join(line, "|") != `settings|set|zsh|{"execute":"withhold"}` { + t.Fatalf("asked %q", line) + } + if code != 3 || out.String() != "out\n" || errOut.String() != "err\n" { + t.Fatalf("exit %d, stdout %q, stderr %q", code, out.String(), errOut.String()) + } +} + +// Not the terminal, refused, or cut: each is said, and a refusal never exits 0. +func TestWhyARefusalAndACutAreSaid(t *testing.T) { + t.Setenv("MESH_VERB", "") + engine(t, cli.Answer{Why: "not the controller's terminal: agents on laptop may run as operator", + Refused: "set at the controller's terminal only", Cut: true}) + var out, errOut bytes.Buffer + code := run(context.Background(), []string{"settings", "set", "x", "{}"}, &out, &errOut) + said := errOut.String() + if code == 0 || !strings.Contains(said, "not the controller's terminal") || !strings.Contains(said, "terminal only") || + !strings.Contains(said, "cut") { + t.Fatalf("exit %d, said %q", code, said) + } +} + +// A verb's own process is never the operator: refused before anything is asked. +func TestAVerbsProcessIsRefusedBeforeAsking(t *testing.T) { + t.Setenv("MESH_VERB", "command") + before := socket + socket = filepath.Join(t.TempDir(), "nothing-listens.sock") + t.Cleanup(func() { socket = before }) + var out, errOut bytes.Buffer + if code := run(context.Background(), []string{"status"}, &out, &errOut); code != 1 || + !strings.Contains(errOut.String(), "never the operator") { + t.Fatalf("exit %d, said %q", code, errOut.String()) + } +} + +// An engine that does not answer is said, with where it was asked. +func TestAnEngineThatDoesNotAnswerIsSaid(t *testing.T) { + t.Setenv("MESH_VERB", "") + before := socket + socket = filepath.Join(t.TempDir(), "nothing-listens.sock") + t.Cleanup(func() { socket = before }) + var out, errOut bytes.Buffer + if code := run(context.Background(), []string{"status"}, &out, &errOut); code != 1 || + !strings.Contains(errOut.String(), "does not answer") { + t.Fatalf("exit %d, said %q", code, errOut.String()) + } +} + +// An outcome not known is said as such — the line may have run — and never as nothing having run. +func TestAnOutcomeNotKnownIsSaid(t *testing.T) { + t.Setenv("MESH_VERB", "") + engine(t, cli.NotKnown("call-4", "no answer within 6m0s")) + var out, errOut bytes.Buffer + code := run(context.Background(), []string{"push", "laptop"}, &out, &errOut) + if code == 0 || !strings.Contains(errOut.String(), "may have run") || !strings.Contains(errOut.String(), "call-4") { + t.Fatalf("exit %d, said %q", code, errOut.String()) + } +} diff --git a/modules/mesh-cli/cmd/mesh-cli/manifest_test.go b/modules/mesh-cli/cmd/mesh-cli/manifest_test.go new file mode 100644 index 00000000..bf143a9b --- /dev/null +++ b/modules/mesh-cli/cmd/mesh-cli/manifest_test.go @@ -0,0 +1,56 @@ +package main + +import ( + "encoding/json" + "os" + "testing" +) + +// **The archive places exactly one file, /usr/local/bin/mesh-cli** (review of novox/hq ADR 0272): one archive +// resource, unpacking one artifact into /usr/local/bin, and that artifact a compiled Go bundle — the builder writes +// such a bundle as its binary alone (`go build -o /`), named mesh-cli — with nothing the runtime would +// load or put beside it. A second file in the bundle, or a second resource writing there, fails here. +func TestTheArchivePlacesExactlyOneFile(t *testing.T) { + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m struct { + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + if len(m.Resources) != 1 { + t.Fatalf("the module declares %d resources; it places one file", len(m.Resources)) + } + r := m.Resources[0] + if r["type"] != "archive" || r["path"] != "/usr/local/bin" { + t.Fatalf("its resource is %v", r) + } + if len(m.Build.Artifacts) != 1 { + t.Fatalf("it builds %d artifacts", len(m.Build.Artifacts)) + } + a := m.Build.Artifacts[0] + if a["name"] != r["artifact"] || a["kind"] != "bundle" || a["language"] != "go" || a["binary"] != "mesh-cli" || + a["from"] != "cmd/mesh-cli" { + t.Fatalf("its artifact is %v, unpacked as %v", a, r["artifact"]) + } + for _, extra := range []string{"loads", "env", "context", "files"} { + if _, has := a[extra]; has { + t.Fatalf("its artifact says %q, which would put more than the binary in the bundle or load it", extra) + } + } + entries, err := os.ReadDir(".") + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if e.IsDir() { + t.Fatalf("cmd/mesh-cli holds a directory %s, which a build could carry into the bundle", e.Name()) + } + } +} diff --git a/modules/mesh-cli/go.mod b/modules/mesh-cli/go.mod new file mode 100644 index 00000000..4d6d5938 --- /dev/null +++ b/modules/mesh-cli/go.mod @@ -0,0 +1,5 @@ +module mesh-cli + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009103748-a5a040c9049b diff --git a/modules/mesh-cli/go.sum b/modules/mesh-cli/go.sum new file mode 100644 index 00000000..22f2ed57 --- /dev/null +++ b/modules/mesh-cli/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009103748-a5a040c9049b h1:HmasScQ02QeFlAFDnmRCOEserbtDJJtutK+acBmGlNs= +git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009103748-a5a040c9049b/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/mesh-cli/module.json b/modules/mesh-cli/module.json new file mode 100644 index 00000000..3939eb35 --- /dev/null +++ b/modules/mesh-cli/module.json @@ -0,0 +1,31 @@ +{ + "module": "mesh-cli", + "version": "1", + "resources": [ + { + "id": "program", + "type": "archive", + "artifact": "program", + "path": "/usr/local/bin" + } + ], + "shell": [ + { + "for": "zsh", + "slot": "normal", + "code": "# The operator's command (module mesh-cli, novox/hq ADR 0272): nox is mesh-cli, in interactive zsh only.\n# A script, sudo and a document meant to work everywhere say mesh-cli.\nalias nox=mesh-cli\n" + } + ], + "build": { + "artifacts": [ + { + "name": "program", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-cli", + "binary": "mesh-cli" + } + ] + } +} diff --git a/modules/route-adapter/README.md b/modules/route-adapter/README.md index 498b7e7d..2e92b98a 100644 --- a/modules/route-adapter/README.md +++ b/modules/route-adapter/README.md @@ -107,7 +107,7 @@ into the container. | `machine` | `host.docker.internal` | how the predecessor's proxy reaches this machine | ``` -mesh-controller settings set route-adapter settings.json --node +mesh-cli settings set route-adapter "$(cat settings.json)" --node ``` **One thing does not follow the setting yet, and it fails loudly rather than quietly.** The