Skip to content

fix(bin): prove the primary harness owns the pane before away-mode injection - #13

Merged
dardant merged 3 commits into
mainfrom
fm/fm-afk-inject-security
Sep 25, 2026
Merged

dardant merged 3 commits into
mainfrom
fm/fm-afk-inject-security

Conversation

@dardant

@dardant dardant commented Sep 25, 2026

Copy link
Copy Markdown
Owner

Intent

Security fix: prove the harness owns the pane before the away-mode daemon types into it, so a digest can never be typed into a bare shell.

Context: the away-mode investigation found that on 2026-09-23 the away-mode supervise daemon deferred digest injection 1,558 times for six hours because an idle primary Claude pane still displayed a worker's echoed spinner line, which Herdr's native Claude detector and Firstmate's unanchored rendered regex both read as busy; the wedge alarm had no channel on Linux. Separately it proved a security defect: the composer classifier treats a bare ❯ row as an empty agent composer, so if the primary Claude exits during away mode the daemon types a digest into a plain zsh prompt, zsh executes any $(...) in worker-quoted status text, and the delivery is recorded as confirmed. The captain answered the report's calls: "both fixes, yes to Herdr".

The report's security fix, which this change is: in inject_msg, refuse unless the supervisor pane's foreground process is the detected primary harness - on Herdr, agent get must report that harness (agent == claude for a Claude primary) and the pane's process state must not be a shell; on tmux, compare pane_current_command. Also stop treating a bare, unbordered ❯ row as an empty composer for harnesses whose composer is always bordered (Claude draws ─ rules above and below it), and never accept a shell prompt reappearing as submit confirmation. The report marks this security-sensitive and gives it its own ship; its busy-guard stall fixes are a separate change.

What Changed

  • inject_msg in bin/fm-supervise-daemon.sh now refuses to type unless the new fm_backend_pane_harness_state in bin/fm-backend.sh reports that the detected primary harness owned the supervisor pane. It checks this before typing and again before counting a confirmed submit as delivered, so a shell prompt that reappears after Enter no longer counts as a submitted turn. On Herdr, agent get must name the harness and a foreground process from pane process-info must be that harness. On tmux, pane_current_command or a process in the pane tty's foreground group must match, read through an exact pane id. A foreign or unreadable result logs the refusal and keeps the escalation buffered. bin/fm-agent-process-lib.sh adds helpers that work out which harness family a process belongs to.
  • The composer classifier in bin/fm-composer-lib.sh now takes the pane's harness through FM_COMPOSER_HARNESS. For harnesses listed in FM_COMPOSER_FRAMED_HARNESSES (currently only Claude), a bare ❯ row reads unknown instead of empty unless it has a ─ rule directly above it and a closing rule below it. The daemon passes its primary harness for both the composer check and the submit confirmation.
  • bin/fm-afk-launch.sh detects the primary harness in the captain's context and passes it to the detached daemon terminal as FM_DAEMON_PRIMARY_HARNESS, on both tmux and Herdr. The /afk skill, the backend docs and the runtime verification notes describe the new ownership proof. Unit and E2E tests cover it for tmux, Herdr, the composer classifier and the launcher.

🤖 Generated with Claude Code

Risk Assessment

⚠️ Medium: This is a security-sensitive guard on unattended keystroke injection, and it fails closed with behavioral and live tests. The fix-round launcher change passes the detected harness correctly in both terminal paths. The remaining risk is delivery availability: ownership has been proven live only for claude, codex and opencode. If the family matcher cannot name a live kimi, omp, cursor or grok primary process, every digest is refused and escalation falls to the wedge alarm (the max-defer path).

Testing

