From 45429af74d3a768f1d385fd1b08115cf4df74081 Mon Sep 17 00:00:00 2001 From: Dongkeun Lee Date: Sun, 26 Jul 2026 06:19:21 -0400 Subject: [PATCH 01/34] fix: isolate task temp roots across runs --- bin/fm-account-routing-lib.sh | 58 +++- bin/fm-checkout-lock-lib.sh | 19 +- bin/fm-home-seed.sh | 106 ++++--- bin/fm-spawn.sh | 50 +++- bin/fm-teardown.sh | 144 ++++++--- bin/fm-watch-checkpoint.sh | 4 +- tests/fm-account-directory.test.sh | 28 +- tests/fm-account-routing.test.sh | 131 ++++++-- tests/fm-arm-pretool-check.test.sh | 12 +- tests/fm-autocompact.test.sh | 2 +- tests/fm-backend-cmux.test.sh | 2 +- tests/fm-backend-herdr.test.sh | 2 +- tests/fm-backend-zellij.test.sh | 2 +- tests/fm-backend.test.sh | 10 +- tests/fm-backlog-handoff.test.sh | 2 +- tests/fm-bearings-snapshot.test.sh | 2 +- tests/fm-bootstrap.test.sh | 2 +- tests/fm-brief.test.sh | 2 +- tests/fm-cd-pretool-check.test.sh | 2 +- tests/fm-checkout-refresh.test.sh | 2 +- tests/fm-checkout-return-boundary.test.sh | 2 +- tests/fm-composer-ghost.test.sh | 2 +- tests/fm-crew-state.test.sh | 2 +- tests/fm-daemon.test.sh | 2 +- tests/fm-dispatch-select.test.sh | 2 +- tests/fm-ensure-agents-md.test.sh | 2 +- tests/fm-fleet-snapshot-view.test.sh | 2 +- tests/fm-herdr-lab.test.sh | 2 +- tests/fm-lint.test.sh | 8 +- tests/fm-lock.test.sh | 2 +- tests/fm-macos-permissions.test.sh | 2 +- tests/fm-pi-watch-extension.test.sh | 2 +- tests/fm-pr-merge.test.sh | 2 +- tests/fm-prompt-exec.test.sh | 2 +- tests/fm-report-stack.test.sh | 2 +- tests/fm-review-diff.test.sh | 2 +- tests/fm-secondmate-lifecycle-e2e.test.sh | 9 +- tests/fm-secondmate-liveness.test.sh | 36 ++- tests/fm-secondmate-safety.test.sh | 332 ++++++++++++++++----- tests/fm-secondmate-sync.test.sh | 19 +- tests/fm-send-popup-settle.test.sh | 2 +- tests/fm-send-secondmate-marker.test.sh | 2 +- tests/fm-send-settle.test.sh | 2 +- tests/fm-send-strict.test.sh | 2 +- tests/fm-session-start.test.sh | 2 +- tests/fm-spawn-batch.test.sh | 2 +- tests/fm-spawn-dispatch-profile.test.sh | 95 +++++- tests/fm-supervision-events.test.sh | 2 +- tests/fm-supervision-instructions.test.sh | 2 +- tests/fm-tangle-guard.test.sh | 65 +++- tests/fm-teardown.test.sh | 266 +++++++++++++---- tests/fm-turnend-guard.test.sh | 2 +- tests/fm-update.test.sh | 2 +- tests/fm-wake-daemon-lifecycle-e2e.test.sh | 2 +- tests/fm-wake-queue.test.sh | 2 +- tests/fm-watch-checkpoint.test.sh | 18 +- tests/fm-watch-triage.test.sh | 6 +- tests/fm-watcher-lock.test.sh | 2 +- tests/fm-x-mode.test.sh | 2 +- tests/secondmate-helpers.sh | 20 +- tests/wake-helpers.sh | 18 +- 61 files changed, 1135 insertions(+), 397 deletions(-) diff --git a/bin/fm-account-routing-lib.sh b/bin/fm-account-routing-lib.sh index fa65dccead..565e3b9f68 100644 --- a/bin/fm-account-routing-lib.sh +++ b/bin/fm-account-routing-lib.sh @@ -236,6 +236,51 @@ fm_account_real_directory() { [ -d "$1" ] && [ ! -L "$1" ] } +fm_account_task_tmp_path() { # + local task=$1 state + fm_account_valid_id "$task" || return 1 + if [ -n "${STATE:-}" ]; then + state=$STATE + elif [ -n "${FM_STATE_OVERRIDE:-}" ]; then + state=$FM_STATE_OVERRIDE + elif [ -n "${FM_HOME:-}" ]; then + state=$FM_HOME/state + else + return 1 + fi + fm_account_real_directory "$state" || return 1 + state=$(cd "$state" 2>/dev/null && pwd -P) || return 1 + [ "$state" != / ] || return 1 + # The physical task-state directory is the run identity. Nesting the temp + # root under it prevents cross-home and cross-test collisions, and makes a + # suite's ordinary run-root cleanup own task temp even after an early abort. + printf '%s/.task-tmp/fm-%s\n' "${state%/}" "$task" +} + +fm_account_legacy_task_tmp_path() { # + fm_account_valid_id "$1" || return 1 + printf '/tmp/fm-%s\n' "$1" +} + +fm_account_task_tmp_is_current() { # + local expected + expected=$(fm_account_task_tmp_path "$1") || return 1 + [ "$2" = "$expected" ] +} + +fm_account_task_tmp_is_legacy() { # + local legacy + legacy=$(fm_account_legacy_task_tmp_path "$1") || return 1 + [ "$2" = "$legacy" ] +} + +fm_account_task_tmp_is_expected() { # + # Legacy metadata remains recognizable, but callers must use + # fm_account_task_tmp_is_current before any filesystem mutation. + fm_account_task_tmp_is_current "$1" "$2" \ + || fm_account_task_tmp_is_legacy "$1" "$2" +} + fm_account_safe_file_destination() { [ ! -L "$1" ] && { [ ! -e "$1" ] || [ -f "$1" ]; } } @@ -1213,11 +1258,16 @@ fm_account_restore_artifacts() { fi done if [ -n "$tasktmp" ]; then - [ "$tasktmp" = "/tmp/fm-$task" ] || return 1 - if [ -e "$backup/tasktmp-existed" ]; then - [ -e "$backup/gotmp-existed" ] || fm_account_system_exec "$FM_ACCOUNT_SYSTEM_RM_BIN" -rf "$tasktmp/gotmp" || return 1 + if fm_account_task_tmp_is_current "$task" "$tasktmp"; then + if [ -e "$backup/tasktmp-existed" ]; then + [ -e "$backup/gotmp-existed" ] || fm_account_system_exec "$FM_ACCOUNT_SYSTEM_RM_BIN" -rf "$tasktmp/gotmp" || return 1 + else + fm_account_system_exec "$FM_ACCOUNT_SYSTEM_RM_BIN" -rf "$tasktmp" || return 1 + fi else - fm_account_system_exec "$FM_ACCOUNT_SYSTEM_RM_BIN" -rf "$tasktmp" || return 1 + # A legacy tasktmp is metadata-only compatibility. Never inspect or + # mutate the shared /tmp path while restoring task-owned artifacts. + fm_account_task_tmp_is_legacy "$task" "$tasktmp" || return 1 fi fi [ "$retain" = 1 ] || fm_account_system_exec "$FM_ACCOUNT_SYSTEM_RM_BIN" -rf "$backup" diff --git a/bin/fm-checkout-lock-lib.sh b/bin/fm-checkout-lock-lib.sh index e81b069010..222707ee43 100644 --- a/bin/fm-checkout-lock-lib.sh +++ b/bin/fm-checkout-lock-lib.sh @@ -106,7 +106,7 @@ fm_checkout_git_common_dir() { } fm_checkout_validate_git_metadata() { - local checkout=$1 root metadata absolute_git common listed line listed_root found=0 + local checkout=$1 root metadata absolute_git common top listed line listed_root found=0 root=$(fm_checkout_trusted_dir "$checkout") || return 1 metadata="$root/.git" [ -e "$metadata" ] && [ ! -L "$metadata" ] || return 1 @@ -118,11 +118,16 @@ fm_checkout_validate_git_metadata() { *) common="$root/$common" ;; esac common=$(fm_checkout_trusted_dir "$common") || return 1 + top=$(git -C "$root" rev-parse --show-toplevel 2>/dev/null) || return 1 + top=$(fm_checkout_trusted_dir "$top") || return 1 + [ "$top" = "$root" ] || return 1 if [ -d "$metadata" ]; then [ "$(fm_checkout_trusted_dir "$metadata")" = "$absolute_git" ] || return 1 [ "$absolute_git" = "$common" ] || return 1 elif [ -f "$metadata" ]; then - case "$absolute_git" in "$common"/worktrees/*) ;; *) return 1 ;; esac + if [ "$absolute_git" != "$common" ]; then + case "$absolute_git" in "$common"/worktrees/*) ;; *) return 1 ;; esac + fi else return 1 fi @@ -131,7 +136,15 @@ fm_checkout_validate_git_metadata() { case "$line" in "worktree "*) listed_root=$(fm_checkout_trusted_dir "${line#worktree }" 2>/dev/null) || return 1 - [ "$listed_root" != "$root" ] || found=$((found + 1)) + if [ "$listed_root" = "$root" ]; then + found=$((found + 1)) + elif [ -f "$metadata" ] && [ "$absolute_git" = "$common" ] \ + && [ "$listed_root" = "$common" ]; then + # An absorbed submodule's worktree listing identifies its common + # Git directory rather than the checked-out root. The exact + # show-toplevel proof above binds that Git directory back to root. + found=$((found + 1)) + fi ;; esac done <&2; return 1; } else + default=$(git -C "$FM_ROOT" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true) + default=${default#origin/} + if [ -z "$default" ]; then + for default in main master; do + git -C "$FM_ROOT" show-ref --verify --quiet "refs/heads/$default" && break + done + fi + target=$(git -C "$FM_ROOT" rev-parse --verify --quiet "refs/heads/$default^{commit}") || { + echo "error: cannot resolve the firstmate default branch for $home" >&2 + return 1 + } + source_origin=$(git -C "$FM_ROOT" remote get-url origin 2>/dev/null || true) mkdir -p "$(dirname "$home")" - git clone --quiet "$FM_ROOT" "$home" + git clone --quiet --no-checkout --single-branch --branch "$default" "$FM_ROOT" "$home" + git -C "$home" checkout --quiet --detach "$target" || return 1 + if [ -n "$source_origin" ]; then + git -C "$home" remote set-url origin "$source_origin" || return 1 + git -C "$home" update-ref "refs/remotes/origin/$default" "$target" || return 1 + git -C "$home" symbolic-ref refs/remotes/origin/HEAD "refs/remotes/origin/$default" || return 1 + else + git -C "$home" remote remove origin || return 1 + fi fi verify_firstmate_home "$home" } @@ -881,8 +901,44 @@ refuse_projectful_projectless_charter() { return 1 } +prepare_seed_charter() { + local id=$1 no_projects=$2 charter_summary charter_scope + shift 2 + if [ ! -f "$SEED_PARENT_BRIEF" ]; then + [ -n "${FM_SECONDMATE_CHARTER:-}" ] || { + echo "error: no filled secondmate charter brief at $SEED_PARENT_BRIEF; set FM_SECONDMATE_CHARTER or scaffold one and replace {TASK}" >&2 + return 1 + } + [ -d "$DATA/$id" ] || SEED_PARENT_BRIEF_DIR_CREATED=1 + if [ "$no_projects" -eq 1 ]; then + "$FM_ROOT/bin/fm-brief.sh" "$id" --secondmate --no-projects + else + "$FM_ROOT/bin/fm-brief.sh" "$id" --secondmate "$@" + fi + SEED_PARENT_BRIEF_CREATED=1 + fi + if grep -F '{TASK}' "$SEED_PARENT_BRIEF" >/dev/null 2>&1; then + echo "error: secondmate charter brief at $SEED_PARENT_BRIEF still contains {TASK}; fill it before seeding" >&2 + return 1 + fi + charter_summary=$(registry_summary_for_brief "$SEED_PARENT_BRIEF") + [ -n "$charter_summary" ] || { + echo "error: secondmate charter brief at $SEED_PARENT_BRIEF has an empty Charter section; fill it before seeding" >&2 + return 1 + } + charter_scope=$(registry_scope_for_brief "$SEED_PARENT_BRIEF") + [ -n "$charter_scope" ] || { + echo "error: secondmate charter brief at $SEED_PARENT_BRIEF has an empty Routing scope section; fill it before seeding" >&2 + return 1 + } + if [ "$no_projects" -eq 1 ]; then + refuse_projectful_projectless_charter "$id" "$SEED_PARENT_BRIEF" || return 1 + fi +} + seed_home() { - local id=$1 requested_home=$2 requested_abs home projects_csv project project_dst charter_summary charter_scope + local id=$1 requested_home=$2 requested_abs home projects_csv project project_dst + local preflight_out local no_projects=0 arg local filtered=() shift 2 @@ -941,12 +997,18 @@ seed_home() { } fi + if [ "$requested_home" != "-" ]; then + prepare_seed_charter "$id" "$no_projects" "$@" || return 1 + fi + if [ "$requested_home" = "-" ]; then SEED_HOME_ACQUIRED=1 home=$(acquire_treehouse_home "$id") SEED_HOME="$home" SEED_HOME_RETAINED=1 + refuse_active_home_path "$home" || return 1 SEED_HOME_LIFECYCLE_LOCK=$(fm_secondmate_home_lifecycle_lock_acquire "$CHECKOUT_LOCK_ROOT" "$home") || return 1 + prepare_seed_charter "$id" "$no_projects" "$@" || return 1 freshness_status=0 "$SCRIPT_DIR/fm-checkout-refresh.sh" verify-worktree "$home" "$FM_ROOT" || freshness_status=$? if [ "$freshness_status" -ne 0 ]; then @@ -965,8 +1027,11 @@ seed_home() { SEED_HOME="$requested_abs" [ -e "$requested_abs" ] || SEED_HOME_CREATED=1 home=$(ensure_home "$id" "$requested_abs") - "$SCRIPT_DIR/fm-checkout-refresh.sh" preflight "$home" || { - echo "error: refusing explicit secondmate home whose default branch cannot be refreshed safely" >&2 + if [ "$no_projects" -eq 1 ]; then + refuse_populated_projectless_home "$home" || return 1 + fi + preflight_out=$("$SCRIPT_DIR/fm-checkout-refresh.sh" preflight "$home" 2>&1) || { + echo "error: refusing explicit secondmate home whose default branch cannot be refreshed safely: $(printf '%s\n' "$preflight_out" | sed -n '1p')" >&2 return 1 } "$SCRIPT_DIR/fm-checkout-refresh.sh" verify-home "$home" "$FM_ROOT" || { @@ -981,9 +1046,6 @@ seed_home() { validate_seed_leaf_files "$home" || return 1 if [ "$no_projects" -eq 1 ]; then refuse_populated_projectless_home "$home" || return 1 - if [ -f "$SEED_PARENT_BRIEF" ]; then - refuse_projectful_projectless_charter "$id" "$SEED_PARENT_BRIEF" || return 1 - fi fi mkdir -p "$DATA" "$home/data" "$home/state" "$home/config" "$home/projects" if [ -f "$home/data/projects.md" ]; then @@ -1000,34 +1062,6 @@ seed_home() { fi SEED_HOME_BACKED_UP=1 - if [ ! -f "$SEED_PARENT_BRIEF" ]; then - [ -n "${FM_SECONDMATE_CHARTER:-}" ] || { - echo "error: no filled secondmate charter brief at $SEED_PARENT_BRIEF; set FM_SECONDMATE_CHARTER or scaffold one and replace {TASK}" >&2 - return 1 - } - [ -d "$DATA/$id" ] || SEED_PARENT_BRIEF_DIR_CREATED=1 - if [ "$no_projects" -eq 1 ]; then - "$FM_ROOT/bin/fm-brief.sh" "$id" --secondmate --no-projects - else - "$FM_ROOT/bin/fm-brief.sh" "$id" --secondmate "$@" - fi - SEED_PARENT_BRIEF_CREATED=1 - fi - if grep -F '{TASK}' "$SEED_PARENT_BRIEF" >/dev/null 2>&1; then - echo "error: secondmate charter brief at $SEED_PARENT_BRIEF still contains {TASK}; fill it before seeding" >&2 - return 1 - fi - charter_summary=$(registry_summary_for_brief "$SEED_PARENT_BRIEF") - [ -n "$charter_summary" ] || { - echo "error: secondmate charter brief at $SEED_PARENT_BRIEF has an empty Charter section; fill it before seeding" >&2 - return 1 - } - charter_scope=$(registry_scope_for_brief "$SEED_PARENT_BRIEF") - [ -n "$charter_scope" ] || { - echo "error: secondmate charter brief at $SEED_PARENT_BRIEF has an empty Routing scope section; fill it before seeding" >&2 - return 1 - } - for project in "$@"; do project_dst=$(validate_project_destination "$home" "$project") || return 1 [ -e "$project_dst" ] || printf '%s\n' "$project_dst" >> "$SEED_CREATED_PROJECTS_FILE" diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index cec185d8bb..67b9c2a5fa 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -602,7 +602,8 @@ reconcile_failed_direct_recovery() { echo "error: retained direct recovery artifacts are missing or unsafe for $task" >&2 return 1 fi - if [ "$tasktmp" != "/tmp/fm-$task" ] || [ -z "$generation" ] || [ -z "$target" ]; then + if ! fm_account_task_tmp_is_expected "$task" "$tasktmp" \ + || [ -z "$generation" ] || [ -z "$target" ]; then fm_account_meta_lock_release "$lock" >/dev/null 2>&1 || true echo "error: retained direct recovery metadata is incomplete for $task" >&2 return 1 @@ -867,7 +868,7 @@ if [ "$RECOVERY_ACCOUNT" = 1 ]; then rollback_backup=$(fm_account_meta_value "$RESUME_META" account_rollback_backup) fm_account_meta_lock_release "$rollback_meta_lock" || exit 1 rollback_meta_lock= - if [ -n "$rollback_tasktmp" ] && [ "$rollback_tasktmp" != "/tmp/fm-$rollback_id" ]; then + if [ -n "$rollback_tasktmp" ] && ! fm_account_task_tmp_is_expected "$rollback_id" "$rollback_tasktmp"; then echo "error: unsafe task temp path in rollback metadata for $rollback_id: $rollback_tasktmp" >&2 exit 1 fi @@ -893,7 +894,9 @@ if [ "$RECOVERY_ACCOUNT" = 1 ]; then rollback_profile=$(fm_account_meta_value "$RESUME_META" account_profile) if [ -z "$rollback_profile" ] && [ "$rollback_kind" = secondmate ] && [ -z "$rollback_backup" ]; then rm -f "$RESUME_META" "$STATE/$rollback_id.status" "$STATE/$rollback_id.turn-ended" "$STATE/$rollback_id.check.sh" "$STATE/$rollback_id.pi-ext.ts" "$STATE/$rollback_id.grok-turnend-token" - [ -z "$rollback_tasktmp" ] || rm -rf "$rollback_tasktmp" + [ -z "$rollback_tasktmp" ] \ + || ! fm_account_task_tmp_is_current "$rollback_id" "$rollback_tasktmp" \ + || rm -rf "$rollback_tasktmp" fi if [ -z "$rollback_profile" ]; then if [ -n "$rollback_backup" ]; then @@ -1007,7 +1010,7 @@ ORIGINAL_GROK_TOKEN_PRESENT=-1 ORIGINAL_TASK_TMP_PRESENT=-1 snapshot_existing_artifacts() { - local backup name source tasktmp="/tmp/fm-$ID" + local backup name source tasktmp=$SPAWN_TASK_TMP backup=$(mktemp -d "$STATE/.$ID.artifacts.rollback.XXXXXX") || return 1 for name in "$ID.status" "$ID.turn-ended" "$ID.check.sh" "$ID.pi-ext.ts" "$ID.grok-turnend-token"; do source="$STATE/$name" @@ -1262,7 +1265,7 @@ persist_failed_direct_recovery() { echo "kind=${KIND:-${RECORDED_KIND:-ship}}" echo "mode=${MODE:-${RECORDED_MODE:-no-mistakes}}" echo "yolo=${YOLO:-${RECORDED_YOLO:-off}}" - echo "tasktmp=${TASK_TMP:-${RECORDED_TASKTMP:-/tmp/fm-$ID}}" + echo "tasktmp=${TASK_TMP:-$SPAWN_TASK_TMP}" echo "model=${RECORDED_MODEL:-${MODEL:-default}}" echo "effort=${RECORDED_EFFORT:-${EFFORT:-default}}" echo "generation_id=${RECORDED_GENERATION:-${SPAWN_GENERATION_ID:-}}" @@ -1351,7 +1354,7 @@ persist_failed_direct_spawn() { # echo "kind=${KIND:-ship}" echo "mode=$retained_mode" echo "yolo=$retained_yolo" - echo "tasktmp=${TASK_TMP:-/tmp/fm-$ID}" + echo "tasktmp=${TASK_TMP:-$SPAWN_TASK_TMP}" echo "model=${MODEL:-default}" echo "effort=${EFFORT:-default}" echo "generation_id=${SPAWN_GENERATION_ID:-}" @@ -1464,7 +1467,7 @@ spawn_restore_unmanaged_state_locked() { [ "${ACCOUNT_EFFECTIVE_MODE:-off}" != enforce ] || return 0 if [ -n "$EXISTING_ARTIFACT_BACKUP" ]; then artifact_backup_name=${EXISTING_ARTIFACT_BACKUP##*/} - fm_account_restore_artifacts "$STATE" "$ID" "$artifact_backup_name" "/tmp/fm-$ID" 1 || return 1 + fm_account_restore_artifacts "$STATE" "$ID" "$artifact_backup_name" "$SPAWN_TASK_TMP" 1 || return 1 fi if [ -n "$META_BACKUP" ]; then [ -f "$META_BACKUP" ] && [ -f "$meta" ] || return 1 @@ -1636,7 +1639,7 @@ spawn_abort_cleanup() { fi if [ -n "$rollback_lock" ]; then artifact_backup_name=${EXISTING_ARTIFACT_BACKUP##*/} - if fm_account_restore_artifacts "$STATE" "$ID" "$artifact_backup_name" "${TASK_TMP:-/tmp/fm-$ID}" 1; then + if fm_account_restore_artifacts "$STATE" "$ID" "$artifact_backup_name" "${TASK_TMP:-$SPAWN_TASK_TMP}" 1; then if [ "$META_INSTALLED" = 1 ] && [ -n "$META_BACKUP" ] && [ -f "$META_BACKUP" ]; then if fm_account_meta_merge_extensions "$STATE/$ID.meta" "$META_BACKUP" \ && fm_account_safe_file_destination "$STATE/$ID.meta" \ @@ -1670,7 +1673,7 @@ spawn_abort_cleanup() { if [ -n "$rollback_lock" ] && [ "$worktree_clean" = 1 ]; then if [ -n "$META_BACKUP" ] && [ -f "$META_BACKUP" ]; then artifact_backup_name=${EXISTING_ARTIFACT_BACKUP##*/} - if fm_account_restore_artifacts "$STATE" "$ID" "$artifact_backup_name" "${TASK_TMP:-/tmp/fm-$ID}" 1 \ + if fm_account_restore_artifacts "$STATE" "$ID" "$artifact_backup_name" "${TASK_TMP:-$SPAWN_TASK_TMP}" 1 \ && fm_account_meta_merge_extensions "$STATE/$ID.meta" "$META_BACKUP" \ && fm_account_safe_file_destination "$STATE/$ID.meta" \ && mv "$META_BACKUP" "$STATE/$ID.meta"; then @@ -1845,6 +1848,23 @@ if [ "${#POS[@]}" -gt 0 ] && [ "${POS[0]}" != "$idpart" ] && case "$idpart" in * exit "$rc" fi ID=${POS[0]} +mkdir -p "$STATE" || { + echo "error: cannot establish state directory at $STATE" >&2 + exit 1 +} +SPAWN_TASK_TMP=$(fm_account_task_tmp_path "$ID") || { + echo "error: cannot establish a safe task temp path for $ID" >&2 + exit 1 +} +if [ "$SPAWN_META_PRESENT" = 1 ]; then + EXISTING_TASK_TMP=$(spawn_preflight_meta_value tasktmp) + if [ -n "$EXISTING_TASK_TMP" ]; then + fm_account_task_tmp_is_expected "$ID" "$EXISTING_TASK_TMP" || { + echo "error: existing task metadata has an unsafe tasktmp for $ID" >&2 + exit 1 + } + fi +fi PROJ= ARG3= FIRSTMATE_HOME= @@ -1929,7 +1949,7 @@ if [ -e "$STATE/$ID.turn-ended" ] || [ -L "$STATE/$ID.turn-ended" ]; then ORIGIN if [ -e "$STATE/$ID.check.sh" ] || [ -L "$STATE/$ID.check.sh" ]; then ORIGINAL_CHECK_PRESENT=1; else ORIGINAL_CHECK_PRESENT=0; fi if [ -e "$STATE/$ID.pi-ext.ts" ] || [ -L "$STATE/$ID.pi-ext.ts" ]; then ORIGINAL_PI_EXT_PRESENT=1; else ORIGINAL_PI_EXT_PRESENT=0; fi if [ -e "$STATE/$ID.grok-turnend-token" ] || [ -L "$STATE/$ID.grok-turnend-token" ]; then ORIGINAL_GROK_TOKEN_PRESENT=1; else ORIGINAL_GROK_TOKEN_PRESENT=0; fi -if [ -e "/tmp/fm-$ID" ] || [ -L "/tmp/fm-$ID" ]; then ORIGINAL_TASK_TMP_PRESENT=1; else ORIGINAL_TASK_TMP_PRESENT=0; fi +if [ -e "$SPAWN_TASK_TMP" ] || [ -L "$SPAWN_TASK_TMP" ]; then ORIGINAL_TASK_TMP_PRESENT=1; else ORIGINAL_TASK_TMP_PRESENT=0; fi if [ "$RECOVERY_ACCOUNT" = 1 ]; then RECORDED_KIND=$(fm_meta_get "$RESUME_META" kind) @@ -2007,7 +2027,7 @@ if [ "$RECOVERY_ACCOUNT" = 1 ]; then [ -n "$RECORDED_MODE" ] || { echo "error: direct account recovery metadata has no mode for $ID" >&2; exit 1; } [ -n "$RECORDED_YOLO" ] || { echo "error: direct account recovery metadata has no yolo setting for $ID" >&2; exit 1; } [ -n "$RECORDED_GENERATION" ] || { echo "error: direct account recovery metadata has no generation_id for $ID" >&2; exit 1; } - [ "$RECORDED_TASKTMP" = "/tmp/fm-$ID" ] || { echo "error: direct account recovery metadata has an invalid tasktmp for $ID" >&2; exit 1; } + fm_account_task_tmp_is_expected "$ID" "$RECORDED_TASKTMP" || { echo "error: direct account recovery metadata has an invalid tasktmp for $ID" >&2; exit 1; } RECORDED_META_WORKTREE_GIT_REF=$RECORDED_WORKTREE_GIT_REF RECORDED_META_WORKTREE_GIT_HEAD=$RECORDED_WORKTREE_GIT_HEAD RECORDED_META_WORKTREE_GIT_SETUP_REF=$RECORDED_WORKTREE_GIT_SETUP_REF @@ -2981,12 +3001,14 @@ if [ "$DIRECT_ACCOUNT_ROUTING" = 1 ] && [ "$DIRECT_ACCOUNT_RECOVERY" = 0 ] && [ } fi -# Per-task temp root: /tmp/fm-/ with Go's build temp nested at gotmp/. Go won't +# Per-task temp root with Go's build temp nested at gotmp/. The physical task +# state directory is its namespace, so separate homes and test runs cannot +# share a root even when their human-readable task ids match. 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 +# Nested (not a bare task-root/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). -TASK_TMP="/tmp/fm-$ID" +TASK_TMP=$SPAWN_TASK_TMP mkdir -p "$TASK_TMP/gotmp" # herdr sets GOTMPDIR natively at agent start. Every other backend exports it into # the pane shell just before the launch line, further down. CREW_PATH rides the same diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index ea03eda10b..be85537399 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -93,6 +93,7 @@ FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" +PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}" CHECKOUT_STATE_BASE="${FM_CHECKOUT_REFRESH_STATE_BASE:-${XDG_STATE_HOME:-$HOME/.local/state}/firstmate/checkout-refresh}" SECONDMATE_REG="$DATA/secondmates.md" SUB_HOME_MARKER=".fm-secondmate-home" @@ -125,6 +126,10 @@ case "$TEARDOWN_UPSTREAM_TIMEOUT" in esac ID=$1 FORCE=${2:-} +fm_account_task_tmp_path "$ID" >/dev/null || { + echo "error: cannot establish a safe task temp path for $ID" >&2 + exit 1 +} META="$STATE/$ID.meta" @@ -202,10 +207,10 @@ if [ "$BACKEND" = orca ]; 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 recorded by fm-spawn for tasks that set up a per-task temp root; +# absent for tasks spawned before that change, so tolerate empty. TASK_TMP=$(grep '^tasktmp=' "$META" | cut -d= -f2- || true) -if [ -n "$TASK_TMP" ] && [ "$TASK_TMP" != "/tmp/fm-$ID" ]; then +if [ -n "$TASK_TMP" ] && ! fm_account_task_tmp_is_expected "$ID" "$TASK_TMP"; then echo "REFUSED: unsafe task temp path in metadata for $ID: $TASK_TMP" >&2 exit 1 fi @@ -1547,19 +1552,17 @@ import sys home, state_path = sys.argv[1:] try: - if os.path.islink(state_path): - raise OSError("state directory must not be a symlink") - metadata = os.stat(state_path) + home_root = os.path.realpath(home) + state_root = os.path.realpath(state_path) + if state_root == home_root or os.path.commonpath((home_root, state_root)) != home_root: + raise OSError("state directory resolves outside its secondmate home") + metadata = os.stat(state_root) permissions = stat.S_IMODE(metadata.st_mode) if not stat.S_ISDIR(metadata.st_mode): raise NotADirectoryError(state_path) if not permissions & 0o444 or not permissions & 0o111: raise PermissionError("state directory is unreadable") - home_root = os.path.realpath(home) - state_root = os.path.realpath(state_path) - if state_root != os.path.join(home_root, "state"): - raise OSError("state directory resolves outside its secondmate home") - with os.scandir(state_path) as entries: + with os.scandir(state_root) as entries: for entry in sorted(entries, key=lambda item: item.name): metadata = entry.stat(follow_symlinks=False) if not entry.name.endswith(".meta"): @@ -1570,7 +1573,7 @@ try: raise PermissionError(f"unreadable child metadata entry: {entry.path}") if any(character in entry.path for character in ("\n", "\r")): raise OSError("child metadata path contains unsupported control characters") - print(entry.path) + print(os.path.join(state_root, entry.name)) except OSError as error: print( f"REFUSED: secondmate child state is unprovable at {state_path}: {error}", @@ -1826,15 +1829,29 @@ safe_rm_rf_child_worktree() { } safe_remove_task_tmp() { - local target=$1 base + local target=$1 base legacy [ -n "$target" ] || return 0 - [ "$target" = "/tmp/fm-$ID" ] || return 1 - base=$(python3 - <<'PY' + fm_account_task_tmp_is_expected "$ID" "$target" || return 1 + legacy=$(fm_account_legacy_task_tmp_path "$ID") || return 1 + # Legacy compatibility is classification-only. Never inspect or mutate the + # old process-wide /tmp/fm- location from a current teardown. + [ "$target" != "$legacy" ] || return 0 + fm_account_task_tmp_is_current "$ID" "$target" || return 1 + [ -e "$target" ] || [ -L "$target" ] || return 0 + base=${target%/*} + base=$(python3 - "$base" <<'PY' import os import stat +import sys -base = os.path.realpath("/tmp") -if base not in ("/tmp", "/private/tmp"): +raw = sys.argv[1] +base = os.path.realpath(raw) +if raw == "/tmp": + if base not in ("/tmp", "/private/tmp"): + raise SystemExit(1) +elif raw != base: + raise SystemExit(1) +if not base.startswith(os.path.sep) or base == os.path.sep: raise SystemExit(1) current = os.path.sep for component in base.split(os.path.sep): @@ -1849,7 +1866,8 @@ if not stat.S_ISDIR(os.lstat(base).st_mode): print(base) PY ) || return 1 - removal_tree_operation "$base/fm-$ID" "task temp root" remove + removal_tree_operation "$base/${target##*/}" "task temp root" remove || return 1 + fm_account_system_exec "$FM_ACCOUNT_SYSTEM_RMDIR_BIN" "$base" 2>/dev/null || true } remove_worktree_compatibility_artifacts() { @@ -1971,6 +1989,10 @@ validate_secondmate_home_landed_state() { $0 == "?? .claude/settings.local.json" { next } $0 == "?? .opencode/plugins/fm-turn-end.js" { next } $0 == "?? .fm-grok-turnend" { next } + $0 == "?? config" { next } + $0 == "?? data" { next } + $0 == "?? projects" { next } + $0 == "?? state" { next } $0 != "" { print } ') [ -z "$unsafe" ] || { @@ -2708,6 +2730,7 @@ EOF } while IFS= read -r ref; do [ -n "$ref" ] || continue + [ "$ref" != refs/stash ] || continue git -C "$repository" cat-file -e "$ref^{object}" 2>/dev/null || { echo "REFUSED: $label ref $ref depends on unavailable objects" >&2 return 1 @@ -3074,8 +3097,29 @@ except OSError: PY } +secondmate_child_worktree_is_registered() { + local home=$1 repository=$2 worktree=$3 child_metas child_meta child_kind child_project child_worktree + local canonical_project canonical_worktree matches=0 + child_metas=$(secondmate_state_metadata "$home") || return 1 + while IFS= read -r child_meta; do + [ -n "$child_meta" ] || continue + child_kind=$(meta_value "$child_meta" kind) + [ "$child_kind" != secondmate ] || continue + child_project=$(meta_value "$child_meta" project) + child_worktree=$(meta_value "$child_meta" worktree) + canonical_project=$(exact_git_worktree_root "$child_project") || continue + canonical_worktree=$(exact_git_worktree_root "$child_worktree") || continue + if [ "$canonical_project" = "$repository" ] && [ "$canonical_worktree" = "$worktree" ]; then + matches=$((matches + 1)) + fi + done </dev/null) || return 1 common=$(git -C "$repository" rev-parse --git-common-dir 2>/dev/null) || return 1 @@ -3144,6 +3188,9 @@ validate_secondmate_repository_worktree_graph() { : elif [ "$submodule_admin" -eq 1 ] && [ "$canonical" = "$common" ]; then : + elif [ "$allow_registered_children" -eq 1 ] \ + && secondmate_child_worktree_is_registered "$retiring_home" "$repository" "$canonical"; then + : else echo "REFUSED: secondmate project common Git directory owns another linked worktree at $canonical" >&2 return 1 @@ -3154,10 +3201,17 @@ validate_secondmate_repository_worktree_graph() { done <&2 - return 1 - } + if [ "$allow_registered_children" -eq 1 ]; then + [ "$count" -ge 1 ] || { + echo "REFUSED: secondmate project linked-worktree ownership is ambiguous at $repository" >&2 + return 1 + } + else + [ "$count" -eq 1 ] || { + echo "REFUSED: secondmate project linked-worktree ownership is ambiguous at $repository" >&2 + return 1 + } + fi } validate_secondmate_declared_submodules() { @@ -3187,7 +3241,7 @@ EOF validate_secondmate_project_repository_landed_state() { local repository=$1 source_repository=$2 retiring_home=$3 repository_container=${4:-$1} - local source_container=${5:-$2} dirty refs ref tip reflog_tips + local source_container=${5:-$2} allow_registered_children=${6:-0} dirty refs ref tip reflog_tips local remote_tips remote_tip landed repository_identity source_identity bare stash_status local authority_record authority_kind authority cleanup_status=0 [ "$(exact_git_repository_root "$repository" "$repository_container")" = "$repository" ] || { @@ -3203,7 +3257,7 @@ validate_secondmate_project_repository_landed_state() { "registered source project repository" || return 1 git_history_rewrite_state_is_clean "$repository" "secondmate project repository" || return 1 validate_secondmate_repository_worktree_graph \ - "$repository" "$retiring_home" "$repository_container" || return 1 + "$repository" "$retiring_home" "$repository_container" "$allow_registered_children" || return 1 validate_secondmate_declared_submodules "$repository" "$repository_container" || { echo "REFUSED: secondmate project submodule state is uninspectable at $repository" >&2 return 1 @@ -3306,7 +3360,7 @@ EOF validate_secondmate_project_clones() { local home=$1 registry=$2 expected_id=$3 expected_source=$4 projects_root source_projects_root - local expected listed project clone source_clone repositories relative repository source_repository + local allow_registered_children=${5:-0} home_root expected listed project clone source_clone repositories relative repository source_repository if ! expected=$(fm_secondmate_registry_query "$registry" query "$expected_id" projects); then if [ "$registry" = "$PREPARED_REGISTRY_PATH" ] \ && [ "$expected_id" = "$PREPARED_REGISTRY_ID" ] \ @@ -3320,7 +3374,12 @@ validate_secondmate_project_clones() { return 1 fi fi - projects_root=$(fm_checkout_trusted_dir "$home/projects") || { + home_root=$(canonical_existing_dir "$home") || return 1 + projects_root=$(cd "$home/projects" 2>/dev/null && pwd -P) || { + echo "REFUSED: secondmate projects directory is missing, redirected, or unreadable at $home/projects" >&2 + return 1 + } + path_is_ancestor_of "$home_root" "$projects_root" || { echo "REFUSED: secondmate projects directory is missing, redirected, or unreadable at $home/projects" >&2 return 1 } @@ -3352,8 +3411,8 @@ PY echo "REFUSED: secondmate project clones do not exactly match the registration for $expected_id" >&2 return 1 } - if [ "$expected_source" = "$FM_ROOT" ] && [ -n "${FM_PROJECTS_OVERRIDE:-}" ]; then - source_projects_root=$FM_PROJECTS_OVERRIDE + if [ "$expected_source" = "$FM_ROOT" ]; then + source_projects_root=$PROJECTS else source_projects_root="$expected_source/projects" fi @@ -3386,7 +3445,7 @@ PY } fi validate_secondmate_project_repository_landed_state \ - "$repository" "$source_repository" "$home" "$clone" "$source_clone" || return 1 + "$repository" "$source_repository" "$home" "$clone" "$source_clone" "$allow_registered_children" || return 1 done <}" >&2 @@ -3437,7 +3496,7 @@ validate_firstmate_home_for_removal() { validate_secondmate_home_landed_state "$abs_home_path" "$expected_source" || return 1 if [ -n "$expected_id" ]; then validate_secondmate_project_clones \ - "$abs_home_path" "$expected_registry" "$expected_id" "$expected_source" || return 1 + "$abs_home_path" "$expected_registry" "$expected_id" "$expected_source" "$allow_registered_children" || return 1 fi validate_firstmate_operational_dirs_for_removal "$abs_home_path" "$label" || return 1 secondmate_state_metadata "$abs_home_path" >/dev/null || return 1 @@ -3553,7 +3612,7 @@ validate_firstmate_home_children_removal() { child_proj=$(meta_value "$child_meta" project) child_home=$(meta_value "$child_meta" home) [ -n "$child_home" ] || child_home=$child_wt - validate_firstmate_home_for_removal "$child_home" "child firstmate home" "$child_id" "$home" "$home/data/secondmates.md" "$child_proj" >/dev/null || return 1 + validate_firstmate_home_for_removal "$child_home" "child firstmate home" "$child_id" "$home" "$home/data/secondmates.md" "$child_proj" 1 1 >/dev/null || return 1 validate_firstmate_home_children_removal "$child_home" || return 1 elif [ "$child_backend" = orca ]; then require_orca_task_metadata_identity "$child_meta" "$child_id" || return 1 @@ -3900,15 +3959,12 @@ if [ "$KIND" = secondmate ]; then exit 1 } validate_firstmate_home_for_removal \ - "$HOME_PATH" "secondmate home" "$ID" "$FM_ROOT" "$SECONDMATE_REG" "$PROJ" 0 \ + "$HOME_PATH" "secondmate home" "$ID" "$FM_ROOT" "$SECONDMATE_REG" "$PROJ" 0 1 \ >/dev/null || exit 1 - if [ "$FORCE" = "--force" ]; then - validate_firstmate_home_children_removal "$HOME_PATH" || exit 1 - fi - quiesce_secondmate_endpoint || exit 1 if [ "$FORCE" = "--force" ]; then validate_firstmate_home_children_removal "$HOME_PATH" || exit 1 else + require_empty_secondmate_registry "$HOME_PATH" || exit 1 SUB_STATE="$HOME_PATH/state" CHILD_METAS=$(secondmate_state_metadata "$HOME_PATH") || exit 1 if [ -n "$CHILD_METAS" ]; then @@ -3918,6 +3974,10 @@ if [ "$KIND" = secondmate ]; then exit 1 fi fi + quiesce_secondmate_endpoint || exit 1 + if [ "$FORCE" = "--force" ]; then + validate_firstmate_home_children_removal "$HOME_PATH" || exit 1 + fi fi if [ "$KIND" = scout ]; then @@ -4065,7 +4125,11 @@ if [ "$MANAGED_ACCOUNT" = 1 ]; then fi if [ "$KIND" = secondmate ] && [ "$FORCE" = "--force" ]; then - cleanup_firstmate_home_children "$HOME_PATH" || exit 1 + if cleanup_firstmate_home_children "$HOME_PATH"; then + : + else + exit $? + fi fi [ "$KIND" = secondmate ] || validate_teardown_target_identity || exit 1 @@ -4182,7 +4246,7 @@ EOF fi remove_grok_turnend_auth "$STATE" "$ID" fm_backend_clear_transition "$BACKEND" "$STATE" "$T" || true -# Remove the per-task temp root (/tmp/fm-/, incl. its gotmp/) recorded by spawn. +# Remove the per-task temp root, including its gotmp, recorded by spawn. # Read before the state-file rm below; empty (pre-fix tasks without tasktmp=) is a no-op. [ -z "$TASK_TMP" ] || safe_remove_task_tmp "$TASK_TMP" || exit 1 rm -f "$STATE/$ID.status" "$STATE/$ID.turn-ended" "$STATE/$ID.check.sh" "$STATE/$ID.meta" "$STATE/$ID.pi-ext.ts" "$STATE/$ID.grok-turnend-token" diff --git a/bin/fm-watch-checkpoint.sh b/bin/fm-watch-checkpoint.sh index d754301fa4..9d153b1f69 100755 --- a/bin/fm-watch-checkpoint.sh +++ b/bin/fm-watch-checkpoint.sh @@ -88,10 +88,10 @@ run_with_perl_timeout() { set +e if command -v timeout >/dev/null 2>&1; then - timeout "$SECONDS_ARG" "$SCRIPT_DIR/fm-watch.sh" >"$OUT" 2>"$ERR" + timeout --kill-after=2 "$SECONDS_ARG" "$SCRIPT_DIR/fm-watch.sh" >"$OUT" 2>"$ERR" RC=$? elif command -v gtimeout >/dev/null 2>&1; then - gtimeout "$SECONDS_ARG" "$SCRIPT_DIR/fm-watch.sh" >"$OUT" 2>"$ERR" + gtimeout --kill-after=2 "$SECONDS_ARG" "$SCRIPT_DIR/fm-watch.sh" >"$OUT" 2>"$ERR" RC=$? else run_with_perl_timeout >"$OUT" 2>"$ERR" diff --git a/tests/fm-account-directory.test.sh b/tests/fm-account-directory.test.sh index 96e496b525..b4da5284cd 100755 --- a/tests/fm-account-directory.test.sh +++ b/tests/fm-account-directory.test.sh @@ -6,7 +6,7 @@ set -u . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" SELECTOR="$ROOT/bin/fm-account-directory.sh" -TMP_ROOT=$(fm_test_tmproot fm-account-directory-tests) +fm_test_tmproot_into TMP_ROOT fm-account-directory-tests ACCOUNT_ROOT="$TMP_ROOT/accounts" FAKEBIN=$(fm_fakebin "$TMP_ROOT") QUOTA_LOG="$TMP_ROOT/quota.log" @@ -315,7 +315,7 @@ make_spawn_case() { worktree="$case_dir/worktree" launch_log="$case_dir/launch.log" mkdir -p "$home/data/$id" "$home/projects" "$home/state" "$home/config" \ - "$home/treehouse-pools" + "$home/state/.task-tmp" "$home/treehouse-pools" printf '%s\n' "$harness" > "$home/config/crew-harness" printf 'brief for %s\n' "$id" > "$home/data/$id/brief.md" touch "$home/state/.last-watcher-beat" @@ -744,8 +744,8 @@ test_direct_recovery_tracks_retained_replacement_endpoint() { set_remaining 1 20,15 set_remaining 2 95,90 rm -f "$SPAWN_HOME/state/.fake-endpoint" - rm -rf "/tmp/fm-$id" - : > "/tmp/fm-$id" + rm -rf "$SPAWN_HOME/state/.task-tmp/fm-$id" + : > "$SPAWN_HOME/state/.task-tmp/fm-$id" if out=$(FM_FAKE_KILL_RETAIN=1 \ run_direct_spawn "$SPAWN_HOME" "$SPAWN_WORKTREE" "$SPAWN_LAUNCH_LOG" \ @@ -765,7 +765,7 @@ test_direct_recovery_tracks_retained_replacement_endpoint() { [ -f "$SPAWN_HOME/state/$backup_name" ] || fail "failed recovery did not retain its prior metadata backup" [ -d "$SPAWN_HOME/state/$artifacts_name" ] || fail "failed recovery did not retain its artifact backup" - rm -f "/tmp/fm-$id" "$SPAWN_HOME/state/.fake-endpoint" + rm -f "$SPAWN_HOME/state/.task-tmp/fm-$id" "$SPAWN_HOME/state/.fake-endpoint" retry=$(run_direct_spawn "$SPAWN_HOME" "$SPAWN_WORKTREE" "$SPAWN_LAUNCH_LOG" \ "$id" --recover-direct-account 2>&1) launch=$(cat "$SPAWN_LAUNCH_LOG") @@ -776,7 +776,7 @@ test_direct_recovery_tracks_retained_replacement_endpoint() { if grep -q '^direct_recovery_' "$meta"; then fail "successful retry left direct recovery markers in metadata"; fi [ ! -e "$SPAWN_HOME/state/$backup_name" ] || fail "successful retry left the retained metadata backup" [ ! -e "$SPAWN_HOME/state/$artifacts_name" ] || fail "successful retry left the retained artifact backup" - rm -rf "/tmp/fm-$id" + rm -rf "$SPAWN_HOME/state/.task-tmp/fm-$id" pass "failed direct recovery tracks and reconciles a retained replacement endpoint" } @@ -788,7 +788,7 @@ test_new_direct_spawn_tracks_retained_endpoint_and_worktree() { id=direct-new-retained-z8 record=$(make_spawn_case direct-new-retained codex "$id") read_spawn_case "$record" - : > "/tmp/fm-$id" + : > "$SPAWN_HOME/state/.task-tmp/fm-$id" if out=$(FM_FAKE_KILL_RETAIN=1 \ run_direct_spawn "$SPAWN_HOME" "$SPAWN_WORKTREE" "$SPAWN_LAUNCH_LOG" \ @@ -827,7 +827,7 @@ test_new_direct_spawn_tracks_retained_endpoint_and_worktree() { [ "$recovery_status" -ne 0 ] || fail "direct recovery bypassed pending new-spawn cleanup" assert_contains "$recovery_out" "failed direct spawn cleanup is pending" \ "pending new-spawn cleanup refusal was not actionable" - rm -f "/tmp/fm-$id" "$SPAWN_HOME/state/.fake-endpoint" + rm -f "$SPAWN_HOME/state/.task-tmp/fm-$id" "$SPAWN_HOME/state/.fake-endpoint" pass "new direct spawn tracks retained endpoint and worktree state" } @@ -840,7 +840,7 @@ test_failed_new_direct_spawn_returns_worktree_after_endpoint_cleanup() { read_spawn_case "$record" recorded_worktree=$(cd "$SPAWN_WORKTREE" && pwd -P) : > "$TREEHOUSE_LOG" - : > "/tmp/fm-$id" + : > "$SPAWN_HOME/state/.task-tmp/fm-$id" if out=$(run_direct_spawn "$SPAWN_HOME" "$SPAWN_WORKTREE" "$SPAWN_LAUNCH_LOG" \ "$id" "$SPAWN_PROJECT" --account-pool legacy-codex-pool 2>&1); then @@ -855,7 +855,7 @@ test_failed_new_direct_spawn_returns_worktree_after_endpoint_cleanup() { [ ! -e "$SPAWN_WORKTREE" ] || fail "failed new direct spawn left its worktree registered" [ ! -e "$SPAWN_HOME/state/$id.meta" ] || fail "successful direct rollback left task metadata" [ ! -e "$SPAWN_HOME/state/.fake-endpoint" ] || fail "successful direct rollback left its endpoint" - rm -f "/tmp/fm-$id" + rm -f "$SPAWN_HOME/state/.task-tmp/fm-$id" pass "failed new direct spawn removes its endpoint and returns its worktree" } @@ -867,7 +867,7 @@ test_failed_new_direct_spawn_retains_cleanup_when_worktree_return_fails() { record=$(make_spawn_case direct-new-return-fail codex "$id") read_spawn_case "$record" : > "$TREEHOUSE_LOG" - : > "/tmp/fm-$id" + : > "$SPAWN_HOME/state/.task-tmp/fm-$id" if out=$(FM_FAKE_TREEHOUSE_RETURN_FAIL=1 \ run_direct_spawn "$SPAWN_HOME" "$SPAWN_WORKTREE" "$SPAWN_LAUNCH_LOG" \ @@ -884,7 +884,7 @@ test_failed_new_direct_spawn_retains_cleanup_when_worktree_return_fails() { assert_grep "rollback_pending=1" "$meta" \ "direct return failure did not fail closed" [ ! -e "$SPAWN_HOME/state/.fake-endpoint" ] || fail "direct return failure retained an already-removed endpoint" - rm -f "/tmp/fm-$id" + rm -f "$SPAWN_HOME/state/.task-tmp/fm-$id" pass "direct spawn persists cleanup state when worktree return cannot be confirmed" } @@ -896,7 +896,7 @@ test_failed_new_direct_spawn_never_records_an_uncreated_endpoint() { record=$(make_spawn_case direct-new-no-endpoint codex "$id") read_spawn_case "$record" : > "$TREEHOUSE_LOG" - : > "/tmp/fm-$id" + : > "$SPAWN_HOME/state/.task-tmp/fm-$id" if out=$(T=default:captain-pane FM_FAKE_TREEHOUSE_RETURN_FAIL=1 \ run_direct_spawn "$SPAWN_HOME" "$SPAWN_WORKTREE" "$SPAWN_LAUNCH_LOG" \ @@ -919,7 +919,7 @@ test_failed_new_direct_spawn_never_records_an_uncreated_endpoint() { assert_no_grep "herdr_pane_id=" "$meta" \ "pre-endpoint failure copied an ambient Herdr pane identity" [ ! -e "$SPAWN_HOME/state/.fake-endpoint" ] || fail "pre-endpoint failure created an endpoint" - rm -f "/tmp/fm-$id" + rm -f "$SPAWN_HOME/state/.task-tmp/fm-$id" pass "failed direct spawn never records an endpoint it did not create" } diff --git a/tests/fm-account-routing.test.sh b/tests/fm-account-routing.test.sh index 7e795905ff..021f377d62 100755 --- a/tests/fm-account-routing.test.sh +++ b/tests/fm-account-routing.test.sh @@ -16,7 +16,7 @@ SEND="$ROOT/bin/fm-send.sh" TEARDOWN="$ROOT/bin/fm-teardown.sh" SESSION_SYNC="$ROOT/bin/fm-account-session-sync.sh" CONTINUATION="$ROOT/bin/fm-account-continuation.sh" -TMP_ROOT=$(fm_test_tmproot fm-account-routing-tests) +fm_test_tmproot_into TMP_ROOT fm-account-routing-tests assert_not_grep() { local pattern=$1 file=$2 label=$3 @@ -237,14 +237,37 @@ if [ "${1:-}" = return ] && [ -n "${FM_FAKE_TREEHOUSE_RETURN_CHILD_PID_FILE:-}" fi if [ "${1:-}" = get ]; then lease_holder= - previous= - for argument in "$@"; do - [ "$previous" != --lease-holder ] || lease_holder=$argument - previous=$argument + prev= + for arg in "$@"; do + if [ "$prev" = --lease-holder ]; then + lease_holder=$arg + break + fi + prev=$arg done - pool=$(dirname "$(dirname "${FM_FAKE_TREEHOUSE_PATH:?}")") - printf '{"worktrees":[{"path":"%s","leased":true,"lease_holder":"%s","destroying":false}]}\n' \ - "$FM_FAKE_TREEHOUSE_PATH" "$lease_holder" > "$pool/treehouse-state.json" + if [ -n "${FM_FAKE_TREEHOUSE_STATE:-}" ] && [ -n "$lease_holder" ]; then + python3 - "$FM_FAKE_TREEHOUSE_STATE" "${FM_FAKE_TREEHOUSE_PATH:?}" "$lease_holder" <<'PY' +import json +import os +import sys + +state, path, holder = sys.argv[1:] +with open(state, "w", encoding="utf-8") as stream: + json.dump( + { + "worktrees": [ + { + "name": "1", + "path": os.path.realpath(path), + "leased": True, + "lease_holder": holder, + } + ] + }, + stream, + ) +PY + fi printf '%s\n' "${FM_FAKE_TREEHOUSE_PATH:?}" fi exit 0 @@ -310,6 +333,7 @@ case "$*" in [ -n "$pool" ] || pool=${FM_FAKE_AF_POOL:-claude-crew} case "$*" in *" lease recover "*) + [ -z "${FM_FAKE_AF_RECOVER_MARKER:-}" ] || touch "$FM_FAKE_AF_RECOVER_MARKER" [ "${FM_FAKE_AF_RECOVER_FAIL:-0}" != 1 ] || exit "${FM_FAKE_AF_RECOVER_FAIL_STATUS:-42}" if [ -n "${FM_FAKE_AF_RECOVER_FAIL_ONCE_FILE:-}" ] && [ ! -e "$FM_FAKE_AF_RECOVER_FAIL_ONCE_FILE" ]; then : > "$FM_FAKE_AF_RECOVER_FAIL_ONCE_FILE" @@ -320,6 +344,7 @@ case "$*" in [ "${FM_FAKE_AF_STALE_REFRESH_ON_RECOVER:-0}" != 1 ] || touch "${FM_FAKE_AF_SESSION_REFRESHED:?}" ;; *" lease choose "*|*" lease acquire "*) + [ -z "${FM_FAKE_AF_SELECT_MARKER:-}" ] || touch "$FM_FAKE_AF_SELECT_MARKER" [ -z "${FM_FAKE_AF_CHOOSE_FAIL_STATUS:-}" ] || exit "$FM_FAKE_AF_CHOOSE_FAIL_STATUS" [ -z "${FM_FAKE_AF_SELECT_SLEEP:-}" ] || sleep "$FM_FAKE_AF_SELECT_SLEEP" [ -z "${FM_FAKE_AF_SELECT_COMPLETED:-}" ] || touch "$FM_FAKE_AF_SELECT_COMPLETED" @@ -385,6 +410,7 @@ case "$*" in printf '{"schema":%s,"task":"%s","profile":"%s","provider":"%s","pool":"%s","workspace":"%s","session_id":"sess-%s","session_event_seq":%s,"updated_at":"%s"}\n' "$session_schema" "$task" "$profile" "$provider" "$pool" "$workspace" "$task" "$session_event_seq" "$updated_at" ;; *" lease release "*) + [ -z "${FM_FAKE_AF_RELEASE_START_MARKER:-}" ] || touch "$FM_FAKE_AF_RELEASE_START_MARKER" [ -z "${FM_FAKE_AF_RELEASE_SLEEP:-}" ] || sleep "$FM_FAKE_AF_RELEASE_SLEEP" [ -z "${FM_FAKE_AF_RELEASE_COMPLETED:-}" ] || touch "$FM_FAKE_AF_RELEASE_COMPLETED" [ -z "${FM_FAKE_AF_RELEASE_MARKER:-}" ] || touch "$FM_FAKE_AF_RELEASE_MARKER" @@ -475,7 +501,8 @@ run_spawn() { FM_FAKE_TREEHOUSE_LOG="$TREEHOUSE_LOG" FM_FAKE_LIFECYCLE_LOG="$LIFECYCLE_LOG" \ FM_EXPECT_CHECKOUT_LOCK_ROOT="${FM_EXPECT_CHECKOUT_LOCK_ROOT:-}" \ FM_EXPECT_CHECKOUT_LOCK_MARKER="${FM_EXPECT_CHECKOUT_LOCK_MARKER:-}" \ - FM_FAKE_TREEHOUSE_PATH="$WT_DIR" FM_TREEHOUSE_ROOT="$CASE_DIR/treehouse-pools" \ + FM_FAKE_TREEHOUSE_PATH="$WT_DIR" FM_FAKE_TREEHOUSE_STATE="$CASE_DIR/treehouse-root/pool/treehouse-state.json" \ + FM_TREEHOUSE_ROOT="$CASE_DIR/treehouse-root" \ FM_FAKE_TREEHOUSE_SLEEP="${FM_FAKE_TREEHOUSE_SLEEP:-}" \ FM_FAKE_TREEHOUSE_RETURN_SLEEP="${FM_FAKE_TREEHOUSE_RETURN_SLEEP:-}" \ FM_FAKE_TREEHOUSE_RETURN_CHILD_PID_FILE="${FM_FAKE_TREEHOUSE_RETURN_CHILD_PID_FILE:-}" \ @@ -501,6 +528,7 @@ run_teardown() { FM_ROOT_OVERRIDE="${FM_TEST_ROOT_OVERRIDE:-}" FM_HOME="$HOME_DIR" \ FM_STATE_OVERRIDE="$HOME_DIR/state" FM_DATA_OVERRIDE="$HOME_DIR/data" \ FM_PROJECTS_OVERRIDE="$HOME_DIR/projects" FM_CONFIG_OVERRIDE="$HOME_DIR/config" \ + FM_REPORT_STACK_ROOT="$CASE_DIR/report-stack" \ FM_FAKE_TMUX_LOG="$TMUX_LOG" FM_FAKE_AF_LOG="$AF_LOG" \ FM_FAKE_TREEHOUSE_LOG="$TREEHOUSE_LOG" FM_FAKE_ENDPOINT_FILE="$CASE_DIR/endpoint-live" \ FM_FAKE_TREEHOUSE_RETURN_SLEEP="${FM_FAKE_TREEHOUSE_RETURN_SLEEP:-}" \ @@ -552,7 +580,7 @@ use_named_fake_tmux_target() { } test_off_is_byte_compatible_and_never_calls_agent_fleet() { - local id rec out status launch expected + local id rec out status launch expected task_tmp id=account-off-z1 rec=$(make_case off claude "$id") read_case "$rec" @@ -565,6 +593,11 @@ test_off_is_byte_compatible_and_never_calls_agent_fleet() { [ "$launch" = "$expected" ] || fail "routing off changed the launch bytes" assert_not_grep '^account_' "$HOME_DIR/state/$id.meta" "routing off wrote account metadata" assert_not_grep '^provider_session_id=' "$HOME_DIR/state/$id.meta" "routing off wrote session metadata" + task_tmp=$(sed -n 's/^tasktmp=//p' "$HOME_DIR/state/$id.meta") + [ "$task_tmp" = "$HOME_DIR/state/.task-tmp/fm-$id" ] \ + || fail "routing fixture did not namespace its task temp root to this test run" + assert_present "$task_tmp/gotmp" \ + "routing fixture did not create its run-scoped Go temp root" assert_regex '^generation_id=spawn:a[0-9a-f]{15}$' "$HOME_DIR/state/$id.meta" "routing off did not record a stable spawn generation" assert_grep 'report_required=1' "$HOME_DIR/state/$id.meta" "post-cutover spawn did not activate the report gate" assert_grep '# Completion report' "$HOME_DIR/data/$id/brief.md" "post-cutover spawn did not upgrade a legacy unspawned brief" @@ -704,9 +737,13 @@ test_changed_acquisition_is_retained_during_unmanaged_rollback() { FM_FAKE_TMUX_FAIL_SEND_MATCH=GOTMPDIR \ run_spawn "$id" "$PROJ_DIR" > "$out_file" & spawn_pid=$! - for _ in $(seq 1 600); do [ -f "$marker" ] && break; sleep 0.05; done + for _ in $(seq 1 600); do + [ -f "$marker" ] && break + kill -0 "$spawn_pid" 2>/dev/null || break + sleep 0.05 + done [ -f "$marker" ] \ - || { kill "$spawn_pid" 2>/dev/null || true; fail "changed-acquisition test never reached the post-install failure"; } + || { touch "$release"; wait "$spawn_pid" 2>/dev/null || true; fail "changed-acquisition test never reached the post-install failure"; } printf '%s\n' retained-commit > "$WT_DIR/retained-commit.txt" git -C "$WT_DIR" add retained-commit.txt git -C "$WT_DIR" -c user.name='Firstmate Tests' -c user.email='tests@example.invalid' \ @@ -801,7 +838,7 @@ SH common=$(git -C "$WT_DIR" rev-parse --git-common-dir) case "$common" in /*) ;; *) common="$WT_DIR/$common" ;; esac common=$(cd "$common" && pwd -P) - key=$(printf '%s' "$common" | shasum -a 256 | awk '{print substr($1,1,24)}') + key=$(printf '%s' "$common" | tr '[:upper:]' '[:lower:]' | shasum -a 256 | awk '{print substr($1,1,24)}') lock="$CASE_DIR/checkout-refresh-locks/$key.lock" assert_present "$CASE_DIR/treehouse-return-child.pid" \ "spawn rollback did not exercise a surviving Treehouse descendant" @@ -1351,8 +1388,16 @@ test_off_metadata_merge_waits_for_metadata_lock() { FM_FAKE_TMUX_NEW_WINDOW_MARKER="$marker" FM_FAKE_TMUX_NEW_WINDOW_GATE="$gate" \ run_spawn "$id" "$PROJ_DIR" > "$out_file" & spawn_pid=$! - for _ in $(seq 1 100); do [ -f "$marker" ] && break; sleep 0.05; done - [ -f "$marker" ] || { kill "$spawn_pid" 2>/dev/null || true; fail "off metadata-lock test never reached endpoint creation"; } + for _ in $(seq 1 600); do + [ -f "$marker" ] && break + kill -0 "$spawn_pid" 2>/dev/null || break + sleep 0.05 + done + [ -f "$marker" ] || { + touch "$gate" + wait "$spawn_pid" 2>/dev/null || true + fail "off metadata-lock test never reached endpoint creation: $(cat "$out_file")" + } # shellcheck source=bin/fm-account-routing-lib.sh . "$ROOT/bin/fm-account-routing-lib.sh" held=$(fm_account_meta_lock_acquire "$HOME_DIR/state" "$id") \ @@ -2467,7 +2512,11 @@ SH kill -0 "$spawn_pid" 2>/dev/null || break sleep 0.05 done - [ -f "$marker" ] || { touch "$gate" "$installed_gate"; wait "$spawn_pid" 2>/dev/null || true; fail "spawn never persisted provisional managed metadata"; } + [ -f "$marker" ] || { + touch "$gate" "$installed_gate" + wait "$spawn_pid" 2>/dev/null || true + fail "spawn never persisted provisional managed metadata: $(cat "$CASE_DIR/spawn-stdout" "$CASE_DIR/spawn-stderr")" + } meta="$HOME_DIR/state/$id.meta" task=$(meta_account_task "$id") assert_grep 'account_rollback_cleanup=pending' "$meta" "provisional metadata was not marked for rollback recovery" @@ -2483,7 +2532,11 @@ SH kill -0 "$spawn_pid" 2>/dev/null || break sleep 0.05 done - [ -f "$installed_marker" ] || { touch "$installed_gate"; wait "$spawn_pid" 2>/dev/null || true; fail "spawn never installed endpoint metadata"; } + [ -f "$installed_marker" ] || { + touch "$installed_gate" + wait "$spawn_pid" 2>/dev/null || true + fail "spawn never installed endpoint metadata: $(cat "$CASE_DIR/spawn-stdout" "$CASE_DIR/spawn-stderr")" + } assert_grep 'account_rollback_cleanup=pending' "$meta" "endpoint metadata cleared rollback recovery before launch commit" assert_grep 'account_profile=claude-2' "$meta" "endpoint metadata lost the selected account profile" assert_grep "tmux_session_target=firstmate:fm-$id" "$meta" \ @@ -3175,8 +3228,9 @@ test_concurrent_continuations_serialize_before_mutation() { FM_FAKE_AF_PROFILE=claude-3 FM_FAKE_AF_POOL=explicit FM_FAKE_TMUX_NEW_WINDOW_MARKER="$marker" FM_FAKE_TMUX_NEW_WINDOW_GATE="$gate" \ run_spawn "$id" --continue-account --account-profile claude-3 > "$CASE_DIR/first.out" 2>&1 & first_pid=$! - for _ in $(seq 1 100); do + for _ in $(seq 1 600); do [ -f "$marker" ] && break + kill -0 "$first_pid" 2>/dev/null || break sleep 0.05 done [ -f "$marker" ] || { kill "$first_pid" 2>/dev/null || true; fail "first continuation never reached endpoint creation"; } @@ -3185,14 +3239,16 @@ test_concurrent_continuations_serialize_before_mutation() { FM_ACCOUNT_TEST_HOOKS=firstmate-account-tests-v1 FM_ACCOUNT_LOCK_WAIT_TEST_OBSERVED="$second_lock_waiter" \ run_spawn "$id" --continue-account --account-profile claude-3 > "$CASE_DIR/second.out" 2>&1 & second_pid=$! - for _ in $(seq 1 100); do + for _ in $(seq 1 600); do [ -f "$second_lock_waiter" ] && break + kill -0 "$second_pid" 2>/dev/null || break sleep 0.05 done if [ ! -f "$second_lock_waiter" ]; then touch "$gate" - kill "$first_pid" "$second_pid" 2>/dev/null || true - fail "second continuation never waited behind the first lifecycle owner" + wait "$first_pid" 2>/dev/null || true + wait "$second_pid" 2>/dev/null || true + fail "second continuation never waited behind the first lifecycle owner: first=$(cat "$CASE_DIR/first.out"); second=$(cat "$CASE_DIR/second.out")" fi touch "$gate" wait "$first_pid" @@ -3285,6 +3341,7 @@ test_session_sync_cannot_recreate_metadata_after_teardown() { teardown_pid=$! for _ in $(seq 1 300); do [ -f "$release_marker" ] && break + kill -0 "$teardown_pid" 2>/dev/null || break sleep 0.1 done [ -f "$release_marker" ] || { @@ -5099,6 +5156,34 @@ SH pass "account metadata stat helpers select GNU stat without probing BSD filesystem stat" } +test_darwin_stat_mode_preserves_special_permission_bits() { + local case_dir fakebin file output + case_dir="$TMP_ROOT/darwin-stat-mode" + fakebin="$case_dir/fakebin" + file="$case_dir/file" + mkdir -p "$fakebin" + : > "$file" + cat > "$fakebin/uname" <<'SH' +#!/usr/bin/env bash +printf 'Darwin\n' +SH + cat > "$fakebin/stat" <<'SH' +#!/usr/bin/env bash +[ "${1:-}" = -f ] && [ "${2:-}" = %Mp%Lp ] || exit 2 +printf '1777\n' +SH + chmod +x "$fakebin/uname" "$fakebin/stat" + output=$(bash -c ' + . "$1" + FM_ACCOUNT_SYSTEM_UNAME_BIN=$2/uname + FM_ACCOUNT_SYSTEM_STAT_BIN=$2/stat + fm_account_path_mode "$3" + ' _ "$ROOT/bin/fm-account-routing-lib.sh" "$fakebin" "$file") \ + || fail "Darwin account mode helper failed" + [ "$output" = 1777 ] || fail "Darwin account mode helper lost special permission bits: $output" + pass "account metadata stat helpers preserve Darwin special permission bits" +} + test_stale_reclaim_guard_is_owned_before_lock_removal() { local case_dir lock output status case_dir="$TMP_ROOT/reclaim-guard-ownership" @@ -5568,6 +5653,7 @@ test_agent_fleet_lifecycle_calls_are_bounded() { assert_grep 'lease recover ' "$AF_LOG" "timed-out lease choice did not reconcile ownership" rm -f "$CASE_DIR/endpoint-live" + write_completion_report "$id" clear_case_logs release_completed="$CASE_DIR/release-completed" if out=$(FM_FAKE_AF_RELEASE_SLEEP=10 FM_FAKE_AF_RELEASE_COMPLETED="$release_completed" \ @@ -5856,6 +5942,7 @@ test_teardown_stops_after_rollback_restores_predecessor() { [ "$failed_task" != "$old_task" ] || fail "failed continuation did not install its rollback generation" rm -f "$CASE_DIR/endpoint-live" + write_completion_report "$id" clear_case_logs if out=$(run_teardown "$id" --force 2>&1); then status=0; else status=$?; fi [ "$status" -ne 0 ] || fail "teardown continued after restoring predecessor metadata" @@ -6380,6 +6467,7 @@ if [ "${FM_TEST_FOCUSED:-}" = production-routing-authority ]; then run_isolated_test test_agent_fleet_entrypoint_is_physically_pinned_per_operation run_isolated_test test_agent_fleet_validation_ignores_ambient_system_tool_shadows run_isolated_test test_linux_stat_selection_avoids_filesystem_stat_output + run_isolated_test test_darwin_stat_mode_preserves_special_permission_bits run_isolated_test test_production_routing_ignores_ambient_mode_and_forbids_binary_override exit 0 fi @@ -6495,6 +6583,7 @@ run_isolated_test test_account_locks_never_reclaim_on_indeterminate_process_prob run_isolated_test test_ownerless_lock_marker_rejects_symlink_clobber run_isolated_test test_account_lock_owner_controls_reject_symlinks run_isolated_test test_linux_stat_selection_avoids_filesystem_stat_output +run_isolated_test test_darwin_stat_mode_preserves_special_permission_bits run_isolated_test test_stale_reclaim_guard_is_owned_before_lock_removal run_isolated_test test_task_owned_account_artifacts_reject_symlink_paths run_isolated_test test_secondmate_home_lock_key_fails_closed_without_fixed_hasher diff --git a/tests/fm-arm-pretool-check.test.sh b/tests/fm-arm-pretool-check.test.sh index 52345bd43e..3c86757f00 100755 --- a/tests/fm-arm-pretool-check.test.sh +++ b/tests/fm-arm-pretool-check.test.sh @@ -371,7 +371,7 @@ test_failopen_garbage_stdin() { test_failopen_missing_jq() { local dir fakebin rc real - dir=$(fm_test_tmproot fm-arm-pretool-check) + fm_test_tmproot_into dir fm-arm-pretool-check fakebin="$dir/fakebin" mkdir -p "$fakebin" local tool @@ -387,7 +387,7 @@ test_failopen_missing_jq() { test_failopen_missing_node() { local dir fakebin rc real tool - dir=$(fm_test_tmproot fm-arm-pretool-node) + fm_test_tmproot_into dir fm-arm-pretool-node fakebin="$dir/fakebin" mkdir -p "$fakebin" for tool in bash dirname; do @@ -403,11 +403,11 @@ test_failopen_missing_node() { # --- --claude output shaping --------------------------------------------------- test_claude_mode_stdout_empty_on_deny() { - local out err rc - out=$("$CHECK" --claude --command 'bin/fm-watch-arm.sh &' 2>/tmp/fm-arm-pretool-check-claude-stderr.$$) + local out err rc err_file="$MATRIX_TMP/claude-stderr" + out=$("$CHECK" --claude --command 'bin/fm-watch-arm.sh &' 2>"$err_file") rc=$? - err=$(cat "/tmp/fm-arm-pretool-check-claude-stderr.$$" 2>/dev/null) - rm -f "/tmp/fm-arm-pretool-check-claude-stderr.$$" + err=$(cat "$err_file" 2>/dev/null) + rm -f "$err_file" [ "$rc" -eq 2 ] || fail "--claude deny must still exit 2, got $rc" [ -z "$out" ] || fail "--claude deny must leave stdout EMPTY (Claude Code only honors a stderr-only deny), got: $out" printf '%s' "$err" | jq -e '.hookSpecificOutput.permissionDecision == "deny"' >/dev/null 2>&1 \ diff --git a/tests/fm-autocompact.test.sh b/tests/fm-autocompact.test.sh index 2c27621ae5..ae1a719d00 100755 --- a/tests/fm-autocompact.test.sh +++ b/tests/fm-autocompact.test.sh @@ -8,7 +8,7 @@ set -u # shellcheck disable=SC2153 AUTOCOMPACT="$ROOT/bin/fm-autocompact.sh" -TMP_ROOT=$(fm_test_tmproot fm-autocompact-tests) +fm_test_tmproot_into TMP_ROOT fm-autocompact-tests fm_git_identity fmtest fmtest@example.invalid new_primary() { diff --git a/tests/fm-backend-cmux.test.sh b/tests/fm-backend-cmux.test.sh index 22918c89d8..b31b3481c0 100755 --- a/tests/fm-backend-cmux.test.sh +++ b/tests/fm-backend-cmux.test.sh @@ -15,7 +15,7 @@ set -u command -v jq >/dev/null 2>&1 || { echo "skip: jq not found (required by the cmux adapter)"; exit 0; } -TMP_ROOT=$(fm_test_tmproot fm-backend-cmux-tests) +fm_test_tmproot_into TMP_ROOT fm-backend-cmux-tests # make_cmux_fakebin: a `cmux` stub that logs every invocation (one line, # unit-separated args, to $FM_CMUX_LOG) and returns the canned response for diff --git a/tests/fm-backend-herdr.test.sh b/tests/fm-backend-herdr.test.sh index 084e8b3dc7..9b3bc1d44a 100755 --- a/tests/fm-backend-herdr.test.sh +++ b/tests/fm-backend-herdr.test.sh @@ -18,7 +18,7 @@ set -u command -v jq >/dev/null 2>&1 || { echo "skip: jq not found (required by the herdr adapter)"; exit 0; } -TMP_ROOT=$(fm_test_tmproot fm-backend-herdr-tests) +fm_test_tmproot_into TMP_ROOT fm-backend-herdr-tests export FM_BACKEND_HERDR_SUBMIT_MIN_SLEEP=0 export FM_BACKEND_HERDR_TEST_LAB=firstmate-herdr-test-lab-v1 diff --git a/tests/fm-backend-zellij.test.sh b/tests/fm-backend-zellij.test.sh index 60bd56e9c0..1cafdd277e 100755 --- a/tests/fm-backend-zellij.test.sh +++ b/tests/fm-backend-zellij.test.sh @@ -14,7 +14,7 @@ set -u command -v jq >/dev/null 2>&1 || { echo "skip: jq not found (required by the zellij adapter)"; exit 0; } -TMP_ROOT=$(fm_test_tmproot fm-backend-zellij-tests) +fm_test_tmproot_into TMP_ROOT fm-backend-zellij-tests # make_zellij_fakebin: a `zellij` stub that logs every invocation (one line, # unit-separated args, to $FM_ZELLIJ_LOG) and returns the canned response for diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index 7efdb305c0..b232b1f588 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -35,7 +35,7 @@ fm_git_identity fmtest fmtest@example.invalid # shellcheck source=bin/fm-backend.sh . "$ROOT/bin/fm-backend.sh" -TMP_ROOT=$(fm_test_tmproot fm-backend-tests) +fm_test_tmproot_into TMP_ROOT fm-backend-tests # fm_backend_detect's cmux fallback (bundle id + process ancestry, # docs/cmux-backend.md "Runtime auto-detection") consults uname, lsappinfo, @@ -1108,7 +1108,7 @@ run_spawn_symlink_case() { #