Skip to content

fix(bin): stage the worker launch command in a private file so long launches aren't cut off - #4665

Closed
svycka wants to merge 3 commits into
kunchenguid:mainfrom
svycka:fm/fm-launch-source
Closed

svycka wants to merge 3 commits into
kunchenguid:mainfrom
svycka:fm/fm-launch-source

Conversation

@svycka

@svycka svycka commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Intent

Upstream issue #4559 reports that starting a worker sometimes leaves its terminal holding a mangled, unfinished command with no agent running.
Check that issue, and if it needs a fix, contribute one as a pull request to the upstream project.

Context needed to read that ask:

The issue is open and unfixed on the current default branch.
A maintainer labelled it ready-for-pr, classed it restore, and credited the finding that the launch command is being truncated by the terminal's canonical line buffer at about 1,024 bytes.
That maintainer comment names this exact approach as in scope: "A short typed source of a private per-task launch.sh (mode 0600) ... is in scope for a contributor PR. Prefer proof over fixed sleeps."
The same comment sets a security condition on it: the staged file must keep mode 0600, must live in a task-private path, must not outlive the task, and must not leak into a shared temporary location.
A competing pull request #4598 is already open with the same diagnosis but a different mechanism, waiting for the pane's terminal to leave canonical mode with a configurable timeout.
This contribution stays on its own approach and does not duplicate that one's work.
The repair itself has already been written and proven locally; the deliverable here is getting it reviewed and opened as a pull request, not redesigning it.

What Changed

  • bin/fm-spawn.sh now writes the full launch command to /tmp/fm-<id>/launch.sh with mode 0600, inside the task's existing temp root. Instead of typing the whole command into the new pane, it types one short line, . '<launch.sh>', which runs the file. The terminal's line buffer on macOS drops input past about 1,024 bytes, so a long typed command could arrive unfinished and no agent would start.
  • The task temp root /tmp/fm-<id> is now created with mode 0700. If the directory already exists, the spawn reuses it only when it's a real directory owned by the current user that nobody else can write to, and then tightens it to 0700. Otherwise it stops with an error before writing the launch file or launching anything.
  • Test fakes now spot the short line, read the launch file and log the full command it holds, so the existing launch checks still see the real command. The kimi harness tests also check the new behavior: the temp root is 0700, launch.sh is 0600 and the pane gets the short line. A new test covers the refusal of a world-writable temp root and the tightening of an owned one.

🤖 Generated with Claude Code

Risk Assessment

✅ Low: A small, contained change: fm-spawn now types a short . '/tmp/fm-<id>/launch.sh' line instead of the whole launch command. The file is written under umask 077 (mode 0600) inside a task root that is checked to be the user's own and set to 0700, and fm-teardown already deletes that root. It has only one call site, and the security conditions from the issue hold.

Testing

I built a live driver that runs the real bin/fm-spawn.sh (secondmate, codex harness) with a real tmux server on a private socket. It uses a fake codex that records its arguments, and a home path long enough to push the launch command past 1,024 bytes. Because this runs inside the no-mistakes gate, the driver unsets NO_MISTAKES_GATE and sets the project's own test switch FM_GATE_REFUSE_BYPASS=1; without them, fm-spawn refuses to run here. On the base commit, a pane shell that is still busy when the text arrives ended up holding a command cut off at 1,024 bytes, with no agent running. That is the reported bug. On the target commit, the same run started the agent with its full 7,989 bytes of arguments. A second base/target pair, where the pane shell only waited before starting, did not reproduce the bug on base; both commits started the agent in that pair. In that pair and in every target run, the staged file was 0600 and its folder 0700. The adversarial runs also passed: a world-writable or symlinked task temp folder was refused before anything was written or launched, a 0755 folder owned by this user was tightened to 0700, and teardown removed the task temp folder and launch.sh. The changed kimi harness suite also passed. I removed all temp worlds, the base extract and the tmux servers; the worktree is clean. There is no UI surface, so the evidence is CLI transcripts rather than screenshots.

  • Live validation: ✅ go - 6 of 6 scenarios driven live against the product
