Commit graph

141 commits

Author SHA1 Message Date
Rod Boev
8522fcbc40
fix(code): quarantine Perl parser from code-aware compression (#2204)
## Description

`headroom proxy` can wedge when code-aware compression enters the
tree-sitter Perl external scanner and the native scan keeps the GIL
indefinitely. Current main still has two routes into that scanner:
explicit `perl` or `pl` hints flow through `CodeAwareCompressor`, and
`detect_language()` can nominate Perl on non-Perl code because its
prefilter matches generic sigils such as decorators, JSDoc tags, and
shell variables before phase 2 parses every surviving candidate grammar.

This change quarantines Perl at the code-aware compression funnel
without widening scope. Perl remains recognized at the input boundary,
but live proxy compression no longer requests a Perl parser. Non-Perl
code keeps its existing code-aware behavior. Real Perl falls back
through the existing safe Kompress or passthrough contract instead of
entering tree-sitter. The diff stays inside
`headroom/transforms/code_compressor.py` plus focused parser-safety
regressions.

Refs #2185

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

- Added a Perl quarantine list in
`headroom/transforms/code_compressor.py` and used it to stop Perl
candidate parsing during language detection.
- Added a hard `_get_parser()` guard so no live code-aware path can
construct a Perl parser.
- Routed resolved explicit Perl hints through the existing safe fallback
or passthrough contract before AST compression.
- Added focused parser-safety regressions for non-Perl candidate bleed,
explicit `perl` and `pl`, inferred real Perl, mixed fenced Perl content,
fallback-disabled passthrough, and non-Perl negative space.

## Testing

- [x] Unit tests pass (`uv run pytest tests/test_perl_scanner_safety.py
-q`)
- [x] Linting passes (`uv run ruff check
headroom/transforms/code_compressor.py
tests/test_perl_scanner_safety.py`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for new functionality when applicable
- [ ] Manual testing performed

### Test Output

```text
uv run pytest tests/test_perl_scanner_safety.py -q
8 passed in 1.93s
uv run ruff check headroom/transforms/code_compressor.py tests/test_perl_scanner_safety.py
All checks passed!
uv run ruff format headroom/transforms/code_compressor.py tests/test_perl_scanner_safety.py --check
2 files already formatted
```

## Real Behavior Proof

- Environment: Windows, Python from the synced `uv` environment, `dev`
and `code` extras installed, no provider call
- Exact command / steps: Run `uv run pytest
tests/test_perl_scanner_safety.py -q`.
- Observed result: `8 passed in 1.93s`; the suite proves explicit `perl`
and `pl`, inferred real Perl, mixed fenced Perl, and non-Perl
negative-space routes all avoid Perl parser entry.
- Not tested: the reporter's macOS payload and long-running concurrent
workload

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

## Additional Notes

- Use `Refs #2185`, not `Closes #2185`. The wedge surface is this PR's
scope, but #2185 also carries a separate orphaned `headroom mcp serve`
report that this slice does not address.
- This is a reachability fix. It does not repair the upstream Perl
scanner and it does not harden other grammars against the same class of
native wedge.
- `CHANGELOG.md` remains unchanged because Headroom generates release
notes from conventional commits.
- Merged PR https://github.com/headroomlabs-ai/headroom/pull/2114
addresses a different cooperative compression stall and stays separate
from this native parser-entry slice.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-14 20:18:51 -07:00
David Wells
2a954b69b4
feat(wrap): add ZCode desktop app support (#1845)
## Description

Add `headroom wrap zcode` and `headroom unwrap zcode` commands for the
ZCode desktop app (zcode.z.ai). ZCode is a desktop Electron IDE built by
Z.AI, optimized for GLM-5.2 models. It has no CLI binary, so this
follows the Pattern-B (proxy-only, print instructions) approach — same
as Cursor, Cline, and Continue.

**Upstream auto-detection:** `headroom wrap zcode` now reads
`~/.zcode/v2/config.json` to detect the enabled provider and
automatically configures the proxy upstream — no manual flags needed.

Closes #1844

## 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 module: `headroom/providers/zcode/__init__.py` and `runtime.py`
(ZCodeProxyTargets, ZCodeUpstream, build_proxy_targets, detect_upstream,
upstream_to_proxy_urls, render_setup_lines)
- New CLI command: `headroom wrap zcode` in `headroom/cli/wrap.py:4815`
— starts proxy, injects RTK into AGENTS.md, prints Base URL setup
instructions
- New CLI command: `headroom unwrap zcode` in
`headroom/cli/wrap.py:5960` — removes RTK markers, stops proxy
- New helper: `zcode_config_dir()` in `headroom/install/paths.py`
- Updated `_run_proxy_only_watcher` to accept
`anthropic_api_url`/`openai_api_url` params
- Updated README.md: ZCode row in compatibility matrix, unwrap list,
wrap command list
- Updated CHANGELOG.md: entry under [Unreleased] > Added

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`) — pre-existing numpy type
stubs issue prevents full mypy run
- [x] New tests added for new functionality (24 tests in
`tests/test_cli/test_wrap_zcode.py`)
- [x] Manual testing performed

### Test Output

```text
tests/test_cli/test_wrap_zcode.py ........................               [100%]

24 passed, 1 warning in 0.22s
```

## Real Behavior Proof

- Environment: macOS 15.5, Python 3.12.13, headroom installed via `pip
install -e .[dev]`
- Exact command / steps: `headroom wrap zcode --port 9000` then
`headroom unwrap zcode --port 9000`
- Observed result: Wrap detects provider from `~/.zcode/v2/config.json`
(e.g. "Z.ai Coding"), starts proxy with correct upstream on port 9000,
injects RTK into AGENTS.md, prints detected provider + upstream + Base
URL setup instructions. Unwrap removes RTK markers, deletes empty
AGENTS.md, stops proxy.
- Not tested: Actual ZCode app integration (ZCode is a desktop Electron
app; Base URL configuration is manual in Settings > Model Settings)

## Review Readiness

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

## Checklist

- [x] My code follows the project style guidelines
- [x] I have performed a self-review of my code
- [ ] I have commented my code, particularly in hard-to-understand areas
— N/A: code follows existing patterns
- [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-only changes

## Additional Notes

- **Pattern-B approach:** ZCode is a desktop Electron app with no CLI
binary. Same pattern as Cursor, Cline, and Continue — proxy-only, print
instructions.
- **Upstream auto-detection:** Reads `~/.zcode/v2/config.json`, finds
the enabled provider, and passes its `baseURL` to the proxy. Falls back
to Z.ai Anthropic endpoint if no config found.
- **httpProxy investigation:** ZCode has an `httpProxy` setting in
`~/.zcode/v2/setting.json`, but it is an Electron-level forward proxy
(CONNECT tunneling), incompatible with headroom reverse proxy. The Base
URL approach in Model Settings is the correct integration point.
- **No dependencies added:** This PR adds zero new dependencies.

---------

Co-authored-by: Epicism <epicism@Epiphanie.local>
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-14 16:07:39 -04:00
Noam Asor
5dbe3314a1
fix(auth): support GitHub Enterprise Copilot OAuth domain (#2192)
## Description

Adds GitHub Enterprise OAuth domain support for Copilot auth. When
`GITHUB_COPILOT_ENTERPRISE_URL` is set, the default OAuth domain
resolves to that enterprise host; explicit `--domain` values still take
precedence.

Closes #1152

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

- `headroom/copilot_auth.py`: derive the default OAuth domain from
`GITHUB_COPILOT_ENTERPRISE_URL` when present, falling back to
`github.com` for unset or blank values.
- `headroom/cli/copilot_auth.py`: keep explicit CLI domain overrides
authoritative even when the enterprise env var is set.
- `README.md`: document the enterprise OAuth environment setting and
precedence.
- Added regression tests for enterprise URL handling, blank/unset
fallback, and explicit CLI override precedence.

## 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
uv run pytest tests/test_copilot_auth.py tests/test_cli/test_copilot_auth.py -q
69 passed in 1.05s

uvx ruff@0.15.17 check headroom/cli/copilot_auth.py headroom/copilot_auth.py tests/test_cli/test_copilot_auth.py tests/test_copilot_auth.py
All checks passed!

uvx ruff@0.15.17 format --check headroom/cli/copilot_auth.py headroom/copilot_auth.py tests/test_cli/test_copilot_auth.py tests/test_copilot_auth.py
4 files already formatted
```

## Real Behavior Proof

- Environment: Windows 11 development checkout, Python 3.13.3, with
focused Copilot auth tests using monkeypatched enterprise env vars.
- Exact command / steps: ran the Copilot auth unit/CLI tests plus ruff
check and format-check against the touched auth files and tests.
- Observed result:
`GITHUB_COPILOT_ENTERPRISE_URL=https://ghe.example.com` resolves
`default_oauth_domain()` to `ghe.example.com`; unset/blank enterprise
env vars fall back to `github.com`; and `headroom copilot-auth login
--domain github.com` still honors the explicit override when enterprise
env vars are set.
- Not tested: live OAuth against a real GitHub Enterprise Server
instance.

## 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 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/auth behavior and README update.

## Additional Notes

`GITHUB_COPILOT_ENTERPRISE_URL` takes precedence over
`GITHUB_COPILOT_ENTERPRISE_DOMAIN`; explicit `--domain` remains
authoritative for the login command.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-14 16:07:17 -04:00
Eyal Mizrachi
14011b42dd
fix(wrap): drop -p short flag from wrap claude so claude's own -p/--print passes through (#2048)
## Description

`headroom wrap claude` declares `--port/-p`, and click parses wrapper
options anywhere in the argv before unknown options fall through to
`CLAUDE_ARGS`. So a user running claude's headless print mode through
the wrapper — `headroom wrap claude -p "some prompt"` — fails with
`Invalid value for '--port' / '-p': 'some prompt' is not a valid integer
range`, and claude's own `-p`/`--print` can never reach claude. This
bites hardest when `claude` is shell-aliased to `headroom wrap claude
...`: every `claude -p` invocation breaks.

This PR drops the `-p` short alias from `wrap claude`'s `--port` option
(long form stays; other subcommands' `-p` are untouched), so `-p` now
falls through to `CLAUDE_ARGS` like any other claude flag.

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

- `headroom/cli/wrap.py`: removed `"-p"` from the `wrap claude`
command's `--port` option; added a comment stating why the short alias
must not exist there.

## Testing

- [x] Unit tests pass (`pytest`) — targeted CLI suites, see output
- [x] Linting passes (`ruff check .`) — on the touched file
- [ ] Type checking passes (`mypy headroom`)
- [ ] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ pytest tests/test_cli/test_wrap_codex.py tests/test_cli/test_wrap_claude_base_url.py tests/test_cli/test_unwrap_claude.py -q
125 passed in 6.68s

$ ruff check headroom/cli/wrap.py
All checks passed!

Full tests/test_cli run: 549 passed, 2 failed —
test_wrap_copilot_auto_detects_running_proxy_backend fails identically on a
clean upstream/main checkout (pre-existing, environment-sensitive), and
test_wrap_codex_prepare_only_registers_serena_when_uvx_exists passes in
isolation on this branch (full-suite ordering interaction, not this change).
```

## Real Behavior Proof

- Environment: Fedora 44, Python 3.14 editable install, `claude` aliased
to `systemd-run --user --scope ... headroom wrap claude
--no-context-tool` via terminal shell integration
- Exact command / steps: `claude --model sonnet -p "Say only:
ALIAS-P-FIXED"` in a fresh interactive shell (alias → wrapper → proxy →
claude)
- Observed result: before the fix — `Error: Invalid value for '--port' /
'-p': ... is not a valid integer range` (exit 2, claude never spawns).
After — headroom banner, proxy attach, claude prints `ALIAS-P-FIXED`,
exit 0; `Extra args: --model sonnet -p Say only: ALIAS-P-FIXED` shows
the passthrough.
- Not tested: Windows; other wrapped tools' `-p` flags (left untouched
by design); 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
- [ ] 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 — CLI flag parsing.

## Additional Notes

Docs/CHANGELOG: no user-facing docs mention `-p` as a `wrap claude` port
alias, so no doc change; happy to add a CHANGELOG entry if maintainers
want one. No new test added because the passthrough behavior is covered
by the manual end-to-end proof above; can add a click-runner test
asserting `-p` lands in `CLAUDE_ARGS` if preferred.

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

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-14 14:10:34 -04:00
Carlos Duplar Mello
5d17e9addc
fix: check feature configuration before reusing persistent deployments (#1330)
## Description

A persistent proxy started for one use case (e.g. `--backend anthropic`)
would be silently reused for another (e.g. `--subscription
--provider-type openai`) causing 401 auth failures because
`_ensure_proxy()` only checked health + version, skipping the feature
configuration check (memory, openai_api_url, learn, code_graph).

Closes #N/A

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

- Added feature configuration check in the persistent deployment path of
`_ensure_proxy()` in `headroom/cli/wrap.py:1726-1752`. When features
mismatch, the code now falls through to the non-persistent path which
handles proxy restart with upgraded config.
- Added three new tests in `tests/test_cli/test_wrap_persistent.py`:
-
`test_ensure_proxy_restarts_persistent_deployment_for_feature_mismatch`
— verifies proxy restart when openai_api_url differs
- `test_ensure_proxy_restarts_persistent_deployment_for_memory_mismatch`
— verifies proxy restart when memory is requested but not enabled
- `test_ensure_proxy_reuses_persistent_deployment_when_features_match` —
verifies proxy reuse when all features match
- Updated `CHANGELOG.md` with fix description

## 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
$ ruff check headroom/cli/wrap.py tests/test_cli/test_wrap_persistent.py
All checks passed!

$ ruff format --check headroom/cli/wrap.py tests/test_cli/test_wrap_persistent.py
2 files already formatted

$ python -c "
from tests.test_cli.test_wrap_persistent import *
import pytest
test_ensure_proxy_restarts_persistent_deployment_for_feature_mismatch(pytest.MonkeyPatch())
print('Test 1 passed: feature mismatch restarts proxy')
test_ensure_proxy_restarts_persistent_deployment_for_memory_mismatch(pytest.MonkeyPatch())
print('Test 2 passed: memory mismatch restarts proxy')
test_ensure_proxy_reuses_persistent_deployment_when_features_match(pytest.MonkeyPatch())
print('Test 3 passed: matching features reuse proxy')
print('All tests passed!')
"
Test 1 passed: feature mismatch restarts proxy
Test 2 passed: memory mismatch restarts proxy
Test 3 passed: matching features reuse proxy
All tests passed!

$ .venv/bin/mypy headroom
headroom/proxy/server.py:1230: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs  [annotation-unchecked]
headroom/proxy/server.py:1301: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs  [annotation-unchecked]
headroom/proxy/server.py:1305: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs  [annotation-unchecked]
Success: no issues found in 397 source files
```

## Real Behavior Proof

- Environment: Linux (WSL2) Ubuntu 24.04, Python 3.12, persistent
deployment via `headroom install agent run --profile default` with
`--backend anthropic`
- Exact command / steps:
1. Started persistent deployment: `headroom install agent run --profile
default`
2. Ran copilot wrapper: `headroom wrap copilot --subscription
--provider-type openai --wire-api responses --memory -- --model
gpt-5.4-mini`
- Observed result:
- Before fix: Proxy forwarded to `api.openai.com` instead of
`api.githubcopilot.com`, causing 401 auth errors
- After fix: Proxy correctly forwards to `api.githubcopilot.com` and
copilot works as expected
- Not tested: macOS, Windows, enterprise/data-residency accounts, other
wrapped tools (claude, codex, aider)

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

## Additional Notes

The fix is minimal and targeted. It only adds a feature configuration
check in the persistent deployment path without changing any other
behavior. The non-persistent proxy path already had this check; this PR
brings the same logic to persistent deployments.

Co-authored-by: carlosduplar <[email protected]>
2026-07-14 14:10:31 -04:00
Rod Boev
4ea96a417c
feat(mcp): add streamable HTTP MCP transport (#1773)
## Description

`headroom mcp serve` only exposed stdio, which blocked MCP clients that
require a Streamable HTTP endpoint. This PR adds an explicit HTTP
transport mode around the existing Headroom MCP server while keeping
stdio as the default and keeping tool registration single-sourced.

Closes #1346.

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

## Changes Made

- Add `headroom mcp serve --transport http` with host, port, and path
options.
- Serve `headroom_compress`, `headroom_retrieve`, and `headroom_stats`
through the same MCP server instance used by stdio.
- Keep `headroom mcp serve` defaulting to stdio for current Claude Code
and local MCP host configs.
- Update MCP docs for stdio and HTTP setup without implying the proxy
automatically owns `/mcp`.
- Keep the scope clean, rebased, and covered by focused tests.

## Testing

- [x] Unit tests pass (`uv run pytest tests/test_ccr_mcp_http.py
tests/test_cli/test_mcp.py -q`)
- [x] Linting passes (`uv run ruff check headroom/cli/mcp.py
headroom/ccr/mcp_server.py headroom/ccr/mcp_http.py
tests/test_ccr_mcp_http.py tests/test_cli/test_mcp.py`)
- [x] Type checking passes (`uv run mypy headroom
--ignore-missing-imports`)
- [x] New tests added for new functionality when applicable
- [x] Manual testing performed

### Test Output

```text
uv run pytest tests/test_ccr_mcp_http.py tests/test_cli/test_mcp.py -q
20 passed in 0.53s

uv run ruff check headroom/cli/mcp.py headroom/ccr/mcp_server.py headroom/ccr/mcp_http.py tests/test_ccr_mcp_http.py tests/test_cli/test_mcp.py
All checks passed!

uv run ruff format headroom/cli/mcp.py headroom/ccr/mcp_server.py headroom/ccr/mcp_http.py tests/test_ccr_mcp_http.py tests/test_cli/test_mcp.py --check
5 files already formatted

uv run mypy headroom --ignore-missing-imports
Success: no issues found in 407 source files
```

## Real Behavior Proof

- Environment: Local Python environment with Headroom dev dependencies
and MCP extra installed.
- Exact command / steps: Start `headroom mcp serve --transport http
--host 127.0.0.1 --port <test-port> --path /mcp`, then perform an MCP
SDK Streamable HTTP initialize/list-tools exchange.
- Observed result: The HTTP transport initializes and lists the existing
Headroom MCP tools; `headroom mcp serve` without `--transport` still
selects stdio, and mixed-case `--transport HTTP` routes to the HTTP
transport.
- Not tested: live validation against external MCP hosts

## Review Readiness

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

## Additional Notes

`CHANGELOG.md` is not edited because this repository generates changelog
entries from conventional commits. Full-suite validation is left to CI.
2026-07-14 13:25:45 -04:00
Ben Younes
541500811f
feat(cli): add wrap openclaude for OpenClaude CLI (#1416)
## Description

Adds `headroom wrap openclaude`, a Click subcommand that launches the
prose-format OpenClaude CLI through the local Headroom proxy using the
same OpenAI/Anthropic base URL environment shape as `wrap aider`.

Fixes #1411.

## Type of Change

- [x] Bug fix
- [x] New feature
- [ ] Breaking change
- [ ] Documentation update
- [x] Tests

## Changes Made

- Added the `wrap openclaude` command path for OpenClaude CLI launch env
routing.
- Kept `--no-context-tool` / `--no-rtk` support for proxy-only launch
behavior.
- Fixed the default RTK setup path requested in review: when RTK is
selected and installed, `wrap openclaude` now injects the RTK
instruction marker block into `CONVENTIONS.md` at the project root
instead of only downloading the binary.
- Added a regression test for the default RTK path so the PR fails if
OpenClaude stops receiving RTK instructions.

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

RED, with the production RTK injection path temporarily reverted while
keeping the new regression test:

```text
.venv/bin/python -m pytest tests/test_cli/test_wrap_openclaude.py::test_wrap_openclaude_default_rtk_injects_instructions -q

FAILED tests/test_cli/test_wrap_openclaude.py::test_wrap_openclaude_default_rtk_injects_instructions
E   AssertionError: assert False
E    +  where False = exists()
E    +    where exists = PosixPath('/tmp/pytest-of-ousama/pytest-1/test_wrap_openclaude_default_r0/CONVENTIONS.md').exists
```

GREEN, after restoring the fix:

```text
.venv/bin/python -m pytest tests/test_cli/test_wrap_openclaude.py -q
3 passed in 0.46s
```

Additional validation on the pushed commit
`e97f17908d`:

```text
.venv/bin/python -m ruff check headroom/cli/wrap.py tests/test_cli/test_wrap_openclaude.py
All checks passed!

.venv/bin/python -m mypy headroom/cli/wrap.py --ignore-missing-imports
Success: no issues found in 1 source file

.venv/bin/python -m pytest tests/test_cli -q
439 passed, 3 skipped in 10.11s
```

## Real Behavior Proof

- Environment: local Linux checkout, Python 3.13.12, pytest 9.0.3,
branch `fix/issue-1411-pr` pushed to `ousamabenyounes:fix/issue-1411`.
- Exact command / steps: ran the new OpenClaude RTK regression test with
the production injection path reverted, then restored the fix and reran
targeted, lint, type, and CLI suite checks.
- Observed result: the reverted production path failed because
`CONVENTIONS.md` was not created; the fixed path creates the OpenClaude
instruction file and includes the Headroom RTK marker block.
- Not tested: launching a real installed `openclaude` binary
interactively; tests patch process launch to assert the exact env/args
and instruction-file side effects without starting an external CLI.

## Review Readiness

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

## Checklist

- [x] Code follows the project style
- [x] Tests cover the changed behavior
- [x] Existing CLI tests pass locally
- [x] No secrets or credentials are included

## Screenshots (if applicable)

N/A.

## Additional Notes

Addressed the requested RTK setup-path review by making default `wrap
openclaude` write durable RTK instructions instead of treating RTK setup
as a binary-only no-op.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-14 13:25:33 -04:00
Abhay Singh
8f867e4622
fix(install): guard non-dict health config in 'install status' (#2150)
## Description

`headroom install status` crashes with an `AttributeError` when the
probed health endpoint returns a non-dict `config`.

```python
if payload and isinstance(payload, dict):
    click.echo(f"Health URL: {manifest.health_url.replace('/readyz', '/health')}")
    click.echo(f"Backend:    {payload.get('config', {}).get('backend', manifest.backend)}")
```

`payload` is guarded as a dict, but `payload['config']` is not.
`dict.get('config', {})` only substitutes the `{}` default when the key
is **absent** — a present-but-non-dict `config` (`null`, a string, a
list) is returned as-is, and the chained `.get('backend', ...)` then
raises `AttributeError`, crashing the command with a raw traceback.

Reachability: the Headroom proxy normally returns `config` as an object,
so this bites when `install status` probes a port that a different or
older service is occupying (which can emit `config: null` or a
non-object), or a build that emits `config: null`. The correctly-guarded
sibling already exists in the codebase — `wrap.py`'s
`_proxy_health_config` does `config = payload.get("config"); return
config if isinstance(config, dict) else None`.

## Fix

Guard the `config` value with `isinstance(config, dict)` before the
`.get('backend', ...)` lookup, mirroring `_proxy_health_config`. A
non-dict (or missing) `config` falls back to the manifest's backend.

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

- `headroom/cli/install.py`: `install status` guards `config` with
`isinstance(config, dict)` before reading `backend`.
- `tests/test_cli/test_install_cli.py`: add
`test_install_status_survives_non_dict_config` (health payload with
`config: null` must not crash; backend falls back to the manifest).
- `CHANGELOG.md`: Bug Fixes entry.

## Testing

- [ ] 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
$ uvx ruff@0.15.17 check headroom/cli/install.py tests/test_cli/test_install_cli.py
All checks passed!
$ python -m py_compile headroom/cli/install.py tests/test_cli/test_install_cli.py
OK
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17`. Importing
`headroom` pulls in the torch/transformers stack and a full `pytest`
gets OOM-killed on this box, so I verified the access with a
dependency-free script that replicates the old vs guarded lookup, and
left the full pytest (including the new CLI test) to CI.
- Exact command / steps: ran the old `payload.get('config',
{}).get('backend', ...)` and the new guarded lookup against `config`
values of `null`, a string, a list, a proper object, and a missing key.
- Observed result: the old lookup raises `AttributeError` for every
non-dict `config`; the new lookup falls back to the manifest backend for
those and returns the real backend for a proper object (and the
missing-key case is unchanged). The new CLI test drives `install status`
with `probe_json` returning `{"config": null}` and asserts a clean exit
with the manifest backend.
- Not tested: a live foreign service occupying the port; full local
`pytest` deferred to CI (OOM, per above).

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

## Additional Notes

The "unit tests pass locally" and "type checking" boxes are unchecked
because the full suite imports the ML stack, which I can't run in this
environment; the change adds an `isinstance` guard mirroring an existing
sibling, verified by the standalone proof and a new CLI test that reuses
the file's existing `install status` mocking harness.

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-14 12:19:58 -04:00
Abhay Singh
84f66da36f
fix(init/codex): merge into hooks.json instead of overwriting it (#2173)
## Description

`headroom init codex` destroys a user's existing Codex hooks.
`_ensure_codex_hooks` builds a fresh payload containing only Headroom's
two hooks and writes it wholesale:

```python
payload = { "hooks": { "SessionStart": [...headroom...], "PreToolUse": [...headroom...] } }
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
```

It never reads the existing file, so any user-managed hooks (and any
other top-level keys) in `~/.codex/hooks.json` are silently replaced
with just Headroom's entries — data loss on every `init codex` run.

The sibling registrars do it correctly: `_ensure_claude_hooks` and
`_ensure_copilot_hooks` both read via `_json_file`, then merge per event
and dedup on the Headroom marker, preserving unrelated user entries. The
codex path was the lone writer that overwrote.

## Fix

Read-merge-write, mirroring `_ensure_claude_hooks`: load the existing
payload, keep each event's entries that don't carry the
`headroom-init-codex` marker, append Headroom's, and write back. User
hooks and other top-level keys survive; Headroom's are deduped
(idempotent re-runs).

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

- `headroom/cli/init.py`: `_ensure_codex_hooks` reads via `_json_file`,
merges per event with marker dedup, and writes via `_write_json` (no
more wholesale overwrite).
- `tests/test_cli/test_init_cli.py`: add
`test_ensure_codex_hooks_preserves_user_hooks` — a user hook and an
unrelated top-level key survive; Headroom's hook is appended once.
- `CHANGELOG.md`: Bug Fixes entry.

## Testing

- [ ] 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
$ uvx ruff@0.15.17 check headroom/cli/init.py tests/test_cli/test_init_cli.py
All checks passed!
$ python -m py_compile headroom/cli/init.py tests/test_cli/test_init_cli.py
OK
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17`. Importing
`headroom` pulls in the torch/transformers stack and a full `pytest`
gets OOM-killed on this box, so I verified the merge with a
dependency-free script that replicates the old (overwrite) vs new
(merge) logic, and left the full pytest to CI.
- Exact command / steps: gave a config with a user `SessionStart` hook
(`echo my-own-hook`) and an unrelated top-level key (`notify: true`),
then ran both the old and new logic.
- Observed result: old drops both the user hook and the top-level key;
new keeps both and appends Headroom's hook exactly once. The new test
asserts this against the real `_ensure_codex_hooks`.
- Not tested: a live `headroom init codex` end to end; full local
`pytest` deferred to CI (OOM, per above).

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

## Additional Notes

The "unit tests pass locally" and "type checking" boxes are unchecked
because the full suite imports the ML stack, which I can't run in this
environment; the change mirrors the existing `_ensure_claude_hooks`
merge logic, verified by the standalone proof and the new test (which
reuses the file's existing hooks-test harness).
2026-07-14 12:14:19 -04:00
Abhay Singh
8da4384bfc
fix(init/codex): don't delete per-profile provider settings (#2146)
## Description

`headroom init codex` silently deletes a user's per-profile provider
settings.

`_ensure_codex_provider` owns the root-level `model_provider` /
`openai_base_url` keys, and to avoid emitting a duplicate top-level key
it strips any prior assignment before re-inserting its block:

```python
content = re.sub(r"(?m)^[ \t]*model_provider[ \t]*=.*\r?\n", "", content)
content = re.sub(r"(?m)^[ \t]*openai_base_url[ \t]*=.*\r?\n", "", content)
```

Those multiline regexes match the keys at any indentation, **in any TOML
table**. Codex supports per-profile overrides:

```toml
[profiles.work]
model_provider = "azure"
[profiles.gpt5]
model_provider = "openai"
```

So a user with named Codex profiles who runs `headroom init codex` has
every `[profiles.*]` `model_provider` / `openai_base_url` line silently
removed. Those profiles then fall through to the injected root
`model_provider = "headroom"` default — their routing is quietly
changed. That collateral deletion isn't needed to prevent the root-level
duplicate the strip exists for (#260); the unwrap-side sibling
`_strip_codex_init_block` proves the intent is precise (it only removes
the Headroom-owned value).

## Fix

Scope the strip to the document root — everything before the first table
header. Root-level `model_provider` / `openai_base_url` are still
replaced (init owns them), but keys inside `[profiles.*]` (or any other
table) are left untouched.

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

- `headroom/cli/init.py`: `_ensure_codex_provider` splits the config at
the first table header and strips `model_provider`/`openai_base_url`
only from the root section.
- `tests/test_cli/test_init_cli.py`: add
`test_ensure_codex_provider_preserves_profile_overrides` — a
`[profiles.work]` override survives init while the root key is replaced
by `headroom`.
- `CHANGELOG.md`: Bug Fixes entry.

## Testing

- [ ] 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
$ uvx ruff@0.15.17 check headroom/cli/init.py tests/test_cli/test_init_cli.py
All checks passed!
$ python -m py_compile headroom/cli/init.py tests/test_cli/test_init_cli.py
OK
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17`. Importing
`headroom` pulls in the torch/transformers stack and a full `pytest`
gets OOM-killed on this box, so I verified the strip with a
dependency-free script that replicates the old (whole-file) vs new
(root-scoped) regex, and left the full pytest to CI.
- Exact command / steps: ran both strippers on a config with a root
`model_provider = "openai"` and a `[profiles.work]` block overriding
`model_provider`/`openai_base_url`.
- Observed result: the old strip deletes the `[profiles.work]` overrides
too; the new strip keeps them and still removes the root assignment. The
new test asserts the profile override survives and the root becomes
`headroom`.
- Not tested: a live `headroom init codex` end to end; full local
`pytest` deferred to CI (OOM, per above).

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

## Additional Notes

The "unit tests pass locally" and "type checking" boxes are unchecked
because the full suite imports the ML stack, which I can't run in this
environment; the change scopes an existing regex strip to the document
root, verified by the standalone proof and the new test (the two
existing `_ensure_codex_provider` tests only exercise root-level and
block-placement behavior, both preserved). I kept the fix to
root-scoping rather than also matching only the `"headroom"` value,
since that preserves the #260 duplicate-key guard without the broad
deletion.

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-14 12:14:10 -04:00
Abhay Singh
faed4dcfe7
fix(wrap/claude): bind _wrap_settings_path before the try (#2126)
## Description

`headroom wrap claude` crashes with an `UnboundLocalError` from its
cleanup `finally` whenever the proxy fails to start, which both hides
the real error and skips cleanup.

`claude()` initializes its cleanup state before the `try` so the
`finally` can always reference it — `proxy_holder`, `_saved_base_url`,
`_settings_foundry`, `port_holder`, `_settings_vertex` are all bound up
front. But `_wrap_settings_path` was the exception: it was assigned only
inside the `try`, after `_ensure_proxy`:

```python
try:
    ...
    proxy_holder[0], actual_port = _ensure_proxy(port, ...)   # can raise
    ...
    _wrap_settings_path = Path.cwd() / ".claude" / "settings.local.json"  # assigned here
    ...
finally:
    _restore_claude_wrap_base_url(..., settings_path=_wrap_settings_path)  # referenced here
    cleanup()
```

`_ensure_proxy` raises when the requested port is unavailable and the
range is exhausted, or when the proxy subprocess fails to start. When it
does, control jumps to the `finally`, which evaluates
`settings_path=_wrap_settings_path` — a local that was never assigned —
and raises `UnboundLocalError`. That replaces the real failure with a
raw traceback, and because the `finally` aborts on that line,
`cleanup()` never runs, so proxy cleanup and wrap-marker clearing are
skipped too.

## Fix

Bind `_wrap_settings_path` before the `try`, next to the other cleanup
holders, so the `finally` can always reference it. The value is
unchanged (the in-`try` assignment is removed since it computed the same
path).

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

- `headroom/cli/wrap.py`: hoist the `_wrap_settings_path` initialization
to before the `try` (alongside `proxy_holder`/`_saved_base_url`/…) and
drop the redundant in-`try` assignment.
- `tests/test_cli/test_wrap_claude_finally_unbound.py`: new test — drive
`wrap claude` with `_ensure_proxy` patched to raise and assert the
`finally` completes (no `UnboundLocalError`, and both restore and
cleanup ran).
- `CHANGELOG.md`: Bug Fixes entry.

## Testing

- [x] Unit tests pass (`uv run --extra dev pytest
tests/test_cli/test_wrap_claude_finally_unbound.py -q`)
- [x] Linting passes (`uvx ruff@0.15.17 check headroom/cli/wrap.py
tests/test_cli/test_wrap_claude_finally_unbound.py
headroom/memory/factory.py`)
- [x] Type checking passes (`uvx mypy==1.20.2
headroom/memory/factory.py`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
$ uvx ruff@0.15.17 check headroom/cli/wrap.py tests/test_cli/test_wrap_claude_finally_unbound.py
All checks passed!
$ python -m py_compile headroom/cli/wrap.py tests/test_cli/test_wrap_claude_finally_unbound.py
OK
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17`. Importing
`headroom` pulls in the torch/transformers stack and running the CLI
test locally OOM-kills this box, so I verified the control flow with a
dependency-free script that reproduces the try/finally with the variable
assigned inside vs before the try, and left the full pytest (including
the new CLI test) to CI.
- Exact command / steps: ran the flow with the variable bound inside the
try (old) and before the try (new), each with an early failure that
fires before the in-try assignment.
- Observed result: old raises `UnboundLocalError` from the finally and
skips restore/cleanup; new runs the finally cleanly and lets the real
`RuntimeError` propagate. The new CLI test drives `wrap claude` with
`_ensure_proxy` raising and asserts no `UnboundLocalError` and that
restore and cleanup both ran.
- Not tested: a live proxy port-exhaustion end to end; full local
`pytest` deferred to CI (OOM, per above).

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

## Additional Notes

Merged current `main` to pick up the repository-wide mypy cache-key
annotation fix, then verified the focused regression locally. the change
hoists one assignment to before the `try` (mirroring the four sibling
holders three lines above), verified by the control-flow proof and a new
CLI test that reuses the same mocking pattern the existing `wrap claude`
vertex tests use.

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-14 12:02:39 -04:00
Abhay Singh
d236b27c60
fix(wrap/codex): export the detected custom upstream base URL (#2125)
## Description

`headroom wrap codex` detects a user's custom upstream gateway but never
tells Codex to use it, so the user's gateway key is sent to
`api.openai.com`.

`_inject_codex_provider_config` handles a Codex user who has an
OpenAI-compatible gateway declared in `~/.codex/config.toml`, e.g.

```toml
model_provider = "freemodel"
[model_providers.freemodel]
base_url = "https://api.freemodel.dev"
```

It injects the Headroom provider with `env_http_headers = { ...
"X-Headroom-Base-Url" = "HEADROOM_CODEX_UPSTREAM_BASE_URL" }` and
**returns the preserved upstream URL** so the caller can export it. Its
docstring even says: *"Callers that go on to launch Codex should export
this value into `HEADROOM_CODEX_UPSTREAM_BASE_URL`."*

But `_prepare_codex_wrap_state` called it as a bare statement and
discarded the return, and `_run_codex_wrap` / `_build_codex_launch_env`
only ever set `OPENAI_BASE_URL`. A repo-wide grep confirms
`HEADROOM_CODEX_UPSTREAM_BASE_URL` (`_UPSTREAM_BASE_URL_ENV_VAR`) is
never assigned into any process env — it appears only at its definition
and in that docstring. Since Codex only emits the `X-Headroom-Base-Url`
header when the env var exists, the header is omitted, the proxy's
OpenAI handler falls back to its hardcoded `https://api.openai.com`, and
the user's `freemodel.dev` key is sent to OpenAI, which rejects it.

This is a regression: the wiring existed in the original `#1614` fix
(`_codex_custom_upstream = _inject_codex_provider_config(...)` then
`env[_UPSTREAM_BASE_URL_ENV_VAR] = ...`) and was dropped by a later
refactor that extracted `_prepare_codex_wrap_state`.

## Fix

Restore the wiring: `_prepare_codex_wrap_state` now captures and returns
`_inject_codex_provider_config`'s value, and `_run_codex_wrap` exports
it into the launch env (`env[_UPSTREAM_BASE_URL_ENV_VAR] =
custom_upstream`) when it is non-None and not already set, so a
user-provided value still wins.

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

- `headroom/cli/wrap.py`: `_prepare_codex_wrap_state` returns the
detected custom upstream URL; `_run_codex_wrap` exports it into the
launch env (and its display list) when set.
- `tests/test_cli/test_wrap_codex.py`: add
`TestCodexLaunchExportsCustomUpstream` — drives `_run_codex_wrap` with
mocked prepare/launch and asserts the launch env carries
`HEADROOM_CODEX_UPSTREAM_BASE_URL` when a custom upstream is detected,
and does not when there isn't one.
- `CHANGELOG.md`: Bug Fixes entry.

## Testing

- [x] Unit tests pass (`uv run --extra dev pytest
tests/test_cli/test_wrap_codex.py::TestCodexLaunchExportsCustomUpstream
-q`)
- [x] Linting passes (`uvx ruff@0.15.17 check headroom/cli/wrap.py
tests/test_cli/test_wrap_codex.py headroom/memory/factory.py`)
- [x] Type checking passes (`uvx mypy==1.20.2
headroom/memory/factory.py`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
$ uvx ruff@0.15.17 check headroom/cli/wrap.py tests/test_cli/test_wrap_codex.py
All checks passed!
$ python -m py_compile headroom/cli/wrap.py tests/test_cli/test_wrap_codex.py
OK
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17`. Importing
`headroom` pulls in the torch/transformers stack and running the CLI
test locally OOM-kills this box, so I verified the wiring with a
dependency-free script that models prepare -> run -> the proxy's
upstream fallback, and left the full pytest (including the new CLI test)
to CI.
- Exact command / steps: modelled the old flow (inject return discarded)
and the new flow (return exported into the launch env), then applied the
proxy's rule that a missing `HEADROOM_CODEX_UPSTREAM_BASE_URL` falls
back to `api.openai.com`.
- Observed result: old effective upstream is `https://api.openai.com`
(the gateway key is misrouted); new effective upstream is
`https://api.freemodel.dev` (the user's gateway). The new CLI test
asserts the launch env carries the var when a custom upstream is present
and omits it otherwise.
- Not tested: a live Codex process reading the env and emitting the
header; full local `pytest` deferred to CI (OOM, per above).

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

## Additional Notes

Merged current `main` to pick up the repository-wide mypy cache-key
annotation fix, then verified the focused regression locally. the change
threads one return value through two functions and exports it, verified
by the wiring proof and a new CLI test that drives `_run_codex_wrap`
with the heavy prepare/launch steps mocked so only the env-export logic
is exercised.

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-14 12:02:24 -04:00
Rod Boev
3a39cb99ad
install: couple Codex routing to persistent runtime readiness (#2043)
## Description

Persistent install currently writes Codex routing before runtime
readiness, and it leaves that routing in place while the runtime is
stopped or after a failed start. This changes the lifecycle so routing
is applied only after the persistent runtime is ready and reverted
before stop or removal, which prevents Codex from getting stranded on a
dead `127.0.0.1:8787` provider.

Closes #2038

## Type of Change

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

## Changes Made

- Apply persistent provider mutations only after runtime readiness.
- Revert persistent provider mutations before stop and remove.
- Clear stale routing before recovery start paths, then reapply after
readiness.
- Add focused install lifecycle tests for the ordering change.

## Testing

- [x] Unit tests pass
- [x] Linting passes
- [ ] Type checking passes
- [x] New tests added for new functionality when applicable
- [ ] Manual testing performed

### Test Output

```text
uv run pytest tests/test_cli/test_install_cli.py -q
23 passed, 1 warning in 0.27s

uv run ruff check headroom/cli/install.py tests/test_cli/test_install_cli.py
All checks passed!

uv run ruff format --check headroom/cli/install.py tests/test_cli/test_install_cli.py
2 files already formatted
```

## Real Behavior Proof

- Environment: Windows, Python 3.12.13, no provider (persistent install
lifecycle change; no live Codex or proxy call)
- Exact command / steps: `uv run pytest
tests/test_cli/test_install_cli.py -q`, then `uv run ruff check` and `uv
run ruff format --check` on `headroom/cli/install.py` and
`tests/test_cli/test_install_cli.py`
- Observed result: 23 install-lifecycle tests pass and lint/format
checks pass, confirming persistent Codex provider mutations are now
applied only after runtime readiness and reverted before stop/remove
- Not tested: a live persistent-runtime start/stop cycle with Codex
routing

## Review Readiness

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

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-14 11:52:51 -04:00
Rod Boev
4364eb8dc4
fix(copilot): refresh wrapped subscription tokens (#2156) (#2182)
## Description

`headroom wrap copilot --subscription` currently validates a Copilot
subscription credential once at launch, exchanges it once, and then pins
that short-lived API token into the proxy as an explicit override. When
the token expires, long-lived wrapped sessions start returning
`transient_auth_error` and then a final HTTP 401 until the entire
wrapped session is restarted.

This PR keeps the validated launch token for first-request determinism,
carries reusable OAuth refresh material into the proxy, and refreshes
inside `CopilotTokenProvider` when the seeded token is expired. The
explicit `GITHUB_COPILOT_API_TOKEN` override path stays unchanged when
no reusable OAuth token exists. The configured `GITHUB_COPILOT_API_URL`
also stays pinned across refresh, matching the current wrap contract.

Closes #2156.

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

- Extended the Copilot subscription token resolution path to carry
reusable OAuth refresh material and expiry metadata instead of
discarding it after the wrap-time exchange.
- Reworked `CopilotTokenProvider.get_api_token()` so it seeds the
wrapper-validated launch token once for the first request, then
refreshes through the existing exchange path when that token is expired
and reusable OAuth material exists.
- Rejected non-finite seeded expiry values such as `inf`, so malformed
`GITHUB_COPILOT_API_TOKEN_EXPIRES_AT` inputs cannot pin a stale launch
token forever.
- Preserved the explicit `GITHUB_COPILOT_API_TOKEN` override path when
no reusable OAuth token exists, so non-refreshable overrides keep
today's fixed behavior.
- Kept explicitly configured `GITHUB_COPILOT_API_URL` values pinned
across refresh rather than adopting a refreshed payload's host.
- Replaced wrapper-managed seeded `tid_` bearer passthrough with the
refresh-aware provider path, so the wrapped CLI no longer bypasses
expiry refresh just because it keeps sending the launch token back to
the proxy.
- Started a dedicated local proxy instance whenever a
subscription-seeded session targets a shared or persistent proxy port,
so per-session refresh material is not silently dropped on healthy-proxy
reuse or cross-wired between concurrent sessions.
- Scrubbed inherited Copilot refresh-seed environment variables from
both the Copilot child env and the proxy subprocess env before
re-injecting the explicit launch-time values.
- Added focused auth, wrap, proxy-env, and proxy-reuse regression
coverage for expiry refresh, non-finite expiry rejection, session-local
proxy isolation, explicit-override preservation, exchange-flag
independence, configured API URL pinning, and secret handling.

## Testing

- [x] Unit tests pass (`uv run pytest tests/test_copilot_auth.py
tests/test_cli/test_wrap_copilot.py tests/test_cli_proxy_env.py
tests/test_cli/test_wrap_persistent.py -q`)
- [x] Linting passes (`uv run ruff check headroom/copilot_auth.py
headroom/cli/wrap.py tests/test_copilot_auth.py
tests/test_cli/test_wrap_copilot.py tests/test_cli_proxy_env.py
tests/test_cli/test_wrap_persistent.py`)
- [x] Formatting passes (`uv run ruff format --check
headroom/copilot_auth.py headroom/cli/wrap.py tests/test_copilot_auth.py
tests/test_cli/test_wrap_copilot.py tests/test_cli_proxy_env.py
tests/test_cli/test_wrap_persistent.py`)
- [x] Type checking passes (`uv run mypy headroom/copilot_auth.py`)
- [x] New tests added for new functionality when applicable
- [ ] Manual testing performed

### Test Output

```text
195 passed, 1 skipped in 3.14s
```

```text
All checks passed!
```

```text
6 files already formatted
```

```text
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Python 3.12.13, `uv`, no live Copilot credentials.
- Exact command / steps: run the focused auth, wrap, proxy-env, and
proxy-reuse regression suite after seeding an expired launch token plus
reusable OAuth refresh material, then rerun lint and format checks on
the touched files.
- Observed result: base reproduces the bug because the explicit-token
branch never refreshes, accepts non-finite expiry inputs, and
shared-proxy reuse can keep the wrong per-session refresh seed alive;
head refreshes through the reusable OAuth token, rejects non-finite
seeded expiry, replaces the wrapper-managed seeded bearer instead of
blindly passing it through, preserves the valid-seed fast path and the
fixed override path when no refresh material exists, keeps the
configured API URL pinned across refresh, starts a dedicated local proxy
when the requested port already belongs to a shared or persistent proxy,
and keeps the reusable OAuth token confined to explicit proxy launch env
only. The focused suite passed with `195 passed, 1 skipped in 3.14s`.
- Not tested: live business-subscription session past the provider's
real token-expiry window.

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

## Additional Notes

- Scope stays provider-local. The fix remains inside
`headroom/copilot_auth.py` and the Copilot wrap handoff in
`headroom/cli/wrap.py`; it does not add generic 401 retry logic to
provider-neutral proxy layers.
- The line that disables token exchange for the Copilot CLI child env is
unchanged because it never reached the proxy env and was not the root
cause.
- Subscription-seeded sessions now get a dedicated local proxy whenever
the requested port already belongs to a shared or persistent proxy;
existing shared proxies are left alone to avoid disrupting attached
wrappers.
- Live provider confirmation still needs maintainer or reporter
validation because that truth is owned by GitHub's real subscription
APIs, not by local stubs.
- Headroom's release pipeline generates changelog entries from
conventional commits, so `CHANGELOG.md` is intentionally untouched.
2026-07-14 11:52:43 -04:00
Tejas Chopra
c5545d6ac4
fix(wrap): use canonical headroom-openclaw npm package for wrap openclaw (#1969) (#2120)
## Description
`headroom wrap openclaw` installed a non-existent npm spec — the
`--plugin-spec` default was `headroom-ai/openclaw`, which npm reads as a
GitHub shorthand and fails; the published package is `headroom-openclaw`
(see `plugins/openclaw/package.json`).

Fix introduces a single `OPENCLAW_NPM_PACKAGE = "headroom-openclaw"`
constant (kept in sync with `package.json` and the release env), uses it
as the default, and defers writing the `plugins.entries.headroom` config
until after a successful install so a hard failure leaves no stale
entry.

Closes #1969

## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made
- `headroom/providers/openclaw/wrap.py` + `__init__.py`: canonical
`OPENCLAW_NPM_PACKAGE` constant.
- `headroom/cli/wrap.py`: use it as `--plugin-spec` default; write
config only after successful install.
- `tests/test_cli/test_wrap_openclaw.py`: expect `headroom-openclaw`;
install-before-config ordering; failed-install-writes-no-config test.

## Testing
- [x] Unit tests pass (`pytest tests/test_cli/test_wrap_openclaw.py`) —
29 passed
- [x] Linting passes (`ruff check`)
### Test Output
```text
29 passed
ruff: All checks passed!
```

## Real Behavior Proof
- Before: `wrap openclaw` → npm "unsupported spec" error; a failed
install left a stale config entry.
- After: installs `headroom-openclaw`; no config written on failure.
2026-07-13 16:33:44 -04:00
Ayush Kumar Jha
daeff69a75
fix(wrap): surface Claude Remote Control base-URL gate accurately (#1… (#1883)
…779)

Claude Code 2.1.196 deterministically disables first-party Remote
Control (/remote-control, /rc) behind a custom ANTHROPIC_BASE_URL, which
Headroom always sets. Make the wrap/doctor warning accurate (state the
disable as fact, name the /rc command, detect the installed version),
suppress it for auth modes that never had RC (API key,
Bedrock/Vertex/Foundry) and for builds older than 2.1.196, co-report the
sibling #746/#1158 gates session-accurately, and fix
is_custom_anthropic_base_url host handling (scheme-less hosts, malformed
URLs). UX/notice-only; no request bytes touched.

## Description

<!-- Briefly explain the change and why it is needed. -->

Closes #

## Type of Change

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

- 

## Testing

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

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

### Test Output

```text
# Paste relevant command output or artifact links here
```

## Real Behavior Proof

- Environment:
- Exact command / steps:
- Observed result:
- Not tested:

## Review Readiness

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

## Checklist

- [ ] My code follows the project's style guidelines
- [ ] I have performed a self-review of my code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

Add screenshots to help explain your changes.

## Additional Notes

<!-- Mention any N/A checklist items, tradeoffs, follow-ups, or
maintainer context. -->

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-13 14:01:37 -04:00
Rudimar Ronsoni
c4ddcb93a7
fix(codex): skip sockets in session home overlay (#2104)
## Description

Prevent `headroom wrap codex` from failing when the active `CODEX_HOME`
contains a Unix socket. The session overlay copied every entry with
`shutil.copytree()`, which raises `shutil.Error` when it reaches Git's
`fsmonitor--daemon.ipc` socket.

The overlay now skips socket entries while continuing to copy regular
Codex state and surface unrelated copy errors.

Closes #2103

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

- Ignore filesystem sockets while seeding the temporary Codex session
home.
- Add a regression test with a real nested `fsmonitor--daemon.ipc`
socket and a regular sibling file.

## Testing

- [x] Focused unit tests pass (`pytest tests/test_cli/test_wrap_codex.py
-q`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New regression test added
- [ ] Manual interactive testing performed

### Test Output

```text
Docker, Linux arm64, Python 3.12.12

pytest tests/test_cli/test_wrap_codex.py -q
88 passed in 5.92s

ruff check .
All checks passed!

ruff format --check .
1191 files already formatted

mypy headroom --ignore-missing-imports
Success: no issues found in 469 source files
```

## Real Behavior Proof

- Environment: isolated Docker container on Linux arm64 with Python
3.12.12 and Rust 1.95.0
- Exact command / steps: bind a real Unix socket at
`vendor_imports/skills/.git/fsmonitor--daemon.ipc`, then enter
`_codex_session_home_overlay()` through the focused pytest regression
- Observed result: the regular sibling file is copied, the socket is
omitted, the source socket remains active, and the overlay exits cleanly
- Not tested: an interactive Codex launch against the live host
`~/.codex`

## 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] My changes generate no new warnings
- [x] I have added tests that prove the fix is effective
- [x] New and existing focused Codex wrapper tests pass with my changes

## Additional Notes

The filter is intentionally limited to socket entries. Permission errors
and failures involving regular files still propagate from
`shutil.copytree()`.
2026-07-13 10:09:32 -04:00
Abhay Singh
20968a4fa4
fix(wrap/opencode): unwrap removes the rtk block from AGENTS.md (#2025)
## Description

`headroom wrap opencode` injects the marker-fenced rtk guidance block —
"prefix shell commands
with `rtk`" — into **both** instruction files (`headroom/cli/wrap.py`):

```python
# wrap opencode
project_agents = Path.cwd() / "AGENTS.md"
_inject_rtk_instructions(project_agents, verbose=verbose)
global_agents = _opencode_home_dir() / "AGENTS.md"
_inject_rtk_instructions(global_agents, verbose=verbose)
```

But `unwrap_opencode` only restores the OpenCode config and cleans up
MCP servers — it never
removes that rtk block. So after `unwrap opencode`, both `AGENTS.md`
files still contain the
marker-fenced instruction, and a plain `opencode` launch keeps following
"prefix shell commands
with `rtk`" and fails once the managed rtk binary is off PATH.

`unwrap_codex` (#1421) and `unwrap_copilot` both already do this cleanup
via
`_remove_rtk_instructions`; opencode was simply never given the
equivalent — a wrap/unwrap
asymmetry.

Closes: no issue filed — found while auditing wrap/unwrap symmetry
across agents.

## Fix

In `unwrap_opencode`, after the MCP cleanup, strip the rtk block from
both files it was injected
into, mirroring `unwrap_codex`:

```python
for _agents_md in (Path.cwd() / "AGENTS.md", _opencode_home_dir() / "AGENTS.md"):
    if _remove_rtk_instructions(_agents_md):
        click.echo(f"  Removed Headroom rtk instructions from {_agents_md}.")
```

Best-effort and unconditional, matching the existing MCP cleanup and the
codex/copilot unwrap
paths. `_remove_rtk_instructions` already no-ops when the file or marker
is absent.

## Type of Change

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

## Changes Made

- `headroom/cli/wrap.py`: `unwrap_opencode` removes the rtk block from
the project and global `AGENTS.md`.
- `tests/test_cli/test_wrap_opencode.py`: add
`test_unwrap_opencode_removes_rtk_from_agents_md` (wrap injects into
both, unwrap removes from both).

## Testing

- [x] New regression test added (`tests/test_cli/test_wrap_opencode.py`)
- [x] Linting/formatting clean — run with the CI-pinned `ruff==0.15.17`
- [ ] Full `pytest` deferred to CI (local-OOM reason below).

```text
$ uvx ruff@0.15.17 check headroom/cli/wrap.py tests/test_cli/test_wrap_opencode.py
All checks passed!
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.10, headroom from this branch.
Importing `headroom` pulls in the torch/transformers stack and a full
`pytest` gets OOM-killed on this box, so I verified the wrap→unwrap
round-trip through the new Click-runner test (which drives the real
command) and reasoned through the marker logic; the full pytest runs on
CI.
- Exact command / steps: the added test runs `wrap opencode --no-mcp`
(asserts `_RTK_MARKER` present in both the project and global
`AGENTS.md`), then `unwrap opencode`, and asserts the marker is gone
from both.
- Observed result (the assertions the test enforces): before the fix,
`unwrap opencode` left `_RTK_MARKER` in both files; after the fix both
are clean:

```text
after wrap:   _RTK_MARKER in project AGENTS.md  ✓   _RTK_MARKER in global AGENTS.md  ✓
after unwrap: _RTK_MARKER absent (project)      ✓   _RTK_MARKER absent (global)      ✓
```

- Not tested: launching a real `opencode` binary (mocked in the test, as
the existing wrap tests do). Full local `pytest` deferred to CI (OOM,
per above).

## 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
- [ ] New and existing unit tests pass locally with my changes — ran
lint + the new Click-runner test path; full pytest deferred to CI (local
OOM, disclosed above)
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

- Directly parallels the merged `unwrap codex` rtk cleanup (#1421); no
new dependencies.
- @JerrettDavis tagging you — same class as the codex rtk fix, just the
opencode side that was missed. Thanks!

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-13 09:53:45 -04:00
Tejas Chopra
1cc99792ac
fix(version): mark source-checkout builds as -dev (#2072)
## Description

`headroom --version` and the dashboard show `0.32.0` from a source
checkout, but the latest published release is `0.31.0`. That `0.32.0` is
not a real release: on a git checkout `get_version()` predicts the
*next* release from conventional commits since the last tag (`v0.31.0` +
`feat:` commits → `0.32.0`) and renders it identically to a shipped
version — so a dev build looks published.

This appends `-dev` on the source-checkout path so a dev build is never
mistaken for the published release.

Closes #

## Type of Change

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

## Changes Made

- `headroom/_version.py`: the source-checkout branch of `get_version()`
now returns `f"{source_version}-dev"`.
- `tests/test_package_init_lazy.py`: updated the source-tree version
test to assert the `-dev` suffix.

Released installs are unaffected: pip wheels and Docker images with a
baked `BUILD_VERSION` never take the source-checkout path, so they still
report clean release versions (`0.31.0` / `v0.31.0`). The suffix makes
`is_release_version()` return `False` and `normalize_release_version()`
return `None`, which every comparison site already handles — e.g.
`wrap.py`'s `_proxy_needs_version_restart` requires both sides to
normalize, so a dev build short-circuits to "no restart" (no behavior
change).

## Testing

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

### Test Output

```text
$ python -m pytest tests/test_package_init_lazy.py tests/test_cli_doctor.py -q
============================== 12 passed in 1.79s ==============================
============================== 51 passed in 0.56s ==============================

$ ruff check headroom/_version.py tests/test_package_init_lazy.py
All checks passed!

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

## Real Behavior Proof

- Environment: local source checkout (macOS), `.venv`, latest release
tag `v0.31.0`
- Exact command / steps: `headroom --version`
- Observed result:
- Before: `headroom, version 0.32.0` — indistinguishable from a release
  - After: `headroom, version 0.32.0-dev`
- Not tested: behavior inside a built Docker image / installed pip wheel
— unchanged by design, since those paths never compute a source-tree
version.

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

Scope kept to a bare `-dev` marker, which answers "is this a release?".
Appending the short git SHA (`-dev+g<sha>`) to distinguish individual
dev builds in bug reports is an easy follow-up if wanted. Docs/CHANGELOG
unchecked as N/A for a dev-only version-string fix.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-13 09:38:17 -04:00
Andrew Barnes
f53f720eb5
feat(wrap): allow project RTK instruction opt-out (#2078)
## Description

Add an opt-out for project-level RTK guidance when wrapping OpenCode, so
teams can preserve an existing repository `AGENTS.md` while still
installing RTK and its global OpenCode instructions.

Closes #1980

## Type of Change

- [ ] Bug fix (non-breaking change that fixes an issue)
- [x] New feature (non-breaking change that adds functionality)
- [ ] Breaking change
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring

## Changes Made

- add `--no-project-rtk` to `headroom wrap opencode`
- leave the repository `AGENTS.md` untouched when requested
- continue installing RTK and its global OpenCode guidance
- cover preservation of existing team instructions

## Testing

- [x] Focused unit test passes (`pytest`)
- [x] Touched-file linting passes (`ruff check`)
- [ ] Type checking passes (`mypy headroom`) — not run
- [x] New test added for new functionality
- [ ] Manual testing performed

### Test Output

```text
uv run pytest tests/test_cli/test_wrap_opencode.py::test_wrap_opencode_no_project_rtk_only_skips_project_agents_md -q
1 passed

uv run ruff check headroom/cli/wrap.py tests/test_cli/test_wrap_opencode.py
All checks passed!

uv run ruff format --check headroom/cli/wrap.py tests/test_cli/test_wrap_opencode.py
2 files already formatted
```

## Real Behavior Proof

- Environment: local Python test environment
- Exact command / steps: invoke the focused OpenCode wrapper test with
an existing project `AGENTS.md` and project RTK guidance disabled
- Observed result: the existing project instructions remain
byte-for-byte untouched while the global RTK guidance path still runs
- Not tested: manual OpenCode launch or the full repository test suite

## 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] My changes generate no new warnings in the scoped checks
- [x] I have added a test that proves the feature works
- [x] Relevant existing and new unit coverage passes locally
- [ ] Documentation and changelog updates — not applicable for this
self-documenting CLI option

## Additional Notes

The opt-out is deliberately narrow: it suppresses only the project
`AGENTS.md` mutation, not RTK installation or global OpenCode
configuration.

Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-13 09:37:17 -04:00
Rod Boev
ad9d086f43
feat(codex): keep wrap routing session-scoped (#1507)
## Description

Keeps Codex wrap routing session-scoped so routing state from one
wrapped session does not leak into another.

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

- Scope Codex wrap routing state to the active session.
- Avoid cross-session routing contamination for wrapped Codex traffic.
- Keep changes focused on wrap/proxy routing behavior.

## Testing

- [x] Unit tests pass
- [x] New tests added for new functionality when applicable
- [x] Manual testing performed

### Test Output

```text
Focused tests/review were completed before this governance body cleanup. The current branch is conflicted and still needs merge resolution before final merge readiness.
```

## Real Behavior Proof

- Environment: Headroom development/review context.
- Exact command / steps: Reviewed session-scoped Codex wrap routing
behavior and existing focused coverage.
- Observed result: Routing state is scoped to the active wrap session
rather than shared globally across sessions.
- Not tested: Current conflicted branch after merge resolution;
conflicts still need to be resolved before merge.

## Review Readiness

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

## Additional Notes

This body was normalized by a maintainer after approval so the
governance parser reflects the already-reviewed PR state. The PR remains
blocked by merge conflicts.
2026-07-11 11:03:57 -05:00
Rod Boev
f536aa0801
fix(wrap): keep Claude context-tool setup explicit (#1999)
## Description

`headroom wrap claude` currently installs RTK's global Claude hook and
instruction imports on a flag-free launch, even though the wrapped
session already routes through Headroom's proxy. The wrapper now
requires an explicit Claude context-tool opt-in before it runs the
existing RTK or lean-ctx setup path. Existing negative flags remain
accepted, and other wrapped agents keep their current behavior.

Closes #1915

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

- Made Claude context-tool installation explicit instead of running it
on every default wrap.
- Preserved the existing RTK and lean-ctx installers behind the positive
opt-in.
- Kept `--no-context-tool` and `--no-rtk` compatible and left other
agent wrappers unchanged.
- Added focused command-parser coverage for default, opt-in, selector,
and negative-space behavior.
- Documented the changed default and opt-in command in `CHANGELOG.md`.

## Testing

- [x] Unit tests pass (`uv run --no-project pytest
tests/test_cli/test_wrap_helpers.py -q`)
- [x] Linting passes (`uv run --no-project ruff check
headroom/cli/wrap.py tests/test_cli/test_wrap_helpers.py`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for new functionality when applicable
- [ ] Manual testing performed

### Test Output

```text
uv run --no-project pytest tests/test_cli/test_wrap_helpers.py -q
65 passed

uv run --no-project ruff check headroom/cli/wrap.py tests/test_cli/test_wrap_helpers.py
All checks passed

uv run --no-project ruff format --check headroom/cli/wrap.py tests/test_cli/test_wrap_helpers.py
2 files already formatted
```

## Real Behavior Proof

- Environment: isolated HOME on Linux or macOS, Python 3.12+, Claude CLI
available.
- Exact command / steps: run `headroom wrap claude --prepare-only`
without a context-tool flag, inspect the isolated Claude config, then
repeat with the explicit context-tool opt-in.
- Observed result: the focused Click harness now proves the default run
creates no RTK setup calls, the explicit opt-in performs the existing
RTK setup, `--no-context-tool` still wins if both flags are present, and
Copilot still keeps its default context-tool behavior.
- Not tested: a live `headroom wrap claude` run against a real Claude
installation and a real RTK or lean-ctx hook write on this host.
- Scope: Claude context-tool activation and global configuration
artifacts.

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

N/A

## Additional Notes

The exact project-bound `uv sync --extra dev` flow was blocked on this
host by a `rustup.exe` access error, so the focused checks used `uv run
--no-project` against the existing environment. This PR does not change
RTK installation internals, proxy compression, or context-tool defaults
for other agents.
2026-07-11 10:18:57 -05:00
JD Davis
1d2b76e72e
fix: harden persistent install startup (#1851)
## Description

Hardens persistent install startup and proxy compression behavior for
issue #1843. Repeated `headroom install start` / scheduled ensure calls
no longer spawn duplicate runtimes by default, and `/v1/compress` now
fails open on compression timeout instead of returning a 503. The PR
also adds a machine-readable platform feature matrix and app-level
stabilization tests for health, compression functionality, timeout
behavior, and matrix evidence.

Refs #1843

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

## Changes Made

- Wrapped direct persistent deployment starts with the existing
profile-local runtime start lock.
- Made `headroom install start` idempotent when the deployment is
already healthy.
- Added wedged-runtime handling: if a PID is running but `/readyz` does
not recover inside the grace window, stop it before starting again.
- Kept `install agent ensure` inside the already-held lock while
delegating to the shared start helper.
- Changed `/v1/compress` timeout behavior from `503 compression_timeout`
to fail-open `200` with original messages, `compression_skipped: true`,
and `skip_reason: compression_timeout`.
- Added `tests/test_platform_stabilization_functional.py` covering real
FastAPI health/compression routes, successful compression metrics,
timeout fail-open speed, and a real JSON tool payload that reduces
tokens.
- Added `docs/platform-feature-matrix.json` and
`docs/platform-stabilization.md` for Linux/macOS/Windows hardening
coverage and known gaps.
- Strengthened matrix tests so cited local test/workflow paths must
exist.

## 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
# Clean source tree without copied Rust extension: functional module is skipped locally, as CI copies _core from the built wheel.
> python -m pytest tests/test_install tests/test_cli/test_install_cli.py tests/test_platform_feature_matrix.py tests/test_platform_stabilization_functional.py -q
collected 120 items / 1 skipped
119 passed, 2 skipped in 17.08s

> python -m ruff check headroom/proxy/handlers/openai.py tests/test_platform_stabilization_functional.py tests/test_platform_feature_matrix.py
All checks passed!

# Local Windows compiled-core proof:
> python -m maturin build --profile ci --out dist-local
Built wheel for abi3 Python >= 3.10 to dist-local\headroom_ai-0.29.0-cp310-abi3-win_amd64.whl

# Copied _core.pyd from the wheel into headroom/ for local route execution, then:
> python -m pytest tests/test_platform_stabilization_functional.py -q
collected 4 items
4 passed in 6.71s

> python -m pytest tests/test_install tests/test_cli/test_install_cli.py tests/test_platform_feature_matrix.py -q
collected 120 items
119 passed, 1 skipped in 17.16s

Commit hooks:
Sync plugin versions.....................................................Passed
check for merge conflicts................................................Passed
ruff.....................................................................Passed
ruff-format..............................................................Passed
mypy.....................................................................Passed
```

## Real Behavior Proof

- Environment: Windows 11, PowerShell, Python 3.13.13, worktree
`C:\git\headroom-stabilization` on branch
`jd/cross-platform-stabilization`.
- Exact command / steps: built the Windows wheel with `maturin`,
extracted `_core.pyd`, ran the new FastAPI route tests and
install/matrix tests listed above, then removed generated artifacts
before committing.
- Observed result: direct start paths now no-op when healthy, skip
spawning when the start lock is contended, and stop a wedged runtime
before restart. `/v1/compress` now returns original messages quickly on
timeout instead of a 503. The real JSON tool-payload smoke test returns
`tokens_before > tokens_after`, `tokens_saved > 0`, `compression_ratio <
1.0`, and non-empty transforms through the public route.
- Not tested: full native Windows persistent process e2e remains blocked
by the upstream CRT/wheel issue already documented in workflows and in
the matrix. No real OS service was installed locally; service manager
behavior is covered by argument-level unit 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
- [ ] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A

## Additional Notes

CHANGELOG is not updated because this is an unreleased
hardening/test/documentation pass. The platform matrix intentionally
records partial/blocked Windows/macOS e2e gaps instead of claiming full
coverage where the repo cannot currently run it.
2026-07-10 00:40:34 -04:00
Rod Boev
372d6c8cd4
fix(wrap): preserve custom Codex provider base_url during proxy injection (#1894)
## Description

Refs #1614 (Bug 2 only; Bug 1's config-mutation ordering is covered by a
separate PR).

`headroom wrap codex` unconditionally pointed the proxy's upstream
OpenAI route at `api.openai.com`, even when the user's Codex config
already declared a custom OpenAI-compatible provider such as
`freemodel.dev`, LiteLLM, or vLLM under `[model_providers.<name>]`. The
proxy then silently rerouted traffic to OpenAI, which rejected the
user's gateway API key, and Codex interpreted the resulting auth
failures as an invalid session.

## Type of Change

- [x] Bug fix

## Changes Made

- `_detect_custom_codex_upstream_base_url` and
`_codex_custom_provider_base_urls` in `headroom/cli/wrap.py` scan the
existing `config.toml` for a user-declared custom `[model_providers.*]`
table, excluding Codex built-ins and Headroom's own table, and return
its `base_url` when the selection is unambiguous: either the top-level
`model_provider` names it directly, or a prior wrap left the original
provider in the `# was: <original>` comment from
`_redirect_existing_top_level_keys`.
- The detector falls back to the sole custom provider when exactly one
candidate exists and no matching top-level selection is present, which
covers the issue repro where the custom table exists without a static
top-level provider pin.
- `_inject_codex_provider_config` now detects that custom upstream
before building the injected provider block. When found, it adds
`X-Headroom-Base-Url` to `env_http_headers`, mapped to
`HEADROOM_CODEX_UPSTREAM_BASE_URL`, matching Codex's env-var-based
header contract.
- `codex()` exports the detected value into
`HEADROOM_CODEX_UPSTREAM_BASE_URL` for the launched Codex process unless
the user already set it. The proxy's OpenAI HTTP handlers already honor
`X-Headroom-Base-Url`, so HTTP `/v1/chat/completions` and
`/v1/responses` requests forward to the preserved gateway instead of the
default OpenAI upstream.

This is scoped to the HTTP request path. Codex's WebSocket transport for
`/v1/responses` resolves its upstream from a separate header-independent
path and keeps the existing behavior.

## Testing

- [x] Focused Codex wrap tests passed locally before PR review: `pytest
tests/test_cli/test_wrap_codex.py -q`
- [x] Broader Codex CLI test selection passed locally before PR review:
`pytest tests/test_cli/ -k codex -q`
- [x] CI lint, format, and type checks passed on PR head `fb769349`.
- [x] CI build, test shards, native wrapper, Docker init, Docker wrap,
security, merge-conflict, and governance jobs passed on PR head
`fb769349`.
- [ ] Live `headroom wrap codex` against a real `freemodel.dev` account
was not run for this PR.

## Test Output

Previously reported local focused test output:

```text
pytest tests/test_cli/test_wrap_codex.py -q
83 passed

pytest tests/test_cli/ -k codex -q
113 passed, 426 deselected
```

Previously reported local static checks:

```text
ruff check
ruff format --check
mypy
```

CI evidence on PR head `fb769349`: GitHub Actions run `28987811482`
completed successfully for CI, including `lint` with `ruff check`, `ruff
format --check`, and `mypy`; `build`; `build-wheel`; four test shards;
`test-agno`; `test-extras`; `test-dashboard-ui`;
`windows-native-wrapper`; `macos-native-wrapper`; and
`docker-native-e2e`. PR Governance run `28987811437` completed
successfully.

## Real Behavior Proof

- Environment: Unit-level Codex config injection using pytest tmp home,
PR head `fb769349`, and GitHub Actions CI run `28987811482`.
- Exact command / steps: With a Codex config containing
`[model_providers.freemodel]`, `base_url = "https://api.freemodel.dev"`,
and `wire_api = "responses"`, call
`_inject_codex_provider_config(8787)`.
- Observed result: The injector returns `https://api.freemodel.dev`; the
injected `[model_providers.headroom]` table contains `env_http_headers =
{ "X-Headroom-Project" = "HEADROOM_PROJECT", "X-Headroom-Base-Url" =
"HEADROOM_CODEX_UPSTREAM_BASE_URL" }`; the user's
`[model_providers.freemodel]` table remains unchanged; and re-running
`_inject_codex_provider_config(9999)` preserves the same upstream while
updating the proxy port.
- Not tested: Live external traffic through a real `freemodel.dev` key,
WebSocket custom-upstream routing, and Bug 1's
dependency-check-before-config-mutation path.

## Review Readiness

- [x] Scope is limited to #1614 Bug 2, custom provider `base_url`
preservation for Codex wrap.
- [x] Bug 1 remains out of scope and is called out separately.
- [x] The changed code uses the existing proxy `X-Headroom-Base-Url`
contract instead of adding a new proxy route.
- [x] Ambiguous multiple custom providers keep prior fallback behavior
instead of guessing.
- [x] A collaborator reviewed and approved the current head after
running the focused Codex wrap tests locally.
- [x] This PR is ready for human review.

## Checklist

- [x] I have performed a self-review.
- [x] Focused tests added and passing.
- [x] Lint, format, and type-check clean on CI.
- [x] No unrelated files changed.
- [x] This PR is ready for human review.
2026-07-09 16:55:00 -05:00
Tejas Chopra
68676daa50
feat: ship the coding profile as Headroom's out-of-box default posture (#1893)
Make a bare `headroom proxy` (and the uvicorn factory / argparse main)
default to the cache-mode coding posture instead of requiring users to
set a dozen env vars.

Profile (agent_savings.py):
* "coding" is now the DEFAULT profile (DEFAULT_PROFILE), and it is
rewritten for cache mode: proxy_mode="cache" and
compress_user_messages=True (cache mode compresses the newest
OBSERVATION delta — a user/tool turn — so compress_user must be on or
there is nothing to compress; prefix stability is preserved by the delta
engine, not by refusing to touch user turns).
* AgentSavingsProfile carries the standalone router/handler toggles too
(tool_search, cross_turn_dedup, lossless_then_lossy, protect_reads,
code_aware, effort_router, lossless, min_chars_for_block); proxy_env()
emits them. Defaults preserve current behavior for the other profiles.
* coding sets: tool_search=1, dedupe=1, lossless_then_lossy=1,
protect_reads=1, code_aware=1, effort_router=0, lossless=0,
min_chars_for_block=25. CCR stays ON (no HEADROOM_NO_CCR) so any lossy
loss is recoverable.
* apply_agent_savings_env_defaults() now honors an explicit
HEADROOM_SAVINGS_PROFILE already in the env before falling back to the
default.

Delivery (pollution-free by construction):
* MODE and savings_profile default via INLINE defaults in the config
builders (cache / coding) — no global env mutation, so unit tests that
build config directly keep clean defaults.
* The request-time toggles are seeded into os.environ (setdefault) via
seed_proxy_env_defaults() ONLY at the executable/deployment entries —
run_server() (before serving) and create_app_from_env() (uvicorn
factory) — NOT in the CLI command or any library builder, so CliRunner
tests never leak coding defaults into os.environ across tests.
* CLI code_aware now defaults ON, matching the argparse server path
(degrades to a no-op without tree-sitter).

All explicit user env vars / CLI flags still win (setdefault + `or`
fallbacks). HEADROOM_LOSSLESS was already 0 by default; unchanged.

Tests: coding-profile + CLI-proxy-env tests updated to the new defaults;
1047 passed across the touched areas (only pre-existing memory/env
failures remain).

## Description

<!-- Briefly explain the change and why it is needed. -->

Closes #

## Type of Change

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

- 

## Testing

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

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

### Test Output

```text
# Paste relevant command output or artifact links here
```

## Real Behavior Proof

- Environment:
- Exact command / steps:
- Observed result:
- Not tested:

## Review Readiness

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

## Checklist

- [ ] My code follows the project's style guidelines
- [ ] I have performed a self-review of my code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

Add screenshots to help explain your changes.

## Additional Notes

<!-- Mention any N/A checklist items, tradeoffs, follow-ups, or
maintainer context. -->

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 07:49:54 -07:00
Vinay Gupta
38074888ac
fix(docker): report source build version (#1862)
## Description

Closes #1858

Docker/Compose source builds could report stale or misleading version
information: the dashboard initially rendered a hardcoded `v0.3.0`, then
`/health` replaced it with installed package metadata, which can be
stale when building locally from `main` without release metadata in the
image.

This change makes source Docker Compose builds report an explicit
source-build identity, removes the stale dashboard fallback, and keeps
CLI/doctor version checks from treating source-build labels as
release-version drift.

## 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_VERSION` / `HEADROOM_BUILD_VERSION` runtime version
overrides and optional packaged `_build_info.py` metadata.
- Teach Docker Compose source builds to pass a `source-build` sentinel
that the Dockerfile expands to `source-build+g<sha>` when git metadata
is available, or `source-build+sha256.<digest>` otherwise.
- Keep release/published image builds on normal package metadata when
`HEADROOM_BUILD_VERSION` is unset.
- Include only minimal `.git` metadata in the Docker build context so
the source-build label can identify the checkout without copying git
objects.
- Treat source-build labels and raw hashes as non-release labels in
`wrap` and `doctor`, avoiding false stale-proxy restarts and drift
warnings.
- Replace the dashboard hardcoded `0.3.0` fallback with `loading` /
`unknown` and format non-release build labels without a `v` prefix.
- Include the runtime version in proxy startup logs, `/health`,
`/livez`, and OTEL service version reporting.

## Testing

- [x] Unit tests pass (`pytest` in GitHub CI)
- [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
GitHub CI: all checks passing
- CI: build, build-wheel, lint, test shards, test-extras, test-agno, test-dashboard-ui
- Docker: docker-native-e2e, docker-wrap-e2e, docker-init-e2e
- Native wrappers: macOS, Windows, Ubuntu
- Security: CodeQL, gitleaks, pip-audit
- Governance: template, label, merge-conflicts, commitlint

$ HEADROOM_REQUIRE_RUST_CORE=false PYTHONPATH=/Users/vinaygupta/Desktop/git/headroom-fix-1858-version-mismatch pytest tests/test_package_init_lazy.py::test_version_prefers_explicit_build_env tests/test_package_init_lazy.py::test_version_label_helpers_only_prefix_release_versions tests/test_package_init_lazy.py::test_version_uses_packaged_build_metadata tests/test_package_init_lazy.py::test_observability_version_uses_runtime_version tests/test_docker_compose_persistence.py tests/test_cli_doctor.py::TestProxyLiveness::test_up_leaves_source_label_unprefixed tests/test_cli_doctor.py::TestVersionDrift::test_non_release_version_labels_skip_drift_comparison tests/test_cli/test_wrap_persistent.py::test_proxy_version_restart_ignores_non_release_source_labels tests/test_proxy_dashboard_stats_cache.py::test_dashboard_uses_cached_stats_and_lazy_history_feed_polling -q
13 passed, 1 warning

$ uvx ruff==0.15.17 check .
All checks passed!

$ uvx ruff==0.15.17 format --check .
1058 files already formatted

$ uvx mypy==1.20.2 headroom --ignore-missing-imports
Success: no issues found in 407 source files

$ git diff --check
# no output

$ docker compose config
# resolved headroom-proxy build args include HEADROOM_BUILD_VERSION: source-build

$ HEADROOM_BUILD_VERSION=6266a1d docker compose config
# explicit override is preserved as HEADROOM_BUILD_VERSION: 6266a1d

$ docker build --check --build-arg HEADROOM_BUILD_VERSION=source-build .
Check complete, no warnings found.
```

## Real Behavior Proof

- Environment: macOS local checkout, Python 3.13.5, Docker Desktop
builder `desktop-linux`, plus GitHub Actions CI.
- Exact command / steps: `docker compose config`,
`HEADROOM_BUILD_VERSION=6266a1d docker compose config`, and `docker
build --check --build-arg HEADROOM_BUILD_VERSION=source-build .`.
- Observed result: Compose defaults the top-level `headroom-proxy` build
arg to the `source-build` sentinel, preserves explicit overrides, and
Dockerfile syntax/check validation passes for the source-build path.
- Not tested: Full end-to-end release publishing flow; this PR only
changes local/source-build reporting.
- CI proof: GitHub Actions completed successfully across Docker E2E, CI
test shards, lint/type checks, native wrapper checks, security checks,
and PR governance.

## 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/CI with my changes
- [ ] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A

## Additional Notes

Docs and changelog are N/A for this runtime-reporting bug fix. The PR is
open and ready for review with all GitHub checks passing.
2026-07-08 13:32:04 -05:00
Parideboy
1573f1fd07
fix: use rtk native Cursor hook instead of injecting .cursorrules (#756) (#1846)
## Description

`headroom wrap cursor` unconditionally injected an `rtk`-usage
instructions block into `.cursorrules`. rtk itself supports a native
hook for Cursor (`rtk init --agent cursor`) — the same registration
mechanism headroom already uses for Claude Code — which rewrites shell
commands transparently with zero custom-instructions text needed.
Headroom never tried that path for Cursor, so users got a redundant
`.cursorrules` file duplicating guidance the native hook already
provides silently.

A follow-up commit hardens the switch: `register_agent_hooks` returns
`True` on rtk exit 0, but some rtk builds exit 0 without writing
`~/.cursor/hooks.json`. headroom now trusts the on-disk hook file, not
the exit code, before skipping the `.cursorrules` fallback.

Closes #756

## 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/rtk/installer.py`: generalized `register_claude_hooks` into
`register_agent_hooks(rtk_path, *, agent="claude")`, which passes
`--agent <agent>` to `rtk init` for non-Claude agents.
`register_claude_hooks` kept as a thin wrapper for backward
compatibility. Added `RTK_NATIVE_HOOK_AGENTS` documenting which agents
rtk supports a native hook for.
- `headroom/cli/wrap.py`: `wrap cursor` now calls
`register_agent_hooks(rtk_path, agent="cursor")` first, and only skips
the `.cursorrules` fallback when `~/.cursor/hooks.json` is actually on
disk; otherwise it falls back to `_inject_rtk_instructions(...)`.
- Tests: `tests/test_rtk_installer.py` and
`tests/test_cli/test_wrap_bridge.py` cover the native-hook path, the
on-disk verification, and the `.cursorrules` fallback.
- `CHANGELOG.md`: added an entry under `## Unreleased` / `### Fixed`.

## 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
$ python -m ruff format --check headroom/ tests/ e2e/
953 files already formatted

$ python -m ruff check headroom/cli/wrap.py headroom/rtk/installer.py tests/test_cli/test_wrap_bridge.py tests/test_rtk_installer.py
All checks passed!

$ python -m pytest tests/test_cli/test_wrap_bridge.py -k cursor -q
3 passed
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.13, local checkout; `python -m
pytest` / `ruff` run directly.
- Exact command / steps: `python -m pytest
tests/test_cli/test_wrap_bridge.py -k cursor -q` — the first test mocks
`register_agent_hooks` to write `~/.cursor/hooks.json` and asserts
`.cursorrules` is NOT created; the second mocks it to write nothing and
asserts `.cursorrules` IS created with the `headroom:rtk-instructions`
marker; the third exercises the explicit registration-failure fallback.
- Observed result: `3 passed`. Native-hook path skips `.cursorrules`
only when the hook file exists on disk; every other outcome falls back
to `.cursorrules`, so Cursor always gets RTK guidance.
- Not tested: real `rtk` binary writing `~/.cursor/hooks.json`
end-to-end — that path is covered by the `docker-wrap-e2e` CI job, not
locally.

## 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
- [x] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A — CLI-only change.

## Additional Notes

Scope: rtk's native-hook-capable agents include `claude`, `cursor`,
`windsurf`, `cline`, `kilocode`, `antigravity`, `pi`, `hermes`, but only
`cursor` and `claude` have a corresponding `headroom wrap` subcommand
today, so this fix only changes `wrap cursor` behavior.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:31:54 -05:00
Manmit Singh
140cb05fbc
fix(rtk): link managed rtk onto PATH instead of mutating the hook (#1698)
## Description

`headroom wrap claude` / `headroom update` patched
`~/.claude/hooks/rtk-rewrite.sh` after `rtk init --global --auto-patch`
wrote it. `rtk` bakes the expected SHA-256 of the canonical hook into
itself, so the post-write mutation trips its integrity guard — `rtk
verify` reports `hook integrity check FAILED … RTK will not execute` and
rtk hard-refuses to run. The patch also only absolutized the `rtk`
inside the hook, but `rtk rewrite` emits a bare `rtk` on stdout at
runtime that still needs PATH resolution, so the original silent-no-op
(#487) was never actually fixed. This leaves the hook untouched and
instead links the managed binary onto PATH.

Closes #1631

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

- Removed `_patch_rtk_hook_absolute_path` (mutated the canonical hook →
broke rtk's SHA-256 integrity guard).
- Added `_ensure_rtk_on_path`: symlinks the Headroom-managed `rtk` into
a PATH dir (prefers `~/.local/bin`) so the bare `rtk` that `rtk rewrite`
emits resolves, leaving the hook byte-for-byte as `rtk init` wrote it.
- No-op when a `rtk` already resolves on PATH, on Windows, or when no
writable PATH dir exists; never clobbers an existing real file or
foreign binary.
- Rewrote the test module (`test_wrap_rtk_on_path.py`) for the new
behavior.

## 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/bin/python -m pytest tests/test_cli/test_wrap_rtk_on_path.py -q
collected 7 items
tests/test_cli/test_wrap_rtk_on_path.py .......                          [100%]
============================== 7 passed in 0.25s ===============================

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

## Real Behavior Proof

- Environment: macOS arm64, Python 3.14, repo `.venv`, rtk hook-version
2 (matches reporter's rtk 0.28.2 setup).
- Exact command / steps: `.venv/bin/python -m pytest
tests/test_cli/test_wrap_rtk_on_path.py -q` — covers: no-op when rtk
already on PATH, symlink created into a PATH dir when missing,
`~/.local/bin` preferred + created on demand, idempotent second run,
existing-file not clobbered (falls through to next dir), no-op on
Windows and when no writable PATH dir exists.
- Observed result: 7 passed; the canonical hook file is never written,
so rtk's baked-in SHA-256 stays valid and `rtk verify` no longer fails.
- Not tested: live end-to-end `rtk verify` PASS on a machine with rtk
installed (no rtk binary in CI sandbox); logic mirrors the reporter's
verified manual fix (symlink managed rtk into a PATH dir + untouched
canonical hook).

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

Type checking / docs / CHANGELOG left unchecked: no public API or docs
change, and CHANGELOG is release-managed. The fix is confined to
`wrap.py`'s rtk setup path.
2026-07-07 12:23:26 -05:00
GUOHAO LIU
b4205c68e6
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description

Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.

Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.

## Problem

When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.

This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.

### Related issues

- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code

## Type of Change

- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)

## Changes Made

- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.

Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.

## Testing

- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality

### Test Output

```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED

> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```

## Real Behavior Proof

- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).

## 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] 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
2026-07-07 12:10:52 -05:00
Rod Boev
6fb5f3bc3d
fix(install): persist --no-http2 override through install apply (#1676)
## Description

`headroom install apply` regenerates the deployment manifest on every
run, and that regeneration silently drops any manually-added
`--no-http2` override. The HTTP/2 workaround itself is already real and
already supported by `headroom proxy`, but persistent installs had no
first-class way to keep it. This PR adds `--no-http2` to `install
apply`, threads it into `build_manifest()`, and persists the flag in
`manifest.proxy_args` so it survives reapply. Closes #1615

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

- Added `--no-http2` to `headroom install apply`, and forwarded the flag
into `build_manifest()`.
- Extended `headroom/install/planner.py` so `build_manifest(...,
no_http2=True)` persists `--no-http2` into `manifest.proxy_args`.
- Added planner-level regression coverage for both the override path and
the default-preservation path.
- Added CLI-level regression coverage that proves `install apply
--no-http2` forwards correctly and that the help surface advertises the
flag.
- `CHANGELOG.md` intentionally not touched: repo policy generates
changelog entries from conventional commits rather than manual PR edits.

## Testing

- [x] Unit tests pass (`uv run pytest
tests/test_install/test_planner.py` and `uv run pytest
tests/test_cli/test_install_cli.py`)
- [x] Linting passes (`uv run ruff check .` and `uv run ruff format .
--check`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
> rtk uv run pytest tests/test_install/test_planner.py -k no_http2 -q
collected 7 items / 5 deselected / 2 selected
tests\test_install\test_planner.py ..                                    [100%]
2 passed, 5 deselected in 0.18s

> rtk uv run pytest tests/test_cli/test_install_cli.py -k no_http2 -q
collected 19 items / 17 deselected / 2 selected
tests\test_cli\test_install_cli.py ..                                    [100%]
2 passed, 17 deselected in 0.23s

> rtk uv run pytest tests/test_install/test_runtime.py -q
collected 19 items
tests\test_install\test_runtime.py ..........F........                   [100%]
FAILED tests/test_install/test_runtime.py::test_runtime_start_lock_blocks_another_process
1 failed, 18 passed in 0.44s
(Confirmed pre-existing on unmodified origin/main via `git stash` in this worktree,
identical failure with none of this PR's changes applied. Environment-specific lock-file
flakiness in this sandbox, unrelated to install-manifest persistence; runtime.py was not
touched by this change.)

> rtk uv run ruff check headroom/cli/install.py headroom/install/planner.py tests/test_install/test_planner.py tests/test_cli/test_install_cli.py
All checks passed!

> rtk uv run ruff format --check headroom/cli/install.py headroom/install/planner.py tests/test_install/test_planner.py tests/test_cli/test_install_cli.py
4 files already formatted
```

## Real Behavior Proof

- Environment: local source checkout with `uv` dev environment, using
the existing install CLI and manifest builder, in worktree
`D:\Repos\headroom-pr-1615-persist-install-http2-override`.
- Exact command / steps: ran `headroom install apply --help` through
`CliRunner`, ran a direct `build_manifest(..., no_http2=True)` proof,
and ran the focused planner, CLI, runtime, and lint checks.
- Observed result: on `origin/main`, `install apply --help` lacked
`--no-http2` and `build_manifest(..., no_http2=True)` raised `TypeError:
build_manifest() got an unexpected keyword argument 'no_http2'`; on this
branch, `install apply --help` lists `--no-http2`, `build_manifest(...,
no_http2=True)` returns a manifest whose `proxy_args` contains exactly
one `--no-http2` entry (`['--host', '127.0.0.1', '--port', '8787',
'--mode', 'token', '--backend', 'anthropic', '--telemetry',
'--no-http2']`), persistent installs now preserve the existing HTTP/2
disable flag across `install apply` regeneration, and runtime behavior
still comes entirely from replaying manifest `proxy_args` (`runtime.py`
was not modified).
- Not tested: a full persistent-service supervisor round-trip or full CI
suite locally.

## 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
- [ ] 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
- [ ] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable (not applicable,
changelog entries are generated from conventional commits per repo
policy)

## Additional Notes

This stays scoped to the install-manifest persistence seam only; it does
not revisit HTTP/2 default policy, retry behavior, or proxy transport
construction. Attribution: the implementation shape follows the
persistence pattern already established by #1365, and the remaining
install-layer gap was confirmed by `sarkarsital1959` in the 2026-07-01
comment on #1615.
2026-07-07 11:37:23 -05:00
Parideboy
84509a4b89
fix: detect and clear stale ANTHROPIC_BASE_URL from crashed wrap sessions (#1768) (#1837)
## Description

`headroom wrap claude` writes `env.ANTHROPIC_BASE_URL` (or the
foundry/vertex variant) into a project's `.claude/settings.local.json`
so daemon-spawned Claude Code workers route through the local Headroom
proxy. Removal only happened in the wrap process's `finally:` block. An
unclean exit — `SIGKILL`, OOM, reboot, or terminal/tmux close (`SIGHUP`,
which was not caught; only `SIGINT`/`SIGTERM` were) — skipped that
cleanup, so the entry persisted indefinitely. Every subsequent bare
`claude` in that project then routed to the dead port and hung
indefinitely retrying it.

Closes #1768

## 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_claude_wrap_base_url` now optionally stamps a sidecar marker
(`.claude/.headroom_wrap_marker.json`) recording the writer's
pid/identity, the port, and the true prior value — kept out of
`settings.local.json` itself so Headroom bookkeeping never shows up as a
stray key in a file Claude Code's own config loader parses.
- A shared `_identity_mismatch` helper (factored out of the existing
`_marker_pid_reused` proxy-client-refcounting logic) lets a marker be
judged stale: missing/invalid pid, dead pid, or a live pid whose
identity doesn't match the recorded one (PID reuse after a crash).
- `claude()` now checks for — and self-heals — a stale marker
immediately before writing a fresh entry, restoring the recorded prior
value instead of trusting a leftover from a dead session.
- `claude()` now also registers a `SIGHUP` handler (guarded via
`hasattr`, since Windows has none) alongside the existing `SIGTERM`
handler, so terminal-close triggers the same cleanup/restore path.
- `headroom unwrap claude` now reads the marker's recorded prior value
before restoring, instead of unconditionally deleting the key — so a
user's own pre-existing `ANTHROPIC_BASE_URL` (set before ever running
`wrap`) isn't blindly wiped.
- `headroom doctor` gained a new check (`check_wrap_marker_staleness`)
that flags a stale project-local marker and points at `headroom unwrap
claude` to clean it up — separate from the existing global-settings
`check_claude_routing` check.
- (Unrelated, pre-existing on `main`) reformatted
`headroom/proxy/handlers/openai.py`,
`tests/test_openai_codex_ws_lifecycle.py`, `tests/test_output_shaper.py`
— whitespace/indentation only, no logic change — since they were already
failing `ruff format --check .` on `main` before this branch touched
anything, and the repo-wide lint gate blocks on it.

Out of scope: `wrap --worktree` — no such flag or multi-worktree
`.claude` handling exists anywhere in `wrap.py` today; not adding new
surface for an aspirational scenario the issue mentions but that isn't
implemented.

## 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 tests/test_cli/test_wrap_claude_base_url.py tests/test_cli/test_unwrap_claude.py tests/test_cli/test_wrap_stale_marker.py -q
42 passed

$ pytest tests/test_cli -q
512 passed, 1 failed (test_wrap_codex_prepare_only_registers_serena_when_uvx_exists —
confirmed to fail identically on a clean checkout of main with no changes applied;
test-order flake, unrelated to this PR)

$ ruff check .
All checks passed!

$ ruff format --check .
1047 files already formatted

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

## Real Behavior Proof

- Environment: local checkout, Python 3.13, Windows.
- Exact command / steps: wrote a base_url entry + marker via
`_write_claude_wrap_base_url(..., port=8787)`, then overwrote the
marker's recorded pid with a value guaranteed not to be a live process
(simulating the crash from the issue's own repro: `headroom wrap claude
-- -p ok & ; kill -9 <wrap-pid>`). Ran
`headroom.cli.doctor.check_wrap_marker_staleness()` against that path,
then called `_check_and_clear_stale_wrap_marker()` (the same check
`claude()` now runs before writing a fresh entry).
- Observed result: `doctor`'s check correctly reports `WARN` naming the
dead pid/port and pointing at `headroom unwrap claude`. The stale-check
call then self-heals: in the "nothing existed before wrap" case the
leaked entry is removed; in a second run seeded with a real pre-existing
`ANTHROPIC_BASE_URL` (set before `wrap` ever ran), that original value
is recovered instead of being deleted. In both cases the marker file is
cleared afterward.
- Not tested: actual OS-level signal delivery (`kill -HUP` against a
real running `headroom wrap claude` subprocess) — the SIGHUP
registration is exercised via a source-inspection test instead of a live
signal, since spawning/killing the real CLI subprocess isn't practical
in this environment; verified E2E via CI's `wrap-native` jobs
(Ubuntu/macOS) which passed.

## 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
- [x] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A — CLI/backend fix, no UI surface.

## Additional Notes

- Documentation checklist item left unchecked: no user-facing docs
currently describe wrap's settings.local.json write/cleanup behavior in
enough detail to need updating; happy to add a troubleshooting note if
maintainers want one.
- `wrap --worktree` handling is out of scope (see Changes Made) —
flagging in case maintainers want it tracked as a separate follow-up
issue.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 08:35:40 -07:00
Rod Boev
afd9cbdfaf
fix(copilot): normalize subscription routing host (#1836)
## Description

`headroom wrap copilot --subscription` can currently trust the
token-exchange host for individual Copilot seats, which routes newer
responses-API models like `gpt-5.4` to
`api.individual.githubcopilot.com` and reproduces the transient `502`
retry loop from issue #1694. This normalizes that public individual-seat
host back to the generic Copilot API host while preserving dedicated
business or explicitly pinned hosts. Closes #1694.

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

- Normalized exchanged Copilot subscription hosts through the existing
public-host classifier instead of trusting the raw token-exchange
payload.
- Added a regression proving `api.individual.githubcopilot.com`
downgrades to `https://api.githubcopilot.com` for subscription routing.
- Added a wrap-level regression proving subscription launches export the
normalized host into the proxy env.
- Preserved business-host and explicit `GITHUB_COPILOT_API_URL` routing
behavior.

## Testing

- [x] Unit tests pass (`uv run pytest tests/test_copilot_auth.py -q`)
- [x] Unit tests pass (`uv run pytest
tests/test_cli/test_wrap_copilot.py -q`)
- [x] Linting passes (`uv run ruff check headroom/copilot_auth.py
tests/test_copilot_auth.py tests/test_cli/test_wrap_copilot.py`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for new functionality when applicable
- [ ] Manual testing performed

### Test Output

```text
uv run pytest tests/test_copilot_auth.py -q
57 passed, 1 warning in 0.34s

uv run pytest tests/test_cli/test_wrap_copilot.py -q
31 passed, 1 warning in 0.32s

uv run ruff check headroom/copilot_auth.py tests/test_copilot_auth.py tests/test_cli/test_wrap_copilot.py
All checks passed!

uv run ruff format --check headroom/copilot_auth.py tests/test_copilot_auth.py tests/test_cli/test_wrap_copilot.py
3 files already formatted
```

## Real Behavior Proof

- Environment: Windows, Python `uv` environment, mocked Copilot
token-exchange and wrap launch surfaces.
- Exact command / steps: run the focused Copilot auth and wrap
regression tests after teaching subscription token-exchange routing to
normalize the public individual-seat host.
- Observed result: exchanged subscription tokens that advertise
`https://api.individual.githubcopilot.com` now route through
`https://api.githubcopilot.com`, while business-host and explicit-host
pin cases stay unchanged.
- Not tested: a live GitHub Copilot subscription request against the
upstream service.

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

## Additional Notes

This is intentionally scoped to host selection for exchanged Copilot
subscription tokens. It does not change token discovery, token pinning,
or non-subscription OAuth routing.
2026-07-06 06:23:48 -07:00
Rudimar Ronsoni
4bd3ddfaa5
fix(opencode): use local MCP config (#1383)
## Description

Fixes OpenCode Headroom MCP configuration across wrap, MCP
install/status/uninstall, and persistent install docs/CLI.

OpenCode was being configured to use a remote HTTP MCP endpoint at
`/mcp`, but the Headroom proxy does not expose MCP there. The correct
OpenCode configuration is a local stdio MCP server that runs `headroom
mcp serve`.

Closes #1380

## Type of Change

- [x] Bug fix
- [ ] New feature
- [ ] Breaking change
- [x] Documentation update
- [x] Tests

## Changes Made

- Changed OpenCode MCP registration to emit `type: "local"` with
`command: ["headroom", "mcp", "serve"]`.
- Changed OpenCode MCP environment serialization from `env` to
OpenCode's `environment` key, while still reading legacy `env` entries.
- Removed generated remote `/mcp` entries from OpenCode wrap/runtime
config.
- Made `wrap opencode --no-mcp` skip persistent `mcp.headroom`
injection.
- Kept provider-only OpenCode config injection from writing MCP; MCP
persistence is owned by the registrar path.
- Made `headroom mcp status` and `headroom mcp uninstall` use the
registrar lifecycle so OpenCode is covered.
- Added `opencode` to persistent install `--target` choices.
- Clarified OpenCode persistent install docs to use `--scope provider`
for direct `opencode.json` edits.
- Added regression coverage for registrar serialization, wrap behavior,
runtime config, provider-scope install, MCP CLI lifecycle, and install
target parsing.

## Testing

- [x] `rtk .venv/bin/python -m pytest tests/test_mcp_registry
tests/test_cli/test_mcp.py tests/test_cli/test_wrap_opencode.py
tests/test_providers_opencode_config.py
tests/test_providers_opencode_install.py tests/test_install -q`
- [x] Result after absorbing #1381 overlap: `263 passed, 1 skipped`
- [x] Targeted Ruff check passed for the changed Python/test files.
- [x] Targeted Ruff format check passed for the changed Python/test
files.
- [x] Isolated HOME smoke tests with real `opencode mcp list --pure`.

## Real Behavior Proof

- `headroom mcp install --agent opencode --proxy-url
http://127.0.0.1:9000 --force` against an isolated HOME wrote a valid
local OpenCode MCP entry with `environment.HEADROOM_PROXY_URL`.
- `opencode mcp list --pure` against that isolated HOME connected to
`headroom mcp serve`.
- `headroom wrap opencode --prepare-only --no-rtk --no-serena --port
9001` wrote local MCP plus provider config.
- `headroom wrap opencode --prepare-only --no-rtk --no-serena --no-mcp
--port 9002` wrote provider config without `mcp.headroom`.
- Generated runtime `OPENCODE_CONFIG_CONTENT` was accepted by `opencode
mcp list --pure`; `include_mcp=False` reported no MCP servers.
- `headroom mcp status` detected the isolated OpenCode config and read
the custom proxy URL.
- `headroom mcp uninstall` removed `mcp.headroom` from the isolated
OpenCode config while leaving provider config intact.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review
2026-07-06 06:22:15 -07:00
Abhay Singh
c9d717c13c
fix(wrap): remove rtk instructions from Codex AGENTS.md on unwrap (#1604)
## Description

`headroom wrap codex` injects Headroom's marker-fenced rtk instruction
block
into the Codex **global** `AGENTS.md` (`_codex_home_dir() /
"AGENTS.md"`), so
Codex voluntarily prefixes shell commands with `rtk`. But `headroom
unwrap
codex` only restored `config.toml` and cleaned up the MCP/Serena servers
— it
never removed that `AGENTS.md` block.

The result: after unwrapping, a plain `codex` launch still inherits
Headroom's
behavior and keeps trying to run `rtk`. If the managed rtk binary
directory is
no longer on `PATH`, commands fail outright:

```text
rtk : The term 'rtk' is not recognized as the name of a cmdlet, function, script file, or operable program.
Conversation interrupted
```

`unwrap copilot` already calls `_remove_rtk_instructions(...)`; Codex
was simply
missing the same cleanup step.

Closes #1421

## Fix

Call the existing `_remove_rtk_instructions` helper on the Codex global
`AGENTS.md` inside `unwrap_codex`, right after the MCP-server cleanup:

```python
if _remove_rtk_instructions(_codex_home_dir() / "AGENTS.md"):
    click.echo("  Removed Headroom rtk instructions from Codex AGENTS.md.")
```

The helper strips only the marker-fenced block and rewrites the rest of
the
file (deleting it only if nothing else remains), so user-authored
`AGENTS.md`
content is preserved. The call is unconditional and best-effort,
matching the
existing MCP-server cleanup in the same function.

## Type of Change

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

## Changes Made

- `headroom/cli/wrap.py`: `unwrap_codex` now removes the marker-fenced
rtk block from the Codex global `AGENTS.md` via
`_remove_rtk_instructions`, with a status echo.
- `tests/test_cli/test_wrap_codex.py`: regression tests — block removed
on unwrap, surrounding user content preserved, and a no-op when
`AGENTS.md` is absent.
- `CHANGELOG.md`: Bug Fixes entry under Unreleased.

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

Before the fix the two removal tests fail (the no-AGENTS.md safety test
passes
either way); after the fix the whole file is green:

```text
# before the fix (wrap.py reverted, tests kept)
FAILED tests/test_cli/test_wrap_codex.py::...::test_unwrap_removes_rtk_block_from_global_agents
FAILED tests/test_cli/test_wrap_codex.py::...::test_unwrap_preserves_user_content_in_global_agents
================= 2 failed, 1 passed, 66 deselected in 1.00s ==================

# after the fix
tests\test_cli\test_wrap_codex.py ......................................
...............................
============================= 69 passed in 7.45s ==============================
```

```text
$ uv run ruff check headroom/cli/wrap.py tests/test_cli/test_wrap_codex.py
All checks passed!
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12.11, headroom built from this
branch (`uv sync --extra dev`).
- Exact command / steps: set `CODEX_HOME` to a temp dir, wrote a user
`AGENTS.md`, injected the rtk block with the same helper `wrap codex`
uses, then ran the real `unwrap codex` command
(`unwrap_codex.callback(port=8787, no_stop_proxy=True)`) and re-read the
file. No mocking of the code under test.
- Observed result: the command printed `Removed Headroom rtk
instructions from Codex AGENTS.md.`, the rtk marker is gone, and the
user's own content survived:

```text
=== AGENTS.md BEFORE unwrap ===
# My rules

Always write tests.

<!-- headroom:rtk-instructions -->
# RTK (Rust Token Killer) - Token-Optimized Commands
...
<!-- /headroom:rtk-instructions -->
rtk marker present before: True

--- running: headroom unwrap codex --no-stop-proxy ---
  Removed Headroom rtk instructions from Codex AGENTS.md.
✓ Codex is no longer routed through the Headroom proxy.

=== AGENTS.md AFTER unwrap ===
# My rules

Always write tests.

rtk marker present after: False
user content preserved: True
```

- Not tested: did not run a full real `codex` binary session end-to-end
(not installed in this environment); the global-`AGENTS.md` state is the
durable thing the bug was about, and it's exercised here for real. Did
not run the full `mypy headroom` pass (one-line cleanup call, no new
types).

## 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
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

- Single logical change, no new dependencies. Reuses the existing
`_remove_rtk_instructions` helper, so there's no new removal logic to
maintain.
- @chopratejas this mirrors the `unwrap copilot` cleanup; flagging you
since you've been triaging the wrap/unwrap issues.

Co-authored-by: JD Davis <mxjerrett@gmail.com>
2026-07-02 23:55:41 -05:00
Rod Boev
4bf7f92417
fix(claude): surface Remote Control proxy incompatibility (#1610)
## Description

Claude Code hides Remote Control when it sees a custom
`ANTHROPIC_BASE_URL`, so `headroom wrap claude` can make the menu
disappear even though normal API requests still route through Headroom.
The reported proxy logs show no Remote Control registration, session
bootstrap, or device-attestation request at all, which means the
decision happens inside Claude before Headroom can forward anything.

This change makes that client-side incompatibility explicit in
Headroom's Claude launch flow, `headroom doctor`, and troubleshooting
docs. API proxying and the existing `ENABLE_TOOL_SEARCH` compatibility
shim stay unchanged; users who need Remote Control get a direct
instruction to launch Claude without the Headroom proxy for that
session.

Closes #1601

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

- Add a Claude-specific helper and warning text for the Remote Control
custom-base incompatibility.
- Surface that warning from `headroom wrap claude` when Claude is
launched through `ANTHROPIC_BASE_URL`.
- Add a separate `headroom doctor` warning for Claude Remote Control
availability, while keeping Claude API-routing status independent.
- Document the limitation and workaround next to the existing Claude
custom-endpoint troubleshooting guidance.
- Add focused regression tests for gated and non-gated Claude routing
states, plus preservation coverage for `ENABLE_TOOL_SEARCH`.

## Testing

- [x] Unit tests pass (`uv run pytest
tests/test_issue_1601_remote_control_gate.py tests/test_cli_doctor.py
tests/test_cli/test_wrap_claude_vertex_proxy_env.py -q`)
- [x] Unit tests pass (`uv run pytest
tests/test_issue_746_tool_search.py
tests/test_cli/test_init_enable_tool_search.py -q`)
- [x] Linting passes (`uv run ruff check headroom/cli/wrap.py
tests/test_cli_doctor.py
tests/test_cli/test_wrap_claude_vertex_proxy_env.py`)
- [x] Formatting passes (`uv run ruff format --check
headroom/cli/wrap.py tests/test_cli_doctor.py
tests/test_cli/test_wrap_claude_vertex_proxy_env.py`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for the bugfix
- [ ] Manual testing performed

### Test Output

```text
rtk uv run pytest tests/test_issue_1601_remote_control_gate.py tests/test_cli_doctor.py tests/test_cli/test_wrap_claude_vertex_proxy_env.py -q
============================= test session starts =============================
collected 62 items
62 passed, 1 warning

rtk uv run pytest tests/test_issue_746_tool_search.py tests/test_cli/test_init_enable_tool_search.py -q
============================= test session starts =============================
collected 33 items
33 passed, 1 warning

rtk uv run ruff check headroom/cli/wrap.py tests/test_cli_doctor.py tests/test_cli/test_wrap_claude_vertex_proxy_env.py
All checks passed!

rtk uv run ruff format --check headroom/cli/wrap.py tests/test_cli_doctor.py tests/test_cli/test_wrap_claude_vertex_proxy_env.py
3 files already formatted
```

## Real Behavior Proof

- Environment: Windows, Python via `uv`, focused Claude CLI and doctor
tests.
- Exact command / steps: with Claude settings or shell environment
containing `ANTHROPIC_BASE_URL=http://127.0.0.1:8787`, run the focused
helper and doctor tests, then run the existing `ENABLE_TOOL_SEARCH`
preservation tests.
- Observed result: Headroom surfaces a Claude Remote Control warning for
custom `ANTHROPIC_BASE_URL`, while Claude API routing and
`ENABLE_TOOL_SEARCH` behavior stay intact.
- Not tested: live Claude Remote Control UI automation. The issue
evidence says Claude hides the menu before any request reaches Headroom,
so this PR proves Headroom's launch, diagnostics, and docs behavior.

## 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
- [ ] I have updated the CHANGELOG.md if applicable

## Additional Notes

`CHANGELOG.md` stays untouched because this repo's release pipeline
generates changelog entries from conventional commits.

This is a visibility fix, not a proxy transport restore. The issue
evidence shows Claude never sends a Remote Control request while the
custom-base gate is active, so the surviving slice is launch-time
warning, doctor warning, and documentation.

PR `#1600` is adjacent and non-blocking because `#1601` reproduces from
process-env `ANTHROPIC_BASE_URL` alone.

This intentionally changes `headroom doctor` for fully routed Claude
sessions from an all-pass result to one warnings-only result, because
the proxied Claude setup is operational for API traffic but still
incompatible with Remote Control.
2026-07-01 23:19:25 -05:00
Vinay Gupta
75427bbd4a
fix(wrap): preserve custom Vertex base URL (#1477)
## Description

Fixes `headroom wrap claude` in Vertex mode when the user has configured
a custom Vertex-compatible gateway through `ANTHROPIC_VERTEX_BASE_URL`.

Before this change, wrap mode redirected Claude Code's
`ANTHROPIC_VERTEX_BASE_URL` to the local Headroom proxy, but the
original custom upstream was not forwarded to the proxy as
`VERTEX_TARGET_API_URL`. The proxy therefore fell back to the default
Google Vertex endpoints and custom gateways could return 404 or
auth/model errors.

Closes #1476

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

- Capture the original Vertex upstream before `wrap claude` redirects
Claude Code to the local proxy.
- Pass custom Vertex upstreams to the proxy as `--vertex-api-url` /
`VERTEX_TARGET_API_URL`.
- Let explicit `VERTEX_TARGET_API_URL` take precedence over
`ANTHROPIC_VERTEX_BASE_URL`.
- Guard against accidentally using the local Headroom proxy URL as the
proxy's own Vertex upstream.
- Restart idle running proxies when their configured Vertex upstream
does not match the requested Vertex mode state.
- Persist and restore `ANTHROPIC_VERTEX_BASE_URL` for Vertex-mode Claude
daemon workers, and clean it up during `unwrap claude`.
- Expose `vertex_api_url` in loopback health config so wrapper reuse
checks can detect mismatches.

## 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
$ rtk gh pr checks 1477 --repo headroomlabs-ai/headroom
CI Checks Summary:
  [ok] Passed: 22
  [FAIL] Failed: 0

$ rtk pytest tests/test_cli/test_wrap_claude_vertex_proxy_env.py tests/test_cli/test_unwrap_claude.py tests/test_azure_foundry_claude_compression.py tests/test_cli/test_wrap_persistent.py tests/test_provider_registry.py -q
Pytest: 64 passed

$ rtk uvx ruff==0.15.17 check headroom/cli/wrap.py headroom/proxy/server.py tests/test_cli/test_wrap_claude_vertex_proxy_env.py tests/test_cli/test_unwrap_claude.py
All checks passed!

$ rtk uvx ruff==0.15.17 format --check headroom/cli/wrap.py headroom/proxy/server.py tests/test_cli/test_wrap_claude_vertex_proxy_env.py tests/test_cli/test_unwrap_claude.py
4 files already formatted

$ rtk python3 -m py_compile headroom/cli/wrap.py headroom/proxy/server.py tests/test_cli/test_wrap_claude_vertex_proxy_env.py tests/test_cli/test_unwrap_claude.py
# passed, no output

$ rtk uv run --python 3.13 pytest tests/test_vertex_claude_compression.py -q
Failed before test collection while building the local editable package:
esaxx-rs build failed with fatal error: 'cstdint' file not found.
```

## Real Behavior Proof

- Environment: GitHub Actions CI on PR #1477 plus local macOS worktree
`fix/1476-vertex-base-url`.
- Exact command / steps: CI ran lint, type checking, build, unit-test
shards, native wrapper checks, wrap-native e2e, and Docker e2e jobs;
locally ran focused wrapper, unwrap, Foundry, persistent-proxy, and
provider-registry tests.
- Observed result: CI passed 22 checks with 0 failures; local focused
tests passed; Ruff check/format passed; Python compile passed.
- Not tested: broader proxy-route tests that import
`headroom.proxy.server` through a local editable build could not run
locally because the native `esaxx-rs` build fails before test collection
with missing `cstdint`.

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

## Additional Notes

- Documentation, CHANGELOG, and extra code-comment checklist items are
N/A for this narrow wrapper bug fix.
- Full local unit test execution is limited by the existing native
extension build issue described above; focused Python-only coverage
passes and GitHub CI is green.
2026-06-30 14:15:57 -05:00
quentinmaisonneuve
6cba4419d0
fix(wrap): detach the shared proxy on Windows so it survives an ungraceful agent close (#1464)
## Description

Closing one `headroom wrap <agent>` instance on Windows could kill the
**shared proxy** out from under every other running instance, so their
requests started failing.

`_start_proxy` launched the proxy as a child of whichever agent started
it first, without detaching it from that agent's console and Job object.
The wrapper already reference-counts clients via per-PID markers and
`_make_cleanup` leaves the proxy running while other clients exist — but
that only runs on a *graceful* exit. On an *ungraceful* close (closing
the terminal window, `taskkill`, a crash) Windows tree-kills the whole
process group/Job and the proxy dies directly, bypassing the reference
counting. Every other instance's `ANTHROPIC_BASE_URL` then points at a
dead `127.0.0.1:8787`, so all of its API traffic fails.

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

- `_start_proxy` creates the proxy with `DETACHED_PROCESS |
CREATE_NEW_PROCESS_GROUP | CREATE_BREAKAWAY_FROM_JOB` on Windows, so an
ungraceful close of the launching agent can no longer reach it; only the
ref-counted `_make_cleanup` ends the proxy.
- Falls back without `CREATE_BREAKAWAY_FROM_JOB` (catching `OSError`)
when the launcher's Job forbids breakaway; `DETACHED_PROCESS` still
spares the proxy from console-close events.
- Platform guard is `sys.platform == "win32"` (not `os.name == "nt"`) so
mypy narrows the platform and resolves the Windows-only `subprocess`
constants.
- POSIX path unchanged: `creationflags=0`, detachment still via
`start_new_session` (`setsid`).
- Added `tests/test_cli/test_wrap_proxy_detach.py` and a CHANGELOG Bug
Fixes entry.

## 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 tests/test_cli/test_wrap_proxy_detach.py -q
..                                                                       [100%]
2 passed, 2 warnings in 1.50s

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

$ mypy --follow-imports=silent headroom/cli/wrap.py tests/test_cli/test_wrap_proxy_detach.py
Success: no issues found
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.11.9, headroom-ai (pipx). Two
concurrent `headroom wrap claude` instances sharing proxy
`127.0.0.1:8787`. `_start_proxy` was also exercised directly on this
host with `subprocess.Popen` stubbed.
- Exact command / steps: (1) start two `headroom wrap claude` instances;
(2) close the terminal window of the one that started the proxy
(ungraceful — not `/exit`); (3) issue a request from the surviving
instance. Separately: call `_start_proxy(8787)` with `subprocess.Popen`
stubbed and read back the creation flags.
- Observed result: before the fix the proxy died with the closed window
and the surviving instance failed (`ANTHROPIC_BASE_URL` → dead `:8787`),
because the OS tree-killed the child before the ref-count path could
spare it. After the fix the detached proxy survives the close and the
surviving instance keeps working; the stub harness reports
`creationflags=0x1000208` (`DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
| CREATE_BREAKAWAY_FROM_JOB`) on win32 and `0` when forced off-Windows.
- Not tested: real breakaway behavior under an actual restrictive Job
object on this host (the OS-level effect). The `OSError` fallback path
itself now has a dedicated unit test
(`test_start_proxy_retries_without_breakaway_when_job_forbids_it`).

## 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
- [x] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A — no UI changes.

## Additional Notes

- Documentation checklist item is N/A: this is a behavioral bug fix with
no user-facing doc surface.
- Scope is the single `subprocess.Popen` call in `_start_proxy`; the
marker-based reference counting in `_make_cleanup` is unchanged and
remains the only thing that intentionally stops the proxy.
2026-06-30 13:49:28 -05:00
Rudimar Ronsoni
ddd4adf911
fix(codex): avoid duplicate headroom provider config (#1431)
## Description

Fixes #1425.

`headroom wrap codex` could leave `~/.codex/config.toml` invalid when
the user already had a `[model_providers.headroom]` table. The previous
duplicate-key handling covered top-level `model_provider` and
`openai_base_url`, but the provider table was still appended as a static
block. That could produce duplicate `env_http_headers` or duplicate
provider-table TOML errors before Codex started.

## Type of Change

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

## Changes Made

- Added a Codex config cleanup helper that removes any pre-existing
`[model_providers.headroom]` table from the working copy before `wrap
codex` appends the managed Headroom provider block.
- Kept unwrap behavior backed by the existing pre-wrap snapshot, so a
custom prior `headroom` provider table is restored byte-for-byte on
`headroom unwrap codex`.
- Added regression tests for TOML validity, a single `env_http_headers`
mapping, one managed `[model_providers.headroom]` table, and unwrap
restoration.

## Testing

- [x] Unit tests pass (`pytest`)
- [ ] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added to cover the fix

### Test Output

```text
Docker: python:3.12-slim
Command: uv run --frozen --with pytest pytest tests/test_cli/test_wrap_codex.py
Result: 68 passed, 1 warning
```

## Real Behavior Proof

- Environment: disposable Docker container, `python:3.12-slim`, Linux,
Python 3.12.13.
- Exact command / steps: mounted the worktree into `/workspace`,
installed build tools inside the container, then ran `uv run --frozen
--with pytest pytest tests/test_cli/test_wrap_codex.py`.
- Observed result: all Codex wrap tests passed, including the new
regression where an existing `[model_providers.headroom]` table contains
`env_http_headers` before wrapping.
- Not tested: live interactive `headroom wrap codex` launch against a
real user Codex session.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review
2026-06-30 13:42:48 -05:00
Ralf Escher
16c638bc21
fix: recover persistent proxy feature checks and reject non-Copilot exchange URL (#1465)
## Description

This PR fixes two related reliability issues in Copilot
wrap/subscription flows:

1. Recovered persistent proxy instances could be reused too early,
before validating requested feature-sensitive config (especially
`openai_api_url`), which could lead to wrong upstream routing.
2. Subscription token-exchange payloads could provide a non-Copilot API
URL; this is now rejected and we safely fall back to user-info/default
Copilot endpoint resolution.

Related: #488

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

- Updated persistent proxy recover path to:
- continue into feature checks when feature-sensitive options are
requested
- restart persistent deployment when config is missing/mismatched after
recovery
  - keep historical fast return for plain recover-only calls
- Hardened subscription exchange URL resolution:
  - accept exchange `api_url` only when it is a Copilot host
  - log warning and fall back when non-Copilot host is provided
- Added regression tests for:
- recovered persistent proxy feature mismatch and config-unavailable
restart behavior
  - non-Copilot exchange host rejection with/without user-info fallback

## 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 -q tests/test_copilot_auth.py tests/test_cli/test_wrap_persistent.py
============================= test session starts =============================
platform win32 -- Python 3.12.8, pytest-9.1.1, pluggy-1.6.0
rootdir: C:\Users\ralf.escher\Documents\headroom
collected 82 items

tests\test_copilot_auth.py ............................................. [ 54%]
...........                                                              [ 68%]
tests\test_cli\test_wrap_persistent.py ..........................        [100%]

============================= 82 passed in 1.60s ==============================
```

## Real Behavior Proof

- Environment:
  - Windows
  - Python 3.12.8
  - Local Headroom branch with this patch
  - Copilot subscription route through local proxy

- Exact command / steps:
  1. Start local proxy and run Copilot wrap in subscription mode.
  2. Execute chat-completions requests through proxy.
  3. Inspect runtime proxy logs for outbound target and inbound status.
  4. Run focused regression tests:
- `python -m pytest -q tests/test_copilot_auth.py
tests/test_cli/test_wrap_persistent.py`

- Observed result:
  - Outbound requests routed to Copilot business host:
    - `path=https://api.business.githubcopilot.com/chat/completions`
  - Successful proxy responses observed:
    - `path=/v1/chat/completions status=200`
  - Model activity logged during successful requests:
    - `PERF model=gpt-4.1 ...`
  - Regression tests pass (`82 passed`), covering both fixes.

- Not tested:
  - Full repository test suite
  - Full lint/typecheck across entire project
  - Non-Windows runtime verification in this 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

- This PR intentionally excludes incidental local edits to
`.github/copilot-instructions.md`.
- Scope is limited to this bug fix and regression coverage; linked as
related work to #488.
2026-06-28 15:26:38 -07:00
Tejas Chopra
bd76235f5c
fix(cli): harden all CLI surfaces + fix docs accuracy (#1491)
## Summary

Full CLI audit + documentation accuracy pass. All 5 commits on this
branch:

### CLI Hardening (4 commits)
- **Clean errors instead of tracebacks**: corrupt manifests, missing
Docker, malformed JSONL, bad `--profile`, invalid env-var values all now
raise `click.ClickException` with helpful messages
- **Range validation**: ~25 numeric flags across 10 files now use
`click.IntRange`/`FloatRange` — `--port 0`, `--hours -1`, `--limit 0`
etc. produce clean usage errors instead of silent wrong behavior
- **Flag combination warnings**: conflicting combos (`--no-rate-limit` +
`--rpm`, `--no-optimize` + `--target-ratio`, `--telemetry` +
`--no-telemetry`) emit yellow warnings on stderr
- **`memory --db-path` default fixed**: was resolving to
`headroom_memory.db` (wrong bare file); now uses project store
`./.headroom/memory.db` if present, else `~/.headroom/memory.db`
- **`memory list --search` + filters**: `--scope`/`--session`/`--since`
were silently ignored when `--search` was also set; now filters are
applied to search results
- **`learn --verbosity --apply` now works**: the output shaper is off by
default (`HEADROOM_OUTPUT_SHAPER`); `--apply` now hot-enables it via
`POST /admin/runtime-env` on a running proxy, or prints explicit `export
HEADROOM_OUTPUT_SHAPER=1` instructions when no proxy is running
- **`perf --hours` overflow**: `1e9` hours no longer raises
`OverflowError`; treated as "all data"
- **`evals memory --categories` invalid input**: `abc,1,2` now raises
`BadParameter` instead of a raw `ValueError` traceback

### Documentation (1 commit, 20 files)

Corrected factual errors found by 3 parallel audit agents across root
docs, wiki, and the published Fumadocs site:

**Critical (caused runtime errors or wrong behavior if followed):**
- `simulation.mdx`: `plan.transforms_applied` -> `plan.transforms`;
`plan.savings_percent` -> computed from available fields (both raised
`AttributeError`)
- `shared-context.mdx`: `import { SharedContext } from "headroom"` ->
`"headroom-ai"` (5x `ImportError`)
- `claude-code-azure-foundry.mdx`: `pip install headroom` -> `pip
install headroom-ai`
- `api-reference.mdx` + `configuration.mdx`: `from headroom import
GoogleProvider` -> `from headroom.providers import GoogleProvider`
- `ccr.mdx`: CCR TTL default 300s -> 1800s (30 min)

**Fabricated flags removed:**
- `wiki/proxy.md` + `wiki/cli.md`: `--no-intelligent-context`,
`--no-intelligent-scoring`, `--no-compress-first` (none exist); replaced
with real CCR flags
- `wiki/configuration.md`: `--no-ccr-responses`, `--no-ccr-expansion`
(none exist); replaced with real flags
- `wiki/troubleshooting.md`, `wiki/metrics.md`,
`docs/troubleshooting.mdx`: `headroom proxy --log-level debug` (flag
doesn't exist)

**Stale content corrected:**
- `llms.txt`: telemetry stated as enabled-by-default (it's opt-in); wrap
list had 5 tools (now 11)
- `README.md`: compatibility matrix added 5 missing `wrap` targets;
`unwrap`, `doctor`, `init`/`install`, savings-analytics now mentioned
- `SECURITY.md`: supported version table showed 0.2.x (current: 0.27.x)
- `wiki/learn.md`: 5 missing flags added; verbosity shaper-off behavior
documented
- `wiki/quickstart.md`: "Configuration Reference" linked to `api.md`
(wrong) -> `configuration.md`
- `CacheAlignerConfig.enabled` default corrected: `True` -> `False`
- `opencode.mdx`: `--port` default wrong ("random") -> 8787; `openai`
backend removed
- `CONTRIBUTING.md`: broken Markdown table cell fixed
- `docs/meta.json`: `claude-code-azure-foundry` added to nav (was
unreachable orphan page)
- `configuration.mdx`: SDK modes vs proxy `--mode` now clearly
distinguished

## Test plan

- [x] `python -m pytest tests/ -x -q` — 857 passed, 0 failures
- [x] 41-combination CLI smoke test (all flag combos across 8 commands)
— 0 tracebacks
- [x] `ruff check` on all modified Python files — clean
- [x] Docs changes are removals/corrections of fabricated or stale
content; no new claims introduced
2026-06-27 14:48:43 -07:00
Rod Boev
22def93177
fix(mcp): register managed installs with a resolvable headroom command (#1386)
## Description

Managed Headroom installs can register the MCP server with a bare
`headroom mcp serve` command even when the active runtime lives in a
venv outside `PATH`. That leaves Claude and Codex with a registration
they cannot re-launch reliably, and Claude eventually fails with `Failed
to reconnect to headroom: ENOENT`.

This PR reuses the existing runtime command resolver when building the
shared Headroom MCP spec, so the generated registration follows the
active install instead of assuming `headroom` is globally discoverable.
It also updates the shared-builder and registrar tests so the proof rows
now flow through `build_headroom_spec()` and prove the same resolved
command contract on both the Claude CLI path and the Codex TOML path. A
follow-up CI fix keeps the Docker init E2E expectation aligned with that
same resolver-backed contract.

Closes #487

## 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/mcp_registry/install.py`: build the Headroom MCP server spec
from the canonical runtime command resolver instead of hardcoding
`headroom mcp serve`
- `tests/test_mcp_registry/test_install.py`: cover the shared builder's
direct-binary and module-fallback command shapes
- `tests/test_mcp_registry/test_claude_registrar.py`: prove the Claude
CLI registration forwards the resolved command vector end to end
- `tests/test_mcp_registry/test_codex_registrar.py`: prove the Codex
registrar writes the same resolved command vector into TOML
- `e2e/init/run.py`: derive the Docker init E2E's expected Claude MCP
registration argv from `resolve_headroom_command()` so the CI harness
follows the same runtime contract
- `CHANGELOG.md`: note the managed-install MCP registration fix

## Testing

- [x] Unit tests pass (`uv run pytest
tests/test_mcp_registry/test_install.py -v`, `uv run pytest
tests/test_mcp_registry/test_claude_registrar.py -v`, `uv run pytest
tests/test_mcp_registry/test_codex_registrar.py -v`)
- [x] Linting passes (`uv run ruff check .`)
- [ ] Type checking passes (`uv run mypy headroom`) or explain N/A
truthfully
- [x] New tests added for new functionality when applicable
- [ ] Manual testing performed

### Test Output

```text
$ uv run pytest tests/test_mcp_registry/test_install.py -v
============================= 12 passed in 0.13s ==============================

$ uv run pytest tests/test_mcp_registry/test_claude_registrar.py -v
============================= 24 passed in 0.18s ==============================

$ uv run pytest tests/test_mcp_registry/test_codex_registrar.py -v
============================= 25 passed in 0.20s ==============================

$ uv run python -c "from e2e.init.run import _expected_headroom_mcp_call; print(_expected_headroom_mcp_call('http://127.0.0.1:9011'))"
['mcp', 'add', 'headroom', '-s', 'user', '-e', 'HEADROOM_PROXY_URL=http://127.0.0.1:9011', '--', '.../headroom', 'mcp', 'serve']

$ uv run ruff check e2e/init/run.py
All checks passed!

$ uv run ruff format e2e/init/run.py --check
1 file already formatted

$ uv run ruff check .
All checks passed!

$ uv run ruff format . --check
987 files already formatted
```

`uv run mypy headroom` was not run locally; this repo's focused local
gate for the touched Python registry path is the targeted pytest set
plus Ruff.

## Real Behavior Proof

- Environment: managed-install-safe MCP registration path, Python 3.11+,
no provider required
- Exact command / steps: run the focused MCP registry pytest files,
inspect the captured Claude CLI argv and rendered Codex TOML block, and
verify the Docker init E2E expectation derives its Claude MCP argv from
the same runtime helper
- Observed result: the persisted MCP registration uses a resolvable
command tied to the active Headroom runtime instead of bare `headroom`,
while `HEADROOM_PROXY_URL` handling stays unchanged
- Not tested: full live Claude reconnect against a real managed venv,
unless that is run during implementation

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

## Additional Notes

- Scoped to the MCP registration slice in `#487`. The RTK hook rewriting
thread from the same issue is intentionally out of scope here.
- `@erikpr1994` isolated the managed-install `ENOENT` failure mode in
the issue thread and narrowed it to the bare-command MCP registration
path.
- If existing owned registrations with the old bare-command contract
need an in-place upgrade path, that should be handled explicitly in the
final diff rather than left implicit.
2026-06-26 23:39:00 -05:00
Ello_
b618d2d11a
fix: patch rtk hook script to use absolute path after register_claude_hooks (#571)
```markdown
## Description

When `headroom wrap claude` registers RTK hooks, the generated `~/.claude/hooks/rtk-rewrite.sh` script uses a bare `rtk` command that depends on PATH lookup. Since `~/.headroom/bin` is not automatically added to PATH, the hook fails silently and token compression never occurs.

After `register_claude_hooks()` succeeds, a new helper `_patch_rtk_hook_absolute_path()` reads the generated hook script and replaces bare `rtk` references with the absolute binary path (e.g. `/home/user/.headroom/bin/rtk`). The patch is idempotent and only writes back if content actually changed. Paths containing spaces or shell-special characters are safely quoted via `shlex.quote()` before being inserted into the script.

## Type of Change

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

## Changes Made

- Added `_patch_rtk_hook_absolute_path(rtk_path, hook_script_path)` in `headroom/cli/wrap.py`
- Called it immediately after `register_claude_hooks()` succeeds in `_setup_rtk()`
- Uses `shlex.quote()` to safely handle absolute paths containing spaces or shell-special characters
- Added regression test `tests/test_cli/test_wrap_rtk_hook_patch.py` covering the basic patch, the space-in-path case, idempotency, missing hook file, and non-bare `rtk` tokens

## Testing

- [x] Manual testing performed

### Test Output

```
python3 -m pytest tests/test_cli/test_wrap_rtk_hook_patch.py -v
============ test session starts ============
collected 5 items

tests/test_cli/test_wrap_rtk_hook_patch.py::test_patches_bare_rtk_to_absolute_path
PASSED [ 20%]

tests/test_cli/test_wrap_rtk_hook_patch.py::test_quotes_path_containing_spaces
PASSED [ 40%]

tests/test_cli/test_wrap_rtk_hook_patch.py::test_idempotent_second_run_is_noop
PASSED [ 60%]

tests/test_cli/test_wrap_rtk_hook_patch.py::test_missing_hook_script_is_noop
PASSED [ 80%]

tests/test_cli/test_wrap_rtk_hook_patch.py::test_does_not_touch_words_containing_rtk
PASSED [100%]
============= 5 passed in 0.73s =============
```

## Real Behavior Proof

- Environment: Linux, Python 3.14.4, pytest 9.1.0, headroom repo at commit d5987fb2
- Exact command / steps: `python3 -m pytest tests/test_cli/test_wrap_rtk_hook_patch.py -v`
- Observed result: All 5 tests passed — test_patches_bare_rtk_to_absolute_path, test_quotes_path_containing_spaces, test_idempotent_second_run_is_noop, test_missing_hook_script_is_noop, test_does_not_touch_words_containing_rtk (5 passed in 0.73s)
- Not tested: End-to-end test against a real `rtk init --global --auto-patch` run on macOS/Windows; only the patch function itself is unit-tested

## Review Readiness

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

Fixes #487
```

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-06-26 14:09:37 -05:00
Vinay Gupta
6c83790680
fix(opencode): write local MCP config (#1381)
## Description

Fixes the OpenCode config corruption reported in #1380 for wrap, MCP
registration, and provider-scope install paths.

OpenCode MCP entries are local stdio servers, not remote HTTP endpoints.
This changes Headroom's OpenCode MCP serialization to write `type:
"local"` with `command: ["headroom", "mcp", "serve"]`, uses OpenCode's
`environment` field for MCP env vars, and still reads the older `env`
key for compatibility.

This also stops provider-only OpenCode config injection from creating a
fake `http://127.0.0.1:<port>/mcp` entry, so `headroom wrap opencode
--no-mcp` no longer leaves `mcp.headroom` behind. Finally, the install
CLI/docs now accept and document `--target opencode` with provider
scope.

This does not change the broader `headroom mcp status/uninstall`
behavior from #1380; that looks like a separate follow-up.

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

- Write OpenCode MCP entries as local stdio config instead of remote
`/mcp` config.
- Use `environment` for OpenCode MCP env vars while continuing to read
legacy `env` entries.
- Stop OpenCode provider injection/persistent provider install from
adding MCP config.
- Keep `--no-mcp` from writing `mcp.headroom` while preserving other MCP
entries such as Serena.
- Allow `headroom install apply --target opencode` at the CLI layer.
- Update OpenCode docs and changelog.

## Testing

- [x] Focused unit tests pass
- [x] Linting passes (`ruff check .`)
- [x] Formatting passes (`ruff format --check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for the fixed behavior
- [x] Manual testing performed

### Test Output

```text
$ pytest tests/test_mcp_registry_opencode.py tests/test_cli/test_wrap_opencode.py tests/test_providers_opencode_config.py tests/test_providers_opencode_install.py tests/test_cli/test_install_cli.py tests/test_install/test_providers.py
Pytest: 164 passed

$ uvx ruff check .
All checks passed!

$ uvx ruff format --check .
986 files already formatted

$ uvx mypy --config-file pyproject.toml headroom
Success: no issues found in 398 source files
```

## Real Behavior Proof

- Environment: macOS local worktree at
`/Users/vinaygupta/Desktop/git/headroom-fix-opencode-mcp-config`; branch
`fix-opencode-mcp-config`; commit `aea96208`.
- Exact command / steps: ran the focused OpenCode/installer regression
suite plus Ruff lint/format checks and mypy commands shown above.
- Observed result: the focused tests pass and cover OpenCode MCP
serialization as `type: "local"`, `command: ["headroom", "mcp",
"serve"]`, `environment` env vars, `--no-mcp` not writing
`mcp.headroom`, provider-scope install not adding MCP config, and
`install apply --target opencode` being accepted.
- Not tested: full `pytest` locally, because collection requires the
native `headroom._core` extension in this worktree. Attempting the
project runner hit a local native build failure first: `esaxx-rs` failed
compiling `src/esaxx.cpp` with `fatal error: 'cstdint' file not found`.
The broader generic `headroom mcp status/uninstall` behavior from #1380
is intentionally left for a follow-up.

## Review Readiness

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

Scope note: generic `mcp status/uninstall` support from #1380 is
intentionally left as a separate follow-up PR.
2026-06-26 12:23:54 -05:00
Shengbo_Wang
a0cb7982e3
fix(cli): add explicit UTF-8 encoding to file I/O in wrap commands (#1126) (#1164)
## Description

On Windows, `Path.read_text()` and `open()` default to the system locale
encoding (cp1252, GBK, etc.) instead of UTF-8. This causes
`UnicodeDecodeError` when reading or writing instruction files that
contain multi-byte UTF-8 characters such as smart quotes or em dashes.

The RTK instructions block itself contains an em dash (U+2014, `—`), so
`_inject_rtk_instructions` crashes on any non-UTF-8 Windows locale when
writing to `.copilot_instructions`, `.clinerules`, `AGENTS.md`, or
similar hint files.

Closes #1126

## 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 `encoding="utf-8"` to all bare `read_text()`, `write_text()`, and
`open()` calls in `headroom/cli/wrap.py` that handle instruction or
config files (18 call sites)
- Update test assertions in `test_wrap_hintfile_agents.py`,
`test_wrap_copilot.py`, and `test_wrap_bridge.py` to read with
`encoding="utf-8"`
- Add `test_inject_rtk_handles_utf8_content` verifying that existing
hint files with smart quotes and em dashes survive RTK injection without
crashing

## 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
$ python -m pytest tests/test_cli/test_wrap_hintfile_agents.py tests/test_cli/test_wrap_copilot.py tests/test_cli/test_wrap_bridge.py -v
47 passed in 1.28s
```

## Real Behavior Proof

- Environment: Windows 11 China (GBK locale), Python 3.11, headroom main
(f03e77b)
- Exact command / steps: python -m pytest
tests/test_cli/test_wrap_hintfile_agents.py -v (on GBK Windows)
- Observed result: Before fix,
test_prepare_only_injects_rtk_into_hintfile fails with
UnicodeDecodeError 'gbk' at position 283 (the em dash in RTK block).
After fix, all 12 hintfile tests pass including new UTF-8 round-trip
test.
- Not tested: no manual `headroom wrap copilot` run against a real
Copilot installation

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

This is the same class of bug reported in #733 (GBK config.toml
corruption). This PR fixes the `wrap.py` call sites; other modules
(`learn/analyzer.py`, `install/providers.py`) have the same pattern and
could benefit from the same treatment in a follow-up.

---------

Signed-off-by: Yiming Zeng <yzeng424@gmail.com>
Signed-off-by: RTCartist <wangshengb@buaa.edu.cn>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-26 12:07:03 -05:00
Paperinik
dca9853ed9
feat(wrap): make tokensave the primary coding-task compressor, Serena the backup (#1230)
## Description

Makes **tokensave**
([github.com/aovestdipaperino/tokensave](https://github.com/aovestdipaperino/tokensave))
the **primary coding-task compressor** that `headroom wrap` installs,
and demotes **Serena** to a **backup**. tokensave is a local semantic
code-graph MCP server (`tokensave serve`): the agent queries it for
symbols, call chains, and impact analysis instead of grepping/reading
whole files — the same role Serena filled, but as a pre-indexed graph.
Serena now only registers when tokensave is unavailable (or when forced
with `--serena`).

Closes #

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

- `headroom/graph/tokensave_installer.py` (new): fetch the prebuilt
tokensave release binary for the platform (release-binary only — no
`cargo` compile at wrap time); honors `HEADROOM_BINARIES_OFFLINE`;
returns `None` (→ Serena) when no asset exists (e.g. x86_64 macOS) or
the download fails.
- `mcp_registry`: `build_tokensave_spec()`; registration/disable/migrate
go through the existing `ServerSpec` + ownership-ledger flow, identical
to Serena.
- `cli/wrap.py`: new `_setup_coding_compressor` primary/backup policy;
tokensave setup/disable/migrate/index helpers. New flags
`--no-tokensave` (skip primary) and `--serena` (force backup on);
`--no-serena` now means "never register the backup". Default wrap
removes a previously Headroom-installed Serena entry once tokensave is
primary (user-managed entries preserved). `--code-graph` repointed to
tokensave; the legacy `codebase-memory-mcp` install path is dropped
(unwrap still cleans up legacy entries). `unwrap claude|codex` remove a
ledger-owned tokensave entry.
- Strands `HeadroomBundle`: `enable_tokensave_mcp=True` (primary);
`enable_serena_mcp` now defaults `False` (backup).
- `docs/content/docs/proxy.mdx`: `--code-graph` description updated from
codebase-memory-mcp to tokensave.
- Tests: tokensave installer (incl. error paths),
register/disable/migrate, primary/backup policy, and the
binary-resolution/indexing helpers. A scoped
`tests/test_cli/conftest.py` offline guard keeps the CLI suite hermetic.

## 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 pytest -q tests/test_graph_tokensave.py tests/test_cli/test_tokensave_setup.py tests/test_cli/test_tokensave_helpers.py
41 passed

$ uv run pytest -q tests/test_cli/ tests/test_graph.py tests/test_graph_tokensave.py
421 passed   # full CLI + graph suites, incl. all pre-existing Serena/unwrap/registry tests

$ uv run pytest -q tests/test_mcp_registry/ tests/test_proxy_healthchecks.py
passed

$ uv run ruff format --check headroom/ tests/      # 822 files already formatted
$ uv run ruff check <changed files>                # All checks passed!
$ uv run mypy headroom/graph/tokensave_installer.py headroom/mcp_registry/install.py
Success: no issues found in 2 source files

# Coverage on new module
headroom/graph/tokensave_installer.py    99%
```

## Real Behavior Proof

- Environment: macOS (darwin arm64), Python 3.14, `uv` dev env;
tokensave 7.0.2 binary present on PATH and exercised against this repo's
`.tokensave/` graph during development. The installer pins release
**v7.0.2** (SHA-256-verified) across macOS arm64, Linux aarch64/x86_64,
and Windows x86_64/aarch64.
- Exact command / steps: `headroom wrap claude` registers `tokensave
serve` as the primary MCP code-graph server and indexes the project;
with the binary removed from PATH and `HEADROOM_BINARIES_OFFLINE=1`, the
same command falls back to registering Serena. Behavior is pinned by the
unit tests (binary-present → tokensave registered + Serena entry
removed; binary-absent → Serena fallback; `--serena` forces backup on;
`--no-serena` suppresses it; `--no-tokensave` disables primary).
- Observed result: tokensave registered as primary on the binary-present
path; Serena registered on the unavailable path; unwrap removes only
ledger-owned entries.
- Not tested: live end-to-end agent session inside Claude Code / Codex
against a real provider API; Windows/Linux release-asset download
(covered by unit tests with mocked archives, not a live fetch).

## 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
- [ ] I have updated the CHANGELOG.md if applicable

## Additional Notes

- CHANGELOG is left untouched: this repo generates it via release-please
from Conventional Commits, so a manual edit is N/A.
- `strands/bundle.py` shows 0% patch coverage because that module
hard-imports the optional `strands` SDK, which CI does not install (the
pre-existing `_make_serena_client` was likewise uncovered) — not a
regression.
- A `test (3)` shard failure on `headroom.memory.bridge` is a
pre-existing offline-CI flake (cannot reach huggingface.co); it touches
no file in this PR and the scoped offline guard only applies under
`tests/test_cli/`.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 16:55:37 -05:00
Ben Younes
b50d9c17ce
feat(wrap): add --1m to preserve the 1M context window on wrap claude (#1158) (#1351)
## Description

`headroom wrap claude` is the recommended Claude Code integration, but
for subscription users entitled to the **1M** context window it silently
caps usable context at **200k**. Root cause (upstream,
anthropics/claude-code#68522): when `ANTHROPIC_BASE_URL` points at a
custom host (the Headroom proxy), Claude Code does **not** send the
`context-1m-2025-08-07` beta header and treats the window as 200k. The
`/model opus[1m]` picker selection does not survive a custom base URL,
and `CLAUDE_CODE_AUTO_COMPACT_WINDOW` alone does not lift the cap.

Headroom itself already forwards `anthropic-beta` and sizes Opus at 1M
internally — but since `wrap claude` owns the launched process's
environment and is the documented path, users hit this and blame
Headroom first. This adds the opt-in fix the issue proposes.

Closes #1158

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

- `headroom/cli/wrap.py`: new opt-in `--1m` flag on `wrap claude`. When
set, `ANTHROPIC_MODEL=<opus>[1m]` is exported on the launched process so
Claude Code sends the `context-1m` beta header. Logic extracted to a
testable helper `_resolve_1m_model`: a model the user already selected
via `ANTHROPIC_MODEL` is preserved (only the `[1m]` suffix is appended
when missing); otherwise it falls back to the default Opus. Idempotent
(no double suffix). Default behavior is unchanged (opt-in).
- `tests/test_cli/test_wrap_helpers.py`: unit tests for
`_resolve_1m_model` (append-to-user-model, idempotent, default
fallback).
- `README.md`: `--1m` added to the Claude Code row of the agent
compatibility matrix.
- `CHANGELOG.md`: Unreleased → Features entry.

## 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 pytest tests/test_cli/test_wrap_helpers.py tests/test_cli/test_wrap_claude_base_url.py -q
61 passed in 0.46s

$ uv run ruff check headroom/cli/wrap.py tests/test_cli/test_wrap_helpers.py
All checks passed!

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

#### TDD verification (RED → GREEN)

RED — new tests with the prod change reverted (`_resolve_1m_model`
absent):
```text
E   AttributeError: module 'headroom.cli.wrap' has no attribute '_resolve_1m_model'
3 failed, 40 deselected in 0.56s
```
GREEN — with the change applied:
```text
3 passed, 40 deselected in 0.34s
```

## Real Behavior Proof

- Environment: Linux, Python 3.13, headroom @ this branch.
- Exact command / steps: `headroom wrap claude --1m --help` shows the
new flag, and the flag resolves the model id that triggers the 1M
window:
  ```text
  $ headroom wrap claude --help | grep -A1 -- --1m
--1m Preserve the 1M context window. Behind a custom
ANTHROPIC_BASE_URL Claude Code drops the ...

  # model-id resolution (what --1m exports as ANTHROPIC_MODEL):
_resolve_1m_model("claude-opus-4-1-20250805") ->
"claude-opus-4-1-20250805[1m]"
_resolve_1m_model("claude-opus-4-8[1m]") -> "claude-opus-4-8[1m]"
(idempotent)
_resolve_1m_model(None) -> "claude-opus-4-8[1m]" (default)
  ```
- Observed result: with `--1m`, the launched Claude Code process gets
`ANTHROPIC_MODEL=<opus>[1m]`, which is the documented trigger for the
`context-1m` beta header (verified in the issue against
`~/.headroom/logs/proxy.log`).
- Not tested: the live Claude Code subscription handshake against
Anthropic's servers (requires a 1M-entitled subscription + the
proprietary client); the model-id → header behavior is Claude Code's,
documented in the issue and upstream anthropics/claude-code#68522.
Headroom's side (export the env var that flips it on) is covered above
and by the unit 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

## Additional Notes

Opt-in only — without `--1m` nothing changes. The `_DEFAULT_1M_MODEL`
constant is only consulted when the user has no `ANTHROPIC_MODEL` set;
users on a specific model keep it (suffix appended), so the default's
freshness does not affect them.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:15:19 -05:00
Ben Younes
e6bbc40b11
fix(codex): retag threads on init so Codex Desktop history stays visible (#961) (#1349)
## Description

Installing Headroom for Codex via `headroom init` can make Codex Desktop
appear to lose its local chat/thread history. The data is never deleted
— Codex filters its sidebar/search by the active `model_provider`, and
the init path set `model_provider = "headroom"` without retagging
existing threads, so native `openai` threads disappeared from the menu.

The install (`headroom.providers.codex.install`) and wrap
(`headroom.cli.wrap`) paths already reconcile thread provider tags
across the proxy boundary (retag `openai -> headroom` on enable). The
init path — `_ensure_codex_provider` in `headroom/cli/init.py`, which is
exactly what the issue reproduces ("Headroom init proxy" provider,
`headroom-init-codex` hook) — was the one place that injected the
provider without retagging. This wires the same reconciliation into the
init path. The revert direction is already handled by `headroom unwrap
codex`.

Closes #961

## 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/init.py`: `_ensure_codex_provider` now calls
`retag_to_headroom(path.parent)` after writing the init provider block,
so existing native threads stay visible under the active `headroom`
provider. Third-party providers (e.g. `anthropic`) are left untouched
(existing `retag_thread_providers` behaviour).
- `tests/test_cli/test_init_cli.py`: regression test seeding a Codex
Desktop `state_5.sqlite` and asserting `_ensure_codex_provider` retags
`openai -> headroom` while leaving other providers alone.
- `CHANGELOG.md`: Unreleased → Bug Fixes entry.

## 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 pytest tests/test_cli/test_init_cli.py tests/test_provider_codex_threads.py tests/test_provider_codex_install.py -q
84 passed in 0.84s

$ uv run ruff check headroom/cli/init.py tests/test_cli/test_init_cli.py
All checks passed!

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

#### RED → GREEN proof

RED — new test with the prod fix (the `retag_to_headroom` call)
reverted:
```text
E   AssertionError: existing openai threads not retagged: {'anthropic': 1, 'openai': 2}
1 failed in 0.44s
```
GREEN — with the fix applied:
```text
tests/test_cli/test_init_cli.py::test_init_codex_provider_retags_existing_threads
1 passed in 0.36s
```

## Real Behavior Proof

- Environment: Linux, Python 3.13, headroom @ this branch.
- Exact command / steps: seed a Codex Desktop store
(`<codex_home>/sqlite/state_5.sqlite`) with native threads, then run the
init provider injection:
  ```text
  before init: {'anthropic': 1, 'openai': 2}
  after  init: {'anthropic': 1, 'headroom': 2}
config model_provider line: ['model_provider = "headroom"',
'[model_providers.headroom]']
  ```
- Observed result: after init, the two `openai` threads are retagged to
`headroom` (so they stay visible under the now-active provider), while
the `anthropic` thread is left untouched. Before the fix they stayed
`openai` and were filtered out of Codex Desktop's menu.
- Not tested: the live Codex Desktop GUI itself (proprietary, no
sandbox); the filtering behaviour is the documented `thread/list`
provider filter described in the issue, and the store-level retag that
makes history visible is covered above and by the unit test.

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

## Additional Notes

Scope is limited to the init provider path; the install and wrap paths
already perform this reconciliation. Screenshots N/A (no UI change on
Headroom's side).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: JD Davis <mxjerrett@gmail.com>
2026-06-24 10:14:40 -05:00
Priyanshu Sharma
8da0b4e565
fix(install): guard install_agent_ensure against duplicate runtime spawns (#1301)
## Type of Change

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

## Description

`install_agent_ensure` in `cli/install.py` only checked
`probe_ready(health_url)`. If the proxy was alive-but-not-ready (e.g.
during cold start while tokenizers load — ~38s on Windows),
`probe_ready` returned false and it unconditionally called
`_start_deployment` → `start_detached_agent`, spawning a **second
runtime** without:

1. acquiring `acquire_runtime_start_lock`
2. checking `runtime_status`
3. stopping the existing instance

Two proxies then contend for `127.0.0.1:<port>`; only one can bind, and
the deployment ends up wedged (never ready). Every subsequent ensure
spawns yet another runtime → restart storm.

By contrast, the hook path `cli/init.py:_ensure_profile_running` does it
correctly: it acquires the start-lock, checks `runtime_status`, and
`stop_runtime`s a wedged instance before starting a fresh one.

Closes #1151.

## Changes Made

- Added `acquire_runtime_start_lock` to the imports from
`install.runtime` in `headroom/cli/install.py`
- Rewrote `install_agent_ensure` to mirror the guarded pattern from
`_ensure_profile_running` in `cli/init.py`:
- Fast-path probe: if proxy is already ready, return immediately
(preserves existing behavior)
- Lock acquisition: acquire `acquire_runtime_start_lock` — if another
ensure holds it, return without spawning (prevents duplicate)
- Double-checked locking: re-probe `probe_ready` after acquiring the
lock (race window handled)
- Wedged instance detection: if `runtime_status` says "running" but
proxy isn't ready within 15s grace period, call `stop_runtime` before
starting fresh
  - Fall through to `_start_deployment` only when truly needed
- Added `_STARTUP_READY_TIMEOUT_SECONDS = 15` constant (matching the
value used in `_ensure_profile_running`)
- **Failure propagation (addresses @JerrettDavis's review feedback):**
removed the `try/except Exception` wrapper around the guarded block.
`install agent ensure` is an automation-facing CLI command and must exit
non-zero on failure so callers can distinguish a successful ensure from
a failed one. The `init.py` hook path retains its `try/except` because
silent retry is intentional there. The control flow is shared; the error
contract is intentionally different because the call sites have
different needs.
- Added 5 regression tests in `tests/test_cli/test_install_cli.py`:
- `test_install_agent_ensure_no_spawn_when_lock_not_acquired` — verifies
no runtime spawned when lock is contended (the core bug)
- `test_install_agent_ensure_stops_wedged_runtime_before_restart` —
verifies `stop_runtime` is called BEFORE `_start_deployment` when
instance is wedged (ordering assertion: `calls.index("stop") <
calls.index("start_deployment")`)
- `test_install_agent_ensure_starts_when_stopped_and_lock_acquired` —
verifies the normal start path including the real `_start_deployment` →
`start_detached_agent` wiring
- `test_install_agent_ensure_no_duplicate_spawn_after_lock_recheck` —
verifies double-checked locking prevents duplicate when proxy becomes
ready between initial probe and lock acquisition
- `test_install_agent_ensure_propagates_start_deployment_failure` —
**new** regression test for the failure-propagation fix: monkeypatches
`_start_deployment` to raise `click.ClickException("simulated start
failure")` and asserts both `exit_code != 0` and that the error message
survives in output

## 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 run python -m pytest tests/test_cli/test_install_cli.py -v --tb=short

tests/test_cli/test_install_cli.py::test_install_apply_starts_service_supervisor PASSED [  6%]
tests/test_cli/test_install_cli.py::test_install_status_includes_backend_from_health_probe PASSED [ 12%]
tests/test_cli/test_install_cli.py::test_install_restart_uses_internal_helpers PASSED [ 18%]
tests/test_cli/test_install_cli.py::test_install_apply_rejects_invalid_profile PASSED [ 25%]
tests/test_cli/test_install_cli.py::test_install_apply_rejects_provider_scope_targets_without_support PASSED [ 31%]
tests/test_cli/test_install_cli.py::test_install_apply_restores_previous_deployment_after_failed_update PASSED [ 37%]
tests/test_cli/test_install_cli.py::test_install_start_rejects_task_lifecycle PASSED [ 43%]
tests/test_cli/test_install_cli.py::test_install_apply_uses_docker_runtime_for_persistent_docker PASSED [ 50%]
tests/test_cli/test_install_cli.py::test_install_remove_continues_when_runtime_teardown_errors PASSED [ 56%]
tests/test_cli/test_install_cli.py::test_install_agent_ensure_reports_already_healthy PASSED [ 62%]
tests/test_cli/test_install_cli.py::test_install_agent_run_exits_with_foreground_status PASSED [ 68%]
tests/test_cli/test_install_cli.py::test_install_agent_ensure_no_spawn_when_lock_not_acquired PASSED [ 75%]
tests/test_cli/test_install_cli.py::test_install_agent_ensure_stops_wedged_runtime_before_restart PASSED [ 81%]
tests/test_cli/test_install_cli.py::test_install_agent_ensure_starts_when_stopped_and_lock_acquired PASSED [ 87%]
tests/test_cli/test_install_cli.py::test_install_agent_ensure_no_duplicate_spawn_after_lock_recheck PASSED [ 93%]
tests/test_cli/test_install_cli.py::test_install_agent_ensure_propagates_start_deployment_failure PASSED [100%]

============================== 16 passed in 0.29s ==============================
```

```
$ uv run ruff check headroom/cli/install.py tests/test_cli/test_install_cli.py
All checks passed!

$ uv run ruff format --check headroom/cli/install.py tests/test_cli/test_install_cli.py
2 files already formatted

$ uv run mypy headroom/cli/install.py --ignore-missing-imports
Success: no issues found in 1 source file
```

## Real Behavior Proof

- **Environment**: Python 3.11.14, Linux 6.17.0, headroom dev
environment (uv-synced), rebased onto `upstream/main` at `3be2526b`
- **Exact command / steps**: `uv run python -m pytest
tests/test_cli/test_install_cli.py -v --tb=short` (and the ruff + mypy
commands above)
- **Observed result**: All 16 tests pass (11 existing + 5 new regression
tests). The 5 new tests verify: (1) no-spawn when the lock is contended,
(2) `stop_runtime` ordering before `_start_deployment` on a wedged
instance, (3) normal start path, (4) double-checked locking after the
lock is acquired, (5) failure propagation when `_start_deployment`
raises — this last test is the regression for @JerrettDavis's review
feedback. ruff check, ruff format --check, and mypy all pass clean.
- **Not tested**: Live deployment with concurrent `install agent ensure`
invocations on Windows (only unit tests with monkeypatched runtime
functions). The fix mirrors the proven pattern from
`_ensure_profile_running` which is already battle-tested in the init
hook path.

## Review Readiness

- [x] I have performed a self-review of my own code
- [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

## Additional Notes

**Triage of labels on this PR:**

- `status: needs author action` — **stale**. The 4 `Real Behavior Proof`
fields (`Environment`, `Exact command / steps`, `Observed result`, `Not
tested`) are all present in this body. The bot snapshot was taken before
the body was filled in. Requesting the label be dropped on the next bot
run.
- `status: ci failing` — **CI env, not caused by this PR.**
`install-native (macos-latest)` and `wrap-native (macos-latest)` fail
during the editable Rust/Python extension build with `ld: library
'clang_rt.osx' not found`, which is before this command path runs.
@JerrettDavis confirmed this is not caused by the PR. All Linux jobs,
all unit/integration/E2E jobs, lint, commitlint, template check, and
Docker E2E jobs are green. `mergeable: MERGEABLE` is the actual gate.

**CHANGELOG:** not updated — this is a single bug fix in an unreleased
section, and the maintainers have not requested CHANGELOG entries for
individual PRs in past PRs in this repo. Happy to add an entry under `##
Unreleased` if requested.
2026-06-24 09:54:28 -05:00
Parideboy
d633e8172c
fix(windows): pin UTF-8 encoding on text-mode subprocess calls (#1311)
Fixes #1310.

## Description

On Windows, `headroom` startup crashes a subprocess reader thread:

```
UnicodeDecodeError: 'charmap' codec can't decode byte 0x8d in position 7894: character maps to <undefined>
  ... subprocess.py _readerthread -> buffer.append(fh.read())
  ... encodings/cp1252.py
```

Text-mode `subprocess` calls omit `encoding=`, so Python decodes child
output with the locale codec (**cp1252** on Windows). Children that emit
UTF-8 ??? `cbm index_repository` (indexing sources with chars like
`???`/`???`), `claude mcp get/add`, the memory-sync process ??? produce
bytes invalid in cp1252 and kill the reader thread. Linux/macOS default
to UTF-8, so it's invisible there.

## Type of Change

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

## Changes Made

- Add `encoding="utf-8", errors="replace"` to every text-mode
(`text=True` / `universal_newlines=True`) subprocess call in the
`headroom/` package (~50 call sites; several already had it).
- `errors="replace"` (not `ignore`) so corrupt bytes surface as `???`
rather than vanishing from parsed output.
- Add `tests/test_cli/test_subprocess_utf8_encoding.py`: an AST guard
asserting every text-mode subprocess call pins `encoding=`. The runtime
crash can't reproduce on UTF-8 CI, so the invariant is enforced at the
source level instead.

## Testing

- [x] Unit tests pass (`pytest`)
- New guard test passes (validates 51 call sites).
- `tests/test_install`, `tests/test_cli/test_mcp.py`,
`tests/test_mcp_registry` pass.
(`test_runtime_start_lock_blocks_another_process` fails on this Windows
box, but it fails identically on unmodified `main` ??? a pre-existing
`msvcrt` lock flake, unrelated.)

### Test Output

```text
> python -m pytest tests/test_cli/test_subprocess_utf8_encoding.py -q
1 passed in 0.12s

> python -m pytest tests/test_install/ tests/test_cli/test_mcp.py tests/test_mcp_registry/ -q
133 passed, 2 skipped in 15.34s
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.13.13.
- Exact command / steps: Started `headroom` without `PYTHONUTF8=1` on a
repo with UTF-8 chars in indexable files. Observed the
`UnicodeDecodeError` crash. Applied the fix (pinning `encoding="utf-8"`
on all text-mode subprocess calls). Re-ran. No crash. The AST guard
enforces the invariant on CI (which runs UTF-8 locales and cannot
reproduce the cp1252 crash natively).
- Observed result: Subprocess reader threads no longer crash on UTF-8
output under cp1252 locale.
- Not tested: All third-party tools that `headroom` shells out to; each
was given `errors="replace"` as a safety net.

## Workaround for affected users (before fix is deployed)

`PYTHONUTF8=1` (PowerShell: `$env:PYTHONUTF8=1; headroom ...`).

## 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-23 12:52:49 -05:00
Lucas Santos
b0146c4ccd
fix(wrap): show the dashboard URL when the proxy is already running (#1313)
## Description

I was running `headroom wrap claude` and could not find the dashboard
URL anywhere. I eventually spotted it in the README demo gif. The reason
is that `_ensure_proxy` only echoes the URL on the path that starts or
restarts the proxy. Once a proxy is already up, the function prints
`Proxy already running on port {port}` and returns, with no URL. That
early-return path is the common case: every wrap after the first one
hits it, so in practice the dashboard URL is almost never shown.

This adds the same `Dashboard: http://127.0.0.1:{port}/dashboard` line
to the two already-running branches (the inline one and the
persistent-deployment one), so the URL shows up every time, not just on
a cold start.

Closes # N/A (no tracking issue)

## 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`: echo the dashboard URL in both "proxy already
running" branches of `_ensure_proxy`, matching the line the
start/restart path already prints.
- `tests/test_cli/test_wrap_helpers.py`: new test that drives
`_ensure_proxy` down the already-running path and asserts the dashboard
URL is in the output.

## 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 python -m pytest tests/test_cli/test_wrap_helpers.py -q
40 passed in 0.20s

$ uv run --extra dev ruff check headroom/cli/wrap.py tests/test_cli/test_wrap_helpers.py
All checks passed!

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

## Real Behavior Proof

- Environment: macOS, Python 3.13, this branch, `headroom wrap claude`
against an already-running proxy on port 8787.
- Exact command / steps: run `claude` (aliased to `headroom wrap
claude`) a second time, so the proxy is already up and `_ensure_proxy`
takes the early-return path.
- Observed result: before this change the output stopped at `Proxy
already running on port 8787` with no URL. After it, the next line is
`Dashboard: http://127.0.0.1:8787/dashboard`. The new unit test pins
this by mocking a healthy running proxy and asserting the URL is
printed.
- Not tested: I did not open the rendered dashboard in a browser as part
of this change. The fix is purely the printed line, which the unit test
covers.

## 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
- [ ] I have updated the CHANGELOG.md if applicable

## Additional Notes

I scoped this to the print line plus its test on purpose. ruff and mypy
are clean on the files I touched. I left the CHANGELOG checkbox
unchecked because this is a one-line user-facing string fix with no
behavior change beyond the extra output, but I am happy to add a
CHANGELOG entry if you would like one. The same for docs, I don't think
it's needed to have one about this
2026-06-23 11:17:11 -05:00