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
18 changes: 17 additions & 1 deletion bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,12 @@
# itself a linked worktree of the project repository still launches. A pane
# that never reaches an isolated worktree refuses at the end of that wait,
# naming the last path seen and why it was rejected.
# That placement is proven only at launch. Every ship or scout pane therefore
# also receives `export FM_TASK_ID=<task-id>` before the launch command, on
# the same channel as GOTMPDIR, and bin/fm-test-run.sh refuses to execute the
# behavior suite from the repository primary checkout while that marker is
# set (its header owns the refusal). A secondmate runs in its own home and is
# not marked.
# Only after this isolation check, every fresh ship or scout requires a clean
# task worktree. When an origin configuration is detected, spawn fetches it,
# resolves the current remote default branch, and resets to its tip. When none
Expand Down Expand Up @@ -224,7 +230,8 @@
# LANG LC_ALL LC_CTYPE TMPDIR TMP TEMP GOTMPDIR, plus backend identity/routing:
# TMUX TMUX_PANE HERDR_ENV HERDR_SESSION HERDR_SOCKET_PATH HERDR_PANE_ID
# CMUX_WORKSPACE_ID CMUX_SURFACE_ID CMUX_TAB_ID CMUX_PANEL_ID CMUX_SOCKET_PATH
# ZELLIJ ZELLIJ_SESSION_NAME ZELLIJ_PANE_ID FM_ZELLIJ_SESSION.
# ZELLIJ ZELLIJ_SESSION_NAME ZELLIJ_PANE_ID FM_ZELLIJ_SESSION, plus the task
# marker FM_TASK_ID that ship and scout panes receive above.
# An enabled task trace also retains TRACEPARENT. Explicit Firstmate launch
# assignments still apply inside the filtered environment. Raw commands must
# be POSIX sh compatible under this opt-in; the absent-file path is unchanged.
Expand Down Expand Up @@ -3796,6 +3803,14 @@ spawn_record_traceparent() {
# 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"
# Mark the pane as a task worker so bin/fm-test-run.sh can refuse to run the
# suite in the repository's primary checkout. Ship and scout workers are the
# ones assigned an isolated worktree; a secondmate runs its own home instead.
# The id reached a validated bare-slug charset above, so it carries no shell
# syntax of its own.
if [ "$KIND" = ship ] || [ "$KIND" = scout ]; then
spawn_send_text_line "$T" "export FM_TASK_ID=$ID"
fi
# Send through the exact channel that already ships GOTMPDIR, so every backend
# and harness - ship, scout, and secondmate - gets it before launch. Skipped
# entirely when trace context is off.
Expand All @@ -3819,6 +3834,7 @@ if [ "$LAUNCH_ENV_ENABLED" = 1 ]; then
TMPDIR TMP TEMP GOTMPDIR TMUX TMUX_PANE HERDR_ENV HERDR_SESSION HERDR_SOCKET_PATH \
HERDR_PANE_ID CMUX_WORKSPACE_ID CMUX_SURFACE_ID CMUX_TAB_ID CMUX_PANEL_ID \
CMUX_SOCKET_PATH ZELLIJ ZELLIJ_SESSION_NAME ZELLIJ_PANE_ID FM_ZELLIJ_SESSION \
FM_TASK_ID \
$LAUNCH_ENV_NAMES; do
# Only validated names enter shell syntax. Values expand once, quoted, in
# the pane shell and never become source text or spawn-process snapshots.
Expand Down
42 changes: 42 additions & 0 deletions bin/fm-test-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,15 @@
# FM_TEST_SLOWEST rank=<k> script=<path> duration_ms=<n>
# FM_TEST_BUDGET max_wall_ms=<n> duration_ms=<n> (only with --max-wall-ms)
#
# Placement refusal:
# A task worker is assigned an isolated worktree, and that placement is
# checked only when its task starts. When FM_TASK_ID marks such a worker and
# this runner resolves to the repository's PRIMARY checkout, every executing
# mode refuses before selecting a suite: the suite creates and switches
# branches, and the primary is the checkout every linked worktree resolves
# against. Inspection modes execute nothing and stay available, and a run with
# no FM_TASK_ID set is unchanged.
#
# Exit status is non-zero if any selected script exits non-zero, a configured
# --fail-on-gate-skip token appears, the measured duration exceeds
# --max-wall-ms, timing-artifact finalization fails, or a concurrent worker
Expand Down Expand Up @@ -192,6 +201,29 @@ now_iso() {
date -u +%Y-%m-%dT%H:%M:%SZ
}

# Enforce the placement refusal described in this script's header.
#
# The primary checkout is the working tree whose own git dir IS the repository's
# common git dir; every linked worktree has a git dir under it instead. That is
# the same predicate bin/fm-spawn.sh uses to keep a launch out of the primary,
# and unlike comparing top-level paths it still holds when the primary is
# reached through a different path. When git resolves neither directory - a
# non-repository fixture, a detached copy - nothing proves this is the primary,
# so the run proceeds.
refuse_primary_checkout_for_task() {
local task_id git_dir common_dir top
task_id=${FM_TASK_ID:-}
[ -n "$task_id" ] || return 0
git_dir=$(git -C "$ROOT" rev-parse --absolute-git-dir 2>/dev/null) \
&& git_dir=$(cd "$git_dir" 2>/dev/null && pwd -P) || git_dir=
common_dir=$(git -C "$ROOT" rev-parse --path-format=absolute --git-common-dir 2>/dev/null) \
&& common_dir=$(cd "$common_dir" 2>/dev/null && pwd -P) || common_dir=
[ -n "$git_dir" ] && [ -n "$common_dir" ] || return 0
[ "$git_dir" = "$common_dir" ] || return 0
top=$(cd "$ROOT" && pwd -P)
die "refusing to run in the repository primary checkout $top while FM_TASK_ID=$task_id is set; run from the assigned task worktree instead"
}

cpu_count() {
local n
n=$(getconf _NPROCESSORS_ONLN 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 1)
Expand Down Expand Up @@ -1818,6 +1850,16 @@ case "$PER_SCRIPT_TIMEOUT_SECS" in
''|*[!0-9]*) die "--per-script-timeout-secs requires a whole number of seconds (0 disables)" ;;
esac

# Refuse before any suite is selected or run. The inspection modes execute
# nothing: --list-families, --list-concurrent-safe-families, --list-lanes,
# --check-coverage, --concurrent-safe-family-jobs-max and --aggregate-json have
# already exited above, and --list/--list-scheduled print their selection and
# exit below. An unset MODE still falls through to the usage error, so a caller
# who named no selection mode is told that rather than this.
if [ -n "${MODE:-}" ] && [ "$LIST_ONLY" -eq 0 ] && [ "$LIST_SCHEDULED" -eq 0 ]; then
refuse_primary_checkout_for_task
fi

case "${MODE:-}" in
all)
select_all
Expand Down
1 change: 1 addition & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ Only a named non-default branch checked out in `FM_ROOT` is a worktree tangle.
`fm-guard.sh` prints the repair command on the next mutable fleet action, while `bin/fm-session-start.sh` reports the same condition through bootstrap as a `TANGLE:` line at session start.
If another live session holds the fleet lock, both surfaces keep the alarm but switch to read-only wording with no repair command.
Ship briefs also tell the crewmate to verify `pwd -P` and `git rev-parse --show-toplevel` before creating `fm/<id>`, then stop with a blocked status if it landed in the primary checkout.
Placement is proven only at launch, so `bin/fm-spawn.sh` also exports the task id as `FM_TASK_ID` into every ship and scout pane, and `bin/fm-test-run.sh` refuses to execute the behavior suite from the primary checkout while that marker is set; the runner's header owns the predicate and [`tests/fm-test-run.test.sh`](../tests/fm-test-run.test.sh) pins it.

