From 9fcb14e74141836935f5e1c2baf95aa085e08074 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 10:21:21 +0100 Subject: [PATCH] A3h item 1: the tool runner serves a held seat's approvals from its gate's record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seat verb approvals is the gate's own, served wherever a marked verb is: the runner answers it by caller from the calls its gate holds, with no bundle naming it; a marked verb already reaches the gate by caller. Tested with zsh's node-login-shell execute as the case. Builds: design 47 G §677, §697; lead answers A-264, A-265, A-266, A-271 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/internal/runtime/gate.go | 10 +++ .../internal/runtime/gate_approvals_test.go | 79 +++++++++++++++++++ node-tools/internal/runtime/runtime.go | 26 +++++- .../test/fixtures/login-shell-execute.py | 23 ++++++ 4 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 node-tools/internal/runtime/gate_approvals_test.go create mode 100755 node-tools/test/fixtures/login-shell-execute.py diff --git a/node-tools/internal/runtime/gate.go b/node-tools/internal/runtime/gate.go index 61af8a6..4ae83fb 100644 --- a/node-tools/internal/runtime/gate.go +++ b/node-tools/internal/runtime/gate.go @@ -18,6 +18,8 @@ type Gate interface { Unserved(module, fingerprint string, registered []string) []string // Run is a call through the gate: run is called only when the call may run now, once. Run(call approval.Call, run func() (any, error)) (any, error) + // Approvals is the seat verb `approvals` for a caller: the calls the gate holds, its acts and claims (A-265, A-271). + Approvals(caller string) (approval.Approvals, error) } // gatedRun is how a handler calls a tool: through the gate where there is one. @@ -32,6 +34,14 @@ func (c *consumers) gated(call approval.Call, t launch.Tool, caller string) (any return c.gate.Run(call, run) } +// approvalsOf is how the runner answers a held seat's verb `approvals`: from its gate's record, nil without a gate. +func (r *runner) approvalsOf() func(caller string) (any, error) { + if r.events.gate == nil { + return nil + } + return func(caller string) (any, error) { return r.events.gate.Approvals(caller) } +} + // WithGate serves every call through the approval gate. func WithGate(g Gate) Option { return func(c *consumers) { c.gate = g } } diff --git a/node-tools/internal/runtime/gate_approvals_test.go b/node-tools/internal/runtime/gate_approvals_test.go new file mode 100644 index 0000000..41d1c8b --- /dev/null +++ b/node-tools/internal/runtime/gate_approvals_test.go @@ -0,0 +1,79 @@ +package runtime + +import ( + "encoding/json" + "testing" + + "git.novox.be/novox/mesh-sdk/go/approval" + "git.novox.be/novox/mesh-sdk/go/seatdef" + + "github.com/novox/mesh-tools/node-tools/internal/bus" + mt "github.com/novox/mesh-tools/node-tools/internal/meshtest" +) + +// gateHolding is the runner's gate as far as the runtime reaches it: it records the calls it is run for and answers +// `approvals` from them. +type gateHolding struct{ calls []approval.Call } + +func (g *gateHolding) Unserved(string, string, []string) []string { return nil } +func (g *gateHolding) Run(c approval.Call, run func() (any, error)) (any, error) { + g.calls = append(g.calls, c) + return run() +} +func (g *gateHolding) Approvals(caller string) (approval.Approvals, error) { + out := approval.Approvals{Calls: []approval.ListedCall{}, Acts: []approval.ListedAct{}, Claims: []string{}} + for i, c := range g.calls { + l := approval.ListedCall{Call: string(rune('a' + i)), Caller: c.Caller, Verb: c.Seat + "." + c.Name, State: "waiting"} + if caller == c.Caller { + l.Arguments = c.Args + } + out.Calls = append(out.Calls, l) + } + return out, nil +} + +// **The tool runner answers a held seat's marked verb by caller and serves the seat's `approvals`** (design 47 G §677; lead +// answers A-265, A-271), with zsh's node-login-shell `execute` as the case: the marked verb reaches the gate with the caller +// its subject names, and `approvals` is answered from the runner's own record of the calls its gate holds, with the +// arguments for the call's own caller only, though no bundle names the verb. +func TestTheRunnerServesApprovalsForTheSeatsItsBundlesHold(t *testing.T) { + mesh := mt.New(t) + mesh.Issue(t, mt.MembershipOf("zsh", "anchor", true, map[string][]string{"node-login-shell": {"execute", seatdef.ApprovalsVerb}})) + nodeTools := connect(t, "node-tools", "anchor") + gate := &gateHolding{} + defined := WithSeatDefinitions(func() map[string]seatdef.Definition { + return map[string]seatdef.Definition{"node-login-shell": {Seat: "node-login-shell", Verbs: []seatdef.Verb{ + {Name: "execute", NeedsApproval: true}, {Name: seatdef.ApprovalsVerb}}}} + }) + stop, err := Run(nodeTools, []Served{{"zsh", []string{mt.Fixture("login-shell-execute.py")}}}, nil, func(string, ...any) {}, defined, WithGate(gate)) + if err != nil { + t.Fatal(err) + } + defer stop() + asker, err := bus.Connect(bus.Credential{URL: mt.URL(t), Module: "node-tools", Node: "shanks", User: "shanks.node-tools", Password: "unused"}) + if err != nil { + t.Fatal(err) + } + asker.Logf = func(string, ...any) {} + defer asker.Close() + + mt.Until(t, func() error { + _, err := asker.Ask("seat:node-login-shell.execute@anchor", map[string]any{"command": "ls"}, "") + return err + }) + if len(gate.calls) != 1 || gate.calls[0].Seat != "node-login-shell" || gate.calls[0].Name != "execute" || gate.calls[0].Caller != "shanks.node-tools" { + t.Fatalf("the marked verb reached the gate as %+v, want the seat's execute by shanks.node-tools", gate.calls) + } + var got approval.Approvals + mt.Until(t, func() error { + answered, err := asker.Ask("seat:node-login-shell.approvals@anchor", map[string]any{}, "") + if err == nil { + err = json.Unmarshal(answered.Result, &got) + } + return err + }) + if len(got.Calls) != 1 || got.Calls[0].Caller != "shanks.node-tools" || got.Calls[0].Verb != "node-login-shell.execute" || + string(got.Calls[0].Arguments) != `{"command":"ls"}` { + t.Fatalf("approvals answered %+v, want the one call the gate holds, with its caller's arguments", got) + } +} diff --git a/node-tools/internal/runtime/runtime.go b/node-tools/internal/runtime/runtime.go index d42b3a4..c5da7b0 100644 --- a/node-tools/internal/runtime/runtime.go +++ b/node-tools/internal/runtime/runtime.go @@ -306,7 +306,7 @@ func Run(conn *bus.Conn, served []Served, envs map[string]map[string]string, log } logf("%s", line) conn.OnMembership(func(bus.Membership) { go func() { r.refreshRepairs(); conn.Flush() }() }) - stopSeats, again := serveSeats(conn, r.modules, r.registrations, r.events.gated, r.events.admitted, r.fingerprintOf, logf) + stopSeats, again := serveSeats(conn, r.modules, r.registrations, r.events.gated, r.events.admitted, r.approvalsOf(), r.fingerprintOf, logf) r.seatsAgain = again r.stops = append(r.stops, stopSeats) @@ -655,6 +655,9 @@ func endpointsOf(conn *bus.Conn, own []registration, registrations []registratio } for _, v := range m.Seats { t, ok := impl[v.Seat][v.Verb] + if v.Verb == seatdef.ApprovalsVerb && !ok { + t, ok = launch.Tool{Name: v.Verb, Description: "The calls this seat's holder holds for the operator's approval, its acts and its claims."}, true + } if !ok || have[v.Subject] { continue } @@ -710,7 +713,8 @@ func subjectsOf(conn *bus.Conn, module, tool string) []string { // whenever a membership changes, and when again is called because a bundle was loaded again. Whether // this machine holds the seat is the bus's to decide. It answers its stop and again. func serveSeats(conn *bus.Conn, modules []string, current func() []registration, gated gatedRun, - admitted func(seat, verb, caller string) error, fingerprintOf func(string) string, logf func(string, ...any)) (func(), func()) { + admitted func(seat, verb, caller string) error, approvals func(caller string) (any, error), fingerprintOf func(string) string, + logf func(string, ...any)) (func(), func()) { var mu sync.Mutex var stops []func() stopped := false @@ -744,6 +748,24 @@ func serveSeats(conn *bus.Conn, modules []string, current func() []registration, continue } have[v.Subject] = true + // The seat's verb `approvals` is the gate's own, served wherever a marked verb is: the runner answers it from its + // record of the calls its gate holds, by caller, and no bundle names it (lead answers A-265, A-266, A-271). + if v.Verb == seatdef.ApprovalsVerb && approvals != nil { + seat := v.Seat + stop, err := conn.HandleSubjectCalled(v.Subject, func(caller string, _ json.RawMessage) (any, error) { + if err := admitted(seat, seatdef.ApprovalsVerb, caller); err != nil { + return nil, err + } + return approvals(caller) + }) + if err != nil { + logf("[mesh-tools] cannot serve %s's approvals on %s: %v", seat, v.Subject, err) + continue + } + stops = append(stops, stop) + logf("[mesh-tools] serving %s's approvals on %s, the gate's own verb", seat, v.Subject) + continue + } t, ok := impl[v.Seat][v.Verb] if !ok { logf("[mesh-tools] %s claims %s and implements no %s, which that seat promises; not served", module, v.Seat, v.Verb) diff --git a/node-tools/test/fixtures/login-shell-execute.py b/node-tools/test/fixtures/login-shell-execute.py new file mode 100755 index 0000000..3c4517e --- /dev/null +++ b/node-tools/test/fixtures/login-shell-execute.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +# The login shell's bundle as the seat node-login-shell has it: MCP over stdio, no SDK, one seat verb, execute, which says +# the command it was given and the caller the call's `_meta` named. +import json, sys + +def say(m): + m["jsonrpc"] = "2.0"; sys.stdout.write(json.dumps(m) + "\n"); sys.stdout.flush() + +TOOLS = [ + {"name": "node-login-shell.execute", "description": "run a command", "inputSchema": {"type": "object", "properties": {"command": {"type": "string"}}}}, +] +for line in sys.stdin: + req = json.loads(line); rid = req.get("id"); m = req.get("method"); p = req.get("params") or {} + if m == "initialize": + say({"id": rid, "result": {"protocolVersion": "2025-03-26", "capabilities": {"tools": {}}, "serverInfo": {"name": "zsh", "version": "1"}}}) + elif m == "tools/list": + say({"id": rid, "result": {"tools": TOOLS}}) + elif m == "tools/call": + meta = p.get("_meta") or {} + said = {"ran": (p.get("arguments") or {}).get("command", ""), "caller": meta.get("mesh/caller", "")} + say({"id": rid, "result": {"content": [{"type": "text", "text": json.dumps(said)}]}}) + elif rid is not None: + say({"id": rid, "result": {}})