I ran the targeted tmux and Herdr away-mode inject E2E suites and the afk-launch start-path E2E, and all passed. The new shell-refusal scenario fails against the pre-fix tree, so it reproduces the bug. I then drove the real daemon flush in an isolated Herdr lab with real zsh, Claude Code 2.1.280 and codex-cli 0.154.0. The pre-fix daemon ran a digest-quoted $(touch) in zsh and marked the digest delivered. The fixed daemon refused the bare-❯ zsh, suspended Claude, exited Claude and a codex pane, and kept each escalation buffered. It delivered to idle Claude, which answered. I tore the lab down and the default Herdr session was untouched. This is a terminal/daemon change, so the evidence is text captures of the Herdr pane rather than screenshots.

  • Live validation: ✅ go - 8 of 8 scenarios driven live against the product
Scenario Result Live Evidence
Primary exited to a plain zsh with a bare ❯ prompt (Herdr): the daemon refuses to type, the quoted $(touch) never runs, and the escalation stays buffered ✅ pass live herdr-live-shell-refused-fixed.txt and herdr-live-claude-exited-refused.txt: flush_rc=1, proof_file=absent, buffer kept, log shows 'inject refused ... harness=claude owner=foreign'; pre-fix contrast i…
Primary exited to a bare-❯ shell (tmux): refused, nothing typed or executed; the same test fails on pre-fix code ✅ pass live tmux-inject-e2e.txt Scenario D ok; tmux-inject-e2e-against-prefix-base.txt 'not ok - Scenario D: the shell executed a command substitution from the digest'
Idle real Claude primary on Herdr still receives the digest, the submit is confirmed and the buffer cleared ✅ pass live herdr-live-claude-delivered.txt: flush_rc=0, buffer=cleared, Claude pane shows the FIRSTMATE_OP digest and the reply 'ACK'; the probe read owner=owned and composer_claude=empty
Claude suspended with ^Z (zsh owns the terminal, Claude still a descendant) is refused ✅ pass live herdr-live-claude-suspended-refused.txt: foreground=[zsh], owner(claude)=foreign, composer_claude=unknown, flush refused, buffer kept
A different harness (codex) holding the pane while the primary is claude is refused ✅ pass live herdr-live-foreign-harness-refused.txt: agent get=codex, owner(claude)=foreign and owner(codex)=owned, flush refused with owner=foreign
The Claude framed-composer rule reads an unframed bare ❯ as unknown when the harness is named, and keeps real framed Claude empty ✅ pass live Live probes: zsh bare ❯ gives composer_unnamed=empty but composer_claude=unknown; idle Claude between ─ rules gives composer_claude=empty; Claude trust dialog gives pending
Existing tmux and Herdr inject flows (partial-input defer, swallowed Enter, normal digest) still deliver exactly one clean digest ✅ pass live tmux-inject-e2e.txt Scenarios A-C and herdr-inject-e2e.txt Scenarios A-D ok
fm-afk-launch.sh start passes the harness detected in the captain's context (codex, which publishes no env marker) to the daemon terminal on both herdr and tmux ✅ pass live afk-launch-start-path.txt: 'herdr e2e: daemon terminal receives the captain's detected codex primary' and the same for tmux
Evidence: Live Herdr: pre-fix daemon typed the digest into zsh, ran $(touch) and cleared the buffer

Source: Live Herdr: pre-fix daemon typed the digest into zsh, ran $(touch) and cleared the buffer

\### PRE-FIX base da71dc8 daemon, same live Herdr pane (zsh, bare ❯), same escalation
flush_rc=0
proof_file=EXISTS (shell executed $(touch ...))
buffer=cleared
--- daemon log ---
\### pane after:
/tmp/fm-afkown-cwd.zuqki8
❯ <2063>FIRSTMATE_OP: v1 away-supervisor: Supervisor escalate (1 event(s)): needs-decision: worker quoted $(touch /tmp/
fm-afkown-proof-base) in its status line; (pre-read; re-arm not needed — watcher daemon-managed)
zsh: no matches found: (1 event(s)):
zsh: command not found: pre-read
zsh: command not found: re-arm
/tmp/fm-afkown-cwd.zuqki8
❯
Evidence: Live Herdr: fixed daemon refuses the bare-❯ zsh pane, nothing typed, buffer kept

