From d714b2b7e3f2a21a7447a271d94367cd224deb8a Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Tue, 4 Aug 2026 00:59:31 -0700 Subject: [PATCH 1/4] Pin remote secondmates to fm-remote --- AGENTS.md | 2 +- bin/fm-remote-doctor.sh | 12 ++++--- bin/fm-remote-secondmate-control.sh | 34 +++++++++++++------ bin/fm-send.sh | 5 +++ bin/fm-spawn.sh | 11 +++++- docs/remote-secondmates.md | 14 +++++--- tests/fm-remote-doctor.test.sh | 6 ++-- ...fm-remote-secondmate-lifecycle-e2e.test.sh | 25 ++++++++++++++ tests/fm-send-strict.test.sh | 28 +++++++++++++++ tests/remote-herdr-fixture.sh | 2 +- 10 files changed, 115 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 14a97ad7fba..9f900897783 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,7 +91,7 @@ state/ volatile runtime signals; gitignored .turn-ended touched by turn-end hooks .grok-turnend-token firstmate-owned grok hook registry token for the task; removed by teardown .kimi-turnend-token firstmate-owned Kimi hook registry token for the task; removed by teardown - .meta written by fm-spawn: window=, endpoint_task_id=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; an optional traceparent= only when trace context is enabled (docs/configuration.md "Trace context propagation"); kind=secondmate also records home= and projects=, plus remote_host=/remote_root=/remote_backend=/remote_target= for a remote route; a non-default runtime backend records further backend-specific fields (docs/configuration.md "Runtime backend"; bin/fm-backend.sh, section 8); fm-pr-check, including through fm-pr-merge, records one canonical pr= and the forge's pr_head= when available (GitHub pull requests and GitLab merge requests; docs/gitlab-merge-watch.md); fm-x-link appends x_request=, x_request_ts=, x_followups=, and optional x_platform=/x_reply_max_chars= for an X-mode-originated task (section 14) + .meta written by fm-spawn: window=, endpoint_task_id=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; an optional traceparent= only when trace context is enabled (docs/configuration.md "Trace context propagation"); kind=secondmate also records home= and projects=, plus remote_host=/remote_root=/remote_backend=/remote_herdr_session=/remote_target= for a remote route; a non-default runtime backend records further backend-specific fields (docs/configuration.md "Runtime backend"; bin/fm-backend.sh, section 8); fm-pr-check, including through fm-pr-merge, records one canonical pr= and the forge's pr_head= when available (GitHub pull requests and GitLab merge requests; docs/gitlab-merge-watch.md); fm-x-link appends x_request=, x_request_ts=, x_followups=, and optional x_platform=/x_reply_max_chars= for an X-mode-originated task (section 14) .herdr-presentation quarantinable attempt and restart-binding journal for Herdr's optional visual projection; never task or endpoint authority; see docs/herdr-backend.md "Optional presentation spaces" .check.sh authenticated slow poll; the watcher dispatches validated PR data and the byte-identified X shim through trusted repository scripts, runs registered custom checks from hash-validated private snapshots, and rejects every other state check without execution .check-trust private content binding created by fm-check-register.sh for an intentional custom check diff --git a/bin/fm-remote-doctor.sh b/bin/fm-remote-doctor.sh index fc2ee785a1d..343fc33ae69 100755 --- a/bin/fm-remote-doctor.sh +++ b/bin/fm-remote-doctor.sh @@ -9,9 +9,10 @@ # recomposing it, so the entrypoint stays the single owner of that ordering and # the two can never drift. # -# A remote second mate always runs on the Herdr backend, so readiness is more -# than tool resolution. herdr must resolve, its server must be reachable, and on -# macOS the Firstmate-owned launch agent dev.firstmate.herdr at +# A remote second mate always runs on the Herdr backend in the dedicated +# fm-remote session, so readiness is more than tool resolution. herdr must +# resolve, that server must be reachable, and on macOS the Firstmate-owned +# launch agent dev.firstmate.herdr at # ~/Library/LaunchAgents/dev.firstmate.herdr.plist must exist, carry # LimitLoadToSessionType=Aqua, and be loaded into the console user's gui/ # domain, so the server belongs to the GUI login session and survives logout and @@ -54,7 +55,10 @@ SCRIPT_DIR=$(CDPATH='' cd -- "$SCRIPT_DIR" && pwd -P) REQUIRED_TOOLS=(git jq) OPTIONAL_TOOLS=(tmux treehouse no-mistakes tasks-axi claude codex opencode pi grok kimi) LAUNCH_AGENT_LABEL=dev.firstmate.herdr -HERDR_SESSION_NAME=default +# The dedicated remote-secondmate session. The user's interactive Herdr work +# remains in the separate default session, which this readiness check never +# requires or changes. +HERDR_SESSION_NAME=fm-remote LAUNCH_AGENT_DIR="${HOME:-}/Library/LaunchAgents" LAUNCH_AGENT_PLIST="$LAUNCH_AGENT_DIR/$LAUNCH_AGENT_LABEL.plist" LAUNCH_AGENT_LOG_DIR="${HOME:-}/Library/Logs" diff --git a/bin/fm-remote-secondmate-control.sh b/bin/fm-remote-secondmate-control.sh index 9ba809eb32e..158b45f48b8 100755 --- a/bin/fm-remote-secondmate-control.sh +++ b/bin/fm-remote-secondmate-control.sh @@ -13,13 +13,16 @@ # fm-remote-secondmate-control.sh update # fm-remote-secondmate-control.sh retire [--force] # -# Remote placement ends here, but the second-mate agent itself always runs on -# the Herdr backend, so launch refuses any other selection rather than reading -# this home's config/backend; fm-spawn/fm-send/fm-teardown keep owning the local -# endpoint mechanics, and the home's own workers keep its ordinary backend -# selection. bin/fm-remote-doctor.sh owns that host's readiness for Herdr, and -# docs/remote-secondmates.md owns why. A private parent-route state directory -# stores only the remote secondmate agent's endpoint record; the home's own +# Remote placement ends here, but the second-mate agent always runs on the +# Herdr backend in the dedicated fm-remote session, so launch refuses any other +# selection rather than reading this home's config/backend. The interactive +# default session remains for the user's work. +# fm-spawn/fm-send/fm-teardown keep owning the local endpoint mechanics. +# The home's own workers keep their ordinary backend selection. +# bin/fm-remote-doctor.sh owns that host's readiness for Herdr. +# docs/remote-secondmates.md owns why. +# 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. # # The optional launch traceparent is the per-task W3C trace-context carrier the @@ -35,6 +38,7 @@ FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" TARGET_HOME=${FM_HOME:?FM_HOME is required} CONTROL_STATE="$TARGET_HOME/state/parent-route" CONTROL_DATA="$TARGET_HOME/data/.parent-route" +REMOTE_HERDR_SESSION=fm-remote # shellcheck source=bin/fm-backend.sh . "$SCRIPT_DIR/fm-backend.sh" @@ -69,7 +73,7 @@ state_value() { # ; prints recovery-grade state } print_route() { # - local meta=$1 backend target harness traceparent + local meta=$1 backend target harness traceparent herdr_session meta=$(meta_path "$meta") backend=$(fm_backend_of_meta "$meta") target=$(fm_backend_target_of_meta "$meta") @@ -78,6 +82,10 @@ print_route() { # printf 'schema=fm-remote-secondmate-control.v1\n' printf 'backend=%s\n' "$backend" printf 'target=%s\n' "$target" + [ "$backend" != herdr ] || { + herdr_session=$(fm_meta_get "$meta" herdr_session) + printf 'herdr_session=%s\n' "$herdr_session" + } printf 'harness=%s\n' "$harness" [ -z "$traceparent" ] || printf 'traceparent=%s\n' "$traceparent" } @@ -95,7 +103,7 @@ cmd_route() { cmd_launch() { local id=$1 harness=$2 model=$3 effort=$4 selected_backend=$5 traceparent=${6:-} - local current meta out backend target + local current meta out backend target herdr_session validate_id "$id" validate_home "$id" @@ -114,6 +122,9 @@ cmd_launch() { backend=$(fm_backend_of_meta "$meta") [ "$backend" = herdr ] \ || die "remote secondmate $id has an alive endpoint recorded on backend '$backend'; refusing reuse until it is explicitly migrated or retired" + herdr_session=$(fm_meta_get "$meta" herdr_session) + [ "$herdr_session" = "$REMOTE_HERDR_SESSION" ] \ + || die "remote secondmate $id has an alive endpoint in Herdr session '${herdr_session:-missing}', expected '$REMOTE_HERDR_SESSION'; refusing reuse until it is explicitly migrated or retired" print_route "$id" return 0 ;; @@ -130,7 +141,7 @@ cmd_launch() { [ "$model" = - ] || ARGS+=(--model "$model") [ "$effort" = - ] || ARGS+=(--effort "$effort") [ -z "$traceparent" ] || ARGS+=(--traceparent "$traceparent") - if ! out=$(FM_HOME="$FM_ROOT" FM_ROOT_OVERRIDE="$FM_ROOT" \ + if ! out=$(HERDR_SESSION="$REMOTE_HERDR_SESSION" FM_HOME="$FM_ROOT" FM_ROOT_OVERRIDE="$FM_ROOT" \ FM_STATE_OVERRIDE="$CONTROL_STATE" FM_DATA_OVERRIDE="$CONTROL_DATA" \ FM_CONFIG_OVERRIDE="$TARGET_HOME/config" FM_SKIP_SECONDMATE_INHERIT=1 \ "$SCRIPT_DIR/fm-spawn.sh" "${ARGS[@]}" 2>&1); then @@ -138,6 +149,9 @@ cmd_launch() { die "remote host-local secondmate launch failed" fi [ -f "$meta" ] || die "remote launch returned without endpoint metadata" + herdr_session=$(fm_meta_get "$meta" herdr_session) + [ "$herdr_session" = "$REMOTE_HERDR_SESSION" ] \ + || die "remote launch recorded Herdr session '${herdr_session:-missing}', expected '$REMOTE_HERDR_SESSION'" print_route "$id" } diff --git a/bin/fm-send.sh b/bin/fm-send.sh index ccce65ea82b..52dabb4b348 100755 --- a/bin/fm-send.sh +++ b/bin/fm-send.sh @@ -165,6 +165,11 @@ fm_send_resolve_target() { # fi case "$raw" in + fm-*:*) + # A named Herdr session may itself begin with "fm-". Keep that explicit + # session:pane target on the validated backend-target path below rather + # than mistaking it for an unresolved task selector. + ;; fm-*) RESOLUTION_TRIED="meta=$STATE/$raw.meta; legacy-meta=$STATE/${raw#fm-}.meta; backend=none" echo "error: no metadata for $raw in $STATE (tried $RESOLUTION_TRIED); pass a well-formed explicit backend target only when targeting outside this firstmate home" >&2 diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 35a8df2b248..f8e682ba51e 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -335,7 +335,7 @@ fi spawn_remote_secondmate() { local id=$1 remote host root home harness positional model effort backend out rc meta tmp - local remote_backend remote_target remote_harness registry_lock remote_lock remote_generation + local remote_backend remote_target remote_harness remote_herdr_session registry_lock remote_lock remote_generation local remote_traceparent remote_recorded_traceparent local -a launch_args id=${POS[0]:-} @@ -513,6 +513,7 @@ spawn_remote_secondmate() { remote_backend=$(printf '%s\n' "$out" | sed -n 's/^backend=//p' | tail -1) remote_target=$(printf '%s\n' "$out" | sed -n 's/^target=//p' | tail -1) remote_harness=$(printf '%s\n' "$out" | sed -n 's/^harness=//p' | tail -1) + remote_herdr_session=$(printf '%s\n' "$out" | sed -n 's/^herdr_session=//p' | tail -1) if [ "$remote_backend" != herdr ]; then fm_lock_release "$remote_lock" || true fm_lock_release "$registry_lock" || true @@ -527,6 +528,13 @@ spawn_remote_secondmate() { echo "error: remote launch returned malformed route metadata; preserving the remote route for reconciliation" >&2 return 1 } + if [ "$remote_herdr_session" != fm-remote ] || [ "${remote_target%%:*}" != "$remote_herdr_session" ]; then + fm_lock_release "$remote_lock" || true + fm_lock_release "$registry_lock" || true + fm_lock_release "$SPAWN_TASK_LOCK" || true + echo "error: remote launch returned Herdr session '${remote_herdr_session:-missing}', expected 'fm-remote'; preserving the remote route for reconciliation" >&2 + return 1 + fi # Record what the remote endpoint ACTUALLY carries, read back from its own # launch, rather than what this side hoped to deliver. That keeps the #995 # guarantee that the recorded carrier is the identity the child received even @@ -553,6 +561,7 @@ spawn_remote_secondmate() { echo "remote_host=$host" echo "remote_root=$root" echo "remote_backend=$remote_backend" + echo "remote_herdr_session=$remote_herdr_session" echo "remote_target=$remote_target" [ -z "$remote_recorded_traceparent" ] || echo "traceparent=$remote_recorded_traceparent" } > "$tmp" diff --git a/docs/remote-secondmates.md b/docs/remote-secondmates.md index fa31953aba8..6f17ad37c6f 100644 --- a/docs/remote-secondmates.md +++ b/docs/remote-secondmates.md @@ -4,9 +4,11 @@ Remote second mates place a whole persistent Firstmate home on another SSH-reach The primary still owns routing and supervision, while the remote home owns its own projects, backlog, and workers. Firstmate does not support placing an individual worker remotely or failing a remote route over to a local replacement. -The remote second-mate agent itself always runs on the [Herdr backend](herdr-backend.md), and every path that provisions or launches one refuses a host that is not ready for it. -Herdr's server belongs to the host's own GUI login session rather than to the SSH connection, so the agent's endpoint survives every disconnection the primary's supervision depends on. -Local second mates are unaffected and keep their ordinary backend selection, as do the workers a remote second mate supervises inside its own home. +The remote second-mate agent itself always runs on the [Herdr backend](herdr-backend.md) in the shared `fm-remote` session, and every path that provisions or launches one refuses a host that is not ready for it. +`fm-remote` is reserved for remote fleet work and must not be used for personal work. +The user's interactive Herdr session remains `default` and is not a remote-secondmate prerequisite. +Herdr's remote-session server belongs to the host's own GUI login session rather than to the SSH connection, so the agent's endpoint survives every disconnection the primary's supervision depends on. +Local second mates are unaffected and keep their ordinary backend and session selection, as do the workers a remote second mate supervises inside its own home. ## Prerequisites @@ -81,7 +83,8 @@ The script's own header owns the full line protocol. bin/fm-on.sh fm-remote-doctor.sh --fix ``` -It writes and reloads the Firstmate-owned launch agent `dev.firstmate.herdr` at `~/Library/LaunchAgents/dev.firstmate.herdr.plist`, scoped with `LimitLoadToSessionType=Aqua` so it belongs to the GUI login session, bootstraps and starts it in `gui/`, starts the herdr server directly on a host where no launch agent applies, and recreates the `~/.local/bin/fm-remote-entrypoint.sh` symlink when it is absent. +It writes and reloads the Firstmate-owned launch agent `dev.firstmate.herdr` at `~/Library/LaunchAgents/dev.firstmate.herdr.plist`, scoped with `LimitLoadToSessionType=Aqua` so it belongs to the GUI login session, bootstraps and starts the `fm-remote` server in `gui/`, starts that server directly on a host where no launch agent applies, and recreates the `~/.local/bin/fm-remote-entrypoint.sh` symlink when it is absent. +The launch agent owns only the remote-secondmate server and does not start, stop, or require the user's interactive `default` session. It re-derives every check from the host afterwards, so what it prints is the state after the repair rather than the intent of one. These steps are never automated and are always reported rather than silently attempted, because SSH cannot create a GUI session from nothing: @@ -122,7 +125,8 @@ Launch or recover the remote second mate with the same command used for a local bin/fm-spawn.sh --secondmate ``` -The primary resolves the verified secondmate harness and optional model and effort, runs the same readiness gate the seed runs, transfers the inherited-material allowlist, and asks the remote host to launch on Herdr. +The primary resolves the verified secondmate harness and optional model and effort, runs the same readiness gate the seed runs, transfers the inherited-material allowlist, and asks the remote host to launch on Herdr in `fm-remote`. +All remote secondmates on one host share `fm-remote` and retain separate `2ndmate-` workspaces inside it. An explicit request for any other backend is refused rather than honored, and the remote host refuses one too. A launch after a host has drifted out of readiness fails with the doctor's own gap text instead of leaving a half-created endpoint. Raw launch commands are not accepted for remote secondmates. diff --git a/tests/fm-remote-doctor.test.sh b/tests/fm-remote-doctor.test.sh index 6247846cb14..d277a8346db 100755 --- a/tests/fm-remote-doctor.test.sh +++ b/tests/fm-remote-doctor.test.sh @@ -80,7 +80,7 @@ arguments = { $FM_FAKE_HERDR_BIN server --session - default + fm-remote } stdout path = $FM_FAKE_LAUNCH_AGENT_LOG stderr path = $FM_FAKE_LAUNCH_AGENT_LOG @@ -179,7 +179,7 @@ arguments = { $herdr_bin server --session - default + fm-remote } stdout path = $CASE_HOME/Library/Logs/$LABEL.log stderr path = $CASE_HOME/Library/Logs/$LABEL.log @@ -236,6 +236,8 @@ assert_present "$CASE_PLIST" "--fix reported success without writing the plist" assert_grep 'Aqua' "$CASE_PLIST" "the written plist is not Aqua-scoped" assert_grep "$LABEL" "$CASE_PLIST" "the written plist does not carry the Firstmate label" assert_grep 'server' "$CASE_PLIST" "the written plist does not run a herdr server" +assert_grep 'fm-remote' "$CASE_PLIST" "the written plist does not pin the remote-secondmate session" +assert_no_grep 'default' "$CASE_PLIST" "the written plist pins the interactive default session" assert_grep "gui/$(id -u)" "$CASE_LAUNCHCTL_LOG" "the launch agent was not bootstrapped into the GUI domain" assert_no_dangerous_calls "the repair reached for auto-login, FileVault, or the keychain" pass "--fix installs, Aqua-scopes, loads, and starts the Firstmate herdr launch agent" diff --git a/tests/fm-remote-secondmate-lifecycle-e2e.test.sh b/tests/fm-remote-secondmate-lifecycle-e2e.test.sh index 15e8d4e3c09..e50b9369181 100755 --- a/tests/fm-remote-secondmate-lifecycle-e2e.test.sh +++ b/tests/fm-remote-secondmate-lifecycle-e2e.test.sh @@ -198,6 +198,15 @@ case "${FM_FAKE_SSH_MODE:-normal}:$command_name:$command_rel" in printf 'harness=codex\n' exit 0 ;; + launch-default-session-route:fm-remote-secondmate-control.sh:*) + [ "$_command_action" = launch ] || exit 93 + printf 'schema=fm-remote-secondmate-control.v1\n' + printf 'backend=herdr\n' + printf 'target=default:w1:p2\n' + printf 'herdr_session=default\n' + printf 'harness=codex\n' + exit 0 + ;; provision-block-fail:fm-remote-home-provision.sh:*) touch "$FM_FAKE_SEED_ENTERED" while [ ! -f "$FM_FAKE_SEED_RELEASE" ]; do sleep 0.02; done @@ -479,6 +488,11 @@ out=$(remote_env "$ROOT/bin/fm-spawn.sh" ios --secondmate) assert_contains "$out" 'remote=remote-mac backend=herdr' "remote spawn did not report separate host and backend dimensions" assert_grep 'remote_host=remote-mac' "$PARENT/state/ios.meta" "parent metadata omitted the remote host" assert_grep 'remote_backend=herdr' "$PARENT/state/ios.meta" "parent metadata omitted the remote-local backend" +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 '--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" assert_present "$PARENT/state/procevent/remote-reply-ios.source" "remote spawn did not arm its reply source" publish_healthy_watcher_identity "$PARENT/state" "$PARENT" "$ROOT/bin/fm-watch.sh" @@ -505,6 +519,17 @@ cmp -s "$TMP_ROOT/parent-ios-before-nonherdr.meta" "$PARENT/state/ios.meta" \ cmp -s "$TMP_ROOT/registry-before-nonherdr.md" "$PARENT/data/secondmates.md" \ || fail "parent removed or changed the registry route after a non-herdr route refusal" +set +e +FM_FAKE_SSH_MODE=launch-default-session-route remote_env "$ROOT/bin/fm-spawn.sh" ios --secondmate \ + > "$TMP_ROOT/spawn-default-session-route.out" 2>&1 +default_session_parent_rc=$? +set -e +[ "$default_session_parent_rc" -ne 0 ] || fail "parent accepted an interactive default-session remote route" +assert_grep "remote launch returned Herdr session 'default', expected 'fm-remote'" "$TMP_ROOT/spawn-default-session-route.out" \ + "parent refusal did not name the default session" +cmp -s "$TMP_ROOT/parent-ios-before-nonherdr.meta" "$PARENT/state/ios.meta" \ + || fail "parent rewrote its endpoint metadata after a default-session route refusal" + remote_route_meta="$REMOTE_HOME/state/parent-route/ios.meta" cp "$remote_route_meta" "$TMP_ROOT/remote-ios-before-legacy.meta" cat > "$remote_route_meta" < "$fb/herdr" <<'SH' +#!/usr/bin/env bash +set -u +printf '%s\n' "$*" >> "$FM_HERDR_LOG" +case "${1:-} ${2:-}" in + "status --json") printf '{"client":{"version":"0.7.5","protocol":16},"server":{"running":true}}\n' ;; + "pane get") printf '{"result":{"pane":{"pane_id":"%s"}}}\n' "${3:-}" ;; + "pane send-keys") : ;; +esac +SH + chmod +x "$fb/herdr" cat > "$fb/sleep" <<'SH' #!/usr/bin/env bash exit 0 @@ -147,6 +158,22 @@ test_unmatched_single_colon_target_must_exist() { pass "fm-send strict: unmatched single-colon explicit targets must verify live before sending" } +test_fm_prefixed_herdr_session_is_an_explicit_target() { + local dir fb home err log herdr_log rc + dir="$TMP_ROOT/fm-remote-explicit"; mkdir -p "$dir" + fb=$(make_stubs "$dir"); home=$(setup_home fmremote); err="$dir/send.err"; log="$dir/tmux.log"; herdr_log="$dir/herdr.log" + : > "$log" + : > "$herdr_log" + + PATH="$fb:$PATH" FM_HOME="$home" FM_ROOT_OVERRIDE="$home" FM_TMUX_LOG="$log" FM_HERDR_LOG="$herdr_log" FM_SEND_SETTLE=0 \ + "$SEND" fm-remote:w1:p2 --key Enter >/dev/null 2>"$err"; rc=$? + expect_code 0 "$rc" "an fm-prefixed Herdr session target should be accepted as explicit" + assert_grep 'pane get w1:p2 --session fm-remote' "$herdr_log" "fm-prefixed Herdr target was not verified in its session" + assert_grep 'pane send-keys w1:p2 enter --session fm-remote' "$herdr_log" "fm-prefixed Herdr target was not sent its key in its session" + assert_no_grep '--session default' "$herdr_log" "fm-prefixed Herdr target fell back to the default session" + pass "fm-send strict: fm-prefixed Herdr sessions remain explicit backend targets" +} + test_healthy_fm_id_send_still_works() { local dir fb home err log rc got dir="$TMP_ROOT/healthy"; mkdir -p "$dir" @@ -168,4 +195,5 @@ test_unset_fm_home_fails test_unresolvable_target_does_not_tmux_fallback test_prefixless_herdr_pane_id_fails test_unmatched_single_colon_target_must_exist +test_fm_prefixed_herdr_session_is_an_explicit_target test_healthy_fm_id_send_still_works diff --git a/tests/remote-herdr-fixture.sh b/tests/remote-herdr-fixture.sh index 26de3975b46..b01419066cc 100644 --- a/tests/remote-herdr-fixture.sh +++ b/tests/remote-herdr-fixture.sh @@ -110,7 +110,7 @@ case "${1:-} ${2:-}" in fi ;; "session list"*) - printf '{"sessions":[{"name":"default","running":true,"socket_path":"%s"}]}\n' "$SOCKET" ;; + printf '{"sessions":[{"name":"default","running":true,"socket_path":"%s"},{"name":"fm-remote","running":true,"socket_path":"%s"}]}\n' "$SOCKET" "$SOCKET" ;; esac exit 0 SH From e4a6e7983af6a66bd4d35967877dfdc1e2a38347 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Tue, 4 Aug 2026 01:10:39 -0700 Subject: [PATCH 2/4] no-mistakes(review): Fail closed on legacy remote Herdr endpoints --- bin/fm-remote-secondmate-control.sh | 120 ++++++++++-------- ...fm-remote-secondmate-lifecycle-e2e.test.sh | 42 +++++- 2 files changed, 104 insertions(+), 58 deletions(-) diff --git a/bin/fm-remote-secondmate-control.sh b/bin/fm-remote-secondmate-control.sh index 158b45f48b8..ed986044d3f 100755 --- a/bin/fm-remote-secondmate-control.sh +++ b/bin/fm-remote-secondmate-control.sh @@ -62,30 +62,59 @@ validate_home() { # [allow-absent] meta_path() { printf '%s/%s.meta\n' "$CONTROL_STATE" "$1"; } +remote_endpoint_load() { + local id=$1 herdr_session + REMOTE_ENDPOINT_ERROR= + REMOTE_ENDPOINT_META=$(meta_path "$id") + if ! fm_backend_validate_task_endpoint "$REMOTE_ENDPOINT_META" "$id" 2>/dev/null; then + REMOTE_ENDPOINT_ERROR="remote secondmate $id endpoint metadata is invalid; refusing access until it is explicitly migrated" + return 1 + fi + REMOTE_ENDPOINT_BACKEND=$FM_BACKEND_VALIDATED_BACKEND + REMOTE_ENDPOINT_TARGET=$FM_BACKEND_VALIDATED_TARGET + if [ "$REMOTE_ENDPOINT_BACKEND" != herdr ]; then + REMOTE_ENDPOINT_ERROR="remote secondmate $id endpoint is recorded on backend '$REMOTE_ENDPOINT_BACKEND', expected 'herdr'; refusing access until it is explicitly migrated" + return 1 + fi + herdr_session=$(fm_backend_meta_exact_value "$REMOTE_ENDPOINT_META" herdr_session 2>/dev/null || true) + if [ "$herdr_session" != "$REMOTE_HERDR_SESSION" ]; then + REMOTE_ENDPOINT_ERROR="remote secondmate $id endpoint is recorded in Herdr session '${herdr_session:-missing}', expected '$REMOTE_HERDR_SESSION'; refusing access until it is explicitly migrated" + return 1 + fi + case "$REMOTE_ENDPOINT_TARGET" in + "$REMOTE_HERDR_SESSION":?*) ;; + *) + REMOTE_ENDPOINT_ERROR="remote secondmate $id endpoint target '$REMOTE_ENDPOINT_TARGET' is outside Herdr session '$REMOTE_HERDR_SESSION'; refusing access until it is explicitly migrated" + return 1 + ;; + esac +} + +remote_endpoint_require() { + remote_endpoint_load "$1" || die "$REMOTE_ENDPOINT_ERROR" +} + state_value() { # ; prints recovery-grade state - local id=$1 meta backend target + local id=$1 meta meta=$(meta_path "$id") [ -f "$meta" ] && [ ! -L "$meta" ] || { printf 'missing\n'; return 0; } - backend=$(fm_backend_of_meta "$meta") - target=$(fm_backend_target_of_meta "$meta") - [ -n "$target" ] || { printf 'unreadable\n'; return 0; } - fm_backend_agent_state "$backend" "$target" 2>/dev/null || printf 'unreadable\n' + if ! remote_endpoint_load "$id"; then + printf 'error: %s\n' "$REMOTE_ENDPOINT_ERROR" >&2 + printf 'unverified\n' + return 0 + fi + fm_backend_agent_state "$REMOTE_ENDPOINT_BACKEND" "$REMOTE_ENDPOINT_TARGET" 2>/dev/null || printf 'unreadable\n' } print_route() { # - local meta=$1 backend target harness traceparent herdr_session - meta=$(meta_path "$meta") - backend=$(fm_backend_of_meta "$meta") - target=$(fm_backend_target_of_meta "$meta") - harness=$(fm_meta_get "$meta" harness) - traceparent=$(fm_meta_get "$meta" traceparent) + local id=$1 harness traceparent + remote_endpoint_require "$id" + harness=$(fm_meta_get "$REMOTE_ENDPOINT_META" harness) + traceparent=$(fm_meta_get "$REMOTE_ENDPOINT_META" traceparent) printf 'schema=fm-remote-secondmate-control.v1\n' - printf 'backend=%s\n' "$backend" - printf 'target=%s\n' "$target" - [ "$backend" != herdr ] || { - herdr_session=$(fm_meta_get "$meta" herdr_session) - printf 'herdr_session=%s\n' "$herdr_session" - } + printf 'backend=%s\n' "$REMOTE_ENDPOINT_BACKEND" + printf 'target=%s\n' "$REMOTE_ENDPOINT_TARGET" + printf 'herdr_session=%s\n' "$REMOTE_HERDR_SESSION" printf 'harness=%s\n' "$harness" [ -z "$traceparent" ] || printf 'traceparent=%s\n' "$traceparent" } @@ -103,7 +132,7 @@ cmd_route() { cmd_launch() { local id=$1 harness=$2 model=$3 effort=$4 selected_backend=$5 traceparent=${6:-} - local current meta out backend target herdr_session + local current meta out herdr_session validate_id "$id" validate_home "$id" @@ -116,22 +145,16 @@ cmd_launch() { mkdir -p "$CONTROL_STATE" "$CONTROL_DATA" meta=$(meta_path "$id") if [ -f "$meta" ]; then - current=$(state_value "$id") + remote_endpoint_require "$id" + current=$(fm_backend_agent_state "$REMOTE_ENDPOINT_BACKEND" "$REMOTE_ENDPOINT_TARGET" 2>/dev/null || printf 'unreadable\n') case "$current" in alive) - backend=$(fm_backend_of_meta "$meta") - [ "$backend" = herdr ] \ - || die "remote secondmate $id has an alive endpoint recorded on backend '$backend'; refusing reuse until it is explicitly migrated or retired" - herdr_session=$(fm_meta_get "$meta" herdr_session) - [ "$herdr_session" = "$REMOTE_HERDR_SESSION" ] \ - || die "remote secondmate $id has an alive endpoint in Herdr session '${herdr_session:-missing}', expected '$REMOTE_HERDR_SESSION'; refusing reuse until it is explicitly migrated or retired" print_route "$id" return 0 ;; dead) - backend=$(fm_backend_of_meta "$meta") - target=$(fm_backend_target_of_meta "$meta") - fm_backend_kill "$backend" "$target" 2>/dev/null || die "could not remove the confirmed agent-less endpoint" + fm_backend_kill "$REMOTE_ENDPOINT_BACKEND" "$REMOTE_ENDPOINT_TARGET" 2>/dev/null \ + || die "could not remove the confirmed agent-less endpoint" ;; missing) ;; *) die "remote endpoint state is $current; refusing duplicate launch" ;; @@ -156,53 +179,40 @@ cmd_launch() { } cmd_send() { - local id=$1 message=$2 meta backend target + local id=$1 message=$2 validate_id "$id" validate_home "$id" - meta=$(meta_path "$id") - [ -f "$meta" ] || die "remote secondmate has no endpoint metadata" - backend=$(fm_backend_of_meta "$meta") - target=$(fm_backend_target_of_meta "$meta") - [ -n "$target" ] || die "remote secondmate endpoint is unreadable" + remote_endpoint_require "$id" FM_HOME="$TARGET_HOME" FM_ROOT_OVERRIDE="$FM_ROOT" FM_STATE_OVERRIDE="$TARGET_HOME/state" \ - "$SCRIPT_DIR/fm-send.sh" "$target" "$message" + "$SCRIPT_DIR/fm-send.sh" "$REMOTE_ENDPOINT_TARGET" "$message" } cmd_key() { - local id=$1 key=$2 meta target + local id=$1 key=$2 validate_id "$id" validate_home "$id" - meta=$(meta_path "$id") - [ -f "$meta" ] || die "remote secondmate has no endpoint metadata" - target=$(fm_backend_target_of_meta "$meta") + remote_endpoint_require "$id" FM_HOME="$TARGET_HOME" FM_ROOT_OVERRIDE="$FM_ROOT" FM_STATE_OVERRIDE="$TARGET_HOME/state" \ - "$SCRIPT_DIR/fm-send.sh" "$target" --key "$key" + "$SCRIPT_DIR/fm-send.sh" "$REMOTE_ENDPOINT_TARGET" --key "$key" } cmd_capture() { - local id=$1 lines=${2:-20} meta backend target + local id=$1 lines=${2:-20} validate_id "$id" validate_home "$id" case "$lines" in ''|*[!0-9]*|0) die "capture line count must be positive" ;; esac [ "$lines" -le 100 ] || die "capture line count exceeds 100" - meta=$(meta_path "$id") - [ -f "$meta" ] || die "remote secondmate has no endpoint metadata" - backend=$(fm_backend_of_meta "$meta") - target=$(fm_backend_target_of_meta "$meta") - fm_backend_capture "$backend" "$target" "$lines" "fm-$id" | head -c 65536 + remote_endpoint_require "$id" + fm_backend_capture "$REMOTE_ENDPOINT_BACKEND" "$REMOTE_ENDPOINT_TARGET" "$lines" "fm-$id" | head -c 65536 } cmd_observe() { - local id=$1 meta backend target harness + local id=$1 harness validate_id "$id" validate_home "$id" - meta=$(meta_path "$id") - [ -f "$meta" ] || die "remote secondmate has no endpoint metadata" - backend=$(fm_backend_of_meta "$meta") - target=$(fm_backend_target_of_meta "$meta") - harness=$(fm_meta_get "$meta" harness) - [ -n "$target" ] || die "remote secondmate endpoint is unreadable" - fm_pending_reply_backend_observation "$backend" "$target" "fm-$id" "$harness" + remote_endpoint_require "$id" + harness=$(fm_meta_get "$REMOTE_ENDPOINT_META" harness) + fm_pending_reply_backend_observation "$REMOTE_ENDPOINT_BACKEND" "$REMOTE_ENDPOINT_TARGET" "fm-$id" "$harness" printf '\n' } @@ -258,7 +268,7 @@ cmd_retire() { return 0 fi [ -z "$force" ] || [ "$force" = --force ] || usage - [ -f "$(meta_path "$id")" ] || die "remote secondmate has no endpoint metadata to retire safely" + remote_endpoint_require "$id" FM_HOME="$TARGET_HOME" FM_ROOT_OVERRIDE="$FM_ROOT" FM_STATE_OVERRIDE="$TARGET_HOME/state" \ FM_CONFIG_OVERRIDE="$TARGET_HOME/config" "$SCRIPT_DIR/fm-guard.sh" || true if [ -n "$force" ]; then diff --git a/tests/fm-remote-secondmate-lifecycle-e2e.test.sh b/tests/fm-remote-secondmate-lifecycle-e2e.test.sh index e50b9369181..5c93743c66e 100755 --- a/tests/fm-remote-secondmate-lifecycle-e2e.test.sh +++ b/tests/fm-remote-secondmate-lifecycle-e2e.test.sh @@ -504,6 +504,42 @@ publish_healthy_watcher_identity "$PARENT/state" "$PARENT" "$ROOT/bin/fm-watch.s || fail "remote endpoint delivery observation did not execute on its own host" pass "remote spawn launches on the remote-local backend and records a host-qualified route" +remote_route_meta="$REMOTE_HOME/state/parent-route/ios.meta" +cp "$remote_route_meta" "$TMP_ROOT/remote-ios-before-default-session.meta" +legacy_pane=$(sed -n 's/^herdr_pane_id=//p' "$remote_route_meta") +awk -v pane="$legacy_pane" ' + /^window=/ { print "window=default:" pane; next } + /^herdr_session=/ { print "herdr_session=default"; next } + { print } +' "$TMP_ROOT/remote-ios-before-default-session.meta" > "$remote_route_meta" +cp "$HERDR_LOG" "$TMP_ROOT/herdr-before-default-session.log" +[ "$(remote_env "$ROOT/bin/fm-on.sh" ios fm-remote-secondmate-control.sh state ios 2>/dev/null)" = unverified ] \ + || fail "legacy default-session metadata was not classified unverified" +if remote_env "$ROOT/bin/fm-on.sh" ios fm-remote-secondmate-control.sh route ios >/dev/null 2>&1 \ + || remote_env "$ROOT/bin/fm-on.sh" ios fm-remote-secondmate-control.sh send ios probe >/dev/null 2>&1 \ + || remote_env "$ROOT/bin/fm-on.sh" ios fm-remote-secondmate-control.sh key ios Enter >/dev/null 2>&1 \ + || remote_env "$ROOT/bin/fm-on.sh" ios fm-remote-secondmate-control.sh capture ios >/dev/null 2>&1 \ + || remote_env "$ROOT/bin/fm-on.sh" ios fm-remote-secondmate-control.sh observe ios >/dev/null 2>&1 \ + || remote_env "$ROOT/bin/fm-on.sh" ios fm-remote-secondmate-control.sh retire ios --force >/dev/null 2>&1 \ + || remote_env "$ROOT/bin/fm-on.sh" ios fm-remote-secondmate-control.sh launch ios codex - - herdr >/dev/null 2>&1; then + fail "legacy default-session metadata remained operational" +fi +cmp -s "$TMP_ROOT/herdr-before-default-session.log" "$HERDR_LOG" \ + || fail "legacy default-session metadata caused a Herdr operation" +assert_present "$REMOTE_HOME" "refused legacy retirement removed the remote home" +assert_grep 'herdr_session=default' "$remote_route_meta" "refused legacy retirement rewrote endpoint metadata" + +awk -v pane="$legacy_pane" ' + /^window=/ { print "window=default:" pane; next } + { print } +' "$TMP_ROOT/remote-ios-before-default-session.meta" > "$remote_route_meta" +[ "$(remote_env "$ROOT/bin/fm-on.sh" ios fm-remote-secondmate-control.sh state ios 2>/dev/null)" = unverified ] \ + || fail "mismatched fm-remote target was not classified unverified" +cmp -s "$TMP_ROOT/herdr-before-default-session.log" "$HERDR_LOG" \ + || fail "mismatched fm-remote target caused a Herdr operation" +mv -f "$TMP_ROOT/remote-ios-before-default-session.meta" "$remote_route_meta" +pass "legacy and mismatched remote endpoints fail closed before backend access" + cp "$PARENT/state/ios.meta" "$TMP_ROOT/parent-ios-before-nonherdr.meta" cp "$PARENT/data/secondmates.md" "$TMP_ROOT/registry-before-nonherdr.md" set +e @@ -548,8 +584,8 @@ remote_env "$ROOT/bin/fm-on.sh" ios fm-remote-secondmate-control.sh launch ios c legacy_alive_rc=$? set -e [ "$legacy_alive_rc" -ne 0 ] || fail "remote control reused an alive legacy tmux endpoint" -assert_grep "alive endpoint recorded on backend 'tmux'" "$TMP_ROOT/legacy-alive-refusal.out" \ - "remote refusal did not name the alive endpoint's recorded backend" +assert_grep "endpoint is recorded on backend 'tmux', expected 'herdr'" "$TMP_ROOT/legacy-alive-refusal.out" \ + "remote refusal did not name the endpoint's recorded backend" cmp -s "$TMP_ROOT/remote-ios-legacy-before-refusal.meta" "$remote_route_meta" \ || fail "remote refusal changed the legacy endpoint metadata" assert_present "$TMUX_STATE" "remote refusal killed the alive legacy endpoint" @@ -802,7 +838,7 @@ printf 'fm-ios|%s\n' "$REMOTE_HOME" > "$TMUX_STATE" tmux_state_before=$(cat "$TMUX_STATE") launches_before_legacy=$(grep -c '^tab create' "$HERDR_LOG" || true) BOOT_LEGACY=$(remote_env "$ROOT/bin/fm-bootstrap.sh") -assert_contains "$BOOT_LEGACY" "SECONDMATE_LIVENESS: secondmate ios: skipped: alive remote endpoint is recorded on backend 'tmux'; migrate or retire it explicitly" \ +assert_contains "$BOOT_LEGACY" "SECONDMATE_LIVENESS: secondmate ios: skipped: remote endpoint state is unverified on remote-mac" \ "liveness accepted an alive legacy remote backend" cmp -s "$TMP_ROOT/remote-ios-liveness-legacy.meta" "$remote_route_meta" \ || fail "liveness rewrote the alive legacy endpoint metadata" From fe61e76d9762d2351b253b7eb1e8fb4e18161ed4 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Tue, 4 Aug 2026 01:15:34 -0700 Subject: [PATCH 3/4] no-mistakes(review): Isolate fm-remote launch agent from interactive default --- bin/fm-remote-doctor.sh | 6 ++--- docs/remote-secondmates.md | 4 ++-- tests/fm-remote-doctor.test.sh | 44 ++++++++++++++++++++++++++++++---- 3 files changed, 45 insertions(+), 9 deletions(-) diff --git a/bin/fm-remote-doctor.sh b/bin/fm-remote-doctor.sh index 343fc33ae69..4e76c755d72 100755 --- a/bin/fm-remote-doctor.sh +++ b/bin/fm-remote-doctor.sh @@ -12,8 +12,8 @@ # A remote second mate always runs on the Herdr backend in the dedicated # fm-remote session, so readiness is more than tool resolution. herdr must # resolve, that server must be reachable, and on macOS the Firstmate-owned -# launch agent dev.firstmate.herdr at -# ~/Library/LaunchAgents/dev.firstmate.herdr.plist must exist, carry +# launch agent dev.firstmate.herdr.fm-remote at +# ~/Library/LaunchAgents/dev.firstmate.herdr.fm-remote.plist must exist, carry # LimitLoadToSessionType=Aqua, and be loaded into the console user's gui/ # domain, so the server belongs to the GUI login session and survives logout and # SSH disconnection. SSH cannot create an Aqua session, so a host with no GUI @@ -54,7 +54,7 @@ SCRIPT_DIR=${SCRIPT_SELF%/*} SCRIPT_DIR=$(CDPATH='' cd -- "$SCRIPT_DIR" && pwd -P) REQUIRED_TOOLS=(git jq) OPTIONAL_TOOLS=(tmux treehouse no-mistakes tasks-axi claude codex opencode pi grok kimi) -LAUNCH_AGENT_LABEL=dev.firstmate.herdr +LAUNCH_AGENT_LABEL=dev.firstmate.herdr.fm-remote # The dedicated remote-secondmate session. The user's interactive Herdr work # remains in the separate default session, which this readiness check never # requires or changes. diff --git a/docs/remote-secondmates.md b/docs/remote-secondmates.md index 6f17ad37c6f..064ab5fad1f 100644 --- a/docs/remote-secondmates.md +++ b/docs/remote-secondmates.md @@ -83,8 +83,8 @@ The script's own header owns the full line protocol. bin/fm-on.sh fm-remote-doctor.sh --fix ``` -It writes and reloads the Firstmate-owned launch agent `dev.firstmate.herdr` at `~/Library/LaunchAgents/dev.firstmate.herdr.plist`, scoped with `LimitLoadToSessionType=Aqua` so it belongs to the GUI login session, bootstraps and starts the `fm-remote` server in `gui/`, starts that server directly on a host where no launch agent applies, and recreates the `~/.local/bin/fm-remote-entrypoint.sh` symlink when it is absent. -The launch agent owns only the remote-secondmate server and does not start, stop, or require the user's interactive `default` session. +It writes and reloads the Firstmate-owned launch agent `dev.firstmate.herdr.fm-remote` at `~/Library/LaunchAgents/dev.firstmate.herdr.fm-remote.plist`, scoped with `LimitLoadToSessionType=Aqua` so it belongs to the GUI login session, bootstraps and starts the `fm-remote` server in `gui/`, starts that server directly on a host where no launch agent applies, and recreates the `~/.local/bin/fm-remote-entrypoint.sh` symlink when it is absent. +The dedicated launch agent owns only the remote-secondmate server and does not inspect, rewrite, start, stop, or require the user's interactive `default` session or its `dev.firstmate.herdr` launch agent. It re-derives every check from the host afterwards, so what it prints is the state after the repair rather than the intent of one. These steps are never automated and are always reported rather than silently attempted, because SSH cannot create a GUI session from nothing: diff --git a/tests/fm-remote-doctor.test.sh b/tests/fm-remote-doctor.test.sh index d277a8346db..c05eed4896c 100755 --- a/tests/fm-remote-doctor.test.sh +++ b/tests/fm-remote-doctor.test.sh @@ -13,7 +13,8 @@ set -u command -v jq >/dev/null 2>&1 || { echo "skip: jq not found (the herdr adapter parses its JSON)"; exit 0; } TMP_ROOT=$(fm_test_tmproot fm-remote-doctor) -LABEL=dev.firstmate.herdr +LABEL=dev.firstmate.herdr.fm-remote +INTERACTIVE_LABEL=dev.firstmate.herdr CASE_N=0 # A fixture must be able to present a host with NO herdr, so the doctor never @@ -40,6 +41,7 @@ new_case() { CASE_FORBIDDEN_LOG="$CASE_STATE/forbidden.log" CASE_HERDR_RUNNING="$CASE_STATE/herdr.running" CASE_PLIST="$CASE_HOME/Library/LaunchAgents/$LABEL.plist" + CASE_INTERACTIVE_PLIST="$CASE_HOME/Library/LaunchAgents/$INTERACTIVE_LABEL.plist" mkdir -p "$CASE_BIN" "$CASE_HOME" "$CASE_STATE" printf 'false\n' > "$CASE_HERDR_RUNNING" : > "$CASE_LAUNCHCTL_LOG" @@ -59,14 +61,24 @@ domain=${2:-} case "${1:-}" in print) case "$domain" in - */*/*) [ -f "$FM_FAKE_STATE/loaded-contract" ] || exit 113; cat "$FM_FAKE_STATE/loaded-contract" ;; + */dev.firstmate.herdr.fm-remote) + [ -f "$FM_FAKE_STATE/loaded-contract" ] || exit 113 + cat "$FM_FAKE_STATE/loaded-contract" + ;; + */dev.firstmate.herdr) + [ -f "$FM_FAKE_STATE/interactive-loaded" ] || exit 113 + printf 'interactive default job\n' + ;; *) [ -f "$FM_FAKE_STATE/gui-session" ] || exit 113 ;; esac exit 0 ;; bootout) [ ! -f "$FM_FAKE_STATE/bootout-fail" ] || { printf 'Boot-out failed: operation not permitted\n' >&2; exit 6; } - rm -f "$FM_FAKE_STATE/loaded-contract" + case "$domain" in + */dev.firstmate.herdr.fm-remote) rm -f "$FM_FAKE_STATE/loaded-contract" ;; + */dev.firstmate.herdr) rm -f "$FM_FAKE_STATE/interactive-loaded" ;; + esac exit 0 ;; bootstrap) @@ -212,6 +224,25 @@ pass "a missing herdr CLI is a human gap that --fix never claims to close" # --- an absent launch agent is a fixable gap that --fix installs ------------- new_case Darwin with-herdr gui +mkdir -p "$(dirname "$CASE_INTERACTIVE_PLIST")" +cat > "$CASE_INTERACTIVE_PLIST" < + + + Label + $INTERACTIVE_LABEL + ProgramArguments + + $CASE_BIN/herdr + server + --session + default + + + +XML +cp "$CASE_INTERACTIVE_PLIST" "$CASE_STATE/interactive-before.plist" +touch "$CASE_STATE/interactive-loaded" doctor expect_code 1 "$DOCTOR_RC" "a host with no launch agent was reported ready" assert_contains "$DOCTOR_OUT" 'check herdr=ok:' "the fake herdr CLI was not detected" @@ -239,8 +270,13 @@ assert_grep 'server' "$CASE_PLIST" "the written plist does not assert_grep 'fm-remote' "$CASE_PLIST" "the written plist does not pin the remote-secondmate session" assert_no_grep 'default' "$CASE_PLIST" "the written plist pins the interactive default session" assert_grep "gui/$(id -u)" "$CASE_LAUNCHCTL_LOG" "the launch agent was not bootstrapped into the GUI domain" +cmp -s "$CASE_STATE/interactive-before.plist" "$CASE_INTERACTIVE_PLIST" \ + || fail "the fm-remote repair rewrote the interactive default launch agent" +assert_present "$CASE_STATE/interactive-loaded" "the fm-remote repair unloaded the interactive default launch agent" +assert_no_grep "gui/$(id -u)/$INTERACTIVE_LABEL$" "$CASE_LAUNCHCTL_LOG" \ + "the fm-remote repair inspected or controlled the interactive default launch agent" assert_no_dangerous_calls "the repair reached for auto-login, FileVault, or the keychain" -pass "--fix installs, Aqua-scopes, loads, and starts the Firstmate herdr launch agent" +pass "--fix installs the dedicated fm-remote launch agent without touching default" PLIST_BEFORE=$(cat "$CASE_PLIST") : > "$CASE_LAUNCHCTL_LOG" From 5211284a756edf7b3326dc4cbb222926923c4de4 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Tue, 4 Aug 2026 01:33:30 -0700 Subject: [PATCH 4/4] no-mistakes(document): Document shared remote Herdr retirement safety --- bin/fm-remote-secondmate-control.sh | 2 ++ docs/remote-secondmates.md | 2 ++ ...fm-remote-secondmate-lifecycle-e2e.test.sh | 19 ++++++++++++++++++- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/bin/fm-remote-secondmate-control.sh b/bin/fm-remote-secondmate-control.sh index ed986044d3f..cce92873ef4 100755 --- a/bin/fm-remote-secondmate-control.sh +++ b/bin/fm-remote-secondmate-control.sh @@ -24,6 +24,8 @@ # 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. +# Retirement closes only this secondmate's panes or workspace and never +# stops fm-remote or removes a sibling secondmate's workspace or panes. # # The optional launch traceparent is the per-task W3C trace-context carrier the # PARENT home resolved for this secondmate; this host only delivers it to the diff --git a/docs/remote-secondmates.md b/docs/remote-secondmates.md index 064ab5fad1f..f641569501b 100644 --- a/docs/remote-secondmates.md +++ b/docs/remote-secondmates.md @@ -128,6 +128,7 @@ bin/fm-spawn.sh --secondmate The primary resolves the verified secondmate harness and optional model and effort, runs the same readiness gate the seed runs, transfers the inherited-material allowlist, and asks the remote host to launch on Herdr in `fm-remote`. All remote secondmates on one host share `fm-remote` and retain separate `2ndmate-` workspaces inside it. An explicit request for any other backend is refused rather than honored, and the remote host refuses one too. +An existing remote endpoint recorded in another Herdr session, including `default`, is classified as unverified and left untouched; launch, liveness recovery, control, and retirement refuse it until an operator explicitly migrates it instead of attempting a live cutover. A launch after a host has drifted out of readiness fails with the doctor's own gap text instead of leaving a half-created endpoint. Raw launch commands are not accepted for remote secondmates. Backends that already refuse secondmate launch, currently Orca and cmux, remain unsupported on the remote host. @@ -183,6 +184,7 @@ bin/fm-teardown.sh ``` Retirement is executed on the configured host and refuses while the remote home has child work, while the primary has an unfinished backlog outbox, or while a routed reply remains unresolved. +It closes only the retiring secondmate's panes or `2ndmate-` workspace in `fm-remote`; it never stops the shared session or removes a sibling secondmate's workspace or panes. SSH exit 255 preserves both the route and local records because completion is unknown. `--force` remains the explicit discard path and requires the same captain authority as local secondmate discard. No generic remote delete or write surface exists: remote writes are confined to inherited allowlist files and backlog handoff scratch files, and remote home removal is reachable only through guarded secondmate retirement. diff --git a/tests/fm-remote-secondmate-lifecycle-e2e.test.sh b/tests/fm-remote-secondmate-lifecycle-e2e.test.sh index 5c93743c66e..4f30e749298 100755 --- a/tests/fm-remote-secondmate-lifecycle-e2e.test.sh +++ b/tests/fm-remote-secondmate-lifecycle-e2e.test.sh @@ -876,10 +876,20 @@ pass "unreachable remote state remains unknown with no local respawn or failover # Retirement delegates its safety check to the remote home. An in-flight child # record refuses cleanup and preserves both machines' durable routes. +# A sibling remote secondmate workspace shares fm-remote and must survive every +# refusal and the eventual successful retirement of ios. # This fixture overrides FM_ROOT for transport, so teardown's root-owned guard # sees the fixture root rather than the source script path used by fm-send. publish_healthy_watcher_identity "$PARENT/state" "$PARENT" "$REMOTE_ROOT/bin/fm-watch.sh" resolve_ios_pending +SIBLING_CREATE=$("$REMOTE_ROOT/bin/herdr" workspace create --cwd "$REMOTE_ROOT" \ + --label 2ndmate-macos --no-focus --session fm-remote) +SIBLING_WORKSPACE=$(printf '%s' "$SIBLING_CREATE" | jq -r '.result.workspace.workspace_id') +SIBLING_PANE=$(printf '%s' "$SIBLING_CREATE" | jq -r '.result.root_pane.pane_id') +[ -n "$SIBLING_WORKSPACE" ] && [ "$SIBLING_WORKSPACE" != null ] \ + || fail "the shared-session sibling fixture did not create a workspace" +[ -n "$SIBLING_PANE" ] && [ "$SIBLING_PANE" != null ] \ + || fail "the shared-session sibling fixture did not create a pane" printf 'kind=ship\n' > "$REMOTE_HOME/state/child.meta" rm -rf "$PARENT/state/procevent" : > "$PARENT/state/procevent" @@ -964,6 +974,13 @@ fi assert_absent "$REMOTE_HOME" "remote retirement did not remove the remote home" assert_absent "$PARENT/state/ios.meta" "remote retirement did not remove parent metadata" assert_no_grep '- ios ' "$PARENT/data/secondmates.md" "remote retirement did not remove the registry route" -pass "remote retirement refuses child work, then cleans the same host through existing guards" +jq -e --arg workspace "$SIBLING_WORKSPACE" --arg pane "$SIBLING_PANE" ' + any(.workspaces[]; .workspace_id == $workspace and .label == "2ndmate-macos") + and any(.tabs[]; .workspace_id == $workspace and .pane_id == $pane) +' "$HERDR_STATE" >/dev/null \ + || fail "remote retirement removed the sibling secondmate workspace or pane from fm-remote" +assert_no_grep 'session stop' "$HERDR_LOG" "remote retirement stopped the shared fm-remote session" +assert_no_grep 'server stop' "$HERDR_LOG" "remote retirement stopped the shared fm-remote server" +pass "remote retirement refuses child work, then removes only its own endpoint while a shared-session sibling survives" echo "ALL TESTS PASSED"