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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions bin/fm-claude-stop-autoarm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@
# the model.
set -u

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}"
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd -- "$SCRIPT_DIR/.." && pwd)}"
FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}"
STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}"
CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}"
Expand Down
4 changes: 2 additions & 2 deletions bin/fm-lock.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
# fm-lock.sh status print holder and liveness; always exits 0
set -u

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}"
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd -- "$SCRIPT_DIR/.." && pwd)}"
FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}"
STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}"
LOCK="$STATE/.lock"
Expand Down
6 changes: 3 additions & 3 deletions bin/fm-turnend-guard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@
# fail-open only for an already verified failure episode.
set -u

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}"
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd -- "$SCRIPT_DIR/.." && pwd)}"
FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}"
STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}"
CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}"
Expand All @@ -86,7 +86,7 @@ for arg in "$@"; do
case "$arg" in
--claude) CLAUDE_MODE=1 ;;
--cursor) CURSOR_MODE=1 ;;
*) echo "usage: $(basename "$0") [--claude|--cursor]" >&2; exit 2 ;;
*) echo "usage: $(basename -- "$0") [--claude|--cursor]" >&2; exit 2 ;;
esac
done

Expand Down
23 changes: 15 additions & 8 deletions bin/fm-wake-lib.sh
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
#!/usr/bin/env bash
# Shared durable wake queue and portable lock helpers.

FM_WAKE_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FM_WAKE_DEFAULT_ROOT="$(cd "$FM_WAKE_LIB_DIR/.." && pwd)"
FM_WAKE_LIB_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
FM_WAKE_DEFAULT_ROOT="$(cd -- "$FM_WAKE_LIB_DIR/.." && pwd)"
FM_ROOT="${FM_ROOT_OVERRIDE:-${FM_ROOT:-$FM_WAKE_DEFAULT_ROOT}}"
FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}"
STATE="${FM_STATE_OVERRIDE:-${STATE:-$FM_HOME/state}}"
Expand Down Expand Up @@ -314,11 +314,18 @@ fm_lock_role() {
cat "$1/role" 2>/dev/null
}

# basename, dirname and cd take `--` here because they are reached from the
# ancestry walk, where a login shell's own argv[0] arrives dash-prefixed and BSD
# basename would otherwise reject it as an option bundle. The readlink calls
# below deliberately do NOT: every lock path is built under the home's state
# directory from FM_HOME and is absolute, so none can begin with a dash, and
# adding a separator there would be hardening beyond the defect at the cost of
# depending on `--` support in one more BSD tool.
fm_lock_abs_path() {
local path=$1 dir base
dir=$(dirname "$path")
base=$(basename "$path")
dir=$(cd "$dir" 2>/dev/null && pwd -P) || return 1
dir=$(dirname -- "$path")
base=$(basename -- "$path")
dir=$(cd -- "$dir" 2>/dev/null && pwd -P) || return 1
printf '%s/%s\n' "$dir" "$base"
}