Scenario Result Live Evidence
Spawning a worker whose launch command is over 1,024 bytes into a pane shell that is still busy starts the agent with its full arguments (no truncated leftover command) ✅ pass live target-busyread-run.txt: a short . &#39;/tmp/fm-&lt;id&gt;/launch.sh&#39; line was typed, FAKE-CODEX-STARTED, argv 7989 bytes; base-busyread-run.txt on 9f8ad95 under the same conditions: 1,024-byte cut-off comman…
The staged launch file is mode 0600 inside a task-private 0700 folder /tmp/fm-<id> ✅ pass live target-busyread-run.txt / target-run.txt: staged file: -rw------- vytautas ...; task root: drwx------
Adversarial: a world-writable /tmp/fm-<id> planted in advance is refused before anything is staged or launched ✅ pass live target-pre-world.txt: spawn rc=1, 'is not a private directory owned by this user', no staged file, agent not started
Adversarial: /tmp/fm-<id> planted as a symlink to a world-writable folder is refused and nothing is written through it ✅ pass live target-pre-symlink.txt: spawn rc=1, refusal message, the symlink's target folder is still empty
A leftover /tmp/fm-<id> owned by this user with mode 0755 is tightened to 0700 and the spawn succeeds ✅ pass live target-pre-owned755.txt: task root drwx------, file 0600, agent started
The staged launch file does not outlive the task: teardown removes /tmp/fm-<id> including launch.sh ✅ pass live target-teardown.txt: teardown rc=0, 'task temp root /tmp/fm-<id> removed (launch.sh gone)'
Evidence: Live driver script (real tmux + real fm-spawn.sh)

Source: Live driver script (real tmux + real fm-spawn.sh)

