headroom/tests/test_cli/test_wrap_codex.py

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

1076 lines
42 KiB
Python
Raw Normal View History

fix(wrap): unwrap codex restores prior config.toml `headroom wrap codex` injects a `model_provider = "headroom"` block plus a `[model_providers.headroom]` table into `~/.codex/config.toml` so Codex routes both HTTP and WebSocket traffic through the proxy. The matching `unwrap codex` subcommand did not exist, so the injected block stayed in `config.toml` forever — the moment the proxy stopped, Codex (CLI and macOS app) started erroring with `Missing environment variable: OPENAI_API_KEY`, and users had to hand- edit the file to recover. Fix: * `_inject_codex_provider_config` now snapshots the pre-wrap file to `~/.codex/config.toml.headroom-backup` before the first modification and leaves that snapshot untouched on subsequent wrap runs. The injection is also rewritten to use two self-contained marker- delimited blocks (top-level key and provider table) so stripping them never consumes user content that sits between them. * `_inject_memory_mcp_config` takes the same snapshot, so `wrap codex --memory` without a full provider injection is still fully reversible. * New `_restore_codex_provider_config` helper and `unwrap codex` click command: * backup present → restore byte-for-byte and delete the backup; * backup absent but Headroom block present → strip the block and keep surrounding user content; * config contained only Headroom content → remove the file so Codex falls back to defaults; * nothing to undo → safe no-op. Codex is the only wrap target that modifies a persistent user config file: claude/aider/cursor/copilot all go through env vars or project- scoped files only, so this bug was unique to Codex. Tests: * `tests/test_cli/test_wrap_codex.py` adds 20 new cases covering the strip/snapshot helpers directly, round-trip idempotency of wrap → wrap → unwrap, handling of malformed prior configs, and end-to-end CliRunner invocations of `headroom wrap codex --prepare-only` / `headroom unwrap codex` against a temp `$HOME`. * All 153 existing `tests/test_cli/` tests continue to pass. Plugin manifest versions were re-synced from `pyproject.toml` (0.11.2) by the `sync-plugin-versions` pre-commit hook; the previous values (0.10.3) had drifted. Reported by @raenaryl in Discord on 0.6.3; confirmed still broken on current `main` (0.11.x). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:13:54 -05:00
"""Tests for `headroom wrap codex` and `headroom unwrap codex`.
These exercise the Codex-specific ``config.toml`` injection and restoration
helpers that route Codex through the Headroom proxy. They are deliberately
end-to-end-ish: the unit tests call the helpers directly against a temp
``$HOME``, and the integration tests invoke the real Click commands the same
way a user would from the shell.
"""
from __future__ import annotations
fix(codex): retag thread providers so history menu stays whole across the proxy boundary (#1034) ## Description Codex stamps every thread with the `model_provider` it ran under and filters its history/projects menu by the currently active provider set. When Headroom rewrites Codex's config to route through the custom `headroom` provider, threads created through Headroom are tagged `headroom` while native threads keep `openai` — so the two sets never appear in the same menu. The visible symptom: enabling Headroom appears to "lose" the entire native Codex history, and disabling it hides everything created while wrapped. This reconciles the thread tags in Codex's SQLite store alongside the existing config edits, so the menu stays whole across the proxy boundary in both directions: `openai -> headroom` on enable/wrap, `headroom -> openai` on revert/unwrap. Only rows matching the source provider are touched, so third-party providers (e.g. `anthropic`) are left alone. The provider key cannot be unified by config — Codex rejects naming a custom provider `openai` ("reserved built-in provider IDs") — so retagging the store is the only path. A DB-only retag is sufficient: resuming a retagged session still routes completions through the active provider; the rollout `.jsonl` files do not need rewriting. Every operation is best-effort: a missing store, a missing `threads` table, or a corrupt store is logged and skipped, never raised, so install/uninstall and wrap/unwrap never fail on account of the history menu. The store is WAL-mode, so the update succeeds even while Codex is running; the short busy timeout only covers a transient checkpoint lock. Closes # ## 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 - New `headroom/providers/codex/threads.py`: best-effort retag of Codex thread provider tags across both known stores (`<codex_home>/sqlite/state_5.sqlite` for the GUI and `<codex_home>/state_5.sqlite` for the CLI/TUI). `retag_to_headroom` / `retag_to_native` wrap the directional helper. `codex_home` is passed in by callers (never re-derived from `Path.home()`), so tests stay pointed at a temp dir. - `providers/codex/install.py`: `apply_provider_scope` calls `retag_to_headroom` after writing the provider block; `revert_provider_scope` calls `retag_to_native` after stripping it. - `cli/wrap.py`: `_inject_codex_provider_config` calls `retag_to_headroom`; `unwrap_codex` calls `retag_to_native` once the config restore reports a `restored`/`cleaned`/`removed` status. - Tests: `tests/test_provider_codex_threads.py` (retag direction, threads-table no-op, missing/corrupt store best-effort) and a wrap/unwrap round-trip integration test in `tests/test_cli/test_wrap_codex.py`. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ uv run --extra dev pytest tests/test_provider_codex_threads.py tests/test_cli/test_wrap_codex.py tests/test_install/test_providers.py -q ... 88 passed, 2 failed # The 2 failures are TestInjectAvoidsDuplicateTopLevelKeys::* — pre-existing on a clean # upstream/main checkout, unrelated to this change: they `import tomllib`, which is # stdlib only on Python 3.11+, and this environment runs Python 3.10.18. $ uv run --extra dev ruff check headroom/cli/wrap.py headroom/providers/codex/threads.py \ headroom/providers/codex/install.py tests/test_cli/test_wrap_codex.py tests/test_provider_codex_threads.py All checks passed! $ uv run --extra dev mypy headroom/providers/codex/threads.py headroom/providers/codex/install.py Success: no issues found in 2 source files ``` ## Real Behavior Proof - Environment: macOS, Codex GUI v148 + Codex CLI, Python 3.10.18. - Exact command / steps: The root cause and fix were confirmed live in the Headroom desktop app, which performs the identical SQLite retag. Connecting Codex to Headroom hid ~140 native (`openai`) threads from the history menu; running `UPDATE threads SET model_provider='headroom' WHERE model_provider='openai'` on the live store (`~/.codex/sqlite/state_5.sqlite`) made the full menu reappear, and resuming a retagged session still routed completions through the active provider. This Python port is a 1:1 of that logic, exercised by the unit + wrap/unwrap integration tests above. - Observed result: full Codex history menu restored across enable/disable; third-party provider rows untouched; the real `~/.codex` stores were snapshotted before/after the test run and were not mutated by the tests. - Not tested: an end-to-end `headroom wrap codex` run against a live Codex GUI in this CI environment (no Codex install here); covered instead by the integration test invoking the real `wrap`/`unwrap` Click commands against a temp `$HOME`. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A — behavior is in Codex's own history menu; covered by the proof above. ## Additional Notes - Documentation / CHANGELOG: N/A — internal behavior with no user-facing surface beyond the restored menu. - The 2 failing `TestInjectAvoidsDuplicateTopLevelKeys` tests are pre-existing on upstream/main and fail only because this environment runs Python 3.10 (no `tomllib`); they are unrelated to this change. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:13:21 +02:00
import sqlite3
fix(wrap): unwrap codex restores prior config.toml `headroom wrap codex` injects a `model_provider = "headroom"` block plus a `[model_providers.headroom]` table into `~/.codex/config.toml` so Codex routes both HTTP and WebSocket traffic through the proxy. The matching `unwrap codex` subcommand did not exist, so the injected block stayed in `config.toml` forever — the moment the proxy stopped, Codex (CLI and macOS app) started erroring with `Missing environment variable: OPENAI_API_KEY`, and users had to hand- edit the file to recover. Fix: * `_inject_codex_provider_config` now snapshots the pre-wrap file to `~/.codex/config.toml.headroom-backup` before the first modification and leaves that snapshot untouched on subsequent wrap runs. The injection is also rewritten to use two self-contained marker- delimited blocks (top-level key and provider table) so stripping them never consumes user content that sits between them. * `_inject_memory_mcp_config` takes the same snapshot, so `wrap codex --memory` without a full provider injection is still fully reversible. * New `_restore_codex_provider_config` helper and `unwrap codex` click command: * backup present → restore byte-for-byte and delete the backup; * backup absent but Headroom block present → strip the block and keep surrounding user content; * config contained only Headroom content → remove the file so Codex falls back to defaults; * nothing to undo → safe no-op. Codex is the only wrap target that modifies a persistent user config file: claude/aider/cursor/copilot all go through env vars or project- scoped files only, so this bug was unique to Codex. Tests: * `tests/test_cli/test_wrap_codex.py` adds 20 new cases covering the strip/snapshot helpers directly, round-trip idempotency of wrap → wrap → unwrap, handling of malformed prior configs, and end-to-end CliRunner invocations of `headroom wrap codex --prepare-only` / `headroom unwrap codex` against a temp `$HOME`. * All 153 existing `tests/test_cli/` tests continue to pass. Plugin manifest versions were re-synced from `pyproject.toml` (0.11.2) by the `sync-plugin-versions` pre-commit hook; the previous values (0.10.3) had drifted. Reported by @raenaryl in Discord on 0.6.3; confirmed still broken on current `main` (0.11.x). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:13:54 -05:00
from pathlib import Path
from unittest.mock import patch
import pytest
from click.testing import CliRunner
from headroom.cli import wrap as wrap_mod
from headroom.cli.main import main
def _set_test_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
home = str(tmp_path)
monkeypatch.setenv("HOME", home)
monkeypatch.setenv("USERPROFILE", home)
fix(codex): respect CODEX_HOME for wrap config (#731) > ⚠️ This PR was opened using Codex (gpt-5.5 on `xhigh`) Fixes #730. ## Summary - centralize Codex config path resolution in `headroom wrap codex` so it honors `CODEX_HOME` when set - make the Codex MCP registrar use `$CODEX_HOME/config.toml` instead of always writing to `~/.codex/config.toml` - route optional memory MCP and global Codex `AGENTS.md` injection through the same Codex home helper - make `headroom unwrap codex` print a warning, but still succeed, when `CODEX_HOME` is unset and the default Codex config has no Headroom markers - add regression coverage for provider injection, prepare-only wrapping, MCP registration under a custom Codex home, and the ambiguous unwrap warning - update `CHANGELOG.md` under `Unreleased > Bug Fixes` ## Real behavior proof Setup tested on: - Linux `7.0.10-2-cachyos` - Python 3.14.3 via `uv` - local fork branch `fix/codex-home` - custom Codex home created outside `~/.codex` Exact command run after the patch: ```bash tmp_home=$(mktemp -d) mkdir -p "$tmp_home/codex_custom" HOME="$tmp_home" USERPROFILE="$tmp_home" CODEX_HOME="$tmp_home/codex_custom" \ UV_SKIP_WHEEL_FILENAME_CHECK=1 PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 \ uv run --with fastapi --with uvicorn --with httpx --with websockets \ headroom wrap codex --no-context-tool --no-serena --prepare-only --port 8787 find "$tmp_home" -maxdepth 3 -type f -print | sort sed -n '1,140p' "$tmp_home/codex_custom/config.toml" test -e "$tmp_home/.codex/config.toml" && echo yes || echo no HOME="$tmp_home" USERPROFILE="$tmp_home" \ UV_SKIP_WHEEL_FILENAME_CHECK=1 PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 \ uv run --with fastapi --with uvicorn --with httpx --with websockets \ headroom unwrap codex --no-stop-proxy ``` After-fix evidence + observed result: Interactive check: - A full `CODEX_HOME="$HOME/.codex_p" headroom wrap codex --no-serena` launch was tested locally after the patch and worked as expected. - The prepare-only proof below shows the same config path behavior without requiring an interactive Codex session in CI/reviewer environments. ```text MCP retrieve tool: registered (restart OpenAI Codex CLI if it was already running) Codex config: injected Headroom provider (WS + HTTP) into /tmp/tmp.UPUvloGNYE/codex_custom/config.toml --- files --- /tmp/tmp.UPUvloGNYE/codex_custom/config.toml /tmp/tmp.UPUvloGNYE/codex_custom/config.toml.headroom-backup --- custom config --- # --- Headroom proxy (auto-injected by headroom wrap codex) --- model_provider = "headroom" openai_base_url = "http://127.0.0.1:8787/v1" # --- end Headroom --- # --- Headroom MCP server --- [mcp_servers.headroom] command = "headroom" args = ["mcp", "serve"] # --- end Headroom MCP server --- # --- Headroom proxy (auto-injected by headroom wrap codex) --- [model_providers.headroom] name = "OpenAI via Headroom proxy" base_url = "http://127.0.0.1:8787/v1" supports_websockets = true # --- end Headroom --- --- default config exists? --- no Warning: found no Headroom wrap markers in the default Codex config. If you wrapped Codex with CODEX_HOME, rerun unwrap with the same environment variable, e.g. CODEX_HOME=/path/to/codex-home headroom unwrap codex. Nothing to undo: .../.codex/config.toml has no Headroom wrap markers. ``` What I did not test: - Windows/macOS path behavior - full repository test suite, because this local Python 3.14 environment hits optional dependency/build constraints outside this patch ## Testing ```bash UV_SKIP_WHEEL_FILENAME_CHECK=1 PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 uv run --with pytest --with fastapi --with uvicorn --with httpx --with websockets pytest tests/test_mcp_registry/test_codex_registrar.py tests/test_cli/test_wrap_codex.py -q UV_SKIP_WHEEL_FILENAME_CHECK=1 PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 uv run --with ruff ruff check headroom/cli/wrap.py headroom/mcp_registry/codex.py tests/test_cli/test_wrap_codex.py tests/test_mcp_registry/test_codex_registrar.py UV_SKIP_WHEEL_FILENAME_CHECK=1 PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 uv run --with ruff ruff format --check headroom/cli/wrap.py headroom/mcp_registry/codex.py tests/test_cli/test_wrap_codex.py tests/test_mcp_registry/test_codex_registrar.py ``` Results: ```text 63 passed, 1 warning in 1.48s All checks passed! 4 files already formatted ``` Notes: - Python 3.14 required `PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1` for the editable build. - `uv` required `UV_SKIP_WHEEL_FILENAME_CHECK=1` because the existing `uv.lock` has a GitPython wheel filename/version mismatch.
2026-06-10 20:21:29 -03:00
monkeypatch.delenv("CODEX_HOME", raising=False)
fix(wrap): unwrap codex restores prior config.toml `headroom wrap codex` injects a `model_provider = "headroom"` block plus a `[model_providers.headroom]` table into `~/.codex/config.toml` so Codex routes both HTTP and WebSocket traffic through the proxy. The matching `unwrap codex` subcommand did not exist, so the injected block stayed in `config.toml` forever — the moment the proxy stopped, Codex (CLI and macOS app) started erroring with `Missing environment variable: OPENAI_API_KEY`, and users had to hand- edit the file to recover. Fix: * `_inject_codex_provider_config` now snapshots the pre-wrap file to `~/.codex/config.toml.headroom-backup` before the first modification and leaves that snapshot untouched on subsequent wrap runs. The injection is also rewritten to use two self-contained marker- delimited blocks (top-level key and provider table) so stripping them never consumes user content that sits between them. * `_inject_memory_mcp_config` takes the same snapshot, so `wrap codex --memory` without a full provider injection is still fully reversible. * New `_restore_codex_provider_config` helper and `unwrap codex` click command: * backup present → restore byte-for-byte and delete the backup; * backup absent but Headroom block present → strip the block and keep surrounding user content; * config contained only Headroom content → remove the file so Codex falls back to defaults; * nothing to undo → safe no-op. Codex is the only wrap target that modifies a persistent user config file: claude/aider/cursor/copilot all go through env vars or project- scoped files only, so this bug was unique to Codex. Tests: * `tests/test_cli/test_wrap_codex.py` adds 20 new cases covering the strip/snapshot helpers directly, round-trip idempotency of wrap → wrap → unwrap, handling of malformed prior configs, and end-to-end CliRunner invocations of `headroom wrap codex --prepare-only` / `headroom unwrap codex` against a temp `$HOME`. * All 153 existing `tests/test_cli/` tests continue to pass. Plugin manifest versions were re-synced from `pyproject.toml` (0.11.2) by the `sync-plugin-versions` pre-commit hook; the previous values (0.10.3) had drifted. Reported by @raenaryl in Discord on 0.6.3; confirmed still broken on current `main` (0.11.x). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:13:54 -05:00
@pytest.fixture
def runner() -> CliRunner:
return CliRunner()
# ---------------------------------------------------------------------------
# Unit tests: helpers operating on ~/.codex/config.toml
# ---------------------------------------------------------------------------
class TestStripCodexHeadroomBlocks:
"""Tests for the regex-based cleanup helper."""
def test_empty_content_returns_empty(self) -> None:
assert wrap_mod._strip_codex_headroom_blocks("") == ""
def test_returns_content_unchanged_when_no_markers(self) -> None:
original = '[profiles.default]\nmodel = "gpt-4o"\n'
cleaned = wrap_mod._strip_codex_headroom_blocks(original)
# Trailing whitespace normalization only — semantic content preserved.
assert 'model = "gpt-4o"' in cleaned
assert "[profiles.default]" in cleaned
def test_removes_complete_headroom_block(self) -> None:
wrapped = (
f"{wrap_mod._CODEX_TOP_LEVEL_MARKER}\n"
'model_provider = "headroom"\n'
"\n"
"[model_providers.headroom]\n"
'base_url = "http://127.0.0.1:8787/v1"\n'
f"{wrap_mod._CODEX_END_MARKER}\n"
)
assert wrap_mod._strip_codex_headroom_blocks(wrapped) == ""
def test_preserves_user_content_around_block(self) -> None:
user_pre = '[profiles.default]\nmodel = "gpt-4o"\n'
user_post = '[mcp_servers.foo]\ncommand = "echo"\n'
wrapped = (
f"{wrap_mod._CODEX_TOP_LEVEL_MARKER}\n"
'model_provider = "headroom"\n'
f"{wrap_mod._CODEX_END_MARKER}\n" + user_pre + "\n"
f"{wrap_mod._CODEX_TOP_LEVEL_MARKER}\n"
"[model_providers.headroom]\n"
'base_url = "http://127.0.0.1:8787/v1"\n'
f"{wrap_mod._CODEX_END_MARKER}\n" + user_post
)
cleaned = wrap_mod._strip_codex_headroom_blocks(wrapped)
assert wrap_mod._CODEX_TOP_LEVEL_MARKER not in cleaned
assert wrap_mod._CODEX_END_MARKER not in cleaned
assert 'model = "gpt-4o"' in cleaned
assert "[mcp_servers.foo]" in cleaned
def test_removes_stray_top_level_model_provider_line(self) -> None:
# Old wrap versions left `model_provider = "headroom"` outside markers.
content = 'foo = 1\nmodel_provider = "headroom"\nbar = 2\n'
cleaned = wrap_mod._strip_codex_headroom_blocks(content, remove_mcp=True)
fix(wrap): unwrap codex restores prior config.toml `headroom wrap codex` injects a `model_provider = "headroom"` block plus a `[model_providers.headroom]` table into `~/.codex/config.toml` so Codex routes both HTTP and WebSocket traffic through the proxy. The matching `unwrap codex` subcommand did not exist, so the injected block stayed in `config.toml` forever — the moment the proxy stopped, Codex (CLI and macOS app) started erroring with `Missing environment variable: OPENAI_API_KEY`, and users had to hand- edit the file to recover. Fix: * `_inject_codex_provider_config` now snapshots the pre-wrap file to `~/.codex/config.toml.headroom-backup` before the first modification and leaves that snapshot untouched on subsequent wrap runs. The injection is also rewritten to use two self-contained marker- delimited blocks (top-level key and provider table) so stripping them never consumes user content that sits between them. * `_inject_memory_mcp_config` takes the same snapshot, so `wrap codex --memory` without a full provider injection is still fully reversible. * New `_restore_codex_provider_config` helper and `unwrap codex` click command: * backup present → restore byte-for-byte and delete the backup; * backup absent but Headroom block present → strip the block and keep surrounding user content; * config contained only Headroom content → remove the file so Codex falls back to defaults; * nothing to undo → safe no-op. Codex is the only wrap target that modifies a persistent user config file: claude/aider/cursor/copilot all go through env vars or project- scoped files only, so this bug was unique to Codex. Tests: * `tests/test_cli/test_wrap_codex.py` adds 20 new cases covering the strip/snapshot helpers directly, round-trip idempotency of wrap → wrap → unwrap, handling of malformed prior configs, and end-to-end CliRunner invocations of `headroom wrap codex --prepare-only` / `headroom unwrap codex` against a temp `$HOME`. * All 153 existing `tests/test_cli/` tests continue to pass. Plugin manifest versions were re-synced from `pyproject.toml` (0.11.2) by the `sync-plugin-versions` pre-commit hook; the previous values (0.10.3) had drifted. Reported by @raenaryl in Discord on 0.6.3; confirmed still broken on current `main` (0.11.x). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:13:54 -05:00
assert 'model_provider = "headroom"' not in cleaned
assert "foo = 1" in cleaned
assert "bar = 2" in cleaned
def test_removes_codex_mcp_blocks(self) -> None:
content = (
'[profiles.default]\nmodel = "gpt-4o"\n\n'
f"{wrap_mod._CODEX_MCP_MARKER}\n"
"[mcp_servers.headroom]\n"
'command = "headroom"\n'
f"{wrap_mod._CODEX_MCP_END}\n\n"
2026-05-09 22:57:35 -07:00
"# --- Headroom MCP server: serena ---\n"
"[mcp_servers.serena]\n"
'command = "uvx"\n'
"# --- end Headroom MCP server: serena ---\n\n"
f"{wrap_mod._MEMORY_MCP_MARKER}\n"
"[mcp_servers.headroom_memory]\n"
'command = "python"\n'
f"{wrap_mod._MEMORY_MCP_END}\n"
)
cleaned = wrap_mod._strip_codex_headroom_blocks(content, remove_mcp=True)
assert "[mcp_servers.headroom]" not in cleaned
2026-05-09 22:57:35 -07:00
assert "[mcp_servers.serena]" not in cleaned
assert "[mcp_servers.headroom_memory]" not in cleaned
assert 'model = "gpt-4o"' in cleaned
fix(wrap): unwrap codex restores prior config.toml `headroom wrap codex` injects a `model_provider = "headroom"` block plus a `[model_providers.headroom]` table into `~/.codex/config.toml` so Codex routes both HTTP and WebSocket traffic through the proxy. The matching `unwrap codex` subcommand did not exist, so the injected block stayed in `config.toml` forever — the moment the proxy stopped, Codex (CLI and macOS app) started erroring with `Missing environment variable: OPENAI_API_KEY`, and users had to hand- edit the file to recover. Fix: * `_inject_codex_provider_config` now snapshots the pre-wrap file to `~/.codex/config.toml.headroom-backup` before the first modification and leaves that snapshot untouched on subsequent wrap runs. The injection is also rewritten to use two self-contained marker- delimited blocks (top-level key and provider table) so stripping them never consumes user content that sits between them. * `_inject_memory_mcp_config` takes the same snapshot, so `wrap codex --memory` without a full provider injection is still fully reversible. * New `_restore_codex_provider_config` helper and `unwrap codex` click command: * backup present → restore byte-for-byte and delete the backup; * backup absent but Headroom block present → strip the block and keep surrounding user content; * config contained only Headroom content → remove the file so Codex falls back to defaults; * nothing to undo → safe no-op. Codex is the only wrap target that modifies a persistent user config file: claude/aider/cursor/copilot all go through env vars or project- scoped files only, so this bug was unique to Codex. Tests: * `tests/test_cli/test_wrap_codex.py` adds 20 new cases covering the strip/snapshot helpers directly, round-trip idempotency of wrap → wrap → unwrap, handling of malformed prior configs, and end-to-end CliRunner invocations of `headroom wrap codex --prepare-only` / `headroom unwrap codex` against a temp `$HOME`. * All 153 existing `tests/test_cli/` tests continue to pass. Plugin manifest versions were re-synced from `pyproject.toml` (0.11.2) by the `sync-plugin-versions` pre-commit hook; the previous values (0.10.3) had drifted. Reported by @raenaryl in Discord on 0.6.3; confirmed still broken on current `main` (0.11.x). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:13:54 -05:00
class TestSnapshotCodexConfig:
"""Tests for ``_snapshot_codex_config_if_unwrapped``."""
def test_creates_backup_on_first_call(self, tmp_path: Path) -> None:
config_file = tmp_path / "config.toml"
backup_file = tmp_path / "config.toml.headroom-backup"
config_file.write_text('model = "gpt-4o"\n')
wrap_mod._snapshot_codex_config_if_unwrapped(config_file, backup_file)
assert backup_file.exists()
assert backup_file.read_text() == 'model = "gpt-4o"\n'
def test_does_not_overwrite_existing_backup(self, tmp_path: Path) -> None:
config_file = tmp_path / "config.toml"
backup_file = tmp_path / "config.toml.headroom-backup"
config_file.write_text("second-wrap content\n")
backup_file.write_text("original-pre-wrap content\n")
wrap_mod._snapshot_codex_config_if_unwrapped(config_file, backup_file)
# Backup must still contain the *original* pre-wrap content.
assert backup_file.read_text() == "original-pre-wrap content\n"
def test_no_backup_when_config_missing(self, tmp_path: Path) -> None:
config_file = tmp_path / "config.toml"
backup_file = tmp_path / "config.toml.headroom-backup"
wrap_mod._snapshot_codex_config_if_unwrapped(config_file, backup_file)
assert not backup_file.exists()
def test_no_backup_when_config_already_wrapped(self, tmp_path: Path) -> None:
config_file = tmp_path / "config.toml"
backup_file = tmp_path / "config.toml.headroom-backup"
config_file.write_text(
f"{wrap_mod._CODEX_TOP_LEVEL_MARKER}\n"
'model_provider = "headroom"\n'
f"{wrap_mod._CODEX_END_MARKER}\n"
)
wrap_mod._snapshot_codex_config_if_unwrapped(config_file, backup_file)
# Pre-wrap snapshot must never snapshot an already-wrapped file.
assert not backup_file.exists()
class TestInjectAndRestoreRoundTrip:
"""End-to-end wrap → unwrap cycle operating directly on a temp $HOME."""
def test_wrap_unwrap_restores_empty_state(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_set_test_home(monkeypatch, tmp_path)
config_file = tmp_path / ".codex" / "config.toml"
wrap_mod._inject_codex_provider_config(8787)
assert config_file.exists()
assert 'model_provider = "headroom"' in config_file.read_text()
status, _ = wrap_mod._restore_codex_provider_config()
# No prior config existed → the injected file is fully removed.
assert status == "removed"
assert not config_file.exists()
assert not (tmp_path / ".codex" / "config.toml.headroom-backup").exists()
fix(codex): respect CODEX_HOME for wrap config (#731) > ⚠️ This PR was opened using Codex (gpt-5.5 on `xhigh`) Fixes #730. ## Summary - centralize Codex config path resolution in `headroom wrap codex` so it honors `CODEX_HOME` when set - make the Codex MCP registrar use `$CODEX_HOME/config.toml` instead of always writing to `~/.codex/config.toml` - route optional memory MCP and global Codex `AGENTS.md` injection through the same Codex home helper - make `headroom unwrap codex` print a warning, but still succeed, when `CODEX_HOME` is unset and the default Codex config has no Headroom markers - add regression coverage for provider injection, prepare-only wrapping, MCP registration under a custom Codex home, and the ambiguous unwrap warning - update `CHANGELOG.md` under `Unreleased > Bug Fixes` ## Real behavior proof Setup tested on: - Linux `7.0.10-2-cachyos` - Python 3.14.3 via `uv` - local fork branch `fix/codex-home` - custom Codex home created outside `~/.codex` Exact command run after the patch: ```bash tmp_home=$(mktemp -d) mkdir -p "$tmp_home/codex_custom" HOME="$tmp_home" USERPROFILE="$tmp_home" CODEX_HOME="$tmp_home/codex_custom" \ UV_SKIP_WHEEL_FILENAME_CHECK=1 PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 \ uv run --with fastapi --with uvicorn --with httpx --with websockets \ headroom wrap codex --no-context-tool --no-serena --prepare-only --port 8787 find "$tmp_home" -maxdepth 3 -type f -print | sort sed -n '1,140p' "$tmp_home/codex_custom/config.toml" test -e "$tmp_home/.codex/config.toml" && echo yes || echo no HOME="$tmp_home" USERPROFILE="$tmp_home" \ UV_SKIP_WHEEL_FILENAME_CHECK=1 PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 \ uv run --with fastapi --with uvicorn --with httpx --with websockets \ headroom unwrap codex --no-stop-proxy ``` After-fix evidence + observed result: Interactive check: - A full `CODEX_HOME="$HOME/.codex_p" headroom wrap codex --no-serena` launch was tested locally after the patch and worked as expected. - The prepare-only proof below shows the same config path behavior without requiring an interactive Codex session in CI/reviewer environments. ```text MCP retrieve tool: registered (restart OpenAI Codex CLI if it was already running) Codex config: injected Headroom provider (WS + HTTP) into /tmp/tmp.UPUvloGNYE/codex_custom/config.toml --- files --- /tmp/tmp.UPUvloGNYE/codex_custom/config.toml /tmp/tmp.UPUvloGNYE/codex_custom/config.toml.headroom-backup --- custom config --- # --- Headroom proxy (auto-injected by headroom wrap codex) --- model_provider = "headroom" openai_base_url = "http://127.0.0.1:8787/v1" # --- end Headroom --- # --- Headroom MCP server --- [mcp_servers.headroom] command = "headroom" args = ["mcp", "serve"] # --- end Headroom MCP server --- # --- Headroom proxy (auto-injected by headroom wrap codex) --- [model_providers.headroom] name = "OpenAI via Headroom proxy" base_url = "http://127.0.0.1:8787/v1" supports_websockets = true # --- end Headroom --- --- default config exists? --- no Warning: found no Headroom wrap markers in the default Codex config. If you wrapped Codex with CODEX_HOME, rerun unwrap with the same environment variable, e.g. CODEX_HOME=/path/to/codex-home headroom unwrap codex. Nothing to undo: .../.codex/config.toml has no Headroom wrap markers. ``` What I did not test: - Windows/macOS path behavior - full repository test suite, because this local Python 3.14 environment hits optional dependency/build constraints outside this patch ## Testing ```bash UV_SKIP_WHEEL_FILENAME_CHECK=1 PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 uv run --with pytest --with fastapi --with uvicorn --with httpx --with websockets pytest tests/test_mcp_registry/test_codex_registrar.py tests/test_cli/test_wrap_codex.py -q UV_SKIP_WHEEL_FILENAME_CHECK=1 PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 uv run --with ruff ruff check headroom/cli/wrap.py headroom/mcp_registry/codex.py tests/test_cli/test_wrap_codex.py tests/test_mcp_registry/test_codex_registrar.py UV_SKIP_WHEEL_FILENAME_CHECK=1 PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 uv run --with ruff ruff format --check headroom/cli/wrap.py headroom/mcp_registry/codex.py tests/test_cli/test_wrap_codex.py tests/test_mcp_registry/test_codex_registrar.py ``` Results: ```text 63 passed, 1 warning in 1.48s All checks passed! 4 files already formatted ``` Notes: - Python 3.14 required `PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1` for the editable build. - `uv` required `UV_SKIP_WHEEL_FILENAME_CHECK=1` because the existing `uv.lock` has a GitPython wheel filename/version mismatch.
2026-06-10 20:21:29 -03:00
def test_wrap_unwrap_respects_codex_home(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_set_test_home(monkeypatch, tmp_path)
codex_home = tmp_path / "custom-codex-home"
monkeypatch.setenv("CODEX_HOME", str(codex_home))
config_file = codex_home / "config.toml"
wrap_mod._inject_codex_provider_config(8787)
assert config_file.exists()
assert 'model_provider = "headroom"' in config_file.read_text()
assert not (tmp_path / ".codex" / "config.toml").exists()
status, _ = wrap_mod._restore_codex_provider_config()
assert status == "removed"
assert not config_file.exists()
fix(wrap): unwrap codex restores prior config.toml `headroom wrap codex` injects a `model_provider = "headroom"` block plus a `[model_providers.headroom]` table into `~/.codex/config.toml` so Codex routes both HTTP and WebSocket traffic through the proxy. The matching `unwrap codex` subcommand did not exist, so the injected block stayed in `config.toml` forever — the moment the proxy stopped, Codex (CLI and macOS app) started erroring with `Missing environment variable: OPENAI_API_KEY`, and users had to hand- edit the file to recover. Fix: * `_inject_codex_provider_config` now snapshots the pre-wrap file to `~/.codex/config.toml.headroom-backup` before the first modification and leaves that snapshot untouched on subsequent wrap runs. The injection is also rewritten to use two self-contained marker- delimited blocks (top-level key and provider table) so stripping them never consumes user content that sits between them. * `_inject_memory_mcp_config` takes the same snapshot, so `wrap codex --memory` without a full provider injection is still fully reversible. * New `_restore_codex_provider_config` helper and `unwrap codex` click command: * backup present → restore byte-for-byte and delete the backup; * backup absent but Headroom block present → strip the block and keep surrounding user content; * config contained only Headroom content → remove the file so Codex falls back to defaults; * nothing to undo → safe no-op. Codex is the only wrap target that modifies a persistent user config file: claude/aider/cursor/copilot all go through env vars or project- scoped files only, so this bug was unique to Codex. Tests: * `tests/test_cli/test_wrap_codex.py` adds 20 new cases covering the strip/snapshot helpers directly, round-trip idempotency of wrap → wrap → unwrap, handling of malformed prior configs, and end-to-end CliRunner invocations of `headroom wrap codex --prepare-only` / `headroom unwrap codex` against a temp `$HOME`. * All 153 existing `tests/test_cli/` tests continue to pass. Plugin manifest versions were re-synced from `pyproject.toml` (0.11.2) by the `sync-plugin-versions` pre-commit hook; the previous values (0.10.3) had drifted. Reported by @raenaryl in Discord on 0.6.3; confirmed still broken on current `main` (0.11.x). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:13:54 -05:00
def test_wrap_unwrap_restores_prior_model_provider(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_set_test_home(monkeypatch, tmp_path)
config_dir = tmp_path / ".codex"
config_dir.mkdir()
config_file = config_dir / "config.toml"
original = (
'model_provider = "openai"\n'
"\n"
"[model_providers.openai]\n"
'name = "OpenAI"\n'
'base_url = "https://api.openai.com/v1"\n'
)
config_file.write_text(original)
wrap_mod._inject_codex_provider_config(8787)
wrapped = config_file.read_text()
assert 'model_provider = "headroom"' in wrapped
assert "[model_providers.headroom]" in wrapped
status, _ = wrap_mod._restore_codex_provider_config()
assert status == "restored"
assert config_file.read_text() == original
assert not (config_dir / "config.toml.headroom-backup").exists()
def test_wrap_is_idempotent(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
_set_test_home(monkeypatch, tmp_path)
config_dir = tmp_path / ".codex"
config_dir.mkdir()
config_file = config_dir / "config.toml"
original = '[profiles.default]\nmodel = "gpt-4o"\n'
config_file.write_text(original)
wrap_mod._inject_codex_provider_config(8787)
wrap_mod._inject_codex_provider_config(8787)
wrap_mod._inject_codex_provider_config(9999) # port change
content = config_file.read_text()
# Exactly two Headroom blocks — a top-level-key block and the
# provider-table block. Re-wrapping must not duplicate them.
assert content.count(wrap_mod._CODEX_TOP_LEVEL_MARKER) == 2
assert content.count(wrap_mod._CODEX_END_MARKER) == 2
# Latest port is honoured in both keys.
fix(wrap): unwrap codex restores prior config.toml `headroom wrap codex` injects a `model_provider = "headroom"` block plus a `[model_providers.headroom]` table into `~/.codex/config.toml` so Codex routes both HTTP and WebSocket traffic through the proxy. The matching `unwrap codex` subcommand did not exist, so the injected block stayed in `config.toml` forever — the moment the proxy stopped, Codex (CLI and macOS app) started erroring with `Missing environment variable: OPENAI_API_KEY`, and users had to hand- edit the file to recover. Fix: * `_inject_codex_provider_config` now snapshots the pre-wrap file to `~/.codex/config.toml.headroom-backup` before the first modification and leaves that snapshot untouched on subsequent wrap runs. The injection is also rewritten to use two self-contained marker- delimited blocks (top-level key and provider table) so stripping them never consumes user content that sits between them. * `_inject_memory_mcp_config` takes the same snapshot, so `wrap codex --memory` without a full provider injection is still fully reversible. * New `_restore_codex_provider_config` helper and `unwrap codex` click command: * backup present → restore byte-for-byte and delete the backup; * backup absent but Headroom block present → strip the block and keep surrounding user content; * config contained only Headroom content → remove the file so Codex falls back to defaults; * nothing to undo → safe no-op. Codex is the only wrap target that modifies a persistent user config file: claude/aider/cursor/copilot all go through env vars or project- scoped files only, so this bug was unique to Codex. Tests: * `tests/test_cli/test_wrap_codex.py` adds 20 new cases covering the strip/snapshot helpers directly, round-trip idempotency of wrap → wrap → unwrap, handling of malformed prior configs, and end-to-end CliRunner invocations of `headroom wrap codex --prepare-only` / `headroom unwrap codex` against a temp `$HOME`. * All 153 existing `tests/test_cli/` tests continue to pass. Plugin manifest versions were re-synced from `pyproject.toml` (0.11.2) by the `sync-plugin-versions` pre-commit hook; the previous values (0.10.3) had drifted. Reported by @raenaryl in Discord on 0.6.3; confirmed still broken on current `main` (0.11.x). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:13:54 -05:00
assert 'base_url = "http://127.0.0.1:9999/v1"' in content
assert 'openai_base_url = "http://127.0.0.1:9999/v1"' in content
fix(wrap): unwrap codex restores prior config.toml `headroom wrap codex` injects a `model_provider = "headroom"` block plus a `[model_providers.headroom]` table into `~/.codex/config.toml` so Codex routes both HTTP and WebSocket traffic through the proxy. The matching `unwrap codex` subcommand did not exist, so the injected block stayed in `config.toml` forever — the moment the proxy stopped, Codex (CLI and macOS app) started erroring with `Missing environment variable: OPENAI_API_KEY`, and users had to hand- edit the file to recover. Fix: * `_inject_codex_provider_config` now snapshots the pre-wrap file to `~/.codex/config.toml.headroom-backup` before the first modification and leaves that snapshot untouched on subsequent wrap runs. The injection is also rewritten to use two self-contained marker- delimited blocks (top-level key and provider table) so stripping them never consumes user content that sits between them. * `_inject_memory_mcp_config` takes the same snapshot, so `wrap codex --memory` without a full provider injection is still fully reversible. * New `_restore_codex_provider_config` helper and `unwrap codex` click command: * backup present → restore byte-for-byte and delete the backup; * backup absent but Headroom block present → strip the block and keep surrounding user content; * config contained only Headroom content → remove the file so Codex falls back to defaults; * nothing to undo → safe no-op. Codex is the only wrap target that modifies a persistent user config file: claude/aider/cursor/copilot all go through env vars or project- scoped files only, so this bug was unique to Codex. Tests: * `tests/test_cli/test_wrap_codex.py` adds 20 new cases covering the strip/snapshot helpers directly, round-trip idempotency of wrap → wrap → unwrap, handling of malformed prior configs, and end-to-end CliRunner invocations of `headroom wrap codex --prepare-only` / `headroom unwrap codex` against a temp `$HOME`. * All 153 existing `tests/test_cli/` tests continue to pass. Plugin manifest versions were re-synced from `pyproject.toml` (0.11.2) by the `sync-plugin-versions` pre-commit hook; the previous values (0.10.3) had drifted. Reported by @raenaryl in Discord on 0.6.3; confirmed still broken on current `main` (0.11.x). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:13:54 -05:00
assert 'base_url = "http://127.0.0.1:8787/v1"' not in content
assert 'openai_base_url = "http://127.0.0.1:8787/v1"' not in content
fix(wrap): unwrap codex restores prior config.toml `headroom wrap codex` injects a `model_provider = "headroom"` block plus a `[model_providers.headroom]` table into `~/.codex/config.toml` so Codex routes both HTTP and WebSocket traffic through the proxy. The matching `unwrap codex` subcommand did not exist, so the injected block stayed in `config.toml` forever — the moment the proxy stopped, Codex (CLI and macOS app) started erroring with `Missing environment variable: OPENAI_API_KEY`, and users had to hand- edit the file to recover. Fix: * `_inject_codex_provider_config` now snapshots the pre-wrap file to `~/.codex/config.toml.headroom-backup` before the first modification and leaves that snapshot untouched on subsequent wrap runs. The injection is also rewritten to use two self-contained marker- delimited blocks (top-level key and provider table) so stripping them never consumes user content that sits between them. * `_inject_memory_mcp_config` takes the same snapshot, so `wrap codex --memory` without a full provider injection is still fully reversible. * New `_restore_codex_provider_config` helper and `unwrap codex` click command: * backup present → restore byte-for-byte and delete the backup; * backup absent but Headroom block present → strip the block and keep surrounding user content; * config contained only Headroom content → remove the file so Codex falls back to defaults; * nothing to undo → safe no-op. Codex is the only wrap target that modifies a persistent user config file: claude/aider/cursor/copilot all go through env vars or project- scoped files only, so this bug was unique to Codex. Tests: * `tests/test_cli/test_wrap_codex.py` adds 20 new cases covering the strip/snapshot helpers directly, round-trip idempotency of wrap → wrap → unwrap, handling of malformed prior configs, and end-to-end CliRunner invocations of `headroom wrap codex --prepare-only` / `headroom unwrap codex` against a temp `$HOME`. * All 153 existing `tests/test_cli/` tests continue to pass. Plugin manifest versions were re-synced from `pyproject.toml` (0.11.2) by the `sync-plugin-versions` pre-commit hook; the previous values (0.10.3) had drifted. Reported by @raenaryl in Discord on 0.6.3; confirmed still broken on current `main` (0.11.x). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:13:54 -05:00
# User's original content is preserved.
assert 'model = "gpt-4o"' in content
status, _ = wrap_mod._restore_codex_provider_config()
assert status == "restored"
assert config_file.read_text() == original
def test_unwrap_is_noop_when_never_wrapped(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_set_test_home(monkeypatch, tmp_path)
status, _ = wrap_mod._restore_codex_provider_config()
assert status == "noop"
def test_unwrap_cleans_block_without_backup(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Handles crash-case where wrap injected but backup was wiped."""
_set_test_home(monkeypatch, tmp_path)
config_dir = tmp_path / ".codex"
config_dir.mkdir()
config_file = config_dir / "config.toml"
user_content = '[profiles.default]\nmodel = "gpt-4o"\n'
config_file.write_text(
user_content + f"{wrap_mod._CODEX_TOP_LEVEL_MARKER}\n"
'model_provider = "headroom"\n\n'
"[model_providers.headroom]\n"
'base_url = "http://127.0.0.1:8787/v1"\n'
f"{wrap_mod._CODEX_END_MARKER}\n"
)
status, _ = wrap_mod._restore_codex_provider_config()
assert status == "cleaned"
cleaned = config_file.read_text()
assert wrap_mod._CODEX_TOP_LEVEL_MARKER not in cleaned
assert wrap_mod._CODEX_END_MARKER not in cleaned
assert 'model_provider = "headroom"' not in cleaned
assert 'model = "gpt-4o"' in cleaned
def test_unwrap_without_backup_removes_provider_and_mcp_blocks(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_set_test_home(monkeypatch, tmp_path)
config_dir = tmp_path / ".codex"
config_dir.mkdir()
config_file = config_dir / "config.toml"
config_file.write_text(
'[profiles.default]\nmodel = "gpt-4o"\n\n'
f"{wrap_mod._CODEX_TOP_LEVEL_MARKER}\n"
'model_provider = "headroom"\n'
f"{wrap_mod._CODEX_END_MARKER}\n\n"
f"{wrap_mod._CODEX_MCP_MARKER}\n"
"[mcp_servers.headroom]\n"
'command = "headroom"\n'
f"{wrap_mod._CODEX_MCP_END}\n\n"
f"{wrap_mod._MEMORY_MCP_MARKER}\n"
"[mcp_servers.headroom_memory]\n"
'command = "python"\n'
f"{wrap_mod._MEMORY_MCP_END}\n"
)
status, _ = wrap_mod._restore_codex_provider_config()
assert status == "cleaned"
cleaned = config_file.read_text()
assert 'model = "gpt-4o"' in cleaned
assert "headroom" not in cleaned
fix(wrap): unwrap codex restores prior config.toml `headroom wrap codex` injects a `model_provider = "headroom"` block plus a `[model_providers.headroom]` table into `~/.codex/config.toml` so Codex routes both HTTP and WebSocket traffic through the proxy. The matching `unwrap codex` subcommand did not exist, so the injected block stayed in `config.toml` forever — the moment the proxy stopped, Codex (CLI and macOS app) started erroring with `Missing environment variable: OPENAI_API_KEY`, and users had to hand- edit the file to recover. Fix: * `_inject_codex_provider_config` now snapshots the pre-wrap file to `~/.codex/config.toml.headroom-backup` before the first modification and leaves that snapshot untouched on subsequent wrap runs. The injection is also rewritten to use two self-contained marker- delimited blocks (top-level key and provider table) so stripping them never consumes user content that sits between them. * `_inject_memory_mcp_config` takes the same snapshot, so `wrap codex --memory` without a full provider injection is still fully reversible. * New `_restore_codex_provider_config` helper and `unwrap codex` click command: * backup present → restore byte-for-byte and delete the backup; * backup absent but Headroom block present → strip the block and keep surrounding user content; * config contained only Headroom content → remove the file so Codex falls back to defaults; * nothing to undo → safe no-op. Codex is the only wrap target that modifies a persistent user config file: claude/aider/cursor/copilot all go through env vars or project- scoped files only, so this bug was unique to Codex. Tests: * `tests/test_cli/test_wrap_codex.py` adds 20 new cases covering the strip/snapshot helpers directly, round-trip idempotency of wrap → wrap → unwrap, handling of malformed prior configs, and end-to-end CliRunner invocations of `headroom wrap codex --prepare-only` / `headroom unwrap codex` against a temp `$HOME`. * All 153 existing `tests/test_cli/` tests continue to pass. Plugin manifest versions were re-synced from `pyproject.toml` (0.11.2) by the `sync-plugin-versions` pre-commit hook; the previous values (0.10.3) had drifted. Reported by @raenaryl in Discord on 0.6.3; confirmed still broken on current `main` (0.11.x). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:13:54 -05:00
def test_unwrap_handles_malformed_prior_config(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Unwrap preserves backup content verbatim — TOML validity isn't required."""
_set_test_home(monkeypatch, tmp_path)
config_dir = tmp_path / ".codex"
config_dir.mkdir()
config_file = config_dir / "config.toml"
malformed = 'this is not valid toml ][ "" \x00\n'
config_file.write_text(malformed)
wrap_mod._inject_codex_provider_config(8787)
status, _ = wrap_mod._restore_codex_provider_config()
assert status == "restored"
assert config_file.read_text() == malformed
fix(codex): retag thread providers so history menu stays whole across the proxy boundary (#1034) ## Description Codex stamps every thread with the `model_provider` it ran under and filters its history/projects menu by the currently active provider set. When Headroom rewrites Codex's config to route through the custom `headroom` provider, threads created through Headroom are tagged `headroom` while native threads keep `openai` — so the two sets never appear in the same menu. The visible symptom: enabling Headroom appears to "lose" the entire native Codex history, and disabling it hides everything created while wrapped. This reconciles the thread tags in Codex's SQLite store alongside the existing config edits, so the menu stays whole across the proxy boundary in both directions: `openai -> headroom` on enable/wrap, `headroom -> openai` on revert/unwrap. Only rows matching the source provider are touched, so third-party providers (e.g. `anthropic`) are left alone. The provider key cannot be unified by config — Codex rejects naming a custom provider `openai` ("reserved built-in provider IDs") — so retagging the store is the only path. A DB-only retag is sufficient: resuming a retagged session still routes completions through the active provider; the rollout `.jsonl` files do not need rewriting. Every operation is best-effort: a missing store, a missing `threads` table, or a corrupt store is logged and skipped, never raised, so install/uninstall and wrap/unwrap never fail on account of the history menu. The store is WAL-mode, so the update succeeds even while Codex is running; the short busy timeout only covers a transient checkpoint lock. Closes # ## 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 - New `headroom/providers/codex/threads.py`: best-effort retag of Codex thread provider tags across both known stores (`<codex_home>/sqlite/state_5.sqlite` for the GUI and `<codex_home>/state_5.sqlite` for the CLI/TUI). `retag_to_headroom` / `retag_to_native` wrap the directional helper. `codex_home` is passed in by callers (never re-derived from `Path.home()`), so tests stay pointed at a temp dir. - `providers/codex/install.py`: `apply_provider_scope` calls `retag_to_headroom` after writing the provider block; `revert_provider_scope` calls `retag_to_native` after stripping it. - `cli/wrap.py`: `_inject_codex_provider_config` calls `retag_to_headroom`; `unwrap_codex` calls `retag_to_native` once the config restore reports a `restored`/`cleaned`/`removed` status. - Tests: `tests/test_provider_codex_threads.py` (retag direction, threads-table no-op, missing/corrupt store best-effort) and a wrap/unwrap round-trip integration test in `tests/test_cli/test_wrap_codex.py`. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ uv run --extra dev pytest tests/test_provider_codex_threads.py tests/test_cli/test_wrap_codex.py tests/test_install/test_providers.py -q ... 88 passed, 2 failed # The 2 failures are TestInjectAvoidsDuplicateTopLevelKeys::* — pre-existing on a clean # upstream/main checkout, unrelated to this change: they `import tomllib`, which is # stdlib only on Python 3.11+, and this environment runs Python 3.10.18. $ uv run --extra dev ruff check headroom/cli/wrap.py headroom/providers/codex/threads.py \ headroom/providers/codex/install.py tests/test_cli/test_wrap_codex.py tests/test_provider_codex_threads.py All checks passed! $ uv run --extra dev mypy headroom/providers/codex/threads.py headroom/providers/codex/install.py Success: no issues found in 2 source files ``` ## Real Behavior Proof - Environment: macOS, Codex GUI v148 + Codex CLI, Python 3.10.18. - Exact command / steps: The root cause and fix were confirmed live in the Headroom desktop app, which performs the identical SQLite retag. Connecting Codex to Headroom hid ~140 native (`openai`) threads from the history menu; running `UPDATE threads SET model_provider='headroom' WHERE model_provider='openai'` on the live store (`~/.codex/sqlite/state_5.sqlite`) made the full menu reappear, and resuming a retagged session still routed completions through the active provider. This Python port is a 1:1 of that logic, exercised by the unit + wrap/unwrap integration tests above. - Observed result: full Codex history menu restored across enable/disable; third-party provider rows untouched; the real `~/.codex` stores were snapshotted before/after the test run and were not mutated by the tests. - Not tested: an end-to-end `headroom wrap codex` run against a live Codex GUI in this CI environment (no Codex install here); covered instead by the integration test invoking the real `wrap`/`unwrap` Click commands against a temp `$HOME`. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A — behavior is in Codex's own history menu; covered by the proof above. ## Additional Notes - Documentation / CHANGELOG: N/A — internal behavior with no user-facing surface beyond the restored menu. - The 2 failing `TestInjectAvoidsDuplicateTopLevelKeys` tests are pre-existing on upstream/main and fail only because this environment runs Python 3.10 (no `tomllib`); they are unrelated to this change. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:13:21 +02:00
# ---------------------------------------------------------------------------
# Thread retag: wrap pulls native threads into the headroom menu, unwrap hands
# them back, so the Codex history list stays whole across the proxy boundary.
# ---------------------------------------------------------------------------
class TestWrapRetagsThreadProviders:
"""``wrap codex`` retags ``openai`` threads to ``headroom`` and back."""
@staticmethod
def _seed_threads(db: Path, rows: list[tuple[str, str]]) -> None:
db.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db))
try:
conn.execute("CREATE TABLE threads (id TEXT PRIMARY KEY, model_provider TEXT NOT NULL)")
conn.executemany("INSERT INTO threads (id, model_provider) VALUES (?, ?)", rows)
conn.commit()
finally:
conn.close()
@staticmethod
def _count(db: Path, provider: str) -> int:
conn = sqlite3.connect(str(db))
try:
(n,) = conn.execute(
"SELECT COUNT(*) FROM threads WHERE model_provider = ?", (provider,)
).fetchone()
return n
finally:
conn.close()
def test_wrap_unwrap_round_trips_thread_providers(
self, runner: CliRunner, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_set_test_home(monkeypatch, tmp_path)
gui_db = tmp_path / ".codex" / "sqlite" / "state_5.sqlite"
cli_db = tmp_path / ".codex" / "state_5.sqlite"
self._seed_threads(gui_db, [("a", "openai"), ("b", "headroom"), ("c", "anthropic")])
self._seed_threads(cli_db, [("d", "openai")])
with patch("headroom.cli.wrap._ensure_rtk_binary", return_value=None):
wrap_result = runner.invoke(main, ["wrap", "codex", "--prepare-only", "--port", "8787"])
assert wrap_result.exit_code == 0, wrap_result.output
# Native threads are now visible under the headroom provider menu;
# third-party providers are left untouched.
assert self._count(gui_db, "headroom") == 2
assert self._count(gui_db, "openai") == 0
assert self._count(gui_db, "anthropic") == 1
assert self._count(cli_db, "headroom") == 1
with patch("headroom.cli.wrap._stop_local_proxy_for_unwrap", return_value="stopped"):
unwrap_result = runner.invoke(main, ["unwrap", "codex", "--port", "8787"])
assert unwrap_result.exit_code == 0, unwrap_result.output
# Back to native so the unproxied Codex menu is whole again.
assert self._count(gui_db, "openai") == 2
assert self._count(gui_db, "headroom") == 0
assert self._count(gui_db, "anthropic") == 1
assert self._count(cli_db, "openai") == 1
# ---------------------------------------------------------------------------
# Subscription routing: openai_base_url intercepts ChatGPT plan traffic
# ---------------------------------------------------------------------------
class TestSubscriptionRouting:
"""Codex subscription (ChatGPT plan) bypasses OPENAI_BASE_URL and the
custom model_provider; it uses the built-in ``openai`` provider whose
base_url defaults to ``https://chatgpt.com/backend-api/codex``.
Setting ``openai_base_url`` overrides that default for all auth modes."""
def test_inject_writes_openai_base_url(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_set_test_home(monkeypatch, tmp_path)
wrap_mod._inject_codex_provider_config(8787)
content = (tmp_path / ".codex" / "config.toml").read_text()
assert 'openai_base_url = "http://127.0.0.1:8787/v1"' in content
fix(codex): poll /wham/usage for subscription limits (handshake no longer sends x-codex-* headers) (#924) ## Description Codex's subscription usage window (primary/secondary rate-limit gauges) stopped populating for ChatGPT-OAuth sessions. This PR restores it by polling Codex's dedicated usage endpoint instead of relying on response headers that are no longer sent. ### Why the previous approach no longer works The existing code populates `CodexRateLimitState` from `x-codex-*` rate-limit headers captured on the `/v1/responses` WebSocket handshake (`update_from_headers` at WS accept). That worked when OpenAI returned `x-codex-primary-used-percent`, `x-codex-primary-window-minutes`, etc. on the handshake response. OpenAI has since stopped sending those headers on the ChatGPT WebSocket handshake. I confirmed this by faithfully replaying a real Plus-account handshake (both `prewarm` and regular `request_kind`): no `x-codex-*` headers come back on either. This matches OpenAI's own move to a dedicated usage endpoint (`GET /backend-api/codex/usage` in CodexApi mode) and reports such as openai/codex#14728. So `update_from_headers` now runs on every accept but finds nothing to parse, and the window silently stays empty. The headers aren't coming back, so there is nothing to fix in the parsing path. The data now lives behind a request we have to make ourselves. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `subscription/codex_rate_limits.py`: - `parse_codex_usage_payload()` / `update_from_usage_payload()` — map the `GET /backend-api/wham/usage` JSON (`rate_limit.primary_window` / `secondary_window` with `used_percent`, `limit_window_seconds`, `reset_at`; `credits`; `rate_limit_reached_type`) into the existing `CodexRateLimitState`. `limit_window_seconds` is converted to window-minutes with the same round-up codex-rs uses (`(secs + 59) // 60`). - `maybe_schedule_usage_poll()` — fire-and-forget, throttled to one request per 60s, scoped to ChatGPT sessions (requires both a Bearer token and `ChatGPT-Account-Id`; API-key traffic is skipped). Uses an in-flight guard so concurrent accepts don't stack polls. Endpoint is overridable via `HEADROOM_CODEX_USAGE_URL`. - `proxy/handlers/openai.py`: - At the Codex WS accept site, after the now-usually-empty `update_from_headers` block, schedule the usage poll. Wrapped in `contextlib.suppress` and fully non-blocking so it can never delay or fail the WebSocket accept. The old header-capture path is intentionally left in place as a no-cost fallback in case OpenAI restores the headers. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed (live `/wham/usage` replay against a Plus account returned HTTP 200 with the expected schema; payload fixture in tests mirrors that real shape) ## Test Output ``` $ uv run pytest tests/test_codex_rate_limits.py -q ........................................ [100%] 41 passed in 0.16s $ uv run ruff check headroom/subscription/codex_rate_limits.py headroom/proxy/handlers/openai.py tests/test_codex_rate_limits.py All checks passed! $ uv run mypy headroom/subscription/codex_rate_limits.py Success: no issues found in 1 source file ``` ## Additional Notes - New tests cover: full-payload mapping, window-minutes round-up, credits balance kept only when `has_credits`, promo object vs string, empty payload returns `None`, missing `used_percent` skipped, header-gating (requires Bearer + account-id), poll throttling, and no-event-loop safety. - Scoping to `ChatGPT-Account-Id` keeps the poll off API-key traffic, and the 60s throttle plus in-flight guard bound it to at most one lightweight GET per minute per running proxy. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 00:03:14 +02:00
def test_inject_emits_requires_openai_auth_for_chatgpt(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_set_test_home(monkeypatch, tmp_path)
config_dir = tmp_path / ".codex"
config_dir.mkdir()
(config_dir / "auth.json").write_text('{"auth_mode": "chatgpt"}', encoding="utf-8")
wrap_mod._inject_codex_provider_config(8787)
assert "requires_openai_auth = true" in (config_dir / "config.toml").read_text()
def test_inject_omits_requires_openai_auth_for_api_key(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_set_test_home(monkeypatch, tmp_path)
config_dir = tmp_path / ".codex"
config_dir.mkdir()
(config_dir / "auth.json").write_text('{"auth_mode": "apikey"}', encoding="utf-8")
wrap_mod._inject_codex_provider_config(8787)
assert "requires_openai_auth" not in (config_dir / "config.toml").read_text()
def test_openai_base_url_port_updates_on_rewrap(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_set_test_home(monkeypatch, tmp_path)
wrap_mod._inject_codex_provider_config(8787)
wrap_mod._inject_codex_provider_config(9999)
content = (tmp_path / ".codex" / "config.toml").read_text()
assert 'openai_base_url = "http://127.0.0.1:9999/v1"' in content
assert 'openai_base_url = "http://127.0.0.1:8787/v1"' not in content
def test_openai_base_url_removed_on_unwrap(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_set_test_home(monkeypatch, tmp_path)
config_dir = tmp_path / ".codex"
config_dir.mkdir()
config_file = config_dir / "config.toml"
original = '[profiles.default]\nmodel = "gpt-4o"\n'
config_file.write_text(original)
wrap_mod._inject_codex_provider_config(8787)
assert 'openai_base_url = "http://127.0.0.1:8787/v1"' in config_file.read_text()
wrap_mod._restore_codex_provider_config()
assert config_file.read_text() == original
def test_strip_cleans_orphaned_openai_base_url(self) -> None:
"""Safety net: orphaned openai_base_url lines are cleaned up."""
content = (
'[profiles.default]\nmodel = "gpt-4o"\nopenai_base_url = "http://127.0.0.1:8787/v1"\n'
)
cleaned = wrap_mod._strip_codex_headroom_blocks(content)
assert "openai_base_url" not in cleaned
assert 'model = "gpt-4o"' in cleaned
def test_no_env_key_in_injected_provider(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""env_key must be absent so Codex doesn't require OPENAI_API_KEY.
Codex treats env_key as a hard requirement if the env var is missing
it throws "Missing environment variable" at startup. Subscription
(ChatGPT Plus) users don't have OPENAI_API_KEY set, so injecting
env_key breaks them (issue #393).
"""
_set_test_home(monkeypatch, tmp_path)
wrap_mod._inject_codex_provider_config(8787)
content = (tmp_path / ".codex" / "config.toml").read_text()
assert "env_key" not in content
fix(wrap): avoid duplicate top-level keys when injecting codex provider (#884) ## Description `_inject_codex_provider_config` in `headroom/cli/wrap.py` unconditionally prepended a top-level block to `~/.codex/config.toml`: ```toml # --- Headroom proxy (auto-injected by headroom wrap codex) --- model_provider = "headroom" openai_base_url = "http://127.0.0.1:8787/v1" # --- end Headroom --- ``` If the user already had a top-level `model_provider` (or `openai_base_url`), the result was two top-level keys with the same name. That violates the TOML spec, and Codex refuses to start with `duplicate key`. This change makes the injector rewrite any pre-existing top-level `model_provider` / `openai_base_url` in place to the headroom values (keeping the user's original value in a `# was: …` trailing comment) and only emit the marker-delimited top-level block for keys the user has not declared. The pre-wrap snapshot mechanism is unchanged, so `headroom unwrap codex` still restores the file byte-for-byte. Closes #883 ## 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 - `headroom/cli/wrap.py` - New helper `_redirect_existing_top_level_keys(content, port)`: rewrites existing top-level `model_provider` / `openai_base_url` lines to the headroom values and preserves the previous value in a trailing `# was: …` comment. - New helper `_has_redirectable_top_level_key(content, key)`: cheap predicate for the two redirectable keys. - New helper `_build_top_level_block(user_content)`: emits a marker-delimited block containing only the redirectable keys the user has **not** already declared (declared ones are rewritten in place instead, avoiding the TOML duplicate-key error). - `_inject_codex_provider_config` now rewrites declared keys in place and only prepends the marker block for the remaining keys; `requires_openai_auth` handling (#406) is preserved. - `tests/test_cli/test_wrap_codex.py` - New class `TestInjectAvoidsDuplicateTopLevelKeys` (TOML-validity after wrap on a config already declaring a provider, original-value preservation in a `# was:` comment, idempotent re-wrap with a port change, marker-block fallback on an empty file, snapshot-based unwrap restoration). The TOML-validity test parses the wrapped file with `tomllib.loads`, which fails before the fix and passes after. - `CHANGELOG.md` - Added entry under `## Unreleased` → `### Bug Fixes`. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom --ignore-missing-imports`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ uv run pytest tests/test_cli/test_wrap_codex.py -q ======================== 52 passed, 1 warning in 5.28s ========================= $ uv run ruff check headroom/cli/wrap.py tests/test_cli/test_wrap_codex.py All checks passed! $ uv run ruff format --check headroom/cli/wrap.py tests/test_cli/test_wrap_codex.py 2 files already formatted $ mypy headroom --ignore-missing-imports Success: no issues found in 358 source files ``` ## Real Behavior Proof - Environment: macOS 24.6.0, Python 3.13.3, branch `fix/wrap-codex-no-duplicate-keys` rebased onto upstream `main`, Codex CLI config at `~/.codex/config.toml`. - Exact command / steps: Seed a user config matching the bug report (`model_provider = "ccswitch"` + `openai_base_url = "…"` + `[model_providers.ccswitch]`), run the same path `headroom wrap codex` takes (`_inject_codex_provider_config(8787)`), then parse the result with `tomllib.loads(...)` and run `headroom unwrap codex`. - Observed result: On patched code the wrapped `config.toml` parses cleanly — exactly one `model_provider` and one `openai_base_url` remain (the user's prior value preserved in a `# was: …` comment) and the `[model_providers.headroom]` table is present; `unwrap` restores the file byte-for-byte. On the unpatched code the same file raises `tomllib.TOMLDecodeError` (duplicate key). Full suite: 52 passed, ruff lint + format clean (see Test Output). - Not tested: End-to-end launch of the Codex CLI against a live proxy (no Codex e2e harness in this sandbox); Windows / `$CODEX_HOME` override paths (covered only by existing tests). ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A — CLI/config change, no UI. ## Additional Notes `ruff check .`, `ruff format --check .`, and `mypy headroom --ignore-missing-imports` all pass on the rebased branch. The diff stays narrow: `headroom/cli/wrap.py` + its tests + a CHANGELOG entry. Co-authored-by: wangxiangyu7 <wangxiangyu7@lixiang.com>
2026-06-16 00:04:29 +08:00
class TestInjectAvoidsDuplicateTopLevelKeys:
"""Wrap must not produce a TOML-validity-breaking duplicate-key error.
Codex's ``config.toml`` is parsed strictly: two top-level
``model_provider = `` (or two ``openai_base_url = ``) declarations
cause ``codex`` to refuse to start with
``Error loading config.toml: : :1: duplicate key``. The injector
used to unconditionally prepend a top-level block, breaking any user
who had already configured their own provider (e.g. ``ccswitch``).
"""
def test_inject_does_not_create_duplicate_model_provider(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
import tomllib # Python 3.11+ stdlib
_set_test_home(monkeypatch, tmp_path)
config_dir = tmp_path / ".codex"
config_dir.mkdir()
config_file = config_dir / "config.toml"
config_file.write_text(
'model_provider = "ccswitch"\n'
'openai_base_url = "http://llm-gateway-proxy/v1"\n'
'model = "azure-gpt-5_5"\n'
"\n"
"[model_providers.ccswitch]\n"
'name = "OpenAI"\n'
'base_url = "http://llm-gateway-proxy/v1"\n'
'wire_api = "responses"\n'
)
wrap_mod._inject_codex_provider_config(8787)
content = config_file.read_text()
# The wrapped file must be TOML-parseable — duplicate keys were
# the failure mode the user reported.
tomllib.loads(content)
# No duplicate top-level key for either redirectable key.
assert content.count("model_provider =") == 1
assert content.count("openai_base_url =") == 1
# And the rewritten values are the headroom ones.
assert 'model_provider = "headroom"' in content
assert 'openai_base_url = "http://127.0.0.1:8787/v1"' in content
@pytest.mark.parametrize("blank", ["", " ", "\n\t\n"])
def test_redirect_existing_top_level_keys_noop_on_blank(self, blank: str) -> None:
# No redirectable keys to rewrite in blank/whitespace content — the
# helper returns it unchanged so the caller falls back to prepending
# the marker-delimited top-level block.
assert wrap_mod._redirect_existing_top_level_keys(blank, 8787) == blank
def test_inject_preserves_user_value_in_trailing_comment(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_set_test_home(monkeypatch, tmp_path)
config_dir = tmp_path / ".codex"
config_dir.mkdir()
config_file = config_dir / "config.toml"
config_file.write_text(
'model_provider = "ccswitch"\nopenai_base_url = "http://llm-gateway-proxy/v1"\n'
)
wrap_mod._inject_codex_provider_config(8787)
content = config_file.read_text()
# Original value kept in a comment so the user can recover it.
# The comment intentionally drops the surrounding quotes — the
# value is a single TOML string and the comment is human-facing.
assert "was: ccswitch" in content
assert "was: http://llm-gateway-proxy/v1" in content
def test_inject_rewrap_updates_existing_redirected_keys(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Idempotent re-wrap on a config that already has top-level keys."""
import tomllib
_set_test_home(monkeypatch, tmp_path)
config_dir = tmp_path / ".codex"
config_dir.mkdir()
config_file = config_dir / "config.toml"
config_file.write_text('model_provider = "ccswitch"\n')
wrap_mod._inject_codex_provider_config(8787)
wrap_mod._inject_codex_provider_config(9999) # port change
content = config_file.read_text()
tomllib.loads(content)
assert content.count("model_provider =") == 1
assert 'model_provider = "headroom"' in content
# Port updated in the openai_base_url we injected.
assert 'openai_base_url = "http://127.0.0.1:9999/v1"' in content
assert 'openai_base_url = "http://127.0.0.1:8787/v1"' not in content
def test_inject_empty_file_still_uses_marker_block(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""No existing top-level keys → fall back to the marker-delimited block."""
_set_test_home(monkeypatch, tmp_path)
wrap_mod._inject_codex_provider_config(8787)
content = (tmp_path / ".codex" / "config.toml").read_text()
assert wrap_mod._CODEX_TOP_LEVEL_MARKER in content
assert 'model_provider = "headroom"' in content
assert 'openai_base_url = "http://127.0.0.1:8787/v1"' in content
assert "[model_providers.headroom]" in content
def test_unwrap_restores_prior_model_provider_after_rewrite(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""The snapshot mechanism must still restore the pre-wrap state byte-for-byte."""
_set_test_home(monkeypatch, tmp_path)
config_dir = tmp_path / ".codex"
config_dir.mkdir()
config_file = config_dir / "config.toml"
original = 'model_provider = "ccswitch"\nopenai_base_url = "http://llm-gateway-proxy/v1"\n'
config_file.write_text(original)
wrap_mod._inject_codex_provider_config(8787)
status, _ = wrap_mod._restore_codex_provider_config()
assert status == "restored"
assert config_file.read_text() == original
fix(wrap): unwrap codex restores prior config.toml `headroom wrap codex` injects a `model_provider = "headroom"` block plus a `[model_providers.headroom]` table into `~/.codex/config.toml` so Codex routes both HTTP and WebSocket traffic through the proxy. The matching `unwrap codex` subcommand did not exist, so the injected block stayed in `config.toml` forever — the moment the proxy stopped, Codex (CLI and macOS app) started erroring with `Missing environment variable: OPENAI_API_KEY`, and users had to hand- edit the file to recover. Fix: * `_inject_codex_provider_config` now snapshots the pre-wrap file to `~/.codex/config.toml.headroom-backup` before the first modification and leaves that snapshot untouched on subsequent wrap runs. The injection is also rewritten to use two self-contained marker- delimited blocks (top-level key and provider table) so stripping them never consumes user content that sits between them. * `_inject_memory_mcp_config` takes the same snapshot, so `wrap codex --memory` without a full provider injection is still fully reversible. * New `_restore_codex_provider_config` helper and `unwrap codex` click command: * backup present → restore byte-for-byte and delete the backup; * backup absent but Headroom block present → strip the block and keep surrounding user content; * config contained only Headroom content → remove the file so Codex falls back to defaults; * nothing to undo → safe no-op. Codex is the only wrap target that modifies a persistent user config file: claude/aider/cursor/copilot all go through env vars or project- scoped files only, so this bug was unique to Codex. Tests: * `tests/test_cli/test_wrap_codex.py` adds 20 new cases covering the strip/snapshot helpers directly, round-trip idempotency of wrap → wrap → unwrap, handling of malformed prior configs, and end-to-end CliRunner invocations of `headroom wrap codex --prepare-only` / `headroom unwrap codex` against a temp `$HOME`. * All 153 existing `tests/test_cli/` tests continue to pass. Plugin manifest versions were re-synced from `pyproject.toml` (0.11.2) by the `sync-plugin-versions` pre-commit hook; the previous values (0.10.3) had drifted. Reported by @raenaryl in Discord on 0.6.3; confirmed still broken on current `main` (0.11.x). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:13:54 -05:00
# ---------------------------------------------------------------------------
# Integration tests: full `headroom wrap codex` / `headroom unwrap codex`
# ---------------------------------------------------------------------------
def test_wrap_codex_prepare_only_creates_backup_and_config(
runner: CliRunner, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_set_test_home(monkeypatch, tmp_path)
config_file = tmp_path / ".codex" / "config.toml"
config_file.parent.mkdir(parents=True)
original = 'model_provider = "openai"\n'
config_file.write_text(original)
with patch("headroom.cli.wrap._ensure_rtk_binary", return_value=None):
result = runner.invoke(main, ["wrap", "codex", "--prepare-only", "--port", "8787"])
assert result.exit_code == 0, result.output
assert 'model_provider = "headroom"' in config_file.read_text()
backup = tmp_path / ".codex" / "config.toml.headroom-backup"
assert backup.exists()
assert backup.read_text() == original
fix(codex): respect CODEX_HOME for wrap config (#731) > ⚠️ This PR was opened using Codex (gpt-5.5 on `xhigh`) Fixes #730. ## Summary - centralize Codex config path resolution in `headroom wrap codex` so it honors `CODEX_HOME` when set - make the Codex MCP registrar use `$CODEX_HOME/config.toml` instead of always writing to `~/.codex/config.toml` - route optional memory MCP and global Codex `AGENTS.md` injection through the same Codex home helper - make `headroom unwrap codex` print a warning, but still succeed, when `CODEX_HOME` is unset and the default Codex config has no Headroom markers - add regression coverage for provider injection, prepare-only wrapping, MCP registration under a custom Codex home, and the ambiguous unwrap warning - update `CHANGELOG.md` under `Unreleased > Bug Fixes` ## Real behavior proof Setup tested on: - Linux `7.0.10-2-cachyos` - Python 3.14.3 via `uv` - local fork branch `fix/codex-home` - custom Codex home created outside `~/.codex` Exact command run after the patch: ```bash tmp_home=$(mktemp -d) mkdir -p "$tmp_home/codex_custom" HOME="$tmp_home" USERPROFILE="$tmp_home" CODEX_HOME="$tmp_home/codex_custom" \ UV_SKIP_WHEEL_FILENAME_CHECK=1 PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 \ uv run --with fastapi --with uvicorn --with httpx --with websockets \ headroom wrap codex --no-context-tool --no-serena --prepare-only --port 8787 find "$tmp_home" -maxdepth 3 -type f -print | sort sed -n '1,140p' "$tmp_home/codex_custom/config.toml" test -e "$tmp_home/.codex/config.toml" && echo yes || echo no HOME="$tmp_home" USERPROFILE="$tmp_home" \ UV_SKIP_WHEEL_FILENAME_CHECK=1 PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 \ uv run --with fastapi --with uvicorn --with httpx --with websockets \ headroom unwrap codex --no-stop-proxy ``` After-fix evidence + observed result: Interactive check: - A full `CODEX_HOME="$HOME/.codex_p" headroom wrap codex --no-serena` launch was tested locally after the patch and worked as expected. - The prepare-only proof below shows the same config path behavior without requiring an interactive Codex session in CI/reviewer environments. ```text MCP retrieve tool: registered (restart OpenAI Codex CLI if it was already running) Codex config: injected Headroom provider (WS + HTTP) into /tmp/tmp.UPUvloGNYE/codex_custom/config.toml --- files --- /tmp/tmp.UPUvloGNYE/codex_custom/config.toml /tmp/tmp.UPUvloGNYE/codex_custom/config.toml.headroom-backup --- custom config --- # --- Headroom proxy (auto-injected by headroom wrap codex) --- model_provider = "headroom" openai_base_url = "http://127.0.0.1:8787/v1" # --- end Headroom --- # --- Headroom MCP server --- [mcp_servers.headroom] command = "headroom" args = ["mcp", "serve"] # --- end Headroom MCP server --- # --- Headroom proxy (auto-injected by headroom wrap codex) --- [model_providers.headroom] name = "OpenAI via Headroom proxy" base_url = "http://127.0.0.1:8787/v1" supports_websockets = true # --- end Headroom --- --- default config exists? --- no Warning: found no Headroom wrap markers in the default Codex config. If you wrapped Codex with CODEX_HOME, rerun unwrap with the same environment variable, e.g. CODEX_HOME=/path/to/codex-home headroom unwrap codex. Nothing to undo: .../.codex/config.toml has no Headroom wrap markers. ``` What I did not test: - Windows/macOS path behavior - full repository test suite, because this local Python 3.14 environment hits optional dependency/build constraints outside this patch ## Testing ```bash UV_SKIP_WHEEL_FILENAME_CHECK=1 PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 uv run --with pytest --with fastapi --with uvicorn --with httpx --with websockets pytest tests/test_mcp_registry/test_codex_registrar.py tests/test_cli/test_wrap_codex.py -q UV_SKIP_WHEEL_FILENAME_CHECK=1 PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 uv run --with ruff ruff check headroom/cli/wrap.py headroom/mcp_registry/codex.py tests/test_cli/test_wrap_codex.py tests/test_mcp_registry/test_codex_registrar.py UV_SKIP_WHEEL_FILENAME_CHECK=1 PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 uv run --with ruff ruff format --check headroom/cli/wrap.py headroom/mcp_registry/codex.py tests/test_cli/test_wrap_codex.py tests/test_mcp_registry/test_codex_registrar.py ``` Results: ```text 63 passed, 1 warning in 1.48s All checks passed! 4 files already formatted ``` Notes: - Python 3.14 required `PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1` for the editable build. - `uv` required `UV_SKIP_WHEEL_FILENAME_CHECK=1` because the existing `uv.lock` has a GitPython wheel filename/version mismatch.
2026-06-10 20:21:29 -03:00
def test_wrap_codex_prepare_only_respects_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))
with patch("headroom.cli.wrap._ensure_rtk_binary", return_value=None):
result = runner.invoke(
main,
["wrap", "codex", "--prepare-only", "--no-serena", "--port", "8787"],
)
assert result.exit_code == 0, result.output
config_file = codex_home / "config.toml"
assert config_file.exists()
content = config_file.read_text()
assert 'model_provider = "headroom"' in content
assert "[mcp_servers.headroom]" in content
assert not (tmp_path / ".codex" / "config.toml").exists()
def test_unwrap_codex_without_codex_home_warns_on_ambiguous_noop(
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))
with patch("headroom.cli.wrap._ensure_rtk_binary", return_value=None):
wrap_result = runner.invoke(
main,
[
"wrap",
"codex",
"--prepare-only",
"--no-mcp",
"--no-serena",
"--port",
"8787",
],
)
assert wrap_result.exit_code == 0, wrap_result.output
config_file = codex_home / "config.toml"
assert 'openai_base_url = "http://127.0.0.1:8787/v1"' in config_file.read_text()
monkeypatch.delenv("CODEX_HOME", raising=False)
unwrap_result = runner.invoke(main, ["unwrap", "codex", "--no-stop-proxy"])
assert unwrap_result.exit_code == 0, unwrap_result.output
assert "Warning: found no Headroom wrap markers in the default Codex config" in (
unwrap_result.output
)
assert "If you wrapped Codex with CODEX_HOME" in unwrap_result.output
assert "CODEX_HOME=/path/to/codex-home headroom unwrap codex" in unwrap_result.output
assert "Nothing to undo" in unwrap_result.output
assert 'openai_base_url = "http://127.0.0.1:8787/v1"' in config_file.read_text()
def test_start_proxy_uses_separate_session_for_signal_isolation(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Proxy child should not receive Ctrl-C intended for the wrapped CLI."""
popen_kwargs: dict[str, object] = {}
class FakeProc:
returncode = None
def poll(self) -> None:
return None
def fake_popen(*args: object, **kwargs: object) -> FakeProc:
popen_kwargs.update(kwargs)
return FakeProc()
monkeypatch.setattr(wrap_mod, "_get_log_path", lambda: tmp_path / "proxy.log")
monkeypatch.setattr(wrap_mod, "_check_proxy", lambda port: True)
monkeypatch.setattr(wrap_mod.subprocess, "Popen", fake_popen)
proc = wrap_mod._start_proxy(8787, agent_type="codex")
assert isinstance(proc, FakeProc)
assert popen_kwargs["start_new_session"] == (wrap_mod.os.name == "posix")
feat: add dashboard agent usage stats (#814) ## Description Add a clear dashboard view for per-agent token usage so end users can see Cursor, Claude, Codex, and other detected clients with before/after token counts, tokens saved, and savings percentages. The stats API now exposes a stable `agent_usage` object that the dashboard renders near the top of the session view. Fixes # ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [x] 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 ### New Files **Tests:** - `tests/test_dashboard_agent_usage.py` — Covers agent classification, exact per-request aggregation, and aggregate fallback behavior. ### Modified Files - `headroom/proxy/server.py` — Adds per-agent usage aggregation to `/stats` with before tokens, after tokens, output tokens, saved tokens, savings percentage, source, providers, and models. - `headroom/dashboard/templates/dashboard.html` — Adds a prominent Agent Usage panel with totals, coverage status, per-agent token-flow bars, request counts, before/after tokens, saved tokens, and share of savings. ## Testing - [x] Unit tests pass: `.venv312/bin/pytest tests/test_dashboard_agent_usage.py` - [x] Linting passes: `.venv312/bin/ruff check headroom/proxy/server.py tests/test_dashboard_agent_usage.py` - [x] Diff whitespace check passes: `git diff --check origin/main...HEAD` - [x] Dashboard smoke render: local proxy on `127.0.0.1:8790`, captured Chrome headless screenshot of `/dashboard` - [x] New tests added for new functionality ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing relevant unit tests pass locally with my changes - [ ] I have made corresponding changes to the documentation - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes The agent usage panel uses exact request-log data when available. If detailed request logs are empty, it falls back to aggregate provider/model request counts and labels the coverage as aggregate fallback so users are not misled.
2026-06-12 22:12:22 +03:00
@pytest.mark.parametrize("agent_type", ["claude", "codex", "cursor"])
def test_start_proxy_applies_agent_90_defaults(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, agent_type: str
) -> None:
"""Wrapped coding agents should start the proxy with high-savings defaults."""
popen_kwargs: dict[str, object] = {}
class FakeProc:
returncode = None
def poll(self) -> None:
return None
def fake_popen(*args: object, **kwargs: object) -> FakeProc:
popen_kwargs.update(kwargs)
return FakeProc()
monkeypatch.setattr(wrap_mod, "_get_log_path", lambda: tmp_path / "proxy.log")
monkeypatch.setattr(wrap_mod, "_check_proxy", lambda port: True)
monkeypatch.setattr(wrap_mod.subprocess, "Popen", fake_popen)
wrap_mod._start_proxy(8787, agent_type=agent_type)
env = popen_kwargs["env"]
assert isinstance(env, dict)
assert env["HEADROOM_SAVINGS_PROFILE"] == "agent-90"
assert env["HEADROOM_TARGET_RATIO"] == "0.10"
assert env["HEADROOM_MAX_ITEMS"] == "8"
assert env["HEADROOM_SMART_CRUSHER_COMPACTION"] == "0"
def test_start_proxy_preserves_explicit_savings_overrides(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""User-provided savings env vars should override wrapper defaults."""
popen_kwargs: dict[str, object] = {}
class FakeProc:
returncode = None
def poll(self) -> None:
return None
def fake_popen(*args: object, **kwargs: object) -> FakeProc:
popen_kwargs.update(kwargs)
return FakeProc()
monkeypatch.setenv("HEADROOM_TARGET_RATIO", "0.20")
monkeypatch.setenv("HEADROOM_MAX_ITEMS", "12")
monkeypatch.setattr(wrap_mod, "_get_log_path", lambda: tmp_path / "proxy.log")
monkeypatch.setattr(wrap_mod, "_check_proxy", lambda port: True)
monkeypatch.setattr(wrap_mod.subprocess, "Popen", fake_popen)
wrap_mod._start_proxy(8787, agent_type="codex")
env = popen_kwargs["env"]
assert isinstance(env, dict)
assert env["HEADROOM_TARGET_RATIO"] == "0.20"
assert env["HEADROOM_MAX_ITEMS"] == "12"
def test_launch_tool_ignores_sigint_in_wrapper(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Ctrl-C should be handled by the child CLI, not kill the proxy from wrapper."""
signal_handlers: dict[object, object] = {}
class FakeCompleted:
returncode = 0
monkeypatch.setattr(wrap_mod, "_ensure_proxy", lambda *args, **kwargs: None)
monkeypatch.setattr(
wrap_mod.signal, "signal", lambda sig, fn: signal_handlers.setdefault(sig, fn)
)
monkeypatch.setattr(wrap_mod.subprocess, "run", lambda *args, **kwargs: FakeCompleted())
with pytest.raises(SystemExit) as exc:
wrap_mod._launch_tool(
binary="codex",
args=(),
env={},
port=8787,
no_proxy=True,
tool_label="CODEX",
env_vars_display=[],
)
assert exc.value.code == 0
assert signal_handlers[wrap_mod.signal.SIGINT] is wrap_mod._ignore_child_sigint
def test_wrap_codex_prepare_only_updates_stale_mcp_proxy_url(
runner: CliRunner, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_set_test_home(monkeypatch, tmp_path)
config_file = tmp_path / ".codex" / "config.toml"
config_file.parent.mkdir(parents=True)
config_file.write_text(
"# --- Headroom MCP server ---\n"
"[mcp_servers.headroom]\n"
'command = "headroom"\n'
'args = ["mcp", "serve"]\n'
"\n"
"[mcp_servers.headroom.env]\n"
'HEADROOM_PROXY_URL = "http://127.0.0.1:9000"\n'
"# --- end Headroom MCP server ---\n"
)
with patch("headroom.cli.wrap._ensure_rtk_binary", return_value=None):
result = runner.invoke(main, ["wrap", "codex", "--prepare-only", "--port", "8787"])
assert result.exit_code == 0, result.output
content = config_file.read_text()
assert "[mcp_servers.headroom]" in content
assert 'command = "headroom"' in content
assert 'args = ["mcp", "serve"]' in content
assert "http://127.0.0.1:9000" not in content
2026-05-09 22:57:35 -07:00
def test_wrap_codex_prepare_only_registers_serena_when_uvx_exists(
runner: CliRunner, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_set_test_home(monkeypatch, tmp_path)
config_file = tmp_path / ".codex" / "config.toml"
config_file.parent.mkdir(parents=True)
def fake_which(cmd: str) -> str | None:
if cmd == "uvx":
return "/usr/local/bin/uvx"
return None
with patch("headroom.cli.wrap._ensure_rtk_binary", return_value=None):
with patch("headroom.cli.wrap.shutil.which", side_effect=fake_which):
result = runner.invoke(main, ["wrap", "codex", "--prepare-only"])
assert result.exit_code == 0, result.output
content = config_file.read_text()
assert "[mcp_servers.serena]" in content
assert 'command = "uvx"' in content
assert '"--context", "codex"' in content
def test_wrap_codex_prepare_only_no_serena_skips_serena(
runner: CliRunner, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_set_test_home(monkeypatch, tmp_path)
config_file = tmp_path / ".codex" / "config.toml"
config_file.parent.mkdir(parents=True)
with patch("headroom.cli.wrap._ensure_rtk_binary", return_value=None):
result = runner.invoke(main, ["wrap", "codex", "--prepare-only", "--no-serena"])
assert result.exit_code == 0, result.output
assert "[mcp_servers.serena]" not in config_file.read_text()
fix(wrap): unwrap codex restores prior config.toml `headroom wrap codex` injects a `model_provider = "headroom"` block plus a `[model_providers.headroom]` table into `~/.codex/config.toml` so Codex routes both HTTP and WebSocket traffic through the proxy. The matching `unwrap codex` subcommand did not exist, so the injected block stayed in `config.toml` forever — the moment the proxy stopped, Codex (CLI and macOS app) started erroring with `Missing environment variable: OPENAI_API_KEY`, and users had to hand- edit the file to recover. Fix: * `_inject_codex_provider_config` now snapshots the pre-wrap file to `~/.codex/config.toml.headroom-backup` before the first modification and leaves that snapshot untouched on subsequent wrap runs. The injection is also rewritten to use two self-contained marker- delimited blocks (top-level key and provider table) so stripping them never consumes user content that sits between them. * `_inject_memory_mcp_config` takes the same snapshot, so `wrap codex --memory` without a full provider injection is still fully reversible. * New `_restore_codex_provider_config` helper and `unwrap codex` click command: * backup present → restore byte-for-byte and delete the backup; * backup absent but Headroom block present → strip the block and keep surrounding user content; * config contained only Headroom content → remove the file so Codex falls back to defaults; * nothing to undo → safe no-op. Codex is the only wrap target that modifies a persistent user config file: claude/aider/cursor/copilot all go through env vars or project- scoped files only, so this bug was unique to Codex. Tests: * `tests/test_cli/test_wrap_codex.py` adds 20 new cases covering the strip/snapshot helpers directly, round-trip idempotency of wrap → wrap → unwrap, handling of malformed prior configs, and end-to-end CliRunner invocations of `headroom wrap codex --prepare-only` / `headroom unwrap codex` against a temp `$HOME`. * All 153 existing `tests/test_cli/` tests continue to pass. Plugin manifest versions were re-synced from `pyproject.toml` (0.11.2) by the `sync-plugin-versions` pre-commit hook; the previous values (0.10.3) had drifted. Reported by @raenaryl in Discord on 0.6.3; confirmed still broken on current `main` (0.11.x). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:13:54 -05:00
def test_unwrap_codex_restores_prior_config_end_to_end(
runner: CliRunner, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""The bug report, reproduced: wrap → unwrap must round-trip cleanly."""
_set_test_home(monkeypatch, tmp_path)
config_file = tmp_path / ".codex" / "config.toml"
config_file.parent.mkdir(parents=True)
original = (
"[profiles.default]\n"
'model = "gpt-4o"\n'
"\n"
"[model_providers.openai]\n"
'base_url = "https://api.openai.com/v1"\n'
)
config_file.write_text(original)
with patch("headroom.cli.wrap._ensure_rtk_binary", return_value=None):
wrap_result = runner.invoke(main, ["wrap", "codex", "--prepare-only", "--port", "8787"])
assert wrap_result.exit_code == 0, wrap_result.output
assert 'model_provider = "headroom"' in config_file.read_text()
stopped: list[int] = []
with patch(
"headroom.cli.wrap._stop_local_proxy_for_unwrap",
side_effect=lambda port: stopped.append(port) or "stopped",
):
unwrap_result = runner.invoke(main, ["unwrap", "codex", "--port", "9999"])
fix(wrap): unwrap codex restores prior config.toml `headroom wrap codex` injects a `model_provider = "headroom"` block plus a `[model_providers.headroom]` table into `~/.codex/config.toml` so Codex routes both HTTP and WebSocket traffic through the proxy. The matching `unwrap codex` subcommand did not exist, so the injected block stayed in `config.toml` forever — the moment the proxy stopped, Codex (CLI and macOS app) started erroring with `Missing environment variable: OPENAI_API_KEY`, and users had to hand- edit the file to recover. Fix: * `_inject_codex_provider_config` now snapshots the pre-wrap file to `~/.codex/config.toml.headroom-backup` before the first modification and leaves that snapshot untouched on subsequent wrap runs. The injection is also rewritten to use two self-contained marker- delimited blocks (top-level key and provider table) so stripping them never consumes user content that sits between them. * `_inject_memory_mcp_config` takes the same snapshot, so `wrap codex --memory` without a full provider injection is still fully reversible. * New `_restore_codex_provider_config` helper and `unwrap codex` click command: * backup present → restore byte-for-byte and delete the backup; * backup absent but Headroom block present → strip the block and keep surrounding user content; * config contained only Headroom content → remove the file so Codex falls back to defaults; * nothing to undo → safe no-op. Codex is the only wrap target that modifies a persistent user config file: claude/aider/cursor/copilot all go through env vars or project- scoped files only, so this bug was unique to Codex. Tests: * `tests/test_cli/test_wrap_codex.py` adds 20 new cases covering the strip/snapshot helpers directly, round-trip idempotency of wrap → wrap → unwrap, handling of malformed prior configs, and end-to-end CliRunner invocations of `headroom wrap codex --prepare-only` / `headroom unwrap codex` against a temp `$HOME`. * All 153 existing `tests/test_cli/` tests continue to pass. Plugin manifest versions were re-synced from `pyproject.toml` (0.11.2) by the `sync-plugin-versions` pre-commit hook; the previous values (0.10.3) had drifted. Reported by @raenaryl in Discord on 0.6.3; confirmed still broken on current `main` (0.11.x). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:13:54 -05:00
assert unwrap_result.exit_code == 0, unwrap_result.output
# Config must be byte-for-byte what the user had before wrap, and the
# injected block must be gone — no more "Missing OPENAI_API_KEY" when the
# proxy is stopped.
assert config_file.read_text() == original
assert 'model_provider = "headroom"' not in config_file.read_text()
assert not (tmp_path / ".codex" / "config.toml.headroom-backup").exists()
assert stopped == [9999]
assert "Stopped local Headroom proxy on port 9999" in unwrap_result.output
def test_unwrap_codex_no_stop_proxy_leaves_proxy_alone(
runner: CliRunner, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_set_test_home(monkeypatch, tmp_path)
fix(codex): respect CODEX_HOME for wrap config (#731) > ⚠️ This PR was opened using Codex (gpt-5.5 on `xhigh`) Fixes #730. ## Summary - centralize Codex config path resolution in `headroom wrap codex` so it honors `CODEX_HOME` when set - make the Codex MCP registrar use `$CODEX_HOME/config.toml` instead of always writing to `~/.codex/config.toml` - route optional memory MCP and global Codex `AGENTS.md` injection through the same Codex home helper - make `headroom unwrap codex` print a warning, but still succeed, when `CODEX_HOME` is unset and the default Codex config has no Headroom markers - add regression coverage for provider injection, prepare-only wrapping, MCP registration under a custom Codex home, and the ambiguous unwrap warning - update `CHANGELOG.md` under `Unreleased > Bug Fixes` ## Real behavior proof Setup tested on: - Linux `7.0.10-2-cachyos` - Python 3.14.3 via `uv` - local fork branch `fix/codex-home` - custom Codex home created outside `~/.codex` Exact command run after the patch: ```bash tmp_home=$(mktemp -d) mkdir -p "$tmp_home/codex_custom" HOME="$tmp_home" USERPROFILE="$tmp_home" CODEX_HOME="$tmp_home/codex_custom" \ UV_SKIP_WHEEL_FILENAME_CHECK=1 PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 \ uv run --with fastapi --with uvicorn --with httpx --with websockets \ headroom wrap codex --no-context-tool --no-serena --prepare-only --port 8787 find "$tmp_home" -maxdepth 3 -type f -print | sort sed -n '1,140p' "$tmp_home/codex_custom/config.toml" test -e "$tmp_home/.codex/config.toml" && echo yes || echo no HOME="$tmp_home" USERPROFILE="$tmp_home" \ UV_SKIP_WHEEL_FILENAME_CHECK=1 PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 \ uv run --with fastapi --with uvicorn --with httpx --with websockets \ headroom unwrap codex --no-stop-proxy ``` After-fix evidence + observed result: Interactive check: - A full `CODEX_HOME="$HOME/.codex_p" headroom wrap codex --no-serena` launch was tested locally after the patch and worked as expected. - The prepare-only proof below shows the same config path behavior without requiring an interactive Codex session in CI/reviewer environments. ```text MCP retrieve tool: registered (restart OpenAI Codex CLI if it was already running) Codex config: injected Headroom provider (WS + HTTP) into /tmp/tmp.UPUvloGNYE/codex_custom/config.toml --- files --- /tmp/tmp.UPUvloGNYE/codex_custom/config.toml /tmp/tmp.UPUvloGNYE/codex_custom/config.toml.headroom-backup --- custom config --- # --- Headroom proxy (auto-injected by headroom wrap codex) --- model_provider = "headroom" openai_base_url = "http://127.0.0.1:8787/v1" # --- end Headroom --- # --- Headroom MCP server --- [mcp_servers.headroom] command = "headroom" args = ["mcp", "serve"] # --- end Headroom MCP server --- # --- Headroom proxy (auto-injected by headroom wrap codex) --- [model_providers.headroom] name = "OpenAI via Headroom proxy" base_url = "http://127.0.0.1:8787/v1" supports_websockets = true # --- end Headroom --- --- default config exists? --- no Warning: found no Headroom wrap markers in the default Codex config. If you wrapped Codex with CODEX_HOME, rerun unwrap with the same environment variable, e.g. CODEX_HOME=/path/to/codex-home headroom unwrap codex. Nothing to undo: .../.codex/config.toml has no Headroom wrap markers. ``` What I did not test: - Windows/macOS path behavior - full repository test suite, because this local Python 3.14 environment hits optional dependency/build constraints outside this patch ## Testing ```bash UV_SKIP_WHEEL_FILENAME_CHECK=1 PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 uv run --with pytest --with fastapi --with uvicorn --with httpx --with websockets pytest tests/test_mcp_registry/test_codex_registrar.py tests/test_cli/test_wrap_codex.py -q UV_SKIP_WHEEL_FILENAME_CHECK=1 PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 uv run --with ruff ruff check headroom/cli/wrap.py headroom/mcp_registry/codex.py tests/test_cli/test_wrap_codex.py tests/test_mcp_registry/test_codex_registrar.py UV_SKIP_WHEEL_FILENAME_CHECK=1 PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 uv run --with ruff ruff format --check headroom/cli/wrap.py headroom/mcp_registry/codex.py tests/test_cli/test_wrap_codex.py tests/test_mcp_registry/test_codex_registrar.py ``` Results: ```text 63 passed, 1 warning in 1.48s All checks passed! 4 files already formatted ``` Notes: - Python 3.14 required `PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1` for the editable build. - `uv` required `UV_SKIP_WHEEL_FILENAME_CHECK=1` because the existing `uv.lock` has a GitPython wheel filename/version mismatch.
2026-06-10 20:21:29 -03:00
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "explicit-codex-home"))
with patch("headroom.cli.wrap._stop_local_proxy_for_unwrap") as stop_proxy:
result = runner.invoke(main, ["unwrap", "codex", "--no-stop-proxy"])
assert result.exit_code == 0, result.output
stop_proxy.assert_not_called()
def test_stop_local_proxy_for_unwrap_kills_identified_headroom_proxy(
monkeypatch: pytest.MonkeyPatch,
) -> None:
killed: list[tuple[int, int]] = []
monkeypatch.setattr(wrap_mod, "_check_proxy", lambda port: True)
monkeypatch.setattr(wrap_mod, "_query_proxy_config", lambda port: {"pid": "12345"})
monkeypatch.setattr(
wrap_mod,
"_kill_proxy_by_pid",
lambda pid, port: killed.append((pid, port)) or True,
)
assert wrap_mod._stop_local_proxy_for_unwrap(8787) == "stopped"
assert killed == [(12345, 8787)]
def test_stop_local_proxy_for_unwrap_refuses_unidentified_listener(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(wrap_mod, "_check_proxy", lambda port: True)
monkeypatch.setattr(wrap_mod, "_query_proxy_config", lambda port: None)
with patch("headroom.cli.wrap._kill_proxy_by_pid") as kill_proxy:
assert wrap_mod._stop_local_proxy_for_unwrap(8787) == "unidentified"
kill_proxy.assert_not_called()
fix(wrap): unwrap codex restores prior config.toml `headroom wrap codex` injects a `model_provider = "headroom"` block plus a `[model_providers.headroom]` table into `~/.codex/config.toml` so Codex routes both HTTP and WebSocket traffic through the proxy. The matching `unwrap codex` subcommand did not exist, so the injected block stayed in `config.toml` forever — the moment the proxy stopped, Codex (CLI and macOS app) started erroring with `Missing environment variable: OPENAI_API_KEY`, and users had to hand- edit the file to recover. Fix: * `_inject_codex_provider_config` now snapshots the pre-wrap file to `~/.codex/config.toml.headroom-backup` before the first modification and leaves that snapshot untouched on subsequent wrap runs. The injection is also rewritten to use two self-contained marker- delimited blocks (top-level key and provider table) so stripping them never consumes user content that sits between them. * `_inject_memory_mcp_config` takes the same snapshot, so `wrap codex --memory` without a full provider injection is still fully reversible. * New `_restore_codex_provider_config` helper and `unwrap codex` click command: * backup present → restore byte-for-byte and delete the backup; * backup absent but Headroom block present → strip the block and keep surrounding user content; * config contained only Headroom content → remove the file so Codex falls back to defaults; * nothing to undo → safe no-op. Codex is the only wrap target that modifies a persistent user config file: claude/aider/cursor/copilot all go through env vars or project- scoped files only, so this bug was unique to Codex. Tests: * `tests/test_cli/test_wrap_codex.py` adds 20 new cases covering the strip/snapshot helpers directly, round-trip idempotency of wrap → wrap → unwrap, handling of malformed prior configs, and end-to-end CliRunner invocations of `headroom wrap codex --prepare-only` / `headroom unwrap codex` against a temp `$HOME`. * All 153 existing `tests/test_cli/` tests continue to pass. Plugin manifest versions were re-synced from `pyproject.toml` (0.11.2) by the `sync-plugin-versions` pre-commit hook; the previous values (0.10.3) had drifted. Reported by @raenaryl in Discord on 0.6.3; confirmed still broken on current `main` (0.11.x). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:13:54 -05:00
fix(codex): respect CODEX_HOME for wrap config (#731) > ⚠️ This PR was opened using Codex (gpt-5.5 on `xhigh`) Fixes #730. ## Summary - centralize Codex config path resolution in `headroom wrap codex` so it honors `CODEX_HOME` when set - make the Codex MCP registrar use `$CODEX_HOME/config.toml` instead of always writing to `~/.codex/config.toml` - route optional memory MCP and global Codex `AGENTS.md` injection through the same Codex home helper - make `headroom unwrap codex` print a warning, but still succeed, when `CODEX_HOME` is unset and the default Codex config has no Headroom markers - add regression coverage for provider injection, prepare-only wrapping, MCP registration under a custom Codex home, and the ambiguous unwrap warning - update `CHANGELOG.md` under `Unreleased > Bug Fixes` ## Real behavior proof Setup tested on: - Linux `7.0.10-2-cachyos` - Python 3.14.3 via `uv` - local fork branch `fix/codex-home` - custom Codex home created outside `~/.codex` Exact command run after the patch: ```bash tmp_home=$(mktemp -d) mkdir -p "$tmp_home/codex_custom" HOME="$tmp_home" USERPROFILE="$tmp_home" CODEX_HOME="$tmp_home/codex_custom" \ UV_SKIP_WHEEL_FILENAME_CHECK=1 PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 \ uv run --with fastapi --with uvicorn --with httpx --with websockets \ headroom wrap codex --no-context-tool --no-serena --prepare-only --port 8787 find "$tmp_home" -maxdepth 3 -type f -print | sort sed -n '1,140p' "$tmp_home/codex_custom/config.toml" test -e "$tmp_home/.codex/config.toml" && echo yes || echo no HOME="$tmp_home" USERPROFILE="$tmp_home" \ UV_SKIP_WHEEL_FILENAME_CHECK=1 PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 \ uv run --with fastapi --with uvicorn --with httpx --with websockets \ headroom unwrap codex --no-stop-proxy ``` After-fix evidence + observed result: Interactive check: - A full `CODEX_HOME="$HOME/.codex_p" headroom wrap codex --no-serena` launch was tested locally after the patch and worked as expected. - The prepare-only proof below shows the same config path behavior without requiring an interactive Codex session in CI/reviewer environments. ```text MCP retrieve tool: registered (restart OpenAI Codex CLI if it was already running) Codex config: injected Headroom provider (WS + HTTP) into /tmp/tmp.UPUvloGNYE/codex_custom/config.toml --- files --- /tmp/tmp.UPUvloGNYE/codex_custom/config.toml /tmp/tmp.UPUvloGNYE/codex_custom/config.toml.headroom-backup --- custom config --- # --- Headroom proxy (auto-injected by headroom wrap codex) --- model_provider = "headroom" openai_base_url = "http://127.0.0.1:8787/v1" # --- end Headroom --- # --- Headroom MCP server --- [mcp_servers.headroom] command = "headroom" args = ["mcp", "serve"] # --- end Headroom MCP server --- # --- Headroom proxy (auto-injected by headroom wrap codex) --- [model_providers.headroom] name = "OpenAI via Headroom proxy" base_url = "http://127.0.0.1:8787/v1" supports_websockets = true # --- end Headroom --- --- default config exists? --- no Warning: found no Headroom wrap markers in the default Codex config. If you wrapped Codex with CODEX_HOME, rerun unwrap with the same environment variable, e.g. CODEX_HOME=/path/to/codex-home headroom unwrap codex. Nothing to undo: .../.codex/config.toml has no Headroom wrap markers. ``` What I did not test: - Windows/macOS path behavior - full repository test suite, because this local Python 3.14 environment hits optional dependency/build constraints outside this patch ## Testing ```bash UV_SKIP_WHEEL_FILENAME_CHECK=1 PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 uv run --with pytest --with fastapi --with uvicorn --with httpx --with websockets pytest tests/test_mcp_registry/test_codex_registrar.py tests/test_cli/test_wrap_codex.py -q UV_SKIP_WHEEL_FILENAME_CHECK=1 PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 uv run --with ruff ruff check headroom/cli/wrap.py headroom/mcp_registry/codex.py tests/test_cli/test_wrap_codex.py tests/test_mcp_registry/test_codex_registrar.py UV_SKIP_WHEEL_FILENAME_CHECK=1 PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 uv run --with ruff ruff format --check headroom/cli/wrap.py headroom/mcp_registry/codex.py tests/test_cli/test_wrap_codex.py tests/test_mcp_registry/test_codex_registrar.py ``` Results: ```text 63 passed, 1 warning in 1.48s All checks passed! 4 files already formatted ``` Notes: - Python 3.14 required `PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1` for the editable build. - `uv` required `UV_SKIP_WHEEL_FILENAME_CHECK=1` because the existing `uv.lock` has a GitPython wheel filename/version mismatch.
2026-06-10 20:21:29 -03:00
def test_unwrap_codex_is_safe_noop_with_explicit_codex_home(
fix(wrap): unwrap codex restores prior config.toml `headroom wrap codex` injects a `model_provider = "headroom"` block plus a `[model_providers.headroom]` table into `~/.codex/config.toml` so Codex routes both HTTP and WebSocket traffic through the proxy. The matching `unwrap codex` subcommand did not exist, so the injected block stayed in `config.toml` forever — the moment the proxy stopped, Codex (CLI and macOS app) started erroring with `Missing environment variable: OPENAI_API_KEY`, and users had to hand- edit the file to recover. Fix: * `_inject_codex_provider_config` now snapshots the pre-wrap file to `~/.codex/config.toml.headroom-backup` before the first modification and leaves that snapshot untouched on subsequent wrap runs. The injection is also rewritten to use two self-contained marker- delimited blocks (top-level key and provider table) so stripping them never consumes user content that sits between them. * `_inject_memory_mcp_config` takes the same snapshot, so `wrap codex --memory` without a full provider injection is still fully reversible. * New `_restore_codex_provider_config` helper and `unwrap codex` click command: * backup present → restore byte-for-byte and delete the backup; * backup absent but Headroom block present → strip the block and keep surrounding user content; * config contained only Headroom content → remove the file so Codex falls back to defaults; * nothing to undo → safe no-op. Codex is the only wrap target that modifies a persistent user config file: claude/aider/cursor/copilot all go through env vars or project- scoped files only, so this bug was unique to Codex. Tests: * `tests/test_cli/test_wrap_codex.py` adds 20 new cases covering the strip/snapshot helpers directly, round-trip idempotency of wrap → wrap → unwrap, handling of malformed prior configs, and end-to-end CliRunner invocations of `headroom wrap codex --prepare-only` / `headroom unwrap codex` against a temp `$HOME`. * All 153 existing `tests/test_cli/` tests continue to pass. Plugin manifest versions were re-synced from `pyproject.toml` (0.11.2) by the `sync-plugin-versions` pre-commit hook; the previous values (0.10.3) had drifted. Reported by @raenaryl in Discord on 0.6.3; confirmed still broken on current `main` (0.11.x). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:13:54 -05:00
runner: CliRunner, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_set_test_home(monkeypatch, tmp_path)
fix(codex): respect CODEX_HOME for wrap config (#731) > ⚠️ This PR was opened using Codex (gpt-5.5 on `xhigh`) Fixes #730. ## Summary - centralize Codex config path resolution in `headroom wrap codex` so it honors `CODEX_HOME` when set - make the Codex MCP registrar use `$CODEX_HOME/config.toml` instead of always writing to `~/.codex/config.toml` - route optional memory MCP and global Codex `AGENTS.md` injection through the same Codex home helper - make `headroom unwrap codex` print a warning, but still succeed, when `CODEX_HOME` is unset and the default Codex config has no Headroom markers - add regression coverage for provider injection, prepare-only wrapping, MCP registration under a custom Codex home, and the ambiguous unwrap warning - update `CHANGELOG.md` under `Unreleased > Bug Fixes` ## Real behavior proof Setup tested on: - Linux `7.0.10-2-cachyos` - Python 3.14.3 via `uv` - local fork branch `fix/codex-home` - custom Codex home created outside `~/.codex` Exact command run after the patch: ```bash tmp_home=$(mktemp -d) mkdir -p "$tmp_home/codex_custom" HOME="$tmp_home" USERPROFILE="$tmp_home" CODEX_HOME="$tmp_home/codex_custom" \ UV_SKIP_WHEEL_FILENAME_CHECK=1 PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 \ uv run --with fastapi --with uvicorn --with httpx --with websockets \ headroom wrap codex --no-context-tool --no-serena --prepare-only --port 8787 find "$tmp_home" -maxdepth 3 -type f -print | sort sed -n '1,140p' "$tmp_home/codex_custom/config.toml" test -e "$tmp_home/.codex/config.toml" && echo yes || echo no HOME="$tmp_home" USERPROFILE="$tmp_home" \ UV_SKIP_WHEEL_FILENAME_CHECK=1 PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 \ uv run --with fastapi --with uvicorn --with httpx --with websockets \ headroom unwrap codex --no-stop-proxy ``` After-fix evidence + observed result: Interactive check: - A full `CODEX_HOME="$HOME/.codex_p" headroom wrap codex --no-serena` launch was tested locally after the patch and worked as expected. - The prepare-only proof below shows the same config path behavior without requiring an interactive Codex session in CI/reviewer environments. ```text MCP retrieve tool: registered (restart OpenAI Codex CLI if it was already running) Codex config: injected Headroom provider (WS + HTTP) into /tmp/tmp.UPUvloGNYE/codex_custom/config.toml --- files --- /tmp/tmp.UPUvloGNYE/codex_custom/config.toml /tmp/tmp.UPUvloGNYE/codex_custom/config.toml.headroom-backup --- custom config --- # --- Headroom proxy (auto-injected by headroom wrap codex) --- model_provider = "headroom" openai_base_url = "http://127.0.0.1:8787/v1" # --- end Headroom --- # --- Headroom MCP server --- [mcp_servers.headroom] command = "headroom" args = ["mcp", "serve"] # --- end Headroom MCP server --- # --- Headroom proxy (auto-injected by headroom wrap codex) --- [model_providers.headroom] name = "OpenAI via Headroom proxy" base_url = "http://127.0.0.1:8787/v1" supports_websockets = true # --- end Headroom --- --- default config exists? --- no Warning: found no Headroom wrap markers in the default Codex config. If you wrapped Codex with CODEX_HOME, rerun unwrap with the same environment variable, e.g. CODEX_HOME=/path/to/codex-home headroom unwrap codex. Nothing to undo: .../.codex/config.toml has no Headroom wrap markers. ``` What I did not test: - Windows/macOS path behavior - full repository test suite, because this local Python 3.14 environment hits optional dependency/build constraints outside this patch ## Testing ```bash UV_SKIP_WHEEL_FILENAME_CHECK=1 PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 uv run --with pytest --with fastapi --with uvicorn --with httpx --with websockets pytest tests/test_mcp_registry/test_codex_registrar.py tests/test_cli/test_wrap_codex.py -q UV_SKIP_WHEEL_FILENAME_CHECK=1 PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 uv run --with ruff ruff check headroom/cli/wrap.py headroom/mcp_registry/codex.py tests/test_cli/test_wrap_codex.py tests/test_mcp_registry/test_codex_registrar.py UV_SKIP_WHEEL_FILENAME_CHECK=1 PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 uv run --with ruff ruff format --check headroom/cli/wrap.py headroom/mcp_registry/codex.py tests/test_cli/test_wrap_codex.py tests/test_mcp_registry/test_codex_registrar.py ``` Results: ```text 63 passed, 1 warning in 1.48s All checks passed! 4 files already formatted ``` Notes: - Python 3.14 required `PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1` for the editable build. - `uv` required `UV_SKIP_WHEEL_FILENAME_CHECK=1` because the existing `uv.lock` has a GitPython wheel filename/version mismatch.
2026-06-10 20:21:29 -03:00
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "explicit-codex-home"))
fix(wrap): unwrap codex restores prior config.toml `headroom wrap codex` injects a `model_provider = "headroom"` block plus a `[model_providers.headroom]` table into `~/.codex/config.toml` so Codex routes both HTTP and WebSocket traffic through the proxy. The matching `unwrap codex` subcommand did not exist, so the injected block stayed in `config.toml` forever — the moment the proxy stopped, Codex (CLI and macOS app) started erroring with `Missing environment variable: OPENAI_API_KEY`, and users had to hand- edit the file to recover. Fix: * `_inject_codex_provider_config` now snapshots the pre-wrap file to `~/.codex/config.toml.headroom-backup` before the first modification and leaves that snapshot untouched on subsequent wrap runs. The injection is also rewritten to use two self-contained marker- delimited blocks (top-level key and provider table) so stripping them never consumes user content that sits between them. * `_inject_memory_mcp_config` takes the same snapshot, so `wrap codex --memory` without a full provider injection is still fully reversible. * New `_restore_codex_provider_config` helper and `unwrap codex` click command: * backup present → restore byte-for-byte and delete the backup; * backup absent but Headroom block present → strip the block and keep surrounding user content; * config contained only Headroom content → remove the file so Codex falls back to defaults; * nothing to undo → safe no-op. Codex is the only wrap target that modifies a persistent user config file: claude/aider/cursor/copilot all go through env vars or project- scoped files only, so this bug was unique to Codex. Tests: * `tests/test_cli/test_wrap_codex.py` adds 20 new cases covering the strip/snapshot helpers directly, round-trip idempotency of wrap → wrap → unwrap, handling of malformed prior configs, and end-to-end CliRunner invocations of `headroom wrap codex --prepare-only` / `headroom unwrap codex` against a temp `$HOME`. * All 153 existing `tests/test_cli/` tests continue to pass. Plugin manifest versions were re-synced from `pyproject.toml` (0.11.2) by the `sync-plugin-versions` pre-commit hook; the previous values (0.10.3) had drifted. Reported by @raenaryl in Discord on 0.6.3; confirmed still broken on current `main` (0.11.x). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:13:54 -05:00
result = runner.invoke(main, ["unwrap", "codex"])
assert result.exit_code == 0, result.output
assert "Nothing to undo" in result.output
fix(codex): respect CODEX_HOME for wrap config (#731) > ⚠️ This PR was opened using Codex (gpt-5.5 on `xhigh`) Fixes #730. ## Summary - centralize Codex config path resolution in `headroom wrap codex` so it honors `CODEX_HOME` when set - make the Codex MCP registrar use `$CODEX_HOME/config.toml` instead of always writing to `~/.codex/config.toml` - route optional memory MCP and global Codex `AGENTS.md` injection through the same Codex home helper - make `headroom unwrap codex` print a warning, but still succeed, when `CODEX_HOME` is unset and the default Codex config has no Headroom markers - add regression coverage for provider injection, prepare-only wrapping, MCP registration under a custom Codex home, and the ambiguous unwrap warning - update `CHANGELOG.md` under `Unreleased > Bug Fixes` ## Real behavior proof Setup tested on: - Linux `7.0.10-2-cachyos` - Python 3.14.3 via `uv` - local fork branch `fix/codex-home` - custom Codex home created outside `~/.codex` Exact command run after the patch: ```bash tmp_home=$(mktemp -d) mkdir -p "$tmp_home/codex_custom" HOME="$tmp_home" USERPROFILE="$tmp_home" CODEX_HOME="$tmp_home/codex_custom" \ UV_SKIP_WHEEL_FILENAME_CHECK=1 PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 \ uv run --with fastapi --with uvicorn --with httpx --with websockets \ headroom wrap codex --no-context-tool --no-serena --prepare-only --port 8787 find "$tmp_home" -maxdepth 3 -type f -print | sort sed -n '1,140p' "$tmp_home/codex_custom/config.toml" test -e "$tmp_home/.codex/config.toml" && echo yes || echo no HOME="$tmp_home" USERPROFILE="$tmp_home" \ UV_SKIP_WHEEL_FILENAME_CHECK=1 PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 \ uv run --with fastapi --with uvicorn --with httpx --with websockets \ headroom unwrap codex --no-stop-proxy ``` After-fix evidence + observed result: Interactive check: - A full `CODEX_HOME="$HOME/.codex_p" headroom wrap codex --no-serena` launch was tested locally after the patch and worked as expected. - The prepare-only proof below shows the same config path behavior without requiring an interactive Codex session in CI/reviewer environments. ```text MCP retrieve tool: registered (restart OpenAI Codex CLI if it was already running) Codex config: injected Headroom provider (WS + HTTP) into /tmp/tmp.UPUvloGNYE/codex_custom/config.toml --- files --- /tmp/tmp.UPUvloGNYE/codex_custom/config.toml /tmp/tmp.UPUvloGNYE/codex_custom/config.toml.headroom-backup --- custom config --- # --- Headroom proxy (auto-injected by headroom wrap codex) --- model_provider = "headroom" openai_base_url = "http://127.0.0.1:8787/v1" # --- end Headroom --- # --- Headroom MCP server --- [mcp_servers.headroom] command = "headroom" args = ["mcp", "serve"] # --- end Headroom MCP server --- # --- Headroom proxy (auto-injected by headroom wrap codex) --- [model_providers.headroom] name = "OpenAI via Headroom proxy" base_url = "http://127.0.0.1:8787/v1" supports_websockets = true # --- end Headroom --- --- default config exists? --- no Warning: found no Headroom wrap markers in the default Codex config. If you wrapped Codex with CODEX_HOME, rerun unwrap with the same environment variable, e.g. CODEX_HOME=/path/to/codex-home headroom unwrap codex. Nothing to undo: .../.codex/config.toml has no Headroom wrap markers. ``` What I did not test: - Windows/macOS path behavior - full repository test suite, because this local Python 3.14 environment hits optional dependency/build constraints outside this patch ## Testing ```bash UV_SKIP_WHEEL_FILENAME_CHECK=1 PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 uv run --with pytest --with fastapi --with uvicorn --with httpx --with websockets pytest tests/test_mcp_registry/test_codex_registrar.py tests/test_cli/test_wrap_codex.py -q UV_SKIP_WHEEL_FILENAME_CHECK=1 PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 uv run --with ruff ruff check headroom/cli/wrap.py headroom/mcp_registry/codex.py tests/test_cli/test_wrap_codex.py tests/test_mcp_registry/test_codex_registrar.py UV_SKIP_WHEEL_FILENAME_CHECK=1 PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 uv run --with ruff ruff format --check headroom/cli/wrap.py headroom/mcp_registry/codex.py tests/test_cli/test_wrap_codex.py tests/test_mcp_registry/test_codex_registrar.py ``` Results: ```text 63 passed, 1 warning in 1.48s All checks passed! 4 files already formatted ``` Notes: - Python 3.14 required `PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1` for the editable build. - `uv` required `UV_SKIP_WHEEL_FILENAME_CHECK=1` because the existing `uv.lock` has a GitPython wheel filename/version mismatch.
2026-06-10 20:21:29 -03:00
assert "Warning:" not in result.output
fix(wrap): unwrap codex restores prior config.toml `headroom wrap codex` injects a `model_provider = "headroom"` block plus a `[model_providers.headroom]` table into `~/.codex/config.toml` so Codex routes both HTTP and WebSocket traffic through the proxy. The matching `unwrap codex` subcommand did not exist, so the injected block stayed in `config.toml` forever — the moment the proxy stopped, Codex (CLI and macOS app) started erroring with `Missing environment variable: OPENAI_API_KEY`, and users had to hand- edit the file to recover. Fix: * `_inject_codex_provider_config` now snapshots the pre-wrap file to `~/.codex/config.toml.headroom-backup` before the first modification and leaves that snapshot untouched on subsequent wrap runs. The injection is also rewritten to use two self-contained marker- delimited blocks (top-level key and provider table) so stripping them never consumes user content that sits between them. * `_inject_memory_mcp_config` takes the same snapshot, so `wrap codex --memory` without a full provider injection is still fully reversible. * New `_restore_codex_provider_config` helper and `unwrap codex` click command: * backup present → restore byte-for-byte and delete the backup; * backup absent but Headroom block present → strip the block and keep surrounding user content; * config contained only Headroom content → remove the file so Codex falls back to defaults; * nothing to undo → safe no-op. Codex is the only wrap target that modifies a persistent user config file: claude/aider/cursor/copilot all go through env vars or project- scoped files only, so this bug was unique to Codex. Tests: * `tests/test_cli/test_wrap_codex.py` adds 20 new cases covering the strip/snapshot helpers directly, round-trip idempotency of wrap → wrap → unwrap, handling of malformed prior configs, and end-to-end CliRunner invocations of `headroom wrap codex --prepare-only` / `headroom unwrap codex` against a temp `$HOME`. * All 153 existing `tests/test_cli/` tests continue to pass. Plugin manifest versions were re-synced from `pyproject.toml` (0.11.2) by the `sync-plugin-versions` pre-commit hook; the previous values (0.10.3) had drifted. Reported by @raenaryl in Discord on 0.6.3; confirmed still broken on current `main` (0.11.x). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:13:54 -05:00
assert not (tmp_path / ".codex" / "config.toml").exists()
def test_unwrap_codex_removes_headroom_only_config_file(
runner: CliRunner, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_set_test_home(monkeypatch, tmp_path)
with patch("headroom.cli.wrap._ensure_rtk_binary", return_value=None):
wrap_result = runner.invoke(main, ["wrap", "codex", "--prepare-only", "--port", "8787"])
assert wrap_result.exit_code == 0, wrap_result.output
config_file = tmp_path / ".codex" / "config.toml"
assert config_file.exists()
unwrap_result = runner.invoke(main, ["unwrap", "codex"])
assert unwrap_result.exit_code == 0, unwrap_result.output
assert not config_file.exists()
def test_unwrap_codex_preserves_unrelated_sections(
runner: CliRunner, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_set_test_home(monkeypatch, tmp_path)
config_file = tmp_path / ".codex" / "config.toml"
config_file.parent.mkdir(parents=True)
# A config with an MCP server the user configured by hand.
original = '[mcp_servers.local_thing]\ncommand = "/usr/local/bin/thing"\nargs = ["--serve"]\n'
config_file.write_text(original)
with patch("headroom.cli.wrap._ensure_rtk_binary", return_value=None):
runner.invoke(main, ["wrap", "codex", "--prepare-only", "--port", "8787"])
result = runner.invoke(main, ["unwrap", "codex"])
assert result.exit_code == 0, result.output
restored = config_file.read_text()
assert restored == original
feat(proxy): per-project savings breakdown on the dashboard (claude, codex, aider, copilot, cursor) (#803) ## Summary Adds a **per-project savings breakdown** to the proxy dashboard, covering **all wrap-supported agents: Claude Code, Codex, aider, Copilot, and Cursor**. Spec and rationale in #802 (feature-request issue — happy to adjust scope per maintainer feedback). How it works — two attribution channels, by client capability: **Header channel** (clients that can send custom headers): - `headroom wrap claude` appends `X-Headroom-Project: <basename(cwd)>` to `ANTHROPIC_CUSTOM_HEADERS` (a user-supplied x-headroom-project header always wins; other user headers preserved). - `headroom wrap codex` extends the injected `[model_providers.headroom]` block with `env_http_headers = { "X-Headroom-Project" = "HEADROOM_PROJECT" }` and sets `HEADROOM_PROJECT` per launch — Codex only sends the header when the env var is set, so the static config stays inert outside wrap. **Base-URL prefix channel** (clients that cannot send custom headers): - The proxy accepts `/p/<url-encoded-name>/...`; middleware strips the prefix before routing (before Starlette caches the URL) and binds the project. The explicit header wins over the prefix. - `headroom wrap aider` points `OPENAI_API_BASE` / `ANTHROPIC_BASE_URL` at the prefixed URL. - `headroom wrap copilot` points `COPILOT_PROVIDER_BASE_URL` at the prefixed URL (BYOK anthropic/openai provider types and the GitHub-subscription path). - `headroom wrap cursor` prints the prefixed Override Base URL in its setup instructions, with a note explaining the attribution. **Shared plumbing:** - New `headroom/proxy/project_context.py`: header classification, `/p/` prefix split/strip, URL-prefix builder, and a contextvar bound per request (HTTP middleware + WS accept for the Codex responses bridge); the outcome funnel resolves it (explicit `RequestOutcome.project` wins), stamps `RequestLog.tags["project"]`, and forwards it through `PrometheusMetrics.record_request` into the `SavingsTracker`. - `SavingsTracker`: persisted state gains a `projects` map (requests, tokens saved, savings USD, input tokens/cost, last activity). Schema v2 → v3 with transparent forward migration (v2 files load cleanly, `projects` starts empty). Names sanitized (printable-only, 128-char cap); map capped at 50 projects, evicting the smallest bucket. - `/stats` exposes `savings.per_project` (and `projects`/`projects_limit` inside `persistent_savings`); `/stats-history` exposes `projects`. - Dashboard gains a "Per-Project Savings" table mirroring the per-model table (Alpine `x-text` only — names are user-supplied, no HTML injection). **Behavior changes:** none for unattributed traffic — no header and no prefix means no bucket, aggregate totals exactly as before (regression-tested: legacy `/stats` and `/stats-history` shapes are pinned by tests). ## Real behavior proof **Setup tested on:** macOS (Darwin 25.5.0), Python 3.11.9, `pip install -e ".[dev]"`, proxy on spare ports with isolated `HEADROOM_SAVINGS_PATH`; real Claude Code CLI (subscription auth) and real Codex CLI (ChatGPT auth) as clients. **Header channel — exact steps:** ``` HEADROOM_SAVINGS_PATH=/tmp/headroom-proof-savings.json .venv/bin/python -m headroom.cli proxy --port 9123 # background cd /tmp/proof-alpha && headroom wrap claude --port 9123 --no-rtk --no-mcp --no-serena -- -p "Reply with exactly: OK" cd /tmp/proof-beta && headroom wrap claude --port 9123 --no-rtk --no-mcp --no-serena -- -p "Reply with exactly: OK" cd /tmp/proof-beta && headroom wrap codex --port 9123 --no-rtk --no-mcp --no-serena -- exec --skip-git-repo-check "Reply with exactly: OK" ``` **Observed** (copied live `/stats` output; all runs replied `OK` through the proxy): ```json { "proof-beta": { "requests": 3, "tokens_saved": 140, "compression_savings_usd": 0.0007, "total_input_tokens": 38581, "total_input_cost_usd": 0.360433, "last_activity_at": "2026-06-10T09:19:17Z", "savings_percent": 0.36 }, "proof-alpha": { "requests": 1, "tokens_saved": 0, "compression_savings_usd": 0.0, "total_input_tokens": 9050, "total_input_cost_usd": 0.32245, "last_activity_at": "2026-06-10T09:18:09Z", "savings_percent": 0.0 } } ``` `proof-beta` aggregates one Claude Code turn + one Codex `exec` run (Codex attribution flows through `env_http_headers`). **Prefix channel — exact steps** (the same mechanism the aider/copilot/cursor wraps emit, driven by a real client): ``` .venv/bin/python -m headroom.cli proxy --port 9124 # background, isolated savings path ANTHROPIC_BASE_URL="http://127.0.0.1:9124/p/aider-style-project" claude -p "Reply with exactly: OK" ``` **Observed:** ```json { "aider-style-project": { "requests": 1, "tokens_saved": 0, "compression_savings_usd": 0.0, "total_input_tokens": 8571, "total_input_cost_usd": 1.018575, "last_activity_at": "2026-06-10T10:10:13Z", "savings_percent": 0.0 } } ``` `/stats-history` returned `schema_version: 3` with the same `projects` keys; `GET /dashboard` HTML contains the new "Per-Project Savings" table; persisted state survived a tracker reload; a hand-written v2 savings file loaded cleanly with an empty `projects` map. **What I did NOT test live:** the actual aider/Copilot/Cursor binaries end-to-end (their wraps emit exactly the prefixed URLs exercised above — unit tests pin the emitted env/URLs); Codex subscription-mode routing via the built-in `openai` provider (no provider headers there — such traffic simply stays unattributed); multi-worker uvicorn; Windows. ## Tests - `tests/test_proxy_project_savings.py` (17 tests): sanitization, header classification, `/p/` prefix split + URL-builder round-trip, tracker aggregation/persistence/migration/cardinality-cap/state-sanitization, funnel→`/stats` end-to-end, middleware binding for header + prefix + precedence, plus regression tests pinning legacy `/stats`/`/stats-history` shape and unattributed-traffic totals. - Wrap/provider tests extended: `test_cli/test_wrap_helpers.py`, `test_cli/test_wrap_codex.py`, `test_provider_aider.py`, `test_provider_cursor.py`, `test_provider_copilot_wrap.py` (prefixed env/URLs, user-override wins, no duplicate header, TOML block contents, block strip, setup-line note). - Full `pytest` suite run locally; `ruff check` + `ruff format` clean on all touched files. - `CHANGELOG.md` updated. ## Dependencies None added or bumped. Closes #802 (pending maintainer 👍 per CONTRIBUTING — raised there first with the full spec). --------- Co-authored-by: Ash Rhodes <ashley.rhodes@king.com>
2026-06-11 04:04:45 +02:00
# ---------------------------------------------------------------------------
# Per-project savings: env_http_headers in the injected provider block
# ---------------------------------------------------------------------------
class TestCodexProjectHeaderConfig:
"""The injected provider maps X-Headroom-Project to HEADROOM_PROJECT.
Codex's ``env_http_headers`` sends a header only when the mapped env var
is set at Codex runtime, so `headroom wrap codex` exports
``HEADROOM_PROJECT`` and the proxy attributes savings per project.
"""
def test_inject_writes_env_http_headers_mapping(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_set_test_home(monkeypatch, tmp_path)
wrap_mod._inject_codex_provider_config(8787)
content = (tmp_path / ".codex" / "config.toml").read_text()
assert 'env_http_headers = { "X-Headroom-Project" = "HEADROOM_PROJECT" }' in content
def test_env_http_headers_inside_provider_section(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""The mapping must live inside [model_providers.headroom], before
the closing marker, so it applies to the Headroom provider."""
_set_test_home(monkeypatch, tmp_path)
wrap_mod._inject_codex_provider_config(8787)
content = (tmp_path / ".codex" / "config.toml").read_text()
section_start = content.index("[model_providers.headroom]")
mapping_pos = content.index("env_http_headers")
end_marker_pos = content.index(wrap_mod._CODEX_END_MARKER, section_start)
assert section_start < mapping_pos < end_marker_pos
def test_strip_removes_block_with_env_http_headers(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""_strip_codex_headroom_blocks removes the whole injected block,
including the new env_http_headers line, leaving user content."""
_set_test_home(monkeypatch, tmp_path)
config_dir = tmp_path / ".codex"
config_dir.mkdir()
config_file = config_dir / "config.toml"
original = '[profiles.default]\nmodel = "gpt-4o"\n'
config_file.write_text(original)
wrap_mod._inject_codex_provider_config(8787)
wrapped = config_file.read_text()
assert "env_http_headers" in wrapped
cleaned = wrap_mod._strip_codex_headroom_blocks(wrapped)
assert "env_http_headers" not in cleaned
assert "X-Headroom-Project" not in cleaned
assert "[model_providers.headroom]" not in cleaned
assert 'model = "gpt-4o"' in cleaned