Keep a mesh seat's node argument, and refuse what a call would drop

The console took node out of every schema and every call, so the
controller's push, plan, assign, pin and settings were described without
the machine and called without it: a push naming one machine ran as a
push of every machine behind (hq issue 244). node is now taken out only
where the address names the machine, a different machine there is
refused, and an argument a seat's verb does not declare is refused.
This commit is contained in:
jochen
2026-10-06 00:37:15 +02:00
parent 93c1ad8c4a
commit 3a7c9645e0
2 changed files with 122 additions and 9 deletions
+70 -9
View File
@@ -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 {
@@ -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)
}
}