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
|
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)
|
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'
|
2026-05-09 13:47:53 -07:00
|
|
|
cleaned = wrap_mod._strip_codex_headroom_blocks(content, remove_mcp=True)
|
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
|
|
|
|
|
|
2026-05-09 13:47:53 -07:00
|
|
|
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"
|
2026-05-09 13:47:53 -07:00
|
|
|
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
|
2026-05-09 13:47:53 -07:00
|
|
|
assert "[mcp_servers.headroom_memory]" not in cleaned
|
|
|
|
|
assert 'model = "gpt-4o"' in cleaned
|
|
|
|
|
|
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()
|
|
|
|
|
|
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
|
2026-05-05 14:33:41 -07:00
|
|
|
# Latest port is honoured in both keys.
|
2026-04-23 11:13:54 -05:00
|
|
|
assert 'base_url = "http://127.0.0.1:9999/v1"' in content
|
2026-05-05 14:33:41 -07:00
|
|
|
assert 'openai_base_url = "http://127.0.0.1:9999/v1"' in content
|
2026-04-23 11:13:54 -05:00
|
|
|
assert 'base_url = "http://127.0.0.1:8787/v1"' not in content
|
2026-05-05 14:33:41 -07:00
|
|
|
assert 'openai_base_url = "http://127.0.0.1:8787/v1"' not in content
|
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
|
|
|
|
|
|
2026-05-09 13:47:53 -07:00
|
|
|
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
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-05-05 14:33:41 -07:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# 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()
|
|
|
|
|
|
2026-05-05 14:33:41 -07:00
|
|
|
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
|
|
|
|
|
|
2026-05-05 14:54:01 -07:00
|
|
|
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
|
|
|
|
|
|
2026-05-05 14:33:41 -07:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
|
2026-05-09 13:47:53 -07:00
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
|
2026-05-09 13:47:53 -07:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-05-08 19:01:32 -07:00
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
2026-05-09 13:47:53 -07:00
|
|
|
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"])
|
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()
|
2026-05-09 13:47:53 -07:00
|
|
|
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"))
|
2026-05-09 13:47:53 -07:00
|
|
|
|
|
|
|
|
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()
|
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(
|
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"))
|
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
|
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
|