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.
This commit is contained in:
Gonzalo Zanelli 2026-06-10 20:21:29 -03:00 committed by GitHub
parent 27befef694
commit 96abf38b09
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 155 additions and 19 deletions

View file

@ -55,6 +55,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Bug Fixes
* **codex:** respect `CODEX_HOME` when `headroom wrap codex` writes provider, MCP, memory, backup, and global `AGENTS.md` config, and warn when `unwrap codex` may be looking at the default Codex home because `CODEX_HOME` is unset.
* **startup:** suppress proxy startup log noise — litellm banner, trafilatura parse errors, HuggingFace Hub unauthenticated warnings, tiktoken fallback warning, and httpx INFO lines from sentence_transformers HEAD checks. Affected files: `headroom/providers/litellm.py`, `headroom/transforms/html_extractor.py`, `headroom/memory/adapters/embedders.py`, `headroom/providers/anthropic.py`, `headroom/providers/registry.py`, `headroom/image/onnx_router.py`, `headroom/transforms/kompress_compressor.py`.
* **deps:** move `gunicorn` to `[proxy-prod]` extra with `sys_platform != 'win32'` guard; removed from `[proxy]` to avoid forcing a Unix-only package on dev, CI, and Windows users ([#537](https://github.com/chopratejas/headroom/pull/537))
* **startup:** suppress proxy startup log noise — litellm banner, trafilatura parse errors, HuggingFace Hub unauthenticated warnings, tiktoken fallback warning, and httpx INFO lines from sentence_transformers HEAD checks. Affected files: `headroom/providers/litellm.py`, `headroom/transforms/html_extractor.py`, `headroom/memory/adapters/embedders.py`, `headroom/providers/anthropic.py`, `headroom/providers/registry.py`, `headroom/image/onnx_router.py`, `headroom/transforms/kompress_compressor.py`.

View file

@ -746,16 +746,24 @@ _CODEX_TOP_LEVEL_MARKER = "# --- Headroom proxy (auto-injected by headroom wrap
_CODEX_END_MARKER = "# --- end Headroom ---"
_CODEX_MCP_MARKER = "# --- Headroom MCP server ---"
_CODEX_MCP_END = "# --- end Headroom MCP server ---"
# File name used for the pre-wrap snapshot of ~/.codex/config.toml. The
# File name used for the pre-wrap snapshot of the Codex config file. The
# snapshot lets `headroom unwrap codex` restore the exact prior state, even
# if the user had their own `model_provider` / `[model_providers.*]` config
# before running wrap.
_CODEX_CONFIG_BACKUP_SUFFIX = ".headroom-backup"
def _codex_home_dir() -> Path:
"""Return Codex's config directory, respecting ``CODEX_HOME`` when set."""
codex_home = os.environ.get("CODEX_HOME")
if codex_home:
return Path(codex_home).expanduser()
return Path.home() / ".codex"
def _codex_config_paths() -> tuple[Path, Path]:
"""Return ``(config_file, backup_file)`` paths for the Codex TOML config."""
config_dir = Path.home() / ".codex"
config_dir = _codex_home_dir()
config_file = config_dir / "config.toml"
backup_file = config_dir / f"config.toml{_CODEX_CONFIG_BACKUP_SUFFIX}"
return config_file, backup_file
@ -895,7 +903,7 @@ def _inject_codex_provider_config(port: int) -> None:
Safe to call multiple times the injected block is fully replaced on
each call, so re-running with a different ``port`` updates the config.
Before the first injection, the pre-wrap file is snapshotted to
``~/.codex/config.toml.headroom-backup`` so ``headroom unwrap codex``
``config.toml.headroom-backup`` so ``headroom unwrap codex``
can restore it byte-for-byte.
"""
config_file, backup_file = _codex_config_paths()
@ -953,7 +961,7 @@ def _inject_codex_provider_config(port: int) -> None:
def _restore_codex_provider_config() -> tuple[str, Path]:
"""Undo ``_inject_codex_provider_config`` for ``~/.codex/config.toml``.
"""Undo ``_inject_codex_provider_config`` for the active Codex config file.
Returns a tuple of ``(status, config_file)`` where status is one of:
@ -1175,8 +1183,8 @@ def _inject_memory_mcp_config(db_path: str, user_id: str) -> None:
"""
import sys
config_dir = Path.home() / ".codex"
config_file = config_dir / "config.toml"
config_file, _ = _codex_config_paths()
config_dir = config_file.parent
# Use forward slashes in TOML paths (works on all platforms, avoids
# backslash escaping issues on Windows)
@ -2815,8 +2823,8 @@ def codex(
Sets OPENAI_BASE_URL to route all OpenAI API calls through Headroom.
Sets up the selected CLI context tool so Codex uses token-optimized
commands (60-90% savings on shell output). Also
registers the headroom MCP server in ~/.codex/config.toml so Codex
can call ``headroom_retrieve`` on compression markers.
registers the headroom MCP server in the active Codex config file
so Codex can call ``headroom_retrieve`` on compression markers.
\b
Examples:
@ -2828,7 +2836,7 @@ def codex(
headroom wrap codex --port 9999 # Custom proxy port
headroom wrap codex --backend anyllm --anyllm-provider groq
"""
# Snapshot ~/.codex/config.toml BEFORE any wrap-time mutation so
# Snapshot Codex config.toml BEFORE any wrap-time mutation so
# `headroom unwrap codex` can restore the user's pre-wrap state
# byte-for-byte. The snapshot is a no-op if the backup already exists
# or if the file already has Headroom markers, so this is safe to
@ -2850,11 +2858,11 @@ def codex(
agents_md = Path.cwd() / "AGENTS.md"
_inject_rtk_instructions(agents_md, verbose=verbose)
# Also inject into global ~/.codex/AGENTS.md
global_agents = Path.home() / ".codex" / "AGENTS.md"
# Also inject into global Codex AGENTS.md
global_agents = _codex_home_dir() / "AGENTS.md"
_inject_rtk_instructions(global_agents, verbose=verbose)
# Register headroom MCP server in ~/.codex/config.toml so Codex can
# Register headroom MCP server in Codex config.toml so Codex can
# call headroom_retrieve on compression markers from the proxy.
if not no_mcp:
from headroom.mcp_registry import CodexRegistrar
@ -3950,7 +3958,7 @@ def unwrap_openclaw(
@click.option("--port", "-p", default=8787, type=int, help="Proxy port (default: 8787)")
@click.option("--no-stop-proxy", is_flag=True, help="Do not stop the local Headroom proxy")
def unwrap_codex(port: int, no_stop_proxy: bool) -> None:
"""Undo ``headroom wrap codex`` edits to ``~/.codex/config.toml``.
"""Undo ``headroom wrap codex`` edits to the active Codex config file.
Behaviour:
@ -3962,7 +3970,10 @@ def unwrap_codex(port: int, no_stop_proxy: bool) -> None:
* If the config only ever contained Headroom-written content, the file
is removed entirely so Codex falls back to its defaults.
* If neither a backup nor a Headroom block is present, this is a safe
no-op (the user either never wrapped, or already unwrapped).
no-op (the user either never wrapped that config, or already unwrapped
it). When ``CODEX_HOME`` is unset, print a warning hint because Headroom
may be looking at the default config while Codex was wrapped with a
custom home.
"""
click.echo()
click.echo(" ╔═══════════════════════════════════════════════╗")
@ -3982,6 +3993,13 @@ def unwrap_codex(port: int, no_stop_proxy: bool) -> None:
elif status == "removed":
click.echo(f" Removed {config_file} (contained only Headroom-written config).")
else:
if not os.environ.get("CODEX_HOME"):
click.echo(
" 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."
)
click.echo(f" Nothing to undo: {config_file} has no Headroom wrap markers.")
click.echo()

View file

@ -1,6 +1,7 @@
"""OpenAI Codex CLI MCP registrar.
Codex stores MCP server config in ``~/.codex/config.toml`` as
Codex stores MCP server config in ``$CODEX_HOME/config.toml`` when
``CODEX_HOME`` is set, otherwise ``~/.codex/config.toml``, as
``[mcp_servers.<name>]`` tables (with optional ``[mcp_servers.<name>.env]``
sub-tables). There is no general-purpose CLI for adding entries, so we
edit the file in place using marker-delimited blocks so we can
@ -11,6 +12,7 @@ anything else the user has configured.
from __future__ import annotations
import logging
import os
import sys
from pathlib import Path
from typing import Any
@ -47,9 +49,13 @@ class CodexRegistrar(MCPRegistrar):
display_name = "OpenAI Codex CLI"
def __init__(self, *, home_dir: Path | None = None) -> None:
home = home_dir if home_dir is not None else Path.home()
self._codex_dir = home / ".codex"
self._config_file = home / ".codex" / "config.toml"
if home_dir is not None:
self._codex_dir = home_dir / ".codex"
elif os.environ.get("CODEX_HOME"):
self._codex_dir = Path(os.environ["CODEX_HOME"]).expanduser()
else:
self._codex_dir = Path.home() / ".codex"
self._config_file = self._codex_dir / "config.toml"
# ------------------------------------------------------------------
# MCPRegistrar interface

View file

@ -23,6 +23,7 @@ def _set_test_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
home = str(tmp_path)
monkeypatch.setenv("HOME", home)
monkeypatch.setenv("USERPROFILE", home)
monkeypatch.delenv("CODEX_HOME", raising=False)
@pytest.fixture
@ -176,6 +177,23 @@ class TestInjectAndRestoreRoundTrip:
assert not config_file.exists()
assert not (tmp_path / ".codex" / "config.toml.headroom-backup").exists()
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()
def test_wrap_unwrap_restores_prior_model_provider(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
@ -411,6 +429,68 @@ def test_wrap_codex_prepare_only_creates_backup_and_config(
assert backup.read_text() == original
def test_wrap_codex_prepare_only_respects_codex_home(
runner: CliRunner, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_set_test_home(monkeypatch, tmp_path)
codex_home = tmp_path / "custom-codex-home"
codex_home.mkdir()
monkeypatch.setenv("CODEX_HOME", str(codex_home))
with patch("headroom.cli.wrap._ensure_rtk_binary", return_value=None):
result = runner.invoke(
main,
["wrap", "codex", "--prepare-only", "--no-serena", "--port", "8787"],
)
assert result.exit_code == 0, result.output
config_file = codex_home / "config.toml"
assert config_file.exists()
content = config_file.read_text()
assert 'model_provider = "headroom"' in content
assert "[mcp_servers.headroom]" in content
assert not (tmp_path / ".codex" / "config.toml").exists()
def test_unwrap_codex_without_codex_home_warns_on_ambiguous_noop(
runner: CliRunner, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_set_test_home(monkeypatch, tmp_path)
codex_home = tmp_path / "custom-codex-home"
codex_home.mkdir()
monkeypatch.setenv("CODEX_HOME", str(codex_home))
with patch("headroom.cli.wrap._ensure_rtk_binary", return_value=None):
wrap_result = runner.invoke(
main,
[
"wrap",
"codex",
"--prepare-only",
"--no-mcp",
"--no-serena",
"--port",
"8787",
],
)
assert wrap_result.exit_code == 0, wrap_result.output
config_file = codex_home / "config.toml"
assert 'openai_base_url = "http://127.0.0.1:8787/v1"' in config_file.read_text()
monkeypatch.delenv("CODEX_HOME", raising=False)
unwrap_result = runner.invoke(main, ["unwrap", "codex", "--no-stop-proxy"])
assert unwrap_result.exit_code == 0, unwrap_result.output
assert "Warning: found no Headroom wrap markers in the default Codex config" in (
unwrap_result.output
)
assert "If you wrapped Codex with CODEX_HOME" in unwrap_result.output
assert "CODEX_HOME=/path/to/codex-home headroom unwrap codex" in unwrap_result.output
assert "Nothing to undo" in unwrap_result.output
assert 'openai_base_url = "http://127.0.0.1:8787/v1"' in config_file.read_text()
def test_start_proxy_uses_separate_session_for_signal_isolation(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
@ -576,6 +656,7 @@ 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)
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "explicit-codex-home"))
with patch("headroom.cli.wrap._stop_local_proxy_for_unwrap") as stop_proxy:
result = runner.invoke(main, ["unwrap", "codex", "--no-stop-proxy"])
@ -613,14 +694,16 @@ def test_stop_local_proxy_for_unwrap_refuses_unidentified_listener(
kill_proxy.assert_not_called()
def test_unwrap_codex_is_safe_noop_with_no_prior_wrap(
def test_unwrap_codex_is_safe_noop_with_explicit_codex_home(
runner: CliRunner, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_set_test_home(monkeypatch, tmp_path)
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "explicit-codex-home"))
result = runner.invoke(main, ["unwrap", "codex"])
assert result.exit_code == 0, result.output
assert "Nothing to undo" in result.output
assert "Warning:" not in result.output
assert not (tmp_path / ".codex" / "config.toml").exists()

View file

@ -49,6 +49,10 @@ def _config_path(tmp_path: Path) -> Path:
return tmp_path / ".codex" / "config.toml"
def _codex_home_config_path(codex_home: Path) -> Path:
return codex_home / "config.toml"
# ----------------------------------------------------------------------
# detect()
# ----------------------------------------------------------------------
@ -63,6 +67,29 @@ def test_detect_false_when_codex_dir_missing(tmp_path: Path) -> None:
assert _make_registrar(tmp_path).detect() is False
def test_detect_true_when_codex_home_exists(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
codex_home = tmp_path / "custom-codex-home"
codex_home.mkdir()
monkeypatch.setenv("CODEX_HOME", str(codex_home))
assert CodexRegistrar().detect() is True
def test_register_uses_codex_home_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
codex_home = tmp_path / "custom-codex-home"
monkeypatch.setenv("CODEX_HOME", str(codex_home))
result = CodexRegistrar().register_server(_spec())
assert result.status == RegisterStatus.REGISTERED
assert _codex_home_config_path(codex_home).exists()
assert not _config_path(tmp_path).exists()
text = _codex_home_config_path(codex_home).read_text()
assert "[mcp_servers.headroom]" in text
# ----------------------------------------------------------------------
# get_server()
# ----------------------------------------------------------------------