From 65e22d928ebdbba0f932ad2e07c5763ba7947d51 Mon Sep 17 00:00:00 2001 From: AG Werschky Date: Wed, 29 Jul 2026 11:06:06 -0600 Subject: [PATCH 1/4] fix: preserve secondmate reply routes across relative homes Resolve relative home, data, and state inputs before durable charter generation, and fail when caller-relative directories cannot be resolved. Use absolute paths at the related spawn, AFK daemon, and X-mode cross-process handoffs so later processes cannot reinterpret them from another working directory. --- bin/fm-afk-launch.sh | 10 +++ bin/fm-bootstrap.sh | 13 +++- bin/fm-brief.sh | 27 +++++++- bin/fm-spawn.sh | 4 +- tests/fm-afk-launch.test.sh | 28 ++++++++ tests/fm-brief.test.sh | 90 +++++++++++++++++++++++++ tests/fm-spawn-dispatch-profile.test.sh | 34 +++++++++- tests/fm-x-mode.test.sh | 18 +++++ 8 files changed, 214 insertions(+), 10 deletions(-) diff --git a/bin/fm-afk-launch.sh b/bin/fm-afk-launch.sh index 57b7f6590d..4c0285b246 100755 --- a/bin/fm-afk-launch.sh +++ b/bin/fm-afk-launch.sh @@ -48,6 +48,16 @@ set -u FM_AFK_LAUNCH_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$FM_AFK_LAUNCH_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +case "$FM_HOME" in + /*) ;; + *) + FM_AFK_LAUNCH_HOME_INPUT=$FM_HOME + FM_HOME=$(cd "$FM_AFK_LAUNCH_HOME_INPUT" 2>/dev/null && pwd -P) || { + echo "error: FM_HOME directory cannot be resolved: $FM_AFK_LAUNCH_HOME_INPUT" >&2 + exit 1 + } + ;; +esac FM_AFK_LAUNCH_STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" FM_AFK_LAUNCH_RECORD="$FM_AFK_LAUNCH_STATE/.afk-daemon-terminal" FM_AFK_LAUNCH_LOCK="$FM_AFK_LAUNCH_STATE/.afk-launch.lock" diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index c86b7e839a..ce12529b3f 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -620,7 +620,7 @@ x_mode_remove_artifact() { # applying a cadence transition to a running watcher is the caller's job via # the emitted harness-aware supervision repair instruction. x_mode_setup() { - local env_file token shim cadence shim_body cadence_body tool missing + local env_file token shim cadence shim_body cadence_body tool missing shim_home env_file="$FM_HOME/.env" shim="$STATE/x-watch.check.sh" cadence="$CONFIG/x-mode.env" @@ -683,9 +683,16 @@ x_mode_setup() { mkdir -p "$STATE" "$CONFIG" 2>/dev/null || { fmx_arm_failed; return 0; } - shim_body=$(fmx_poll_shim_content "$FM_HOME" "$FM_ROOT") + case "$FM_HOME" in + /*) shim_home=$FM_HOME ;; + *) + shim_home=$(cd "$FM_HOME" 2>/dev/null && pwd -P) \ + || { fmx_arm_failed; return 0; } + ;; + esac + shim_body=$(fmx_poll_shim_content "$shim_home" "$FM_ROOT") x_mode_write_if_changed "$shim" "$shim_body" 700 || { fmx_arm_failed; return 0; } - fmx_poll_shim_valid "$shim" "$FM_HOME" "$FM_ROOT" \ + fmx_poll_shim_valid "$shim" "$shim_home" "$FM_ROOT" \ || { fmx_arm_failed; return 0; } cadence_body=$(cat <<'EOF' diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index 8125aa2e9e..d296c3badc 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -66,10 +66,31 @@ esac # shellcheck source=bin/fm-classify-lib.sh . "$SCRIPT_DIR/fm-classify-lib.sh" PAUSED_VERB=${FM_CLASSIFY_PAUSED_VERB:-$FM_CLASSIFY_PAUSED_VERB_DEFAULT} + +resolve_directory_input() { + local name=$1 path=$2 resolved + case "$path" in + /*) printf '%s\n' "$path"; return 0 ;; + esac + resolved=$(cd "$path" 2>/dev/null && pwd -P) || { + echo "error: $name directory cannot be resolved: $path" >&2 + return 1 + } + printf '%s\n' "$resolved" +} + FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" -DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" -STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +FM_HOME=$(resolve_directory_input FM_HOME "${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}") || exit 1 +if [ -n "${FM_DATA_OVERRIDE:-}" ]; then + DATA=$(resolve_directory_input FM_DATA_OVERRIDE "$FM_DATA_OVERRIDE") || exit 1 +else + DATA="$FM_HOME/data" +fi +if [ -n "${FM_STATE_OVERRIDE:-}" ]; then + STATE=$(resolve_directory_input FM_STATE_OVERRIDE "$FM_STATE_OVERRIDE") || exit 1 +else + STATE="$FM_HOME/state" +fi KIND=ship HERDR_LAB=0 NO_PROJECTS=0 diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 76eed3e736..25ef6bafeb 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -1475,9 +1475,9 @@ META_WINDOW=$T } > "$STATE/$ID.meta" [ "$BACKEND" = orca ] && ORCA_ABORT_CLEANUP=0 -sq_brief=$(shell_quote "$BRIEF") +sq_brief=$(shell_quote "$BRIEF_REAL") sq_turnend=$(shell_quote "$TURNEND") -sq_piext=$(shell_quote "$STATE/$ID.pi-ext.ts") +sq_piext=$(shell_quote "$STATE_REAL/$ID.pi-ext.ts") sq_piturnend=$(shell_quote "$PROJ_ABS/.pi/extensions/fm-primary-turnend-guard.ts") sq_piwatch=$(shell_quote "$PROJ_ABS/.pi/extensions/fm-primary-pi-watch.ts") sq_opinput=$(shell_quote "$FM_ROOT/bin/fm-operational-input.sh") diff --git a/tests/fm-afk-launch.test.sh b/tests/fm-afk-launch.test.sh index 8075d7067a..55ca205010 100755 --- a/tests/fm-afk-launch.test.sh +++ b/tests/fm-afk-launch.test.sh @@ -70,6 +70,33 @@ unit_clear_stale() { rm -rf "$st" } +unit_relative_home_is_absolute_before_daemon_launch() { + local root home out status + root=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-relative-home.XXXXXX") + mkdir -p "$root/home" + home=$(cd "$root/home" && pwd -P) + out=$( + cd "$root" || exit 1 + FM_HOME=home bash -c '. "$1"; printf "%s\n" "$FM_HOME"' _ "$LAUNCH" + ) + if [ "$out" = "$home" ]; then + pass "launcher paths: relative FM_HOME is absolute before daemon command construction" + else + fail "launcher paths: relative FM_HOME remained cwd-dependent ($out)" + fi + out=$( + cd "$root" || exit 1 + FM_HOME=missing-home "$LAUNCH" help 2>&1 + ) + status=$? + if [ "$status" -ne 0 ] && printf '%s\n' "$out" | grep -F "FM_HOME directory cannot be resolved: missing-home" >/dev/null; then + pass "launcher paths: unresolved relative FM_HOME fails loudly" + else + fail "launcher paths: unresolved relative FM_HOME did not name the bad input ($out)" + fi + rm -rf "$root" +} + # --------------------------------------------------------------------------- # UNIT 2: a FRESH entry clears; a REFRESH (daemon already alive) preserves the # current session's buffered escalations. @@ -861,6 +888,7 @@ e2e_tmux() { } unit_clear_stale +unit_relative_home_is_absolute_before_daemon_launch unit_fresh_vs_refresh unit_stop_ordering unit_stop_rejects_reused_pid diff --git a/tests/fm-brief.test.sh b/tests/fm-brief.test.sh index bed170b741..8ea4fc396f 100755 --- a/tests/fm-brief.test.sh +++ b/tests/fm-brief.test.sh @@ -436,6 +436,95 @@ test_secondmate_marked_request_reporting_contract() { pass "fm-brief.sh: marked requests avoid generic acknowledgements and preserve material reporting" } +test_secondmate_directory_paths_are_absolute_and_output_is_stable() { + local root home data_override state_override brief baseline err status + root="$TMP_ROOT/relative-directory-inputs" + mkdir -p "$root" + root=$(cd "$root" && pwd -P) + home="$root/home" + data_override="$root/data-override" + state_override="$root/state-override" + mkdir -p "$home/data" "$home/state" "$data_override" "$state_override" + + brief="$home/data/relative-home/brief.md" + FM_HOME="$home" FM_SECONDMATE_CHARTER=x \ + "$ROOT/bin/fm-brief.sh" relative-home --secondmate --no-projects >/dev/null 2>&1 + baseline="$root/absolute-home-charter" + cp "$brief" "$baseline" + rm -f "$brief" + ( + cd "$root" || exit 1 + FM_HOME=home FM_SECONDMATE_CHARTER=x \ + "$ROOT/bin/fm-brief.sh" relative-home --secondmate --no-projects >/dev/null 2>&1 + ) + cmp -s "$baseline" "$brief" \ + || fail "relative FM_HOME changed charter bytes compared with the same absolute home" + assert_grep ">> '$home/state/relative-home.status'" "$brief" \ + "relative FM_HOME did not render an absolute secondmate status path" + + brief="$home/data/relative-state/brief.md" + FM_HOME="$home" FM_STATE_OVERRIDE="$state_override" FM_SECONDMATE_CHARTER=x \ + "$ROOT/bin/fm-brief.sh" relative-state --secondmate --no-projects >/dev/null 2>&1 + baseline="$root/absolute-state-charter" + cp "$brief" "$baseline" + rm -f "$brief" + ( + cd "$root" || exit 1 + FM_HOME="$home" FM_STATE_OVERRIDE=state-override FM_SECONDMATE_CHARTER=x \ + "$ROOT/bin/fm-brief.sh" relative-state --secondmate --no-projects >/dev/null 2>&1 + ) + cmp -s "$baseline" "$brief" \ + || fail "relative FM_STATE_OVERRIDE changed charter bytes compared with the same absolute state directory" + assert_grep ">> '$state_override/relative-state.status'" "$brief" \ + "relative FM_STATE_OVERRIDE did not render an absolute secondmate status path" + + brief="$data_override/relative-data/brief.md" + FM_HOME="$home" FM_DATA_OVERRIDE="$data_override" FM_SECONDMATE_CHARTER=x \ + "$ROOT/bin/fm-brief.sh" relative-data --secondmate --no-projects >/dev/null 2>&1 + baseline="$root/absolute-data-charter" + cp "$brief" "$baseline" + rm -f "$brief" + ( + cd "$root" || exit 1 + FM_HOME="$home" FM_DATA_OVERRIDE=data-override FM_SECONDMATE_CHARTER=x \ + "$ROOT/bin/fm-brief.sh" relative-data --secondmate --no-projects >/dev/null 2>&1 + ) + cmp -s "$baseline" "$brief" \ + || fail "relative FM_DATA_OVERRIDE changed charter bytes compared with the same absolute data directory" + assert_grep ">> '$home/state/relative-data.status'" "$brief" \ + "relative FM_DATA_OVERRIDE changed the absolute default status path" + + err="$root/unresolved.err" + ( + cd "$root" || exit 1 + FM_HOME=missing-home FM_SECONDMATE_CHARTER=x \ + "$ROOT/bin/fm-brief.sh" unresolved-home --secondmate --no-projects >/dev/null 2>"$err" + ); status=$? + expect_code 1 "$status" "an unresolved relative FM_HOME must fail" + assert_grep "FM_HOME directory cannot be resolved: missing-home" "$err" \ + "unresolved relative FM_HOME did not fail loudly" + + ( + cd "$root" || exit 1 + FM_HOME="$home" FM_STATE_OVERRIDE=missing-state FM_SECONDMATE_CHARTER=x \ + "$ROOT/bin/fm-brief.sh" unresolved-state --secondmate --no-projects >/dev/null 2>"$err" + ); status=$? + expect_code 1 "$status" "an unresolved relative FM_STATE_OVERRIDE must fail" + assert_grep "FM_STATE_OVERRIDE directory cannot be resolved: missing-state" "$err" \ + "unresolved relative FM_STATE_OVERRIDE did not fail loudly" + + ( + cd "$root" || exit 1 + FM_HOME="$home" FM_DATA_OVERRIDE=missing-data FM_SECONDMATE_CHARTER=x \ + "$ROOT/bin/fm-brief.sh" unresolved-data --secondmate --no-projects >/dev/null 2>"$err" + ); status=$? + expect_code 1 "$status" "an unresolved relative FM_DATA_OVERRIDE must fail" + assert_grep "FM_DATA_OVERRIDE directory cannot be resolved: missing-data" "$err" \ + "unresolved relative FM_DATA_OVERRIDE did not fail loudly" + + pass "fm-brief.sh: relative directory inputs render stable absolute charter paths or fail loudly" +} + test_herdr_lab_contract_applies_to_scouts_but_not_secondmates() { local home brief status=0 home="$TMP_ROOT/herdr-kind-home" @@ -540,6 +629,7 @@ test_herdr_lab_omission_is_loud_for_ship_and_scout test_herdr_lab_contract_applies_to_scouts_but_not_secondmates test_secondmate_no_projects_charter test_secondmate_marked_request_reporting_contract +test_secondmate_directory_paths_are_absolute_and_output_is_stable test_pause_verb_override_renders_all_brief_scaffolds test_scout_and_secondmate_load_decision_hold_policy test_scout_and_secondmate_scaffold diff --git a/tests/fm-spawn-dispatch-profile.test.sh b/tests/fm-spawn-dispatch-profile.test.sh index 4f0695e7e5..107eda43a0 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -111,10 +111,11 @@ assert_meta_profile() { } test_no_profile_keeps_claude_profile_defaults() { - local rec id out status expected launch + local rec id out status expected launch home_real id=profile-off-z1 rec=$(make_spawn_case profile-off claude "$id") read_case_record "$rec" + home_real=$(cd "$HOME_DIR" && pwd -P) out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR") status=$? @@ -123,11 +124,39 @@ test_no_profile_keeps_claude_profile_defaults() { assert_meta_profile "$HOME_DIR/state/$id.meta" claude default default launch=$(cat "$LAUNCH_LOG") - expected="CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions \"\$('${ROOT}/bin/fm-operational-input.sh' encode launch-brief < '$HOME_DIR/data/$id/brief.md')\"" + expected="CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions \"\$('${ROOT}/bin/fm-operational-input.sh' encode launch-brief < '$home_real/data/$id/brief.md')\"" [ "$launch" = "$expected" ] || fail "no-profile claude launch did not use the canonical launch kind"$'\n'"expected: $expected"$'\n'"actual: $launch" pass "no --model/--effort records defaults and types the claude launch instructions" } +test_relative_home_overrides_launch_with_absolute_cross_process_paths() { + local rec id out status launch home_real + id=profile-relative-paths-z1b + rec=$(make_spawn_case profile-relative-paths pi "$id") + read_case_record "$rec" + home_real=$(cd "$HOME_DIR" && pwd -P) + : > "$LAUNCH_LOG" + + out=$( + cd "$CASE_DIR" || exit 1 + 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="$WT_DIR" TMUX="fake,1,0" \ + CLAUDE_CONFIG_DIR='' FM_FAKE_LAUNCH_LOG="$LAUNCH_LOG" \ + GROK_HOME=home/grok-home PATH="$FAKEBIN_DIR:$PATH" \ + "$SPAWN" "$id" "$PROJ_DIR" 2>&1 + ) + status=$? + expect_code 0 "$status" "spawn with relative home overrides should succeed" + launch=$(cat "$LAUNCH_LOG") + assert_contains "$launch" "-e '$home_real/state/$id.pi-ext.ts'" \ + "relative FM_STATE_OVERRIDE leaked into Pi's cross-process extension path" + assert_contains "$launch" "< '$home_real/data/$id/brief.md'" \ + "relative FM_DATA_OVERRIDE leaked into the cross-process brief path" + pass "relative home overrides become absolute before spawn launch construction" +} + test_active_dispatch_profile_requires_explicit_harness_for_ship() { local rec id out status id=profile-required-ship-z11 @@ -509,6 +538,7 @@ test_active_dispatch_profile_does_not_block_secondmate_launch() { } test_no_profile_keeps_claude_profile_defaults +test_relative_home_overrides_launch_with_absolute_cross_process_paths test_active_dispatch_profile_requires_explicit_harness_for_ship test_active_dispatch_profile_requires_explicit_harness_for_scout test_active_dispatch_profile_allows_explicit_harness diff --git a/tests/fm-x-mode.test.sh b/tests/fm-x-mode.test.sh index 505860689d..1ebdf944cb 100755 --- a/tests/fm-x-mode.test.sh +++ b/tests/fm-x-mode.test.sh @@ -700,6 +700,23 @@ test_bootstrap_activates_on_env_token() { pass "bootstrap activates X mode from an .env token, idempotently" } +test_bootstrap_relative_home_writes_absolute_poll_shim() { + local root home out quoted_home + root="$TMP_ROOT/boot-relative-home" + mkdir -p "$root/home" + home=$(cd "$root/home" && pwd -P) + printf 'FMX_PAIRING_TOKEN=tok-relative\n' > "$home/.env" + out=$( + cd "$root" || exit 1 + FM_HOME=home "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null + ) + assert_contains "$out" "FMX: X mode on" "relative-home bootstrap must announce X mode" + quoted_home=$(printf '%q' "$home") + assert_grep "export FM_HOME=$quoted_home" "$home/state/x-watch.check.sh" \ + "relative FM_HOME leaked into the durable X-mode poll shim" + pass "bootstrap writes an absolute FM_HOME into the durable X-mode poll shim" +} + test_bootstrap_reports_missing_x_dependency() { local home fakebin out tool tool_path home="$TMP_ROOT/boot-missing-x"; mkdir -p "$home" @@ -2862,6 +2879,7 @@ test_followup_post_dry_run_increments_counter_keeps_link test_followup_post_dry_run_final_clears_link test_followup_usage_errors test_bootstrap_activates_on_env_token +test_bootstrap_relative_home_writes_absolute_poll_shim test_bootstrap_reports_missing_x_dependency test_bootstrap_does_not_announce_when_arm_fails test_bootstrap_does_not_follow_x_artifact_symlinks From bda65341ca12270e8177b53ec08920e48817e9ad Mon Sep 17 00:00:00 2001 From: AG Werschky Date: Wed, 29 Jul 2026 11:15:50 -0600 Subject: [PATCH 2/4] no-mistakes(review): Preserve absolute overrides and normalize relative durable paths --- bin/fm-afk-launch.sh | 12 +++++ bin/fm-spawn.sh | 23 ++++++++- tests/fm-afk-launch.test.sh | 37 +++++++++++--- tests/fm-spawn-dispatch-profile.test.sh | 65 +++++++++++++++++++++++-- 4 files changed, 124 insertions(+), 13 deletions(-) diff --git a/bin/fm-afk-launch.sh b/bin/fm-afk-launch.sh index 4c0285b246..c84137ee19 100755 --- a/bin/fm-afk-launch.sh +++ b/bin/fm-afk-launch.sh @@ -58,6 +58,18 @@ case "$FM_HOME" in } ;; esac +if [ -n "${FM_STATE_OVERRIDE:-}" ]; then + case "$FM_STATE_OVERRIDE" in + /*) ;; + *) + FM_AFK_LAUNCH_STATE_INPUT=$FM_STATE_OVERRIDE + FM_STATE_OVERRIDE=$(cd "$FM_AFK_LAUNCH_STATE_INPUT" 2>/dev/null && pwd -P) || { + echo "error: FM_STATE_OVERRIDE directory cannot be resolved: $FM_AFK_LAUNCH_STATE_INPUT" >&2 + exit 1 + } + ;; + esac +fi FM_AFK_LAUNCH_STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" FM_AFK_LAUNCH_RECORD="$FM_AFK_LAUNCH_STATE/.afk-daemon-terminal" FM_AFK_LAUNCH_LOCK="$FM_AFK_LAUNCH_STATE/.afk-launch.lock" diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 25ef6bafeb..e5820d3a7d 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -124,6 +124,25 @@ esac FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" + +resolve_directory_input() { + local name=$1 path=$2 resolved + case "$path" in + /*) printf '%s\n' "$path"; return 0 ;; + esac + resolved=$(cd "$path" 2>/dev/null && pwd -P) || { + echo "error: $name directory cannot be resolved: $path" >&2 + return 1 + } + printf '%s\n' "$resolved" +} + +if [ -n "${FM_STATE_OVERRIDE:-}" ]; then + FM_STATE_OVERRIDE=$(resolve_directory_input FM_STATE_OVERRIDE "$FM_STATE_OVERRIDE") || exit 1 +fi +if [ -n "${FM_DATA_OVERRIDE:-}" ]; then + FM_DATA_OVERRIDE=$(resolve_directory_input FM_DATA_OVERRIDE "$FM_DATA_OVERRIDE") || exit 1 +fi STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}" @@ -1475,9 +1494,9 @@ META_WINDOW=$T } > "$STATE/$ID.meta" [ "$BACKEND" = orca ] && ORCA_ABORT_CLEANUP=0 -sq_brief=$(shell_quote "$BRIEF_REAL") +sq_brief=$(shell_quote "$BRIEF") sq_turnend=$(shell_quote "$TURNEND") -sq_piext=$(shell_quote "$STATE_REAL/$ID.pi-ext.ts") +sq_piext=$(shell_quote "$STATE/$ID.pi-ext.ts") sq_piturnend=$(shell_quote "$PROJ_ABS/.pi/extensions/fm-primary-turnend-guard.ts") sq_piwatch=$(shell_quote "$PROJ_ABS/.pi/extensions/fm-primary-pi-watch.ts") sq_opinput=$(shell_quote "$FM_ROOT/bin/fm-operational-input.sh") diff --git a/tests/fm-afk-launch.test.sh b/tests/fm-afk-launch.test.sh index 55ca205010..2fd3cbe0fa 100755 --- a/tests/fm-afk-launch.test.sh +++ b/tests/fm-afk-launch.test.sh @@ -70,19 +70,30 @@ unit_clear_stale() { rm -rf "$st" } -unit_relative_home_is_absolute_before_daemon_launch() { - local root home out status +unit_relative_paths_are_absolute_before_daemon_launch() { + local root home state out status linked_home root=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-relative-home.XXXXXX") - mkdir -p "$root/home" + mkdir -p "$root/home/state" home=$(cd "$root/home" && pwd -P) + state="$home/state" out=$( cd "$root" || exit 1 - FM_HOME=home bash -c '. "$1"; printf "%s\n" "$FM_HOME"' _ "$LAUNCH" + FM_HOME=home FM_STATE_OVERRIDE=home/state \ + bash -c '. "$1"; printf "%s\n%s\n" "$FM_HOME" "$FM_AFK_LAUNCH_STATE"' _ "$LAUNCH" ) - if [ "$out" = "$home" ]; then - pass "launcher paths: relative FM_HOME is absolute before daemon command construction" + if [ "$out" = "$home"$'\n'"$state" ]; then + pass "launcher paths: relative home and state are absolute before daemon command construction" else - fail "launcher paths: relative FM_HOME remained cwd-dependent ($out)" + fail "launcher paths: relative home or state remained cwd-dependent ($out)" + fi + linked_home="$root/home-link" + ln -s "$root/home" "$linked_home" + out=$(FM_HOME="$linked_home" FM_STATE_OVERRIDE="$linked_home/state" \ + bash -c '. "$1"; printf "%s\n%s\n" "$FM_HOME" "$FM_AFK_LAUNCH_STATE"' _ "$LAUNCH") + if [ "$out" = "$linked_home"$'\n'"$linked_home/state" ]; then + pass "launcher paths: absolute symlink spellings are preserved" + else + fail "launcher paths: absolute symlink spelling changed ($out)" fi out=$( cd "$root" || exit 1 @@ -94,6 +105,16 @@ unit_relative_home_is_absolute_before_daemon_launch() { else fail "launcher paths: unresolved relative FM_HOME did not name the bad input ($out)" fi + out=$( + cd "$root" || exit 1 + FM_HOME=home FM_STATE_OVERRIDE=missing-state "$LAUNCH" help 2>&1 + ) + status=$? + if [ "$status" -ne 0 ] && printf '%s\n' "$out" | grep -F "FM_STATE_OVERRIDE directory cannot be resolved: missing-state" >/dev/null; then + pass "launcher paths: unresolved relative FM_STATE_OVERRIDE fails loudly" + else + fail "launcher paths: unresolved relative FM_STATE_OVERRIDE did not name the bad input ($out)" + fi rm -rf "$root" } @@ -888,7 +909,7 @@ e2e_tmux() { } unit_clear_stale -unit_relative_home_is_absolute_before_daemon_launch +unit_relative_paths_are_absolute_before_daemon_launch unit_fresh_vs_refresh unit_stop_ordering unit_stop_rejects_reused_pid diff --git a/tests/fm-spawn-dispatch-profile.test.sh b/tests/fm-spawn-dispatch-profile.test.sh index 107eda43a0..48f17a982d 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -111,11 +111,10 @@ assert_meta_profile() { } test_no_profile_keeps_claude_profile_defaults() { - local rec id out status expected launch home_real + local rec id out status expected launch id=profile-off-z1 rec=$(make_spawn_case profile-off claude "$id") read_case_record "$rec" - home_real=$(cd "$HOME_DIR" && pwd -P) out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR") status=$? @@ -124,7 +123,7 @@ test_no_profile_keeps_claude_profile_defaults() { assert_meta_profile "$HOME_DIR/state/$id.meta" claude default default launch=$(cat "$LAUNCH_LOG") - expected="CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions \"\$('${ROOT}/bin/fm-operational-input.sh' encode launch-brief < '$home_real/data/$id/brief.md')\"" + expected="CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions \"\$('${ROOT}/bin/fm-operational-input.sh' encode launch-brief < '$HOME_DIR/data/$id/brief.md')\"" [ "$launch" = "$expected" ] || fail "no-profile claude launch did not use the canonical launch kind"$'\n'"expected: $expected"$'\n'"actual: $launch" pass "no --model/--effort records defaults and types the claude launch instructions" } @@ -157,6 +156,64 @@ test_relative_home_overrides_launch_with_absolute_cross_process_paths() { pass "relative home overrides become absolute before spawn launch construction" } +test_absolute_override_spelling_is_preserved_in_launch_paths() { + local rec id out status launch linked_home + id=profile-absolute-paths-z1c + rec=$(make_spawn_case profile-absolute-paths pi "$id") + read_case_record "$rec" + linked_home="$CASE_DIR/home-link" + ln -s "$HOME_DIR" "$linked_home" + : > "$LAUNCH_LOG" + + out=$( + FM_ROOT_OVERRIDE='' FM_HOME="$linked_home" \ + FM_STATE_OVERRIDE="$linked_home/state" FM_DATA_OVERRIDE="$linked_home/data" \ + FM_PROJECTS_OVERRIDE="$linked_home/projects" FM_CONFIG_OVERRIDE="$linked_home/config" \ + FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$WT_DIR" TMUX="fake,1,0" \ + CLAUDE_CONFIG_DIR='' FM_FAKE_LAUNCH_LOG="$LAUNCH_LOG" \ + GROK_HOME="$linked_home/grok-home" PATH="$FAKEBIN_DIR:$PATH" \ + "$SPAWN" "$id" "$PROJ_DIR" 2>&1 + ) + status=$? + expect_code 0 "$status" "spawn with absolute symlink-spelled overrides should succeed" + launch=$(cat "$LAUNCH_LOG") + assert_contains "$launch" "-e '$linked_home/state/$id.pi-ext.ts'" \ + "absolute FM_STATE_OVERRIDE spelling changed in Pi's cross-process extension path" + assert_contains "$launch" "< '$linked_home/data/$id/brief.md'" \ + "absolute FM_DATA_OVERRIDE spelling changed in the cross-process brief path" + pass "absolute override spellings are preserved in spawn launch paths" +} + +test_unresolvable_relative_overrides_fail_loudly() { + local rec id out status + id=profile-unresolvable-paths-z1d + rec=$(make_spawn_case profile-unresolvable-paths pi "$id") + read_case_record "$rec" + + out=$( + cd "$CASE_DIR" || exit 1 + FM_ROOT_OVERRIDE='' FM_HOME=home \ + FM_STATE_OVERRIDE=missing-state FM_DATA_OVERRIDE=home/data \ + "$SPAWN" "$id" "$PROJ_DIR" 2>&1 + ) + status=$? + expect_code 1 "$status" "spawn with an unresolvable relative state override should fail" + assert_contains "$out" "FM_STATE_OVERRIDE directory cannot be resolved: missing-state" \ + "spawn did not name the unresolvable FM_STATE_OVERRIDE" + + out=$( + cd "$CASE_DIR" || exit 1 + FM_ROOT_OVERRIDE='' FM_HOME=home \ + FM_STATE_OVERRIDE=home/state FM_DATA_OVERRIDE=missing-data \ + "$SPAWN" "$id" "$PROJ_DIR" 2>&1 + ) + status=$? + expect_code 1 "$status" "spawn with an unresolvable relative data override should fail" + assert_contains "$out" "FM_DATA_OVERRIDE directory cannot be resolved: missing-data" \ + "spawn did not name the unresolvable FM_DATA_OVERRIDE" + pass "unresolvable relative spawn overrides fail with named diagnostics" +} + test_active_dispatch_profile_requires_explicit_harness_for_ship() { local rec id out status id=profile-required-ship-z11 @@ -539,6 +596,8 @@ test_active_dispatch_profile_does_not_block_secondmate_launch() { test_no_profile_keeps_claude_profile_defaults test_relative_home_overrides_launch_with_absolute_cross_process_paths +test_absolute_override_spelling_is_preserved_in_launch_paths +test_unresolvable_relative_overrides_fail_loudly test_active_dispatch_profile_requires_explicit_harness_for_ship test_active_dispatch_profile_requires_explicit_harness_for_scout test_active_dispatch_profile_allows_explicit_harness From 588276cd97c0cb9911ddd7bf0c97108178a6b3db Mon Sep 17 00:00:00 2001 From: AG Werschky Date: Wed, 29 Jul 2026 11:19:46 -0600 Subject: [PATCH 3/4] no-mistakes(review): Normalize relative home before deriving durable paths --- bin/fm-spawn.sh | 1 + tests/fm-spawn-dispatch-profile.test.sh | 61 +++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index e5820d3a7d..d545b9e70b 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -137,6 +137,7 @@ resolve_directory_input() { printf '%s\n' "$resolved" } +FM_HOME=$(resolve_directory_input FM_HOME "$FM_HOME") || exit 1 if [ -n "${FM_STATE_OVERRIDE:-}" ]; then FM_STATE_OVERRIDE=$(resolve_directory_input FM_STATE_OVERRIDE "$FM_STATE_OVERRIDE") || exit 1 fi diff --git a/tests/fm-spawn-dispatch-profile.test.sh b/tests/fm-spawn-dispatch-profile.test.sh index 48f17a982d..c022f53c6d 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -156,6 +156,55 @@ test_relative_home_overrides_launch_with_absolute_cross_process_paths() { pass "relative home overrides become absolute before spawn launch construction" } +test_home_defaults_preserve_absolute_or_resolve_relative_paths() { + local rec relative_id absolute_id out status launch home_real linked_home + relative_id=profile-relative-home-defaults-z1c + absolute_id=profile-absolute-home-defaults-z1d + rec=$(make_spawn_case profile-home-defaults pi "$relative_id" "$absolute_id") + read_case_record "$rec" + home_real=$(cd "$HOME_DIR" && pwd -P) + + : > "$LAUNCH_LOG" + out=$( + cd "$CASE_DIR" || exit 1 + FM_ROOT_OVERRIDE='' FM_HOME=home \ + FM_STATE_OVERRIDE='' FM_DATA_OVERRIDE='' \ + FM_PROJECTS_OVERRIDE=home/projects FM_CONFIG_OVERRIDE=home/config \ + FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$WT_DIR" TMUX="fake,1,0" \ + CLAUDE_CONFIG_DIR='' FM_FAKE_LAUNCH_LOG="$LAUNCH_LOG" \ + GROK_HOME=home/grok-home PATH="$FAKEBIN_DIR:$PATH" \ + "$SPAWN" "$relative_id" "$PROJ_DIR" 2>&1 + ) + status=$? + expect_code 0 "$status" "spawn with relative FM_HOME defaults should succeed" + launch=$(cat "$LAUNCH_LOG") + assert_contains "$launch" "-e '$home_real/state/$relative_id.pi-ext.ts'" \ + "relative FM_HOME leaked into Pi's default cross-process extension path" + assert_contains "$launch" "< '$home_real/data/$relative_id/brief.md'" \ + "relative FM_HOME leaked into the default cross-process brief path" + + linked_home="$CASE_DIR/home-link" + ln -s "$HOME_DIR" "$linked_home" + : > "$LAUNCH_LOG" + out=$( + FM_ROOT_OVERRIDE='' FM_HOME="$linked_home" \ + FM_STATE_OVERRIDE='' FM_DATA_OVERRIDE='' \ + FM_PROJECTS_OVERRIDE="$linked_home/projects" FM_CONFIG_OVERRIDE="$linked_home/config" \ + FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$WT_DIR" TMUX="fake,1,0" \ + CLAUDE_CONFIG_DIR='' FM_FAKE_LAUNCH_LOG="$LAUNCH_LOG" \ + GROK_HOME="$linked_home/grok-home" PATH="$FAKEBIN_DIR:$PATH" \ + "$SPAWN" "$absolute_id" "$PROJ_DIR" 2>&1 + ) + status=$? + expect_code 0 "$status" "spawn with absolute symlink-spelled FM_HOME defaults should succeed" + launch=$(cat "$LAUNCH_LOG") + assert_contains "$launch" "-e '$linked_home/state/$absolute_id.pi-ext.ts'" \ + "absolute FM_HOME spelling changed in Pi's default cross-process extension path" + assert_contains "$launch" "< '$linked_home/data/$absolute_id/brief.md'" \ + "absolute FM_HOME spelling changed in the default cross-process brief path" + pass "FM_HOME defaults resolve relative paths and preserve absolute spellings" +} + test_absolute_override_spelling_is_preserved_in_launch_paths() { local rec id out status launch linked_home id=profile-absolute-paths-z1c @@ -190,6 +239,17 @@ test_unresolvable_relative_overrides_fail_loudly() { rec=$(make_spawn_case profile-unresolvable-paths pi "$id") read_case_record "$rec" + out=$( + cd "$CASE_DIR" || exit 1 + FM_ROOT_OVERRIDE='' FM_HOME=missing-home \ + FM_STATE_OVERRIDE='' FM_DATA_OVERRIDE='' \ + "$SPAWN" "$id" "$PROJ_DIR" 2>&1 + ) + status=$? + expect_code 1 "$status" "spawn with an unresolvable relative home should fail" + assert_contains "$out" "FM_HOME directory cannot be resolved: missing-home" \ + "spawn did not name the unresolvable FM_HOME" + out=$( cd "$CASE_DIR" || exit 1 FM_ROOT_OVERRIDE='' FM_HOME=home \ @@ -596,6 +656,7 @@ test_active_dispatch_profile_does_not_block_secondmate_launch() { test_no_profile_keeps_claude_profile_defaults test_relative_home_overrides_launch_with_absolute_cross_process_paths +test_home_defaults_preserve_absolute_or_resolve_relative_paths test_absolute_override_spelling_is_preserved_in_launch_paths test_unresolvable_relative_overrides_fail_loudly test_active_dispatch_profile_requires_explicit_harness_for_ship From e5dcf7ad218291f647fe5d932f4e824395ee2132 Mon Sep 17 00:00:00 2001 From: AG Werschky Date: Wed, 29 Jul 2026 11:27:16 -0600 Subject: [PATCH 4/4] no-mistakes(document): Document relative durable-path normalization --- docs/configuration.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/configuration.md b/docs/configuration.md index c5215512b2..adf215b408 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -169,6 +169,8 @@ When it is unset, most scripts use the repo root as the home; when it is set, sc When `FM_HOME` is unset, it also behaves as the old whole-root override. `bin/fm-send.sh` is intentionally stricter than that general fallback: it requires `FM_HOME` to be set before resolving a target, so operator steers cannot silently resolve against the wrong home. `FM_STATE_OVERRIDE`, `FM_DATA_OVERRIDE`, `FM_PROJECTS_OVERRIDE`, and `FM_CONFIG_OVERRIDE` override individual operational directories for tests and specialized harness setup. +Before `fm-brief.sh`, `fm-spawn.sh`, or `fm-afk-launch.sh` persists a path or passes it to another process, it resolves each applicable relative `FM_HOME`, `FM_STATE_OVERRIDE`, or `FM_DATA_OVERRIDE` directory against the caller's working directory, preserves absolute spellings unchanged, and rejects an unresolvable relative directory with the offending variable named. +Bootstrap applies the same relative `FM_HOME` resolution only when embedding that home in the generated X-mode poll shim; other transient consumers retain their existing shell-relative behavior. For the herdr backend, `FM_HOME` also determines the workspace label used by the adapter. For the zellij backend, `FM_HOME` does not split containers, but it determines the readable home prefix embedded in visible tab titles; use `FM_ZELLIJ_SESSION` when a separate zellij session is needed. The full zellij home label also includes a short hash of the resolved `FM_ROOT` path.