diff --git a/SESSION_LOG_2026_07_18.md b/SESSION_LOG_2026_07_18.md new file mode 100644 index 0000000..0c82df1 --- /dev/null +++ b/SESSION_LOG_2026_07_18.md @@ -0,0 +1,80 @@ +# Session log — 2026-07-18 + +## Objective + +Merge and deploy the bounded multi-session lifecycle work, then complete the +caller-side Codex identity correction. + +## Merge and deployment + +- PR #367 merged to `main` as `67f9e1921b2b6d3f01fc3cd844d1edd8ad2a045e` + after all 12 protected checks passed. +- Canonical local gate `test-env/run.sh` passed immediately before merge. +- Installed the merged release binary at `~/.cargo/bin/wire` after candidate + and staged SHA-256 hashes matched. Preserved the previous binary at + `~/.cargo/bin/wire.pre-daemon-lifecycle-20260718` for rollback. +- Installed the missing launchd local-relay service. It initially remained in + `xpcproxy` under process pressure, then reached `exec` and accepted TCP after + the managed daemon restart reduced load. +- Restarted only `sh.slancha.wire.daemon` through launchd. No wildcard kill, + hand-started daemon, upgrade command, live-home rewrite, or destructive + cleanup occurred. + +## Measurements + +- Before rollout: 592 daemons, 4,692,192 KiB aggregate RSS, 41 MCP processes, + 690 session homes, local relay refused connections. +- After managed restart: 60 daemons, about 760,240 KiB aggregate RSS, 41 MCP + processes, local relay accepting connections. +- Remaining workers comprise legacy MCP-owned children and capped/reparented + supervisor workers. Existing MCP processes loaded the prior binary and age + out with their owning Codex sessions. + +## Follow-up root cause + +The fixed Codex config override cannot yet be removed safely. Live Codex +exposes `CODEX_THREAD_ID` but not `CODEX_SESSION_ID`; Wire reads only the latter. +Without a new adapter, Wire would fall through to a minted or machine-default +identity instead of a stable per-thread identity. + +GitNexus impact for `resolve_session_key` is LOW: four direct dependants, six +impacted symbols, and two affected runtime entry flows (`cli::run` and +`mcp::run`). Selected fix is documented in +`docs/superpowers/specs/2026-07-18-codex-thread-identity-design.md`. + +## Codex thread adapter implementation + +- Added `CODEX_THREAD_ID` immediately after the existing + `CODEX_SESSION_ID` compatibility adapter. Both resolve with source + `codex-cli`; explicit Wire and Claude Code overrides retain precedence. +- RED: the focused Codex adapter test failed with `CODEX_THREAD_ID` resolving + to `None` before the production change. +- GREEN: three session-key adapter tests passed. The isolated lifecycle, + 655-stale-home supervisor, relay-unavailable, and bilateral local-sister + suites passed; local-sister dial converged without manual pull. +- `cargo fmt --all -- --check` and + `cargo clippy --all-targets --all-features -- -D warnings` passed. +- Added `CODEX_THREAD_ID` cleanup anywhere an isolated test process already + removed the older Codex variable. Every temporary `WIRE_HOME` spawn in this + scope retains `WIRE_HOME_FORCE=1`. + +## Canonical gate + +- First `test-env/run.sh` attempt passed 653 library tests, then Cargo could + not execute its just-built zero-test `src/bin/wire.rs` harness because that + artifact was absent from the shared Docker target volume. Source inspection + found no test invoking binary purge, no concurrent Wire test container, and + a Dockerfile note documenting Cargo 1.88 target-path loss on named volumes. +- Rebuilding that single bin-test target in the same container volume passed. +- A second full `test-env/run.sh` run passed end-to-end, including formatting, + clippy, all serial Cargo targets, release build, demos, and shell integration + checks. + +## Deployment decision + +Push and merge through protected checks before changing caller configuration. +Install the merged binary atomically, then remove only the fixed +`WIRE_SESSION_ID` assignment from Codex's app-managed configuration. Preserve +a mode/timestamp-retaining backup; do not touch shell dotfiles. Existing Codex +processes keep inherited identity until they exit; fresh processes use their +thread ID. diff --git a/docs/superpowers/plans/2026-07-18-codex-thread-identity.md b/docs/superpowers/plans/2026-07-18-codex-thread-identity.md new file mode 100644 index 0000000..e861df4 --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-codex-thread-identity.md @@ -0,0 +1,197 @@ +# Codex Thread Identity Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Resolve fresh Codex processes from their stable `CODEX_THREAD_ID` so removing a shared `WIRE_SESSION_ID` restores independent Wire identities. + +**Architecture:** Extend the existing ordered environment adapter in `resolve_session_key`; do not add another identity path. Keep `WIRE_SESSION_ID`, Claude Code, and `CODEX_SESSION_ID` precedence intact, reuse the `codex-cli` source label, and make every relevant test process clear the newly recognized host variable. + +**Tech Stack:** Rust 2024, Cargo tests, shell CI mirror, launchd-managed local service, GitHub protected checks. + +## Global Constraints + +- Preserve live Wire homes and launchd ownership; do not hand-start daemons, run `wire upgrade`, kill process families, or rewrite live homes. +- Every spawned `wire` process using temporary `WIRE_HOME` must set `WIRE_HOME_FORCE=1`. +- Never print or persist the raw Codex thread ID outside Wire's existing hashed session-home mapping. +- Install the compatible binary before removing the fixed user-level `WIRE_SESSION_ID` override. +- Run GitNexus change detection before every commit and `test-env/run.sh` before merge. + +--- + +### Task 1: Codex thread adapter regression + +**Files:** +- Modify: `src/session.rs:838-867` +- Test: `src/session.rs:1847-2188` +- Modify: `test-env/runtime-210.sh:23-24` + +**Interfaces:** +- Consumes: `resolve_session_key() -> Option<(String, &'static str)>` and `valid_session_key(&str) -> bool`. +- Produces: `CODEX_THREAD_ID` resolution with source `codex-cli`, after `CODEX_SESSION_ID` and before Copilot. + +- [ ] **Step 1: Extend the existing Codex test with a failing thread-ID case** + +Snapshot and clear `CODEX_THREAD_ID` beside `CODEX_SESSION_ID`, then add these assertions before setting `CODEX_SESSION_ID`: + +```rust +unsafe { std::env::set_var("CODEX_THREAD_ID", "019f1111-1111-7111-8111-111111111111") }; +let thread_a = resolve_session_key(); +assert!( + matches!(&thread_a, Some((key, source)) + if key == "019f1111-1111-7111-8111-111111111111" && *source == "codex-cli"), + "CODEX_THREAD_ID must resolve as codex-cli; got {thread_a:?}" +); +let thread_home_a = session_home_for_key(&thread_a.as_ref().unwrap().0).unwrap(); +unsafe { std::env::set_var("CODEX_THREAD_ID", "019f2222-2222-7222-8222-222222222222") }; +let thread_home_b = session_home_for_key(&resolve_session_key().unwrap().0).unwrap(); +assert_ne!(thread_home_a, thread_home_b); +``` + +- [ ] **Step 2: Run the focused test to prove RED** + +Run: + +```bash +cargo test session::tests::resolve_session_key_codex_cli_adapter_and_priority -- --exact --nocapture +``` + +Expected: FAIL because `resolve_session_key` does not yet read `CODEX_THREAD_ID`. + +- [ ] **Step 3: Add the minimal adapter and precedence assertions** + +Insert the adapter immediately after `CODEX_SESSION_ID`: + +```rust +("CODEX_SESSION_ID", "codex-cli"), +("CODEX_THREAD_ID", "codex-cli"), +``` + +Update the function documentation to state Codex currently exposes `CODEX_THREAD_ID`. In the test, assert `CODEX_SESSION_ID` wins when both Codex variables exist, `WIRE_SESSION_ID` wins over both, and `${SOME_PLACEHOLDER}` in `CODEX_THREAD_ID` never resolves. + +- [ ] **Step 4: Make adapter tests and the runtime harness hermetic** + +In the VS Code, Copilot, and Codex adapter tests, snapshot, clear, and restore `CODEX_THREAD_ID` beside `CODEX_SESSION_ID`. Add `CODEX_THREAD_ID` to the `unset` list in `test-env/runtime-210.sh`. + +- [ ] **Step 5: Run focused verification** + +Run: + +```bash +cargo test resolve_session_key_ -- --nocapture +cargo fmt -- --check +cargo clippy --all-targets --all-features -- -D warnings +``` + +Expected: all adapter tests PASS; formatting and clippy exit 0. + +- [ ] **Step 6: Commit the adapter** + +Run: + +```bash +git add src/session.rs test-env/runtime-210.sh +node .gitnexus/run.cjs detect-changes --scope staged --repo wire +git diff --cached --check +git commit -m "fix: resolve Codex identities by thread" +``` + +Expected: change detection lists only session-key resolution/test scope; commit succeeds. + +### Task 2: Canonical verification and session record + +**Files:** +- Modify: `SESSION_LOG_2026_07_18.md` + +**Interfaces:** +- Consumes: Task 1's adapter and tests. +- Produces: canonical gate evidence and auditable deployment commands. + +- [ ] **Step 1: Run the canonical CI mirror** + +Run: + +```bash +test-env/run.sh +``` + +Expected: exit 0 with every gate section passing. + +- [ ] **Step 2: Record results without raw identifiers** + +Append the RED failure, focused PASS commands, canonical gate result, and planned config migration to `SESSION_LOG_2026_07_18.md`. Record only presence and precedence of environment variables, never their values. + +- [ ] **Step 3: Review the actual branch diff** + +Run: + +```bash +git diff --check origin/main...HEAD +node .gitnexus/run.cjs detect-changes --scope compare --base-ref main --repo wire +git diff --stat origin/main...HEAD +git diff origin/main...HEAD -- src/session.rs test-env/runtime-210.sh SESSION_LOG_2026_07_18.md +``` + +Expected: only design, plan, session adapter, runtime isolation, and session log changes. + +- [ ] **Step 4: Commit the verification record** + +Run: + +```bash +git add SESSION_LOG_2026_07_18.md +node .gitnexus/run.cjs detect-changes --scope staged --repo wire +git diff --cached --check +git commit -m "docs: record Codex identity verification" +``` + +Expected: documentation-only commit succeeds. + +### Task 3: Protected merge and managed deployment + +**Files:** +- Modify after compatible install: `~/.codex/config.toml` (remove only the fixed `WIRE_SESSION_ID` entry) + +**Interfaces:** +- Consumes: verified branch and existing launchd services. +- Produces: merged Wire binary plus future Codex processes keyed by their unique thread IDs. + +- [ ] **Step 1: Push and open the pull request** + +Run: + +```bash +git push -u origin fix/codex-thread-identity +gh pr create --base main --head fix/codex-thread-identity --fill +``` + +Expected: branch pushed and PR URL returned. + +- [ ] **Step 2: Wait for every protected check, then merge** + +Run: + +```bash +pr_number="$(gh pr view --json number --jq .number)" +gh pr checks --watch "$pr_number" +gh pr merge "$pr_number" --merge +``` + +Expected: all required checks pass and GitHub reports a merge commit on `main`. + +- [ ] **Step 3: Build and atomically install the merged binary** + +Build merged `main` in the isolated worktree, compare SHA-256 hashes for candidate and staged file, preserve the currently installed binary as `~/.cargo/bin/wire.pre-codex-thread-20260718`, then atomically move the staged candidate onto `~/.cargo/bin/wire`. + +- [ ] **Step 4: Remove only the fixed override** + +Back up `~/.codex/config.toml` with mode and timestamps preserved. Remove only +the `WIRE_SESSION_ID` assignment from `[shell_environment_policy.set]` without +printing its value. + +Do not rewrite the file or edit shell dotfiles. Existing Codex processes keep their inherited identity; fresh processes use `CODEX_THREAD_ID`. + +- [ ] **Step 5: Verify managed runtime state** + +Run read-only checks for `wire service status`, launchd service state, daemon/MCP process counts and aggregate RSS, TCP health on `127.0.0.1:8771`, binary version/hash consistency, and `wire doctor`. Use isolated tests for thread-key behavior; do not mint probe identities in the live Wire root. + +Expected: local relay healthy, daemon count stays bounded, no new version skew, doctor no longer reports the fixed-literal operator collision, and no destructive cleanup is needed. diff --git a/docs/superpowers/specs/2026-07-18-codex-thread-identity-design.md b/docs/superpowers/specs/2026-07-18-codex-thread-identity-design.md new file mode 100644 index 0000000..a51bdec --- /dev/null +++ b/docs/superpowers/specs/2026-07-18-codex-thread-identity-design.md @@ -0,0 +1,55 @@ +# Codex thread identity adapter design + +## Goal + +Give each Codex thread a stable, independent Wire identity without a shared +literal `WIRE_SESSION_ID` override. + +## Evidence + +Codex exposes a unique `CODEX_THREAD_ID` to tool subprocesses. It does not +expose the `CODEX_SESSION_ID` name Wire currently reads. The fixed user-level +`WIRE_SESSION_ID` therefore wins for every concurrent Codex process and +collapses them onto one Wire home. + +## Selected change + +Add `CODEX_THREAD_ID` to `resolve_session_key` immediately after the existing +`CODEX_SESSION_ID` adapter. Both names report the `codex-cli` source label. +Keep `WIRE_SESSION_ID` as the highest-priority operator override and preserve +all other host adapters and fallback behavior. + +This makes the live Codex caller work without launcher interpolation or +process/rollout-file discovery. After deploying the change, remove the fixed +user-level `WIRE_SESSION_ID`; existing sessions keep their current identity +until restarted, while fresh Codex threads resolve from their thread ID. + +## Rejected approaches + +- Map the thread ID in static Codex configuration. Static TOML cannot expand a + per-thread runtime value reliably for MCP children. +- Discover the thread through rollout filenames or process ancestry. That adds + filesystem timing and host-internal coupling when a stable environment value + already exists. + +## Safety and compatibility + +- Never print or persist the raw thread ID outside the existing hashed session + home mapping. +- Reject empty and unexpanded `${...}` values through `valid_session_key`. +- Existing explicit `WIRE_SESSION_ID`, Claude Code, Copilot, and VS Code + precedence remains unchanged. +- The live config override is removed only after the compatible binary is + installed. + +## Verification + +- A focused test must fail before implementation because two distinct + `CODEX_THREAD_ID` values currently resolve to no Codex adapter. +- Focused session tests must cover distinct IDs, `CODEX_SESSION_ID` precedence, + `WIRE_SESSION_ID` precedence, and placeholder rejection. +- Run `test-env/run.sh`, GitNexus change detection, protected GitHub checks, and + an isolated live probe with the fixed override absent from the probe only. +- After deployment and config correction, fresh Codex sessions must resolve to + distinct by-key homes and `wire doctor` must clear the literal-override + warning. diff --git a/src/cli/pairing.rs b/src/cli/pairing.rs index 4f2d96d..cf6190f 100644 --- a/src/cli/pairing.rs +++ b/src/cli/pairing.rs @@ -835,6 +835,7 @@ fn run_wire_for_session(home: &std::path::Path, args: &[&str], one_way: bool) -> .env_remove("WIRE_SESSION_ID") .env_remove("CLAUDE_CODE_SESSION_ID") .env_remove("CODEX_SESSION_ID") + .env_remove("CODEX_THREAD_ID") .env_remove("COPILOT_AGENT_SESSION_ID") .env_remove("VSCODE_GIT_REPOSITORY_ROOT"); if one_way { diff --git a/src/cli/session.rs b/src/cli/session.rs index f94a150..f73f353 100644 --- a/src/cli/session.rs +++ b/src/cli/session.rs @@ -1223,6 +1223,7 @@ fn drive_bilateral_pair( .env_remove("WIRE_SESSION_ID") .env_remove("CLAUDE_CODE_SESSION_ID") .env_remove("CODEX_SESSION_ID") + .env_remove("CODEX_THREAD_ID") .env_remove("COPILOT_AGENT_SESSION_ID") .env_remove("VSCODE_GIT_REPOSITORY_ROOT") .env_remove("RUST_LOG") diff --git a/src/session.rs b/src/session.rs index 5b2da19..4b5d56f 100644 --- a/src/session.rs +++ b/src/session.rs @@ -835,20 +835,16 @@ pub fn detect_session_wire_home(cwd: &std::path::Path) -> Option { /// 1. `WIRE_SESSION_ID` — explicit universal override (any harness). /// 2. `CLAUDE_CODE_SESSION_ID` — Claude Code adapter (stable per /// conversation; the same id the auto-memory system keys off). -/// 3. `CODEX_SESSION_ID` — OpenAI Codex CLI adapter. Stable per Codex -/// thread (the same UUIDv7 emitted in `thread.started` and used as -/// the rollout-file suffix under `$CODEX_HOME/sessions/`). Codex -/// does not yet forward this var to MCP children out of the box — -/// operators must set it via `[mcp_servers..env]` in -/// `~/.codex/config.toml` (or upstream Codex must add it to the -/// MCP child env). Wiring the name in advance means once Codex -/// ships the env, wire picks it up with zero further code change. -/// 4. `COPILOT_AGENT_SESSION_ID` — GitHub Copilot CLI (`gh copilot` / +/// 3. `CODEX_SESSION_ID` — Codex compatibility adapter for hosts that +/// forward this older name. +/// 4. `CODEX_THREAD_ID` — current OpenAI Codex runtime adapter. Stable +/// per thread and inherited by tool subprocesses. +/// 5. `COPILOT_AGENT_SESSION_ID` — GitHub Copilot CLI (`gh copilot` / /// `copilot`) adapter. Set by the Copilot CLI host for every /// session; stable per conversation; UUID-shaped. -/// 5. `VSCODE_GIT_REPOSITORY_ROOT` — VS Code/GitHub Copilot workspace-based +/// 6. `VSCODE_GIT_REPOSITORY_ROOT` — VS Code/GitHub Copilot workspace-based /// identity (stable per workspace). -/// 6. `None` — caller falls back to legacy cwd-detect (bare CLI / +/// 7. `None` — caller falls back to legacy cwd-detect (bare CLI / /// pre-v0.13 hosts). Future host adapters slot in before this. /// /// Returns `(key, source-label)`. @@ -857,6 +853,7 @@ pub fn resolve_session_key() -> Option<(String, &'static str)> { ("WIRE_SESSION_ID", "override"), ("CLAUDE_CODE_SESSION_ID", "claude-code"), ("CODEX_SESSION_ID", "codex-cli"), + ("CODEX_THREAD_ID", "codex-cli"), ("COPILOT_AGENT_SESSION_ID", "copilot-cli"), ("VSCODE_GIT_REPOSITORY_ROOT", "vscode-workspace"), ] { @@ -1870,6 +1867,7 @@ mod tests { let prev_override = std::env::var_os("WIRE_SESSION_ID"); let prev_claude = std::env::var_os("CLAUDE_CODE_SESSION_ID"); let prev_codex = std::env::var_os("CODEX_SESSION_ID"); + let prev_codex_thread = std::env::var_os("CODEX_THREAD_ID"); let prev_copilot = std::env::var_os("COPILOT_AGENT_SESSION_ID"); let prev_vscode = std::env::var_os("VSCODE_GIT_REPOSITORY_ROOT"); // SAFETY: ENV_LOCK is held, serializing all env access. @@ -1877,6 +1875,7 @@ mod tests { std::env::remove_var("WIRE_SESSION_ID"); std::env::remove_var("CLAUDE_CODE_SESSION_ID"); std::env::remove_var("CODEX_SESSION_ID"); + std::env::remove_var("CODEX_THREAD_ID"); std::env::remove_var("COPILOT_AGENT_SESSION_ID"); std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT"); } @@ -1933,6 +1932,7 @@ mod tests { std::env::remove_var("WIRE_SESSION_ID"); std::env::remove_var("CLAUDE_CODE_SESSION_ID"); std::env::remove_var("CODEX_SESSION_ID"); + std::env::remove_var("CODEX_THREAD_ID"); std::env::remove_var("COPILOT_AGENT_SESSION_ID"); std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT"); if let Some(v) = prev_override { @@ -1944,6 +1944,9 @@ mod tests { if let Some(v) = prev_codex { std::env::set_var("CODEX_SESSION_ID", v); } + if let Some(v) = prev_codex_thread { + std::env::set_var("CODEX_THREAD_ID", v); + } if let Some(v) = prev_copilot { std::env::set_var("COPILOT_AGENT_SESSION_ID", v); } @@ -1980,6 +1983,7 @@ mod tests { let prev_override = std::env::var_os("WIRE_SESSION_ID"); let prev_claude = std::env::var_os("CLAUDE_CODE_SESSION_ID"); let prev_codex = std::env::var_os("CODEX_SESSION_ID"); + let prev_codex_thread = std::env::var_os("CODEX_THREAD_ID"); let prev_copilot = std::env::var_os("COPILOT_AGENT_SESSION_ID"); let prev_vscode = std::env::var_os("VSCODE_GIT_REPOSITORY_ROOT"); // SAFETY: ENV_LOCK is held, serializing all env access. @@ -1987,6 +1991,7 @@ mod tests { std::env::remove_var("WIRE_SESSION_ID"); std::env::remove_var("CLAUDE_CODE_SESSION_ID"); std::env::remove_var("CODEX_SESSION_ID"); + std::env::remove_var("CODEX_THREAD_ID"); std::env::remove_var("COPILOT_AGENT_SESSION_ID"); std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT"); } @@ -2047,6 +2052,7 @@ mod tests { std::env::remove_var("WIRE_SESSION_ID"); std::env::remove_var("CLAUDE_CODE_SESSION_ID"); std::env::remove_var("CODEX_SESSION_ID"); + std::env::remove_var("CODEX_THREAD_ID"); std::env::remove_var("COPILOT_AGENT_SESSION_ID"); std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT"); if let Some(v) = prev_override { @@ -2058,6 +2064,9 @@ mod tests { if let Some(v) = prev_codex { std::env::set_var("CODEX_SESSION_ID", v); } + if let Some(v) = prev_codex_thread { + std::env::set_var("CODEX_THREAD_ID", v); + } if let Some(v) = prev_copilot { std::env::set_var("COPILOT_AGENT_SESSION_ID", v); } @@ -2069,21 +2078,15 @@ mod tests { #[test] fn resolve_session_key_codex_cli_adapter_and_priority() { - // Per-adapter test for the OpenAI Codex CLI path (#__pr_codex__). - // resolve_session_key reads CODEX_SESSION_ID as a TARGETED env adapter - // — exactly like CLAUDE_CODE_SESSION_ID and COPILOT_AGENT_SESSION_ID. - // Until Codex itself forwards the thread id to MCP child env, operators - // wire it via `[mcp_servers..env]` in `~/.codex/config.toml`; - // landing the adapter now means once Codex ships the env it works - // with zero further code change. Holds three invariants: + // Per-adapter test for the OpenAI Codex path. Current Codex processes + // expose CODEX_THREAD_ID; CODEX_SESSION_ID remains a higher-priority + // compatibility name. Holds three invariants: // // (a) Set to a real thread id -> that key wins resolution and two // distinct threads map to two distinct session homes // (per-thread identity contract). - // (b) WIRE_SESSION_ID overrides CODEX_SESSION_ID (priority 1 - // trumps priority 3); CLAUDE_CODE_SESSION_ID also outranks - // CODEX_SESSION_ID (priority 2 trumps priority 3) — the - // Codex adapter slots between Claude Code and Copilot. + // (b) WIRE_SESSION_ID and CLAUDE_CODE_SESSION_ID outrank both Codex + // names; CODEX_SESSION_ID outranks CODEX_THREAD_ID. // (c) Unexpanded ${...} literal is rejected by the ${} guard, // falling through rather than collapsing all sessions // (mirrors the guard inherited from every other adapter). @@ -2096,6 +2099,7 @@ mod tests { let prev_override = std::env::var_os("WIRE_SESSION_ID"); let prev_claude = std::env::var_os("CLAUDE_CODE_SESSION_ID"); let prev_codex = std::env::var_os("CODEX_SESSION_ID"); + let prev_codex_thread = std::env::var_os("CODEX_THREAD_ID"); let prev_copilot = std::env::var_os("COPILOT_AGENT_SESSION_ID"); let prev_vscode = std::env::var_os("VSCODE_GIT_REPOSITORY_ROOT"); // SAFETY: ENV_LOCK is held, serializing all env access. @@ -2103,12 +2107,30 @@ mod tests { std::env::remove_var("WIRE_SESSION_ID"); std::env::remove_var("CLAUDE_CODE_SESSION_ID"); std::env::remove_var("CODEX_SESSION_ID"); + std::env::remove_var("CODEX_THREAD_ID"); std::env::remove_var("COPILOT_AGENT_SESSION_ID"); std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT"); } - // (a) CODEX_SESSION_ID set -> wins resolution over the no-id baseline; - // distinct thread ids map to distinct session homes. + // (a) CODEX_THREAD_ID is the runtime value current Codex processes + // expose. Distinct thread ids must map to distinct session homes. + unsafe { std::env::set_var("CODEX_THREAD_ID", "019f1111-1111-7111-8111-111111111111") }; + let thread_a = resolve_session_key(); + assert!( + matches!(&thread_a, Some((key, source)) + if key == "019f1111-1111-7111-8111-111111111111" && *source == "codex-cli"), + "CODEX_THREAD_ID must resolve as codex-cli; got {thread_a:?}" + ); + let thread_home_a = session_home_for_key(&thread_a.as_ref().unwrap().0).unwrap(); + unsafe { std::env::set_var("CODEX_THREAD_ID", "019f2222-2222-7222-8222-222222222222") }; + let thread_home_b = session_home_for_key(&resolve_session_key().unwrap().0).unwrap(); + assert_ne!( + thread_home_a, thread_home_b, + "distinct CODEX_THREAD_ID values must map to distinct session homes" + ); + + // CODEX_SESSION_ID remains the higher-priority compatibility name; + // distinct session ids map to distinct session homes. unsafe { std::env::set_var("CODEX_SESSION_ID", "019e66ad-277e-7be3-bdd9-b7708e069f3b") }; let r1 = resolve_session_key(); assert!( @@ -2134,8 +2156,8 @@ mod tests { "same Codex thread id must yield the same home across calls" ); - // (b) WIRE_SESSION_ID at priority 1 overrides CODEX_SESSION_ID at - // priority 3 (operator explicit override always wins). + // (b) WIRE_SESSION_ID overrides both Codex names (operator explicit + // override always wins). unsafe { std::env::set_var("WIRE_SESSION_ID", "operator-override") }; let r_override = resolve_session_key(); assert!( @@ -2144,9 +2166,8 @@ mod tests { ); unsafe { std::env::remove_var("WIRE_SESSION_ID") }; - // CLAUDE_CODE_SESSION_ID at priority 2 also beats CODEX_SESSION_ID at - // priority 3. (Earlier adapters get to claim the host they were - // designed for; Codex slots in after Claude Code.) + // CLAUDE_CODE_SESSION_ID also beats both Codex names. Earlier host + // adapters keep their established precedence. unsafe { std::env::set_var("CLAUDE_CODE_SESSION_ID", "claude-wins-over-codex") }; let r_claude_wins = resolve_session_key(); assert!( @@ -2155,15 +2176,16 @@ mod tests { ); unsafe { std::env::remove_var("CLAUDE_CODE_SESSION_ID") }; - // (c) Unexpanded ${...} literal is rejected by the ${} guard. - // If a host's config-forwarding ever ships a literal placeholder, - // the guard rejects it (same as for every other adapter) so we - // never hash the literal and collapse sessions. - unsafe { std::env::set_var("CODEX_SESSION_ID", "${SOME_PLACEHOLDER}") }; + // (c) Unexpanded ${...} literal is rejected by the ${} guard. Clear + // the compatibility name so this exercises CODEX_THREAD_ID. + unsafe { + std::env::remove_var("CODEX_SESSION_ID"); + std::env::set_var("CODEX_THREAD_ID", "${SOME_PLACEHOLDER}"); + } let r_guard = resolve_session_key(); assert!( !matches!(&r_guard, Some((k, _)) if k.contains("${")), - "unexpanded ${{...}} in CODEX_SESSION_ID must be rejected by the ${{}} guard; got {r_guard:?}" + "unexpanded ${{...}} in CODEX_THREAD_ID must be rejected by the ${{}} guard; got {r_guard:?}" ); // Restore any env we displaced. @@ -2172,6 +2194,7 @@ mod tests { std::env::remove_var("WIRE_SESSION_ID"); std::env::remove_var("CLAUDE_CODE_SESSION_ID"); std::env::remove_var("CODEX_SESSION_ID"); + std::env::remove_var("CODEX_THREAD_ID"); std::env::remove_var("COPILOT_AGENT_SESSION_ID"); std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT"); if let Some(v) = prev_override { @@ -2183,6 +2206,9 @@ mod tests { if let Some(v) = prev_codex { std::env::set_var("CODEX_SESSION_ID", v); } + if let Some(v) = prev_codex_thread { + std::env::set_var("CODEX_THREAD_ID", v); + } if let Some(v) = prev_copilot { std::env::set_var("COPILOT_AGENT_SESSION_ID", v); } diff --git a/test-env/runtime-210.sh b/test-env/runtime-210.sh index 18c9562..be4848c 100644 --- a/test-env/runtime-210.sh +++ b/test-env/runtime-210.sh @@ -20,7 +20,7 @@ cargo build --release --quiet || { echo "build failed"; exit 1; } SBX="$(mktemp -d)" export XDG_STATE_HOME="$SBX/state" XDG_DATA_HOME="$SBX/share" XDG_CONFIG_HOME="$SBX/config" export WIRE_VERBOSE=1 -unset WIRE_SESSION_ID CLAUDE_CODE_SESSION_ID CODEX_SESSION_ID \ +unset WIRE_SESSION_ID CLAUDE_CODE_SESSION_ID CODEX_SESSION_ID CODEX_THREAD_ID \ COPILOT_AGENT_SESSION_ID VSCODE_GIT_REPOSITORY_ROOT WIRE_HOME WIRE_HOME_FORCE # Bring an identity online (offline keygen) under the env's resolved home, then diff --git a/tests/session_lifecycle.rs b/tests/session_lifecycle.rs index 530b0b1..f8c4518 100644 --- a/tests/session_lifecycle.rs +++ b/tests/session_lifecycle.rs @@ -19,6 +19,7 @@ fn mcp_process_owns_lease_for_its_lifetime() { .env_remove("WIRE_SESSION_ID") .env_remove("CLAUDE_CODE_SESSION_ID") .env_remove("CODEX_SESSION_ID") + .env_remove("CODEX_THREAD_ID") .stdin(Stdio::piped()) .stdout(Stdio::null()) .stderr(Stdio::null()) diff --git a/tests/supervisor_bounded.rs b/tests/supervisor_bounded.rs index 4d478de..da3c9b1 100644 --- a/tests/supervisor_bounded.rs +++ b/tests/supervisor_bounded.rs @@ -41,6 +41,7 @@ fn spawn_supervisor(root: &Path) -> Child { .env_remove("WIRE_SESSION_ID") .env_remove("CLAUDE_CODE_SESSION_ID") .env_remove("CODEX_SESSION_ID") + .env_remove("CODEX_THREAD_ID") .env_remove("COPILOT_AGENT_SESSION_ID") .env_remove("VSCODE_GIT_REPOSITORY_ROOT") .stdin(Stdio::null())