diff --git a/go.mod b/go.mod index a3de83e..8127078 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/novox/mesh-host go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.9 + git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970 github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 golang.org/x/sys v0.48.0 diff --git a/go.sum b/go.sum index 9c0ea19..cee9b08 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.9 h1:2zq9tbwoXEYi+dDBxPoeNmn39KdRRKRp8CK33F4LWd0= -git.novox.be/novox/mesh-sdk/go v0.1.9/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970 h1:9tFDQsgmI+4X7/BpZGXIr+HemPKE7YddYGqWV0lINAI= +git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= diff --git a/internal/meshcli/meshcli.go b/internal/meshcli/meshcli.go index 3113b81..d6a74ea 100644 --- a/internal/meshcli/meshcli.go +++ b/internal/meshcli/meshcli.go @@ -41,6 +41,10 @@ type Asked struct { // a process another account's session started. Only a login session is a person at a terminal (novox/hq ADR // 0272): the controller makes nothing else the terminal. Session string `json:"session,omitempty"` + // Stdin is what mesh-cli's standard input held, at most cli.MaxStdin: carried to the controller with the line + // and nowhere else. The engine says it in no journal, keeps it in no state, and never sends it again: a follow + // carries none. The controller gives it to a line that runs as its terminal alone. + Stdin []byte `json:"stdin,omitempty"` } // Forward asks the controller and follows the line to what came of it (AskTheController): what it printed, that @@ -279,5 +283,5 @@ func answer(ctx context.Context, conn *net.UnixConn, held *slots, forward Forwar _ = conn.SetDeadline(time.Now().Add(cli.AskWithin)) asking, cancel := context.WithTimeout(ctx, cli.AskWithin-30*time.Second) defer cancel() - reply(forward(asking, Asked{Line: req.Line, Account: account, UID: uid, Session: session})) + reply(forward(asking, Asked{Line: req.Line, Account: account, UID: uid, Session: session, Stdin: req.Stdin})) } diff --git a/internal/meshcli/meshcli_test.go b/internal/meshcli/meshcli_test.go index b764c83..efcb923 100644 --- a/internal/meshcli/meshcli_test.go +++ b/internal/meshcli/meshcli_test.go @@ -135,6 +135,10 @@ func TestTheRequestToTheControllerKeepsItsFieldNames(t *testing.T) { if got := keysOf(t, body); got != "account line uid" { t.Fatalf("the request's fields are %q", got) } + body, _ = json.Marshal(Asked{Line: []string{"secret"}, Account: "a", UID: 1, Stdin: []byte("x")}) + if got := keysOf(t, body); got != "account line stdin uid" { + t.Fatalf("a request with standard input has the fields %q", got) + } body, _ = json.Marshal(Asked{Follow: "call-1", Account: "a", UID: 1}) if got := keysOf(t, body); got != "account follow uid" { t.Fatalf("a follow's fields are %q", got) @@ -346,3 +350,62 @@ func TestTheSessionIsReadBeforeTheLine(t *testing.T) { t.Fatalf("read in the order %v, sent %q", order, got.Session) } } + +// mesh-cli's standard input reaches the controller with its line, whole, and only with the line: a follow of the +// same call carries none, and the engine says it nowhere (novox/hq ADR 0259 ยง10, ADR 0272). +func TestStandardInputGoesToTheControllerWithTheLineAndNowhereElse(t *testing.T) { + secret := []byte("123456789:the-bot-token-given-at-the-terminal\n") + var said []string + var mu sync.Mutex + var asked [][]byte + ask := func(_ context.Context, body []byte) ([]byte, error) { + mu.Lock() + defer mu.Unlock() + asked = append(asked, append([]byte(nil), body...)) + if len(asked) == 1 { + return []byte(`{"result":{"running":true,"call":"call-7"}}`), nil + } + return []byte(`{"result":{"exit":0,"terminal":true}}`), nil + } + was := followEvery + followEvery = time.Millisecond + t.Cleanup(func() { followEvery = was }) + socket := filepath.Join(t.TempDir(), "run", "cli.sock") + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + done <- Serve(ctx, socket, func(ctx context.Context, a Asked) cli.Answer { return AskTheController(ctx, ask, a) }, + func(s string) { mu.Lock(); said = append(said, s); mu.Unlock() }) + }() + defer func() { cancel(); <-done }() + for i := 0; i < 200; i++ { + if _, err := os.Stat(socket); err == nil { + break + } + time.Sleep(5 * time.Millisecond) + } + a, err := cli.AskWith(context.Background(), socket, cli.Request{Line: []string{"secret", "accept", "anchor", + "telegram", "telegram-token", "--from", "-"}, Stdin: secret}) + if err != nil || a.Exit != 0 { + t.Fatalf("%v %+v", err, a) + } + mu.Lock() + defer mu.Unlock() + if len(asked) != 2 { + t.Fatalf("asked %d times, want the line and one follow", len(asked)) + } + var first, follow Asked + _ = json.Unmarshal(asked[0], &first) + _ = json.Unmarshal(asked[1], &follow) + if string(first.Stdin) != string(secret) { + t.Fatalf("the controller was not given the standard input whole: %q", first.Stdin) + } + if len(follow.Stdin) != 0 || strings.Contains(string(asked[1]), "stdin") { + t.Fatalf("a follow carried the standard input again: %s", asked[1]) + } + for _, s := range said { + if strings.Contains(s, "bot-token") { + t.Fatalf("the engine said the standard input: %s", s) + } + } +}