From d9e24e95a2424e15b373771125f83b5eeb50dd3a Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 11 Oct 2026 05:11:08 +0200 Subject: [PATCH] go: a tool reads its caller from the runtime, so a record can say who acted (hq issue 365) The runtime names the caller it took from the subject a call arrived on in the call's _meta; stdio.CallerOf(args) hands it to the tool. Never read from the arguments, and Unproven when the runtime named none, so a bundle on an older runtime still says, explicitly, that its caller was not proven. --- go/README.md | 2 +- go/caller.go | 59 ++++++++++++++++++++++++++ go/caller_test.go | 105 ++++++++++++++++++++++++++++++++++++++++++++++ go/stdio.go | 11 +++-- 4 files changed, 172 insertions(+), 5 deletions(-) create mode 100644 go/caller.go create mode 100644 go/caller_test.go diff --git a/go/README.md b/go/README.md index 32b6969..680c496 100644 --- a/go/README.md +++ b/go/README.md @@ -2,7 +2,7 @@ `stdio.Serve("", tools)` speaks MCP over stdio to the node's tool runtime, as the module the runtime names (novox/hq ADR 0188, ADR 0193). A `Tool` is a name, a description, an argument schema and a -function; calls run concurrently. A tool named `.` is the seat's implementation. +function; calls run concurrently. `stdio.CallerOf(args)` inside a tool is who called it: the bus user the runtime took from the subject the call arrived on, named in the call's `_meta`, never read from the arguments — or `stdio.Unproven` when the runtime named none (novox/hq issue 365). A tool named `.` is the seat's implementation. `stdio.Emit(type, body)` publishes an event as the module, `stdio.Subscribe(pattern, handler)` receives the module's events (taken when the handler returns nil, offered again when it returns an error — the one rule is in the repository README, "Taking an event") and `stdio.Ask(key, body)` calls a tool, and `stdio.State(name)` gets, puts, deletes, lists and watches the module's declared state — all through the runtime (novox/hq ADR 0193, 0198, 0201). Standard library only. ## Installing diff --git a/go/caller.go b/go/caller.go new file mode 100644 index 0000000..f9a108a --- /dev/null +++ b/go/caller.go @@ -0,0 +1,59 @@ +package stdio + +import ( + "reflect" + "regexp" + "sync" + "unsafe" +) + +// Unproven is the caller of a call the runtime named no caller for: one that arrived on the subject that names +// none, or from a runtime that names none (novox/hq issue 365). Said as a word, never left empty, so a record +// shows it was not proven. +const Unproven = "unproven" + +// MetaCaller is where in a `tools/call`'s `_meta` the runtime names the caller: the bus user that published the +// call, which the runtime takes from the subject the call arrived on — that subject's last token, which the +// bus lets each user publish with its own name alone (novox/hq issue 365, by ADR 0259 §3's precedent). The +// runtime writes `_meta`; a caller writes only `arguments`, so no caller can name another. +const MetaCaller = "mesh/caller" + +// busUser is the shape of a bus user's name: `.`, `person.`, `controller` — names whose +// parts are safe in a subject. Anything else the runtime named is not a caller and reads as Unproven. +var busUser = regexp.MustCompile(`^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$`) + +// calls holds the caller of every call while its tool runs, by the arguments map that call was handed. +var calls sync.Map // unsafe.Pointer → string + +func argsKey(args map[string]any) unsafe.Pointer { + return reflect.ValueOf(args).UnsafePointer() +} + +// CallerOf is who called the tool that was handed args, as the runtime proved it: the bus user that +// published the call, or Unproven. Read from the runtime, never from the arguments' contents — a `caller` +// argument names nobody. Valid while the tool runs; arguments no call was handed have no caller. +func CallerOf(args map[string]any) string { + if args == nil { + return Unproven + } + if c, ok := calls.Load(argsKey(args)); ok { + return c.(string) + } + return Unproven +} + +// callerIn is the caller a `tools/call`'s `_meta` names, or Unproven. +func callerIn(meta map[string]any) string { + if s, ok := meta[MetaCaller].(string); ok && busUser.MatchString(s) { + return s + } + return Unproven +} + +// runAs runs a tool with the caller held for its arguments for as long as it runs. +func runAs(t Tool, caller string, args map[string]any) (any, error) { + key := argsKey(args) + calls.Store(key, caller) + defer calls.Delete(key) + return t.Run(args) +} diff --git a/go/caller_test.go b/go/caller_test.go new file mode 100644 index 0000000..7fae75c --- /dev/null +++ b/go/caller_test.go @@ -0,0 +1,105 @@ +package stdio + +import ( + "bufio" + "encoding/json" + "io" + "testing" + "time" +) + +// callWith serves one tool that answers the caller CallerOf reads from its arguments, sends it one tools/call +// with the params given, and returns the caller it answered. +func callWith(t *testing.T, params map[string]any) string { + t.Helper() + inR, inW := io.Pipe() + outR, outW := io.Pipe() + go func() { + _ = ServeOn("tracker", []Tool{{Name: "who", Run: func(args map[string]any) (any, error) { + return map[string]any{"caller": CallerOf(args)}, nil + }}}, inR, outW) + }() + defer inW.Close() + b, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": params}) + _, _ = inW.Write(append(b, '\n')) + lines := bufio.NewScanner(outR) + done := make(chan string, 1) + go func() { + if lines.Scan() { + var m struct { + Result struct { + Content []struct { + Text string `json:"text"` + } `json:"content"` + } `json:"result"` + } + _ = json.Unmarshal(lines.Bytes(), &m) + var answer struct { + Caller string `json:"caller"` + } + if len(m.Result.Content) > 0 { + _ = json.Unmarshal([]byte(m.Result.Content[0].Text), &answer) + } + done <- answer.Caller + } + }() + select { + case got := <-done: + return got + case <-time.After(3 * time.Second): + t.Fatal("the bundle did not answer the call") + return "" + } +} + +// **A tool reads its caller from the runtime, never from the call's arguments** (novox/hq issue 365): the +// runtime names the caller it took from the subject the call arrived on in the call's `_meta`, and CallerOf +// hands it to the tool by the arguments the tool was given. +func TestAToolReadsTheCallerTheRuntimeNamed(t *testing.T) { + got := callWith(t, map[string]any{"name": "who", "arguments": map[string]any{"title": "x"}, + "_meta": map[string]any{MetaCaller: "shanks.node-tools"}}) + if got != "shanks.node-tools" { + t.Fatalf("the tool read caller %q, want the one the runtime named", got) + } +} + +// A caller named in the arguments is not the caller: an argument is the caller's to write. +func TestACallerInTheArgumentsIsNeverTheCaller(t *testing.T) { + got := callWith(t, map[string]any{"name": "who", "arguments": map[string]any{"caller": "novox.node-tools", + "_meta": map[string]any{MetaCaller: "novox.node-tools"}, MetaCaller: "novox.node-tools"}}) + if got != Unproven { + t.Fatalf("a caller the arguments claimed was read as %q, want %q", got, Unproven) + } + got = callWith(t, map[string]any{"name": "who", "arguments": map[string]any{"caller": "novox.node-tools"}, + "_meta": map[string]any{MetaCaller: "shanks.node-tools"}}) + if got != "shanks.node-tools" { + t.Fatalf("with a caller in the arguments the tool read %q, want the runtime's", got) + } +} + +// A runtime that names no caller — one from before issue 365, or a call on the subject that names none — is +// said as Unproven, explicitly; a caller that is not a bus user's name is Unproven too. +func TestACallTheRuntimeNamedNoCallerForIsUnproven(t *testing.T) { + for _, params := range []map[string]any{ + {"name": "who", "arguments": map[string]any{}}, + {"name": "who"}, + {"name": "who", "arguments": map[string]any{}, "_meta": map[string]any{}}, + {"name": "who", "arguments": map[string]any{}, "_meta": map[string]any{MetaCaller: ""}}, + {"name": "who", "arguments": map[string]any{}, "_meta": map[string]any{MetaCaller: "a b"}}, + {"name": "who", "arguments": map[string]any{}, "_meta": map[string]any{MetaCaller: 7}}, + } { + if got := callWith(t, params); got != Unproven { + t.Errorf("%v: the tool read caller %q, want %q", params, got, Unproven) + } + } +} + +// Arguments the runtime never handed a tool have no caller. +func TestArgumentsOfNoCallHaveNoCaller(t *testing.T) { + if got := CallerOf(map[string]any{"x": 1}); got != Unproven { + t.Fatalf("CallerOf of a map no call was given answered %q", got) + } + if got := CallerOf(nil); got != Unproven { + t.Fatalf("CallerOf(nil) answered %q", got) + } +} diff --git a/go/stdio.go b/go/stdio.go index 3582f14..4ce6003 100644 --- a/go/stdio.go +++ b/go/stdio.go @@ -39,7 +39,8 @@ type Tool struct { Description string // Input is the JSON schema of the arguments; nil is a tool that takes nothing. Input map[string]any - Run func(args map[string]any) (any, error) + // Run answers one call. Who called is CallerOf(args): the bus user the runtime proved, or Unproven. + Run func(args map[string]any) (any, error) } type message struct { @@ -223,6 +224,8 @@ func ServeOn(name string, tools []Tool, in io.Reader, out io.Writer) error { var p struct { Name string `json:"name"` Arguments map[string]any `json:"arguments"` + // Meta is the runtime's, never the caller's: who called, taken from the subject (CallerOf). + Meta map[string]any `json:"_meta"` } _ = json.Unmarshal(m.Params, &p) t, ok := byName[p.Name] @@ -232,18 +235,18 @@ func ServeOn(name string, tools []Tool, in io.Reader, out io.Writer) error { } // Beside the loop, never in it: a tool that emits waits for the runtime's answer, which // arrives on the line this loop reads next — and calls do not queue behind a slow one. - go func(id any, t Tool, args map[string]any) { + go func(id any, t Tool, args map[string]any, caller string) { if args == nil { args = map[string]any{} } - result, err := t.Run(args) + result, err := runAs(t, caller, args) if err != nil { c.say(map[string]any{"id": id, "result": map[string]any{"content": []map[string]any{{"type": "text", "text": err.Error()}}, "isError": true}}) return } text, _ := json.Marshal(result) c.say(map[string]any{"id": id, "result": map[string]any{"content": []map[string]any{{"type": "text", "text": string(text)}}}}) - }(id, t, p.Arguments) + }(id, t, p.Arguments, callerIn(p.Meta)) case "mesh/event": // One event the runtime read from this module's consumer (ADR 0198), answered once every // handler it is for has taken it; an error is answered as one, so the runtime does not -- 2.54.0