diff --git a/bin/backends/tmux.sh b/bin/backends/tmux.sh index de55dbd8af..31c259fa54 100644 --- a/bin/backends/tmux.sh +++ b/bin/backends/tmux.sh @@ -21,6 +21,26 @@ # shellcheck source=bin/fm-tmux-lib.sh . "$FM_BACKEND_LIB_DIR/fm-tmux-lib.sh" +# fm_backend_tmux_resolve_pane: re-exported verbatim from bin/fm-tmux-lib.sh +# under this adapter's naming convention (the same pattern the submit core +# uses), so the adapter and the shared library cannot drift on what counts as +# a resolvable target. It is the gate EVERY read of a caller-supplied target in +# this file goes through; see that function for why `tmux display-message` +# cannot be trusted to refuse one. +fm_backend_tmux_resolve_pane() { # -> prints pane id, or returns 1 + fm_tmux_resolve_pane "$@" +} + +# fm_backend_tmux_target_exists: pane-PRESENCE of , and the one owner +# of that question for this backend - fm_backend_target_exists (bin/fm-backend.sh) +# and fm-crew-state.sh's pane_readable both dispatch here rather than each +# running their own raw probe, which is how two of the three copies kept using +# the unguarded display-message form after the hazard was already documented at +# the third (bin/fm-spawn.sh's worktree-discovery poll). +fm_backend_tmux_target_exists() { # + fm_backend_tmux_resolve_pane "$1" >/dev/null +} + # fm_backend_tmux_resolve_bare_selector: the live-window-listing fallback for a # selector that is neither an explicit target nor a task selector routed # through meta - an ad hoc window name with no recorded task. Mirrors the @@ -38,11 +58,17 @@ fm_backend_tmux_capture() { # tmux capture-pane -p -t "$1" -S -"$2" } -# fm_backend_tmux_send_key: one named key. Mirrors fm-send.sh's --key path: -# `tmux display-message -p -t "$T" '#{pane_id}' >/dev/null`, then -# `tmux send-keys -t "$T" "$2"`. +# fm_backend_tmux_send_key: one named key. Mirrors fm-send.sh's --key path, +# whose preflight used to be `tmux display-message -p -t "$T" '#{pane_id}' +# >/dev/null` - a check that could not fail, because display-message answers +# for another window instead of refusing (fm_tmux_resolve_pane). The preflight +# now actually verifies the target before any key is sent. `tmux send-keys` +# itself was measured to refuse an unresolvable target correctly (rc=1, nothing +# delivered anywhere - docs/tmux-backend.md), so no keystroke was ever +# misdelivered by the old form; it simply reported the failure from send-keys +# rather than from the preflight that claimed to be guarding it. fm_backend_tmux_send_key() { # - tmux display-message -p -t "$1" '#{pane_id}' >/dev/null + fm_backend_tmux_resolve_pane "$1" >/dev/null || return 1 tmux send-keys -t "$1" "$2" } @@ -95,10 +121,19 @@ fm_backend_tmux_create_task() { # -> prints } # fm_backend_tmux_current_path: the live pane's current working directory, or -# empty on any tmux error. Mirrors fm-spawn.sh's worktree-discovery poll: -# `tmux display-message -p -t "$T" '#{pane_current_path}'`. +# empty on any tmux error AND on a target that does not resolve. Mirrors +# fm-spawn.sh's worktree-discovery poll. +# +# The resolve gate is what bin/fm-spawn.sh's own comment at that poll has warned +# about since it was written: an unresolvable target makes display-message read +# firstmate's OWN pane path as the crewmate's worktree and tangle a hook into +# the primary checkout. fm-spawn.sh answered that by passing the stable window +# id, which is a correct fix for that ONE caller and no protection for any +# other. The gate lives here instead, so the hazard is closed for every caller. fm_backend_tmux_current_path() { # - tmux display-message -p -t "$1" '#{pane_current_path}' 2>/dev/null + local pane + pane=$(fm_backend_tmux_resolve_pane "$1") || return 1 + tmux display-message -p -t "$pane" '#{pane_current_path}' 2>/dev/null } # fm_backend_tmux_send_text_line: send one line of TEXT then Enter, with no @@ -133,8 +168,15 @@ fm_backend_tmux_kill() { # # a persisting parent script running `sleep` as a child reports the PARENT's # own name throughout; the value reverts to the shell's own name only once # the foreground command actually exits). Empty on any tmux error. +# +# Gated on fm_backend_tmux_resolve_pane: an unresolvable target returns 1 with +# no output rather than the foreground command of some OTHER pane. Without that +# gate this function - and therefore fm_backend_tmux_agent_alive, its only +# consumer - answered for the supervising pane and never read the target at all. fm_backend_tmux_current_command() { # - tmux display-message -p -t "$1" '#{pane_current_command}' 2>/dev/null + local pane + pane=$(fm_backend_tmux_resolve_pane "$1") || return 1 + tmux display-message -p -t "$pane" '#{pane_current_command}' 2>/dev/null } # fm_backend_tmux_agent_alive: CONFIDENT liveness of a live harness-agent @@ -151,10 +193,21 @@ fm_backend_tmux_current_command() { # # unknown - anything else, INCLUDING a bare "node"/"python" interpreter # name (pi's own launcher execs into a generic "node" process # with no reliable way to attribute it back to pi from outside -# the pane - docs/tmux-backend.md "Known gaps"), or an unreadable -# pane. Callers must never treat unknown as a confirmed-dead -# signal (bin/fm-bootstrap.sh's secondmate-liveness sweep gates a -# respawn on `dead` only). +# the pane - docs/tmux-backend.md "Known gaps"), an unreadable +# pane, or a TARGET THAT DOES NOT RESOLVE. Callers must never treat +# unknown as a confirmed-dead signal (bin/fm-bootstrap.sh's +# secondmate-liveness sweep gates a respawn on `dead` only). +# +# An unresolvable target reports unknown, not dead, and the distinction is the +# point of the fix: `dead` here means "this pane exists and confidently holds no +# agent", which is a reading of the target. Whether the endpoint exists at all +# is fm_backend_target_exists's question, and a gone endpoint routes to the +# recovery path (AGENTS.md section 5), so nothing is lost by refusing to answer +# a liveness question about a pane that is not there. The sweep turns unknown +# into a REPORTED skip; before the gate, the same target silently produced a +# confident alive or dead verdict read off the supervising pane, which is how +# one host would never respawn a dead secondmate while another risked spawning +# a duplicate over a live one. fm_backend_tmux_agent_alive() { # local target=$1 comm comm=$(fm_backend_tmux_current_command "$target") || { printf 'unknown'; return 0; } diff --git a/bin/fm-backend.sh b/bin/fm-backend.sh index 1c18f32ace..60ff0e77ae 100644 --- a/bin/fm-backend.sh +++ b/bin/fm-backend.sh @@ -666,11 +666,38 @@ fm_backend_composer_state() { # -> empty|pending|unknown # Mirrors fm-crew-state.sh's pane_readable check; exists here as one shared # primitive so callers that only need a fast alive/dead read (recovery # digests, the session-start fleet digest) do not re-derive it inline. +# +# Answering-for-the-wrong-target survey (2026-08-05, incident +# fm-liveness-probe-target-fallback): the hazard is tmux-specific, and the other +# four adapters were read for the same pattern rather than assumed clean. +# tmux - WAS affected. `display-message` answers for another window instead +# of refusing, so this branch reported a non-existent window as +# present; it now dispatches through fm_backend_tmux_target_exists, +# which resolves first (docs/tmux-backend.md "Target resolution"). +# herdr - not affected, and the model to copy: fm_backend_herdr_pane_agent_state +# round-trips the echoed pane_id and reports `dead` only on an explicit +# pane_not_found error code, so an unknown id can never be answered +# for by some other pane. +# zellij - not affected: fm_backend_zellij_target_ready parses the target, then +# checks the session and the pane by ENUMERATION, which is the same +# resolve-then-answer shape the tmux gate now uses. +# orca - not affected: `orca terminal read --terminal ` is id-addressed +# and its JSON `ok:false` is handled explicitly. +# cmux - not affected: fm_backend_cmux_target_ready parses the target and +# looks the workspace/surface up by id. +# The common property of the four unaffected backends is that they ask an API +# that errors on an unknown id; tmux was the outlier because its probe command +# substitutes a different target rather than failing. fm_backend_target_exists() { # [expected-label] local backend=$1 target=$2 expected_label=${3:-} session pane case "$backend" in tmux) - tmux display-message -p -t "$target" '#{pane_id}' >/dev/null 2>&1 + # Dispatched, not inlined: the raw `tmux display-message -p -t "$target" + # '#{pane_id}'` this used to run reported a NON-EXISTENT window as present, + # because display-message answers for another window instead of refusing + # and so returns 0 either way (fm_tmux_resolve_pane, bin/fm-tmux-lib.sh). + fm_backend_source tmux || return 1 + fm_backend_tmux_target_exists "$target" ;; herdr) fm_backend_source herdr || return 1 diff --git a/bin/fm-context-reset.sh b/bin/fm-context-reset.sh index 22a8f922cd..dc6af36153 100755 --- a/bin/fm-context-reset.sh +++ b/bin/fm-context-reset.sh @@ -50,6 +50,8 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" . "$SCRIPT_DIR/fm-wake-lib.sh" # shellcheck source=bin/fm-supervisor-target-lib.sh . "$SCRIPT_DIR/fm-supervisor-target-lib.sh" +# shellcheck source=bin/fm-tmux-lib.sh +. "$SCRIPT_DIR/fm-tmux-lib.sh" LOG="$STATE/.context-reset.log" CHECK_ONLY=0 @@ -206,7 +208,14 @@ BACKEND=$(discover_supervisor_backend) \ PANE=$(discover_supervisor_target) \ || refuse "could not identify this session's own terminal pane; a reset must never be typed into a pane it cannot identify" -TARGET=$(tmux display-message -p -t "$PANE" '#{session_name}:#{window_index}.#{pane_index}' 2>/dev/null || true) +# Resolve BEFORE reading, or the refusal below cannot fire: `tmux +# display-message` answers for a different pane instead of refusing an +# unresolvable target (fm_tmux_resolve_pane, bin/fm-tmux-lib.sh), so a $PANE +# that no longer names anything would produce a real-looking TARGET belonging to +# some other window - and this script types a reset into whatever TARGET names. +RESOLVED_PANE=$(fm_tmux_resolve_pane "$PANE") \ + || refuse "this session's own terminal pane '$PANE' does not name a live pane" +TARGET=$(tmux display-message -p -t "$RESOLVED_PANE" '#{session_name}:#{window_index}.#{pane_index}' 2>/dev/null || true) [ -n "$TARGET" ] \ || refuse "this session's own terminal pane '$PANE' could not be resolved" diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index 705934e516..9ec5f536ce 100755 --- a/bin/fm-crew-state.sh +++ b/bin/fm-crew-state.sh @@ -244,7 +244,11 @@ BACKEND_TARGET=$(fm_backend_target_of_meta "$META") EXPECTED_LABEL="fm-$ID" pane_readable() { # case "$TASK_BACKEND" in - tmux) tmux display-message -p -t "$1" '#{pane_id}' >/dev/null 2>&1 ;; + # Through fm_backend_target_exists, not a raw probe of its own: the inline + # `tmux display-message -p -t "$1" '#{pane_id}'` this replaced returned 0 + # for a window that does not exist, because display-message answers for + # another window rather than refusing (fm_tmux_resolve_pane). + tmux) fm_backend_target_exists tmux "$1" ;; *) fm_backend_capture "$TASK_BACKEND" "$1" 1 "$EXPECTED_LABEL" >/dev/null 2>&1 ;; esac } diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 924645a37b..06f32c53c3 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -1079,10 +1079,13 @@ if [ "$KIND" != secondmate ] && [ "$BACKEND" != orca ]; then spawn_send_text_line "$WT_TARGET" 'treehouse get' # Wait for the treehouse subshell: the pane's cwd moves from the project to the worktree. - # Target the stable window id, not the name: if the name is ever lost (e.g. an - # automatic-rename slips through), display-message -t falls back to the - # active client's window, which would misread firstmate's OWN pane path as the - # worktree and tangle a hook into the primary checkout. The window id never lies. + # Target the stable window id, not the name: an automatic rename can lose the + # name, and the window id never lies. The fallback hazard that first motivated + # this - a lost name making the path read answer for firstmate's OWN pane and + # tangle a hook into the primary checkout - is now closed for every caller by + # fm_tmux_resolve_pane (bin/fm-tmux-lib.sh), the gate every tmux read of a + # caller-supplied target passes; docs/tmux-backend.md "Target resolution" + # owns that fact. # Compare against PROJ_ABS_REAL (physical), not PROJ_ABS: a symlinked project # prefix would otherwise make the pane's OS-level cwd read differ from # PROJ_ABS on the very first poll, before the pane has actually moved. diff --git a/bin/fm-supervise-daemon.sh b/bin/fm-supervise-daemon.sh index 10c13219ce..2d0557895a 100755 --- a/bin/fm-supervise-daemon.sh +++ b/bin/fm-supervise-daemon.sh @@ -1377,9 +1377,10 @@ fm_super_main() { # --- validate supervisor target at startup (a missing target is a typo) --- # Dispatches through bin/fm-backend.sh instead of a raw `tmux display-message` - # probe, so a herdr supervisor pane is checked via the herdr adapter; for - # backend=tmux this runs the exact same `tmux display-message -p -t "$TARGET" - # '#{pane_id}'` call as before. + # probe, so a herdr supervisor pane is checked via the herdr adapter, and the + # tmux arm resolves the target before answering rather than accepting + # display-message's answer for some other window (docs/tmux-backend.md + # "Target resolution"). if ! fm_backend_target_exists "$BACKEND" "$TARGET"; then echo "error: supervisor target '$TARGET' does not resolve to a $BACKEND pane; set FM_SUPERVISOR_TARGET" >&2 log "startup failed: target '$TARGET' not found (backend=$BACKEND)" diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index becb6d2ab5..57c27c9d14 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -128,7 +128,8 @@ family_for_basename() { fm-operational-input.test.sh|fm-pi-primary-types.test.sh|\ fm-send-popup-settle.test.sh|fm-send-settle.test.sh|fm-stow-contract.test.sh|\ fm-subagent-pretool-check.test.sh|\ - fm-supervision-instructions.test.sh|fm-tmux-submit-busy.test.sh|fm-transition-lib.test.sh|\ + fm-supervision-instructions.test.sh|fm-tmux-submit-busy.test.sh|\ + fm-tmux-target-resolve.test.sh|fm-transition-lib.test.sh|\ fm-test-run.test.sh|fm-test-isolation-proof.test.sh) printf '%s\n' pure-contract-unit ;; diff --git a/bin/fm-tmux-lib.sh b/bin/fm-tmux-lib.sh index 2c55dd75aa..66bad12fd7 100755 --- a/bin/fm-tmux-lib.sh +++ b/bin/fm-tmux-lib.sh @@ -65,6 +65,79 @@ # (grok's mid-turn cancel hint, shown iff a turn is running - verified grok 0.2.73). FM_TMUX_BUSY_REGEX_DEFAULT='esc (to )?interrupt|Working\.\.\.|Ctrl\+c:cancel' +# fm_tmux_resolve_pane: the ONE sanctioned gate every read of a caller-supplied +# tmux target must pass. Prints the pane id actually names and returns +# 0; prints nothing and returns 1 when the target does not resolve. +# +# Why this exists (incident fm-liveness-probe-target-fallback, reported by +# Tugboat 2026-07-28 and reproduced 2026-08-05): `tmux display-message -p -t +# ` does NOT refuse an unresolvable target. It answers for a DIFFERENT +# window - the target session's current window, or the active client's - or +# expands the format to empty, and it returns 0 in every one of those cases. +# Verified on tmux 3.4 (docs/tmux-backend.md "Target resolution: display-message +# answers for the wrong window"): every invented shape - `sess:no-such-window`, +# `sess:@9999`, `sess:win.99`, `%9999`, `@9999`, a bare unknown name - returned +# rc=0, and three of them returned a real pane id belonging to someone else. +# So a probe built on display-message's exit code never touches its target at +# all: it describes whatever the supervising pane happens to be running, which +# is why the same invented name read ALIVE on a host whose fallback pane ran +# claude and DEAD on a host whose fallback pane ran bash. +# +# `tmux list-panes -t ` is the correct primitive for the REFUSAL and +# needs no shape-parsing of our own: it refuses (rc=1, "can't find +# window/pane/session") for every one of those invented shapes and succeeds for +# every real one - window names, window ids (@N), pane ids (%N), and +# session:window.pane. Callers read the RESOLVED pane id rather than the original +# target, so the read is exact rather than subject to tmux's own prefix matching, +# and a pane that disappears between the resolve and the read degrades to an +# empty format expansion, which every caller already treats as unreadable. +# +# list-panes alone cannot say WHICH pane the target named, because it takes a +# target-WINDOW: it lists every pane of the containing window. Measured on tmux +# 3.4 in a two-pane window whose active pane is %1, both `list-panes -t %0` and +# `list-panes -t sess:win.0` print `%0 0` and `%1 1`, so picking the active row +# answers %1 for a target that named %0. The IDENTITY therefore comes from +# `display-message -p -t '#{pane_id}'`, which is exact for every shape +# (%0 for %0, the named pane for sess:win.N, the window's active pane for a +# window-qualified target - the correct answer in each case). That read is only +# ever reached AFTER list-panes has proven the target resolves, so it can never +# fall back to another window, and its exit status is never trusted: the id it +# prints must appear in the listing or this refuses. That membership check is +# also what catches a pane that dies between the two commands, which is the one +# window in which display-message could still fall back. +# +# Deliberately NOT a presence verdict on its own: it answers "does this target +# name something", which is what the liveness probes in bin/backends/tmux.sh +# and bin/fm-backend.sh build their presence and agent-alive verdicts on. +# +# Deliberately strict about a BARE window name, and it is the caller's job not to +# pass one. tmux resolves a bare target only within its own current session +# (measured: a window named fm-target living in a non-current session is "can't +# find window" to both list-panes and display-message), so this refuses it. A +# cross-session bare lookup is firstmate's SELECTOR semantic, owned upstream by +# fm_backend_tmux_resolve_bare_selector, which turns a bare name into +# session:window BEFORE any probe runs - and it belongs upstream, because +# guessing which session a bare name meant is the very thing this gate exists to +# stop a probe from doing. Every production caller already passes the +# session-qualified target recorded in state/.meta's window=. +fm_tmux_resolve_pane() { # -> prints pane id, or returns 1 + local target=${1:-} listing named id + [ -n "$target" ] || return 1 + listing=$(tmux list-panes -t "$target" -F '#{pane_id}' 2>/dev/null) || return 1 + [ -n "$listing" ] || return 1 + named=$(tmux display-message -p -t "$target" '#{pane_id}' 2>/dev/null) || return 1 + [ -n "$named" ] || return 1 + while read -r id _; do + if [ "$id" = "$named" ]; then + printf '%s\n' "$named" + return 0 + fi + done < │` # (claude's own idle composer) read empty while a bare, unbordered `$ ` dead-shell # prompt reads unknown. +# The cursor-row read is gated on fm_tmux_resolve_pane for the same reason the +# liveness probes are: display-message would otherwise report ANOTHER pane's +# cursor row for an unresolvable target. That never produced a wrong verdict +# here - the capture-pane on the same target refuses correctly, so the state +# already collapsed to unknown - but the gate makes the refusal come from the +# target check rather than from a second command happening to be stricter. fm_tmux_composer_state() { # -> empty|pending|unknown - local target=$1 cy raw plain stripped bordered=0 - cy=$(tmux display-message -p -t "$target" '#{cursor_y}' 2>/dev/null) || { printf 'unknown'; return 0; } + local target=$1 pane cy raw plain stripped bordered=0 + pane=$(fm_tmux_resolve_pane "$target") || { printf 'unknown'; return 0; } + cy=$(tmux display-message -p -t "$pane" '#{cursor_y}' 2>/dev/null) || { printf 'unknown'; return 0; } case "$cy" in ''|*[!0-9]*) printf 'unknown'; return 0 ;; esac - raw=$(tmux capture-pane -e -p -t "$target" -S "$cy" -E "$cy" 2>/dev/null) || { printf 'unknown'; return 0; } + raw=$(tmux capture-pane -e -p -t "$pane" -S "$cy" -E "$cy" 2>/dev/null) || { printf 'unknown'; return 0; } # bordered: from the plain row (borders survive an all-ANSI strip). plain=$(printf '%s\n' "$raw" | fm_composer_strip_ansi) plain="${plain#"${plain%%[![:space:]]*}"}" diff --git a/docs/scripts.md b/docs/scripts.md index 3e7e63a994..c60e176f4b 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -122,7 +122,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-wake-lib.sh` | Shared durable wake queue, portable locks, and watcher/away-daemon identity/health helpers | | `fm-classify-lib.sh` | Shared captain-relevant and declared-external-wait wake classification vocabulary | | `fm-send.sh` | Send one verified literal line or supported key through the target's recorded backend | -| `fm-tmux-lib.sh` | Shared tmux pane primitives for own-window startup repair, busy detection, composer capture, and verified submit | +| `fm-tmux-lib.sh` | Shared tmux pane primitives for target resolution, own-window startup repair, busy detection, composer capture, and verified submit | | `fm-peek.sh` | Print a bounded tail of a crewmate endpoint | | `fm-check-register.sh` | Bind an intentional custom watcher check to its current bytes | | `fm-check-lib.sh` | Validate custom-check registrations and prepare private execution snapshots | diff --git a/docs/tmux-backend.md b/docs/tmux-backend.md index 2eb19afd2b..45d62417c8 100644 --- a/docs/tmux-backend.md +++ b/docs/tmux-backend.md @@ -75,6 +75,108 @@ tmux list-windows -t Use the current tmux session name for the run-inside-tmux path, or `firstmate` for the detached outside-tmux path. You should see a `fm-` window for the task, live and updating as the crewmate works. +## Target resolution: `display-message` answers for the wrong window + +`tmux display-message -p -t ` does not refuse a target that does not resolve. +It answers for a different window and still returns 0, so any check built on its exit status passes for a window that does not exist. +Reported by Tugboat 2026-07-28 and reproduced here 2026-08-05 on tmux 3.4 (Linux 6.8.0), with three invented window names against a live session: + +```sh +$ for t in firstmate:zzz-nope firstmate:also-not-real coditan:zzz-nope; do + tmux display-message -p -t "$t" '#{pane_id} #{session_name}:#{window_name} #{pane_current_command}'; done +%89 firstmate:bash bash +%89 firstmate:bash bash +%0 coditan:claude claude +``` + +Every invented shape behaves this way, and the exit status never signals it: + +```sh +$ for t in firstmate:zzz-nope 'firstmate:@9999' 'coditan:claude.99' '%9999' '@9999' nosuchsession:win zzz-bare-nope; do + out=$(tmux display-message -p -t "$t" '#{pane_id}' 2>&1); printf '%-22s rc=%s -> %s\n' "$t" "$?" "$out"; done +firstmate:zzz-nope rc=0 -> %89 +firstmate:@9999 rc=0 -> %89 +coditan:claude.99 rc=0 -> %0 +%9999 rc=0 -> +@9999 rc=0 -> +nosuchsession:win rc=0 -> +zzz-bare-nope rc=0 -> +``` + +The fallback is the target session's current window, or the active client's, and it falls back to an empty format expansion only when the session itself cannot be resolved. + +**Why the symptom differs by host, and why that made it worse.** +On Tugboat's host the fallback window was running `claude`, so an invented name returned `alive`. +On this host it was running `bash`, so the same name returned `dead`. +Neither is a reading of the target. +A fleet-wide defect whose symptom depends on what the operator's own pane happens to be doing gets diagnosed as a local environment quirk on whichever host reports it second. + +**`display-message` is the only offender among the commands this backend uses.** +Measured the same day, on the same server: + +```sh +$ tmux capture-pane -p -t firstmate:zzz-nope -S -1 >/dev/null 2>&1; echo $? +1 +$ tmux send-keys -t "$S:zzz-nope-window" 'echo MISDELIVERED_KEYSTROKE' Enter; echo $? +can't find window: zzz-nope-window +1 +$ tmux list-panes -t firstmate:zzz-nope >/dev/null 2>&1; echo $? +1 +$ tmux list-panes -t firstmate:bash -F '#{pane_id} #{pane_active}' +%89 1 +``` + +`capture-pane` and `send-keys` refuse correctly, so no pane content was ever misread and no keystroke was ever misdelivered. +`list-panes` refuses every invented shape above and resolves every real one - window names, window ids (`@N`), pane ids (`%N`), and `session:window.pane` - which makes it the correct primitive and needs no target-shape parsing of firstmate's own. + +**`list-panes` refuses correctly but cannot say WHICH pane the target named.** +It takes a target-*window*, so it lists every pane of the containing window whatever pane the target named. +Measured on tmux 3.4 in a two-pane window whose active pane is `%1`: + +```sh +$ tmux list-panes -t probe:win -F '#{pane_id} active=#{pane_active} idx=#{pane_index}' +%0 active=0 idx=0 +%1 active=1 idx=1 +$ tmux list-panes -t %0 -F '#{pane_id} #{pane_active}' # an INACTIVE pane id +%0 0 +%1 1 +$ tmux list-panes -t probe:win.0 -F '#{pane_id} #{pane_active}' +%0 0 +%1 1 +``` + +So picking the active row out of that listing answers `%1` for a target that named `%0`. +`display-message` is exact for the identity once the target is known to resolve, on the same server: + +```sh +$ tmux display-message -p -t %0 '#{pane_id}' # a pane id +%0 +$ tmux display-message -p -t probe:win.0 '#{pane_id}' # pane-qualified +%0 +$ tmux display-message -p -t probe:win '#{pane_id}' # window-qualified +%1 +``` + +The window-qualified answer is the window's active pane, which is the correct answer for a target that names no pane of its own. + +**The gate.** +`fm_tmux_resolve_pane` (`bin/fm-tmux-lib.sh`) is the one sanctioned way to turn a caller-supplied target into something readable: it prints the pane id the target names, or refuses. +It uses each command for the one thing that command does correctly. +`list-panes -t ` is the REFUSAL: nothing else runs until it has succeeded, so the identity read can never be reached by a target that did not resolve. +`display-message -p -t '#{pane_id}'` is the IDENTITY, and its exit status is never consulted - the pane id it prints must appear in the listing that just gated the target, or the gate refuses. +That membership check is also what catches the one remaining window in which `display-message` could still fall back: a pane that dies between the two commands. +Callers then read the resolved pane id rather than the original target, so the read is exact rather than subject to tmux's own prefix matching, and a pane that disappears between the resolve and the read degrades to an empty format expansion that every caller already treats as unreadable. +`tests/fm-tmux-target-resolve.test.sh` enforces that no `bin/` script reads `display-message -p -t` against an unresolved caller-supplied target, and self-checks that the rule still detects a known offender - both the `-t "$target"` spelling and a caller-supplied target merely *named* `$pane`, since an exemption keyed on spelling would prove a naming convention rather than that resolution happened. +The gate's own body is exempt from that rule and is measured behaviorally instead: it must return the pane a pane-qualified target names, and must refuse an identity absent from the listing. +`tests/fm-backend-tmux-smoke.test.sh` runs the same two-pane control against a real tmux, in a genuine split window with a non-target pane left active, because in a single-pane window "every pane of the window" and "the pane the target names" are indistinguishable - which is why the first round of verification missed this. +The hazard had been documented in a comment beside `bin/fm-spawn.sh`'s worktree poll since that poll was written, while `fm_backend_target_exists`, `fm_backend_tmux_current_command`, and `fm-crew-state.sh`'s `pane_readable` kept using the unguarded form. +A comment next to one caller is not enforcement, which is why the rule is now a test. + +**Adjacent sites not changed, recorded rather than silently permitted.** +`fm_backend_tmux_container_ensure` (`bin/backends/tmux.sh`) reads `#{session_name}` with a bare `display-message -p` and no `-t`, which resolves against the caller's own current window rather than an arbitrary target. +`bin/fm-supervise-daemon.sh`'s away-mode status-line flash calls `display-message` without `-p` to display a message rather than to produce a verdict. +Neither was measured to answer wrongly during this work, so neither was changed on assumption. + ## Agent liveness probe `fm_backend_target_exists` (`bin/fm-backend.sh`) only checks that a window's pane still exists. @@ -84,6 +186,10 @@ A secondmate agent that exits leaves its pane alive as a bare idle shell, which It reads tmux's own `#{pane_current_command}`, which reports the pane's live foreground process name - already resolved by tmux from the pty's controlling process group, not something this adapter derives itself. The same probe is also used by the codex-only stale-path backstop in `bin/fm-watch.sh`, because codex-cli 0.145.0 can drop its rendered busy row while the agent process is still alive. +Both probes read through the resolve gate above, so a target that does not resolve is refused rather than answered for. +A target that does not resolve reports `unknown`, never `dead`: `dead` means "this pane exists and confidently holds no agent", which is a reading of the target, while whether the endpoint exists at all is `fm_backend_target_exists`'s question and a gone endpoint routes to the recovery path instead. +The secondmate-liveness sweep turns `unknown` into a reported skip and gates a respawn on `dead` only, so under the defect that gate could be satisfied or starved for reasons unrelated to the target: a dead secondmate never respawned on one host, or a live one at risk of duplication on another. + Agent liveness and composer safety are separate checks. During away-mode escalation delivery, `fm_tmux_composer_state` sends a bare shell glyph on an unbordered row to the shared composer classifier as `unknown`, and the daemon injects only into an affirmatively `empty` composer; see [Composer-emptiness safety](herdr-backend.md#composer-emptiness-safety-2026-07-10-fleet-wide-across-all-four-backends). diff --git a/tests/fm-backend-tmux-smoke.test.sh b/tests/fm-backend-tmux-smoke.test.sh index 5e98458f74..60894759f8 100755 --- a/tests/fm-backend-tmux-smoke.test.sh +++ b/tests/fm-backend-tmux-smoke.test.sh @@ -30,10 +30,12 @@ command -v tmux >/dev/null 2>&1 || { echo "skip: tmux not found"; exit 0; } REAL_TMUX=$(command -v tmux) SOCKET="fm-backend-smoke-$$" SHIM_DIR= +AGENT_BIN_DIR= trap cleanup_all EXIT cleanup_all() { "$REAL_TMUX" -L "$SOCKET" kill-server >/dev/null 2>&1 || true + [ -n "${AGENT_BIN_DIR:-}" ] && rm -rf "$AGENT_BIN_DIR" [ -n "${SHIM_DIR:-}" ] && rm -rf "$SHIM_DIR" } @@ -156,6 +158,155 @@ if fm_backend_tmux_resolve_bare_selector "no-such-window-xyz" 2>/dev/null; then fi pass "real tmux: fm_backend_tmux_resolve_bare_selector fails for a window that does not exist" +# --- target resolution: the probes must not answer for another window --------- +# +# Incident fm-liveness-probe-target-fallback. `tmux display-message -p -t +# ` never refuses: for an unresolvable target it answers for the +# session's CURRENT window and still returns 0. Both liveness probes were built +# on that, so their verdict described the supervising pane instead of the target. +# +# The verdict therefore differed by host, which is what made it dangerous: on a +# host whose fallback window ran claude an invented name read ALIVE (Tugboat's +# report), and on a host whose fallback window ran bash the SAME name read DEAD +# (reproduced locally). A fix verified against only the bash shape proves +# nothing, because that shape already returned "dead" for entirely the wrong +# reason. Both shapes are therefore driven here, by changing which window is +# current and asserting the verdict does not move. +# +# The agent-running fallback is produced, not simulated away: a real binary +# copied to the name `claude` runs in the pane, so tmux's own +# #{pane_current_command} reports `claude` exactly as a real agent pane does. +# That is the only stand-in - the fallback itself is genuine tmux behavior. + +AGENT_BIN_DIR=$(mktemp -d "${TMPDIR:-/tmp}/fm-backend-smoke-agent.XXXXXX") +cp "$(command -v sleep)" "$AGENT_BIN_DIR/claude" \ + || fail "could not stage an agent-named binary for the fallback control" +cleanup_agent_bin() { rm -rf "$AGENT_BIN_DIR"; } + +tmux new-window -d -t "$SESSION" -n "agent-fallback" "$AGENT_BIN_DIR/claude 300" \ + || fail "could not create the agent-running fallback window" +# A bare shell, spawned explicitly rather than inheriting the host's login shell: +# `tmux new-window` with no command starts $SHELL, so a zsh or fish host would +# drive a different shape than a bash one, and this control must be the same +# experiment everywhere. What the pane actually reports is still READ rather than +# assumed below - hardcoding a shell name is exactly what made this half fragile. +tmux new-window -d -t "$SESSION" -n "shell-fallback" "$(command -v sh)" \ + || fail "could not create the bare-shell fallback window" +sleep 0.3 +SHELL_FALLBACK_CMD=$(tmux display-message -p -t "$SESSION:shell-fallback" '#{pane_current_command}') +[ -n "$SHELL_FALLBACK_CMD" ] \ + || fail "could not read the bare-shell fallback window's own pane_current_command" + +INVENTED="$SESSION:zzz-no-such-window" + +assert_probes_refuse_invented_target() { # + local fallback_window=$1 expected_cmd=$2 raw verdict + tmux select-window -t "$SESSION:$fallback_window" \ + || fail "could not make $fallback_window the session's current window" + sleep 0.3 + + # Precondition: this tmux really does exhibit the fallback, and it really is + # answering with the fallback window's command. If this ever stops holding the + # control below would pass vacuously, so it is asserted, not assumed. + raw=$(tmux display-message -p -t "$INVENTED" '#{pane_current_command}' 2>/dev/null || true) + [ "$raw" = "$expected_cmd" ] || fail \ + "precondition: raw display-message on an invented window should have answered '$expected_cmd' (the $fallback_window pane), got '$raw'" + + verdict=$(fm_backend_agent_alive tmux "$INVENTED") + [ "$verdict" != alive ] || fail \ + "fm_backend_agent_alive returned ALIVE for an invented window while the current window ran '$expected_cmd'" + [ "$verdict" = unknown ] || fail \ + "fm_backend_agent_alive must report unknown for an invented window, got '$verdict' (fallback pane ran '$expected_cmd')" + + if fm_backend_target_exists tmux "$INVENTED"; then + fail "fm_backend_target_exists reported an invented window as present (fallback pane ran '$expected_cmd')" + fi + + [ -z "$(fm_backend_tmux_current_path "$INVENTED" 2>/dev/null)" ] || fail \ + "fm_backend_tmux_current_path returned the fallback pane's cwd for an invented window" +} + +# The exact case that produced the false ALIVE. +assert_probes_refuse_invented_target agent-fallback claude +pass "real tmux: an invented window reads not-alive even when the current window is running an agent" + +# The shape this host reproduces naturally, which alone would have proved nothing. +assert_probes_refuse_invented_target shell-fallback "$SHELL_FALLBACK_CMD" +pass "real tmux: an invented window reads not-alive when the current window is a bare shell, and for the right reason" + +# The probes must still read their own target correctly, or "never alive" would +# be a trivially safe answer rather than a correct one. +tmux select-window -t "$SESSION:shell-fallback" +[ "$(fm_backend_agent_alive tmux "$SESSION:agent-fallback")" = alive ] \ + || fail "fm_backend_agent_alive must still report a real agent pane as alive" +[ "$(fm_backend_agent_alive tmux "$SESSION:shell-fallback")" = dead ] \ + || fail "fm_backend_agent_alive must still report a real bare-shell pane as dead" +fm_backend_target_exists tmux "$SESSION:agent-fallback" \ + || fail "fm_backend_target_exists must still report a live window as present" +pass "real tmux: the probes still read a real target's own agent/shell state after the gate" + +tmux kill-window -t "$SESSION:agent-fallback" 2>/dev/null || true +tmux kill-window -t "$SESSION:shell-fallback" 2>/dev/null || true +cleanup_agent_bin + +# --- target resolution, pane scope: the gate must name the pane it was asked --- +# +# `tmux list-panes` takes a target-WINDOW, so it lists EVERY pane of the +# containing window whatever pane the target named. Measured here, on this +# server, in a real two-pane window. A gate that picked the ACTIVE row from that +# listing therefore answered for a neighbouring pane - which reaches +# bin/fm-context-reset.sh, whose whole premise is that a reset is never typed +# into a pane it cannot identify, and the away-mode composer-emptiness check. +# +# This needs a genuine SPLIT window: in a single-pane window "every pane of the +# window" and "the pane the target names" are indistinguishable, which is exactly +# why the original verification missed it. + +tmux new-window -d -t "$SESSION" -n "split-probe" -c "$HOME" \ + || fail "could not create the split-probe window" +tmux split-window -d -t "$SESSION:split-probe" -c "$HOME" \ + || fail "could not split the split-probe window" + +# Leave a pane OTHER than the one under test active, or the two answers coincide. +tmux select-pane -t "$SESSION:split-probe.1" \ + || fail "could not make pane 1 the active pane of the split-probe window" + +SPLIT_PANES=$(tmux list-panes -t "$SESSION:split-probe" -F '#{pane_id} #{pane_active}') +SPLIT_P0=$(printf '%s\n' "$SPLIT_PANES" | awk 'NR==1 {print $1}') +SPLIT_P1=$(printf '%s\n' "$SPLIT_PANES" | awk 'NR==2 {print $1}') +[ -n "$SPLIT_P0" ] && [ -n "$SPLIT_P1" ] && [ "$SPLIT_P0" != "$SPLIT_P1" ] \ + || fail "the split-probe window did not end up with two distinct panes:"$'\n'"$SPLIT_PANES" +[ "$(printf '%s\n' "$SPLIT_PANES" | awk 'NR==2 {print $2}')" = 1 ] \ + || fail "pane 1 of the split-probe window is not the active one, so this control proves nothing:"$'\n'"$SPLIT_PANES" + +# The precondition the gate has to work around, asserted rather than assumed: +# list-panes really does list the whole window for a pane-qualified target. +[ "$(tmux list-panes -t "$SPLIT_P0" -F '#{pane_id}' | wc -l | tr -d ' ')" = 2 ] \ + || fail "precondition: tmux list-panes -t $SPLIT_P0 should list both panes of the containing window" + +[ "$(fm_backend_tmux_resolve_pane "$SPLIT_P0")" = "$SPLIT_P0" ] \ + || fail "fm_backend_tmux_resolve_pane resolved the pane id $SPLIT_P0 to '$(fm_backend_tmux_resolve_pane "$SPLIT_P0")', not to itself" +[ "$(fm_backend_tmux_resolve_pane "$SESSION:split-probe.0")" = "$SPLIT_P0" ] \ + || fail "fm_backend_tmux_resolve_pane resolved $SESSION:split-probe.0 to the window's ACTIVE pane instead of the pane the target names" +[ "$(fm_backend_tmux_resolve_pane "$SPLIT_P1")" = "$SPLIT_P1" ] \ + || fail "fm_backend_tmux_resolve_pane did not resolve the pane id $SPLIT_P1 to itself" +# A window-qualified target names no pane of its own, so its ACTIVE pane is the +# correct answer - the property the old form got right and must keep. +[ "$(fm_backend_tmux_resolve_pane "$SESSION:split-probe")" = "$SPLIT_P1" ] \ + || fail "fm_backend_tmux_resolve_pane must resolve a window-qualified target to that window's active pane" +pass "real tmux: fm_backend_tmux_resolve_pane names the pane the target names, in a genuine two-pane window" + +# The costly path: bin/fm-context-reset.sh resolves $TMUX_PANE and then types a +# reset into whatever the result names, so the round trip back to a +# session:window.pane target must land on the SAME pane it started from. +SPLIT_TARGET=$(tmux display-message -p -t "$(fm_backend_tmux_resolve_pane "$SPLIT_P0")" \ + '#{session_name}:#{window_index}.#{pane_index}') +[ "$(tmux display-message -p -t "$SPLIT_TARGET" '#{pane_id}')" = "$SPLIT_P0" ] \ + || fail "the resolve-then-address round trip fm-context-reset.sh performs landed on a pane other than $SPLIT_P0 (got target '$SPLIT_TARGET')" +pass "real tmux: fm-context-reset.sh's resolve-then-address round trip stays on the caller's own pane" + +tmux kill-window -t "$SESSION:split-probe" 2>/dev/null || true + # --- fm_tmux_ensure_own_window (firstmate must not sit in a crew window) ------ # The helper reads the CALLER's own window (no -t), so drive it from INSIDE a # window via send-keys and assert the rename, exactly the resume path it guards. diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index f86dcc2b0b..6d1553e529 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -616,9 +616,10 @@ make_send_fakebin() { # -> echoes fakebin dir; logs every tmux call to $F set -u { printf 'tmux'; for a in "$@"; do printf '\x1f%s' "$a"; done; printf '\n'; } >> "${FM_TMUX_LOG:?}" case "${1:-}" in + list-panes) printf '%%1 1\n'; exit 0 ;; send-keys) exit 0 ;; display-message) - for a in "$@"; do case "$a" in *cursor_y*) printf '0\n'; exit 0 ;; esac; done + for a in "$@"; do case "$a" in *pane_id*) printf '%%1\n'; exit 0 ;; *cursor_y*) printf '0\n'; exit 0 ;; esac; done printf 'fakepane\n'; exit 0 ;; capture-pane) printf '\xe2\x94\x82 \xe2\x94\x82\n'; exit 0 ;; list-windows) exit 0 ;; @@ -638,14 +639,26 @@ run_send_case() { # -- "$bin/bin/fm-send.sh" "$@" >/dev/null 2>&1 } +# The send preflight is the ONE tmux call that legitimately differs old vs new, +# so it is stripped from both logs before the conformance diff. It changed shape +# with incident fm-liveness-probe-target-fallback: the old form asked +# `display-message` whether the target existed, which cannot fail (it answers for +# another window and returns 0 either way - docs/tmux-backend.md "Target +# resolution"), and the new form gates on `list-panes`, which refuses, before +# reading the pane id. The new form still ends in the same display-message read +# the old one used - what changed is that it can no longer be reached by a target +# that did not resolve - so both spellings are stripped and the diff still +# compares the calls that carry the actual send behavior. strip_send_preflight() { # - local preflight - preflight=$'tmux\x1fdisplay-message\x1f-p\x1f-t\x1fsess:win\x1f#{pane_id}' - awk -v preflight="$preflight" '$0 != preflight { print }' "$1" + local dm_read lp_gate + dm_read=$'tmux\x1fdisplay-message\x1f-p\x1f-t\x1fsess:win\x1f#{pane_id}' + lp_gate=$'tmux\x1flist-panes\x1f-t\x1fsess:win\x1f-F\x1f#{pane_id}' + awk -v dm="$dm_read" -v lp="$lp_gate" \ + '$0 != dm && $0 != lp { print }' "$1" } test_send_conformance_old_vs_new() { - local old_bin fb log_old log_new home rc_old rc_new filtered_old filtered_new + local old_bin fb log_old log_new home rc_old rc_new filtered_old filtered_new first_probe old_bin=$(build_old_bin send-old) fb=$(make_send_fakebin "$TMP_ROOT/send-fake") home="$TMP_ROOT/send-home"; mkdir -p "$home/state" @@ -658,8 +671,20 @@ test_send_conformance_old_vs_new() { run_send_case "$ROOT" "$fb" "$log_new" "$home" -- "sess:win" --key Escape rc_new=$? expect_code "$rc_old" "$rc_new" "fm-send --key: old vs new exit code" - assert_contains "$(cat "$log_new")" $'\x1f''display-message'$'\x1f''-p'$'\x1f''-t'$'\x1f''sess:win'$'\x1f''#{pane_id}' \ - "fm-send --key did not verify the explicit tmux target before sending" + # The preflight must RESOLVE the target, not merely ask display-message about + # it: display-message answers for another window rather than refusing, so the + # old form verified nothing at all. It may still READ the pane id afterwards - + # that read is what makes the resolved id exact - but list-panes must come + # first, because it is the only one of the two that can refuse. + assert_contains "$(cat "$log_new")" $'\x1f''list-panes'$'\x1f''-t'$'\x1f''sess:win' \ + "fm-send --key did not resolve the explicit tmux target before sending" + first_probe=$(grep -n -e $'\x1f''list-panes'$'\x1f''-t'$'\x1f''sess:win' \ + -e $'\x1f''display-message'$'\x1f''-p'$'\x1f''-t'$'\x1f''sess:win' \ + "$log_new" | head -1) + case "$first_probe" in + *$'\x1f'list-panes$'\x1f'*) : ;; + *) fail "fm-send --key read display-message before list-panes had gated the target: $first_probe" ;; + esac strip_send_preflight "$log_old" > "$filtered_old" strip_send_preflight "$log_new" > "$filtered_new" diff -u "$filtered_old" "$filtered_new" > "$TMP_ROOT/send-diff-key.txt" 2>&1 \ @@ -754,8 +779,9 @@ make_spawn_fakebin() { # -> echoes fakebin dir set -u { printf 'tmux'; for a in "\$@"; do printf '\\x1f%s' "\$a"; done; printf '\\n'; } >> "\${FM_TMUX_LOG:?}" case "\${1:-}" in + list-panes) printf '%%1 1\n'; exit 0 ;; display-message) - for a in "\$@"; do case "\$a" in *pane_current_path*) printf '%s\\n' "$wt"; exit 0 ;; esac; done + for a in "\$@"; do case "\$a" in *pane_id*) printf '%%1\\n'; exit 0 ;; *pane_current_path*) printf '%s\\n' "$wt"; exit 0 ;; esac; done printf 'firstmate\\n'; exit 0 ;; list-windows) exit 0 ;; esac @@ -816,8 +842,9 @@ make_spawn_symlink_fakebin() { # - set -u { printf 'tmux'; for a in "\$@"; do printf '\\x1f%s' "\$a"; done; printf '\\n'; } >> "\${FM_TMUX_LOG:?}" case "\${1:-}" in + list-panes) printf '%%1 1\n'; exit 0 ;; display-message) - for a in "\$@"; do case "\$a" in *pane_current_path*) + for a in "\$@"; do case "\$a" in *pane_id*) printf '%%1\\n'; exit 0 ;; *pane_current_path*) printf x >> "$counter" if [ "\$(wc -c < "$counter")" -le 1 ]; then printf '%s\\n' "$initial_path" diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index ad990e8d61..1884495fd3 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -29,6 +29,7 @@ SH cat > "$fb/tmux" <<'SH' #!/usr/bin/env bash case "${1:-}" in + list-panes) case "$*" in *dead-*) exit 1 ;; *) printf '%%1 1\n' ;; esac ;; display-message) case "$*" in *dead-*) exit 1 ;; *) printf '%%1\n' ;; esac ;; capture-pane) case "$*" in diff --git a/tests/fm-bootstrap.test.sh b/tests/fm-bootstrap.test.sh index afb81fdac0..c72a87f568 100755 --- a/tests/fm-bootstrap.test.sh +++ b/tests/fm-bootstrap.test.sh @@ -677,7 +677,14 @@ make_routine_bootstrap_fixture() { add_real_jq "$fakebin" cat > "$fakebin/tmux" <<'SH' #!/usr/bin/env bash +# The liveness probe resolves the target to a pane before reading it, so this +# secondmate endpoint has to resolve or the sweep reports it inconclusive. +if [ "${1:-}" = list-panes ]; then + printf '%s\n' '%1 1' + exit 0 +fi if [ "${1:-}" = display-message ]; then + case "$*" in *pane_id*) printf '%s\n' '%1'; exit 0 ;; esac printf '%s\n' codex exit 0 fi diff --git a/tests/fm-composer-ghost.test.sh b/tests/fm-composer-ghost.test.sh index ffaa7e5c2d..9cf966d693 100755 --- a/tests/fm-composer-ghost.test.sh +++ b/tests/fm-composer-ghost.test.sh @@ -43,8 +43,9 @@ make_fake_tmux() { # #!/usr/bin/env bash set -u case "${1:-}" in + list-panes) printf '%%1 1\n'; exit 0 ;; display-message) - for a in "$@"; do case "$a" in *cursor_y*) printf '%s\n' "${FM_FAKE_CY:-0}"; exit 0 ;; esac; done + for a in "$@"; do case "$a" in *pane_id*) printf '%%1\n'; exit 0 ;; *cursor_y*) printf '%s\n' "${FM_FAKE_CY:-0}"; exit 0 ;; esac; done printf 'fakepane\n'; exit 0 ;; capture-pane) has_e=0 diff --git a/tests/fm-context-reset.test.sh b/tests/fm-context-reset.test.sh index bfaf1dbaca..d27e2c53ff 100755 --- a/tests/fm-context-reset.test.sh +++ b/tests/fm-context-reset.test.sh @@ -75,6 +75,12 @@ install_fake_tmux() { # #!/usr/bin/env bash printf '%s\n' "$*" >> "${FM_FAKE_TMUX_LOG:-/dev/null}" case "${1:-}" in + # list-panes takes a target-WINDOW, so it lists every pane of the window + # containing $TMUX_PANE - here a SPLIT window whose active pane (%1) is not + # this script's own (%9). That is the shape the resolver has to get right: a + # gate that picked the active row would hand fm-context-reset.sh a neighbour's + # pane and type the reset into it. + list-panes) printf '%%1 1\n%%9 0\n'; exit 0 ;; display-message) case "$*" in *cursor_y*) printf '5\n' ;; @@ -183,7 +189,18 @@ test_reset_proceeds_when_everything_verifies() { assert_grep 'cleared' "$STATE_DIR/.context-reset.log" "a completed reset left no durable record" [ -f "$(printf '%s/.stow-receipt' "$STATE_DIR")" ] \ && fail "the receipt survived a completed reset and could be replayed" - pass "a verified reset clears through the shared submit path and records that it did" + # The pane the reset was ADDRESSED to must be this script's own ($TMUX_PANE = + # %9), never the neighbour that happens to be active in the same split window + # (%1). fm_tmux_resolve_pane's whole job here is that identity: it resolves + # $TMUX_PANE and this script types into whatever the result names, so a gate + # that answered with the window's active pane would send /clear next door - + # exactly what "a reset must never be typed into a pane it cannot identify" + # exists to prevent. + assert_grep 'display-message -p -t %9 ' "$TMUX_LOG" \ + "the reset did not address its own pane (%9) when reading the target it types into" + assert_no_grep 'display-message -p -t %1 ' "$TMUX_LOG" \ + "the reset addressed the active neighbouring pane (%1) instead of its own (%9)" + pass "a verified reset clears through the shared submit path, addressing its own pane, and records that it did" } test_check_mode_verifies_without_clearing() { diff --git a/tests/fm-crew-state.test.sh b/tests/fm-crew-state.test.sh index 1930497f8e..545f90d14a 100755 --- a/tests/fm-crew-state.test.sh +++ b/tests/fm-crew-state.test.sh @@ -84,6 +84,9 @@ SH #!/usr/bin/env bash set -u case "${1:-}" in + list-panes) + [ "${FM_FAKE_TMUX_MISSING:-0}" = 1 ] && exit 1 + printf '%%1 1\n'; exit 0 ;; display-message) [ "${FM_FAKE_TMUX_MISSING:-0}" = 1 ] && exit 1 printf '%%1\n' ;; diff --git a/tests/fm-fleet-snapshot-argv-limit.test.sh b/tests/fm-fleet-snapshot-argv-limit.test.sh index a376114147..9c89436465 100755 --- a/tests/fm-fleet-snapshot-argv-limit.test.sh +++ b/tests/fm-fleet-snapshot-argv-limit.test.sh @@ -49,6 +49,7 @@ make_fakebin() { # #!/usr/bin/env bash set -u case "${1:-}" in + list-panes) printf '%%1 1\n'; exit 0 ;; display-message) case "$*" in *pane_current_command*) printf 'codex\n' ;; diff --git a/tests/fm-fleet-snapshot-view.test.sh b/tests/fm-fleet-snapshot-view.test.sh index f26311f9ab..5fbe39ebb3 100755 --- a/tests/fm-fleet-snapshot-view.test.sh +++ b/tests/fm-fleet-snapshot-view.test.sh @@ -28,16 +28,25 @@ for arg in "$@"; do if [ "$prev" = "-t" ]; then target=$arg; fi prev=$arg done +# Each window gets its OWN pane id, because the probes resolve a target to its +# pane id first and then read that pane (docs/tmux-backend.md "Target +# resolution"). A stub that answered per window NAME would stop distinguishing +# its fixtures the moment the read is addressed by pane, exactly as real tmux is. +case "$target" in + *dead-secondmate*|%9) pane='%9' ;; + *) pane='%1' ;; +esac case "${1:-}" in + list-panes) printf '%s 1\n' "$pane"; exit 0 ;; display-message) case "$*" in *pane_current_command*) - case "$target" in - *dead-secondmate*) printf 'zsh\n' ;; + case "$pane" in + '%9') printf 'zsh\n' ;; *) printf 'codex\n' ;; esac ;; - *) printf '%%1\n' ;; + *) printf '%s\n' "$pane" ;; esac ;; capture-pane) diff --git a/tests/fm-gate-refuse.test.sh b/tests/fm-gate-refuse.test.sh index 1ca4f20850..2089f00617 100755 --- a/tests/fm-gate-refuse.test.sh +++ b/tests/fm-gate-refuse.test.sh @@ -148,7 +148,10 @@ case "$*" in *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; esac case "${1:-}" in - display-message) printf 'firstmate\n'; exit 0 ;; + list-panes) printf '%%1 1\n'; exit 0 ;; + display-message) + for a in "$@"; do case "$a" in *pane_id*) printf '%%1\n'; exit 0 ;; esac; done + printf 'firstmate\n'; exit 0 ;; list-windows) exit 0 ;; has-session|new-session|new-window|send-keys|set-window-option) exit 0 ;; esac @@ -215,6 +218,7 @@ make_send_fakebin() { #!/usr/bin/env bash set -u case "${1:-}" in + list-panes) printf '%%1 1\n'; exit 0 ;; send-keys) shift; literal=0; target= while [ $# -gt 0 ]; do diff --git a/tests/fm-grok-harness.test.sh b/tests/fm-grok-harness.test.sh index 4376cdebef..46d3979bd6 100755 --- a/tests/fm-grok-harness.test.sh +++ b/tests/fm-grok-harness.test.sh @@ -19,7 +19,10 @@ case "$*" in *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; esac case "${1:-}" in - display-message) printf 'firstmate\n'; exit 0 ;; + list-panes) printf '%%1 1\n'; exit 0 ;; + display-message) + for a in "$@"; do case "$a" in *pane_id*) printf '%%1\n'; exit 0 ;; esac; done + printf 'firstmate\n'; exit 0 ;; list-windows) exit 0 ;; has-session|new-session|new-window|send-keys|kill-window) exit 0 ;; esac diff --git a/tests/fm-pending-reply.test.sh b/tests/fm-pending-reply.test.sh index 5e700bf074..9716997cbe 100755 --- a/tests/fm-pending-reply.test.sh +++ b/tests/fm-pending-reply.test.sh @@ -44,6 +44,7 @@ make_stubs() { # -> fakebin #!/usr/bin/env bash set -u case "${1:-}" in + list-panes) printf '%%1 1\n'; exit 0 ;; send-keys) shift literal=0 @@ -59,7 +60,7 @@ case "${1:-}" in fi exit 0 ;; display-message) - for a in "$@"; do case "$a" in *cursor_y*) printf '0\n'; exit 0 ;; esac; done + for a in "$@"; do case "$a" in *pane_id*) printf '%%1\n'; exit 0 ;; *cursor_y*) printf '0\n'; exit 0 ;; esac; done printf 'fakepane\n'; exit 0 ;; capture-pane) printf '\xe2\x94\x82 \xe2\x94\x82\n'; exit 0 ;; list-windows) exit 0 ;; diff --git a/tests/fm-role-config.test.sh b/tests/fm-role-config.test.sh index faf150e2dc..f450c6dca1 100755 --- a/tests/fm-role-config.test.sh +++ b/tests/fm-role-config.test.sh @@ -464,7 +464,10 @@ case "$*" in *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; esac case "${1:-}" in - display-message) printf 'firstmate\n'; exit 0 ;; + list-panes) printf '%%1 1\n'; exit 0 ;; + display-message) + for a in "$@"; do case "$a" in *pane_id*) printf '%%1\n'; exit 0 ;; esac; done + printf 'firstmate\n'; exit 0 ;; list-windows) exit 0 ;; esac exit 0 diff --git a/tests/fm-secondmate-harness.test.sh b/tests/fm-secondmate-harness.test.sh index e59aadcfb7..bc017daeeb 100755 --- a/tests/fm-secondmate-harness.test.sh +++ b/tests/fm-secondmate-harness.test.sh @@ -428,7 +428,10 @@ case "$*" in *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; esac case "${1:-}" in - display-message) printf 'firstmate\n'; exit 0 ;; + list-panes) printf '%%1 1\n'; exit 0 ;; + display-message) + for a in "$@"; do case "$a" in *pane_id*) printf '%%1\n'; exit 0 ;; esac; done + printf 'firstmate\n'; exit 0 ;; list-windows) exit 0 ;; has-session|new-session|new-window|kill-window) exit 0 ;; send-keys) diff --git a/tests/fm-secondmate-liveness.test.sh b/tests/fm-secondmate-liveness.test.sh index 9d56911be4..d2cf4e2af3 100755 --- a/tests/fm-secondmate-liveness.test.sh +++ b/tests/fm-secondmate-liveness.test.sh @@ -56,8 +56,9 @@ make_probe_tmux() { #!/usr/bin/env bash set -u case "\${1:-}" in + list-panes) printf '%%1 1\n'; exit 0 ;; display-message) - for a in "\$@"; do case "\$a" in *pane_current_command*) printf '%s\n' '$comm'; exit 0 ;; esac; done + for a in "\$@"; do case "\$a" in *pane_id*) printf '%%1\n'; exit 0 ;; *pane_current_command*) printf '%s\n' '$comm'; exit 0 ;; esac; done exit 0 ;; esac exit 0 @@ -221,8 +222,9 @@ make_liveness_tmux() { #!/usr/bin/env bash set -u case "${1:-}" in + list-panes) printf '%%1 1\n'; exit 0 ;; display-message) - for a in "$@"; do case "$a" in *pane_current_command*) printf '%s\n' "${FM_TEST_PANE_CMD:-zsh}"; exit 0 ;; esac; done + for a in "$@"; do case "$a" in *pane_id*) printf '%%1\n'; exit 0 ;; *pane_current_command*) printf '%s\n' "${FM_TEST_PANE_CMD:-zsh}"; exit 0 ;; esac; done exit 0 ;; new-window|kill-window) printf '%s\n' "$*" >> "${FM_TMUX_CALL_LOG:?}" diff --git a/tests/fm-secondmate-sync.test.sh b/tests/fm-secondmate-sync.test.sh index dc4ff32557..2ec64456d7 100755 --- a/tests/fm-secondmate-sync.test.sh +++ b/tests/fm-secondmate-sync.test.sh @@ -298,6 +298,9 @@ if [ -n "${FM_FAKE_TMUX_LOG:-}" ]; then printf '%s\n' "$*" >> "$FM_FAKE_TMUX_LOG" fi case "$*" in + # The liveness probe resolves a target to its pane before reading it, so an + # endpoint has to resolve here or the sweep reports it inconclusive. + 'list-panes'*) printf '%s\n' '%1 1'; exit 0 ;; *display-message*'#{pane_current_command}'*) printf '%s\n' codex; exit 0 ;; *display-message*'#{pane_id}'*) printf '%s\n' '%1'; exit 0 ;; *display-message*'#{cursor_y}'*) printf '%s\n' 0; exit 0 ;; diff --git a/tests/fm-send-popup-settle.test.sh b/tests/fm-send-popup-settle.test.sh index 7423aeed2c..47542c99a7 100755 --- a/tests/fm-send-popup-settle.test.sh +++ b/tests/fm-send-popup-settle.test.sh @@ -48,9 +48,10 @@ make_stubs() { # -> echoes fakebin dir #!/usr/bin/env bash set -u case "${1:-}" in + list-panes) printf '%%1 1\n'; exit 0 ;; send-keys) exit 0 ;; display-message) - for a in "$@"; do case "$a" in *cursor_y*) printf '0\n'; exit 0 ;; esac; done + for a in "$@"; do case "$a" in *pane_id*) printf '%%1\n'; exit 0 ;; *cursor_y*) printf '0\n'; exit 0 ;; esac; done printf 'fakepane\n'; exit 0 ;; capture-pane) printf '\xe2\x94\x82 \xe2\x94\x82\n'; exit 0 ;; list-windows) exit 0 ;; diff --git a/tests/fm-send-secondmate-marker.test.sh b/tests/fm-send-secondmate-marker.test.sh index b0d205cba4..2248bffeb1 100755 --- a/tests/fm-send-secondmate-marker.test.sh +++ b/tests/fm-send-secondmate-marker.test.sh @@ -38,6 +38,7 @@ make_stubs() { # -> echoes fakebin dir #!/usr/bin/env bash set -u case "${1:-}" in + list-panes) printf '%%1 1\n'; exit 0 ;; send-keys) shift literal=0 @@ -53,7 +54,7 @@ case "${1:-}" in fi exit 0 ;; display-message) - for a in "$@"; do case "$a" in *cursor_y*) printf '0\n'; exit 0 ;; esac; done + for a in "$@"; do case "$a" in *pane_id*) printf '%%1\n'; exit 0 ;; *cursor_y*) printf '0\n'; exit 0 ;; esac; done printf 'fakepane\n'; exit 0 ;; capture-pane) printf '\xe2\x94\x82 \xe2\x94\x82\n'; exit 0 ;; list-windows) exit 0 ;; diff --git a/tests/fm-send-settle.test.sh b/tests/fm-send-settle.test.sh index 0ef7f7ee79..2736de7cc2 100755 --- a/tests/fm-send-settle.test.sh +++ b/tests/fm-send-settle.test.sh @@ -34,9 +34,10 @@ make_stubs() { # -> echoes fakebin dir #!/usr/bin/env bash set -u case "${1:-}" in + list-panes) printf '%%1 1\n'; exit 0 ;; send-keys) exit 0 ;; display-message) - for a in "$@"; do case "$a" in *cursor_y*) printf '0\n'; exit 0 ;; esac; done + for a in "$@"; do case "$a" in *pane_id*) printf '%%1\n'; exit 0 ;; *cursor_y*) printf '0\n'; exit 0 ;; esac; done printf 'fakepane\n'; exit 0 ;; capture-pane) printf '\xe2\x94\x82 \xe2\x94\x82\n'; exit 0 ;; list-windows) exit 0 ;; diff --git a/tests/fm-send-strict.test.sh b/tests/fm-send-strict.test.sh index d54cb6926b..db89f4a911 100755 --- a/tests/fm-send-strict.test.sh +++ b/tests/fm-send-strict.test.sh @@ -20,6 +20,21 @@ make_stubs() { # -> echoes fakebin dir #!/usr/bin/env bash set -u case "${1:-}" in + list-panes) + # Target resolution is what refuses a dead endpoint now, so this models the + # dead target here as well as under display-message. + target= + while [ $# -gt 0 ]; do + case "$1" in + -t) target=$2; shift 2 ;; + *) shift ;; + esac + done + if [ -n "${FM_FAKE_TMUX_DEAD_TARGET:-}" ] && [ "$target" = "$FM_FAKE_TMUX_DEAD_TARGET" ]; then + exit 1 + fi + printf '%%1 1\n' + exit 0 ;; send-keys) shift literal=0 diff --git a/tests/fm-session-start.test.sh b/tests/fm-session-start.test.sh index 1180e27c7d..493879a902 100755 --- a/tests/fm-session-start.test.sh +++ b/tests/fm-session-start.test.sh @@ -201,14 +201,20 @@ make_fake_tmux() { #!/usr/bin/env bash set -u case "\${1:-}" in - display-message) + list-panes|display-message) target="" prev="" for a in "\$@"; do [ "\$prev" = "-t" ] && target="\$a" prev="\$a" done - [ "\$target" = "$live" ] && { printf '%%1\n'; exit 0; } + # Only the one live target resolves - the exact primitive + # fm_backend_target_exists uses for a tmux endpoint liveness read, mirroring + # make_fake_herdr below. + if [ "\$target" = "$live" ]; then + [ "\${1:-}" = list-panes ] && { printf '%%1 1\n'; exit 0; } + printf '%%1\n'; exit 0 + fi exit 1 ;; esac diff --git a/tests/fm-spawn-dispatch-profile.test.sh b/tests/fm-spawn-dispatch-profile.test.sh index e0d707cd30..70f970868b 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -25,7 +25,10 @@ case "$*" in *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; esac case "${1:-}" in - display-message) printf 'firstmate\n'; exit 0 ;; + list-panes) printf '%%1 1\n'; exit 0 ;; + display-message) + for a in "$@"; do case "$a" in *pane_id*) printf '%%1\n'; exit 0 ;; esac; done + printf 'firstmate\n'; exit 0 ;; list-windows) exit 0 ;; has-session|new-session|new-window|kill-window) exit 0 ;; send-keys) diff --git a/tests/fm-spawn-worktree-settle.test.sh b/tests/fm-spawn-worktree-settle.test.sh index fe16ac3559..e8ebedc748 100755 --- a/tests/fm-spawn-worktree-settle.test.sh +++ b/tests/fm-spawn-worktree-settle.test.sh @@ -46,7 +46,10 @@ case "$*" in ;; esac case "${1:-}" in - display-message) printf 'firstmate\n'; exit 0 ;; + list-panes) printf '%%1 1\n'; exit 0 ;; + display-message) + for a in "$@"; do case "$a" in *pane_id*) printf '%%1\n'; exit 0 ;; esac; done + printf 'firstmate\n'; exit 0 ;; list-windows) exit 0 ;; has-session|new-session|new-window|kill-window) exit 0 ;; send-keys) exit 0 ;; diff --git a/tests/fm-tangle-guard.test.sh b/tests/fm-tangle-guard.test.sh index 8273ecd7bd..ef333b2461 100755 --- a/tests/fm-tangle-guard.test.sh +++ b/tests/fm-tangle-guard.test.sh @@ -163,7 +163,10 @@ case "$*" in *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; esac case "${1:-}" in - display-message) printf 'firstmate\n'; exit 0 ;; + list-panes) printf '%%1 1\n'; exit 0 ;; + display-message) + for a in "$@"; do case "$a" in *pane_id*) printf '%%1\n'; exit 0 ;; esac; done + printf 'firstmate\n'; exit 0 ;; list-windows) exit 0 ;; has-session|new-session|new-window|send-keys) exit 0 ;; esac @@ -241,7 +244,10 @@ case "$*" in *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; esac case "${1:-}" in - display-message) printf 'firstmate\n'; exit 0 ;; + list-panes) printf '%%1 1\n'; exit 0 ;; + display-message) + for a in "$@"; do case "$a" in *pane_id*) printf '%%1\n'; exit 0 ;; esac; done + printf 'firstmate\n'; exit 0 ;; new-window) printf '%s\n' "@spawnwid"; exit 0 ;; list-windows) exit 0 ;; has-session|new-session|send-keys|set-window-option) exit 0 ;; @@ -296,8 +302,16 @@ test_spawn_tmux_window_construction() { # Bug 2 fix (b): treehouse-get and the worktree wait loop target the stable id. assert_grep "send-keys -t @spawnwid treehouse get Enter" "$rec" \ "treehouse get must be sent to the stable window id" - assert_grep "display-message -p -t @spawnwid #{pane_current_path}" "$rec" \ - "the worktree wait loop must query the stable window id, not the name" + # The wait loop now RESOLVES the stable window id to its pane and reads that + # pane, rather than asking display-message about the id directly - a raw + # display-message read answers for another window instead of refusing an + # unresolvable target (docs/tmux-backend.md "Target resolution"). The property + # under test is unchanged and stronger: the stable id, never the renameable + # name, is what identifies the window, and the read is then pane-exact. + assert_grep "list-panes -t @spawnwid" "$rec" \ + "the worktree wait loop must resolve the stable window id, not the name" + assert_no_grep "display-message -p -t firstmate:fm-rec-win-gg7 #{pane_current_path}" "$rec" \ + "the worktree path must never be read against the renameable window name" pass "fm-spawn: appends windows by session-colon, pins the name, and targets the window id" } diff --git a/tests/fm-tmux-submit-busy.test.sh b/tests/fm-tmux-submit-busy.test.sh index c9e03a6757..2cfbeedc21 100755 --- a/tests/fm-tmux-submit-busy.test.sh +++ b/tests/fm-tmux-submit-busy.test.sh @@ -25,9 +25,13 @@ make_submit_mock() { set -u COMPOSER="${FM_FAKE_COMPOSER:?}" case "${1:-}" in + list-panes) printf '%%1 1\n'; exit 0 ;; display-message) for a in "$@"; do - case "$a" in *cursor_y*) printf '0\n'; exit 0 ;; esac + case "$a" in + *pane_id*) printf '%%1\n'; exit 0 ;; + *cursor_y*) printf '0\n'; exit 0 ;; + esac done exit 0 ;; capture-pane) cat "$COMPOSER" 2>/dev/null; exit 0 ;; diff --git a/tests/fm-tmux-target-resolve.test.sh b/tests/fm-tmux-target-resolve.test.sh new file mode 100755 index 0000000000..87cbd34b6e --- /dev/null +++ b/tests/fm-tmux-target-resolve.test.sh @@ -0,0 +1,416 @@ +#!/usr/bin/env bash +# tests/fm-tmux-target-resolve.test.sh - regression: a tmux liveness probe must +# never answer for a window other than the one it was asked about. +# +# Incident fm-liveness-probe-target-fallback (reported by Tugboat 2026-07-28, +# reproduced 2026-08-05): `tmux display-message -p -t ` does not refuse +# an unresolvable target. It answers for the target session's current window, or +# the active client's, or expands the format to empty - and returns 0 in every +# case. Both probes built on it were therefore describing the SUPERVISING pane: +# fm_backend_target_exists reported a non-existent window as present, and +# fm_backend_agent_alive computed its confident alive/dead verdict from whatever +# that other pane was running. +# +# These are the hermetic, always-run halves. The real-tmux control - including +# the case that produced Tugboat's false ALIVE, an invented name on a host whose +# fallback pane is running an agent - lives in tests/fm-backend-tmux-smoke.test.sh +# against a private tmux socket, because only a real tmux exhibits the fallback. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +TMP_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/fm-tmux-target-resolve.XXXXXX") +trap 'rm -rf "$TMP_ROOT"' EXIT + +# --- fake tmux --------------------------------------------------------------- +# +# Models the two behaviors that matter, exactly as tmux 3.4 was measured to +# behave (docs/tmux-backend.md "Target resolution: display-message answers for +# the wrong window"): +# list-panes - refuses an unknown target (rc=1), lists real ones. +# display-message - NEVER refuses. For an unknown target it answers for +# FM_FAKE_FALLBACK_CMD, the supervising pane's command, which +# is precisely the bug. +# One live window, "sess:real", whose pane %1 runs FM_FAKE_REAL_CMD. +make_fake_tmux() { # -> echoes fakebin dir + local fb="$1/fakebin" + mkdir -p "$fb" + cat > "$fb/tmux" <<'SH' +#!/usr/bin/env bash +set -u +target= +prev= +for a in "$@"; do + [ "$prev" = -t ] && target=$a + prev=$a +done +known=0 +case "$target" in + sess:real|%1|@1) known=1 ;; +esac +case "${1:-}" in + list-panes) + [ "$known" = 1 ] || { echo "can't find window: $target" >&2; exit 1; } + printf '%%1 1\n'; exit 0 ;; + display-message) + # The defect, faithfully: rc=0 whether or not the target resolved, and an + # unresolved one answers with the FALLBACK pane's command. + for a in "$@"; do + case "$a" in + *pane_current_command*) + if [ "$known" = 1 ]; then printf '%s\n' "${FM_FAKE_REAL_CMD:-bash}" + else printf '%s\n' "${FM_FAKE_FALLBACK_CMD:-bash}"; fi + exit 0 ;; + *pane_current_path*) + if [ "$known" = 1 ]; then printf '%s\n' "${FM_FAKE_REAL_PATH:-/real/worktree}" + else printf '%s\n' "${FM_FAKE_FALLBACK_PATH:-/supervisor/primary-checkout}"; fi + exit 0 ;; + *pane_id*) printf '%%1\n'; exit 0 ;; + esac + done + exit 0 ;; + send-keys) + # Real tmux refuses an unresolvable send target (measured rc=1); model that + # so the test cannot pass by accident when the preflight is removed. + [ "$known" = 1 ] || { echo "can't find window: $target" >&2; exit 1; } + printf 'sent\n' >> "${FM_FAKE_SENT_LOG:?}"; exit 0 ;; +esac +exit 0 +SH + chmod +x "$fb/tmux" + printf '%s\n' "$fb" +} + +FAKEBIN=$(make_fake_tmux "$TMP_ROOT") +PATH="$FAKEBIN:$PATH" +export PATH + +# shellcheck source=bin/fm-backend.sh +. "$ROOT/bin/fm-backend.sh" +fm_backend_source tmux || fail "fm_backend_source tmux failed" + +# --- fm_tmux_resolve_pane ---------------------------------------------------- + +test_resolver_refuses_what_display_message_would_answer() { + local t + fm_tmux_resolve_pane 'sess:real' >/dev/null \ + || fail "fm_tmux_resolve_pane must resolve a live target" + [ "$(fm_tmux_resolve_pane 'sess:real')" = '%1' ] \ + || fail "fm_tmux_resolve_pane must print the pane id the target names" + + for t in 'sess:zzz-nope' 'sess:also-not-real' '%9999' '@9999' 'nosuchsession:win' ''; do + if fm_tmux_resolve_pane "$t" >/dev/null 2>&1; then + fail "fm_tmux_resolve_pane accepted the unresolvable target '$t'" + fi + [ -z "$(fm_tmux_resolve_pane "$t" 2>/dev/null)" ] \ + || fail "fm_tmux_resolve_pane printed a pane id for the unresolvable target '$t'" + done + pass "fm_tmux_resolve_pane resolves a live target and refuses every unresolvable one" +} + +# --- the control: verdict must not depend on the supervising pane ------------ +# +# The same invented window name, on two hosts that differ ONLY in what their own +# fallback pane is running. Before the gate this returned alive on one and dead +# on the other; neither was a reading of the target. That host-dependence is why +# the defect got diagnosed as an environment quirk instead of a fleet-wide bug. +test_agent_alive_never_reads_the_fallback_pane() { + local verdict + for fallback in claude codex opencode grok bash zsh; do + verdict=$(FM_FAKE_FALLBACK_CMD="$fallback" fm_backend_agent_alive tmux 'sess:zzz-nope') + [ "$verdict" != alive ] \ + || fail "an invented window read ALIVE off a fallback pane running '$fallback'" + [ "$verdict" = unknown ] \ + || fail "an invented window must read unknown, not '$verdict' (fallback pane: '$fallback')" + done + pass "fm_backend_agent_alive reports unknown for an invented window whatever the fallback pane runs" +} + +test_agent_alive_still_reads_a_real_target() { + local verdict + verdict=$(FM_FAKE_REAL_CMD=claude FM_FAKE_FALLBACK_CMD=bash fm_backend_agent_alive tmux 'sess:real') + [ "$verdict" = alive ] || fail "a live agent pane must still read alive, got '$verdict'" + verdict=$(FM_FAKE_REAL_CMD=bash FM_FAKE_FALLBACK_CMD=claude fm_backend_agent_alive tmux 'sess:real') + [ "$verdict" = dead ] || fail "a bare-shell pane must still read dead, got '$verdict'" + pass "fm_backend_agent_alive still reads a real target's own command, not the fallback's" +} + +test_target_exists_refuses_an_invented_window() { + fm_backend_target_exists tmux 'sess:real' \ + || fail "fm_backend_target_exists must report a live window as present" + local t + for t in 'sess:zzz-nope' '%9999' '@9999' 'nosuchsession:win'; do + if fm_backend_target_exists tmux "$t"; then + fail "fm_backend_target_exists reported the non-existent target '$t' as present" + fi + done + pass "fm_backend_target_exists refuses a non-existent window instead of reporting it present" +} + +# fm-crew-state.sh's pane_readable was the third copy of the same raw probe. It +# now dispatches through fm_backend_target_exists, so it cannot drift again. +test_crew_state_presence_has_one_owner() { + local probes + probes=$(grep -n 'tmux display-message' "$ROOT/bin/fm-crew-state.sh" | grep -v '^[0-9]*: *#' || true) + [ -z "$probes" ] \ + || fail "fm-crew-state.sh must not run its own raw tmux presence probe:"$'\n'"$probes" + assert_grep 'fm_backend_target_exists tmux' "$ROOT/bin/fm-crew-state.sh" \ + "fm-crew-state.sh's pane_readable must dispatch through the shared presence owner" + pass "fm-crew-state.sh asks the shared presence owner instead of probing tmux itself" +} + +test_current_path_refuses_an_invented_window() { + local out + out=$(FM_FAKE_FALLBACK_PATH=/supervisor/primary-checkout \ + fm_backend_tmux_current_path 'sess:zzz-nope' 2>/dev/null) || true + [ -z "$out" ] \ + || fail "fm_backend_tmux_current_path returned the supervising pane's cwd '$out' for an invented window" + out=$(FM_FAKE_REAL_PATH=/real/worktree fm_backend_tmux_current_path 'sess:real') + [ "$out" = /real/worktree ] \ + || fail "fm_backend_tmux_current_path must still read a live target's cwd, got '$out'" + pass "fm_backend_tmux_current_path refuses an invented window rather than reading the supervisor's cwd" +} + +test_send_key_preflight_actually_verifies() { + export FM_FAKE_SENT_LOG="$TMP_ROOT/sent.log" + : > "$FM_FAKE_SENT_LOG" + if fm_backend_tmux_send_key 'sess:zzz-nope' Enter 2>/dev/null; then + fail "fm_backend_tmux_send_key must refuse an unresolvable target" + fi + [ ! -s "$FM_FAKE_SENT_LOG" ] \ + || fail "fm_backend_tmux_send_key sent a key to an unresolvable target" + fm_backend_tmux_send_key 'sess:real' Enter || fail "fm_backend_tmux_send_key must still send to a live target" + [ -s "$FM_FAKE_SENT_LOG" ] || fail "fm_backend_tmux_send_key did not send to the live target" + pass "fm_backend_tmux_send_key's preflight refuses an unresolvable target before sending" +} + +# --- the anti-recurrence rule ------------------------------------------------ +# +# The hazard was documented in a comment beside ONE caller (bin/fm-spawn.sh's +# worktree poll) while two other call sites kept using the unguarded form. A +# comment is not enforcement, so this is the enforcement: every `display-message +# -p` read in bin/ must target a pane the caller already RESOLVED or OWNS. +# +# The exemption is tied to evidence, not to spelling. An earlier form of this +# rule exempted any `-t` whose argument was literally "$pane", "$PANE", +# "$RESOLVED_PANE" or "$TMUX_PANE", which proved a naming convention rather than +# that resolution happened: `pane=$1; tmux display-message -p -t "$pane" ...` - +# an unresolved caller-supplied target, the exact incident shape - passed it. The +# rule now reads each file for the variables that actually hold a resolved pane +# (assigned from fm_tmux_resolve_pane / fm_backend_tmux_resolve_pane, or from +# $TMUX_PANE, which is the caller's OWN pane and needs no resolving) and exempts +# only a `-t "$VAR"` naming one of those. Every other form - `-t "$target"`, +# `-t "$1"`, `-t "$win"`, or a `$pane` that was never resolved - offends. +# +# fm_tmux_resolve_pane's own body is skipped, because it IS the gate: it is the +# one place that may read display-message against a raw target, and only after +# `list-panes` has refused an unresolvable one. That body is not unmeasured - +# test_resolver_gates_before_it_reads below drives it against a fake tmux that +# answers with a pane outside the listing and requires it to refuse. +# +# Two forms are deliberately NOT covered and are recorded rather than silently +# permitted (docs/tmux-backend.md "Adjacent sites not changed"): a `-p` read +# with no -t at all, which reads the caller's own current window rather than an +# arbitrary target, and bin/fm-supervise-daemon.sh's non-`-p` status-line flash, +# which displays a message rather than producing a verdict. + +# resolved_pane_vars: the variables in that provably hold a pane the +# script resolved (from the shared gate) or owns ($TMUX_PANE). +# +# The sed expressions below are literal source text being matched, not +# variables to expand. +# shellcheck disable=SC2016 +resolved_pane_vars() { # -> one variable name per line + sed -n \ + -e 's/^[[:space:]]*\(local[[:space:]]\{1,\}\)\{0,1\}\([A-Za-z_][A-Za-z0-9_]*\)=\$(.*fm_\(backend_\)\{0,1\}tmux_resolve_pane.*/\2/p' \ + -e 's/^[[:space:]]*\(local[[:space:]]\{1,\}\)\{0,1\}\([A-Za-z_][A-Za-z0-9_]*\)="\{0,1\}\${\{0,1\}TMUX_PANE.*/\2/p' \ + "$1" | sort -u +} + +unguarded_display_message_reads() { # -> prints offending file:line matches + local file vars + while IFS= read -r file; do + [ -n "$file" ] || continue + vars=" $(resolved_pane_vars "$file" | tr '\n' ' ')TMUX_PANE " + awk -v vars="$vars" -v file="$file" ' + /^[[:space:]]*#/ { next } + /^fm_tmux_resolve_pane\(\)/ { in_gate = 1; next } + in_gate { if ($0 ~ /^}/) in_gate = 0; next } + !/display-message -p -t/ { next } + { + arg = $0 + sub(/.*display-message -p -t[[:space:]]+/, "", arg) + sub(/[[:space:]].*/, "", arg) + if (arg ~ /^"\$[A-Za-z_][A-Za-z0-9_]*"$/) { + name = substr(arg, 3, length(arg) - 3) + if (index(vars, " " name " ") > 0) next + } + printf "%s:%d:%s\n", file, FNR, $0 + } + ' "$file" + done </dev/null | sort) +EOF +} + +test_no_unguarded_display_message_read_in_bin() { + # First prove the detector can fail, on BOTH shapes it must catch. A rule that + # cannot fire is exactly the defect under repair: bin/fm-spawn.sh's comment + # named this hazard while two other call sites kept the unguarded form, and + # nothing was measuring them. + local probe_dir + probe_dir="$TMP_ROOT/rule-selfcheck" + rm -rf "$probe_dir"; mkdir -p "$probe_dir" + # Literal offending source text, deliberately unexpanded. + # shellcheck disable=SC2016 + printf '%s\n' 'cmd=$(tmux display-message -p -t "$target" '"'"'#{pane_current_command}'"'"')' \ + > "$probe_dir/offender-target.sh" + [ -n "$(unguarded_display_message_reads "$probe_dir")" ] \ + || fail "the unguarded-display-message rule does not detect the '\$target' spelling; it is not enforcing anything" + + # The shape the old spelling-based rule let through: a caller-supplied target + # that is merely NAMED like a resolved pane. + rm -rf "$probe_dir"; mkdir -p "$probe_dir" + # shellcheck disable=SC2016 + printf '%s\n' 'probe() {' 'pane=$1' \ + 'tmux display-message -p -t "$pane" '"'"'#{pane_current_command}'"'"'' '}' \ + > "$probe_dir/offender-unresolved-pane.sh" + [ -n "$(unguarded_display_message_reads "$probe_dir")" ] \ + || fail "the unguarded-display-message rule exempts an UNRESOLVED target just because it is spelled \"\$pane\"; it proves a naming convention, not resolution" + + # ...and that the same spelling is accepted once it is actually resolved, or + # the rule would be unusable and the real call sites would have to be excused. + rm -rf "$probe_dir"; mkdir -p "$probe_dir" + # shellcheck disable=SC2016 + printf '%s\n' 'probe() {' 'pane=$(fm_tmux_resolve_pane "$1") || return 1' \ + 'tmux display-message -p -t "$pane" '"'"'#{pane_current_command}'"'"'' '}' \ + > "$probe_dir/resolved-pane.sh" + [ -z "$(unguarded_display_message_reads "$probe_dir")" ] \ + || fail "the unguarded-display-message rule rejects a properly resolved pane read" + + local offenders + offenders=$(unguarded_display_message_reads "$ROOT/bin") + [ -z "$offenders" ] || fail \ + "a tmux display-message read targets an unresolved, caller-supplied target; resolve it with fm_tmux_resolve_pane first:"$'\n'"$offenders" + pass "no bin/ script reads tmux display-message against an unresolved caller-supplied target (rule self-checked on both offending shapes)" +} + +# The resolver itself must stay built on a command that refuses. If the refusal +# is ever moved onto display-message, every test above would still pass against a +# fake that models the real fallback - but the fleet would be broken again. +test_resolver_is_built_on_a_refusing_command() { + local body lp dm + body=$(awk '/^fm_tmux_resolve_pane\(\)/,/^}/' "$ROOT/bin/fm-tmux-lib.sh") + assert_contains "$body" "list-panes" \ + "fm_tmux_resolve_pane must resolve through tmux list-panes, which refuses an unknown target" + # display-message may be READ from, but never before list-panes has refused an + # unresolvable target, and never for its exit status (see the behavioral test + # below). Ordering is asserted here because it is the one property the fakes + # cannot observe: a display-message-first resolver would answer for another + # window before anything had a chance to refuse. + lp=$(printf '%s\n' "$body" | grep -n 'list-panes' | head -1 | cut -d: -f1) + dm=$(printf '%s\n' "$body" | grep -n 'display-message' | head -1 | cut -d: -f1) + [ -n "$lp" ] || fail "fm_tmux_resolve_pane no longer calls list-panes" + if [ -n "$dm" ]; then + [ "$lp" -lt "$dm" ] \ + || fail "fm_tmux_resolve_pane reads display-message before list-panes has gated the target" + fi + pass "fm_tmux_resolve_pane gates on a tmux command that refuses before it reads any identity" +} + +# --- the gate reads the pane the target NAMES, not the window's active one ---- +# +# list-panes takes a target-WINDOW: `list-panes -t %0` and `list-panes -t +# sess:win.0` both list EVERY pane of the containing window (measured on tmux +# 3.4: `%0 0` and `%1 1`). A resolver that picked the active row from that +# listing answered %1 for a target that named %0 - the same answer-for-the-wrong- +# thing defect this branch exists to close, narrowed from window scope to pane +# scope. It reached bin/fm-context-reset.sh, which types a reset into whatever +# the resolved pane names, and the away-mode composer-emptiness safety check. +# +# The genuine two-pane control runs against real tmux in +# tests/fm-backend-tmux-smoke.test.sh. This is the hermetic half. +make_two_pane_tmux() { # -> echoes fakebin dir + local fb="$1/fakebin" + mkdir -p "$fb" + cat > "$fb/tmux" <<'SH' +#!/usr/bin/env bash +set -u +target= +prev= +for a in "$@"; do + [ "$prev" = -t ] && target=$a + prev=$a +done +# One window "sess:win" with two panes; %1 is ACTIVE, %0 is not. +case "$target" in + sess:win|sess:win.0|sess:win.1|%0|%1|@1) ;; + *) echo "can't find window: $target" >&2; exit 1 ;; +esac +case "${1:-}" in + list-panes) + # Real tmux lists the whole containing WINDOW whatever pane was named. + printf '%%0\n%%1\n'; exit 0 ;; + display-message) + # Real tmux is exact here: the named pane, or the active one for a + # window-qualified target. + case "$target" in + %0|sess:win.0) printf '%%0\n' ;; + %1|sess:win.1) printf '%%1\n' ;; + *) printf '%s\n' "${FM_FAKE_DM_PANE:-%1}" ;; + esac + exit 0 ;; +esac +exit 0 +SH + chmod +x "$fb/tmux" + printf '%s\n' "$fb" +} + +test_resolver_returns_the_pane_the_target_names() { + local fb got + fb=$(make_two_pane_tmux "$TMP_ROOT/two-pane") + got=$(PATH="$fb:$PATH" fm_tmux_resolve_pane 'sess:win.0') + [ "$got" = '%0' ] \ + || fail "fm_tmux_resolve_pane returned '$got' for sess:win.0; it must name pane 0, not the window's active pane" + got=$(PATH="$fb:$PATH" fm_tmux_resolve_pane '%0') + [ "$got" = '%0' ] \ + || fail "fm_tmux_resolve_pane returned '$got' for the pane id %0; a pane id must resolve to itself" + got=$(PATH="$fb:$PATH" fm_tmux_resolve_pane '%1') + [ "$got" = '%1' ] || fail "fm_tmux_resolve_pane returned '$got' for the pane id %1" + # A window-qualified target has no pane of its own, so the window's ACTIVE + # pane is the correct answer there. + got=$(PATH="$fb:$PATH" fm_tmux_resolve_pane 'sess:win') + [ "$got" = '%1' ] \ + || fail "fm_tmux_resolve_pane returned '$got' for the window sess:win; a window-qualified target must resolve to its active pane" + pass "fm_tmux_resolve_pane returns the pane the target names, not the containing window's active pane" +} + +# The identity read is never TRUSTED, only used: its answer must appear in the +# listing that already refused. This is what catches the one window where +# display-message could still fall back - a pane that dies between the two +# commands - and it is why the gate cannot be reimplemented as a bare +# display-message read that happens to be preceded by a list-panes call. +test_resolver_gates_before_it_reads() { + local fb + fb=$(make_two_pane_tmux "$TMP_ROOT/two-pane-stale") + if PATH="$fb:$PATH" FM_FAKE_DM_PANE='%77' fm_tmux_resolve_pane 'sess:win' >/dev/null 2>&1; then + fail "fm_tmux_resolve_pane accepted a pane id that was not in the listing it had just gated on" + fi + [ -z "$(PATH="$fb:$PATH" FM_FAKE_DM_PANE='%77' fm_tmux_resolve_pane 'sess:win' 2>/dev/null)" ] \ + || fail "fm_tmux_resolve_pane printed a pane id that was not in the listing" + pass "fm_tmux_resolve_pane refuses an identity that is absent from the listing it gated on" +} + +test_resolver_refuses_what_display_message_would_answer +test_resolver_returns_the_pane_the_target_names +test_resolver_gates_before_it_reads +test_agent_alive_never_reads_the_fallback_pane +test_agent_alive_still_reads_a_real_target +test_target_exists_refuses_an_invented_window +test_crew_state_presence_has_one_owner +test_current_path_refuses_an_invented_window +test_send_key_preflight_actually_verifies +test_no_unguarded_display_message_read_in_bin +test_resolver_is_built_on_a_refusing_command diff --git a/tests/fm-watch-triage.test.sh b/tests/fm-watch-triage.test.sh index 3fd917a2d1..96602e6a80 100755 --- a/tests/fm-watch-triage.test.sh +++ b/tests/fm-watch-triage.test.sh @@ -1559,6 +1559,7 @@ SH #!/usr/bin/env bash set -u case "${1:-}" in + list-panes) printf '%%1 1\n'; exit 0 ;; list-windows) [ -n "${FM_FAKE_TMUX_WINDOW:-}" ] && printf '%s\n' "$FM_FAKE_TMUX_WINDOW"; exit 0 ;; capture-pane) [ -n "${FM_FAKE_TMUX_CAPTURE:-}" ] && cat "$FM_FAKE_TMUX_CAPTURE"; exit 0 ;; display-message) diff --git a/tests/secondmate-helpers.sh b/tests/secondmate-helpers.sh index 7b5ab63472..9c042949cb 100644 --- a/tests/secondmate-helpers.sh +++ b/tests/secondmate-helpers.sh @@ -24,6 +24,7 @@ make_fake_tmux() { #!/usr/bin/env bash set -u case "${1:-}" in + list-panes) printf '%%1 1\n'; exit 0 ;; has-session|new-session|new-window|send-keys|kill-window) printf '%s\n' "$*" >> "$FM_FAKE_TMUX_LOG" exit 0 @@ -35,6 +36,7 @@ case "${1:-}" in exit 0 ;; display-message) + case "$*" in *pane_id*) printf '%%1\n'; exit 0 ;; esac printf 'firstmate\n' exit 0 ;; diff --git a/tests/wake-helpers.sh b/tests/wake-helpers.sh index b0f3dcf8b3..fbf4919efb 100644 --- a/tests/wake-helpers.sh +++ b/tests/wake-helpers.sh @@ -77,8 +77,15 @@ if [ "${1:-}" = "capture-pane" ]; then fi exit 0 fi +# The agent-liveness probe resolves the target to a pane before reading its +# command, so the endpoint has to resolve here or every verdict reads unknown. +if [ "${1:-}" = "list-panes" ]; then + printf '%s\n' '%1 1' + exit 0 +fi if [ "${1:-}" = "display-message" ]; then case "$*" in + *pane_id*) printf '%s\n' '%1'; exit 0 ;; *pane_current_command*) printf '%s\n' "${FM_FAKE_TMUX_CURRENT_COMMAND:-}"; exit 0 ;; esac fi @@ -123,12 +130,18 @@ make_supercase() { #!/usr/bin/env bash set -u case "${1:-}" in + list-panes) + [ "${FM_FAKE_TMUX_PANE_ALIVE:-1}" = "1" ] || exit 1 + printf '%%1 1\n'; exit 0 ;; display-message) [ "${FM_FAKE_TMUX_PANE_ALIVE:-1}" = "1" ] || exit 1 _print=0 # Return cursor_y when the format asks for it (pane_input_pending). for _a in "$@"; do - case "$_a" in *cursor_y*) printf '%s\n' "${FM_FAKE_TMUX_CURSOR_Y:-0}"; exit 0 ;; esac + case "$_a" in + *pane_id*) printf '%%1\n'; exit 0 ;; + *cursor_y*) printf '%s\n' "${FM_FAKE_TMUX_CURSOR_Y:-0}"; exit 0 ;; + esac [ "$_a" = "-p" ] && _print=1 done [ "$_print" = 1 ] && printf 'fakepane\n' @@ -204,9 +217,10 @@ make_bordered_case() { set -u COMPOSER="${FM_FAKE_COMPOSER:?FM_FAKE_COMPOSER unset}" case "${1:-}" in + list-panes) printf '%%1 1\n'; exit 0 ;; display-message) print=0 - for a in "$@"; do case "$a" in *cursor_y*) printf '0\n'; exit 0 ;; esac; done + for a in "$@"; do case "$a" in *pane_id*) printf '%%1\n'; exit 0 ;; *cursor_y*) printf '0\n'; exit 0 ;; esac; done for a in "$@"; do [ "$a" = "-p" ] && print=1; done [ "$print" = 1 ] && printf 'fakepane\n' exit 0 ;;