feat(codex): keep wrap routing session-scoped (#1507)

## Description

Keeps Codex wrap routing session-scoped so routing state from one
wrapped session does not leak into another.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- Scope Codex wrap routing state to the active session.
- Avoid cross-session routing contamination for wrapped Codex traffic.
- Keep changes focused on wrap/proxy routing behavior.

## Testing

- [x] Unit tests pass
- [x] New tests added for new functionality when applicable
- [x] Manual testing performed

### Test Output

```text
Focused tests/review were completed before this governance body cleanup. The current branch is conflicted and still needs merge resolution before final merge readiness.
```

## Real Behavior Proof

- Environment: Headroom development/review context.
- Exact command / steps: Reviewed session-scoped Codex wrap routing
behavior and existing focused coverage.
- Observed result: Routing state is scoped to the active wrap session
rather than shared globally across sessions.
- Not tested: Current conflicted branch after merge resolution;
conflicts still need to be resolved before merge.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Additional Notes

This body was normalized by a maintainer after approval so the
governance parser reflects the already-reviewed PR state. The PR remains
blocked by merge conflicts.
This commit is contained in:
Rod Boev 2026-07-11 12:03:57 -04:00 committed by GitHub
parent b0440f958d
commit ad9d086f43
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 486 additions and 316 deletions

View file

@ -230,6 +230,7 @@ def create_shims(shim_dir: Path) -> None:
"OPENAI_BASE_URL",
"OPENAI_API_BASE",
"ANTHROPIC_BASE_URL",
"CODEX_HOME",
"OPENCODE_CONFIG_CONTENT",
)
if os.environ.get(key) is not None
@ -286,6 +287,7 @@ def create_shims(shim_dir: Path) -> None:
"OPENAI_BASE_URL",
"OPENAI_API_BASE",
"ANTHROPIC_BASE_URL",
"CODEX_HOME",
"OPENCODE_CONFIG_CONTENT",
)
if os.environ.get(key) is not None
@ -351,6 +353,13 @@ def create_shims(shim_dir: Path) -> None:
by_model.get(model_name) if isinstance(by_model, dict) else None
)
codex_home = os.environ.get("CODEX_HOME")
if codex_home:
session_config = Path(codex_home) / "config.toml"
record["session_config"] = (
session_config.read_text(encoding="utf-8") if session_config.exists() else None
)
record["probes"] = probes
with (log_dir / f"{tool}.jsonl").open("a", encoding="utf-8") as handle:
@ -571,32 +580,50 @@ def verify_codex_wrap(
)
config_path = Path(base_env["HOME"]) / ".codex" / "config.toml"
assert_true(config_path.exists(), "Codex wrap should create ~/.codex/config.toml")
config = config_path.read_text(encoding="utf-8")
assert_true(
f'openai_base_url = "http://127.0.0.1:{port}/v1"' in config,
"Codex wrap should inject openai_base_url for subscription routing",
)
assert_true(
f'base_url = "http://127.0.0.1:{port}/v1"' in config,
"Codex wrap should inject the headroom provider base_url",
)
assert_true(
'env_key = "OPENAI_API_KEY"' not in config,
"Codex wrap should preserve OAuth and never inject env_key",
)
# Bug 3 (#406): requires_openai_auth must be absent from headroom provider blocks.
assert_true(
"requires_openai_auth" not in config,
"Codex wrap must NOT inject requires_openai_auth into the headroom provider block",
)
assert_true(
"supports_websockets = true" in config, "Codex wrap missing 'supports_websockets = true'"
not config_path.exists(),
"Codex wrap should leave ~/.codex/config.toml untouched during normal launch",
)
entries = read_jsonl(log_dir / "codex.jsonl")
assert_true(len(entries) > 0, "Codex shim should have been invoked")
env_vars = entries[-1]["env"]
session_home = env_vars.get("CODEX_HOME")
assert_true(
isinstance(session_home, str) and session_home,
"Codex wrap should launch the child with a session-scoped CODEX_HOME",
)
assert_true(
Path(session_home) != config_path.parent,
"Codex wrap should not point the child at the real ~/.codex home",
)
config = entries[-1].get("session_config")
assert_true(
isinstance(config, str) and config,
"Codex wrap should capture the session-scoped config during launch",
)
assert_true(
f'openai_base_url = "http://127.0.0.1:{port}/v1"' in config,
"Codex wrap should inject openai_base_url into the session config",
)
assert_true(
f'base_url = "http://127.0.0.1:{port}/v1"' in config,
"Codex wrap should inject the headroom provider base_url into the session config",
)
assert_true(
'env_key = "OPENAI_API_KEY"' not in config,
"Codex wrap should preserve OAuth and never inject env_key into the session config",
)
# Bug 3 (#406): requires_openai_auth must be absent from headroom provider blocks.
assert_true(
"requires_openai_auth" not in config,
"Codex wrap must NOT inject requires_openai_auth into the session headroom provider block",
)
assert_true(
"supports_websockets = true" in config,
"Codex wrap missing 'supports_websockets = true' in the session config",
)
assert_true(
env_vars.get("OPENAI_BASE_URL") == f"http://127.0.0.1:{port}/v1",
"Codex wrap should set OPENAI_BASE_URL",

View file

@ -30,6 +30,7 @@ import tempfile
import time
import urllib.parse
from collections.abc import Callable
from contextlib import contextmanager
from pathlib import Path
from typing import Any, cast
@ -1495,6 +1496,27 @@ def _codex_home_dir() -> Path:
return Path.home() / ".codex"
@contextmanager
def _codex_session_home_overlay() -> Any:
"""Seed a temporary Codex home from the active home and point the process at it."""
source_home = _codex_home_dir()
original_codex_home = os.environ.get("CODEX_HOME")
with tempfile.TemporaryDirectory(prefix="headroom-codex-home-") as tmp_dir:
session_home = Path(tmp_dir)
if source_home.exists():
shutil.copytree(source_home, session_home, dirs_exist_ok=True)
os.environ["CODEX_HOME"] = str(session_home)
try:
yield session_home
finally:
if original_codex_home is None:
os.environ.pop("CODEX_HOME", None)
else:
os.environ["CODEX_HOME"] = original_codex_home
def _codex_config_paths() -> tuple[Path, Path]:
"""Return ``(config_file, backup_file)`` paths for the Codex TOML config."""
config_dir = _codex_home_dir()
@ -4537,6 +4559,200 @@ def unwrap_copilot(port: int, no_stop_proxy: bool) -> None:
# =============================================================================
def _prepare_codex_wrap_state(
*,
port: int,
no_rtk: bool,
no_mcp: bool,
no_tokensave: bool,
serena: bool,
no_serena: bool,
memory: bool,
verbose: bool,
rtk_home: Path | None = None,
) -> None:
"""Prepare the active Codex home for a wrap or prepare-only invocation."""
# Snapshot Codex config.toml BEFORE any wrap-time mutation so
# `headroom unwrap codex` can restore the user's pre-wrap state
# byte-for-byte. The snapshot is a no-op if the backup already exists
# or if the file already has Headroom markers, so this is safe to
# call repeatedly. Crucially this must run before MCP install, which
# writes its marker block to the same file.
_codex_config_file, _codex_backup_file = _codex_config_paths()
_snapshot_codex_config_if_unwrapped(_codex_config_file, _codex_backup_file)
# Setup CLI context tool for Codex.
if not no_rtk:
if _selected_context_tool() == _CONTEXT_TOOL_LEAN_CTX:
click.echo(" Setting up lean-ctx for Codex...")
_setup_lean_ctx_agent("codex", verbose=verbose)
else:
click.echo(" Setting up rtk for Codex...")
rtk_path = _ensure_rtk_binary(verbose=verbose)
if rtk_path:
# Keep RTK guidance local to the user's Codex configuration.
global_agents = (rtk_home or _codex_home_dir()) / "AGENTS.md"
_inject_rtk_instructions(global_agents, verbose=verbose)
# Register headroom MCP server in Codex config.toml so Codex can
# call headroom_retrieve on compression markers from the proxy.
if not no_mcp:
from headroom.mcp_registry import CodexRegistrar
# Codex starts a long-lived local MCP subprocess from config.toml.
# If a previous wrap used another port, retrieval can silently point
# at the wrong proxy while model traffic uses the right one.
_setup_headroom_mcp(CodexRegistrar(), port, verbose=verbose, force=True)
elif verbose:
click.echo(" Skipping MCP retrieve tool (--no-mcp)")
# Coding-task compressor: tokensave primary, Serena backup. Codex starts
# long-lived MCP subprocesses from config.toml, so force re-registration.
from headroom.mcp_registry import CodexRegistrar
_setup_coding_compressor(
CodexRegistrar(),
serena_context="codex",
serena=serena,
no_serena=no_serena,
no_tokensave=no_tokensave,
verbose=verbose,
force=True,
)
# Setup memory MCP server for Codex (native tool integration)
if memory:
click.echo(" Setting up memory for Codex...")
mem_dir = Path.cwd() / ".headroom"
mem_dir.mkdir(parents=True, exist_ok=True)
db_path = str(mem_dir / "memory.db")
mem_user = os.environ.get("USER", os.environ.get("USERNAME", "default"))
# Register MCP server in Codex config
_inject_memory_mcp_config(mem_user)
# Inject memory guidance into project AGENTS.md
agents_md = Path.cwd() / "AGENTS.md"
_inject_memory_agents_md(agents_md)
# Sync Claude's memories → DB so MCP search finds them
try:
import asyncio
from headroom.memory.sync import _build_sync_backend, sync_import
from headroom.memory.sync_adapters.claude_code import (
ClaudeCodeAdapter,
get_claude_memory_dir,
)
claude_memory_dir = get_claude_memory_dir()
async def _import_claude_memories() -> int:
backend = _build_sync_backend(db_path)
await backend._ensure_initialized()
adapter = ClaudeCodeAdapter(claude_memory_dir)
count = await sync_import(backend, adapter, mem_user)
await backend.close()
return count
imported = asyncio.run(_import_claude_memories())
if imported:
click.echo(f" Memory: imported {imported} memories from Claude")
except Exception as e:
click.echo(f" Warning: Claude memory import failed: {e}")
# Inject Headroom provider into Codex config so WebSocket traffic also
# routes through the proxy. Codex ignores OPENAI_BASE_URL for its WS
# transport unless a custom provider declares supports_websockets = true.
# NOTE: this must run BEFORE _inject_memory_mcp_config because it rewrites
# the config file. Re-inject MCP config after if memory is enabled.
_inject_codex_provider_config(port)
if memory:
_inject_memory_mcp_config(os.environ.get("USER", os.environ.get("USERNAME", "default")))
def _run_codex_wrap(
*,
port: int,
no_rtk: bool,
no_mcp: bool,
no_tokensave: bool,
serena: bool,
no_serena: bool,
code_graph: bool,
no_proxy: bool,
learn: bool,
memory: bool,
backend: str | None,
anyllm_provider: str | None,
region: str | None,
verbose: bool,
prepare_only: bool,
codex_args: tuple,
) -> None:
"""Execute the Codex wrap flow with the session overlay when launching."""
if prepare_only:
_prepare_codex_wrap_state(
port=port,
no_rtk=no_rtk,
no_mcp=no_mcp,
no_tokensave=no_tokensave,
serena=serena,
no_serena=no_serena,
memory=memory,
verbose=verbose,
)
return
codex_bin = shutil.which("codex")
if not codex_bin:
click.echo("Error: 'codex' not found in PATH.")
click.echo("Install Codex CLI: npm install -g @openai/codex")
raise SystemExit(1)
active_codex_home = _codex_home_dir()
with _codex_session_home_overlay() as session_codex_home:
_prepare_codex_wrap_state(
port=port,
no_rtk=no_rtk,
no_mcp=no_mcp,
no_tokensave=no_tokensave,
serena=serena,
no_serena=no_serena,
memory=memory,
verbose=verbose,
rtk_home=active_codex_home,
)
env, env_vars_display = _build_codex_launch_env(port, os.environ)
# Per-project savings attribution: the injected provider config maps the
# X-Headroom-Project header to HEADROOM_PROJECT via env_http_headers, so
# Codex sends it only when this var is set. A user-set value wins.
_codex_project = _project_name_from_cwd()
if _codex_project and "HEADROOM_PROJECT" not in env:
env["HEADROOM_PROJECT"] = _codex_project
env["CODEX_HOME"] = str(session_codex_home)
_launch_tool(
binary=codex_bin,
args=codex_args,
env=env,
port=port,
no_proxy=no_proxy,
tool_label="CODEX",
env_vars_display=env_vars_display,
learn=learn,
memory=memory,
agent_type="codex",
code_graph=code_graph,
backend=backend,
anyllm_provider=anyllm_provider,
region=region,
)
@wrap.command(context_settings={"ignore_unknown_options": True})
@click.option(
"--port", "-p", default=8787, type=click.IntRange(1, 65535), help="Proxy port (default: 8787)"
@ -4630,258 +4846,25 @@ def codex(
headroom wrap codex --port 9999 # Custom proxy port
headroom wrap codex --backend anyllm --anyllm-provider groq
"""
# Snapshot Codex config.toml BEFORE any wrap-time mutation so
# `headroom unwrap codex` can restore the user's pre-wrap state
# byte-for-byte. The snapshot is a no-op if the backup already exists
# or if the file already has Headroom markers, so this is safe to
# call repeatedly. Crucially this must run before MCP install, which
# writes its marker block to the same file.
_codex_config_file, _codex_backup_file = _codex_config_paths()
_snapshot_codex_config_if_unwrapped(_codex_config_file, _codex_backup_file)
# Non-port-dependent setup first (RTK, etc.).
if not no_rtk:
if _selected_context_tool() == _CONTEXT_TOOL_LEAN_CTX:
click.echo(" Setting up lean-ctx for Codex...")
_setup_lean_ctx_agent("codex", verbose=verbose)
else:
click.echo(" Setting up rtk for Codex...")
rtk_path = _ensure_rtk_binary(verbose=verbose)
if rtk_path:
# Keep RTK guidance local to the user's Codex configuration.
global_agents = _codex_home_dir() / "AGENTS.md"
_inject_rtk_instructions(global_agents, verbose=verbose)
# --prepare-only: only update Codex config, do NOT start proxy.
# MCP/memory/provider config are all config-file writes — they don't
# need a running proxy. Use the raw requested port (no health check,
# no port fallback) since the user will run the full command later.
if prepare_only:
if not no_mcp:
from headroom.mcp_registry import CodexRegistrar
_setup_headroom_mcp(CodexRegistrar(), port, verbose=verbose, force=True)
elif verbose:
click.echo(" Skipping MCP retrieve tool (--no-mcp)")
from headroom.mcp_registry import CodexRegistrar
_setup_coding_compressor(
CodexRegistrar(),
serena_context="codex",
serena=serena,
no_serena=no_serena,
no_tokensave=no_tokensave,
verbose=verbose,
force=True,
)
if memory:
click.echo(" Setting up memory for Codex...")
mem_dir = Path.cwd() / ".headroom"
mem_dir.mkdir(parents=True, exist_ok=True)
db_path = str(mem_dir / "memory.db")
mem_user = os.environ.get("USER", os.environ.get("USERNAME", "default"))
_inject_memory_mcp_config(mem_user)
agents_md = Path.cwd() / "AGENTS.md"
_inject_memory_agents_md(agents_md)
# Sync Claude's memories → DB so MCP search finds them
try:
import asyncio
from headroom.memory.sync import _build_sync_backend, sync_import
from headroom.memory.sync_adapters.claude_code import (
ClaudeCodeAdapter,
get_claude_memory_dir,
)
claude_memory_dir = get_claude_memory_dir()
async def _import_claude_memories() -> int:
backend = _build_sync_backend(db_path)
await backend._ensure_initialized()
adapter = ClaudeCodeAdapter(claude_memory_dir)
count = await sync_import(backend, adapter, mem_user)
await backend.close()
return count
imported = asyncio.run(_import_claude_memories())
if imported:
click.echo(f" Memory: imported {imported} memories from Claude")
except Exception as e:
click.echo(f" Warning: Claude memory import failed: {e}")
_inject_codex_provider_config(port)
return
# Register headroom MCP server in Codex config.toml so Codex can
# call headroom_retrieve on compression markers from the proxy.
# These config writes do not need a running proxy — they run before
# _ensure_proxy so unwrap has config to clean up even when proxy
# startup or binary lookup fails.
if not no_mcp:
from headroom.mcp_registry import CodexRegistrar
# Codex starts a long-lived local MCP subprocess from config.toml.
# If a previous wrap used another port, retrieval can silently point
# at the wrong proxy while model traffic uses the right one.
_setup_headroom_mcp(CodexRegistrar(), port, verbose=verbose, force=True)
elif verbose:
click.echo(" Skipping MCP retrieve tool (--no-mcp)")
# Coding-task compressor: tokensave primary, Serena backup. Codex starts
# long-lived MCP subprocesses from config.toml, so force re-registration.
from headroom.mcp_registry import CodexRegistrar
_setup_coding_compressor(
CodexRegistrar(),
serena_context="codex",
return _run_codex_wrap(
port=port,
no_rtk=no_rtk,
no_mcp=no_mcp,
no_tokensave=no_tokensave,
serena=serena,
no_serena=no_serena,
no_tokensave=no_tokensave,
verbose=verbose,
force=True,
)
# Setup memory MCP server for Codex (native tool integration)
if memory:
click.echo(" Setting up memory for Codex...")
mem_dir = Path.cwd() / ".headroom"
mem_dir.mkdir(parents=True, exist_ok=True)
db_path = str(mem_dir / "memory.db")
mem_user = os.environ.get("USER", os.environ.get("USERNAME", "default"))
# Register MCP server in Codex config
_inject_memory_mcp_config(mem_user)
# Inject memory guidance into project AGENTS.md
agents_md = Path.cwd() / "AGENTS.md"
_inject_memory_agents_md(agents_md)
# Sync Claude's memories → DB so MCP search finds them
try:
import asyncio
from headroom.memory.sync import _build_sync_backend, sync_import
from headroom.memory.sync_adapters.claude_code import (
ClaudeCodeAdapter,
get_claude_memory_dir,
)
claude_memory_dir = get_claude_memory_dir()
async def _import_claude_memories() -> int:
backend = _build_sync_backend(db_path)
await backend._ensure_initialized()
adapter = ClaudeCodeAdapter(claude_memory_dir)
count = await sync_import(backend, adapter, mem_user)
await backend.close()
return count
imported = asyncio.run(_import_claude_memories())
if imported:
click.echo(f" Memory: imported {imported} memories from Claude")
except Exception as e:
click.echo(f" Warning: Claude memory import failed: {e}")
codex_bin = shutil.which("codex")
if not codex_bin:
click.echo("Error: 'codex' not found in PATH.")
click.echo("Install Codex CLI: npm install -g @openai/codex")
raise SystemExit(1)
# Register our proxy client marker BEFORE _ensure_proxy so that another
# wrapper's cleanup sees us as an active client and doesn't terminate a
# shared proxy during the startup gap.
_register_proxy_client(port)
# Let _ensure_proxy decide the port (same contract as other wrappers).
# Called after config writes so unwrap has config to restore even when
# proxy startup fails.
_codex_proxy, actual_port = _ensure_proxy(
port,
no_proxy,
code_graph=code_graph,
no_proxy=no_proxy,
learn=learn,
memory=memory,
agent_type="codex",
code_graph=code_graph,
backend=backend,
anyllm_provider=anyllm_provider,
region=region,
verbose=verbose,
prepare_only=prepare_only,
codex_args=codex_args,
)
# If the proxy fell back to a different port, move our marker to the
# actual port so cleanup tracking stays accurate.
if actual_port != port:
_unregister_proxy_client(port)
_register_proxy_client(actual_port)
# If the proxy fell back to a different port, update the MCP config so
# the retrieval tool URL points at the port the proxy is actually on.
if actual_port != port and not no_mcp:
from headroom.mcp_registry import CodexRegistrar
_setup_headroom_mcp(CodexRegistrar(), actual_port, verbose=verbose, force=True)
env, env_vars_display = _build_codex_launch_env(actual_port, os.environ)
# Per-project savings attribution: the injected provider config maps the
# X-Headroom-Project header to HEADROOM_PROJECT via env_http_headers, so
# Codex sends it only when this var is set. A user-set value wins.
_codex_project = _project_name_from_cwd()
if _codex_project and "HEADROOM_PROJECT" not in env:
env["HEADROOM_PROJECT"] = _codex_project
# Inject Headroom provider into Codex config so WebSocket traffic also
# routes through the proxy. Codex ignores OPENAI_BASE_URL for its WS
# transport unless a custom provider declares supports_websockets = true.
# NOTE: this must run BEFORE _inject_memory_mcp_config because it rewrites
# the config file. Re-inject MCP config after if memory is enabled.
_codex_custom_upstream = _inject_codex_provider_config(actual_port)
if _codex_custom_upstream and _UPSTREAM_BASE_URL_ENV_VAR not in env:
# Carries the preserved custom base_url (#1614) to the injected
# env_http_headers entry, which maps it to X-Headroom-Base-Url —
# the proxy's OpenAI HTTP handlers forward there instead of the
# hardcoded api.openai.com default. A user-set value wins.
env[_UPSTREAM_BASE_URL_ENV_VAR] = _codex_custom_upstream
env_vars_display.append(f"{_UPSTREAM_BASE_URL_ENV_VAR}={_codex_custom_upstream}")
if memory:
_inject_memory_mcp_config(os.environ.get("USER", os.environ.get("USERNAME", "default")))
# Proxy already started by _ensure_proxy above; tell _launch_tool to
# skip duplicate startup. Cleanup of _codex_proxy happens on exit
# via the finally block below.
try:
_launch_tool(
binary=codex_bin,
args=codex_args,
env=env,
port=actual_port,
no_proxy=True,
tool_label="CODEX",
env_vars_display=env_vars_display,
learn=learn,
memory=memory,
agent_type="codex",
code_graph=code_graph,
backend=backend,
anyllm_provider=anyllm_provider,
region=region,
)
finally:
# _launch_tool's internal cleanup unregisters this client marker,
# but doesn't know about the proxy we started. Terminate it when
# no other clients remain.
if _codex_proxy and _codex_proxy.poll() is None:
_other = _live_proxy_clients(actual_port, exclude_self=True)
if not _other:
_codex_proxy.terminate()
try:
_codex_proxy.wait(timeout=5)
except subprocess.TimeoutExpired:
_codex_proxy.kill()
# =============================================================================
# Aider

View file

@ -1053,6 +1053,166 @@ def test_wrap_codex_prepare_only_respects_codex_home(
assert not (tmp_path / ".codex" / "config.toml").exists()
def test_codex_session_home_overlay_seeds_active_home_and_cleans_up(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_set_test_home(monkeypatch, tmp_path)
codex_home = tmp_path / "custom-codex-home"
codex_home.mkdir()
monkeypatch.setenv("CODEX_HOME", str(codex_home))
config_file = codex_home / "config.toml"
auth_file = codex_home / "auth.json"
original_config = '[profiles.default]\nmodel = "gpt-4o"\n'
original_auth = '{"auth_mode": "apikey"}'
config_file.write_text(original_config, encoding="utf-8")
auth_file.write_text(original_auth, encoding="utf-8")
with wrap_mod._codex_session_home_overlay() as session_home:
seeded_config = (session_home / "config.toml").read_text(encoding="utf-8")
seeded_auth = (session_home / "auth.json").read_text(encoding="utf-8")
assert seeded_config == original_config
assert seeded_auth == original_auth
(session_home / "config.toml").write_text('model_provider = "headroom"\n', encoding="utf-8")
assert config_file.read_text(encoding="utf-8") == original_config
assert not session_home.exists()
assert config_file.read_text(encoding="utf-8") == original_config
assert auth_file.read_text(encoding="utf-8") == original_auth
def test_wrap_codex_launch_uses_session_scoped_codex_home(
runner: CliRunner, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_set_test_home(monkeypatch, tmp_path)
codex_home = tmp_path / "custom-codex-home"
codex_home.mkdir()
monkeypatch.setenv("CODEX_HOME", str(codex_home))
config_file = codex_home / "config.toml"
auth_file = codex_home / "auth.json"
original_config = '[profiles.default]\nmodel = "gpt-4o"\n'
original_auth = '{"auth_mode": "apikey"}'
config_file.write_text(original_config, encoding="utf-8")
auth_file.write_text(original_auth, encoding="utf-8")
launch_env: dict[str, str] = {}
session_home_seen: list[Path] = []
def fake_launch(
*,
binary: str,
args: tuple,
env: dict[str, str],
port: int,
no_proxy: bool,
tool_label: str,
env_vars_display: list[str],
**kwargs: object,
) -> None:
del args, port, no_proxy, tool_label, env_vars_display, kwargs
assert binary == "/fake/codex"
launch_env.update(env)
session_home = Path(env["CODEX_HOME"])
session_home_seen.append(session_home)
assert session_home.exists()
seeded_config = (session_home / "config.toml").read_text(encoding="utf-8")
assert original_config in seeded_config
assert 'model_provider = "headroom"' in seeded_config
assert 'base_url = "http://127.0.0.1:8787/v1"' in seeded_config
assert "[mcp_servers.headroom]" in seeded_config
assert (session_home / "auth.json").read_text(encoding="utf-8") == original_auth
with patch("headroom.cli.wrap._ensure_rtk_binary", return_value=None):
with patch(
"headroom.cli.wrap.shutil.which",
side_effect=lambda cmd: "/fake/codex" if cmd == "codex" else None,
):
with patch("headroom.cli.wrap._launch_tool", side_effect=fake_launch):
result = runner.invoke(
main,
[
"wrap",
"codex",
"--port",
"8787",
"--no-tokensave",
"--no-serena",
],
)
assert result.exit_code == 0, result.output
assert session_home_seen
assert launch_env["CODEX_HOME"] == str(session_home_seen[0])
assert launch_env["OPENAI_BASE_URL"] == "http://127.0.0.1:8787/v1"
assert config_file.read_text(encoding="utf-8") == original_config
assert auth_file.read_text(encoding="utf-8") == original_auth
assert not session_home_seen[0].exists()
def test_wrap_codex_launches_use_distinct_session_homes_per_port(
runner: CliRunner, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_set_test_home(monkeypatch, tmp_path)
codex_home = tmp_path / "custom-codex-home"
codex_home.mkdir()
monkeypatch.setenv("CODEX_HOME", str(codex_home))
config_file = codex_home / "config.toml"
auth_file = codex_home / "auth.json"
original_config = '[profiles.default]\nmodel = "gpt-4o"\n'
original_auth = '{"auth_mode": "apikey"}'
config_file.write_text(original_config, encoding="utf-8")
auth_file.write_text(original_auth, encoding="utf-8")
launch_records: list[tuple[int, Path, str]] = []
def fake_launch(
*,
binary: str,
args: tuple,
env: dict[str, str],
port: int,
no_proxy: bool,
tool_label: str,
env_vars_display: list[str],
**kwargs: object,
) -> None:
del args, no_proxy, tool_label, env_vars_display, kwargs
assert binary == "/fake/codex"
session_home = Path(env["CODEX_HOME"])
assert session_home.exists()
launch_records.append(
(port, session_home, (session_home / "config.toml").read_text(encoding="utf-8"))
)
with patch("headroom.cli.wrap._ensure_rtk_binary", return_value=None):
with patch(
"headroom.cli.wrap.shutil.which",
side_effect=lambda cmd: "/fake/codex" if cmd == "codex" else None,
):
with patch("headroom.cli.wrap._launch_tool", side_effect=fake_launch):
first = runner.invoke(
main,
["wrap", "codex", "--port", "8787", "--no-tokensave", "--no-serena"],
)
second = runner.invoke(
main,
["wrap", "codex", "--port", "9898", "--no-tokensave", "--no-serena"],
)
assert first.exit_code == 0, first.output
assert second.exit_code == 0, second.output
assert len(launch_records) == 2
assert launch_records[0][1] != launch_records[1][1]
assert 'base_url = "http://127.0.0.1:8787/v1"' in launch_records[0][2]
assert 'base_url = "http://127.0.0.1:9898/v1"' in launch_records[1][2]
assert config_file.read_text(encoding="utf-8") == original_config
assert auth_file.read_text(encoding="utf-8") == original_auth
assert not launch_records[0][1].exists()
assert not launch_records[1][1].exists()
def test_wrap_codex_injects_rtk_globally_without_changing_project_agents(
runner: CliRunner, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
@ -1086,6 +1246,41 @@ def test_wrap_codex_injects_rtk_globally_without_changing_project_agents(
assert wrap_mod._RTK_MARKER.encode() in global_agents.read_bytes()
def test_wrap_codex_launch_injects_rtk_globally_without_changing_project_agents(
runner: CliRunner, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_set_test_home(monkeypatch, tmp_path)
project_dir = tmp_path / "project"
project_dir.mkdir()
project_agents = project_dir / "AGENTS.md"
original = "# Project instructions\n\nUse the repository conventions.\n"
project_agents.write_text(original, encoding="utf-8")
original_bytes = project_agents.read_bytes()
monkeypatch.chdir(project_dir)
with patch("headroom.cli.wrap._ensure_rtk_binary", return_value=tmp_path / "rtk"):
with patch(
"headroom.cli.wrap.shutil.which",
side_effect=lambda cmd: "/fake/codex" if cmd == "codex" else None,
):
with patch("headroom.cli.wrap._launch_tool"):
result = runner.invoke(
main,
[
"wrap",
"codex",
"--no-mcp",
"--no-serena",
"--no-tokensave",
],
)
assert result.exit_code == 0, result.output
assert project_agents.read_bytes() == original_bytes
global_agents = tmp_path / ".codex" / "AGENTS.md"
assert wrap_mod._RTK_MARKER.encode() in global_agents.read_bytes()
def test_unwrap_codex_without_codex_home_warns_on_ambiguous_noop(
runner: CliRunner, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
@ -1539,16 +1734,14 @@ def test_wrap_codex_memory_launch_failure_unwrap_cleans_memory_only_config(
assert wrap_result.exit_code == 1
config_file = tmp_path / ".codex" / "config.toml"
content = config_file.read_text()
assert "[mcp_servers.headroom_memory]" in content
assert wrap_mod._CODEX_TOP_LEVEL_MARKER not in content
assert not config_file.exists()
assert not (tmp_path / ".codex" / "config.toml.headroom-backup").exists()
with patch("headroom.cli.wrap._stop_local_proxy_for_unwrap") as stop_proxy:
unwrap_result = runner.invoke(main, ["unwrap", "codex", "--no-stop-proxy"])
assert unwrap_result.exit_code == 0, unwrap_result.output
assert not config_file.exists()
assert not (tmp_path / ".codex" / "config.toml.headroom-backup").exists()
assert "Nothing to undo" in unwrap_result.output
stop_proxy.assert_not_called()
@ -1692,58 +1885,29 @@ class TestCodexProjectHeaderConfig:
# ---------------------------------------------------------------------------
# Regression: codex delegates port resolution to _ensure_proxy
# Regression: codex preserves the requested port through the session-scoped runner
# ---------------------------------------------------------------------------
class TestCodexPortResolution:
"""codex() uses _ensure_proxy() to resolve ports (not early _find_available_port).
"""codex() hands the requested port to the session-scoped wrap runner.
Regression for headroom#1406 round 2 review: codex() must follow
the same selected-port contract as other wrappers (aider, copilot, etc.)
so that a healthy existing proxy on the requested port is reused instead
of skipped by a blind socket probe.
Regression for headroom#1406 round 2 review: the codex command must keep
the selected-port contract intact after the session-home refactor instead
of silently dropping or rewriting the requested port before the shared
launch path handles proxy reuse and fallback.
"""
def test_delegates_to_ensure_proxy(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""codex() calls _ensure_proxy and uses the returned port."""
def test_delegates_to_session_runner(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""codex() passes the requested port through to _run_codex_wrap."""
_set_test_home(monkeypatch, Path("/tmp/test_headroom_codex"))
captured_port: list[int] = []
call_kw: dict = {}
# Mock _ensure_proxy to capture the requested port
def mock_ensure_proxy(port: int, no_proxy: bool, **kwargs: object) -> tuple[None, int]:
captured_port.append(port)
# Simulate port fallback: requested 8787, actual 8788
return None, 8788
def mock_run_codex_wrap(**kwargs: object) -> None:
call_kw.update(kwargs)
monkeypatch.setattr(wrap_mod, "_ensure_proxy", mock_ensure_proxy)
# Mock all heavy dependencies
monkeypatch.setattr(
wrap_mod, "_codex_config_paths", lambda: (Path("/dev/null"), Path("/dev/null"))
)
monkeypatch.setattr(wrap_mod, "_snapshot_codex_config_if_unwrapped", lambda *a, **kw: None)
monkeypatch.setattr(wrap_mod, "_ensure_rtk_binary", lambda *a, **kw: None)
monkeypatch.setattr(wrap_mod, "_setup_lean_ctx_agent", lambda *a, **kw: None)
monkeypatch.setattr(wrap_mod, "_inject_rtk_instructions", lambda *a, **kw: None)
monkeypatch.setattr(wrap_mod, "_codex_home_dir", lambda: Path("/tmp"))
monkeypatch.setattr(wrap_mod, "_setup_headroom_mcp", lambda *a, **kw: None)
monkeypatch.setattr(wrap_mod, "_setup_serena_mcp", lambda *a, **kw: None)
monkeypatch.setattr(wrap_mod, "_disable_serena_mcp", lambda *a, **kw: None)
monkeypatch.setattr("shutil.which", lambda x: "/usr/bin/codex" if x == "codex" else None)
monkeypatch.setattr(wrap_mod, "_build_codex_launch_env", lambda port, env: ({}, []))
monkeypatch.setattr(wrap_mod, "_inject_codex_provider_config", lambda port: None)
monkeypatch.setattr(wrap_mod, "_project_name_from_cwd", lambda: None)
monkeypatch.setattr(wrap_mod, "_live_proxy_clients", lambda *a, **kw: [])
# Intercept _launch_tool to verify port propagation
launch_kw: dict = {}
def mock_launch_tool(**kwargs: object) -> None:
launch_kw.update(kwargs)
monkeypatch.setattr(wrap_mod, "_launch_tool", mock_launch_tool)
monkeypatch.setattr(wrap_mod, "_run_codex_wrap", mock_run_codex_wrap)
runner = CliRunner()
result = runner.invoke(
@ -1752,10 +1916,6 @@ class TestCodexPortResolution:
)
assert result.exit_code == 0, f"CLI failed: {result.output}"
assert captured_port == [8787], (
f"_ensure_proxy called with {captured_port}, expected [8787]"
)
assert launch_kw.get("port") == 8788, (
f"_launch_tool port={launch_kw.get('port')}, expected 8788 "
"(the actual_port from _ensure_proxy fallback)"
)
assert call_kw.get("port") == 8787
assert call_kw.get("no_proxy") is False
assert call_kw.get("prepare_only") is False