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 `), "+ "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 [--replace] [--node ], settings clear " + "[--node ], settings show [--history] [--node ], or settings preferences [] " + "[--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) }