diff --git a/scripts/drivers/types/codex/codex-monitor.sh b/scripts/drivers/types/codex/codex-monitor.sh index 8f580c917..b297518ed 100755 --- a/scripts/drivers/types/codex/codex-monitor.sh +++ b/scripts/drivers/types/codex/codex-monitor.sh @@ -101,6 +101,16 @@ PROJECT="$(cd "$PROJECT" && pwd)" # otherwise message receipt stops silently. The earlier echoes give the specific # reason + log path; this prints the one-line summary just before handoff. exec_plain_codex() { + # Fail-open is for a PERSON at the keyboard, who reads the banner and keeps a + # working codex. A seat spawned by agmsg (spawn.sh exports AGMSG_SPAWNED=1) + # has nobody at its pane: a plain codex there never receives a message, and + # the banner is read by no one — the outcome looks exactly like the shim + # bypass this launch path was built to close. So a spawned seat fails CLOSED: + # say why, leave the pane's shell for whoever comes to look, start nothing. + if [ "${AGMSG_SPAWNED:-}" = "1" ]; then + echo "agmsg: Codex monitor bridge unavailable, and this session was spawned by agmsg (AGMSG_SPAWNED=1). Refusing to start a plain Codex: a spawned seat has no person at the pane to read this, and without the bridge it would never receive a message. The reason is printed above; see the app-server log for details." >&2 + exit 1 + fi echo "agmsg: Codex monitor bridge unavailable - launching plain Codex. Real-time agmsg delivery is OFF this session (messages still queue; check your inbox manually). Likely cause: the Codex app-server interface changed in 0.142+. Fix in progress." >&2 cd "$PROJECT" 2>/dev/null || true case "$CODEX_COMMAND" in diff --git a/scripts/drivers/types/codex/type.conf b/scripts/drivers/types/codex/type.conf index 695489828..a87b57608 100644 --- a/scripts/drivers/types/codex/type.conf +++ b/scripts/drivers/types/codex/type.conf @@ -3,6 +3,61 @@ name=codex template=template.md cli=codex spawnable=yes +# spawn runs THIS script (relative to this directory) instead of the bare `cli` +# (#1063). The agmsg monitor bridge is entered through codex-shim.sh, which is +# deliberately installed as an interactive-shell function (PR #193); a function +# is not exported into the non-interactive shell that runs a spawn boot script, +# so a bare `codex` there resolves to the real binary and the seat starts +# without --remote (measured 2026-09-06: the bridge then restarts every few +# seconds against a thread it cannot own). The shim is addressed by its bundled +# path; a declared wrapper that is missing is a spawn failure, never a fallback +# to the bare cli. +spawn_wrapper=codex-shim.sh +# Unset in the boot script BEFORE the wrapper runs (#294's mechanism). A spawned +# seat is a NEW session and inherits none of the shim stack's runtime control +# state from the process that spawned it. Two of those controls are exported by +# the stack itself in normal operation, so inheritance is the ordinary case: +# codex-monitor exports AGMSG_CODEX_BRIDGE=1 before it execs the bridged TUI +# (inherited, the shim execs the real binary at once — its contract for nested +# invocations inside a bridged session), and the installed PATH wrapper exports +# AGMSG_CODEX_SHIM_WRAPPER=1 / AGMSG_CODEX_SHIM_SCRIPT_DIR (inherited, the bundled +# shim takes the PARENT's install dir as its own; a stale one has no delivery.sh +# and reads as "not a monitor project"). Both fire ahead of any guard inside +# codex-monitor (review, 2026-09-06, one name per round) — so the rule is a +# NAMESPACE, not a list: `AGMSG_CODEX_*` is the stack's runtime-control +# namespace and every variable in it is cleared, including ones that do not +# exist yet. A full allow-list (exec the boot under `env -i`) was rejected: the +# CLI needs the user's own environment — auth, PATH, HOME, locale, terminal — +# and spawn cannot enumerate what a given CLI version needs, so a miss there +# breaks every spawn; a miss here (a control named OUTSIDE the namespace) is +# caught by the reader-inventory test in tests/test_spawn.bats. +# +# Derivation (both sides, 2026-09-06): readers = every `$VAR`/`${VAR` in +# codex-shim.sh, codex-monitor.sh, _app-server.sh, codex-bridge-launcher.sh, +# codex-record-session.sh, _session-start.sh, codex-shim-install.sh, +# _delivery.sh and every `process.env.VAR` in codex-bridge.js, for AGMSG_*/CODEX_*; +# writers = `export` lines (codex-monitor: BRIDGE, BRIDGE_APP_SERVER, +# BRIDGE_LAUNCHER), the command-local AGMSG_REAL_CODEX the shim hands to +# codex-monitor (so the TUI inherits it), and the wrapper codex-shim-install.sh +# generates (SHIM_WRAPPER, SHIM_SCRIPT_DIR, SHIM_TARGET). Verdict per name: +# AGMSG_CODEX_* (19 today) cleared — bridge markers, shim install state, +# resolution overrides, bridge tuning knobs +# AGMSG_REAL_CODEX cleared — the parent's resolved binary path +# CODEX_THREAD_ID cleared — the parent's thread; codex-record-session +# prefers it and would record the parent's thread +# for this seat +# AGMSG_SPAWNED kept — this seat's own marker, set by the boot +# AGMSG_BASH, AGMSG_WATCH_ONCE_INTERVAL/_TIMEOUT +# kept — agmsg-wide configuration, not codex +# stack control; the actas flow inherits it on purpose +# AGMSG_TEST_* kept — set only under bats +# AGMSG_ROLE_SESSION_UUID/_PROJECT, CODEX_ARGS/_COMMAND/_VERSION, +# CODEX_MONITOR_DOC_URL not inputs — script-local variables assigned +# inside the stack before they are read +# If this rule leaks — a control variable added OUTSIDE AGMSG_CODEX_* — the +# inventory test goes red at review time; if it is bypassed at runtime, the +# symptom is the original one: a seat that looks spawned and never speaks. +spawn_unset_env=CODEX_THREAD_ID AGMSG_REAL_CODEX AGMSG_CODEX_* # Resume a prior session (#339). codex 0.142 resumes via a SUBCOMMAND, not a flag: # `codex resume [PROMPT]`. The one-key convention emits this value # verbatim right after the cli (subcommands must lead the argv), so `resume` is diff --git a/scripts/spawn.sh b/scripts/spawn.sh index 6f2db35ea..4892dcbe6 100755 --- a/scripts/spawn.sh +++ b/scripts/spawn.sh @@ -239,6 +239,32 @@ elif [ -z "$SPAWN_LAUNCHER" ]; then die "agent type '$AGENT_TYPE' manifest declares neither a 'cli' binary nor a 'spawn' launcher" fi +# --- Optional launch wrapper (manifest `spawn_wrapper=`) --- +# A type may name a script, relative to its own driver directory, that the boot +# script runs INSTEAD of the bare `cli=` executable. codex needs this (#1063): +# its monitor bridge is entered through codex-shim.sh, which is installed on +# purpose as an interactive-shell FUNCTION (PR #193) — so it is not exported +# and does not exist in the non-interactive shell that runs a boot script, +# where `command -v codex` resolves the REAL binary and the spawned seat starts +# without --remote (measured 2026-09-06: the bridge then restarts every few +# seconds against a thread it cannot own, and no message is ever delivered). +# The function form stays; what spawn needs is a resolution that works from a +# non-interactive shell, so the wrapper is addressed by its bundled path — +# never through PATH or function lookup. A wrapper that is declared but +# missing or not executable is a REFUSAL: launching the bare CLI instead would +# recreate the bypass with no signal, and the symptom is a seat that looks +# spawned and never says a word. The `cli=` check above still applies, because +# the wrapper needs the real binary too. +SPAWN_WRAPPER="" +_spawn_wrapper_rel="$(agmsg_type_get "$AGENT_TYPE" spawn_wrapper)" +if [ -n "$_spawn_wrapper_rel" ]; then + _spawn_type_dir="$(agmsg_type_dir "$AGENT_TYPE")" \ + || die "agent type '$AGENT_TYPE' declares spawn_wrapper but its driver directory could not be resolved" + SPAWN_WRAPPER="$_spawn_type_dir/$_spawn_wrapper_rel" + [ -x "$SPAWN_WRAPPER" ] \ + || die "agent type '$AGENT_TYPE' declares a launch wrapper that is missing or not executable: $SPAWN_WRAPPER — refusing to launch the bare '$CLI_BIN_EXE' in its place (bypassing the wrapper is exactly what it exists to prevent)" +fi + # --model is pass-through: the model id is handed to the CLI unchecked (the CLI # rejects an unknown id), so agmsg never has to track each vendor's model list. # The flag SPELLING differs per CLI, so it comes from the manifest `model_arg=` @@ -461,8 +487,27 @@ esac # suppresses the "rename this session" tip meant for hand-started sessions. echo 'export AGMSG_SPAWNED=1' # Drop inherited same-type session-identity vars before exec'ing the CLI (#294). + # An entry ending in `*` is a NAMESPACE: every exported variable whose name + # starts with that prefix is unset, enumerated from `env` at boot time, so a + # control variable the type's runtime adds later is cleared without anyone + # remembering to list it (#1063: the codex shim stack keeps all of its runtime + # controls under AGMSG_CODEX_*, and two review rounds each found one more name + # a deny-list had missed). Names without `*` are unset literally. if [ -n "$SPAWN_UNSET_VARS" ]; then - printf 'unset %s\n' "$SPAWN_UNSET_VARS" + _unset_literal="" + for _v in $SPAWN_UNSET_VARS; do + case "$_v" in + *\*) + # `sed -e … -e …`, not `sed -n`: the emitted line must not contain a + # bare ` -n `, which is also claude-code's name flag and is asserted + # absent from a codex boot script. + _prefix="${_v%\*}" + printf 'for _v in $(env | sed -e '"'"'/^%s[A-Za-z0-9_]*=/!d'"'"' -e '"'"'s/=.*//'"'"'); do unset "$_v"; done\n' "$_prefix" + ;; + *) _unset_literal="$_unset_literal $_v" ;; + esac + done + [ -n "$_unset_literal" ] && printf 'unset%s\n' "$_unset_literal" fi if [ -n "$SPAWN_AGENT" ]; then # Node-launcher path: pass the universal agmsg context + the actas prompt. @@ -491,7 +536,15 @@ esac # agmsg_role_cli_args so its flag order matches resurrect-panes.sh. # MSYS_GUARD (#336) prefixes the CLI line as a command-local env assignment; # emitted with %s (not %q) so it stays an assignment, not a single token. - printf '%s%s' "$MSYS_GUARD" "$CLI_BIN" + # With a manifest spawn_wrapper, the wrapper's bundled path replaces the + # cli's FIRST word (the executable); any fixed-prefix tokens after it are + # kept, since the wrapper forwards its argv to the real binary. + if [ -n "$SPAWN_WRAPPER" ]; then + printf '%s%q' "$MSYS_GUARD" "$SPAWN_WRAPPER" + case "$CLI_BIN" in *' '*) printf ' %s' "${CLI_BIN#* }" ;; esac + else + printf '%s%s' "$MSYS_GUARD" "$CLI_BIN" + fi agmsg_role_resume_head "$AGENT_TYPE" "$RESUME_UUID" [ -n "$MODEL_ID" ] && printf ' %s %q' "$MODEL_ARG" "$MODEL_ID" for _tok in ${SPAWN_OPT_TOKENS[@]+"${SPAWN_OPT_TOKENS[@]}"}; do diff --git a/tests/test_codex_monitor.bats b/tests/test_codex_monitor.bats index ebf4be667..b5b3f88ef 100644 --- a/tests/test_codex_monitor.bats +++ b/tests/test_codex_monitor.bats @@ -394,3 +394,28 @@ EOF # No truncating redirect to the published path. ! grep -qE '>[[:space:]]*"\$PORT_FILE"' "$src" } + +# --- fail-closed for a spawned seat --- + +@test "codex-monitor: a spawned seat (AGMSG_SPAWNED=1) fails CLOSED when the bridge cannot start" { + # Fail-open exists for a person at the keyboard. A seat spawned by agmsg has + # nobody at its pane, and a plain codex there never receives a message -- the + # same outcome as the shim bypass spawn was fixed to close. So with the + # spawn marker set, a broken bridge must refuse to start codex at all. + run env FAKE_CODEX_MODE=broken AGMSG_SPAWNED=1 AGMSG_REAL_CODEX="$FAKE_CODEX" \ + bash "$TYPES/codex/codex-monitor.sh" --project "$TEST_PROJECT" --codex-command codex -- --foo + [ "$status" -ne 0 ] + # No plain codex was started, bridged or not. + [ ! -e "$CALL_LOG" ] + # And the refusal says why, and that the session was spawned. + printf '%s\n' "$output" | grep -qF 'Refusing to start a plain Codex' + printf '%s\n' "$output" | grep -qF 'AGMSG_SPAWNED=1' +} + +@test "codex-monitor: without the spawn marker the same failure still fails open (a person keeps a codex)" { + # The control for the test above: the ONLY difference is the marker. + run env FAKE_CODEX_MODE=broken AGMSG_REAL_CODEX="$FAKE_CODEX" \ + bash "$TYPES/codex/codex-monitor.sh" --project "$TEST_PROJECT" --codex-command codex -- --foo + [ "$status" -eq 0 ] + grep -qx 'plain-codex <--foo>' "$CALL_LOG" +} diff --git a/tests/test_spawn.bats b/tests/test_spawn.bats index 66d107d39..e6620de65 100644 --- a/tests/test_spawn.bats +++ b/tests/test_spawn.bats @@ -289,8 +289,9 @@ seed_resumable() { run bash "$SCRIPTS/spawn.sh" codex bob --project "$PROJ" --no-wait [ "$status" -eq 0 ] boot="$(cat "$CAPTURE")"; run cat "$boot" - # Subcommand shape: `codex resume cx-uuid-1 ...` -- resume token right after cli. - [[ "$output" == *"codex resume cx-uuid-1"* ]] + # Subcommand shape: ` resume cx-uuid-1 ...` -- resume token right after + # the executable (the bundled codex-shim.sh, which forwards argv to codex). + [[ "$output" == *"codex-shim.sh resume cx-uuid-1"* ]] [[ "$output" == *"actas"* ]] # codex has no name_arg, so no -n. [[ "$output" != *" -n "* ]] @@ -379,7 +380,7 @@ seed_resumable() { [ "$status" -eq 0 ] boot="$(cat "$CAPTURE")" run cat "$boot" - [[ "$output" == *"codex -m gpt-5"* ]] + [[ "$output" == *"codex-shim.sh -m gpt-5"* ]] } @test "spawn --model: grok-build launch uses its --model flag" { @@ -1377,3 +1378,298 @@ T refute grep -qx -- '-' <<<"$output" # no line that is just the protocol '-' grep -q "terminal template" <<<"$output" # the OS-terminal path did run } + +# --- codex launches through the bundled shim, by path (spawn_wrapper=) --------- +# A person's shell reaches codex-shim.sh as a function or a PATH entry; the +# non-interactive shell that runs a boot script has neither, so a bare `codex` +# there is the real binary and the seat starts without --remote (measured: the +# bridge restarts every few seconds against a thread it cannot own). The +# manifest names the shim relative to the type directory and spawn addresses it +# by that bundled path. + +# The executable of a boot script's CLI line: the first word that is not a +# leading VAR=value assignment (the MSYS argv guard, #336, prefixes every line). +_boot_line_executable() { + printf '%s\n' "$1" | awk '{ for (i = 1; i <= NF; i++) if ($i !~ /=/) { print $i; exit } }' +} + +@test "spawn: codex is launched through the bundled shim by its absolute path, not a bare cli" { + bash "$SCRIPTS/join.sh" myteam existing codex "$PROJ" + run bash "$SCRIPTS/spawn.sh" codex reviewer --project "$PROJ" --no-wait + [ "$status" -eq 0 ] + boot="$(cat "$CAPTURE")" + [ -f "$boot" ] + # The CLI line's first word is the shim inside THIS skill dir -- not `codex`, + # not something found on PATH. + local shim; shim="$TEST_SKILL_DIR/scripts/drivers/types/codex/codex-shim.sh" + grep -qF "$shim" "$boot" + # And it is the executable of the actas line (the token before the prompt). + local line; line="$(grep -F 'actas' "$boot" | head -1)" + echo "actas line: $line" + [ "$(_boot_line_executable "$line")" = "$shim" ] + # The bare cli must not be launched anywhere in the script. + [ "$(grep -cE '^codex |^[A-Z_]+=[^ ]* codex ' "$boot")" -eq 0 ] +} + +@test "spawn: a declared wrapper that is missing is a refusal, never a silent bare-cli launch" { + bash "$SCRIPTS/join.sh" myteam existing codex "$PROJ" + # Break the bundled shim (the type manifest still declares it). + chmod -x "$TEST_SKILL_DIR/scripts/drivers/types/codex/codex-shim.sh" + run bash "$SCRIPTS/spawn.sh" codex reviewer --project "$PROJ" --no-wait + [ "$status" -ne 0 ] + printf '%s\n' "$output" | grep -qF 'launch wrapper' + printf '%s\n' "$output" | grep -qF 'codex-shim.sh' + # Nothing was placed or launched: no boot script was handed to the terminal. + [ ! -s "$CAPTURE" ] + chmod +x "$TEST_SKILL_DIR/scripts/drivers/types/codex/codex-shim.sh" +} + +@test "spawn: a type without spawn_wrapper still launches its bare cli (claude-code)" { + bash "$SCRIPTS/join.sh" myteam existing claude-code "$PROJ" + run bash "$SCRIPTS/spawn.sh" claude-code reviewer --project "$PROJ" --no-wait + [ "$status" -eq 0 ] + boot="$(cat "$CAPTURE")" + # The actas line's executable is the bare `claude` (resolved by the pane's + # PATH), with no wrapper in front of it. + local line; line="$(grep -F 'actas' "$boot" | head -1)" + echo "actas line: $line" + [ "$(_boot_line_executable "$line")" = claude ] + [ "$(grep -c 'codex-shim' "$boot")" -eq 0 ] +} + +@test "spawn: a spawned codex reaches the real binary WITH --remote (argv read from the boot script's launch)" { + # End to end: run the boot script spawn wrote. Its shim resolves the real + # codex on PATH (a fake that records argv and can bring up an app-server), + # enters codex-monitor because the project is in monitor mode, and execs the + # real binary with --remote. The argv the fake recorded is the evidence. + bash "$SCRIPTS/join.sh" myteam existing codex "$PROJ" + bash "$SCRIPTS/delivery.sh" set monitor codex "$PROJ" >/dev/null + _install_fake_codex_bridge_stack + run bash "$SCRIPTS/spawn.sh" codex reviewer --project "$PROJ" --no-wait + [ "$status" -eq 0 ] + boot="$(cat "$CAPTURE")" + run env SHELL="$STUB_BIN/noshell" bash "$boot" + _assert_bridged_argv +} + +@test "spawn: a codex seat spawned FROM a bridged codex seat still reaches the real binary WITH --remote" { + # codex-monitor exports AGMSG_CODEX_BRIDGE=1 right before it execs the bridged + # TUI, so a seat spawned from inside that session inherits it, and the shim + # passes straight to the real binary when it sees it -- ahead of any guard in + # codex-monitor (found in review). The boot script must unset the inherited + # bridge/opt-out state BEFORE the wrapper runs; this seeds all of it and reads + # the argv that arrives. + bash "$SCRIPTS/join.sh" myteam existing codex "$PROJ" + bash "$SCRIPTS/delivery.sh" set monitor codex "$PROJ" >/dev/null + _install_fake_codex_bridge_stack + run bash "$SCRIPTS/spawn.sh" codex reviewer --project "$PROJ" --no-wait + [ "$status" -eq 0 ] + boot="$(cat "$CAPTURE")" + # The guard sits BEFORE the act: the namespace-clearing line (every + # AGMSG_CODEX_* exported variable, enumerated at boot time) and the literal + # unset both precede the wrapper line. One assertion per line, on purpose: an + # `a && b && c` list does not fail the test when `a` fails. + local ns_ln lit_ln cli_ln + ns_ln="$(grep -n 'for _v in $(env | sed .*AGMSG_CODEX_' "$boot" | head -1 | cut -d: -f1)" + lit_ln="$(grep -n '^unset .*CODEX_THREAD_ID' "$boot" | head -1 | cut -d: -f1)" + cli_ln="$(grep -n 'codex-shim.sh' "$boot" | head -1 | cut -d: -f1)" + [ -n "$ns_ln" ] + [ -n "$lit_ln" ] + [ -n "$cli_ln" ] + [ "$ns_ln" -lt "$cli_ln" ] + [ "$lit_ln" -lt "$cli_ln" ] + grep -q '^unset .*AGMSG_REAL_CODEX' "$boot" + run env SHELL="$STUB_BIN/noshell" \ + AGMSG_CODEX_BRIDGE=1 AGMSG_CODEX_BRIDGE_APP_SERVER="ws://127.0.0.1:1" AGMSG_CODEX_BRIDGE_LAUNCHER=1 \ + CODEX_THREAD_ID=parent-thread bash "$boot" + _assert_bridged_argv +} + +@test "spawn: an inherited AGMSG_CODEX_SHIM_DISABLE=1 does not make a spawned codex seat bypass the shim" { + # The shim's other early exit. Seeded ALONE, so this variable is proven + # handled on its own and not only in the company of the bridge one. + bash "$SCRIPTS/join.sh" myteam existing codex "$PROJ" + bash "$SCRIPTS/delivery.sh" set monitor codex "$PROJ" >/dev/null + _install_fake_codex_bridge_stack + run bash "$SCRIPTS/spawn.sh" codex reviewer --project "$PROJ" --no-wait + [ "$status" -eq 0 ] + boot="$(cat "$CAPTURE")" + run env SHELL="$STUB_BIN/noshell" \ + AGMSG_CODEX_SHIM_DISABLE=1 bash "$boot" + _assert_bridged_argv +} + +# Fake codex + bridge stack for the end-to-end launches: a `codex` on PATH that +# records its argv and can serve a fake app-server, a stub bridge launcher, and a +# stand-in for the shell the boot script execs into at the end. +_install_fake_codex_bridge_stack() { + export CALL_LOG="$TEST_SKILL_DIR/codex-calls.log" + cat > "$STUB_BIN/codex" <<'FAKE' +#!/usr/bin/env bash +case "${1:-}" in + --version) echo "codex-cli 0.142.2"; exit 0 ;; + app-server) + python3 - <<'PY' +import socket, sys, os +s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +s.bind(("127.0.0.1", 0)); s.listen(16); s.settimeout(0.2) +print("codex app-server (WebSockets)") +print(" listening on: ws://127.0.0.1:%d" % s.getsockname()[1]); sys.stdout.flush() +ppid = os.getppid() +while True: + if os.getppid() != ppid: + break + try: + c, _ = s.accept(); c.close() + except Exception: + pass +PY + ;; + *) + printf 'real-codex' >> "$CALL_LOG" + for a in "$@"; do printf ' <%s>' "$a" >> "$CALL_LOG"; done + printf '\n' >> "$CALL_LOG" + ;; +esac +FAKE + chmod +x "$STUB_BIN/codex" + # The bridge launcher is detached and Node-based; stand in for it BY FILE in + # this test copy of the skill, not through AGMSG_CODEX_BRIDGE_LAUNCHER_CMD: + # the boot script unsets every shim-stack override, so an env hook set by the + # test would be cleared before the wrapper runs (which is the point). + printf '#!/usr/bin/env bash\nexit 0\n' > "$TEST_SKILL_DIR/scripts/drivers/types/codex/codex-bridge-launcher.sh" + # The boot script ends with `exec "$SHELL" -i`; give it a shell that just exits. + printf '#!/usr/bin/env bash\nexit 0\n' > "$STUB_BIN/noshell"; chmod +x "$STUB_BIN/noshell" +} + +# The evidence both end-to-end tests read: the real binary was reached exactly +# once, bridged, with the actas prompt. Then the fake app-server is put down. +_assert_bridged_argv() { + [ -f "$CALL_LOG" ] + # The real binary was reached exactly once, with the bridge flag and the actas prompt. + [ "$(grep -c '^real-codex' "$CALL_LOG")" -eq 1 ] + grep -q '^real-codex <--remote> ' "$CALL_LOG" + grep -q 'actas' "$CALL_LOG" + grep -q 'reviewer' "$CALL_LOG" + # Clean up the fake app-server the shim brought up. + local pf pid + for pf in "$TEST_SKILL_DIR"/run/codex-app-server.*.pid; do + [ -f "$pf" ] || continue + pid="$(cat "$pf" 2>/dev/null)"; [ -n "$pid" ] || continue + kill "$pid" 2>/dev/null || true + done +} + +@test "spawn: an inherited PATH-wrapper install (AGMSG_CODEX_SHIM_WRAPPER/SCRIPT_DIR) cannot repoint the bundled shim" { + # The installed ~/.agents/bin wrapper exports AGMSG_CODEX_SHIM_WRAPPER=1 and + # AGMSG_CODEX_SHIM_SCRIPT_DIR before starting the bundled shim; a codex started + # through it passes both to anything it spawns. Inherited, the bundled shim + # would take the PARENT's script dir as its own (codex-shim.sh, the WRAPPER + # branch) -- here a stale directory with no delivery.sh, which the shim reads + # as "not a monitor project" and passes straight to the real binary (found in + # review). Seeded with exactly that, the seat must still arrive bridged. + bash "$SCRIPTS/join.sh" myteam existing codex "$PROJ" + bash "$SCRIPTS/delivery.sh" set monitor codex "$PROJ" >/dev/null + _install_fake_codex_bridge_stack + local stale; stale="$TEST_SKILL_DIR/stale-install"; mkdir -p "$stale" + run bash "$SCRIPTS/spawn.sh" codex reviewer --project "$PROJ" --no-wait + [ "$status" -eq 0 ] + boot="$(cat "$CAPTURE")" + run env SHELL="$STUB_BIN/noshell" \ + AGMSG_CODEX_SHIM_WRAPPER=1 AGMSG_CODEX_SHIM_SCRIPT_DIR="$stale" AGMSG_CODEX_SHIM_TARGET="$stale/codex" bash "$boot" + _assert_bridged_argv +} + +@test "spawn: inherited resolution overrides (real binary, monitor, launcher) do not steer a spawned codex seat" { + # The remaining shim-stack inputs: a parent's AGMSG_REAL_CODEX, + # AGMSG_CODEX_MONITOR_CMD and the launcher command overrides. Seeded with paths + # that do not exist, the seat must still resolve everything from its own + # install and arrive bridged; if any of them leaked through, the launch would + # fail or go plain, and the argv below would not be recorded. + bash "$SCRIPTS/join.sh" myteam existing codex "$PROJ" + bash "$SCRIPTS/delivery.sh" set monitor codex "$PROJ" >/dev/null + _install_fake_codex_bridge_stack + run bash "$SCRIPTS/spawn.sh" codex reviewer --project "$PROJ" --no-wait + [ "$status" -eq 0 ] + boot="$(cat "$CAPTURE")" + run env SHELL="$STUB_BIN/noshell" \ + AGMSG_REAL_CODEX=/nonexistent/parent-codex AGMSG_CODEX_MONITOR_CMD=/nonexistent/parent-monitor \ + AGMSG_CODEX_BRIDGE_LAUNCHER_CMD=/nonexistent/parent-launcher AGMSG_CODEX_BRIDGE_CMD=/nonexistent/parent-bridge \ + bash "$boot" + _assert_bridged_argv +} + +@test "spawn: every environment variable the codex shim stack reads is cleared for a spawned seat (reader inventory)" { + # The list in type.conf is only as good as its coverage. This pins it to the + # readers: every ${AGMSG_CODEX_*} / ${AGMSG_REAL_CODEX} the shim, monitor and + # launcher consult must be in spawn_unset_env, so the next control variable + # added to the stack cannot be inherited by a spawned seat unnoticed. The + # spawn marker itself is the one exception -- it is what the seat is. + # Derivation: every `$VAR`/`${VAR` in the stack's bash files and every + # `process.env.VAR` in the bridge, for VAR matching AGMSG_* or CODEX_*. + # Each such name must be covered by spawn_unset_env — literally, or by a + # namespace entry (`PREFIX*`) — unless it is on the short, reasoned keep list + # below. A new reader outside the namespace fails here, which is the only way + # a deny rule can say "we would notice". + local dir="$SCRIPTS/drivers/types/codex" v listed entry covered + export SKILL_DIR="$TEST_SKILL_DIR" + # shellcheck disable=SC1090 + source "$SCRIPTS/lib/type-registry.sh" + listed="$(agmsg_type_get codex spawn_unset_env)" + # Kept on purpose (each with its reason in type.conf): the seat's own marker; + # agmsg-wide configuration the CLI's actas flow legitimately inherits; test + # hooks that only bats sets; and names that are script-local variables + # assigned inside the stack before they are read, never environment inputs + # (role-session lookup results, codex-monitor's parsed command/args/version, + # the doc URL constant from delivery.sh). + local keep=" AGMSG_SPAWNED AGMSG_BASH AGMSG_WATCH_ONCE_INTERVAL AGMSG_WATCH_ONCE_TIMEOUT AGMSG_TEST_DISPATCHER_STALE_BARRIER AGMSG_TEST_ASSUME_CODEX_SOCKET AGMSG_ROLE_SESSION_UUID AGMSG_ROLE_SESSION_PROJECT CODEX_ARGS CODEX_COMMAND CODEX_VERSION CODEX_MONITOR_DOC_URL " + local inventory missing="" + inventory="$( { grep -ohE '\$\{?(AGMSG_[A-Z0-9_]+|CODEX_[A-Z0-9_]+)' "$dir"/codex-shim.sh "$dir"/codex-monitor.sh "$dir"/_app-server.sh "$dir"/codex-bridge-launcher.sh "$dir"/codex-record-session.sh "$dir"/_session-start.sh "$dir"/codex-shim-install.sh "$dir"/_delivery.sh | sed -E 's/^\$\{?//'; grep -ohE 'process\.env\.(AGMSG_[A-Z0-9_]+|CODEX_[A-Z0-9_]+)' "$dir"/codex-bridge.js | sed 's/process\.env\.//'; } | sort -u )" + while IFS= read -r v; do + [ -n "$v" ] || continue + case "$keep" in *" $v "*) continue ;; esac + covered=0 + for entry in $listed; do + case "$entry" in + *\*) case "$v" in "${entry%\*}"*) covered=1 ;; esac ;; + *) [ "$v" = "$entry" ] && covered=1 ;; + esac + done + [ "$covered" -eq 1 ] || missing="$missing $v" + done </dev/null + _install_fake_codex_bridge_stack + # Extend the fake: on the bridged launch, also dump its environment. + sed -i.bak 's| printf .real-codex. >> "\$CALL_LOG"| env > "$CALL_LOG.env"\n&|' "$STUB_BIN/codex"; rm -f "$STUB_BIN/codex.bak" + grep -q 'env > "\$CALL_LOG.env"' "$STUB_BIN/codex" + run bash "$SCRIPTS/spawn.sh" codex reviewer --project "$PROJ" --no-wait + [ "$status" -eq 0 ] + boot="$(cat "$CAPTURE")" + run env SHELL="$STUB_BIN/noshell" AGMSG_CODEX_FUTURE_KNOB=1 SPAWN_TEST_CANARY=alive bash "$boot" + _assert_bridged_argv + [ -f "$CALL_LOG.env" ] + grep -q '^SPAWN_TEST_CANARY=alive$' "$CALL_LOG.env" + [ "$(grep -c '^AGMSG_CODEX_FUTURE_KNOB=' "$CALL_LOG.env")" -eq 0 ] + # And the seat's own marker is NOT cleared by the namespace rule. + grep -q '^AGMSG_SPAWNED=1$' "$CALL_LOG.env" +}