headroom/tests/test_cli
wangxiangyu7 dd22cfd72a
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-15 11:04:29 -05:00
..
test_copilot_auth.py fix: support Copilot Business subscription auth (#641) 2026-06-12 20:46:38 -05:00
test_init_cli.py fix(codex): poll /wham/usage for subscription limits (handshake no longer sends x-codex-* headers) (#924) 2026-06-12 17:03:14 -05:00
test_install_cli.py test: expand persistent install coverage 2026-04-11 18:24:15 -05:00
test_main_help_version.py feat(cli): add Docker-native install flow and parity docs 2026-04-10 23:27:24 -05:00
test_mcp.py fix(mcp): auto-register headroom MCP server in wrap claude/codex and init -g 2026-05-08 17:18:32 -07:00
test_serena_disable.py fix(serena): stop the Serena dashboard popup and make --no-serena actually disable Serena (#1003) 2026-06-14 23:32:46 -07:00
test_unwrap_claude.py fix: register serena mcp during wrap 2026-05-09 23:05:59 -07:00
test_wrap_aider.py feat(proxy): per-project savings breakdown on the dashboard (claude, codex, aider, copilot, cursor) (#803) 2026-06-10 21:04:45 -05:00
test_wrap_bridge.py feat: add lean-ctx context tool support 2026-05-11 17:54:17 -04:00
test_wrap_codex.py fix(wrap): avoid duplicate top-level keys when injecting codex provider (#884) 2026-06-15 11:04:29 -05:00
test_wrap_continue.py fix(cli): G1 remediation — non-string clobber, per-model systemMessage, openhands gate 2026-05-25 11:54:06 -07:00
test_wrap_copilot.py fix: support Copilot Business subscription auth (#641) 2026-06-12 20:46:38 -05:00
test_wrap_goose.py refactor(cli): factor shared wrap-subcommand scaffolding 2026-05-26 11:22:50 -07:00
test_wrap_helpers.py fix(wrap): track shared proxy clients with markers (#877) 2026-06-11 19:42:43 -05:00
test_wrap_hintfile_agents.py refactor(cli): factor shared wrap-subcommand scaffolding 2026-05-26 11:22:50 -07:00
test_wrap_openclaw.py fix: stabilize codex compression, stats, and proxy lifecycle 2026-05-09 13:47:53 -07:00
test_wrap_openhands.py fix(cli): G1 remediation — non-string clobber, per-model systemMessage, openhands gate 2026-05-25 11:54:06 -07:00
test_wrap_persistent.py fix(wrap): track shared proxy clients with markers (#877) 2026-06-11 19:42:43 -05:00
test_wrap_rtk_metrics.py fix(observability): G3 remediation — bound cardinality + wire dead metrics 2026-05-24 10:41:56 -07:00