Skip to content

fix(bin): scope each task's temp root to its Firstmate home - #20

Merged
cloud-practitioner merged 3 commits into
mainfrom
fm/fm-spawn-tasktmp-home-scope
Oct 1, 2026
Merged

cloud-practitioner merged 3 commits into
mainfrom
fm/fm-spawn-tasktmp-home-scope

Conversation

@cloud-practitioner

@cloud-practitioner cloud-practitioner commented Oct 1, 2026 •

Copy link
Copy Markdown
Owner

Intent

Fix test hygiene found along the way: bin/fm-spawn.sh's per-task temp root /tmp/fm- is not scoped to the Firstmate home, so test spawns that are never torn down leak it into /tmp, and two homes could collide on the same task id. Scope each task's temp root to its Firstmate home.

What Changed

  • bin/fm-spawn.sh now creates each task's private 0700 temp root at state/<id>.tasktmp/ inside the spawning home instead of the shared /tmp/fm-<id>. It still records the path as tasktmp= in the task meta and exports GOTMPDIR (now shell-quoted) to gotmp/ under that root. When a task spawned before this change is relaunched, it keeps its recorded legacy /tmp/fm-<id> root, so teardown still removes the directory the task actually used. The path contract now lives in the script header.
  • bin/fm-live-lab.sh down now removes only the lab's home-hashed launch dirs; its task temp roots are deleted along with the lab root. Comments in bin/fm-teardown.sh now point to the spawn header. The home layout skill and docs/configuration.md list the new state/<id>.tasktmp/ entry.
  • Tests no longer delete /tmp/fm-<id> by hand: fixtures and assertions use the home-scoped root. A new relaunch test checks that a legacy recorded root is kept and that a record with no root gets the home-scoped one.

🤖 Generated with Claude Code

Risk Assessment

✅ Low: The change is well bounded. It moves the per-task temp root from /tmp/fm- to state/.tasktmp in the spawning home (realpath, created 0700, and the existing unsafe-root refusal still applies) and records it as tasktmp=. Teardown removes exactly the recorded root. Relaunch keeps a recorded legacy /tmp/fm- so no root is left behind. GOTMPDIR is now shell-quoted. Both fix rounds are verified: the legacy child removal is gone from forced teardown, and the relaunch fixtures record home-scoped roots, with /tmp/fm- left only in the rl91 legacy case, which registers it for cleanup.

Testing

I drove the real fm-spawn, fm-teardown, and fm-control relaunch in two throwaway fm-lab-* Herdr sessions. tmux is not installed on this host. The runs used marked disposable lab homes, a scratch git project, raw shell workers, and one real Claude worker. All five live scenarios passed: - home-scoped root creation and the pane GOTMPDIR export, including a home path with spaces - no collision between homes on the same id - teardown that only touches its own home - refusal of an unsafe pre-existing root - a legacy-root relaunch followed by its removal The sixth scenario, relaunching a record that has no tasktmp=, was only covered by the repo's fake-backend test suite, so it is marked untested. I also ran the three changed test files (fm-gotmp, fm-kimi-harness, and fm-control-relaunch), and all of them pass. Teardown removed both lab sessions, every scratch home, the treehouse pool entries my spawns created, and the leftover dirs from my relaunch-test run. The live default Herdr session was never touched. The change has no UI, so the evidence is CLI transcripts.

  • Live validation: ✅ go - 5 of 6 scenarios driven live against the product
