From d330979e983d455e3eea4fa53ec3ce081d42c37b Mon Sep 17 00:00:00 2001 From: Vu Nguyen Date: Tue, 29 Sep 2026 23:40:32 +0000 Subject: [PATCH 1/3] fix(bin): scope each task's temp root to its Firstmate home fm-spawn put every task's temp root at /tmp/fm-, 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/.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- 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- for its own ids. bin/fm-spawn.sh's header owns the path contract. --- .../skills/operational-home-layout/SKILL.md | 1 + bin/fm-live-lab.sh | 22 +++--- bin/fm-spawn.sh | 40 ++++++---- bin/fm-teardown.sh | 12 +-- docs/configuration.md | 1 + tests/fm-backend-orca.test.sh | 4 +- tests/fm-backend.test.sh | 4 - tests/fm-claude-trust.test.sh | 11 ++- tests/fm-control-relaunch.test.sh | 30 ++++++++ tests/fm-gotmp.test.sh | 9 ++- tests/fm-kimi-harness.test.sh | 73 +++++++++++++++---- tests/fm-live-lab.test.sh | 16 ++-- tests/fm-secondmate-safety.test.sh | 8 +- tests/fm-session-start.test.sh | 4 +- 14 files changed, 159 insertions(+), 76 deletions(-) diff --git a/.agents/skills/operational-home-layout/SKILL.md b/.agents/skills/operational-home-layout/SKILL.md index f51340058bb..81dab4e2133 100644 --- a/.agents/skills/operational-home-layout/SKILL.md +++ b/.agents/skills/operational-home-layout/SKILL.md @@ -67,6 +67,7 @@ state/ runtime records and signals; gitignored .devin-config.json firstmate-owned per-task Devin config (mode 600 snapshot of the user config plus the busy-state and turn-end hooks) passed through --config so no user or project config is edited; bin/fm-devin-config.sh owns it; removed by teardown .muse-session muse busy-source binding (sessions root plus task worktree) written by fm-spawn; removed by teardown .cursor-session cursor busy-source binding (projects root, task worktree, prior conversations) written by fm-spawn; removed by teardown + .tasktmp/ per-task private temp root with Go's build temp at gotmp/ (the pane's GOTMPDIR), recorded as tasktmp= in the task's meta; written by fm-spawn, removed by teardown; bin/fm-spawn.sh's header owns the path contract, including legacy /tmp/fm- records .git-hooks/ per-task git hooksPath that strips AI commit trailers at the commit object unless config/keep-ai-trailers is present; written by fm-spawn, removed by teardown (bin/fm-git-strip-ai-trailers.sh) .reconcile-nudged epoch second of the last inventory-reconcile nudge sent to this secondmate; bin/fm-secondmate-reconcile.sh owns its per-home cooldown window .backlog-close the exact backlog transition a teardown recorded before removing the task's record, so an interrupted cleanup can still be finished at the next session start; bin/fm-backlog-transition-lib.sh owns its format and replay, and a landed transition removes it diff --git a/bin/fm-live-lab.sh b/bin/fm-live-lab.sh index 65462181a02..81e041adeaf 100755 --- a/bin/fm-live-lab.sh +++ b/bin/fm-live-lab.sh @@ -53,9 +53,9 @@ # only, so the Pi trust store is never written and all of # .pi/extensions loads; sessions stay under # /pi-sessions. -# task ids lab-mate and lab-worker, unique per lab, -# because a spawn keeps a task temp dir at /tmp/fm- -# that a fixed id would share with other labs and tasks. +# task ids lab-mate and lab-worker, unique per lab. +# Their task temp roots live in the lab home's state/ and +# go with the lab root; down removes their launch dirs. # mate/ --mate: bin/fm-home-seed.sh /mate # --no-projects (an explicit path cloned from the git lab # home), launched by bin/fm-spawn.sh --secondmate. @@ -789,9 +789,9 @@ cmd_down() { die "refusing to remove the lab: its processes did not exit (pid ppid pgid state command): $details" fi echo "stopped: lab tmux server and lab processes" - # A spawn keeps /tmp/fm- and /tmp/fm-+. - # The second is scoped to this lab home for any task it spawned; the first is - # removed only for the lab's own unique ids, since another home may share it. + # A spawn keeps its launch dir at /tmp/fm-+, + # scoped to this lab home for any task it spawned; its task temp root lives + # in the home's own state/ and goes with the lab root (bin/fm-spawn.sh). home_hash=$(printf '%s' "$LAB" | shasum -a 256 | awk '{print $1}') ids=("$MATE_ID" "$WORKER_ID") for meta in "$LAB"/state/*.meta; do @@ -799,12 +799,10 @@ cmd_down() { done for id in "${ids[@]}"; do [ -n "$id" ] || continue - for dir in "/tmp/fm-$id+$home_hash" "/tmp/fm-$id"; do - [ "$dir" != "/tmp/fm-$id" ] || [ "$id" = "$MATE_ID" ] || [ "$id" = "$WORKER_ID" ] || continue - if [ -d "$dir" ] && [ ! -L "$dir" ] && [ -O "$dir" ]; then - rm -rf "$dir" && echo "removed: task temp $dir" - fi - done + dir="/tmp/fm-$id+$home_hash" + if [ -d "$dir" ] && [ ! -L "$dir" ] && [ -O "$dir" ]; then + rm -rf "$dir" && echo "removed: task launch dir $dir" + fi done if [ -f "$LAB/.fm-lab-home" ]; then "$LAB_HOME_HELPER" teardown "$LAB" || die "cannot remove the private tmux directory" diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index a1435a22c4d..fcee336cec6 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -278,6 +278,19 @@ # prevents equal task ids in different Firstmate homes from sharing a file. # Spawn refuses an unsafe pre-existing task temp root or launch namespace, and # task teardown removes only the current home's launch namespace. +# Task temp root: +# Each task gets a private 0700 temp root at state/.tasktmp/ in the +# spawning home, recorded as tasktmp= in its meta, with Go's build temp at +# gotmp/ exported to the pane as GOTMPDIR (GOTMPDIR, not the far broader +# TMPDIR). Scoping it to the home keeps equal task ids in different Firstmate +# homes from sharing a root, and a disposable home that is never torn down +# takes its roots with it instead of stranding them in /tmp. +# fm-teardown removes exactly the recorded tasktmp= root, including a forced +# secondmate teardown's children. Tasks spawned before this contract recorded +# the legacy shared root /tmp/fm-; a relaunch keeps that recorded root so +# the live task's record stays valid and teardown still removes the root the +# task used. Spawn reuses a pre-existing root only as a real directory owned by +# this user and writable by nobody else, then tightens it. # Launch environment (config/launch-env-allowlist): # Absent means unchanged ambient inheritance. A present readable regular file # opts every launch (ship, scout, secondmate, raw command, and relaunch) into @@ -655,6 +668,7 @@ YOLO_SET=0 BRANCH_PREFIX_SET=0 TRACEPARENT_SET=0 RELAUNCH=0 +RELAUNCH_TASK_TMP= POS=() want_value= for a in "$@"; do @@ -1786,6 +1800,7 @@ if [ "$RELAUNCH" -eq 1 ]; then exit 1 fi fi + RELAUNCH_TASK_TMP=$(fm_meta_get "$RELAUNCH_META" tasktmp) RELAUNCH_WT=$(fm_meta_get "$RELAUNCH_META" worktree) [ -n "$RELAUNCH_WT" ] && [ -d "$RELAUNCH_WT" ] || { echo "error: task $ID's recorded worktree '${RELAUNCH_WT:-none}' is missing; refusing to relaunch without the local copy its work lives in" >&2 @@ -4365,22 +4380,19 @@ agy) ;; esac -# Per-task temp root: /tmp/fm-/ with Go's build temp nested at gotmp/. Go won't -# create GOTMPDIR, so mkdir before it is used; fm-teardown removes the whole root. -# Nested (not a bare /tmp/fm-/gotmp) so other per-task temp can live alongside -# later, and teardown cleans one deterministic path. GOTMPDIR (not TMPDIR) is the -# targeted knob: TMPDIR is too broad (affects every program's temp, not just Go's). -# The root is private (0700) because its path is predictable under a shared -# /tmp: a root that already exists is reused only as a real directory owned by -# this user and writable by nobody else, then tightened, so no other local user -# can plant or swap a file in it. The staged launch command lives in a sibling -# directory namespaced by home identity, not in this shared per-id root. -TASK_TMP="/tmp/fm-$ID" +mkdir -p "$STATE" +STATE_REAL=$(cd "$STATE" && pwd -P) + +# Implement the task temp root contract in this script's header. +TASK_TMP="$STATE_REAL/$ID.tasktmp" +if [ "$RELAUNCH" -eq 1 ] && [ "$RELAUNCH_TASK_TMP" = "/tmp/fm-$ID" ]; then + TASK_TMP=$RELAUNCH_TASK_TMP +fi if ! (umask 077 && mkdir "$TASK_TMP") 2>/dev/null; then if [ -L "$TASK_TMP" ] || [ ! -d "$TASK_TMP" ] || [ ! -O "$TASK_TMP" ] || [ -n "$(find "$TASK_TMP" -prune \( -perm -g=w -o -perm -o=w \) -print 2>/dev/null)" ] || ! chmod 700 "$TASK_TMP"; then - echo "error: task temp root $TASK_TMP 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" >&2 + echo "error: task temp root $TASK_TMP already exists and is not a private directory owned by this user; refusing to use it; inspect and remove it, then retry" >&2 exit 1 fi fi @@ -4390,8 +4402,6 @@ mkdir -p "$TASK_TMP/gotmp" # state/.turn-ended when the agent finishes a turn. Worktree-resident hooks # and token pointers stay out of git's view so they never block teardown's dirty # check or leak into a commit. -mkdir -p "$STATE" -STATE_REAL=$(cd "$STATE" && pwd -P) TURNEND="$STATE_REAL/$ID.turn-ended" exclude_path() { local rel=$1 EXCL @@ -5223,7 +5233,7 @@ spawn_record_traceparent() { # Export GOTMPDIR into the crewmate's pane shell so the agent and every child # process (go build, go test, ...) inherit it. Sent before the launch command so # the env is set when the agent starts; the brief sleep lets the export land. -spawn_send_text_line "$T" "export GOTMPDIR=$TASK_TMP/gotmp" +spawn_send_text_line "$T" "export GOTMPDIR=$(shell_quote "$TASK_TMP/gotmp")" # Export the compact-adviser kill switch into the pane shell through the same # pre-launch channel, so later commands in that shell inherit it too. The launch # command independently establishes the value for the agent process itself. diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index c632e9bf390..39f24a88b7d 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -1131,8 +1131,8 @@ if [ "${FM_TEARDOWN_GUARD_DONE:-0}" != 1 ]; then fi HOME_PATH=$(grep '^home=' "$META" | cut -d= -f2- || true) PR_URL=$(grep '^pr=' "$META" | tail -1 | cut -d= -f2- || true) -# tasktmp is recorded by fm-spawn for tasks that set up a per-task temp root -# (/tmp/fm-/); absent for tasks spawned before that change, so tolerate empty. +# tasktmp= is the per-task temp root whose path contract bin/fm-spawn.sh's header +# owns; absent for tasks spawned before that root existed, so tolerate empty. TASK_TMP=$(grep '^tasktmp=' "$META" | cut -d= -f2- || true) BUSY_GEN=$(fm_meta_get "$META" busy_gen) if [ -z "$BUSY_GEN" ]; then @@ -3242,7 +3242,7 @@ endpoint_close_refusal() { # } cleanup_firstmate_home_children() { - local home=$1 sub_state child_meta child_id child_t child_wt child_proj child_kind child_home child_backend child_orca_worktree_id child_return_rc child_busy_gen child_owner_rc + local home=$1 sub_state child_meta child_id child_t child_wt child_proj child_kind child_home child_backend child_orca_worktree_id child_return_rc child_busy_gen child_owner_rc child_task_tmp sub_state="$home/state" [ -d "$sub_state" ] || return 0 for child_meta in "$sub_state"/*.meta; do @@ -3336,6 +3336,8 @@ cleanup_firstmate_home_children() { remove_grok_turnend_auth "$sub_state" "$child_id" || return 1 remove_kimi_turnend_auth "$sub_state" "$child_id" || return 1 remove_pr_poll_artifacts "$sub_state" "$child_id" || return 1 + child_task_tmp=$(meta_value "$child_meta" tasktmp) + [ -z "$child_task_tmp" ] || rm -rf "$child_task_tmp" child_busy_gen=$(meta_value "$child_meta" busy_gen) if [ -z "$child_busy_gen" ]; then child_busy_gen=$(cat "$sub_state/$child_id.busy-gen" 2>/dev/null || true) @@ -3785,8 +3787,8 @@ fi remove_grok_turnend_auth "$STATE" "$ID" || exit 1 remove_kimi_turnend_auth "$STATE" "$ID" || exit 1 fm_backend_clear_transition "$BACKEND" "$STATE" "$T" || true -# Remove the per-task temp root (/tmp/fm-/, incl. its gotmp/) recorded by spawn. -# Read before the state-file rm below; empty (pre-fix tasks without tasktmp=) is a no-op. +# Remove the recorded per-task temp root (incl. its gotmp/). Read before the +# state-file rm below; empty (tasks without tasktmp=) is a no-op. [ -n "$TASK_TMP" ] && rm -rf "$TASK_TMP" # Retire only this Firstmate home's launch namespace. Its never-reused per-spawn # files leave the equal task-id namespace of every other home untouched. diff --git a/docs/configuration.md b/docs/configuration.md index 69693761b41..9498bc9a276 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -91,6 +91,7 @@ Each effective `FM_HOME` contains private operational directories. - One-shot Bearings reconcile requests under `state/reconcile-notify/`. - Private secondmate config-reread generations with their retry and quarantine state. - Per-task steering-inbox records under `state/.inbox/` (`bin/fm-task-inbox-lib.sh`). +- Per-task private temp roots under `state/.tasktmp/` (`bin/fm-spawn.sh`). - Parent-owned secondmate pending-reply records under `state/pending-replies/` (`bin/fm-pending-reply-lib.sh`). `config/` holds local gitignored operating choices, including explicit extension bindings under `config/extensions.d/`. diff --git a/tests/fm-backend-orca.test.sh b/tests/fm-backend-orca.test.sh index a38e030356d..13cd572c829 100755 --- a/tests/fm-backend-orca.test.sh +++ b/tests/fm-backend-orca.test.sh @@ -558,7 +558,7 @@ test_spawn_writes_orca_metadata_and_launches_harness() { assert_grep "worktree=$wt" "$state/$id.meta" "meta missing Orca worktree path" assert_not_contains "$(cat "$log")" $'orca\x1f''terminal'$'\x1f''create' \ "spawn should reuse the implicit terminal returned by Orca worktree creation" - assert_contains "$(cat "$log")" $'orca\x1f''terminal'$'\x1f''send'$'\x1f''--terminal'$'\x1f''term-spawn'$'\x1f''--text'$'\x1f''export GOTMPDIR=/tmp/fm-orcaspawnz1/gotmp'$'\x1f''--enter'$'\x1f''--json' \ + assert_contains "$(cat "$log")" $'orca\x1f''terminal'$'\x1f''send'$'\x1f''--terminal'$'\x1f''term-spawn'$'\x1f''--text'$'\x1f'"export GOTMPDIR='$(cd "$state" && pwd -P)/$id.tasktmp/gotmp'"$'\x1f''--enter'$'\x1f''--json' \ "spawn did not export GOTMPDIR through the Orca terminal" staged=$(tr '\037' '\n' < "$log" | sed -n "s/^\. '\([^']*\)'$/\1/p" | tail -1) [ -n "$staged" ] && [ -f "$staged" ] \ @@ -567,7 +567,7 @@ test_spawn_writes_orca_metadata_and_launches_harness() { add_dirs="--add-dir '$(cd "$state" && pwd -P)/operational-inbox' --add-dir '$(cd "$state" && pwd -P)/$id.inbox' --add-dir '$(cd "$data" && pwd -P)/$id' --add-dir '$(cd "$ROOT" && pwd -P)/.agents/skills'" assert_contains "$launch" "CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false CLAUDE_CODE_SEND_FEEDBACK=0 claude --dangerously-skip-permissions $add_dirs --settings '{\"feedbackDrafts\":\"off\",\"attribution\":{\"commit\":\"\",\"pr\":\"\",\"sessionUrl\":false}}'" \ "the staged launch sent through Orca did not select the Claude harness" - rm -rf "/tmp/fm-$id" "$(dirname "$staged")" + rm -rf "$(dirname "$staged")" pass "fm-spawn.sh --backend orca: reuses implicit terminal, records metadata, launches harness" } diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index 6699092f522..346b33cab0f 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -1073,7 +1073,6 @@ run_spawn_symlink_case() { #