Files
mesh-catalog/modules/claude-code/cmd/claude-code/stop_test.go
T
jochen e3a714d5f0
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
Stop writing an empty refusal-counter file on every stop of every session (issue 1749)
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.
2026-10-21 19:41:05 +02:00

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)
}
}