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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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'
}

# 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 @@ -311,6 +311,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 @@ -236,7 +236,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
6 changes: 3 additions & 3 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,8 @@ Every routine firstmate backlog command therefore runs through [`bin/fm-tasks-ax
For spawn-capable adapters, the runtime session-provider backend controls where task windows/endpoints are created, captured, sent to, watched, and killed.
`tmux` is the verified reference backend (see [`docs/tmux-backend.md`](tmux-backend.md)); `herdr` has its own required CI lane (see [`docs/herdr-backend.md`](herdr-backend.md)); `zellij`, `orca`, and `cmux` remain experimental spawn backends with no dedicated real-backend CI lane (see [`docs/zellij-backend.md`](zellij-backend.md), [`docs/orca-backend.md`](orca-backend.md), and [`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.
New spawns choose the backend in this order: an explicit `--backend` flag that current authority for that exact task alone has authorized (a present captain instruction or the task's own accepted brief; never later-task precedent by analogy), then `FM_BACKEND`, then the first non-empty line of local gitignored `config/backend`, then runtime auto-detection from `$TMUX`, `HERDR_ENV=1`, or cmux runtime signals, then 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.
New spawns choose the backend in this order: an explicit `--backend` flag that current authority for that exact task alone has authorized (a present captain instruction or the task's own accepted brief; never later-task precedent by analogy), then `FM_BACKEND`, then the first non-empty line of local gitignored `config/backend`, then runtime auto-detection from `$TMUX`, `HERDR_ENV=1`, `HERDR_SOCKET_PATH` (when a socket is available), or cmux runtime signals, then default `tmux`.
If more than one runtime marker is present, detection resolves innermost-first: `$TMUX` is checked before `HERDR_ENV=1` (with `HERDR_SOCKET_PATH` as a fallback for containers where `HERDR_ENV=1` is not injected), 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.
See [`docs/cmux-backend.md`](cmux-backend.md#runtime-detection) for why cmux can be selected when `CMUX_WORKSPACE_ID` is absent.
Auto-detected Herdr stays silent like tmux, while auto-detected cmux prints a stderr notice naming `config/backend` and `--backend tmux` because cmux remains experimental.
Zellij and Orca are never auto-detected; select them by putting the name in a local `config/backend` file, by exporting `FM_BACKEND=<name>`, or by telling the first mate in chat.
Expand Down Expand Up @@ -183,7 +183,7 @@ Test cleanup must use the guarded path in [`docs/cmux-backend.md`](cmux-backend.
The `/afk` sub-supervisor injects escalation digests into firstmate's own pane independently of where new task endpoints are spawned.
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`.
Without overrides, backend detection uses `$TMUX_PANE` first, then `HERDR_ENV=1` with `HERDR_PANE_ID` (or `HERDR_SOCKET_PATH` as a fallback for containers), 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.
Selecting any other supervisor backend, including `zellij`, `orca`, or `cmux`, refuses at daemon startup instead of trying tmux injection primitives against a non-tmux pane.
Expand Down
3 changes: 2 additions & 1 deletion docs/herdr-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ Firstmate invokes its CLI as a separate process.

Select Herdr with local `config/backend` containing `herdr`, `FM_BACKEND=herdr` for one launch, or 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.
It 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