Four agents reviewed this in parallel -- correctness, GNOME integration, edge cases, security. Everything below was reproduced before being fixed; several findings that survived the first reading did not survive a probe and are not here. State machine, the two that mattered most. A pending permission prompt was erased by any subagent bookkeeping event: SubagentStop or the next PreToolUse recomputed the state from scratch, so a session sat at "working" with a dialog open and nothing ever raised it again. Blocked now outlives everything except evidence the question was answered. Separately, the stale-event guard refused whole events, including the subagent counter's increments and decrements -- but those are deltas and deltas commute, so a "+1" that lost a timestamp race left the count short and the batch freed the session while a subagent was still running. The guard now gates the state decision only. Corrupt or hostile state files could wedge the panel or take the hook down for every session: a non-numeric pid raised inside sweep_dead before the hook wrote its own file, so one bad byte stopped new sessions appearing at all. Numbers read back from disk are coerced, one unreadable file no longer aborts the sweep, and a stored timestamp far in the future -- corruption, or a clock stepped backwards by NTP -- no longer refuses every later event forever. Resource bounds, all in the compositor process. A state file was read whole with no size check: a symlink to /dev/zero took a test process past 4 GB in three seconds, which in gnome-shell ends the session. Sizes are checked before the read, sessions and zellij subprocesses are capped, labels ellipsize, and cwd and messages are truncated at the hook. Teardown hung off an overridden destroy(), which only runs when JS calls it. An actor destroyed any other way -- another extension rebuilding the panel boxes -- left the timer and the file monitor running against a disposed actor. It is a destroy signal now. The zellij child is killed rather than merely abandoned. The glyph was pinned to physical pixels and rendered half-size on HiDPI; size comes from the stylesheet, and the foreground colour is normalised by inspection rather than assuming which colour struct the shell hands back. Chip labels: non-Latin names all collapsed to "?", because the split treated every Cyrillic letter as a separator -- notable for a tool whose own README is Russian. Seniority also ranked by time-in-state rather than session age, so after a shell restart the older session could take the digit; the hook now records when the session began. zellij: a dump ends with new_tab_template and swap_tiled_layout blocks whose tab lines carry no name, and their panes were being attached to the last real tab -- which then answered for every unmatched directory, confidently and wrongly. install.py no longer widens the mode of a settings.json someone narrowed to 0600, no longer overwrites the pristine .bak on a second run, no longer replaces a symlink out of a dotfiles repository with a regular file, and quotes the hook path. The debug log is capped and README now says plainly that it records prompts verbatim. Not fixed, deliberately: the panel does push the clock about 70 px left with three labelled chips, which is inherent to putting them in the centre box; two different projects abbreviating alike still read as one project with a digit; GNOME 48 remains unverified for the colour struct and for St.BoxLayout's vertical property, both flagged rather than guessed at.
207 lines
8.6 KiB
Bash
Executable File
207 lines
8.6 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Exercises the hook's state machine and its behaviour under concurrency.
|
|
#
|
|
# Run: tests/test-hook.sh
|
|
set -u
|
|
|
|
HOOK="$(cd "$(dirname "$0")/.." && pwd)/hooks/claude-status-hook.py"
|
|
export XDG_STATE_HOME="$(mktemp -d)"
|
|
DIR="$XDG_STATE_HOME/claude-code-status"
|
|
SID="test-session"
|
|
FILE="$DIR/$SID.json"
|
|
failures=0
|
|
|
|
check() { # name expected actual
|
|
if [ "$2" = "$3" ]; then
|
|
echo "ok $1"
|
|
else
|
|
echo "FAIL $1 (expected '$2', got '$3')"
|
|
failures=$((failures + 1))
|
|
fi
|
|
}
|
|
|
|
emit() { # json
|
|
echo "$1" | "$HOOK"
|
|
}
|
|
|
|
field() { # key
|
|
python3 -c "import json,sys;print(json.load(open('$FILE')).get('$1',''))" 2>/dev/null
|
|
}
|
|
|
|
ev() { # event [extra json]
|
|
echo "{\"session_id\":\"$SID\",\"hook_event_name\":\"$1\",\"cwd\":\"/tmp/proj\"${2:+,$2}}"
|
|
}
|
|
|
|
# --- identifying the claude process ----------------------------------------
|
|
# The hook is spawned as `/bin/sh -c /.../claude-status-hook.py`, so its
|
|
# parent's command line contains "claude" in a path without being claude.
|
|
# Matching on the raw string latches onto that shell, which exits at once.
|
|
python3 - "$HOOK" <<'PY'
|
|
import importlib.util, sys
|
|
spec = importlib.util.spec_from_file_location("h", sys.argv[1])
|
|
h = importlib.util.module_from_spec(spec); spec.loader.exec_module(h)
|
|
cases = [
|
|
("/bin/sh -c /home/u/claude-code-gnome-extension/hooks/claude-status-hook.py ", False),
|
|
("/home/u/.local/bin/claude --resume ", True),
|
|
("bash /home/u/bin/claude ", True),
|
|
("node /usr/lib/node_modules/@anthropic-ai/claude-code/cli.js ", True),
|
|
("/home/u/bin/zellij --server /run/user/1000/zellij/x ", False),
|
|
("nvim /home/u/.claude/settings.json ", False),
|
|
]
|
|
bad = 0
|
|
for cmd, want in cases:
|
|
got = h.looks_like_claude(cmd)
|
|
print(("ok " if got == want else "FAIL ") + "claude in %r -> %s" % (cmd[:46], got))
|
|
bad += got != want
|
|
sys.exit(1 if bad else 0)
|
|
PY
|
|
check "command lines classified correctly" "0" "$?"
|
|
|
|
# --- state machine ---------------------------------------------------------
|
|
emit "$(ev SessionStart '"source":"startup"')"
|
|
check "SessionStart -> waiting" "waiting" "$(field state)"
|
|
|
|
# Pins the recorded pid to one process, so a state file that outlives a reboot
|
|
# cannot be revived by whatever inherits that pid number next.
|
|
start=$(field pid_start)
|
|
check "process start time recorded" "yes" "$([ -n "$start" ] && [ "$start" != "0" ] && echo yes || echo no)"
|
|
|
|
emit "$(ev UserPromptSubmit '"prompt":"hi"')"
|
|
check "UserPromptSubmit -> busy" "busy" "$(field state)"
|
|
|
|
before=$(field event_ts)
|
|
emit "$(ev PostToolUse '"tool_name":"Bash"')"
|
|
check "PostToolUse while busy does not rewrite" "$before" "$(field event_ts)"
|
|
|
|
emit "$(ev Notification '"notification_type":"permission_prompt","message":"run rm -rf"')"
|
|
check "permission_prompt -> blocked" "blocked" "$(field state)"
|
|
check "permission message kept" "run rm -rf" "$(field message)"
|
|
|
|
emit "$(ev PostToolUse '"tool_name":"Bash"')"
|
|
check "PostToolUse clears blocked" "busy" "$(field state)"
|
|
|
|
emit "$(ev Notification '"notification_type":"auth_success","message":"logged in"')"
|
|
check "auth_success ignored" "busy" "$(field state)"
|
|
|
|
emit "$(ev Stop)"
|
|
check "Stop -> waiting" "waiting" "$(field state)"
|
|
|
|
before=$(field event_ts)
|
|
emit "$(ev Notification '"notification_type":"idle_prompt","message":"waiting for input"')"
|
|
check "idle_prompt while waiting does not rewrite" "$before" "$(field event_ts)"
|
|
|
|
# Subagent activity must not touch the state. The background case is the one
|
|
# that bites: the main agent has already stopped, so a subagent's tool call
|
|
# arriving afterwards would claim the session is working when it is waiting.
|
|
emit "$(ev Stop '"agent_id":"sub-1"')"
|
|
check "subagent Stop ignored" "waiting" "$(field state)"
|
|
|
|
emit "$(ev PostToolUse '"tool_name":"Bash","agent_id":"sub-1","agent_type":"general-purpose"')"
|
|
check "background subagent does not un-wait the session" "waiting" "$(field state)"
|
|
|
|
# ...but a subagent that gets stuck still needs a human, so its notification
|
|
# must come through.
|
|
emit "$(ev Notification '"notification_type":"permission_prompt","message":"x","agent_id":"sub-1"')"
|
|
check "subagent permission prompt still blocks" "blocked" "$(field state)"
|
|
emit "$(ev PostToolUse '"tool_name":"Bash"')"
|
|
check "main agent tool call clears it again" "busy" "$(field state)"
|
|
emit "$(ev Stop)"
|
|
check "back to waiting" "waiting" "$(field state)"
|
|
|
|
# Deliberately checked from "busy": compaction raises SessionStart mid-turn,
|
|
# and taking it at face value would drop a working session back to waiting.
|
|
emit "$(ev UserPromptSubmit '"prompt":"go"')"
|
|
since_before=$(field since)
|
|
emit "$(ev SessionStart '"source":"compact"')"
|
|
check "compaction does not reset a live session" "busy" "$(field state)"
|
|
check "compaction does not reset the clock" "$since_before" "$(field since)"
|
|
|
|
# --- subagents -------------------------------------------------------------
|
|
# The scenario this exists for: you ask for a batch, the main agent launches it
|
|
# and ends its turn, and the session waits for the results to consolidate them.
|
|
# It is working, not waiting for you, and must not call you over.
|
|
emit "$(ev UserPromptSubmit '"prompt":"launch a batch"')"
|
|
check "a new turn resets the count" "0" "$(field agents)"
|
|
|
|
emit "$(ev PreToolUse '"tool_name":"Agent"')"
|
|
emit "$(ev PreToolUse '"tool_name":"Agent"')"
|
|
check "two launches counted" "2" "$(field agents)"
|
|
|
|
emit "$(ev PreToolUse '"tool_name":"TaskCreate"')"
|
|
check "a tool that merely starts with Task is not a subagent" "2" "$(field agents)"
|
|
|
|
emit "$(ev Stop)"
|
|
check "main agent stopping does not free a running batch" "busy" "$(field state)"
|
|
|
|
emit "$(ev SubagentStop '"agent_id":"sub-1","agent_type":"general-purpose"')"
|
|
check "one down, still working" "busy" "$(field state)"
|
|
check "count decremented" "1" "$(field agents)"
|
|
|
|
emit "$(ev SubagentStop '"agent_id":"sub-2","agent_type":"general-purpose"')"
|
|
check "last subagent finishing frees the session" "waiting" "$(field state)"
|
|
check "count back to zero" "0" "$(field agents)"
|
|
|
|
# A batch that finishes while the main agent is still mid-turn must not free it.
|
|
emit "$(ev UserPromptSubmit '"prompt":"again"')"
|
|
emit "$(ev PreToolUse '"tool_name":"Agent"')"
|
|
emit "$(ev SubagentStop '"agent_id":"sub-3"')"
|
|
check "batch done mid-turn leaves the session working" "busy" "$(field state)"
|
|
|
|
# An idle nudge while a batch runs must not claim the session is free either.
|
|
emit "$(ev PreToolUse '"tool_name":"Agent"')"
|
|
emit "$(ev Stop)"
|
|
emit "$(ev Notification '"notification_type":"idle_prompt","message":"still there?"')"
|
|
check "idle nudge cannot free a running batch" "busy" "$(field state)"
|
|
emit "$(ev SubagentStop '"agent_id":"sub-4"')"
|
|
check "and it frees properly once the batch ends" "waiting" "$(field state)"
|
|
|
|
# --- concurrency -----------------------------------------------------------
|
|
# The hazard: the last PostToolUse of a turn is async and can still be running
|
|
# when the turn's Stop fires. Two mechanisms defend against it and they are
|
|
# tested separately, because the burst below passes on the timestamp guard
|
|
# alone -- it does not prove the lock is doing anything.
|
|
emit "$(ev UserPromptSubmit '"prompt":"go"')"
|
|
for _ in $(seq 30); do
|
|
emit "$(ev PostToolUse '"tool_name":"Bash"')" &
|
|
done
|
|
sleep 0.3 # let the racers start, so Stop's timestamp is genuinely later
|
|
emit "$(ev Stop)"
|
|
wait
|
|
check "Stop survives a burst of concurrent PostToolUse" "waiting" "$(field state)"
|
|
python3 -c "import json;json.load(open('$FILE'))" 2>/dev/null
|
|
check "state file is still valid JSON" "0" "$?"
|
|
|
|
# The lock itself: what the timestamp guard cannot cover is one hook reading the
|
|
# old state, being descheduled while another writes, then writing its own stale
|
|
# decision on top. Holding the lock from outside makes that window observable --
|
|
# a hook must wait for it rather than read-and-write straight through.
|
|
emit "$(ev UserPromptSubmit '"prompt":"go"')"
|
|
python3 -c "
|
|
import fcntl, time
|
|
with open('$FILE.lock', 'w') as fh:
|
|
fcntl.flock(fh, fcntl.LOCK_EX)
|
|
time.sleep(1.5)
|
|
" &
|
|
holder=$!
|
|
sleep 0.4
|
|
emit "$(ev Stop)" &
|
|
sleep 0.5
|
|
check "hook blocks while the lock is held" "busy" "$(field state)"
|
|
wait $holder
|
|
wait
|
|
check "hook proceeds once the lock is released" "waiting" "$(field state)"
|
|
|
|
# --- teardown --------------------------------------------------------------
|
|
emit "$(ev SessionEnd '"reason":"other"')"
|
|
[ -e "$FILE" ]; check "SessionEnd removes the state file" "1" "$?"
|
|
# The lock deliberately stays. Unlinking it while holding it would drop mutual
|
|
# exclusion for any hook already blocked on the old inode; the reader sweeps it
|
|
# once it is orphaned and old.
|
|
[ -e "$FILE.lock" ]; check "SessionEnd keeps the lock inode" "0" "$?"
|
|
|
|
rm -rf "$XDG_STATE_HOME"
|
|
if [ "$failures" -gt 0 ]; then
|
|
echo; echo "$failures failure(s)"; exit 1
|
|
fi
|
|
echo; echo "all passed"
|