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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions bin/backends/herdr.sh
Original file line number Diff line number Diff line change
Expand Up @@ -773,14 +773,26 @@ fm_backend_herdr_projection_workspace_label() { # <task-id> <projection-id>
printf 'β”” %s Β· p:%s' "$(fm_backend_herdr_projection_concise_task_label "$1")" "$2"
}

# fm_backend_herdr_presentation_session_lock_path: one machine-private lock
# fm_backend_herdr_presentation_session_lock_path: one account-private lock
# path per live named Herdr session/socket, shared across every Firstmate home
# that uses that session.
# of this OS account that uses that session.
# The path is never under any one home's state/ and secondmates never write the
# primary home. Returns non-zero when the named session's socket cannot be
# resolved unambiguously.
# The namespace directory is suffixed with this account's uid, so another OS
# account on the same host can never create it first by ordinary use and lock
# this account out; a deliberately pre-created name still fails the ownership
# and mode checks below and is refused, never adopted, chowned, or removed.
# The uid rather than $XDG_RUNTIME_DIR names it because that variable can differ
# or be absent between login contexts of one account, which would split one
# session's lock across processes.
fm_backend_herdr_presentation_lock_namespace() {
printf '%s' '/tmp/firstmate-herdr-presentation'
local uid
uid=$(id -u 2>/dev/null) || return 1
case "$uid" in
''|*[!0-9]*) return 1 ;;
esac
printf '/tmp/firstmate-herdr-presentation-%s' "$uid"
}