#!/usr/bin/env bash
# Live driver: run a real bin/fm-spawn.sh (secondmate, codex harness) against a
# REAL tmux server on a private socket. The pane shell is deliberately slow to
# start (sleep before exec bash) so the typed launch text queues in the tty's
# canonical line buffer, the condition issue #4559 describes.
# Usage: live-spawn-real-tmux.sh <repo-root> <label> [startup-delay-seconds]
set -u
ROOT=$1; LABEL=$2; DELAY=${3:-4}
# PANE_CMD: the pane shell. Default: busy for DELAY seconds before bash starts.
# busyread mode: the pane consumes and runs the first typed line (export GOTMPDIR)
# in canonical mode, then stays busy for DELAY seconds, like a shell still
# finishing startup work after reading its first command.
if [ "${MODE:-sleep}" = busyread ]; then PANE_CMD="IFS= read -r l; eval \"\$l\"; sleep $DELAY; exec /bin/bash --norc --noprofile"; else PANE_CMD="sleep $DELAY; exec /bin/bash --norc --noprofile"; fi
REAL_TMUX=$(command -v tmux)
W=$(mktemp -d /tmp/fm-live-4559.XXXXXX); W=$(cd -P "$W" && pwd -P)
SOCK="fm-live-4559-$$"
ID="lv$$"
cleanup() { "$REAL_TMUX" -L "$SOCK" kill-server >/dev/null 2>&1; rm -rf "$W" "/tmp/fm-$ID"; }
trap cleanup EXIT
FB="$W/fakebin"; mkdir -p "$FB"
cat > "$FB/tmux" <<SH
#!/usr/bin/env bash
exec "$REAL_TMUX" -L "$SOCK" -f /dev/null "\$@"
SH
cat > "$FB/codex" <<SH
#!/usr/bin/env bash
printf '%s\n' "\$@" > "$W/codex-argv"
printf 'argc=%s\n' "\$#" >> "$W/codex-argv"
printf 'FAKE-CODEX-STARTED\n❯ \n'
exec sleep 600
SH
chmod +x "$FB/tmux" "$FB/codex"
export PATH="$FB:$PATH"
unset TMUX HERDR_ENV NO_MISTAKES_GATE  # isolated test fleet, not the gate agent
export FM_BACKEND=tmux FM_GATE_REFUSE_BYPASS=1
PAD=$(printf "long-secondmate-home-path-segment-%.0s" 1 2 3 4 5 6); HOME_DIR="$W/home"; SUB="$W/$PAD/$PAD/sub"; mkdir -p "$W/$PAD/$PAD"
mkdir -p "$HOME_DIR/projects" "$HOME_DIR/data" "$HOME_DIR/state"
git init -q "$HOME_DIR/projects/alpha" && git -C "$HOME_DIR/projects/alpha" -c user.name=t -c user.email=t@e commit -q --allow-empty -m init
git init -q --bare "$W/alpha.git"; git -C "$HOME_DIR/projects/alpha" remote add origin "$W/alpha.git"; git -C "$HOME_DIR/projects/alpha" push -q origin HEAD 2>/dev/null
echo '- alpha [direct-PR] - alpha project (added 2026-06-22)' > "$HOME_DIR/data/projects.md"
FM_HOME="$HOME_DIR" FM_SECONDMATE_CHARTER='live 4559 charter' "$ROOT/bin/fm-brief.sh" "$ID" --secondmate alpha >/dev/null || { echo "brief failed"; exit 2; }
FM_HOME="$HOME_DIR" "$ROOT/bin/fm-home-seed.sh" "$ID" "$SUB" alpha >/dev/null || { echo "seed failed"; exit 2; }
tmux new-session -d -s firstmate -x 220 -y 60 "$PANE_CMD"
tmux set-option -g default-command "$PANE_CMD"
# PRE: pre-plant /tmp/fm-<id> before spawn: world (0777 dir), symlink (to a dir
# another path controls), owned755 (own dir, too-open mode).
case "${PRE:-}" in
  world) mkdir "/tmp/fm-$ID"; chmod 777 "/tmp/fm-$ID"; echo "pre-planted: $(stat -f '%Sp' /tmp/fm-$ID) /tmp/fm-$ID" ;;
  symlink) mkdir "$W/attacker"; chmod 777 "$W/attacker"; ln -s "$W/attacker" "/tmp/fm-$ID"; echo "pre-planted: symlink /tmp/fm-$ID -> $W/attacker" ;;
  owned755) mkdir "/tmp/fm-$ID"; chmod 755 "/tmp/fm-$ID"; echo "pre-planted: $(stat -f '%Sp' /tmp/fm-$ID) /tmp/fm-$ID" ;;
esac
echo "== [$LABEL] fm-spawn.sh $ID <subhome> codex --secondmate (pane mode=${MODE:-sleep}, busy ${DELAY}s)"
start=$(date +%s)
FM_HOME="$HOME_DIR" "$ROOT/bin/fm-spawn.sh" "$ID" "$SUB" codex --secondmate > "$W/spawn.out" 2>&1
rc=$?
echo "spawn rc=$rc after $(( $(date +%s) - start ))s"; tail -5 "$W/spawn.out" | sed 's/^/  spawn: /'
i=0; while [ $i -lt 150 ] && [ ! -s "$W/codex-argv" ]; do sleep 0.1; i=$((i+1)); done
if [ -f "/tmp/fm-$ID/launch.sh" ]; then
  echo "staged file: $(stat -f '%Sp %Su' "/tmp/fm-$ID/launch.sh") size=$(wc -c < "/tmp/fm-$ID/launch.sh") bytes; task root: $(stat -f '%Sp' "/tmp/fm-$ID")"
else
  echo "staged file: none (launch typed directly)"
