From 97dbcab26febabb1386d5d303dd2bac6dec7cc84 Mon Sep 17 00:00:00 2001 From: Quartermaster Date: Sat, 19 Sep 2026 22:35:12 +0000 Subject: [PATCH 01/13] Fix remote secondmate launch inbox routing --- bin/fm-remote-home-seed.sh | 9 ++- bin/fm-spawn.sh | 56 ++++++++++++++++++- docs/remote-secondmates.md | 2 +- ...fm-remote-secondmate-lifecycle-e2e.test.sh | 5 ++ tests/fm-spawn-dispatch-profile.test.sh | 9 ++- 5 files changed, 73 insertions(+), 8 deletions(-) diff --git a/bin/fm-remote-home-seed.sh b/bin/fm-remote-home-seed.sh index d1a434f1f24..423cb1b3a17 100755 --- a/bin/fm-remote-home-seed.sh +++ b/bin/fm-remote-home-seed.sh @@ -22,6 +22,7 @@ # # Known provisioning failure rolls the registry back. SSH status 255 preserves # the route and any newly scaffolded brief because completion is unknown and a same-route rerun converges. +# The remote charter copy replaces the parent's local parent-channel and steering-inbox paths with the remote home paths the route actually uses. set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -147,11 +148,15 @@ REG_EXISTED=0 [ -f "$REG" ] && { cp "$REG" "$TMP/registry.before"; REG_EXISTED=1; } # Keep the parent charter as its durable source, but publish a remote copy whose -# status path is the remote append-only relay log rather than a local Mac path. +# parent-channel and steering-inbox paths are remote-home paths rather than local +# parent-home paths. PARENT_STATUS="$STATE/$ID.status" REMOTE_STATUS="$REMOTE_HOME/state/parent-replies.status" +PARENT_INBOX="$STATE/$ID.inbox" +REMOTE_INBOX="$REMOTE_HOME/state/parent-route/$ID.inbox" while IFS= read -r line || [ -n "$line" ]; do - printf '%s\n' "${line//"$PARENT_STATUS"/"$REMOTE_STATUS"}" + line=${line//"$PARENT_STATUS"/"$REMOTE_STATUS"} + printf '%s\n' "${line//"$PARENT_INBOX"/"$REMOTE_INBOX"}" done < "$BRIEF" > "$TMP/charter.remote" PROJECTS_CSV= diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 518e905f60f..0e309f4f265 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -26,8 +26,9 @@ # refused as a flag value. # Ship/scout launches always put fm-dod-lib.sh's current worker role scope # first in the private launch-brief overlay, including the exact task-owned -# steering inbox. This never rewrites a project's instruction files or a -# secondmate's charter. +# steering inbox. Secondmate launches put their current parent-channel and +# steering-inbox route first for the same stale-charter protection. Neither +# path rewrites a project's instruction files or a secondmate's charter. # fm-spawn.sh --relaunch [--harness ] [--model ] [--effort ] # --relaunch launches a replacement agent for an EXISTING task into that # task's own recorded worktree, reusing its recorded endpoint when that @@ -455,6 +456,8 @@ PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}" CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" # shellcheck source=bin/fm-config-inherit-lib.sh . "$SCRIPT_DIR/fm-config-inherit-lib.sh" +# shellcheck source=bin/fm-parent-channel-lib.sh +. "$SCRIPT_DIR/fm-parent-channel-lib.sh" if ! LAUNCH_ENV_ENABLED=$(fm_config_source_present "$CONFIG/launch-env-allowlist"); then exit 1 fi @@ -1728,6 +1731,47 @@ shell_quote() { printf "'" } +secondmate_launch_parent_channel() { # + local home=$1 id=$2 dest rc=0 + dest=$(fm_parent_channel_destination "$home" "$home/state" 2>/dev/null) || rc=$? + if [ "$rc" -eq 0 ] && [ -n "$dest" ]; then + printf '%s\n' "$dest" + else + printf '%s/%s.status\n' "$STATE" "$id" + fi +} + +render_secondmate_launch_brief() { # + local source=$1 launch=$2 home=$3 id=$4 tmp parent_channel inbox_dir q_parent q_inbox + parent_channel=$(secondmate_launch_parent_channel "$home" "$id") || return 1 + inbox_dir="$STATE/$id.inbox" + q_parent=$(shell_quote "$parent_channel") + q_inbox=$(shell_quote "$inbox_dir") + mkdir -p "$(dirname "$launch")" || return 1 + tmp="$(dirname "$launch")/.launch-brief.md.${BASHPID:-$$}" + { + cat <> $q_parent\`. +Firstmate steers you through durable message files in $q_inbox. +When a terminal message says an instruction is waiting there - and at any natural checkpoint when you are unsure - list $q_inbox/*.msg, read and act on each message in numeric order, then acknowledge each handled message by moving it: \`mv $q_inbox/NNN.msg $q_inbox/handled/\`. +That exact inbox path belongs to this routed secondmate endpoint for this launch; do not substitute another home or the charter's older path. +The move IS the acknowledgement: without it firstmate rings again and eventually treats you as stuck. An empty or absent inbox needs no action. + +EOF + cat "$source" + } >"$tmp" || { + rm -f -- "$tmp" + return 1 + } + mv "$tmp" "$launch" || { + rm -f -- "$tmp" + return 1 + } +} + resolve_pi_executable() { local candidate dir candidate=$(type -P -- "$1" 2>/dev/null) || return 1 @@ -2720,6 +2764,14 @@ fi echo "error: task $ID has no brief at inaccessible data path $BRIEF" >&2 exit 1 } +if [ "$KIND" = secondmate ]; then + SOURCE_BRIEF=$BRIEF + BRIEF="$DATA/$ID/launch-brief.md" + if ! render_secondmate_launch_brief "$SOURCE_BRIEF" "$BRIEF" "$PROJ_ABS" "$ID"; then + echo "error: could not render current secondmate launch route for $SOURCE_BRIEF" >&2 + exit 1 + fi +fi if [ "$KIND" = ship ] || [ "$KIND" = scout ]; then if fm_brief_task_placeholders_present "$BRIEF"; then echo "error: $BRIEF still contains {TASK} or {FIRSTMATE_SPEC}; fill ## Captain's intent and ## Firstmate spec before spawn" >&2 diff --git a/docs/remote-secondmates.md b/docs/remote-secondmates.md index bf8f044e0e4..4656fe72364 100644 --- a/docs/remote-secondmates.md +++ b/docs/remote-secondmates.md @@ -179,7 +179,7 @@ FM_HOME= bin/fm-send.sh fm- '' ``` The [`fm-send.sh` header](../bin/fm-send.sh) owns the exact delivery-status contract. -A routed request is delivered as a durable record in the remote home's steering inbox plus a best-effort doorbell, never by typing the payload into the pane; exit 0 means the record durably exists. +A routed request is delivered as a durable record in the remote home's `state/parent-route/.inbox` steering inbox plus a best-effort doorbell, never by typing the payload into the pane; exit 0 means the record durably exists. Every remote transport attempt is bounded by `FM_SEND_REMOTE_BUDGET`; that header owns the setting's default and validation contract. An unconfirmed SSH transport (exit 255) is retried identically once, while a budget expiry is not retried because completion is unknown; either outcome preserves this ordinary reply-bearing request's pending-reply expectation for the record that may have landed. If delivery remains unconfirmed, only the exact `FM_PENDING_REPLY_EXISTING_CORR=` resend command printed by `fm-send` is safe to run later because it preserves the request body and lets the remote enqueue deduplicate onto the same record; a plain rerun mints a different correlation and is not idempotent. diff --git a/tests/fm-remote-secondmate-lifecycle-e2e.test.sh b/tests/fm-remote-secondmate-lifecycle-e2e.test.sh index be7022401bb..64aae6da350 100755 --- a/tests/fm-remote-secondmate-lifecycle-e2e.test.sh +++ b/tests/fm-remote-secondmate-lifecycle-e2e.test.sh @@ -659,6 +659,8 @@ assert_present "$REMOTE_HOME/.fm-secondmate-home" "remote provisioning did not p assert_present "$REMOTE_HOME/projects/alpha/.git" "remote provisioning did not clone the project on that host" assert_grep "$REMOTE_HOME/state/parent-replies.status" "$REMOTE_HOME/data/charter.md" "remote charter did not use its append-only reply log" assert_no_grep "$PARENT/state/ios.status" "$REMOTE_HOME/data/charter.md" "remote charter retained the inaccessible local status path" +assert_grep "$REMOTE_HOME/state/parent-route/ios.inbox" "$REMOTE_HOME/data/charter.md" "remote charter did not use its host-local steering inbox" +assert_no_grep "$PARENT/state/ios.inbox" "$REMOTE_HOME/data/charter.md" "remote charter retained the inaccessible local steering inbox" if FM_SECONDMATE_CHARTER='Own iOS delivery on the build Mac.' \ FM_SECONDMATE_SCOPE='iOS implementation and Xcode validation' \ remote_env "$ROOT/bin/fm-remote-home-seed.sh" ios remote-mac "$REMOTE_ROOT" "$TMP_ROOT/other-home" alpha \ @@ -725,6 +727,9 @@ assert_grep 'remote_backend=herdr' "$PARENT/state/ios.meta" "parent metadata omi assert_grep 'remote_herdr_session=fm-remote' "$PARENT/state/ios.meta" "parent metadata omitted the pinned remote Herdr session" assert_grep 'remote_target=fm-remote:' "$PARENT/state/ios.meta" "parent metadata did not record an fm-remote endpoint" assert_grep 'herdr_session=fm-remote' "$REMOTE_HOME/state/parent-route/ios.meta" "remote metadata did not record the pinned Herdr session" +assert_grep "$REMOTE_HOME/state/parent-route/ios.inbox" "$REMOTE_HOME/data/.parent-route/ios/launch-brief.md" "remote launch brief did not put the host-local steering inbox first" +assert_no_grep "$PARENT/state/ios.inbox" "$REMOTE_HOME/data/.parent-route/ios/launch-brief.md" "remote launch brief retained the inaccessible local steering inbox" +assert_grep "$REMOTE_HOME/state/parent-replies.status" "$REMOTE_HOME/data/.parent-route/ios/launch-brief.md" "remote launch brief did not put the remote parent channel first" assert_grep '--session fm-remote' "$HERDR_LOG" "remote launch did not target the fm-remote session" assert_no_grep '--session default' "$HERDR_LOG" "remote launch targeted the interactive default session" assert_grep 'window=remote:ios' "$PARENT/state/ios.meta" "parent metadata pretended the endpoint was local" diff --git a/tests/fm-spawn-dispatch-profile.test.sh b/tests/fm-spawn-dispatch-profile.test.sh index 09f50d5b87a..d557eaea078 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -837,16 +837,19 @@ test_pi_signed_persistent_secondmate_uses_pi_extensions_and_identity() { assert_meta_profile "$HOME_DIR/state/$id.meta" pi-signed default default cmp -s "$ROOT/AGENTS.md" "$sm/AGENTS.md" || fail "secondmate launch rewrote the supervisor contract" cmp -s "$CASE_DIR/charter-before" "$sm/data/charter.md" || fail "secondmate launch rewrote the charter" - assert_absent "$HOME_DIR/data/$id/launch-brief.md" "secondmate launch received a worker overlay" + assert_present "$HOME_DIR/data/$id/launch-brief.md" "secondmate launch did not publish its current route overlay" + assert_grep '# Current secondmate launch route' "$HOME_DIR/data/$id/launch-brief.md" "secondmate launch route overlay is missing" + assert_grep "$HOME_DIR/state/$id.inbox" "$HOME_DIR/data/$id/launch-brief.md" "secondmate launch route overlay omitted the current steering inbox" + assert_no_grep 'You are a crewmate: an autonomous worker agent managed by firstmate' "$HOME_DIR/data/$id/launch-brief.md" "secondmate launch route overlay became a task-worker role overlay" launch=$(cat "$LAUNCH_LOG") - assert_contains "$launch" "< '$sm/data/charter.md'" "secondmate launch lost its original charter" + assert_contains "$launch" "< '$HOME_DIR/data/$id/launch-brief.md'" "secondmate launch lost its current route overlay" assert_contains "$launch" "FM_PI_HARNESS=pi-signed '$FAKEBIN_DIR/pi-signed' --tui-mode regular -e '$sm/.pi/extensions/fm-primary-turnend-guard.ts' -e '$sm/.pi/extensions/fm-primary-pi-watch.ts'" \ "pi-signed secondmate did not force the regular TUI with Pi's primary extension launch shape" if [ "${FM_TEST_EVIDENCE:-0}" = 1 ]; then printf '# evidence begin: persistent secondmate\n%s\n' "$out" printf 'launch command:\n%s\noriginal charter:\n' "$launch" cat "$sm/data/charter.md" - printf 'supervisor AGENTS.md and charter remain byte-identical; no worker overlay created\n# evidence end\n' + printf 'supervisor AGENTS.md and charter remain byte-identical; route overlay created separately\n# evidence end\n' fi pass "pi-signed is a distinct persistent secondmate runtime with shared Pi supervision semantics" } From c2fd563d599e5445afc8eefe3b06f9459a814ff3 Mon Sep 17 00:00:00 2001 From: Quartermaster Date: Sat, 19 Sep 2026 22:55:01 +0000 Subject: [PATCH 02/13] no-mistakes(review): Gate secondmate launch overlay to remote routes, drop parent-channel guess --- bin/fm-spawn.sh | 42 +++++++++---------- ...fm-remote-secondmate-lifecycle-e2e.test.sh | 11 +++-- tests/fm-spawn-dispatch-profile.test.sh | 9 ++-- 3 files changed, 30 insertions(+), 32 deletions(-) diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 0e309f4f265..86113f44766 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -26,8 +26,9 @@ # refused as a flag value. # Ship/scout launches always put fm-dod-lib.sh's current worker role scope # first in the private launch-brief overlay, including the exact task-owned -# steering inbox. Secondmate launches put their current parent-channel and -# steering-inbox route first for the same stale-charter protection. Neither +# steering inbox. A REMOTE-route secondmate launch puts that home's +# host-local steering inbox first, because only a remote route can inherit a +# charter naming a steering inbox on a host this agent cannot reach. Neither # path rewrites a project's instruction files or a secondmate's charter. # fm-spawn.sh --relaunch [--harness ] [--model ] [--effort ] # --relaunch launches a replacement agent for an EXISTING task into that @@ -456,8 +457,8 @@ PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}" CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" # shellcheck source=bin/fm-config-inherit-lib.sh . "$SCRIPT_DIR/fm-config-inherit-lib.sh" -# shellcheck source=bin/fm-parent-channel-lib.sh -. "$SCRIPT_DIR/fm-parent-channel-lib.sh" +# shellcheck source=bin/fm-secondmate-parent-lib.sh +. "$SCRIPT_DIR/fm-secondmate-parent-lib.sh" if ! LAUNCH_ENV_ENABLED=$(fm_config_source_present "$CONFIG/launch-env-allowlist"); then exit 1 fi @@ -1731,30 +1732,25 @@ shell_quote() { printf "'" } -secondmate_launch_parent_channel() { # - local home=$1 id=$2 dest rc=0 - dest=$(fm_parent_channel_destination "$home" "$home/state" 2>/dev/null) || rc=$? - if [ "$rc" -eq 0 ] && [ -n "$dest" ]; then - printf '%s\n' "$dest" - else - printf '%s/%s.status\n' "$STATE" "$id" - fi +# A secondmate home whose durable parent binding reports a REMOTE route. Only +# that shape can carry a charter naming a steering inbox in the parent's own +# home, which this agent's host cannot reach. An absent or unparsable binding is +# not a remote route, so the standing charter stays the sole authority. +secondmate_launch_route_is_remote() { # + local home=$1 + fm_secondmate_parent_record_parse "$home/.fm-secondmate-parent" || return 1 + [ "$FM_SECONDMATE_PARENT_ROUTE" = remote ] } -render_secondmate_launch_brief() { # - local source=$1 launch=$2 home=$3 id=$4 tmp parent_channel inbox_dir q_parent q_inbox - parent_channel=$(secondmate_launch_parent_channel "$home" "$id") || return 1 - inbox_dir="$STATE/$id.inbox" - q_parent=$(shell_quote "$parent_channel") - q_inbox=$(shell_quote "$inbox_dir") +render_secondmate_launch_brief() { # + local source=$1 launch=$2 id=$3 tmp q_inbox + q_inbox=$(shell_quote "$STATE/$id.inbox") mkdir -p "$(dirname "$launch")" || return 1 tmp="$(dirname "$launch")/.launch-brief.md.${BASHPID:-$$}" { cat <> $q_parent\`. +This launch-time route contract supersedes any conflicting instruction-inbox path in the standing charter below. Firstmate steers you through durable message files in $q_inbox. When a terminal message says an instruction is waiting there - and at any natural checkpoint when you are unsure - list $q_inbox/*.msg, read and act on each message in numeric order, then acknowledge each handled message by moving it: \`mv $q_inbox/NNN.msg $q_inbox/handled/\`. That exact inbox path belongs to this routed secondmate endpoint for this launch; do not substitute another home or the charter's older path. @@ -2764,10 +2760,10 @@ fi echo "error: task $ID has no brief at inaccessible data path $BRIEF" >&2 exit 1 } -if [ "$KIND" = secondmate ]; then +if [ "$KIND" = secondmate ] && secondmate_launch_route_is_remote "$PROJ_ABS"; then SOURCE_BRIEF=$BRIEF BRIEF="$DATA/$ID/launch-brief.md" - if ! render_secondmate_launch_brief "$SOURCE_BRIEF" "$BRIEF" "$PROJ_ABS" "$ID"; then + if ! render_secondmate_launch_brief "$SOURCE_BRIEF" "$BRIEF" "$ID"; then echo "error: could not render current secondmate launch route for $SOURCE_BRIEF" >&2 exit 1 fi diff --git a/tests/fm-remote-secondmate-lifecycle-e2e.test.sh b/tests/fm-remote-secondmate-lifecycle-e2e.test.sh index 64aae6da350..7bbd6c2f04e 100755 --- a/tests/fm-remote-secondmate-lifecycle-e2e.test.sh +++ b/tests/fm-remote-secondmate-lifecycle-e2e.test.sh @@ -727,9 +727,14 @@ assert_grep 'remote_backend=herdr' "$PARENT/state/ios.meta" "parent metadata omi assert_grep 'remote_herdr_session=fm-remote' "$PARENT/state/ios.meta" "parent metadata omitted the pinned remote Herdr session" assert_grep 'remote_target=fm-remote:' "$PARENT/state/ios.meta" "parent metadata did not record an fm-remote endpoint" assert_grep 'herdr_session=fm-remote' "$REMOTE_HOME/state/parent-route/ios.meta" "remote metadata did not record the pinned Herdr session" -assert_grep "$REMOTE_HOME/state/parent-route/ios.inbox" "$REMOTE_HOME/data/.parent-route/ios/launch-brief.md" "remote launch brief did not put the host-local steering inbox first" -assert_no_grep "$PARENT/state/ios.inbox" "$REMOTE_HOME/data/.parent-route/ios/launch-brief.md" "remote launch brief retained the inaccessible local steering inbox" -assert_grep "$REMOTE_HOME/state/parent-replies.status" "$REMOTE_HOME/data/.parent-route/ios/launch-brief.md" "remote launch brief did not put the remote parent channel first" +remote_launch_brief="$REMOTE_HOME/data/.parent-route/ios/launch-brief.md" +assert_present "$remote_launch_brief" "remote secondmate launch did not publish its current route overlay" +# The overlay is the preamble the renderer emits above the concatenated charter, +# so the route assertions below discriminate it from the charter's own wording. +sed -n '1,/^$/p' "$remote_launch_brief" > "$TMP_ROOT/remote-launch-route.overlay" +assert_grep '# Current secondmate launch route' "$TMP_ROOT/remote-launch-route.overlay" "remote launch brief omitted the launch-time route contract" +assert_grep "$REMOTE_HOME/state/parent-route/ios.inbox" "$TMP_ROOT/remote-launch-route.overlay" "remote launch brief did not put the host-local steering inbox first" +assert_no_grep "$PARENT/state/ios.inbox" "$remote_launch_brief" "remote launch brief retained the inaccessible local steering inbox" assert_grep '--session fm-remote' "$HERDR_LOG" "remote launch did not target the fm-remote session" assert_no_grep '--session default' "$HERDR_LOG" "remote launch targeted the interactive default session" assert_grep 'window=remote:ios' "$PARENT/state/ios.meta" "parent metadata pretended the endpoint was local" diff --git a/tests/fm-spawn-dispatch-profile.test.sh b/tests/fm-spawn-dispatch-profile.test.sh index d557eaea078..f61af751a9e 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -837,19 +837,16 @@ test_pi_signed_persistent_secondmate_uses_pi_extensions_and_identity() { assert_meta_profile "$HOME_DIR/state/$id.meta" pi-signed default default cmp -s "$ROOT/AGENTS.md" "$sm/AGENTS.md" || fail "secondmate launch rewrote the supervisor contract" cmp -s "$CASE_DIR/charter-before" "$sm/data/charter.md" || fail "secondmate launch rewrote the charter" - assert_present "$HOME_DIR/data/$id/launch-brief.md" "secondmate launch did not publish its current route overlay" - assert_grep '# Current secondmate launch route' "$HOME_DIR/data/$id/launch-brief.md" "secondmate launch route overlay is missing" - assert_grep "$HOME_DIR/state/$id.inbox" "$HOME_DIR/data/$id/launch-brief.md" "secondmate launch route overlay omitted the current steering inbox" - assert_no_grep 'You are a crewmate: an autonomous worker agent managed by firstmate' "$HOME_DIR/data/$id/launch-brief.md" "secondmate launch route overlay became a task-worker role overlay" + assert_absent "$HOME_DIR/data/$id/launch-brief.md" "local-route secondmate launch received a launch overlay" launch=$(cat "$LAUNCH_LOG") - assert_contains "$launch" "< '$HOME_DIR/data/$id/launch-brief.md'" "secondmate launch lost its current route overlay" + assert_contains "$launch" "< '$sm/data/charter.md'" "secondmate launch lost its original charter" assert_contains "$launch" "FM_PI_HARNESS=pi-signed '$FAKEBIN_DIR/pi-signed' --tui-mode regular -e '$sm/.pi/extensions/fm-primary-turnend-guard.ts' -e '$sm/.pi/extensions/fm-primary-pi-watch.ts'" \ "pi-signed secondmate did not force the regular TUI with Pi's primary extension launch shape" if [ "${FM_TEST_EVIDENCE:-0}" = 1 ]; then printf '# evidence begin: persistent secondmate\n%s\n' "$out" printf 'launch command:\n%s\noriginal charter:\n' "$launch" cat "$sm/data/charter.md" - printf 'supervisor AGENTS.md and charter remain byte-identical; route overlay created separately\n# evidence end\n' + printf 'supervisor AGENTS.md and charter remain byte-identical; no launch overlay created\n# evidence end\n' fi pass "pi-signed is a distinct persistent secondmate runtime with shared Pi supervision semantics" } From 7d24419185c69dfb13e73d7153186cd8292903f0 Mon Sep 17 00:00:00 2001 From: Quartermaster Date: Sat, 19 Sep 2026 23:25:13 +0000 Subject: [PATCH 03/13] no-mistakes(document): document remote-route steering inbox invariant in remote-secondmates --- docs/remote-secondmates.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/remote-secondmates.md b/docs/remote-secondmates.md index 4656fe72364..022a54b928f 100644 --- a/docs/remote-secondmates.md +++ b/docs/remote-secondmates.md @@ -180,6 +180,7 @@ FM_HOME= bin/fm-send.sh fm- '' The [`fm-send.sh` header](../bin/fm-send.sh) owns the exact delivery-status contract. A routed request is delivered as a durable record in the remote home's `state/parent-route/.inbox` steering inbox plus a best-effort doorbell, never by typing the payload into the pane; exit 0 means the record durably exists. +The seeded remote charter names that same host-local inbox, and every remote-route launch re-states it above the standing charter, so the mate acts on the record the doorbell rang for instead of an unreachable inbox path in the parent's home. Every remote transport attempt is bounded by `FM_SEND_REMOTE_BUDGET`; that header owns the setting's default and validation contract. An unconfirmed SSH transport (exit 255) is retried identically once, while a budget expiry is not retried because completion is unknown; either outcome preserves this ordinary reply-bearing request's pending-reply expectation for the record that may have landed. If delivery remains unconfirmed, only the exact `FM_PENDING_REPLY_EXISTING_CORR=` resend command printed by `fm-send` is safe to run later because it preserves the request body and lets the remote enqueue deduplicate onto the same record; a plain rerun mints a different correlation and is not idempotent. From 28f9d9ade56622048903894cac12127e3cfb1482 Mon Sep 17 00:00:00 2001 From: Quartermaster Date: Sun, 20 Sep 2026 18:15:53 +0000 Subject: [PATCH 04/13] no-mistakes(review): Narrow remote launch overlay to route correction only --- bin/fm-spawn.sh | 5 +---- ...fm-remote-secondmate-lifecycle-e2e.test.sh | 19 ++++++++++++++++--- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 86113f44766..bfe94401a43 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -1751,10 +1751,7 @@ render_secondmate_launch_brief() { # cat < "$TMP_ROOT/remote-launch-route.overlay" +# The delivered brief is the launch-route overlay followed by the standing +# charter verbatim, so the overlay is exactly the lines above that copy. +remote_charter_lines=$(wc -l < "$REMOTE_HOME/data/charter.md") +remote_brief_lines=$(wc -l < "$remote_launch_brief") +remote_overlay_lines=$((remote_brief_lines - remote_charter_lines)) +[ "$remote_overlay_lines" -gt 0 ] || fail "remote launch brief carried no route overlay above the standing charter" +tail -n "$remote_charter_lines" "$remote_launch_brief" | cmp -s - "$REMOTE_HOME/data/charter.md" \ + || fail "remote launch brief did not carry the standing charter verbatim below its route overlay" +head -n "$remote_overlay_lines" "$remote_launch_brief" > "$TMP_ROOT/remote-launch-route.overlay" assert_grep '# Current secondmate launch route' "$TMP_ROOT/remote-launch-route.overlay" "remote launch brief omitted the launch-time route contract" assert_grep "$REMOTE_HOME/state/parent-route/ios.inbox" "$TMP_ROOT/remote-launch-route.overlay" "remote launch brief did not put the host-local steering inbox first" assert_no_grep "$PARENT/state/ios.inbox" "$remote_launch_brief" "remote launch brief retained the inaccessible local steering inbox" +# The steering-inbox receive-and-ack procedure has one owner, so the overlay may +# correct the route but must never become a second copy of charter-owned lines. +grep -v '^[[:space:]]*$' "$TMP_ROOT/remote-launch-route.overlay" > "$TMP_ROOT/remote-launch-route.lines" || true +if grep -Fx -f "$REMOTE_HOME/data/charter.md" "$TMP_ROOT/remote-launch-route.lines" \ + > "$TMP_ROOT/remote-launch-route.restated"; then + fail "remote launch overlay restated charter-owned lines: $(head -n 1 "$TMP_ROOT/remote-launch-route.restated")" +fi assert_grep '--session fm-remote' "$HERDR_LOG" "remote launch did not target the fm-remote session" assert_no_grep '--session default' "$HERDR_LOG" "remote launch targeted the interactive default session" assert_grep 'window=remote:ios' "$PARENT/state/ios.meta" "parent metadata pretended the endpoint was local" From fc8e954f0ff466161b979308079691401854a772 Mon Sep 17 00:00:00 2001 From: Quartermaster Date: Sun, 20 Sep 2026 18:44:46 +0000 Subject: [PATCH 05/13] no-mistakes(document): note remote launch route in steering-inbox owner header --- bin/fm-task-inbox-lib.sh | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/bin/fm-task-inbox-lib.sh b/bin/fm-task-inbox-lib.sh index 6a0287ab78f..2b5b0d03b0f 100644 --- a/bin/fm-task-inbox-lib.sh +++ b/bin/fm-task-inbox-lib.sh @@ -7,9 +7,11 @@ # the self-describing doorbell line, and the watcher's re-ring ladder policy. # bin/fm-send.sh writes and rings locally, the host-local remote steer leg # (bin/fm-remote-secondmate-control.sh cmd_send) writes idempotently and rings -# on the remote host, bin/fm-watch.sh polls and re-rings, and the brief -# scaffold (bin/fm-brief.sh) tells the worker how to read and acknowledge; -# none of them restates the format. +# on the remote host, bin/fm-watch.sh polls and re-rings, the brief +# scaffold (bin/fm-brief.sh) tells the worker how to read and acknowledge, and +# a remote-route secondmate launch (bin/fm-spawn.sh) names only the reachable +# inbox path for that launch, because a seeded charter can carry a path on the +# parent's host; none of them restates the format. # # Design (captain-adopted, data/fm-send-reliability-reframe-s1/report.md): the # payload moves to the filesystem, which is reliable; the terminal carries only From 0d85273f1b22a63937ada9dd4217c7b03948a602 Mon Sep 17 00:00:00 2001 From: Quartermaster Date: Sun, 20 Sep 2026 19:06:25 +0000 Subject: [PATCH 06/13] fix(bin): clone remote homes from owned code roots safely --- bin/fm-remote-home-provision.sh | 9 +++- tests/fm-remote-home-provision.test.sh | 74 ++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100755 tests/fm-remote-home-provision.test.sh diff --git a/bin/fm-remote-home-provision.sh b/bin/fm-remote-home-provision.sh index 8f733d6d3c4..cb99cf6789a 100755 --- a/bin/fm-remote-home-provision.sh +++ b/bin/fm-remote-home-provision.sh @@ -174,7 +174,14 @@ if [ -e "$FM_HOME" ] || [ -L "$FM_HOME" ]; then fi else CREATED_HOME=1 - git clone --quiet -- "$FM_ROOT" "$FM_HOME" || die "could not clone the remote Firstmate home" + env \ + GIT_CONFIG_COUNT=2 \ + GIT_CONFIG_KEY_0=safe.directory \ + GIT_CONFIG_VALUE_0="$FM_ROOT" \ + GIT_CONFIG_KEY_1=safe.directory \ + GIT_CONFIG_VALUE_1="$FM_ROOT/.git" \ + git clone --quiet -- "$FM_ROOT" "$FM_HOME" \ + || die "could not clone the remote Firstmate home" fi for operational_dir in data state config projects; do operational_path="$FM_HOME/$operational_dir" diff --git a/tests/fm-remote-home-provision.test.sh b/tests/fm-remote-home-provision.test.sh new file mode 100755 index 00000000000..143e7b5e84a --- /dev/null +++ b/tests/fm-remote-home-provision.test.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# fm-remote-home-provision.sh remote-root clone behavior. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P) +TMP_ROOT=$(fm_test_tmproot fm-remote-home-provision) +mkdir -p "$TMP_ROOT" +TMP_ROOT=$(cd "$TMP_ROOT" && pwd -P) +cleanup() { rm -rf -- "$TMP_ROOT"; } +trap cleanup EXIT + +encode() { base64 | tr -d '\n'; } + +fakebin=$(fm_fakebin "$TMP_ROOT/fake") +remote_root="$TMP_ROOT/root-owned-firstmate" +remote_home="$TMP_ROOT/remote-home" +mkdir -p "$remote_root/.git" "$remote_root/bin" "$remote_root/data" "$remote_root/state" "$remote_root/config" "$remote_root/projects" +printf 'root fixture\n' > "$remote_root/AGENTS.md" +cat > "$fakebin/git" <<'SH' +#!/usr/bin/env bash +set -eu +log=${FM_FAKE_GIT_LOG:?} +required=${FM_FAKE_SAFE_DIRECTORY:?} +printf 'args=%s\n' "$*" >> "$log" +printf 'count=%s key0=%s value0=%s key1=%s value1=%s\n' \ + "${GIT_CONFIG_COUNT:-}" "${GIT_CONFIG_KEY_0:-}" "${GIT_CONFIG_VALUE_0:-}" \ + "${GIT_CONFIG_KEY_1:-}" "${GIT_CONFIG_VALUE_1:-}" >> "$log" +case "$*" in + clone\ --quiet\ --\ *) + src=${4:?} + dest=${5:?} + if [ "$src/.git" = "$required" ]; then + case "${GIT_CONFIG_VALUE_0:-}|${GIT_CONFIG_VALUE_1:-}" in + *"$required"*) ;; + *) printf 'missing safe.directory for %s\n' "$required" >&2; exit 128 ;; + esac + fi + mkdir -p "$dest/bin" + printf 'clone fixture\n' > "$dest/AGENTS.md" + exit 0 + ;; +esac +printf 'unexpected git call: %s\n' "$*" >&2 +exit 99 +SH +chmod +x "$fakebin/git" + +charter="$TMP_ROOT/charter.md" +printf 'Remote charter\n' > "$charter" +manifest="$TMP_ROOT/manifest" +{ + printf 'schema=fm-remote-home-provision.v1\n' + printf 'id_b64=%s\n' "$(printf '%s' route | encode)" + printf 'charter_b64=%s\n' "$(encode < "$charter")" + printf 'parent_host_b64=%s\n' "$(printf '%s' remote-host | encode)" + printf 'project_count=0\n' +} > "$manifest" + +FM_FAKE_GIT_LOG="$TMP_ROOT/git.log" \ +FM_FAKE_SAFE_DIRECTORY="$remote_root/.git" \ +PATH="$fakebin:$PATH" \ +FM_ROOT_OVERRIDE="$remote_root" \ +FM_HOME="$remote_home" \ + "$ROOT/bin/fm-remote-home-provision.sh" < "$manifest" >/dev/null \ + || fail "remote provisioning did not clone with scoped safe.directory" + +assert_grep "value1=$remote_root/.git" "$TMP_ROOT/git.log" \ + "remote root clone did not authorize the source git directory for this one git invocation" +assert_present "$remote_home/.fm-secondmate-home" \ + "remote provisioning did not publish the identity marker after the guarded clone" +pass "remote provisioning authorizes a root-owned remote code root only for its own home clone" From f27c4aea1d750afc7a0c17487a3edd2a618f6322 Mon Sep 17 00:00:00 2001 From: Quartermaster Date: Sun, 20 Sep 2026 19:40:48 +0000 Subject: [PATCH 07/13] no-mistakes(review): route every remote code-root git read through one guard --- bin/fm-git-code-root-lib.sh | 92 ++++++++++++++ bin/fm-remote-home-provision.sh | 14 +-- bin/fm-remote-secondmate-control.sh | 15 ++- tests/fm-remote-code-root-ownership.test.sh | 130 ++++++++++++++++++++ tests/fm-remote-home-provision.test.sh | 125 +++++++++++-------- 5 files changed, 313 insertions(+), 63 deletions(-) create mode 100644 bin/fm-git-code-root-lib.sh create mode 100755 tests/fm-remote-code-root-ownership.test.sh diff --git a/bin/fm-git-code-root-lib.sh b/bin/fm-git-code-root-lib.sh new file mode 100644 index 00000000000..14efa14eb00 --- /dev/null +++ b/bin/fm-git-code-root-lib.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Read a Firstmate code root this host's agent account does not own. +# +# A remote host commonly keeps its Firstmate code root under a different +# account than the agent user (a shared /opt checkout, a deploy user, root). +# Git refuses such a repository outright - "detected dubious ownership" - and +# the only setting that lifts the refusal is safe.directory. Git reads that one +# through its VERY EARLY config, which ignores the command scope, so neither +# `git -c safe.directory=...` nor the GIT_CONFIG_COUNT/GIT_CONFIG_KEY_n +# environment form reaches the check: both are silently discarded and the +# command still fails. The exception is honored only from a protected scope, so +# this library stages a throwaway global config carrying it and runs the one +# command that has to read the code root against that config. +# +# ONE OWNER of that exception for every remote leg that reads the code root: +# the provisioning clone (bin/fm-remote-home-provision.sh) and the host-local +# sync/update reads, fetches, and code-root update (bin/fm-remote-secondmate- +# control.sh). A caller that reaches the code root without this helper is the +# bug, because a home provisioned from an unowned root could then never follow +# its parent again. +# +# The exception is deliberately minimal: +# - it names ONLY the code root handed in, never a wildcard and never another +# path, so nothing else on the host becomes acceptable to git; +# - it lives in a temporary file for the duration of ONE command, so no +# persistent configuration of this host or account is changed; +# - the account's real global config is included first, so proxy, transport, +# identity, and any other ambient settings git would have read still apply. +# Ownership remains git's decision everywhere else, including inside the home +# itself: nothing here marks the home, its origin, or a project clone safe. + +# fm_git_code_root_config_value : git-config-quoted form of . +fm_git_code_root_config_value() { + local value=$1 + value=${value//\\/\\\\} + value=${value//\"/\\\"} + printf '"%s"' "$value" +} + +# fm_git_code_root_inherited_config: the global config file git would have read +# on its own, or nothing when the account has none. GIT_CONFIG_GLOBAL wins when +# set (including /dev/null, git's own way of saying "no global config"), +# otherwise git prefers ~/.gitconfig over the XDG location. +fm_git_code_root_inherited_config() { + local xdg + if [ -n "${GIT_CONFIG_GLOBAL:-}" ]; then + [ "$GIT_CONFIG_GLOBAL" = /dev/null ] || printf '%s\n' "$GIT_CONFIG_GLOBAL" + return 0 + fi + if [ -f "${HOME:-}/.gitconfig" ]; then + printf '%s\n' "$HOME/.gitconfig" + return 0 + fi + xdg="${XDG_CONFIG_HOME:-${HOME:-}/.config}/git/config" + [ ! -f "$xdg" ] || printf '%s\n' "$xdg" +} + +# fm_git_code_root_stage_config : write the throwaway global +# config that accepts . Both the work tree and its .git directory +# are named because git reports the refusal against whichever one the command +# opens (a clone source resolves to the .git directory, an in-tree read to the +# work tree). +fm_git_code_root_stage_config() { + local root=$1 dest=$2 inherited + case $root in /*) ;; *) return 1 ;; esac + inherited=$(fm_git_code_root_inherited_config) + { + [ -z "$inherited" ] \ + || printf '[include]\n\tpath = %s\n' "$(fm_git_code_root_config_value "$inherited")" + printf '[safe]\n\tdirectory = %s\n\tdirectory = %s\n' \ + "$(fm_git_code_root_config_value "$root")" \ + "$(fm_git_code_root_config_value "$root/.git")" + } > "$dest" +} + +# fm_git_code_root_run [arg...]: run one command allowed +# to read , and return its status. The command is usually git itself; +# a helper script that runs several git commands against the root is passed +# whole so its children inherit the same single exception. +fm_git_code_root_run() { + local root=$1 config rc=0 + shift + [ "$#" -gt 0 ] || return 2 + config=$(mktemp "${TMPDIR:-/tmp}/fm-git-code-root.XXXXXX") || return 1 + if ! fm_git_code_root_stage_config "$root" "$config"; then + rm -f -- "$config" + return 1 + fi + GIT_CONFIG_GLOBAL="$config" "$@" || rc=$? + rm -f -- "$config" + return "$rc" +} diff --git a/bin/fm-remote-home-provision.sh b/bin/fm-remote-home-provision.sh index cb99cf6789a..39afe4a5af4 100755 --- a/bin/fm-remote-home-provision.sh +++ b/bin/fm-remote-home-provision.sh @@ -8,7 +8,9 @@ # base64 parent SSH alias, and one base64 project record per line. Each project # record's origin is the URL the parent resolved and named, so this host clones # from it and re-validates it through bin/fm-project-origin-lib.sh instead of -# trusting the sender. The remote code root is cloned into an absent home, +# trusting the sender. The remote code root is cloned into an absent home +# through bin/fm-git-code-root-lib.sh, which owns the one git exception that +# lets this host read a code root another account owns, # project origins are cloned on this host, the project registry and charter are # published, the durable .fm-secondmate-parent record names this home's route to its parent as # "remote" - read by bin/fm-teardown.sh's cleanup gate so a delegated public @@ -26,6 +28,8 @@ MAX_MANIFEST_BYTES=1048576 # shellcheck source=bin/fm-project-origin-lib.sh . "$SCRIPT_DIR/fm-project-origin-lib.sh" +# shellcheck source=bin/fm-git-code-root-lib.sh +. "$SCRIPT_DIR/fm-git-code-root-lib.sh" die() { printf 'error: %s\n' "$1" >&2; exit 1; } @@ -174,13 +178,7 @@ if [ -e "$FM_HOME" ] || [ -L "$FM_HOME" ]; then fi else CREATED_HOME=1 - env \ - GIT_CONFIG_COUNT=2 \ - GIT_CONFIG_KEY_0=safe.directory \ - GIT_CONFIG_VALUE_0="$FM_ROOT" \ - GIT_CONFIG_KEY_1=safe.directory \ - GIT_CONFIG_VALUE_1="$FM_ROOT/.git" \ - git clone --quiet -- "$FM_ROOT" "$FM_HOME" \ + fm_git_code_root_run "$FM_ROOT" git clone --quiet -- "$FM_ROOT" "$FM_HOME" \ || die "could not clone the remote Firstmate home" fi for operational_dir in data state config projects; do diff --git a/bin/fm-remote-secondmate-control.sh b/bin/fm-remote-secondmate-control.sh index e440001aa38..e9d6dccefed 100755 --- a/bin/fm-remote-secondmate-control.sh +++ b/bin/fm-remote-secondmate-control.sh @@ -32,6 +32,9 @@ # code root from origin. Because this home is a standalone clone, the target # commit is imported here first and the fast-forward itself is the shared one in # bin/fm-ff-lib.sh, so the clean, ancestry, and branch guards have a single owner. +# Every read of that code root - the sync rev-parse, either import fetch, and the +# update leg's own code-root refresh - goes through bin/fm-git-code-root-lib.sh, +# because the host may keep the root under another account than the agent user. # A private parent-route state directory stores only the remote secondmate # agent's endpoint record; the home's own # state/*.meta remains reserved for workers the secondmate supervises. @@ -65,6 +68,8 @@ REMOTE_HERDR_SESSION=fm-remote . "$SCRIPT_DIR/fm-pending-reply-lib.sh" # shellcheck source=bin/fm-task-inbox-lib.sh . "$SCRIPT_DIR/fm-task-inbox-lib.sh" +# shellcheck source=bin/fm-git-code-root-lib.sh +. "$SCRIPT_DIR/fm-git-code-root-lib.sh" die() { printf 'error: %s\n' "$1" >&2; exit 1; } usage() { sed -n '2,24p' "$0" | sed 's/^# \{0,1\}//'; exit 2; } @@ -332,12 +337,12 @@ cmd_observe() { import_home_commit() { # local home=$1 commit=$2 if git -C "$home" cat-file -e "$commit^{commit}" 2>/dev/null; then return 0; fi - if git -C "$home" fetch --quiet --no-tags -- "$FM_ROOT" "$commit" 2>/dev/null \ + if fm_git_code_root_run "$FM_ROOT" git -C "$home" fetch --quiet --no-tags -- "$FM_ROOT" "$commit" 2>/dev/null \ && git -C "$home" cat-file -e "$commit^{commit}" 2>/dev/null; then return 0 fi if git -C "$home" remote get-url origin >/dev/null 2>&1 \ - && git -C "$home" fetch --quiet --no-tags -- origin "$commit" 2>/dev/null \ + && fm_git_code_root_run "$FM_ROOT" git -C "$home" fetch --quiet --no-tags -- origin "$commit" 2>/dev/null \ && git -C "$home" cat-file -e "$commit^{commit}" 2>/dev/null; then return 0 fi @@ -353,7 +358,8 @@ cmd_sync() { case "$commit" in *[!0-9a-f]*) die "sync target must be a full 40-character commit id" ;; esac [ "${#commit}" -eq 40 ] || die "sync target must be a full 40-character commit id" else - commit=$(git -C "$FM_ROOT" rev-parse HEAD 2>/dev/null) || die "remote code root HEAD is unreadable" + commit=$(fm_git_code_root_run "$FM_ROOT" git -C "$FM_ROOT" rev-parse HEAD 2>/dev/null) \ + || die "remote code root HEAD is unreadable" fi import_home_commit "$TARGET_HOME" "$commit" \ || die "remote home could not import $commit from this host's Firstmate copy or the home's origin; run /updatefirstmate to refresh this host's copy, or push that commit first" @@ -378,7 +384,8 @@ cmd_update() { local id=$1 update_out root_status validate_id "$id" validate_home "$id" - if ! update_out=$(FM_HOME="$FM_ROOT" FM_ROOT_OVERRIDE="$FM_ROOT" \ + if ! update_out=$(fm_git_code_root_run "$FM_ROOT" \ + env FM_HOME="$FM_ROOT" FM_ROOT_OVERRIDE="$FM_ROOT" \ "$SCRIPT_DIR/fm-update.sh" 2>&1); then [ -z "$update_out" ] || printf '%s\n' "$update_out" >&2 die "remote code root update failed" diff --git a/tests/fm-remote-code-root-ownership.test.sh b/tests/fm-remote-code-root-ownership.test.sh new file mode 100755 index 00000000000..5e76e7b745d --- /dev/null +++ b/tests/fm-remote-code-root-ownership.test.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# Host-local remote lifecycle reads of a code root this account does not own. +# +# A remote home is provisioned from a code root the host often keeps under +# another account, so every later leg that reads that root - the sync fetch of +# the parent's commit and the code-root HEAD read that /updatefirstmate's sync +# targets - has to carry the same exception the clone did, or the home can +# never follow its parent again. The refusal here is git's real one +# (GIT_TEST_ASSUME_DIFFERENT_OWNER, git's own knob) and the fixture authorizes +# only the repositories this account really owns, so nothing but a genuine +# safe.directory exception in a scope git honors can make these pass. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P) +TMP_ROOT=$(fm_test_tmproot fm-remote-code-root-ownership) +mkdir -p "$TMP_ROOT" +TMP_ROOT=$(cd "$TMP_ROOT" && pwd -P) +cleanup() { rm -rf -- "$TMP_ROOT"; } +trap cleanup EXIT + +CODE_ROOT="$TMP_ROOT/unowned-code-root" +REMOTE_HOME="$TMP_ROOT/remote-home" +OWNED_CONFIG="$TMP_ROOT/owned.gitconfig" +ID=route + +mkdir -p "$CODE_ROOT/bin" +printf 'remote code root fixture\n' > "$CODE_ROOT/AGENTS.md" +printf '#!/usr/bin/env bash\nexit 0\n' > "$CODE_ROOT/bin/fm-fixture.sh" +chmod +x "$CODE_ROOT/bin/fm-fixture.sh" +printf 'projects/\nstate/\ndata/\nconfig/\n.fm-secondmate-home\n.fm-secondmate-parent\n' \ + > "$CODE_ROOT/.gitignore" +git -C "$CODE_ROOT" init -q -b main +git -C "$CODE_ROOT" config user.email test@example.com +git -C "$CODE_ROOT" config user.name Test +git -C "$CODE_ROOT" add . +git -C "$CODE_ROOT" commit -qm 'code root fixture' +FIRST_COMMIT=$(git -C "$CODE_ROOT" rev-parse HEAD) + +git clone --quiet -- "$CODE_ROOT" "$REMOTE_HOME" || fail "cannot stage the remote home fixture" +mkdir -p "$REMOTE_HOME/data" "$REMOTE_HOME/state" "$REMOTE_HOME/config" "$REMOTE_HOME/projects" +printf '%s\n' "$ID" > "$REMOTE_HOME/.fm-secondmate-home" +{ + printf 'schema=fm-secondmate-parent.v1\n' + printf 'route=remote\n' +} > "$REMOTE_HOME/.fm-secondmate-parent" + +# The code root then advances past the commit the home holds, which is the only +# thing a later sync can import. +printf 'second revision\n' >> "$CODE_ROOT/AGENTS.md" +git -C "$CODE_ROOT" commit -qam 'code root advance' +SECOND_COMMIT=$(git -C "$CODE_ROOT" rev-parse HEAD) + +# Everything the agent account owns on this host, and nothing else: the code +# root is deliberately absent. An ambient setting is included to prove the +# guarded invocation still reads the account's real global config. +{ + printf '[fm]\n\tcoderootfixture = inherited\n' + printf '[safe]\n\tdirectory = %s\n\tdirectory = %s\n' "$REMOTE_HOME" "$REMOTE_HOME/.git" +} > "$OWNED_CONFIG" + +as_foreign_root() { + env GIT_TEST_ASSUME_DIFFERENT_OWNER=1 \ + GIT_CONFIG_SYSTEM=/dev/null \ + GIT_CONFIG_GLOBAL="$OWNED_CONFIG" \ + "$@" +} + +control() { + as_foreign_root env FM_ROOT_OVERRIDE="$CODE_ROOT" FM_HOME="$REMOTE_HOME" \ + "$ROOT/bin/fm-remote-secondmate-control.sh" "$@" +} + +if as_foreign_root git -C "$CODE_ROOT" rev-parse HEAD >/dev/null 2>&1; then + echo "skip: this git does not simulate a differently-owned repository" + exit 0 +fi +as_foreign_root git -C "$REMOTE_HOME" rev-parse HEAD >/dev/null 2>&1 \ + || fail "the fixture wrongly refuses the home this account owns" + +# sync : the commit exists only in the unowned code root, +# so import_home_commit has to fetch it from there. +SYNC_OUT=$(control sync "$ID" "$SECOND_COMMIT" 2>&1) \ + || fail "sync could not import the parent's commit from an unowned code root: $SYNC_OUT" +assert_contains "$SYNC_OUT" "synced: $SECOND_COMMIT" \ + "sync did not fast-forward the home to the parent's commit" +assert_equals "$SECOND_COMMIT" "$(as_foreign_root git -C "$REMOTE_HOME" rev-parse HEAD)" \ + "the home did not actually advance to the parent's commit" + +# sync with no target: the target is the unowned code root's own HEAD, so +# the read that resolves it has to carry the exception too. +git -C "$REMOTE_HOME" reset --quiet --hard "$FIRST_COMMIT" \ + || fail "cannot rewind the home fixture" +printf 'third revision\n' >> "$CODE_ROOT/AGENTS.md" +git -C "$CODE_ROOT" commit -qam 'code root advance again' +THIRD_COMMIT=$(git -C "$CODE_ROOT" rev-parse HEAD) + +HEAD_SYNC_OUT=$(control sync "$ID" 2>&1) \ + || fail "sync could not read the unowned code root's HEAD: $HEAD_SYNC_OUT" +assert_contains "$HEAD_SYNC_OUT" "synced: $THIRD_COMMIT" \ + "sync did not follow this host's code-root HEAD" +assert_equals "$THIRD_COMMIT" "$(as_foreign_root git -C "$REMOTE_HOME" rev-parse HEAD)" \ + "the home did not actually advance to the code root's HEAD" + +# The exception names the code root only, lives for one command, and leaves the +# account's own global config in force while it does. +# shellcheck disable=SC2016 # The inner shell expands these, not this one. +INHERITED=$(as_foreign_root bash -c ' + . "$1/bin/fm-git-code-root-lib.sh" + fm_git_code_root_run "$2" git config --get fm.coderootfixture +' bash "$ROOT" "$CODE_ROOT" 2>&1) \ + || fail "the guarded invocation dropped the account's global git config: $INHERITED" +assert_equals inherited "$INHERITED" \ + "the guarded invocation did not keep the account's own global git config" + +SIBLING="$TMP_ROOT/sibling-root" +git clone --quiet -- "$CODE_ROOT" "$SIBLING" || fail "cannot stage the sibling repository" +# shellcheck disable=SC2016 # The inner shell expands these, not this one. +as_foreign_root bash -c ' + . "$1/bin/fm-git-code-root-lib.sh" + fm_git_code_root_run "$2" git -C "$3" rev-parse HEAD +' bash "$ROOT" "$CODE_ROOT" "$SIBLING" >/dev/null 2>&1 \ + && fail "the code-root exception wrongly authorized an unrelated repository" + +as_foreign_root git -C "$CODE_ROOT" rev-parse HEAD >/dev/null 2>&1 \ + && fail "the lifecycle left a durable ownership exception for the code root" + +pass "remote sync reads and imports from a code root owned by another account" diff --git a/tests/fm-remote-home-provision.test.sh b/tests/fm-remote-home-provision.test.sh index 143e7b5e84a..f34d73f70e3 100755 --- a/tests/fm-remote-home-provision.test.sh +++ b/tests/fm-remote-home-provision.test.sh @@ -1,5 +1,13 @@ #!/usr/bin/env bash -# fm-remote-home-provision.sh remote-root clone behavior. +# fm-remote-home-provision.sh clones a code root this account does not own. +# +# The refusal is git's real one. GIT_TEST_ASSUME_DIFFERENT_OWNER is git's own +# knob for "every repository looks like someone else's" (its t0033 uses it), and +# the fixture hands git a global config that names every fixture repository this +# account really owns EXCEPT the code root. So git accepts the home and refuses +# the code root, exactly as on a host whose Firstmate checkout lives under +# another account, and the only thing that can lift that refusal here is a +# genuine safe.directory exception in a scope git honors. set -u # shellcheck source=tests/lib.sh @@ -14,61 +22,76 @@ trap cleanup EXIT encode() { base64 | tr -d '\n'; } -fakebin=$(fm_fakebin "$TMP_ROOT/fake") -remote_root="$TMP_ROOT/root-owned-firstmate" -remote_home="$TMP_ROOT/remote-home" -mkdir -p "$remote_root/.git" "$remote_root/bin" "$remote_root/data" "$remote_root/state" "$remote_root/config" "$remote_root/projects" -printf 'root fixture\n' > "$remote_root/AGENTS.md" -cat > "$fakebin/git" <<'SH' -#!/usr/bin/env bash -set -eu -log=${FM_FAKE_GIT_LOG:?} -required=${FM_FAKE_SAFE_DIRECTORY:?} -printf 'args=%s\n' "$*" >> "$log" -printf 'count=%s key0=%s value0=%s key1=%s value1=%s\n' \ - "${GIT_CONFIG_COUNT:-}" "${GIT_CONFIG_KEY_0:-}" "${GIT_CONFIG_VALUE_0:-}" \ - "${GIT_CONFIG_KEY_1:-}" "${GIT_CONFIG_VALUE_1:-}" >> "$log" -case "$*" in - clone\ --quiet\ --\ *) - src=${4:?} - dest=${5:?} - if [ "$src/.git" = "$required" ]; then - case "${GIT_CONFIG_VALUE_0:-}|${GIT_CONFIG_VALUE_1:-}" in - *"$required"*) ;; - *) printf 'missing safe.directory for %s\n' "$required" >&2; exit 128 ;; - esac - fi - mkdir -p "$dest/bin" - printf 'clone fixture\n' > "$dest/AGENTS.md" - exit 0 - ;; -esac -printf 'unexpected git call: %s\n' "$*" >&2 -exit 99 -SH -chmod +x "$fakebin/git" +CODE_ROOT="$TMP_ROOT/unowned-code-root" +REMOTE_HOME="$TMP_ROOT/remote-home" +OWNED_CONFIG="$TMP_ROOT/owned.gitconfig" + +mkdir -p "$CODE_ROOT/bin" +printf 'remote code root fixture\n' > "$CODE_ROOT/AGENTS.md" +printf '#!/usr/bin/env bash\nexit 0\n' > "$CODE_ROOT/bin/fm-fixture.sh" +chmod +x "$CODE_ROOT/bin/fm-fixture.sh" +printf 'projects/\nstate/\ndata/\nconfig/\n.fm-secondmate-home\n.fm-secondmate-parent\n' \ + > "$CODE_ROOT/.gitignore" +git -C "$CODE_ROOT" init -q -b main +git -C "$CODE_ROOT" config user.email test@example.com +git -C "$CODE_ROOT" config user.name Test +git -C "$CODE_ROOT" add . +git -C "$CODE_ROOT" commit -qm 'code root fixture' +CODE_ROOT_HEAD=$(git -C "$CODE_ROOT" rev-parse HEAD) -charter="$TMP_ROOT/charter.md" -printf 'Remote charter\n' > "$charter" -manifest="$TMP_ROOT/manifest" +# Everything the agent account owns on this host, and nothing else. The code +# root is deliberately absent. +{ + printf '[safe]\n\tdirectory = %s\n\tdirectory = %s\n' "$REMOTE_HOME" "$REMOTE_HOME/.git" +} > "$OWNED_CONFIG" + +# GIT_CONFIG_SYSTEM=/dev/null so a host-wide safe.directory cannot defeat the +# simulation and report a false pass. +as_foreign_root() { + env GIT_TEST_ASSUME_DIFFERENT_OWNER=1 \ + GIT_CONFIG_SYSTEM=/dev/null \ + GIT_CONFIG_GLOBAL="$OWNED_CONFIG" \ + "$@" +} + +if as_foreign_root git clone --quiet -- "$CODE_ROOT" "$TMP_ROOT/unguarded" 2>/dev/null; then + echo "skip: this git does not simulate a differently-owned repository" + exit 0 +fi + +CHARTER="$TMP_ROOT/charter.md" +printf 'Remote charter\n' > "$CHARTER" +MANIFEST="$TMP_ROOT/manifest" { printf 'schema=fm-remote-home-provision.v1\n' printf 'id_b64=%s\n' "$(printf '%s' route | encode)" - printf 'charter_b64=%s\n' "$(encode < "$charter")" + printf 'charter_b64=%s\n' "$(encode < "$CHARTER")" printf 'parent_host_b64=%s\n' "$(printf '%s' remote-host | encode)" printf 'project_count=0\n' -} > "$manifest" +} > "$MANIFEST" + +PROVISION_OUT=$(as_foreign_root \ + env FM_ROOT_OVERRIDE="$CODE_ROOT" FM_HOME="$REMOTE_HOME" \ + "$ROOT/bin/fm-remote-home-provision.sh" < "$MANIFEST" 2>&1) \ + || fail "provisioning could not clone a code root owned by another account: $PROVISION_OUT" + +assert_contains "$PROVISION_OUT" "provisioned: $REMOTE_HOME" \ + "provisioning did not report the published home" +assert_present "$REMOTE_HOME/.fm-secondmate-home" \ + "provisioning did not publish the identity marker" +assert_equals route "$(cat "$REMOTE_HOME/.fm-secondmate-home")" \ + "provisioned home carries the wrong secondmate id" +assert_present "$REMOTE_HOME/data/charter.md" \ + "provisioning did not publish the charter into the cloned home" + +HOME_HEAD=$(as_foreign_root git -C "$REMOTE_HOME" rev-parse HEAD) \ + || fail "the provisioned home is not a readable clone" +assert_equals "$CODE_ROOT_HEAD" "$HOME_HEAD" \ + "the provisioned home does not carry the code root's commit" -FM_FAKE_GIT_LOG="$TMP_ROOT/git.log" \ -FM_FAKE_SAFE_DIRECTORY="$remote_root/.git" \ -PATH="$fakebin:$PATH" \ -FM_ROOT_OVERRIDE="$remote_root" \ -FM_HOME="$remote_home" \ - "$ROOT/bin/fm-remote-home-provision.sh" < "$manifest" >/dev/null \ - || fail "remote provisioning did not clone with scoped safe.directory" +# The exception is spent on that one clone: nothing persistent was written, so +# the code root is still refused afterwards. +as_foreign_root git -C "$CODE_ROOT" rev-parse HEAD >/dev/null 2>&1 \ + && fail "provisioning left a durable ownership exception for the code root" -assert_grep "value1=$remote_root/.git" "$TMP_ROOT/git.log" \ - "remote root clone did not authorize the source git directory for this one git invocation" -assert_present "$remote_home/.fm-secondmate-home" \ - "remote provisioning did not publish the identity marker after the guarded clone" -pass "remote provisioning authorizes a root-owned remote code root only for its own home clone" +pass "remote provisioning clones a code root owned by another account without keeping the exception" From bccf99e582f76e2d880495625ad4fde0cd6cae28 Mon Sep 17 00:00:00 2001 From: Quartermaster Date: Sun, 20 Sep 2026 19:52:25 +0000 Subject: [PATCH 08/13] no-mistakes(review): inherit every global git config in code-root guard --- bin/fm-git-code-root-lib.sh | 28 ++++++++++--------- tests/fm-remote-code-root-ownership.test.sh | 30 +++++++++++++++++++++ 2 files changed, 45 insertions(+), 13 deletions(-) diff --git a/bin/fm-git-code-root-lib.sh b/bin/fm-git-code-root-lib.sh index 14efa14eb00..2ac85aa9d41 100644 --- a/bin/fm-git-code-root-lib.sh +++ b/bin/fm-git-code-root-lib.sh @@ -24,8 +24,10 @@ # path, so nothing else on the host becomes acceptable to git; # - it lives in a temporary file for the duration of ONE command, so no # persistent configuration of this host or account is changed; -# - the account's real global config is included first, so proxy, transport, -# identity, and any other ambient settings git would have read still apply. +# - every global config file the account really has is included first, in +# git's own order, so proxy, transport, identity, and any other ambient +# settings git would have read still apply, and still resolve to the same +# value they would have without the guard. # Ownership remains git's decision everywhere else, including inside the home # itself: nothing here marks the home, its origin, or a project clone safe. @@ -37,22 +39,21 @@ fm_git_code_root_config_value() { printf '"%s"' "$value" } -# fm_git_code_root_inherited_config: the global config file git would have read -# on its own, or nothing when the account has none. GIT_CONFIG_GLOBAL wins when -# set (including /dev/null, git's own way of saying "no global config"), -# otherwise git prefers ~/.gitconfig over the XDG location. +# fm_git_code_root_inherited_config: every global config file git would have +# read on its own, one per line and in git's own order, or nothing when the +# account has none. GIT_CONFIG_GLOBAL replaces the whole layer when set +# (including /dev/null, git's own way of saying "no global config"); otherwise +# git reads BOTH the XDG file and ~/.gitconfig, in that order, so a later value +# in ~/.gitconfig keeps overriding the XDG one exactly as it would unguarded. fm_git_code_root_inherited_config() { local xdg if [ -n "${GIT_CONFIG_GLOBAL:-}" ]; then [ "$GIT_CONFIG_GLOBAL" = /dev/null ] || printf '%s\n' "$GIT_CONFIG_GLOBAL" return 0 fi - if [ -f "${HOME:-}/.gitconfig" ]; then - printf '%s\n' "$HOME/.gitconfig" - return 0 - fi xdg="${XDG_CONFIG_HOME:-${HOME:-}/.config}/git/config" [ ! -f "$xdg" ] || printf '%s\n' "$xdg" + [ ! -f "${HOME:-}/.gitconfig" ] || printf '%s\n' "$HOME/.gitconfig" } # fm_git_code_root_stage_config : write the throwaway global @@ -63,10 +64,11 @@ fm_git_code_root_inherited_config() { fm_git_code_root_stage_config() { local root=$1 dest=$2 inherited case $root in /*) ;; *) return 1 ;; esac - inherited=$(fm_git_code_root_inherited_config) { - [ -z "$inherited" ] \ - || printf '[include]\n\tpath = %s\n' "$(fm_git_code_root_config_value "$inherited")" + while IFS= read -r inherited; do + [ -n "$inherited" ] || continue + printf '[include]\n\tpath = %s\n' "$(fm_git_code_root_config_value "$inherited")" + done < <(fm_git_code_root_inherited_config) printf '[safe]\n\tdirectory = %s\n\tdirectory = %s\n' \ "$(fm_git_code_root_config_value "$root")" \ "$(fm_git_code_root_config_value "$root/.git")" diff --git a/tests/fm-remote-code-root-ownership.test.sh b/tests/fm-remote-code-root-ownership.test.sh index 5e76e7b745d..c12e21d7963 100755 --- a/tests/fm-remote-code-root-ownership.test.sh +++ b/tests/fm-remote-code-root-ownership.test.sh @@ -115,6 +115,36 @@ INHERITED=$(as_foreign_root bash -c ' assert_equals inherited "$INHERITED" \ "the guarded invocation did not keep the account's own global git config" +# With no GIT_CONFIG_GLOBAL of its own, the account's global layer is BOTH the +# XDG file and ~/.gitconfig. A guarded command has to resolve every setting the +# unguarded one would - a proxy, a credential helper, an insteadOf rewrite - +# from either file, and keep ~/.gitconfig winning where the two disagree. +AGENT_HOME="$TMP_ROOT/agent-home" +mkdir -p "$AGENT_HOME/.config/git" +printf '[fm]\n\txdgonly = fromxdg\n\tlayered = fromxdg\n' > "$AGENT_HOME/.config/git/config" +printf '[fm]\n\tuseronly = fromuser\n\tlayered = fromuser\n' > "$AGENT_HOME/.gitconfig" + +with_agent_home() { # [guarded] + # shellcheck disable=SC2016 # The inner shell expands these, not this one. + env -u GIT_CONFIG_GLOBAL -u XDG_CONFIG_HOME \ + HOME="$AGENT_HOME" GIT_CONFIG_SYSTEM=/dev/null GIT_CONFIG_NOSYSTEM=1 \ + bash -c ' + if [ -n "${3:-}" ]; then + . "$1/bin/fm-git-code-root-lib.sh" + fm_git_code_root_run "$2" git config --get "$4" + else + git config --get "$4" + fi + ' bash "$ROOT" "$CODE_ROOT" "${2:-}" "$1" 2>/dev/null +} + +for FM_KEY in fm.xdgonly fm.useronly fm.layered; do + UNGUARDED=$(with_agent_home "$FM_KEY") + [ -n "$UNGUARDED" ] || fail "the global-config fixture does not resolve $FM_KEY unguarded" + assert_equals "$UNGUARDED" "$(with_agent_home "$FM_KEY" guarded)" \ + "the guarded invocation resolved $FM_KEY differently than git would have" +done + SIBLING="$TMP_ROOT/sibling-root" git clone --quiet -- "$CODE_ROOT" "$SIBLING" || fail "cannot stage the sibling repository" # shellcheck disable=SC2016 # The inner shell expands these, not this one. From 09714a44f1260972fbf314f436adbb7bd849629e Mon Sep 17 00:00:00 2001 From: Quartermaster Date: Sun, 20 Sep 2026 20:04:41 +0000 Subject: [PATCH 09/13] no-mistakes(review): cover remote update leg under ownership-refusal simulation --- tests/fm-remote-code-root-ownership.test.sh | 35 +++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/tests/fm-remote-code-root-ownership.test.sh b/tests/fm-remote-code-root-ownership.test.sh index c12e21d7963..5352dc934bb 100755 --- a/tests/fm-remote-code-root-ownership.test.sh +++ b/tests/fm-remote-code-root-ownership.test.sh @@ -22,6 +22,7 @@ cleanup() { rm -rf -- "$TMP_ROOT"; } trap cleanup EXIT CODE_ROOT="$TMP_ROOT/unowned-code-root" +CODE_ROOT_ORIGIN="$TMP_ROOT/code-root-origin.git" REMOTE_HOME="$TMP_ROOT/remote-home" OWNED_CONFIG="$TMP_ROOT/owned.gitconfig" ID=route @@ -54,11 +55,14 @@ git -C "$CODE_ROOT" commit -qam 'code root advance' SECOND_COMMIT=$(git -C "$CODE_ROOT" rev-parse HEAD) # Everything the agent account owns on this host, and nothing else: the code -# root is deliberately absent. An ambient setting is included to prove the +# root is deliberately absent. The home and the code root's own origin mirror +# are the account's, standing in for the real remote where origin is a URL no +# ownership check applies to. An ambient setting is included to prove the # guarded invocation still reads the account's real global config. { printf '[fm]\n\tcoderootfixture = inherited\n' - printf '[safe]\n\tdirectory = %s\n\tdirectory = %s\n' "$REMOTE_HOME" "$REMOTE_HOME/.git" + printf '[safe]\n\tdirectory = %s\n\tdirectory = %s\n\tdirectory = %s\n' \ + "$REMOTE_HOME" "$REMOTE_HOME/.git" "$CODE_ROOT_ORIGIN" } > "$OWNED_CONFIG" as_foreign_root() { @@ -115,6 +119,33 @@ INHERITED=$(as_foreign_root bash -c ' assert_equals inherited "$INHERITED" \ "the guarded invocation did not keep the account's own global git config" +# update : the whole code-root refresh runs against the unowned root - the +# origin fetch and the fast-forward of the root itself - before the home is +# synced to whatever that left behind. +git -C "$CODE_ROOT" remote add origin "$CODE_ROOT_ORIGIN" \ + || fail "cannot give the code-root fixture an origin" +git init -q --bare "$CODE_ROOT_ORIGIN" || fail "cannot stage the code-root origin" +git -C "$CODE_ROOT" push -q origin main || fail "cannot publish the code-root fixture" +git --git-dir="$CODE_ROOT_ORIGIN" symbolic-ref HEAD refs/heads/main + +PUBLISHER="$TMP_ROOT/publisher" +git clone --quiet -- "$CODE_ROOT_ORIGIN" "$PUBLISHER" || fail "cannot stage the publisher fixture" +git -C "$PUBLISHER" config user.email test@example.com +git -C "$PUBLISHER" config user.name Test +printf 'origin revision\n' >> "$PUBLISHER/AGENTS.md" +git -C "$PUBLISHER" commit -qam 'origin advance' +git -C "$PUBLISHER" push -q origin main +ORIGIN_TIP=$(git -C "$PUBLISHER" rev-parse HEAD) + +UPDATE_OUT=$(control update "$ID" 2>&1) \ + || fail "update could not refresh an unowned code root: $UPDATE_OUT" +assert_equals "$ORIGIN_TIP" "$(git -C "$CODE_ROOT" rev-parse HEAD)" \ + "update did not fast-forward the unowned code root from its origin" +assert_contains "$UPDATE_OUT" "synced: $ORIGIN_TIP" \ + "update did not report the home following the refreshed code root" +assert_equals "$ORIGIN_TIP" "$(as_foreign_root git -C "$REMOTE_HOME" rev-parse HEAD)" \ + "the home did not follow the refreshed code root" + # With no GIT_CONFIG_GLOBAL of its own, the account's global layer is BOTH the # XDG file and ~/.gitconfig. A guarded command has to resolve every setting the # unguarded one would - a proxy, a credential helper, an insteadOf rewrite - From fc09932bc19f677f88c024de8eef748641351dc3 Mon Sep 17 00:00:00 2001 From: Quartermaster Date: Sun, 20 Sep 2026 20:34:22 +0000 Subject: [PATCH 10/13] no-mistakes(document): document remote code-root ownership guard in remote-secondmates --- docs/remote-secondmates.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/remote-secondmates.md b/docs/remote-secondmates.md index 022a54b928f..cac671ab546 100644 --- a/docs/remote-secondmates.md +++ b/docs/remote-secondmates.md @@ -18,6 +18,7 @@ Do not enable agent forwarding for Firstmate. `fm-on.sh` also disables agent forwarding, forwarding setup, and configured `SendEnv` patterns on every call, and arms bounded SSH dead-peer detection so a vanished host (a reboot, a dropped link) fails within a bounded window instead of hanging indefinitely; its [script header](../bin/fm-on.sh) owns the keepalive defaults and environment overrides. Clone Firstmate on the remote host at an absolute code-root path. +That code root may belong to an account other than the one the second mate runs as - a shared checkout, a deploy user - because every lifecycle read of it, the home clone at provisioning time and the later sync and update reads alike, carries a temporary git ownership exception naming only that code root; [`bin/fm-git-code-root-lib.sh`](../bin/fm-git-code-root-lib.sh) owns that exception, and nothing else on the host, the remote home included, becomes exempt from git's own ownership check. Expose that clone's fixed entrypoint on the account's non-interactive SSH `PATH`, for example: ```sh @@ -277,6 +278,7 @@ bin/fm-test-run.sh tests/fm-remote-doctor.test.sh bin/fm-test-run.sh tests/fm-remote-herdr-guard.test.sh bin/fm-test-run.sh tests/fm-project-origin.test.sh bin/fm-test-run.sh tests/fm-secondmate-sync.test.sh +bin/fm-test-run.sh tests/fm-remote-code-root-ownership.test.sh bin/fm-test-run.sh tests/fm-remote-reply.test.sh bin/fm-test-run.sh tests/fm-remote-backlog-handoff.test.sh bin/fm-test-run.sh tests/fm-remote-secondmate-lifecycle-e2e.test.sh From 0bbef5c09926d2fa3eafdb5982df866e901a1c3b Mon Sep 17 00:00:00 2001 From: Quartermaster Date: Sun, 20 Sep 2026 21:01:06 +0000 Subject: [PATCH 11/13] no-mistakes(ci): Fixed the "Behavior portable serial 7" failure. The failing test was tests/fm-remote-transport-lanes.test.sh (family=secondmate, the 1 failure of 2 in that family); it died with ".../remote-root/bin/fm-git-code-root-lib.sh: No such file or directory" at the control-state case. Root cause: the fixture builds a synthetic remote code root by copying an explicit list of bin/ scripts (tests/fm-remote-transport-lanes.test.sh:50). This branch added `. "$SCRIPT_DIR/fm-git-code-root-lib.sh"` to bin/fm-remote-secondmate-control.sh:72, but the new library was not added to that list, so the control script aborted at source time inside the fixture root. Fix (one line): stage bin/fm-git-code-root-lib.sh with the other libraries the fixture copies (tests/fm-remote-transport-lanes.test.sh:59). No production code changed. Verification run locally: fm-remote-transport-lanes reproduced the exact CI failure before the edit and reports ALL TESTS PASSED after; fm-remote-job (the other secondmate test in that shard), fm-remote-home-provision, fm-secondmate-sync, fm-remote-secondmate-parent-binding and fm-remote-code-root-ownership all pass; bin/fm-lint.sh on the changed file is clean. bin/fm-remote-home-provision.sh is the only other consumer of the new library and no test stages it into a synthetic bin tree, so no other fixture needed the same entry --- tests/fm-remote-transport-lanes.test.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/fm-remote-transport-lanes.test.sh b/tests/fm-remote-transport-lanes.test.sh index cbdf1356092..76c0e367088 100755 --- a/tests/fm-remote-transport-lanes.test.sh +++ b/tests/fm-remote-transport-lanes.test.sh @@ -56,6 +56,7 @@ cp "$ROOT/bin/fm-remote-job-lib.sh" "$ROOT/bin/fm-remote-job-worker.sh" \ "$ROOT/bin/fm-composer-lib.sh" "$ROOT/bin/fm-cursor-lib.sh" \ "$ROOT/bin/fm-classify-lib.sh" "$ROOT/bin/fm-timeout-lib.sh" \ "$ROOT/bin/fm-ff-lib.sh" "$ROOT/bin/fm-secondmate-registry-lib.sh" \ + "$ROOT/bin/fm-git-code-root-lib.sh" \ "$REMOTE_ROOT/bin/" mkdir -p "$REMOTE_ROOT/bin/backends" cp "$ROOT/bin/backends/herdr.sh" "$REMOTE_ROOT/bin/backends/herdr.sh" From cf8edff616e11ea198bd1321fc9cdad5a1a4928f Mon Sep 17 00:00:00 2001 From: Quartermaster Date: Sun, 20 Sep 2026 23:39:05 +0000 Subject: [PATCH 12/13] no-mistakes(review): Drop launch overlay and code-root guard; cover charter reseed --- bin/fm-git-code-root-lib.sh | 94 --------- bin/fm-remote-home-provision.sh | 9 +- bin/fm-remote-secondmate-control.sh | 15 +- bin/fm-spawn.sh | 49 +---- bin/fm-task-inbox-lib.sh | 8 +- docs/remote-secondmates.md | 4 +- tests/fm-remote-code-root-ownership.test.sh | 191 ------------------ tests/fm-remote-home-provision.test.sh | 97 --------- ...fm-remote-secondmate-lifecycle-e2e.test.sh | 36 ++-- tests/fm-remote-transport-lanes.test.sh | 1 - tests/fm-spawn-dispatch-profile.test.sh | 4 +- 11 files changed, 29 insertions(+), 479 deletions(-) delete mode 100644 bin/fm-git-code-root-lib.sh delete mode 100755 tests/fm-remote-code-root-ownership.test.sh delete mode 100755 tests/fm-remote-home-provision.test.sh diff --git a/bin/fm-git-code-root-lib.sh b/bin/fm-git-code-root-lib.sh deleted file mode 100644 index 2ac85aa9d41..00000000000 --- a/bin/fm-git-code-root-lib.sh +++ /dev/null @@ -1,94 +0,0 @@ -#!/usr/bin/env bash -# Read a Firstmate code root this host's agent account does not own. -# -# A remote host commonly keeps its Firstmate code root under a different -# account than the agent user (a shared /opt checkout, a deploy user, root). -# Git refuses such a repository outright - "detected dubious ownership" - and -# the only setting that lifts the refusal is safe.directory. Git reads that one -# through its VERY EARLY config, which ignores the command scope, so neither -# `git -c safe.directory=...` nor the GIT_CONFIG_COUNT/GIT_CONFIG_KEY_n -# environment form reaches the check: both are silently discarded and the -# command still fails. The exception is honored only from a protected scope, so -# this library stages a throwaway global config carrying it and runs the one -# command that has to read the code root against that config. -# -# ONE OWNER of that exception for every remote leg that reads the code root: -# the provisioning clone (bin/fm-remote-home-provision.sh) and the host-local -# sync/update reads, fetches, and code-root update (bin/fm-remote-secondmate- -# control.sh). A caller that reaches the code root without this helper is the -# bug, because a home provisioned from an unowned root could then never follow -# its parent again. -# -# The exception is deliberately minimal: -# - it names ONLY the code root handed in, never a wildcard and never another -# path, so nothing else on the host becomes acceptable to git; -# - it lives in a temporary file for the duration of ONE command, so no -# persistent configuration of this host or account is changed; -# - every global config file the account really has is included first, in -# git's own order, so proxy, transport, identity, and any other ambient -# settings git would have read still apply, and still resolve to the same -# value they would have without the guard. -# Ownership remains git's decision everywhere else, including inside the home -# itself: nothing here marks the home, its origin, or a project clone safe. - -# fm_git_code_root_config_value : git-config-quoted form of . -fm_git_code_root_config_value() { - local value=$1 - value=${value//\\/\\\\} - value=${value//\"/\\\"} - printf '"%s"' "$value" -} - -# fm_git_code_root_inherited_config: every global config file git would have -# read on its own, one per line and in git's own order, or nothing when the -# account has none. GIT_CONFIG_GLOBAL replaces the whole layer when set -# (including /dev/null, git's own way of saying "no global config"); otherwise -# git reads BOTH the XDG file and ~/.gitconfig, in that order, so a later value -# in ~/.gitconfig keeps overriding the XDG one exactly as it would unguarded. -fm_git_code_root_inherited_config() { - local xdg - if [ -n "${GIT_CONFIG_GLOBAL:-}" ]; then - [ "$GIT_CONFIG_GLOBAL" = /dev/null ] || printf '%s\n' "$GIT_CONFIG_GLOBAL" - return 0 - fi - xdg="${XDG_CONFIG_HOME:-${HOME:-}/.config}/git/config" - [ ! -f "$xdg" ] || printf '%s\n' "$xdg" - [ ! -f "${HOME:-}/.gitconfig" ] || printf '%s\n' "$HOME/.gitconfig" -} - -# fm_git_code_root_stage_config : write the throwaway global -# config that accepts . Both the work tree and its .git directory -# are named because git reports the refusal against whichever one the command -# opens (a clone source resolves to the .git directory, an in-tree read to the -# work tree). -fm_git_code_root_stage_config() { - local root=$1 dest=$2 inherited - case $root in /*) ;; *) return 1 ;; esac - { - while IFS= read -r inherited; do - [ -n "$inherited" ] || continue - printf '[include]\n\tpath = %s\n' "$(fm_git_code_root_config_value "$inherited")" - done < <(fm_git_code_root_inherited_config) - printf '[safe]\n\tdirectory = %s\n\tdirectory = %s\n' \ - "$(fm_git_code_root_config_value "$root")" \ - "$(fm_git_code_root_config_value "$root/.git")" - } > "$dest" -} - -# fm_git_code_root_run [arg...]: run one command allowed -# to read , and return its status. The command is usually git itself; -# a helper script that runs several git commands against the root is passed -# whole so its children inherit the same single exception. -fm_git_code_root_run() { - local root=$1 config rc=0 - shift - [ "$#" -gt 0 ] || return 2 - config=$(mktemp "${TMPDIR:-/tmp}/fm-git-code-root.XXXXXX") || return 1 - if ! fm_git_code_root_stage_config "$root" "$config"; then - rm -f -- "$config" - return 1 - fi - GIT_CONFIG_GLOBAL="$config" "$@" || rc=$? - rm -f -- "$config" - return "$rc" -} diff --git a/bin/fm-remote-home-provision.sh b/bin/fm-remote-home-provision.sh index 39afe4a5af4..8f733d6d3c4 100755 --- a/bin/fm-remote-home-provision.sh +++ b/bin/fm-remote-home-provision.sh @@ -8,9 +8,7 @@ # base64 parent SSH alias, and one base64 project record per line. Each project # record's origin is the URL the parent resolved and named, so this host clones # from it and re-validates it through bin/fm-project-origin-lib.sh instead of -# trusting the sender. The remote code root is cloned into an absent home -# through bin/fm-git-code-root-lib.sh, which owns the one git exception that -# lets this host read a code root another account owns, +# trusting the sender. The remote code root is cloned into an absent home, # project origins are cloned on this host, the project registry and charter are # published, the durable .fm-secondmate-parent record names this home's route to its parent as # "remote" - read by bin/fm-teardown.sh's cleanup gate so a delegated public @@ -28,8 +26,6 @@ MAX_MANIFEST_BYTES=1048576 # shellcheck source=bin/fm-project-origin-lib.sh . "$SCRIPT_DIR/fm-project-origin-lib.sh" -# shellcheck source=bin/fm-git-code-root-lib.sh -. "$SCRIPT_DIR/fm-git-code-root-lib.sh" die() { printf 'error: %s\n' "$1" >&2; exit 1; } @@ -178,8 +174,7 @@ if [ -e "$FM_HOME" ] || [ -L "$FM_HOME" ]; then fi else CREATED_HOME=1 - fm_git_code_root_run "$FM_ROOT" git clone --quiet -- "$FM_ROOT" "$FM_HOME" \ - || die "could not clone the remote Firstmate home" + git clone --quiet -- "$FM_ROOT" "$FM_HOME" || die "could not clone the remote Firstmate home" fi for operational_dir in data state config projects; do operational_path="$FM_HOME/$operational_dir" diff --git a/bin/fm-remote-secondmate-control.sh b/bin/fm-remote-secondmate-control.sh index e9d6dccefed..e440001aa38 100755 --- a/bin/fm-remote-secondmate-control.sh +++ b/bin/fm-remote-secondmate-control.sh @@ -32,9 +32,6 @@ # code root from origin. Because this home is a standalone clone, the target # commit is imported here first and the fast-forward itself is the shared one in # bin/fm-ff-lib.sh, so the clean, ancestry, and branch guards have a single owner. -# Every read of that code root - the sync rev-parse, either import fetch, and the -# update leg's own code-root refresh - goes through bin/fm-git-code-root-lib.sh, -# because the host may keep the root under another account than the agent user. # A private parent-route state directory stores only the remote secondmate # agent's endpoint record; the home's own # state/*.meta remains reserved for workers the secondmate supervises. @@ -68,8 +65,6 @@ REMOTE_HERDR_SESSION=fm-remote . "$SCRIPT_DIR/fm-pending-reply-lib.sh" # shellcheck source=bin/fm-task-inbox-lib.sh . "$SCRIPT_DIR/fm-task-inbox-lib.sh" -# shellcheck source=bin/fm-git-code-root-lib.sh -. "$SCRIPT_DIR/fm-git-code-root-lib.sh" die() { printf 'error: %s\n' "$1" >&2; exit 1; } usage() { sed -n '2,24p' "$0" | sed 's/^# \{0,1\}//'; exit 2; } @@ -337,12 +332,12 @@ cmd_observe() { import_home_commit() { # local home=$1 commit=$2 if git -C "$home" cat-file -e "$commit^{commit}" 2>/dev/null; then return 0; fi - if fm_git_code_root_run "$FM_ROOT" git -C "$home" fetch --quiet --no-tags -- "$FM_ROOT" "$commit" 2>/dev/null \ + if git -C "$home" fetch --quiet --no-tags -- "$FM_ROOT" "$commit" 2>/dev/null \ && git -C "$home" cat-file -e "$commit^{commit}" 2>/dev/null; then return 0 fi if git -C "$home" remote get-url origin >/dev/null 2>&1 \ - && fm_git_code_root_run "$FM_ROOT" git -C "$home" fetch --quiet --no-tags -- origin "$commit" 2>/dev/null \ + && git -C "$home" fetch --quiet --no-tags -- origin "$commit" 2>/dev/null \ && git -C "$home" cat-file -e "$commit^{commit}" 2>/dev/null; then return 0 fi @@ -358,8 +353,7 @@ cmd_sync() { case "$commit" in *[!0-9a-f]*) die "sync target must be a full 40-character commit id" ;; esac [ "${#commit}" -eq 40 ] || die "sync target must be a full 40-character commit id" else - commit=$(fm_git_code_root_run "$FM_ROOT" git -C "$FM_ROOT" rev-parse HEAD 2>/dev/null) \ - || die "remote code root HEAD is unreadable" + commit=$(git -C "$FM_ROOT" rev-parse HEAD 2>/dev/null) || die "remote code root HEAD is unreadable" fi import_home_commit "$TARGET_HOME" "$commit" \ || die "remote home could not import $commit from this host's Firstmate copy or the home's origin; run /updatefirstmate to refresh this host's copy, or push that commit first" @@ -384,8 +378,7 @@ cmd_update() { local id=$1 update_out root_status validate_id "$id" validate_home "$id" - if ! update_out=$(fm_git_code_root_run "$FM_ROOT" \ - env FM_HOME="$FM_ROOT" FM_ROOT_OVERRIDE="$FM_ROOT" \ + if ! update_out=$(FM_HOME="$FM_ROOT" FM_ROOT_OVERRIDE="$FM_ROOT" \ "$SCRIPT_DIR/fm-update.sh" 2>&1); then [ -z "$update_out" ] || printf '%s\n' "$update_out" >&2 die "remote code root update failed" diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index bfe94401a43..518e905f60f 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -26,10 +26,8 @@ # refused as a flag value. # Ship/scout launches always put fm-dod-lib.sh's current worker role scope # first in the private launch-brief overlay, including the exact task-owned -# steering inbox. A REMOTE-route secondmate launch puts that home's -# host-local steering inbox first, because only a remote route can inherit a -# charter naming a steering inbox on a host this agent cannot reach. Neither -# path rewrites a project's instruction files or a secondmate's charter. +# steering inbox. This never rewrites a project's instruction files or a +# secondmate's charter. # fm-spawn.sh --relaunch [--harness ] [--model ] [--effort ] # --relaunch launches a replacement agent for an EXISTING task into that # task's own recorded worktree, reusing its recorded endpoint when that @@ -457,8 +455,6 @@ PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}" CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" # shellcheck source=bin/fm-config-inherit-lib.sh . "$SCRIPT_DIR/fm-config-inherit-lib.sh" -# shellcheck source=bin/fm-secondmate-parent-lib.sh -. "$SCRIPT_DIR/fm-secondmate-parent-lib.sh" if ! LAUNCH_ENV_ENABLED=$(fm_config_source_present "$CONFIG/launch-env-allowlist"); then exit 1 fi @@ -1732,39 +1728,6 @@ shell_quote() { printf "'" } -# A secondmate home whose durable parent binding reports a REMOTE route. Only -# that shape can carry a charter naming a steering inbox in the parent's own -# home, which this agent's host cannot reach. An absent or unparsable binding is -# not a remote route, so the standing charter stays the sole authority. -secondmate_launch_route_is_remote() { # - local home=$1 - fm_secondmate_parent_record_parse "$home/.fm-secondmate-parent" || return 1 - [ "$FM_SECONDMATE_PARENT_ROUTE" = remote ] -} - -render_secondmate_launch_brief() { # - local source=$1 launch=$2 id=$3 tmp q_inbox - q_inbox=$(shell_quote "$STATE/$id.inbox") - mkdir -p "$(dirname "$launch")" || return 1 - tmp="$(dirname "$launch")/.launch-brief.md.${BASHPID:-$$}" - { - cat <"$tmp" || { - rm -f -- "$tmp" - return 1 - } - mv "$tmp" "$launch" || { - rm -f -- "$tmp" - return 1 - } -} - resolve_pi_executable() { local candidate dir candidate=$(type -P -- "$1" 2>/dev/null) || return 1 @@ -2757,14 +2720,6 @@ fi echo "error: task $ID has no brief at inaccessible data path $BRIEF" >&2 exit 1 } -if [ "$KIND" = secondmate ] && secondmate_launch_route_is_remote "$PROJ_ABS"; then - SOURCE_BRIEF=$BRIEF - BRIEF="$DATA/$ID/launch-brief.md" - if ! render_secondmate_launch_brief "$SOURCE_BRIEF" "$BRIEF" "$ID"; then - echo "error: could not render current secondmate launch route for $SOURCE_BRIEF" >&2 - exit 1 - fi -fi if [ "$KIND" = ship ] || [ "$KIND" = scout ]; then if fm_brief_task_placeholders_present "$BRIEF"; then echo "error: $BRIEF still contains {TASK} or {FIRSTMATE_SPEC}; fill ## Captain's intent and ## Firstmate spec before spawn" >&2 diff --git a/bin/fm-task-inbox-lib.sh b/bin/fm-task-inbox-lib.sh index 2b5b0d03b0f..6a0287ab78f 100644 --- a/bin/fm-task-inbox-lib.sh +++ b/bin/fm-task-inbox-lib.sh @@ -7,11 +7,9 @@ # the self-describing doorbell line, and the watcher's re-ring ladder policy. # bin/fm-send.sh writes and rings locally, the host-local remote steer leg # (bin/fm-remote-secondmate-control.sh cmd_send) writes idempotently and rings -# on the remote host, bin/fm-watch.sh polls and re-rings, the brief -# scaffold (bin/fm-brief.sh) tells the worker how to read and acknowledge, and -# a remote-route secondmate launch (bin/fm-spawn.sh) names only the reachable -# inbox path for that launch, because a seeded charter can carry a path on the -# parent's host; none of them restates the format. +# on the remote host, bin/fm-watch.sh polls and re-rings, and the brief +# scaffold (bin/fm-brief.sh) tells the worker how to read and acknowledge; +# none of them restates the format. # # Design (captain-adopted, data/fm-send-reliability-reframe-s1/report.md): the # payload moves to the filesystem, which is reliable; the terminal carries only diff --git a/docs/remote-secondmates.md b/docs/remote-secondmates.md index cac671ab546..46c811226d2 100644 --- a/docs/remote-secondmates.md +++ b/docs/remote-secondmates.md @@ -18,7 +18,6 @@ Do not enable agent forwarding for Firstmate. `fm-on.sh` also disables agent forwarding, forwarding setup, and configured `SendEnv` patterns on every call, and arms bounded SSH dead-peer detection so a vanished host (a reboot, a dropped link) fails within a bounded window instead of hanging indefinitely; its [script header](../bin/fm-on.sh) owns the keepalive defaults and environment overrides. Clone Firstmate on the remote host at an absolute code-root path. -That code root may belong to an account other than the one the second mate runs as - a shared checkout, a deploy user - because every lifecycle read of it, the home clone at provisioning time and the later sync and update reads alike, carries a temporary git ownership exception naming only that code root; [`bin/fm-git-code-root-lib.sh`](../bin/fm-git-code-root-lib.sh) owns that exception, and nothing else on the host, the remote home included, becomes exempt from git's own ownership check. Expose that clone's fixed entrypoint on the account's non-interactive SSH `PATH`, for example: ```sh @@ -181,7 +180,7 @@ FM_HOME= bin/fm-send.sh fm- '' The [`fm-send.sh` header](../bin/fm-send.sh) owns the exact delivery-status contract. A routed request is delivered as a durable record in the remote home's `state/parent-route/.inbox` steering inbox plus a best-effort doorbell, never by typing the payload into the pane; exit 0 means the record durably exists. -The seeded remote charter names that same host-local inbox, and every remote-route launch re-states it above the standing charter, so the mate acts on the record the doorbell rang for instead of an unreachable inbox path in the parent's home. +The seeded remote charter names that same host-local inbox, so the mate acts on the record the doorbell rang for instead of an unreachable inbox path in the parent's home; re-running `fm-remote-home-seed.sh` republishes that charter into an existing home. Every remote transport attempt is bounded by `FM_SEND_REMOTE_BUDGET`; that header owns the setting's default and validation contract. An unconfirmed SSH transport (exit 255) is retried identically once, while a budget expiry is not retried because completion is unknown; either outcome preserves this ordinary reply-bearing request's pending-reply expectation for the record that may have landed. If delivery remains unconfirmed, only the exact `FM_PENDING_REPLY_EXISTING_CORR=` resend command printed by `fm-send` is safe to run later because it preserves the request body and lets the remote enqueue deduplicate onto the same record; a plain rerun mints a different correlation and is not idempotent. @@ -278,7 +277,6 @@ bin/fm-test-run.sh tests/fm-remote-doctor.test.sh bin/fm-test-run.sh tests/fm-remote-herdr-guard.test.sh bin/fm-test-run.sh tests/fm-project-origin.test.sh bin/fm-test-run.sh tests/fm-secondmate-sync.test.sh -bin/fm-test-run.sh tests/fm-remote-code-root-ownership.test.sh bin/fm-test-run.sh tests/fm-remote-reply.test.sh bin/fm-test-run.sh tests/fm-remote-backlog-handoff.test.sh bin/fm-test-run.sh tests/fm-remote-secondmate-lifecycle-e2e.test.sh diff --git a/tests/fm-remote-code-root-ownership.test.sh b/tests/fm-remote-code-root-ownership.test.sh deleted file mode 100755 index 5352dc934bb..00000000000 --- a/tests/fm-remote-code-root-ownership.test.sh +++ /dev/null @@ -1,191 +0,0 @@ -#!/usr/bin/env bash -# Host-local remote lifecycle reads of a code root this account does not own. -# -# A remote home is provisioned from a code root the host often keeps under -# another account, so every later leg that reads that root - the sync fetch of -# the parent's commit and the code-root HEAD read that /updatefirstmate's sync -# targets - has to carry the same exception the clone did, or the home can -# never follow its parent again. The refusal here is git's real one -# (GIT_TEST_ASSUME_DIFFERENT_OWNER, git's own knob) and the fixture authorizes -# only the repositories this account really owns, so nothing but a genuine -# safe.directory exception in a scope git honors can make these pass. -set -u - -# shellcheck source=tests/lib.sh -. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" - -ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P) -TMP_ROOT=$(fm_test_tmproot fm-remote-code-root-ownership) -mkdir -p "$TMP_ROOT" -TMP_ROOT=$(cd "$TMP_ROOT" && pwd -P) -cleanup() { rm -rf -- "$TMP_ROOT"; } -trap cleanup EXIT - -CODE_ROOT="$TMP_ROOT/unowned-code-root" -CODE_ROOT_ORIGIN="$TMP_ROOT/code-root-origin.git" -REMOTE_HOME="$TMP_ROOT/remote-home" -OWNED_CONFIG="$TMP_ROOT/owned.gitconfig" -ID=route - -mkdir -p "$CODE_ROOT/bin" -printf 'remote code root fixture\n' > "$CODE_ROOT/AGENTS.md" -printf '#!/usr/bin/env bash\nexit 0\n' > "$CODE_ROOT/bin/fm-fixture.sh" -chmod +x "$CODE_ROOT/bin/fm-fixture.sh" -printf 'projects/\nstate/\ndata/\nconfig/\n.fm-secondmate-home\n.fm-secondmate-parent\n' \ - > "$CODE_ROOT/.gitignore" -git -C "$CODE_ROOT" init -q -b main -git -C "$CODE_ROOT" config user.email test@example.com -git -C "$CODE_ROOT" config user.name Test -git -C "$CODE_ROOT" add . -git -C "$CODE_ROOT" commit -qm 'code root fixture' -FIRST_COMMIT=$(git -C "$CODE_ROOT" rev-parse HEAD) - -git clone --quiet -- "$CODE_ROOT" "$REMOTE_HOME" || fail "cannot stage the remote home fixture" -mkdir -p "$REMOTE_HOME/data" "$REMOTE_HOME/state" "$REMOTE_HOME/config" "$REMOTE_HOME/projects" -printf '%s\n' "$ID" > "$REMOTE_HOME/.fm-secondmate-home" -{ - printf 'schema=fm-secondmate-parent.v1\n' - printf 'route=remote\n' -} > "$REMOTE_HOME/.fm-secondmate-parent" - -# The code root then advances past the commit the home holds, which is the only -# thing a later sync can import. -printf 'second revision\n' >> "$CODE_ROOT/AGENTS.md" -git -C "$CODE_ROOT" commit -qam 'code root advance' -SECOND_COMMIT=$(git -C "$CODE_ROOT" rev-parse HEAD) - -# Everything the agent account owns on this host, and nothing else: the code -# root is deliberately absent. The home and the code root's own origin mirror -# are the account's, standing in for the real remote where origin is a URL no -# ownership check applies to. An ambient setting is included to prove the -# guarded invocation still reads the account's real global config. -{ - printf '[fm]\n\tcoderootfixture = inherited\n' - printf '[safe]\n\tdirectory = %s\n\tdirectory = %s\n\tdirectory = %s\n' \ - "$REMOTE_HOME" "$REMOTE_HOME/.git" "$CODE_ROOT_ORIGIN" -} > "$OWNED_CONFIG" - -as_foreign_root() { - env GIT_TEST_ASSUME_DIFFERENT_OWNER=1 \ - GIT_CONFIG_SYSTEM=/dev/null \ - GIT_CONFIG_GLOBAL="$OWNED_CONFIG" \ - "$@" -} - -control() { - as_foreign_root env FM_ROOT_OVERRIDE="$CODE_ROOT" FM_HOME="$REMOTE_HOME" \ - "$ROOT/bin/fm-remote-secondmate-control.sh" "$@" -} - -if as_foreign_root git -C "$CODE_ROOT" rev-parse HEAD >/dev/null 2>&1; then - echo "skip: this git does not simulate a differently-owned repository" - exit 0 -fi -as_foreign_root git -C "$REMOTE_HOME" rev-parse HEAD >/dev/null 2>&1 \ - || fail "the fixture wrongly refuses the home this account owns" - -# sync : the commit exists only in the unowned code root, -# so import_home_commit has to fetch it from there. -SYNC_OUT=$(control sync "$ID" "$SECOND_COMMIT" 2>&1) \ - || fail "sync could not import the parent's commit from an unowned code root: $SYNC_OUT" -assert_contains "$SYNC_OUT" "synced: $SECOND_COMMIT" \ - "sync did not fast-forward the home to the parent's commit" -assert_equals "$SECOND_COMMIT" "$(as_foreign_root git -C "$REMOTE_HOME" rev-parse HEAD)" \ - "the home did not actually advance to the parent's commit" - -# sync with no target: the target is the unowned code root's own HEAD, so -# the read that resolves it has to carry the exception too. -git -C "$REMOTE_HOME" reset --quiet --hard "$FIRST_COMMIT" \ - || fail "cannot rewind the home fixture" -printf 'third revision\n' >> "$CODE_ROOT/AGENTS.md" -git -C "$CODE_ROOT" commit -qam 'code root advance again' -THIRD_COMMIT=$(git -C "$CODE_ROOT" rev-parse HEAD) - -HEAD_SYNC_OUT=$(control sync "$ID" 2>&1) \ - || fail "sync could not read the unowned code root's HEAD: $HEAD_SYNC_OUT" -assert_contains "$HEAD_SYNC_OUT" "synced: $THIRD_COMMIT" \ - "sync did not follow this host's code-root HEAD" -assert_equals "$THIRD_COMMIT" "$(as_foreign_root git -C "$REMOTE_HOME" rev-parse HEAD)" \ - "the home did not actually advance to the code root's HEAD" - -# The exception names the code root only, lives for one command, and leaves the -# account's own global config in force while it does. -# shellcheck disable=SC2016 # The inner shell expands these, not this one. -INHERITED=$(as_foreign_root bash -c ' - . "$1/bin/fm-git-code-root-lib.sh" - fm_git_code_root_run "$2" git config --get fm.coderootfixture -' bash "$ROOT" "$CODE_ROOT" 2>&1) \ - || fail "the guarded invocation dropped the account's global git config: $INHERITED" -assert_equals inherited "$INHERITED" \ - "the guarded invocation did not keep the account's own global git config" - -# update : the whole code-root refresh runs against the unowned root - the -# origin fetch and the fast-forward of the root itself - before the home is -# synced to whatever that left behind. -git -C "$CODE_ROOT" remote add origin "$CODE_ROOT_ORIGIN" \ - || fail "cannot give the code-root fixture an origin" -git init -q --bare "$CODE_ROOT_ORIGIN" || fail "cannot stage the code-root origin" -git -C "$CODE_ROOT" push -q origin main || fail "cannot publish the code-root fixture" -git --git-dir="$CODE_ROOT_ORIGIN" symbolic-ref HEAD refs/heads/main - -PUBLISHER="$TMP_ROOT/publisher" -git clone --quiet -- "$CODE_ROOT_ORIGIN" "$PUBLISHER" || fail "cannot stage the publisher fixture" -git -C "$PUBLISHER" config user.email test@example.com -git -C "$PUBLISHER" config user.name Test -printf 'origin revision\n' >> "$PUBLISHER/AGENTS.md" -git -C "$PUBLISHER" commit -qam 'origin advance' -git -C "$PUBLISHER" push -q origin main -ORIGIN_TIP=$(git -C "$PUBLISHER" rev-parse HEAD) - -UPDATE_OUT=$(control update "$ID" 2>&1) \ - || fail "update could not refresh an unowned code root: $UPDATE_OUT" -assert_equals "$ORIGIN_TIP" "$(git -C "$CODE_ROOT" rev-parse HEAD)" \ - "update did not fast-forward the unowned code root from its origin" -assert_contains "$UPDATE_OUT" "synced: $ORIGIN_TIP" \ - "update did not report the home following the refreshed code root" -assert_equals "$ORIGIN_TIP" "$(as_foreign_root git -C "$REMOTE_HOME" rev-parse HEAD)" \ - "the home did not follow the refreshed code root" - -# With no GIT_CONFIG_GLOBAL of its own, the account's global layer is BOTH the -# XDG file and ~/.gitconfig. A guarded command has to resolve every setting the -# unguarded one would - a proxy, a credential helper, an insteadOf rewrite - -# from either file, and keep ~/.gitconfig winning where the two disagree. -AGENT_HOME="$TMP_ROOT/agent-home" -mkdir -p "$AGENT_HOME/.config/git" -printf '[fm]\n\txdgonly = fromxdg\n\tlayered = fromxdg\n' > "$AGENT_HOME/.config/git/config" -printf '[fm]\n\tuseronly = fromuser\n\tlayered = fromuser\n' > "$AGENT_HOME/.gitconfig" - -with_agent_home() { # [guarded] - # shellcheck disable=SC2016 # The inner shell expands these, not this one. - env -u GIT_CONFIG_GLOBAL -u XDG_CONFIG_HOME \ - HOME="$AGENT_HOME" GIT_CONFIG_SYSTEM=/dev/null GIT_CONFIG_NOSYSTEM=1 \ - bash -c ' - if [ -n "${3:-}" ]; then - . "$1/bin/fm-git-code-root-lib.sh" - fm_git_code_root_run "$2" git config --get "$4" - else - git config --get "$4" - fi - ' bash "$ROOT" "$CODE_ROOT" "${2:-}" "$1" 2>/dev/null -} - -for FM_KEY in fm.xdgonly fm.useronly fm.layered; do - UNGUARDED=$(with_agent_home "$FM_KEY") - [ -n "$UNGUARDED" ] || fail "the global-config fixture does not resolve $FM_KEY unguarded" - assert_equals "$UNGUARDED" "$(with_agent_home "$FM_KEY" guarded)" \ - "the guarded invocation resolved $FM_KEY differently than git would have" -done - -SIBLING="$TMP_ROOT/sibling-root" -git clone --quiet -- "$CODE_ROOT" "$SIBLING" || fail "cannot stage the sibling repository" -# shellcheck disable=SC2016 # The inner shell expands these, not this one. -as_foreign_root bash -c ' - . "$1/bin/fm-git-code-root-lib.sh" - fm_git_code_root_run "$2" git -C "$3" rev-parse HEAD -' bash "$ROOT" "$CODE_ROOT" "$SIBLING" >/dev/null 2>&1 \ - && fail "the code-root exception wrongly authorized an unrelated repository" - -as_foreign_root git -C "$CODE_ROOT" rev-parse HEAD >/dev/null 2>&1 \ - && fail "the lifecycle left a durable ownership exception for the code root" - -pass "remote sync reads and imports from a code root owned by another account" diff --git a/tests/fm-remote-home-provision.test.sh b/tests/fm-remote-home-provision.test.sh deleted file mode 100755 index f34d73f70e3..00000000000 --- a/tests/fm-remote-home-provision.test.sh +++ /dev/null @@ -1,97 +0,0 @@ -#!/usr/bin/env bash -# fm-remote-home-provision.sh clones a code root this account does not own. -# -# The refusal is git's real one. GIT_TEST_ASSUME_DIFFERENT_OWNER is git's own -# knob for "every repository looks like someone else's" (its t0033 uses it), and -# the fixture hands git a global config that names every fixture repository this -# account really owns EXCEPT the code root. So git accepts the home and refuses -# the code root, exactly as on a host whose Firstmate checkout lives under -# another account, and the only thing that can lift that refusal here is a -# genuine safe.directory exception in a scope git honors. -set -u - -# shellcheck source=tests/lib.sh -. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" - -ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P) -TMP_ROOT=$(fm_test_tmproot fm-remote-home-provision) -mkdir -p "$TMP_ROOT" -TMP_ROOT=$(cd "$TMP_ROOT" && pwd -P) -cleanup() { rm -rf -- "$TMP_ROOT"; } -trap cleanup EXIT - -encode() { base64 | tr -d '\n'; } - -CODE_ROOT="$TMP_ROOT/unowned-code-root" -REMOTE_HOME="$TMP_ROOT/remote-home" -OWNED_CONFIG="$TMP_ROOT/owned.gitconfig" - -mkdir -p "$CODE_ROOT/bin" -printf 'remote code root fixture\n' > "$CODE_ROOT/AGENTS.md" -printf '#!/usr/bin/env bash\nexit 0\n' > "$CODE_ROOT/bin/fm-fixture.sh" -chmod +x "$CODE_ROOT/bin/fm-fixture.sh" -printf 'projects/\nstate/\ndata/\nconfig/\n.fm-secondmate-home\n.fm-secondmate-parent\n' \ - > "$CODE_ROOT/.gitignore" -git -C "$CODE_ROOT" init -q -b main -git -C "$CODE_ROOT" config user.email test@example.com -git -C "$CODE_ROOT" config user.name Test -git -C "$CODE_ROOT" add . -git -C "$CODE_ROOT" commit -qm 'code root fixture' -CODE_ROOT_HEAD=$(git -C "$CODE_ROOT" rev-parse HEAD) - -# Everything the agent account owns on this host, and nothing else. The code -# root is deliberately absent. -{ - printf '[safe]\n\tdirectory = %s\n\tdirectory = %s\n' "$REMOTE_HOME" "$REMOTE_HOME/.git" -} > "$OWNED_CONFIG" - -# GIT_CONFIG_SYSTEM=/dev/null so a host-wide safe.directory cannot defeat the -# simulation and report a false pass. -as_foreign_root() { - env GIT_TEST_ASSUME_DIFFERENT_OWNER=1 \ - GIT_CONFIG_SYSTEM=/dev/null \ - GIT_CONFIG_GLOBAL="$OWNED_CONFIG" \ - "$@" -} - -if as_foreign_root git clone --quiet -- "$CODE_ROOT" "$TMP_ROOT/unguarded" 2>/dev/null; then - echo "skip: this git does not simulate a differently-owned repository" - exit 0 -fi - -CHARTER="$TMP_ROOT/charter.md" -printf 'Remote charter\n' > "$CHARTER" -MANIFEST="$TMP_ROOT/manifest" -{ - printf 'schema=fm-remote-home-provision.v1\n' - printf 'id_b64=%s\n' "$(printf '%s' route | encode)" - printf 'charter_b64=%s\n' "$(encode < "$CHARTER")" - printf 'parent_host_b64=%s\n' "$(printf '%s' remote-host | encode)" - printf 'project_count=0\n' -} > "$MANIFEST" - -PROVISION_OUT=$(as_foreign_root \ - env FM_ROOT_OVERRIDE="$CODE_ROOT" FM_HOME="$REMOTE_HOME" \ - "$ROOT/bin/fm-remote-home-provision.sh" < "$MANIFEST" 2>&1) \ - || fail "provisioning could not clone a code root owned by another account: $PROVISION_OUT" - -assert_contains "$PROVISION_OUT" "provisioned: $REMOTE_HOME" \ - "provisioning did not report the published home" -assert_present "$REMOTE_HOME/.fm-secondmate-home" \ - "provisioning did not publish the identity marker" -assert_equals route "$(cat "$REMOTE_HOME/.fm-secondmate-home")" \ - "provisioned home carries the wrong secondmate id" -assert_present "$REMOTE_HOME/data/charter.md" \ - "provisioning did not publish the charter into the cloned home" - -HOME_HEAD=$(as_foreign_root git -C "$REMOTE_HOME" rev-parse HEAD) \ - || fail "the provisioned home is not a readable clone" -assert_equals "$CODE_ROOT_HEAD" "$HOME_HEAD" \ - "the provisioned home does not carry the code root's commit" - -# The exception is spent on that one clone: nothing persistent was written, so -# the code root is still refused afterwards. -as_foreign_root git -C "$CODE_ROOT" rev-parse HEAD >/dev/null 2>&1 \ - && fail "provisioning left a durable ownership exception for the code root" - -pass "remote provisioning clones a code root owned by another account without keeping the exception" diff --git a/tests/fm-remote-secondmate-lifecycle-e2e.test.sh b/tests/fm-remote-secondmate-lifecycle-e2e.test.sh index ede9dfe6729..15d0deeb3aa 100755 --- a/tests/fm-remote-secondmate-lifecycle-e2e.test.sh +++ b/tests/fm-remote-secondmate-lifecycle-e2e.test.sh @@ -661,6 +661,21 @@ assert_grep "$REMOTE_HOME/state/parent-replies.status" "$REMOTE_HOME/data/charte assert_no_grep "$PARENT/state/ios.status" "$REMOTE_HOME/data/charter.md" "remote charter retained the inaccessible local status path" assert_grep "$REMOTE_HOME/state/parent-route/ios.inbox" "$REMOTE_HOME/data/charter.md" "remote charter did not use its host-local steering inbox" assert_no_grep "$PARENT/state/ios.inbox" "$REMOTE_HOME/data/charter.md" "remote charter retained the inaccessible local steering inbox" +# A home seeded before the charter carried its host-local steering inbox is +# corrected by reseeding the same route, not by patching the home. +remote_inbox_line=$(grep -F "$REMOTE_HOME/state/parent-route/ios.inbox" "$REMOTE_HOME/data/charter.md" | head -n 1) +old_inbox_line=${remote_inbox_line//"$REMOTE_HOME/state/parent-route/ios.inbox"/"$PARENT/state/ios.inbox"} +while IFS= read -r line || [ -n "$line" ]; do + printf '%s\n' "${line//"$REMOTE_HOME/state/parent-route/ios.inbox"/"$PARENT/state/ios.inbox"}" +done < "$REMOTE_HOME/data/charter.md" > "$TMP_ROOT/charter.old-seed" +cp "$TMP_ROOT/charter.old-seed" "$REMOTE_HOME/data/charter.md" +assert_grep "$old_inbox_line" "$REMOTE_HOME/data/charter.md" "the old-seed fixture did not restore the parent steering inbox" +FM_SECONDMATE_CHARTER='Own iOS delivery on the build Mac.' \ + FM_SECONDMATE_SCOPE='iOS implementation and Xcode validation' \ + remote_env "$ROOT/bin/fm-remote-home-seed.sh" ios remote-mac "$REMOTE_ROOT" "$REMOTE_HOME" alpha >/dev/null \ + || fail "reseeding the existing remote route failed" +assert_grep "$remote_inbox_line" "$REMOTE_HOME/data/charter.md" "reseeding did not republish the host-local steering inbox" +assert_no_grep "$PARENT/state/ios.inbox" "$REMOTE_HOME/data/charter.md" "reseeding kept the old charter's inaccessible steering inbox" if FM_SECONDMATE_CHARTER='Own iOS delivery on the build Mac.' \ FM_SECONDMATE_SCOPE='iOS implementation and Xcode validation' \ remote_env "$ROOT/bin/fm-remote-home-seed.sh" ios remote-mac "$REMOTE_ROOT" "$TMP_ROOT/other-home" alpha \ @@ -727,27 +742,6 @@ assert_grep 'remote_backend=herdr' "$PARENT/state/ios.meta" "parent metadata omi assert_grep 'remote_herdr_session=fm-remote' "$PARENT/state/ios.meta" "parent metadata omitted the pinned remote Herdr session" assert_grep 'remote_target=fm-remote:' "$PARENT/state/ios.meta" "parent metadata did not record an fm-remote endpoint" assert_grep 'herdr_session=fm-remote' "$REMOTE_HOME/state/parent-route/ios.meta" "remote metadata did not record the pinned Herdr session" -remote_launch_brief="$REMOTE_HOME/data/.parent-route/ios/launch-brief.md" -assert_present "$remote_launch_brief" "remote secondmate launch did not publish its current route overlay" -# The delivered brief is the launch-route overlay followed by the standing -# charter verbatim, so the overlay is exactly the lines above that copy. -remote_charter_lines=$(wc -l < "$REMOTE_HOME/data/charter.md") -remote_brief_lines=$(wc -l < "$remote_launch_brief") -remote_overlay_lines=$((remote_brief_lines - remote_charter_lines)) -[ "$remote_overlay_lines" -gt 0 ] || fail "remote launch brief carried no route overlay above the standing charter" -tail -n "$remote_charter_lines" "$remote_launch_brief" | cmp -s - "$REMOTE_HOME/data/charter.md" \ - || fail "remote launch brief did not carry the standing charter verbatim below its route overlay" -head -n "$remote_overlay_lines" "$remote_launch_brief" > "$TMP_ROOT/remote-launch-route.overlay" -assert_grep '# Current secondmate launch route' "$TMP_ROOT/remote-launch-route.overlay" "remote launch brief omitted the launch-time route contract" -assert_grep "$REMOTE_HOME/state/parent-route/ios.inbox" "$TMP_ROOT/remote-launch-route.overlay" "remote launch brief did not put the host-local steering inbox first" -assert_no_grep "$PARENT/state/ios.inbox" "$remote_launch_brief" "remote launch brief retained the inaccessible local steering inbox" -# The steering-inbox receive-and-ack procedure has one owner, so the overlay may -# correct the route but must never become a second copy of charter-owned lines. -grep -v '^[[:space:]]*$' "$TMP_ROOT/remote-launch-route.overlay" > "$TMP_ROOT/remote-launch-route.lines" || true -if grep -Fx -f "$REMOTE_HOME/data/charter.md" "$TMP_ROOT/remote-launch-route.lines" \ - > "$TMP_ROOT/remote-launch-route.restated"; then - fail "remote launch overlay restated charter-owned lines: $(head -n 1 "$TMP_ROOT/remote-launch-route.restated")" -fi assert_grep '--session fm-remote' "$HERDR_LOG" "remote launch did not target the fm-remote session" assert_no_grep '--session default' "$HERDR_LOG" "remote launch targeted the interactive default session" assert_grep 'window=remote:ios' "$PARENT/state/ios.meta" "parent metadata pretended the endpoint was local" diff --git a/tests/fm-remote-transport-lanes.test.sh b/tests/fm-remote-transport-lanes.test.sh index 76c0e367088..cbdf1356092 100755 --- a/tests/fm-remote-transport-lanes.test.sh +++ b/tests/fm-remote-transport-lanes.test.sh @@ -56,7 +56,6 @@ cp "$ROOT/bin/fm-remote-job-lib.sh" "$ROOT/bin/fm-remote-job-worker.sh" \ "$ROOT/bin/fm-composer-lib.sh" "$ROOT/bin/fm-cursor-lib.sh" \ "$ROOT/bin/fm-classify-lib.sh" "$ROOT/bin/fm-timeout-lib.sh" \ "$ROOT/bin/fm-ff-lib.sh" "$ROOT/bin/fm-secondmate-registry-lib.sh" \ - "$ROOT/bin/fm-git-code-root-lib.sh" \ "$REMOTE_ROOT/bin/" mkdir -p "$REMOTE_ROOT/bin/backends" cp "$ROOT/bin/backends/herdr.sh" "$REMOTE_ROOT/bin/backends/herdr.sh" diff --git a/tests/fm-spawn-dispatch-profile.test.sh b/tests/fm-spawn-dispatch-profile.test.sh index f61af751a9e..09f50d5b87a 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -837,7 +837,7 @@ test_pi_signed_persistent_secondmate_uses_pi_extensions_and_identity() { assert_meta_profile "$HOME_DIR/state/$id.meta" pi-signed default default cmp -s "$ROOT/AGENTS.md" "$sm/AGENTS.md" || fail "secondmate launch rewrote the supervisor contract" cmp -s "$CASE_DIR/charter-before" "$sm/data/charter.md" || fail "secondmate launch rewrote the charter" - assert_absent "$HOME_DIR/data/$id/launch-brief.md" "local-route secondmate launch received a launch overlay" + assert_absent "$HOME_DIR/data/$id/launch-brief.md" "secondmate launch received a worker overlay" launch=$(cat "$LAUNCH_LOG") assert_contains "$launch" "< '$sm/data/charter.md'" "secondmate launch lost its original charter" assert_contains "$launch" "FM_PI_HARNESS=pi-signed '$FAKEBIN_DIR/pi-signed' --tui-mode regular -e '$sm/.pi/extensions/fm-primary-turnend-guard.ts' -e '$sm/.pi/extensions/fm-primary-pi-watch.ts'" \ @@ -846,7 +846,7 @@ test_pi_signed_persistent_secondmate_uses_pi_extensions_and_identity() { printf '# evidence begin: persistent secondmate\n%s\n' "$out" printf 'launch command:\n%s\noriginal charter:\n' "$launch" cat "$sm/data/charter.md" - printf 'supervisor AGENTS.md and charter remain byte-identical; no launch overlay created\n# evidence end\n' + printf 'supervisor AGENTS.md and charter remain byte-identical; no worker overlay created\n# evidence end\n' fi pass "pi-signed is a distinct persistent secondmate runtime with shared Pi supervision semantics" } From ad500bf6739018e36174da53a3b23d9a58e7e710 Mon Sep 17 00:00:00 2001 From: Quartermaster Date: Mon, 21 Sep 2026 00:58:50 +0000 Subject: [PATCH 13/13] no-mistakes(document): note remote seed inbox must match control-side inbox --- bin/fm-remote-home-seed.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bin/fm-remote-home-seed.sh b/bin/fm-remote-home-seed.sh index 423cb1b3a17..e5bad4779bb 100755 --- a/bin/fm-remote-home-seed.sh +++ b/bin/fm-remote-home-seed.sh @@ -149,7 +149,10 @@ REG_EXISTED=0 # Keep the parent charter as its durable source, but publish a remote copy whose # parent-channel and steering-inbox paths are remote-home paths rather than local -# parent-home paths. +# parent-home paths. The steering-inbox path below has to stay the exact inbox +# bin/fm-remote-secondmate-control.sh's cmd_send writes records into and rings +# the doorbell for (its CONTROL_STATE); a charter naming any other path leaves +# the mate reading an inbox nothing delivers to. PARENT_STATUS="$STATE/$ID.status" REMOTE_STATUS="$REMOTE_HOME/state/parent-replies.status" PARENT_INBOX="$STATE/$ID.inbox"