#!/usr/bin/env bash # CI-fit regression suite for the #1408 legacy-socket guard in # start-agent-session.sh. # # Same hermeticity contract as test-agent-session-broker-preflight.sh: a fake # tmux on PATH that scripts its own answers, a real unix socket in a tmpdir so # the broker preflight passes, env -i with a fake HOME. No case depends on host # state. # # The failure this suite is written down to catch: during a socket cutover a # seat's session still lives on the DEFAULT tmux socket while the launcher # targets the named one. The declared-socket has-session check cannot see the # legacy session (measured 2026-08-24: rc=1, script proceeds), so launch # creates a same-name duplicate — and comms delivery, which addresses sessions # by NAME, cannot tell the two apart. The guard refuses with its own code # (exit 76, after 75 broker-absent) BEFORE any tmux mutation. # # Cases: # 1. legacy session present -> exit 76, message names seat-on-legacy-socket # + both sockets' roles, and NO tmux session was created. # 2. legacy session absent -> proceeds PAST the guard (the run then stops at # a later precondition; asserted: exit != 76, stderr lacks the guard's # code, proving the guard was not the refusal). # 3. MOSAIC_TMUX_SOCKET empty (single-socket host) -> guard is inert: the # default-socket probe must not fire at all. # # Sabotage control, run by the developer (not in-suite): remove the guard # block, re-run — case 1 fails (exit is not 76), cases 2-3 still pass; # restore byte-identically. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/agent-session-legacy-socket-guard}" FAKE_HOME="$WORK_DIR/home" BIN_DIR="$WORK_DIR/bin" SOCK_DIR="$WORK_DIR/sockets" LOG_FILE="$WORK_DIR/tmux-calls.log" LEGACY_FLAG="$WORK_DIR/legacy-session-present" rm -rf "$WORK_DIR" mkdir -p "$FAKE_HOME/.config/mosaic/fleet/agents" "$BIN_DIR" "$SOCK_DIR" chmod 700 "$FAKE_HOME/.config/mosaic" "$FAKE_HOME/.config/mosaic/fleet/agents" chmod 750 "$FAKE_HOME/.config/mosaic/fleet" cat > "$FAKE_HOME/.config/mosaic/fleet/agents/lsguard-test.env.generated" <<'ENVEOF' MOSAIC_AGENT_NAME=lsguard-test MOSAIC_GIT_IDENTITY=lsguard-test MOSAIC_AGENT_CLASS=worker MOSAIC_AGENT_RUNTIME=pi MOSAIC_AGENT_MODEL= MOSAIC_AGENT_REASONING= MOSAIC_AGENT_TOOL_POLICY=code MOSAIC_AGENT_WORKDIR=/tmp MOSAIC_TMUX_SOCKET=mosaic-fleet ENVEOF chmod 600 "$FAKE_HOME/.config/mosaic/fleet/agents/lsguard-test.env.generated" # A projection with NO named socket, for case 3. Same file minus the socket line. sed '/^MOSAIC_TMUX_SOCKET=/d; s/lsguard-test/lsguard-nosock/' \ "$FAKE_HOME/.config/mosaic/fleet/agents/lsguard-test.env.generated" \ > "$FAKE_HOME/.config/mosaic/fleet/agents/lsguard-nosock.env.generated" echo 'MOSAIC_TMUX_SOCKET=' >> "$FAKE_HOME/.config/mosaic/fleet/agents/lsguard-nosock.env.generated" chmod 600 "$FAKE_HOME/.config/mosaic/fleet/agents/lsguard-nosock.env.generated" # Ownership identity the launcher validates before anything touches tmux: # a 0600 uuid file plus a tmux global environment that matches it exactly. mkdir -p "$FAKE_HOME/.config/mosaic/fleet/run" chmod 750 "$FAKE_HOME/.config/mosaic/fleet/run" OWNER_UUID="aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" printf '%s' "$OWNER_UUID" > "$FAKE_HOME/.config/mosaic/fleet/run/holder-owner" chmod 600 "$FAKE_HOME/.config/mosaic/fleet/run/holder-owner" # The exact env block assert_owned_tmux_server expects; the socket value differs # per case, so cases rewrite it via write_tmux_env before each run. write_tmux_env() { printf '%s\n' \ "HOME=$FAKE_HOME" \ 'PATH=/usr/bin:/bin' \ "PWD=$FAKE_HOME" \ "MOSAIC_FLEET_OWNER=$OWNER_UUID" \ 'MOSAIC_TMUX_HOLDER=_holder' \ "MOSAIC_TMUX_SOCKET=$1" > "$WORK_DIR/tmux-env" } # ─── Fake tmux ────────────────────────────────────────────────────────────── # Scripted answers: a DEFAULT-socket has-session (argv carries no -L) answers # by the flag file; every named-socket call succeeds (holder present, no # existing session is fine for these cases since refusal happens first). cat > "$BIN_DIR/tmux" <> "$LOG_FILE" if [[ "\$*" == *new-session* ]]; then echo "TMUX-NEW-SESSION-INVOKED" >> "$LOG_FILE" fi if [[ "\$*" == *show-environment* ]]; then cat "$WORK_DIR/tmux-env" exit 0 fi if [[ "\$*" == *has-session* ]]; then # holder session always present; the seat's DEFAULT-socket presence is the # flag file; the seat is never already-running on the NAMED socket. [[ "\$*" == *_holder* ]] && exit 0 if [[ "\$1" == "-L" ]]; then exit 1; fi [[ -e "$LEGACY_FLAG" ]] && exit 0 || exit 1 fi exit 0 SH chmod +x "$BIN_DIR/tmux" for bin in mosaic pi claude; do printf '#!/usr/bin/env bash\nexit 0\n' > "$BIN_DIR/$bin" chmod +x "$BIN_DIR/$bin" done # Real socket so the #1292 broker preflight passes and the run reaches the guard. # Same idiom as the broker-preflight suite: AF_UNIX binds cap at 108 path bytes, # so the socket lives at a SHORT /tmp path held by a detached python holder (a # foreground bind would close on exit; -S on a closed-but-unlinked path fails). LIVE_SOCK="/tmp/mosaic-lsguard-$RANDOM-$$.sock" trap 'rm -f "$LIVE_SOCK"' EXIT rm -f "$LIVE_SOCK" cat > "$SOCK_DIR/holder.py" <<'PY' import socket, sys, time path = sys.argv[1] s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) s.bind(path) s.listen(1) time.sleep(120) PY python3 "$SOCK_DIR/holder.py" "$LIVE_SOCK" >/dev/null 2>"$SOCK_DIR/holder.err" & for _ in $(seq 1 50); do [ -S "$LIVE_SOCK" ] && break sleep 0.1 done [ -S "$LIVE_SOCK" ] || { echo "FAIL: could not create live socket" >&2; exit 1; } run_session_script() { local agent="$1"; shift ( cd "$WORK_DIR" env -i HOME="$FAKE_HOME" PATH="$BIN_DIR:/usr/bin:/bin" \ GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \ MOSAIC_HOME="$FAKE_HOME/.config/mosaic" \ MOSAIC_LEASE_BROKER_SOCKET="$LIVE_SOCK" \ "$@" \ bash "$SCRIPT_DIR/start-agent-session.sh" "$agent" ) } fail=0 assert() { local desc="$1" expected="$2" actual="$3" [[ "$expected" == "$actual" ]] || { echo "FAIL: $desc — expected '$expected', got '$actual'" >&2; fail=1; } } assert_contains() { local desc="$1" haystack="$2" needle="$3" [[ "$haystack" == *"$needle"* ]] || { echo "FAIL: $desc — missing '$needle'" >&2; fail=1; } } assert_not_contains() { local desc="$1" haystack="$2" needle="$3" if [[ "$haystack" == *"$needle"* ]]; then echo "FAIL: $desc — must not contain '$needle'" >&2 fail=1 fi return 0 } # ─── 1. Legacy session present → exit 76, no tmux mutation. ───────────────── write_tmux_env "mosaic-fleet" : > "$LOG_FILE"; touch "$LEGACY_FLAG" stderr_file="$WORK_DIR/stderr-1.tmp" set +e run_session_script lsguard-test >/dev/null 2>"$stderr_file" rc=$? set -e err=$(cat "$stderr_file") assert "legacy present exit code" "76" "$rc" assert_contains "names the failure" "$err" "FAIL_LAUNCH seat-on-legacy-socket" assert_contains "names the agent" "$err" "lsguard-test" assert_contains "names the target socket" "$err" "mosaic-fleet" assert_not_contains "no session created" "$(cat "$LOG_FILE")" "TMUX-NEW-SESSION-INVOKED" # ─── 2. Legacy session absent → guard is not the refusal. ─────────────────── write_tmux_env "mosaic-fleet" : > "$LOG_FILE"; rm -f "$LEGACY_FLAG" stderr_file="$WORK_DIR/stderr-2.tmp" set +e run_session_script lsguard-test >/dev/null 2>"$stderr_file" rc=$? set -e err=$(cat "$stderr_file") if [[ "$rc" == "76" ]]; then echo "FAIL: legacy absent must not exit 76" >&2; fail=1 fi assert_not_contains "guard code absent from stderr" "$err" "seat-on-legacy-socket" # ─── 3. Empty MOSAIC_TMUX_SOCKET → guard inert, no default-socket probe. ──── write_tmux_env "" : > "$LOG_FILE"; touch "$LEGACY_FLAG" # even with a legacy session present stderr_file="$WORK_DIR/stderr-3.tmp" set +e run_session_script lsguard-nosock >/dev/null 2>"$stderr_file" rc=$? set -e err=$(cat "$stderr_file") if [[ "$rc" == "76" ]]; then echo "FAIL: empty socket must never exit 76 (single-socket host)" >&2; fail=1 fi assert_not_contains "guard code absent on single-socket host" "$err" "seat-on-legacy-socket" rm -f "$LEGACY_FLAG" if [[ "$fail" -ne 0 ]]; then echo "start-agent-session legacy-socket guard regression FAILED" >&2 exit 1 fi echo "start-agent-session legacy-socket guard regression passed"