## No-mistakes gate authority boundary

Expand Down
3 changes: 2 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,7 @@ OPENAI_API_KEY
SSH_AUTH_SOCK
```

Firstmate retains basic home, executable search, terminal, locale, temporary-directory, and backend routing variables, plus its explicit launch assignments and enabled task trace.
Firstmate retains basic home, executable search, terminal, locale, temporary-directory, and backend routing variables, plus its explicit launch assignments, its ship and scout task marker, and enabled task trace.
[`fm-spawn.sh --help`](../bin/fm-spawn.sh) owns the exact retained names and parsing mechanics.
Other ambient names must be listed explicitly, including custom credential-store locations, proxy settings, and certificate overrides when required by the selected tools.
The command shell and worker may still create their own variables.
Expand Down Expand Up @@ -852,6 +852,7 @@ FM_CONFIG_OVERRIDE= # alternate config dir, mainly for tests
FM_PROC_ROOT_OVERRIDE= # alternate /proc root for Linux process-identity reads in fm-wake-lib.sh and fm-teardown.sh, mainly for tests
FM_BACKEND= # optional runtime backend override for new spawns; tmux/herdr/zellij/orca/cmux support ship/scout spawns, codex-app is not accepted
FM_TRACE_CONTEXT= # optional trace-context override; see "Trace context propagation"
FM_TASK_ID= # internal task-worker marker fm-spawn.sh exports into ship and scout panes, never set by hand; bin/fm-test-run.sh refuses to execute in the repository primary checkout while it is set
HERDR_SESSION=default # herdr-only: named session for normal backend ops; not enough for destructive cleanup (docs/herdr-backend.md)
FM_BACKEND_HERDR_SUBMIT_POLLS=6 # herdr-only: agent-state samples spread across each Enter attempt's budget when confirming a submit (docs/herdr-backend.md "Current transport behavior")
FM_BACKEND_HERDR_SUBMIT_MIN_SLEEP=0.6 # herdr-only: minimum per-Enter confirmation budget before polling agent-state after an idle baseline
Expand Down
2 changes: 1 addition & 1 deletion docs/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize
| `fm-install-herdr.sh` | Install CI's exact-version Herdr pin with official asset URL, SHA-256, and protocol checks |
| `fm-install-treehouse.sh`| Install CI's exact-version Treehouse pin for real-Herdr E2E that needs spawn worktrees |
| `fm-herdr-ci-cleanup.sh` | Snapshot and tear down only job-owned `fm-lab-*` sessions in the Herdr CI lane |
| `fm-test-run.sh` | Behavior-test runner: selection, portable lanes, bounded concurrency, budgets, coverage guard, timing/JSON |
| `fm-test-run.sh` | Behavior-test runner: selection, portable lanes, bounded concurrency, budgets, coverage guard, timing/JSON; refuses to execute in the repository primary checkout when `FM_TASK_ID` marks a task worker |
| `fm-test-isolation-proof.sh` | Concurrent isolation harness and portable candidate set owner |
| `fm-ensure-agents-md.sh` | Ensure a project's real `AGENTS.md`, its `CLAUDE.md` `@AGENTS.md` pointer, and self-governance guidance (explicit project mark documented in the helper's header and help) |
| `fm-guard.sh` | Warn on primary-checkout tangles, main-session pending wakes, and unhealthy supervision |
Expand Down
2 changes: 1 addition & 1 deletion docs/verification/trace-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Comparison base: `main` at `976d97f`.

The colocated unit suite `tests/fm-trace-context-lib.test.sh` (26 assertions) exercises validation (valid accepted; malformed, wrong-length, uppercase, all-zero, `ff` version, and shell-metacharacter values rejected), root minting with every mint a distinct sampled root and no parent-adoption input, the recovery reuse path with the recorded carrier winning over the ambient environment, default-off omission, the enable precedence of `FM_TRACE_CONTEXT` over `config/trace-context` with unset or empty deferring to the file, normalized home-session state, atomic replacement of a read-only prior record, stale-session rejection after failed publication, missing or invalid state defaulting off, the Secondmate home-session boundary with later file state plus the per-task trace boundary (two resolves under one persistent ambient `TRACEPARENT` root two distinct traces and adopt neither), forced entropy failure omitting safely, and the minted-root fixed-shape check.

The spawn-path integration suite `tests/fm-trace-context-spawn.test.sh` (12 assertions), hermetic against an ambient `FM_TRACE_CONTEXT`, drives `bin/fm-spawn.sh` end to end with a fake tmux pane and a real isolated git worktree: enabled, one resolved carrier is recorded as `traceparent=` in the meta only after the identical `TRACEPARENT` export is sent before the launch literal; disabled, neither is written nor sent (only `GOTMPDIR` is); a failed carrier delivery leaves no `traceparent=` claim while the source task still launches; an unsafe delivery whose partial input cannot be cleared stops before appending the launch command; a failed metadata append removes the carrier from the launched task without aborting it; duplicate Secondmate preflight leaves inherited trace configuration unchanged; a relaunch reuses the recorded carrier verbatim; and spawns ignore later config and environment edits in favor of the frozen home-session decision.
The spawn-path integration suite `tests/fm-trace-context-spawn.test.sh` (12 assertions), hermetic against an ambient `FM_TRACE_CONTEXT`, drives `bin/fm-spawn.sh` end to end with a fake tmux pane and a real isolated git worktree: enabled, one resolved carrier is recorded as `traceparent=` in the meta only after the identical `TRACEPARENT` export is sent before the launch literal; disabled, neither is written nor sent (`GOTMPDIR` still is); a failed carrier delivery leaves no `traceparent=` claim while the source task still launches; an unsafe delivery whose partial input cannot be cleared stops before appending the launch command; a failed metadata append removes the carrier from the launched task without aborting it; duplicate Secondmate preflight leaves inherited trace configuration unchanged; a relaunch reuses the recorded carrier verbatim; and spawns ignore later config and environment edits in favor of the frozen home-session decision.
The per-task boundary regression models the reviewed Secondmate scenario exactly: two unrelated tasks spawned sequentially from one home while the same fixed `TRACEPARENT` sits in the spawning environment (a persistent Secondmate's launch-time carrier) record and inject valid carriers whose trace ids differ from each other and from the ambient carrier, and a relaunch of the first task reuses its original carrier verbatim for both the meta record and the injected export.
Two further assertions drive a genuine two-level primary -> Secondmate -> worker chain, running `bin/fm-spawn.sh` twice with the exact environment the primary injects into the Secondmate, and prove the primary's effective override governs the nested worker both ways: env-on with no config file keeps the nested worker enabled while it roots its own per-task trace distinct from the Secondmate's carrier, and env-off with the file present keeps the nested worker disabled even though the `config/trace-context` file was copied into the Secondmate home.
A final assertion drives the file-decided path (`FM_TRACE_CONTEXT` unset) and proves the Secondmate's recorded/injected carrier and its delivered `FM_TRACE_CONTEXT=on|off` snapshot are always derived from one frozen decision, so a carrier is never paired with the opposite enable state.
Expand Down
2 changes: 2 additions & 0 deletions tests/fm-kimi-harness.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,8 @@ test_kimi_launch_then_send_is_verified() {
assert_present "$task_tmp/gotmp" "kimi spawn did not create its Go temp directory"
assert_grep "export GOTMPDIR=$task_tmp/gotmp" "$CASE_DIR/tmux-calls.log" \
"kimi spawn did not export its Go temp directory into the pane"
assert_grep "export FM_TASK_ID=$id" "$CASE_DIR/tmux-calls.log" \
"kimi spawn did not mark the pane with its task id"
assert_grep 'BEGIN FIRSTMATE KIMI TURN-END HOOK' "$HOME_DIR/.kimi-code/config.toml" \
"kimi spawn did not install its guarded global hook region"
assert_grep 'token=' "$WT_DIR/.fm-kimi-turnend" "kimi spawn did not write its token pointer"
Expand Down
64 changes: 64 additions & 0 deletions tests/fm-test-run.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,69 @@ init_changed_fixture_repo() {
git -C "$repo" -c user.name=test -c user.email=test@example.invalid commit -qm baseline
}

# Build a repository with a primary checkout and one linked worktree, each
# holding a runnable copy of the runner and a probe suite that records the fact
# that it ran. Untracked copies are enough: the runner resolves its root from
# its own path, and the probe is named explicitly.
init_primary_and_linked_worktree() {
local repo=$1 linked=$2 tree
fm_git_init_commit "$repo"
git -C "$repo" worktree add --quiet -b linked-probe "$linked"
for tree in "$repo" "$linked"; do
mkdir -p "$tree/bin" "$tree/tests"
cp "$RUNNER" "$tree/bin/fm-test-run.sh"
chmod +x "$tree/bin/fm-test-run.sh"
cat >"$tree/tests/probe.test.sh" <<PROBE
#!/usr/bin/env bash
echo "ok - probe suite"
: >"$tree/ran"
PROBE
chmod +x "$tree/tests/probe.test.sh"
done
}

# A task worker's isolated placement is checked once, when the task starts.
# Nothing re-checks it, so a worker that later changes directory into the
# repository's primary checkout would run this branch-switching suite in the one
# checkout every linked worktree resolves against. The runner refuses that.
test_task_marker_refuses_the_primary_checkout() {
local tmp repo linked out rc
tmp=$(mktemp -d "${TMPDIR:-/tmp}/fm-test-run-primary.XXXXXX")
repo="$tmp/repo"
linked="$tmp/linked"
init_primary_and_linked_worktree "$repo" "$linked"

# Marker set, primary checkout: refuse, name the primary, and run nothing.
out=$(FM_TASK_ID=probe-task "$repo/bin/fm-test-run.sh" tests/probe.test.sh 2>&1) && rc=0 || rc=$?
[ "$rc" -ne 0 ] || { rm -rf "$tmp"; fail "the runner must refuse the primary checkout under a task marker"; }
assert_contains "$out" "primary checkout" "refusal did not name the primary checkout"
assert_contains "$out" "FM_TASK_ID=probe-task" "refusal did not name the task marker"
assert_contains "$out" "task worktree" "refusal did not point at the task worktree"
assert_not_contains "$out" "FM_TEST_BEGIN" "the refusal must happen before any suite runs"
assert_absent "$repo/ran" "the refused run still executed a suite"

# Marker set, linked worktree: the assigned placement, so the suite runs.
FM_TASK_ID=probe-task "$linked/bin/fm-test-run.sh" tests/probe.test.sh >/dev/null 2>&1 \
|| { rm -rf "$tmp"; fail "the runner must still run in a linked task worktree"; }
assert_present "$linked/ran" "the linked-worktree run did not execute its suite"

# No marker: a person in their own checkout is unaffected.
"$repo/bin/fm-test-run.sh" tests/probe.test.sh >/dev/null 2>&1 \
|| { rm -rf "$tmp"; fail "an unmarked run in the primary checkout must be unchanged"; }
assert_present "$repo/ran" "the unmarked run did not execute its suite"

# Inspection executes nothing, so it stays available even in the primary.
rm -f "$repo/ran"
out=$(FM_TASK_ID=probe-task "$repo/bin/fm-test-run.sh" --list tests/probe.test.sh 2>&1) \
|| { rm -rf "$tmp"; fail "--list must remain available under a task marker"; }
[ "$out" = "tests/probe.test.sh" ] \
|| { rm -rf "$tmp"; fail "--list under a task marker printed: $out"; }
assert_absent "$repo/ran" "--list must not execute a suite"

rm -rf "$tmp"
pass "a task marker refuses execution in the primary checkout and leaves worktrees and inspection alone"
}

test_changed_runner_surfaces_select_their_family() {
local tmp repo listed
tmp=$(mktemp -d "${TMPDIR:-/tmp}/fm-test-run-owner-scope.XXXXXX")
Expand Down Expand Up @@ -1447,6 +1510,7 @@ test_list_all_exact_suite_coverage
test_family_selection
test_single_script_selection
test_changed_file_selection_is_conservative
test_task_marker_refuses_the_primary_checkout
test_changed_runner_surfaces_select_their_family
test_shell_line_ending_policy_selects_runner_contract
test_changed_dependency_selection_and_unmapped_failure
Expand Down
6 changes: 6 additions & 0 deletions tests/lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ umask 022
# strips this to verify real refusal.
export FM_GATE_REFUSE_BYPASS=1

# Clear the task-worker marker bin/fm-spawn.sh exports into ship and scout
# panes. This suite builds git-init fixture repositories whose primary checkout
# it runs a copied bin/fm-test-run.sh in, and that runner refuses the primary
# under the marker. A case that verifies the refusal sets FM_TASK_ID itself.
unset FM_TASK_ID

# Resolve the repo root from this library's own location. Consumed by sourcing
# test files, not by this library, so it reads as "unused" here.
# shellcheck disable=SC2034
Expand Down
Loading