Skip to content
Open
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
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ Write the task-specific brief under section 11 before spawning.
### Dispatch and supervision handoff

Spawn only through `bin/fm-spawn.sh` after the profile and backend checks in section 4.
The spawn must resolve a genuine isolated task worktree distinct from the primary checkout; a failed isolation assertion stops the task.
The spawn must resolve a genuine isolated task worktree distinct from both the primary checkout and the firstmate home/root; a failed isolation assertion stops the task.
After spawning, confirm the worker is processing the brief, handle any trust dialog through `harness-adapters`, and record ship or scout work as in flight.
A persistent secondmate is recorded in the secondmate registry and runtime state, never as a backlog work item.

Expand Down Expand Up @@ -336,7 +336,7 @@ A forced repair must use the home-scoped owner path emitted by supervision instr

Guard warnings do not replace the contract.
Queued wakes must be drained before other action, stale liveness must be repaired through the emitted protocol, and the worktree-tangle warning must be resolved without touching unlanded work.
The spawn assertion and generated ship brief must both enforce that project work starts in an isolated disposable worktree, never the primary checkout.
The spawn assertion and generated ship brief must both enforce that project work starts in an isolated disposable worktree, never the primary checkout or the firstmate home/root.
Harness-aware turn-end guards are structural backstops, not permission to omit the live cycle.