fi
grep -o 'FM_HOME=[^ ]* [^;]*codex' "$HOME_DIR/state/$ID.meta" >/dev/null 2>&1
echo "--- pane capture (window fm-$ID) ---"
tmux capture-pane -p -J -t "firstmate:fm-$ID" -S -40 | sed '/^$/d' | cut -c1-300 | tail -15
echo "--- result ---"
if [ -s "$W/codex-argv" ]; then echo "AGENT STARTED: fake codex ran with $(tail -1 "$W/codex-argv"); argv bytes=$(wc -c < "$W/codex-argv")"; else echo "AGENT DID NOT START"; fi
echo "pending command-line bytes in pane (last line of joined capture): $(tmux capture-pane -p -J -t "firstmate:fm-$ID" -S -40 | sed '/^$/d' | tail -1 | wc -c)"
echo "pane foreground: $(tmux display-message -p -t "firstmate:fm-$ID" '#{pane_current_command}' 2>&1)"
[ "${PRE:-}" = symlink ] && echo "attacker dir contents: [$(ls -A "$W/attacker")]"
if [ "${TEARDOWN:-0}" = 1 ]; then
  echo "--- teardown ---"
  FM_HOME="$HOME_DIR" "$ROOT/bin/fm-teardown.sh" "$ID" --force > "$W/td.out" 2>&1; echo "teardown rc=$?"; tail -3 "$W/td.out" | cut -c1-300 | sed 's/^/  teardown: /'
  if [ -e "/tmp/fm-$ID" ]; then echo "task temp root STILL PRESENT: $(ls -la /tmp/fm-$ID)"; else echo "task temp root /tmp/fm-$ID removed (launch.sh gone)"; fi
fi
Evidence: Base 9f8ad95 reproduces #4559: command cut off at 1,024 bytes, agent did not start

Source: Base 9f8ad95 reproduces #4559: command cut off at 1,024 bytes, agent did not start

staged file: none (launch typed directly) ... FM_HOME='/private/tmp/.../long-secondmate-home-path-segment-long-second AGENT DID NOT START pending command-line bytes in pane: 1035 pane foreground: bash

== [base 9f8ad95] fm-spawn.sh lv87479 <subhome> codex --secondmate (pane mode=busyread, busy 6s)
spawn rc=0 after 2s
  spawn: spawned lv87479 harness=codex kind=secondmate mode=secondmate yolo=off window=firstmate:fm-lv87479 worktree=/private/tmp/fm-live-4559.s5pPfr/long-secondmate-home-path-segment-long-secondmate-home-path-segment-long-secondmate-home-path-segment-long-secondmate-home-path-segment-long-secondmate-home-path-segment-long-secondmate-home-path-segment-/long-secondmate-home-path-segment-long-secondmate-home-path-segment-long-secondmate-home-path-segment-long-secondmate-home-path-segment-long-secondmate-home-path-segment-long-secondmate-home-path-segment-/sub
staged file: none (launch typed directly)
--- pane capture (window fm-lv87479) ---
export GOTMPDIR=/tmp/fm-lv87479/gotmp
FM_ROOT_OVERRIDE= FM_STATE_OVERRIDE= FM_DATA_OVERRIDE= FM_PROJECTS_OVERRIDE= FM_CONFIG_OVERRIDE= FM_PUBLIC_FOLLOWUP_PRIMARY_HOME='/private/tmp/fm-live-4559.s5pPfr/home' FM_HOME='/private/tmp/fm-live-4559.s5pPfr/long-secondmate-home-path-segment-long-secondmate-home-path-segment-long-secondmate-home-
bash-3.2$ FM_ROOT_OVERRIDE= FM_STATE_OVERRIDE= FM_DATA_OVERRIDE= FM_PROJECTS_OVERRIDE= FM_CONFIG_OVERRIDE= FM_PUBLIC_FOLLOWUP_PRIMARY_HOME='/private/tmp/fm-live-4559.s5pPfr/home' FM_HOME='/private/tmp/fm-live-4559.s5pPfr/long-secondmate-home-path-segment-long-secondmate-home-path-segment-long-second
--- result ---
AGENT DID NOT START
pending command-line bytes in pane (last line of joined capture):     1035
pane foreground: bash
Evidence: Target 85129fd under the same conditions: short source line, agent started

Source: Target 85129fd under the same conditions: short source line, agent started

staged file: -rw------- vytautas size=1384 bytes; task root: drwx------ bash-3.2$ . '/tmp/fm-lv89750/launch.sh' FAKE-CODEX-STARTED AGENT STARTED: fake codex ran with argc=2; argv bytes=7989








