mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check fail: its merge-check.sh failed: --- FAIL: TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose (0.62s)
mesh/delivery-group group feat/a-terminal-line-takes-standard-input rejected: a member's own check failed
mesh/delivery superseded: a newer head of the same pull request
A secret given at the controller's terminal through mesh-cli (secret accept … --from -) never reached the line: every line ran with no standard input. A line that runs as the terminal now reads what mesh-cli carried (at most 64 KiB); an ordinary call carrying any is refused and nothing runs; the calls record keeps only that some was given, the journal and the answer nothing of it.
272 lines
11 KiB
Go
272 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os/exec"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/novox/mesh-controller/internal/inventory"
|
|
"github.com/novox/mesh-controller/internal/link"
|
|
)
|
|
|
|
// The operator's command, `mesh-cli` (alias `nox`), answered here (novox/hq ADR 0272).
|
|
//
|
|
// mesh-cli asks the node-engine on its own machine; the engine reads the asking account from the kernel and asks
|
|
// this controller on its own node's subject. Here it is judged — the controller's terminal, an ordinary call, or
|
|
// nothing — and run as every verb's line is: a fresh process of this binary, with this process's environment.
|
|
//
|
|
// **The terminal** is a line from a node's operator account, on the control-node (§4). Phase 2 adds a node whose
|
|
// agents run under an account of their own, judged unable to become root (ADR 0266, not built yet); until then no
|
|
// other node is the terminal, because agents there run as its operator. **An ordinary call** is a line from that
|
|
// account elsewhere: it meets every refusal of the generic `command` verb and runs with `MESH_VERB=mesh-cli`, so a
|
|
// terminal-only change is refused with its reason. **Any other account is refused**, root included: those two
|
|
// accounts already reach the mesh's verbs through the mesh MCP server, and no other account gains anything here.
|
|
|
|
// cliVerb is what a line from mesh-cli that is not the terminal runs as: the verb its process names, so every
|
|
// terminal-only refusal applies and says it came through mesh-cli.
|
|
const cliVerb = "mesh-cli"
|
|
|
|
// cliServers are commands that serve until stopped. Run for mesh-cli they would hold a second server under this
|
|
// one until the call's bound killed it.
|
|
var cliServers = map[string]bool{"serve": true, "api": true, "board": true}
|
|
|
|
// cliVerdict is how a line is run, or why it is not.
|
|
type cliVerdict struct {
|
|
terminal bool
|
|
// why says why the line is or is not the terminal, in words the operator reads under the answer.
|
|
why string
|
|
// refused says why nothing runs.
|
|
refused string
|
|
}
|
|
|
|
// judgeCLI decides how a line from mesh-cli runs (ADR 0272 §4). nodes is every node the mesh knows; control is
|
|
// every node the controller's module is assigned to, which is exactly one in a mesh that is well.
|
|
func judgeCLI(node string, asked link.CLIAsked, nodes []inventory.Node, control []string) cliVerdict {
|
|
var record *inventory.Node
|
|
for i := range nodes {
|
|
if nodes[i].Name == node {
|
|
record = &nodes[i]
|
|
break
|
|
}
|
|
}
|
|
switch {
|
|
case record == nil:
|
|
return cliVerdict{refused: fmt.Sprintf("%s is not a node this mesh knows, so nothing ran", node)}
|
|
case asked.UID == 0 || asked.Account == "root":
|
|
return cliVerdict{refused: "mesh-cli answers the operator's own account, never root: run it as yourself, " +
|
|
"not under sudo. Nothing ran"}
|
|
case record.Account == "":
|
|
return cliVerdict{refused: fmt.Sprintf("the mesh does not know %s's operator account (`node account <node> <login>`), "+
|
|
"so no account there is answered. Nothing ran", node)}
|
|
case asked.Account != record.Account:
|
|
return cliVerdict{refused: fmt.Sprintf("mesh-cli answers %s's operator account (%s) only, and this line came "+
|
|
"from %s. Nothing ran", node, record.Account, asked.Account)}
|
|
}
|
|
if len(control) != 1 {
|
|
return cliVerdict{why: fmt.Sprintf("not the controller's terminal: the mesh names %d control-nodes, and the "+
|
|
"terminal is the one control-node's operator account", len(control))}
|
|
}
|
|
if control[0] == node {
|
|
// **A person at a terminal, not a service of the operator's** (review of ADR 0272): the tool runner and the
|
|
// account's user units run as the operator too, and only a login session is the terminal.
|
|
if asked.Session == "" {
|
|
return cliVerdict{why: fmt.Sprintf("not the controller's terminal: %s on %s asked from no login session — a "+
|
|
"service or a user unit running as the operator, not a person at a terminal", asked.Account, node)}
|
|
}
|
|
return cliVerdict{terminal: true, why: fmt.Sprintf("the controller's terminal: %s on the control-node %s, "+
|
|
"login session %s", asked.Account, node, asked.Session)}
|
|
}
|
|
return cliVerdict{why: fmt.Sprintf("not the controller's terminal: agents on %s may run as %s, so a "+
|
|
"terminal-only change is made from the control-node %s (novox/hq ADR 0272)", node, asked.Account, control[0])}
|
|
}
|
|
|
|
// controlNodes is every node the controller's module is assigned to.
|
|
func controlNodes(ctx context.Context, inv *inventory.Inventory, nodes []inventory.Node) ([]string, error) {
|
|
var out []string
|
|
for _, n := range nodes {
|
|
modules, err := inv.Assigned(ctx, n.Name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if slices.Contains(modules, controllerModule) {
|
|
out = append(out, n.Name)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// controllerModule is the module that runs the controller, and so marks the control-node.
|
|
const controllerModule = "mesh-controller"
|
|
|
|
// answerMeshCLI is the serving controller's answer to mesh-cli.
|
|
func answerMeshCLI(inv *inventory.Inventory) link.CLIHandler {
|
|
return func(ctx context.Context, node string, asked link.CLIAsked) link.CLIAnswer {
|
|
if handingOver.Load() {
|
|
return link.CLIRefusal("this controller is stopping and runs no new command; ask again in a moment. Nothing ran")
|
|
}
|
|
nodes, err := inv.Nodes(ctx)
|
|
if err != nil {
|
|
return link.CLIRefusal("the mesh's nodes cannot be read, so nothing ran: " + err.Error())
|
|
}
|
|
control, err := controlNodes(ctx, inv, nodes)
|
|
if err != nil {
|
|
return link.CLIRefusal("which node is the control-node cannot be read, so nothing ran: " + err.Error())
|
|
}
|
|
return runForMeshCLI(ctx, node, asked, judgeCLI(node, asked, nodes, control))
|
|
}
|
|
}
|
|
|
|
// cliJournal says one line in the controller's journal (its standard output); a variable so a test can read it.
|
|
var cliJournal = func(line string) { fmt.Println(line) }
|
|
|
|
// runForMeshCLI runs a judged line and answers what it said. Every line is said in the journal first: its call,
|
|
// who asked on which node, its command word only, and how it runs (review of ADR 0272).
|
|
func runForMeshCLI(ctx context.Context, node string, asked link.CLIAsked, v cliVerdict) link.CLIAnswer {
|
|
how := "as an ordinary call"
|
|
switch {
|
|
case v.refused != "":
|
|
how = "refused: " + v.refused
|
|
case v.terminal:
|
|
how = "as the controller's terminal"
|
|
}
|
|
call := link.CallIDIn(ctx)
|
|
if call == "" {
|
|
call = "(no call)"
|
|
}
|
|
cliJournal(fmt.Sprintf("mesh-cli %s: %s on %s asked %q, %s", call, asked.Account, node, asked.Line[0], how))
|
|
if v.refused != "" {
|
|
return link.CLIRefusal(v.refused)
|
|
}
|
|
// Standard input is the terminal's alone: a secret given at the terminal reaches `secret accept`, and no ordinary
|
|
// call is handed what the asker's standard input held (novox/hq ADR 0259 §10, ADR 0272).
|
|
if len(asked.Stdin) > 0 && !v.terminal {
|
|
return link.CLIAnswer{Exit: 1, Why: v.why, Refused: "standard input is given to a line that runs as the " +
|
|
"controller's terminal alone, and this one does not. Nothing ran"}
|
|
}
|
|
if cliServers[asked.Line[0]] {
|
|
return link.CLIAnswer{Exit: 1, Why: v.why, Refused: fmt.Sprintf("%s serves until stopped, and is not a "+
|
|
"command line mesh-cli runs. Nothing ran", asked.Line[0])}
|
|
}
|
|
verb, line := "", asked.Line
|
|
if !v.terminal {
|
|
composed, err := ordinaryLine(asked.Line)
|
|
if err != nil {
|
|
return link.CLIAnswer{Exit: 1, Why: v.why, Refused: err.Error()}
|
|
}
|
|
verb, line = cliVerb, composed
|
|
}
|
|
cmd := selfCommand(ctx, line)
|
|
cmd.Env = commandEnvironment(fmt.Sprintf("%s through mesh-cli on %s", asked.Account, node), verb, v.terminal)
|
|
// No standard input unless mesh-cli carried one for a terminal line: a command that reads one gets nothing, and
|
|
// fails saying so (ADR 0272 §5).
|
|
cmd.Stdin = nil
|
|
if len(asked.Stdin) > 0 {
|
|
cmd.Stdin = bytes.NewReader(asked.Stdin)
|
|
}
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
|
err := cmd.Run()
|
|
answer := link.CLIAnswer{Stdout: stdout.Bytes(), Stderr: stderr.Bytes(), Terminal: v.terminal, Why: v.why}
|
|
var exit *exec.ExitError
|
|
switch {
|
|
case err == nil:
|
|
case errors.As(err, &exit):
|
|
answer.Exit = exit.ExitCode()
|
|
if answer.Exit < 0 {
|
|
// Killed: by the call's bound, or by this controller stopping.
|
|
answer.Exit = 1
|
|
answer.Stderr = append(answer.Stderr, []byte(fmt.Sprintf("\nmesh-cli: the command was stopped (%v)\n",
|
|
exit))...)
|
|
}
|
|
default:
|
|
answer.Exit = 1
|
|
answer.Refused = fmt.Sprintf("could not run %s from this controller's own build: %v", strings.Join(asked.Line, " "), err)
|
|
}
|
|
return answer
|
|
}
|
|
|
|
// settingsForms is what an ordinary `settings` line may say: the settings verb's own forms.
|
|
const settingsForms = "settings set <module> <values> [--replace] [--node <node>], settings clear <module> " +
|
|
"[--node <node>], settings show <module> [--history] [--node <node>], or settings preferences [<module>] " +
|
|
"[--node <node>]"
|
|
|
|
// ordinaryLine is the command line an ordinary call runs (ADR 0272 §4): a `settings` line composed exactly as the
|
|
// settings verb composes its own, so its refusals — the terminal-only keys among them — are that verb's (review of
|
|
// ADR 0272); any other line as the generic `command` verb takes it, with its refusals.
|
|
func ordinaryLine(argv []string) ([]string, error) {
|
|
if len(argv) == 0 || argv[0] != "settings" {
|
|
// What the generic verb runs, and nothing it would refuse: its reading forms only, and never a command that
|
|
// is the terminal's alone (novox/hq ADR 0266) — the two checks argvFor makes of the `command` verb's line,
|
|
// made of the words as given rather than re-split from one string.
|
|
if err := refusedAsTheGenericCommand(argv); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := terminalOnly(argv); err != nil {
|
|
return nil, err
|
|
}
|
|
return argv, nil
|
|
}
|
|
args := map[string]any{}
|
|
var words []string
|
|
rest := argv[1:]
|
|
for i := 0; i < len(rest); i++ {
|
|
w := rest[i]
|
|
switch {
|
|
case w == "--replace" || w == "-replace":
|
|
args["replace"] = "true"
|
|
case w == "--history" || w == "-history":
|
|
args["history"] = "true"
|
|
case w == "--node" || w == "-node":
|
|
if i+1 >= len(rest) {
|
|
return nil, fmt.Errorf("--node names no node; settings takes %s. Nothing ran", settingsForms)
|
|
}
|
|
i++
|
|
args["node"] = rest[i]
|
|
case strings.HasPrefix(w, "--node=") || strings.HasPrefix(w, "-node="):
|
|
_, args["node"], _ = strings.Cut(w, "=")
|
|
case strings.HasPrefix(w, "-"):
|
|
return nil, fmt.Errorf("settings takes no %s through mesh-cli outside the terminal; it takes %s. "+
|
|
"Nothing ran", w, settingsForms)
|
|
default:
|
|
words = append(words, w)
|
|
}
|
|
}
|
|
wrong := fmt.Errorf("through mesh-cli outside the terminal, settings takes the settings verb's forms: %s. "+
|
|
"Nothing ran", settingsForms)
|
|
if len(words) == 0 {
|
|
return nil, wrong
|
|
}
|
|
switch words[0] {
|
|
case "set":
|
|
if len(words) != 3 {
|
|
return nil, wrong
|
|
}
|
|
args["module"], args["values"] = words[1], words[2]
|
|
case "clear":
|
|
if len(words) != 2 {
|
|
return nil, wrong
|
|
}
|
|
args["module"], args["clear"] = words[1], "true"
|
|
case "show":
|
|
if len(words) != 2 {
|
|
return nil, wrong
|
|
}
|
|
args["module"] = words[1]
|
|
case "preferences":
|
|
if len(words) > 2 {
|
|
return nil, wrong
|
|
}
|
|
args["list"] = "preferences"
|
|
if len(words) == 2 {
|
|
args["module"] = words[1]
|
|
}
|
|
default:
|
|
return nil, wrong
|
|
}
|
|
return argvFor("settings", args)
|
|
}
|