Expand All @@ -342,7 +349,7 @@ fm_lock_link_owner() {
[ -n "$owner" ] || return 1
case "$owner" in
/*) printf '%s\n' "$owner" ;;
*) printf '%s/%s\n' "$(dirname "$lockdir")" "$owner" ;;
*) printf '%s/%s\n' "$(dirname -- "$lockdir")" "$owner" ;;
esac
}

Expand All @@ -361,7 +368,7 @@ fm_lock_discard_owner() {

fm_lock_remove_stray_owner_link() {
local lockdir=$1 ownerdir=$2 stray
stray="$lockdir/$(basename "$ownerdir")"
stray="$lockdir/$(basename -- "$ownerdir")"
if [ -L "$stray" ] && [ "$(readlink "$stray" 2>/dev/null || true)" = "$ownerdir" ]; then
rm -f "$stray" 2>/dev/null || true
fi
Expand Down Expand Up @@ -1228,7 +1235,7 @@ fm_wake_signal_sig() { # <file> -> "size:mtime"
}

fm_wake_signal_seen_path() { # <state> <file>
printf '%s/.seen-%s' "$1" "$(basename "$2" | tr '.' '_')"
printf '%s/.seen-%s' "$1" "$(basename -- "$2" | tr '.' '_')"
}

# 0 when <file>'s current signature exactly matches its recorded seen marker,
Expand Down
4 changes: 2 additions & 2 deletions bin/fm-watch-arm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
# (secondmate homes run the same script) and would kill siblings.
set -u

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=bin/fm-wake-lib.sh
. "$SCRIPT_DIR/fm-wake-lib.sh"

Expand Down Expand Up @@ -397,7 +397,7 @@ case "${1:-}" in
case "$handling_watcher_pid" in ''|*[!0-9]*) echo "watcher: invalid successor watcher pid" >&2; exit 2 ;; esac
[ "$#" -eq 4 ] || { echo "watcher: unexpected handling delivery arguments" >&2; exit 2; }
;;
*) echo "usage: $(basename "$0") [--restart | --handling-delivered GENERATION --watcher-pid PID]" >&2; exit 2 ;;
*) echo "usage: $(basename -- "$0") [--restart | --handling-delivered GENERATION --watcher-pid PID]" >&2; exit 2 ;;
esac

if [ "$mode" = handling-delivered ]; then
Expand Down
10 changes: 5 additions & 5 deletions bin/fm-watch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@
# no-op through the watcher singleton lock.
set -u

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}"
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd -- "$SCRIPT_DIR/.." && pwd)}"
FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}"
STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}"
mkdir -p "$STATE"
Expand Down Expand Up @@ -1011,7 +1011,7 @@ while :; do
for c in "$STATE"/*.check.sh; do
[ -e "$c" ] || continue
is_pr_poll=0
if [ "$(basename "$c")" = x-watch.check.sh ]; then
if [ "$(basename -- "$c")" = x-watch.check.sh ]; then
if fmx_poll_shim_valid "$c" "$FM_HOME" "$FM_ROOT" \
&& [ -f "$FM_ROOT/bin/fm-x-poll.sh" ] && [ ! -L "$FM_ROOT/bin/fm-x-poll.sh" ]; then
FM_HOME="$FM_HOME" run_check_capture "$FM_ROOT/bin/fm-x-poll.sh" || exit 1
Expand All @@ -1021,7 +1021,7 @@ while :; do
continue
fi
else
id=$(basename "$c" .check.sh)
id=$(basename -- "$c" .check.sh)
if fm_pr_poll_snapshot_capture "$STATE" "$id" "$SCRIPT_DIR/fm-pr-poll.sh"; then
is_pr_poll=1
provider=$FM_PR_POLL_SNAPSHOT_PROVIDER
Expand Down Expand Up @@ -1101,7 +1101,7 @@ EOF
if afk_present || signal_reason_is_actionable $files || ! signal_crew_provably_working $files; then
while IFS=$(printf '\t') read -r sf sig f; do
[ -n "$sf" ] || continue
fm_wake_append signal "$(basename "$f")" "$reason" || exit 1
fm_wake_append signal "$(basename -- "$f")" "$reason" || exit 1
done <<EOF
$pending
EOF
Expand Down
94 changes: 94 additions & 0 deletions tests/fm-claude-stop-autoarm.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -786,6 +786,98 @@ test_fm_lock_status_still_works_with_shared_lib() {
pass "fm-lock: shared session-lock lib preserves the status path"
}

# A login shell carries its own executable PATH as argv[0] with a leading dash
# ("-/bin/zsh" under macOS `login`, "-/bin/bash" here), and that string reaches
# every ancestry-walking path tool as an ordinary argument. BSD basename and
# dirname parse a leading dash as an option bundle and reject it, so an
# unguarded call printed a diagnostic ahead of every hook message the primary
# session saw. These run the real hook and the real lock acquisition beneath
# such an ancestor and require both a working outcome and a silent one.

# Build a two-stage shim: stage 1 re-execs itself with a login-shell-shaped
# argv[0], stage 2 runs $1 beneath the fake harness holding the fixture lock.
write_login_shell_shim() {
local shim=$1
cat > "$shim" <<'SH'
#!/usr/bin/env bash
set -u
if [ "${FM_LOGIN_SHELL_STAGE:-0}" = 0 ]; then
FM_LOGIN_SHELL_STAGE=1 exec -a "-$BASH" "$BASH" "$0" "$@"
fi
# Record what this platform's ps actually exposes, so the test can prove the
# dash-prefixed path really reached the walk instead of passing vacuously.
LC_ALL=C ps -o comm= -p $$ > "$FM_HOME/state/ancestor-comm" 2>/dev/null
LC_ALL=C ps -o args= -p $$ > "$FM_HOME/state/ancestor-args" 2>/dev/null
# No exec below: this process must SURVIVE as the dash-named ancestor, and the
# harness must be its own pid, or the whole chain collapses into a single
# process and the ancestry walk never sees a login shell at all.
# The trailing `exit` on each level defeats bash's last-command exec
# optimization, which would otherwise replace this shell (and the harness
# shell) in place and collapse the whole chain into one pid with no login
# shell left to walk past.
"$FAKE_CLAUDE" -c '
printf "%s\n" "$$" > "$FM_HOME/state/.lock"
"$@"
exit $?
' fm-login-shell-shim "$@"
exit $?
SH
chmod +x "$shim"
}

# 0 when either ps field recorded by the shim carries the dash-prefixed path.
# macOS ps reports argv[0] in comm=; procps reports the kernel exec name there
# and exposes argv[0] only through args=, so either field satisfies this.
assert_login_shell_argv0_reached_walk() {
local dir=$1 comm args
comm=$(cat "$dir/state/ancestor-comm" 2>/dev/null || true)
args=$(cat "$dir/state/ancestor-args" 2>/dev/null || true)
case "$comm$args" in
*-/*) return 0 ;;
esac
fail "no ps field carried the login-shell argv[0]; comm='$comm' args='$args'"
}

assert_no_path_tool_diagnostic() {
local out=$1 what=$2
case "$out" in
*"illegal option"*|*"basename:"*|*"dirname:"*|*"usage: basename"*|*"usage: dirname"*)
fail "$what printed a path-tool diagnostic: $out"
;;
esac
}

test_rewake_is_clean_under_login_shell_ancestor() {
local dir shim out status
dir=$(make_primary_dir "$TMP_ROOT/login-shell-rewake")
: > "$dir/state/task.meta"
write_arm_fixture "$dir" actionable
shim="$TMP_ROOT/login-shell-rewake-shim.sh"
write_login_shell_shim "$shim"
status=0
out=$(printf '%s\n' '{"session_id":"sess-login-shell","stop_hook_active":false}' \
| FM_HOME="$dir" "$shim" "$dir/bin/fm-claude-stop-autoarm.sh" 2>&1) || status=$?
assert_login_shell_argv0_reached_walk "$dir"
assert_no_path_tool_diagnostic "$out" "the Stop-owned auto-arm"
expect_code 2 "$status" "an actionable close must still rewake beneath a login-shell ancestor"
assert_contains "$out" "firstmate watcher wake" "the rewake banner must still be delivered"
pass "auto-arm: rewake beneath a login-shell ancestor carries no path-tool diagnostic"
}

test_lock_acquire_is_clean_under_login_shell_ancestor() {
local dir shim out status
dir=$(make_primary_dir "$TMP_ROOT/login-shell-lock")
shim="$TMP_ROOT/login-shell-lock-shim.sh"
write_login_shell_shim "$shim"
status=0
out=$(FM_HOME="$dir" "$shim" "$ROOT/bin/fm-lock.sh" 2>&1) || status=$?
assert_login_shell_argv0_reached_walk "$dir"
assert_no_path_tool_diagnostic "$out" "session-lock acquisition"
expect_code 0 "$status" "lock acquisition must succeed beneath a login-shell ancestor"
assert_contains "$out" "lock acquired: harness pid" "the lock must still report its harness pid"
pass "fm-lock: acquisition beneath a login-shell ancestor carries no path-tool diagnostic"
}

test_inert_in_child_worktree
test_inert_without_session_lock
test_reclaims_stale_session_lock_before_arming
Expand Down Expand Up @@ -815,3 +907,5 @@ test_need_vanished_mid_cycle_closes_quietly
test_afk_mid_cycle_suppresses_rewake
test_active_in_marked_secondmate_home
test_fm_lock_status_still_works_with_shared_lib
test_rewake_is_clean_under_login_shell_ancestor
test_lock_acquire_is_clean_under_login_shell_ancestor
39 changes: 39 additions & 0 deletions tests/fm-watcher-lock.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,44 @@ test_lock_single_winner_under_concurrency() {
pass "concurrent fm_lock_try_acquire yields exactly one winner"
}

# The owner-link helpers resolve a lock symlink through readlink, which is
# deliberately called WITHOUT a `--` separator: every lock path is built under
# the home's state directory from FM_HOME and is absolute, so a dash-prefixed
# lock path is outside the contract these helpers accept and is not pinned here.
# What is in contract is the ordinary absolute owner link, which nothing else
# exercises directly: an acquired lock must report the owner directory it points
# at, agree only with that directory, and stop resolving once released.
test_lock_helpers_resolve_owner_link_for_absolute_lock_path() {
local dir state out
dir=$(make_case lock-owner-link)
state="$dir/state"
out=$(FM_STATE_OVERRIDE="$state" bash -c '
. "$1"
lockdir="$2/.owner-link.lock"
fm_lock_try_acquire "$lockdir" || { printf "acquire-failed\n"; exit 1; }
owner=$(fm_lock_link_owner "$lockdir") || { printf "unreadable\n"; exit 1; }
case "$owner" in
/*) ;;
*) printf "not-absolute:%s\n" "$owner"; exit 1 ;;
esac
[ -d "$owner" ] || { printf "owner-missing:%s\n" "$owner"; exit 1; }
fm_lock_points_to_owner "$lockdir" "$owner" || { printf "mismatch\n"; exit 1; }
if fm_lock_points_to_owner "$lockdir" "$owner.other"; then
printf "false-match\n"
exit 1
fi
fm_lock_release "$lockdir"
if fm_lock_link_owner "$lockdir" >/dev/null 2>&1; then
printf "still-linked\n"
exit 1
fi
printf "ok\n"
' _ "$LIB" "$state" 2>&1)
[ "$out" = ok ] \
|| fail "an absolute lock path must resolve, match only its own owner, and clear on release, got: $out"
pass "lock helpers resolve and clear an absolute lock path's owner link"
}

test_lock_steals_dead_pid_lock() {
local dir state lockdir dead rc newpid
dir=$(make_case lock-dead-steal)
Expand Down Expand Up @@ -1107,6 +1145,7 @@ test_stale_watch_reclaim_publishes_before_clear
test_live_stale_watch_lock_is_actionable
test_guard_warnings
test_lock_single_winner_under_concurrency
test_lock_helpers_resolve_owner_link_for_absolute_lock_path
test_lock_steals_dead_pid_lock
test_lock_stale_steal_single_winner_under_concurrency
test_lock_live_steal_mutex_is_not_reclaimed
Expand Down