Commit graph

191 commits

Author SHA1 Message Date
Rod Boev
ad7993bf15
fix(codex): stop pinning Codex memory MCP to one project db (#1269)
## Description

Stop `headroom wrap codex --memory` from pinning the global
`headroom_memory` MCP server to one absolute SQLite path. Today the
wrapper writes `--db <wrap-cwd>/.headroom/memory.db` into
`~/.codex/config.toml`, which makes later Codex sessions either reopen a
stale project-local DB or fail with `unable to open database file` when
that original path disappears. This change lets the MCP server use its
existing per-cwd default again, so each Codex session resolves
`.headroom/memory.db` from the active project instead of a serialized
past cwd. Closes #1147

The current Codex-memory config surface was shaped by
https://github.com/chopratejas/headroom/issues/462 and
https://github.com/chopratejas/headroom/issues/730; this PR keeps that
surface project-scoped again instead of globally pinning one DB.

## 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

- remove the injected `--db` argument from the global `headroom_memory`
Codex MCP block while keeping `--user` intact
- preserve the wrap-time local `.headroom/memory.db` setup and
Claude-memory import path for the current project
- treat only wrap-owned Codex markers as snapshot-suppression and
unwrap-cleanup signals, so pre-existing named MCP blocks still back up
and restore
- log a startup diagnostic from `headroom.memory.mcp_server` that
records the configured DB path, config source, cwd/project root,
resolved storage scope, path existence/readability, and whether the path
was static or cwd-derived
- add a shared MCP SDK test stub so both the memory MCP and CCR MCP test
surfaces still run in CI when `mcp` is absent
- make the shared MCP stub re-import target modules under the stubbed
dependency set and restore any pre-existing target module object plus
dotted parent-package attribute state after cleanup
- add focused regressions and guard coverage for the persisted Codex
config shape, named-MCP marker backup and restore, the no-backup
memory-only unwrap path, the wrap-memory-then-unwrap cleanup path, the
failed-wrap memory-only cleanup path, the startup-diagnostic path
classification, the shared-store CCR retrieval path, and the shared MCP
stub import lifecycle
- add a `CHANGELOG.md` entry for the user-visible Codex memory scoping
fix

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
uv run pytest tests/test_ccr_mcp_server.py tests/test_memory/test_mcp_server.py tests/test_cli/test_wrap_codex.py tests/test_mcp_stub.py
======================== 78 passed, 1 warning in 5.96s ========================
Pytest warning:
PytestConfigWarning: Unknown config option: asyncio_mode
Pytest post-success atexit noise:
PermissionError: [WinError 5] Access is denied: 'C:\Users\Rod\AppData\Local\Temp\pytest-of-Rod\pytest-current'

uv run ruff check headroom/cli/wrap.py headroom/memory/mcp_server.py tests/_mcp_stub.py tests/test_ccr_mcp_server.py tests/test_cli/test_wrap_codex.py tests/test_mcp_stub.py tests/test_memory/test_mcp_server.py
All checks passed!

uv run ruff format headroom/cli/wrap.py headroom/memory/mcp_server.py tests/_mcp_stub.py tests/test_ccr_mcp_server.py tests/test_cli/test_wrap_codex.py tests/test_mcp_stub.py tests/test_memory/test_mcp_server.py --check
7 files already formatted
```

## Real Behavior Proof

- Environment: isolated temp project directories, a temp Codex home, the
real `wrap codex` and `unwrap codex` CLI commands under pytest, a mocked
missing-`codex` launch path for the failed-wrap cleanup case, and shared
MCP-SDK stubs for the memory MCP and CCR MCP test modules so CI still
exercises those paths without a real `mcp` install.
- Exact command / steps: run `uv run pytest tests/test_ccr_mcp_server.py
tests/test_memory/test_mcp_server.py tests/test_cli/test_wrap_codex.py
tests/test_mcp_stub.py`; prove the persisted config shape with
`TestCodexMemoryMcpConfig::test_inject_omits_db_and_replaces_existing_memory_block`;
prove prepare-only wrap cleanup with
`test_wrap_codex_memory_prepare_only_unwrap_removes_memory_mcp_without_prior_config`;
prove failed-wrap cleanup with
`test_wrap_codex_memory_launch_failure_unwrap_cleans_memory_only_config`;
guard pre-existing named Codex MCP preservation with
`test_memory_only_wrap_restores_preexisting_named_mcp_block` and
`test_memory_only_wrap_without_backup_preserves_named_mcp_block`; prove
the startup diagnostic classifications with
`test_memory_mcp_startup_context_reports_dynamic_project_db` and
`test_memory_mcp_startup_context_reports_static_external_db`; prove the
shared-store CCR retrieval path with
`test_mcp_uses_shared_singleton_store` and
`test_mcp_retrieves_proxy_stored_content`; prove stub import cleanup
with `test_import_module_with_mcp_stub_imports_target_and_cleans_up`,
`test_import_module_with_mcp_stub_reimports_target_and_restores_originals`,
and
`test_import_module_with_mcp_stub_cleans_up_dotted_target_attribute`.
- Observed result: the persisted global `headroom_memory` block now
keeps `--user` but omits `--db`; prepare-only memory setup still
bootstraps the current project's `.headroom/memory.db`; `headroom unwrap
codex --no-stop-proxy` now removes both the prepare-only generated
config and the failed-wrap memory-only config instead of leaving
`[mcp_servers.headroom_memory]` behind; pre-existing named Codex MCP
blocks remain restorable across both normal and no-backup memory-only
unwrap paths because only wrap-owned markers suppress backups or trigger
named-block cleanup; the memory MCP server now logs whether its DB path
came from the cwd default or an explicit static path, along with the
resolved path and scope it will open; CI can exercise both MCP test
modules even when the `mcp` package is absent from the shard
environment, and the shared stub now re-imports target modules under the
stubbed SDK while restoring both dependency and dotted parent-package
target-module import state after cleanup.
- Not tested: full end-to-end interactive Codex CLI launch.

## 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
- [ ] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [ ] 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

## Additional Notes

The code change stays narrowly scoped to Codex memory config
persistence, cleanup, and startup observability. It does not widen into
larger memory-routing redesign or startup-failure recovery logic.
2026-06-23 07:49:07 -05:00
Terminal Chai
b4fde0c3a4
fix(wrap): add Copilot unwrap command (#1251)
## Description

Adds the missing `headroom unwrap copilot` command so the durable setup
created by `headroom wrap copilot` can be removed without touching
user-authored Copilot instructions.

Closes #1172

## 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

- Add `headroom unwrap copilot` with `--port` and `--no-stop-proxy`
options.
- Remove only Headroom's marker-fenced RTK block from
`.github/copilot-instructions.md`.
- Preserve user-authored content and leave malformed/unmatched markers
unchanged.
- Remove an instruction file that contains only Headroom's generated
block.
- Update the changelog.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
> .\.venv\Scripts\python.exe -X utf8 -m pytest tests/test_cli/test_wrap_copilot.py -q
30 passed in 1.11s

> .\.venv\Scripts\ruff.exe check .
All checks passed!

> uv run --extra dev ruff format --check headroom/cli/wrap.py tests/test_cli/test_wrap_copilot.py
2 files already formatted

> uv run --extra dev mypy headroom/cli/wrap.py
Success: no issues found in 1 source file
```

The new command test failed before the implementation with:

```text
Error: No such command 'copilot'.
```

## Real Behavior Proof

- Environment: Windows, Python 3.12.12, local editable Headroom
checkout.
- Exact command / steps: created an isolated project containing user
guidance plus a Headroom marker-fenced RTK block, then ran
`.venv\Scripts\headroom.exe unwrap copilot --no-stop-proxy`.
- Observed result: command exited `0`, printed `Removed Headroom rtk
instructions from Copilot.`, and the resulting file contained only `Keep
user guidance.`.
- Not tested: a live Copilot CLI session or terminating a real proxy
process; proxy shutdown delegates to the existing tested unwrap helper
and is covered here with a command-level mock.

## 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
- [ ] 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)

Not applicable; this is a CLI-only change.

## Additional Notes

No dependencies were added. The unchecked comment item is not applicable
because the cleanup helper and command are straightforward and
documented with docstrings.