Source: Live Herdr: fixed daemon refuses the bare-❯ zsh pane, nothing typed, buffer kept

\### Live Herdr 0.9.0 lab fm-lab-afkown-1503470-15867, pane w1:p1 = operator zsh (bare ❯ prompt, no harness), primary harness claude
\### pane before:
/tmp/fm-afkown-cwd.zuqki8
❯
\### agent get:
{"error":{"code":"agent_not_found","message":"agent target w1:p1 not found"},"id":"cli:agent:get"}
\### verdicts (fixed code):
owner(claude)=foreign  composer_unnamed=empty  composer_claude=unknown
\### FIXED daemon escalate_flush with worker text quoting $(touch ...):
flush_rc=1
proof_file=absent
buffer=kept: needs-decision: worker quoted $(touch /tmp/fm-afkown-proof-fixed) in its status line;
--- daemon log ---
  [2026-09-25T14:45:30-0400] inject refused: supervisor pane is not owned by the primary harness (harness=claude owner=foreign); a shell or other program would receive the digest
\### pane after (nothing typed):
/tmp/fm-afkown-cwd.zuqki8
❯
Evidence: Live Herdr: fixed daemon delivers to idle real Claude 2.1.280, which replies ACK

Source: Live Herdr: fixed daemon delivers to idle real Claude 2.1.280, which replies ACK

\### FIXED daemon escalate_flush into idle real Claude 2.1.280 (Herdr lab pane w1:p1)
flush_rc=0
proof_file=absent
buffer=cleared
--- daemon log ---
\### pane after:
  ▝▝ ▝▝    /tmp/fm-afkown-cwd.zuqki8


❯ FIRSTMATE_OP: v1 away-supervisor: Supervisor escalate (1 event(s)): needs-decision: afk ownership lab check - reply
  with the single word ACK and do nothing else (pre-read; re-arm not needed — watcher daemon-managed)

● ACK

✻ Baked for 1s · done 2:46 PM

───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
❯
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  ⚠ Transcript saving is off — inherited CLAUDE_CODE_CHILD_SESSION marker · restart with CLAUDE_CODE_FORCE_SESSION_P…
  Opus 5.5 (1M context) | 1M ctx | 975k left (97%)
  ⏵⏵ auto mode on (shift+tab to cycle)
Evidence: Live Herdr: Claude suspended with ^Z is refused (zsh in the foreground)

Source: Live Herdr: Claude suspended with ^Z is refused (zsh in the foreground)

\### Claude suspended with ^Z; zsh (bare ❯) owns the terminal; claude still a descendant of the pane shell
\### agent get:
{"error":{"code":"agent_not_found","message":"agent target w1:p1 not found"},"id":"cli:agent:get"}

\### pane process-info foreground:
{"shell_pid":1523314,"fg":[{"pid":1523314,"name":"zsh"}]}
\### verdicts:
owner(claude)=foreign  composer_unnamed=empty  composer_claude=unknown
\### FIXED daemon escalate_flush:
flush_rc=1
proof_file=absent
buffer=kept: needs-decision: worker quoted $(touch /tmp/fm-afkown-proof-susp) in its status line;
--- daemon log ---
  [2026-09-25T14:46:43-0400] inject refused: supervisor pane is not owned by the primary harness (harness=claude owner=foreign); a shell or other program would receive the digest
\### pane after:
zsh: suspended  claudeft+tab to cycle)
/tmp/fm-afkown-cwd.zuqki8 50s
❯
Evidence: Live Herdr: pane refused after Claude /exit

Source: Live Herdr: pane refused after Claude /exit

