Merge pull request 'nox works without the controller: each command calls the seat or tool that owns its job (issue 1126)' (#593) from fix/1126-nox into main

This commit was merged in pull request #593.
This commit is contained in:
2026-11-02 10:56:31 +00:00
12 changed files with 1399 additions and 271 deletions
+2
View File
@@ -42,3 +42,5 @@ modules/mesh-config/cmd/mesh-config/mesh-config
modules/forticlient/cmd/forticlient-tools/forticlient-tools
modules/networkmanager/uplink-tools
checks/words/words
modules/mesh-cli/mesh-cli
modules/mesh-cli/cmd/mesh-cli/mesh-cli
+46 -38
View File
@@ -1,12 +1,16 @@
# mesh-cli
The operator's command on every node (novox/hq ADR 0272): `mesh-cli <command line>` asks the controller to run one
of its command lines, from wherever the operator is. In interactive zsh it is also `nox`.
The operator's command on every node: `mesh-cli <command line>`, in interactive zsh also `nox`. Each line calls the
seat verb or the module tool that owns its job, through this machine's console, and prints the owner's answer.
```
nox status
nox settings show zsh --node <node>
nox settings set zsh '{"execute":"withhold"}' --node <node>
nox conditions --severity urgent
nox deliveries
nox assign <node> <module> --why <text>
nox issue <number>
nox call <node>/node-service-manager.status '{"unit":"plex"}'
nox help
```
## What it places
@@ -14,57 +18,61 @@ nox settings set zsh '{"execute":"withhold"}' --node <node>
| 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 console's address | `/etc/mesh-cli/console` | a file resource from `files/console`: `http://127.0.0.1:${bound:mcp-endpoint:port}/mcp`, from the `mcp-endpoint` the module requires |
| 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.
No link and no second copy. `/usr/local/bin` is on every account's `PATH` already. 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.
`mesh-cli` holds no credential and has no bus account of its own (so its manifest declares no `invokes`: an account
nothing dials would only be minted and kept). It calls `mesh_call` on this machine's console, the mesh MCP server the
module node-tools serves on loopback (novox/hq ADR 0152: whoever is on the machine owns the mesh there), and the console
puts the call on the bus, as every agent session on the machine does. The console's address is read from
`/etc/mesh-cli/console`, or `MESH_CONSOLE` when set, or node-tools' declared port when the file is not there yet.
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.
- **Who judges.** The seat that owns the job: a change of placement is a proposal the operator approves
(`mesh-placement.propose`), a config value is set with its why (`mesh-config.config`), a silence or a hand act is
recorded as one. Until novox/hq issue 1126 the controller ran these lines and judged the account (ADR 0272); with
the controller unassigned, that judgement went with it.
- **The map.** Every line the controller ran, and the seat verb or tool that does its job now, or none, is
`commands.go`'s table (and hq's `.work/1126/nox-map.md` while issue 1126 runs). A line no seat or module owns says
that it was the controller's, that the controller is unassigned, and what to use instead, and exits 1 having called
nothing. A line nox cannot read says how it is written and exits 2.
- **Any address.** `nox call <address> [json]` calls any seat verb or tool; `nox search <words>` and
`nox describe <address>` find one and its arguments.
- **What is printed.** The owner's answer as the console gave it (JSON), on standard output with exit 0; an answer the
console marks an error (a refusal, nothing serving, no answer within its bound) on standard error with exit 1. A
console that does not answer is said as nothing having been called; no answer in time is said as a call that may
have run.
- **`nox status`** is the delivery view's (`mesh-delivery-view.view_status`). When the view does not answer, nox says
so first, shows the open conditions and the deliveries under their own addresses, and exits 1: they are never
passed off as the status.
- **`nox enrol open --key <file>`** runs here and asks nothing of the mesh (ADR 0587 §8).
A process that runs for a verb (it carries `MESH_VERB`) is refused before anything is asked: a verb is never the
A process that runs for a verb (it carries `MESH_VERB`) is refused before anything is called: 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 <module> "$(cat settings.json)" --node <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.
- **No formatting beyond `nox nodes`.** Answers are the owners' JSON.
- **Lines with no owner.** Accepting a secret from a person, making or setting the operator key, opening a sealed
secret locally, registering a module from another repository and forgetting a module have no seat verb yet; nox
names what to use meanwhile.
## 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.
`go test ./...`: every owned line calls its owner's address with the arguments it reads, against a stand-in console
answering MCP `tools/call` as node-tools does; a line with no owner or not read calls nothing and says why; an error
answer, a silent console and a verb's process are said; the status's fallback is labelled and exits 1; the console's
address is found; `enrol open` opens a sealed token locally; the module places the program and the console's address
and nothing else.
## Files it does not know
Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416).
- `/usr/local/bin`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `/etc/mesh-cli`: the program's; it holds the console's address alone.
+729
View File
@@ -0,0 +1,729 @@
package main
// Every command line nox takes, and the seat verb or module tool that owns its job now that the controller is
// unassigned (novox/hq issue 1126; the map is hq's .work/1126/nox-map.md). A line no seat or module owns says so and
// names what to use instead: it never fails silently, and it never pretends to have run.
import (
"encoding/json"
"fmt"
"io"
"sort"
"strconv"
"strings"
"time"
)
// line is a command line's words after the command's own: positionals and --flags, in any order.
type line struct {
pos []string
flags map[string]string
}
// boolFlags are the flags that take no value.
var boolFlags = map[string]bool{"json": true, "confirm": true, "collected": true, "brief": true, "all": true,
"reasoning": true, "clear": true}
func parseLine(args []string) (line, error) {
l := line{flags: map[string]string{}}
for i := 0; i < len(args); i++ {
a := args[i]
if !strings.HasPrefix(a, "--") || a == "--" {
l.pos = append(l.pos, a)
continue
}
name, value, has := strings.Cut(strings.TrimPrefix(a, "--"), "=")
if boolFlags[name] {
if has {
return l, usageErr("--%s takes no value", name)
}
l.flags[name] = "true"
continue
}
if !has {
if i+1 >= len(args) {
return l, usageErr("--%s needs a value", name)
}
i++
value = args[i]
}
l.flags[name] = value
}
return l, nil
}
func (l line) has(flag string) bool { return l.flags[flag] != "" }
// usageError is a line nox cannot read: nothing was called.
type usageError struct{ msg string }
func (u usageError) Error() string { return u.msg }
func usageErr(format string, a ...any) error { return usageError{fmt.Sprintf(format, a...)} }
// call is what one line asks: an address and its arguments.
type call struct {
Address string
Args map[string]any
}
// command is one of nox's lines.
type command struct {
words []string
usage string
does string
// build reads the line and answers the call; nil for a line no seat owns.
build func(l line) (call, error)
// allowed are the flags the line reads; any other is refused before anything is called.
allowed []string
// instead says, for a line with no owner, what to use; empty for one that is owned.
instead string
// render shapes a successful answer for reading; nil prints it as the console gave it.
render func(text string) string
}
// gone is the reason every unowned line gives.
const gone = "this was the controller's, and the controller is unassigned (novox/hq issue 1126). Nothing was called"
// commands is every line, longest words first within a word.
var commands = []command{
// What runs where.
{words: []string{"status"}, does: "what is wrong, what is quiet, what is moving (the delivery view)",
build: fixed("mesh-delivery-view.view_status")},
{words: []string{"nodes"}, does: "the nodes placement holds", build: fixed("mesh-placement.placement"),
render: renderNodes},
{words: []string{"node", "list"}, does: "the nodes placement holds", build: fixed("mesh-placement.placement"),
render: renderNodes},
{words: []string{"node", "show"}, usage: "<node>", does: "one node's placement: what it runs and its decided fields",
build: nodeArg("mesh-placement.placement")},
{words: []string{"node", "public-domain"}, usage: "<node> [<domain> | --clear] [--why <text>]",
does: "the domain a node composes its routed names under; with a domain or --clear, proposed to placement",
allowed: []string{"clear", "why"}, build: publicDomain},
{words: []string{"node", "add"}, instead: "a new machine joins through the seat mesh-enrolment: nox enrol issue <node> --why <text>"},
{words: []string{"node", "networks"}, instead: "no seat keeps a node's routed networks yet; mesh-placement.placement shows what a node decides"},
{words: []string{"plan"}, usage: "<node>", does: "what the assigner wants the node to run", build: optNode("mesh-delivery-view.assigner_wants")},
{words: []string{"haves"}, usage: "[<node>]", does: "what each node runs, as its node-engine said", build: optNode("mesh-delivery-view.node_haves")},
{words: []string{"switch"}, does: "what each node runs, as its node-engine said", build: fixed("mesh-delivery-view.node_haves")},
{words: []string{"composition"}, usage: "<node> <module>", does: "one module's composition on a node beside what runs",
build: func(l line) (call, error) {
if len(l.pos) != 2 {
return call{}, usageErr("give the node and the module")
}
return call{l.pos[0] + "/node-engine.composition", map[string]any{"module": l.pos[1]}}, nil
}},
{words: []string{"seats"}, does: "who holds each seat, and where", build: func(line) (call, error) {
return call{"mesh-placement.placement", map[string]any{"seats": "true"}}, nil
}},
{words: []string{"seat"}, instead: "a seat is held by placing its module: nox assign <node> <module> --why <text> (mesh-placement.propose)"},
{words: []string{"assign"}, usage: "<node> <module>... --why <text>", does: "propose placing modules on a node",
allowed: []string{"why"}, build: placeModules("place")},
{words: []string{"unassign"}, usage: "<node> <module>... --why <text>", does: "propose taking modules off a node",
allowed: []string{"why"}, build: placeModules("take-off")},
{words: []string{"pin"}, usage: "<node> <provision> <from-node> <module> --why <text>", does: "propose which provider a node gets a provision from",
allowed: []string{"why"}, build: func(l line) (call, error) {
if len(l.pos) != 4 {
return call{}, usageErr("give the node, the provision, the provider's node and its module")
}
return propose(l, map[string]any{"op": "pin", "node": l.pos[0], "provision": l.pos[1],
"provider": map[string]any{"node": l.pos[2], "module": l.pos[3]}})
}},
{words: []string{"unpin"}, usage: "<node> <provision> --why <text>", does: "propose putting that question back",
allowed: []string{"why"}, build: func(l line) (call, error) {
if len(l.pos) != 2 {
return call{}, usageErr("give the node and the provision")
}
return propose(l, map[string]any{"op": "unpin", "node": l.pos[0], "provision": l.pos[1]})
}},
{words: []string{"proposals"}, usage: "[<id>]", does: "the open proposals of placement", build: optArg("mesh-placement.proposals", "id")},
{words: []string{"overlay", "show"}, does: "every node's placement, with its tunnel", build: fixed("mesh-placement.placement")},
{words: []string{"overlay"}, instead: "a node's tunnel is a decided field of its placement: mesh-placement.propose with op decide"},
// Deliveries and builds.
{words: []string{"deliveries"}, usage: "[--state s] [--repository owner/repo] [--group g] [--all]",
does: "every delivery not final, and those that ended in the last day",
allowed: []string{"state", "repository", "group", "all"}, build: func(l line) (call, error) {
return call{"mesh-delivery.deliveries", pick(l, "state", "repository", "group", "all")}, nil
}},
{words: []string{"delivery"}, usage: "<id>", does: "one delivery or group whole", build: needArg("mesh-delivery.show", "id")},
{words: []string{"groups"}, usage: "[--all]", does: "every delivery group", allowed: []string{"all"},
build: func(l line) (call, error) { return call{"mesh-delivery.groups", pick(l, "all")}, nil }},
{words: []string{"stalled"}, does: "every delivery held past its state's bound", build: fixed("mesh-delivery.stalled")},
{words: []string{"checks"}, usage: "<owner/repo> [--number n | --commit c]", does: "what the mesh's checks said of a head",
allowed: []string{"number", "commit"}, build: func(l line) (call, error) {
if len(l.pos) != 1 {
return call{}, usageErr("give the repository, owner/repo")
}
a := pick(l, "number", "commit")
a["repository"] = l.pos[0]
return call{"mesh-delivery.checks", a}, nil
}},
{words: []string{"try"}, usage: "<owner/repo> <head> [--level l] [--trunk t] [--tests t]",
does: "try a pushed head before its pull request is opened", allowed: []string{"level", "trunk", "tests"},
build: func(l line) (call, error) {
if len(l.pos) != 2 {
return call{}, usageErr("give the repository and the head")
}
a := pick(l, "level", "trunk", "tests")
a["repository"], a["head"] = l.pos[0], l.pos[1]
return call{"mesh-delivery-checker.try", a}, nil
}},
{words: []string{"builds"}, usage: "[--since <RFC 3339 time>]", does: "every build that ended since (the last day by default)",
allowed: []string{"since"}, build: func(l line) (call, error) {
since := l.flags["since"]
if since == "" {
since = now().Add(-24 * time.Hour).UTC().Format(time.RFC3339)
}
return call{"mesh-delivery-builder.builder_builds_since", map[string]any{"since": since}}, nil
}},
{words: []string{"upgrade"}, usage: "[<module>] [--policy roll|together|record|default --why <text>]",
does: "whether each module's new builds wait for a person; with --policy, a person's choice",
allowed: []string{"policy", "why"}, build: func(l line) (call, error) {
if len(l.pos) > 1 {
return call{}, usageErr("give at most one module; rolling out is the delivery's: nox deliveries")
}
a := pick(l, "policy", "why")
if len(l.pos) == 1 {
a["module"] = l.pos[0]
}
if a["policy"] != nil && (a["module"] == nil || a["why"] == nil) {
return call{}, usageErr("a policy needs the module and --why")
}
return call{"mesh-wants.upgrade", a}, nil
}},
{words: []string{"bus"}, does: "what a bus upgrade would do", build: fixed("mesh-wants.bus")},
// Conditions, the self-check, the healers and hand acts.
{words: []string{"conditions", "show"}, usage: "<key>", does: "one condition whole", build: needArg("mesh-conditions.conditions", "key")},
{words: []string{"conditions", "silence"}, usage: "<key> --for <30m|4h|2d> --why <text> [--cause <word>]",
does: "send no message for it a while; a hand act", allowed: []string{"for", "why", "cause"},
build: func(l line) (call, error) {
if len(l.pos) != 1 || !l.has("for") || !l.has("why") {
return call{}, usageErr("give the key, --for and --why")
}
a := pick(l, "for", "why", "cause")
a["key"] = l.pos[0]
return call{"mesh-conditions.silence", a}, nil
}},
{words: []string{"conditions", "history"}, usage: "[--days N] [--key K] [--limit N]", does: "every raising, change and clearing lately",
allowed: []string{"days", "key", "limit"}, build: func(l line) (call, error) {
if l.has("key") {
return call{"mesh-conditions.conditions_history", pick(l, "key", "limit")}, nil
}
a := pick(l, "days")
a["history"] = "true"
return call{"mesh-conditions.conditions", a}, nil
}},
{words: []string{"conditions"}, usage: "[--scope S] [--severity S] [--machine M]", does: "what is wrong now, urgent first",
allowed: []string{"scope", "severity", "machine"}, build: func(l line) (call, error) {
if len(l.pos) > 0 {
return call{}, usageErr("%q is not a conditions line", l.pos[0])
}
return call{"mesh-conditions.conditions", pick(l, "scope", "severity", "machine")}, nil
}},
{words: []string{"doctor", "probes"}, does: "the self-check's probes", build: fixed("mesh-self-check.probes")},
{words: []string{"doctor", "run"}, does: "run every probe now", build: func(line) (call, error) {
return call{"mesh-self-check.doctor", map[string]any{"run": true}}, nil
}},
{words: []string{"doctor", "probe"}, usage: "<id>", does: "one probe's verdict whole", build: needArg("mesh-self-check.doctor", "probe")},
{words: []string{"doctor", "signals"}, does: "the watchman's signals and their ages", build: fixed("mesh-watchdogs.signals")},
{words: []string{"doctor"}, does: "the self-check's last verdict", build: fixed("mesh-self-check.doctor")},
{words: []string{"healers"}, usage: "[--days N]", does: "the healers, what they did lately, and their brake",
allowed: []string{"days"}, build: func(l line) (call, error) {
a, err := numbers(pick(l, "days"), "days")
return call{"mesh-healers.healers", a}, err
}},
{words: []string{"hand-act", "record"}, usage: "<what> --why <text> [--cause <word>] [--condition <key>]",
does: "record an act done by hand", allowed: []string{"why", "cause", "condition"},
build: func(l line) (call, error) {
if len(l.pos) == 0 || !l.has("why") {
return call{}, usageErr("give what was done and --why")
}
a := pick(l, "why", "cause", "condition")
a["what"] = strings.Join(l.pos, " ")
return call{"mesh-hand-acts.hand-act", a}, nil
}},
{words: []string{"hand-acts"}, usage: "[--days N] [--cause c] [--who w]", does: "what was done by hand lately",
allowed: []string{"days", "cause", "who"}, build: func(l line) (call, error) {
a, err := numbers(pick(l, "days", "cause", "who"), "days")
return call{"mesh-hand-acts.hand-acts", a}, err
}},
{words: []string{"stuck"}, does: "what stopped moving, as the watchman holds it", build: fixed("mesh-watchdogs.stuck")},
{words: []string{"dead-letters"}, usage: "[--consumer c]", does: "what a consumer gave up on", allowed: []string{"consumer"},
build: func(l line) (call, error) { return call{"mesh-watchdogs.dead-letters", pick(l, "consumer")}, nil }},
// Config, secrets, the vault, artifacts, licences, modules.
{words: []string{"settings", "show"}, usage: "<module> [--node n]", does: "a module's config values",
allowed: []string{"node"}, build: func(l line) (call, error) {
if len(l.pos) != 1 {
return call{}, usageErr("give the module")
}
a := pick(l, "node")
a["action"], a["module"] = "list", l.pos[0]
return call{"mesh-config.config", a}, nil
}},
{words: []string{"settings", "set"}, usage: "<module> <key> <value> --node n --why <text>",
does: "set one of a module's config values on one node", allowed: []string{"node", "why"},
build: func(l line) (call, error) { return configChange(l, "set") }},
{words: []string{"settings", "clear"}, usage: "<module> <key> --node n --why <text>",
does: "clear one of a module's config values on one node", allowed: []string{"node", "why"},
build: func(l line) (call, error) { return configChange(l, "clear") }},
{words: []string{"config"}, usage: "[list|set|clear|history|propose|proposals] [--module m] [--node n] [--key k] [--value v] [--why w]",
does: "the mesh's config values, at any level", allowed: []string{"module", "node", "key", "value", "why", "limit", "proposal", "pull", "clear"},
build: func(l line) (call, error) {
if len(l.pos) > 1 {
return call{}, usageErr("give at most one action")
}
a := pick(l, "module", "node", "key", "why", "proposal", "pull", "clear", "limit")
if len(l.pos) == 1 {
a["action"] = l.pos[0]
}
if v, ok := l.flags["value"]; ok {
a["value"] = jsonOrText(v)
}
if c, ok := a["clear"]; ok {
a["clear"] = c == "true"
}
a, err := numbers(a, "limit")
return call{"mesh-config.config", a}, err
}},
{words: []string{"secret", "export"}, does: "every secret the vault holds, as its copy sealed to the operator key",
build: fixed("mesh-vault.secret_vault_export")},
{words: []string{"secret", "accept"}, instead: "mesh-vault.accept takes a value from the controller alone, and no seat takes one from a person yet; a module's own secret is asked at a desk (mesh-vault.secret-ask)"},
{words: []string{"secret", "recover"}, instead: "opening a sealed copy locally is not built yet (beside nox enrol open); nox secret export gives the sealed copies"},
{words: []string{"secret"}, instead: "nox secret export; mesh-vault.rotate makes one anew"},
{words: []string{"operator", "key", "show"}, does: "what needs a person's eye in the vault, the operator key among it", build: fixed("mesh-vault.vault_status")},
{words: []string{"operator"}, instead: "no seat sets or makes the operator key yet; nox operator key show reads it (mesh-vault.vault_status)"},
{words: []string{"rotate"}, usage: "[--node n --module m --name s | --provider n --provision p] --why <text>",
does: "make a secret anew", allowed: []string{"node", "module", "name", "provider", "provision", "consumer", "consumer_module", "why"},
build: func(l line) (call, error) {
if len(l.pos) == 1 {
l.flags["provision"] = l.pos[0]
} else if len(l.pos) > 1 {
return call{}, usageErr("give the secret by its flags")
}
if !l.has("why") {
return call{}, usageErr("give --why")
}
return call{"mesh-vault.rotate", pick(l, "node", "module", "name", "provider", "provision", "consumer", "consumer_module", "why")}, nil
}},
{words: []string{"identity", "show"}, does: "the vault's signing key and authority", build: fixed("mesh-vault.vault_signers")},
{words: []string{"retire", "approve"}, usage: "<consumer>... --why <text>", does: "retire the set waiting for a person",
allowed: []string{"why"}, build: retirement("mesh-vault.provisioner_retire_approve")},
{words: []string{"retire", "reject"}, usage: "<consumer>... --why <text>", does: "keep the set waiting for a person active",
allowed: []string{"why"}, build: retirement("mesh-vault.provisioner_retire_reject")},
{words: []string{"retire"}, does: "every retirement waiting for a person", build: fixed("mesh-vault.provisioner_retirement")},
{words: []string{"cleanup", "delete"}, usage: "<consumer> --why <text> --confirm", does: "delete one retired holder from the ledger",
allowed: []string{"why", "confirm"}, build: func(l line) (call, error) {
if len(l.pos) != 1 || !l.has("why") {
return call{}, usageErr("give the consumer and --why")
}
a := pick(l, "why")
a["consumer"] = l.pos[0]
// The vault deletes only when confirm repeats the holder's name (mesh-vault retirement.go); without
// --confirm it answers what it would delete.
if l.has("confirm") {
a["confirm"] = l.pos[0]
}
return call{"mesh-vault.provisioner_delete", a}, nil
}},
{words: []string{"cleanup"}, does: "every retired holder per provider", build: fixed("mesh-vault.provisioner_retirement")},
{words: []string{"artifacts"}, usage: "[--collected]", does: "every artifact the mesh made: kept and why, or eligible",
allowed: []string{"collected"}, build: func(l line) (call, error) {
return call{"mesh-artifact-sweep.artifacts", pick(l, "collected")}, nil
}},
{words: []string{"collection"}, does: "every artifact the mesh made: kept and why, or eligible", build: fixed("mesh-artifact-sweep.artifacts")},
{words: []string{"collect"}, usage: "[--confirm --why <text>] [--most n]", does: "the sweep: a dry run, or with --confirm the sweep now",
allowed: []string{"confirm", "why", "most"}, build: func(l line) (call, error) {
if l.has("confirm") && !l.has("why") {
return call{}, usageErr("--confirm needs --why")
}
return call{"mesh-artifact-sweep.collect", pick(l, "confirm", "why", "most")}, nil
}},
{words: []string{"images"}, usage: "<node>", does: "every container image a machine's wants and haves name", build: needArg("mesh-artifact-sweep.images", "node")},
{words: []string{"mirrors"}, does: "the bases the mesh copied into its store", build: fixed("mesh-artifact-sweep.mirrors")},
{words: []string{"licence", "list"}, does: "every licence the manager holds", build: fixed("anthropic-licence-manager.licences")},
{words: []string{"licence", "use"}, usage: "<consumer> <licence>", does: "move a node's agent to another licence",
build: func(l line) (call, error) {
if len(l.pos) != 2 {
return call{}, usageErr("give the consumer (a node) and the licence")
}
return call{"anthropic-licence-manager.switch", map[string]any{"consumer": l.pos[0], "licence": l.pos[1]}}, nil
}},
{words: []string{"licence", "refresh"}, usage: "[<licence>]", does: "refresh a licence now, or every due one", build: optArg("anthropic-licence-manager.refresh", "licence")},
{words: []string{"licence", "key"}, does: "the manager's public key", build: fixed("anthropic-licence-manager.public-key")},
{words: []string{"licence"}, instead: "anthropic-licence-manager.adopt takes an API key from a file on the manager's node; nox licence list|use|refresh|key"},
{words: []string{"module", "list"}, does: "every module the indexer holds", build: fixed("mesh-delivery-view.indexer_modules")},
{words: []string{"module", "show"}, usage: "<module>", does: "what the indexer holds of one module", build: needArg("mesh-delivery-view.indexer_module", "module")},
{words: []string{"module", "moved"}, usage: "<module> --why <text>", does: "take a registered module in now", allowed: []string{"why"},
build: func(l line) (call, error) {
if len(l.pos) < 1 {
return call{}, usageErr("give the module")
}
return call{"mesh-delivery-indexer.indexer_take_in", map[string]any{"module": l.pos[0], "why": l.flags["why"]}}, nil
}},
{words: []string{"module"}, instead: "the indexer takes modules in from their repositories; nox module list|show|moved, and nothing forgets one by hand yet"},
{words: []string{"register"}, instead: "no seat registers a module from another repository yet (novox/hq ADR 0501 §4); nox module moved <module> takes a registered one in"},
{words: []string{"account", "issue"}, usage: "<user>", does: "mint one bus account the grants part wants, now", build: needArg("mesh-vault.issue-account", "user")},
// Issues and records.
{words: []string{"issues"}, usage: "[<words>] [--status s] [--limit n] [--owner o]", does: "the issues, newest first, or those that match",
allowed: []string{"status", "limit", "owner", "severity", "needs"}, build: func(l line) (call, error) {
a := pick(l, "status", "owner", "severity", "needs", "limit")
if len(l.pos) > 0 {
a["search"] = strings.Join(l.pos, " ")
}
a, err := numbers(a, "limit")
return call{"issue-tracker.list", a}, err
}},
{words: []string{"issue"}, usage: "<number> [--brief]", does: "one issue whole", allowed: []string{"brief"},
build: func(l line) (call, error) {
if len(l.pos) != 1 {
return call{}, usageErr("give the issue's number")
}
n, err := strconv.Atoi(l.pos[0])
if err != nil || n < 1 {
return call{}, usageErr("%q is not an issue's number", l.pos[0])
}
a := map[string]any{"number": n}
if l.has("brief") {
a["brief"] = true
}
return call{"issue-tracker.get", a}, nil
}},
{words: []string{"kb", "ask"}, usage: "<question>", does: "what holds about something, and why", build: func(l line) (call, error) {
if len(l.pos) == 0 {
return call{}, usageErr("give the question")
}
return call{"knowledge-base.ask", map[string]any{"question": strings.Join(l.pos, " ")}}, nil
}},
{words: []string{"kb", "get"}, usage: "<number> [--kind research] [--reasoning]", does: "one record by its number",
allowed: []string{"kind", "reasoning"}, build: func(l line) (call, error) {
if len(l.pos) != 1 {
return call{}, usageErr("give the record's number")
}
n, err := strconv.Atoi(l.pos[0])
if err != nil {
return call{}, usageErr("%q is not a record's number", l.pos[0])
}
a := pick(l, "kind")
a["number"] = n
if l.has("reasoning") {
a["reasoning"] = true
}
return call{"knowledge-base.get", a}, nil
}},
{words: []string{"progress"}, usage: "[<design>|mesh] [--detail minimal|detailed]", does: "how far the designs have come",
allowed: []string{"detail", "since"}, build: func(l line) (call, error) {
a := pick(l, "detail", "since")
a["of"] = "mesh"
if len(l.pos) == 1 {
a["of"] = l.pos[0]
if _, err := strconv.Atoi(l.pos[0]); err == nil {
a["of"] = "design " + l.pos[0]
}
}
return call{"knowledge-base.progress", a}, nil
}},
// Enrolment (the seat mesh-enrolment; `enrol open` is local, in main.go).
{words: []string{"enrol", "issue"}, usage: "<node> --why <text> [--expires 1h]", does: "a one-time token for a new machine, on the operator's Approve",
allowed: []string{"why", "expires"}, build: enrolIssue},
{words: []string{"token", "issue"}, usage: "--new <node> --why <text>", does: "the same as nox enrol issue",
allowed: []string{"new", "node", "why", "expires", "adopted"}, build: func(l line) (call, error) {
node := l.flags["new"]
if node == "" {
node = l.flags["node"]
}
if node != "" {
l.pos = append([]string{node}, l.pos...)
}
return enrolIssue(l)
}},
{words: []string{"enrol", "tokens"}, usage: "[<node>]", does: "every open enrolment, or one", build: optArg("mesh-enrolment.tokens", "node")},
{words: []string{"enrol", "revoke"}, usage: "<node> --why <text>", does: "end an enrolment at any stage", allowed: []string{"why"},
build: func(l line) (call, error) {
if len(l.pos) != 1 || !l.has("why") {
return call{}, usageErr("give the node and --why")
}
return call{"mesh-enrolment.revoke", map[string]any{"node": l.pos[0], "why": l.flags["why"]}}, nil
}},
{words: []string{"enrol", "again"}, usage: "<node>", does: "raise an enrolment's ask again", build: needArg("mesh-enrolment.again", "node")},
// Any address.
{words: []string{"call"}, usage: "<address> [<json arguments>]", does: "call any seat verb or module tool, e.g. ace/node-service-manager.status",
build: func(l line) (call, error) {
if len(l.pos) < 1 || len(l.pos) > 2 {
return call{}, usageErr("give the address and at most one JSON object of arguments")
}
return addressCall(l.pos[0], l.pos[1:])
}},
{words: []string{"ask"}, usage: "<module> <tool> [<json arguments>]", does: "call one of a module's tools",
build: func(l line) (call, error) {
if len(l.pos) < 2 || len(l.pos) > 3 {
return call{}, usageErr("give the module, the tool and at most one JSON object of arguments")
}
return addressCall(l.pos[0]+"."+l.pos[1], l.pos[2:])
}},
}
// unowned are the controller's own lines that no seat or module took over, and what to use where there is anything.
var unowned = map[string]string{
"migrate": "", "prepare": "", "serve": "", "declare": "", "broker": "", "board": "", "api": "", "facts": "",
"rehearse": "", "take": "", "converge": "", "adopt": "", "move-placement": "", "move-config": "", "durations": "",
"data": "each machine's backups: nox call <node>/node-backup.backed-up",
"forget-checks": "nox checks <owner/repo>", "queue": "nox builds; nox stuck", "cancel": "nox deliveries",
"clear": "nox deliveries", "kill": "nox deliveries", "replay": "nox dead-letters",
"pause": "nox call <node>/node-build-go.pause (or node-build-node, node-build-image)",
"resume": "nox call <node>/node-build-go.resume (or node-build-node, node-build-image)",
"push": "a merge is delivered by mesh-delivery: nox deliveries; one version on one machine is mesh-delivery-assigner.assigner_put",
"deliver": "nox deliveries", "plans": "nox deliveries", "delivery-stop": "nox stalled",
"delivery-backups-again": "nox deliveries", "delivery-walks": "nox deliveries", "delivery-order": "nox deliveries",
"rollout": "nox upgrade <module>; delivery is mesh-delivery's: nox deliveries",
}
// find answers the command whose words begin the line, the longest first, and what follows them.
func find(args []string) (*command, []string) {
var best *command
for i := range commands {
c := &commands[i]
if len(c.words) > len(args) || (best != nil && len(c.words) <= len(best.words)) {
continue
}
match := true
for j, w := range c.words {
if args[j] != w {
match = false
break
}
}
if match {
best = c
}
}
if best == nil {
return nil, nil
}
return best, args[len(best.words):]
}
// read builds the call of a line for a command that has an owner.
func (c *command) read(args []string) (call, error) {
l, err := parseLine(args)
if err != nil {
return call{}, err
}
allowed := map[string]bool{"json": true}
for _, f := range c.allowed {
allowed[f] = true
}
for f := range l.flags {
if !allowed[f] {
return call{}, usageErr("--%s is not a flag of this line", f)
}
}
return c.build(l)
}
// now is the clock; a variable so a test can say.
var now = time.Now
func fixed(address string) func(line) (call, error) {
return func(l line) (call, error) {
if len(l.pos) > 0 {
return call{}, usageErr("this line takes no %q", l.pos[0])
}
return call{address, map[string]any{}}, nil
}
}
func needArg(address, name string) func(line) (call, error) {
return func(l line) (call, error) {
if len(l.pos) != 1 {
return call{}, usageErr("give the %s", name)
}
return call{address, map[string]any{name: l.pos[0]}}, nil
}
}
func optArg(address, name string) func(line) (call, error) {
return func(l line) (call, error) {
switch len(l.pos) {
case 0:
return call{address, map[string]any{}}, nil
case 1:
return call{address, map[string]any{name: l.pos[0]}}, nil
}
return call{}, usageErr("give at most one %s", name)
}
}
func nodeArg(address string) func(line) (call, error) { return needArg(address, "node") }
func optNode(address string) func(line) (call, error) { return optArg(address, "node") }
// pick is the line's flags named, as string arguments.
func pick(l line, names ...string) map[string]any {
a := map[string]any{}
for _, n := range names {
if v, ok := l.flags[n]; ok {
a[n] = v
}
}
return a
}
// numbers turns the named arguments into numbers; the call's address is left to the caller.
func numbers(a map[string]any, names ...string) (map[string]any, error) {
for _, n := range names {
v, ok := a[n].(string)
if !ok {
continue
}
i, err := strconv.Atoi(v)
if err != nil {
return a, usageErr("--%s takes a number, not %q", n, v)
}
a[n] = i
}
return a, nil
}
// propose is a change of placement with its why.
func propose(l line, changes ...map[string]any) (call, error) {
if !l.has("why") {
return call{}, usageErr("a change of placement needs --why; the operator is asked to approve it")
}
list := make([]any, len(changes))
for i, c := range changes {
list[i] = c
}
return call{"mesh-placement.propose", map[string]any{"why": l.flags["why"], "changes": list}}, nil
}
func placeModules(op string) func(line) (call, error) {
return func(l line) (call, error) {
if len(l.pos) < 2 {
return call{}, usageErr("give the node and at least one module")
}
var changes []map[string]any
for _, m := range l.pos[1:] {
changes = append(changes, map[string]any{"op": op, "node": l.pos[0], "module": m})
}
return propose(l, changes...)
}
}
func publicDomain(l line) (call, error) {
switch {
case len(l.pos) == 1 && !l.has("clear"):
return call{"mesh-placement.placement", map[string]any{"node": l.pos[0]}}, nil
case len(l.pos) == 1:
return propose(l, map[string]any{"op": "decide", "node": l.pos[0], "field": "public-domain", "value": nil})
case len(l.pos) == 2 && !l.has("clear"):
return propose(l, map[string]any{"op": "decide", "node": l.pos[0], "field": "public-domain", "value": l.pos[1]})
}
return call{}, usageErr("give the node, and a domain or --clear")
}
func configChange(l line, action string) (call, error) {
want := 3
if action == "clear" {
want = 2
}
if len(l.pos) == 2 && action == "set" && strings.HasPrefix(strings.TrimSpace(l.pos[1]), "{") {
return call{}, usageErr("a whole settings file is not set at once any more: set each key, " +
"nox settings set <module> <key> <value> --node <node> --why <text> (mesh-config.config)")
}
if len(l.pos) != want || !l.has("node") || !l.has("why") {
return call{}, usageErr("give the module, the key%s, --node and --why", map[bool]string{true: " and the value"}[action == "set"])
}
a := map[string]any{"action": action, "module": l.pos[0], "key": l.pos[1], "node": l.flags["node"], "why": l.flags["why"]}
if action == "set" {
a["value"] = jsonOrText(l.pos[2])
}
return call{"mesh-config.config", a}, nil
}
// jsonOrText is a value as JSON when it reads as JSON (a number, true, an object), else the text itself.
func jsonOrText(v string) any {
var out any
if err := json.Unmarshal([]byte(v), &out); err == nil {
return out
}
return v
}
func retirement(address string) func(line) (call, error) {
return func(l line) (call, error) {
if len(l.pos) == 0 || !l.has("why") {
return call{}, usageErr("give the consumers waiting and --why")
}
consumers := make([]any, len(l.pos))
for i, c := range l.pos {
consumers[i] = c
}
return call{address, map[string]any{"consumers": consumers, "why": l.flags["why"]}}, nil
}
}
func enrolIssue(l line) (call, error) {
if len(l.pos) != 1 || !l.has("why") {
return call{}, usageErr("give the new machine's name and --why")
}
a := pick(l, "why", "expires")
a["node"] = l.pos[0]
return call{"mesh-enrolment.issue", a}, nil
}
func addressCall(address string, rest []string) (call, error) {
a := map[string]any{}
if len(rest) == 1 {
if err := json.Unmarshal([]byte(rest[0]), &a); err != nil {
return call{}, usageErr("the arguments are not a JSON object: %v", err)
}
}
return call{address, a}, nil
}
// renderNodes shapes placement's keys as one line per node: its name, address and site.
func renderNodes(text string) string {
var p struct {
Keys map[string]struct {
Placement struct {
Decided map[string]any `json:"decided"`
Modules []any `json:"modules"`
} `json:"placement"`
} `json:"keys"`
}
// The console says after the answer which machine answered it ("answered by ace"): kept, below the lines.
dec := json.NewDecoder(strings.NewReader(text))
if err := dec.Decode(&p); err != nil || len(p.Keys) == 0 {
return text
}
after := ""
if rest, err := io.ReadAll(dec.Buffered()); err == nil {
after = strings.TrimSpace(string(rest))
}
names := make([]string, 0, len(p.Keys))
for n := range p.Keys {
names = append(names, n)
}
sort.Strings(names)
var b strings.Builder
for _, n := range names {
k := p.Keys[n]
fmt.Fprintf(&b, "%-10s %-15v %-8v %d modules\n", n, orDash(k.Placement.Decided["address"]),
orDash(k.Placement.Decided["site"]), len(k.Placement.Modules))
}
if after != "" {
b.WriteString(after + "\n")
}
return b.String()
}
func orDash(v any) any {
if v == nil || v == "" {
return "-"
}
return v
}
+144
View File
@@ -0,0 +1,144 @@
package main
// How mesh-cli reaches the mesh now that the controller is unassigned (novox/hq issue 1126): through this machine's
// console, the mesh MCP server the module node-tools serves on loopback (provision mcp-endpoint, ADR 0152: the
// machine's login is the authority). mesh-cli calls `mesh_call` with a seat verb's or a module tool's address, and the
// console puts the call on the bus, the way every agent session on the machine does. mesh-cli holds no credential and
// keeps no bus account of its own.
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
// consoleFile is where the module writes the console's address, from the mcp-endpoint it requires; a variable so a
// test can name its own.
var consoleFile = "/etc/mesh-cli/console"
// defaultConsole is the console's address when the file is not there yet: the port node-tools declares it serves.
const defaultConsole = "http://127.0.0.1:4270/mcp"
// callWithin bounds one call: the console's own bound on a call is 30 seconds, and a wait is longer.
var callWithin = 2 * time.Minute
// maxAnswer bounds what is read of the console's answer.
const maxAnswer = 16 << 20
// consoleAddress is where the console listens, and where that was read from, for what is said when it does not answer.
func consoleAddress() (string, string) {
if v := strings.TrimSpace(os.Getenv("MESH_CONSOLE")); v != "" {
return v, "MESH_CONSOLE"
}
if raw, err := os.ReadFile(consoleFile); err == nil {
if v := strings.TrimSpace(string(raw)); v != "" && !strings.Contains(v, "${") {
return v, consoleFile
}
}
return defaultConsole, "the console's declared port (" + consoleFile + " is not there)"
}
// answer is what the console said of one call: its text, and whether the console marked it an error (a refusal, a
// tool's failure, nothing serving, no answer in time).
type answer struct {
Text string
IsError bool
}
// errNoConsole is a console that did not answer at all: nothing was called.
var errNoConsole = errors.New("no console")
// consoleTool asks the console one of its own tools (mesh_call, mesh_search, mesh_describe) and answers what it said.
func consoleTool(ctx context.Context, tool string, arguments map[string]any) (answer, error) {
url, from := consoleAddress()
body, err := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": map[string]any{"name": tool, "arguments": arguments}})
if err != nil {
return answer{}, err
}
ctx, cancel := context.WithTimeout(ctx, callWithin)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return answer{}, fmt.Errorf("the console's address %q (from %s) is not usable: %v", url, from, err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
resp, err := http.DefaultClient.Do(req)
if err != nil {
if ctx.Err() != nil {
return answer{}, ctx.Err()
}
return answer{}, fmt.Errorf("%w: nothing answers at %s (from %s), so nothing was called. The console is the "+
"module node-tools on this machine: `systemctl status` of its unit, or call from another machine: %v",
errNoConsole, url, from, err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxAnswer))
if err != nil {
return answer{}, fmt.Errorf("the console's answer could not be read: %v; the call may have run", err)
}
if resp.StatusCode != http.StatusOK {
return answer{}, fmt.Errorf("the console at %s answered %s: %s", url, resp.Status, strings.TrimSpace(string(raw)))
}
return decodeAnswer(raw)
}
// decodeAnswer reads the console's JSON-RPC answer, given as JSON or as a server-sent event's data.
func decodeAnswer(raw []byte) (answer, error) {
payload := bytes.TrimSpace(raw)
if !bytes.HasPrefix(payload, []byte("{")) {
sc := bufio.NewScanner(bytes.NewReader(raw))
sc.Buffer(make([]byte, 0, 64<<10), maxAnswer)
payload = nil
for sc.Scan() {
if d, ok := strings.CutPrefix(sc.Text(), "data:"); ok {
payload = []byte(strings.TrimSpace(d))
}
}
}
var rpc struct {
Result *struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
IsError bool `json:"isError"`
} `json:"result"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(payload, &rpc); err != nil {
return answer{}, fmt.Errorf("the console's answer is not one mesh-cli reads (%v); the call may have run", err)
}
if rpc.Error != nil {
return answer{Text: rpc.Error.Message, IsError: true}, nil
}
if rpc.Result == nil {
return answer{}, errors.New("the console answered nothing; the call may have run")
}
var parts []string
for _, c := range rpc.Result.Content {
if c.Type == "text" || c.Type == "" {
parts = append(parts, c.Text)
}
}
return answer{Text: strings.Join(parts, "\n"), IsError: rpc.Result.IsError}, nil
}
// meshCall calls one address through the console.
func meshCall(ctx context.Context, address string, args map[string]any) (answer, error) {
if args == nil {
args = map[string]any{}
}
return consoleTool(ctx, "mesh_call", map[string]any{"address": address, "arguments": args})
}
+5 -5
View File
@@ -17,8 +17,8 @@ import (
// 774/S51/AC1, AC2 (novox/hq ADR 0587 §6, §8): a token sealed to a test operator key (as mesh-vault seals it: the text
// of the token, anonymous box, base64) is opened with the private half and printed as a token the engine's parser reads
// (base64url of version 1 JSON with node, broker, fingerprint, signer and secret), and nothing is asked of the engine's
// socket, which here is a path nobody listens on; the wrong key prints nothing and says the token is not sealed to it.
// (base64url of version 1 JSON with node, broker, fingerprint, signer and secret), and nothing is asked of the console, which
// here is an address nobody listens on; the wrong key prints nothing and says the token is not sealed to it.
func TestEnrolOpenOpensASealedTokenLocallyAndOnlyWithItsKey(t *testing.T) {
t.Setenv("MESH_VERB", "")
token := base64.RawURLEncoding.EncodeToString([]byte(`{"v":1,"node":"new1","broker":"tls://10.77.0.1:4222",` +
@@ -37,9 +37,9 @@ func TestEnrolOpenOpensASealedTokenLocallyAndOnlyWithItsKey(t *testing.T) {
}
return p
}
before, beforeIn := socket, standardInput
socket = filepath.Join(dir, "nobody-listens.sock")
t.Cleanup(func() { socket, standardInput = before, beforeIn })
beforeIn := standardInput
t.Setenv("MESH_CONSOLE", "http://127.0.0.1:1/mcp") // nothing listens: enrol open asks nothing of the mesh
t.Cleanup(func() { standardInput = beforeIn })
for _, c := range []struct {
name string
args []string
+166 -82
View File
@@ -1,11 +1,8 @@
// Command mesh-cli is the operator's command on every node (novox/hq ADR 0272): `mesh-cli <command line>` asks the
// controller to run one of its command lines, and prints what it said. In interactive zsh it is also `nox`.
// Command mesh-cli is the operator's command on every node: `mesh-cli <command line>`, in interactive zsh 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.
// Each line calls the seat verb or the module tool that owns its job (novox/hq issue 1126: the controller that ran
// these lines before is unassigned), through this machine's console, the mesh MCP server on loopback (console.go).
// A line no seat or module owns says so and names what to use instead. `enrol open` runs here and asks nothing.
package main
import (
@@ -15,16 +12,14 @@ import (
"io"
"os"
"os/signal"
"runtime/debug"
"sort"
"strings"
"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
// standardInput is mesh-cli's own standard input, and whether it is a terminal (or a device such as /dev/null),
// which is never read; a variable so a test can say.
// standardInput is mesh-cli's own standard input, and whether it is a terminal (or a device such as /dev/null); a
// variable so a test can say.
var standardInput = func() (io.Reader, bool) {
info, err := os.Stdin.Stat()
if err != nil {
@@ -33,39 +28,6 @@ var standardInput = func() (io.Reader, bool) {
return os.Stdin, info.Mode()&os.ModeCharDevice != 0
}
// carried is what mesh-cli carries of its standard input for the line: what a pipe or a file held, at most
// cli.MaxStdin, for the controller's terminal alone (a secret given with `secret accept … --from -`). A terminal is
// never read: the line is asked without. More than the bound is refused, and nothing is asked.
func carried() ([]byte, error) {
in, terminal := standardInput()
if terminal {
return nil, nil
}
body, err := io.ReadAll(io.LimitReader(in, cli.MaxStdin+1))
if err != nil {
return nil, fmt.Errorf("standard input could not be read (%v); nothing was asked", err)
}
if len(body) > cli.MaxStdin {
return nil, fmt.Errorf("standard input is more than %d bytes, which mesh-cli does not carry; nothing was asked", cli.MaxStdin)
}
return body, nil
}
const usage = `mesh-cli — the controller's command lines, from this node (novox/hq ADR 0272)
mesh-cli <command line> e.g. mesh-cli status, mesh-cli settings show zsh --node <node>
nox <command line> the same, in interactive zsh
nox enrol open --key <file> opens a sealed enrolment token here, asking nothing of the mesh (ADR 0587)
Run it as yourself, never under sudo: the node-engine tells the controller which account asks.
What a pipe or a file gives its standard input (at most 64 KiB) goes with the line to the controller's
terminal alone, e.g. a secret: mesh-cli secret accept <node> <module> <name> --from - < file
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)
@@ -74,56 +36,178 @@ func main() {
}
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
if len(args) == 0 || args[0] == "-h" || args[0] == "--help" || args[0] == "help" {
fmt.Fprint(stderr, usage())
if len(args) == 0 {
return 2
}
return 0
}
// A verb's own process carries the verb it runs for; a verb is never a person, so it is never the terminal.
// A verb's own process carries the verb it runs for; a verb is never a person.
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)
"Nothing was called\n", verb)
return 1
}
switch args[0] {
case "version":
fmt.Fprintln(stdout, version())
return 0
case "search":
if len(args) < 2 {
fmt.Fprintln(stderr, "mesh-cli: give the words: nox search <words>")
return 2
}
return show(ctx, "mesh_search", map[string]any{"query": strings.Join(args[1:], " ")}, nil, stdout, stderr)
case "describe":
if len(args) != 2 {
fmt.Fprintln(stderr, "mesh-cli: give one address: nox describe <address>")
return 2
}
return show(ctx, "mesh_describe", map[string]any{"address": args[1]}, nil, stdout, stderr)
}
// `enrol open` is local: it opens a sealed enrolment token with the operator's private key and asks nothing of the
// node-engine or the mesh (novox/hq ADR 0587 §8).
// mesh (novox/hq ADR 0587 §8).
if len(args) >= 2 && args[0] == "enrol" && args[1] == "open" {
in, terminal := standardInput()
return enrolOpen(args[2:], in, terminal, stdout, stderr)
}
in, err := carried()
c, rest := find(args)
if c == nil {
if instead, ok := unowned[args[0]]; ok {
return noOwner(args[0], instead, stderr)
}
fmt.Fprintf(stderr, "mesh-cli: %q is not a command. nox help lists them; nox search <words> finds a seat verb "+
"or a tool, and nox call <address> calls it\n", args[0])
return 2
}
if c.build == nil {
return noOwner(strings.Join(c.words, " "), c.instead, stderr)
}
k, err := c.read(rest)
if err != nil {
fmt.Fprintf(stderr, "mesh-cli: %v\n", err)
return 1
fmt.Fprintf(stderr, "mesh-cli: %v. Nothing was called\n nox %s %s\n", err, strings.Join(c.words, " "), c.usage)
return 2
}
a, err := cli.AskWith(ctx, socket, cli.Request{Line: args, Stdin: in})
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
if k.Address == "mesh-delivery-view.view_status" {
return status(ctx, stdout, stderr)
}
_, _ = 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
return show(ctx, "mesh_call", map[string]any{"address": k.Address, "arguments": k.Args}, c.render, stdout, stderr)
}
// noOwner says a line has no owner since the controller went, and what to use.
func noOwner(what, instead string, stderr io.Writer) int {
fmt.Fprintf(stderr, "mesh-cli: nox %s: %s.\n", what, gone)
if instead != "" {
fmt.Fprintf(stderr, " instead: %s\n", instead)
} else {
fmt.Fprintln(stderr, " no seat or module does this now; nox search <words> finds what does nearby")
}
return 1
}
// show calls one of the console's tools and prints what it answered: an answer marked an error on standard error, with
// exit 1.
func show(ctx context.Context, tool string, args map[string]any, render func(string) string, stdout, stderr io.Writer) int {
a, err := consoleTool(ctx, tool, args)
if err != nil {
return failed(err, stderr)
}
if a.IsError {
fmt.Fprintln(stderr, "mesh-cli: "+strings.TrimRight(a.Text, "\n"))
return 1
}
text := a.Text
if render != nil {
text = render(text)
}
fmt.Fprintln(stdout, strings.TrimRight(text, "\n"))
return 0
}
// failed says why a call did not come back, and never as nothing having run unless nothing answered.
func failed(err error, stderr io.Writer) int {
switch {
case errors.Is(err, context.Canceled):
fmt.Fprintln(stderr, "mesh-cli: stopped waiting; the call may still run")
return 130
case errors.Is(err, context.DeadlineExceeded):
fmt.Fprintln(stderr, "mesh-cli: no answer in time; the call may have run. Check before repeating a change")
return 1
}
fmt.Fprintf(stderr, "mesh-cli: %v\n", err)
return 1
}
// status is the delivery view's status. When the view does not answer, that is said first and loudly, and what the
// condition keeper and mesh-delivery hold is shown in its place, each under its address, with exit 1: never shown as
// the status.
func status(ctx context.Context, stdout, stderr io.Writer) int {
a, err := meshCall(ctx, "mesh-delivery-view.view_status", nil)
if err == nil && !a.IsError {
fmt.Fprintln(stdout, strings.TrimRight(a.Text, "\n"))
return 0
}
if errors.Is(err, errNoConsole) || errors.Is(err, context.Canceled) {
return failed(err, stderr)
}
why := a.Text
if err != nil {
why = err.Error()
}
fmt.Fprintf(stderr, "mesh-cli: the status (mesh-delivery-view.view_status) did not answer: %s\n"+
"mesh-cli: what follows is not the status: the open conditions and the deliveries, read on their own\n",
strings.TrimRight(why, "\n"))
for _, address := range []string{"mesh-conditions.conditions", "mesh-delivery.deliveries"} {
fmt.Fprintf(stdout, "== %s\n", address)
show(ctx, "mesh_call", map[string]any{"address": address, "arguments": map[string]any{}}, nil, stdout, stderr)
}
return 1
}
// version is what this binary was built from.
func version() string {
info, ok := debug.ReadBuildInfo()
if !ok {
return "mesh-cli (unknown build)"
}
v := "mesh-cli " + info.Main.Version
for _, s := range info.Settings {
if s.Key == "vcs.revision" {
v += " " + s.Value
}
}
if a.Refused != "" {
fmt.Fprintln(stderr, "mesh-cli: "+a.Refused)
if a.Exit == 0 {
return 1
return v
}
// usage is every line nox takes, grouped by its first word.
func usage() string {
var b strings.Builder
b.WriteString(`mesh-cli: the operator's command (nox in interactive zsh)
Each line calls the seat verb or module tool that owns the job, through this machine's console (the mesh MCP
server on loopback). The controller that ran these lines before is unassigned (novox/hq issue 1126).
`)
lines := make([]string, 0, len(commands))
for _, c := range commands {
if c.build == nil {
continue
}
lines = append(lines, fmt.Sprintf(" nox %s %s\n %s", strings.Join(c.words, " "), c.usage, c.does))
}
return a.Exit
sort.Strings(lines)
b.WriteString(strings.Join(lines, "\n"))
b.WriteString(`
nox search <words>
find a seat verb or a module tool
nox describe <address>
one address's arguments
nox enrol open --key <file>
open an enrolment token sealed to the operator key, here; asks nothing of the mesh
nox version
Every answer is the owner's own, as JSON. A line the controller ran that no seat owns says so and what to use.
`)
return b.String()
}
+250 -118
View File
@@ -3,153 +3,285 @@ package main
import (
"bytes"
"context"
"encoding/json"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/cli"
"time"
)
// The tests' own standard input is the runner's, which may be a pipe nobody closes: read as a terminal unless a test
// says otherwise.
// 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 } }
// lastStdin is the standard input the engine was last given.
var lastStdin []byte
// 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
}
// 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 {
// 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()
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)
lastStdin = nil
go func() {
conn, err := l.Accept()
if err != nil {
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
}
defer conn.Close()
req, err := cli.ReadRequest(conn)
if err != nil {
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"`
}
lastStdin = req.Stdin
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) {
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", "")
asked := engine(t, cli.Answer{Stdout: []byte("out\n"), Stderr: []byte("err\n"), Exit: 3, Terminal: true, Why: "the terminal"})
return &got
}
func runLine(args ...string) (int, string, string) {
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())
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}`},
{"secret export", "mesh-vault.secret_vault_export", `{}`},
{"operator key show", "mesh-vault.vault_status", `{}`},
{"rotate --node ace --module plex --name token --why leak", "mesh-vault.rotate", `{"module":"plex","name":"token","node":"ace","why":"leak"}`},
{"retire approve c1 c2 --why done", "mesh-vault.provisioner_retire_approve", `{"consumers":["c1","c2"],"why":"done"}`},
{"cleanup delete ace.plex --why done --confirm", "mesh-vault.provisioner_delete", `{"confirm":"ace.plex","consumer":"ace.plex","why":"done"}`},
{"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)
}
}
}
// 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 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)
}
}
}
// 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())
// 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)
}
}
// 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())
// 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)
}
}
// 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())
// 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")
}
}
// A pipe's or a file's standard input goes with the line, whole; a terminal's is never read; more than the bound is
// refused and nothing is asked (novox/hq ADR 0259 §10).
func TestStandardInputGoesWithTheLineAndATerminalIsNeverRead(t *testing.T) {
t.Setenv("MESH_VERB", "")
was := standardInput
t.Cleanup(func() { standardInput = was })
secret := "123456789:the-bot-token\n"
standardInput = func() (io.Reader, bool) { return strings.NewReader(secret), false }
asked := engine(t, cli.Answer{Terminal: true})
var out, errOut bytes.Buffer
if code := run(context.Background(), []string{"secret", "accept", "anchor", "telegram", "telegram-token", "--from", "-"},
&out, &errOut); code != 0 {
t.Fatalf("exit %d: %s", code, errOut.String())
}
<-asked
if string(lastStdin) != secret {
t.Fatalf("the engine was given %q", lastStdin)
}
if strings.Contains(out.String()+errOut.String(), "bot-token") {
t.Fatal("mesh-cli printed what it carried")
}
standardInput = func() (io.Reader, bool) { return strings.NewReader("typed"), true }
asked = engine(t, cli.Answer{})
_ = run(context.Background(), []string{"status"}, &out, &errOut)
<-asked
if len(lastStdin) != 0 {
t.Fatalf("a terminal's input was read: %q", lastStdin)
}
standardInput = func() (io.Reader, bool) { return bytes.NewReader(make([]byte, cli.MaxStdin+1)), false }
errOut.Reset()
if code := run(context.Background(), []string{"secret", "accept", "a", "b", "c", "--from", "-"}, &out, &errOut); code == 0 ||
!strings.Contains(errOut.String(), "nothing was asked") {
t.Fatalf("more than the bound was asked: %d %s", code, errOut.String())
// 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)
}
}
}
+38 -7
View File
@@ -6,11 +6,11 @@ import (
"testing"
)
// **The archive places exactly one file, /usr/local/bin/mesh-cli** (review of novox/hq ADR 0272): one archive
// **The archive places exactly one file, /usr/local/bin/mesh-cli**, and the console's address is one file more (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 <out>/<binary>`), 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) {
func TestTheArchivePlacesExactlyOneFileAndTheConsolesAddressOne(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
@@ -24,12 +24,24 @@ func TestTheArchivePlacesExactlyOneFile(t *testing.T) {
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))
// Beside the program, one directory and one file: the console's address, from the mcp-endpoint it requires
// (novox/hq issue 1126). Nothing else.
var r map[string]any
for _, res := range m.Resources {
switch {
case res["type"] == "archive":
if r != nil {
t.Fatalf("two archives: %v", res)
}
r = res
case res["type"] == "directory" && res["path"] == "/etc/mesh-cli":
case res["type"] == "file" && res["path"] == consoleFile && res["template"] == "files/console":
default:
t.Fatalf("the module declares %v beside the program and the console's address", res)
}
}
r := m.Resources[0]
if r["type"] != "archive" || r["path"] != "/usr/local/bin" {
t.Fatalf("its resource is %v", r)
if len(m.Resources) != 3 || r == nil || r["path"] != "/usr/local/bin" {
t.Fatalf("its resources are %v", m.Resources)
}
// The build has a second artifact, the files archive the controller reads the templates back from; it
// places no file. The artifact the resource unpacks is the one that does.
@@ -66,3 +78,22 @@ func TestTheArchivePlacesExactlyOneFile(t *testing.T) {
}
}
}
// The console's address is written from the mcp-endpoint the module requires, as claude-code writes it, so it follows
// the port node-tools is bound on.
func TestTheConsolesAddressIsBoundFromTheEndpoint(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Requires []string `json:"requires"`
}
if err := json.Unmarshal(raw, &m); err != nil || len(m.Requires) != 1 || m.Requires[0] != "mcp-endpoint" {
t.Fatalf("it requires %v (%v)", m.Requires, err)
}
tpl, err := os.ReadFile("../../files/console")
if err != nil || string(tpl) != "http://127.0.0.1:${bound:mcp-endpoint:port}/mcp\n" {
t.Fatalf("files/console is %q (%v)", tpl, err)
}
}
+1
View File
@@ -0,0 +1 @@
http://127.0.0.1:${bound:mcp-endpoint:port}/mcp
+1 -9
View File
@@ -2,14 +2,6 @@ module mesh-cli
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102004146-dff0ce2abb21
require golang.org/x/crypto v0.57.0
require (
github.com/klauspost/compress v1.20.0 // indirect
github.com/nats-io/nats.go v1.54.0 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
golang.org/x/sys v0.48.0 // indirect
)
require golang.org/x/sys v0.48.0 // indirect
-12
View File
@@ -1,15 +1,3 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 h1:x8k7IeMirMTxUo8BH+xUO+MDnHYgU+636jl1NIw1wvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102004146-dff0ce2abb21 h1:4I4ayo0fZYP4PBfEX1sGDDd6arnw5IQJySJ3jcFobPM=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102004146-dff0ce2abb21/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
+17
View File
@@ -2,6 +2,9 @@
"module": "mesh-cli",
"version": "1",
"moved": true,
"requires": [
"mcp-endpoint"
],
"resources": [
{
"id": "program",
@@ -9,6 +12,20 @@
"artifact": "program",
"path": "/usr/local/bin",
"unknown": "program"
},
{
"id": "console-dir",
"type": "directory",
"path": "/etc/mesh-cli",
"mode": "0755",
"unknown": "program"
},
{
"id": "console",
"type": "file",
"path": "/etc/mesh-cli/console",
"mode": "0644",
"template": "files/console"
}
],
"items": [