Files
stack/tools/agent-watch/test-agent-watch-socket.sh
T

273 lines
9.5 KiB
Bash
Executable File

#!/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