\### real Claude exited with /exit during away mode; pane back at operator zsh (bare ❯)
\### pane:
                                         installation health — what the `claude doctor` terminal diagnostics cover —…
  /pptx                                  Presentation creation, editing, and analysis. When Claude needs to work with
                                         presentations (.pptx files) for: (1) Creating new presentations, (2) Modify…
  /anthropic-skills:docx                 Use this skill whenever the user wants to create, read, edit, or manipulate
                                         Word documents (.docx) or Word templates (.dotx). Triggers include: any men…

/tmp/fm-afkown-cwd.zuqki8 10s
❯
\### agent get:
{"error":{"code":"agent_not_found","message":"agent target w1:p1 not found"},"id":"cli:agent:get"}

\### verdicts:
owner(claude)=foreign  composer_unnamed=empty  composer_claude=unknown
\### FIXED daemon escalate_flush:
flush_rc=1
proof_file=absent
buffer=kept: needs-decision: worker quoted $(touch /tmp/fm-afkown-proof-exit) in its status line;
--- daemon log ---
  [2026-09-25T14:47:05-0400] inject refused: supervisor pane is not owned by the primary harness (harness=claude owner=foreign); a shell or other program would receive the digest
\### pane after:

/tmp/fm-afkown-cwd.zuqki8 10s
❯
Evidence: Live Herdr: codex in the pane while primary=claude is refused (foreign)

Source: Live Herdr: codex in the pane while primary=claude is refused (foreign)

\### real codex codex-cli 0.154.0 in the supervisor pane; detected primary harness = claude
\### pane:
> You are in /tmp/fm-afkown-cwd.zuqki8

  Do you trust the contents of this directory? Working with untrusted contents comes with higher risk of prompt
  injection. Trusting the directory allows project-local config, hooks, and exec policies to load.

› 1. Yes, continue
  2. No, quit

  Press enter to continue
\### agent get:
{"agent":"codex","agent_status":"blocked"}
\### verdicts for claude:
owner(claude)=foreign  composer_unnamed=pending  composer_claude=pending
\### verdicts for codex:
owner(codex)=owned  composer_unnamed=pending  composer_codex=pending
\### FIXED daemon escalate_flush (primary=claude):
flush_rc=1
proof_file=absent
buffer=kept: needs-decision: worker quoted $(touch /tmp/fm-afkown-proof-codex) in its status line;
--- daemon log ---
  [2026-09-25T14:47:22-0400] inject refused: supervisor pane is not owned by the primary harness (harness=claude owner=foreign); a shell or other program would receive the digest
Evidence: Live Herdr: Claude trust dialog reads owned but composer pending

Source: Live Herdr: Claude trust dialog reads owned but composer pending

\### real Claude 2.1.280 trust dialog ('❯ No, exit' row):
{"id":"cli:agent:get","result":{"agent":{"agent":"claude","agent_status":"blocked","cwd":"/tmp/fm-afkown-cwd.zuqki8","focused":true,"foreground_cwd":"/tmp/fm-afkown-cwd.zuqki8","pane_id":"w1:p1","revision":0,"state_change_seq":1,"tab_id":"w1:t1","terminal_id":"term_65c531c7287311","workspace_id":"w1"},"type":"agent_info"}}

owner(claude)=owned  composer_unnamed=pending  composer_claude=pending
Evidence: tmux inject E2E (A-D) on the fix

Source: tmux inject E2E (A-D) on the fix

ok - Scenario A: partial input defers injection; digest arrives clean after idle
ok - Scenario B: swallowed Enter produces exactly one clean digest
ok - Scenario C: a normal captain status injects exactly one clean single-line sentinel digest
ok - Scenario D: a bare-❯ shell in the supervisor pane is refused; nothing is typed or executed and the escalation stays buffered
all e2e injection tests passed
exit=0
Evidence: tmux inject E2E Scenario D failing against pre-fix base (regression reproduced)

Source: tmux inject E2E Scenario D failing against pre-fix base (regression reproduced)

ok - Scenario A: partial input defers injection; digest arrives clean after idle
ok - Scenario B: swallowed Enter produces exactly one clean digest
ok - Scenario C: a normal captain status injects exactly one clean single-line sentinel digest
not ok - Scenario D: the shell executed a command substitution from the digest
exit=1
Evidence: Real-Herdr inject E2E

