Add the plugin's Stop hook so a written-down wait is never left unwatched (issue 1743)
A session could end its turn while a wait it wrote down with claude_code_wait had nothing following it. The plugin's Stop hook (claude-code stop <waits-dir>) refuses the stop while a wait is within its bound and unwatched, or past its bound and not said, reading the waits file directly with no network and a 5s bound; it fails open on a missing or malformed file or a panic, and lets a wait through after three refusals in a row rather than holding the session forever. Renames TestThePluginsOnlyHookIsTheGuard to cover the session-start and Stop hooks too.
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
package main
|
||||
|
||||
// The plugin's Stop hook (novox/hq ADR 0489 §3-§6, issue 1743).
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"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.
|
||||
func TestStopRefusesAnUnwatchedWaitWithinItsBound(t *testing.T) {
|
||||
w := openWait("w-1", 10)
|
||||
w.Read = WaitRead{Address: "mesh-delivery.recheck"}
|
||||
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: a missing, unreadable or malformed waits file is allowed, saying so on stderr; the decision does
|
||||
// not touch the network and answers at once.
|
||||
func TestStopAllowsWhenTheWaitsFileCannotBeRead(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() // no file at all for sess-4b
|
||||
var stderr bytes.Buffer
|
||||
stdin := strings.NewReader(`{"session_id":"sess-4b","stop_hook_active":false}`)
|
||||
start := time.Now()
|
||||
code := runStop(waitsDir, stdin, &stderr)
|
||||
if code != 0 || !strings.Contains(stderr.String(), "could not be read") {
|
||||
t.Fatalf("code %d, stderr %s", code, stderr.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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 stderr bytes.Buffer
|
||||
if code := runStop(waitsDir, panicReader{}, &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 stderr bytes.Buffer
|
||||
if code := runStop(waitsDir, stdin(), &stderr); code != 2 {
|
||||
t.Fatalf("refusal %d: code %d, stderr %s", i, code, stderr.String())
|
||||
}
|
||||
}
|
||||
var stderr bytes.Buffer
|
||||
if code := runStop(waitsDir, stdin(), &stderr); code != 0 || !strings.Contains(stderr.String(), "unwatched") {
|
||||
t.Fatalf("the fourth call: code %d, stderr %s", code, stderr.String())
|
||||
}
|
||||
raw, err := os.ReadFile(refusalCountersPath(waitsDir, "sess-6"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var counters map[string]int
|
||||
if json.Unmarshal(raw, &counters) != nil || len(counters) != 0 {
|
||||
t.Fatalf("the counter file was not reset: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user