== [target 85129fd] fm-spawn.sh lv89750 <subhome> codex --secondmate (pane mode=busyread, busy 6s)
spawn rc=0 after 2s
  spawn: warning: secondmate lv89750 sync skipped before launch: primary default-branch commit cannot be resolved
  spawn: spawned lv89750 harness=codex kind=secondmate mode=secondmate yolo=off window=firstmate:fm-lv89750 worktree=/private/tmp/fm-live-4559.VhJg4S/long-secondmate-home-path-segment-long-secondmate-home-path-segment-long-secondmate-home-path-segment-long-secondmate-home-path-segment-long-secondmate-home-path-segment-long-secondmate-home-path-segment-/long-secondmate-home-path-segment-long-secondmate-home-path-segment-long-secondmate-home-path-segment-long-secondmate-home-path-segment-long-secondmate-home-path-segment-long-secondmate-home-path-segment-/sub
staged file: -rw------- vytautas size=    1384 bytes; task root: drwx------
--- pane capture (window fm-lv89750) ---
export GOTMPDIR=/tmp/fm-lv89750/gotmp
. '/tmp/fm-lv89750/launch.sh'
bash-3.2$ . '/tmp/fm-lv89750/launch.sh'
FAKE-CODEX-STARTED
❯ 
--- result ---
AGENT STARTED: fake codex ran with argc=2; argv bytes=    7989
pending command-line bytes in pane (last line of joined capture):        5
pane foreground: sleep
Evidence: World-writable task temp root refused

Source: World-writable task temp root refused








pre-planted: drwxrwxrwx /tmp/fm-lv91424
== [target 85129fd PRE=world] fm-spawn.sh lv91424 <subhome> codex --secondmate (pane mode=busyread, busy 2s)
spawn rc=1 after 1s
  spawn: warning: secondmate lv91424 sync skipped before launch: primary default-branch commit cannot be resolved
  spawn: error: task temp root /tmp/fm-lv91424 already exists and is not a private directory owned by this user; refusing to stage the launch command there; inspect and remove it, then retry
staged file: none (launch typed directly)
--- pane capture (window fm-lv91424) ---
--- result ---
AGENT DID NOT START
pending command-line bytes in pane (last line of joined capture):        0
pane foreground: zsh
Evidence: Symlinked task temp root refused, nothing written to the symlink's target

Source: Symlinked task temp root refused, nothing written to the symlink's target








pre-planted: symlink /tmp/fm-lv93273 -> /private/tmp/fm-live-4559.giV6L2/attacker
== [target 85129fd PRE=symlink] fm-spawn.sh lv93273 <subhome> codex --secondmate (pane mode=busyread, busy 2s)
spawn rc=1 after 1s
  spawn: warning: secondmate lv93273 sync skipped before launch: primary default-branch commit cannot be resolved
  spawn: error: task temp root /tmp/fm-lv93273 already exists and is not a private directory owned by this user; refusing to stage the launch command there; inspect and remove it, then retry
staged file: none (launch typed directly)
--- pane capture (window fm-lv93273) ---
--- result ---
AGENT DID NOT START
pending command-line bytes in pane (last line of joined capture):        0
pane foreground: zsh
attacker dir contents: []
Evidence: Own 0755 task temp root tightened to 0700 and launch succeeds

Source: Own 0755 task temp root tightened to 0700 and launch succeeds








pre-planted: drwxr-xr-x /tmp/fm-lv95293
== [target 85129fd PRE=owned755] fm-spawn.sh lv95293 <subhome> codex --secondmate (pane mode=busyread, busy 2s)
spawn rc=0 after 2s
  spawn: warning: secondmate lv95293 sync skipped before launch: primary default-branch commit cannot be resolved
staged file: -rw------- vytautas size=    1384 bytes; task root: drwx------
--- pane capture (window fm-lv95293) ---
export GOTMPDIR=/tmp/fm-lv95293/gotmp
. '/tmp/fm-lv95293/launch.sh'
bash-3.2$ . '/tmp/fm-lv95293/launch.sh'
FAKE-CODEX-STARTED
❯ 
--- result ---
AGENT STARTED: fake codex ran with argc=2; argv bytes=    7989
pending command-line bytes in pane (last line of joined capture):        5
pane foreground: sleep
Evidence: Teardown removes /tmp/fm-<id> and launch.sh