Source: Real-Herdr inject E2E

ok - real herdr Scenario A: partial input defers injection; digest arrives clean after idle
ok - real herdr Scenario B: swallowed Enter (via the herdr shim) produces exactly one clean digest
ok - real herdr Scenario C: a normal captain status injects exactly one clean single-line sentinel digest
ok - real herdr Scenario D: a persistently pending composer raises the max-defer wedge alarm, preserves the buffer, and never crashes the daemon
all real-herdr afk injection e2e tests passed
exit=0
Evidence: afk-launch start path passes the detected harness to the daemon (herdr and tmux)

Source: afk-launch start path passes the detected harness to the daemon (herdr and tmux)

ok - clear-stale: removes escalations buffer, sidecar, and wedge marker
ok - clear-stale: leaves the durable wake-queue intact (no pending work dropped)
ok - enter: one call writes the record with the words, expected return, and spend cap, reads it back without asking for a go, and launches no daemon
ok - enter: the retired --grant flag is refused by name and leaves the standing record alone
ok - enter: refuses while the prior return catch-up is pending
ok - propose: the retired wait-for-go step is refused by name, writes nothing, and releases the launcher lock
ok - confirm: the retired wait-for-go step is refused by name, writes nothing, and releases the launcher lock
ok - pi: start refuses to launch the daemon and writes no state
ok - pi: start-native refuses to prepare a daemon
ok - pi-signed: start refuses to launch the daemon and writes no state
ok - pi-signed: start-native refuses to prepare a daemon
ok - pi enter stop: reports that no daemon terminal was running
ok - daemon entry: no daemon lifecycle starts without the away-posture record
ok - daemon entry: enter then start-native run back to back with no confirmation between them
ok - failed start: preserves the posture record enter wrote
ok - stop: clears the away flag and archives the posture record under its entry time
ok - launcher paths: relative home and state ignore CDPATH before daemon command construction
ok - launcher paths: absolute symlink spellings are preserved
ok - launcher paths: unresolved relative FM_HOME fails loudly
ok - launcher paths: unresolved relative FM_STATE_OVERRIDE fails loudly
ok - refresh: daemon already alive - stale artifacts preserved (current session's buffer kept)
ok - mode: a fresh entry with FM_AFK_MODE=quiet writes quiet
ok - mode: a fresh entry with FM_AFK_MODE unset defaults to away
ok - mode: a bare refresh (FM_AFK_MODE unset) of an already-running quiet daemon preserves quiet, never resets to away
ok - mode: an empty (legacy pre-mode) flag reads as away
ok - mode: a bare-epoch-timestamp (legacy pre-mode) flag reads as away
ok - mode: unrecognized content falls back to away
ok - mode: a missing flag reads as away
ok - stop-ordering: daemon SIGTERM'd while .afk still present (flush is not a no-op)
ok - stop-ordering: .afk cleared last
ok - stop-ordering: daemon-terminal record removed
ok - stop identity: stale lock cannot signal an unrelated live process
ok - failed start: away flag and delivery artifacts roll back
ok - concurrent start: one serialized daemon terminal remains tracked
ok - launcher lock: incomplete publication receives initialization grace
ok - launcher signal: TERM exits and releases the lifecycle lock
fm-afk-launch: daemon launched in non-visible herdr workspace ws-partial (pane lab:pane-exact), supervising lab:captain
ok - herdr create: malformed response recovers durable exact ownership
fm-afk-launch: herdr create failed after returning exact ids; closing lab:pane-exact
fm-afk-launch: recorded terminal teardown is unconfirmed; preserving exact id
ok - herdr create error: unconfirmed exact id is persisted for reconciliation
fm-afk-launch: failed to run daemon in herdr pane lab:pane-exact; closing it
fm-afk-launch: recorded terminal teardown is unconfirmed; preserving exact id
ok - herdr run failure: unconfirmed exact id remains reconcilable
fm-afk-launch: failed to persist daemon terminal record; closing tmux:exact-session
ok - record failure: newly created terminal is closed by exact id
fm-afk-launch: daemon did not become ready; closing tmux:exact-session
ok - readiness failure: exact terminal and durable record roll back
fm-afk-launch: daemon did not become ready; closing tmux:exact-session
fm-afk-launch: recorded terminal teardown is unconfirmed; preserving exact id
ok - readiness failure: unconfirmed terminal retains its reconciliation id
ok - tmux absence: clean missing differs from transport probe failure
ok - native lifecycle: launcher owns state with no terminal
ok - native lifecycle: uniform stop clears state without closing a terminal
ok - native entry: launcher-prepared lifecycle state is not rewritten
fm-afk-launch: reconciling leaked daemon terminal tmux:exact-session
fm-afk-launch: recorded terminal teardown is unconfirmed; preserving exact id
ok - teardown failure: exact terminal record is preserved
ok - record publication: failed atomic rename preserves the complete prior record
fm-afk-launch: daemon terminal record is malformed; refusing to act on it
ok - record read: malformed record fails closed without acting on a partial id
fm-afk-launch: daemon terminal record is malformed; refusing to act on it
fm-afk-launch: malformed daemon terminal record; refusing to stop away mode
ok - stop: malformed terminal record preserves away state and fails closed
fm-afk-launch: failed to create detached tmux daemon session 'fm-afk-daemon-3580037265-1477822-4911-1790361864'
ok - tmux launch: planned exact target is recorded before creation and removed on failure
fm-afk-launch: failed to create detached tmux daemon session 'fm-afk-daemon-3256921001-1478038-21526-1790361865'
ok - tmux launch: unique names eliminate collision teardown
ok - stop validation: malformed record causes no daemon or state side effects
ok - launcher lock: incomplete metadata fails acquisition and releases lock
fm-afk-launch: failed to clear away-mode flag
fm-afk-launch: away mode stopped; terminal teardown or the record archive remains recorded for retry
ok - stop state: away-flag removal failure is surfaced
fm-afk-launch: away-mode daemon did not exit after SIGTERM; preserving lifecycle state
ok - stop liveness: captured live daemon preserves lifecycle state after lock release
fm-afk-launch: an away-posture record is required; run enter before starting the daemon
fm-afk-launch: an away-posture record is required; run enter before starting the daemon
ok - refresh record: malformed terminal identity fails closed
fm-afk-launch: an away-posture record is required; run enter before starting the daemon
ok - clear failure: native entry aborts and restores prior state
fm-afk-launch: reconciling leaked daemon terminal tmux:exact-session
fm-afk-launch: terminal close command failed, but exact absence was confirmed
ok - confirmed absence: cleanup succeeds and removes the stale record
fm-afk-launch: rollback restoration incomplete; backup retained at /tmp/fm-afk-restore-fail.ncc5lK/state/.afk-launch-backup.PH6EWw
ok - rollback restore: incomplete restoration retains its recovery backup
fm-afk-launch: an away-posture record is required; run enter before starting the daemon
ok - flag failure: lifecycle aborts without active state
ok - herdr e2e: captain tab pane count unchanged after start (no split)
ok - herdr e2e: daemon launched in a separate non-visible workspace
ok - herdr e2e: daemon pane is NOT in the captain's tab
ok - herdr e2e: daemon terminal scoped to the lab session
ok - herdr e2e: daemon terminal receives the captain's detected codex primary as FM_DAEMON_PRIMARY_HARNESS
ok - herdr e2e: captain tab pane count restored after stop
ok - herdr e2e: daemon workspace removed by exact id on stop
ok - herdr e2e: record + .afk cleared on stop
ok - tmux e2e: captain window pane count unchanged after start (no split-window)
ok - tmux e2e: daemon launched in a separate detached session
ok - tmux e2e: daemon terminal receives the captain's detected codex primary as FM_DAEMON_PRIMARY_HARNESS
ok - tmux e2e: captain window pane count unchanged after stop
ok - tmux e2e: daemon session killed by exact id on stop
ok - tmux e2e: record + .afk cleared on stop
exit=0

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 info
  • 🚨 bin/fm-supervise-daemon.sh:673 - The new ownership proof silently disables away-mode delivery for the verified primaries whose daemon bin/fm-afk-launch.sh start launches (codex, opencode, omp, kimi, cursor, per .agents/skills/afk/SKILL.md step 2). supervisor_pane_owned compares the pane against fm_daemon_primary_harness, which runs fm-harness.sh inside the daemon. fm_afk_launch_create_tmux and fm_afk_launch_create_herdr (bin/fm-afk-launch.sh:500 and :527) start the daemon as exec env FM_HOME=.. FM_SUPERVISOR_TARGET=.. FM_SUPERVISOR_BACKEND=.. fm-afk-start.sh in a detached tmux session or Herdr workspace. No harness is among the daemon's parent processes, and codex, opencode, omp and kimi publish no environment marker, so detection returns unknown. This was checked directly: fm-harness.sh run orphaned from any harness with a clean environment prints unknown. fm_agent_harness_family unknown returns 1, so fm_backend_*_pane_harness_state prints foreign. Every digest is then refused with harness=unknown owner=foreign and stays buffered until the wedge alarm fires. Before this change those digests were delivered. tests/fm-afk-inject-herdr-e2e.test.sh masks the problem by pinning FM_DAEMON_PRIMARY_HARNESS=omp. Fix: in both launch paths, resolve the harness in the captain's context with the existing fm_afk_launch_primary_harness and pass it as FM_DAEMON_PRIMARY_HARNESS in the exec env command, the same way FM_SUPERVISOR_TARGET is passed. Add a test that drives the start path without pinning the harness.

🔧 Fix applied.
1 info still open:

  • ℹ️ bin/backends/herdr.sh:118 - The intent says that on Herdr, agent get must report the primary harness (for example agent == claude for a Claude primary) and the pane's process state must not be a shell. fm_backend_herdr_native_agent_name checks the native name only for claude, codex and pi. For omp, opencode, kimi, cursor and grok, fm_backend_herdr_pane_harness_state skips agent get and relies on the foreground-process-group proof alone. That proof is stronger than "not a shell", so digests still cannot be typed into a shell for those harnesses; the only missing piece is the extra registration check. The change documents this as deliberate, because an unverified Herdr name would refuse every delivery. Please confirm that dropping agent get for harnesses without a verified Herdr name is acceptable, or extend the verified list once those names are measured.
✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 8 of 8 scenarios driven live against the product
Scenario Result Live Evidence
Primary exited to a plain zsh with a bare ❯ prompt (Herdr): the daemon refuses to type, the quoted $(touch) never runs, and the escalation stays buffered ✅ pass live herdr-live-shell-refused-fixed.txt and herdr-live-claude-exited-refused.txt: flush_rc=1, proof_file=absent, buffer kept, log shows 'inject refused ... harness=claude owner=foreign'; pre-fix contrast i…
Primary exited to a bare-❯ shell (tmux): refused, nothing typed or executed; the same test fails on pre-fix code ✅ pass live tmux-inject-e2e.txt Scenario D ok; tmux-inject-e2e-against-prefix-base.txt 'not ok - Scenario D: the shell executed a command substitution from the digest'
Idle real Claude primary on Herdr still receives the digest, the submit is confirmed and the buffer cleared ✅ pass live herdr-live-claude-delivered.txt: flush_rc=0, buffer=cleared, Claude pane shows the FIRSTMATE_OP digest and the reply 'ACK'; the probe read owner=owned and composer_claude=empty
Claude suspended with ^Z (zsh owns the terminal, Claude still a descendant) is refused ✅ pass live herdr-live-claude-suspended-refused.txt: foreground=[zsh], owner(claude)=foreign, composer_claude=unknown, flush refused, buffer kept
A different harness (codex) holding the pane while the primary is claude is refused ✅ pass live herdr-live-foreign-harness-refused.txt: agent get=codex, owner(claude)=foreign and owner(codex)=owned, flush refused with owner=foreign
The Claude framed-composer rule reads an unframed bare ❯ as unknown when the harness is named, and keeps real framed Claude empty ✅ pass live Live probes: zsh bare ❯ gives composer_unnamed=empty but composer_claude=unknown; idle Claude between ─ rules gives composer_claude=empty; Claude trust dialog gives pending
Existing tmux and Herdr inject flows (partial-input defer, swallowed Enter, normal digest) still deliver exactly one clean digest ✅ pass live tmux-inject-e2e.txt Scenarios A-C and herdr-inject-e2e.txt Scenarios A-D ok
fm-afk-launch.sh start passes the harness detected in the captain's context (codex, which publishes no env marker) to the daemon terminal on both herdr and tmux ✅ pass live afk-launch-start-path.txt: 'herdr e2e: daemon terminal receives the captain's detected codex primary' and the same for tmux
  • bash tests/fm-afk-inject-e2e.test.sh (private tmux server, real zsh bare-❯ shell, Scenarios A-D)
  • tests/fm-afk-inject-e2e.test.sh run against a git archive da71dc8 copy of the pre-fix tree: Scenario D fails with 'the shell executed a command substitution from the digest', so the regression reproduces
  • bash tests/fm-afk-inject-herdr-e2e.test.sh (real Herdr 0.9.0, isolated fm-lab session)
  • bash tests/fm-afk-launch.test.sh (real herdr and tmux start path under a codex-named captain, no pinned FM_DAEMON_PRIMARY_HARNESS)
  • Live Herdr lab via bin/fm-herdr-lab.sh provision/run/teardown (session fm-lab-afkown-*): the real daemon escalate_flush (sourced from bin/fm-supervise-daemon.sh, FM_SUPERVISOR_BACKEND=herdr) was run against the operator's zsh with a bare ❯ prompt, fixed vs pre-fix code
  • Live lab: real Claude Code 2.1.280 trust dialog probe (owned, composer pending)
  • Live lab: daemon flush into idle real Claude 2.1.280, which delivered the digest and got the reply ACK
  • Live lab: daemon flush with Claude suspended via ctrl+z (zsh in the foreground)
  • Live lab: daemon flush after Claude /exit
  • Live lab: daemon flush with real codex-cli 0.154.0 in the pane while primary=claude
  • Per-state probes of fm_backend_pane_harness_state and fm_backend_composer_state with and without FM_COMPOSER_HARNESS=claude
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

…jection

The away-mode daemon typed digests into whatever its rendered guards called an
empty composer. A zsh prompt themed with a bare `❯` reads exactly like an idle
Claude composer, so a primary that exited or crashed to its login shell received
the digest, zsh ran any command substitution a worker had quoted, and the
delivery was recorded as confirmed.

inject_msg now requires fm_backend_pane_harness_state to prove, from the pane's
foreground process group (plus Herdr's native agent name where verified), that
the detected primary harness owns the terminal before any guard runs, and again
before a submit counts as delivered. A refusal keeps the escalation buffered for
the existing wedge path. The composer classifier also takes the caller's harness
(FM_COMPOSER_HARNESS): for Claude, whose composer is always framed by solid
rules, an unframed agent glyph row now reads unknown instead of empty.
@dardant
dardant merged commit f648a32 into main Sep 25, 2026
21 checks passed
dardant added a commit that referenced this pull request Sep 25, 2026
Keep both sides where this branch's harness-pid helpers and main's pane
ownership helpers (#13) were added next to each other in the tmux and
Herdr adapters.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant