go: a tool reads its caller from the runtime, so a record can say who acted (hq issue 365)
mesh/merge-gate pass: builds mesh-sdk, sent nowhere; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript), go/merge-check.sh (go)
mesh/delivery delivered
mesh/delivery-group group fix/365-a-tool-call-names-its-caller delivered: every member is delivered
mesh/merge-gate pass: builds mesh-sdk, sent nowhere; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript), go/merge-check.sh (go)
mesh/delivery delivered
mesh/delivery-group group fix/365-a-tool-call-names-its-caller delivered: every member is delivered
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.
This commit is contained in:
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
`stdio.Serve("", tools)` speaks MCP over stdio to the node's tool runtime, as the module the runtime
|
`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
|
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 `<seat>.<verb>` 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 `<seat>.<verb>` 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.
|
`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
|
## Installing
|
||||||
|
|||||||
@@ -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: `<node>.<module>`, `person.<name>`, `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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-3
@@ -39,6 +39,7 @@ type Tool struct {
|
|||||||
Description string
|
Description string
|
||||||
// Input is the JSON schema of the arguments; nil is a tool that takes nothing.
|
// Input is the JSON schema of the arguments; nil is a tool that takes nothing.
|
||||||
Input map[string]any
|
Input map[string]any
|
||||||
|
// 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)
|
Run func(args map[string]any) (any, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +224,8 @@ func ServeOn(name string, tools []Tool, in io.Reader, out io.Writer) error {
|
|||||||
var p struct {
|
var p struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Arguments map[string]any `json:"arguments"`
|
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)
|
_ = json.Unmarshal(m.Params, &p)
|
||||||
t, ok := byName[p.Name]
|
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
|
// 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.
|
// 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 {
|
if args == nil {
|
||||||
args = map[string]any{}
|
args = map[string]any{}
|
||||||
}
|
}
|
||||||
result, err := t.Run(args)
|
result, err := runAs(t, caller, args)
|
||||||
if err != nil {
|
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}})
|
c.say(map[string]any{"id": id, "result": map[string]any{"content": []map[string]any{{"type": "text", "text": err.Error()}}, "isError": true}})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
text, _ := json.Marshal(result)
|
text, _ := json.Marshal(result)
|
||||||
c.say(map[string]any{"id": id, "result": map[string]any{"content": []map[string]any{{"type": "text", "text": string(text)}}}})
|
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":
|
case "mesh/event":
|
||||||
// One event the runtime read from this module's consumer (ADR 0198), answered once every
|
// 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
|
// handler it is for has taken it; an error is answered as one, so the runtime does not
|
||||||
|
|||||||
Reference in New Issue
Block a user