### Away-mode stub
Expand Down
54 changes: 32 additions & 22 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,12 @@
# provisioned firstmate home; the default is kind=ship.
# Before a secondmate launch, the home is locally fast-forwarded to the primary
# default-branch commit when safe; skipped syncs warn and launch unchanged.
# Ship/scout spawns refuse to launch unless the resolved task path is a real
# git worktree root distinct from the primary project checkout.
# Ship/scout treehouse-backed spawns accept a sampled task path only once it is a
# real git worktree root distinct from the primary project checkout and the
# firstmate home/root (treehouse setup hops through intermediate cwds; mid-hop
# samples are rejected and polling continues), and refuse to launch if none
# appears within FM_SPAWN_WT_TIMEOUT (default 60s); orca spawns keep validating
# the worktree path orca returns against the project checkout.
# Batch dispatch: pass one or more `id=repo` pairs instead of a single <id> <project>, e.g.
# fm-spawn.sh fix-a-k3=projects/foo add-b-q7=projects/bar [--scout]
# Each pair re-execs this script in single-task mode, so the single path stays the only
Expand Down Expand Up @@ -104,6 +108,8 @@ SUB_HOME_MARKER=".fm-secondmate-home"
. "$SCRIPT_DIR/fm-config-inherit-lib.sh"
# shellcheck source=bin/fm-backend.sh
. "$SCRIPT_DIR/fm-backend.sh"
# shellcheck source=bin/fm-tangle-lib.sh
. "$SCRIPT_DIR/fm-tangle-lib.sh"
# shellcheck source=bin/fm-gate-refuse-lib.sh
. "$SCRIPT_DIR/fm-gate-refuse-lib.sh"
# shellcheck source=bin/fm-pr-lib.sh
Expand Down Expand Up @@ -678,18 +684,11 @@ real_path_or_raw() { # <path>
# that every downstream operation (send/capture/kill) already treats as opaque
# per-backend routing (fm_backend_resolve_selector).
validate_spawn_worktree() { # <source> <inspect-target>
local source=$1 inspect_target=$2 wt_real proj_real wt_top wt_top_real
wt_real=
if ! wt_real=$(cd "$WT" 2>/dev/null && pwd -P); then
wt_real=
fi
proj_real=$PROJ_ABS_REAL
wt_top=$(git -C "$WT" rev-parse --show-toplevel 2>/dev/null || true)
wt_top_real=
if ! wt_top_real=$(cd "$wt_top" 2>/dev/null && pwd -P); then
wt_top_real=
fi
if [ -z "$wt_real" ] || [ -z "$wt_top_real" ] || [ "$wt_real" != "$wt_top_real" ] || [ "$wt_real" = "$proj_real" ]; then
local source=$1 inspect_target=$2 wt_top
# One definition of "a genuine isolated worktree": fm_isolated_worktree_root
# (bin/fm-tangle-lib.sh), shared with the treehouse worktree-discovery poll.
if ! fm_isolated_worktree_root "$WT" "$PROJ_ABS_REAL" >/dev/null; then
wt_top=$(git -C "$WT" rev-parse --show-toplevel 2>/dev/null || true)
echo "error: $source did not yield an isolated worktree (resolved '$WT'; worktree root '${wt_top:-none}'; primary '$PROJ_ABS'); refusing to launch to avoid tangling the primary checkout. Inspect target $inspect_target" >&2
exit 1
fi
Expand Down Expand Up @@ -845,23 +844,34 @@ if [ "$KIND" != secondmate ] && [ "$BACKEND" != orca ]; then
# automatic-rename slips through), display-message -t <bad-name> falls back to the
# active client's window, which would misread firstmate's OWN pane path as the
# worktree and tangle a hook into the primary checkout. The window id never lies.
# Compare against PROJ_ABS_REAL (physical), not PROJ_ABS: a symlinked project
# prefix would otherwise make the pane's OS-level cwd read differ from
# PROJ_ABS on the very first poll, before the pane has actually moved.
for _ in $(seq 1 60); do
# The move is not atomic - treehouse's setup hops through intermediate cwds
# (the user's home, the firstmate home) before settling - so "left the
# project" is not enough: a sample landing mid-hop would record a wrong
# worktree= in meta and install the crew's turn-end hook into the WRONG
# checkout. Accept a candidate only once it proves it is a genuine ISOLATED
# worktree root: physically AT a git worktree top, distinct from the project's
# primary checkout AND from the firstmate home/root (a mis-sample can itself be
# another valid repo root; see fm_isolated_worktree_root in fm-tangle-lib.sh).
# Branching/committing in a mis-detected checkout would tangle it onto a
# feature branch (fm-tangle-lib.sh), so anything else keeps polling. The
# predicate compares physical (pwd -P) forms on both sides, so a symlinked
# project prefix cannot make the identical directory read as two strings.
home_real=$(real_path_or_raw "$FM_HOME")
root_real=$(real_path_or_raw "$FM_ROOT")
wt_wait_secs=${FM_SPAWN_WT_TIMEOUT:-60} # override is for tests; production keeps 60s
p=
for _ in $(seq 1 "$wt_wait_secs"); do
p=$(spawn_current_path "$WT_TARGET" || true)
if [ -n "$p" ] && [ "$(real_path_or_raw "$p")" != "$PROJ_ABS_REAL" ]; then
if [ -n "$p" ] && fm_isolated_worktree_root "$p" "$PROJ_ABS_REAL" "$home_real" "$root_real" >/dev/null; then
WT="$p"
break
fi
sleep 1
done
if [ -z "$WT" ]; then
echo "error: treehouse get did not enter a worktree within 60s; inspect window $T" >&2
echo "error: treehouse get did not enter an isolated worktree within ${wt_wait_secs}s (project '$PROJ_ABS'); refusing to launch to avoid tangling a primary checkout. Last sample '$p'. Inspect window $T" >&2
exit 1
fi

validate_spawn_worktree "treehouse get" "$T"
fi

# Per-task temp root: /tmp/fm-<id>/ with Go's build temp nested at gotmp/. Go won't
Expand Down
26 changes: 26 additions & 0 deletions bin/fm-tangle-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,29 @@ fm_primary_tangle_branch() {
printf '%s\n' "$cur"
return 0
}

# Prevention-side isolation check for the same tangle, used by fm-spawn's
# worktree-discovery poll. Accept <candidate> as a genuine ISOLATED worktree
# root only when its physical path (a) sits AT a git worktree top - not merely
# inside some repo or in a bare non-repo directory - and (b) differs from every
# <forbidden> physical path passed after it (the project's primary checkout,
# the firstmate home/root). treehouse get's subshell hops through intermediate
# cwds (the user's home, the firstmate home) before settling in the final
# worktree, and a mis-sample can itself be another VALID repo root, so the
# not-the-project check alone is insufficient. Echoes the candidate's physical
# path and returns 0 on acceptance; echoes nothing and returns 1 otherwise.
fm_isolated_worktree_root() {
local cand=$1 cand_real top top_real f
shift
cand_real=$(cd "$cand" 2>/dev/null && pwd -P) || return 1
top=$(git -C "$cand_real" rev-parse --show-toplevel 2>/dev/null) || return 1
top_real=$(cd "$top" 2>/dev/null && pwd -P) || return 1
[ "$cand_real" = "$top_real" ] || return 1
for f in "$@"; do
if [ -n "$f" ] && [ "$cand_real" = "$f" ]; then
return 1
fi
done
printf '%s\n' "$cand_real"
return 0
}
1 change: 1 addition & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ Codex App support is recorded in `docs/codex-app-backend.md`; it is not selectab

Crewmates never intentionally touch your project clone; [treehouse](https://github.com/kunchenguid/treehouse) pools clean worktrees for tmux, herdr, zellij, and cmux tasks, while Orca creates its own worktrees for `backend=orca`.
For ship and scout work, `fm-spawn.sh` refuses to launch unless the resolved task path is a real git worktree root that is distinct from the project primary checkout.
Treehouse-backed spawns apply that check to every sampled pane path and additionally reject the firstmate home/root: treehouse setup hops through intermediate directories (the user's home, the firstmate home) before settling, so mid-hop samples - which can themselves be valid repo roots - are rejected and polling continues until a genuine isolated worktree appears or the wait bound expires.

The firstmate repo has one extra exposure because it can dispatch crewmates to work on itself.
Its operating checkout (`FM_ROOT`) and the disposable crewmate worktrees are all linked git worktrees of the same repository, so the valid discriminator is branch state, not whether the checkout is linked.
Expand Down
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,7 @@ FM_CODEX_WATCH_CHECKPOINT=180 # seconds per foreground watcher checkpoint in C
FM_CREW_STATE_NM_TIMEOUT=10 # seconds allowed per no-mistakes query inside fm-crew-state.sh
FM_CREW_STATE_RUNS_LIMIT=200 # recent no-mistakes runs rows scanned when cross-branch attribution falls back from axi status
FM_CREW_STATE_BIN=bin/fm-crew-state.sh # test override for the current-state reader used by working/paused watcher triage
FM_SPAWN_WT_TIMEOUT=60 # seconds fm-spawn waits for treehouse get to settle in an isolated worktree; test-only override, production keeps 60
FMX_PAIRING_TOKEN= # X mode pairing token; .env opt-in authorizes replies and eligible lifecycle actions
FMX_RELAY_URL=https://myfirstmate.io # optional X relay override, mainly for local relay development
FMX_ENV_FILE= # optional alternate .env file for direct X client invocations; bootstrap still checks $FM_HOME/.env
Expand Down
2 changes: 1 addition & 1 deletion docs/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ The shared no-mistakes gate refusal used by `fm-spawn.sh`, `fm-send.sh`, and `fm
| `fm-supervisor-target-lib.sh` | Resolve the shared supervisor target and backend for the daemon and launcher |
| `fm-supervise-daemon.sh` | Presence-gated away-mode sub-supervisor: self-handle routine wakes, escalate batched digests, alert on failed delivery |
| `fm-crew-state.sh` | Print one deterministic current-state line for a crew |
| `fm-tangle-lib.sh` | Shared default-branch resolution and primary-checkout tangle classification |
| `fm-tangle-lib.sh` | Shared default-branch resolution, primary-checkout tangle classification, and the isolated-worktree-root check used by fm-spawn's worktree-discovery poll and validation |
| `fm-supervision-lib.sh` | Shared in-flight-work-without-fresh-watcher-beacon predicate |
| `fm-ff-lib.sh` | Shared guarded fast-forward helper for origin pulls and local secondmate syncs |
| `fm-lock-lib.sh` | Shared "is this git lock provably abandoned?" proof used by teardown and fleet-sync |
Expand Down
125 changes: 125 additions & 0 deletions tests/fm-spawn-worktree-wait.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
#!/usr/bin/env bash
# Behavior tests for fm-spawn's worktree-discovery poll after `treehouse get`.
#
# The pane's cwd does not move from the project to the worktree atomically:
# treehouse's setup hops through intermediate directories (the user's home, the
# firstmate home) before settling. The old poll accepted the FIRST cwd that
# differed from the project clone, so a sample landing mid-hop recorded a wrong
# worktree= in the task meta and installed the crew's turn-end hook into the
# wrong checkout. The fix accepts a candidate only once fm_isolated_worktree_root
# (bin/fm-tangle-lib.sh) proves it: physically AT a git worktree top, distinct
# from the project clone and from the firstmate home/root.
#
# These tests pin the predicate against every observed mis-sample shape, then
# replay the observed hop sequence (project -> home dir -> firstmate home ->
# real worktree) through the poll's accept logic and assert it settles only on
# the last. Structural checks pin fm-spawn's poll to the predicate.
set -u

# shellcheck source=tests/lib.sh
. "$(dirname "${BASH_SOURCE[0]}")/lib.sh"

# shellcheck source=bin/fm-tangle-lib.sh
. "$ROOT/bin/fm-tangle-lib.sh"

SPAWN="$ROOT/bin/fm-spawn.sh"
TMP_ROOT=$(fm_test_tmproot fm-spawn-worktree-wait)
fm_git_identity fmtest fmtest@example.invalid

# --- fixtures: the directory shapes the pane cwd can land in -----------------

# The project clone: a normal git repo on main with one commit.
PROJ="$TMP_ROOT/projects/someproj"
mkdir -p "$(dirname "$PROJ")"
git init -q -b main "$PROJ"
git -C "$PROJ" commit -q --allow-empty -m init

# The firstmate home: a DIFFERENT valid git repo root (the 2026-07-04 recurrence:
# a mis-sample can be another valid repo root, so not-the-project is not enough).
FM_HOME_DIR="$TMP_ROOT/fm-home"
git init -q -b main "$FM_HOME_DIR"
git -C "$FM_HOME_DIR" commit -q --allow-empty -m init

# A bare non-repo directory (the user's home mid-hop).
HOME_DIR="$TMP_ROOT/userhome"
mkdir -p "$HOME_DIR"

# A real linked worktree of the project: a genuine isolated worktree top.
WORKTREE="$TMP_ROOT/pool/someproj-wt"
mkdir -p "$(dirname "$WORKTREE")"
git -C "$PROJ" worktree add -q --detach "$WORKTREE"
mkdir -p "$WORKTREE/subdir"

# A symlink to the project clone: physical resolution must still reject it.
PROJ_LINK="$TMP_ROOT/proj-link"
ln -s "$PROJ" "$PROJ_LINK"

PROJ_REAL=$(cd "$PROJ" && pwd -P)
FM_HOME_REAL=$(cd "$FM_HOME_DIR" && pwd -P)
WORKTREE_REAL=$(cd "$WORKTREE" && pwd -P)

# --- the predicate: accept only a genuine isolated worktree root --------------

test_predicate_cases() {
local label cand expect out status
while IFS='|' read -r label cand expect; do
[ -n "$label" ] || continue
out=$(fm_isolated_worktree_root "$cand" "$PROJ_REAL" "$FM_HOME_REAL") && status=0 || status=$?
if [ "$expect" = accept ]; then
[ "$status" -eq 0 ] || fail "$label: expected accept, got reject"
[ "$out" = "$WORKTREE_REAL" ] || fail "$label: echoed '$out', want '$WORKTREE_REAL'"
else
[ "$status" -ne 0 ] || fail "$label: expected reject, got accept ('$out')"
fi
done <<ROWS
bare non-repo dir (user home mid-hop) is rejected|$HOME_DIR|reject
nonexistent path is rejected|$TMP_ROOT/does-not-exist|reject
the project clone itself is rejected|$PROJ|reject
a symlink to the project clone is rejected (physical path check)|$PROJ_LINK|reject
the firstmate home is rejected even though it is a valid repo root|$FM_HOME_DIR|reject
a subdir INSIDE a worktree is rejected (must sit AT the top)|$WORKTREE/subdir|reject
a real isolated worktree top is accepted|$WORKTREE|accept
ROWS
pass "fm_isolated_worktree_root accepts only a genuine isolated worktree top"
}

# --- the race: replay the observed hop sequence through the poll's logic ------

test_poll_settles_only_on_the_worktree() {
# The exact accept condition fm-spawn's poll uses, over the observed hop
# sequence. The old `!= project` condition would have settled on the FIRST
# hop (the user's home); the fix must skip every intermediate and settle on
# the real worktree.
local WT='' p
for p in "$PROJ" "$HOME_DIR" "$FM_HOME_DIR" "$WORKTREE"; do
if [ -n "$p" ] && fm_isolated_worktree_root "$p" "$PROJ_REAL" "$FM_HOME_REAL" >/dev/null; then
WT="$p"
break
fi
done
[ "$WT" = "$WORKTREE" ] || fail "poll settled on '$WT', want '$WORKTREE'"
pass "poll logic skips project/home-dir/firstmate-home hops and settles on the worktree"
}

# --- structural: fm-spawn's poll is wired to the predicate --------------------

test_spawn_uses_predicate() {
# shellcheck disable=SC2016 # literal source string
grep -F '. "$SCRIPT_DIR/fm-tangle-lib.sh"' "$SPAWN" >/dev/null \
|| fail "fm-spawn does not source fm-tangle-lib.sh"
# shellcheck disable=SC2016 # literal source string
grep -F 'fm_isolated_worktree_root "$p"' "$SPAWN" >/dev/null \
|| fail "fm-spawn's poll does not validate candidates with fm_isolated_worktree_root"
# The racy first-cwd-that-differs acceptance must be gone (both the original
# raw-string form and the later physically-canonicalized form of it).
# shellcheck disable=SC2016 # literal source strings
if grep -F '[ "$p" != "$PROJ_ABS" ]' "$SPAWN" >/dev/null \
|| grep -F '!= "$PROJ_ABS_REAL" ]' "$SPAWN" >/dev/null; then
fail "fm-spawn still accepts the first cwd that merely differs from the project"
fi
pass "fm-spawn's worktree poll validates each sample with fm_isolated_worktree_root"
}

test_predicate_cases
test_poll_settles_only_on_the_worktree
test_spawn_uses_predicate
10 changes: 6 additions & 4 deletions tests/fm-tangle-guard.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ run_spawn() {
FM_ROOT_OVERRIDE='' FM_HOME="$home" \
FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \
FM_PROJECTS_OVERRIDE="$home/projects" FM_CONFIG_OVERRIDE="$home/config" \
FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$pane" TMUX="fake,1,0" \
FM_SPAWN_NO_GUARD=1 FM_SPAWN_WT_TIMEOUT=2 FM_FAKE_PANE_PATH="$pane" TMUX="fake,1,0" \
PATH="$fakebin:$PATH" \
"$ROOT/bin/fm-spawn.sh" "$id" "$proj" codex 2>&1
}
Expand All @@ -196,21 +196,23 @@ test_spawn_isolation_abort() {
mkdir -p "$TMP_ROOT/spawn-notgit" "$proj/sub"

# Abort: the pane resolves to a plain non-git directory (not a worktree at all).
# Invalid candidates are rejected per-sample by the discovery poll, so the
# spawn times out without ever accepting one.
out=$(run_spawn "$home" abort-notgit-dd4 "$proj" "$TMP_ROOT/spawn-notgit" "$fakebin"); status=$?
expect_code 1 "$status" "spawn into a non-worktree dir should abort"
assert_contains "$out" "did not yield an isolated worktree" "non-worktree spawn lacked the isolation error"
assert_contains "$out" "did not enter an isolated worktree" "non-worktree spawn lacked the isolation error"
assert_absent "$home/state/abort-notgit-dd4.meta" "aborted spawn must not record meta"

# Abort: the pane resolves INTO the primary checkout (a subdir of PROJ_ABS).
out=$(run_spawn "$home" abort-primary-ee5 "$proj" "$proj/sub" "$fakebin"); status=$?
expect_code 1 "$status" "spawn landing inside the primary checkout should abort"
assert_contains "$out" "did not yield an isolated worktree" "primary-checkout spawn lacked the isolation error"
assert_contains "$out" "did not enter an isolated worktree" "primary-checkout spawn lacked the isolation error"

# Proceed: the pane resolves to a genuine, isolated worktree.
out=$(run_spawn "$home" ok-isolated-ff6 "$proj" "$TMP_ROOT/spawn-wt" "$fakebin"); status=$?
expect_code 0 "$status" "spawn into a genuine isolated worktree should succeed"
assert_contains "$out" "spawned ok-isolated-ff6" "isolated spawn did not report success"
assert_not_contains "$out" "did not yield an isolated worktree" "isolated spawn wrongly tripped the guard"
assert_not_contains "$out" "did not enter an isolated worktree" "isolated spawn wrongly tripped the guard"
pass "fm-spawn: aborts unless the resolved worktree is a genuine, isolated worktree"
}

Expand Down