Merge pull request 'go/cli: carry mesh-cli's standard input with its line, for the controller's terminal alone (hq ADR 0259 §10, ADR 0272)' (#15) from feat/a-terminal-line-takes-standard-input into main

This commit was merged in pull request #15.
This commit is contained in:
2026-10-09 15:31:47 +00:00
2 changed files with 65 additions and 6 deletions
+28 -5
View File
@@ -24,9 +24,13 @@ import (
// address (ADR 0272 §3). // address (ADR 0272 §3).
const Socket = "/run/nox-mesh-host/cli.sock" 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 // MaxStdin bounds the standard input mesh-cli carries for a line: a secret, a key, a settings file — kilobytes. More
// inline are a few kilobytes. // is refused by mesh-cli before anything is asked, and by the node-engine and the controller again.
const MaxRequest = 64 << 10 const MaxStdin = 64 << 10
// MaxRequest bounds what the node-engine reads of a request: a command line of a few hundred bytes, settings given
// inline of a few kilobytes, and standard input of at most MaxStdin, which JSON writes as base64 (4 bytes for every 3).
const MaxRequest = 64<<10 + MaxStdin*4/3 + 4
// MaxAnswer bounds what mesh-cli reads of an answer. The controller cuts what it runs to fit one bus message, and // 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. // says so; this is the local bound above that.
@@ -36,9 +40,16 @@ const MaxAnswer = 16 << 20
// and a minute for the link on either side. // and a minute for the link on either side.
const AskWithin = 6 * time.Minute const AskWithin = 6 * time.Minute
// Request is what mesh-cli asks: the controller's command line, as words. // Request is what mesh-cli asks: the controller's command line, as words, and what mesh-cli's own standard input
// held when it was not a terminal.
//
// **Stdin is for the controller's terminal alone** (novox/hq ADR 0259 §10, ADR 0272): a secret given with
// `secret accept … --from -`, so it never stands on a command line. The controller gives it to a line that runs as
// its terminal and refuses any other line that carries it; nobody keeps it — not the node-engine, not the
// controller's record of calls, not its journal.
type Request struct { type Request struct {
Line []string `json:"line"` Line []string `json:"line"`
Stdin []byte `json:"stdin,omitempty"`
} }
// Answer is what comes back: what the command printed, how it exited, and whether it ran as the controller's // Answer is what comes back: what the command printed, how it exited, and whether it ran as the controller's
@@ -87,6 +98,9 @@ func ReadRequest(r io.Reader) (Request, error) {
if len(req.Line) == 0 { if len(req.Line) == 0 {
return Request{}, errors.New("the request names no command") return Request{}, errors.New("the request names no command")
} }
if len(req.Stdin) > MaxStdin {
return Request{}, fmt.Errorf("%w: standard input of more than %d bytes", ErrTooLong, MaxStdin)
}
return req, nil return req, nil
} }
@@ -102,9 +116,18 @@ func ReadAnswer(r io.Reader) (Answer, error) {
// Ask is mesh-cli's side: connect to the socket, ask, and wait for the answer within AskWithin or ctx. // 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) { func Ask(ctx context.Context, socket string, line []string) (Answer, error) {
return AskWith(ctx, socket, Request{Line: line})
}
// AskWith asks a whole request: a line, and the standard input it carries, at most MaxStdin.
func AskWith(ctx context.Context, socket string, req Request) (Answer, error) {
line := req.Line
if len(line) == 0 { if len(line) == 0 {
return Answer{}, errors.New("no command given") return Answer{}, errors.New("no command given")
} }
if len(req.Stdin) > MaxStdin {
return Answer{}, fmt.Errorf("standard input is more than %d bytes, which mesh-cli does not carry; nothing was asked", MaxStdin)
}
ctx, cancel := context.WithTimeout(ctx, AskWithin) ctx, cancel := context.WithTimeout(ctx, AskWithin)
defer cancel() defer cancel()
var d net.Dialer var d net.Dialer
@@ -116,7 +139,7 @@ func Ask(ctx context.Context, socket string, line []string) (Answer, error) {
if deadline, ok := ctx.Deadline(); ok { if deadline, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(deadline) _ = conn.SetDeadline(deadline)
} }
if err := WriteRequest(conn, Request{Line: line}); err != nil { if err := WriteRequest(conn, req); err != nil {
return Answer{}, fmt.Errorf("asking the node-engine: %w", err) return Answer{}, fmt.Errorf("asking the node-engine: %w", err)
} }
a, err := ReadAnswer(conn) a, err := ReadAnswer(conn)
+36
View File
@@ -3,6 +3,7 @@ package cli
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"errors" "errors"
"net" "net"
"path/filepath" "path/filepath"
@@ -44,6 +45,17 @@ func TestARequestAndAnAnswerSurviveTheirRoundTrip(t *testing.T) {
// A request over the bound is refused, not read whole; one naming no command is refused. // A request over the bound is refused, not read whole; one naming no command is refused.
func TestARequestOverTheBoundOrNamingNothingIsRefused(t *testing.T) { func TestARequestOverTheBoundOrNamingNothingIsRefused(t *testing.T) {
long := Request{Line: []string{strings.Repeat("x", MaxRequest)}} long := Request{Line: []string{strings.Repeat("x", MaxRequest)}}
big := Request{Line: []string{"secret"}, Stdin: bytes.Repeat([]byte("s"), MaxStdin+1)}
var bb bytes.Buffer
if err := WriteRequest(&bb, big); err != nil {
t.Fatal(err)
}
if _, err := ReadRequest(&bb); !errors.Is(err, ErrTooLong) {
t.Fatalf("standard input over its bound was read: %v", err)
}
if _, err := AskWith(context.Background(), "/nonexistent.sock", big); err == nil || !strings.Contains(err.Error(), "nothing was asked") {
t.Fatalf("mesh-cli would ask with standard input over its bound: %v", err)
}
var b bytes.Buffer var b bytes.Buffer
if err := WriteRequest(&b, long); err != nil { if err := WriteRequest(&b, long); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -103,3 +115,27 @@ func TestAnUnknownOutcomeIsNotARefusal(t *testing.T) {
t.Fatalf("an unknown outcome with no call names one: %+v", b) t.Fatalf("an unknown outcome with no call names one: %+v", b)
} }
} }
// Standard input up to its bound travels whole, beside the line: a secret given with --from -.
func TestStandardInputTravelsWithItsLine(t *testing.T) {
given := append(bytes.Repeat([]byte{0, 0xff, '\n', 'x'}, MaxStdin/4-1), "end"...)
var b bytes.Buffer
if err := WriteRequest(&b, Request{Line: []string{"secret", "accept", "anchor", "telegram", "telegram-token", "--from", "-"},
Stdin: given}); err != nil {
t.Fatal(err)
}
if b.Len() > MaxRequest {
t.Fatalf("a request with standard input at its bound is %d bytes, over MaxRequest %d", b.Len(), MaxRequest)
}
got, err := ReadRequest(&b)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got.Stdin, given) || got.Line[1] != "accept" {
t.Fatalf("standard input did not travel whole: %d bytes, want %d", len(got.Stdin), len(given))
}
none, _ := json.Marshal(Request{Line: []string{"status"}})
if strings.Contains(string(none), "stdin") {
t.Fatalf("a request without standard input says it has one: %s", none)
}
}