fm_backend_herdr_presentation_lock_namespace_mode() {
Expand Down
4 changes: 3 additions & 1 deletion docs/herdr-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ Projected children are placed in one contiguous block immediately after their ow
- The protocol.
- The socket.
- `python3`.
- The machine-private per-session lock.
- The account-private per-session lock.

Existing legacy child labels may extend an already adjacent block read-only but are never renamed or migrated.
A foreign, ambiguous, detached, or manually interleaved child makes ordering skip with a warning rather than rewriting the layout.
Expand Down Expand Up @@ -363,6 +363,8 @@ Once the exact pane is confirmed gone, teardown retires the task's own journal w
Recovery is deliberately conservative and presentation-only.
An existing journal suppresses another projected create.
Before any recovery mutation, Firstmate holds both the task spawn lock and the named-session presentation lock.
That presentation lock lives in a namespace private to the OS account, so another account on the same host running its own Firstmate on Herdr cannot block this account's spawn, recovery, or teardown.
A namespace at this account's name that another account owns, or that is not mode 700, is still refused and is never adopted, chowned, or removed.

A same-identity version 2 binding may replace one exact agent-free restart husk in place.
A husk is a restored same-labeled tab with a missing pane or no registered agent, as [Restart and liveness behavior](#restart-and-liveness-behavior) describes.
Expand Down
4 changes: 2 additions & 2 deletions tests/fm-backend-herdr.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3390,8 +3390,8 @@ test_presentation_session_lock_path_is_shared_across_homes() {
|| fail "session lock path resolution failed for home B"
[ "$path_a" = "$path_b" ] || fail "same session/socket must resolve one shared lock path"
case "$path_a" in
/tmp/firstmate-herdr-presentation/order-*.lock) ;;
*) fail "session lock path must use the shared machine namespace: $path_a" ;;
"/tmp/firstmate-herdr-presentation-$(id -u)"/order-*.lock) ;;
*) fail "session lock path must use this account's namespace: $path_a" ;;
esac
case "$path_a" in
*/state/*) fail "session lock path must not live under a home state directory: $path_a" ;;
Expand Down
141 changes: 141 additions & 0 deletions tests/fm-teardown.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2561,6 +2561,146 @@ test_herdr_flat_teardown_preflight_refuses_before_changes() {
pass "herdr flat teardown preflight refuses before every destructive change"
}

# The Herdr presentation-lock namespace is named per OS account. These cases
# act as a fixture account uid (via an `id -u` shim) so they never touch the
# real account's namespace or the old shared /tmp/firstmate-herdr-presentation,
# and every directory the fixture account resolves is really owned by the
# running account, i.e. by another uid from the fixture account's view.
# FM_FAKE_NS_STAT names one path whose owner and mode the `stat` shim reports
# instead; the adapter reads ownership through a PATH `stat` only on its non-
# Darwin branch, so the arms that need it run only there.
herdr_lock_ns_fake_uid() {
printf '%s' "$((3000000000 + $$ % 1000000))"
}

configure_herdr_lock_ns_shims() { # <case-dir>
local case_dir=$1 real_id real_stat
real_id=$(command -v id); real_stat=$(command -v stat)
cat > "$case_dir/fakebin/id" <<SH
#!/usr/bin/env bash
if [ "\${1:-}" = -u ] && [ "\$#" -eq 1 ] && [ -n "\${FM_FAKE_ACCOUNT_UID:-}" ]; then
printf '%s\n' "\$FM_FAKE_ACCOUNT_UID"
exit 0
fi
exec "$real_id" "\$@"
SH
cat > "$case_dir/fakebin/stat" <<SH
#!/usr/bin/env bash
if [ "\$#" -eq 3 ] && [ "\$1" = -c ] && [ -n "\${FM_FAKE_NS_STAT:-}" ] \\
&& [ "\$3" = "\${FM_FAKE_NS_STAT%%:*}" ]; then
rest=\${FM_FAKE_NS_STAT#*:}
case "\$2" in
%u) printf '%s\n' "\${rest%%:*}"; exit 0 ;;
%a) printf '%s\n' "\${rest#*:}"; exit 0 ;;
esac
fi
exec "$real_stat" "\$@"
SH
chmod +x "$case_dir/fakebin/id" "$case_dir/fakebin/stat"
}

herdr_lock_ns_path_state() { # <path>
if [ -e "$1" ] || [ -L "$1" ]; then
# A fixed, known path: ls is the portable way to read mode and numeric owner.
# shellcheck disable=SC2012
ls -ldn "$1" 2>/dev/null | awk '{print $1, $3, $4}'
else
printf 'absent'
fi
}

run_herdr_lock_ns_teardown() { # <case-dir> <fake-uid> [stat-spec]
local case_dir=$1 fake_uid=$2 stat_spec=${3:-} rc=0
FM_FAKE_ACCOUNT_UID="$fake_uid" FM_FAKE_NS_STAT="$stat_spec" \
FM_FAKE_HERDR_LOG="$case_dir/herdr.log" FM_FAKE_HERDR_CLOSED="$case_dir/closed" \
FM_BACKEND_HERDR_IDLE_SHELL_PROOF_POLLS=1 \
run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$?
return "$rc"
}

new_herdr_lock_ns_case() { # <name>
local case_dir
case_dir=$(make_case "$1")
write_meta "$case_dir" local-only ship
configure_flat_herdr_teardown_case "$case_dir"
configure_herdr_lock_ns_shims "$case_dir"
: > "$case_dir/herdr.log"
: > "$case_dir/state/task-x1.status"
printf '%s' "$case_dir"
}

assert_herdr_lock_ns_refused() { # <case-dir> <label>
local case_dir=$1 label=$2
assert_grep "presentation lock could not be resolved" "$case_dir/stderr" \
"$label: the namespace refusal was not explained visibly"
[ -e "$case_dir/state/task-x1.meta" ] || fail "$label: refusal erased the durable endpoint metadata"
[ -d "$case_dir/wt" ] || fail "$label: refusal removed the isolated copy"
[ ! -e "$case_dir/closed" ] || fail "$label: refusal attempted a pane close without the lock"
}

test_herdr_teardown_presentation_lock_namespace_is_per_account() {
local fake_uid own_ns legacy legacy_before legacy_after own_before case_dir rc lock linux_arms=0
fake_uid=$(herdr_lock_ns_fake_uid)
[ "$fake_uid" != "$(id -u)" ] || fail "herdr-lock-ns: fixture account uid collides with the running account"
own_ns="/tmp/firstmate-herdr-presentation-$fake_uid"
legacy=/tmp/firstmate-herdr-presentation
[ ! -e "$own_ns" ] && [ ! -L "$own_ns" ] \
|| fail "herdr-lock-ns: fixture namespace $own_ns already exists; refusing to reuse it"
printf '%s\n' "$own_ns" >> "$FM_TEST_CLEANUP_REGISTRY"
mkdir -m 700 "$own_ns" || fail "herdr-lock-ns: could not stage $own_ns"
legacy_before=$(herdr_lock_ns_path_state "$legacy")

# This account's own name, really owned by another uid, is still refused and
# is left exactly as it was.
own_before=$(herdr_lock_ns_path_state "$own_ns")
case_dir=$(new_herdr_lock_ns_case herdr-lock-ns-foreign-owner)
rc=0; run_herdr_lock_ns_teardown "$case_dir" "$fake_uid" || rc=$?
[ "$rc" -ne 0 ] || fail "herdr-lock-ns-foreign-owner: teardown adopted a namespace another uid owns"
assert_herdr_lock_ns_refused "$case_dir" herdr-lock-ns-foreign-owner
[ "$(herdr_lock_ns_path_state "$own_ns")" = "$own_before" ] \
|| fail "herdr-lock-ns-foreign-owner: refusal changed the foreign-owned namespace: $(herdr_lock_ns_path_state "$own_ns")"

if [ "$(uname -s)" != Darwin ]; then
linux_arms=1
# The old shared name is owned by another uid whenever it exists here, as
# on a host where a second account created it first; this account's
# teardown no longer consults it and completes in its own namespace.
case_dir=$(new_herdr_lock_ns_case herdr-lock-ns-other-account)
run_herdr_lock_ns_teardown "$case_dir" "$fake_uid" "$own_ns:$fake_uid:700" \
|| fail "herdr-lock-ns-other-account: teardown was blocked: $(cat "$case_dir/stderr")"
[ -e "$case_dir/closed" ] || fail "herdr-lock-ns-other-account: the pane was not closed under the lock"
[ ! -e "$case_dir/state/task-x1.meta" ] || fail "herdr-lock-ns-other-account: teardown left the metadata behind"
grep -q "teardown task-x1 complete" "$case_dir/stdout" \
|| fail "herdr-lock-ns-other-account: teardown did not report completion"
lock=$(FM_FAKE_ACCOUNT_UID="$fake_uid" FM_FAKE_NS_STAT="$own_ns:$fake_uid:700" \
FM_FAKE_HERDR_LOG="$case_dir/herdr.log" FM_FAKE_HERDR_CLOSED="$case_dir/closed" \
PATH="$case_dir/fakebin:$PATH" \
bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_presentation_session_lock_path default' "$ROOT") \
|| fail "herdr-lock-ns-other-account: could not resolve the fixture account's lock path"
case "$lock" in
"$own_ns"/order-*.lock) ;;
*) fail "herdr-lock-ns-other-account: the lock is not in this account's namespace: $lock" ;;
esac

# This account's own name with the right owner but the wrong mode is refused.
case_dir=$(new_herdr_lock_ns_case herdr-lock-ns-wrong-mode)
rc=0; run_herdr_lock_ns_teardown "$case_dir" "$fake_uid" "$own_ns:$fake_uid:755" || rc=$?
[ "$rc" -ne 0 ] || fail "herdr-lock-ns-wrong-mode: teardown adopted a namespace that is not mode 700"
assert_herdr_lock_ns_refused "$case_dir" herdr-lock-ns-wrong-mode
[ -d "$own_ns" ] || fail "herdr-lock-ns-wrong-mode: refusal removed the namespace"
fi

legacy_after=$(herdr_lock_ns_path_state "$legacy")
[ "$legacy_after" = "$legacy_before" ] \
|| fail "herdr-lock-ns: teardown changed the old shared namespace: $legacy_before -> $legacy_after"
rm -rf "$own_ns"
if [ "$linux_arms" = 1 ]; then
pass "herdr teardown takes its lock in a per-account namespace another account cannot block, and still refuses a foreign-owned or wrong-mode one"
else
pass "herdr teardown refuses a foreign-owned per-account namespace (owner-shim arms need the non-Darwin stat branch; skipped on Darwin)"
fi
}

configure_secondmate_with_herdr_child() { # <case-dir>
local case_dir=$1 home="$1/secondmate-home"
mkdir -p "$home/state" "$home/data" "$home/config" "$home/projects"
Expand Down Expand Up @@ -4519,6 +4659,7 @@ test_herdr_teardown_clears_escalation_marker
test_herdr_flat_teardown_refuses_orphaning_records_then_retry_completes
test_herdr_flat_teardown_refuses_records_on_unparseable_presence
test_herdr_flat_teardown_preflight_refuses_before_changes
test_herdr_teardown_presentation_lock_namespace_is_per_account
test_forced_secondmate_herdr_child_preflight_refuses_before_changes
test_forced_secondmate_teardown_holds_descendant_lifecycle_locks
test_forced_secondmate_herdr_child_retains_records_when_close_unconfirmed
Expand Down
Loading