From bfe82315f42c8f4509d0598c32569a202d5eada6 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 12:03:14 +0200 Subject: [PATCH 1/2] go/cli: the local protocol mesh-cli speaks to the node-engine (hq ADR 0272) mesh-cli holds no credential: it asks the node-engine on its own machine over a socket, and the node-engine reads the account from the kernel. The socket's path, the request and the answer are shared by the program and the node-engine, so they live here once (ADR 0039: a local protocol a module's program speaks to its runtime). The bus request the node-engine then makes is the transport's, and stays out of the SDK. --- go/README.md | 5 +- go/cli/cli.go | 151 +++++++++++++++++++++++++++++++++++++++++++++ go/cli/cli_test.go | 92 +++++++++++++++++++++++++++ 3 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 go/cli/cli.go create mode 100644 go/cli/cli_test.go diff --git a/go/README.md b/go/README.md index 4c50d16..32b6969 100644 --- a/go/README.md +++ b/go/README.md @@ -14,5 +14,6 @@ GOPRIVATE=git.novox.be go get git.novox.be/novox/mesh-sdk/go@latest Releases are tagged `go/vX.Y.Z` in this repository, the subdirectory form Go's module system expects, at the same version as the TypeScript package. -What it holds, under ADR 0039's test: the stdio loop and the tool type. Nothing else — no bus, no -client, nothing of any module's. `go build ./example` and pipe a `tools/list` request into it. +What it holds, under ADR 0039's test: the stdio loop and the tool type, and `cli`, the local protocol +`mesh-cli` speaks to the node-engine on its machine (novox/hq ADR 0272): the socket's path and one request and one +answer. Nothing else — no bus, no client, nothing of any module's. `go build ./example` and pipe a `tools/list` request into it. diff --git a/go/cli/cli.go b/go/cli/cli.go new file mode 100644 index 0000000..a8222a1 --- /dev/null +++ b/go/cli/cli.go @@ -0,0 +1,151 @@ +// Package cli is the local protocol between `mesh-cli` and the node-engine on its machine (novox/hq ADR 0272). +// +// `mesh-cli ` asks the controller to run one of its command lines. It holds no credential and +// reads no file of the mesh: it connects to the node-engine's socket, writes one Request, and reads one Answer. +// The node-engine reads the connecting process's account from the kernel and asks the controller over its own +// link; that request is the node-engine's and the controller's, and lives in neither this package nor any SDK +// (ADR 0039 as ADR 0188 reads it: the transport lives in the runtime). +// +// One request and one answer per connection, each one line of JSON. Standard library only. +package cli + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "time" +) + +// Socket is where the node-engine listens for mesh-cli: a file under its runtime directory, with no network +// address (ADR 0272 §3). +const Socket = "/run/nox-mesh-host/cli.sock" + +// MaxRequest bounds what the node-engine reads of a request. A command line is a few hundred bytes; settings given +// inline are a few kilobytes. +const MaxRequest = 64 << 10 + +// MaxAnswer bounds what mesh-cli reads of an answer. The controller cuts what it runs to fit one bus message, and +// says so; this is the local bound above that. +const MaxAnswer = 16 << 20 + +// AskWithin is how long mesh-cli waits for its answer: the controller's own bound on a call (five minutes), +// and a minute for the link on either side. +const AskWithin = 6 * time.Minute + +// Request is what mesh-cli asks: the controller's command line, as words. +type Request struct { + Line []string `json:"line"` +} + +// Answer is what comes back: what the command printed, how it exited, and whether it ran as the controller's +// terminal and why. Refused is set when nothing ran at all, and says why; Exit is then 1. +type Answer struct { + Stdout []byte `json:"stdout,omitempty"` + Stderr []byte `json:"stderr,omitempty"` + Exit int `json:"exit"` + Terminal bool `json:"terminal"` + // Why says whether the line ran as the terminal, and why or why not, in the controller's words. + Why string `json:"why,omitempty"` + // Refused says why nothing ran: the account, the node, the controller unreachable. + Refused string `json:"refused,omitempty"` + // Cut says the output was longer than one bus message and was cut there. + Cut bool `json:"cut,omitempty"` +} + +// Refusal is an answer saying that nothing ran, and why. +func Refusal(why string) Answer { return Answer{Exit: 1, Refused: why} } + +// ErrTooLong is a request or an answer over its bound. +var ErrTooLong = errors.New("longer than the protocol allows") + +// WriteRequest writes one request, as one line. +func WriteRequest(w io.Writer, r Request) error { return writeLine(w, r) } + +// ReadRequest reads one request, refusing one over MaxRequest or one that names no command. +func ReadRequest(r io.Reader) (Request, error) { + var req Request + if err := readLine(r, MaxRequest, &req); err != nil { + return Request{}, err + } + if len(req.Line) == 0 { + return Request{}, errors.New("the request names no command") + } + return req, nil +} + +// WriteAnswer writes one answer, as one line. +func WriteAnswer(w io.Writer, a Answer) error { return writeLine(w, a) } + +// ReadAnswer reads one answer, refusing one over MaxAnswer. +func ReadAnswer(r io.Reader) (Answer, error) { + var a Answer + err := readLine(r, MaxAnswer, &a) + return a, err +} + +// Ask is mesh-cli's side: connect to the socket, ask, and wait for the answer within AskWithin or ctx. +func Ask(ctx context.Context, socket string, line []string) (Answer, error) { + if len(line) == 0 { + return Answer{}, errors.New("no command given") + } + ctx, cancel := context.WithTimeout(ctx, AskWithin) + defer cancel() + var d net.Dialer + conn, err := d.DialContext(ctx, "unix", socket) + if err != nil { + return Answer{}, fmt.Errorf("the node-engine on this machine does not answer at %s: %w", socket, err) + } + defer conn.Close() + if deadline, ok := ctx.Deadline(); ok { + _ = conn.SetDeadline(deadline) + } + if err := WriteRequest(conn, Request{Line: line}); err != nil { + return Answer{}, fmt.Errorf("asking the node-engine: %w", err) + } + a, err := ReadAnswer(conn) + if err != nil { + return Answer{}, fmt.Errorf("reading the node-engine's answer: %w", err) + } + return a, nil +} + +func writeLine(w io.Writer, v any) error { + body, err := json.Marshal(v) + if err != nil { + return err + } + _, err = w.Write(append(body, '\n')) + return err +} + +// readLine reads one line of JSON of at most limit bytes. A line over the limit is refused rather than read +// whole: the reader is the side that pays for it. +func readLine(r io.Reader, limit int, into any) error { + br := bufio.NewReaderSize(io.LimitReader(r, int64(limit)+1), 64<<10) + var body []byte + for { + chunk, err := br.ReadSlice('\n') + body = append(body, chunk...) + if len(body) > limit { + return fmt.Errorf("%w: more than %d bytes", ErrTooLong, limit) + } + if err == nil { + break + } + if errors.Is(err, bufio.ErrBufferFull) { + continue + } + if errors.Is(err, io.EOF) && len(body) > 0 { + break + } + return err + } + if err := json.Unmarshal(body, into); err != nil { + return fmt.Errorf("not one: %w", err) + } + return nil +} diff --git a/go/cli/cli_test.go b/go/cli/cli_test.go new file mode 100644 index 0000000..c418639 --- /dev/null +++ b/go/cli/cli_test.go @@ -0,0 +1,92 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "net" + "path/filepath" + "strings" + "testing" +) + +// A request and an answer survive a round trip, bytes and all. +func TestARequestAndAnAnswerSurviveTheirRoundTrip(t *testing.T) { + var b bytes.Buffer + line := []string{"settings", "set", "zsh", `{"execute":"withhold"}`, "--node", "a"} + if err := WriteRequest(&b, Request{Line: line}); err != nil { + t.Fatal(err) + } + got, err := ReadRequest(&b) + if err != nil { + t.Fatal(err) + } + if strings.Join(got.Line, "\x00") != strings.Join(line, "\x00") { + t.Fatalf("the line came back as %q", got.Line) + } + + b.Reset() + sent := Answer{Stdout: []byte("ok\n\xff"), Stderr: []byte("said"), Exit: 3, Terminal: true, Why: "the terminal", Cut: true} + if err := WriteAnswer(&b, sent); err != nil { + t.Fatal(err) + } + a, err := ReadAnswer(&b) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(a.Stdout, sent.Stdout) || string(a.Stderr) != "said" || a.Exit != 3 || !a.Terminal || a.Why != sent.Why || !a.Cut { + t.Fatalf("the answer came back as %+v", a) + } +} + +// A request over the bound is refused, not read whole; one naming no command is refused. +func TestARequestOverTheBoundOrNamingNothingIsRefused(t *testing.T) { + long := Request{Line: []string{strings.Repeat("x", MaxRequest)}} + var b bytes.Buffer + if err := WriteRequest(&b, long); err != nil { + t.Fatal(err) + } + if _, err := ReadRequest(&b); !errors.Is(err, ErrTooLong) { + t.Fatalf("a request over the bound was read: %v", err) + } + if _, err := ReadRequest(strings.NewReader(`{"line":[]}` + "\n")); err == nil { + t.Fatal("a request naming no command was taken") + } + if _, err := ReadRequest(strings.NewReader("not json\n")); err == nil { + t.Fatal("a request that is not one was taken") + } +} + +// Ask speaks the protocol over a socket, as mesh-cli does to the node-engine. +func TestAskSpeaksTheProtocolOverASocket(t *testing.T) { + socket := filepath.Join(t.TempDir(), "cli.sock") + l, err := net.Listen("unix", socket) + if err != nil { + t.Skipf("no unix socket here: %v", err) + } + defer l.Close() + go func() { + conn, err := l.Accept() + if err != nil { + return + } + defer conn.Close() + req, err := ReadRequest(conn) + if err != nil { + _ = WriteAnswer(conn, Refusal(err.Error())) + return + } + _ = WriteAnswer(conn, Answer{Stdout: []byte(strings.Join(req.Line, " "))}) + }() + a, err := Ask(context.Background(), socket, []string{"status"}) + if err != nil { + t.Fatal(err) + } + if string(a.Stdout) != "status" || a.Exit != 0 || a.Refused != "" { + t.Fatalf("asked status and heard %+v", a) + } + if _, err := Ask(context.Background(), filepath.Join(t.TempDir(), "none.sock"), []string{"status"}); err == nil || + !strings.Contains(err.Error(), "does not answer") { + t.Fatalf("a missing socket was not said: %v", err) + } +} From a5a040c9049b01c40ce2ed3bb996d83f2632c0d7 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 12:37:48 +0200 Subject: [PATCH 2/2] go/cli: an outcome not known is not a refusal; the Go binding is checked in the Go toolchain Review of hq ADR 0272: a line whose answer did not come in time may have run, and saying "nothing ran" there is a lie. Answer.Unknown and NotKnown say it may have run and name the call to look at. The SDK's repo-check ran in the TypeScript toolchain only and said "NOT TESTED HERE: go/". go/merge-check.sh now runs the Go binding in the Go toolchain, declared by the root check (mesh-check-also), as the lab does. Released as go/v0.1.9 (go/v0.1.8 is mesh-sdk #12's). --- go/cli/cli.go | 13 +++++++++++++ go/cli/cli_test.go | 17 +++++++++++++++-- go/merge-check.sh | 20 ++++++++++++++++++++ merge-check.sh | 8 +++++--- 4 files changed, 53 insertions(+), 5 deletions(-) create mode 100644 go/merge-check.sh diff --git a/go/cli/cli.go b/go/cli/cli.go index a8222a1..30180fc 100644 --- a/go/cli/cli.go +++ b/go/cli/cli.go @@ -54,11 +54,24 @@ type Answer struct { Refused string `json:"refused,omitempty"` // Cut says the output was longer than one bus message and was cut there. Cut bool `json:"cut,omitempty"` + // Unknown says the line was asked and its outcome is not known: no answer came in time, and it may have + // run. Never said as a refusal, which means nothing ran. + Unknown string `json:"unknown,omitempty"` } // Refusal is an answer saying that nothing ran, and why. func Refusal(why string) Answer { return Answer{Exit: 1, Refused: why} } +// NotKnown is an answer saying the line was asked and what came of it is not known: it may have run. call is +// the controller's call the line runs as, when it is known, which `calls` answers. +func NotKnown(call, why string) Answer { + where := "the controller's `calls` lists what came of each line" + if call != "" { + where = "see the call " + call + " (`calls " + call + "`)" + } + return Answer{Exit: 1, Unknown: why + "; the line may have run — " + where} +} + // ErrTooLong is a request or an answer over its bound. var ErrTooLong = errors.New("longer than the protocol allows") diff --git a/go/cli/cli_test.go b/go/cli/cli_test.go index c418639..893dba4 100644 --- a/go/cli/cli_test.go +++ b/go/cli/cli_test.go @@ -26,7 +26,8 @@ func TestARequestAndAnAnswerSurviveTheirRoundTrip(t *testing.T) { } b.Reset() - sent := Answer{Stdout: []byte("ok\n\xff"), Stderr: []byte("said"), Exit: 3, Terminal: true, Why: "the terminal", Cut: true} + sent := Answer{Stdout: []byte("ok\n\xff"), Stderr: []byte("said"), Exit: 3, Terminal: true, Why: "the terminal", Cut: true, + Unknown: "no answer; the line may have run — see the call call-1"} if err := WriteAnswer(&b, sent); err != nil { t.Fatal(err) } @@ -34,7 +35,8 @@ func TestARequestAndAnAnswerSurviveTheirRoundTrip(t *testing.T) { if err != nil { t.Fatal(err) } - if !bytes.Equal(a.Stdout, sent.Stdout) || string(a.Stderr) != "said" || a.Exit != 3 || !a.Terminal || a.Why != sent.Why || !a.Cut { + if !bytes.Equal(a.Stdout, sent.Stdout) || string(a.Stderr) != "said" || a.Exit != 3 || !a.Terminal || a.Why != sent.Why || !a.Cut || + a.Unknown != sent.Unknown { t.Fatalf("the answer came back as %+v", a) } } @@ -90,3 +92,14 @@ func TestAskSpeaksTheProtocolOverASocket(t *testing.T) { t.Fatalf("a missing socket was not said: %v", err) } } + +// An outcome not known is never a refusal: it says the line may have run, and exits non-zero. +func TestAnUnknownOutcomeIsNotARefusal(t *testing.T) { + a := NotKnown("call-7", "no answer within 6m0s") + if a.Refused != "" || a.Exit == 0 || !strings.Contains(a.Unknown, "may have run") || !strings.Contains(a.Unknown, "call-7") { + t.Fatalf("an unknown outcome reads as %+v", a) + } + if b := NotKnown("", "no answer"); strings.Contains(b.Unknown, "call ") { + t.Fatalf("an unknown outcome with no call names one: %+v", b) + } +} diff --git a/go/merge-check.sh b/go/merge-check.sh new file mode 100644 index 0000000..19a7ec0 --- /dev/null +++ b/go/merge-check.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# mesh-check-toolchain: go +# +# The Go part of the SDK's own check (novox/hq ADR 0237 as amended, ADR 0272): the Go binding, run by the build +# seat in the mesh's Go toolchain, its own container, because the SDK's merge-check.sh declares it +# (`# mesh-check-also: go go/merge-check.sh`) — and by hand, from anywhere: `sh go/merge-check.sh`. +# +# Formatted, vetted, and its whole suite run: the stdio loop and the local protocol mesh-cli speaks to the +# node-engine. Standard library only, so nothing is fetched. +set -eu +cd "$(dirname "$0")" + +unformatted=$(gofmt -l .) +if [ -n "$unformatted" ]; then + echo "not gofmt'd:" + echo "$unformatted" + exit 1 +fi +go vet ./... +go test -count=1 ./... diff --git a/merge-check.sh b/merge-check.sh index 32c532f..b1c0cd0 100644 --- a/merge-check.sh +++ b/merge-check.sh @@ -1,5 +1,6 @@ #!/bin/sh # mesh-check-toolchain: typescript +# mesh-check-also: go go/merge-check.sh # # The SDK's own check (novox/hq ADR 0237 as amended): the second layer of a pull request's merge check, # `mesh/repo-check`, run by the build seat in the mesh's TypeScript toolchain — and by hand. @@ -9,8 +10,9 @@ # spine every module imports, type-checked and its suite run, its dependencies installed from the lock # file alone (public packages: the SDK depends on nothing of the mesh's). # -# **Said, never passed silently**: the Go, Python, Rust and C bindings are not tested in this toolchain. -# The Go binding's tests run when a Go compiler is present (a person's run), and are said as not run when not. +# **Said, never passed silently**: the Python, Rust and C bindings are not tested in any toolchain. The Go +# binding runs in the Go toolchain, from go/merge-check.sh, declared on the second line (novox/hq ADR 0272); here +# it runs only when a Go compiler is present (a person's run). set -eu npm ci --no-audit --no-fund --loglevel=error @@ -20,6 +22,6 @@ npm test if command -v go >/dev/null 2>&1; then (cd go && test -z "$(gofmt -l .)" && go vet ./... && go test -count=1 ./...) else - echo "NOT TESTED HERE: go/ (the Go binding) — the TypeScript toolchain holds no Go compiler" + echo "NOT TESTED HERE: go/ (the Go binding) — the Go toolchain runs it from go/merge-check.sh" fi echo "NOT TESTED HERE: python/, rust/, c/ — no toolchain of theirs runs a merge check"