diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index cf908fa11e8..266dfbd53ea 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -536,14 +536,51 @@ fm_backend_herdr_version_check() { return 0 } +# fm_backend_herdr_socket_session_name: derive the session name encoded in a +# herdr control-socket path, for the container case where HERDR_SESSION is not +# forwarded but HERDR_SOCKET_PATH is (bin/fm-backend.sh's HERDR_SOCKET_PATH +# detection fallback). Verified shape (docs/verification/runtime-backends.md, +# fm_backend_herdr_socket_path's own comment): a NAMED session's socket is +# exactly "/sessions//herdr.sock", while the default +# session's socket has no "sessions" component at all +# ("/herdr.sock"). Prints the derived name only when the path +# matches the named-session shape; prints nothing for the default shape or any +# path that does not match (callers fall back to "default", which is already +# correct for both of those cases). +fm_backend_herdr_socket_session_name() { # + local path=$1 name + case "$path" in + */sessions/*/herdr.sock) + name=$(basename "$(dirname "$path")") + [ -n "$name" ] && printf '%s' "$name" + ;; + esac +} + # fm_backend_herdr_session: resolve which named herdr session this normal # spawn/op uses. HERDR_SESSION mirrors tmux's $TMUX ambient-selection for # adapter workspace/tab/pane operations: an operator (or firstmate's own -# isolated test harness) sets it explicitly; absent means herdr's own -# "default" session. Do not use HERDR_SESSION alone for destructive test -# cleanup; tests/herdr-test-safety.sh documents and guards that path. +# isolated test harness) sets it explicitly and wins outright. Otherwise, when +# HERDR_SESSION is unset but HERDR_SOCKET_PATH is (the container fallback +# bin/fm-backend.sh's fm_backend_detect uses when HERDR_ENV=1 was not +# forwarded), the session name is derived directly from that socket path so +# every downstream herdr call (fm_backend_herdr_server_ensure and everything +# built on it) provably targets the SAME session fm_backend_detect just +# verified the socket belongs to, rather than independently guessing "default" +# and risking a silent, disconnected in-container server. See +# tests/fm-backend-herdr-container-session-e2e.test.sh for the regression test +# proof. Only when neither is set (or the socket path does not match the known +# shape) does this fall back to herdr's own "default" session. Do not use +# HERDR_SESSION alone for destructive test cleanup; tests/herdr-test-safety.sh +# documents and guards that path. fm_backend_herdr_session() { - printf '%s' "${HERDR_SESSION:-default}" + local derived + [ -n "${HERDR_SESSION:-}" ] && { printf '%s' "$HERDR_SESSION"; return 0; } + if [ -n "${HERDR_SOCKET_PATH:-}" ]; then + derived=$(fm_backend_herdr_socket_session_name "$HERDR_SOCKET_PATH") + [ -n "$derived" ] && { printf '%s' "$derived"; return 0; } + fi + printf 'default' } # fm_backend_herdr_projection_id: generate a compact 128-bit base64url token. diff --git a/bin/fm-backend.sh b/bin/fm-backend.sh index f4fdde29436..ccc4213851b 100644 --- a/bin/fm-backend.sh +++ b/bin/fm-backend.sh @@ -96,7 +96,7 @@ fm_backend_is_known() { # # tmux started inside a herdr pane, so $TMUX is checked first and wins over # HERDR_ENV=1 in that nested case. herdr injects HERDR_ENV=1 (plus # HERDR_SOCKET_PATH/HERDR_PANE_ID) into every process it manages a pane for; -# HERDR_ENV=1 alone (no $TMUX) selects herdr. cmux injects CMUX_WORKSPACE_ID +# HERDR_ENV=1 or HERDR_SOCKET_PATH (when a socket is available) alone (no $TMUX) selects herdr. cmux injects CMUX_WORKSPACE_ID # (plus CMUX_SURFACE_ID/CMUX_SOCKET_PATH and the legacy CMUX_TAB_ID/ # CMUX_PANEL_ID aliases) into every terminal surface it spawns - verified from # the shipped source (`TerminalSurface+StartupEnvironment.swift`'s @@ -134,7 +134,7 @@ fm_backend_is_known() { # # tmux, where the tmux server reparents to launchd and the chain never # reaches cmux - which is fine, because $TMUX already won there. # Callers needing the winning signal read FM_BACKEND_DETECT_SIGNAL (set to -# TMUX, HERDR_ENV, CMUX_WORKSPACE_ID, bundle-id, or ancestry) and +# TMUX, HERDR_ENV, HERDR_SOCKET_PATH, CMUX_WORKSPACE_ID, bundle-id, or ancestry) and # FM_BACKEND_DETECTED after a direct (non-command-substitution) call. FM_BACKEND_CMUX_BUNDLE_ID="com.cmuxterm.app" @@ -153,6 +153,22 @@ fm_backend_detect() { printf 'herdr' return 0 fi + # Fallback for containers where Herdr doesn't inject HERDR_ENV=1 but + # HERDR_SOCKET_PATH is available (volume-mounted or env-forwarded into the + # container). This handles the case where Herdr spawns a crewmate or Pi + # process inside a devcontainer and the socket is accessible from within. + # bin/backends/herdr.sh's fm_backend_herdr_session() derives the session + # name straight from this same HERDR_SOCKET_PATH when HERDR_SESSION is not + # separately forwarded, so every downstream operational call agrees with + # this detection about which session and socket it is talking to instead of + # independently guessing "default". See tests/fm-backend-herdr-container-session-e2e.test.sh + # for the regression test proof. + if [ -n "${HERDR_SOCKET_PATH:-}" ] && [ -S "$HERDR_SOCKET_PATH" ]; then + FM_BACKEND_DETECTED=herdr + FM_BACKEND_DETECT_SIGNAL=HERDR_SOCKET_PATH + printf 'herdr' + return 0 + fi if [ -n "${CMUX_WORKSPACE_ID:-}" ]; then FM_BACKEND_DETECTED=cmux FM_BACKEND_DETECT_SIGNAL=CMUX_WORKSPACE_ID diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 34b8232170e..9322e92d445 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -316,6 +316,7 @@ family_for_basename() { fm-afk-inject-herdr-e2e.test.sh|fm-afk-launch.test.sh|fm-backend-autodetect-smoke.test.sh|\ fm-backend-herdr-eventwait-smoke.test.sh|fm-backend-herdr-presentation-e2e.test.sh|\ fm-backend-herdr-launcher-workspace-e2e.test.sh|\ + fm-backend-herdr-container-session-e2e.test.sh|\ fm-backend-herdr-prune-safety-e2e.test.sh|fm-backend-herdr-respawn-idem-e2e.test.sh|\ fm-backend-herdr-focus-flash-e2e.test.sh|\ fm-backend-herdr-stale-active-tab-e2e.test.sh|\ diff --git a/docs/architecture.md b/docs/architecture.md index eafab774866..59c97b38f55 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -258,7 +258,7 @@ The runtime backend is the session-provider layer below firstmate's scripts. It owns task endpoint creation, bounded capture, text/key sends, current-path reads for spawn-time worktree discovery when the backend does not create the worktree itself, live-window fallback lookup, agent-process liveness probes where verified, and endpoint teardown. `bin/fm-backend.sh` centralizes backend selection, `state/.meta` helpers, metadata-only cleanup identity validation, selector resolution, and operation dispatch; `bin/backends/tmux.sh` is the verified reference adapter ([`docs/tmux-backend.md`](tmux-backend.md)), `bin/backends/herdr.sh` (P2) has its own required CI lane ([`docs/herdr-backend.md`](herdr-backend.md)), and `bin/backends/zellij.sh` (P3), `bin/backends/orca.sh` (P4), and `bin/backends/cmux.sh` (P5) remain experimental task-spawn adapters with no dedicated real-backend CI lane. [`configuration.md`](configuration.md#runtime-backend-configbackend--fm_backend) owns new-spawn backend selection precedence and authorization. -Runtime auto-detection is innermost-first: `$TMUX` wins over `HERDR_ENV=1`, which wins over cmux's primary `CMUX_WORKSPACE_ID` marker and documented fallback signals; auto-detected Herdr stays silent like tmux, while auto-detected cmux prints a one-time notice because cmux remains experimental, and zellij and orca are never auto-detected (only explicit selection). +Runtime auto-detection is innermost-first: `$TMUX` wins over `HERDR_ENV=1` (with `HERDR_SOCKET_PATH` as a fallback for containers), which wins over cmux's primary `CMUX_WORKSPACE_ID` marker and documented fallback signals; auto-detected Herdr stays silent like tmux, while auto-detected cmux prints a one-time notice because cmux remains experimental, and zellij and orca are never auto-detected (only explicit selection). Unknown backend names fail loudly. For compatibility, default tmux tasks do not write `backend=tmux`; every reader treats a missing `backend=` field as `tmux`. `fm-watch.sh` decides each window's busy state through the semantic contract above rather than by polling the backend for rendered text. diff --git a/docs/configuration.md b/docs/configuration.md index d06de2f5efa..3090e19d2fa 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -423,7 +423,6 @@ For spawn-capable adapters, the runtime session-provider backend controls where | `cmux` | Experimental; no dedicated real-backend CI lane | [`docs/cmux-backend.md`](cmux-backend.md) | Treehouse remains the worktree provider for tmux, herdr, zellij, and cmux, since herdr, zellij, and cmux are session providers only; Orca provides both the task worktree and terminal endpoint. - ### Backend selection order New spawns choose the backend in this order: @@ -432,7 +431,7 @@ New spawns choose the backend in this order: A later task cannot inherit that authority by analogy. 2. `FM_BACKEND`. 3. The first non-empty line of local, gitignored `config/backend`. -4. Runtime auto-detection from `$TMUX`, `HERDR_ENV=1`, or cmux runtime signals. +4. Runtime auto-detection from `$TMUX`, `HERDR_ENV=1`, `HERDR_SOCKET_PATH` (when a socket is available), or cmux runtime signals 5. Default `tmux`. If more than one runtime marker is present, detection resolves innermost-first: `$TMUX` is checked before `HERDR_ENV=1`, which is checked before cmux's primary `CMUX_WORKSPACE_ID` marker and its documented fallback signals - tmux or herdr started from inside a cmux terminal is the innermost, currently-executing layer, while cmux itself (a terminal application, not a nestable multiplexer) is always checked last. @@ -530,7 +529,6 @@ It currently supports only `tmux` and `herdr` supervisor panes. Set `FM_SUPERVISOR_BACKEND=tmux|herdr` and `FM_SUPERVISOR_TARGET=` to override both axes explicitly; for herdr the target is `":"`. Without overrides, backend detection uses `$TMUX_PANE` first, then `HERDR_ENV=1` with `HERDR_PANE_ID`, then falls back to `tmux`. - That keeps a tmux pane nested inside herdr on the tmux transport, matching the runtime backend's innermost-first rule. Target detection uses `FM_SUPERVISOR_TARGET`, then `$TMUX_PANE`, then `"${HERDR_SESSION:-default}:${HERDR_PANE_ID}"` under herdr, then the legacy `firstmate:0` tmux fallback with a warning. diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index c28afacd8be..0b24b0a1796 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -51,8 +51,8 @@ Select Herdr in any of these ways: - An explicit request to Firstmate. A remote second-mate agent is the one case with no choice: it always runs on Herdr, and [`remote-secondmates.md`](remote-secondmates.md) owns that requirement and the readiness its host must meet. - -Herdr is also auto-detected when the primary runs natively under `HERDR_ENV=1` and is not inside tmux. +It is also auto-detected when the primary runs natively under `HERDR_ENV=1` and is not inside tmux, or when running in a container where `HERDR_SOCKET_PATH` is available but `HERDR_ENV=1` is not injected. +In that container fallback, every downstream herdr call resolves its target session through `fm_backend_herdr_session()` (`bin/backends/herdr.sh`): an explicit `HERDR_SESSION` still wins outright, and otherwise, when only `HERDR_SOCKET_PATH` was forwarded, the session name is derived directly from that socket path's verified `.../sessions//herdr.sock` shape so detection and every operational call agree on the same session and socket instead of guessing `"default"` and risking a silent, disconnected in-container server. A tmux pane nested inside Herdr resolves to tmux because the innermost multiplexer wins. An auto-detected Herdr spawn stays silent, matching the verified tmux default path. diff --git a/tests/fm-backend-herdr-container-session-e2e.test.sh b/tests/fm-backend-herdr-container-session-e2e.test.sh new file mode 100755 index 00000000000..9eaca9229d4 --- /dev/null +++ b/tests/fm-backend-herdr-container-session-e2e.test.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# tests/fm-backend-herdr-container-session-e2e.test.sh - real-herdr end-to-end +# proof for the container session-binding fix (PR #2's HERDR_SOCKET_PATH +# detection fallback + the session-resolution completeness gap). +# +# PR #2 taught bin/fm-backend.sh's fm_backend_detect() to report "herdr" for a +# container that only has HERDR_SOCKET_PATH forwarded (no HERDR_ENV/HERDR_SESSION), +# but left every downstream operational call in bin/backends/herdr.sh resolving +# its target purely by HERDR_SESSION (default "default"), independent of that +# socket. This suite proves the closed gap against a REAL herdr binary: a +# devcontainer-shaped process that has ONLY HERDR_SOCKET_PATH set (HERDR_SESSION +# and HERDR_ENV both absent, exactly the detection fallback's precondition) +# resolves fm_backend_herdr_session() to the socket's OWN session, and an +# operational call chain resolved that way (fm_backend_herdr_container_ensure, +# same as a real spawn) reaches the already-running server behind that socket +# instead of silently starting a brand-new, disconnected one. +# +# Safety: this suite runs against its own isolated, named, throwaway +# HERDR_SESSION (never the default session), created and torn down only +# through tests/herdr-test-safety.sh's guarded helpers (bin/fm-herdr-lab.sh), +# mirroring tests/fm-backend-herdr-smoke.test.sh. The container-shaped call +# below asserts the socket-derived session name matches the real lab session +# BEFORE making any operational call, so a regression can never silently fall +# back to touching this machine's actual "default" session. Skips cleanly when +# herdr (or jq) is not installed. +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +fail() { printf 'not ok - %s\n' "$1" >&2; cleanup_all; exit 1; } +pass() { printf 'ok - %s\n' "$1"; } + +command -v herdr >/dev/null 2>&1 || { echo "skip: herdr not found"; exit 0; } +command -v jq >/dev/null 2>&1 || { echo "skip: jq not found (required by the herdr adapter)"; exit 0; } + +# shellcheck source=tests/herdr-test-safety.sh +. "$ROOT/tests/herdr-test-safety.sh" + +# A Herdr pane identity inherited from the terminal this test was launched in +# must not leak into the "only HERDR_SOCKET_PATH is set" scenario below. +herdr_forget_inherited_pane + +SESSION="fm-lab-container-session-$$" +export HERDR_SESSION="$SESSION" +cleanup_all() { + herdr_safe_stop_and_delete "$SESSION" +} +trap cleanup_all EXIT +fm_herdr_lab_prepare "$SESSION" || fail "could not prepare isolated Herdr lab session" + +# shellcheck source=/dev/null +. "$ROOT/bin/fm-backend.sh" +fm_backend_source herdr || fail "fm_backend_source herdr failed" + +# Stand up the isolated lab session's real server (normal host-side operation, +# HERDR_SESSION set explicitly - not the scenario under test yet). +fm_backend_herdr_server_ensure "$SESSION" \ + || fail "could not bring up the isolated lab session's real herdr server" + +SOCKET=$(fm_backend_herdr_socket_path "$SESSION") +[ -n "$SOCKET" ] || fail "could not resolve the isolated lab session's real control-socket path" +[ -S "$SOCKET" ] || fail "resolved socket path '$SOCKET' is not actually a socket" +pass "real herdr: resolved the isolated lab session's real control-socket path" + +# --- fm_backend_herdr_session: derives the exact lab session from the socket alone --- +# +# This is the devcontainer shape: HERDR_ENV and HERDR_SESSION both absent, +# only HERDR_SOCKET_PATH forwarded/mounted - exactly fm_backend_detect's +# HERDR_SOCKET_PATH fallback precondition (bin/fm-backend.sh). +DERIVED=$(unset HERDR_ENV HERDR_SESSION; HERDR_SOCKET_PATH="$SOCKET" fm_backend_herdr_session) +[ "$DERIVED" = "$SESSION" ] \ + || fail "fm_backend_herdr_session should derive '$SESSION' from its real socket path alone, got '$DERIVED'" +pass "real herdr: fm_backend_herdr_session derives the exact lab session name from a real HERDR_SOCKET_PATH alone" + +# --- the operational call chain this env resolves to reaches the SAME live +# --- server, not a fresh disconnected one --- +BEFORE_SOCKET=$SOCKET + +# Reproduce the exact devcontainer env (only HERDR_SOCKET_PATH set) and run +# the same operational entry point a real spawn uses +# (fm_backend_herdr_container_ensure, which internally resolves its session +# via fm_backend_herdr_session when none is passed - bin/backends/herdr.sh). +# The derived-session equality check runs FIRST and unconditionally refuses +# before any operational call, so a regression here can never fall through to +# operating on this machine's real "default" session. +CONTAINER_OUT=$( + unset HERDR_ENV HERDR_SESSION + export HERDR_SOCKET_PATH="$SOCKET" + CONTAINER_SES=$(fm_backend_herdr_session) + if [ "$CONTAINER_SES" != "$SESSION" ]; then + echo "derived session '$CONTAINER_SES' does not match the real lab session '$SESSION'; refusing the operational call for safety" >&2 + exit 9 + fi + fm_backend_herdr_container_ensure /tmp +) || fail "the container-shaped fm_backend_herdr_session -> fm_backend_herdr_container_ensure chain failed (rc $?)" + +case "$CONTAINER_OUT" in + "$SESSION":*) : ;; + *) fail "container_ensure resolved through the container-shaped env did not target the lab session, got '$CONTAINER_OUT'" ;; +esac +pass "real herdr: container_ensure resolved purely from HERDR_SOCKET_PATH targets the exact real lab session" + +AFTER_SOCKET=$(fm_backend_herdr_socket_path "$SESSION") +[ "$AFTER_SOCKET" = "$BEFORE_SOCKET" ] \ + || fail "the lab session's control-socket path changed after the container-shaped call ($BEFORE_SOCKET -> $AFTER_SOCKET): its server was restarted rather than reused" +pass "real herdr: the container-shaped call reused the already-running server's exact socket, never starting a fresh disconnected one" + +cleanup_all +trap - EXIT diff --git a/tests/fm-backend-herdr.test.sh b/tests/fm-backend-herdr.test.sh index bdfaabc2375..56be8905500 100755 --- a/tests/fm-backend-herdr.test.sh +++ b/tests/fm-backend-herdr.test.sh @@ -283,6 +283,57 @@ herdr_env() { # printf '%s\n%s\n' "$dir/log" "$dir/responses" } +# --- fm_backend_herdr_session: container socket-derived session binding ----- +# (PR #2 completeness gap / see tests/fm-backend-herdr-container-session-e2e.test.sh +# for live herdr proof) An explicit HERDR_SESSION still wins outright. +# Otherwise, when only HERDR_SOCKET_PATH was forwarded (the container detection +# fallback in bin/fm-backend.sh's fm_backend_detect), the session name is derived +# directly from the verified "/sessions//herdr.sock" +# socket-path shape (docs/verification/runtime-backends.md) so every +# downstream herdr call agrees with detection about which session and socket +# it targets, instead of silently guessing "default" and risking a +# disconnected in-container server. Pure-function tests: no herdr binary is +# invoked, so these do not need a fake CLI. + +test_session_prefers_explicit_herdr_session_over_socket_path() { + local out + out=$(HERDR_SESSION=fmtest HERDR_SOCKET_PATH=/home/x/.config/herdr/sessions/other-name/herdr.sock \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_session' "$ROOT") + [ "$out" = fmtest ] || fail "fm_backend_herdr_session should prefer explicit HERDR_SESSION over a disagreeing HERDR_SOCKET_PATH, got '$out'" + pass "fm_backend_herdr_session: explicit HERDR_SESSION wins outright over HERDR_SOCKET_PATH" +} + +test_session_derives_name_from_named_session_socket_path() { + local out + out=$(unset HERDR_SESSION; HERDR_SOCKET_PATH=/home/x/.config/herdr/sessions/fm-lab-container-42/herdr.sock \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_session' "$ROOT") + [ "$out" = fm-lab-container-42 ] || fail "fm_backend_herdr_session should derive the session name from a named-session HERDR_SOCKET_PATH when HERDR_SESSION is unset, got '$out'" + pass "fm_backend_herdr_session: derives the session name from HERDR_SOCKET_PATH's verified sessions//herdr.sock shape" +} + +test_session_falls_back_to_default_for_default_session_socket_shape() { + local out + out=$(unset HERDR_SESSION; HERDR_SOCKET_PATH=/home/x/.config/herdr/herdr.sock \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_session' "$ROOT") + [ "$out" = default ] || fail "fm_backend_herdr_session should read the default session's own socket shape (no sessions/ component) as 'default', got '$out'" + pass "fm_backend_herdr_session: the default session's own socket shape resolves to 'default'" +} + +test_session_falls_back_to_default_for_an_unrecognized_socket_path_shape() { + local out + out=$(unset HERDR_SESSION; HERDR_SOCKET_PATH=/tmp/some-other-shape/herdr.sock \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_session' "$ROOT") + [ "$out" = default ] || fail "fm_backend_herdr_session should fall back to 'default' for a HERDR_SOCKET_PATH that does not match either verified shape, got '$out'" + pass "fm_backend_herdr_session: an unrecognized HERDR_SOCKET_PATH shape falls back to 'default' rather than misreading a name" +} + +test_session_falls_back_to_default_without_any_signal() { + local out + out=$(unset HERDR_SESSION HERDR_SOCKET_PATH; bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_session' "$ROOT") + [ "$out" = default ] || fail "fm_backend_herdr_session should fall back to 'default' with neither HERDR_SESSION nor HERDR_SOCKET_PATH set, got '$out'" + pass "fm_backend_herdr_session: falls back to 'default' with no session signal at all" +} + # --- version_check / tool_check ---------------------------------------------- test_version_check_accepts_current_protocol() { @@ -5743,6 +5794,11 @@ test_wait_transition_clean_timeout_returns_1() { # shellcheck source=bin/fm-backend.sh . "$ROOT/bin/fm-backend.sh" +test_session_prefers_explicit_herdr_session_over_socket_path +test_session_derives_name_from_named_session_socket_path +test_session_falls_back_to_default_for_default_session_socket_shape +test_session_falls_back_to_default_for_an_unrecognized_socket_path_shape +test_session_falls_back_to_default_without_any_signal test_version_check_accepts_current_protocol test_version_check_refuses_old_protocol test_version_check_refuses_missing_herdr diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index 96d00b10303..78a376b5746 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -192,14 +192,14 @@ test_backend_name_precedence() { # source time, from FM_CONFIG_OVERRIDE); a later FM_CONFIG_OVERRIDE=... prefix # on the function call itself does not re-bind it, so these calls set # FM_BACKEND_CONFIG_DIR directly. - [ "$(unset TMUX HERDR_ENV CMUX_WORKSPACE_ID __CFBundleIdentifier; PATH="$FAKE_NONDARWIN_BIN:$PATH" FM_BACKEND='' FM_BACKEND_CONFIG_DIR="$cfg" fm_backend_name)" = tmux ] \ + [ "$(unset TMUX HERDR_ENV HERDR_SOCKET_PATH CMUX_WORKSPACE_ID __CFBundleIdentifier; PATH="$FAKE_NONDARWIN_BIN:$PATH" FM_BACKEND='' FM_BACKEND_CONFIG_DIR="$cfg" fm_backend_name)" = tmux ] \ || fail "fm_backend_name should default to tmux with no env/config/detection markers" printf 'tmux\n' > "$cfg/backend" - [ "$(unset TMUX HERDR_ENV CMUX_WORKSPACE_ID; FM_BACKEND='' FM_BACKEND_CONFIG_DIR="$cfg" fm_backend_name)" = tmux ] \ + [ "$(unset TMUX HERDR_ENV HERDR_SOCKET_PATH CMUX_WORKSPACE_ID; FM_BACKEND='' FM_BACKEND_CONFIG_DIR="$cfg" fm_backend_name)" = tmux ] \ || fail "fm_backend_name should read config/backend" - [ "$(unset TMUX HERDR_ENV CMUX_WORKSPACE_ID; FM_BACKEND=tmux FM_BACKEND_CONFIG_DIR="$cfg" fm_backend_name)" = tmux ] \ + [ "$(unset TMUX HERDR_ENV HERDR_SOCKET_PATH CMUX_WORKSPACE_ID; FM_BACKEND=tmux FM_BACKEND_CONFIG_DIR="$cfg" fm_backend_name)" = tmux ] \ || fail "FM_BACKEND env should win over config/backend" pass "fm_backend_name: FM_BACKEND env > config/backend > default tmux" @@ -214,49 +214,153 @@ test_backend_name_precedence() { test_backend_detect_precedence() { local out - if out=$(unset TMUX HERDR_ENV CMUX_WORKSPACE_ID __CFBundleIdentifier; PATH="$FAKE_NONDARWIN_BIN:$PATH" fm_backend_detect); then + if out=$(unset TMUX HERDR_ENV HERDR_SOCKET_PATH CMUX_WORKSPACE_ID __CFBundleIdentifier; PATH="$FAKE_NONDARWIN_BIN:$PATH" fm_backend_detect); then fail "fm_backend_detect should return 1 (undetected) with no markers set, got '$out'" fi - out=$(unset TMUX CMUX_WORKSPACE_ID; HERDR_ENV=1 fm_backend_detect) \ + out=$(unset TMUX CMUX_WORKSPACE_ID HERDR_SOCKET_PATH; HERDR_ENV=1 fm_backend_detect) \ || fail "fm_backend_detect should succeed when HERDR_ENV=1" [ "$out" = herdr ] || fail "fm_backend_detect should report herdr for HERDR_ENV=1 alone, got '$out'" - out=$(unset HERDR_ENV CMUX_WORKSPACE_ID; TMUX='fake,1,0' fm_backend_detect) \ + out=$(unset HERDR_ENV CMUX_WORKSPACE_ID HERDR_SOCKET_PATH; TMUX='fake,1,0' fm_backend_detect) \ || fail "fm_backend_detect should succeed when \$TMUX is set" [ "$out" = tmux ] || fail "fm_backend_detect should report tmux for \$TMUX alone, got '$out'" - out=$(unset TMUX HERDR_ENV; CMUX_WORKSPACE_ID='fake-uuid' fm_backend_detect) \ + out=$(unset TMUX HERDR_ENV HERDR_SOCKET_PATH; CMUX_WORKSPACE_ID='fake-uuid' fm_backend_detect) \ || fail "fm_backend_detect should succeed when CMUX_WORKSPACE_ID is set" [ "$out" = cmux ] || fail "fm_backend_detect should report cmux for CMUX_WORKSPACE_ID alone, got '$out'" # Nesting: tmux started inside a herdr pane carries BOTH markers. Innermost # (tmux) must win, since that is the surface firstmate is actually running on. - out=$(unset CMUX_WORKSPACE_ID; TMUX='fake,1,0' HERDR_ENV=1 fm_backend_detect) \ + out=$(unset CMUX_WORKSPACE_ID HERDR_SOCKET_PATH; TMUX='fake,1,0' HERDR_ENV=1 fm_backend_detect) \ || fail "fm_backend_detect should succeed with both markers present" [ "$out" = tmux ] || fail "fm_backend_detect should resolve nesting innermost-first (tmux over herdr), got '$out'" # Nesting: tmux started inside a cmux-provided shell carries BOTH markers. # cmux is a terminal application, not a nestable multiplexer, so the # innermost multiplexer (tmux) must still win. - out=$(unset HERDR_ENV; TMUX='fake,1,0' CMUX_WORKSPACE_ID='fake-uuid' fm_backend_detect) \ + out=$(unset HERDR_ENV HERDR_SOCKET_PATH; TMUX='fake,1,0' CMUX_WORKSPACE_ID='fake-uuid' fm_backend_detect) \ || fail "fm_backend_detect should succeed with tmux and cmux markers present" [ "$out" = tmux ] || fail "fm_backend_detect should resolve nesting innermost-first (tmux over cmux), got '$out'" # Nesting: herdr started inside a cmux-provided shell carries BOTH markers. # Same reasoning: herdr (the innermost multiplexer) must win over cmux. - out=$(unset TMUX; HERDR_ENV=1 CMUX_WORKSPACE_ID='fake-uuid' fm_backend_detect) \ + out=$(unset TMUX HERDR_SOCKET_PATH; HERDR_ENV=1 CMUX_WORKSPACE_ID='fake-uuid' fm_backend_detect) \ || fail "fm_backend_detect should succeed with herdr and cmux markers present" [ "$out" = herdr ] || fail "fm_backend_detect should resolve nesting innermost-first (herdr over cmux), got '$out'" # Pathological: all three markers present. tmux still wins (innermost of all). - out=$(TMUX='fake,1,0' HERDR_ENV=1 CMUX_WORKSPACE_ID='fake-uuid' fm_backend_detect) \ + out=$(unset HERDR_SOCKET_PATH; TMUX='fake,1,0' HERDR_ENV=1 CMUX_WORKSPACE_ID='fake-uuid' fm_backend_detect) \ || fail "fm_backend_detect should succeed with all three markers present" [ "$out" = tmux ] || fail "fm_backend_detect should resolve nesting innermost-first with all three markers (tmux wins), got '$out'" pass "fm_backend_detect: no markers -> undetected, HERDR_ENV=1 -> herdr, \$TMUX -> tmux, CMUX_WORKSPACE_ID -> cmux, nested combinations resolve innermost-first" } +# fm_backend_detect's HERDR_SOCKET_PATH fallback (container devcontainer support): +# When Herdr launches a crewmate or Pi agent inside a devcontainer, HERDR_ENV=1 +# is not injected (containers get a fresh environment), but HERDR_SOCKET_PATH is +# available as a volume mount or forwarded env var. This fallback allows +# container-based agents to detect the herdr backend. The check is STRICT: +# HERDR_SOCKET_PATH must name a socket file (using the -S test), not a regular +# file or missing path. Precedence: TMUX > HERDR_ENV > HERDR_SOCKET_PATH > +# CMUX_WORKSPACE_ID, so this fallback never outranks the primary markers. +test_backend_detect_herdr_socket_fallback() { + local dir out socket_file + dir="$TMP_ROOT/detect-herdr-socket"; mkdir -p "$dir" + socket_file="$dir/herdr.sock" + + # Create a real Unix domain socket using Python (mkfifo creates a FIFO, not a + # socket, so we need Python for a real socket that the -S test recognizes) + command -v python3 >/dev/null 2>&1 || { + pass "fm_backend_detect (HERDR_SOCKET_PATH fallback): skipped (python3 not available)" + return 0 + } + + python3 << PYSOCK +import socket +import os +sock_path = "$socket_file" +if os.path.exists(sock_path): + os.remove(sock_path) +sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +sock.bind(sock_path) +sock.close() +PYSOCK + + [ -S "$socket_file" ] || { + fail "failed to create a test socket file at $socket_file" + } + + # Test 1: Valid socket file detection + out=$(unset TMUX HERDR_ENV CMUX_WORKSPACE_ID; HERDR_SOCKET_PATH="$socket_file" fm_backend_detect) \ + || fail "fm_backend_detect should succeed when HERDR_SOCKET_PATH points to a socket" + [ "$out" = herdr ] || fail "socket fallback should report herdr, got '$out'" + + # Test 2: Verify FM_BACKEND_DETECT_SIGNAL is set correctly + ( + unset TMUX HERDR_ENV CMUX_WORKSPACE_ID + HERDR_SOCKET_PATH="$socket_file" fm_backend_detect >/dev/null || exit 1 + [ "$FM_BACKEND_DETECT_SIGNAL" = HERDR_SOCKET_PATH ] || exit 2 + ) || fail "socket fallback should set FM_BACKEND_DETECT_SIGNAL=HERDR_SOCKET_PATH (subshell exit $?)" + + # Test 3: Non-socket file (regular file) should NOT match + local regular_file="$dir/regular.txt" + touch "$regular_file" + if out=$(unset TMUX HERDR_ENV CMUX_WORKSPACE_ID; HERDR_SOCKET_PATH="$regular_file" fm_backend_detect); then + fail "fm_backend_detect should not match a non-socket file, got '$out'" + fi + + # Test 4: Missing file should NOT match + if out=$(unset TMUX HERDR_ENV CMUX_WORKSPACE_ID; HERDR_SOCKET_PATH="$dir/nonexistent.sock" fm_backend_detect); then + fail "fm_backend_detect should not match a non-existent file, got '$out'" + fi + + # Test 5: Empty HERDR_SOCKET_PATH should NOT match + if out=$(unset TMUX HERDR_ENV CMUX_WORKSPACE_ID; HERDR_SOCKET_PATH="" fm_backend_detect); then + fail "fm_backend_detect should not match when HERDR_SOCKET_PATH is empty, got '$out'" + fi + + # Test 6: Unset HERDR_SOCKET_PATH should NOT match + if out=$(unset TMUX HERDR_ENV CMUX_WORKSPACE_ID HERDR_SOCKET_PATH; fm_backend_detect); then + fail "fm_backend_detect should not match when HERDR_SOCKET_PATH is unset, got '$out'" + fi + + # Test 7: HERDR_ENV=1 takes precedence over HERDR_SOCKET_PATH + out=$(unset TMUX CMUX_WORKSPACE_ID; HERDR_ENV=1 HERDR_SOCKET_PATH="$socket_file" fm_backend_detect) \ + || fail "fm_backend_detect should succeed when both HERDR_ENV and HERDR_SOCKET_PATH are set" + [ "$out" = herdr ] || fail "when both HERDR_ENV and HERDR_SOCKET_PATH are set, should report herdr, got '$out'" + ( + unset TMUX CMUX_WORKSPACE_ID + HERDR_ENV=1 HERDR_SOCKET_PATH="$socket_file" fm_backend_detect >/dev/null || exit 1 + [ "$FM_BACKEND_DETECT_SIGNAL" = HERDR_ENV ] || exit 2 + ) || fail "HERDR_ENV should take precedence over HERDR_SOCKET_PATH (signal check, subshell exit $?)" + + # Test 8: TMUX takes precedence over HERDR_SOCKET_PATH + out=$(unset HERDR_ENV CMUX_WORKSPACE_ID; TMUX='fake,1,0' HERDR_SOCKET_PATH="$socket_file" fm_backend_detect) \ + || fail "fm_backend_detect should succeed when both TMUX and HERDR_SOCKET_PATH are set" + [ "$out" = tmux ] || fail "TMUX should win over HERDR_SOCKET_PATH (innermost-first), got '$out'" + + # Test 9: HERDR_SOCKET_PATH fallback still wins over cmux's primary marker + # CMUX_WORKSPACE_ID (review gap noted for PR #2: precedence was exercised + # against TMUX and HERDR_ENV above, but never against cmux's own primary + # signal). + out=$(unset TMUX HERDR_ENV; CMUX_WORKSPACE_ID='fake-uuid' HERDR_SOCKET_PATH="$socket_file" fm_backend_detect) \ + || fail "fm_backend_detect should succeed when both CMUX_WORKSPACE_ID and HERDR_SOCKET_PATH are set" + [ "$out" = herdr ] || fail "HERDR_SOCKET_PATH should win over CMUX_WORKSPACE_ID (checked first), got '$out'" + ( + unset TMUX HERDR_ENV + CMUX_WORKSPACE_ID='fake-uuid' HERDR_SOCKET_PATH="$socket_file" fm_backend_detect >/dev/null || exit 1 + [ "$FM_BACKEND_DETECT_SIGNAL" = HERDR_SOCKET_PATH ] || exit 2 + ) || fail "HERDR_SOCKET_PATH should win over CMUX_WORKSPACE_ID (signal check, subshell exit $?)" + + # Cleanup + rm -f "$socket_file" "$regular_file" + rmdir "$dir" + + pass "fm_backend_detect: HERDR_SOCKET_PATH fallback for containers (valid socket detects herdr, non-socket/missing/empty rejected, HERDR_ENV/TMUX win, HERDR_SOCKET_PATH wins over CMUX_WORKSPACE_ID, signal set correctly)" +} + # fm_backend_detect's cmux FALLBACK signals (docs/cmux-backend.md "Runtime # auto-detection"): cmux's bundled claude wrapper strips every CMUX_* env var # on its passthrough path, so a claude-under-cmux firstmate has no @@ -268,18 +372,18 @@ test_backend_detect_cmux_fallback_bundle_id() { dir="$TMP_ROOT/detect-fallback-bundle"; mkdir -p "$dir" fb=$(make_cmux_fallback_fakebin "$dir") - out=$(unset TMUX HERDR_ENV CMUX_WORKSPACE_ID; PATH="$fb:$PATH" __CFBundleIdentifier='com.cmuxterm.app' fm_backend_detect) \ + out=$(unset TMUX HERDR_ENV HERDR_SOCKET_PATH CMUX_WORKSPACE_ID; PATH="$fb:$PATH" __CFBundleIdentifier='com.cmuxterm.app' fm_backend_detect) \ || fail "fm_backend_detect should fall back to the cmux bundle id when CMUX_WORKSPACE_ID is absent" [ "$out" = cmux ] || fail "bundle-id fallback should report cmux, got '$out'" ( - unset TMUX HERDR_ENV CMUX_WORKSPACE_ID + unset TMUX HERDR_ENV HERDR_SOCKET_PATH CMUX_WORKSPACE_ID PATH="$fb:$PATH" __CFBundleIdentifier='com.cmuxterm.app' fm_backend_detect >/dev/null || exit 1 [ "$FM_BACKEND_DETECT_SIGNAL" = bundle-id ] || exit 2 ) || fail "bundle-id fallback should set FM_BACKEND_DETECT_SIGNAL=bundle-id (subshell exit $?)" # A foreign bundle id (an ordinary terminal app) must not match. - if out=$(unset TMUX HERDR_ENV CMUX_WORKSPACE_ID; PATH="$fb:$PATH" FM_FAKE_PS_TABLE="$dir/no-table" __CFBundleIdentifier='com.apple.Terminal' fm_backend_detect); then + if out=$(unset TMUX HERDR_ENV HERDR_SOCKET_PATH CMUX_WORKSPACE_ID; PATH="$fb:$PATH" FM_FAKE_PS_TABLE="$dir/no-table" __CFBundleIdentifier='com.apple.Terminal' fm_backend_detect); then fail "a non-cmux __CFBundleIdentifier should not detect cmux, got '$out'" fi @@ -288,7 +392,7 @@ test_backend_detect_cmux_fallback_bundle_id() { test_backend_detect_cmux_fallback_requires_darwin() { local out - if out=$(unset TMUX HERDR_ENV CMUX_WORKSPACE_ID; PATH="$FAKE_NONDARWIN_BIN:$PATH" __CFBundleIdentifier='com.cmuxterm.app' fm_backend_detect); then + if out=$(unset TMUX HERDR_ENV HERDR_SOCKET_PATH CMUX_WORKSPACE_ID; PATH="$FAKE_NONDARWIN_BIN:$PATH" __CFBundleIdentifier='com.cmuxterm.app' fm_backend_detect); then fail "the cmux fallback must be macOS-only (cmux itself is), got '$out' on a non-Darwin uname" fi pass "fm_backend_detect: the cmux fallback signals are macOS-only (inert on a non-Darwin uname)" @@ -305,11 +409,11 @@ test_backend_detect_cmux_fallback_tmux_nested_false_positive() { dir="$TMP_ROOT/detect-fallback-nested"; mkdir -p "$dir" fb=$(make_cmux_fallback_fakebin "$dir") - out=$(unset HERDR_ENV CMUX_WORKSPACE_ID; PATH="$fb:$PATH" TMUX='fake,1,0' __CFBundleIdentifier='com.cmuxterm.app' fm_backend_detect) \ + out=$(unset HERDR_ENV HERDR_SOCKET_PATH CMUX_WORKSPACE_ID; PATH="$fb:$PATH" TMUX='fake,1,0' __CFBundleIdentifier='com.cmuxterm.app' fm_backend_detect) \ || fail "fm_backend_detect should still succeed with \$TMUX plus an inherited cmux bundle id" [ "$out" = tmux ] || fail "\$TMUX must win over an inherited cmux bundle id (tmux-inside-cmux pane), got '$out'" - out=$(unset TMUX CMUX_WORKSPACE_ID; PATH="$fb:$PATH" HERDR_ENV=1 __CFBundleIdentifier='com.cmuxterm.app' fm_backend_detect) \ + out=$(unset TMUX CMUX_WORKSPACE_ID HERDR_SOCKET_PATH; PATH="$fb:$PATH" HERDR_ENV=1 __CFBundleIdentifier='com.cmuxterm.app' fm_backend_detect) \ || fail "fm_backend_detect should still succeed with HERDR_ENV=1 plus an inherited cmux bundle id" [ "$out" = herdr ] || fail "HERDR_ENV=1 must win over an inherited cmux bundle id (herdr-inside-cmux pane), got '$out'" @@ -327,7 +431,7 @@ test_backend_detect_cmux_fallback_ancestry_pid_match() { printf '%s\t77777\t/bin/zsh\n77777\t66666\t/usr/bin/login\n66666\t1\t/home/x/Custom.app/Contents/MacOS/custom\n' "$$" > "$table" ( - unset TMUX HERDR_ENV CMUX_WORKSPACE_ID __CFBundleIdentifier + unset TMUX HERDR_ENV HERDR_SOCKET_PATH CMUX_WORKSPACE_ID __CFBundleIdentifier PATH="$fb:$PATH" FM_FAKE_PS_TABLE="$table" FM_FAKE_LSAPPINFO_OUT='"pid"=66666' fm_backend_detect >/dev/null || exit 1 [ "$FM_BACKEND_DETECTED" = cmux ] || exit 2 [ "$FM_BACKEND_DETECT_SIGNAL" = ancestry ] || exit 3 @@ -347,7 +451,7 @@ test_backend_detect_cmux_fallback_ancestry_comm_match() { printf '%s\t77777\t/bin/zsh\n77777\t66666\t/usr/bin/login\n66666\t1\t/home/x/Applications/cmux.app/Contents/MacOS/cmux\n' "$$" > "$table" ( - unset TMUX HERDR_ENV CMUX_WORKSPACE_ID __CFBundleIdentifier FM_FAKE_LSAPPINFO_OUT + unset TMUX HERDR_ENV HERDR_SOCKET_PATH CMUX_WORKSPACE_ID __CFBundleIdentifier FM_FAKE_LSAPPINFO_OUT PATH="$fb:$PATH" FM_FAKE_PS_TABLE="$table" fm_backend_detect >/dev/null || exit 1 [ "$FM_BACKEND_DETECTED" = cmux ] || exit 2 [ "$FM_BACKEND_DETECT_SIGNAL" = ancestry ] || exit 3 @@ -367,7 +471,7 @@ test_backend_detect_cmux_fallback_ancestry_stops_at_launchd() { table="$dir/ps-table" printf '%s\t77777\t/bin/zsh\n77777\t1\ttmux\n' "$$" > "$table" - if out=$(unset TMUX HERDR_ENV CMUX_WORKSPACE_ID __CFBundleIdentifier FM_FAKE_LSAPPINFO_OUT; PATH="$fb:$PATH" FM_FAKE_PS_TABLE="$table" fm_backend_detect); then + if out=$(unset TMUX HERDR_ENV HERDR_SOCKET_PATH CMUX_WORKSPACE_ID __CFBundleIdentifier FM_FAKE_LSAPPINFO_OUT; PATH="$fb:$PATH" FM_FAKE_PS_TABLE="$table" fm_backend_detect); then fail "ancestry fallback should stop undetected at a launchd-reparented chain, got '$out'" fi pass "fm_backend_detect: ancestry fallback stops undetected at launchd (a reparented tmux server never reaches cmux)" @@ -383,7 +487,7 @@ test_backend_name_cmux_fallback_notice() { errfile="$dir/err.txt" : > "$errfile" - out=$(unset TMUX HERDR_ENV CMUX_WORKSPACE_ID; PATH="$fb:$PATH" __CFBundleIdentifier='com.cmuxterm.app' FM_BACKEND='' FM_BACKEND_CONFIG_DIR="$cfg" fm_backend_name 2>"$errfile") + out=$(unset TMUX HERDR_ENV HERDR_SOCKET_PATH CMUX_WORKSPACE_ID; PATH="$fb:$PATH" __CFBundleIdentifier='com.cmuxterm.app' FM_BACKEND='' FM_BACKEND_CONFIG_DIR="$cfg" fm_backend_name 2>"$errfile") [ "$out" = cmux ] || fail "fm_backend_name should auto-detect cmux via the bundle-id fallback, got '$out'" assert_contains "$(cat "$errfile")" "FALLBACK signal __CFBundleIdentifier" \ "the fallback-detected cmux notice did not name the bundle-id fallback signal" @@ -395,7 +499,7 @@ test_backend_name_cmux_fallback_notice() { # The primary-marker notice is unchanged: it names CMUX_WORKSPACE_ID and # carries no FALLBACK wording. : > "$errfile" - out=$(unset TMUX HERDR_ENV; CMUX_WORKSPACE_ID='fake-uuid' FM_BACKEND='' FM_BACKEND_CONFIG_DIR="$cfg" fm_backend_name 2>"$errfile") + out=$(unset TMUX HERDR_ENV HERDR_SOCKET_PATH; CMUX_WORKSPACE_ID='fake-uuid' FM_BACKEND='' FM_BACKEND_CONFIG_DIR="$cfg" fm_backend_name 2>"$errfile") [ "$out" = cmux ] || fail "fm_backend_name should auto-detect cmux from CMUX_WORKSPACE_ID, got '$out'" assert_contains "$(cat "$errfile")" "(CMUX_WORKSPACE_ID)" \ "the primary-marker cmux notice no longer names CMUX_WORKSPACE_ID" @@ -418,22 +522,22 @@ test_backend_name_autodetect_notice() { errfile="$dir/err.txt" : > "$errfile" - out=$(unset TMUX HERDR_ENV CMUX_WORKSPACE_ID __CFBundleIdentifier; PATH="$FAKE_NONDARWIN_BIN:$PATH" FM_BACKEND='' FM_BACKEND_CONFIG_DIR="$cfg" fm_backend_name 2>"$errfile") + out=$(unset TMUX HERDR_ENV HERDR_SOCKET_PATH CMUX_WORKSPACE_ID __CFBundleIdentifier; PATH="$FAKE_NONDARWIN_BIN:$PATH" FM_BACKEND='' FM_BACKEND_CONFIG_DIR="$cfg" fm_backend_name 2>"$errfile") [ "$out" = tmux ] || fail "fm_backend_name should default to tmux with no detection markers, got '$out'" [ -s "$errfile" ] && fail "fm_backend_name must stay silent with no detection markers"$'\n'"$(cat "$errfile")" : > "$errfile" - out=$(unset TMUX CMUX_WORKSPACE_ID; HERDR_ENV=1 FM_BACKEND='' FM_BACKEND_CONFIG_DIR="$cfg" fm_backend_name 2>"$errfile") + out=$(unset TMUX CMUX_WORKSPACE_ID HERDR_SOCKET_PATH; HERDR_ENV=1 FM_BACKEND='' FM_BACKEND_CONFIG_DIR="$cfg" fm_backend_name 2>"$errfile") [ "$out" = herdr ] || fail "fm_backend_name should auto-detect herdr from HERDR_ENV=1, got '$out'" [ ! -s "$errfile" ] || fail "fm_backend_name must keep verified Herdr auto-detection silent"$'\n'"$(cat "$errfile")" : > "$errfile" - out=$(unset HERDR_ENV CMUX_WORKSPACE_ID; TMUX='fake,1,0' FM_BACKEND='' FM_BACKEND_CONFIG_DIR="$cfg" fm_backend_name 2>"$errfile") + out=$(unset HERDR_ENV CMUX_WORKSPACE_ID HERDR_SOCKET_PATH; TMUX='fake,1,0' FM_BACKEND='' FM_BACKEND_CONFIG_DIR="$cfg" fm_backend_name 2>"$errfile") [ "$out" = tmux ] || fail "fm_backend_name should auto-detect tmux from \$TMUX, got '$out'" [ -s "$errfile" ] && fail "auto-detecting tmux must stay silent (today's unchanged default-path behavior)"$'\n'"$(cat "$errfile")" : > "$errfile" - out=$(unset TMUX HERDR_ENV; CMUX_WORKSPACE_ID='fake-uuid' FM_BACKEND='' FM_BACKEND_CONFIG_DIR="$cfg" fm_backend_name 2>"$errfile") + out=$(unset TMUX HERDR_ENV HERDR_SOCKET_PATH; CMUX_WORKSPACE_ID='fake-uuid' FM_BACKEND='' FM_BACKEND_CONFIG_DIR="$cfg" fm_backend_name 2>"$errfile") [ "$out" = cmux ] || fail "fm_backend_name should auto-detect cmux from CMUX_WORKSPACE_ID, got '$out'" assert_contains "$(cat "$errfile")" "EXPERIMENTAL cmux backend" \ "fm_backend_name did not print a loud notice when auto-detecting cmux" @@ -443,12 +547,12 @@ test_backend_name_autodetect_notice() { "fm_backend_name's cmux auto-detect notice did not name the --backend tmux opt-out" : > "$errfile" - out=$(unset CMUX_WORKSPACE_ID; TMUX='fake,1,0' HERDR_ENV=1 FM_BACKEND='' FM_BACKEND_CONFIG_DIR="$cfg" fm_backend_name 2>"$errfile") + out=$(unset CMUX_WORKSPACE_ID HERDR_SOCKET_PATH; TMUX='fake,1,0' HERDR_ENV=1 FM_BACKEND='' FM_BACKEND_CONFIG_DIR="$cfg" fm_backend_name 2>"$errfile") [ "$out" = tmux ] || fail "nested tmux-in-herdr should auto-detect tmux (innermost first), got '$out'" [ -s "$errfile" ] && fail "nested tmux-in-herdr auto-detect (result tmux) must stay silent"$'\n'"$(cat "$errfile")" : > "$errfile" - out=$(unset HERDR_ENV; TMUX='fake,1,0' CMUX_WORKSPACE_ID='fake-uuid' FM_BACKEND='' FM_BACKEND_CONFIG_DIR="$cfg" fm_backend_name 2>"$errfile") + out=$(unset HERDR_ENV HERDR_SOCKET_PATH; TMUX='fake,1,0' CMUX_WORKSPACE_ID='fake-uuid' FM_BACKEND='' FM_BACKEND_CONFIG_DIR="$cfg" fm_backend_name 2>"$errfile") [ "$out" = tmux ] || fail "nested tmux-in-cmux should auto-detect tmux (innermost first), got '$out'" [ -s "$errfile" ] && fail "nested tmux-in-cmux auto-detect (result tmux) must stay silent"$'\n'"$(cat "$errfile")" @@ -477,10 +581,10 @@ test_backend_name_explicit_beats_detection() { # The same opt-out must work for an ambient cmux auto-detect marker: a # captain who is running firstmate inside a cmux terminal but explicitly # wants tmux is never overridden by CMUX_WORKSPACE_ID. - out=$(unset TMUX HERDR_ENV; CMUX_WORKSPACE_ID='fake-uuid' FM_BACKEND=tmux FM_BACKEND_CONFIG_DIR="$dir/config-empty" fm_backend_name) + out=$(unset TMUX HERDR_ENV HERDR_SOCKET_PATH; CMUX_WORKSPACE_ID='fake-uuid' FM_BACKEND=tmux FM_BACKEND_CONFIG_DIR="$dir/config-empty" fm_backend_name) [ "$out" = tmux ] || fail "FM_BACKEND=tmux should win over an ambient CMUX_WORKSPACE_ID auto-detect marker, got '$out'" - out=$(unset TMUX HERDR_ENV; CMUX_WORKSPACE_ID='fake-uuid' FM_BACKEND='' FM_BACKEND_CONFIG_DIR="$cfg" fm_backend_name) + out=$(unset TMUX HERDR_ENV HERDR_SOCKET_PATH; CMUX_WORKSPACE_ID='fake-uuid' FM_BACKEND='' FM_BACKEND_CONFIG_DIR="$cfg" fm_backend_name) [ "$out" = tmux ] || fail "config/backend=tmux should win over an ambient CMUX_WORKSPACE_ID auto-detect marker, got '$out'" pass "fm_backend_name: an explicit FM_BACKEND or config/backend setting always wins over runtime auto-detection, including an ambient cmux marker" @@ -1198,6 +1302,7 @@ backend_base_ref >/dev/null test_backend_name_precedence test_backend_detect_precedence +test_backend_detect_herdr_socket_fallback test_backend_detect_cmux_fallback_bundle_id test_backend_detect_cmux_fallback_requires_darwin test_backend_detect_cmux_fallback_tmux_nested_false_positive