package main import ( "bytes" "context" "encoding/json" "errors" "fmt" "os" "os/exec" "strings" "github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/link" ) // The mesh's own verbs, served as the mesh-controller seat's tools (novox/hq ADR 0154, design 33). // // **Each tool runs the command it names, in this same binary, and answers what it printed.** That is // ADR 0035 taken literally: the logic lives once, in the command, and a surface is an adapter with no // decisions in it. Running a fresh process rather than calling the function keeps two things true // that calling it would not — every command opens and closes its own stores the way it does from a // shell, and nothing a command prints to the process's standard output can leak into another call's // answer. It also means a refusal is the same refusal in the same words, because it is the same // output. // verbAnswer is what a verb answers: what the command printed, whether it succeeded, and — where the // command speaks JSON — the same as data. type verbAnswer struct { Output string `json:"output"` OK bool `json:"ok"` Answer any `json:"answer,omitempty"` } // argvFor is the command line a verb and its arguments become. Only the verbs the seat declares, and // only the arguments each declares: a caller cannot reach a flag the schema did not name. func argvFor(verb string, args map[string]any) ([]string, error) { str := func(key string) string { v, _ := args[key].(string) return strings.TrimSpace(v) } need := func(keys ...string) error { for _, k := range keys { if str(k) == "" { return fmt.Errorf("%s needs %q", verb, k) } } return nil } switch verb { case "status": return []string{"status", "--json"}, nil case "nodes": return []string{"node", "list"}, nil case "node": if err := need("node"); err != nil { return nil, err } return []string{"node", "show", str("node")}, nil case "modules": return []string{"module", "list"}, nil case "seats": return []string{"seats", "--json"}, nil case "builds": if id := str("log"); id != "" { return []string{"builds", "--log", id}, nil } if m := str("module"); m != "" { return []string{"builds", m}, nil } return []string{"builds"}, nil case "plans": if id := str("stop"); id != "" { return []string{"plans", "stop", id}, nil } if id := str("id"); id != "" { return []string{"plans", id}, nil } return []string{"plans"}, nil case "plan": if err := need("node"); err != nil { return nil, err } return []string{"plan", str("node"), "--json"}, nil case "assign", "unassign": if err := need("node", "module"); err != nil { return nil, err } return []string{verb, str("node"), str("module")}, nil case "pin": if err := need("node", "provision", "from", "module"); err != nil { return nil, err } return []string{"pin", str("node"), str("provision"), str("from"), str("module")}, nil case "unpin": if err := need("node", "provision"); err != nil { return nil, err } return []string{"unpin", str("node"), str("provision")}, nil case "push": // Sent and not waited for: the asker reads `status` for what the machine did, which is // what a person at a shell does too. A tool call that blocked for a push's whole apply would // time out on every machine that takes a minute, and say nothing about the ones that did not. if n := str("node"); n != "" { return []string{"push", n, "--wait", "0"}, nil } return []string{"push", "--behind", "--wait", "0"}, nil case "rotate": if p := str("provision"); p != "" { argv := []string{"rotate", p} if c := str("consumer"); c != "" { argv = append(argv, "--consumer", c) } return argv, nil } if str("node") != "" && str("module") != "" && str("secret") != "" { return []string{"secret", "rotate", str("node"), str("module"), str("secret")}, nil } // Half of either shape: the command says its usage, which names both shapes, and that is // the answer the caller needs. return []string{"rotate"}, nil case "build": if err := need("repository"); err != nil { return nil, err } // Not waited for: a tool call cannot hold a connection for the minutes a build takes; the // daemon takes the outcome in when it comes and the id follows the build (issue 176). A // repository given without a scheme is a path on the forge holding the git seat. argv := []string{"build", str("repository"), "--wait", "0"} if !strings.Contains(str("repository"), "://") && !strings.HasPrefix(str("repository"), "git@") { argv = append(argv, "--self") } if p := str("path"); p != "" { argv = append(argv, "--path", p) } if r := str("ref"); r != "" { argv = append(argv, "--ref", r) } return argv, nil } return nil, fmt.Errorf("%q is not a verb the %s seat serves", verb, catalogue.ControllerSeatName) } // jsonVerbs are the verbs whose command speaks JSON, so the answer carries it as data as well. var jsonVerbs = map[string]bool{"status": true, "seats": true, "plan": true} // runVerb runs this binary with the given command line and gathers what it said. func runVerb(ctx context.Context, argv []string) (verbAnswer, error) { self, err := os.Executable() if err != nil { return verbAnswer{}, err } cmd := exec.CommandContext(ctx, self, argv...) // The same environment: the stores' credentials, the bus, the broker — everything a command run // from a shell in this container would have, because it is that. cmd.Env = os.Environ() // Two buffers, one answer. What the command *says* is both streams, in the order a person at // a shell would read them; what it *answers as data* is standard output alone — `status --json` // prints its warnings beside the document, and a JSON parsed from the two together parsed // nothing (2026-09-30, the first status asked through the console had no `answer`). var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr runErr := cmd.Run() answer := verbAnswer{Output: stdout.String() + stderr.String(), OK: runErr == nil} if jsonVerbs[argv[0]] && runErr == nil { var parsed any if json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &parsed) == nil { answer.Answer = parsed } } var exit *exec.ExitError if runErr != nil && !errors.As(runErr, &exit) { // Not the command refusing — the command not running at all, which is this process's fault. return answer, fmt.Errorf("could not run %s: %w", strings.Join(argv, " "), runErr) } return answer, nil } // seatToolHandlers are the handlers for every verb the mesh-controller seat declares, from the // store's row, so a verb the row does not carry is not served and a verb it carries that this binary // cannot run is said at start rather than at the first call. func seatToolHandlers() (map[string]link.ToolHandler, error) { seat, known := catalogue.SeatNamed(catalogue.ControllerSeatName) if !known { return nil, fmt.Errorf("this mesh defines no %s seat", catalogue.ControllerSeatName) } handlers := map[string]link.ToolHandler{} for _, v := range seat.Serves { verb := v.Name if verb == "tools" { handlers[verb] = func(ctx context.Context, _ json.RawMessage) (any, error) { return seatTools(), nil } continue } if _, err := argvFor(verb, sampleArguments(v)); err != nil { return nil, fmt.Errorf("the %s seat's row declares %q, which this control plane cannot run: %w", catalogue.ControllerSeatName, verb, err) } handlers[verb] = func(ctx context.Context, raw json.RawMessage) (any, error) { args := map[string]any{} if len(raw) > 0 { if err := json.Unmarshal(raw, &args); err != nil { return nil, fmt.Errorf("the arguments are not a JSON object: %w", err) } } argv, err := argvFor(verb, args) if err != nil { return nil, err } return runVerb(ctx, argv) } } return handlers, nil } // seatTools is what `tools` answers: every seat with a protocol, and the tools each serves, from the // mesh's own records — no holder in the path, so it is true while a holder restarts (design 33 §5). func seatTools() map[string]any { var seats []map[string]any for _, s := range catalogue.SeatsWithAProtocol() { if len(s.Serves) == 0 { continue } var tools []map[string]any for _, v := range s.Serves { tools = append(tools, map[string]any{ "name": v.Name, "description": v.Description, "input": v.Input, "output": v.Output, }) } seats = append(seats, map[string]any{"seat": s.Name, "scope": s.Scope, "tools": tools}) } return map[string]any{"seats": seats} } // sampleArguments is one of every argument a verb's schema requires, so the check at start proves the // verb runnable rather than that it happens to want the arguments the check guessed. func sampleArguments(v catalogue.Verb) map[string]any { sample := map[string]any{"node": "x", "module": "x", "repository": "x"} switch required := v.Input["required"].(type) { case []string: for _, k := range required { sample[k] = "x" } case []any: for _, k := range required { if name, ok := k.(string); ok { sample[name] = "x" } } } return sample }