Source: Teardown removes /tmp/fm-<id> and launch.sh








== [target 85129fd teardown] fm-spawn.sh lv97086 <subhome> codex --secondmate (pane mode=busyread, busy 2s)
spawn rc=0 after 2s
  spawn: warning: secondmate lv97086 sync skipped before launch: primary default-branch commit cannot be resolved
staged file: -rw------- vytautas size=    1384 bytes; task root: drwx------
--- pane capture (window fm-lv97086) ---
export GOTMPDIR=/tmp/fm-lv97086/gotmp
. '/tmp/fm-lv97086/launch.sh'
bash-3.2$ . '/tmp/fm-lv97086/launch.sh'
FAKE-CODEX-STARTED
❯ 
--- result ---
AGENT STARTED: fake codex ran with argc=2; argv bytes=    7989
pending command-line bytes in pane (last line of joined capture):        5
pane foreground: sleep
--- teardown ---
teardown rc=0
  teardown: ●  watcher supervision needs Stop-owned automatic recovery; inspect the hook registration and startup status before ending the turn.
  teardown: ●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  teardown: teardown lv97086 complete (window firstmate:fm-lv97086, worktree /private/tmp/fm-live-4559.jiIkv6/long-secondmate-home-path-segment-long-secondmate-home-path-segment-long-secondmate-home-path-segment-long-secondmate-home-path-segment-long-secondmate-home-path-segment-long-secondmate-home-path-segmen
task temp root /tmp/fm-lv97086 removed (launch.sh gone)
Evidence: Sleep-only pane on target (both commits started the agent in this mode)

Source: Sleep-only pane on target (both commits started the agent in this mode)








== [target 85129fd] fm-spawn.sh lv80530 <subhome> codex --secondmate (pane shell starts after 4s)
spawn rc=0 after 2s
  spawn: warning: secondmate lv80530 sync skipped before launch: primary default-branch commit cannot be resolved
  spawn: spawned lv80530 harness=codex kind=secondmate mode=secondmate yolo=off window=firstmate:fm-lv80530 worktree=/private/tmp/fm-live-4559.5mvCD1/long-secondmate-home-path-segment-long-secondmate-home-path-segment-long-secondmate-home-path-segment-long-secondmate-home-path-segment-long-secondmate-home-path-segment-long-secondmate-home-path-segment-/long-secondmate-home-path-segment-long-secondmate-home-path-segment-long-secondmate-home-path-segment-long-secondmate-home-path-segment-long-secondmate-home-path-segment-long-secondmate-home-path-segment-/sub
staged file: -rw------- vytautas size=    1384 bytes; task root: drwx------
--- pane capture (window fm-lv80530) ---
export GOTMPDIR=/tmp/fm-lv80530/gotmp
. '/tmp/fm-lv80530/launch.sh'
bash-3.2$ export GOTMPDIR=/tmp/fm-lv80530/gotmp
bash-3.2$ . '/tmp/fm-lv80530/launch.sh'
FAKE-CODEX-STARTED
❯ 
--- result ---
AGENT STARTED: fake codex ran with argc=2; argv bytes=    7989
pane foreground: sleep
Evidence: Sleep-only pane on base (agent started; the bug did not show in this mode)

Source: Sleep-only pane on base (agent started; the bug did not show in this mode)

== [base 9f8ad95] fm-spawn.sh lv81801 <subhome> codex --secondmate (pane shell starts after 4s)
spawn rc=0 after 4s
  spawn: spawned lv81801 harness=codex kind=secondmate mode=secondmate yolo=off window=firstmate:fm-lv81801 worktree=/private/tmp/fm-live-4559.asBRwd/long-secondmate-home-path-segment-long-secondmate-home-path-segment-long-secondmate-home-path-segment-long-secondmate-home-path-segment-long-secondmate-home-path-segment-long-secondmate-home-path-segment-/long-secondmate-home-path-segment-long-secondmate-home-path-segment-long-secondmate-home-path-segment-long-secondmate-home-path-segment-long-secondmate-home-path-segment-long-secondmate-home-path-segment-/sub
staged file: none (launch typed directly)
--- pane capture (window fm-lv81801) ---
export GOTMPDIR=/tmp/fm-lv81801/gotmp
FM_ROOT_OVERRIDE= FM_STATE_OVERRIDE= FM_DATA_OVERRIDE= FM_PROJECTS_OVERRIDE= FM_CONFIG_OVERRIDE= FM_PUBLIC_FOLLOWUP_PRIMARY_HOME='/private/tmp/fm-live-4559.asBRwd/home' FM_HOME='/private/tmp/fm-live-4559.asBRwd/long-secondmate-home-path-segment-long-secondmate-home-path-segment-long-secondmate-home-
bash-3.2$ export GOTMPDIR=/tmp/fm-lv81801/gotmp
bash-3.2$ FM_ROOT_OVERRIDE= FM_STATE_OVERRIDE= FM_DATA_OVERRIDE= FM_PROJECTS_OVERRIDE= FM_CONFIG_OVERRIDE= FM_PUBLIC_FOLLOWUP_PRIMARY_HOME='/private/tmp/fm-live-4559.asBRwd/home' FM_HOME='/private/tmp/fm-live-4559.asBRwd/long-secondmate-home-path-segment-long-secondmate-home-path-segment-long-second
FAKE-CODEX-STARTED
❯ 
--- result ---
AGENT STARTED: fake codex ran with argc=2; argv bytes=    7989
pane foreground: sleep

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

✅ **Review** - passed

✅ No issues found.

✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 6 of 6 scenarios driven live against the product
Scenario Result Live Evidence
Spawning a worker whose launch command is over 1,024 bytes into a pane shell that is still busy starts the agent with its full arguments (no truncated leftover command) ✅ pass live target-busyread-run.txt: a short . &#39;/tmp/fm-&lt;id&gt;/launch.sh&#39; line was typed, FAKE-CODEX-STARTED, argv 7989 bytes; base-busyread-run.txt on 9f8ad95 under the same conditions: 1,024-byte cut-off comman…
The staged launch file is mode 0600 inside a task-private 0700 folder /tmp/fm-<id> ✅ pass live target-busyread-run.txt / target-run.txt: staged file: -rw------- vytautas ...; task root: drwx------
Adversarial: a world-writable /tmp/fm-<id> planted in advance is refused before anything is staged or launched ✅ pass live target-pre-world.txt: spawn rc=1, 'is not a private directory owned by this user', no staged file, agent not started
Adversarial: /tmp/fm-<id> planted as a symlink to a world-writable folder is refused and nothing is written through it ✅ pass live target-pre-symlink.txt: spawn rc=1, refusal message, the symlink's target folder is still empty
A leftover /tmp/fm-<id> owned by this user with mode 0755 is tightened to 0700 and the spawn succeeds ✅ pass live target-pre-owned755.txt: task root drwx------, file 0600, agent started
The staged launch file does not outlive the task: teardown removes /tmp/fm-<id> including launch.sh ✅ pass live target-teardown.txt: teardown rc=0, 'task temp root /tmp/fm-<id> removed (launch.sh gone)'
  • MODE=busyread live-spawn-real-tmux.sh &lt;base 9f8ad95 extract&gt; base 6 (real tmux, secondmate spawn with codex; the pane reads the first typed line, then stays busy)
  • MODE=busyread live-spawn-real-tmux.sh <worktree> target 6
  • live-spawn-real-tmux.sh &lt;worktree|base&gt; &lt;label&gt; 4 (pane shell starts after a sleep; base did not reproduce under this condition)
  • PRE=world MODE=busyread live-spawn-real-tmux.sh &lt;worktree&gt; ... 2 (world-writable /tmp/fm-<id> planted first)
  • PRE=symlink MODE=busyread live-spawn-real-tmux.sh &lt;worktree&gt; ... 2 (/tmp/fm-<id> is a symlink to a world-writable folder)
  • PRE=owned755 MODE=busyread live-spawn-real-tmux.sh <worktree> ... 2
  • TEARDOWN=1 MODE=busyread live-spawn-real-tmux.sh &lt;worktree&gt; ... 2 (spawn, then fm-teardown.sh &lt;id&gt; --force, then check /tmp/fm-<id>)
  • FM_GATE_REFUSE_BYPASS=1 bash tests/fm-kimi-harness.test.sh (changed suite, including the new shared-temp-root test)
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

… source line

A long launch line typed while the fresh pane shell is still busy waits in the
terminal's canonical line buffer, which drops input past about 1,024 bytes on
macOS, so the pane was left at an unfinished command with no agent running.
fm-spawn now writes the assembled command to the task's own temp root under
umask 077 and types only a short line that sources it.

Refs kunchenguid#4559
…ch command

The root lives at a predictable path under /tmp and now holds the whole launch
command. Create it with mode 0700, refuse one that already exists as anything but
a directory owned by this user that nobody else can write, and tighten an owned
one, so no other local user can plant or swap the staged file.

Refs kunchenguid#4559
@zachlandes

Copy link
Copy Markdown

Independent real-incident reproduction on Darwin, confirming this PR's fix direction.

A Claude-harness worker launched through the Herdr backend never started. Firstmate typed a 1,115-byte launch command into the projected pane as one canonical-mode PTY line, and Darwin's line discipline retained exactly the first 1,024 bytes and silently discarded the remaining 91 — the closing command-substitution and the launch-brief path. The preserved pane capture ends exactly at the 1,024-byte boundary (mid-token at encode lau), so no Claude process started and the pane sat at an agent-free shell that never read its brief. Two guarded relaunches reproduced it identically; a tmux replacement of the same work started immediately.

Two behaviors masked it as a started worker: the pane write was accepted (the kernel accepted the write even though canonical mode dropped the suffix), and the spawn record was published before any agent process was confirmed. That matches this PR's two parts — stage the full command in a private file and type only a short invocation, and verify the worker process actually started. The staging half removes the truncation; the verification half is what turns a future silent truncation (or any failed launch) into a loud failure instead of a phantom "started" worker.

Cross-reference: herdrdev/herdr#2862 independently documents the same 1,024-byte canonical boundary.

@kunchenguid kunchenguid left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found one security invariant that still needs to be enforced before approval: the staged file itself must be forced to mode 0600, not only created under umask 077. printf ... >"$LAUNCH_FILE" preserves the mode of an existing launch.sh, so a reused private task root containing a pre-existing 0644 file leaves the full launch command readable by other users. Please reject or replace an existing non-regular/symlink launch.sh and explicitly chmod 0600 after creating/replacing it (with an appropriate failure path), then add a behavioral regression covering a pre-existing loose-mode launch file. The 0700 root check is good, and teardown already removes the recorded task root.

@kunchenguid kunchenguid left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The requested hardening is now present on #4665. Staging writes a fresh temporary file, forces mode 0600, atomically replaces a regular existing launch.sh, and refuses symlinks or non-regular paths. The Kimi behavior test covers a pre-existing mode-0644 launch.sh and verifies the final mode is 0600. The existing 0700 task-root checks, teardown cleanup, shared spawn_send_literal path (including Herdr), and live truncation evidence remain satisfactory. Local validation passed: FM_GATE_REFUSE_BYPASS=1 bash tests/fm-kimi-harness.test.sh and bin/fm-lint.sh.

@kunchenguid

Copy link
Copy Markdown
Owner

Landed the same approach (with a bit more hardening on our side) in #4994 — thanks for the staging design and the live evidence. Closing this fork PR as superseded by that merge; happy to take follow-ups if anything still looks off on main.

@kunchenguid

Copy link
Copy Markdown
Owner

Superseded by #4994 (merged).

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.

3 participants