Skip to content
45 changes: 41 additions & 4 deletions bin/backends/herdr.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<config_root>/sessions/<name>/herdr.sock", while the default
# session's socket has no "sessions" component at all
# ("<config_root>/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() { # <socket_path>
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'
Comment on lines +580 to +583

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Noncanonical mounts select default

If a forwarded socket is mounted at a container-local path such as /run/herdr.sock, this pattern cannot recover the named session and silently returns default. Herdr operations are then routed with --session default, so detection can select Herdr from the forwarded socket but create or address a disconnected default server instead of the session behind that socket. Require the canonical .../sessions/<name>/herdr.sock mount shape or fail closed when a socket-only path cannot identify its session.

Knowledge Base Used:

}

# fm_backend_herdr_projection_id: generate a compact 128-bit base64url token.
Expand Down
20 changes: 18 additions & 2 deletions bin/fm-backend.sh
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ fm_backend_is_known() { # <name>
# 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
Expand Down Expand Up @@ -134,7 +134,7 @@ fm_backend_is_known() { # <name>
# 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"

Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions bin/fm-test-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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|\
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>.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.
Expand Down
4 changes: 1 addition & 3 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.
Expand Down Expand Up @@ -530,7 +529,6 @@ It currently supports only `tmux` and `herdr` supervisor panes.

Set `FM_SUPERVISOR_BACKEND=tmux|herdr` and `FM_SUPERVISOR_TARGET=<target>` to override both axes explicitly; for herdr the target is `"<session>:<pane-id>"`.
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.

Expand Down
4 changes: 2 additions & 2 deletions docs/herdr-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/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.

Expand Down
109 changes: 109 additions & 0 deletions tests/fm-backend-herdr-container-session-e2e.test.sh
Original file line number Diff line number Diff line change
@@ -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
Loading