diff --git a/packages/mosaic/framework/tools/fleet/start-agent-session.sh b/packages/mosaic/framework/tools/fleet/start-agent-session.sh index f3d2659a..2eb3fb0b 100755 --- a/packages/mosaic/framework/tools/fleet/start-agent-session.sh +++ b/packages/mosaic/framework/tools/fleet/start-agent-session.sh @@ -304,6 +304,17 @@ if [ "$MODE" = stop ]; then exit 0 fi +# #1408 hazard: a seat still living on the DEFAULT tmux socket is invisible to the +# declared-socket guard below, and launching over it creates a same-name duplicate that +# name-addressed comms delivery cannot tell apart. Refuse with a distinct code (76, +# after 75 broker-absent) so a cutover wave script can branch on "seat still on legacy +# socket" vs "already running" (0) vs "broker absent" (75). Stopping the legacy session +# belongs to the cutover procedure, never to this launcher. +if [ -n "$MOSAIC_TMUX_SOCKET" ] && tmux has-session -t "=${AGENT_NAME}" 2>/dev/null; then + echo "[fleet] FAIL_LAUNCH seat-on-legacy-socket: session '${AGENT_NAME}' exists on the DEFAULT tmux socket; stop it before launching on '${MOSAIC_TMUX_SOCKET}'." >&2 + exit 76 +fi + if _tmux has-session -t "=${AGENT_NAME}:0.0" 2>/dev/null; then echo "Mosaic agent session already running: $AGENT_NAME on socket ${MOSAIC_TMUX_SOCKET:-(default)}" exit 0 @@ -421,9 +432,22 @@ if [ "$MOSAIC_AGENT_RUNTIME" = claude ]; then echo "WARNING: could not pre-trust workdir for claude agent $AGENT_NAME" >&2 fi -LAUNCH_COMMAND=(mosaic yolo "$MOSAIC_AGENT_RUNTIME") -if [ -n "$MOSAIC_AGENT_MODEL" ]; then LAUNCH_COMMAND+=(--model "$MOSAIC_AGENT_MODEL"); fi -if [ -n "$MOSAIC_AGENT_REASONING" ]; then LAUNCH_COMMAND+=(--thinking "$MOSAIC_AGENT_REASONING"); fi +# #1408 hazard: prefer the seat's own launch.sh when the brain provides one. It is the +# path that binds the auth profile (CLAUDE_SECURESTORAGE_CONFIG_DIR) and seeds the seat +# config; `mosaic yolo` relocates CLAUDE_CONFIG_DIR to the seat dir (launch.ts +# activeSeatDir/harnessEnv) but performs neither, so a yolo-launched seat points its +# config at a directory holding no credentials. The env -i allowlist below still +# applies: launch.sh reads its own launch.env. +SEAT_LAUNCH="${BRAIN_HOME}/fleet/agents/${AGENT_NAME}/launch.sh" +if [ -x "$SEAT_LAUNCH" ]; then + LAUNCH_COMMAND=("$SEAT_LAUNCH") + echo "[fleet] launch path: seat launch.sh ($SEAT_LAUNCH)" +else + LAUNCH_COMMAND=(mosaic yolo "$MOSAIC_AGENT_RUNTIME") + if [ -n "$MOSAIC_AGENT_MODEL" ]; then LAUNCH_COMMAND+=(--model "$MOSAIC_AGENT_MODEL"); fi + if [ -n "$MOSAIC_AGENT_REASONING" ]; then LAUNCH_COMMAND+=(--thinking "$MOSAIC_AGENT_REASONING"); fi + echo "[fleet] launch path: mosaic yolo (no executable seat launch.sh)" +fi # The tmux holder owns a named server. Explicitly clear the pane environment # so server/session variables cannot cross the launch boundary; retain only diff --git a/packages/mosaic/framework/tools/fleet/test-agent-session-legacy-socket-guard.sh b/packages/mosaic/framework/tools/fleet/test-agent-session-legacy-socket-guard.sh new file mode 100755 index 00000000..7857e4f4 --- /dev/null +++ b/packages/mosaic/framework/tools/fleet/test-agent-session-legacy-socket-guard.sh @@ -0,0 +1,216 @@ +#!/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" diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index be07e02a..13da161a 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -25,7 +25,7 @@ "lint": "eslint src", "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests && pnpm run test:framework-shell", - "test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/lease-broker/revoke_noop_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-create-fallback-default-base.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-ci-queue-wait-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh && bash framework/tools/_scripts/test-structure-anchor-check.sh && bash framework/tools/fleet/test-agent-session-broker-preflight.sh" + "test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/lease-broker/revoke_noop_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-create-fallback-default-base.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-ci-queue-wait-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh && bash framework/tools/_scripts/test-structure-anchor-check.sh && bash framework/tools/fleet/test-agent-session-broker-preflight.sh && bash framework/tools/fleet/test-agent-session-legacy-socket-guard.sh" }, "dependencies": { "@mosaicstack/brain": "workspace:*",