## Description
`headroom install apply --providers manual --target codex --scope
provider` silently failed to route Codex through the proxy whenever
`~/.codex/config.toml` already had a `[table]` section (e.g.
`[features]`, `[mcp_servers.*]`). `apply_provider_scope` appended the
managed `model_provider = "headroom"` block after the last existing
table, so TOML scoped the bare key into that table instead of the
document root — Codex silently ignored it and kept routing through its
default provider. The same code path never overrode a pre-existing
top-level `model_provider` assignment either, so a user's
`model_provider = "openai"` kept winning even when Headroom's block was
appended elsewhere in the file.
This mirrors a bug already fixed in the `headroom init` path
(`_ensure_codex_provider`, #260) that was never ported to the
persistent-install path.
Closes: reported via user session (no tracked issue number yet).
## Type of Change
- [x] Bug fix (non-breaking change which fixes an issue)
## Changes Made
- **`headroom/providers/codex/install.py`**: Added
`_insert_block_at_root()`, which walks the document line-by-line and
inserts the managed marker block immediately above the first
`[table]`/`[[array-of-tables]]` header, falling back to end-of-file
append only when no table exists. Mirrors the root-insertion logic
already used by `cli/init.py:_ensure_codex_provider`.
- Added `_ANY_MODEL_PROVIDER` / `_ANY_OPENAI_BASE_URL` patterns (match
any value, not just `"headroom"`) so `apply_provider_scope` strips
**any** prior top-level `model_provider` / `openai_base_url` assignment
before re-inserting the managed block — the managed keys now override
the user's config outright instead of losing to it.
- `apply_provider_scope` merge order is now: strip old managed block →
strip prior top-level assignments → insert fresh block at document root.
## Testing
- [x] **New regression test**:
`test_apply_codex_provider_scope_lands_model_provider_at_root`
(`tests/test_install/test_providers.py`) — asserts `model_provider =
"headroom"` lands before `[features]`, overrides a prior `"openai"`
value, and the user's own table content survives.
- [x] **Existing tests**: `tests/test_install/test_providers.py` — 42/42
pass (includes prior codex apply/revert/replace/orphan-cleanup
coverage).
- [x] **Adversarial (ad-hoc, not committed)**: 6-case TOML round-trip
proof — parses output with `tomllib` (not substring matching) across:
prior provider before a table, no prior provider, empty file,
scalars-only (no tables), CRLF line endings, multiple tables. All 6 pass
after the fix; first pass caught a false failure from a stale globally
pip-installed `headroom` copy shadowing the repo source when tests run
outside the project directory — re-verified from inside the repo to
confirm the fix itself is correct.
- [x] **Lint**: `ruff check` and `ruff format --check` pass on both
changed files.
```text
$ uv run pytest tests/test_install/test_providers.py -q
42 passed in 0.21s
$ uv run --with ruff ruff check headroom/providers/codex/install.py tests/test_install/test_providers.py
All checks passed!
$ uv run --with ruff ruff format --check headroom/providers/codex/install.py tests/test_install/test_providers.py
2 files already formatted
```
## Real Behavior Proof
- Environment: macOS 26.4.1 (arm64), Python 3.13.14, headroom branch
`patch/install-codex`
- Exact command / steps: constructed a temp `config.toml` with
`[features]\nweb_search = true` (no existing Headroom block), invoked
`apply_provider_scope(manifest)` against it with `codex_config_path`
patched to the temp file, then parsed the result with `tomllib.loads()`.
- Observed result: before the fix,
`tomllib.loads(result)["model_provider"]` raised `KeyError` — the key
was nested inside `[features]` due to end-of-file append. After the fix,
`parsed["model_provider"] == "headroom"` and
`parsed["features"]["web_search"] is True` — both the managed key and
the user's table are present and correctly scoped. Revert removes
`model_provider` and preserves the user's table.
- Tested local build and behavior is correct as expected of this patch.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Description
The CI lint job (`ruff check .` → `ruff format --check .` → `mypy
headroom`) was red on `main`
and therefore on every open PR, for two unrelated reasons that the early
ruff failure was masking:
1. **ruff**: the lint job installs `ruff` unpinned, and ruff 0.15.17
began enforcing import-block
sorting (`I001`) and formatting that older ruff accepted → `ruff check
.` / `ruff format --check .`
fail on files nobody touched.
2. **mypy**: `headroom/providers/opencode/config.py` had two `return
json.loads(...)` statements in
a function declared `-> dict[str, Any]`; `json.loads` is typed `Any`, so
`mypy headroom` fails
with `no-any-return` (reproduced on mypy 1.20.2 — not a version-specific
quirk).
This restores a green lint baseline and pins both linters so a future
release can't silently break
CI again.
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
## Changes Made
- `.github/workflows/ci.yml`: pin `ruff==0.15.17` and `mypy==1.20.2` in
the lint job.
- Applied `ruff check --fix .` (6 `I001` import-sort fixes) and `ruff
format .` (10 files) across
the repo — import ordering and whitespace only, no behavior change.
- `headroom/providers/opencode/config.py`: narrow both
`_parse_json_loose` return sites with an
`isinstance(parsed, dict)` guard, so the `dict[str, Any]` annotation is
true at runtime
(non-dict JSON falls back to `{}`) and mypy's `no-any-return` is
resolved.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`, `ruff format --check .`, `mypy
headroom --ignore-missing-imports`)
### Test Output
```text
$ python -m ruff check .
All checks passed!
$ python -m ruff format --check .
913 files already formatted
$ python -m mypy headroom/providers/opencode/config.py --ignore-missing-imports
Success: no issues found in 1 source file
$ python -m pytest tests/test_providers_opencode_config.py -q
37 passed
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.13, ruff 0.15.17, mypy 1.20.2,
branch ci/fix-ruff-lint off
headroomlabs-ai/main
- Exact command / steps: reproduced the red lint (latest ruff: 6 `I001`
+ 10 unformatted files;
the mypy failure was read from the #1295 CI lint log —
`config.py:125,133 no-any-return`, and
re-confirmed locally on mypy 1.20.2). Applied the ruff auto-fix/format,
added the dict guard,
pinned both linters, and re-ran each lint step.
- Observed result: `ruff check .` → "All checks passed!"; `ruff format
--check .` → "913 files
already formatted"; `mypy` on the fixed file → "Success: no issues
found"; full `mypy headroom`
reports only Unix `fcntl` attributes that don't exist on this Windows
box (present on the Linux
CI runner, where the prior run showed exactly the two now-fixed errors).
37 opencode-config
tests pass.
- Not tested: did not run the full OS/Python test matrix — the change is
formatting + two CI
dependency pins + a two-line type-narrowing guard, with no runtime
behavior change for dict JSON.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
## Summary
This PR implements transparent `headroom wrap opencode` support without
asking users to edit OpenCode provider URLs, choose an extra CLI flag,
or maintain a static provider list.
The wrapper now lives at the runtime transport boundary: OpenCode keeps
its user/provider config, while Headroom intercepts outbound provider
traffic in-process and routes it through the local Headroom proxy.
## What changed
### Transparent OpenCode wrapping
- `headroom wrap opencode` injects the `headroom-opencode` plugin
through `OPENCODE_CONFIG_CONTENT`.
- Existing OpenCode provider URLs are preserved. We do not rewrite user
config URLs to point at Headroom.
- Existing `OPENAI_BASE_URL` and `ANTHROPIC_BASE_URL` env vars are
preserved.
- Local OpenCode traffic, localhost traffic, and Headroom proxy traffic
bypass the shim to avoid loops.
### Runtime transport interception
- Added an OpenCode plugin transport shim that wraps:
- `globalThis.fetch`
- `http.request` / `http.get`
- `https.request` / `https.get`
- External provider calls are routed to the local Headroom proxy.
- The original upstream origin is passed through `x-headroom-base-url`,
so the proxy can forward to the real provider without changing OpenCode
config.
- External `http2.connect` is blocked loudly instead of allowing direct
provider traffic to leak outside Headroom.
### Live provider additions
Provider coverage is no longer based on a static config scan. Because
routing happens at outbound request time, providers added mid-session
are routed through Headroom automatically as long as they use the
covered Node transport paths.
### Subagent and child-process coverage
- The parent OpenCode plugin sets a packaged Node preload shim through
`NODE_OPTIONS=--import=.../hook-shim/handler.js`.
- The transport shim patches `child_process.spawn`, `exec`, `execFile`,
and `fork` so child Node processes receive the Headroom preload even
when OpenCode passes a custom `env`.
- The child-process shim fails closed if it loads without
`HEADROOM_OPENCODE_TRANSPORT_PROXY_URL`.
- This closes the subagent leak path where a child Node process could
otherwise start without Headroom transport interception.
## Why this goes beyond PR #1089
PR #1089 improves OpenCode provider registration, but it still focuses
on provider config shape. This PR moves the enforcement boundary to
runtime transport interception.
This PR goes further because:
- No provider URL rewriting is required.
- New providers added mid-session are covered automatically.
- Subagents and child Node processes inherit the Headroom transport
shim.
- Direct external HTTP/2 paths fail loudly instead of leaking.
- The wrap remains transparent to the user's OpenCode provider config.
- The wrapper is fail-closed for unsupported child-process preload
state.
## Additional robustness fixes
While validating the change in Docker, the full Python suite exposed
unrelated Linux/container robustness issues. These are fixed in this PR
so the suite is green:
- Binary cache handling now treats cache paths under a non-writable
existing parent as unavailable, including when tests run as root in
Docker.
- `release_version.py` honors `MANUAL_VER` before git calls so direct
script execution works outside a `.git` checkout.
- Test logger isolation now resets relevant Headroom child loggers so
proxy logging setup cannot poison later `caplog` tests.
- The scanner missing-path test now uses a guaranteed missing `tmp_path`
child instead of relying on `/nonexistent/path`.
## Validation
All implementation validation was run inside Docker.
- Full Python suite from a fresh Docker copy: `6605 passed, 523
skipped`.
- Ruff on changed Python/OpenCode paths: passed.
- OpenCode plugin typecheck: passed.
- OpenCode plugin tests: `9 passed`.
- OpenCode plugin build: passed.
- Hook shim preload smoke test: passed.
## Notes
This PR intentionally does not add a CLI option. `headroom wrap
opencode` means full wrap. Either Headroom wraps OpenCode transparently,
or the path fails loudly instead of silently leaking provider traffic.
---------
Co-authored-by: Rudimar Ronsoni <6081613+rudironsoni@users.noreply.github.com>
## Description
Claude Code disables on-demand tool loading (Tool Search) when
`ANTHROPIC_BASE_URL` is a custom host and `ENABLE_TOOL_SEARCH` is unset,
materializing all MCP/system tool schemas into its context window
(#746). With many MCP servers this overflows the window — breaking
sub-agent spawns ("prompt too long, ~214k > 200k") and forcing constant
compaction. `headroom wrap claude` already sets it; `init`/install did
not. Refs #746.
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
## Changes Made
- Keep tool deferral on at both entry points, sharing one
`TOOL_SEARCH_ENV` / `TOOL_SEARCH_DEFAULT` constant from the Claude
provider package (`providers/claude/runtime.py`) so the key/default
can't drift:
- `init` (`_ensure_claude_hooks`): sets `ENABLE_TOOL_SEARCH=true` via
`setdefault`, respecting a pre-existing user-provided value.
- `install` (`build_install_env`): always writes
`ENABLE_TOOL_SEARCH=true`. This is the headroom-managed install env
(recorded and reverted on uninstall), so it is authoritative rather than
deferring to an existing value.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] New tests added for new functionality
- [x] Manual testing performed
### Test Output
```text
$ pytest tests/test_cli/test_init_enable_tool_search.py -q
3 passed in 0.63s
```
## Real Behavior Proof
- Environment: Python 3.14, headroom proxy on :8799, ~19 MCP servers
connected
- Exact command / steps: launched `claude` through the proxy with vs
without `ENABLE_TOOL_SEARCH=true`, asked each to spawn 5 parallel
sub-agents
- Observed result: without it, all 5 sub-agents fail ("prompt too long,
~214k > 200k"); with `ENABLE_TOOL_SEARCH=true` all 5 succeed and traffic
compresses
- Not tested: non-Claude-Code agents
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
## 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>
Three files modified in the previous commit (4071d57) needed ruff
format reformatting per CI's `ruff format --check .` step.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Restore build_provider_section() to headroom/providers/codex/install.py
without requires_openai_auth (was removed entirely; pre-existing test
test_provider_codex_install.py imports it and would fail to collect)
- Flip test_codex_provider_section_preserves_openai_oauth to assert
requires_openai_auth is ABSENT, not present (old behavior was wrong)
- Fix test_provider_codex_runtime.py:337 same way — init config must
NOT contain requires_openai_auth
- Fix Ruff B023 lint error in test_providers.py:492 — capture loop
variable config_path in lambda default arg (_p=config_path)
- Fix e2e/init/run.py _verify_codex_local and _verify_codex_global to
assert requires_openai_auth is absent, not present
- Fix e2e/wrap/run.py verify_codex_wrap same way
All unit tests pass locally (82 affected tests green).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per issue #393, env_key = "OPENAI_API_KEY" breaks ChatGPT subscription
users who don't have OPENAI_API_KEY set. Remove it from wrap, init, and
persistent install entry points. The openai_base_url top-level injection
handles subscription routing without requiring env_key.
Bug 3 fix is now consistent across all three Codex entry points.
Subscription (ChatGPT plan) users will always have their traffic routed
through headroom regardless of whether they reached Codex config via
`headroom wrap codex`, `headroom init codex`, or the persistent-install
provider scope — all three now write `openai_base_url` at the TOML
top-level (outside any `[model_providers.*]` block) so Codex's built-in
openai provider is intercepted even when subscription auth bypasses the
`model_provider = "headroom"` selection.
Changes:
- headroom/cli/init.py: add `openai_base_url` line to `_ensure_codex_provider`
block; add `_strip_codex_init_block` helper with orphan-key cleanup
(mirrors `_strip_codex_headroom_blocks` in wrap.py)
- headroom/providers/codex/install.py: add `openai_base_url` line to
`apply_provider_scope` section; add orphan-cleanup regexes and apply
them in `revert_provider_scope` to handle crash-recovery scenarios
- tests/test_install/test_providers.py: add
`test_apply_provider_scope_writes_openai_base_url`,
`test_persistent_install_strip_removes_openai_base_url`
- tests/test_cli/test_init_cli.py: add
`test_init_codex_writes_openai_base_url`,
`test_init_codex_strip_removes_openai_base_url`
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bug 3 (#406) has two halves:
1. Strip requires_openai_auth from all three headroom provider block
emission sites — done in 3ca48d3. This prevented custom-provider traffic
from triggering OpenAI OAuth login prompts.
2. Inject openai_base_url at the top level of ~/.codex/config.toml — this
commit. Without this key, Codex subscription (ChatGPT plan) users bypass
headroom entirely: Codex detects subscription auth and routes through the
built-in openai provider using chatgpt.com/backend-api/codex as the base
URL, ignoring both OPENAI_BASE_URL env var and model_provider = "headroom".
Setting openai_base_url in config.toml overrides that default so both
API-key and subscription traffic flow through the proxy.
Changes:
- headroom/cli/wrap.py: add openai_base_url = "http://127.0.0.1:{port}/v1"
to the top-level marker block in _inject_codex_provider_config; add orphan
cleanup regex for openai_base_url in _strip_codex_headroom_blocks (handles
crash/migration residue).
- tests/test_install/test_providers.py: invert
test_inject_codex_provider_config_does_not_write_openai_base_url →
test_inject_codex_provider_config_writes_openai_base_url (asserts exact
value "http://127.0.0.1:8787/v1" so port drift causes failure); add
test_unwrap_removes_top_level_openai_base_url covering both the
backup-restore path and the _strip_codex_headroom_blocks orphan-cleanup path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add two deterministic, no-network regression tests to prevent bug 3 from
silently re-appearing:
- test_headroom_provider_block_never_sets_requires_openai_auth: calls
apply_provider_scope() directly with multiple ports and asserts the
rendered TOML never contains requires_openai_auth anywhere in the
headroom provider block.
- test_inject_codex_provider_config_does_not_write_openai_base_url:
calls _inject_codex_provider_config(8787) against a tmp_path-based
home dir (via monkeypatched HOME/USERPROFILE) and asserts openai_base_url
is absent at the top level, and requires_openai_auth is absent in the
injected provider block.
Both tests fail loudly with descriptive messages if either field
re-appears after a future change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Remove `requires_openai_auth = true` from all three sites that emit the
`[model_providers.headroom]` block: `headroom/providers/codex/install.py`
(persistent install), `headroom/cli/wrap.py` (_inject_codex_provider_config),
and `headroom/cli/init.py` (_ensure_codex_provider).
The field belongs only on the built-in `openai` provider where codex
hardcodes it. Setting it on a custom local-proxy provider forces codex
to demand OpenAI OAuth login for every headroom-routed request.
Top-level `openai_base_url` injection was audited — it was never written
to config.toml by the current codebase, only referenced in comments and
env-var routing logic. No change needed there.
Update test_apply_codex_provider_scope_replaces_existing_managed_block to
assert the replacement block no longer carries requires_openai_auth.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Preserve Codex OAuth-safe provider config across init, wrap, and
persistent install paths, and strengthen coverage so Codex requests
are proven to reach Headroom and the mock upstream.
The wrap e2e now sends a real chat-completions probe and checks
Headroom /stats. Runtime tests cover temporary launch env, install
env, init config, provider-scope config delivery, and the Python
3.11 ws bootstrap path.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add focused regression coverage for install, runtime, provider, state, health, supervisor, and persistent wrap flows so the new persistent deployment surfaces are exercised more thoroughly in CI.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Align Docker-native wrapper help and runtime behavior with the Python install contract, including persistent deployment metadata, baked install-image defaults, and explicit unsupported wrap targets.
Harden the Python persistent-install path with profile validation, safer provider-scope handling, Windows environment restoration, runtime parity improvements, and rollback-safe apply/update behavior.
Update README, Docker install docs, CI, and focused regressions to cover the Windows BOM failure, wrapper parity, compose coverage, and Docker-native wrap behavior.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>