diff --git a/node-tools/internal/console/address.go b/node-tools/internal/console/address.go index 84de6fd..8739f24 100644 --- a/node-tools/internal/console/address.go +++ b/node-tools/internal/console/address.go @@ -927,6 +927,21 @@ func schemaWithoutNode(raw json.RawMessage) map[string]any { } // answerText is a discovery verb's answer as MCP content: JSON, indented. +// callFailed is why a call failed, as mesh_call says it: a marked tool or verb that reaches no responder names `withheld` +// and the grants part's read (design 46 7-approval-mark §6), and a target known only from an earlier round says so. +func (s *Surface) callFailed(t target, err error) string { + why := whyItFailed(t.Address, err) + if ok, _ := s.markedTool(t); ok && errors.Is(err, bus.ErrNoResponders) { + // A marked subject withheld from a machine whose tool runner does not honour the mark (§6). + why += "; it needs the operator's approval, so it may be withheld where the machine's tool runner does not state " + + "approval-mark/1: mesh-grants.grants_user_list names it under withheld" + } + if t.Stale != nil { + why += " (" + t.Stale.say() + ")" + } + return why +} + func answerText(v any) map[string]any { var b strings.Builder enc := json.NewEncoder(&b) @@ -1137,16 +1152,7 @@ func (s *Surface) discover(ctx context.Context, name string, args map[string]any got, err = callTool(s.conn, t.Key, callArgs, nil, x.Listing) } if err != nil { - why := whyItFailed(t.Address, err) - if ok, _ := s.markedTool(t); ok && errors.Is(err, bus.ErrNoResponders) { - // A marked subject withheld from a machine whose tool runner does not honour the mark (§6). - why += "; it needs the operator's approval, so it may be withheld where the machine's tool runner does not state " + - "approval-mark/1: mesh-grants.grants_user_list names it under withheld" - } - if t.Stale != nil { - why += " (" + t.Stale.say() + ")" - } - return failure(why) + return failure(s.callFailed(t, err)) } result, unread := resolveSent(s.conn, got.Result) content := []map[string]any{{"type": "text", "text": pretty(result)}} diff --git a/node-tools/internal/console/noresponders_test.go b/node-tools/internal/console/noresponders_test.go index 51f0c0a..1cbe5c5 100644 --- a/node-tools/internal/console/noresponders_test.go +++ b/node-tools/internal/console/noresponders_test.go @@ -11,7 +11,8 @@ import ( "github.com/novox/mesh-tools/node-tools/internal/bus" ) -// A tool's own error that mentions 503 is the tool's answer, not a routing fault (issue 1529); a real no-responders still says +// A marked tool or verb that reaches no responder names withheld and the grants part's read (design 46 7-approval-mark §6); +// an unmarked one, or a marked one failing otherwise, does not. A tool's own error that mentions 503 is the tool's answer, not a routing fault (issue 1529); a real no-responders still says // nothing serves. func TestARealNoRespondersStillSaysNothingServes(t *testing.T) { got := whyItFailed("scanner.scan", errors.New("the scanner refused the scan job: 503 Service Unavailable")) @@ -23,4 +24,20 @@ func TestARealNoRespondersStillSaysNothingServes(t *testing.T) { t.Fatalf("%v: got %q", err, got) } } + s := &Surface{Approvals: &fakeApprovals{marked: "shell.wipe"}} + for _, c := range []struct { + target target + err error + hinted bool + }{ + {target{Address: "shell.wipe", Tool: Tool{Module: "shell", Name: "wipe"}}, bus.ErrNoResponders, true}, + {target{Address: "node-login-shell.execute", Seat: true, Tool: Tool{Module: "node-login-shell", Name: "execute"}}, bus.ErrNoResponders, false}, + {target{Address: "shell.tidy", Tool: Tool{Module: "shell", Name: "tidy"}}, bus.ErrNoResponders, false}, + {target{Address: "shell.wipe", Tool: Tool{Module: "shell", Name: "wipe"}}, errors.New("its own failure"), false}, + } { + got := s.callFailed(c.target, c.err) + if hinted := strings.Contains(got, "approval-mark/1: mesh-grants.grants_user_list names it under withheld"); hinted != c.hinted { + t.Errorf("%s (%v): %q", c.target.Address, c.err, got) + } + } } diff --git a/node-tools/internal/console/wait_test.go b/node-tools/internal/console/wait_test.go index 1eb7263..e66fe91 100644 --- a/node-tools/internal/console/wait_test.go +++ b/node-tools/internal/console/wait_test.go @@ -554,17 +554,23 @@ func TestMeshWaitIsAnnouncedAmongSeven(t *testing.T) { func fmtAnswer(out map[string]any) string { raw, _ := json.Marshal(out); return string(raw) } // fakeApprovals is a gate as mesh_approvals reads it. -type fakeApprovals struct{ cancelled string } +type fakeApprovals struct { + cancelled string + // marked is the one `.` its Describe says is marked. + marked string +} func (f *fakeApprovals) List() ([]approval.Listed, error) { return []approval.Listed{{Ask: "a1", State: "asked"}}, nil } func (f *fakeApprovals) Read(id string) (approval.Whole, error) { return approval.Whole{}, nil } func (f *fakeApprovals) Applied() []approval.Marks { return nil } -func (f *fakeApprovals) Describe(_, _, _ string) (bool, string) { return false, "" } -func (f *fakeApprovals) Again(string) error { return nil } -func (f *fakeApprovals) Cancel(id string) error { f.cancelled = id; return nil } -func (f *fakeApprovals) Fill() (uint64, int64, error) { return 10, 64 << 20, nil } +func (f *fakeApprovals) Describe(module, _, name string) (bool, string) { + return f.marked != "" && f.marked == module+"."+name, "entry " + module +} +func (f *fakeApprovals) Again(string) error { return nil } +func (f *fakeApprovals) Cancel(id string) error { f.cancelled = id; return nil } +func (f *fakeApprovals) Fill() (uint64, int64, error) { return 10, 64 << 20, nil } // A client that hangs up or cancels ends its wait at once: the subscription is closed and nothing is left // waiting for the timeout (the review of issue 745). Over the console's own HTTP, as an agent host calls it. diff --git a/node-tools/internal/runtime/runtime_test.go b/node-tools/internal/runtime/runtime_test.go index eb56bfa..775b920 100644 --- a/node-tools/internal/runtime/runtime_test.go +++ b/node-tools/internal/runtime/runtime_test.go @@ -4,10 +4,12 @@ import ( "encoding/json" "errors" "os" + "slices" "strings" "testing" "time" + "github.com/novox/mesh-tools/node-tools/internal/approval" "github.com/novox/mesh-tools/node-tools/internal/bus" "github.com/novox/mesh-tools/node-tools/internal/launch" mt "github.com/novox/mesh-tools/node-tools/internal/meshtest" @@ -192,10 +194,49 @@ func TestEachBundleIsGivenItsOwnEnvironment(t *testing.T) { } } -// An entrypoint that is not executable is refused by name, and the others serve (ADR 0193). +// unservingGate is an approval gate whose marks of each module name the tools given, so a bundle not registering them is +// refused at the load (design 46 7-approval-mark §4). +type unservingGate map[string][]string + +func (g unservingGate) Unserved(module, _ string, registered []string) []string { + var missing []string + for _, tool := range g[module] { + if !slices.Contains(registered, tool) { + missing = append(missing, tool) + } + } + return missing +} +func (unservingGate) Run(_ approval.Call, run func() (any, error)) (any, error) { return run() } + +// An entrypoint that is not executable is refused by name, and the others serve (ADR 0193). A bundle whose marks name a +// tool it does not register is refused at the load, serving no tool of its module, and one that registers it serves +// (design 46 7-approval-mark §4). func TestAnEntrypointThatIsNotExecutableIsRefused(t *testing.T) { - mt.NeedBundles(t) mesh := mt.New(t) + { + mesh.Issue(t, mt.MembershipOf("marked", "anchor", true, nil)) + mesh.Issue(t, mt.MembershipOf("served", "anchor", true, nil)) + nodeTools := connect(t, "node-tools", "anchor") + asker := connect(t, "console", "workstation") + logs := &mt.Logs{} + stop, err := Run(nodeTools, []Served{{"marked", []string{mt.Fixture("caller-echo.py")}}, {"served", []string{mt.Fixture("caller-echo.py")}}}, + nil, logs.Logf, WithGate(unservingGate{"marked": {"wipe"}, "served": {"who"}})) + if err != nil { + t.Fatal(err) + } + if !logs.Has("marked's bundle", "failed to load: its marks name wipe, which it does not register (design 46 7-approval-mark §4)") { + t.Errorf("a bundle not serving its marked tool was not refused at the load:\n%s", logs.All()) + } + if _, err := call(t, asker, "served.who@anchor", map[string]any{}); err != nil { + t.Errorf("a bundle serving its marked tool: %v", err) + } + if _, err := asker.Ask("marked.who@anchor", map[string]any{}, ""); !errors.Is(err, bus.ErrNoResponders) { + t.Errorf("a refused bundle's tool answered: %v", err) + } + stop() + } + mt.NeedBundles(t) mesh.Issue(t, mt.MembershipOf("alpha", "anchor", false, nil)) mesh.Issue(t, mt.MembershipOf("plain", "anchor", false, nil)) nodeTools := connect(t, "node-tools", "anchor")