The gate digests a marked call's act (address, node, module and tool or seat and verb, build, each argument as itself or its canonical JSON), refuses at once and asks nothing for a secret's shape or more than 16 KiB of arguments, and otherwise asks through operator-channel's client under the marked module's name, its ask kept in the runner's state approvals under the machine's keys with the act and the whole arguments, the act's key naming it. The ask shows each argument whole only where it may leave the mesh, and the first call answers Not run, with the router's record or without it within 5 seconds. Builds: design 46 7-approval-mark §8, §9, §10, §11, §12, §13 (keys) Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
277 lines
10 KiB
Go
277 lines
10 KiB
Go
package approval
|
||
|
||
import (
|
||
"encoding/json"
|
||
"sort"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
|
||
seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
|
||
catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||
"git.novox.be/novox/mesh-sdk/go/seatdef"
|
||
)
|
||
|
||
// mem is the runner's state approvals as a fixture; fail makes every read and write fail, as a bus refusing them.
|
||
type mem struct {
|
||
mu sync.Mutex
|
||
vals map[string]json.RawMessage
|
||
revs map[string]uint64
|
||
n uint64
|
||
fail error
|
||
}
|
||
|
||
func newMem() *mem { return &mem{vals: map[string]json.RawMessage{}, revs: map[string]uint64{}} }
|
||
|
||
func (m *mem) Get(k string) (*stdio.StateEntry, error) {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
if m.fail != nil {
|
||
return nil, m.fail
|
||
}
|
||
v, ok := m.vals[k]
|
||
if !ok {
|
||
return nil, nil
|
||
}
|
||
return &stdio.StateEntry{Key: k, Value: v, Revision: m.revs[k]}, nil
|
||
}
|
||
func (m *mem) Create(k string, v any) (uint64, error) {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
if m.fail != nil {
|
||
return 0, m.fail
|
||
}
|
||
if _, ok := m.vals[k]; ok {
|
||
return 0, stdio.ErrChanged
|
||
}
|
||
return m.put(k, v)
|
||
}
|
||
func (m *mem) Update(k string, v any, rev uint64) (uint64, error) {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
if m.fail != nil {
|
||
return 0, m.fail
|
||
}
|
||
if rev == 0 || m.revs[k] != rev {
|
||
return 0, stdio.ErrChanged
|
||
}
|
||
return m.put(k, v)
|
||
}
|
||
func (m *mem) Put(k string, v any) (uint64, error) {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
if m.fail != nil {
|
||
return 0, m.fail
|
||
}
|
||
return m.put(k, v)
|
||
}
|
||
func (m *mem) Keys() ([]string, error) {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
if m.fail != nil {
|
||
return nil, m.fail
|
||
}
|
||
var out []string
|
||
for k := range m.vals {
|
||
out = append(out, k)
|
||
}
|
||
sort.Strings(out)
|
||
return out, nil
|
||
}
|
||
func (m *mem) put(k string, v any) (uint64, error) {
|
||
raw, ok := v.(json.RawMessage)
|
||
if !ok {
|
||
raw, _ = json.Marshal(v)
|
||
}
|
||
m.n++
|
||
m.vals[k], m.revs[k] = raw, m.n
|
||
return m.n, nil
|
||
}
|
||
|
||
// world is a gate on a fixture mesh: the catalog half, the router's records as the router writes them (mesh-catalog's
|
||
// messenger, asks.go), the asks published, the clock.
|
||
type world struct {
|
||
g *Gate
|
||
state *mem
|
||
cat *fakeCatalog
|
||
records map[string]seat.AsksByAskerIDValue // by `<module>.<id>`
|
||
takes bool // the router takes an ask as it is published
|
||
asked []seat.Ask
|
||
now time.Time
|
||
runs int
|
||
findings *findings
|
||
}
|
||
|
||
func newWorld() *world {
|
||
shell := entry("src2:1", []string{"node-login-shell.execute"}, map[string]any{"needs-approval": []any{"wipe"}})
|
||
w := &world{state: newMem(), records: map[string]seat.AsksByAskerIDValue{}, takes: true, findings: &findings{},
|
||
now: time.Date(2026, 11, 12, 10, 0, 0, 0, time.UTC),
|
||
cat: &fakeCatalog{revision: "7", byFp: map[string]catalogseat.CatalogEntry{"src2:1": shell},
|
||
newest: map[string]catalogseat.CatalogEntry{"shell": shell},
|
||
defs: map[string]seatdef.Definition{"node-login-shell": {Seat: "node-login-shell",
|
||
Verbs: []seatdef.Verb{{Name: "execute", NeedsApproval: true}}}}}}
|
||
w.g = New(Deps{Node: "n1", Runner: "node-tools", Catalog: w.cat, State: w.state, Findings: w.findings,
|
||
Now: func() time.Time { return w.now },
|
||
After: func(d time.Duration) <-chan time.Time {
|
||
w.now = w.now.Add(d)
|
||
c := make(chan time.Time, 1)
|
||
c <- w.now
|
||
return c
|
||
},
|
||
Publish: func(module, subject string, body any, id string) error {
|
||
if subject != seat.AcceptSubjectAsk(module) {
|
||
return nil
|
||
}
|
||
a := body.(seat.Ask)
|
||
w.asked = append(w.asked, a)
|
||
if w.takes {
|
||
w.records[module+"."+a.ID] = seat.AsksByAskerIDValue{Asker: module, Key: module + "." + a.ID, State: "open",
|
||
Headline: a.Headline, Expires: a.Expires, Opened: operatorchannelclient.FormatTime(w.now), OfferedOn: []string{"telegram"}}
|
||
}
|
||
return nil
|
||
},
|
||
Record: func(module, id string) (*stdio.StateEntry, error) {
|
||
rec, ok := w.records[module+"."+id]
|
||
if !ok {
|
||
return nil, nil
|
||
}
|
||
raw, _ := json.Marshal(rec)
|
||
return &stdio.StateEntry{Key: module + "." + id, Value: raw}, nil
|
||
}})
|
||
_ = w.g.Unserved("shell", "src2:1", []string{"wipe", "tidy"})
|
||
return w
|
||
}
|
||
|
||
// call is a fixture call of the marked tool wipe, or of another tool of the module.
|
||
func call(name, args string) Call {
|
||
return Call{Address: "shell." + name, Module: "shell", Name: name, Fingerprint: "src2:1", Caller: "agent", Args: json.RawMessage(args),
|
||
Description: "Wipes a scratch directory.\nMore words."}
|
||
}
|
||
|
||
// theActAndItsAsk is §8's and §11's rows: the act's fields, and how an ask shows a call's arguments.
|
||
func theActAndItsAsk(t *testing.T) {
|
||
base := call("wipe", `{"dir":"scratch","n":1}`)
|
||
digest := func(c Call) string {
|
||
act, err := ActOf(c, "n1")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
d, _ := operatorchannelclient.ActDigest(act)
|
||
return d
|
||
}
|
||
act, _ := ActOf(base, "n1")
|
||
want := operatorchannelclient.Act{"address": "shell.wipe", "node": "n1", "module": "shell", "tool": "wipe", "build": "src2:1",
|
||
"arg.dir": "scratch", "arg.n": "1"}
|
||
if len(act) != len(want) {
|
||
t.Fatalf("act %v", act)
|
||
}
|
||
for k, v := range want {
|
||
if act[k] != v {
|
||
t.Errorf("act[%s] = %q, want %q", k, act[k], v)
|
||
}
|
||
}
|
||
seen := map[string]string{digest(base): "the call"}
|
||
for name, c := range map[string]Call{
|
||
"an omitted argument": call("wipe", `{"dir":"scratch"}`),
|
||
"another number": call("wipe", `{"dir":"scratch","n":2}`),
|
||
"an object": call("wipe", `{"dir":{"name":"scratch"},"n":1}`),
|
||
"a changed build": func() Call { c := base; c.Fingerprint = "src2:2"; return c }(),
|
||
"another node's seat": {Address: "node-login-shell.execute", Module: "shell", Seat: "node-login-shell", Name: "execute", Fingerprint: "src2:1"},
|
||
} {
|
||
d := digest(c)
|
||
if other, dup := seen[d]; dup {
|
||
t.Errorf("%s gives the digest of %s", name, other)
|
||
}
|
||
seen[d] = name
|
||
}
|
||
if got, _ := Canonical(json.RawMessage(`{"b":[1.0,2.5e-7,1e21],"a":"é<"}`)); got != `{"a":"é<","b":[1,2.5e-7,1e+21]}` {
|
||
t.Errorf("canonical form %s", got)
|
||
}
|
||
// §11: a path, an address and a long argument are shown in their shape and cut.
|
||
text, whole := shown(json.RawMessage(`{"dir":"/home/someone/scratch","host":"10.0.0.7","ok":"yes"}`))
|
||
if whole || text != "dir: ‹path›\nhost: ‹address›\nok: yes" {
|
||
t.Errorf("shown %q whole %v", text, whole)
|
||
}
|
||
long, _ := json.Marshal(map[string]string{"text": strings.Repeat("word ", 500)})
|
||
if text, whole := shown(long); whole || !strings.HasSuffix(text, "NOT SHOWN IN FULL") || len(text) > PartBytes+len("\nNOT SHOWN IN FULL") {
|
||
t.Errorf("a long argument shown whole %v, %d bytes", whole, len(text))
|
||
}
|
||
// §10: the headline, cut with … to 60 characters, ending Part shown.
|
||
for _, tc := range []struct{ name, node, want string }{
|
||
{"wipe", "n1", "Run wipe on n1?"},
|
||
{strings.Repeat("x", 50), "n1", "Run " + strings.Repeat("x", 50) + "?"},
|
||
{strings.Repeat("y", 80), "n1", "Run " + strings.Repeat("y", 54) + "…?"},
|
||
} {
|
||
if got := headlineOf(tc.name, tc.node, true); got != tc.want || len([]rune(got)) > 60 {
|
||
t.Errorf("headline %q, want %q", got, tc.want)
|
||
}
|
||
}
|
||
if got := headlineOf("wipe", "n1", false); got != "Run wipe on n1? Part shown" {
|
||
t.Errorf("headline %q", got)
|
||
}
|
||
}
|
||
|
||
// Design 46 7-approval-mark §22: the refusals and answers of a marked call, on the fixture mesh, the router's record and
|
||
// the warrant written as the router writes them.
|
||
func TestTheRefusalsAndAnswers(t *testing.T) {
|
||
t.Run("§8, §10, §11: the act's fields give each its digest, and the ask shows the arguments", theActAndItsAsk)
|
||
for _, tc := range []struct {
|
||
name string
|
||
run func(t *testing.T, w *world)
|
||
}{
|
||
{"§9: a secret in the arguments is refused asking nothing", func(t *testing.T, w *world) {
|
||
_, err := w.g.firstCall(call("wipe", `{"token":"ghp_0123456789abcdefghijABCDEFGHIJ012345"}`))
|
||
if err == nil || !strings.Contains(err.Error(), "which no ask may carry") || len(w.asked) != 0 {
|
||
t.Fatalf("%v, asked %d", err, len(w.asked))
|
||
}
|
||
}},
|
||
{"§9: more than 16 KiB of arguments is refused asking nothing", func(t *testing.T, w *world) {
|
||
big, _ := json.Marshal(map[string]string{"text": strings.Repeat("a b ", 5000)})
|
||
_, err := w.g.firstCall(call("wipe", string(big)))
|
||
if err == nil || !strings.Contains(err.Error(), "more than the 16384") || len(w.asked) != 0 {
|
||
t.Fatalf("%v, asked %d", err, len(w.asked))
|
||
}
|
||
}},
|
||
{"§10, §12: the published ask's fields, and Not run with the router's record", func(t *testing.T, w *world) {
|
||
_, err := w.g.firstCall(call("wipe", `{"dir":"scratch"}`))
|
||
if err == nil || !strings.HasPrefix(err.Error(), "Not run: this needs the operator's approval. Asked on telegram at ") ||
|
||
!strings.Contains(err.Error(), "make the same call again within 30 minutes of the approval; the ask expires at ") {
|
||
t.Fatalf("answer %v", err)
|
||
}
|
||
if len(w.asked) != 1 {
|
||
t.Fatalf("asked %d", len(w.asked))
|
||
}
|
||
a := w.asked[0]
|
||
act, _ := ActOf(call("wipe", `{"dir":"scratch"}`), "n1")
|
||
d, _ := operatorchannelclient.ActDigest(act)
|
||
if a.About != d || a.Who != "operator" || a.OnExpiry != OnExpiry || a.Headline != "Run wipe on n1?" ||
|
||
a.Expires != operatorchannelclient.FormatTime(time.Date(2026, 11, 12, 11, 0, 0, 0, time.UTC)) || len(a.Options) != 2 ||
|
||
a.Options[0].ID != "approve" || a.Options[1].ID != "decline" || a.Options[0].Binds != d || a.Options[1].Binds != d ||
|
||
a.Options[1].Level != "approve" {
|
||
t.Errorf("ask %+v", a)
|
||
}
|
||
if !strings.HasPrefix(a.Explanation, "Wipes a scratch directory.\nOn n1, called through the mesh's tools, which cannot tell who called.\ndir: scratch\nFingerprint ") {
|
||
t.Errorf("explanation %q", a.Explanation)
|
||
}
|
||
var k operatorchannelclient.Kept
|
||
_ = json.Unmarshal(w.state.vals["n1.ask."+a.ID], &k)
|
||
if k.Act["arg.dir"] != "scratch" || string(k.Arguments) != `{"dir":"scratch"}` || string(w.state.vals["n1.act."+d]) != `"`+a.ID+`"` {
|
||
t.Errorf("kept %+v, act key %s", k, w.state.vals["n1.act."+d])
|
||
}
|
||
}},
|
||
{"§12: with no record within 5 seconds, Not run, asked; not yet taken by the router", func(t *testing.T, w *world) {
|
||
w.takes = false
|
||
start := w.now
|
||
_, err := w.g.firstCall(call("wipe", `{}`))
|
||
if err == nil || !strings.HasPrefix(err.Error(), "Not run, asked; not yet taken by the router (ask ") || w.now.Sub(start) > RecordWithin {
|
||
t.Fatalf("answer %v after %s", err, w.now.Sub(start))
|
||
}
|
||
}},
|
||
} {
|
||
t.Run(tc.name, func(t *testing.T) { tc.run(t, newWorld()) })
|
||
}
|
||
}
|