#!/usr/bin/env bash # test-agent-watch-socket.sh — P5-C03 independent B1 contract validation. # # Uses real tmux servers on a private TMUX_TMPDIR. systemctl and systemd-run # are stubs because this suite validates start-time socket selection, not # systemd scheduling. HOME, watch state, and all test sockets are isolated. # Run with --no-red-controls only for the mutation child runs. set -uo pipefail HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" TOOL="${AGENT_WATCH_TOOL_OVERRIDE:-$HERE/agent-watch.sh}" REAL_TMUX="$(command -v tmux 2>/dev/null || true)" BASH_BIN="$(command -v bash)" ORIG_PATH="$PATH" [ -x "$REAL_TMUX" ] || { echo "SKIP: no tmux binary (live fixtures impossible)"; exit 77; } [ -x "$TOOL" ] || { echo "FAIL: agent-watch tool not found/executable: $TOOL" >&2; exit 1; } ROOT="$(mktemp -d)" ROOT="$(cd "$ROOT" && pwd)" HOME_DIR="$ROOT/home" TMUX_TMPDIR="$ROOT/tmux" BIN="$ROOT/bin" STATE="$ROOT/state" mkdir -p "$HOME_DIR" "$TMUX_TMPDIR" "$BIN" "$STATE" chmod 700 "$ROOT" "$HOME_DIR" "$TMUX_TMPDIR" "$BIN" "$STATE" unset MOSAIC_TMUX_SOCKET # These stubs satisfy agent-watch's user-bus and transient-unit checks while # leaving real tmux semantics, including socket discovery, under test. cat > "$BIN/systemctl" <<'STUB' #!/usr/bin/env bash case "${*:--}" in "--user show-environment") exit 0 ;; "--user is-active"*) exit 1 ;; "--user start"*) exit 1 ;; *) exit 0 ;; esac STUB cat > "$BIN/systemd-run" <<'STUB' #!/usr/bin/env bash printf '%s\n' "$*" >> "${AGENT_WATCH_TEST_SYSTEMD_RUN_LOG:?}" exit 0 STUB cat > "$BIN/sender-stub" <<'STUB' #!/usr/bin/env bash exit 0 STUB chmod 700 "$BIN/systemctl" "$BIN/systemd-run" "$BIN/sender-stub" SOCKETS=() cleanup() { local socket for socket in "${SOCKETS[@]}"; do TMUX_TMPDIR="$TMUX_TMPDIR" "$REAL_TMUX" -L "$socket" kill-server >/dev/null 2>&1 || true done rm -rf "$ROOT" } trap cleanup EXIT failures=0 check() { local label="$1" result="$2" if [ "$result" -eq 0 ]; then printf 'ok %s\n' "$label" else printf 'FAIL %s\n' "$label" >&2 failures=$((failures + 1)) fi } mk_server() { local socket="$1" session="$2" SOCKETS+=("$socket") TMUX_TMPDIR="$TMUX_TMPDIR" "$REAL_TMUX" -L "$socket" new-session -d -s "$session" -c "$HOME_DIR" \ 'exec bash --noprofile --norc -i' >/dev/null 2>&1 } run_start() { local socket_env=(-u MOSAIC_TMUX_SOCKET) if [ -n "${MOSAIC_TMUX_SOCKET:-}" ]; then socket_env=(MOSAIC_TMUX_SOCKET="$MOSAIC_TMUX_SOCKET") fi env "${socket_env[@]}" \ PATH="$BIN:$ORIG_PATH" HOME="$HOME_DIR" TMUX_TMPDIR="$TMUX_TMPDIR" \ MOSAIC_WATCH_STATE="$STATE" MOSAIC_AGENT_SEND="$BIN/sender-stub" \ AGENT_WATCH_TEST_SYSTEMD_RUN_LOG="$ROOT/systemd-run.log" \ "$BASH_BIN" "$TOOL" start "$@" } run_start_env() { local socket="$1" shift ( MOSAIC_TMUX_SOCKET="$socket" run_start "$@" ) } config_has() { local name="$1" line="$2" grep -qxF "$line" "$STATE/$name/config" 2>/dev/null } check_config() { local label="$1" name="$2" line="$3" if config_has "$name" "$line"; then check "$label" 0 else check "$label" 1 fi } # C1: explicit --socket wins over the environment and discovery. FLAG_SOCKET="aw-c03-flag-$$" mk_server "$FLAG_SOCKET" flag-target if run_start_env "not-the-flag" --name c03-flag --session flag-target --socket "$FLAG_SOCKET" \ --when false --message "socket contract" --interval 10 >/dev/null 2>&1; then check_config "C1 explicit --socket wins over MOSAIC_TMUX_SOCKET" c03-flag "SOCKET=$FLAG_SOCKET" else check "C1 explicit --socket start succeeds" 1 fi # C2: the launcher-exported environment pin wins before discovery. Both # sockets intentionally contain the target, so selecting discovery first # would hit the ambiguity guard instead of producing an env-pinned config. ENV_SOCKET="aw-c03-env-$$" ENV_DISCOVERY_SOCKET="aw-c03-env-discovery-$$" mk_server "$ENV_SOCKET" env-target mk_server "$ENV_DISCOVERY_SOCKET" env-target if run_start_env "$ENV_SOCKET" --name c03-env --session env-target \ --when false --message "socket contract" --interval 10 >/dev/null 2>&1; then check_config "C2 MOSAIC_TMUX_SOCKET wins before discovery" c03-env "SOCKET=$ENV_SOCKET" else check "C2 environment pin start succeeds without discovery ambiguity" 1 fi BAD_ENV_SOCKET="aw-c03-bad-env-$$" bad_env_rc=0 run_start_env "$BAD_ENV_SOCKET" --name c03-bad-env --session env-target \ --when false --message "socket contract" --interval 10 >/dev/null 2>"$ROOT/bad-env.err" || bad_env_rc=$? if [ "$bad_env_rc" -eq 2 ]; then check "C2 stale environment socket is rejected" 0 else check "C2 stale environment socket is rejected" 1 fi if [ ! -e "$STATE/c03-bad-env/config" ]; then check "C2 stale environment socket writes no config" 0 else check "C2 stale environment socket writes no config" 1 fi # C3: with no pin, one exact session hit is discovered from TMUX_TMPDIR. DEFAULT_SOCKET="aw-c03-default-$$" mk_server "$DEFAULT_SOCKET" default-target if run_start --name c03-default --session default-target --when false \ --message "socket contract" --interval 10 >/dev/null 2>&1; then check_config "C3 unique TMUX_TMPDIR hit is selected" c03-default "SOCKET=$DEFAULT_SOCKET" else check "C3 unique discovery start succeeds" 1 fi # C4: duplicate session names refuse with the documented rc 4 and identify # both sockets. The compound form also proves the session component is pinned # exactly while resolving, rather than being treated as a prefix. DUP_SOCKET_A="aw-c03-dup-a-$$" DUP_SOCKET_B="aw-c03-dup-b-$$" mk_server "$DUP_SOCKET_A" duplicate-target mk_server "$DUP_SOCKET_B" duplicate-target DUP_ERR="$ROOT/duplicate.err" dup_rc=0 run_start --name c03-duplicate --session duplicate-target:0.0 --when false \ --message "socket contract" --interval 10 >/dev/null 2>"$DUP_ERR" || dup_rc=$? if [ "$dup_rc" -eq 4 ]; then check "C4 compound duplicate refuses with rc 4" 0 else check "C4 compound duplicate refuses with rc 4" 1 fi if grep -qF "$DUP_SOCKET_A" "$DUP_ERR" && grep -qF "$DUP_SOCKET_B" "$DUP_ERR"; then check "C4 refusal names both duplicate sockets" 0 else check "C4 refusal names both duplicate sockets" 1 fi if [ ! -e "$STATE/c03-duplicate/config" ]; then check "C4 duplicate refusal leaves no watcher config" 0 else check "C4 duplicate refusal leaves no watcher config" 1 fi # C5: exact matching rejects a prefix-only session, then accepts the exact # name when a prefix sibling is also present. PREFIX_SOCKET="aw-c03-prefix-$$" EXACT_SOCKET="aw-c03-exact-$$" mk_server "$PREFIX_SOCKET" exact-target-old prefix_rc=0 run_start --name c03-prefix --session exact-target --when false \ --message "socket contract" --interval 10 >/dev/null 2>"$ROOT/prefix.err" || prefix_rc=$? if [ "$prefix_rc" -eq 2 ]; then check "C5 prefix-only session is not an exact hit" 0 else check "C5 prefix-only session is not an exact hit" 1 fi mk_server "$EXACT_SOCKET" exact-target if run_start --name c03-exact --session =exact-target --when false \ --message "socket contract" --interval 10 >/dev/null 2>&1; then check_config "C5 =name selects the exact session despite prefix sibling" c03-exact "SOCKET=$EXACT_SOCKET" check_config "C5 config retains the exact =name target" c03-exact 'SESSION==exact-target' else check "C5 exact =name start succeeds" 1 fi # C6: compound input without a leading '=' is normalized to '=session:rest' # and discovered. This is the regression that catches constructing '==...'. COMPOUND_SOCKET="aw-c03-compound-$$" mk_server "$COMPOUND_SOCKET" compound-target if run_start --name c03-compound --session compound-target:0.0 --when false \ --message "socket contract" --interval 10 >/dev/null 2>&1; then check_config "C6 compound session discovery pins the session component" c03-compound 'SESSION==compound-target:0.0' check_config "C6 compound session selects its unique socket" c03-compound "SOCKET=$COMPOUND_SOCKET" else check "C6 compound session discovery succeeds" 1 fi run_mutation() { local label="$1" mutation="$2" local mutant="$ROOT/$label-mutant.sh" output="$ROOT/$label.out" python3 - "$TOOL" "$mutant" "$mutation" <<'PY' import sys source, destination, mutation = sys.argv[1:] text = open(source, encoding="utf-8").read() if mutation == "env": old = 'if [[ -z "$socket" && -n "${MOSAIC_TMUX_SOCKET:-}" ]]; then\n' new = 'if false; then\n' else: old = 'tmux -L "$sname" has-session -t "$session" 2>/dev/null' new = 'tmux -L "$sname" has-session -t "${session#=}" 2>/dev/null' assert text.count(old) == 1, f"mutation anchor count for {mutation}: {text.count(old)}" mutant_text = text.replace(old, new) assert mutant_text != text open(destination, "w", encoding="utf-8").write(mutant_text) PY chmod 700 "$mutant" if cmp -s "$TOOL" "$mutant"; then echo "FAIL $label mutation was a no-op" >&2 failures=$((failures + 1)) return fi if AGENT_WATCH_TOOL_OVERRIDE="$mutant" "$BASH_BIN" "$0" --no-red-controls >"$output" 2>&1; then printf 'FAIL %s mutant survived (suite did not go red)\n' "$label" >&2 failures=$((failures + 1)) else printf 'ok %s mutant makes the behavioral suite red\n' "$label" fi } if [ "${1:-}" != "--no-red-controls" ]; then # R1: moving env precedence out of its pre-discovery branch must be caught. run_mutation red-precedence env # R2: removing '=' from discovery must be caught by the exact/prefix arms. run_mutation red-exact exact fi if [ "$failures" -eq 0 ]; then if [ "${1:-}" = "--no-red-controls" ]; then echo "agent-watch socket contract: contract arms OK (C1-C6)" else echo "agent-watch socket contract: all arms OK (C1-C6 + 2 red controls)" fi exit 0 fi printf 'agent-watch socket contract: %d failure(s)\n' "$failures" >&2 exit 1