diff --git a/node-tools/internal/console/address.go b/node-tools/internal/console/address.go index 2d53464..3754936 100644 --- a/node-tools/internal/console/address.go +++ b/node-tools/internal/console/address.go @@ -552,7 +552,71 @@ func firstLine(s string) string { return s } -// schemaWithoutNode is a tool's schema as an agent passes it: the machine is in the address. +// schemaAsPassed is a tool's schema as an agent passes it at this address. Where the address names +// the machine, `node` is taken out: the machine is in the address. Where it does not — a seat held +// once for the mesh, or an interchangeable module — `node` is the tool's own argument, and is kept. +// +// **Taking it out everywhere made the mesh's own verbs lie** (novox/hq issue 244): the controller's +// `push`, `plan`, `assign`, `pin` and `settings` take the machine they act on as `node`, and were +// described without it — `push` as taking nothing at all, so a push naming one machine arrived as a +// push of every machine behind. +func schemaAsPassed(t target) map[string]any { + if t.Node == "" { + return asSchema(t.Tool.Input) + } + return schemaWithoutNode(t.Tool.Input) +} + +// argumentsAsSent are a call's arguments as the tool at this address receives them, or why the call +// is refused. **Nothing given is dropped without a word** (novox/hq issue 244): +// - where the address names the machine, a `node` naming the same machine is redundant and taken +// out; one naming another machine is refused — which of the two was meant is not guessed; +// - where it does not, `node` is the tool's own argument and goes to it like any other; +// - a seat's schema is the mesh's record of the verb, so an argument a seat's verb does not +// declare is refused here, naming it, rather than sent to be passed over. +func argumentsAsSent(t target, given map[string]any) (map[string]any, error) { + args := map[string]any{} + for k, v := range given { + args[k] = v + } + if n, has := args["node"]; has && t.Node != "" { + if named, _ := n.(string); strings.TrimSpace(named) != t.Node { + return nil, fmt.Errorf("%s names the machine %s in its address and %v in its arguments: say it once, in the address", + t.Address, t.Node, n) + } + delete(args, "node") + } + if !t.Seat { + return args, nil + } + declared := map[string]bool{} + var names []string + if p, ok := schemaAsPassed(t)["properties"].(map[string]any); ok { + for k := range p { + declared[k] = true + names = append(names, k) + } + } + sort.Strings(names) + var strangers []string + for k := range args { + if !declared[k] { + strangers = append(strangers, k) + } + } + if len(strangers) > 0 { + sort.Strings(strangers) + takes := "nothing" + if len(names) > 0 { + takes = strings.Join(names, ", ") + } + return nil, fmt.Errorf("%s takes no argument %s — it takes %s (mesh_describe %s); nothing was sent", + t.Address, strings.Join(strangers, ", "), takes, t.Address) + } + return args, nil +} + +// schemaWithoutNode is a tool's schema with `node` taken out, for an address that names the machine. func schemaWithoutNode(raw json.RawMessage) map[string]any { schema := asSchema(raw) out := map[string]any{} @@ -805,20 +869,17 @@ func (s *Surface) discover(name string, args map[string]any) map[string]any { description = t.Name } return answerText(map[string]any{"address": t.Address, "description": description, - "arguments": schemaWithoutNode(t.Tool.Input)}) + "arguments": schemaAsPassed(t)}) case verbCall: t, err := resolve(x, str("address")) if err != nil { return failure(err.Error()) } - callArgs := map[string]any{} - if a, ok := args["arguments"].(map[string]any); ok { - for k, v := range a { - if k != "node" { - callArgs[k] = v - } - } + given, _ := args["arguments"].(map[string]any) + callArgs, err := argumentsAsSent(t, given) + if err != nil { + return failure(err.Error()) } var got bus.Answered if t.Seat { diff --git a/node-tools/internal/console/node_argument_test.go b/node-tools/internal/console/node_argument_test.go new file mode 100644 index 0000000..23f0030 --- /dev/null +++ b/node-tools/internal/console/node_argument_test.go @@ -0,0 +1,52 @@ +package console + +import ( + "encoding/json" + "strings" + "testing" +) + +// novox/hq issue 244: `node` is taken out only where the address names the machine. A seat held once +// for the mesh takes the machine it acts on as `node`, and is described and called with it — on +// 2026-10-05 the controller's push was described as taking nothing, and a push naming one machine +// ran as a push of every machine behind. +func TestAMeshSeatsNodeIsItsOwnArgument(t *testing.T) { + push := Tool{Module: "mesh-controller", Name: "push", Seat: true, Scope: "mesh", + Input: json.RawMessage(`{"type":"object","properties":{"node":{"type":"string"},"behind":{"type":"string"}}}`)} + mesh := target{Address: "mesh-controller.push", Tool: push, Seat: true} + + props := schemaAsPassed(mesh)["properties"].(map[string]any) + if _, has := props["node"]; !has { + t.Fatalf("a mesh seat's verb was described without its node: %v", props) + } + sent, err := argumentsAsSent(mesh, map[string]any{"node": "g1"}) + if err != nil || sent["node"] != "g1" { + t.Fatalf("a mesh seat's verb was called without the machine it was given: %v %v", sent, err) + } + + // An argument the seat's verb does not declare is refused, naming it, never sent to be dropped. + if _, err := argumentsAsSent(mesh, map[string]any{"machine": "g1"}); err == nil || !strings.Contains(err.Error(), "machine") { + t.Fatalf("an undeclared argument was sent: %v", err) + } + + // A machine's seat: the address names the machine, so `node` is out of the schema; given the same + // machine again it is taken out, given another it is refused. + shelf := Tool{Module: "node-shelf", Name: "list", Seat: true, Scope: "node", + Input: json.RawMessage(`{"type":"object","properties":{"limit":{"type":"string"}}}`)} + onDesk := target{Address: "desk/node-shelf.list", Node: "desk", Tool: shelf, Seat: true} + if _, has := schemaAsPassed(onDesk)["properties"].(map[string]any)["node"]; has { + t.Fatal("a machine's seat was described with a node the address already names") + } + if sent, err := argumentsAsSent(onDesk, map[string]any{"node": "desk", "limit": "3"}); err != nil || sent["node"] != nil || sent["limit"] != "3" { + t.Fatalf("the same machine named twice: %v %v", sent, err) + } + if _, err := argumentsAsSent(onDesk, map[string]any{"node": "bench"}); err == nil || !strings.Contains(err.Error(), "bench") { + t.Fatalf("another machine in the arguments than in the address was dropped: %v", err) + } + + // A module's tool is its own: what it takes beyond node is the module's to judge. + beta := target{Address: "desk/beta.three", Node: "desk", Tool: Tool{Module: "beta", Name: "three"}} + if sent, err := argumentsAsSent(beta, map[string]any{"node": "desk", "verbose": true}); err != nil || sent["verbose"] != true || sent["node"] != nil { + t.Fatalf("a module's tool: %v %v", sent, err) + } +}