Scenario Result Live Evidence
Spawning a task in a Firstmate home records tasktmp=<home>/state/<id>.tasktmp, creates it 0700 with gotmp/, exports GOTMPDIR to the pane, and never creates /tmp/fm-<id> ✅ pass live live-tasktmp-herdr-lab.log (S1): meta tasktmp under home-a/state, drwx------, the pane prints the scoped GOTMPDIR, legacy /tmp/fm-<id> created? no
Two Firstmate homes spawning the same task id get distinct temp roots, with no collision, and a home path containing spaces is quoted correctly in the pane export ✅ pass live live-tasktmp-herdr-lab.log (S2): distinct roots: yes; pane B prints GOTMPDIR with the 'home b with space' path intact
Tearing down a task in one home removes only that home's temp root, and the other home's root with the same id survives until its own teardown ✅ pass live live-tasktmp-herdr-lab.log (S3): A root exists after teardown? no; B root exists after A's teardown? yes; B root exists after its own teardown? no
Adversarial: spawn refuses a pre-planted symlink, and also a world-writable directory, at the home-scoped task temp root, and writes no meta and nothing through the symlink ✅ pass live live-tasktmp-herdr-lab.log (S4/S4b): 'error: task temp root ... is not a private directory owned by this user; refusing to use it', spawn rc=1, C meta written? no, decoy got gotmp? no
Relaunching a live task whose record names the legacy /tmp/fm-<id> root keeps that root (the record and the agent's GOTMPDIR point at it), and teardown then removes it ✅ pass live live-legacy-relaunch-herdr-lab.log: a real Claude worker relaunched via fm-control; tasktmp=/tmp/fm-lr3864852x is kept, the sentinel survives, the relaunched claude pid environ shows GOTMPDIR=/tmp/fm-…
Relaunching a record with no tasktmp= gives it the home-scoped root rather than /tmp/fm-<id>, and the changed fixtures keep no shared /tmp/fm-<id> roots ⏸️ untested no The earlier payload did not show a live result for this scenario. It was only exercised by tests/fm-control-relaunch.test.sh and tests/fm-kimi-harness.test.sh, which run against the repo's fake tmux/h…
Evidence: Live Herdr lab transcript: spawn, same-id collision, teardown, unsafe-root refusal

Source: Live Herdr lab transcript: spawn, same-id collision, teardown, unsafe-root refusal


=== provision isolated Herdr lab session fm-lab-tasktmp-3728996-16836

=== S1: spawn task tt3728994x in home A
fm-gate-refuse: gate agent lifecycle permitted only against lab home /tmp/fm-tasktmp-live.X4bA23/home-a
warning: /tmp/fm-tasktmp-live.X4bA23/home-a/data/tt3728994x/launch-brief.md records no ship branch; defaulting to legacy branch fm/tt3728994x
warning: /tmp/fm-tasktmp-live.X4bA23/home-a/data/tt3728994x/launch-brief.md records no delivery contract line (scaffolded before ship briefs recorded one); launching on the explicit --mode no-mistakes - confirm its definition of done matches
spawned tt3728994x harness=sh kind=ship mode=no-mistakes yolo=off window=fm-lab-tasktmp-3728996-16836:w1:p2 worktree=~/.treehouse/proj-937372/1/proj
spawn rc=0
meta tasktmp=/tmp/fm-tasktmp-live.X4bA23/home-a/state/tt3728994x.tasktmp
drwx------ node /tmp/fm-tasktmp-live.X4bA23/home-a/state/tt3728994x.tasktmp
drwxr-xr-x node /tmp/fm-tasktmp-live.X4bA23/home-a/state/tt3728994x.tasktmp/gotmp
legacy /tmp/fm-tt3728994x created? no
pane A sees:
❯ export GOTMPDIR='/tmp/fm-tasktmp-live.X4bA23/home-a/state/tt3728994x.tasktmp/gotmp'
GOTMPDIR=[/tmp/fm-tasktmp-live.X4bA23/home-a/state/tt3728994x.tasktmp/gotmp]

=== S2: spawn the SAME task id tt3728994x in home B (path contains spaces)
fm-gate-refuse: gate agent lifecycle permitted only against lab home /tmp/fm-tasktmp-live.X4bA23/home b with space
warning: /tmp/fm-tasktmp-live.X4bA23/home b with space/data/tt3728994x/launch-brief.md records no ship branch; defaulting to legacy branch fm/tt3728994x
warning: /tmp/fm-tasktmp-live.X4bA23/home b with space/data/tt3728994x/launch-brief.md records no delivery contract line (scaffolded before ship briefs recorded one); launching on the explicit --mode no-mistakes - confirm its definition of done matches
spawned tt3728994x harness=sh kind=ship mode=no-mistakes yolo=off window=fm-lab-tasktmp-3728996-16836:w1:p3 worktree=~/.treehouse/proj-937372/1/proj
spawn rc=0
meta tasktmp=/tmp/fm-tasktmp-live.X4bA23/home b with space/state/tt3728994x.tasktmp
drwx------ node /tmp/fm-tasktmp-live.X4bA23/home b with space/state/tt3728994x.tasktmp
drwxr-xr-x node /tmp/fm-tasktmp-live.X4bA23/home b with space/state/tt3728994x.tasktmp/gotmp
legacy /tmp/fm-tt3728994x created? no
pane B sees:
❯ export GOTMPDIR='/tmp/fm-tasktmp-live.X4bA23/home b with space/state/tt3728994x.tasktmp/gotmp'
GOTMPDIR=[/tmp/fm-tasktmp-live.X4bA23/home b with space/state/tt3728994x.tasktmp/gotmp]
distinct roots: yes

=== S3: tear down tt3728994x in home A; B's root must survive
fm-gate-refuse: gate agent lifecycle permitted only against lab home /tmp/fm-tasktmp-live.X4bA23/home-a
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
●  WATCHER DOWN - SUPERVISION IS OFF
●  1 task(s) in flight, but no watcher has a fresh beacon (last beat: never, grace 300s).
●  Trust the emitted supervision protocol for this harness; do not use shell & for watcher repair.
●  This is a supervision warning only; the guarded operation WILL still run.
●  watcher supervision needs Stop-owned automatic recovery; inspect the hook registration and startup status before ending the turn.
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
teardown: reaping leaked worktree process(es) for tt3728994x: 3731324 3732962 3733786
teardown: force-killing leaked worktree process(es) for tt3728994x: 3731324
🌳 Worktree returned to pool.
/tmp/fm-tasktmp-live.X4bA23/proj: already current
teardown tt3728994x complete (window fm-lab-tasktmp-3728996-16836:w1:p2, worktree ~/.treehouse/proj-937372/1/proj)
Backlog: tt3728994x just finished (this home keeps no markdown backlog at /tmp/fm-tasktmp-live.X4bA23/home-a/data/backlog.md). Update /tmp/fm-tasktmp-live.X4bA23/home-a/data/backlog.md - move tt3728994x to Done, keep Done to the 10 most recent, then re-scan Queued and dispatch only work whose blockers are gone and date is due.
teardown rc=0
A root exists after teardown? no
A meta exists after teardown? no
B root exists after A's teardown? yes
fm-gate-refuse: gate agent lifecycle permitted only against lab home /tmp/fm-tasktmp-live.X4bA23/home b with space
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
●  WATCHER DOWN - SUPERVISION IS OFF
●  1 task(s) in flight, but no watcher has a fresh beacon (last beat: never, grace 300s).
●  Trust the emitted supervision protocol for this harness; do not use shell & for watcher repair.
●  This is a supervision warning only; the guarded operation WILL still run.
●  watcher supervision needs Stop-owned automatic recovery; inspect the hook registration and startup status before ending the turn.
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🌳 Worktree returned to pool.
/tmp/fm-tasktmp-live.X4bA23/proj: already current
teardown tt3728994x complete (window fm-lab-tasktmp-3728996-16836:w1:p3, worktree ~/.treehouse/proj-937372/1/proj)
Backlog: tt3728994x just finished (this home keeps no markdown backlog at /tmp/fm-tasktmp-live.X4bA23/home b with space/data/backlog.md). Update /tmp/fm-tasktmp-live.X4bA23/home b with space/data/backlog.md - move tt3728994x to Done, keep Done to the 10 most recent, then re-scan Queued and dispatch only work whose blockers are gone and date is due.
teardown B rc=0
B root exists after its own teardown? no

=== S4 (adversarial): pre-planted symlink at home C's task temp root
fm-gate-refuse: gate agent lifecycle permitted only against lab home /tmp/fm-tasktmp-live.X4bA23/home-c
warning: /tmp/fm-tasktmp-live.X4bA23/home-c/data/tt3728994x/launch-brief.md records no ship branch; defaulting to legacy branch fm/tt3728994x
warning: /tmp/fm-tasktmp-live.X4bA23/home-c/data/tt3728994x/launch-brief.md records no delivery contract line (scaffolded before ship briefs recorded one); launching on the explicit --mode no-mistakes - confirm its definition of done matches
error: task temp root /tmp/fm-tasktmp-live.X4bA23/home-c/state/tt3728994x.tasktmp already exists and is not a private directory owned by this user; refusing to use it; inspect and remove it, then retry
spawn rc=1
C meta written? no
decoy got gotmp? no

=== S4b (adversarial): group-writable pre-existing root in home C
fm-gate-refuse: gate agent lifecycle permitted only against lab home /tmp/fm-tasktmp-live.X4bA23/home-c
warning: /tmp/fm-tasktmp-live.X4bA23/home-c/data/tt3728994x/launch-brief.md records no ship branch; defaulting to legacy branch fm/tt3728994x
warning: /tmp/fm-tasktmp-live.X4bA23/home-c/data/tt3728994x/launch-brief.md records no delivery contract line (scaffolded before ship briefs recorded one); launching on the explicit --mode no-mistakes - confirm its definition of done matches
error: task temp root /tmp/fm-tasktmp-live.X4bA23/home-c/state/tt3728994x.tasktmp already exists and is not a private directory owned by this user; refusing to use it; inspect and remove it, then retry
spawn rc=1
C meta written? no

=== cleanup
lab session fm-lab-tasktmp-3728996-16836 torn down
legacy root /tmp/fm-tt3728994x exists after cleanup? no
Evidence: Driver script for the spawn/collision/teardown/refusal scenarios

Source: Driver script for the spawn/collision/teardown/refusal scenarios

#!/usr/bin/env bash
# Live validation driver: real bin/fm-spawn.sh / bin/fm-teardown.sh in two
# disposable marked lab homes, on an isolated fm-lab-* Herdr session through
# bin/fm-herdr-lab.sh. Proves each task's temp root is scoped to its home.
set -u
REPO=${REPO:?}
EV=${EV:?}
cd "$REPO"
unset HERDR_ENV HERDR_PANE_ID HERDR_TAB_ID HERDR_WORKSPACE_ID HERDR_SOCKET_PATH HERDR_SESSION
unset FM_GATE_REFUSE_BYPASS FM_ROOT_OVERRIDE FM_STATE_OVERRIDE FM_DATA_OVERRIDE FM_CONFIG_OVERRIDE FM_PROJECTS_OVERRIDE
LAB_HELPER="$REPO/bin/fm-herdr-lab.sh"
SESSION=$("$LAB_HELPER" name tasktmp)
export HERDR_SESSION="$SESSION"
SCRATCH=$(mktemp -d "$(cd "${TMPDIR:-/tmp}" && pwd -P)/fm-tasktmp-live.XXXXXX")
A="$SCRATCH/home-a"
B="$SCRATCH/home b with space"
C="$SCRATCH/home-c"
ID="tt$$x"
step() { printf '\n=== %s\n' "$*"; }
cleanup() {
  step "cleanup"
  for h in "$A" "$B" "$C"; do
    for m in "$h"/state/*.meta; do
      [ -f "$m" ] || continue
      wt=$(grep '^worktree=' "$m" | cut -d= -f2-)
      [ -n "$wt" ] && treehouse return --force "$wt" >/dev/null 2>&1
    done
  done
  "$LAB_HELPER" teardown "$SESSION" && echo "lab session $SESSION torn down"
  find "$SCRATCH" -type d -exec chmod u+rwx {} + 2>/dev/null
  rm -rf "$SCRATCH"
  rm -rf "/tmp/fm-$ID+"* 2>/dev/null
  echo "legacy root /tmp/fm-$ID exists after cleanup? $([ -e "/tmp/fm-$ID" ] && echo yes || echo no)"
}
trap cleanup EXIT

step "provision isolated Herdr lab session $SESSION"
"$LAB_HELPER" provision "$SESSION" || exit 1

for h in "$A" "$B" "$C"; do
  "$REPO/bin/fm-lab-home.sh" create "$h" >/dev/null || exit 1
  printf 'off\n' > "$h/config/herdr-presentation-spaces"
  mkdir -p "$h/data/$ID"
  printf '# Task\n## Captain'"'"'s intent\nLive tasktmp check.\n\n## Firstmate spec\nNothing.\n' > "$h/data/$ID/brief.md"
done
PROJ="$SCRATCH/proj"
mkdir -p "$PROJ"; git -C "$PROJ" init -q; echo '# p' > "$PROJ/README.md"
git -C "$PROJ" add README.md; git -C "$PROJ" -c user.name=t -c user.email=t@e.invalid commit -qm init
git clone -q --bare "$PROJ" "$PROJ.origin.git"; git -C "$PROJ" remote add origin "file://$PROJ.origin.git"

[ -e "/tmp/fm-$ID" ] && { echo "precondition: /tmp/fm-$ID already exists"; exit 1; }

spawn() {  # <home> <label>
  FM_SPAWN_NO_GUARD=1 FM_HOME="$1" "$REPO/bin/fm-spawn.sh" "$ID" "$PROJ" \
    "sh -c 'echo GOTMPDIR=[\$GOTMPDIR]; exec sleep 900'" --mode no-mistakes --yolo off --backend herdr
}
show_root() {  # <home>
  local m="$1/state/$ID.meta" t
  t=$(grep '^tasktmp=' "$m" | cut -d= -f2-)
  echo "meta tasktmp=$t"
  stat -c '%A %U %n' "$t" "$t/gotmp"
}
capture() {  # <home>
  local pane
  pane=$(grep '^herdr_pane_id=' "$1/state/$ID.meta" | cut -d= -f2-)
  ( . "$REPO/bin/fm-backend.sh"; fm_backend_source herdr; fm_backend_herdr_capture "$SESSION:$pane" 40 ) | grep -E 'GOTMPDIR' | tail -3
}

step "S1: spawn task $ID in home A"
spawn "$A"; echo "spawn rc=$?"
show_root "$A"
echo "legacy /tmp/fm-$ID created? $([ -e "/tmp/fm-$ID" ] && echo yes || echo no)"
sleep 3
echo "pane A sees:"; capture "$A"

step "S2: spawn the SAME task id $ID in home B (path contains spaces)"
spawn "$B"; echo "spawn rc=$?"
show_root "$B"
echo "legacy /tmp/fm-$ID created? $([ -e "/tmp/fm-$ID" ] && echo yes || echo no)"
sleep 3
echo "pane B sees:"; capture "$B"
TA=$(grep '^tasktmp=' "$A/state/$ID.meta" | cut -d= -f2-)
TB=$(grep '^tasktmp=' "$B/state/$ID.meta" | cut -d= -f2-)
[ "$TA" != "$TB" ] && echo "distinct roots: yes" || echo "distinct roots: NO"
echo marker-from-A > "$TA/gotmp/a-file"

step "S3: tear down $ID in home A; B's root must survive"
FM_HOME="$A" "$REPO/bin/fm-teardown.sh" "$ID"; echo "teardown rc=$?"
echo "A root exists after teardown? $([ -e "$TA" ] && echo yes || echo no)"
echo "A meta exists after teardown? $([ -e "$A/state/$ID.meta" ] && echo yes || echo no)"
echo "B root exists after A's teardown? $([ -d "$TB/gotmp" ] && echo yes || echo no)"
FM_HOME="$B" "$REPO/bin/fm-teardown.sh" "$ID"; echo "teardown B rc=$?"
echo "B root exists after its own teardown? $([ -e "$TB" ] && echo yes || echo no)"

step "S4 (adversarial): pre-planted symlink at home C's task temp root"
mkdir -p "$SCRATCH/decoy"
ln -s "$SCRATCH/decoy" "$C/state/$ID.tasktmp"
spawn "$C"; echo "spawn rc=$?"
echo "C meta written? $([ -e "$C/state/$ID.meta" ] && echo yes || echo no)"
echo "decoy got gotmp? $([ -e "$SCRATCH/decoy/gotmp" ] && echo yes || echo no)"
rm -f "$C/state/$ID.tasktmp"
step "S4b (adversarial): group-writable pre-existing root in home C"
mkdir "$C/state/$ID.tasktmp"; chmod 777 "$C/state/$ID.tasktmp"
spawn "$C"; echo "spawn rc=$?"
echo "C meta written? $([ -e "$C/state/$ID.meta" ] && echo yes || echo no)"
Evidence: Live Herdr lab transcript: real Claude worker relaunched on a legacy /tmp/fm-<id> record

Source: Live Herdr lab transcript: real Claude worker relaunched on a legacy /tmp/fm-<id> record


=== provision isolated Herdr lab session fm-lab-relaunch-3864853-29160

=== spawn real Claude ship task lr3864852x
fm-gate-refuse: gate agent lifecycle permitted only against lab home /tmp/fm-relaunch-live.mp0aAC/home
warning: /tmp/fm-relaunch-live.mp0aAC/home/data/lr3864852x/launch-brief.md records no ship branch; defaulting to legacy branch fm/lr3864852x
warning: /tmp/fm-relaunch-live.mp0aAC/home/data/lr3864852x/launch-brief.md records no delivery contract line (scaffolded before ship briefs recorded one); launching on the explicit --mode local-only - confirm its definition of done matches
notice: lr3864852x ships mode=local-only while the standing posture for proj is no-mistakes - less rigor than the captain's standing posture; proceed only on a current explicit captain instruction or an intake judgment you can state
spawned lr3864852x harness=claude kind=ship mode=local-only yolo=off window=fm-lab-relaunch-3864853-29160:w1:p2 worktree=~/.treehouse/proj-2a28f6/1/proj
spawn rc=0
harness=claude
tasktmp=/tmp/fm-relaunch-live.mp0aAC/home/state/lr3864852x.tasktmp
backend=herdr
agent idle after 6x2s
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
❯
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  Haiku 4.5 | ctx: 14% used
  ⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents
initial Claude agent process environment:
pid 3867303 comm=claude GOTMPDIR=/tmp/fm-relaunch-live.mp0aAC/home/state/lr3864852x.tasktmp/gotmp

=== simulate a pre-change task: rewrite its record to the legacy shared root /tmp/fm-lr3864852x
tasktmp=/tmp/fm-lr3864852x

=== relaunch through fm-control
fm-gate-refuse: gate agent lifecycle permitted only against lab home /tmp/fm-relaunch-live.mp0aAC/home
fm-gate-refuse: gate agent lifecycle permitted only against lab home /tmp/fm-relaunch-live.mp0aAC/home
warning: /tmp/fm-relaunch-live.mp0aAC/home/data/lr3864852x/launch-brief.md records no ship branch; defaulting to legacy branch fm/lr3864852x
warning: /tmp/fm-relaunch-live.mp0aAC/home/data/lr3864852x/launch-brief.md records no delivery contract line (scaffolded before ship briefs recorded one); launching on the explicit --mode local-only - confirm its definition of done matches
notice: lr3864852x ships mode=local-only while the standing posture for proj is no-mistakes - less rigor than the captain's standing posture; proceed only on a current explicit captain instruction or an intake judgment you can state
relaunched lr3864852x harness=claude from=claude model=haiku effort=default backend=herdr endpoint=fm-lab-relaunch-3864853-29160:w1:p2 worktree=~/.treehouse/proj-2a28f6/1/proj
relaunch rc=0
tasktmp=/tmp/fm-lr3864852x
legacy root kept with sentinel? yes
home-scoped root recreated by relaunch? no (/tmp/fm-relaunch-live.mp0aAC/home/state/lr3864852x.tasktmp)
relaunched Claude agent process environment:
pid 3870298 comm=claude GOTMPDIR=/tmp/fm-lr3864852x/gotmp

=== teardown removes the recorded legacy root
fm-gate-refuse: gate agent lifecycle permitted only against lab home /tmp/fm-relaunch-live.mp0aAC/home
WARNING: watcher still down (same stale episode; last beat: never, grace 300s) - full banner already printed this episode.
teardown: reaping leaked worktree process(es) for lr3864852x: 3865870 3870298 3871134
teardown: force-killing leaked worktree process(es) for lr3864852x: 3865870
🌳 Worktree returned to pool.
teardown lr3864852x complete (window fm-lab-relaunch-3864853-29160:w1:p2, worktree ~/.treehouse/proj-2a28f6/1/proj)
Backlog: lr3864852x just finished (this home keeps no markdown backlog at /tmp/fm-relaunch-live.mp0aAC/home/data/backlog.md). Update /tmp/fm-relaunch-live.mp0aAC/home/data/backlog.md - move lr3864852x to Done, keep Done to the 10 most recent, then re-scan Queued and dispatch only work whose blockers are gone and date is due.
teardown rc=0
legacy root exists after teardown? no

=== cleanup
lab session fm-lab-relaunch-3864853-29160 torn down
Evidence: Driver script for the legacy-root relaunch scenario

Source: Driver script for the legacy-root relaunch scenario

#!/usr/bin/env bash
# Live validation driver: a real Claude ship task in a disposable marked lab
# home on an isolated fm-lab-* Herdr session. Its record is rewritten to the
# legacy shared root /tmp/fm-<id> (what a task spawned before this change
# recorded), then a real bin/fm-control.sh relaunch must keep that root, and
# bin/fm-teardown.sh must remove it.
set -u
REPO=${REPO:?}
cd "$REPO"
unset HERDR_ENV HERDR_PANE_ID HERDR_TAB_ID HERDR_WORKSPACE_ID HERDR_SOCKET_PATH HERDR_SESSION
unset FM_GATE_REFUSE_BYPASS FM_ROOT_OVERRIDE FM_STATE_OVERRIDE FM_DATA_OVERRIDE FM_CONFIG_OVERRIDE FM_PROJECTS_OVERRIDE
export DISABLE_AUTOUPDATER=1
LAB_HELPER="$REPO/bin/fm-herdr-lab.sh"
SESSION=$("$LAB_HELPER" name relaunch)
export HERDR_SESSION="$SESSION"
SCRATCH=$(mktemp -d "$(cd "${TMPDIR:-/tmp}" && pwd -P)/fm-relaunch-live.XXXXXX")
H="$SCRATCH/home"
ID="lr$$x"
LEGACY="/tmp/fm-$ID"
step() { printf '\n=== %s\n' "$*"; }
cleanup() {
  step "cleanup"
  if [ -f "$H/state/$ID.meta" ]; then
    FM_HOME="$H" "$REPO/bin/fm-teardown.sh" "$ID" --force >/dev/null 2>&1
    wt=$(grep '^worktree=' "$H/state/$ID.meta" 2>/dev/null | cut -d= -f2-)
    [ -n "$wt" ] && treehouse return --force "$wt" >/dev/null 2>&1
  fi
  "$LAB_HELPER" teardown "$SESSION" && echo "lab session $SESSION torn down"
  find "$SCRATCH" -type d -exec chmod u+rwx {} + 2>/dev/null
  rm -rf "$SCRATCH" "$LEGACY" "/tmp/fm-$ID+"*
}
trap cleanup EXIT
pane_text() {
  local pane
  pane=$(grep '^herdr_pane_id=' "$H/state/$ID.meta" | cut -d= -f2-)
  ( . "$REPO/bin/fm-backend.sh"; fm_backend_source herdr; fm_backend_herdr_capture "$SESSION:$pane" "${1:-60}" )
}
wait_idle() {  # wait until the agent reports idle via the herdr pane agent state
  local i st pane
  for i in $(seq 1 90); do
    pane=$(grep '^herdr_pane_id=' "$H/state/$ID.meta" | cut -d= -f2-)
    st=$("$LAB_HELPER" run "$SESSION" pane get "$pane" 2>/dev/null | jq -r '.result.pane.agent_status // .result.pane.agent_state // empty')
    [ "$st" = idle ] && { echo "agent idle after ${i}x2s"; return 0; }
    sleep 2
  done
  echo "agent never reached idle (last status: ${st:-?})"; return 1
}

step "provision isolated Herdr lab session $SESSION"
"$LAB_HELPER" provision "$SESSION" || exit 1
"$REPO/bin/fm-lab-home.sh" create "$H" >/dev/null || exit 1
printf 'off\n' > "$H/config/herdr-presentation-spaces"
mkdir -p "$H/data/$ID"
cat > "$H/data/$ID/brief.md" <<'EOF'
# Task
## Captain's intent
Reply with the single word READY and then stop; do not run any tools or edit any files.

## Firstmate spec
Reply READY and wait. Make no changes.
EOF
PROJ="$SCRATCH/proj"
mkdir -p "$PROJ"; git -C "$PROJ" init -q; echo '# p' > "$PROJ/README.md"
git -C "$PROJ" add README.md; git -C "$PROJ" -c user.name=t -c user.email=t@e.invalid commit -qm init
git clone -q --bare "$PROJ" "$PROJ.origin.git"; git -C "$PROJ" remote add origin "file://$PROJ.origin.git"
[ -e "$LEGACY" ] && { echo "precondition: $LEGACY already exists"; exit 1; }

step "spawn real Claude ship task $ID"
FM_SPAWN_NO_GUARD=1 FM_HOME="$H" "$REPO/bin/fm-spawn.sh" "$ID" "$PROJ" --mode local-only --yolo off \
  --harness claude --model haiku --backend herdr; echo "spawn rc=$?"
grep -E '^(tasktmp|harness|backend)=' "$H/state/$ID.meta"
wait_idle
pane_text 25 | grep -v '^\s*$' | tail -6
echo "initial Claude agent process environment:"
wt0=$(grep '^worktree=' "$H/state/$ID.meta" | cut -d= -f2-)
for pid in $(pgrep -u "$(id -u)" -f claude); do [ "$(readlink "/proc/$pid/cwd" 2>/dev/null)" = "$wt0" ] && { printf 'pid %s comm=%s ' "$pid" "$(cat /proc/$pid/comm)"; tr '\0' '\n' < "/proc/$pid/environ" | grep '^GOTMPDIR='; }; done

step "simulate a pre-change task: rewrite its record to the legacy shared root $LEGACY"
SCOPED=$(grep '^tasktmp=' "$H/state/$ID.meta" | cut -d= -f2-)
sed -i "s|^tasktmp=.*|tasktmp=$LEGACY|" "$H/state/$ID.meta"
rm -rf "$SCOPED"
(umask 077; mkdir "$LEGACY"); mkdir "$LEGACY/gotmp"; echo legacy-sentinel > "$LEGACY/gotmp/sentinel"
grep '^tasktmp=' "$H/state/$ID.meta"

step "relaunch through fm-control"
FM_HOME="$H" "$REPO/bin/fm-control.sh" "$ID" relaunch --note "continuing after relaunch; reply READY and stop"; echo "relaunch rc=$?"
grep '^tasktmp=' "$H/state/$ID.meta"
echo "legacy root kept with sentinel? $([ -f "$LEGACY/gotmp/sentinel" ] && echo yes || echo no)"
echo "home-scoped root recreated by relaunch? $([ -e "$SCOPED" ] && echo yes || echo no) ($SCOPED)"
sleep 3
agent_env() {
  local wt pid
  wt=$(grep '^worktree=' "$H/state/$ID.meta" | cut -d= -f2-)
  for pid in $(pgrep -u "$(id -u)" -f claude); do
    [ "$(readlink "/proc/$pid/cwd" 2>/dev/null)" = "$wt" ] || continue
    printf 'pid %s comm=%s ' "$pid" "$(cat /proc/$pid/comm)"; tr '\0' '\n' < "/proc/$pid/environ" | grep '^GOTMPDIR='
  done
}
echo "relaunched Claude agent process environment:"; agent_env

step "teardown removes the recorded legacy root"
FM_HOME="$H" "$REPO/bin/fm-teardown.sh" "$ID"; echo "teardown rc=$?"
echo "legacy root exists after teardown? $([ -e "$LEGACY" ] && echo yes || echo no)"
Evidence: Key live output (same id in two homes)
home A: tasktmp=/tmp/fm-tasktmp-live.X4bA23/home-a/state/tt3728994x.tasktmp drwx------
pane: GOTMPDIR=[/tmp/fm-tasktmp-live.X4bA23/home-a/state/tt3728994x.tasktmp/gotmp]
home B: tasktmp=/tmp/fm-tasktmp-live.X4bA23/home b with space/state/tt3728994x.tasktmp drwx------
pane: GOTMPDIR=[/tmp/fm-tasktmp-live.X4bA23/home b with space/state/tt3728994x.tasktmp/gotmp]
legacy /tmp/fm-tt3728994x created? no
A root exists after teardown? no / B root exists after A's teardown? yes
Evidence: fm-kimi-harness test log

Source: fm-kimi-harness test log

ok - Kimi hook install is idempotent and removal restores every foreign config byte
ok - Kimi hook removal preserves owned newline boundaries and pristine bytes
ok - Kimi hook install refuses missing, malformed, and surprising config without writing
ok - Kimi hook install refuses without jq before any config write
ok - fm-spawn: kimi launches, delivers its brief, and registers a guarded turn-end token
ok - fm-spawn: unsafe task roots are refused, owned roots are tightened, and launch files stay unique and 0600
ok - fm-spawn: the task temp root is scoped to the spawning home, and teardown removes only that home's root
ok - Kimi hook stays silent and inert without a Firstmate registry token
ok - fm-spawn: unsafe Kimi global config refuses before pane creation
ok - fm-teardown: Kimi task pointer and registry token are removed
ok - fm-spawn: Kimi fallback expands the active HOME
ok - fm-spawn: missing Kimi executable refuses before pane creation
ok - fm-spawn: kimi treats a silent pointer drop as a failed spawn
ok - fm-spawn: kimi never sends the brief pointer before an observable ready signal
ok - fm-spawn: a fresh Kimi worktree answers the exact trust dialog once and verifies advancement
ok - fm-spawn: a swallowed Kimi trust keypress is re-sent until the dialog clears
ok - fm-spawn: a Kimi banner captured before the trust dialog paints does not read as ready
ok - fm-spawn: an answered Kimi trust dialog left in scrollback neither re-answers nor fails the spawn
ok - fm-spawn: a blank Kimi viewport frame costs its poll and nothing else
ok - fm-spawn: Kimi refuses a backend that cannot read the viewport, before launching
ok - fm-spawn: a Kimi trust dialog with its hint wrapped across rows is answered normally
ok - fm-spawn: a blank Kimi frame between banners restarts the two-capture ready count
ok - fm-spawn: a failed Kimi viewport read fails readiness with the backend named
ok - fm-spawn: Kimi refuses the ready verdict while trust dialog markers remain
ok - fm-spawn: a Kimi trust dialog must visibly clear before brief delivery
ok - fm-spawn: Kimi trust detection requires every observed dialog signal
ok - fm-harness: markerless kimi keeps its ancestry identity under an inherited marker
lock acquired: harness pid 3809694
ok - fm-lock recognizes Kimi ancestry and live lock holders
ok - busy detection: real Kimi moon-plus-middot captures require its harness while idle labels stay idle
ok - fm-watch classifies Kimi as unknown rather than from its spinner, and Grok's fallback stays isolated
ok - composer classifier: kimi's existing bordered > shape is already safe without an override
fm-kimi-harness rc=0
Evidence: fm-gotmp test log

Source: fm-gotmp test log

ok - fm-teardown removes the dir pointed to by tasktmp= in meta
ok - fm-teardown skips gracefully when tasktmp= is absent (backward compat)
ok - fm-teardown skips gracefully when tasktmp= points to a nonexistent dir
fm-gotmp rc=0

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 1 issue found → auto-fixed ✅
  • ⚠️ tests/fm-control-relaunch.test.sh:1151 - One test fixture still leaks a temp root into /tmp, which is the hygiene problem this change sets out to fix. The promoted-scout test (test_promoted_scout_relaunch_receives_the_current_delivery_contract, id rl-promoted-&lt;mode&gt;) writes tasktmp=/tmp/fm-$id into its meta and then runs fm-spawn --relaunch. Relaunch now deliberately keeps a recorded legacy root (bin/fm-spawn.sh:4389-4390), so each run creates /tmp/fm-rl-promoted-no-mistakes, /tmp/fm-rl-promoted-direct-PR and /tmp/fm-rl-promoted-local-only. Nothing removes them: the id is never added to TASK_TMPS and nothing tears the task down, so they build up in /tmp on every run. Two other fixtures have a related problem: add_ship_task (tests/fm-control-relaunch.test.sh:222/229) and add_herdr_ship_task (tests/fm-control-relaunch.test.sh:2117/2130) also record tasktmp=/tmp/fm-&lt;fixed id&gt;. They are cleaned at exit, but every relaunch test still uses a shared /tmp/fm-rlNN root that two concurrent runs of this suite on one host can share, and one run's cleanup can delete it under the other. That is the cross-home collision the intent describes. Fix: in all three fixtures, record the home-scoped root ($(cd &#34;$home/state&#34; &amp;&amp; pwd -P)/$id.tasktmp) or leave tasktmp= out. Keep /tmp/fm-$id only in the new legacy-root case (rl91), and register that path for cleanup there.

🔧 Fix applied.
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 5 of 6 scenarios driven live against the product
Scenario Result Live Evidence
Spawning a task in a Firstmate home records tasktmp=<home>/state/<id>.tasktmp, creates it 0700 with gotmp/, exports GOTMPDIR to the pane, and never creates /tmp/fm-<id> ✅ pass live live-tasktmp-herdr-lab.log (S1): meta tasktmp under home-a/state, drwx------, the pane prints the scoped GOTMPDIR, legacy /tmp/fm-<id> created? no
Two Firstmate homes spawning the same task id get distinct temp roots, with no collision, and a home path containing spaces is quoted correctly in the pane export ✅ pass live live-tasktmp-herdr-lab.log (S2): distinct roots: yes; pane B prints GOTMPDIR with the 'home b with space' path intact
Tearing down a task in one home removes only that home's temp root, and the other home's root with the same id survives until its own teardown ✅ pass live live-tasktmp-herdr-lab.log (S3): A root exists after teardown? no; B root exists after A's teardown? yes; B root exists after its own teardown? no
Adversarial: spawn refuses a pre-planted symlink, and also a world-writable directory, at the home-scoped task temp root, and writes no meta and nothing through the symlink ✅ pass live live-tasktmp-herdr-lab.log (S4/S4b): 'error: task temp root ... is not a private directory owned by this user; refusing to use it', spawn rc=1, C meta written? no, decoy got gotmp? no
Relaunching a live task whose record names the legacy /tmp/fm-<id> root keeps that root (the record and the agent's GOTMPDIR point at it), and teardown then removes it ✅ pass live live-legacy-relaunch-herdr-lab.log: a real Claude worker relaunched via fm-control; tasktmp=/tmp/fm-lr3864852x is kept, the sentinel survives, the relaunched claude pid environ shows GOTMPDIR=/tmp/fm-…
Relaunching a record with no tasktmp= gives it the home-scoped root rather than /tmp/fm-<id>, and the changed fixtures keep no shared /tmp/fm-<id> roots ⏸️ untested no The earlier payload did not show a live result for this scenario. It was only exercised by tests/fm-control-relaunch.test.sh and tests/fm-kimi-harness.test.sh, which run against the repo's fake tmux/h…
  • bin/fm-herdr-lab.sh provision fm-lab-tasktmp-* + real bin/fm-spawn.sh &lt;id&gt; &lt;proj&gt; &#34;sh -c &#39;echo GOTMPDIR=[$GOTMPDIR]; exec sleep 900&#39;&#34; --mode no-mistakes --yolo off --backend herdr in marked lab home A (driver: live-tasktmp-herdr-lab.sh)
  • Same task id spawned in a second lab home whose path contains spaces; compared the recorded tasktmp=, stat of root/gotmp, and the GOTMPDIR export shown in each pane
  • Real bin/fm-teardown.sh &lt;id&gt; in home A, then checked home B's root, then tore down B
  • Adversarial: spawn with a symlink pre-planted at state/<id>.tasktmp, then with a pre-existing chmod 777 root
  • Checked that /tmp/fm-<id> was never created during spawn, collision, or teardown
  • Real Claude (haiku) ship task in an isolated fm-lab-relaunch-* Herdr session; read GOTMPDIR from /proc/<pid>/environ; rewrote the record to tasktmp=/tmp/fm-<id>; ran real bin/fm-control.sh &lt;id&gt; relaunch --note ...; ran bin/fm-teardown.sh &lt;id&gt; (driver: live-legacy-relaunch-herdr-lab.sh)
  • bash tests/fm-gotmp.test.sh
  • bash tests/fm-kimi-harness.test.sh (includes test_task_temp_root_is_scoped_to_the_spawning_home)
  • bash tests/fm-control-relaunch.test.sh (includes test_relaunch_keeps_a_legacy_task_temp_root_and_scopes_a_missing_one)
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

fm-spawn put every task's temp root at /tmp/fm-<id>, a path not tied to
the Firstmate home. Test spawns that were never torn down stranded it in
/tmp, and two homes spawning one task id (a main home and a secondmate,
or a test lab and the fleet) shared one root, so one home's teardown
deleted the other's live root.

The root now lives at state/<id>.tasktmp/ in the spawning home, next to
the other per-task state directories, so equal ids in different homes
never meet and a disposable home takes its roots with it. The pane's
GOTMPDIR export is shell-quoted now that the path follows the home.

A live task that recorded the legacy /tmp/fm-<id> keeps that root on
relaunch, so its record stays valid and teardown removes the root it
used. The live lab no longer removes /tmp/fm-<id> for its own ids.
bin/fm-spawn.sh's header owns the path contract.
@cloud-practitioner
cloud-practitioner force-pushed the fm/fm-spawn-tasktmp-home-scope branch from d7ff932 to 21c7b56 Compare October 1, 2026 02:17
@cloud-practitioner
cloud-practitioner merged commit 3b9d1d8 into main Oct 1, 2026
19 checks passed
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