From 4d4112a008eadd71bff7e87dcb2e7dadbf3de869 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Tue, 20 Oct 2026 20:04:47 +0000 Subject: [PATCH] Call a module's tool or a seat verb on a named machine (issue 1470), part 3 of 6 --- go/process/call.go | 42 +++++++++++++++++++++++++++-- go/process/call_integration_test.go | 42 +++++++++++++++++++++++++++++ go/process/call_test.go | 42 ++++++++++++++++++++++++++++- 3 files changed, 123 insertions(+), 3 deletions(-) diff --git a/go/process/call.go b/go/process/call.go index 8b79160..a9f609c 100644 --- a/go/process/call.go +++ b/go/process/call.go @@ -28,6 +28,26 @@ func (c *Conn) Call(key string, body any) (json.RawMessage, error) { if err != nil { return nil, err } + return c.callOn(called, fallback, body) +} + +// CallOn is Call to the tool or seat verb of one named machine (novox/hq ADR 0469 ยง3; issue 1470): the tool +// runner of that node alone answers, on mesh.mod..call... (a seat verb: +// mesh.seat..call...), falling back to the subject that names no caller +// (mesh-tools node-tools/internal/bus/caller.go:15-19). node is one subject token: an empty one, or one +// holding '.', '*', '>' or white space, is refused before anything is published. +func (c *Conn) CallOn(node, key string, body any) (json.RawMessage, error) { + if err := checkNode(node); err != nil { + return nil, err + } + called, fallback, err := callSubjectsOn(key, node, c.callerToken()) + if err != nil { + return nil, err + } + return c.callOn(called, fallback, body) +} + +func (c *Conn) callOn(called, fallback string, body any) (json.RawMessage, error) { data, err := json.Marshal(body) if err != nil { return nil, err @@ -46,18 +66,36 @@ func (c *Conn) callerToken() string { return strings.ReplaceAll(c.account.User, // callSubjects is the caller-named subject key is called on, and its plain fallback (mesh-tools // node-tools/internal/bus/caller.go CalledSubject, toolShape, calledPattern). func callSubjects(key, token string) (called, fallback string, err error) { + return callSubjectsOn(key, "", token) +} + +// checkNode refuses a node name that is not one subject token. +func checkNode(node string) error { + if node == "" || strings.ContainsAny(node, ".*> \t\r\n") { + return fmt.Errorf("%q is not a node name (one subject token: no '.', '*', '>' or space)", node) + } + return nil +} + +// callSubjectsOn is callSubjects for one named node ("" names none): the node is a token between the tool and +// the caller on the called subject, and the last one of the fallback. +func callSubjectsOn(key, node, token string) (called, fallback string, err error) { + at := "" + if node != "" { + at = "." + node + } if rest, ok := strings.CutPrefix(key, "seat:"); ok { seat, verb, ok := strings.Cut(rest, ".") if !ok || seat == "" || verb == "" { return "", "", fmt.Errorf("%q names no seat and verb (seat:.)", key) } - return "mesh.seat." + seat + ".call." + verb + "." + token, "mesh.seat." + seat + ".tool." + verb, nil + return "mesh.seat." + seat + ".call." + verb + at + "." + token, "mesh.seat." + seat + ".tool." + verb + at, nil } module, tool, ok := strings.Cut(key, ".") if !ok || module == "" || tool == "" { return "", "", fmt.Errorf("%q is not ., and does not start seat:.", key) } - return "mesh.mod." + module + ".call." + tool + "." + token, "mesh.mod." + module + ".tool." + tool, nil + return "mesh.mod." + module + ".call." + tool + at + "." + token, "mesh.mod." + module + ".tool." + tool + at, nil } // request asks one subject once and reads its reply ({result, error, node}); a reply naming an error is diff --git a/go/process/call_integration_test.go b/go/process/call_integration_test.go index d08be4d..708e38f 100644 --- a/go/process/call_integration_test.go +++ b/go/process/call_integration_test.go @@ -235,3 +235,45 @@ func TestReleaseAcceptanceAProcessDoesExactlyItsBusDeclaration(t *testing.T) { t.Error("subscribing a tool-call subject the process does not serve was not refused") } } + +// 1470/AC4: two answerers of the same verb on the subjects of nodes ace and g14; CallOn naming g14 gets g14's +// answer, and ace's answerer hears nothing. +func TestCallOnReachesTheNamedNodeAlone(t *testing.T) { + url, account := grantedSendProcess(t) + adminNC, _ := adminJS(t, url) + aceHeard := make(chan struct{}, 1) + for _, node := range []string{"ace", "g14"} { + node := node + sub, err := adminNC.Subscribe("mesh.seat.operator-channel.call.tell."+node+".novox~mesh-mail~send", func(m *nats.Msg) { + if node == "ace" { + aceHeard <- struct{}{} + } + _ = m.Respond([]byte(`{"result":{"node":"` + node + `"}}`)) + }) + if err != nil { + t.Fatal(err) + } + defer func() { _ = sub.Unsubscribe() }() + } + if err := adminNC.Flush(); err != nil { + t.Fatal(err) + } + conn, err := Connect(account) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + result, err := conn.CallOn("g14", "seat:operator-channel.tell", map[string]any{}) + if err != nil { + t.Fatalf("CallOn: %v", err) + } + if string(result) != `{"node":"g14"}` { + t.Fatalf("result: %s, want g14's answer", result) + } + select { + case <-aceHeard: + t.Error("ace's answerer heard a call named for g14") + default: + } +} diff --git a/go/process/call_test.go b/go/process/call_test.go index eb50aa9..6f33eb2 100644 --- a/go/process/call_test.go +++ b/go/process/call_test.go @@ -1,6 +1,9 @@ package process -import "testing" +import ( + "strings" + "testing" +) // 1191/AC3: given a module tool key ., when called, then the subject is // mesh.mod..call.. with the same fallback (mesh.mod..tool.). @@ -39,3 +42,40 @@ func TestCallSubjectsRefusesAKeyNamingNeither(t *testing.T) { } } } + +// 1470/AC1: key mesh-build-go.fetch, node ace, caller ace.mesh-delivery-builder. +func TestCallSubjectsOnANodeForAModuleTool(t *testing.T) { + c := &Conn{account: Account{User: "ace.mesh-delivery-builder"}} + called, fallback, err := callSubjectsOn("mesh-build-go.fetch", "ace", c.callerToken()) + if err != nil { + t.Fatal(err) + } + if called != "mesh.mod.mesh-build-go.call.fetch.ace.ace~mesh-delivery-builder" { + t.Errorf("called: %q", called) + } + if fallback != "mesh.mod.mesh-build-go.tool.fetch.ace" { + t.Errorf("fallback: %q", fallback) + } +} + +// 1470/AC2: seat:node-files.read on g14. +func TestCallSubjectsOnANodeForASeatVerb(t *testing.T) { + called, fallback, err := callSubjectsOn("seat:node-files.read", "g14", "tok") + if err != nil { + t.Fatal(err) + } + if called != "mesh.seat.node-files.call.read.g14.tok" || fallback != "mesh.seat.node-files.tool.read.g14" { + t.Errorf("called %q, fallback %q", called, fallback) + } +} + +// 1470/AC3: a node that is not one token is refused before anything is published (the Conn has no +// connection, so only the name check can answer with this error). +func TestCallOnRefusesANodeThatIsNotOneToken(t *testing.T) { + c := &Conn{} + for _, node := range []string{"", "a.b", "*", ">", "a b", "a\tb"} { + if _, err := c.CallOn(node, "mesh-build-go.fetch", nil); err == nil || !strings.Contains(err.Error(), "not a node name") { + t.Errorf("CallOn(%q) = %v, want the refusal of the name", node, err) + } + } +}