Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions scripts/drivers/types/codex/codex-monitor.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions scripts/drivers/types/codex/type.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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 <SESSION_ID> [PROMPT]`. The one-key convention emits this value
# verbatim right after the cli (subcommands must lead the argv), so `resume` is
Expand Down
57 changes: 55 additions & 2 deletions scripts/spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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=`
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions tests/test_codex_monitor.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Loading
Loading