Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agents/skills/operational-home-layout/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ state/ runtime records and signals; gitignored
<id>.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
<id>.muse-session muse busy-source binding (sessions root plus task worktree) written by fm-spawn; removed by teardown
<id>.cursor-session cursor busy-source binding (projects root, task worktree, prior conversations) written by fm-spawn; removed by teardown
<id>.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-<id> records
<id>.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)
<id>.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
<id>.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
Expand Down
22 changes: 10 additions & 12 deletions bin/fm-live-lab.sh
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@
# only, so the Pi trust store is never written and all of
# .pi/extensions loads; sessions stay under
# <lab-root>/pi-sessions.
# task ids lab<nonce>-mate and lab<nonce>-worker, unique per lab,
# because a spawn keeps a task temp dir at /tmp/fm-<id>
# that a fixed id would share with other labs and tasks.
# task ids lab<nonce>-mate and lab<nonce>-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-id> <lab-root>/mate
# --no-projects (an explicit path cloned from the git lab
# home), launched by bin/fm-spawn.sh --secondmate.
Expand Down Expand Up @@ -789,22 +789,20 @@ 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-<id> and /tmp/fm-<id>+<sha256 of the spawning home>.
# 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-<id>+<sha256 of the spawning home>,
# 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
[ -f "$meta" ] && ids+=("$(basename "$meta" .meta)")
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"
Expand Down
41 changes: 26 additions & 15 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,20 @@
# 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/<id>.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; a forced secondmate
# teardown's children lose theirs with the retired home. Tasks spawned before
# this contract recorded the legacy shared root /tmp/fm-<id>; 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
Expand Down Expand Up @@ -655,6 +669,7 @@ YOLO_SET=0
BRANCH_PREFIX_SET=0
TRACEPARENT_SET=0
RELAUNCH=0
RELAUNCH_TASK_TMP=
POS=()
want_value=
for a in "$@"; do
Expand Down Expand Up @@ -1786,6 +1801,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
Expand Down Expand Up @@ -4365,22 +4381,19 @@ agy)
;;
esac

# Per-task temp root: /tmp/fm-<id>/ 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-<id>/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
Expand All @@ -4390,8 +4403,6 @@ mkdir -p "$TASK_TMP/gotmp"
# state/<id>.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
Expand Down Expand Up @@ -5223,7 +5234,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.
Expand Down
8 changes: 4 additions & 4 deletions bin/fm-teardown.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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-<id>/); 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
Expand Down Expand Up @@ -3785,8 +3785,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-<id>/, 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.
Expand Down
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>.inbox/` (`bin/fm-task-inbox-lib.sh`).
- Per-task private temp roots under `state/<id>.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/`.
Expand Down
4 changes: 2 additions & 2 deletions tests/fm-backend-orca.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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" ] \
Expand All @@ -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"
}

Expand Down
4 changes: 0 additions & 4 deletions tests/fm-backend.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1073,7 +1073,6 @@ run_spawn_symlink_case() { # <label> <physical|logical>
assert_contains "$out" "worktree=$wt" \
"fm-spawn.sh did not resolve a symlinked-prefix project to its real worktree when the backend reports $first_reply cwd"

rm -rf "/tmp/fm-$id"
}

test_spawn_symlinked_project_prefix_avoids_false_refusal() {
Expand Down Expand Up @@ -1246,7 +1245,6 @@ test_spawn_default_backend_writes_no_meta_field() {
expect_code 0 $? "explicit --backend tmux should spawn successfully"$'\n'"$out"
assert_no_grep 'backend=' "$state/$id.meta" \
"an explicit --backend tmux (the default) must not write backend= to meta (P1 compatibility contract)"
rm -rf "/tmp/fm-$id"
pass "fm-spawn.sh: an explicit --backend tmux resolves silently and writes no backend= (missing means tmux)"
}

Expand All @@ -1270,7 +1268,6 @@ test_spawn_explicit_backend_flag_beats_autodetect_herdr_env() {
expect_code 0 $? "explicit --backend tmux should spawn successfully even with HERDR_ENV=1 set"$'\n'"$out"
assert_no_grep 'backend=' "$state/$id.meta" \
"an explicit --backend tmux must win over an ambient HERDR_ENV=1 auto-detect marker"
rm -rf "/tmp/fm-$id"
pass "fm-spawn.sh: explicit --backend tmux wins over an ambient HERDR_ENV=1 auto-detect marker"
}

Expand Down Expand Up @@ -1300,7 +1297,6 @@ test_spawn_autodetect_nesting_resolves_tmux_silently() {
case "$out" in
*NOTICE*) fail "auto-detecting tmux (even nested inside herdr) must stay silent, no NOTICE expected"$'\n'"$out" ;;
esac
rm -rf "/tmp/fm-$id"
pass "fm-spawn.sh: auto-detect resolves nested tmux-in-herdr to tmux and stays silent end to end"
}

Expand Down
11 changes: 5 additions & 6 deletions tests/fm-claude-trust.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -587,10 +587,9 @@ test_corrupt_store_fails_closed() {
# The busy-state generation is armed after it, and nothing between that arm and
# the far-later rollback arming can clear it, so a record stranded here would
# read as a task busy forever for an id that has no meta at all. The per-task
# temp root /tmp/fm-<id> is the other resource created on the way to the arm, and
# nothing removes it either: fm-teardown finds it through tasktmp= in the task's
# meta, which a refused spawn never publishes. The id carries this process's pid
# so the temp-root assertion reads only this run's path.
# temp root state/<id>.tasktmp is the other resource created on the way to the
# arm, and nothing removes it either: fm-teardown finds it through tasktmp= in
# the task's meta, which a refused spawn never publishes.
test_refused_spawn_leaves_no_task_state() {
local case_dir home proj wt config fakebin out id
case_dir="$TMP_ROOT/refused-spawn"
Expand Down Expand Up @@ -620,8 +619,8 @@ test_refused_spawn_leaves_no_task_state() {
|| fail "a refused spawn stranded a busy record nothing can clear"
[ ! -e "$home/state/$id.busy-gen" ] \
|| fail "a refused spawn stranded a busy generation nothing can clear"
[ ! -e "/tmp/fm-$id" ] \
|| { rm -rf "/tmp/fm-$id"; fail "a refused spawn stranded a temp root no teardown can find"; }
[ ! -e "$home/state/$id.tasktmp" ] \
|| fail "a refused spawn stranded a temp root no teardown can find"
pass "fm-spawn.sh: a trust-refused claude spawn leaves no task state behind"
}

Expand Down
Loading
Loading