diff --git a/docs/adr/0005-codex-invocation-scoped-app-server.md b/docs/adr/0005-codex-invocation-scoped-app-server.md new file mode 100644 index 000000000..b0a156abb --- /dev/null +++ b/docs/adr/0005-codex-invocation-scoped-app-server.md @@ -0,0 +1,81 @@ +# ADR 0005: Codex invocation-scoped app-server lifecycle + +**Status:** proposed +**Date:** 2026-08-30 +**Deciders:** @fujibee + +## Context + +The Codex monitor normally reuses one app-server per project and replaces itself +with the TUI. Remote tool processes can therefore keep a disposable worktree as +their working directory after that TUI closes. A project hash, process name, or +Codex thread ID cannot prove which OS processes belong to one invocation when +several sessions share the server. This is the narrow managed-worktree case in +[#149](https://github.com/fujibee/agmsg/issues/149). + +## Decision + +Add opt-in `codex-monitor.sh --invocation-scope ` mode. The monitor +validates the token, hashes it with the canonical project path, acquires a lease +for that key, and starts a fresh app-server. It supervises the TUI, app-server, +and top-level bridge launcher as captured shell jobs. Normal TUI exit stops and +waits for the captured server and launcher, removes only matching records and +the keyed request, releases the lease, and returns the TUI status. Direct +`TERM` also reaps the captured TUI and returns 143. + +The raw token is never used as a path. `AGMSG_CODEX_APP_SERVER_KEY` contains +only the derived 40-character hexadecimal key. Scope-less launches scrub that +key and any inherited bridge URL before deriving their project-shared records. +Malformed inherited keys fail closed before any request path or lock resource +is used. + +Scoped SessionStart requests and dispatcher locks use the same app-server key. +The monitor clears a pre-existing keyed request immediately after acquiring the +lease and again during cleanup, so an old request cannot route a new invocation. +A scoped launcher accepts only a three-field request whose thread is nonempty +and whose app-server URL equals the URL captured at launch. Missing, malformed, +or mismatched requests leave messages unread. + +Each scoped dispatcher selects only roles whose canonical project and recorded +thread match its request. The existing project-and-role child lock remains +global, so one role still has at most one bridge consumer across scopes. A +scoped child proves the project-and-thread match before acquiring that lock. If +the role seat moves, the old child uses the existing bridge lease and process +start-token checks to retire its exact bridge, then releases the lock for the +new matching scope. Bare bridge pidfiles are not signal authority. + +Scope-less launches retain project server reuse, the project dispatcher key, +and TUI `exec` behavior. + +## Alternatives considered + +- Treat every child of a shared server as owned by the closing TUI. Rejected: + the server can host work from another session. +- Use only Codex thread IDs. Rejected: they do not enumerate the OS process + tree or establish signal authority. +- Add a daemon, reference counter, or durable scope field to role records. + Rejected: keyed requests, dispatchers, and the existing global role lock are + sufficient. +- Make every launch scoped. Rejected: existing users may depend on project-wide + reuse and its lower startup cost. + +## Consequences + +- Positive: disposable-worktree callers get a bounded, opt-in app-server + lifecycle without signaling foreign sessions. +- Positive: concurrent scopes route distinct role threads to their exact + servers while preserving one consumer per project role. +- Positive: no dependency, daemon, schema change, or second bridge cleanup + mechanism is introduced. +- Negative: every scoped invocation pays fresh app-server startup cost. +- Negative: `SIGKILL` cannot run traps and may leave a server, request, or lease. + Readiness must fail closed and recovery remains an explicit incident action. +- Neutral: the supervisor's signal authority is its captured shell jobs; + bridge replacement separately uses the existing lease and start-token proof. + +## References + +- [Issue #149](https://github.com/fujibee/agmsg/issues/149) +- `scripts/drivers/types/codex/codex-monitor.sh` +- `scripts/drivers/types/codex/codex-bridge-launcher.sh` +- `docs/codex-monitor-beta.md` diff --git a/docs/codex-monitor-beta.md b/docs/codex-monitor-beta.md index 4fa3ba1ef..854e7c8c0 100644 --- a/docs/codex-monitor-beta.md +++ b/docs/codex-monitor-beta.md @@ -13,9 +13,10 @@ approximates the same experience by launching Codex through an app-server bridge > enabling monitor takes effect only after you **restart Codex and send your > first message** — the SessionStart hook fires on the first turn, not the > moment Codex opens, so the bridge is absent until you interact once; an -> already-running session stays unmonitored until you restart it (#151); the -> bridge is not torn down when you close the TUI (orphans linger until reboot -> or `mode off`/manual kill, see #149). +> already-running session stays unmonitored until you restart it (#151). A +> scope-less launch retains the shared project app-server lifetime; callers that +> need a bounded disposable-worktree lifecycle can opt into invocation scope +> as described below (#149). ## Quick Start @@ -110,6 +111,37 @@ codex logout The shim also passes through when the current project is not in Codex monitor mode. +## Invocation-scoped lifetime (opt-in) + +The normal monitor keeps its existing behavior: it reuses a live app-server for +the project and `exec`s the Codex TUI. A caller that owns a disposable worktree +can instead request a lifecycle boundary for one invocation: + +```bash +codex-monitor.sh --project "$PWD" --invocation-scope "$opaque_scope" --codex-command codex -- -C "$PWD" +``` + +`$opaque_scope` is a unique, non-secret token for that invocation. The monitor +validates it, combines it with the canonical project path, and exposes only the +derived record key to its children. Each scoped launch starts a fresh app-server, +so it pays app-server startup cost instead of reusing the project server. + +The scoped monitor supervises its captured TUI, app-server, and top-level bridge +launcher. When the TUI exits normally, it stops and waits for the captured +processes, removes the matching scoped records, and returns the TUI status. A +direct `TERM` takes the same path and returns status `143`. + +Concurrent scopes use separate request files and dispatchers. A role is routed +only when its recorded project and thread match that scope's request, while the +existing project-and-role lock still permits only one bridge consumer for the +role. Ending one scope therefore does not stop a peer scope's dispatcher or +delivery. + +`SIGKILL` cannot run the supervisor's cleanup traps. It may leave a server or +lease behind, so callers must treat readiness as failed and handle recovery as +an explicit incident action. Invocation scope does not change the legacy, +scope-less lifecycle. + ## Bridge Mechanics `codex-monitor.sh` starts (or reuses) an agmsg-managed Codex app-server socket diff --git a/scripts/drivers/types/codex/_app-server.sh b/scripts/drivers/types/codex/_app-server.sh index a8ed5658c..27cd8979c 100644 --- a/scripts/drivers/types/codex/_app-server.sh +++ b/scripts/drivers/types/codex/_app-server.sh @@ -24,20 +24,32 @@ # got nothing". The two are different answers and only the first may fall # through to a weaker source. +# Echo the app-server record key for , or fail for an invalid scoped key. +_agmsg_codex_app_server_record_key() { + local project="$1" + if [ -n "${AGMSG_CODEX_APP_SERVER_KEY:-}" ]; then + case "$AGMSG_CODEX_APP_SERVER_KEY" in ''|*[!0-9a-f]*) return 1 ;; esac + [ "${#AGMSG_CODEX_APP_SERVER_KEY}" -eq 40 ] || return 1 + printf '%s' "$AGMSG_CODEX_APP_SERVER_KEY" + else + printf '%s' "$project" | agmsg_sha1 + fi +} + # Echo the app-server URL for , or nothing. -# -# The environment variable wins when present: it is the value monitor exported -# for this very process, and preferring it keeps every context that already -# worked on exactly the path it used before. +# A scoped key selects its exact record; only unscoped callers may prefer the +# inherited generic URL. _agmsg_codex_app_server_url() { - local project="$1" port_file port + local project="$1" record_key port_file port [ -n "$project" ] || return 0 - if [ -n "${AGMSG_CODEX_BRIDGE_APP_SERVER:-}" ]; then + if [ -z "${AGMSG_CODEX_APP_SERVER_KEY:-}" ] \ + && [ -n "${AGMSG_CODEX_BRIDGE_APP_SERVER:-}" ]; then printf '%s' "$AGMSG_CODEX_BRIDGE_APP_SERVER" return 0 fi command -v agmsg_sha1 >/dev/null 2>&1 || return 0 - port_file="$SKILL_DIR/run/codex-app-server.$(printf '%s' "$project" | agmsg_sha1 2>/dev/null).port" + record_key="$(_agmsg_codex_app_server_record_key "$project")" || return 1 + port_file="$SKILL_DIR/run/codex-app-server.$record_key.port" port="$(cat "$port_file" 2>/dev/null || true)" # Digits, and a port a TCP stack could have handed out. Digits alone are not # enough on their own — a prefix of a real port (5 of 52962) is all digits and diff --git a/scripts/drivers/types/codex/_session-start.sh b/scripts/drivers/types/codex/_session-start.sh index 1aa55fb9b..d8529c806 100644 --- a/scripts/drivers/types/codex/_session-start.sh +++ b/scripts/drivers/types/codex/_session-start.sh @@ -14,6 +14,9 @@ # launcher start the bridge — a hook-launched bridge cannot connect to the unix # socket from inside the Codex sandbox (#41). +# shellcheck source=_app-server.sh +source "$SKILL_DIR/scripts/drivers/types/codex/_app-server.sh" + # Newest-N rollout files under $sessions_dir, sorted by mtime descending. # `ls -t "$dir"/*/*/*/rollout-*.jsonl` is unreliable on Windows/Git Bash -- # reported to intermittently return an empty/truncated list with no @@ -120,28 +123,40 @@ agmsg_session_start() { done <<< "$PAIRS" PAIRS="$safe_pairs" [ -n "$PAIRS" ] || exit 0 - app_server="${AGMSG_CODEX_BRIDGE_APP_SERVER:-}" - if [ -z "$app_server" ]; then - agent_pid=$(agmsg_agent_pid "$TYPE" 2>/dev/null || true) - if [ -n "$agent_pid" ]; then - agent_cmd=$(compat_get_cmdline "$agent_pid" 2>/dev/null || true) - app_server=$(printf '%s\n' "$agent_cmd" \ - | sed -n 's/.*\(unix:\/\/[^[:space:]]*\).*/\1/p' \ - | head -1) + if [ -n "${AGMSG_CODEX_APP_SERVER_KEY:-}" ]; then + if ! app_server="$(_agmsg_codex_app_server_url "$PROJECT")"; then + exit 0 fi - fi - if [ -z "$app_server" ]; then - project_hash=$(printf '%s' "$PROJECT" | agmsg_sha1) - socket_path="$RUN_DIR/codex-app-server.$project_hash.sock" - if [ -S "$socket_path" ] || [ "${AGMSG_TEST_ASSUME_CODEX_SOCKET:-}" = "$socket_path" ]; then - app_server="unix://$socket_path" + else + app_server="${AGMSG_CODEX_BRIDGE_APP_SERVER:-}" + if [ -z "$app_server" ]; then + agent_pid=$(agmsg_agent_pid "$TYPE" 2>/dev/null || true) + if [ -n "$agent_pid" ]; then + agent_cmd=$(compat_get_cmdline "$agent_pid" 2>/dev/null || true) + app_server=$(printf '%s\n' "$agent_cmd" \ + | sed -n 's/.*\(unix:\/\/[^[:space:]]*\).*/\1/p' \ + | head -1) + fi + fi + if [ -z "$app_server" ]; then + app_server="$(_agmsg_codex_app_server_url "$PROJECT")" + fi + if [ -z "$app_server" ]; then + project_hash=$(printf '%s' "$PROJECT" | agmsg_sha1) + socket_path="$RUN_DIR/codex-app-server.$project_hash.sock" + if [ -S "$socket_path" ] || [ "${AGMSG_TEST_ASSUME_CODEX_SOCKET:-}" = "$socket_path" ]; then + app_server="unix://$socket_path" + fi fi fi [ -n "$app_server" ] || exit 0 if [ "${AGMSG_CODEX_BRIDGE_LAUNCHER:-}" = "1" ]; then - project_hash=$(printf '%s' "$PROJECT" | agmsg_sha1) - request_file="$RUN_DIR/codex-bridge-request.$project_hash" + request_key="" + if ! request_key="$(_agmsg_codex_app_server_record_key "$PROJECT")"; then + exit 0 + fi + request_file="$RUN_DIR/codex-bridge-request.$request_key" tmp_request="$request_file.$$" mkdir -p "$RUN_DIR" 2>/dev/null || true printf '%s\t%s\t%s\n' "$TYPE" "$thread_id" "$app_server" > "$tmp_request" diff --git a/scripts/drivers/types/codex/codex-bridge-launcher.sh b/scripts/drivers/types/codex/codex-bridge-launcher.sh index 98fb410ef..3c61de2d0 100755 --- a/scripts/drivers/types/codex/codex-bridge-launcher.sh +++ b/scripts/drivers/types/codex/codex-bridge-launcher.sh @@ -38,6 +38,8 @@ source "$SCRIPT_DIR/../../../lib/close-fds.sh" agmsg_close_inherited_fds # shellcheck source=../../../lib/hash.sh source "$SCRIPT_DIR/../../../lib/hash.sh" +# shellcheck source=_app-server.sh +source "$SCRIPT_DIR/_app-server.sh" # The liveness helpers. Every lifetime and lock-owner check below goes through # one of them, chosen by where the pid was minted: _agmsg_pid_alive_local for # the ones this shell or codex-monitor.sh produced, _agmsg_pid_alive for the @@ -47,9 +49,15 @@ source "$SCRIPT_DIR/../../../lib/hash.sh" # shellcheck source=../../../lib/instance-id.sh source "$SCRIPT_DIR/../../../lib/instance-id.sh" PROJECT_HASH="$(printf '%s' "$PROJECT" | agmsg_sha1)" -REQUEST_FILE="$RUN_DIR/codex-bridge-request.$PROJECT_HASH" +SCOPED_LAUNCH=0 +[ -z "${AGMSG_CODEX_APP_SERVER_KEY:-}" ] || SCOPED_LAUNCH=1 +if ! SERVER_RECORD_KEY="$(_agmsg_codex_app_server_record_key "$PROJECT")"; then + exit 0 +fi +REQUEST_FILE="$RUN_DIR/codex-bridge-request.$SERVER_RECORD_KEY" DISPATCHER_LOCK_RESOURCE="codex-dispatcher:$PROJECT_HASH" -SERVER_PID_FILE="$RUN_DIR/codex-app-server.$PROJECT_HASH.pid" +[ "$SCOPED_LAUNCH" -eq 0 ] || DISPATCHER_LOCK_RESOURCE="codex-dispatcher:$SERVER_RECORD_KEY" +SERVER_PID_FILE="$RUN_DIR/codex-app-server.$SERVER_RECORD_KEY.pid" # shellcheck source=../../../lib/node.sh source "$SCRIPT_DIR/../../../lib/node.sh" @@ -71,12 +79,12 @@ PROJECT_PHYS="$(agmsg_canonical_path "$PROJECT" 2>/dev/null || printf '%s' "$PRO mkdir -p "$RUN_DIR" -# The app-server is shared by every Codex TUI in a project. Bind dispatcher and -# role-child lifetime to that shared process rather than whichever TUI happened -# to start first. Tests/older launchers without the sidecar retain parent-PID -# fallback behavior. +# Scoped launchers bind only to their exact server. Legacy launchers retain the +# parent-PID fallback used before scoped servers existed. LIFETIME_PID="$(cat "$SERVER_PID_FILE" 2>/dev/null || true)" -if [ -z "$LIFETIME_PID" ] || ! _agmsg_pid_alive_local "$LIFETIME_PID"; then +if [ "$SCOPED_LAUNCH" -eq 1 ]; then + [ -n "$LIFETIME_PID" ] && _agmsg_pid_alive_local "$LIFETIME_PID" || exit 0 +elif [ -z "$LIFETIME_PID" ] || ! _agmsg_pid_alive_local "$LIFETIME_PID"; then LIFETIME_PID="$PARENT_PID" fi @@ -140,11 +148,48 @@ acquire_runtime_lock() { return 1 } +load_scoped_request() { + local line rest + SCOPED_REQUEST_TYPE="" + SCOPED_REQUEST_THREAD="" + SCOPED_REQUEST_APP_SERVER="" + [ "$SCOPED_LAUNCH" -eq 1 ] || return 1 + [ -f "$REQUEST_FILE" ] || return 1 + line="$(cat "$REQUEST_FILE" 2>/dev/null)" || return 1 + case "$line" in *$'\n'*) return 1 ;; esac + case "$line" in *"$TAB"*) ;; *) return 1 ;; esac + SCOPED_REQUEST_TYPE="${line%%"$TAB"*}" + rest="${line#*"$TAB"}" + case "$rest" in *"$TAB"*) ;; *) return 1 ;; esac + SCOPED_REQUEST_THREAD="${rest%%"$TAB"*}" + SCOPED_REQUEST_APP_SERVER="${rest#*"$TAB"}" + case "$SCOPED_REQUEST_APP_SERVER" in *"$TAB"*) return 1 ;; esac + [ "$SCOPED_REQUEST_TYPE" = "$TYPE" ] || return 1 + [ -n "$SCOPED_REQUEST_THREAD" ] || return 1 + [ "$SCOPED_REQUEST_APP_SERVER" = "$APP_SERVER" ] || return 1 +} + resolve_identity() { # prints "teamname" lines for the project's codex roles - "$SCRIPT_DIR/../../../identities.sh" "$PROJECT" "$TYPE" 2>/dev/null \ + local identity team name candidate_thread candidate_project candidate_project_phys + identity="$("$SCRIPT_DIR/../../../identities.sh" "$PROJECT" "$TYPE" 2>/dev/null \ | awk -v t="$TAB" 'NF >= 2 { print $1 t $2 }' \ | { if [ -n "$ROLE_PAIR" ]; then grep -Fx "$ROLE_PAIR" || true; else cat; fi; } \ - | sort -u + | sort -u)" + if [ "$SCOPED_LAUNCH" -eq 0 ]; then + printf '%s\n' "$identity" + return 0 + fi + load_scoped_request || return 0 + while IFS="$TAB" read -r team name; do + [ -n "$team" ] || continue + agmsg_role_session_load "$team" "$name" 2>/dev/null || true + candidate_thread="$AGMSG_ROLE_SESSION_UUID" + candidate_project="$AGMSG_ROLE_SESSION_PROJECT" + candidate_project_phys="$(agmsg_canonical_path "$candidate_project" 2>/dev/null || printf '%s' "$candidate_project")" + [ "$candidate_project_phys" = "$PROJECT_PHYS" ] || continue + [ "$candidate_thread" = "$SCOPED_REQUEST_THREAD" ] || continue + printf '%s\t%s\n' "$team" "$name" + done <<< "$identity" } # identities.sh opens and parses EVERY teams/*/config.json on every call: two @@ -184,6 +229,11 @@ identity_cache_is_fresh() { } refresh_identity_cache() { + if [ "$SCOPED_LAUNCH" -eq 1 ]; then + IDENTITY_CACHE_FRESH=0 + IDENTITY_CACHE="$(resolve_identity || true)" + return 0 + fi if identity_cache_is_fresh; then IDENTITY_CACHE_FRESH=1 return 0 @@ -224,6 +274,23 @@ poll_sleep() { return 0 } +acquire_runtime_lock_while_alive() { + local resource="$1" lifetime_pid="$2" + while _agmsg_pid_alive_local "$lifetime_pid"; do + if acquire_runtime_lock "$resource"; then + if _agmsg_pid_alive_local "$lifetime_pid"; then + poll_reset + return 0 + fi + agmsg_runtime_lock_release "$resource" "$$" || true + HELD_LOCK_RESOURCE="" + return 1 + fi + poll_sleep + done + return 1 +} + # Any change here can change the safe subscription set. Include the request # thread plus each role's recorded session/project, not merely registrations: # actas/resume rewrites a role record without changing identities.sh output. @@ -293,7 +360,9 @@ fi # this lock every dispatcher generation left another full set of children behind. # The lock makes those re-spawns exit on arrival instead of accumulating. CHILD_LOCK_RESOURCE="codex-child:$PROJECT_HASH:$(printf '%s' "$ROLE_PAIR" | agmsg_sha1)" -acquire_runtime_lock "$CHILD_LOCK_RESOURCE" || exit 0 +if [ "$SCOPED_LAUNCH" -eq 0 ]; then + acquire_runtime_lock "$CHILD_LOCK_RESOURCE" || exit 0 +fi # Bounded, not open-ended. The dispatcher only spawns a child for a pair it has # already seen registered, so an empty list here is either the brief actas write @@ -358,6 +427,22 @@ if [ -z "$ids" ]; then exec "$0" "$TYPE" "$PROJECT" "$APP_SERVER" "$PARENT_PID" "$ROLE_PAIR" fi +if [ "$SCOPED_LAUNCH" -eq 1 ]; then + acquire_runtime_lock_while_alive "$CHILD_LOCK_RESOURCE" "$PARENT_PID" || exit 0 + ids="" + post_lock_empty_ticks=0 + while _agmsg_pid_alive_local "$PARENT_PID"; do + ids="$(resolve_identity || true)" + [ -n "$ids" ] && break + post_lock_empty_ticks=$((post_lock_empty_ticks + 1)) + [ "$post_lock_empty_ticks" -ge 2 ] && break + sleep 0.3 + done + [ -n "$ids" ] || exit 0 + build_safety_state "$ids" + safety_state="$SAFETY_STATE" +fi + identity_count="$(printf '%s\n' "$ids" | grep -c . || true)" if [ "$identity_count" = "1" ]; then IFS="$TAB" read -r key_team key_name </dev/null || true @@ -638,12 +730,17 @@ while _agmsg_pid_alive_local "$PARENT_PID"; do # so the new role is actually subscribed instead of being stranded. build_safety_state "$current_ids" if [ "$SAFETY_STATE" != "$safety_state" ]; then - if [ -f "$pidfile" ]; then - old_pid="" - IFS= read -r old_pid < "$pidfile" 2>/dev/null || true - _agmsg_pid_valid "$old_pid" && kill "$old_pid" 2>/dev/null || true + if [ "$SCOPED_LAUNCH" -eq 1 ]; then + safety_state="$SAFETY_STATE" + poll_reset + else + if [ -f "$pidfile" ]; then + old_pid="" + IFS= read -r old_pid < "$pidfile" 2>/dev/null || true + _agmsg_pid_valid "$old_pid" && kill "$old_pid" 2>/dev/null || true + fi + exec "$0" "$TYPE" "$PROJECT" "$APP_SERVER" "$PARENT_PID" "$ROLE_PAIR" fi - exec "$0" "$TYPE" "$PROJECT" "$APP_SERVER" "$PARENT_PID" "$ROLE_PAIR" fi # Resolve the app-server URL (and thread) this iteration would launch against # FIRST, so the reuse check can compare a live bridge's bound server with the @@ -651,7 +748,14 @@ while _agmsg_pid_alive_local "$PARENT_PID"; do # discover the live TUI thread via thread/loaded/list. thread_id="loaded" req_app_server="$APP_SERVER" - if [ -f "$REQUEST_FILE" ]; then + if [ "$SCOPED_LAUNCH" -eq 1 ]; then + if ! load_scoped_request; then + poll_sleep + continue + fi + thread_id="$SCOPED_REQUEST_THREAD" + req_app_server="$SCOPED_REQUEST_APP_SERVER" + elif [ -f "$REQUEST_FILE" ]; then _rtype=""; _rthread=""; _rapp="" IFS="$TAB" read -r _rtype _rthread _rapp < "$REQUEST_FILE" 2>/dev/null || true [ -n "${_rthread:-}" ] && thread_id="$_rthread" @@ -675,6 +779,10 @@ EOF poll_sleep continue fi + if [ "$SCOPED_LAUNCH" -eq 1 ] && [ "$rec_thread" != "$SCOPED_REQUEST_THREAD" ]; then + poll_sleep + continue + fi thread_id="$rec_thread" # The role-session record is the sole thread authority (#150 phase 2/#350). diff --git a/scripts/drivers/types/codex/codex-monitor.sh b/scripts/drivers/types/codex/codex-monitor.sh index 8f580c917..2e531d277 100755 --- a/scripts/drivers/types/codex/codex-monitor.sh +++ b/scripts/drivers/types/codex/codex-monitor.sh @@ -29,20 +29,26 @@ source "$SCRIPT_DIR/../../../lib/instance-id.sh" # good one. Only the writer can make that state unobservable. # shellcheck source=../../../lib/registry-lock.sh source "$SCRIPT_DIR/../../../lib/registry-lock.sh" +# shellcheck source=../../../lib/storage.sh +source "$SCRIPT_DIR/../../../lib/storage.sh" PROJECT="$(pwd)" SOCKET_PATH="" CODEX_COMMAND="resume" CODEX_ARGS=() REAL_CODEX="${AGMSG_REAL_CODEX:-codex}" +INVOCATION_SCOPE="" +SCOPE_SEEN=0 usage() { cat <] [--codex-command ] [-- ] +Usage: codex-monitor.sh [--project ] [--invocation-scope TOKEN] [--codex-command ] [-- ] -Starts/reuses an agmsg-managed Codex app-server on a loopback ws:// port, -enables agmsg Codex bridge delivery for this project, then execs: - codex resume --remote ws://127.0.0.1: +Without --invocation-scope, starts/reuses the project's agmsg-managed Codex +app-server, enables bridge delivery, then execs the selected Codex command. +With --invocation-scope TOKEN, starts a dedicated app-server and supervises the +selected Codex command instead of execing it. +Scoped mode waits for its captured TUI, app-server, and bridge launcher processes. (--socket-path is accepted for compatibility but ignored: codex 0.141+ requires a ws:// transport for --remote. See #170.) @@ -67,6 +73,19 @@ while [ "$#" -gt 0 ]; do CODEX_COMMAND="${2:?--codex-command requires codex or resume}" shift 2 ;; + --invocation-scope) + [ "$SCOPE_SEEN" -eq 0 ] || { echo "codex-monitor: --invocation-scope may be given once" >&2; exit 2; } + [ "$#" -ge 2 ] || { echo "codex-monitor: --invocation-scope requires a value" >&2; exit 2; } + INVOCATION_SCOPE="$2" + case "$INVOCATION_SCOPE" in + [A-Za-z0-9]*) ;; + *) exit 2 ;; + esac + case "$INVOCATION_SCOPE" in *[!A-Za-z0-9._-]*) exit 2 ;; esac + [ "${#INVOCATION_SCOPE}" -le 128 ] || exit 2 + SCOPE_SEEN=1 + shift 2 + ;; --) shift CODEX_ARGS=("$@") @@ -87,7 +106,13 @@ case "$CODEX_COMMAND" in ;; esac -PROJECT="$(cd "$PROJECT" && pwd)" +PROJECT="$(cd "$PROJECT" && pwd -P)" + +# A no-scope monitor can be launched from a scoped app-server tool shell. Do not +# let that outer server key/URL turn this launcher's children into scoped peers. +if [ -z "$INVOCATION_SCOPE" ]; then + unset AGMSG_CODEX_APP_SERVER_KEY AGMSG_CODEX_BRIDGE_APP_SERVER +fi # Fail-open: never let a broken bridge block codex. If the agmsg app-server can't # be brought up — e.g. a codex release changes the app-server interface and the @@ -103,6 +128,16 @@ PROJECT="$(cd "$PROJECT" && pwd)" exec_plain_codex() { 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 + if [ -n "$INVOCATION_SCOPE" ]; then + case "$CODEX_COMMAND" in + codex) "$REAL_CODEX" ${CODEX_ARGS[@]+"${CODEX_ARGS[@]}"} <&0 & ;; + resume) "$REAL_CODEX" resume ${CODEX_ARGS[@]+"${CODEX_ARGS[@]}"} <&0 & ;; + esac + tui_bg=$! + if wait "$tui_bg"; then codex_status=0; else codex_status=$?; fi + cleanup_scoped_invocation + exit "$codex_status" + fi case "$CODEX_COMMAND" in codex) exec "$REAL_CODEX" ${CODEX_ARGS[@]+"${CODEX_ARGS[@]}"} ;; resume) exec "$REAL_CODEX" resume ${CODEX_ARGS[@]+"${CODEX_ARGS[@]}"} ;; @@ -110,15 +145,88 @@ exec_plain_codex() { } PROJECT_HASH="$(printf '%s' "$PROJECT" | agmsg_sha1)" -SERVER_LOG="$RUN_DIR/codex-app-server.$PROJECT_HASH.log" -SERVER_PID="$RUN_DIR/codex-app-server.$PROJECT_HASH.pid" -PORT_FILE="$RUN_DIR/codex-app-server.$PROJECT_HASH.port" +APP_SERVER_KEY="$PROJECT_HASH" +if [ -n "$INVOCATION_SCOPE" ]; then + APP_SERVER_KEY="$(printf '%s\n%s' "$PROJECT" "$INVOCATION_SCOPE" | agmsg_sha1)" + export AGMSG_CODEX_APP_SERVER_KEY="$APP_SERVER_KEY" +fi +SERVER_LOG="$RUN_DIR/codex-app-server.$APP_SERVER_KEY.log" +SERVER_PID="$RUN_DIR/codex-app-server.$APP_SERVER_KEY.pid" +PORT_FILE="$RUN_DIR/codex-app-server.$APP_SERVER_KEY.port" # Records the codex version that launched the reusable app-server. A TUI from a # newer/older codex can't speak to an app-server from a different build, so a # stale server left running across a codex upgrade must not be reused. -VERSION_FILE="$RUN_DIR/codex-app-server.$PROJECT_HASH.version" +VERSION_FILE="$RUN_DIR/codex-app-server.$APP_SERVER_KEY.version" +SCOPED_REQUEST_FILE="$RUN_DIR/codex-bridge-request.$APP_SERVER_KEY" +server_bg="" +launcher_bg="" +tui_bg="" +SCOPED_LEASE_RESOURCE="" +scoped_cleanup_done=0 +scoped_lease_held=0 + +scoped_job_is_running() { + local child_pid="$1" job_pid + [ -n "$child_pid" ] || return 1 + for job_pid in $(jobs -pr); do + [ "$job_pid" = "$child_pid" ] && return 0 + done + return 1 +} + +cleanup_scoped_invocation() { + [ -n "${INVOCATION_SCOPE:-}" ] || return 0 + [ "$scoped_cleanup_done" -eq 0 ] || return 0 + + local child_pid recorded_server_pid + for child_pid in "$server_bg" "$launcher_bg"; do + scoped_job_is_running "$child_pid" && kill "$child_pid" 2>/dev/null || true + done + for child_pid in "$server_bg" "$launcher_bg"; do + [ -n "$child_pid" ] || continue + wait "$child_pid" 2>/dev/null || true + done + + recorded_server_pid="$(cat "$SERVER_PID" 2>/dev/null || true)" + if [ -n "$server_bg" ] && [ "$recorded_server_pid" = "$server_bg" ]; then + rm -f "$SERVER_LOG" "$SERVER_PID" "$PORT_FILE" "$VERSION_FILE" 2>/dev/null || true + fi + rm -f "$SCOPED_REQUEST_FILE" 2>/dev/null || true + + if [ "$scoped_lease_held" -eq 1 ]; then + scoped_lease_held=0 + agmsg_runtime_lock_release "$SCOPED_LEASE_RESOURCE" "$$" || true + fi + scoped_cleanup_done=1 +} + +terminate_scoped_invocation() { + local signal_status="$1" + if scoped_job_is_running "$tui_bg"; then + kill "$tui_bg" 2>/dev/null || true + fi + [ -z "$tui_bg" ] || wait "$tui_bg" 2>/dev/null || true + cleanup_scoped_invocation + exit "$signal_status" +} + CODEX_VERSION="$("$REAL_CODEX" --version 2>/dev/null || true)" +if [ -n "$INVOCATION_SCOPE" ]; then + SCOPED_LEASE_RESOURCE="codex-app-server:$APP_SERVER_KEY" + lock_owner="$(agmsg_runtime_lock_acquire "$SCOPED_LEASE_RESOURCE" "$$" 2>/dev/null || true)" + if [ "$lock_owner" != "$$" ] && [ -n "$lock_owner" ] && ! _agmsg_pid_alive_local "$lock_owner"; then + lock_owner="$(agmsg_runtime_lock_acquire "$SCOPED_LEASE_RESOURCE" "$$" "$lock_owner" 2>/dev/null || true)" + fi + if [ "$lock_owner" != "$$" ]; then + echo "codex-monitor: invocation scope is already active" >&2 + exit 1 + fi + scoped_lease_held=1 + trap cleanup_scoped_invocation EXIT + trap 'terminate_scoped_invocation 143' TERM +fi + mkdir -p "$RUN_DIR" # codex 0.141+ accepts only ws:// (not unix://) for the TUI's --remote, so the @@ -129,7 +237,11 @@ port_alive() { # $1 = port; succeeds if something is accepting on 127.0.0.1:$1 } PORT="" -if [ -f "$PORT_FILE" ] && [ -f "$SERVER_PID" ]; then +if [ -n "$INVOCATION_SCOPE" ]; then + # Scoped records identify a previous invocation, never a process this launch + # may reuse or signal. This launch owns only its captured children. + rm -f "$PORT_FILE" "$SERVER_PID" "$VERSION_FILE" "$SCOPED_REQUEST_FILE" +elif [ -f "$PORT_FILE" ] && [ -f "$SERVER_PID" ]; then existing_port="$(cat "$PORT_FILE" 2>/dev/null || true)" existing_pid="$(cat "$SERVER_PID" 2>/dev/null || true)" existing_version="$(cat "$VERSION_FILE" 2>/dev/null || true)" @@ -211,8 +323,10 @@ if [ -z "$PORT" ]; then if [ -z "$PORT" ]; then echo "codex-monitor: app-server did not report a listening port; starting codex without the agmsg bridge" >&2 echo "codex-monitor: see $SERVER_LOG" >&2 - kill "$server_bg" 2>/dev/null || true - rm -f "$SERVER_PID" "$VERSION_FILE" + if [ -z "$INVOCATION_SCOPE" ]; then + kill "$server_bg" 2>/dev/null || true + rm -f "$SERVER_PID" "$VERSION_FILE" + fi exec_plain_codex fi agmsg_write_atomic "$PORT_FILE" "$PORT" @@ -235,13 +349,28 @@ export AGMSG_CODEX_BRIDGE_APP_SERVER="$SOCKET_URL" export AGMSG_CODEX_BRIDGE_LAUNCHER=1 launcher_cmd="${AGMSG_CODEX_BRIDGE_LAUNCHER_CMD:-$SCRIPT_DIR/codex-bridge-launcher.sh}" -# Same guard: the launcher is detached on purpose and outlives this script, so -# an inherited fd 3 would outlive the test file that started it. +# Same guard: the launcher outlives an unscoped monitor, so an inherited fd 3 +# would outlive the test file that started it. "$launcher_cmd" codex "$PROJECT" "$SOCKET_URL" "$$" >/dev/null 2>&1 3>&- 4>&- & +launcher_bg="$!" cd "$PROJECT" # Guard the array expansion: under bash 3.2 + `set -u`, "${CODEX_ARGS[@]}" on an # empty array errors with "unbound variable" (a no-arg `codex`/`codex resume`). +if [ -n "$INVOCATION_SCOPE" ]; then + case "$CODEX_COMMAND" in + codex) + "$REAL_CODEX" --remote "$SOCKET_URL" ${CODEX_ARGS[@]+"${CODEX_ARGS[@]}"} <&0 3>&- 4>&- & + ;; + resume) + "$REAL_CODEX" resume --remote "$SOCKET_URL" ${CODEX_ARGS[@]+"${CODEX_ARGS[@]}"} <&0 3>&- 4>&- & + ;; + esac + tui_bg=$! + if wait "$tui_bg"; then codex_status=0; else codex_status=$?; fi + cleanup_scoped_invocation + exit "$codex_status" +fi case "$CODEX_COMMAND" in codex) exec "$REAL_CODEX" --remote "$SOCKET_URL" ${CODEX_ARGS[@]+"${CODEX_ARGS[@]}"} diff --git a/tests/test_codex_bridge_launcher.bats b/tests/test_codex_bridge_launcher.bats index 8f6b72ec4..52ffbfef3 100644 --- a/tests/test_codex_bridge_launcher.bats +++ b/tests/test_codex_bridge_launcher.bats @@ -108,6 +108,10 @@ _launcher_bridge_pids() { [ -f "$f" ] || continue cat "$f" 2>/dev/null done + for f in "$RUN_DIR"/codex-bridge-lease.*; do + [ -f "$f" ] || continue + printf '%s\n' "${f##*.}" + done } # A test's own kill/wait sequence reaches the dispatcher and the short-lived @@ -148,6 +152,11 @@ write_request() { printf 'codex\t%s\tws://127.0.0.1:1\n' "$thread" > "$RUN_DIR/codex-bridge-request.$hash" } +write_scoped_request() { + local key="$1" thread="$2" url="$3" + printf 'codex\t%s\t%s\n' "$thread" "$url" > "$RUN_DIR/codex-bridge-request.$key" +} + # Drive the launcher against a short-lived parent, blocking until it exits. fd 3 # is closed on the backgrounded parent and the launcher so a stray descriptor # can't keep bats from exiting on macOS (#bats-fd3). @@ -307,6 +316,224 @@ run_launcher() { ! grep -q -- $'--pair team\talice --thread thread-bob' "$CAPTURE" } +@test "launcher: two scoped servers route distinct roles to exact app servers" { + bash "$SCRIPTS/join.sh" team bob codex "$PROJ" >/dev/null + put_record team alice thread-A "$PROJ" codex + put_record team bob thread-B "$PROJ" codex + export MOCK_BRIDGE_SLEEP=25 + local key_a=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + local key_b=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + local url_a=ws://127.0.0.1:1111 url_b=ws://127.0.0.1:2222 + local server_a server_b launcher_a launcher_b + sleep 30 3>&- & server_a=$! + sleep 30 3>&- & server_b=$! + printf '%s' "$server_a" > "$RUN_DIR/codex-app-server.$key_a.pid" + printf '%s' "$server_b" > "$RUN_DIR/codex-app-server.$key_b.pid" + write_scoped_request "$key_a" thread-A "$url_a" + write_scoped_request "$key_b" thread-B "$url_b" + + AGMSG_CODEX_APP_SERVER_KEY="$key_a" \ + bash "$LAUNCHER" codex "$PROJ" "$url_a" "$server_a" >/dev/null 2>&1 3>&- & + launcher_a=$! + AGMSG_CODEX_APP_SERVER_KEY="$key_b" \ + bash "$LAUNCHER" codex "$PROJ" "$url_b" "$server_b" >/dev/null 2>&1 3>&- & + launcher_b=$! + + wait_for_file_contains "$CAPTURE" 'thread-A' + wait_for_file_contains "$CAPTURE" 'thread-B' + grep -Fq -- $'--pair team\talice --thread thread-A --app-server ws://127.0.0.1:1111' "$CAPTURE" + grep -Fq -- $'--pair team\tbob --thread thread-B --app-server ws://127.0.0.1:2222' "$CAPTURE" + refute grep -Fq -- $'--pair team\talice --thread thread-A --app-server ws://127.0.0.1:2222' "$CAPTURE" + refute grep -Fq -- $'--pair team\tbob --thread thread-B --app-server ws://127.0.0.1:1111' "$CAPTURE" + kill -0 "$launcher_a" + kill -0 "$launcher_b" + + kill "$server_a" "$server_b" 2>/dev/null || true + wait "$server_a" 2>/dev/null || true + wait "$server_b" 2>/dev/null || true + wait "$launcher_a" 2>/dev/null || true + wait "$launcher_b" 2>/dev/null || true +} + +@test "launcher: scope exit leaves peer delivery alive" { + bash "$SCRIPTS/join.sh" team bob codex "$PROJ" >/dev/null + put_record team alice thread-A "$PROJ" codex + put_record team bob thread-B "$PROJ" codex + export MOCK_BRIDGE_SLEEP=25 + local key_a=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + local key_b=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + local url_a=ws://127.0.0.1:1111 url_b=ws://127.0.0.1:2222 + local server_a server_b launcher_a launcher_b bob_bridge + sleep 30 3>&- & server_a=$! + sleep 30 3>&- & server_b=$! + printf '%s' "$server_a" > "$RUN_DIR/codex-app-server.$key_a.pid" + printf '%s' "$server_b" > "$RUN_DIR/codex-app-server.$key_b.pid" + write_scoped_request "$key_a" thread-A "$url_a" + write_scoped_request "$key_b" thread-B "$url_b" + + AGMSG_CODEX_APP_SERVER_KEY="$key_a" \ + bash "$LAUNCHER" codex "$PROJ" "$url_a" "$server_a" >/dev/null 2>&1 3>&- & + launcher_a=$! + AGMSG_CODEX_APP_SERVER_KEY="$key_b" \ + bash "$LAUNCHER" codex "$PROJ" "$url_b" "$server_b" >/dev/null 2>&1 3>&- & + launcher_b=$! + wait_for_file_contains "$CAPTURE" 'thread-A' + wait_for_file_contains "$CAPTURE" 'thread-B' + grep -Fq -- $'--pair team\talice --thread thread-A --app-server ws://127.0.0.1:1111' "$CAPTURE" + grep -Fq -- $'--pair team\tbob --thread thread-B --app-server ws://127.0.0.1:2222' "$CAPTURE" + bob_bridge="$(cat "$RUN_DIR/codex-bridge.team.bob.pid")" + + kill "$server_a" + wait "$server_a" 2>/dev/null || true + wait_for_pid_exit "$launcher_a" + wait "$launcher_a" 2>/dev/null || true + kill -0 "$launcher_b" + kill -0 "$server_b" + kill -0 "$bob_bridge" + + kill "$server_b" 2>/dev/null || true + wait "$server_b" 2>/dev/null || true + wait "$launcher_b" 2>/dev/null || true +} + +@test "launcher: scoped role seat transfers between exact servers" { + put_record team alice thread-A "$PROJ" codex + export MOCK_BRIDGE_SLEEP=25 + local key_a=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + local key_b=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + local url_a=ws://127.0.0.1:1111 url_b=ws://127.0.0.1:2222 + local server_a server_b launcher_a launcher_b old_bridge new_bridge + local project_hash pair_hash lock_resource lock_owner i + sleep 30 3>&- & server_a=$! + sleep 30 3>&- & server_b=$! + printf '%s' "$server_a" > "$RUN_DIR/codex-app-server.$key_a.pid" + printf '%s' "$server_b" > "$RUN_DIR/codex-app-server.$key_b.pid" + write_scoped_request "$key_a" thread-A "$url_a" + write_scoped_request "$key_b" thread-B "$url_b" + + AGMSG_CODEX_APP_SERVER_KEY="$key_a" \ + bash "$LAUNCHER" codex "$PROJ" "$url_a" "$server_a" >/dev/null 2>&1 3>&- & + launcher_a=$! + AGMSG_CODEX_APP_SERVER_KEY="$key_b" \ + bash "$LAUNCHER" codex "$PROJ" "$url_b" "$server_b" >/dev/null 2>&1 3>&- & + launcher_b=$! + wait_for_file_contains "$CAPTURE" 'thread-A' + grep -Fq -- $'--pair team\talice --thread thread-A --app-server ws://127.0.0.1:1111' "$CAPTURE" + old_bridge="$(cat "$RUN_DIR/codex-bridge.team.alice.pid")" + kill -0 "$old_bridge" + + put_record team alice thread-B "$PROJ" codex + for i in {1..150}; do + grep -Fq -- $'--pair team\talice --thread thread-B --app-server ws://127.0.0.1:2222' "$CAPTURE" 2>/dev/null && break + sleep 0.1 + done + grep -Fq -- $'--pair team\talice --thread thread-B --app-server ws://127.0.0.1:2222' "$CAPTURE" + wait_for_pid_exit "$old_bridge" + new_bridge="$(cat "$RUN_DIR/codex-bridge.team.alice.pid")" + [ "$new_bridge" != "$old_bridge" ] + kill -0 "$new_bridge" + [ "$(cat "$RUN_DIR/codex-bridge.team.alice.thread")" = thread-B ] + [ "$(cat "$RUN_DIR/codex-bridge.team.alice.appserver")" = "$url_b" ] + [ "$(_wait_exact_role_count "$PROJ" alice 1)" -eq 1 ] + + source "$SCRIPTS/lib/hash.sh" + project_hash="$(printf '%s' "$PROJ" | agmsg_sha1)" + pair_hash="$(printf '%s' $'team\talice' | agmsg_sha1)" + lock_resource="codex-child:$project_hash:$pair_hash" + lock_owner="$(source "$SCRIPTS/lib/storage.sh"; agmsg_runtime_lock_owner "$lock_resource")" + [ -n "$lock_owner" ] + kill -0 "$lock_owner" + + kill "$server_a" "$server_b" 2>/dev/null || true + wait "$server_a" 2>/dev/null || true + wait "$server_b" 2>/dev/null || true + wait "$launcher_a" 2>/dev/null || true + wait "$launcher_b" 2>/dev/null || true +} + +@test "launcher: scoped child survives one empty identity read after role lock" { + put_record team alice thread-A "$PROJ" codex + export MOCK_BRIDGE_SLEEP=3 + local key=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + local url=ws://127.0.0.1:1111 + local counter="$TEST_SKILL_DIR/identity-count" + local real_identities="$SCRIPTS/identities.real.sh" + local server launcher_pid i capture_count + + mv "$SCRIPTS/identities.sh" "$real_identities" + cat > "$SCRIPTS/identities.sh" <<'EOF' +#!/usr/bin/env bash +count=0 +[ ! -f "$AGMSG_TEST_IDENTITY_COUNTER" ] || count="$(cat "$AGMSG_TEST_IDENTITY_COUNTER")" +count=$((count + 1)) +printf '%s' "$count" > "$AGMSG_TEST_IDENTITY_COUNTER" +[ "$count" -eq 2 ] && exit 0 +exec "$AGMSG_TEST_IDENTITIES_REAL" "$@" +EOF + chmod +x "$SCRIPTS/identities.sh" + export AGMSG_TEST_IDENTITY_COUNTER="$counter" + export AGMSG_TEST_IDENTITIES_REAL="$real_identities" + + sleep 4 3>&- & server=$! + printf '%s' "$server" > "$RUN_DIR/codex-app-server.$key.pid" + write_scoped_request "$key" thread-A "$url" + + AGMSG_CODEX_APP_SERVER_KEY="$key" \ + bash "$LAUNCHER" codex "$PROJ" "$url" "$server" $'team\talice' >/dev/null 2>&1 3>&- & + launcher_pid=$! + + for i in {1..30}; do + capture_count="$(grep -Fc -- $'--pair team\talice --thread thread-A --app-server ws://127.0.0.1:1111' "$CAPTURE" 2>/dev/null || true)" + [ "${capture_count:-0}" -eq 1 ] && break + sleep 0.1 + done + + [ "$(cat "$counter")" -ge 3 ] + [ "${capture_count:-0}" -eq 1 ] + + kill "$server" 2>/dev/null || true + wait "$server" 2>/dev/null || true + wait "$launcher_pid" 2>/dev/null || true +} + +@test "launcher: malformed scoped requests stay unread and start no bridge" { + put_record team alice thread-A "$PROJ" codex + local key=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + local url=ws://127.0.0.1:1111 + local request="$RUN_DIR/codex-bridge-request.$key" + local before payload server + local payloads=( + __missing__ + $'codex\tthread-A' + $'codex\tthread-A\tws://127.0.0.1:1111\textra' + $'codex\t\tws://127.0.0.1:1111' + $'claude-code\tthread-A\tws://127.0.0.1:1111' + $'codex\tthread-A\tws://127.0.0.1:2222' + ) + + for payload in "${payloads[@]}"; do + rm -f "$CAPTURE" "$request" + before="" + if [ "$payload" != __missing__ ]; then + printf '%s' "$payload" > "$request" + before="$(cat "$request")" + fi + + sleep 0.7 3>&- & server=$! + printf '%s' "$server" > "$RUN_DIR/codex-app-server.$key.pid" + run env AGMSG_CODEX_APP_SERVER_KEY="$key" \ + bash "$LAUNCHER" codex "$PROJ" "$url" "$server" $'team\talice' + [ "$status" -eq 0 ] + [ ! -e "$CAPTURE" ] + if [ "$payload" = __missing__ ]; then + [ ! -e "$request" ] + else + [ "$(cat "$request")" = "$before" ] + fi + wait "$server" 2>/dev/null || true + done +} + @test "launcher: role record update keeps child scoped to the same pair" { put_record team alice thread-before "$PROJ" codex sleep 6 3>&- & local p=$! diff --git a/tests/test_codex_monitor.bats b/tests/test_codex_monitor.bats index ebf4be667..1ac5b2469 100644 --- a/tests/test_codex_monitor.bats +++ b/tests/test_codex_monitor.bats @@ -4,8 +4,11 @@ load test_helper setup() { setup_test_env - export TEST_PROJECT="$(mktemp -d)" + export TEST_PROJECT="$(cd "$(mktemp -d)" && pwd -P)" export CALL_LOG="$TEST_PROJECT/calls.log" + export TEST_MONITOR_PID="" + export TEST_TUI_RELEASE_FILE="" + export TEST_TUI_EXIT_FILE="" # Fake codex for codex-monitor tests. # --version -> prints "codex-cli $FAKE_CODEX_VERSION" @@ -28,10 +31,26 @@ case "${1:-}" in echo "error: unexpected argument '--listen' found" >&2 exit 2 fi + [ -z "${AGMSG_TEST_APP_SERVER_KEY_LOG:-}" ] || printf '%s' "${AGMSG_CODEX_APP_SERVER_KEY:-}" > "$AGMSG_TEST_APP_SERVER_KEY_LOG" + [ -z "${AGMSG_TEST_APP_SERVER_INHERITED_URL_LOG:-}" ] || printf '%s' "${AGMSG_CODEX_BRIDGE_APP_SERVER:-}" > "$AGMSG_TEST_APP_SERVER_INHERITED_URL_LOG" + if [ -n "${AGMSG_TEST_APP_SERVER_URL_LOG:-}" ]; then + ( + SKILL_DIR="$TEST_SKILL_DIR" + source "$SKILL_DIR/scripts/lib/hash.sh" + source "$SKILL_DIR/scripts/drivers/types/codex/_app-server.sh" + nested_url="" + for _probe in $(seq 1 100); do + nested_url="$(_agmsg_codex_app_server_url "$AGMSG_TEST_APP_SERVER_PROJECT")" + [ -n "$nested_url" ] && break + sleep 0.05 + done + printf '%s' "$nested_url" > "$AGMSG_TEST_APP_SERVER_URL_LOG" + ) & + fi # Run the listener as a CHILD (no exec) so this script stays the recorded pid; # its argv ("...real-codex app-server --listen") is what codex-monitor's - # cmdline check matches. The child exits when this parent is killed. - python3 - <<'PY' + # cmdline check matches. + python3 - <<'PY' & import socket, sys, os s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) @@ -47,11 +66,32 @@ while True: except Exception: pass PY + child=$! + record_server_term() { + [ -z "${FAKE_TERM_LOG:-}" ] || printf 'server TERM\n' >> "$FAKE_TERM_LOG" + kill "$child" 2>/dev/null || true + wait "$child" 2>/dev/null || true + exit 0 + } + trap record_server_term TERM + [ -z "${FAKE_SERVER_READY_FILE:-}" ] || printf '%s' "$$" > "$FAKE_SERVER_READY_FILE" + wait "$child" ;; *) + record_tui_term() { + [ -z "${FAKE_TUI_TERM_LOG:-}" ] || printf 'tui TERM\n' > "$FAKE_TUI_TERM_LOG" + [ -z "${FAKE_TUI_EXIT_MARKER:-}" ] || : > "$FAKE_TUI_EXIT_MARKER" + exit 143 + } + trap record_tui_term TERM + [ -z "${FAKE_TUI_PID_FILE:-}" ] || printf '%s' "$$" > "$FAKE_TUI_PID_FILE" printf 'plain-codex' >> "$CALL_LOG" for a in "$@"; do printf ' <%s>' "$a" >> "$CALL_LOG"; done printf '\n' >> "$CALL_LOG" + [ -z "${FAKE_TUI_READY_FILE:-}" ] || printf '%s' "$$" > "$FAKE_TUI_READY_FILE" + while [ -n "${FAKE_TUI_GATE:-}" ] && [ ! -e "$FAKE_TUI_GATE" ]; do sleep 0.1; done + [ -z "${FAKE_TUI_EXIT_MARKER:-}" ] || : > "$FAKE_TUI_EXIT_MARKER" + exit "${FAKE_TUI_STATUS:-0}" ;; esac EOF @@ -65,6 +105,12 @@ teardown() { # process inside it is alive, so the rm below fails with "Directory not # empty" and the test reports a failure whose assertions all passed. local pf pid + if [ -n "${TEST_MONITOR_PID:-}" ]; then + kill "$TEST_MONITOR_PID" 2>/dev/null || true + wait "$TEST_MONITOR_PID" 2>/dev/null || true + fi + [ -z "${TEST_TUI_RELEASE_FILE:-}" ] || : > "$TEST_TUI_RELEASE_FILE" + [ -z "${TEST_TUI_EXIT_FILE:-}" ] || wait_for_file "$TEST_TUI_EXIT_FILE" || true for pf in "$TEST_SKILL_DIR"/run/codex-app-server.*.pid; do [ -f "$pf" ] || continue pid="$(cat "$pf" 2>/dev/null)" @@ -76,6 +122,21 @@ teardown() { teardown_test_env } +_make_term_recording_launcher() { + local launcher="$1" + cat > "$launcher" <<'EOF' +#!/usr/bin/env bash +record_launcher_term() { + printf 'launcher TERM\n' > "$FAKE_LAUNCHER_TERM_LOG" + exit 0 +} +trap record_launcher_term TERM +printf '%s' "$$" > "$FAKE_LAUNCHER_READY_FILE" +while :; do sleep 0.1; done +EOF + chmod +x "$launcher" +} + # --- fail-open (A) --- @test "codex-monitor: fails open to plain codex when the app-server won't start (#170)" { @@ -136,6 +197,346 @@ teardown() { [ "$(cat "$pidf")" = "$first_pid" ] } +@test "codex-monitor: invocation scope is consumed before Codex argv" { + local key_log="$TEST_PROJECT/app-server-key" + run env AGMSG_TEST_APP_SERVER_KEY_LOG="$key_log" AGMSG_REAL_CODEX="$FAKE_CODEX" \ + bash "$TYPES/codex/codex-monitor.sh" \ + --project "$TEST_PROJECT" --invocation-scope scope-A --codex-command codex -- --foo + [ "$status" -eq 0 ] + grep -Eq 'plain-codex <--remote> <--foo>' "$CALL_LOG" + refute grep -q -- '--invocation-scope' "$CALL_LOG" + local expected_key + expected_key="$(printf '%s\n%s' "$(cd "$TEST_PROJECT" && pwd -P)" scope-A | ( . "$SCRIPTS/lib/hash.sh"; agmsg_sha1 ))" + [ "$(cat "$key_log")" = "$expected_key" ] +} + +@test "codex-monitor: malformed and duplicate invocation scopes fail before launch" { + run env AGMSG_REAL_CODEX="$FAKE_CODEX" bash "$TYPES/codex/codex-monitor.sh" \ + --project "$TEST_PROJECT" --invocation-scope + [ "$status" -eq 2 ] + run env AGMSG_REAL_CODEX="$FAKE_CODEX" bash "$TYPES/codex/codex-monitor.sh" \ + --project "$TEST_PROJECT" --invocation-scope bad/scope --codex-command codex -- + [ "$status" -eq 2 ] + run env AGMSG_REAL_CODEX="$FAKE_CODEX" bash "$TYPES/codex/codex-monitor.sh" \ + --project "$TEST_PROJECT" --invocation-scope one --invocation-scope two \ + --codex-command codex -- + [ "$status" -eq 2 ] + [ ! -e "$CALL_LOG" ] +} + +@test "codex-monitor: help distinguishes legacy reuse from scoped supervision" { + run bash "$TYPES/codex/codex-monitor.sh" --help + [ "$status" -eq 0 ] + grep -Fq -- "--invocation-scope TOKEN" <<< "$output" + grep -Fq -- "Without --invocation-scope, starts/reuses" <<< "$output" + grep -Fq -- "then execs" <<< "$output" + grep -Fq -- "Scoped mode waits for its captured TUI, app-server, and bridge launcher processes." <<< "$output" +} + +@test "codex-monitor: no-scope server ignores inherited scoped routing" { + skip_on_windows "spawns a python socket listener; flaky on the Windows runner" + + local hash base key_log nested_url_log nested_url own_url + hash="$(printf '%s' "$(cd "$TEST_PROJECT" && pwd -P)" | ( . "$SCRIPTS/lib/hash.sh"; agmsg_sha1 ))" + base="$TEST_SKILL_DIR/run/codex-app-server.$hash" + key_log="$TEST_PROJECT/app-server-key" + nested_url_log="$TEST_PROJECT/nested-app-server-url" + mkdir -p "$TEST_SKILL_DIR/run" + printf '1111' > "$TEST_SKILL_DIR/run/codex-app-server.outer-scope.port" + + run env AGMSG_CODEX_APP_SERVER_KEY=outer-scope \ + AGMSG_CODEX_BRIDGE_APP_SERVER=ws://127.0.0.1:3333 \ + AGMSG_TEST_APP_SERVER_KEY_LOG="$key_log" \ + AGMSG_TEST_APP_SERVER_URL_LOG="$nested_url_log" \ + AGMSG_TEST_APP_SERVER_PROJECT="$TEST_PROJECT" \ + AGMSG_REAL_CODEX="$FAKE_CODEX" \ + bash "$TYPES/codex/codex-monitor.sh" --project "$TEST_PROJECT" --codex-command codex -- + [ "$status" -eq 0 ] + wait_for_file "$nested_url_log" + [ -f "$base.pid" ] + [ ! -e "$TEST_SKILL_DIR/run/codex-app-server.outer-scope.pid" ] + nested_url="$(cat "$nested_url_log")" + own_url="ws://127.0.0.1:$(cat "$base.port")" + [ "$nested_url" = "$own_url" ] + [ ! -s "$key_log" ] + grep -Fq "<--remote> <$own_url>" "$CALL_LOG" +} + +@test "codex-monitor: no-scope duplicate dispatcher does not inherit scoped standby" { + local launcher="$TEST_PROJECT/env-recording-launcher" + local launcher_log="$TEST_PROJECT/launcher-env" + cat > "$launcher" <<'EOF' +#!/usr/bin/env bash +printf '%s\n%s\n' "${AGMSG_CODEX_APP_SERVER_KEY:-}" "${AGMSG_CODEX_BRIDGE_APP_SERVER:-}" > "$FAKE_LAUNCHER_ENV_LOG" +EOF + chmod +x "$launcher" + + run env AGMSG_CODEX_APP_SERVER_KEY=outer-scope \ + AGMSG_CODEX_BRIDGE_APP_SERVER=ws://127.0.0.1:3333 \ + FAKE_LAUNCHER_ENV_LOG="$launcher_log" AGMSG_CODEX_BRIDGE_LAUNCHER_CMD="$launcher" \ + AGMSG_REAL_CODEX="$FAKE_CODEX" \ + bash "$TYPES/codex/codex-monitor.sh" --project "$TEST_PROJECT" --codex-command codex -- + [ "$status" -eq 0 ] + wait_for_file "$launcher_log" + [ ! -s "$launcher_log" ] || [ -z "$(sed -n '1p' "$launcher_log")" ] + refute grep -Fxq 'ws://127.0.0.1:3333' "$launcher_log" +} + +@test "codex-monitor: scoped TUI exit preserves status and removes exact artifacts" { + run env FAKE_TUI_STATUS=37 AGMSG_REAL_CODEX="$FAKE_CODEX" \ + bash "$TYPES/codex/codex-monitor.sh" --project "$TEST_PROJECT" \ + --invocation-scope scope-exit --codex-command resume -- --last -C "$TEST_PROJECT" + [ "$status" -eq 37 ] + grep -Eq '^plain-codex <--remote> <--last> <-C>' "$CALL_LOG" + [ "$(find "$TEST_SKILL_DIR/run" -name 'codex-app-server.*' -type f | wc -l | tr -d ' ')" -eq 0 ] +} + +@test "codex-monitor: scoped TUI exit sends TERM to its captured server and launcher children" { + local server_term="$TEST_PROJECT/server-term.log" + local launcher_term="$TEST_PROJECT/launcher-term.log" + local launcher="$TEST_PROJECT/fake-launcher" + local gate="$TEST_PROJECT/release-tui" + local tui_exit="$TEST_PROJECT/tui-exit" + local server_ready="$TEST_PROJECT/server-ready" + local launcher_ready="$TEST_PROJECT/launcher-ready" + local tui_ready="$TEST_PROJECT/tui-ready" + _make_term_recording_launcher "$launcher" + TEST_TUI_RELEASE_FILE="$gate" + TEST_TUI_EXIT_FILE="$tui_exit" + + env FAKE_TERM_LOG="$server_term" FAKE_SERVER_READY_FILE="$server_ready" \ + FAKE_LAUNCHER_TERM_LOG="$launcher_term" FAKE_LAUNCHER_READY_FILE="$launcher_ready" \ + FAKE_TUI_GATE="$gate" FAKE_TUI_READY_FILE="$tui_ready" FAKE_TUI_EXIT_MARKER="$tui_exit" \ + AGMSG_CODEX_BRIDGE_LAUNCHER_CMD="$launcher" AGMSG_REAL_CODEX="$FAKE_CODEX" \ + bash "$TYPES/codex/codex-monitor.sh" --project "$TEST_PROJECT" \ + --invocation-scope scope-term --codex-command codex -- & + TEST_MONITOR_PID=$! + wait_for_file_contains "$server_ready" '[0-9]' + wait_for_file_contains "$launcher_ready" '[0-9]' + wait_for_file_contains "$tui_ready" '[0-9]' + : > "$gate" + wait "$TEST_MONITOR_PID" + TEST_MONITOR_PID="" + + grep -qx 'server TERM' "$server_term" + grep -qx 'launcher TERM' "$launcher_term" +} + +@test "codex-monitor: scoped launch clears stale request before launcher" { + local scope=scope-stale-request key request launcher launcher_log + key="$(printf '%s\n%s' "$(cd "$TEST_PROJECT" && pwd -P)" "$scope" | ( . "$SCRIPTS/lib/hash.sh"; agmsg_sha1 ))" + request="$TEST_SKILL_DIR/run/codex-bridge-request.$key" + launcher="$TEST_PROJECT/request-probing-launcher" + launcher_log="$TEST_PROJECT/request-observation" + mkdir -p "$TEST_SKILL_DIR/run" + printf 'codex\tstale-thread\tws://127.0.0.1:9999\n' > "$request" + cat > "$launcher" <<'EOF' +#!/usr/bin/env bash +request="$TEST_SKILL_DIR/run/codex-bridge-request.$AGMSG_CODEX_APP_SERVER_KEY" +if [ -e "$request" ]; then printf 'present'; else printf 'absent'; fi > "$FAKE_REQUEST_OBSERVATION" +EOF + chmod +x "$launcher" + + run env FAKE_REQUEST_OBSERVATION="$launcher_log" \ + AGMSG_CODEX_BRIDGE_LAUNCHER_CMD="$launcher" AGMSG_REAL_CODEX="$FAKE_CODEX" \ + bash "$TYPES/codex/codex-monitor.sh" --project "$TEST_PROJECT" \ + --invocation-scope "$scope" --codex-command codex -- + [ "$status" -eq 0 ] + wait_for_file "$launcher_log" + [ "$(cat "$launcher_log")" = "absent" ] + [ ! -e "$request" ] +} + +@test "codex-monitor: scoped direct TERM reaps TUI and returns signal status" { + skip_on_windows "uses POSIX direct-child signal semantics" + + local gate="$TEST_PROJECT/hold-tui" + local server_ready="$TEST_PROJECT/server-ready" + local launcher_ready="$TEST_PROJECT/launcher-ready" + local tui_ready="$TEST_PROJECT/tui-ready" + local server_term="$TEST_PROJECT/server-term.log" + local launcher_term="$TEST_PROJECT/launcher-term.log" + local tui_term="$TEST_PROJECT/tui-term.log" + local tui_exit="$TEST_PROJECT/tui-exit" + local tui_pid_file="$TEST_PROJECT/tui.pid" + local launcher="$TEST_PROJECT/fake-launcher" + local key pidfile server_pid launcher_pid tui_pid monitor_status lock_owner + _make_term_recording_launcher "$launcher" + TEST_TUI_RELEASE_FILE="$gate" + TEST_TUI_EXIT_FILE="$tui_exit" + + key="$(printf '%s\n%s' "$(cd "$TEST_PROJECT" && pwd -P)" scope-direct-term | ( . "$SCRIPTS/lib/hash.sh"; agmsg_sha1 ))" + pidfile="$TEST_SKILL_DIR/run/codex-app-server.$key.pid" + env FAKE_TERM_LOG="$server_term" FAKE_SERVER_READY_FILE="$server_ready" \ + FAKE_LAUNCHER_TERM_LOG="$launcher_term" FAKE_LAUNCHER_READY_FILE="$launcher_ready" \ + FAKE_TUI_GATE="$gate" FAKE_TUI_READY_FILE="$tui_ready" FAKE_TUI_EXIT_MARKER="$tui_exit" \ + FAKE_TUI_PID_FILE="$tui_pid_file" FAKE_TUI_TERM_LOG="$tui_term" \ + AGMSG_CODEX_BRIDGE_LAUNCHER_CMD="$launcher" AGMSG_REAL_CODEX="$FAKE_CODEX" \ + bash "$TYPES/codex/codex-monitor.sh" --project "$TEST_PROJECT" \ + --invocation-scope scope-direct-term --codex-command codex -- & + TEST_MONITOR_PID=$! + + wait_for_file_contains "$server_ready" '[0-9]' + wait_for_file_contains "$launcher_ready" '[0-9]' + wait_for_file_contains "$tui_ready" '[0-9]' + server_pid="$(cat "$pidfile")" + launcher_pid="$(cat "$launcher_ready")" + tui_pid="$(cat "$tui_pid_file")" + + kill -TERM "$TEST_MONITOR_PID" + if wait "$TEST_MONITOR_PID"; then monitor_status=0; else monitor_status=$?; fi + TEST_MONITOR_PID="" + lock_owner="$(. "$SCRIPTS/lib/storage.sh"; agmsg_runtime_lock_owner "codex-app-server:$key")" + + [ "$monitor_status" -eq 143 ] + wait_for_pid_exit "$tui_pid" + wait_for_pid_exit "$server_pid" + wait_for_pid_exit "$launcher_pid" + grep -qx 'tui TERM' "$tui_term" + grep -qx 'server TERM' "$server_term" + grep -qx 'launcher TERM' "$launcher_term" + [ "$(find "$TEST_SKILL_DIR/run" -name 'codex-app-server.*' -type f | wc -l | tr -d ' ')" -eq 0 ] + [ -z "$lock_owner" ] +} + +@test "codex-monitor: scoped fail-open exit preserves status and argv" { + run env FAKE_CODEX_MODE=broken FAKE_TUI_STATUS=37 AGMSG_REAL_CODEX="$FAKE_CODEX" \ + bash "$TYPES/codex/codex-monitor.sh" --project "$TEST_PROJECT" \ + --invocation-scope scope-fail-open --codex-command resume -- --last -C "$TEST_PROJECT" + [ "$status" -eq 37 ] + grep -Eq '^plain-codex <--last> <-C>' "$CALL_LOG" + refute grep -q -- '--remote' "$CALL_LOG" + printf '%s\n' "$output" | grep -Fq 'Real-time agmsg delivery is OFF' + [ "$(find "$TEST_SKILL_DIR/run" -name 'codex-app-server.*' -type f | wc -l | tr -d ' ')" -eq 0 ] +} + +@test "codex-monitor: scoped cleanup never signals a foreign pidfile target" { + skip_on_windows "spawns a python socket listener; flaky on the Windows runner" + + local gate="$TEST_PROJECT/release-tui" + local key pidfile server_pid sentinel_pid monitor_pid + key="$(printf '%s\n%s' "$(cd "$TEST_PROJECT" && pwd -P)" scope-foreign | ( . "$SCRIPTS/lib/hash.sh"; agmsg_sha1 ))" + pidfile="$TEST_SKILL_DIR/run/codex-app-server.$key.pid" + + env FAKE_TUI_GATE="$gate" AGMSG_REAL_CODEX="$FAKE_CODEX" \ + bash "$TYPES/codex/codex-monitor.sh" --project "$TEST_PROJECT" \ + --invocation-scope scope-foreign --codex-command codex -- & + monitor_pid=$! + wait_for_file "$pidfile" + wait_for_file_contains "$CALL_LOG" plain-codex + server_pid="$(cat "$pidfile")" + + sleep 60 3>&- & + sentinel_pid=$! + printf '%s' "$sentinel_pid" > "$pidfile" + : > "$gate" + wait "$monitor_pid" + + wait_for_pid_exit "$server_pid" + kill -0 "$sentinel_pid" + [ "$(cat "$pidfile")" = "$sentinel_pid" ] + kill "$sentinel_pid" 2>/dev/null || true + wait "$sentinel_pid" 2>/dev/null || true +} + +@test "codex-monitor: same live project and scope duplicate fails without touching first" { + skip_on_windows "spawns a python socket listener; flaky on the Windows runner" + + local scope=scope-duplicate + local gate="$TEST_PROJECT/release-first-tui" + local server_ready="$TEST_PROJECT/server-ready" + local launcher_ready="$TEST_PROJECT/launcher-ready" + local tui_ready="$TEST_PROJECT/tui-ready" + local tui_exit="$TEST_PROJECT/tui-exit" + local tui_pid_file="$TEST_PROJECT/tui.pid" + local launcher="$TEST_PROJECT/fake-launcher" + local launcher_term="$TEST_PROJECT/launcher-term.log" + local key pidfile first_monitor first_server first_launcher first_tui calls_before + _make_term_recording_launcher "$launcher" + TEST_TUI_RELEASE_FILE="$gate" + TEST_TUI_EXIT_FILE="$tui_exit" + + key="$(printf '%s\n%s' "$(cd "$TEST_PROJECT" && pwd -P)" "$scope" | ( . "$SCRIPTS/lib/hash.sh"; agmsg_sha1 ))" + pidfile="$TEST_SKILL_DIR/run/codex-app-server.$key.pid" + env FAKE_SERVER_READY_FILE="$server_ready" \ + FAKE_LAUNCHER_READY_FILE="$launcher_ready" FAKE_LAUNCHER_TERM_LOG="$launcher_term" \ + FAKE_TUI_GATE="$gate" FAKE_TUI_READY_FILE="$tui_ready" \ + FAKE_TUI_EXIT_MARKER="$tui_exit" FAKE_TUI_PID_FILE="$tui_pid_file" \ + AGMSG_CODEX_BRIDGE_LAUNCHER_CMD="$launcher" AGMSG_REAL_CODEX="$FAKE_CODEX" \ + bash "$TYPES/codex/codex-monitor.sh" --project "$TEST_PROJECT" \ + --invocation-scope "$scope" --codex-command codex -- & + TEST_MONITOR_PID=$! + first_monitor="$TEST_MONITOR_PID" + + wait_for_file_contains "$server_ready" '[0-9]' + wait_for_file_contains "$launcher_ready" '[0-9]' + wait_for_file_contains "$tui_ready" '[0-9]' + first_server="$(cat "$pidfile")" + first_launcher="$(cat "$launcher_ready")" + first_tui="$(cat "$tui_pid_file")" + calls_before="$(wc -l < "$CALL_LOG" | tr -d ' ')" + + run env AGMSG_CODEX_BRIDGE_LAUNCHER_CMD="$launcher" AGMSG_REAL_CODEX="$FAKE_CODEX" \ + bash "$TYPES/codex/codex-monitor.sh" --project "$TEST_PROJECT" \ + --invocation-scope "$scope" --codex-command codex -- + [ "$status" -eq 1 ] + grep -Fq "invocation scope is already active" <<< "$output" + [ "$(wc -l < "$CALL_LOG" | tr -d ' ')" = "$calls_before" ] + [ "$(cat "$pidfile")" = "$first_server" ] + kill -0 "$first_monitor" + kill -0 "$first_server" + kill -0 "$first_launcher" + kill -0 "$first_tui" + + : > "$gate" + wait "$first_monitor" + TEST_MONITOR_PID="" + wait_for_pid_exit "$first_server" + wait_for_pid_exit "$first_launcher" + wait_for_pid_exit "$first_tui" + [ ! -e "$pidfile" ] +} + +@test "codex-monitor: different invocation scopes use different live app-servers" { + skip_on_windows "spawns python socket listeners; flaky on the Windows runner" + + local gate_a="$TEST_PROJECT/release-tui-a" gate_b="$TEST_PROJECT/release-tui-b" + local key_a key_b pidfile_a pidfile_b scope_a_monitor scope_b_monitor scope_a_server scope_b_server + key_a="$(printf '%s\n%s' "$(cd "$TEST_PROJECT" && pwd -P)" scope-A | ( . "$SCRIPTS/lib/hash.sh"; agmsg_sha1 ))" + key_b="$(printf '%s\n%s' "$(cd "$TEST_PROJECT" && pwd -P)" scope-B | ( . "$SCRIPTS/lib/hash.sh"; agmsg_sha1 ))" + pidfile_a="$TEST_SKILL_DIR/run/codex-app-server.$key_a.pid" + pidfile_b="$TEST_SKILL_DIR/run/codex-app-server.$key_b.pid" + + env FAKE_TUI_GATE="$gate_a" AGMSG_REAL_CODEX="$FAKE_CODEX" \ + bash "$TYPES/codex/codex-monitor.sh" --project "$TEST_PROJECT" \ + --invocation-scope scope-A --codex-command codex -- & + scope_a_monitor=$! + TEST_MONITOR_PID="$scope_a_monitor" + wait_for_file "$pidfile_a" + scope_a_server="$(cat "$pidfile_a")" + + env FAKE_TUI_GATE="$gate_b" AGMSG_REAL_CODEX="$FAKE_CODEX" \ + bash "$TYPES/codex/codex-monitor.sh" --project "$TEST_PROJECT" \ + --invocation-scope scope-B --codex-command codex -- & + scope_b_monitor=$! + wait_for_file "$pidfile_b" + scope_b_server="$(cat "$pidfile_b")" + [ "$scope_a_server" != "$scope_b_server" ] + kill -0 "$scope_a_server" + kill -0 "$scope_b_server" + + : > "$gate_a" + wait "$scope_a_monitor" + TEST_MONITOR_PID="" + wait_for_pid_exit "$scope_a_server" + kill -0 "$scope_b_monitor" + kill -0 "$scope_b_server" + + : > "$gate_b" + wait "$scope_b_monitor" + wait_for_pid_exit "$scope_b_server" +} + # --- port discovery vs colorized banner (codex 0.144+) --- @test "codex-monitor: discovers the port when codex colorizes the banner (0.144+)" { diff --git a/tests/test_codex_resume.bats b/tests/test_codex_resume.bats index 4bb27060d..345285fbc 100644 --- a/tests/test_codex_resume.bats +++ b/tests/test_codex_resume.bats @@ -377,6 +377,46 @@ record_with_loaded_via_port_file() { # [ "$(recorded_uuid team alice)" = "thr-unclaimed" ] } +@test "codex record: scoped server key selects only the exact scoped port file" { + local proj ids hash key + proj="$(mktemp -d)" + ids="$TEST_SKILL_DIR/loaded.txt" + key=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + printf 'thr-scoped\n' > "$ids" + source "$SKILL_DIR/scripts/lib/hash.sh" + hash="$(printf '%s' "$proj" | agmsg_sha1)" + printf '1111' > "$TEST_SKILL_DIR/run/codex-app-server.$hash.port" + printf '2222' > "$TEST_SKILL_DIR/run/codex-app-server.$key.port" + + ( unset CODEX_THREAD_ID + AGMSG_CODEX_APP_SERVER_KEY="$key" \ + AGMSG_CODEX_BRIDGE_APP_SERVER=ws://127.0.0.1:3333 \ + AGMSG_NODE="$(fake_node_printing "$ids")" \ + bash "$TYPES/codex/codex-record-session.sh" team alice "$proj" ) + + [ "$(recorded_uuid team alice)" = "thr-scoped" ] +} + +@test "codex record: malformed scoped server key cannot select a path or fall back" { + local proj ids hash + proj="$(mktemp -d)" + ids="$TEST_SKILL_DIR/loaded.txt" + printf 'thr-unsafe\n' > "$ids" + source "$SKILL_DIR/scripts/lib/hash.sh" + hash="$(printf '%s' "$proj" | agmsg_sha1)" + printf '1111' > "$TEST_SKILL_DIR/run/codex-app-server.$hash.port" + mkdir -p "$TEST_SKILL_DIR/run/codex-app-server.bad" + printf '2222' > "$TEST_SKILL_DIR/run/codex-app-server.bad/key.port" + + ( unset CODEX_THREAD_ID + AGMSG_CODEX_APP_SERVER_KEY=bad/key \ + AGMSG_CODEX_BRIDGE_APP_SERVER=ws://127.0.0.1:3333 \ + AGMSG_NODE="$(fake_node_printing "$ids")" \ + bash "$TYPES/codex/codex-record-session.sh" team alice "$proj" ) + + [ -z "$(recorded_uuid team alice)" ] +} + @test "codex record: no port file and no variable records nothing, it does not guess" { # Fail closed. Without a way to ask, the answer is "could not ask" -- never # "asked and found nothing" -- so no weaker signal may seat a thread here. diff --git a/tests/test_delivery.bats b/tests/test_delivery.bats index 6d4e4a7c2..7313bb9a1 100644 --- a/tests/test_delivery.bats +++ b/tests/test_delivery.bats @@ -2306,6 +2306,66 @@ EOF grep -q -- "--inline-inbox" "$log" } +@test "session-start.sh for codex writes exact scoped URL to the scoped request" { + bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null + _seed_role_record team alice thread-scoped "$TEST_PROJECT" codex + local key hash request_file request + key=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + source "$SCRIPTS/lib/hash.sh" + hash="$(printf '%s' "$TEST_PROJECT" | agmsg_sha1)" + request_file="$TEST_SKILL_DIR/run/codex-bridge-request.$key" + mkdir -p "$TEST_SKILL_DIR/run" + printf '1111' > "$TEST_SKILL_DIR/run/codex-app-server.$hash.port" + printf '2222' > "$TEST_SKILL_DIR/run/codex-app-server.$key.port" + + AGMSG_CODEX_BRIDGE=1 AGMSG_CODEX_BRIDGE_LAUNCHER=1 \ + AGMSG_CODEX_APP_SERVER_KEY="$key" \ + AGMSG_CODEX_BRIDGE_APP_SERVER=ws://127.0.0.1:3333 \ + CODEX_THREAD_ID=thread-scoped \ + bash "$SCRIPTS/session-start.sh" codex "$TEST_PROJECT" >/dev/null + + [ -f "$request_file" ] + request="$(cat "$request_file")" + [ "$request" = $'codex\tthread-scoped\tws://127.0.0.1:2222' ] + [ ! -e "$TEST_SKILL_DIR/run/codex-bridge-request.$hash" ] +} + +@test "session-start.sh for codex rejects malformed scoped key without a request" { + bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null + _seed_role_record team alice thread-scoped "$TEST_PROJECT" codex + local hash + source "$SCRIPTS/lib/hash.sh" + hash="$(printf '%s' "$TEST_PROJECT" | agmsg_sha1)" + mkdir -p "$TEST_SKILL_DIR/run/codex-app-server.bad" + printf '1111' > "$TEST_SKILL_DIR/run/codex-app-server.$hash.port" + printf '2222' > "$TEST_SKILL_DIR/run/codex-app-server.bad/key.port" + + run env AGMSG_CODEX_BRIDGE=1 AGMSG_CODEX_BRIDGE_LAUNCHER=1 \ + AGMSG_CODEX_APP_SERVER_KEY=bad/key \ + AGMSG_CODEX_BRIDGE_APP_SERVER=ws://127.0.0.1:3333 \ + CODEX_THREAD_ID=thread-scoped \ + bash "$SCRIPTS/session-start.sh" codex "$TEST_PROJECT" + [ "$status" -eq 0 ] + [ "$(find "$TEST_SKILL_DIR/run" -maxdepth 1 -name 'codex-bridge-request.*' -type f | wc -l | tr -d ' ')" -eq 0 ] +} + +@test "session-start.sh for codex keeps project-hash request without a scoped key" { + bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null + _seed_role_record team alice thread-legacy "$TEST_PROJECT" codex + local hash request_file + source "$SCRIPTS/lib/hash.sh" + hash="$(printf '%s' "$TEST_PROJECT" | agmsg_sha1)" + request_file="$TEST_SKILL_DIR/run/codex-bridge-request.$hash" + + env -u AGMSG_CODEX_APP_SERVER_KEY \ + AGMSG_CODEX_BRIDGE=1 AGMSG_CODEX_BRIDGE_LAUNCHER=1 \ + AGMSG_CODEX_BRIDGE_APP_SERVER=ws://127.0.0.1:3333 \ + CODEX_THREAD_ID=thread-legacy \ + bash "$SCRIPTS/session-start.sh" codex "$TEST_PROJECT" >/dev/null + + [ "$(cat "$request_file")" = $'codex\tthread-legacy\tws://127.0.0.1:3333' ] +} + @test "session-start.sh for codex stays quiet without monitor launcher env" { bash "$SCRIPTS/join.sh" team alice codex "$TEST_PROJECT" >/dev/null local fake="$TEST_SKILL_DIR/fake-codex-bridge"