From 77430ef92fe160acf0c01524511ba6a9c8503a58 Mon Sep 17 00:00:00 2001 From: Coditan-XO Date: Wed, 5 Aug 2026 14:00:48 +0000 Subject: [PATCH 1/3] fix(tmux): resolve a target before probing it, so liveness stops answering for another window `tmux display-message -p -t ` does not refuse a target that does not resolve. It answers for a different window - the target session's current window, or the active client's - and returns 0 in every case. Both tmux liveness probes were built on that exit status, so their verdicts described the supervising pane and never touched the target at all. Reported by Tugboat 2026-07-28 and reproduced on tmux 3.4 (2026-08-05): three invented window names all returned a real pane id belonging to someone else. `fm_backend_target_exists` therefore reported a non-existent window as present, and `fm_backend_agent_alive` computed its confident alive/dead verdict from whatever the fallback pane happened to be running - so the same invented name read ALIVE on a host whose fallback pane ran claude and DEAD on one whose fallback pane ran bash. That host-dependence is why a fleet-wide defect looked like a local environment quirk. The secondmate-liveness sweep gates a respawn on `dead` only, so that gate could be satisfied or starved for reasons unrelated to the target: a dead secondmate never respawned on one host, a live one at risk of duplication on another. `tmux list-panes -t` refuses every invented shape (`sess:no-such-window`, `sess:@9999`, `sess:win.99`, `%9999`, `@9999`, bare unknown names) and resolves every real one, so it needs no target-shape parsing of our own. `fm_tmux_resolve_pane` (bin/fm-tmux-lib.sh) is now the one gate every read of a caller-supplied target passes; callers read the pane id it returns, which is exact rather than subject to tmux's own prefix matching. An unresolvable target reports unknown rather than dead: whether the endpoint exists at all is `fm_backend_target_exists`'s question, and the sweep turns unknown into a reported skip instead of a silent wrong verdict. The hazard was already documented in a comment beside bin/fm-spawn.sh's worktree poll while two other call sites kept the unguarded form, so the rule is now enforced by a test rather than by a comment: no bin/ script may read `display-message -p -t` against an unresolved caller-supplied target, and the rule self-checks that it still detects a known offender. Gated: fm_backend_tmux_current_command (and so agent_alive), fm_backend_tmux_current_path, fm_backend_tmux_send_key's preflight, fm_backend_target_exists's tmux branch, fm-crew-state.sh's pane_readable, fm_tmux_composer_state, and fm-context-reset.sh's own-pane read - the last because its "could not be resolved" refusal could not fire, in the one script that types a reset into whatever the target names. Measured, not assumed: capture-pane and send-keys refuse an unresolvable target correctly, so no pane content was ever misread and no keystroke misdelivered. The other four backends were read for the same pattern and are not affected - herdr, zellij, orca, and cmux each address by id through an API that errors on an unknown one, and herdr additionally round-trips the echoed pane_id. Two adjacent tmux sites are recorded rather than changed, because neither was measured to answer wrongly here: container_ensure's bare `-p` read of its own current window, and the away-mode status-line flash. Tests: the real-tmux control drives BOTH fallback shapes on a private socket, including an agent-named binary in the fallback pane - the exact case that produced the false ALIVE. It fails against the pre-fix code with that symptom. --- bin/backends/tmux.sh | 77 +++++- bin/fm-backend.sh | 29 ++- bin/fm-context-reset.sh | 11 +- bin/fm-crew-state.sh | 6 +- bin/fm-test-run.sh | 3 +- bin/fm-tmux-lib.sh | 69 +++++- docs/tmux-backend.md | 70 ++++++ tests/fm-backend-tmux-smoke.test.sh | 84 +++++++ tests/fm-backend.test.sh | 28 ++- tests/fm-bearings-snapshot.test.sh | 1 + tests/fm-bootstrap.test.sh | 6 + tests/fm-composer-ghost.test.sh | 1 + tests/fm-context-reset.test.sh | 1 + tests/fm-crew-state.test.sh | 3 + tests/fm-fleet-snapshot-argv-limit.test.sh | 1 + tests/fm-fleet-snapshot-view.test.sh | 15 +- tests/fm-gate-refuse.test.sh | 2 + tests/fm-grok-harness.test.sh | 1 + tests/fm-pending-reply.test.sh | 1 + tests/fm-role-config.test.sh | 1 + tests/fm-secondmate-harness.test.sh | 1 + tests/fm-secondmate-liveness.test.sh | 2 + tests/fm-secondmate-sync.test.sh | 3 + tests/fm-send-popup-settle.test.sh | 1 + tests/fm-send-secondmate-marker.test.sh | 1 + tests/fm-send-settle.test.sh | 1 + tests/fm-send-strict.test.sh | 15 ++ tests/fm-session-start.test.sh | 10 +- tests/fm-spawn-dispatch-profile.test.sh | 1 + tests/fm-spawn-worktree-settle.test.sh | 1 + tests/fm-tangle-guard.test.sh | 14 +- tests/fm-tmux-submit-busy.test.sh | 1 + tests/fm-tmux-target-resolve.test.sh | 257 +++++++++++++++++++++ tests/fm-watch-triage.test.sh | 1 + tests/secondmate-helpers.sh | 1 + tests/wake-helpers.sh | 10 + 36 files changed, 699 insertions(+), 31 deletions(-) create mode 100755 tests/fm-tmux-target-resolve.test.sh 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-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..a1adac687a 100755 --- a/bin/fm-tmux-lib.sh +++ b/bin/fm-tmux-lib.sh @@ -65,6 +65,62 @@ # (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 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. +# +# 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 id active pane='' + [ -n "$target" ] || return 1 + listing=$(tmux list-panes -t "$target" -F '#{pane_id} #{pane_active}' 2>/dev/null) || return 1 + while read -r id active; do + [ -n "$id" ] || continue + if [ -z "$pane" ]; then pane=$id; fi + if [ "${active:-0}" = 1 ]; then pane=$id; break; 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/tmux-backend.md b/docs/tmux-backend.md index 2eb19afd2b..bf8f8bb839 100644 --- a/docs/tmux-backend.md +++ b/docs/tmux-backend.md @@ -75,6 +75,72 @@ 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. + +**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 resolves through `list-panes` and prints the pane id, or refuses. +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. +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 +150,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..9a63556030 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,88 @@ 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" +tmux new-window -d -t "$SESSION" -n "shell-fallback" \ + || fail "could not create the bare-shell fallback window" + +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 bash +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 + # --- 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..3255f08c81 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -616,6 +616,7 @@ 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 @@ -638,10 +639,20 @@ 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 resolves through `list-panes`, which refuses. +# Both spellings are stripped so 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 old_preflight new_preflight + old_preflight=$'tmux\x1fdisplay-message\x1f-p\x1f-t\x1fsess:win\x1f#{pane_id}' + new_preflight=$'tmux\x1flist-panes\x1f-t\x1fsess:win\x1f-F\x1f#{pane_id} #{pane_active}' + awk -v old="$old_preflight" -v new="$new_preflight" \ + '$0 != old && $0 != new { print }' "$1" } test_send_conformance_old_vs_new() { @@ -658,8 +669,13 @@ 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. + 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" + assert_not_contains "$(cat "$log_new")" $'\x1f''display-message'$'\x1f''-p'$'\x1f''-t'$'\x1f''sess:win'$'\x1f''#{pane_id}' \ + "fm-send --key still uses the display-message preflight, which cannot refuse an unresolvable target" 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,6 +770,7 @@ 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 printf 'firstmate\\n'; exit 0 ;; @@ -816,6 +833,7 @@ 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*) printf x >> "$counter" 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..15ec7c93c4 100755 --- a/tests/fm-bootstrap.test.sh +++ b/tests/fm-bootstrap.test.sh @@ -677,6 +677,12 @@ 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 printf '%s\n' codex exit 0 diff --git a/tests/fm-composer-ghost.test.sh b/tests/fm-composer-ghost.test.sh index ffaa7e5c2d..e1c5c03ea8 100755 --- a/tests/fm-composer-ghost.test.sh +++ b/tests/fm-composer-ghost.test.sh @@ -43,6 +43,7 @@ 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 printf 'fakepane\n'; exit 0 ;; diff --git a/tests/fm-context-reset.test.sh b/tests/fm-context-reset.test.sh index bfaf1dbaca..15dbe01ea4 100755 --- a/tests/fm-context-reset.test.sh +++ b/tests/fm-context-reset.test.sh @@ -75,6 +75,7 @@ install_fake_tmux() { # #!/usr/bin/env bash printf '%s\n' "$*" >> "${FM_FAKE_TMUX_LOG:-/dev/null}" case "${1:-}" in + list-panes) printf '%%1 1\n'; exit 0 ;; display-message) case "$*" in *cursor_y*) printf '5\n' ;; 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..936f9c20ca 100755 --- a/tests/fm-gate-refuse.test.sh +++ b/tests/fm-gate-refuse.test.sh @@ -148,6 +148,7 @@ case "$*" in *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; esac case "${1:-}" in + list-panes) printf '%%1 1\n'; exit 0 ;; display-message) printf 'firstmate\n'; exit 0 ;; list-windows) exit 0 ;; has-session|new-session|new-window|send-keys|set-window-option) exit 0 ;; @@ -215,6 +216,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..40eca31886 100755 --- a/tests/fm-grok-harness.test.sh +++ b/tests/fm-grok-harness.test.sh @@ -19,6 +19,7 @@ case "$*" in *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; esac case "${1:-}" in + list-panes) printf '%%1 1\n'; exit 0 ;; display-message) printf 'firstmate\n'; exit 0 ;; list-windows) exit 0 ;; has-session|new-session|new-window|send-keys|kill-window) exit 0 ;; diff --git a/tests/fm-pending-reply.test.sh b/tests/fm-pending-reply.test.sh index 5e700bf074..babbb8a816 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 diff --git a/tests/fm-role-config.test.sh b/tests/fm-role-config.test.sh index faf150e2dc..518c6c447e 100755 --- a/tests/fm-role-config.test.sh +++ b/tests/fm-role-config.test.sh @@ -464,6 +464,7 @@ case "$*" in *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; esac case "${1:-}" in + list-panes) printf '%%1 1\n'; exit 0 ;; display-message) printf 'firstmate\n'; exit 0 ;; list-windows) exit 0 ;; esac diff --git a/tests/fm-secondmate-harness.test.sh b/tests/fm-secondmate-harness.test.sh index e59aadcfb7..1f58cd61d4 100755 --- a/tests/fm-secondmate-harness.test.sh +++ b/tests/fm-secondmate-harness.test.sh @@ -428,6 +428,7 @@ case "$*" in *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; esac case "${1:-}" in + list-panes) printf '%%1 1\n'; exit 0 ;; display-message) printf 'firstmate\n'; exit 0 ;; list-windows) exit 0 ;; has-session|new-session|new-window|kill-window) exit 0 ;; diff --git a/tests/fm-secondmate-liveness.test.sh b/tests/fm-secondmate-liveness.test.sh index 9d56911be4..0fb5d55c46 100755 --- a/tests/fm-secondmate-liveness.test.sh +++ b/tests/fm-secondmate-liveness.test.sh @@ -56,6 +56,7 @@ 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 exit 0 ;; @@ -221,6 +222,7 @@ 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 exit 0 ;; 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..edea6dd5c3 100755 --- a/tests/fm-send-popup-settle.test.sh +++ b/tests/fm-send-popup-settle.test.sh @@ -48,6 +48,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) exit 0 ;; display-message) for a in "$@"; do case "$a" in *cursor_y*) printf '0\n'; exit 0 ;; esac; done diff --git a/tests/fm-send-secondmate-marker.test.sh b/tests/fm-send-secondmate-marker.test.sh index b0d205cba4..0f91915fa8 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 diff --git a/tests/fm-send-settle.test.sh b/tests/fm-send-settle.test.sh index 0ef7f7ee79..30e4d649bf 100755 --- a/tests/fm-send-settle.test.sh +++ b/tests/fm-send-settle.test.sh @@ -34,6 +34,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) exit 0 ;; display-message) for a in "$@"; do case "$a" in *cursor_y*) printf '0\n'; exit 0 ;; esac; done 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..e307b79b90 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -25,6 +25,7 @@ case "$*" in *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; esac case "${1:-}" in + list-panes) printf '%%1 1\n'; exit 0 ;; display-message) printf 'firstmate\n'; exit 0 ;; list-windows) exit 0 ;; has-session|new-session|new-window|kill-window) exit 0 ;; diff --git a/tests/fm-spawn-worktree-settle.test.sh b/tests/fm-spawn-worktree-settle.test.sh index fe16ac3559..89909c13f2 100755 --- a/tests/fm-spawn-worktree-settle.test.sh +++ b/tests/fm-spawn-worktree-settle.test.sh @@ -46,6 +46,7 @@ case "$*" in ;; esac case "${1:-}" in + list-panes) printf '%%1 1\n'; exit 0 ;; display-message) printf 'firstmate\n'; exit 0 ;; list-windows) exit 0 ;; has-session|new-session|new-window|kill-window) exit 0 ;; diff --git a/tests/fm-tangle-guard.test.sh b/tests/fm-tangle-guard.test.sh index 8273ecd7bd..a89bb3a06e 100755 --- a/tests/fm-tangle-guard.test.sh +++ b/tests/fm-tangle-guard.test.sh @@ -163,6 +163,7 @@ case "$*" in *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; esac case "${1:-}" in + list-panes) printf '%%1 1\n'; exit 0 ;; display-message) printf 'firstmate\n'; exit 0 ;; list-windows) exit 0 ;; has-session|new-session|new-window|send-keys) exit 0 ;; @@ -241,6 +242,7 @@ case "$*" in *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; esac case "${1:-}" in + list-panes) printf '%%1 1\n'; exit 0 ;; display-message) printf 'firstmate\n'; exit 0 ;; new-window) printf '%s\n' "@spawnwid"; exit 0 ;; list-windows) exit 0 ;; @@ -296,8 +298,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..53ecf17500 100755 --- a/tests/fm-tmux-submit-busy.test.sh +++ b/tests/fm-tmux-submit-busy.test.sh @@ -25,6 +25,7 @@ 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 diff --git a/tests/fm-tmux-target-resolve.test.sh b/tests/fm-tmux-target-resolve.test.sh new file mode 100755 index 0000000000..e4f37b8d25 --- /dev/null +++ b/tests/fm-tmux-target-resolve.test.sh @@ -0,0 +1,257 @@ +#!/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, which +# is spelled as a `-t` whose argument is "$pane", "$PANE", "$RESOLVED_PANE", or +# "$TMUX_PANE". Reading a raw caller-supplied target (`-t "$target"`, +# `-t "$1"`, `-t "$win"`, ...) fails here. +# +# 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. +# The '$pane' spellings below are the literal source text being matched, not +# variables to expand. +# shellcheck disable=SC2016 +unguarded_display_message_reads() { # -> prints offending file:line matches + grep -rn --include='*.sh' 'display-message -p -t' "$1" \ + | grep -v -- '-t "\$pane"' \ + | grep -v -- '-t "\$PANE"' \ + | grep -v -- '-t "\$RESOLVED_PANE"' \ + | grep -v -- '-t "\$TMUX_PANE"' \ + | grep -v '^[^:]*:[0-9]*: *#' || true +} + +test_no_unguarded_display_message_read_in_bin() { + # First prove the detector can fail. 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" + 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.sh" + [ -n "$(unguarded_display_message_reads "$probe_dir")" ] \ + || fail "the unguarded-display-message rule does not detect a known offender; it is not enforcing anything" + + 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)" +} + +# The resolver itself must stay built on a command that refuses. If it is ever +# reimplemented on 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 + 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" + assert_not_contains "$body" "display-message" \ + "fm_tmux_resolve_pane must not be built on display-message, which never refuses" + pass "fm_tmux_resolve_pane is built on a tmux command that refuses an unknown target" +} + +test_resolver_refuses_what_display_message_would_answer +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..072ffb67fa 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 diff --git a/tests/wake-helpers.sh b/tests/wake-helpers.sh index b0f3dcf8b3..150cf02084 100644 --- a/tests/wake-helpers.sh +++ b/tests/wake-helpers.sh @@ -77,6 +77,12 @@ 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_current_command*) printf '%s\n' "${FM_FAKE_TMUX_CURRENT_COMMAND:-}"; exit 0 ;; @@ -123,6 +129,9 @@ 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 @@ -204,6 +213,7 @@ 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 From ca002c3b4823e53562df2906d759c20facf17b5a Mon Sep 17 00:00:00 2001 From: Coditan-XO Date: Thu, 6 Aug 2026 03:54:21 +0000 Subject: [PATCH 2/3] no-mistakes(review): resolve a target to the pane it names, not the active one --- bin/fm-tmux-lib.sh | 49 ++++-- docs/tmux-backend.md | 40 ++++- tests/fm-backend-tmux-smoke.test.sh | 71 +++++++- tests/fm-backend.test.sh | 39 +++-- tests/fm-bootstrap.test.sh | 1 + tests/fm-composer-ghost.test.sh | 2 +- tests/fm-context-reset.test.sh | 20 ++- tests/fm-gate-refuse.test.sh | 4 +- tests/fm-grok-harness.test.sh | 4 +- tests/fm-pending-reply.test.sh | 2 +- tests/fm-role-config.test.sh | 4 +- tests/fm-secondmate-harness.test.sh | 4 +- tests/fm-secondmate-liveness.test.sh | 4 +- tests/fm-send-popup-settle.test.sh | 2 +- tests/fm-send-secondmate-marker.test.sh | 2 +- tests/fm-send-settle.test.sh | 2 +- tests/fm-spawn-dispatch-profile.test.sh | 4 +- tests/fm-spawn-worktree-settle.test.sh | 4 +- tests/fm-tangle-guard.test.sh | 8 +- tests/fm-tmux-submit-busy.test.sh | 5 +- tests/fm-tmux-target-resolve.test.sh | 207 +++++++++++++++++++++--- tests/secondmate-helpers.sh | 1 + tests/wake-helpers.sh | 8 +- 23 files changed, 408 insertions(+), 79 deletions(-) diff --git a/bin/fm-tmux-lib.sh b/bin/fm-tmux-lib.sh index a1adac687a..66bad12fd7 100755 --- a/bin/fm-tmux-lib.sh +++ b/bin/fm-tmux-lib.sh @@ -83,14 +83,28 @@ FM_TMUX_BUSY_REGEX_DEFAULT='esc (to )?interrupt|Working\.\.\.|Ctrl\+c:cancel' # 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 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. +# `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 @@ -107,18 +121,21 @@ FM_TMUX_BUSY_REGEX_DEFAULT='esc (to )?interrupt|Working\.\.\.|Ctrl\+c:cancel' # 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 id active pane='' + local target=${1:-} listing named id [ -n "$target" ] || return 1 - listing=$(tmux list-panes -t "$target" -F '#{pane_id} #{pane_active}' 2>/dev/null) || return 1 - while read -r id active; do - [ -n "$id" ] || continue - if [ -z "$pane" ]; then pane=$id; fi - if [ "${active:-0}" = 1 ]; then pane=$id; break; fi + 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 <` 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. +`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. diff --git a/tests/fm-backend-tmux-smoke.test.sh b/tests/fm-backend-tmux-smoke.test.sh index 9a63556030..60894759f8 100755 --- a/tests/fm-backend-tmux-smoke.test.sh +++ b/tests/fm-backend-tmux-smoke.test.sh @@ -185,8 +185,17 @@ 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" -tmux new-window -d -t "$SESSION" -n "shell-fallback" \ +# 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" @@ -222,7 +231,7 @@ 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 bash +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 @@ -240,6 +249,64 @@ 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 3255f08c81..6d1553e529 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -619,7 +619,7 @@ 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 ;; @@ -644,19 +644,21 @@ run_send_case() { # -- # 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 resolves through `list-panes`, which refuses. -# Both spellings are stripped so the diff still compares the calls that carry the -# actual send behavior. +# 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 old_preflight new_preflight - old_preflight=$'tmux\x1fdisplay-message\x1f-p\x1f-t\x1fsess:win\x1f#{pane_id}' - new_preflight=$'tmux\x1flist-panes\x1f-t\x1fsess:win\x1f-F\x1f#{pane_id} #{pane_active}' - awk -v old="$old_preflight" -v new="$new_preflight" \ - '$0 != old && $0 != new { 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" @@ -671,11 +673,18 @@ test_send_conformance_old_vs_new() { expect_code "$rc_old" "$rc_new" "fm-send --key: old vs new exit code" # 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. + # 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" - assert_not_contains "$(cat "$log_new")" $'\x1f''display-message'$'\x1f''-p'$'\x1f''-t'$'\x1f''sess:win'$'\x1f''#{pane_id}' \ - "fm-send --key still uses the display-message preflight, which cannot refuse an unresolvable target" + 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 \ @@ -772,7 +781,7 @@ set -u 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 @@ -835,7 +844,7 @@ set -u 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-bootstrap.test.sh b/tests/fm-bootstrap.test.sh index 15ec7c93c4..c72a87f568 100755 --- a/tests/fm-bootstrap.test.sh +++ b/tests/fm-bootstrap.test.sh @@ -684,6 +684,7 @@ if [ "${1:-}" = list-panes ]; then 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 e1c5c03ea8..9cf966d693 100755 --- a/tests/fm-composer-ghost.test.sh +++ b/tests/fm-composer-ghost.test.sh @@ -45,7 +45,7 @@ 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 15dbe01ea4..d27e2c53ff 100755 --- a/tests/fm-context-reset.test.sh +++ b/tests/fm-context-reset.test.sh @@ -75,7 +75,12 @@ install_fake_tmux() { # #!/usr/bin/env bash printf '%s\n' "$*" >> "${FM_FAKE_TMUX_LOG:-/dev/null}" case "${1:-}" in - list-panes) printf '%%1 1\n'; exit 0 ;; + # 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' ;; @@ -184,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-gate-refuse.test.sh b/tests/fm-gate-refuse.test.sh index 936f9c20ca..2089f00617 100755 --- a/tests/fm-gate-refuse.test.sh +++ b/tests/fm-gate-refuse.test.sh @@ -149,7 +149,9 @@ case "$*" in esac case "${1:-}" in list-panes) printf '%%1 1\n'; exit 0 ;; - display-message) printf 'firstmate\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 diff --git a/tests/fm-grok-harness.test.sh b/tests/fm-grok-harness.test.sh index 40eca31886..46d3979bd6 100755 --- a/tests/fm-grok-harness.test.sh +++ b/tests/fm-grok-harness.test.sh @@ -20,7 +20,9 @@ case "$*" in esac case "${1:-}" in list-panes) printf '%%1 1\n'; exit 0 ;; - display-message) printf 'firstmate\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 babbb8a816..9716997cbe 100755 --- a/tests/fm-pending-reply.test.sh +++ b/tests/fm-pending-reply.test.sh @@ -60,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 518c6c447e..f450c6dca1 100755 --- a/tests/fm-role-config.test.sh +++ b/tests/fm-role-config.test.sh @@ -465,7 +465,9 @@ case "$*" in esac case "${1:-}" in list-panes) printf '%%1 1\n'; exit 0 ;; - display-message) printf 'firstmate\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 1f58cd61d4..bc017daeeb 100755 --- a/tests/fm-secondmate-harness.test.sh +++ b/tests/fm-secondmate-harness.test.sh @@ -429,7 +429,9 @@ case "$*" in esac case "${1:-}" in list-panes) printf '%%1 1\n'; exit 0 ;; - display-message) printf 'firstmate\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 0fb5d55c46..d2cf4e2af3 100755 --- a/tests/fm-secondmate-liveness.test.sh +++ b/tests/fm-secondmate-liveness.test.sh @@ -58,7 +58,7 @@ 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 @@ -224,7 +224,7 @@ 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-send-popup-settle.test.sh b/tests/fm-send-popup-settle.test.sh index edea6dd5c3..47542c99a7 100755 --- a/tests/fm-send-popup-settle.test.sh +++ b/tests/fm-send-popup-settle.test.sh @@ -51,7 +51,7 @@ 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 0f91915fa8..2248bffeb1 100755 --- a/tests/fm-send-secondmate-marker.test.sh +++ b/tests/fm-send-secondmate-marker.test.sh @@ -54,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 30e4d649bf..2736de7cc2 100755 --- a/tests/fm-send-settle.test.sh +++ b/tests/fm-send-settle.test.sh @@ -37,7 +37,7 @@ 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-spawn-dispatch-profile.test.sh b/tests/fm-spawn-dispatch-profile.test.sh index e307b79b90..70f970868b 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -26,7 +26,9 @@ case "$*" in esac case "${1:-}" in list-panes) printf '%%1 1\n'; exit 0 ;; - display-message) printf 'firstmate\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 89909c13f2..e8ebedc748 100755 --- a/tests/fm-spawn-worktree-settle.test.sh +++ b/tests/fm-spawn-worktree-settle.test.sh @@ -47,7 +47,9 @@ case "$*" in esac case "${1:-}" in list-panes) printf '%%1 1\n'; exit 0 ;; - display-message) printf 'firstmate\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 a89bb3a06e..ef333b2461 100755 --- a/tests/fm-tangle-guard.test.sh +++ b/tests/fm-tangle-guard.test.sh @@ -164,7 +164,9 @@ case "$*" in esac case "${1:-}" in list-panes) printf '%%1 1\n'; exit 0 ;; - display-message) printf 'firstmate\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 @@ -243,7 +245,9 @@ case "$*" in esac case "${1:-}" in list-panes) printf '%%1 1\n'; exit 0 ;; - display-message) printf 'firstmate\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 ;; diff --git a/tests/fm-tmux-submit-busy.test.sh b/tests/fm-tmux-submit-busy.test.sh index 53ecf17500..2cfbeedc21 100755 --- a/tests/fm-tmux-submit-busy.test.sh +++ b/tests/fm-tmux-submit-busy.test.sh @@ -28,7 +28,10 @@ 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 index e4f37b8d25..87cbd34b6e 100755 --- a/tests/fm-tmux-target-resolve.test.sh +++ b/tests/fm-tmux-target-resolve.test.sh @@ -190,63 +190,222 @@ test_send_key_preflight_actually_verifies() { # 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, which -# is spelled as a `-t` whose argument is "$pane", "$PANE", "$RESOLVED_PANE", or -# "$TMUX_PANE". Reading a raw caller-supplied target (`-t "$target"`, -# `-t "$1"`, `-t "$win"`, ...) fails here. +# -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. -# The '$pane' spellings below are the literal source text being matched, not + +# 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 - grep -rn --include='*.sh' 'display-message -p -t' "$1" \ - | grep -v -- '-t "\$pane"' \ - | grep -v -- '-t "\$PANE"' \ - | grep -v -- '-t "\$RESOLVED_PANE"' \ - | grep -v -- '-t "\$TMUX_PANE"' \ - | grep -v '^[^:]*:[0-9]*: *#' || true + 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. 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. + # 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" - mkdir -p "$probe_dir" + 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.sh" + > "$probe_dir/offender-target.sh" [ -n "$(unguarded_display_message_reads "$probe_dir")" ] \ - || fail "the unguarded-display-message rule does not detect a known offender; it is not enforcing anything" + || 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)" + 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 it is ever -# reimplemented on display-message, every test above would still pass against a +# 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 + 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" - assert_not_contains "$body" "display-message" \ - "fm_tmux_resolve_pane must not be built on display-message, which never refuses" - pass "fm_tmux_resolve_pane is built on a tmux command that 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 diff --git a/tests/secondmate-helpers.sh b/tests/secondmate-helpers.sh index 072ffb67fa..9c042949cb 100644 --- a/tests/secondmate-helpers.sh +++ b/tests/secondmate-helpers.sh @@ -36,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 150cf02084..fbf4919efb 100644 --- a/tests/wake-helpers.sh +++ b/tests/wake-helpers.sh @@ -85,6 +85,7 @@ if [ "${1:-}" = "list-panes" ]; then 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 @@ -137,7 +138,10 @@ case "${1:-}" in _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' @@ -216,7 +220,7 @@ 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 ;; From 1540c6e8c995c7a8cf64b2893f30d31fd760b9a9 Mon Sep 17 00:00:00 2001 From: Coditan-XO Date: Thu, 6 Aug 2026 04:11:04 +0000 Subject: [PATCH 3/3] no-mistakes(document): point stale tmux target-read comments at the resolve gate --- bin/fm-spawn.sh | 11 +++++++---- bin/fm-supervise-daemon.sh | 7 ++++--- docs/scripts.md | 2 +- 3 files changed, 12 insertions(+), 8 deletions(-) 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/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 |