This pull request includes code written with the assistance of AI. The
changes have not yet been reviewed by a human.

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-06-22 22:55:36 -05:00
JD Davis
b829ceba84
fix(wrap): keep agent savings opt-in (#1294)
## Description

Fixes a regression from #830 where `headroom wrap codex` / `claude` /
`cursor` treated `agent-90` as required even when the user started a
normal proxy without `HEADROOM_SAVINGS_PROFILE`.

A plain `headroom proxy` on port 8787 followed by `headroom wrap codex`
currently reports `Proxy on port 8787 is missing: --savings-profile` and
tries to restart the already-running proxy. The `agent-90` profile was
documented as opt-in, so wrap should only require or inject it when
`HEADROOM_SAVINGS_PROFILE` is explicitly set.

Closes #1293

## 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

- Stop defaulting agent wrappers to `agent-90` when
`HEADROOM_SAVINGS_PROFILE` is unset.
- Stop reporting agent-savings config mismatches unless an agent savings
profile was explicitly requested.
- Add regression tests for default wrap startup, explicit profile
forwarding, and reuse/restart behavior around existing proxies.
- Fix repo-wide pre-commit issues found during amend: Windows-safe
`fcntl` typing, an optional env typing issue, OpenCode JSON parser
return typing, and ruff import/format drift.

## 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

### Test Output

```text
> maturin develop -m crates/headroom-py/Cargo.toml
Finished `dev` profile [unoptimized + debuginfo] target(s) in 27.41s
Built wheel for abi3 Python >= 3.10
Installed headroom-ai-0.27.0

> C:\git\headroom\.venv\Scripts\python.exe -c "from headroom._core import hello; print(hello())"
headroom-core

> ruff check .
All checks passed!

> ruff format --check .
913 files already formatted

> C:\git\headroom\.venv\Scripts\python.exe -m mypy headroom
Success: no issues found in 388 source files

> C:\git\headroom\.venv\Scripts\python.exe -m pytest tests/test_agent_savings.py tests/test_cli/test_wrap_persistent.py tests/test_proxy_healthchecks.py tests/test_transforms/test_content_router.py -q
collected 112 items
112 passed, 1 warning in 16.04s

> git diff --check
# no output

> git commit --amend --no-edit
Sync plugin versions.....................................................Passed
ruff.....................................................................Passed
ruff-format..............................................................Passed
mypy.....................................................................Passed
```

## Real Behavior Proof

- Environment: Windows dev checkout, Python 3.13.3 via
`C:\git\headroom\.venv\Scripts\python.exe`, Rust extension built with
`maturin develop -m crates/headroom-py/Cargo.toml`.
- Exact command / steps: reproduced the code path from #830 by
exercising `_ensure_proxy(8787, False, agent_type="codex")` with a
running proxy health payload that has no `savings_profile`, and by
exercising `_start_proxy(8787, agent_type="codex")` with
`HEADROOM_SAVINGS_PROFILE` unset and set.
- Observed result: without `HEADROOM_SAVINGS_PROFILE`, wrap reuses the
running proxy and `_start_proxy` does not inject
`HEADROOM_SAVINGS_PROFILE` or `HEADROOM_TARGET_RATIO`; with
`HEADROOM_SAVINGS_PROFILE=agent-90`, wrap still requires/forwards the
profile and restarts an incompatible proxy.
- Not tested: full end-to-end CLI launch against a live Codex binary.
The focused proxy/wrap tests cover the failing restart/config decision
directly.

## 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

## Additional Notes

The pytest run emits an existing Windows `cp1252` background-thread
warning while reading subprocess output; the tests still pass.

No documentation or changelog update is included because this restores
the already-documented opt-in behavior for `agent-90`.
2026-06-22 19:06:04 -05:00
Parideboy
487aa71a3c
ci: restore green lint (reformat for ruff 0.15.17, fix mypy no-any-return, pin linters) (#1295)
## 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>
2026-06-22 15:14:40 -05:00
Rudimar Ronsoni
b4571cc346
feat: headroom wrap opencode / unwrap opencode CLI (#1105)
## 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>
2026-06-22 11:07:12 -05:00
Terminal Chai
7c26a54d53
fix(wrap): keep Codex RTK guidance global (#1240)
## Description

Stops `headroom wrap codex` from writing RTK instructions into the
shared project `AGENTS.md`. RTK guidance remains installed in the global
Codex `AGENTS.md`, where it applies only to the user who configured
Headroom.

Closes #1235

## 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)
- [x] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- Remove project-level RTK guidance injection from `headroom wrap
codex`.
- Preserve global Codex RTK guidance injection.
- Add a regression test proving an existing project `AGENTS.md` remains
byte-for-byte unchanged.
- Document the fix in the Unreleased changelog.

## 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

### Test Output

```text
$ uv run --extra dev pytest tests/test_cli/test_wrap_codex.py -q
57 passed in 9.54s

$ 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

$ uv run mypy headroom/cli/wrap.py
Success: no issues found in 1 source file

$ npx --yes --package=@commitlint/cli --package=@commitlint/config-conventional commitlint --from HEAD~1 --to HEAD --config .commitlintrc.json
exited 0
```

## Real Behavior Proof

- Environment: Windows, Python 3.12.12, locally built Headroom CLI,
isolated project directory, isolated `CODEX_HOME`, and isolated
`HEADROOM_WORKSPACE_DIR`.
- Exact command / steps: created a project `AGENTS.md`, recorded its
SHA-256, then ran `.venv\Scripts\headroom.exe wrap codex --prepare-only
--no-mcp --no-serena` with isolated environment directories and compared
the project hash before and after.
- Observed result: command exited 0; RTK downloaded successfully; the
project `AGENTS.md` hash remained
`2CFF2F420178BFEB9BB863C743805410F2CA30F3F7F70121A8538314CBD0F8B5`; the
global Codex `AGENTS.md` was created and contained the
`headroom:rtk-instructions` marker.
- Not tested: launching an interactive Codex session after preparation;
non-Codex wrapper targets, which are unchanged.

## 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)

Not applicable.

## Additional Notes

The repository-wide pre-commit mypy hook reports existing Windows-only
`fcntl` attribute errors in `headroom/subscription/tracker.py` and
`headroom/install/runtime.py`; targeted mypy for the changed module
passes. The plugin-version hook was also verified directly with the
project interpreter and correctly skipped this feature branch.

This pull request includes code written with the assistance of AI. The
changes have not yet been reviewed by a human.
2026-06-21 10:11:06 -07:00
Eyal Mizrachi
5b84691770
fix(unwrap): remove ANTHROPIC_BASE_URL + ENABLE_TOOL_SEARCH and init hooks on unwrap (#992)
## Description

`headroom init claude` writes `env.ANTHROPIC_BASE_URL` (and
`ENABLE_TOOL_SEARCH`) plus SessionStart/PreToolUse hooks (marker
`headroom-init-claude`) into settings.json. But `unwrap` only matched
`rtk-rewrite` hooks and never removed the env, and it returned early
when no hooks remained — so the routing env survived unwrap, leaving
`claude` pointed at a dead proxy.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

- Broaden the hook-marker match to include `headroom-init-claude`.
- Always strip the headroom-managed env vars (`ANTHROPIC_BASE_URL`,
`ENABLE_TOOL_SEARCH`) even when no hooks remain.

## 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_unwrap_claude.py -q
9 passed in 0.97s
```

## Real Behavior Proof

- Environment: Linux, Python 3.13, isolated $HOME
- Exact command / steps: `headroom init -g claude` then `headroom unwrap
claude`
- Observed result: after unwrap, settings.json `env` is empty/removed
and `hooks` is `[]` (both env vars and the init hooks gone)
- Not tested: Windows settings path

## 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>
2026-06-19 11:30:59 -05:00
Eyal Mizrachi
500ec2b7fa
fix(init): set ENABLE_TOOL_SEARCH=true so Claude Code keeps deferring tools (#746) (#995)
## 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>
2026-06-19 11:26:26 -05:00
Focused Instability
a554c3a0e6
fix(wrap): write env.ANTHROPIC_BASE_URL to settings.json so daemon-spawned conversations inherit proxy (#951) (#1078)
## Description

Claude Code pre-forks conversation workers via spawn (not fork) on
macOS. Those workers read settings files fresh on each new session
rather than inheriting the daemon process's environment. `headroom wrap
claude` was passing `ANTHROPIC_BASE_URL` only via `subprocess.run`'s
`env` dict, which reaches the initial Claude Code process and the daemon
— but not conversation workers spawned later from the daemon pool. New
conversations silently bypassed the proxy and hit `api.anthropic.com`
directly.

### Design decision: why project-local settings

Three approaches were considered:

**1. Global `~/.claude/settings.json`** — rejected. This file is shared
across every Claude Code session on the machine. A user who runs
`headroom wrap claude` in one terminal but opens an unwrapped session
elsewhere would have their global settings rewritten to point at the
Headroom proxy. If the proxy dies and cleanup does not run (SIGKILL,
crash), the stale URL breaks all future sessions until the user manually
edits the global file.

**2. Kill cc-daemon before launch** — rejected. The issue itself
suggests this, but killing the daemon is disruptive: it destroys the
pre-forked worker pool shared by any other open Claude Code windows.
Active conversations may lose their parent process. This is a
hard-to-reverse side-effect of a command the user expects to be safe.

**3. Project-local `<cwd>/.claude/settings.local.json`** — chosen.
Claude Code applies `env` keys from project-local settings per its
documented precedence order (Local > Project > User), and reloads them
per-conversation. Scoping to the project means: other projects and
unwrapped sessions are unaffected; the file is git-ignored by default so
it won't be committed; and the worst-case stale URL (proxy crash without
cleanup) affects only that one project's local settings and is trivially
recoverable by re-running `headroom wrap claude` or deleting the file.

Closes #951

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

- Added `_write_claude_wrap_base_url(proxy_url, *, foundry_mode,
settings_path)` in `headroom/cli/wrap.py`: merges `ANTHROPIC_BASE_URL`
(or `ANTHROPIC_FOUNDRY_BASE_URL` in foundry mode) into
`<cwd>/.claude/settings.local.json` under the `env` key. Returns the
previous value for restore.
- Added `_restore_claude_wrap_base_url(previous, *, foundry_mode,
settings_path)`: called in the `wrap claude` `finally` block and in
`unwrap_claude` to remove or restore the key so a stale proxy URL is
never left behind.
- `unwrap_claude` calls restore for both standard and foundry keys.
- New test file `tests/test_cli/test_wrap_claude_base_url.py` (12 tests
covering write, restore, roundtrip, foundry mode, sibling key
preservation, and noop on absent file).

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
pytest tests/test_cli/test_wrap_claude_base_url.py -v
12 passed in 0.21s

ruff check headroom/cli/wrap.py tests/test_cli/test_wrap_claude_base_url.py
All checks passed!
```

## Real Behavior Proof

- Environment: macOS, branch `fix/daemon-base-url-inheritance`, Python
3.11.9.
- Exact command / steps: Ran `pytest
tests/test_cli/test_wrap_claude_base_url.py -v` and `ruff check` on the
modified files from the PR branch.
- Observed result: 12 new unit tests pass; ruff reports no issues.
- Not tested: Live end-to-end verification (opening a second
conversation via the daemon pool and confirming proxy receives traffic)
— not safe to test inside the current wrapped session on port 8787.

## 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

## Additional Notes

The issue reporter tried `apiBaseUrl` in settings.json and found it
ineffective. That key configures the API endpoint at the CC UI layer,
not the process environment. `env.ANTHROPIC_BASE_URL` is the correct
mechanism for propagating an environment variable to CC worker
processes.
2026-06-18 11:21:04 -05:00
Focused Instability
9f712ccbd7
fix(wrap): percent-encode non-ASCII cwd names in X-Headroom-Project header (#1071)
## Description

Non-ASCII directory names (Chinese, Japanese, Korean, Cyrillic) caused
an immediate API error when using `headroom wrap claude`:

```
API Error: Header 'X-Headroom-Project' has invalid value: '第二大脑共享'
```

RFC 7230 requires HTTP header values to be visible ASCII only. The raw
cwd basename was being sent directly, breaking the entire session before
the first token.

Closes #1069

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

- `headroom/cli/wrap.py` — `_project_name_from_cwd()`: percent-encode
non-ASCII chars via `urllib.parse.quote(name, safe="-_.() ")` so the
header value is always ASCII-safe
- `headroom/proxy/savings_tracker.py` — `sanitize_project_name()`:
`urllib.parse.unquote()` before cleanup so the stored/displayed project
name is the original Unicode directory name

ASCII-only project names are unaffected (quote/unquote is a no-op for
them).

## Testing

- [x] Unit tests pass (`pytest`)
- [x] New tests added for new functionality

### Test Output

```text
tests/test_cli/test_wrap_helpers.py::TestApplyProjectHeaderEnv::test_non_ascii_cwd_name_is_percent_encoded PASSED
tests/test_cli/test_wrap_helpers.py::TestApplyProjectHeaderEnv::test_non_ascii_cwd_header_is_ascii_safe PASSED
tests/test_proxy_project_savings.py::test_sanitize_project_name_decodes_percent_encoded_non_ascii PASSED
======================== 15 passed, 1 warning in 0.42s =========================
```

## Real Behavior Proof

- Environment: macOS 15, Python 3.11.9, headroom dev install from source
- Exact command / steps: `mkdir /tmp/test-中文-项目 && cd /tmp/test-中文-项目`,
then run `.venv/bin/pytest
tests/test_cli/test_wrap_helpers.py::TestApplyProjectHeaderEnv::test_non_ascii_cwd_header_is_ascii_safe
-v` — header_value.encode("ascii") passes without UnicodeEncodeError
- Observed result: `X-Headroom-Project` header contains percent-encoded
ASCII (`test-%E4%B8%AD%E6%96%87-%E9%A1%B9%E7%9B%AE`); proxy decodes back
to `test-中文-项目` for storage
- Not tested: live end-to-end wrap session with a real Claude API key

## 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 added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 11:19:43 -05:00
Eyal Mizrachi
5eec7f6701
fix(serena): migrate stale Headroom-installed Serena entry on re-wrap (#1008)
## Description

#1003 added `--open-web-dashboard False` to the Serena spec to stop the
dashboard browser tab popping up on every session — but the flag only
reaches **fresh** registrations. `register_server` returns `MISMATCH`
and refuses to overwrite a differing entry unless `force=True`, and the
Claude wrap path calls `_setup_serena_mcp` **without** force (unlike the
Codex path, which passes `force=True`).

So anyone wrapped before #1003 has a `serena` entry whose args lack the
flag. Every re-wrap detects the mismatch, prints `existing config
differs … To update: remove the existing serena MCP entry, then rerun`,
and gives up — the stale spec, and the popup, persist forever. The fix
never reaches already-wrapped users, which is most of them.

This completes #1003 by migrating those stale entries in place.

Related to #1003

## 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

- `_setup_serena_mcp` now migrates a stale entry: on `MISMATCH` (and
when not already forced), it force-updates to the current spec **only
when the ledger proves Headroom installed the entry currently on disk**
(`headroom_installed_matching`). Prints `Serena MCP: migrated
previously-installed entry to current spec`.
- A user-managed Serena (absent from the ledger) is left untouched and
the mismatch is reported exactly as before — the same ownership check
`--no-serena` / `_disable_serena_mcp` already use, so a hand-rolled
Serena is never clobbered.
- No call-site change: migration is self-contained and gated on ledger
ownership, not on the `force` param, so the Codex path keeps
hard-overwriting as before.
- New `tests/test_cli/test_serena_migrate.py`.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ python -m pytest tests/test_cli/test_serena_migrate.py tests/test_cli/test_serena_disable.py tests/test_mcp_registry/ -q
============================== 89 passed in 4.26s ==============================

$ ruff check headroom/cli/wrap.py tests/test_cli/test_serena_migrate.py
All checks passed!
```

## Real Behavior Proof

- Environment: Fedora 44, Python 3.14.5, headroom working tree at this
branch (off `upstream/main`), real `ClaudeRegistrar` (cli=None →
file-backed), isolated `$HOME` + ledger via `tempfile` and
`HEADROOM_WORKSPACE_DIR`.
- Exact command / steps: wrote a pre-#1003 `serena` entry (no flag) into
a throwaway `.claude/.claude.json`, recorded it in the ledger as
Headroom-owned, then ran
`_setup_serena_mcp(ClaudeRegistrar(claude_cli=None, home_dir=tmp),
context="claude-code")`. Repeated with a `custom-serena` entry absent
from the ledger.
- Observed result: Headroom-owned entry rewritten on disk to end with
`--open-web-dashboard False` (`migrated previously-installed entry`
printed); user-managed `custom-serena` entry left byte-for-byte
unchanged with the mismatch reported; fresh-install path writes the
dashboard-off spec. Discovered originally on a live machine whose
`~/.claude.json` kept the popup across re-wraps until the entry was
hand-fixed — this PR removes the need for that.
- Not tested: did not launch the Claude CLI end-to-end (the dashboard
auto-open is Serena's documented response to
`web_dashboard_open_on_launch=False`, traced in #1003); `mypy` not run.

## 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

## Additional Notes

- CHANGELOG / version: left to release-please (the repo's `fix:`-driven
release PR aggregator), so no manual CHANGELOG edit.
- Docs unchanged: behavior is internal to `headroom wrap`; the
user-visible outcome (no dashboard popup) matches #1003's documented
intent.
- `mypy` not run locally (heavy dev extra pulls a compiled dep in this
environment); happy to add the result if CI doesn't cover it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:17:13 -05:00
gglucass
74ae781644
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 15:13:21 -05:00
Brian Toye
0932b8bef4
feat: Add support for Mistral Vibe CLI (#935)
## Description

Add `headroom wrap vibe` / `headroom unwrap vibe` support for Mistral
Vibe CLI so Vibe can launch through Headroom's proxy, compression, and
observability path.

## Type of Change

- [x] New feature (non-breaking change that adds functionality)

## Changes Made

- Added `headroom.providers.mistral_vibe` provider runtime helpers.
- Added `headroom wrap vibe` command support and matching unwrap
handling.
- Configured `VIBE_PROVIDERS` so Vibe routes through the Headroom proxy.
- Added tests covering launch, custom ports, no-proxy behavior,
code-graph/learn-memory flags, verbose mode, invalid-command handling,
and provider JSON structure.
- Updated `CHANGELOG.md`.

## 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

### Test Output

```text
pytest -v tests/test_cli/test_wrap_vibe.py
# 10 passed
```

## Real Behavior Proof

- Environment: Linux, Python 3.13.13, local checkout from the PR branch.
- Exact command / steps: Ran the Vibe wrapper tests and manually
launched Mistral Vibe through `headroom wrap vibe` with `VIBE_PROVIDERS`
pointing at the Headroom proxy.
- Observed result: Vibe launched through Headroom's proxy configuration,
and the wrapper tests passed.
- Not tested: RTK hook support for Vibe. Persistent installs may
eventually hold an expired Vibe auth token because Vibe reads its auth
token from the environment at startup; opening another port or removing
the persistent install is the current workaround.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

---------

Co-authored-by: Vibe Nuage Agent <vibe@mistral.ai>
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-06-16 14:59:51 -05:00
Umi_Ma
e67ee2af65
feat: add Copilot BYOK provider wrapper utilities and CLI support (#1041)
## Description

Fix `--model auto` causing `400 The requested model is not supported`
errors when
using Copilot BYOK mode. `auto` is a Copilot-internal virtual routing
token that
external providers (Anthropic, OpenAI) do not recognise as a valid model
name.

In subscription/OAuth mode the wrapper now strips `--model auto` before
launching
Copilot so its own native auto-selection takes effect. In BYOK mode
`auto` is treated
as unconfigured and a clear, actionable error message is shown.

Closes #972

## 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/providers/copilot/wrap.py`: added `is_auto_model()` and
`strip_auto_model_args()` helpers; updated `model_configured()` to treat
`auto` as unconfigured for BYOK
- `headroom/providers/copilot/__init__.py`: exported both new helpers
via `__all__`
- `headroom/cli/wrap.py`: strips `--model auto` in subscription mode
before launch; shows specific actionable error in BYOK mode
- `tests/test_provider_copilot_wrap.py`: 17 new parametrized test cases
for `is_auto_model`, `strip_auto_model_args`, and updated
`model_configured`

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
$ uv run pytest tests/test_provider_copilot_wrap.py -v
platform win32 -- Python 3.14.6, pytest-9.0.3, pluggy-1.6.0
collected 34 items
tests/test_provider_copilot_wrap.py::test_is_auto_model[auto-True] PASSED
tests/test_provider_copilot_wrap.py::test_is_auto_model[Auto-True] PASSED
tests/test_provider_copilot_wrap.py::test_is_auto_model[AUTO-True] PASSED
tests/test_provider_copilot_wrap.py::test_strip_auto_model_args[args0-expected0] PASSED
tests/test_provider_copilot_wrap.py::test_strip_auto_model_args[args1-expected1] PASSED
tests/test_provider_copilot_wrap.py::test_strip_auto_model_args[args2-expected2] PASSED
tests/test_provider_copilot_wrap.py::test_model_configured_detects_env_and_cli_variants PASSED
============================= 34 passed in 0.46s ==============================
$ ruff check headroom/providers/copilot/wrap.py headroom/cli/wrap.py
All checks passed!
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.14.6, headroom-ai 0.25.0 editable
install from branch fix-automode-issue
- Exact command / steps: ran uv run pytest
tests/test_provider_copilot_wrap.py -v and ruff check on all four
changed files; reviewed CLI code path for both subscription and BYOK
modes
- Observed result: 34 passed, ruff All checks passed; --model auto is
stripped silently in subscription mode and rejected with a specific
actionable error in BYOK mode
- Not tested: live end-to-end Copilot CLI session, macOS/Linux keychain
auth, Docker/CI token-injection paths

## 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

## Additional Notes

mypy is not installed in the local venv so type checking was skipped;
the code uses standard type hints and passes ruff checks cleanly.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-06-16 12:07:10 -07:00
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
Tejas Chopra
919379a8a1
fix(serena): stop the Serena dashboard popup and make --no-serena actually disable Serena (#1003)
## Description

Headroom installs the Serena MCP server by default during `headroom
wrap`, and many users reported the Serena web dashboard browser tab
popping up on every session — even when they never opted into Serena.
This PR fixes two distinct root causes: Serena's dashboard auto-open,
and `--no-serena` not actually disabling an already-installed Serena.

## 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

- `build_serena_spec()` now passes `--open-web-dashboard False` to
`serena start-mcp-server`. This is Serena's startup override for
`web_dashboard_open_on_launch` (`serena/mcp.py:317-318`), so it
suppresses the browser popup regardless of the user's
`~/.serena/serena_config.yml` — the correct fix is at the launch point,
not a per-machine config edit. The dashboard backend still runs and
stays reachable at `http://localhost:24282/dashboard/`; only the
auto-open is disabled. Applies to both launch paths (wrap + strands
bundle) since both go through `build_serena_spec()`.
- New `_disable_serena_mcp()`: `--no-serena` now actively removes the
Serena entry Headroom installed (ledger-verified) instead of merely
skipping registration. Previously a prior default wrap persisted a
`serena` entry and the agent kept launching it; the old `Skipping Serena
MCP` message was misleading. A user-managed Serena (absent from the
ledger) is reported and left untouched; an absent Serena prints the skip
message. Wired into both the Claude and Codex wrap paths.
- `unwrap_codex` now removes Headroom-installed Serena. Codex writes
Serena as its own `[mcp_servers.serena]` table, separate from the
provider block the config-restore handles, so a "cleaned" unwrap
previously left it behind (`unwrap_claude` already removed it; Codex was
the gap).
- Tests: updated `build_serena_spec` arg assertion + added a
no-popup-default test; new `test_serena_disable.py` covering
removed-when-headroom-owned, preserved-when-user-managed,
skip-when-absent, noop-when-undetected, and `unwrap_codex` removal.

## Testing

<!-- Check what you actually ran, then paste the real command output
below. -->

- [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
$ python -m pytest tests/test_cli/test_serena_disable.py tests/test_cli/test_wrap_codex.py tests/test_cli/test_unwrap_claude.py tests/test_mcp_registry/ -q
134 passed

$ python -m pytest tests/test_mcp_registry/test_install.py -q
... passed (build_serena_spec arg + no-popup-default assertions)

$ ruff check headroom/cli/wrap.py headroom/mcp_registry/install.py tests/test_cli/test_serena_disable.py tests/test_mcp_registry/test_install.py
All checks passed!

$ ruff format --check headroom/cli/wrap.py headroom/mcp_registry/install.py
... already formatted

$ mypy headroom/cli/wrap.py headroom/mcp_registry/install.py
Success: no issues found in 2 source files
```

## Real Behavior Proof

- Environment: macOS (darwin), Python 3.12 venv, Serena 1.5.4 cached via
uvx, headroom on branch fix/serena-no-dashboard-popup
- Exact command / steps: Traced Serena source — `serena/cli.py` exposes
`--open-web-dashboard <bool>`; `serena/mcp.py:317-318` sets
`config.web_dashboard_open_on_launch = open_web_dashboard`;
`serena/agent.py:706` feeds that to `DashboardManager`, which calls
`webbrowser.open()` (`serena/dashboard.py:831`). Verified click parses
`--open-web-dashboard False` → `False` via a CliRunner probe. Ran the
test suites above.
- Observed result: With the flag injected, the value that gates the
browser-open is forced to False at startup regardless of local config,
so no tab opens; dashboard backend still serves on its port.
`--no-serena` removes the previously-installed `serena` entry
(unregister called, "Removed previously-installed Serena MCP" printed)
and `unwrap codex` removes it too. All 134 targeted tests pass; ruff +
mypy clean.
- Not tested: A full end-to-end `headroom wrap claude` against a live
Claude Code install with a real browser was not run; verification is via
Serena source tracing + the click-parse probe + unit/integration tests
over the registrar and wrap/unwrap paths.

## 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

## Additional Notes

Two unchecked checklist items are N/A: no user-facing docs reference the
Serena dashboard behavior, and CHANGELOG is generated via release-please
from the conventional commits. "Manual testing performed" is left
unchecked deliberately — see `Real Behavior Proof` → `Not tested` for
the exact boundary of what was and wasn't exercised against a live
browser.
2026-06-14 23:32:46 -07:00
Joel Belanger
0b4a4bd483
fix: support Copilot Business subscription auth (#641)
## Description

Adds a first-party `headroom copilot-auth login` flow for Copilot
subscription
mode and uses the resulting Copilot OAuth token to perform GitHub's
Copilot
token exchange before launching the wrapped Copilot CLI.

This fixes Business/Enterprise Cloud accounts where a generic
GitHub/Copilot
token can read Copilot account metadata but is rejected by the Copilot
token
exchange endpoint. It also avoids treating GitHub.com Enterprise Cloud
account
URLs such as `github.com/enterprises/acme` as API hostnames.

Fixes #635
Related: #488, #610
Builds on #576

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [x] New feature (non-breaking change that adds functionality)
- [x] Documentation update
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- Adds `headroom copilot-auth login` and `headroom copilot-auth status`.
- Stores a Headroom-specific Copilot OAuth token under Headroom's state
dir.
- Exchanges reusable Copilot OAuth tokens with Copilot Chat-compatible
headers before subscription-mode launch.
- Carries the resolved Copilot API endpoint into `headroom wrap copilot
--subscription`.
- Handles GitHub.com Enterprise Cloud URLs without synthesizing invalid
`api.github.com/enterprises/...` hosts.
- Adds focused unit tests and README guidance for subscription login.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```console
ruff check headroom/copilot_auth.py headroom/cli/copilot_auth.py headroom/cli/main.py headroom/cli/__init__.py headroom/cli/wrap.py tests/test_copilot_auth.py tests/test_cli/test_copilot_auth.py tests/test_cli/test_wrap_copilot.py tests/test_copilot_subscription_smoke.py
# All checks passed!

ruff format --check headroom/copilot_auth.py headroom/cli/copilot_auth.py headroom/cli/main.py headroom/cli/__init__.py headroom/cli/wrap.py tests/test_copilot_auth.py tests/test_cli/test_copilot_auth.py tests/test_cli/test_wrap_copilot.py tests/test_copilot_subscription_smoke.py
# 9 files already formatted

python -m py_compile headroom/copilot_auth.py headroom/cli/copilot_auth.py headroom/cli/main.py headroom/cli/__init__.py headroom/cli/wrap.py tests/test_copilot_auth.py tests/test_cli/test_copilot_auth.py tests/test_cli/test_wrap_copilot.py tests/test_copilot_subscription_smoke.py

uv run --no-project --with pytest --with pytest-asyncio --with click --with rich --with opentelemetry-api --with pydantic --with tiktoken --with 'litellm==1.82.3' --with fastapi --with uvicorn --with 'httpx[http2]' --with openai --with mcp --with magika --with zstandard --with websockets --with onnxruntime --with transformers --with watchdog --with sqlite-vec pytest tests/test_copilot_auth.py tests/test_cli/test_copilot_auth.py tests/test_cli/test_wrap_copilot.py tests/test_cli_proxy_env.py tests/test_copilot_subscription_smoke.py
# 127 passed
```

Local note: `uv run pytest ...` against the project currently fails
before
running tests because `uv.lock` has an unrelated `gitpython`
wheel/version
mismatch.

## Manual Validation

I tested this with an existing GitHub Copilot Business subscription
associated with a GitHub.com Enterprise Cloud account.

The Enterprise Cloud value I tested was in the form:

```text
github.com/enterprises/<enterprise>
```

The tested flow was:

```text
headroom copilot-auth login
headroom wrap copilot --subscription -- --model gpt-5.4
```

This validated that Headroom does not treat
github.com/enterprises/<enterprise> as a Copilot API hostname. Instead,
token exchange uses GitHub.com and Headroom routes subscription-mode
traffic to the Copilot API endpoint returned by GitHub for the signed-in
account.

I did not test this with GitHub Enterprise Server or a custom enterprise
domain such as ghe.example.com.

No tokens, request IDs, or organization-specific identifiers are
included in this PR.

## Real Behavior Proof

- Environment: macOS Darwin, Python 3.12.7, local checkout on
`codex/copilot-business-auth`.
- Exact command / steps: Ran `headroom copilot-auth login`, then
launched `headroom wrap copilot --subscription -- --model gpt-5.4` with
a GitHub Copilot Business subscription tied to a GitHub.com Enterprise
Cloud account.
- Observed result: Headroom did not treat
`github.com/enterprises/<enterprise>` as a Copilot API hostname; token
exchange used GitHub.com and subscription traffic was routed to the
Copilot API endpoint returned for the signed-in account. The latest
focused Copilot auth/proxy tests pass locally (`127 passed`).
- Not tested: GitHub Enterprise Server or custom enterprise domains such
as `ghe.example.com`; Windows Credential Manager integration still needs
confirmation from someone on Windows.

## 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 targeted unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A

## Additional Notes

Acknowledgement: the OAuth/token-exchange behavior was informed by
`anomalyco/opencode-copilot-auth` by Aiden Cline.

No tokens are printed by the new login/status commands; only a short
SHA-256
fingerprint is displayed for troubleshooting.

The interactive login is included because the missing piece is not just
an
Enterprise URL or routing hint. For GitHub.com Enterprise Cloud
accounts,
URLs like `github.com/enterprises/acme` identify the enterprise account
but
are not Copilot API hostnames; token exchange still happens through
GitHub.com
and then returns the account-specific Copilot API endpoint. A
command-line
enterprise argument can help for true GitHub Enterprise
Server/custom-domain
deployments, but it cannot produce the Copilot OAuth token class that
the
token-exchange endpoint accepts.

Ideally, Headroom would avoid an extra interactive login and reuse an
existing
GitHub/Copilot CLI session everywhere. In practice, some
reusable-looking
tokens can read Copilot account metadata but are rejected by Copilot
token
exchange, which leaves Business/Enterprise Cloud users with missing
model
catalogs. The explicit login command is the smallest independent way to
obtain
and persist the token needed for that exchange without asking users to
pass a
secret on the command line.

---------

Co-authored-by: jbelanger <your-username@users.noreply.github.com>
2026-06-12 20:46:38 -05:00
gglucass
8c00f7103c
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-12 17:03:14 -05:00
Michael Sam
6d3f39f213
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 14:12:22 -05:00
Logan Kang
dff6a19946
fix(codex): write canonical hooks feature flag and migrate deprecated codex_hooks (#743)
## Description

`headroom init codex` writes the hooks feature flag into
`.codex/config.toml`
under the key `codex_hooks`. Codex renamed the canonical key to `hooks`
and kept
`codex_hooks` as a legacy alias (openai/codex#20522). Current Codex
builds warn
about `[features].codex_hooks` and tell users to use `[features].hooks`
instead,
so configs written by headroom should stop emitting the deprecated key.

This PR switches headroom to write the canonical `hooks` key and
**migrates
existing configs in place**. The migration is the tricky part: a config
can
already contain `codex_hooks`, `hooks`, or both, in any order, inside or
outside
headroom's marker block — and a naive replace can emit a *duplicate*
`hooks`
key, which is invalid TOML that Codex rejects outright.

The fix strips every `codex_hooks` line up front (any value, anywhere) —
mirroring the existing top-level key cleanup in `_ensure_codex_provider`
(#260)
— then guarantees `hooks` is present without ever duplicating it, and
respects a
user-managed `hooks` value that lives outside our marker block.

Fixes: N/A (no tracking issue — surfaced while aligning with Codex >=
0.129;
related upstream context: openai/codex#20522 and the warning behavior
discussed
in openai/codex#22148)

## 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

- Write the canonical `hooks` key instead of the deprecated
`codex_hooks` in
  `_ensure_codex_feature_flag` (`headroom/cli/init.py`).
- Strip any `codex_hooks` line (any value, inside or outside the marker
block)
before ensuring the flag, so re-running `init` migrates a legacy config
instead
of leaving a stale key or producing a duplicate `hooks` key (invalid
TOML).
- Respect a user-managed `hooks` value found outside headroom's marker
block
  (e.g. `hooks = false`); only the deprecated alias is removed.
- Make the insert/create paths match `_replace_marker_block`'s
normalisation so
  re-running `init` is byte-idempotent.
- Extract a `_codex_feature_block()` helper to remove the 4x duplicated
marker
  block assembly.
- Add regression tests for the previously-broken edge cases.

## Testing

- [x] Unit tests pass (`pytest`) — affected module fully green (see
output)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom/cli/init.py`)
- [x] New tests added for new functionality
- [x] Manual testing performed (reproduced each edge case against the
patched
      function via `tomllib.loads`)

## Test Output

```
$ pytest -v tests/test_cli/test_init_cli.py -k "feature_flag or hooks_feature"
tests/test_cli/test_init_cli.py::test_init_codex_merges_feature_flag_into_existing_table PASSED
tests/test_cli/test_init_cli.py::test_init_codex_creates_hooks_feature_flag_on_first_init PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_replaces_existing_marker PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_migrates_legacy_codex_hooks_key PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_migrates_when_both_keys_present PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_migrates_when_keys_reversed PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_drops_legacy_key_outside_marker PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_is_idempotent PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_creates_features_section_when_missing PASSED
======================= 9 passed, 45 deselected in 0.24s =======================

$ pytest -q tests/test_cli/test_init_cli.py
54 passed

$ ruff check .
All checks passed!

$ mypy headroom/cli/init.py
Success: no issues found in 1 source file
```

Note: the broader `tests/test_cli/` run has one unrelated failure
(`test_wrap_copilot_auto_detects_running_proxy_backend`) caused by a
real proxy
already bound to port 8787 in the local environment — it fails
identically on a
clean checkout without this change.

## 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 (none
required)
- [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 (managed by
release-please;
      generated from the conventional commit, not edited by hand)

## Screenshots (if applicable)

N/A

## Additional Notes

- **Why the duplicate-key path matters:** TOML forbids duplicate keys,
so a
`[features]` table containing both `codex_hooks` and `hooks` (which the
old
in-place migration could produce) makes Codex reject `config.toml`
entirely.
  The new "strip then ensure" approach can never emit two `hooks` lines.
- **Version provenance:** the `codex_hooks` -> `hooks` rename landed in
openai/codex#20522, first shipped in Codex `rust-v0.129.0`.
`codex_hooks`
remains a working legacy alias, but current Codex builds can warn users
to
  move to `[features].hooks`.
- **Idempotency:** running `headroom init codex` repeatedly now produces
a
  byte-stable `config.toml`, so there is no churn on re-init.
2026-06-12 12:49:34 -05:00
Devanshi Vyas
05bd56bcb6
fix(wrap): track shared proxy clients with markers (#877)
## Description

Replace argv-based proxy client detection with per-port wrap client
markers so cleanup and ephemeral restarts do not tear down a shared
proxy while another wrapped session is still attached.

Also prune stale markers, guard against PID reuse when process identity
is available, and add coverage for the marker-based lifecycle behavior.

Fixes #804 

## 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)

## Testing

Describe the tests you ran to verify your changes:

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-06-11 19:42:43 -05:00
Chris Yau
b4395993ae
fix(init): suppress hook recovery output (#760)
## Summary
- silence best-effort profile recovery while `headroom init hook ensure`
runs from installed hooks
- suppress both Python-level stdout/stderr and child process
file-descriptor output so SessionStart hooks do not emit invalid JSON
- add a regression test for noisy supervisor recovery failures

## Verification
- `python3 -m py_compile headroom/cli/init.py`
- live local hook probe: `headroom init hook ensure --profile default
--marker headroom-init-codex` exits 0 with empty output
- targeted pytest was not runnable locally because `uv.lock` currently
fails to parse due to an inconsistent GitPython wheel version entry
2026-06-11 18:59:31 -05:00
Michael Sam
d2cdab268d
feat(proxy): add agent-90 savings profile (#830)
## Summary
- add an `agent-90` savings profile with cross-agent proxy env exports
- wire the profile into proxy/router runtime kwargs, including
force-Kompress routing and a smaller read-protection window
- expose effective savings-profile config in `/stats` and add focused
regression coverage

## Type of change
- [x] feat (non-breaking change which adds functionality)
- [ ] fix (non-breaking change which fixes an issue)
- [ ] docs
- [ ] test/CI-only
- [ ] refactor-only

## Testing
- [x] `python3 -m py_compile headroom/agent_savings.py
headroom/cli/agent_savings.py headroom/cli/main.py headroom/cli/proxy.py
headroom/proxy/models.py headroom/proxy/server.py
headroom/transforms/content_router.py tests/test_agent_savings.py
tests/test_proxy_healthchecks.py tests/test_cli/test_wrap_persistent.py
tests/test_transforms/test_content_router.py`
- [x] `git diff --check`
- [x] manual smoke: `agent-savings --profile agent-90 --format json`
returns `HEADROOM_TARGET_RATIO=0.10`
- [x] manual smoke:
`proxy_pipeline_kwargs(ProxyConfig(savings_profile="agent-90"))` enables
`force_kompress`, system/user compression, and
`read_protection_window=2`
- [x] manual smoke: Anthropic-style `tool_result` routes through
Kompress with `target_ratio=0.10`
- [ ] `pytest` suite not run: pytest is not installed in the available
local Python environments

## Notes
This keeps agent-90 as an opt-in profile. Existing defaults remain
unchanged unless `HEADROOM_SAVINGS_PROFILE=agent-90` or
`ProxyConfig(savings_profile="agent-90")` is set.
2026-06-11 18:58:06 -05:00
Focused Instability
914a60a2b0
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-10 21:04:45 -05:00
Shengbo_Wang
6ea6e31f09
fix(init): normalize Windows hook paths to forward slashes (#788)
## Description

On Windows, `_command_string()` preserves backslash paths from
`shutil.which()` (e.g. `C:\Users\...\headroom.exe`). Claude Code
executes hooks via Git Bash, which interprets backslashes as escape
characters, corrupting the path and failing with "command not found".

This PR normalizes backslash separators to forward slashes before
passing parts to `subprocess.list2cmdline()`. Forward slashes work in
bash, PowerShell, and cmd.exe on Windows.

Fixes #724

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

- `headroom/cli/init.py`: Normalize backslash path separators to forward
slashes in `_command_string()` on Windows, before calling
`subprocess.list2cmdline()`
- `tests/test_cli/test_init_cli.py`: Add
`test_command_string_normalizes_backslashes_on_windows` verifying no
backslashes remain in the output and the forward-slash path is preserved

## Real behavior proof

**Setup:** Windows 11 (build 26200), Python 3.10.18, headroom repo at
commit 9579567

**Before fix** — `_command_string()` output with a typical Windows path:
```
C:\Users\sheng\.local\bin\headroom.exe init hook ensure --profile default
```
Git Bash interprets `\U`, `\s`, `\.`, `\b`, `\h` as escape sequences →
command not found.

**After fix** — same input, normalized output:
```
C:/Users/sheng/.local/bin/headroom.exe init hook ensure --profile default
```
Forward slashes pass through Git Bash, PowerShell, and cmd.exe without
corruption.

**Edge case — path with spaces** (quoting preserved):
```
"C:/Program Files/headroom/headroom.exe" init hook ensure
```

**What I did not test:** Live `headroom init claude` end-to-end
(headroom native extension build fails on this machine due to Rust
download timeout). The fix is exercised by the unit test which uses the
real `subprocess.list2cmdline` on Windows.

## Testing

- [x] Unit tests pass (`pytest`) — 50/50 passed in `test_init_cli.py`
- [x] Linting passes (`ruff check .`)
- [x] Formatting passes (`ruff format --check .`)
- [x] New tests added for new functionality

## Test Output

```
$ python -m pytest tests/test_cli/test_init_cli.py -v
50 passed, 3 warnings in 4.02s
```

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-10 20:55:43 -05:00
Kumario
84ac332d14
fix(copilot): use responses API for subscription reasoning models (#647)
Fixes #644

## Summary
- default `headroom wrap copilot --subscription` to the responses wire
API when the selected Copilot model is GPT-5/o1/o3-family
- normalize `--subscription` to the OpenAI-compatible provider mode
before validating `--wire-api responses`
- add provider and CLI regressions for model-derived defaults and
explicit `--wire-api responses`

## Tests
- `UV_SKIP_WHEEL_FILENAME_CHECK=1 uv run --frozen --extra dev python -m
pytest tests/test_provider_copilot_wrap.py
tests/test_cli/test_wrap_copilot.py -q`
- `UV_SKIP_WHEEL_FILENAME_CHECK=1 uv run --frozen --extra dev python -m
ruff check headroom/providers/copilot/wrap.py
headroom/providers/copilot/__init__.py headroom/cli/wrap.py
tests/test_provider_copilot_wrap.py tests/test_cli/test_wrap_copilot.py`
- `UV_SKIP_WHEEL_FILENAME_CHECK=1 uv run --frozen --extra dev python -m
compileall -q headroom/providers/copilot/wrap.py
headroom/providers/copilot/__init__.py headroom/cli/wrap.py
tests/test_provider_copilot_wrap.py tests/test_cli/test_wrap_copilot.py`

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 20:54:39 -05:00
Hc
9252d852c5
fix(init): guard persistent task startup (#616)
## Description

Prevent `headroom init` hooks from spawning duplicate persistent-task
runners while a proxy is still starting.

Fixes #615

## 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)

## Problem

`_ensure_profile_running()` checked readiness for only one second and
then launched `start_detached_agent()` whenever the proxy was not ready
yet. When Claude/Codex hooks fired close together, each hook could race
through that path and spawn another detached persistent-task runner.

## Changes Made

- Add a profile-local, nonblocking runtime start lock around init hook
startup.
- Re-check readiness after acquiring the lock so late-arriving hooks do
not start a duplicate runner.
- If a runtime is already alive, wait up to 15 seconds for readiness
before stopping and restarting it.
- Add regression tests for lock contention, slow startup, and
cross-process lock behavior.

## 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

## Test Output

```
UV_SKIP_WHEEL_FILENAME_CHECK=1 uv run --frozen --extra dev --extra proxy pytest tests/test_cli/test_init_cli.py tests/test_cli/test_install_cli.py tests/test_cli/test_wrap_persistent.py tests/test_install/test_runtime.py
# 89 passed in 0.61s

UV_SKIP_WHEEL_FILENAME_CHECK=1 uv run --frozen --extra dev --extra proxy ruff check .
# All checks passed!

UV_SKIP_WHEEL_FILENAME_CHECK=1 uv run --frozen --extra dev --extra proxy ruff format --check .
# 775 files already formatted

UV_SKIP_WHEEL_FILENAME_CHECK=1 uv run --frozen --extra dev --extra proxy mypy headroom --ignore-missing-imports
# Success: no issues found in 346 source files
```

Manual sandbox check:

```
# before this change: 3 ensure calls spawned 3 detached starts
# after this change: 3 ensure calls spawned 1 detached start while the runtime was still starting
```

## 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

## Additional Notes

Docs and CHANGELOG were left unchanged because this is a small runtime
bug fix with no user-facing CLI/API change.
2026-06-10 20:34:43 -05:00
Gonzalo Zanelli
96abf38b09
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 18:21:29 -05:00
oxura
6dfcaa839f
fix(wrap): report unbindable proxy ports (#602) 2026-06-04 18:19:00 -07:00
Tejas Chopra
18925b8c6e
fix(copilot): restore generic endpoint for non-subscription OAuth (#610) (#612)
* fix(copilot): restore generic endpoint for non-subscription OAuth (#610)

0.23.0 re-pointed the shared Copilot OAuth branch from the generic
api.githubcopilot.com host to the account-specific endpoints.api host
returned by /copilot_internal/user, and made resolve_copilot_api_url
ignore the GITHUB_COPILOT_API_URL override whenever a token resolved.

That change was meant to add --subscription, but it also altered the
pre-existing non-subscription OAuth flow that worked on 0.22.4. The
account host does not serve newer models (e.g. gpt-5.4) on the responses
API, so wrapped requests began failing with unsupported-model errors
while plain Copilot and 0.22.4 kept working.

Restore 0.22.4 routing for non-subscription OAuth (generic host, still
overridable) and keep account resolution only for --subscription.
resolve_copilot_api_url now honors GITHUB_COPILOT_API_URL first, so the
override escape hatch works for every path. BYOK is unaffected.

Add a regression suite that mocks a successful user-info response, the
real-world path the prior test never exercised (it relied on the network
call failing in CI and falling back to the generic host).

* fix(copilot): route subscription + OAuth through the generic host (#610)

The 0.23.0 endpoint resolution derived the Copilot API host from
/copilot_internal/user (endpoints.api), which returns a segmented host
(e.g. api.individual.githubcopilot.com) that does not serve newer models
on the responses API and is not the host the official Copilot client
routes with (that comes from the token-exchange endpoint). --subscription
used the identical resolution, so it carried the same latent regression
as the non-subscription OAuth path.

Make Copilot host resolution override -> generic for BOTH --subscription
and the implicit OAuth path, and stop using user-info to route. Accounts
that require a dedicated host (enterprise / data residency) pin it via
GITHUB_COPILOT_API_URL. resolve_copilot_api_url no longer makes a network
call; _fetch_copilot_user_info is retained for token validation.

Update the subscription smoke tests that encoded the old account-host
assumption, and add wrap-level + unit coverage that --subscription routes
to the generic host even when user-info advertises an account host, and
that the GITHUB_COPILOT_API_URL override flows through both paths.

* docs(copilot): document generic-host routing + enterprise override (#610)

Spell out the routing contract introduced by the #610 fix so enterprise
users have a supported path. Headroom routes wrapped Copilot hosted
traffic (--subscription and OAuth) to the generic api.githubcopilot.com,
and accounts on a dedicated host (Enterprise Cloud data residency, egress
proxy) pin it via GITHUB_COPILOT_API_URL.

- copilot --help: note the generic host + GITHUB_COPILOT_API_URL override.
- TESTING-copilot-subscription.md: add "API host & Enterprise / data
  residency" section; correct the stale api.*.githubcopilot.com claim; and
  invite enterprise tenants who want token-exchange-based auto-detection to
  open an issue.
- integration-guide.md: short hosted-host + override note in the Copilot
  section.
2026-06-04 16:27:54 -07:00
Tejas Chopra
72da461217 fix(copilot): deterministic subscription token handoff to the proxy
Pass the wrapper-resolved (and, for --subscription, GitHub-validated) Copilot
token to the proxy as an explicit launch argument instead of mutating the
parent process's global os.environ. The proxy pins it as
GITHUB_COPILOT_API_TOKEN, so upstream auth is deterministic rather than the
proxy re-running unvalidated token discovery (which could otherwise inject a
different token and 401). Removes the global-state mutation and the test
isolation it forced.

Add a hermetic cross-platform smoke suite (no Keychain/secret-tool/network)
proving the env-var token path resolves on any OS, each OS secret reader is
inert off-platform, and the proxy injects exactly the validated token.
2026-06-03 23:11:02 -07:00
Tejas Chopra
ff4a0c6bc6 fix(copilot): support subscription auth through Headroom
Route GitHub Copilot CLI subscription traffic through the Headroom
OpenAI-compatible proxy path and resolve the account-specific Copilot API
endpoint before launch.

Add source-aware Copilot token discovery for explicit Copilot env vars,
macOS Keychain, Windows Credential Manager, Linux Secret Service, credential
files, and generic GitHub fallbacks. Validate subscription candidates against
GitHub Copilot user metadata so generic GH_TOKEN/GITHUB_TOKEN values do not
shadow Copilot CLI auth.

Document the subscription command and platform status in README: macOS
Keychain auth reuse has been smoke-tested, while Windows, Linux, Docker, and
CI auth-discovery paths still need real OS validation.

Tests: .venv/bin/python -m pytest tests/test_copilot_auth.py
tests/test_copilot_macos_keychain.py tests/test_copilot_linux_secret.py
tests/test_cli/test_wrap_copilot.py tests/test_cli/test_wrap_persistent.py
tests/test_proxy_copilot_auth_hooks.py
2026-06-02 21:24:47 -07:00
Matt
849b46de59 fix(codex): keep init model_provider at config root (#260)
`headroom init codex` appended its provider block to the end of
~/.codex/config.toml via _replace_marker_block. When the file ended in a
table (e.g. [features]), TOML scoped the block's root keys (model_provider,
openai_base_url) under that table, so Codex refused to start with:
invalid type: string "headroom", expected a boolean in features.

Add an at_root option to _replace_marker_block that inserts the block before
the first table header (reusing the module's line-based header detection), and
have _ensure_codex_provider use it. _ensure_codex_provider also strips any prior
top-level model_provider/openai_base_url assignment first, so init replaces an
existing value (or one an older version mis-scoped under a table) instead of
emitting a duplicate top-level key. Files with no tables still append, so the
keys stay at the root.

Add regression tests that parse the result with tomllib and assert
model_provider lands at the document root, not under [features], and that a
pre-existing model_provider is replaced rather than duplicated.

Verified end-to-end against the real codex CLI across 7 config shapes
(trailing [features], fresh, no-tables, root-key+table, other trailing table,
double-init, pre-existing provider): `codex doctor` reports "config could not
be loaded" before the fix and "config loaded / parse ok" after.
2026-05-30 19:39:50 -04:00
chopratejas
c74ad113a4 refactor(cli): factor shared wrap-subcommand scaffolding
Phase G's wrap-CLI breadth (PRs #492-#494) inherited a pre-existing
duplication pattern across the wrap subcommands and faithfully
extended it for cline/continue/goose/openhands. Each Pattern-B
subcommand (proxy-only watcher) inlined the same ~50 LOC of
proxy_holder + _make_cleanup + signal handlers + box-drawing banner
+ `while True: time.sleep(1)` watcher + try/except postlude. Each
Pattern-A subcommand (binary-launching) inlined the same ~15 LOC of
rtk-vs-lean-ctx fork + KeyboardInterrupt handler.

Replace with three focused helpers in wrap.py:

  _print_wrap_banner(agent)
    Centered 47-char unicode box. Adding a 9th agent no longer
    requires hand-padding the title to match the box width.

  _setup_context_tool_for_agent(...)
    rtk-or-lean-ctx fork + on_rtk_ready callback + rtk_required
    gate + KeyboardInterrupt -> SystemExit(130) with marker-path
    reporting. Used by cursor/cline/continue/goose/openhands.

  _run_proxy_only_watcher(...)
    Pattern-B scaffolding: signal handlers + banner + _ensure_proxy
    + setup callback + watcher loop + cleanup-on-finally. Used by
    cursor/cline/continue.

Production-code delta is small in raw LOC (+33 net on wrap.py)
because each subcommand still has a ~25-line `_print_X_setup`
callback closure. The win is architectural: adding wrap subcommand
#9 is now a ~25-line affair instead of ~150 lines, and behavior
(banner shape, Ctrl-C handling, cleanup ordering) is centralized
so a future fix lands in every subcommand at once.

Tests:
- New test_wrap_helpers.py (17 tests) directly pins each helper's
  contract — 5 branches of _setup_context_tool, 4 of
  _run_proxy_only_watcher, centering math of _print_wrap_banner.
- Merged the cline+goose hint-file tests into a single parametrized
  test_wrap_hintfile_agents.py (10 tests across [cline, goose]
  agents). test_wrap_cline.py is deleted; test_wrap_goose.py keeps
  only the goose-specific env-fan-out + binary-missing tests.
- Goose gained the "preserves existing hint-file content" test
  case that cline already had — net +1 coverage point.

Side benefit: cursor (pre-existing, not touched by G1) now gets
the SystemExit(130) on Ctrl-C-during-setup behavior the G1
subcommands had. Previously it would have surfaced a KeyboardInterrupt
traceback to the shell.

181 CLI tests pass; ci-precheck green.
2026-05-26 11:22:50 -07:00
Tejas Chopra
b36ad9fe1c
Merge pull request #494 from chopratejas/realign-G3-rtk-metrics-and-obs
fix(observability): RTK metrics + Rust observability (Phase H blocker)
2026-05-25 16:56:14 -07:00
chopratejas
ea1976e37a fix(cli): G1 remediation — non-string clobber, per-model systemMessage, openhands gate
Addresses 1 High + 4 Medium findings from the PR-G1 code review.

H1: `_inject_continue_rtk_systemmessage` previously fell through to an
unconditional `data["systemMessage"] = RTK_INSTRUCTIONS_BLOCK` when the
existing value was non-string (dict / list / number), silently clobbering
user data despite a docstring promising otherwise. Extracted a small helper
`_apply_rtk_to_systemmessage_field` that returns `(changed, ok)` and refuses
loudly on non-string user data with guidance to clear the field before
re-running. The injecting helper reports `ok=False` on any refusal so the
caller surfaces it as a warning instead of pretending the injection
succeeded. Tests cover dict, list, and int values for both top-level and
per-model sites.

M2: Continue overrides top-level `systemMessage` with per-model
`systemMessage` when set, so users with per-model configs were silently
getting no RTK guidance. The helper now visits every `models[i]` dict in
addition to the top-level field, applying the same idempotency and non-
string-clobber rules at each site. Non-dict entries in `models[]` are
skipped.

M3: The openhands subcommand previously called `_ensure_rtk_binary()` and
ignored the result, then proceeded to inject `OPENHANDS_INSTRUCTIONS` even
when rtk install had failed. Mirrored the cline/continue/goose pattern —
if rtk install fails (and `--no-context-tool` was not passed), exit 1 with
a clear error explaining how to install rtk manually or skip rtk. No
silent fallback to env-only injection.

M4: Wrapped the marker-injection + rtk-setup prelude of all four new
subcommands (cline, continue, goose, openhands) in a try/except for
KeyboardInterrupt. On Ctrl-C between marker injection and proxy startup,
we emit a clear "wrap was interrupted; marker file at <path> is on disk;
rerun to retry — it's idempotent" message and exit 130. Pre-compute the
marker path so the message can name it even if the interrupt fires before
`_inject_rtk_instructions` returns. Introduces a small `_emit_wrap_
interrupted` helper.

M1 + M5: Documented the uninstall procedure (hand-remove the
`<!-- headroom:rtk-instructions -->` block) and the lean-ctx agent-name
caveat in each of the four new subcommand docstrings. We chose docstring
guidance over `unwrap cline|continue|goose|openhands` subcommands to keep
the PR scoped. Also documented Continue's modern YAML-first config in the
`continue` docstring so users on the YAML schema know this command only
handles the JSON variant.

Tests: +9 new tests across the 4 wrap test files exercising H1 refusal
(dict/list/int parametrized × top-level + per-model), M2 per-model
injection + idempotency + non-dict-entry skip, M3 rtk install failure
abort + `--no-context-tool` bypass, and M4 KeyboardInterrupt-during-
prelude flows for all four agents.

Cosmetic: Removed the misleading "re-invocation in the same shell session"
comment from openhands; the marker guard is for pre-existing env vars.
2026-05-25 11:54:06 -07:00
chopratejas
2a717a993e fix(observability): G3 remediation — bound cardinality + wire dead metrics
Phase G PR-G3 review identified 5 Critical + 4 High + 5 Medium
findings. This commit lands all 14 fixes plus the optional nits.

CRITICAL

* C1 (cardinality DoS): `service_tier` was read from inbound JSON
  and used verbatim as a metric label. A malicious client could
  blow up the metric vector unboundedly. Added bounded vocabulary
  in `metric_names.rs::service_tier` ({auto, default, flex,
  on_demand, priority, scale, other-sentinel}) + a `validate()`
  helper. Both request-side (`handlers/responses.rs`) and
  response-side (`proxy.rs` Responses arm) gate raw values through
  it.

* C2 (dead metric): `proxy_passthrough_bytes_modified_total` had
  no production emit site. Wired it in `proxy.rs` to fire when a
  dispatcher arm returning `NoCompression`/`Passthrough` produces
  a body of a different byte length (a true cache-poisoning
  regression detector). The check runs BEFORE the PR-E4
  prompt_cache_key injector so legitimate injector mutations do
  not trip the alarm.

* C3 (Python/Rust boundary): `proxy_image_generation_call_log_redacted_total`
  was a dead Rust counter — the redaction happens entirely in the
  Python proxy's request_logger. Removed the Rust counter; moved
  the metric to the Python proxy's `/metrics` exporter via the
  existing `redactions_total()` module-level counter.

* C4 (Python/Rust boundary): `wrap_rtk_invocations_total` was a
  dead Rust counter with no wrap-side bridge. Removed the Rust
  counter; added new `headroom/cli/wrap_rtk_metrics.py` with
  `record_rtk_invocation(tool, delta)` + `rtk_invocation_counts()`
  primitives and surfaced them via the Python proxy's `/metrics`
  exporter.

* C5 (dead metric): `proxy_compression_rejected_by_token_check_total`
  had no production caller. Wired it in
  `live_zone_anthropic.rs`, `live_zone_openai.rs`, and
  `live_zone_responses.rs` to increment on every
  `BlockAction::RejectedNotSmaller` block in the manifest. The
  metric now reflects real "compressor ran but kept original"
  cases.

HIGH

* H1 (per-strategy ratio garbage): `proxy_compression_ratio_by_strategy`
  emitted the same aggregate ratio for every strategy in
  `strategies_applied` when multiple strategies ran on one body.
  Added `per_strategy_tokens: Vec<PerStrategyTokens>` to
  `Outcome::Compressed`; per-strategy `(before, after)` is
  accumulated from the manifest at the wrapper sites and emitted
  one sample per strategy in `proxy.rs`. Empty vec → fallback to
  one aggregate-labelled sample with a debug log (Phase E
  normalization paths that don't track per-strategy tokens).

* H2 (aborted stream): cache_hit_rate observed on client
  disconnects mid-stream. Added a gate: Anthropic only fires when
  `state.status == MessageStop`, OpenAI Responses only when
  `terminal_status().is_some()`. Extracted the gate into the
  pure function `compute_anthropic_session_hit_rate(state)` so
  the H2 contract is unit-testable independent of the shared
  global registry.

* H3 (docs lie + alarm contract): docs claimed HELP/TYPE is
  reachable on fresh boot, then contradicted itself. Force-zero
  every counter / gauge MetricVec with an `__init__` sentinel
  label on each scrape so HELP/TYPE + a zero row are visible from
  boot. Histograms are NOT force-zeroed (a synthetic observe(0.0)
  would pollute percentiles). PromQL queries in docs filter
  `{... != "__init__"}` so the sentinel rows are excluded from
  aggregations.

* H4 (crate-version dependency): pinned `prometheus = "=0.13.4"`
  exactly (no caret) so a future minor bump cannot silently break
  the H3 force-zero contract that relies on this crate's gather()
  semantics. Added a clear "retest the alarm contract on bump"
  paragraph in docs.

MEDIUM

* M1 (saturate on cached > input): OpenAI Chat + Responses cache-
  hit-rate computed `non_cached = input.saturating_sub(cached)`,
  silently clamping to 0 if `cached > input`. Per "no silent
  fallbacks", log + skip the emit on this wire-format pathology.

* M2 (over-fire on non-image base64): Python redactor's "density
  heuristic" over-fired on encrypted blobs / signed tokens /
  minified JSON / tool outputs. Tightened: only redact strings
  inside known image-bearing JSON paths (`data`, `url`,
  `image_url`, `image`) OR strings starting with `data:image/`.

* M3 (NaN clamp): cache_hit_rate::observe used `f64::clamp(0,1)`
  which returns NaN for NaN input; the `debug_assert!` was
  compiled out in release. Added `is_finite()` guard with a
  loud-log + skip before observe.

* M4 (PromQL median-only): added p95, p99, mean (sum/count), and
  Phase H canary-gate query section to docs. Canary fails if ANY
  of {p50, p95, p99, mean} regresses below the Python baseline.

* M5 (label byte vs char): the `<image:base64-redacted bytes=N>`
  placeholder reported character count, not UTF-8 byte count.
  Switched to `.encode('utf-8').__len__()` so the label is
  honest for non-ASCII payloads (ASCII base64 still has byte ==
  char so existing scrapes are unchanged).

OPTIONAL

* Removed dead `debug_assert_eq!(buffered.len(), buffered.len(),
  ...)` no-op in proxy.rs.
* Normalised `record_response_status` log level from `info` to
  `debug` to match peer metric helpers.

Tests:

* Rust: 11 integration_metrics tests (was 6) + 9 cache_hit_rate
  unit tests (was 4) + 2 compression_ratio (unchanged). New
  coverage: service_tier known/unknown bucketing, C2 alarm wire,
  H1 per-strategy ratio, H2 abort gate, M3 NaN/inf skip.
* Python: 27 tests (was 13). New coverage: M2 path-gated
  redaction, M5 byte vs char label, wrap_rtk_metrics primitive
  thread safety and validation.

`cargo fmt --check`, `cargo clippy --workspace -- -D warnings`,
`cargo test -p headroom-proxy --lib` (221 passed) and the
integration_metrics + integration_compression +
integration_volatile_detector + integration_cache_control +
integration_cache_drift + integration_responses +
integration_bedrock_metrics test files all green. Full
`cargo test --workspace` deferred — disk pressure during the
agent session left insufficient space for the linker to write
the full integration test artifacts; runs that did fit all
passed. `make ci-precheck` deferred for the same reason.

ruff check + ruff format + mypy headroom/proxy/request_logger.py
+ headroom/cli/wrap_rtk_metrics.py + headroom/proxy/prometheus_metrics.py
green.
2026-05-24 10:41:56 -07:00
chopratejas
c375fa156d fix(cli): wrap subcommands for cline, continue, goose, openhands
Adds four new `headroom wrap <agent>` subcommands so the proxy can
front-end Cline (VS Code), Continue (VS Code/JetBrains), Goose (Block CLI),
and OpenHands (CLI), extending the existing claude/codex/aider/copilot/
cursor pattern. Phase G PR-G1 of the realignment work.

Architectural decision: extended the existing `headroom/cli/wrap.py`
module in-place rather than splitting it into a `headroom/cli/wrap/`
package. The spec at REALIGNMENT/09-phase-G-rtk-observability.md
mentions per-agent files under a package, but the existing five wrap
subcommands all live in the single module and the extension is small
relative to the file. Keeping the file together preserves the simple
import surface used by tests (`from headroom.cli import wrap as wrap_mod`).

Per-agent wiring:
- cline → injects RTK block into `.clinerules` at project root
  (Cline is a VS Code extension; API base URL is configured in the UI,
  so the command prints config instructions and blocks on the proxy).
- continue → injects RTK guidance into the `systemMessage` field of
  `.continue/config.json` (idempotent; refuses malformed JSON or
  non-object roots; supports `--config` for custom paths).
- goose → injects RTK block into `.goosehints` at project root and
  launches the goose CLI with OPENAI_BASE_URL / OPENAI_API_BASE /
  ANTHROPIC_BASE_URL env vars pointed at the proxy.
- openhands → injects RTK guidance via the `OPENHANDS_INSTRUCTIONS`
  env var at launch (no on-disk artifact) and sets OPENAI_BASE_URL /
  ANTHROPIC_BASE_URL / LLM_BASE_URL. Preserves any pre-existing
  OPENHANDS_INSTRUCTIONS content.

Tests:
- tests/test_cli/test_wrap_cline.py: 4 tests covering prepare-only
  injection, idempotence, --no-context-tool, and existing content
  preservation.
- tests/test_cli/test_wrap_continue.py: 8 tests covering the new
  `_inject_continue_rtk_systemmessage` helper (new-file, existing
  keys, idempotence, malformed JSON, non-object roots) and the click
  command surface (default path, custom --config).
- tests/test_cli/test_wrap_goose.py: 5 tests covering env-var wiring,
  `.goosehints` injection, idempotence, missing-binary error, and
  --no-context-tool.
- tests/test_cli/test_wrap_openhands.py: 6 tests covering env-var
  wiring, OPENHANDS_INSTRUCTIONS injection, preservation of existing
  instructions, idempotence, missing-binary error, and
  --no-context-tool.

E2E: extended `e2e/wrap/run.py` with `--prepare-only` smoke tests for
all four new wrappers (full launches require agent CLIs not present in
the e2e image; unit tests cover the env-var wiring).
2026-05-21 21:04:50 -07:00
Gili Tzabari
4b061792b2 feat: add lean-ctx context tool support 2026-05-11 17:54:17 -04:00
Tejas Chopra
a7160f7eab fix: register serena mcp during wrap 2026-05-09 23:05:59 -07:00
Tejas Chopra
ea1f608e79 fix: make proxy upgrades version-aware
Derive source-tree versions from release history so headroom --version no longer reports stale project metadata.

Restart stale idle proxies after an upgrade, but leave active sessions running to avoid interrupting ongoing conversations.

Remove _version.py from version-sync ownership and make version verification catch package/plugin manifest drift.
2026-05-09 15:58:27 -07:00
Tejas Chopra
eaf5980b4a fix: stabilize codex compression, stats, and proxy lifecycle 2026-05-09 13:47:53 -07:00
Tejas Chopra
ac1d11c9a2 fix: sync Codex MCP proxy config during wrap 2026-05-08 19:14:07 -07:00
Tejas Chopra
d9d8972ac4 fix(mcp): auto-register headroom MCP server in wrap claude/codex and init -g
The proxy compresses tool_result payloads and emits [Retrieve more: hash=…]
markers, but Claude Code / Codex had no headroom_retrieve tool to call on
those markers unless the user separately ran 'headroom mcp install'. The
markers were dead pointers — silent quality loss.

Adds a per-agent MCP registrar abstraction (mcp_registry/) and wires it
into wrap and init so MCP install happens automatically alongside rtk:

  - mcp_registry/base.py — MCPRegistrar ABC, ServerSpec, RegisterResult,
    RegisterStatus enum.
  - mcp_registry/claude.py — Claude Code registrar (claude mcp add CLI
    with .claude.json / mcp.json file fallback).
  - mcp_registry/codex.py — OpenAI Codex registrar (marker-delimited TOML
    block edits to ~/.codex/config.toml; preserves user's other config).
  - mcp_registry/install.py — install_everywhere() orchestrator with
    detect-then-register semantics.
  - mcp_registry/display.py — shared format_result()/format_results() for
    consistent CLI output across wrap, init, and 'headroom mcp install'.

Adding a new agent (Cursor, Continue, Cline, Windsurf, Goose) is now a
single new file plus one entry in get_all_registrars(); call sites and
display logic don't change.

Test seam is constructor injection (home_dir, claude_cli) — zero patches
in 66 new tests across the registry. Removed 13 brittle CLI integration
tests in test_mcp.py that were patching module-level globals; equivalent
coverage now lives at the registrar/orchestrator layer.

wrap codex: snapshot ~/.codex/config.toml at the top of the command so
the existing wrap→unwrap round-trip captures the true pre-wrap state
even though MCP install now writes to the same file mid-flow.

220 tests pass (66 new + 154 existing CLI + integration). ruff and mypy
clean on touched files.
2026-05-08 17:18:32 -07:00
JerrettDavis
bf1e31b27c fix(codex): inject openai_base_url in init and persistent-install paths
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>
2026-05-06 11:32:12 -05:00
JerrettDavis
06428d20fd fix: preserve Codex OAuth proxy delivery
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>
2026-05-05 21:03:42 -05:00
chopratejas
967b0db439 fix: B1 — retire ICM, RollingWindow, scoring, relevance + dependents
Phase B step 1 of the live-zone-only realignment. Removes ~10K LOC of
"drop messages from history" machinery that became unreachable after
PR-A1 made `/v1/messages` a passthrough on the proxy. Live-zone-only
compression (PR-B2..B7) operates on content blocks within messages;
message-list mutation no longer happens in the pipeline.

Python deletes:
- headroom/transforms/intelligent_context.py (1077 LOC)
- headroom/transforms/rolling_window.py (395 LOC)
- headroom/transforms/progressive_summarizer.py (508 LOC)
- headroom/transforms/scoring.py (459 LOC)
- headroom/transforms/tool_crusher.py (338 LOC)
- 5 corresponding tests/test_transforms/* and tests/test_proxy_intelligent_context.py

Rust deletes:
- crates/headroom-core/src/context/* (manager, config, workspace,
  candidate, ccr_drop, strategy/, mod) + safety.rs replaced
- crates/headroom-core/src/scoring/* (mod, score, scorer, traits, weights)
- MessageScorerComparator from crates/headroom-parity (PR #338/#343
  becomes deletable; sunk cost stays sunk)
- 13 message_scorer fixtures + record_message_scorer.py

Rust adds (move + rewrite):
- crates/headroom-core/src/transforms/safety.rs — `tool_pair_indices`
  preserves the OpenAI/Anthropic tool_use ↔ tool_result pairing rule
  the live-zone dispatcher (PR-B2) needs. No IcmConfig dependency.

Surface refactors:
- HeadroomConfig: drop `tool_crusher`, `rolling_window`,
  `intelligent_context` fields; hoist `output_buffer_tokens` to top
  level (used by client.py).
- ProxyConfig: drop `intelligent_context*` fields.
- `headroom wrap` proxy server: retire IntelligentContextManager
  and RollingWindow imports + branch; pipeline is CacheAligner →
  ContentRouter (smart_routing) or CacheAligner → SmartCrusher
  (legacy).
- CLI: drop `--no-intelligent-context`, `--no-intelligent-scoring`,
  `--no-compress-first` flags.
- LangChain memory integration: rename `_apply_rolling_window` →
  `_apply_compression`, drop RollingWindowConfig dep. Threshold is
  now advisory — B6 will rework the contract.
- TransformPipeline.create_pipeline now takes only cache_aligner_config.
- headroom/__init__.py + headroom/transforms/__init__.py: strip
  exports of deleted symbols.

Bug fixes uncovered by full pytest sweep:
- providers/copilot/wrap.py: `environ or os.environ` collapsed
  empty-dict to falsy → callers passing `environ={}` accidentally
  pulled from os.environ. Use `environ if environ is not None else
  os.environ`.

Test correctness fixes:
- _DummyAnthropicHandler._retry_request gains **_kwargs to match
  the real handler signature post-A8.
- test_ws_http_fallback extracts JSON from `content=` (post-A3
  byte-faithful) rather than the obsolete `json=` kwarg.
- test_ccr_response_handler_extra fixture joins SSE events with
  `\n\n` per spec (post-A8 byte-buffer parser requirement).
- test_proxy_responses_phase_preservation: capture via direct
  handler attached to the named logger, so the assertion is
  order-independent (proxy `_setup_file_logging` flips
  `headroom.propagate=False` once any earlier test triggers it).
- conftest.py autouse fixture resets `headroom.propagate=True`
  before each test as a defensive measure for the same pollution.
- test_wrap_copilot_translated_backend_still_requires_byok:
  monkeypatch.delenv every provider key so the BYOK error
  actually fires.
- test_native_installers: skip when system bash < 4.3 (macOS ships 3.2).
- TestGeminiEmbedContent / TestGeminiBatchEmbedContents:
  pytest.mark.skip — proxy currently has no :embedContent route;
  feature gap, not regression.

Acceptance:
- cargo build --workspace + cargo clippy + cargo fmt --check: green.
- cargo test --workspace --exclude headroom-py: 777 passed.
- pytest: 4892 passed, 240 skipped, 0 failed.
- git grep returns only intentional comments referencing the deletion.

Per-PR-B1 plan: REALIGNMENT/04-phase-B-live-zone.md.
2026-05-02 12:23:17 -07:00
Garm
efd2ac1ca4 chore: renormalize line endings to LF
`.gitattributes` declares `*.py text eol=lf` and `*.sh text eol=lf`, but
74 files (73 .py, 1 .sh) are stored in the index with CRLF line endings,
violating that contract. Every macOS/Linux clone reports these files as
"modified" on fresh checkout because git's diff engine sees the stored
bytes don't match the attribute contract, even though the working tree
and index match byte-for-byte.

Running `git add --renormalize .` rewrites each affected blob so the
stored form matches the attribute declaration. No semantic changes —
every affected file's diff is "N insertions, N deletions" with inserts
and deletes being the same lines modulo line endings.

Follow-up commit adds `.git-blame-ignore-revs` so `git blame` / GitHub
blame skip this mechanical commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:33:30 +02:00
JerrettDavis
301563f11d test(init): make verbose stderr assertion click-version-agnostic
The test added in bb91cfe used ``CliRunner(mix_stderr=False)`` to keep
stderr separate from stdout for assertion purposes. That parameter was
removed in Click 8.2. The repo's pyproject.toml pins ``click>=8.1.0``,
so either Click 8.1 (needs mix_stderr) or Click 8.2+ (must omit it)
could appear in CI.

Switch to reading ``result.stderr`` when the attribute is populated,
falling back to ``result.output`` (combined stream) otherwise. This
covers every Click 8.x variant without branching on the installed
version.

Verified in the Docker e2e image (Click 8.3.3): all 45 tests in
tests/test_cli/test_init_cli.py pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 16:19:17 -05:00
JerrettDavis
bb91cfe688 feat(init): add -v/--verbose flag for debug diagnostics
When users hit an init regression it's opaque why: no visible state
about which agents were probed, which paths were written, which
subprocesses ran. Add a top-level flag to ``headroom init`` that routes
debug-level logging from the ``headroom.cli.init`` logger to stderr.

Instrumented decision points:

* detect_init_targets / _probe_init_targets — scope + per-target
  shutil.which result
* _write_json, _ensure_claude_hooks, _ensure_copilot_hooks,
  _ensure_codex_hooks, _ensure_codex_provider — file paths being written
* _apply_user_env — chosen scope (windows vs unix) and env-var keys
* _run_checked — each subprocess command + exit code + truncated
  stdout/stderr (useful when ``claude plugin install`` fails)
* _run_init_targets — target dispatch order and resolved profile
* top-level init callback — all flag values and invoked_subcommand

Log output goes to stderr so stdout stays clean for pipes. The handler
attached by ``_enable_verbose_logging`` is idempotent - nested
subcommand invocations don't duplicate output. The logger does not
propagate to the root logger, so enabling ``headroom init -v`` does not
affect the rest of the process.

The flag is declared on the parent Click group. Subcommands (claude,
codex, copilot, openclaw) inherit the enabled logger automatically
because the group callback runs before dispatch.

Added tests cover:

* ``init -v`` emits the expected markers to stderr, including
  ``detect_init_targets``, ``global_scope=True``, and each agent name
* ``_enable_verbose_logging`` is safe to call repeatedly (handler
  remains singular)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:59:57 -05:00