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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions bin/fm-afk-launch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,28 @@ 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=$(CDPATH='' 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
if [ -n "${FM_STATE_OVERRIDE:-}" ]; then
case "$FM_STATE_OVERRIDE" in
/*) ;;
*)
FM_AFK_LAUNCH_STATE_INPUT=$FM_STATE_OVERRIDE
FM_STATE_OVERRIDE=$(CDPATH='' 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"
Expand Down
13 changes: 10 additions & 3 deletions bin/fm-bootstrap.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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=$(CDPATH='' 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'
Expand Down
27 changes: 24 additions & 3 deletions bin/fm-brief.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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=$(CDPATH='' 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
Expand Down
20 changes: 20 additions & 0 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,26 @@ 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=$(CDPATH='' cd -- "$path" 2>/dev/null && pwd -P) || {
echo "error: $name directory cannot be resolved: $path" >&2
return 1
}
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
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}"
Expand Down
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
49 changes: 49 additions & 0 deletions tests/fm-afk-launch.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,54 @@ unit_clear_stale() {
rm -rf "$st"
}

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/state" "$root/cdpath/home/state"
home=$(cd "$root/home" && pwd -P)
state="$home/state"
out=$(
cd "$root" || exit 1
CDPATH="$root/cdpath" 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"$'\n'"$state" ]; then
pass "launcher paths: relative home and state ignore CDPATH before daemon command construction"
else
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
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
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"
}

# ---------------------------------------------------------------------------
# UNIT 2: a FRESH entry clears; a REFRESH (daemon already alive) preserves the
# current session's buffered escalations.
Expand Down Expand Up @@ -861,6 +909,7 @@ e2e_tmux() {
}

unit_clear_stale
unit_relative_paths_are_absolute_before_daemon_launch
unit_fresh_vs_refresh
unit_stop_ordering
unit_stop_rejects_reused_pid
Expand Down
92 changes: 92 additions & 0 deletions tests/fm-brief.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,97 @@ 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" \
"$root/cdpath/home/data" "$root/cdpath/home/state" \
"$root/cdpath/data-override" "$root/cdpath/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
CDPATH="$root/cdpath" 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
CDPATH="$root/cdpath" 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
CDPATH="$root/cdpath" 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 ignore CDPATH, 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"
Expand Down Expand Up @@ -540,6 +631,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
Loading
Loading