mesh/merge-gate head e3a714d5f0f0 merged into trunk c3564c1accf3 — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head e3a714d5f0f0 merged into trunk c3564c1accf3 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
writeRefusalCounters wrote "{}" and, since it now routes through fsys
(this branch's earlier commit), created the waits folder on every single
stop, even for a session that never had a wait: one counter file per
session id, piling up on a node that uses the wait list at all. It now
removes the file instead of writing empty counters, a no-op when there
was never one to begin with, so a stop with nothing to record leaves
neither the file nor the folder; a refusal still writes it, and the
stop right after the wait is done (or let through) removes it again.
363 lines
16 KiB
Go
363 lines
16 KiB
Go
package main
|
|
|
|
// The plugin's Stop hook (novox/hq ADR 0489 §3-§6, issue 1743).
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
var stopT0 = time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC)
|
|
|
|
func openWait(id string, bound int) Wait {
|
|
return Wait{ID: id, What: "the operator's Approve", Bound: bound, At: stopT0}
|
|
}
|
|
|
|
// 1743/AC1: a wait within its bound and unwatched refuses the stop, naming the wait, its read address and
|
|
// what the session does about it (mesh_wait on its family, conditionally — review round 1 item 3 — or a
|
|
// watcher that rereads the address).
|
|
func TestStopRefusesAnUnwatchedWaitWithinItsBound(t *testing.T) {
|
|
w := openWait("w-1", 10)
|
|
w.Read = WaitRead{Address: "mesh-delivery.recheck"}
|
|
w.Family = "mesh-delivery.decided"
|
|
d := DecideStop("sess-1", WaitsFile{Waits: []Wait{w}}, nil, map[string]int{}, stopT0.Add(time.Minute))
|
|
if !d.Refuse || !strings.Contains(d.Reason, "w-1") || !strings.Contains(d.Reason, "mesh-delivery.recheck") ||
|
|
!strings.Contains(d.Reason, "mesh_wait") || !strings.Contains(d.Reason, "watcher") {
|
|
t.Fatalf("%+v", d)
|
|
}
|
|
}
|
|
|
|
// 1743/AC2: a watched wait within its bound, a done wait (absent from the file), or no file entry: allowed.
|
|
func TestStopAllowsAWatchedOrDoneOrAbsentWait(t *testing.T) {
|
|
watched := openWait("w-2", 10)
|
|
watched.Watcher = "mesh_wait on mesh-delivery.decided"
|
|
if d := DecideStop("sess-2", WaitsFile{Waits: []Wait{watched}}, nil, map[string]int{}, stopT0.Add(time.Minute)); d.Refuse {
|
|
t.Fatalf("a watched wait within its bound was refused: %+v", d)
|
|
}
|
|
if d := DecideStop("sess-2", WaitsFile{Waits: []Wait{}}, nil, map[string]int{}, stopT0); d.Refuse {
|
|
t.Fatalf("no open waits was refused: %+v", d)
|
|
}
|
|
}
|
|
|
|
// 1743/AC3: a wait past its bound, watched or not, is refused until said, then allowed.
|
|
func TestStopRefusesPastBoundUntilSaidWatchedOrNot(t *testing.T) {
|
|
unwatched := openWait("w-3", 5)
|
|
if d := DecideStop("sess-3", WaitsFile{Waits: []Wait{unwatched}}, nil, map[string]int{}, stopT0.Add(10*time.Minute)); !d.Refuse ||
|
|
!strings.Contains(d.Reason, "w-3") || !strings.Contains(d.Reason, "said") {
|
|
t.Fatalf("unwatched past bound: %+v", d)
|
|
}
|
|
watched := unwatched
|
|
watched.Watcher = "mesh_wait on mesh-delivery.decided"
|
|
if d := DecideStop("sess-3", WaitsFile{Waits: []Wait{watched}}, nil, map[string]int{}, stopT0.Add(10*time.Minute)); !d.Refuse {
|
|
t.Fatalf("watched past bound was allowed: %+v", d)
|
|
}
|
|
said := watched
|
|
said.Said = true
|
|
if d := DecideStop("sess-3", WaitsFile{Waits: []Wait{said}}, nil, map[string]int{}, stopT0.Add(10*time.Minute)); d.Refuse {
|
|
t.Fatalf("said past bound was still refused: %+v", d)
|
|
}
|
|
}
|
|
|
|
// 1743/AC4: an unreadable or malformed waits file is allowed, saying so on stderr and as a systemMessage on
|
|
// stdout; the decision does not touch the network and answers at once.
|
|
func TestStopAllowsAndSaysSoWhenTheWaitsFileIsUnreadableOrMalformed(t *testing.T) {
|
|
d := DecideStop("sess-4", WaitsFile{}, errors.New("no such file"), map[string]int{}, stopT0)
|
|
if d.Refuse || !strings.Contains(d.Reason, "could not be read") {
|
|
t.Fatalf("%+v", d)
|
|
}
|
|
waitsDir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(waitsDir, "sess-4b.json"), []byte("{not json"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var stdout, stderr bytes.Buffer
|
|
stdin := strings.NewReader(`{"session_id":"sess-4b","stop_hook_active":false}`)
|
|
start := time.Now()
|
|
code := runStop(waitsDir, stdin, &stdout, &stderr)
|
|
if code != 0 || !strings.Contains(stderr.String(), "could not be read") {
|
|
t.Fatalf("code %d, stderr %s", code, stderr.String())
|
|
}
|
|
// Item (a): a fail-open notice also reaches the screen, as the one JSON object Claude Code shows.
|
|
if !strings.Contains(stdout.String(), "systemMessage") || !strings.Contains(stdout.String(), "could not be read") {
|
|
t.Fatalf("the fail-open notice did not reach stdout: %s", stdout.String())
|
|
}
|
|
if took := time.Since(start); took > 5*time.Second {
|
|
t.Fatalf("took %s; the Stop hook has no network and is bounded under 5s", took)
|
|
}
|
|
}
|
|
|
|
// Review round 2, item 1 (1743/AC2, ADR 0490 §2): a missing waits file is the normal state for a session
|
|
// with no waits — every stop of every such session — so it must print nothing, on either stream, and
|
|
// simply allow; a notice there would fire constantly for nothing wrong.
|
|
func TestStopPrintsNothingWhenTheWaitsFileIsSimplyMissing(t *testing.T) {
|
|
d := DecideStop("sess-4c", WaitsFile{}, fs.ErrNotExist, map[string]int{}, stopT0)
|
|
if d.Refuse || d.Reason != "" {
|
|
t.Fatalf("a missing file (fs.ErrNotExist) said something: %+v", d)
|
|
}
|
|
waitsDir := t.TempDir() // no file at all for sess-4d
|
|
var stdout, stderr bytes.Buffer
|
|
stdin := strings.NewReader(`{"session_id":"sess-4d","stop_hook_active":false}`)
|
|
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 0 {
|
|
t.Fatalf("code %d", code)
|
|
}
|
|
if stdout.Len() != 0 || stderr.Len() != 0 {
|
|
t.Fatalf("a missing waits file printed something: stdout %q, stderr %q", stdout.String(), stderr.String())
|
|
}
|
|
}
|
|
|
|
// 1743/AC5: the same stop refused three times running for the same wait is allowed the fourth time, saying
|
|
// it was left unwatched; the counter resets when the wait changes.
|
|
func TestStopAllowsTheFourthRefusalInARow(t *testing.T) {
|
|
w := openWait("w-5", 10)
|
|
counters := map[string]int{}
|
|
for i := 1; i <= MostStopRefusals; i++ {
|
|
d := DecideStop("sess-5", WaitsFile{Waits: []Wait{w}}, nil, counters, stopT0.Add(time.Minute))
|
|
if !d.Refuse {
|
|
t.Fatalf("refusal %d was allowed: %+v", i, d)
|
|
}
|
|
counters = d.Counters
|
|
}
|
|
d := DecideStop("sess-5", WaitsFile{Waits: []Wait{w}}, nil, counters, stopT0.Add(time.Minute))
|
|
if d.Refuse || !strings.Contains(d.Reason, "unwatched") {
|
|
t.Fatalf("the fourth refusal was not let through: %+v", d)
|
|
}
|
|
if len(d.Counters) != 0 {
|
|
t.Fatalf("the counter was not reset once let through: %+v", d.Counters)
|
|
}
|
|
|
|
// A different wait starts its own count from zero.
|
|
w2 := openWait("w-5b", 10)
|
|
d2 := DecideStop("sess-5", WaitsFile{Waits: []Wait{w2}}, nil, counters, stopT0.Add(time.Minute))
|
|
if !d2.Refuse || d2.Counters["w-5b"] != 1 {
|
|
t.Fatalf("a different wait did not start its own count: %+v", d2)
|
|
}
|
|
}
|
|
|
|
// 1743/AC6: a panic answers allow.
|
|
func TestStopAPanicAllows(t *testing.T) {
|
|
waitsDir := t.TempDir()
|
|
var stdout, stderr bytes.Buffer
|
|
if code := runStop(waitsDir, panicReader{}, &stdout, &stderr); code != 0 {
|
|
t.Fatalf("a panic was not recovered: code %d", code)
|
|
}
|
|
}
|
|
|
|
// runStop through its counter file: three refusals in a row for the one open wait, then let through.
|
|
func TestRunStopKeepsItsCounterBesideTheWaitsFile(t *testing.T) {
|
|
waitsDir := t.TempDir()
|
|
f := WaitsFile{Session: "sess-6", At: stopT0, Waits: []Wait{openWait("w-6", 10)}}
|
|
writeWaitsFixture(t, filepath.Join(waitsDir, "sess-6.json"), f)
|
|
stdin := func() *strings.Reader { return strings.NewReader(`{"session_id":"sess-6","stop_hook_active":false}`) }
|
|
|
|
for i := 1; i <= MostStopRefusals; i++ {
|
|
var stdout, stderr bytes.Buffer
|
|
if code := runStop(waitsDir, stdin(), &stdout, &stderr); code != 2 {
|
|
t.Fatalf("refusal %d: code %d, stderr %s", i, code, stderr.String())
|
|
}
|
|
}
|
|
var stdout, stderr bytes.Buffer
|
|
if code := runStop(waitsDir, stdin(), &stdout, &stderr); code != 0 || !strings.Contains(stderr.String(), "unwatched") {
|
|
t.Fatalf("the fourth call: code %d, stderr %s", code, stderr.String())
|
|
}
|
|
// Item (a): the let-through notice reaches the screen too, not only the log.
|
|
if !strings.Contains(stdout.String(), "systemMessage") || !strings.Contains(stdout.String(), "unwatched") {
|
|
t.Fatalf("the let-through notice did not reach stdout: %s", stdout.String())
|
|
}
|
|
// Once let through, the counters are empty again: the file is removed, not left holding "{}" (issue
|
|
// 1749, follow-up).
|
|
if _, err := os.Stat(refusalCountersPath(waitsDir, "sess-6")); !os.IsNotExist(err) {
|
|
t.Fatalf("the counter file was not removed once its counters emptied: %v", err)
|
|
}
|
|
}
|
|
|
|
// Issue 1749, follow-up: a stop for a session with no waits at all leaves nothing behind — not the counter
|
|
// file, not even the waits folder a node that never added a wait would otherwise gain on every stop of
|
|
// every session.
|
|
func TestStopWithNoWaitsLeavesNoFolderAndNoFile(t *testing.T) {
|
|
waitsDir := filepath.Join(t.TempDir(), "waits") // not created: this is what the hook is handed cold
|
|
var stdout, stderr bytes.Buffer
|
|
stdin := strings.NewReader(`{"session_id":"sess-none","stop_hook_active":false}`)
|
|
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 0 {
|
|
t.Fatalf("code %d, stderr %s", code, stderr.String())
|
|
}
|
|
if _, err := os.Stat(waitsDir); !os.IsNotExist(err) {
|
|
t.Fatalf("a stop with no waits created the waits folder: %v", err)
|
|
}
|
|
}
|
|
|
|
// Issue 1749, follow-up: a refusal writes the counter file (there is something to record).
|
|
func TestStopWithARefusalWritesTheCounterFile(t *testing.T) {
|
|
waitsDir := t.TempDir()
|
|
f := WaitsFile{Session: "sess-7", At: stopT0, Waits: []Wait{openWait("w-7", 10)}}
|
|
writeWaitsFixture(t, filepath.Join(waitsDir, "sess-7.json"), f)
|
|
var stdout, stderr bytes.Buffer
|
|
stdin := strings.NewReader(`{"session_id":"sess-7","stop_hook_active":false}`)
|
|
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 2 {
|
|
t.Fatalf("code %d, stderr %s", code, stderr.String())
|
|
}
|
|
raw, err := os.ReadFile(refusalCountersPath(waitsDir, "sess-7"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var counters map[string]int
|
|
if json.Unmarshal(raw, &counters) != nil || counters["w-7"] != 1 {
|
|
t.Fatalf("the counter file: %s", raw)
|
|
}
|
|
}
|
|
|
|
// Issue 1749, follow-up: once the wait is done (no longer in the waits file), the next stop removes the
|
|
// counter file rather than leaving a stale "{}" behind.
|
|
func TestStopAfterTheWaitIsDoneRemovesTheCounterFile(t *testing.T) {
|
|
waitsDir := t.TempDir()
|
|
path := filepath.Join(waitsDir, "sess-8.json")
|
|
writeWaitsFixture(t, path, WaitsFile{Session: "sess-8", At: stopT0, Waits: []Wait{openWait("w-8", 10)}})
|
|
stdin := strings.NewReader(`{"session_id":"sess-8","stop_hook_active":false}`)
|
|
var stdout, stderr bytes.Buffer
|
|
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 2 {
|
|
t.Fatalf("refusal: code %d, stderr %s", code, stderr.String())
|
|
}
|
|
if _, err := os.Stat(refusalCountersPath(waitsDir, "sess-8")); err != nil {
|
|
t.Fatalf("the counter file was not written: %v", err)
|
|
}
|
|
|
|
// The wait is done: it is no longer in the waits file (DoneWait removes it, waits.go).
|
|
writeWaitsFixture(t, path, WaitsFile{Session: "sess-8", At: stopT0, Waits: []Wait{}})
|
|
var stdout2, stderr2 bytes.Buffer
|
|
stdin2 := strings.NewReader(`{"session_id":"sess-8","stop_hook_active":false}`)
|
|
if code := runStop(waitsDir, stdin2, &stdout2, &stderr2); code != 0 {
|
|
t.Fatalf("after done: code %d, stderr %s", code, stderr2.String())
|
|
}
|
|
if _, err := os.Stat(refusalCountersPath(waitsDir, "sess-8")); !os.IsNotExist(err) {
|
|
t.Fatalf("the counter file was not removed after the wait was done: %v", err)
|
|
}
|
|
}
|
|
|
|
// Review round 1, item 1 (1743/1744, ADR 0489 §5): a session id that is not a plain one (path separators,
|
|
// ".."), is never used to build a file path — the counter file stays inside the waits directory — and the
|
|
// Stop hook allows, saying why.
|
|
func TestStopRejectsAnUnsafeSessionID(t *testing.T) {
|
|
for _, id := range []string{"../../x", "a/b"} {
|
|
root := t.TempDir()
|
|
waitsDir := filepath.Join(root, "agent", "waits")
|
|
if err := os.MkdirAll(waitsDir, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var stdout, stderr bytes.Buffer
|
|
stdin := strings.NewReader(fmt.Sprintf(`{"session_id":%q,"stop_hook_active":false}`, id))
|
|
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 0 {
|
|
t.Fatalf("id %q: code %d, stderr %s", id, code, stderr.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "could not be used") {
|
|
t.Fatalf("id %q: did not say why it allowed: %s", id, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "systemMessage") {
|
|
t.Fatalf("id %q: the notice did not reach stdout: %s", id, stdout.String())
|
|
}
|
|
escaped := filepath.Join(waitsDir, id+".refusals.json")
|
|
if _, err := os.Stat(escaped); !os.IsNotExist(err) {
|
|
t.Fatalf("id %q: a counter file was written outside the waits dir at %s", id, escaped)
|
|
}
|
|
}
|
|
// A blank id still just allows, quietly: it never reaches the point of building a path at all.
|
|
waitsDir := t.TempDir()
|
|
var stdout, stderr bytes.Buffer
|
|
if code := runStop(waitsDir, strings.NewReader(`{"session_id":""}`), &stdout, &stderr); code != 0 {
|
|
t.Fatalf("blank id: code %d", code)
|
|
}
|
|
}
|
|
|
|
// Review round 1, item 2 (ADR 0489 §5 "never loops", 1743/AC5): when the decision is to refuse and the
|
|
// refusal counter cannot be written, the hook allows rather than risk refusing forever with a cap that
|
|
// never takes effect.
|
|
func TestStopAllowsWhenTheRefusalCounterCannotBeWritten(t *testing.T) {
|
|
if os.Geteuid() == 0 {
|
|
t.Skip("running as root: a directory's write bit cannot be taken away")
|
|
}
|
|
waitsDir := t.TempDir()
|
|
writeWaitsFixture(t, filepath.Join(waitsDir, "sess-7.json"),
|
|
WaitsFile{Session: "sess-7", At: stopT0, Waits: []Wait{openWait("w-7", 10)}})
|
|
if err := os.Chmod(waitsDir, 0o500); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = os.Chmod(waitsDir, 0o700) })
|
|
var stdout, stderr bytes.Buffer
|
|
stdin := strings.NewReader(`{"session_id":"sess-7","stop_hook_active":false}`)
|
|
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 0 {
|
|
t.Fatalf("code %d, stderr %s", code, stderr.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "counter") {
|
|
t.Fatalf("did not say the counter could not be written: %s", stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "systemMessage") {
|
|
t.Fatalf("the notice did not reach stdout: %s", stdout.String())
|
|
}
|
|
}
|
|
|
|
// Review round 1, item 3 (1743/AC1, §3 with ADR 0367 §2): a family is offered only conditionally (the tool
|
|
// runner may not be granted it); without one, the advice is only to start a watcher.
|
|
func TestWaitBlocksStopAdvisesMeshWaitOnlyConditionallyOtherwiseAWatcher(t *testing.T) {
|
|
withFamily := openWait("w-f", 10)
|
|
withFamily.Read = WaitRead{Address: "mesh-delivery.recheck"}
|
|
withFamily.Family = "mesh-delivery.decided"
|
|
_, reason := waitBlocksStop("sess-f", withFamily, stopT0.Add(time.Minute))
|
|
if !strings.Contains(reason, "mesh_wait on mesh-delivery.decided if this session's tool runner is granted it") ||
|
|
!strings.Contains(reason, "otherwise start a watcher that rereads mesh-delivery.recheck") {
|
|
t.Fatalf("%s", reason)
|
|
}
|
|
|
|
noFamily := openWait("w-nf", 10)
|
|
noFamily.Read = WaitRead{Address: "mesh-delivery.recheck"}
|
|
_, reason2 := waitBlocksStop("sess-nf", noFamily, stopT0.Add(time.Minute))
|
|
if strings.Contains(reason2, "mesh_wait") || !strings.Contains(reason2, "start a watcher that rereads mesh-delivery.recheck") {
|
|
t.Fatalf("%s", reason2)
|
|
}
|
|
}
|
|
|
|
// 1743/AC7's rendering half (hooks.json holds the guard's PreToolUse entry and the Stop entry) is checked
|
|
// together with 1744/AC4 in terminal_only_test.go's
|
|
// TestThePluginsOnlyHooksAreTheGuardTheSessionStartHookAndTheStopHook. claude_code_hook_register still
|
|
// refuses a Stop hook (or any other) at every scope.
|
|
func TestHookRegisterStillRefusesEveryHook(t *testing.T) {
|
|
p, _ := node(t, "laptop")
|
|
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
|
|
text, isError := s.call(t, "claude_code_hook_register", map[string]any{"name": "x", "event": "Stop", "command": "true"})
|
|
if !isError || !strings.Contains(text, "issue 340") {
|
|
t.Fatalf("a Stop hook was not refused: %v %s", isError, text)
|
|
}
|
|
}
|
|
|
|
// Issue 1749: adopt's refusal-counter reset writes into the agent's home (beside the waits file) and must
|
|
// go through fsys like every other such write (novox/hq ADR 0266) — on a confined node the module runs as
|
|
// the operator's account, which cannot write there directly; only the helper, as the agent's own account,
|
|
// can. The confined-node fake (agentFiles, agent_account_test.go) proves it: a write that reaches the real
|
|
// filesystem only through the helper's request/response protocol bumps its count.
|
|
func TestWriteRefusalCountersGoesThroughTheHelper(t *testing.T) {
|
|
f, home, counted := agentFiles(t)
|
|
was := fsys
|
|
fsys = f
|
|
t.Cleanup(func() { fsys = was })
|
|
|
|
waitsDir := filepath.Join(home, ".claude", "nox-mesh", "waits")
|
|
if err := writeRefusalCounters(waitsDir, "sess-1", map[string]int{"w-1": 2}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if counted.asked.Load() == 0 {
|
|
t.Fatal("the counter file was written directly, never through the helper")
|
|
}
|
|
raw, err := os.ReadFile(filepath.Join(waitsDir, "sess-1.refusals.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var m map[string]int
|
|
if json.Unmarshal(raw, &m) != nil || m["w-1"] != 2 {
|
|
t.Fatalf("the written counters: %s", raw)
|
|
}
|
|
}
|