A session id reaching the Stop and SessionStart hooks was only checked for blank, so "../../x" or "a/b" could read or write outside the waits directory; both hooks now hold every session id to waitSessionID before building any path, and allow (saying why) rather than fail on a bad one. claude_code_wait's list/watched/done/said held only add to that same check, letting a malformed id through as an ordinary, always-empty key; they all refuse it now. The Stop hook's three-strikes cap could be bypassed forever if its counter file could not be written on a refusal: it now allows instead, saying so, so a wait can never hold a turn open indefinitely. Its advice offered mesh_wait on a wait's event family unconditionally, ignoring that the session's tool runner may not be granted it (ADR 0367 §2); it now offers mesh_wait only "if this session's tool runner is granted it, otherwise start a watcher", and drops the family entirely when a wait has none. Both hooks' fail-open and let-through notices were said only on stderr, which Claude Code does not show the operator on an allowed (exit 0) outcome; they now also print the same words as a "systemMessage" JSON object on stdout, which Claude Code does show.
288 lines
11 KiB
Go
288 lines
11 KiB
Go
package main
|
|
|
|
// A session's wait list (novox/hq ADR 0489, issue 1629).
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
)
|
|
|
|
// memWaits is the `waits` state: values with revisions, create-only and compare-and-set, as the bus keeps it.
|
|
type memWaits struct {
|
|
mu sync.Mutex
|
|
m map[string]json.RawMessage
|
|
rev map[string]uint64
|
|
n uint64
|
|
}
|
|
|
|
func newMemWaits() *memWaits {
|
|
return &memWaits{m: map[string]json.RawMessage{}, rev: map[string]uint64{}}
|
|
}
|
|
|
|
func (s *memWaits) Get(key string) (json.RawMessage, uint64, bool, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
raw, ok := s.m[key]
|
|
return raw, s.rev[key], ok, nil
|
|
}
|
|
func (s *memWaits) put(key string, value any) error {
|
|
raw, err := json.Marshal(value)
|
|
s.n++
|
|
s.m[key], s.rev[key] = raw, s.n
|
|
return err
|
|
}
|
|
func (s *memWaits) Create(key string, value any) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if _, ok := s.m[key]; ok {
|
|
return stdio.ErrChanged
|
|
}
|
|
return s.put(key, value)
|
|
}
|
|
func (s *memWaits) Update(key string, value any, revision uint64) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.rev[key] != revision {
|
|
return stdio.ErrChanged
|
|
}
|
|
return s.put(key, value)
|
|
}
|
|
|
|
// addApproveWait adds the wait fixture the operator's Approve is kept under.
|
|
func addApproveWait(t *testing.T, p Paths, store WaitStore, session string, at time.Time) string {
|
|
t.Helper()
|
|
out, err := AddWait(p, WaitInput{Session: session, By: "fix-1629@" + p.Node, What: "the operator's Approve of pull request 42",
|
|
Read: WaitRead{Address: "mesh-delivery.recheck", Arguments: map[string]any{"pr": 42}}, Family: "mesh-delivery.decided",
|
|
Bound: 10}, store, at)
|
|
if err != nil {
|
|
t.Fatalf("add: %v", err)
|
|
}
|
|
return out["id"].(string)
|
|
}
|
|
|
|
// 1629/AC1: add answers an id; list answers the open waits with their age and whether each is past its bound.
|
|
func TestAddAnswersAnIDAndListShowsItsAgeAndPastBound(t *testing.T) {
|
|
p, _ := node(t, "g14")
|
|
store := newMemWaits()
|
|
id := addApproveWait(t, p, store, "sess-1", t0)
|
|
if !strings.HasPrefix(id, WaitPrefix) {
|
|
t.Fatalf("id %q does not start with %q", id, WaitPrefix)
|
|
}
|
|
out, err := ListWaits(p, "sess-1", store, t0.Add(time.Minute))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
open := out["open"].([]map[string]any)
|
|
if len(open) != 1 || open[0]["id"] != id || open[0]["past_bound"] != false || open[0]["age_seconds"] != 60 {
|
|
t.Fatalf("%+v", open)
|
|
}
|
|
out, err = ListWaits(p, "sess-1", store, t0.Add(11*time.Minute))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
open = out["open"].([]map[string]any)
|
|
if open[0]["past_bound"] != true {
|
|
t.Fatalf("past its 10-minute bound after 11 minutes: %+v", open[0])
|
|
}
|
|
}
|
|
|
|
// 1629/AC2: watched, done and said change the wait; said is refused for a wait not past its bound; done removes
|
|
// it from the open list.
|
|
func TestWatchedDoneAndSaidChangeTheWait(t *testing.T) {
|
|
p, _ := node(t, "g14")
|
|
store := newMemWaits()
|
|
id := addApproveWait(t, p, store, "sess-2", t0)
|
|
|
|
if _, err := SaidWait(p, "sess-2", id, store, t0.Add(time.Minute)); err == nil || !strings.Contains(err.Error(), "not past its bound") {
|
|
t.Fatalf("said before the bound: %v", err)
|
|
}
|
|
if _, err := WatchedWait(p, "sess-2", id, "mesh_wait on mesh-delivery.decided", store, t0.Add(time.Minute)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out, _ := ListWaits(p, "sess-2", store, t0.Add(2*time.Minute))
|
|
open := out["open"].([]map[string]any)
|
|
if open[0]["watcher"] != "mesh_wait on mesh-delivery.decided" {
|
|
t.Fatalf("watcher not kept: %+v", open[0])
|
|
}
|
|
if _, err := SaidWait(p, "sess-2", id, store, t0.Add(11*time.Minute)); err != nil {
|
|
t.Fatalf("said past the bound: %v", err)
|
|
}
|
|
out, _ = ListWaits(p, "sess-2", store, t0.Add(12*time.Minute))
|
|
open = out["open"].([]map[string]any)
|
|
if open[0]["said"] != true {
|
|
t.Fatalf("said not kept: %+v", open[0])
|
|
}
|
|
if _, err := DoneWait(p, "sess-2", id, "approved", store, t0.Add(13*time.Minute)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out, _ = ListWaits(p, "sess-2", store, t0.Add(14*time.Minute))
|
|
if len(out["open"].([]map[string]any)) != 0 {
|
|
t.Fatalf("done did not remove it from the open list: %v", out)
|
|
}
|
|
if _, err := DoneWait(p, "sess-2", id, "approved", store, t0.Add(15*time.Minute)); err == nil {
|
|
t.Fatal("done on an already-done wait was not refused")
|
|
}
|
|
}
|
|
|
|
// 1629/AC3: every change rewrites the session's waits file whole, atomically; it holds exactly the open waits
|
|
// and the write time.
|
|
func TestEveryChangeRewritesTheWaitsFileWhole(t *testing.T) {
|
|
p, _ := node(t, "g14")
|
|
store := newMemWaits()
|
|
id1 := addApproveWait(t, p, store, "sess-3", t0)
|
|
f, err := readWaitsFile(waitsFilePath(p, "sess-3"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if f.Session != "sess-3" || len(f.Waits) != 1 || f.Waits[0].ID != id1 || !f.At.Equal(t0.UTC()) {
|
|
t.Fatalf("%+v", f)
|
|
}
|
|
// Written whole: no .mesh-tmp left beside it.
|
|
if _, err := os.Stat(waitsFilePath(p, "sess-3") + ".mesh-tmp"); !os.IsNotExist(err) {
|
|
t.Fatalf("a temp file was left beside it: %v", err)
|
|
}
|
|
id2, err := AddWait(p, WaitInput{Session: "sess-3", By: "fix-1629@" + p.Node, What: "a second wait",
|
|
Read: WaitRead{Address: "mesh-delivery.recheck"}, Bound: 5}, store, t0.Add(time.Minute))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f, err = readWaitsFile(waitsFilePath(p, "sess-3"))
|
|
if err != nil || len(f.Waits) != 2 || !f.At.Equal(t0.Add(time.Minute).UTC()) {
|
|
t.Fatalf("%+v %v", f, err)
|
|
}
|
|
if _, err := DoneWait(p, "sess-3", id1, "approved", store, t0.Add(2*time.Minute)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f, err = readWaitsFile(waitsFilePath(p, "sess-3"))
|
|
if err != nil || len(f.Waits) != 1 || f.Waits[0].ID != id2["id"] {
|
|
t.Fatalf("the file does not hold exactly the open waits: %+v %v", f, err)
|
|
}
|
|
}
|
|
|
|
// 1629/AC4: the waits are read again after the module "restarts" (the state is kept) and for the same session
|
|
// after a /clear (the same session id lists them); a different session id lists none of them.
|
|
func TestWaitsSurviveARestartAndAClearButNotCrossSessions(t *testing.T) {
|
|
p, _ := node(t, "g14")
|
|
store := newMemWaits()
|
|
addApproveWait(t, p, store, "sess-4", t0)
|
|
|
|
// A restart: a fresh Waiting over the same store reads the same waits.
|
|
restarted := &Waiting{Store: store, Now: func() time.Time { return t0.Add(time.Minute) }}
|
|
_ = restarted
|
|
out, err := ListWaits(p, "sess-4", store, t0.Add(time.Minute))
|
|
if err != nil || len(out["open"].([]map[string]any)) != 1 {
|
|
t.Fatalf("not read again after a restart: %v %v", out, err)
|
|
}
|
|
|
|
// A /clear: the same session id still lists it.
|
|
out, err = ListWaits(p, "sess-4", store, t0.Add(2*time.Minute))
|
|
if err != nil || len(out["open"].([]map[string]any)) != 1 {
|
|
t.Fatalf("not read again for the same session: %v %v", out, err)
|
|
}
|
|
|
|
// A different session id: none of them.
|
|
out, err = ListWaits(p, "sess-other", store, t0.Add(2*time.Minute))
|
|
if err != nil || len(out["open"].([]map[string]any)) != 0 {
|
|
t.Fatalf("another session id saw them: %v %v", out, err)
|
|
}
|
|
}
|
|
|
|
// 1629/AC5: add refuses an empty what, a bound under 1 or over 1440 minutes, a read address that is not an
|
|
// address, and more than 50 open waits per session, naming the argument.
|
|
func TestAddRefusesOutsideItsBounds(t *testing.T) {
|
|
p, _ := node(t, "g14")
|
|
base := func() WaitInput {
|
|
return WaitInput{Session: "sess-5", By: "fix-1629@" + p.Node, What: "something", Read: WaitRead{Address: "mesh-delivery.recheck"}, Bound: 10}
|
|
}
|
|
for name, change := range map[string]struct {
|
|
mutate func(*WaitInput)
|
|
want string
|
|
}{
|
|
"empty what": {func(in *WaitInput) { in.What = "" }, "what is required"},
|
|
"bound zero": {func(in *WaitInput) { in.Bound = 0 }, "bound_minutes"},
|
|
"bound under 1": {func(in *WaitInput) { in.Bound = 0 }, "bound_minutes"},
|
|
"bound over 1440": {func(in *WaitInput) { in.Bound = 1441 }, "bound_minutes"},
|
|
"empty address": {func(in *WaitInput) { in.Read.Address = "" }, "read.address"},
|
|
"address no dot": {func(in *WaitInput) { in.Read.Address = "notanaddress" }, "read.address"},
|
|
"address has spaces": {func(in *WaitInput) { in.Read.Address = "not an address" }, "read.address"},
|
|
} {
|
|
in := base()
|
|
change.mutate(&in)
|
|
store := newMemWaits()
|
|
if _, err := AddWait(p, in, store, t0); err == nil || !strings.Contains(err.Error(), change.want) {
|
|
t.Errorf("%s: %v", name, err)
|
|
}
|
|
}
|
|
|
|
// More than 50 open waits per session.
|
|
store := newMemWaits()
|
|
for i := 0; i < MostWaits; i++ {
|
|
if _, err := AddWait(p, base(), store, t0); err != nil {
|
|
t.Fatalf("wait %d: %v", i, err)
|
|
}
|
|
}
|
|
if _, err := AddWait(p, base(), store, t0); err == nil || !strings.Contains(err.Error(), "at most 50 open waits") {
|
|
t.Fatalf("the 51st wait was not refused: %v", err)
|
|
}
|
|
}
|
|
|
|
// Review round 1 item (b): list, watched, done and said refuse a session id that is not a plain one (the
|
|
// same waitSessionID add already holds to), naming "session" — not silently treating it as just-another-key.
|
|
func TestListWatchedDoneAndSaidRefuseABadSessionID(t *testing.T) {
|
|
p, _ := node(t, "g14")
|
|
for _, bad := range []string{"", "a/b", "../x"} {
|
|
store := newMemWaits()
|
|
if _, err := ListWaits(p, bad, store, t0); err == nil || !strings.Contains(err.Error(), "session") {
|
|
t.Errorf("list %q: %v", bad, err)
|
|
}
|
|
if _, err := WatchedWait(p, bad, "w-1", "watcher", store, t0); err == nil || !strings.Contains(err.Error(), "session") {
|
|
t.Errorf("watched %q: %v", bad, err)
|
|
}
|
|
if _, err := DoneWait(p, bad, "w-1", "outcome", store, t0); err == nil || !strings.Contains(err.Error(), "session") {
|
|
t.Errorf("done %q: %v", bad, err)
|
|
}
|
|
if _, err := SaidWait(p, bad, "w-1", store, t0); err == nil || !strings.Contains(err.Error(), "session") {
|
|
t.Errorf("said %q: %v", bad, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 1629/AC6: the tool is served through the module's own dispatch.
|
|
func TestTheWaitToolIsServedThroughTheModulesDispatch(t *testing.T) {
|
|
p, _ := node(t, "g14")
|
|
store := newMemWaits()
|
|
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
|
|
t.Cleanup(func() { waiting = nil })
|
|
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
|
|
|
|
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "sess-6", "by": "fix-1629@g14",
|
|
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
|
|
if isErr {
|
|
t.Fatalf("add: %s", text)
|
|
}
|
|
var added map[string]any
|
|
if err := json.Unmarshal([]byte(text), &added); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
id, _ := added["id"].(string)
|
|
if id == "" {
|
|
t.Fatalf("no id answered: %s", text)
|
|
}
|
|
|
|
text, isErr = s.call(t, "claude_code_wait", map[string]any{"op": "list", "session": "sess-6"})
|
|
if isErr || !strings.Contains(text, id) {
|
|
t.Fatalf("list: %v %s", isErr, text)
|
|
}
|
|
|
|
text, isErr = s.call(t, "claude_code_wait", map[string]any{"op": "done", "session": "sess-6", "id": id, "outcome": "approved"})
|
|
if isErr {
|
|
t.Fatalf("done: %s", text)
|
|
}
|
|
}
|