Commit graph

191 commits

Author SHA1 Message Date
JD Davis
997a47992c
fix(copilot): preserve native enterprise model routing (#2998)
## Description

GitHub Copilot Enterprise/Business users without a BYOK provider key
were routed through Copilot CLI's single-model provider override. Native
model aliases and runtime `/model` switches were therefore forwarded
literally to the override and rejected with `400 model not supported`.
This change routes implicit GitHub OAuth through Copilot's native API
surface while retaining explicit subscription and provider-key behavior.

Closes #1910

## 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 explicit `--native` routing and made it automatic for implicit
GitHub OAuth without BYOK.
- Clears every Copilot BYOK variable before native launch.
- Routes both OpenAI and Anthropic protocol targets through the resolved
tenant Copilot host.
- Preserves Enterprise/Business native aliases and runtime model
switching.
- Rejects BYOK-only options when native routing is selected.
- Refuses known Copilot bundles that do not reference `COPILOT_API_URL`,
avoiding silent proxy bypass.
- Preserves explicit `--subscription` and provider-key BYOK semantics.
- Added coverage for unreadable and unverifiable Copilot CLI bundles.

## 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
884 passed, 4 skipped in 103.11s
ruff check .: All checks passed
ruff format --check .: 1412 files already formatted
mypy headroom/providers/copilot/wrap.py headroom/cli/wrap.py:
Success: no issues found in 2 source files
```

Exact-head CI is entirely green on
`0aca48c096`.

## Real Behavior Proof

- Environment: macOS arm64/Python 3.13 locally; GitHub-hosted macOS and
Ubuntu native-wrap jobs.
- Exact command / steps: invoke `headroom wrap copilot` with implicit
OAuth and an Enterprise model alias; inspect the captured child/proxy
environment and resolved target URLs; exercise explicit native conflicts
and bundle-support probes.
- Observed result: native launch uses `COPILOT_API_URL`, clears all BYOK
state, and points both protocol targets at the tenant host. Native-wrap
jobs are green on macOS and Ubuntu for the refreshed head.
- Not tested: live request against a real Enterprise tenant; the
repository has no organization Enterprise credential available to CI.

## Runtime Rollout Safety

- Rollout-managed feature(s): implicit native Copilot routing for GitHub
OAuth sessions without BYOK.
- Minimum rollout channel: normal patch release.
- Stable/default behavior changed: implicit OAuth now uses native
routing; explicit subscription and BYOK paths are unchanged.
- Kill switch / disable path: use an explicit supported provider-key
BYOK configuration; native mode also fails closed when CLI support is
known absent.
- Unsafe override required: none.
- Qualification impact: native-wrap macOS/Ubuntu, Docker wrapper, full
Python matrix, and Copilot focused suites must pass.
- Rollback path: human revert of this PR restores the fixed-wire OAuth
behavior; no configuration migration is persisted.

## 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 — CLI help
and inline routing documentation; no separate guide required
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Screenshots (if applicable)

Not applicable; CLI routing change.

## Additional Notes

Human review only. No merge or auto-merge is configured. Refreshed from
main after #2996; the MCP cap `mcp>=1.28.1,<2.0.0` is preserved.
2026-08-25 21:46:31 -05:00
Tejas Chopra
f27f235032
fix(wrap): stop concurrent wrap sessions clobbering settings.local.json (#3232)
## Description

Several `headroom wrap` sessions in one project each write the proxy URL
into
`.claude/settings.local.json` and restore it on exit. That
read-modify-write was
unsynchronised. The write itself is atomic so the file never tears, but
the
updates were still lost against each other:

- **Live sessions were silently unrouted.** The first session to exit
deleted the
key while its siblings were still running. They kept working, but their
traffic
  stopped going through the proxy — no error, no warning, no savings.
- **A dead proxy was written back into the project.** A session that
started
second captured the *first* session's proxy URL as "the original", so
its exit
restored a URL pointing at a port that was already gone. Every later
session in
  that project then failed to connect.
- **SIGTERM/SIGHUP never ran the restore at all.** `cleanup` was
registered as the
handler, but a Python signal handler that returns normally does not
unwind the
stack — under PEP 475 the interrupted `waitpid` is simply retried. The
`finally`
block that restores `settings.local.json` never ran, while the handler
had
  already terminated the proxy underneath a child that was still alive.

Closes #3205

## Type of Change

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

## Changes Made

- **`_wrap_settings_lock`** — an exclusive OS lock (flock /
`msvcrt.locking`) held
across the settings read-modify-write. A workspace that cannot hold lock
state
  degrades to the previous behaviour rather than failing, matching
  `_proxy_start_lock`.
- **`.headroom_wrap_owners.json`** — a sidecar recording, per env key,
the true
pre-wrap `original` plus the live sessions holding it. The first writer
records
the original; later writers inherit it and are flagged `inherited`, so
no
session restores a value it did not observe first-hand. A session exits
without
restoring while a sibling still holds the key. Dead holders are pruned
with the
same conservative PID+identity liveness the proxy-client markers use, so
a
  SIGKILLed session cannot wedge the key.
- **`unwrap` passes `force=True`** — unwrap is the user explicitly
asking for
their settings back, so it drops every claim instead of deferring to a
live
sibling and silently printing success while leaving the proxy URL in the
file.
- **The #2221 self-heal passes `dead_ports`** — a wrapper process can
outlive its
proxy (proxy alone SIGKILLed). Its claim would otherwise veto the
self-heal and
  leave `ANTHROPIC_BASE_URL` pointing at a port just proven dead.
- **`_rehome_wrap_marker`** — the wrap marker has one slot, won by the
last
writer. When that writer exits while a sibling still owns the key, the
marker is
rewritten to describe the survivor (carrying the record's true
original), so the
survivor keeps its #2221 self-heal record instead of being left with a
marker
  describing a dead process.
- **`_exit_on_signal`** replaces `cleanup` as the SIGTERM/SIGHUP
handler. Raising
`SystemExit` unwinds, so the settings restore actually runs and cleanup
happens
  exactly once from `finally`.
- **`_proxy_start_lock` now shares `_locked_file`** with the new
settings lock
  rather than carrying a second verbatim copy of the platform branches.

## Testing

- [x] Unit tests pass (`pytest`) — full suite, 11518 passed / 588
skipped
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

`tests/test_wrap_concurrent_settings.py` (14 tests) covers: a sibling
exit leaving
survivors routed, the last session out restoring the true original, a
pre-existing
user URL surviving the whole cycle, three sessions in every exit order,
a crashed
session not wedging the key, forced unwrap past a live session, a holder
that
outlived its proxy not vetoing the self-heal, marker rehoming, and the
signal-handler unwind.

### Test Output

```text
$ uv run pytest tests/test_cli/test_unwrap_claude.py tests/test_cli/test_wrap_claude_base_url.py \
    tests/test_cli/test_wrap_claude_finally_unbound.py tests/test_cli/test_wrap_claude_vertex_proxy_env.py \
    tests/test_cli/test_wrap_claude.py tests/test_cli/test_wrap_dead_marker_selfheal.py \
    tests/test_cli/test_wrap_helpers.py tests/test_cli/test_wrap_stale_marker.py \
    tests/test_cli/test_wrap_persistent.py tests/test_wrap_concurrent_settings.py tests/test_cli_doctor.py -q

tests/test_wrap_concurrent_settings.py ..............                    [ 72%]
tests/test_cli_doctor.py ............................................... [ 89%]
...............................                                          [100%]

============================= 285 passed in 3.01s ==============================

$ uv run pytest tests/ -q
======== 11518 passed, 588 skipped, 6036 warnings in 1831.34s (0:30:31) ========

$ uv run ruff check .
All checks passed!

$ uv run mypy headroom
Success: no issues found in 527 source files
```

## Real Behavior Proof

- **Environment:** macOS 15 (Darwin 25.4.0), Python 3.12.13, repo venv,
Claude
  provider path (`ANTHROPIC_BASE_URL` in `.claude/settings.local.json`).
- **Exact command / steps:** a script spawning **two real OS processes**
— no
  mocks, real PIDs, real files — that call the same
`_write_claude_wrap_base_url` / `_restore_claude_wrap_base_url` helpers
`wrap claude` uses. The project starts with a real user gateway already
set.
Session A (port 8787) starts, session B (port 8788) starts 0.7s later, A
exits
while B is still running, then B exits. Run identically on `main` and on
this
  branch.

**Before (on `main`) — both bugs visible:**

```text
start                       : {"ANTHROPIC_BASE_URL": "https://my-gateway.example.com"}
  session port=8787 started, remembers previous='https://my-gateway.example.com'
  session port=8788 started, remembers previous='http://127.0.0.1:8787'
both sessions running       : {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8788"}
  session port=8787 exited
after FIRST session exits   : {"ANTHROPIC_BASE_URL": "https://my-gateway.example.com"}
  session port=8788 exited
after LAST session exits    : {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787"}
```

Session B is still running, but after A exits the proxy URL is gone from
under it
— B is unrouted with no error. And the final state is
`http://127.0.0.1:8787`: a
dead proxy left permanently in the user's project, with their real
gateway lost.

**After (this branch):**

```text
start                       : {"ANTHROPIC_BASE_URL": "https://my-gateway.example.com"}
  session port=8787 started, remembers previous='https://my-gateway.example.com'
  session port=8788 started, remembers previous='http://127.0.0.1:8787'
both sessions running       : {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8788"}
  session port=8787 exited
after FIRST session exits   : {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8788"}
  session port=8788 exited
after LAST session exits    : {"ANTHROPIC_BASE_URL": "https://my-gateway.example.com"}
```

B stays routed after A exits, and the last session out restores the
user's real
gateway.

- **Observed result:** matches the intent on both counts — no unrouting,
no dead
  proxy residue, user's pre-existing URL preserved.
- **Not tested:** Windows (`msvcrt.locking`) — the lock and dead-holder
pruning
  are exercised on POSIX only; the Windows branch is the same code path
`_proxy_start_lock` has shipped with. No live end-to-end run against a
real
Anthropic endpoint with two concurrent `claude` CLIs; the proof above
drives the
same helpers out of two real processes instead. Foundry/Vertex key
variants are
covered by unit tests, not by a live run. Real SIGTERM/SIGHUP delivery
to a
running `wrap claude` was not exercised end to end — the handler's
unwind is
covered by a unit test, and full signal delivery would need a spawned
and
  killed subprocess, which the existing #1768 test also declined to do.

## Runtime Rollout Safety

- **Rollout-managed feature(s):** none — this is an unconditional
correctness fix
  on the wrap settings path.
- **Minimum rollout channel:** n/a.
- **Stable/default behavior changed:** yes, three ways. (1) A wrap
session exiting
while a sibling holds the key now leaves the key in place instead of
removing
  it. (2) SIGTERM/SIGHUP now unwinds, so the child CLI is terminated by
  `subprocess.run`'s cleanup rather than being left running against a
  torn-down proxy. (3) Two new sidecar files appear next to
`settings.local.json`: `.headroom_wrap_owners.json` (removed when the
last
holder exits) and `.headroom_wrap_settings.lock` (retained by design —
deleting
  a live lock file creates an inode-replacement race).
- **Kill switch / disable path:** none. A workspace where the lock file
cannot be
created degrades to the previous unsynchronised behaviour automatically.
- **Unsafe override required:** no.
- **Qualification impact:** none beyond the wrap settings path.
- **Rollback path:** revert the commit; the sidecar files are ignored by
older
  versions and can be deleted safely.

## 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 did **not** edit `CHANGELOG.md`

## Additional Notes

- The ownership record is keyed per env key, so `ANTHROPIC_BASE_URL`,
the
Foundry/Vertex variants and the tool-search entry are tracked
independently.
- Documentation: the behaviour is documented in the helper docstrings
rather than
user-facing docs — the sidecar files are internal state a user never
configures.
- Follow-up worth considering: `.headroom_wrap_settings.lock` is
intentionally
never deleted (matching `_proxy_start_lock`'s retention rationale), so
it stays
in `.claude/` after `unwrap`. Removing it safely needs a separate think
about
  the inode-replacement race.

Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 22:36:17 -07:00
Rod Boev
701e4616d9
fix(kimi): route managed Kimi Code through the proxy (#3223)
## Description

Managed Kimi Code reads KIMI_CODE_BASE_URL while headroom wrap kimi
previously supplied only KIMI_BASE_URL. The managed client can therefore
keep its direct endpoint while the wrapper appears healthy. Emit both
provider-owned keys and recompute them through the existing launch
callback at the proxy's actual port. Preserve the legacy route and
unrelated wrappers. Closes #3207

## Type of Change

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

## Changes Made

- Set KIMI_CODE_BASE_URL and KIMI_BASE_URL from one project-aware proxy
URL.
- Recompute both values and their display lines through the Kimi
configure_launch callback after port fallback.
- Remove the generic display rewrite from _launch_tool so other wrappers
retain their base behavior.
- Add production-boundary child, fallback-port, legacy-preservation, and
non-Kimi negative-space tests.

## Testing

- [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_kimi.py
-q`)
- [x] Linting passes (`uv run ruff check .`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for the regression
- [x] Manual testing performed through the production subprocess
boundary

### Test Output

```text
uv run pytest tests/test_cli/test_wrap_kimi.py -q
10 passed in 0.40s
uv run pytest tests/test_cli/test_wrap_grok.py -q
2 passed
uv run ruff check .
All checks passed!
uv run ruff format . --check
1534 files already formatted
git diff --check
```

## Real Behavior Proof

- Environment: Windows, isolated Kimi wrapper subprocess harness.
- Exact command / steps: launch a contract-compatible child through the
Kimi wrapper; exercise requested and fallback ports, project prefixes,
legacy selection, and a non-Kimi wrapper.
- Observed result: the child receives the effective project-aware proxy
URL in both Kimi keys; the displayed URL matches it after fallback;
legacy and non-Kimi behavior remain unchanged.
- Not tested: live authenticated Kimi Code managed request

## Runtime Rollout Safety

- Rollout-managed feature(s): None; managed Kimi Code routing is
selected by the existing wrapper mode.
- Minimum rollout channel: Stable; no staged rollout mechanism exists
for this wrapper path.
- Stable/default behavior changed: Yes, managed Kimi Code launches now
receive the effective proxy URL in both provider-owned keys.
- Kill switch / disable path: Stop using the managed Kimi wrapper path
or revert the release commit.
- Unsafe override required: No.
- Qualification impact: None.
- Rollback path: Revert the release commit.

## 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] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

Kimi Code owns OAuth credentials and the /login flow. Headroom does not
read or modify Kimi config or credential files. The changelog is
generated by the release pipeline.
2026-08-23 19:55:14 -07:00
Rod Boev
7550efb68f
fix(mcp): add explicit Serena reconciliation (#3222)
## Description

Headroom repeatedly warns about user-managed Serena drift but has no
scoped remediation command. Add a Claude-only read-only mcp reconcile
command with explicit --adopt consent, using the canonical Serena spec
and existing Claude registrar. Adoption validates every relevant ledger
and Claude config root before mutation, writes only the Serena entry,
and records ownership after the config write succeeds. Automatic wrap
migration and ordinary install remain unchanged. Closes #3054

## Type of Change

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

## Changes Made

- Add Claude-only `headroom mcp reconcile`, read-only by default, with
`--adopt` as its only mutation action.
- Reuse the shared `CLAUDE_SERENA_CONTEXT` and canonical Claude Serena
spec builder.
- Fail closed on malformed or unreadable ledger/config state before
adoption.
- Preserve automatic wrap recovery, user-managed warnings, ordinary `mcp
install --force`, unrelated Claude config, and corrupt-ledger tolerance
outside explicit adoption.
- Record Headroom ownership only after a successful registrar write.

## Testing

- [x] Unit tests pass (`uv run pytest
tests/test_cli/test_mcp_reconcile.py
tests/test_cli/test_serena_reconcile.py
tests/test_mcp_registry/test_ledger.py`)
- [x] Linting passes (`uv run ruff check .`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed through the file-backed Claude registrar

### Test Output

```text
uv run pytest tests/test_cli/test_mcp_reconcile.py tests/test_mcp_registry/test_ledger.py tests/test_cli/test_serena_reconcile.py tests/test_mcp_registry/test_claude_registrar.py tests/test_mcp_registry/test_install.py -q
102 passed in 0.70s
uv run ruff check headroom/mcp_registry/ledger.py headroom/cli/wrap.py tests/test_mcp_registry/test_ledger.py tests/test_cli/test_serena_reconcile.py tests/test_cli/test_mcp_reconcile.py
All checks passed!
uv run ruff format --check headroom/mcp_registry/ledger.py headroom/cli/wrap.py tests/test_mcp_registry/test_ledger.py tests/test_cli/test_serena_reconcile.py tests/test_cli/test_mcp_reconcile.py
5 files already formatted
git diff --check
```

## Real Behavior Proof

- Environment: Windows, file-backed Claude configuration and isolated
MCP ledger.
- Exact command / steps: run the stale user-managed Serena fixture from
`tests/fixtures/headroom-issue-3054.json`; run read-only reconcile; run
`mcp reconcile --adopt`; rerun wrap and ordinary `mcp install --force`;
exercise malformed JSON, non-dict `mcpServers`, null ledger agents, and
unreadable-ledger adoption.
- Observed result: read-only reconciliation leaves config and ledger
bytes unchanged; adoption updates only Claude Serena and records
ownership after a successful write; automatic wrap remains lenient;
unsafe adoption inputs leave all files unchanged; ordinary install does
not adopt Serena.
- Not tested: live Claude CLI acceptance and Serena stdio handshake

## Runtime Rollout Safety

- Rollout-managed feature(s): None; explicit `mcp reconcile --adopt` is
the only mutation path.
- Minimum rollout channel: Stable; no staged rollout mechanism exists
for this command.
- Stable/default behavior changed: No, read-only reconcile is the
default and automatic wrap plus ordinary install remain unchanged.
- Kill switch / disable path: Do not invoke `--adopt` or revert the
release commit.
- Unsafe override required: No.
- Qualification impact: None.
- Rollback path: Revert the release commit.

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

## Additional Notes

The changelog is generated by the release pipeline. This change is
limited to Claude Serena reconciliation and does not add a new
persistent acknowledgement state or a multi-provider adoption route.
2026-08-23 11:52:50 -07:00
Ayush Kumar Jha
202c1895e1
fix(wrap): make the Serena pre-index stall budget configurable (#3183)
## Description

`headroom wrap` blocks the agent launch on a synchronous Serena
pre-index whose
300-second ceiling is a hardcoded module constant. When indexing exceeds
it the user
waits the full five minutes, the work is discarded (`Serena: pre-index
timed out (will
index on demand)`), and nothing — env var, flag, or config — can shrink
that budget.

Closes #3093

### Why this is still open after #2938

`_serena_project_skip_reason` keeps the pre-index off non-project roots,
which covers
the reporter's two repro directories. But it **defers the stall by one
wrap rather than
removing it**: as that function's own docstring notes, Serena's MCP
server generates
`project.yml` itself on first start, "so the pre-index simply resumes
from the next wrap
onwards." A parent-of-many-repos directory therefore gets claimed during
the first
session and pays the full 300s budget on every wrap after that. The
reporter's remaining
ask — "I'd also like the pre-index timeout to be configurable" — is the
unfixed half.

## Type of Change

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

## Changes Made

- Added `HEADROOM_SERENA_INDEX_TIMEOUT` and
`_resolve_serena_index_timeout_seconds()`, modelled on the existing
`_resolve_wrap_proxy_timeout_seconds()` in the same module.
- `_index_serena_project` resolves the budget after the `uvx` guard and
passes it to `communicate()` instead of the bare constant.
- `_SERENA_INDEX_TIMEOUT = 300` stays as the default, so unset behavior
is unchanged.
- Added 19 tests covering the resolver and the pre-index call path.

### Deliberate divergence from the proxy-timeout precedent

`_resolve_wrap_proxy_timeout_seconds` raises `RuntimeError` on a bad
value, which is
right for a subsystem the wrap cannot proceed without. The pre-index is
documented as
best-effort and non-fatal, so raising there would let a typo'd env var
abort a launch
that would otherwise succeed. An unusable value instead warns and falls
back to 300s.
The warning is unconditional (not gated on `--verbose`) because a knob
that looks
applied but is not is the failure this issue reports.

## 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 pytest tests/test_cli/test_wrap_serena_boost.py -q
43 passed, 1 skipped, 1 warning in 1.13s       # 24 pre-existing + 19 new

# the same 19 tests against the unpatched tree:
18 failed, 1 passed, 24 deselected             # the 1 passer is a pre-existing test caught by -k

$ python -m pytest tests/test_cli/ -q
3 failed, 696 passed, 2 skipped in 57.80s
# the 3 are pre-existing Windows failures (symlink handling in test_recover_codex.py
# and test_unwrap_claude.py); they fail identically on an unpatched tree.

$ python -m ruff check headroom/cli/wrap.py tests/test_cli/test_wrap_serena_boost.py
All checks passed!
$ python -m ruff format --check headroom/cli/wrap.py tests/test_cli/test_wrap_serena_boost.py
2 files already formatted
$ python -m mypy headroom/cli/wrap.py
Success: no issues found in 1 source file
```

Regression check across all 42 test modules that import
`headroom.cli.wrap`, run in both
states with the working tree md5-verified before each run: identical
81-line
failure/error set, +19 passing with the fix.

## Real Behavior Proof

- Environment: Windows 11 Home 26200, Python 3.11.9, headroom at 0.36.2
(`5e0ce24`). Serena/`uvx` are not installed on this machine and the Rust
`_core` extension is not built (no Rust toolchain), so a full `headroom
wrap claude` could not be launched — see `Not tested`.
- Exact command / steps: drove the real `_index_serena_project()` with a
real child process, a real process group, real
`communicate(timeout=...)`, real `TimeoutExpired`, and the real
`_kill_serena_index_tree`, timing each phase with a monotonic clock at
`HEADROOM_SERENA_INDEX_TIMEOUT=2` and `=4`. Only *which* binary runs was
substituted (a 120s sleeper in place of `serena project index`), since
the timeout logic is indifferent to the callee.
- Observed result: the configured budget controls the wait exactly — a
2s budget waits 2.02s and a 4s budget waits 4.02s, where before the
change the same harness reports 300s regardless of any env var set. Full
output below.
- Not tested: an end-to-end `headroom wrap claude/opencode` against a
real `serena project index` (uvx/serena unavailable here); non-Windows
platforms; the interaction with a genuinely large monorepo index.

```text
budget=2s | waited  2.02s for timeout | teardown 10.02s | total 12.03s
budget=4s | waited  4.02s for timeout | teardown 10.02s | total 14.03s

misconfigured value:
  Serena: ignoring HEADROOM_SERENA_INDEX_TIMEOUT='30s' (want a positive integer
  number of seconds) - using 300s
  -> resolved to 300s, no exception raised
```

### Incidental finding (not addressed here)

On Windows, `_kill_serena_index_tree` adds a constant ~10s after any
timed-out
pre-index — one of its two 10s bounds (`taskkill` / `proc.wait`) is hit
every time. So a
2s budget still costs ~12s wall clock. That is pre-existing #2938 code
untouched by this
PR, but it caps how small the stall can usefully get and may deserve its
own issue.

## Runtime Rollout Safety

- Rollout-managed feature(s): none — this is a plain env var, not a
rollout-channel feature.
- Minimum rollout channel: n/a — available on every channel, inert
unless set.
- Stable/default behavior changed: no — unset resolves to the existing
300s constant.
- Kill switch / disable path: unset `HEADROOM_SERENA_INDEX_TIMEOUT`;
skipping the pre-index entirely remains `--no-serena`.
- Unsafe override required: no.
- Qualification impact: none — no change to compression, proxy, or
provider behavior.
- Rollback path: revert the commit; no persisted state, no migration, no
config to clean up.

## 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] 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 did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Additional Notes

**Alternatives considered.** A CLI flag (`--serena-index-timeout`) is
more discoverable
but has to be threaded through four `wrap` subcommands, adds CLI surface
that
CONTRIBUTING gates behind maintainer sign-off, and would not reach `wrap
... -- agents`
sessions. Making the pre-index asynchronous removes the stall outright
and is arguably
the better end state, but it is an architectural change and would reopen
the
orphaned-grandchild failure mode #2938 just closed. Auto-scaling the
budget by project
size reintroduces the kind of hand-maintained heuristic #2938
deliberately removed.

**What this does not solve.** The default is still 300s, so a user who
never sets the
variable still stalls; the reporter's third point (using Serena in
background agent
sessions launched from a parent directory) is a Serena-semantics
question rather than a
headroom defect; and an in-flight pre-index is still not interruptible.

**Open questions for maintainers.**

1. Should `0` mean "skip the pre-index" instead of being rejected? I
kept the
proxy-timeout precedent (reject `<= 0`) since `--no-serena` already
covers disabling,
   but the other reading is defensible.
2. `HEADROOM_WRAP_PROXY_TIMEOUT` — the closest precedent — is not in
`docs/content/docs/configuration.mdx`, so I matched it and left docs
alone. Happy to
   add a row if you would rather document it.
3. If you consider a new env knob a feature rather than part of this
bug, say so and I
   will hold for a maintainer sign-off before you spend review time.

Documentation: no `CHANGELOG.md` edit (release-please generates it from
the PR title).
2026-08-21 14:59:53 -07:00
Tejas Chopra
b77d612913
fix(copilot): send VS Code inline completions to the host that serves them (#3112)
## Description

#3077 stopped Copilot's inline completions being forwarded to
`api.openai.com` (the corporate-blocked host in the original report) —
but sent them to the **CAPI host**, which does not serve that endpoint.

Copilot has two surfaces on two different hosts, and GitHub's own client
library keeps them apart:

```js
_getCAPIUrl(t)  -> t?.endpoints.api   || "https://api.githubcopilot.com"
_getProxyUrl(t) -> t?.endpoints.proxy || DEFAULT_PROXY_BASE_URL
DEFAULT_PROXY_BASE_URL = "https://copilot-proxy.githubusercontent.com"
```

building completions as
`${proxyBaseURL}/v1/engines/<engine>/completions` (`@vscode/copilot-api`
0.5.2). Probed unauthenticated against the live hosts:

| host | `POST /v1/engines/<e>/completions` |
|---|---|
| `copilot-proxy.githubusercontent.com` | **401** — exists, needs auth |
| `proxy.individual.githubcopilot.com` | **401** — CNAME to the above |
| `api.githubcopilot.com` | **404** — does not serve this path |

So the destination #3077 chose could not have worked. Three separate
defects were in the way, each sufficient on its own to keep completions
broken.

## Type of Change

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

## Changes Made

- `copilot_auth.py`: added `DEFAULT_COMPLETIONS_PROXY_URL` and made it
the default in `copilot_completions_base_url()`, replacing the CAPI
host.
- `copilot_auth.py`: the "custom deployment keeps its own host" rule now
excludes public Copilot hosts. Without this, `headroom wrap vscode` —
the common setup, and the one that exports
`GITHUB_COPILOT_API_URL=<resolved subscription URL>` — resolved straight
back to the 404 host. **This was a bug in my own first cut of the fix,
found by testing the real `wrap vscode` environment rather than just the
routing table.**
- `copilot_auth.py`: added `is_copilot_completions_host()` and
`is_copilot_upstream_url()` (chat ∪ completions). The completions host
was recognised as Copilot **nowhere**, so `apply_copilot_api_auth`
attached no credentials (401 — routing correctly to a host we then
failed to authenticate against) and `build_copilot_upstream_url` skipped
`mark_request_routed_to_copilot()`, mislabelling the provider in
telemetry.
- The union is applied at exactly those two call sites.
`is_copilot_api_url` is left alone, so validation of a token payload's
`endpoints.api` and the Responses-API preference check keep their strict
chat-only meaning. All six call sites were read before choosing this.
- `proxy_targets.py`: the "already a Copilot host" guard now keys on the
*completions* host. A CAPI host is not a completions host, so it must
still be redirected; a genuine per-SKU completions host or operator
override is still left untouched.
- `providers/copilot/vscode.py`, `cli/wrap.py`,
`docs/…/vscode-copilot.mdx`: stop writing/printing
`github.copilot.advanced.debug.overrideAuthType`. No such setting exists
in the modern Copilot Chat extension — the only one left after
`GitHub.copilot` was deprecated in early 2026. Its full `advanced.*`
surface is `authPermissions`, `authProvider`, `debug.overrideCapiUrl`,
`debug.overrideProxyUrl`, `debug.use*Fetcher`. It is still *recognised*
so a stale hand-written copy is detected, just never emitted.

## Testing

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

### Test Output

```text
tests/test_copilot_vscode_completions_routing.py  59 passed
Copilot-related suites                           293 passed, 8 skipped

Full suite:
3 failed, 11250 passed, 581 skipped in 342.50s
```

The 3 failures are pre-existing and environmental, identical to a
plain-`main` baseline on this machine: no `cargo`
(`test_no_native_tls_in_wheel_build_tree`), no `codex` CLI
(`test_learn/test_integration.py`), and
`test_run_server_installs_cancelled_error_filter`, which fails under
full-suite ordering on `main` too.

## Real Behavior Proof

- Environment: macOS (darwin 25.4.0), Python 3.12.13, worktree off
`main` @ `139c7cbd`, `HEADROOM_SKIP_UPSTREAM_CHECK=1`
- Exact command / steps: (1) composed the real request path —
`select_passthrough_base_url(proxy, headers, path)` →
`build_copilot_upstream_url` → `apply_copilot_api_auth` — across 7
deployment shapes (no config, `wrap vscode`, advertised
`endpoints.proxy`, operator override, GHE `.ghe.com`, GHE custom domain,
target already a completions host); (2) probed the three candidate hosts
unauthenticated with `curl -X POST
/v1/engines/gpt-4o-copilot/completions`; (3) round-tripped
`settings.json` through empty / one-setting / comments+array / CRLF
shapes asserting valid JSON, idempotency and clean removal.
- Observed result: before — `api.githubcopilot.com/...` (404 host), and
with `GITHUB_COPILOT_API_URL` set as `wrap vscode` sets it,
`api.business.githubcopilot.com` (also 404); no `Authorization` header
on the completions host. After —
`copilot-proxy.githubusercontent.com/v1/engines/gpt-41-copilot/completions`
with credentials attached in every public-Copilot shape,
`endpoints.proxy` and the operator override still winning, and a GHE
tenant staying on its own host. `settings.json` stays valid JSON in all
four shapes with the dead key gone; the two `restored=False` cases are
pre-existing whitespace/CRLF normalisation, identical on `main`.
Reverting the source fails 14 of the new tests, including the credential
test on the completions host.
- Not tested: no live VS Code session and no authenticated completion —
the 401 proves the endpoint exists, not that GitHub accepts our
forwarded request, which needs a real Copilot token. Confirmation from
@rganesh-msys is still wanted. **Enterprise remains unresolved by
default**: a GHE tenant stays on its own CAPI host, which is likely
still the wrong surface for completions, but staying in-tenant beats
forwarding keystrokes to a public GitHub host —
`GITHUB_COPILOT_PROXY_URL` is the exact fix and now takes precedence
over everything.

## Runtime Rollout Safety

- Rollout-managed feature(s): None — no rollout channel gates this.
- Minimum rollout channel: n/a
- Stable/default behavior changed: Yes, and deliberately — the
completions destination moves from a host that answers 404 to the one
GitHub's own client defaults to. Only `/v1/engines/<engine>/completions`
is affected; every other path keeps its upstream, pinned by tests.
Copilot credentials now also reach the completions host, which is the
point.
- Kill switch / disable path: `GITHUB_COPILOT_PROXY_URL` pins the
destination explicitly and beats all inference.
- Unsafe override required: No.
- Qualification impact: None.
- Rollback path: Revert this commit; completions return to the CAPI host
(404) and the settings block regains the inert `overrideAuthType`.

## Review Readiness

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

## Additional Notes

Two things found while reading the extension source, **not changed
here**:

1. `advanced.debug.overrideProxyUrl` is **not** deprecated — the report
that Copilot 0.60.0 stopped honouring it does not hold. The current
canonical key is `github.copilot.internal.completionsUrl`, and
`advanced.debug.overrideProxyUrl` is checked as its explicit legacy
fallback (`getEndpointOverrideUrl` in
`completions-core/lib/src/networkConfiguration.ts`), so what we write
still works. Worth migrating to the `internal.*` keys eventually, since
they take precedence.
2. `endpoints.proxy` is still only recorded during a token exchange,
which is opt-in via `GITHUB_COPILOT_USE_TOKEN_EXCHANGE`, and the base
URL is chosen before auth runs. With the default now correct this is a
refinement for per-SKU hosts rather than a correctness requirement, so
it is left as-is.

Closes #3076

---------

Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:21:22 -07:00
Abhay Singh
58f28dc7a6
fix(install): honor HEADROOM_PORT in install apply and deploy (#3085)
## Description

`headroom install apply --preset persistent-service` and `headroom
deploy` ignored an explicit `HEADROOM_PORT` and always configured port
8787, even though `headroom proxy --port` honors `HEADROOM_PORT`. Anyone
running a second instance, or avoiding a port conflict, got a silently
wrong configuration, and the failure is especially confusing because the
override *appears* supported on the direct proxy path.

Root cause: the `--port` options on the `install apply` and `deploy`
commands were declared with a hardcoded `default=8787` and **no**
`envvar` binding:

```python
@click.option("--port", "-p", default=8787, type=int, show_default=True, help="Persistent proxy port.")
```

The proxy command's `--port` already carries `envvar="HEADROOM_PORT"`,
so the two paths disagreed. `build_manifest` /
`_build_deployment_manifest` already thread the `port` argument all the
way through to the generated `HEADROOM_PORT` base-env and the health
URL, so the value was simply never resolved from the environment at the
CLI boundary.

## Fix

Bind both `--port` options to `envvar="HEADROOM_PORT"`, matching the
proxy command. Click resolves the value from the environment when
`--port` is not passed, and an explicit `--port` still wins over the env
var (standard Click precedence: explicit CLI argument over `envvar` over
`default`).

## Scope

This addresses **bug 1** of #3072. Bug 2 (`install status` reporting
`Status: stopped` alongside `Healthy: yes`, disagreeing with `doctor`)
is an unrelated status-reporting concern that the reporter offered a
live repro for; it is left for a separate follow-up rather than bundled
here.

## Type of Change

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

## Changes Made

- `headroom/cli/install.py`: add `envvar="HEADROOM_PORT"` to the
`--port` option on both `install apply` and `deploy` (and note the env
var in each help string), matching `headroom proxy --port`.
- `tests/test_cli/test_install_cli.py`: added
`test_install_apply_honors_headroom_port_env`,
`test_install_apply_explicit_port_overrides_env`, and
`test_deploy_honors_headroom_port_env`, capturing the `port` that
reaches the manifest builder.

## Testing

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

### Test Output

```text
tests/test_cli/test_install_cli.py  40 passed
# uvx ruff@0.15.22 check  -> All checks passed!
# uvx mypy@1.20.2 headroom/cli/install.py -> Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12.11, project venv, pytest 9.1.1,
ruff 0.15.22 and mypy 1.20.2 via uvx.
- Exact command / steps: reverted the source fix and ran the two new
env-var tests to capture the bug (`python -m pytest
tests/test_cli/test_install_cli.py::test_install_apply_honors_headroom_port_env
tests/test_cli/test_install_cli.py::test_deploy_honors_headroom_port_env`
-> both failed with `assert 8787 == 8788`, proving `HEADROOM_PORT=8788`
was dropped); restored the fix; re-ran the full file (`python -m pytest
tests/test_cli/test_install_cli.py` -> 40 passed); then `uvx
ruff@0.15.22 format`, `uvx ruff@0.15.22 check`, and `uvx mypy@1.20.2
headroom/cli/install.py`.
- Observed result: with the fix, `HEADROOM_PORT=8788 headroom install
apply` (and `deploy`) resolves `port=8788` into `build_manifest`, so the
generated service config and `HEADROOM_PORT` base-env use 8788; passing
`--port 9999` alongside the env var still yields 9999.
- Not tested: an end-to-end persistent-service install on a machine with
a running supervisor (the CLI-to-manifest port resolution is verified
through the manifest builder, which already owns the downstream wiring
covered by the existing planner tests).

## Runtime Rollout Safety

- Rollout-managed feature(s): none. This is a CLI option-binding fix on
the install/deploy commands, not a rollout-channel-gated runtime
feature.
- Minimum rollout channel: N/A (no rollout-managed behavior).
- Stable/default behavior changed: only when `HEADROOM_PORT` is set in
the environment. Previously it was ignored (config wired to 8787); now
the install/deploy path honors it, matching `headroom proxy`. With no
`HEADROOM_PORT` set and no `--port`, the default is still 8787, so
existing installs are unaffected.
- Kill switch / disable path: unset `HEADROOM_PORT` (or pass `--port
8787`) to keep the prior port.
- Unsafe override required: no.
- Qualification impact: `install apply` / `deploy` now provision the
proxy on the operator's requested port instead of always 8787, so a
second instance or a port-conflict workaround configures correctly.
- Rollback path: revert this PR; the `--port` options return to ignoring
`HEADROOM_PORT`.

## 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] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md`: it is generated by
release-please from my Conventional Commit PR title

## Additional Notes

Reported by @vsg-prog (split out of #3040 into #3072). The `--port`
option already carried the correct `type`/range validation and threaded
through the manifest builder; the only gap was the missing `envvar`
binding at the CLI boundary.
2026-08-17 20:20:38 -07:00
Abhay Singh
2a8472525d
feat(wrap/claude): make the --1m fallback model configurable via HEADROOM_1M_MODEL (#2983)
## Description

The model `headroom wrap claude --1m` falls back to (when no model is
otherwise selected) was a hardcoded constant `claude-opus-4-8`, with no
env var or config key to override it. So it goes stale with every new
Opus release, and the only workaround is pinning `ANTHROPIC_MODEL`
globally -- which also changes every non-`--1m` session and overrides
Claude Code's own `/model` picker. The knob the user actually wants
("what should `--1m` default to") did not exist (#2937).

## Fix

Add a `HEADROOM_1M_MODEL` env override that `_resolve_1m_model` consults
for its fallback default, and bump the built-in default to
`claude-opus-5` (Opus 5 has shipped):

```python
_1M_MODEL_ENV = "HEADROOM_1M_MODEL"
_DEFAULT_1M_MODEL = "claude-opus-5"

def _resolve_1m_model(current: str | None) -> str:
    fallback = (os.environ.get(_1M_MODEL_ENV) or "").strip() or _DEFAULT_1M_MODEL
    base = (current or "").strip() or fallback
    return base if base.endswith(_CONTEXT_1M_SUFFIX) else f"{base}{_CONTEXT_1M_SUFFIX}"
```

Precedence is unchanged: an explicit `ANTHROPIC_MODEL` (or a
pass-through `--model`, via the existing `_apply_1m_to_claude_args`)
still wins. `HEADROOM_1M_MODEL` only supplies the fallback when nothing
else is selected. The `[1m]` suffixing and idempotency are unchanged.

Fixes #2937

## 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
- `docs/content/docs/configuration.mdx`: document `HEADROOM_1M_MODEL`
(new "Claude 1M context window" subsection covering `--1m` resolution
order and `[1m]` acceptance) and register it in the Environment
Variables catalog with its current default.
- `tests/test_cli/test_wrap_helpers.py`: assert the knob stays
documented and the documented default tracks `_DEFAULT_1M_MODEL`, so it
cannot silently drift.

- `headroom/cli/wrap.py`: add `HEADROOM_1M_MODEL` env override in
`_resolve_1m_model`; bump `_DEFAULT_1M_MODEL` to `claude-opus-5`.
- `tests/test_cli/test_wrap_helpers.py`: env override wins the fallback;
an explicit current model still wins over the env; blank env falls back
to the built-in; env value is idempotent for an already-`[1m]` value.
Updated the existing "falls back to default" test to assert against the
constant (robust to future bumps) and to clear the env var.

## Testing

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

### Test Output

```text
tests/test_cli/test_wrap_helpers.py -k "resolve_1m or apply_1m"  11 passed
tests/test_cli/test_wrap_claude_vertex_proxy_env.py -k 1m         4 passed
# uvx ruff@0.15.22 check  -> All checks passed!
# uvx mypy@1.20.2 headroom/cli/wrap.py -> Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12.11, project venv, pytest 9.1.1,
ruff 0.15.22 and mypy 1.20.2 via uvx.
- Exact command / steps: exercised `_resolve_1m_model` directly with the
env var set/unset. With `HEADROOM_1M_MODEL=claude-opus-9` and no
`ANTHROPIC_MODEL`, `--1m` resolves to `claude-opus-9[1m]`; with the env
var unset it resolves to `claude-opus-5[1m]`; a set `ANTHROPIC_MODEL`
(e.g. `claude-sonnet-5`) still wins as `claude-sonnet-5[1m]`.
- Observed result: operators can point `--1m` at the current Opus
without a code change and without pinning `ANTHROPIC_MODEL` globally,
and a fresh install no longer silently opts `--1m` into the previous
generation.
- Not tested: a live Claude Code 1M session (no entitled account here).
The resolution is verified at the helper the launch path uses.

## Runtime Rollout Safety

- Rollout-managed feature(s): none. `wrap claude --1m` model resolution
is a launch-time CLI helper, not a rollout-channel-gated runtime
feature.
- Minimum rollout channel: N/A (no rollout-managed behavior).
- Stable/default behavior changed: yes, narrowly. The built-in `--1m`
fallback default moves from `claude-opus-4-8` to `claude-opus-5` only
when neither `HEADROOM_1M_MODEL` nor `ANTHROPIC_MODEL` is set; any
explicit selection is unaffected.
- Kill switch / disable path: set `HEADROOM_1M_MODEL` (or
`ANTHROPIC_MODEL`) to pin any model; both override the default.
- Unsafe override required: no.
- Qualification impact: none. No proxy request path, routing, or token
accounting is touched.
- Rollback path: revert this PR, or set
`HEADROOM_1M_MODEL=claude-opus-4-8` to restore the prior default without
a code change.

## 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
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md`: it is generated by
release-please from my Conventional Commit PR title

## Additional Notes

The default bump (`claude-opus-4-8` -> `claude-opus-5`) is the second
half of the issue's request. If you would rather keep the constant and
ship only the env override, I can drop that one line; the override alone
already lets operators avoid the stale default.

---------

Co-authored-by: JD Davis <mxjerrett@gmail.com>
2026-08-16 15:09:46 -07:00
Nestor G Pestelos Jr
c8310819a4
fix(wrap): set xAI upstream for grok-build proxy (#2772)
## Description

`headroom wrap grok-build` injected the client hop into
`~/.grok/config.toml` but started the local proxy **without** setting
the OpenAI-compatible upstream to xAI. The proxy defaulted to
`api.openai.com`, so Grok session auth returned **401** on every chat
completion even though compression still ran.

`wrap grok` already passes `openai_api_url` → xAI. This PR aligns `wrap
grok-build` and the Grok-only persistent `install` path on the shared
`DEFAULT_API_URL` (`https://api.x.ai`).

Closes # (none — discovered in live Grok Build pilot)

## Type of Change

- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which 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

- Pass `openai_api_url=_GROK_DEFAULT_API_URL` into
`_run_proxy_only_watcher` from `wrap grok-build`
- Use shared `DEFAULT_API_URL` from `wrap grok` (no hard-coded string
drift)
- Print proxy upstream in Grok Build setup lines
- Persistent install: when targets are Grok-only, set
`OPENAI_TARGET_API_URL` + `--openai-api-url` (skip when
Codex/Copilot/Aider/OpenCode share the proxy; explicit env still wins)
- Regression tests for wrap kwargs, setup lines, and install planner

## Testing

- [x] Unit tests pass (`pytest` targeted suite)
- [ ] Linting passes (`ruff check .`) — not run in this environment (no
native editable build)
- [ ] Type checking passes (`mypy headroom`) — not run
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ PYTHONPATH=$PWD python -m pytest \
    tests/test_cli/test_wrap_bridge.py::test_wrap_grok_build_passes_xai_openai_api_url \
    tests/test_cli/test_wrap_bridge.py::test_wrap_grok_build_uses_actual_proxy_port \
    tests/test_install/test_planner.py::test_build_manifest_grok_build_only_sets_xai_upstream \
    tests/test_install/test_planner.py::test_build_manifest_grok_with_codex_does_not_force_xai \
    tests/test_install/test_planner.py::test_build_manifest_extra_env_wins_over_grok_xai_default \
    tests/test_provider_grok_build.py::test_grok_build_setup_lines_include_proxy_url -q
......
6 passed in 0.33s
```

## Real Behavior Proof

- Environment: macOS (darwin), Headroom 0.33.0 via `uv tool install
"headroom-ai[proxy,mcp,code]==0.33.0"`, Grok Build CLI, models
`grok-build` and `grok-4.5`, proxy on `127.0.0.1:8787`, upstream must be
xAI
- Exact command / steps: (1) Before: stock `headroom wrap grok-build`
then `grok -m grok-build` one-shot prompt. (2) After: same wrap path
with this branch (`openai_api_url=DEFAULT_API_URL` into
`_run_proxy_only_watcher`) then `grok -m grok-build -p
'…HEADROOM_XAI_OK…'`. Also exercised `grok-4.5` via `[model."grok-4.5"]
base_url` → same proxy.
- Observed result: Before — proxy log outbound `api.openai.com` → HTTP
401; client failed while local compression still ran. After — setup line
prints Proxy upstream `https://api.x.ai`; proxy log `POST
https://api.x.ai/v1/chat/completions` (and `/v1/responses` for grok-4.5)
→ status=200; dashboard shows 0 failed requests and accumulating token
savings on live traffic.
- Not tested: full `uv run` editable/maturin native build on this host;
multi-tool install matrix beyond planner unit tests; Windows; ruff/mypy
full tree

## 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 commented my code, particularly in hard-to-understand areas
- [ ] I made corresponding changes to the documentation (CLI help text /
setup lines only)
- [x] My changes generate no new warnings
- [x] I 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 did **not** edit `CHANGELOG.md` — generated by release-please
from Conventional Commit PR title (a CI guard enforces this)

## Additional Notes

- Intentional non-goal: changing default model, savings %, or Grok Build
context-tool defaults
- Mixed-target install (e.g. `grok_build` + `codex`) does **not** force
xAI — operator must set upstream explicitly if they share one proxy
- Related live routing: manual `[model."grok-4.5"] base_url` through the
same proxy works once upstream is xAI (`/v1/responses`)

---------

Co-authored-by: Grok 4.5 <noreply@x.ai>
Co-authored-by: Nestor G Pestelos Jr <ngpestelos@me.com>
Co-authored-by: JD Davis <jd@jds-macbook-air.tail2a279.ts.net>
2026-08-16 15:04:59 -07:00
JD Davis
2d88e31a40
fix(claude): reject conflicting auth before proxy startup (#2993)
## Description

Fixes #1443.

Claude Code rejects an effective configuration containing both
ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN before any request reaches
Headroom. The existing wrapper started the proxy and mutated project
settings before Claude surfaced its generic Invalid API key message,
leaving users to guess which credential came from their shell, global
settings, or project settings.

Headroom does not own either credential, and both represent legitimate
but different auth/billing modes, so automatically deleting one would be
destructive. This PR detects the contradiction before any proxy/config
mutation and tells the user which source contains each key without
exposing credential values.

## Changes Made

- Add a pure Claude auth-conflict classifier with explicit
settings-layer precedence.
- Cover user settings, project .claude/settings.json, project
.claude/settings.local.json, and shell environment.
- Treat higher-precedence empty values as clearing inherited
credentials.
- Abort wrap claude before proxy registration/startup when both keys
remain effective.
- Add a headroom doctor failure with the same source-aware,
value-redacted remediation.
- Preserve both user credentials and require an explicit choice between
API-key billing and token/gateway auth.

## Type of Change

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

## Testing

- [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
151 Claude runtime, wrap, doctor, Remote Control, and MCP dependency-contract tests passed
ruff check and format checks passed
git diff --check passed
```

Branch contains current main, including the MCP v1 cap and the five
just-merged blocker PRs.

## Real Behavior Proof

- Environment: isolated local worktree on current `main` with Claude
wrapper and doctor fixtures.
- Exact command / steps: exercised conflicting and non-conflicting
shell, user, project, and local-project credential layers through the
focused wrap and doctor test suites.
- Observed result: conflicting effective credentials fail before proxy
startup or settings mutation, report only credential sources, and never
expose values.
- Not tested: a live Claude Code login with production credentials;
credential precedence and side-effect boundaries are covered by
fixtures.

## Runtime Rollout Safety

- Rollout-managed feature(s): Claude authentication-conflict preflight.
- Minimum rollout channel: normal patch release.
- Stable/default behavior changed: only configurations with both
effective credentials now stop early with actionable diagnostics.
- Kill switch / disable path: remove or clear either conflicting
credential in its reported source.
- Unsafe override required: none; Headroom deliberately does not choose
or delete a user credential.
- Qualification impact: Claude wrap, doctor, Remote Control, and MCP
dependency-contract tests must remain green.
- Rollback path: human revert restores the previous late Claude Code
rejection; no persisted migration is involved.

## Review Readiness

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

## Safety

No credential value is returned by the classifier, printed by wrap, or
emitted in doctor JSON. The preflight runs before
_register_proxy_client, proxy startup, MCP registration, or settings
writes.
2026-08-13 23:01:59 -05:00
JD Davis
1aa701adaa
fix(vscode): persist compatible Claude modes and route Copilot CAPI (#2986)
## Description

Fixes #2492, #2028, and #2827.

Claude daemon workers consume project settings rather than reliably
inheriting wrapper environment state, while the Claude VS Code webview
cannot render deferred-tool response blocks. Separately, recent Copilot
Chat versions use the whole CAPI override for generation; the legacy
proxy override alone only sends model discovery through Headroom.

This PR carries both integrations through to the actual consumers
instead of only changing their launch-time surface configuration.

## Type of Change

- [x] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update
- [ ] Build / CI

## Changes Made

- Persist the resolved Claude ENABLE_TOOL_SEARCH value into project
settings for daemon workers and restore it transactionally after wrap
exits.
- Use compatibility-safe Foundry and Claude VS Code defaults while
preserving explicit user choices.
- Configure both Copilot overrideProxyUrl and overrideCapiUrl in the
reversible managed VS Code settings block.
- Route Copilot unprefixed POST /chat/completions and HTTP /responses
requests through the real compression handlers.
- Keep /responses out of the Codex WebSocket aliases because Copilot and
Codex use different WebSocket wire protocols.
- Extend wrap E2E assertions for both the Claude webview mode and
Copilot CAPI routing.

## Testing

- [x] 127 combined Claude, Copilot, route-integration, and MCP
dependency-contract tests pass.
- [x] Ruff check passes on all changed Python files.
- [x] Ruff format check passes.
- [x] Python compilation and git diff --check pass.

## Runtime Safety

Standalone Claude CLI defaults remain unchanged. Explicit Claude
tool-search values retain precedence, and project settings are restored
through the existing cleanup path. Copilot model/session helper
endpoints continue through generic passthrough, while only validated
HTTP generation paths receive explicit compression routes. Existing
Codex WebSocket behavior is unchanged.

## Review Readiness

- [x] Current main and MCP v1 compatibility retained
- [x] Worker-facing Claude persistence covered
- [x] Reversible Copilot and Claude settings behavior covered
- [x] Copilot generation routes covered at registration and proxy
integration layers
- [x] Ready for review
2026-08-13 15:06:41 -05:00
JD Davis
ddd2a259ec
fix(install): consolidate Windows fallback and cleanup safety (#2980)
## Description

Consolidates two fully reviewed installation-safety fixes whose original
PRs can no longer merge under current branch protection: Windows
persistent-service deployments need a supported Task Scheduler fallback,
and legacy context-tool cleanup must never delete user-owned
RTK/lean-ctx artifacts.

Closes #2552
Closes #2817

Supersedes #2600 and #2828 while preserving their authors' commits and
review-driven corrections.

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

- Convert Windows `persistent-service` plans to the supported
`persistent-task` supervisor and make the fallback explicit in CLI
output.
- Restrict context-tool cleanup to artifacts proven to live under
Headroom's managed directory.
- Recognize wrapped, relative, and platform-specific managed commands
without accepting prefixed/path-boundary lookalikes.
- Scope cleanup completion state correctly across projects and alternate
agent homes.
- Stamp cleanup complete only after all managed remnants are settled.
- Preserve the original focused regression suites and behavior-proof
artifact.

## 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 -q tests/test_install/test_planner.py tests/test_install/test_supervisors.py tests/test_cli/test_install_cli.py tests/test_context_tool_cleanup.py tests/test_cli/test_unwrap_claude.py
135 passed in 0.45s

$ uv run ruff check <changed Python and test files>
All checks passed!

$ uv run ruff format --check <changed Python and test files>
8 files already formatted
```

## Real Behavior Proof

- Environment: macOS arm64 for consolidated current-main validation; the
Windows fallback source PR was independently validated on Windows and
includes its captured verification artifact.
- Exact command / steps: run the planner, supervisor, install CLI,
cleanup provenance, and unwrap suites on the rebased combined branch.
- Observed result: 135/135 focused tests pass. Windows service requests
resolve to `persistent-task`; cleanup rejects user-owned and path-prefix
lookalikes while removing managed artifacts.
- Not tested: a fresh privileged Windows host deployment in this local
pass; #2600's accepted review contains the Windows-specific proof.

## Runtime Rollout Safety

- Rollout-managed feature(s): Install supervisor selection and one-time
legacy cleanup.
- Minimum rollout channel: Stable/default; both prevent currently
destructive or nonfunctional install paths.
- Stable/default behavior changed: Windows service requests use Task
Scheduler; cleanup requires managed provenance.
- Kill switch / disable path: Select `persistent-task` explicitly;
cleanup remains bounded by its completion stamp and provenance checks.
- Unsafe override required: No.
- Qualification impact: Windows native install and wrap/unwrap cleanup
suites.
- Rollback path: Revert this PR, restoring the two pre-fix behaviors.

## 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 did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Screenshots (if applicable)

The Windows verification artifact from #2600 is retained at
`.github/pr-images/issue-2552-windows-fallback-verification.png`.

## Additional Notes

This is intentionally an installation-safety batch rather than two
replacement PRs. Original commit authorship is preserved, and the
combined diff was applied cleanly to current `main` after #2832 and
#1628 landed.

---------

Co-authored-by: Inference1 <68734681+Inference1@users.noreply.github.com>
Co-authored-by: Dennis Alexis Valin Dittrich <dd+github@dr-dittrich.de>
2026-08-13 15:05:45 -05:00
石岳峰
b7f342c153
fix(wrap): verify proxy deps before mutating Codex config (#1628)
## Description

\`headroom wrap codex\` now verifies that optional proxy dependencies
(\`headroom-ai[proxy]\`) are installed before mutating Codex
\`config.toml\`. If the check fails, the command exits with the same
error message as \`headroom proxy\` and leaves Codex config untouched.

Fixes #1614 (Bug 1: config mutated before proxy dependency check).

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

- Extract \`ensure_proxy_dependencies()\` in \`headroom/cli/proxy.py\`
(shared with \`headroom proxy\`)
- Call it at the start of \`wrap codex\` when \`not no_proxy\`, before
config snapshot/injection
- Add regression tests for prepare-only abort, \`--no-proxy\` skip, and
import failure messaging

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

\`\`\`bash
pytest
tests/test_cli/test_wrap_codex.py::test_wrap_codex_aborts_before_mutating_config_when_proxy_deps_missing
\

tests/test_cli/test_wrap_codex.py::test_wrap_codex_skips_proxy_dependency_check_with_no_proxy
\

tests/test_cli/test_wrap_codex.py::test_ensure_proxy_dependencies_exits_when_server_import_fails
-q
# 3 passed
ruff check headroom/cli/wrap.py headroom/cli/proxy.py
tests/test_cli/test_wrap_codex.py
ruff format --check headroom/cli/wrap.py headroom/cli/proxy.py
tests/test_cli/test_wrap_codex.py
\`\`\`

## Real Behavior Proof

Environment: Linux (Ubuntu), Python 3.12, local checkout with
\`PYTHONPATH\` pointed at patched sources.

Exact command / steps:
1. Created a temp \`~/.codex/config.toml\` with \`model_provider =
"openai"\`.
2. Patched \`headroom.cli.wrap.ensure_proxy_dependencies\` to raise
\`SystemExit(1)\` (simulating missing \`[proxy]\` extra).
3. Ran \`headroom wrap codex --prepare-only --no-serena --port 8787\`.

Observed result: exit code 1; \`config.toml\` unchanged; no
\`config.toml.headroom-backup\` created; no \`[mcp_servers.headroom]\`
block written.

Also verified: \`headroom wrap codex --prepare-only --no-proxy ...\`
does not invoke the dependency check.

Not tested: Windows-specific proxy selector behavior (covered separately
in #1655).

## 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 did not edit CHANGELOG.md; release notes are generated
automatically

---------

Co-authored-by: syf2211 <syf2211@users.noreply.github.com>
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
Co-authored-by: JD Davis <jd@jds-macbook-air.tail2a279.ts.net>
2026-08-13 11:52:22 -05:00
Abhay Singh
82526191a1
fix(cli/install): resolve the deployment profile instead of dead-ending on default (#2832)
## Description

`headroom init` installs its persistent deployment under a non-`default`
profile name (`init-user` for a global-scope install), but every
`headroom install <lifecycle>` subcommand hardcodes `--profile default`.
The docs show those commands without `--profile`, so on a machine set up
by `headroom init` every documented lifecycle command fails while the
real deployment is running fine:

```console
$ headroom install status
Error: No deployment profile named 'default' is installed.

$ headroom install status --profile init-user
Status:  running
Healthy: yes
```

The error named neither the installed profile nor the `--profile` flag,
so there was nothing to lead the user to `init-user`, which exists only
as an internal constant.

When the requested profile is not installed, `_require_manifest` now
resolves the real target instead of dead-ending on a name the user never
chose:

1. an explicit `HEADROOM_DEPLOYMENT_PROFILE` (which the runtime already
exports) wins;
2. otherwise, when `--profile` was left at its `default` default and
exactly one deployment is installed, that one is used;
3. when it still cannot decide, the error lists the installed profiles
and points at `--profile`.

This changes only the not-found path. An installed `default` still loads
exactly as before, and an explicit typo'd `--profile` still fails, now
with a helpful list.

Fixes #2811

## 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` (`_require_manifest`): on a manifest miss,
resolve via `HEADROOM_DEPLOYMENT_PROFILE`, then a single installed
deployment when the request is the bare `default`, and otherwise raise
an error that lists installed profiles and points at `--profile`.
Imported `list_manifests` (already present in `headroom.install.state`)
for the enumeration.
- `tests/test_cli/test_install_cli.py`: added
`test_require_manifest_resolves_single_profile_when_default_missing`,
`test_require_manifest_honors_env_profile`, and
`test_require_manifest_lists_installed_profiles_when_ambiguous`.

## 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
# Before/after on the exact reported scenario (one installed profile "init-user"):
# ORIGINAL: _require_manifest("default")  -> raises "No deployment profile named 'default' is installed."
# FIXED:    _require_manifest("default")  -> resolves to "init-user"

# Pass-after, install suites:
tests/test_cli/test_install_cli.py         33 passed
tests/test_install/                        174 passed, 1 skipped, 1 pre-existing failure
# the 1 failure is tests/test_install/test_native_installers.py::
#   test_powershell_native_installer_supports_persistent_docker_lifecycle, which
#   runs scripts/install.ps1 and fails identically on clean main with these changes
#   stashed (an environment-specific PowerShell exit, unrelated to this diff).

# uvx ruff@0.15.17 check  -> All checks passed!
# uvx mypy@1.20.2 headroom/cli/install.py -> Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12.11, project venv, pytest 9.1.1,
ruff 0.15.17 and mypy 1.20.2 via uvx.
- Exact command / steps: read `detect`/`_require_manifest` and the
lifecycle command options (`--profile default` at
install.py:720/748/763/775/789/818/830) to confirm the mismatch with
`init.py`'s `_GLOBAL_PROFILE = "init-user"`, then demonstrated
before/after by monkeypatching `load_manifest`/`list_manifests`: on the
original code `_require_manifest("default")` raises "No deployment
profile named 'default' is installed."; with the fix it returns the
single installed manifest (`init-user`). Fail-before via `git stash push
headroom/cli/install.py` and a direct call; pass-after with `git stash
pop` and the install suites (33 passed in the CLI file, 174 passed in
test_install with one pre-existing environment failure).
- Observed result: a bare lifecycle command on an init'd machine now
targets the running deployment instead of failing, matching the
`--profile init-user` command the issue reporter confirmed works. An
explicit `HEADROOM_DEPLOYMENT_PROFILE` selects the target, and an
ambiguous multi-profile machine gets an error naming the installed
profiles and the `--profile` flag.
- Not tested: a full end-to-end `headroom init` then `headroom install
status` on a fresh host (that flow spawns a real deployment and
supervisor). The resolution logic is a pure function verified directly,
and the manifest loading it calls is existing, tested code.

## 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 did **not** edit `CHANGELOG.md`: it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Additional Notes

The resolution deliberately only triggers on the not-found path and only
auto-selects when a single deployment is installed or an explicit env
profile names one, so it never silently picks the wrong deployment on a
multi-profile host. The docs that show the bare commands
(`docs/content/docs/persistent-installs.mdx`,
`wiki/persistent-installs.md`, `wiki/cli.md`) become correct again
without needing a `--profile` on every line.
2026-08-13 11:51:34 -05:00
Parideboy
6147883d5e
fix(wrap): stop the Serena pre-index stalling the launch path for 300s (#2945)
## Description

`headroom wrap <agent>` could sit silently for a full 300 seconds before
the agent launched, and leaked one orphaned process every time it did.

`_setup_serena_mcp` runs `serena project index` synchronously on the
launch path, with `capture_output=True`, an inherited stdin and
`timeout=300`. When a project has no `.serena/project.yml`, Serena
auto-creates one — and that auto-creation asks one `[y/N]` question per
additionally-detected language server. Three things then combine:

1. stdin was inherited, so Serena believed it could prompt.
2. stdout was captured, so the question never reached the terminal.
3. the call was synchronous, so the agent waited out the entire timeout.

The user saw no prompt, no progress and no error — only a wrapper that
appeared to hang. The pre-index could never succeed in that state, so
the 300 seconds bought nothing.

On top of that, `subprocess.run` kills only its direct child on timeout.
`uvx` is a launcher that execs the real `serena` executable as a
grandchild, which was never signalled: it reparented to PID 1 and
survived indefinitely. Same class of bug as #615 and #880.

Closes #2938

## Type of Change

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

## Changes Made

- `_serena_project_skip_reason` (`headroom/cli/wrap.py`) now returns a
skip reason when `.serena/project.yml` is absent, so the pre-index does
not run in the one state where it cannot succeed.
- `_index_serena_project` passes `stdin=subprocess.DEVNULL`, so a
subprocess that decides to prompt gets EOF and exits in about a second
instead of blocking behind a captured pipe. This is deliberately kept as
a second line of defence even though the skip above already avoids the
known prompt.
- `_index_serena_project` now spawns via `subprocess.Popen` in its own
process group (`start_new_session=True` on POSIX,
`CREATE_NEW_PROCESS_GROUP` on Windows) instead of `run(...)`, so the
whole tree can be signalled.
- New `_kill_serena_index_tree` helper kills that tree on timeout —
`killpg(..., SIGKILL)` on POSIX, `taskkill /F /T /PID` on Windows — then
reaps the child and closes the capture pipes. Best-effort throughout; it
never raises.
- Corrected two comments that asserted the opposite of the observed
behaviour ("a failure or timeout here never blocks the wrap", "neither
blocks the wrap"). Both were accurate about intent and wrong about
effect.
- Added `_SERENA_INDEX_TIMEOUT` (still 300) and a line announcing the
pre-index, so a legitimately long index no longer looks like a hang.
- Tests in `tests/test_cli/test_wrap_serena_boost.py` rewritten for the
`Popen` path and extended to cover the DEVNULL stdin, the process-group
flag, the timeout tree-kill, the new skip reason, and the
`_setup_serena_mcp` wiring on both a fresh project and one that already
has `project.yml`.

### Behaviour change worth a reviewer's attention

**On a project with no `.serena/project.yml`, the pre-index no longer
runs at all.** That is the first wrap of any project, so this is the
common case.

I went this way rather than fixing the prompt because there is no way to
fix it from Headroom's side without re-introducing something the project
deliberately removed. Serena's `project index` command has no
non-interactive switch: `ProjectCommands._create_project` calls
`ProjectConfig.autogenerate(..., interactive=True)` with `interactive`
hardcoded. The only path that skips the prompt is passing
`--ls/--language` explicitly, which means Headroom guessing the
project's languages again — exactly the hand-maintained
extension-to-language map that was removed in #2674, with a comment in
this same function explaining why Serena should own that job.

The cost of skipping is small and self-correcting. Serena's MCP server
(`serena start-mcp-server --project-from-cwd`) generates `project.yml`
itself, non-interactively, on first start, and indexes lazily on demand
— which is the fallback the existing docstring already relied on. So the
first wrap now launches immediately with lazy indexing, and every wrap
after that pre-indexes for real. Previously the first wrap cost 300
seconds *and* still produced no index, so nothing of value is lost.

Happy to switch to passing `--ls` instead if maintainers would rather
keep the pre-index on the first wrap and accept a language map; the
other two changes stand either way.

## 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 pytest tests/test_cli/test_wrap_serena_boost.py tests/test_cli/test_serena_migrate.py -q
collected 29 items

tests\test_cli\test_wrap_serena_boost.py .............s...........       [ 86%]
tests\test_cli\test_serena_migrate.py ....                               [100%]

======================== 28 passed, 1 skipped in 0.54s ========================

$ python -m ruff check headroom/cli/wrap.py tests/test_cli/test_wrap_serena_boost.py
All checks passed!

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

$ python -m mypy headroom/cli/wrap.py --ignore-missing-imports --python-version 3.13 --follow-imports=silent
Success: no issues found in 1 source file
```

The single skip is `test_kill_tree_signals_the_group_on_posix`, which is
platform-gated; the Windows counterpart ran. I develop on Windows, so
the POSIX `killpg` branch is covered by unit test only — the end-to-end
tree-kill proof below is the Windows `taskkill` branch.

## Real Behavior Proof

- Environment: Windows 11 Pro 26200, Python 3.13.11, headroom checkout
at 941c25d3 plus this branch, uvx resolving `serena-agent` from PyPI,
throwaway project with bash + TypeScript + Python sources and no
`.serena/`
- Exact command / steps: spawned the real `uvx --from serena-agent
serena project index` against that project twice — once with stdin
readable and never closed (a parent-held pipe, the faithful stand-in for
the idle terminal the old code inherited; `communicate()` cannot be used
here because with `input=None` it closes the child's stdin immediately
and hands it the EOF the real bug never delivers), once with
`stdin=subprocess.DEVNULL`. Then spawned it twice more, stopping it with
a plain `proc.kill()` (the old timeout behaviour) versus the new
`_kill_serena_index_tree`, counting survivors with `Get-CimInstance
Win32_Process` filtered on the command line.
- Observed result: stall reproduced and fixed — `A/open-stdin: STILL
BLOCKED after 30.0s (timeout hit)` versus `B/DEVNULL: exit=1
elapsed=1.1s` with stderr tail `Project configuration auto-generation
failed after 0.000 seconds / Error: EOF when reading a line`. Leak
reproduced and fixed — after spawning a 5-process tree, `after
proc.kill(): still alive: [34468, 35044, 35808]` versus `after
_kill_serena_index_tree: still alive: []`. The 30-second bound in the
first experiment stands in for the shipped 300; the point is that the
child never returns on its own.
- Not tested: the POSIX `killpg`/`start_new_session` branch end-to-end
(no Linux or macOS host available, so it is unit-tested only — and it is
the branch the issue reporter observed failing); a full `headroom wrap
opencode` launch end-to-end, since this machine has no working local
proxy (`tests/test_cli/test_wrap_opencode.py` fails identically with and
without this branch for that reason); and the second-wrap pre-index
success path against a live Serena run, which is covered by unit test
instead.

## 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 did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Additional Notes

- The documentation checkbox is unchecked because no user-facing doc
describes the pre-index; the behaviour is explained in source
docstrings, which this PR rewrites.
- `_kill_serena_index_tree` is deliberately total: every step is wrapped
so cleaning up an already-dead child cannot turn a timeout into a crash
on the launch path. There is a test for that.
- #2754 (use a pre-installed Serena) would remove the `uvx` layer this
leak depends on, but not the prompt itself — the stdin guard here is
still needed after that lands.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: JD Davis <mxjerrett@gmail.com>
2026-08-13 11:46:30 -05:00
Tejas Chopra
e540d64feb
fix(wrap): serialize shared proxy startup (#2946)
## Description
Serialize concurrent `headroom wrap` startup so separate agents can
safely share one local proxy.

## Type of Change
- [x] Bug fix

## Changes Made
- Added a per-port cross-process startup lock.
- Re-checks proxy health/configuration after waiting for the lock.
- Preserves reference-counted cleanup and Copilot subscription
isolation.
- Preserves `_ensure_proxy`'s introspectable keyword signature on the
locking wrapper.

## Testing
- 88 wrap/persistent/detach tests pass locally.
- Focused lock-boundary tests pass.
- Signature inspection exposes `learn` and the existing keyword-only
options.
- Ruff, format, compile, and diff checks pass.
- Remaining CI failures are unrelated existing shard or
external-download failures.

## Real Behavior Proof
Two wraps that start during proxy cold start now serialize: the second
waits, observes the first healthy listener, and reuses it instead of
spawning a competing listener.

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

---------

Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
Co-authored-by: Jerrett Davis <mxjerrett@gmail.com>
2026-08-12 12:50:09 -07:00
Rod Boev
798139608c
fix(claude): stop forcing tool search on Foundry (#2477)
## Description

Foundry sessions launched through `headroom wrap claude` currently
receive Headroom's generic `ENABLE_TOOL_SEARCH=true` default when the
user did not choose a tool-search mode. That can push Claude Code into a
deferred-tool request shape that Azure Foundry rejects with `API Error:
400 ... Some tools are not available`. This narrows the default-only
path so Foundry sessions stop forcing deferred-tool mode when the user
did not ask for it, while explicit overrides and the existing
non-Foundry custom-host behavior stay unchanged.

Closes #2464

## 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 a Foundry-specific default for the no-override tool-search branch
- preserve explicit `--tool-search` values and pre-set
`ENABLE_TOOL_SEARCH` values exactly
- keep the generic non-Foundry default as `true`
- add focused helper-level regression coverage for Foundry defaulting
and adjacent negative space

## Testing

- [x] Focused unit tests pass (`uv run pytest
tests/test_cli/test_wrap_claude.py
tests/test_cli/test_wrap_claude_vertex_proxy_env.py
tests/test_issue_746_tool_search.py -q`)
- [x] Edited-file linting passes (`uv run ruff check
headroom/cli/wrap.py headroom/providers/claude/runtime.py
tests/test_cli/test_wrap_claude.py`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for new functionality when applicable
- [ ] Manual testing performed

### Test Output

```text
Command: uv run pytest tests/test_cli/test_wrap_claude.py tests/test_cli/test_wrap_claude_vertex_proxy_env.py tests/test_issue_746_tool_search.py -q
61 passed

Command: uv run ruff check headroom/cli/wrap.py headroom/providers/claude/runtime.py tests/test_cli/test_wrap_claude.py
All checks passed!
```

## Real Behavior Proof

- Environment: Windows, Python via `uv`, Foundry mode modeled through
the wrap helper inputs
- Exact command / steps: run the focused helper regression and
edited-file lint commands above
- Observed result: `61 passed`; `All checks passed!`; Foundry mode
without an override writes `ENABLE_TOOL_SEARCH=false`, while explicit
overrides, existing values, blank handling, and the non-Foundry default
remain covered
- Not tested: live Azure Foundry tenant 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] 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 Headroom generates release notes
from conventional commits. Live Foundry proof is intentionally left to a
real tenant run; the code and focused tests only claim the launch-mode
change inside Headroom.
2026-08-12 00:15:07 -05:00
Matt Van Horn
1db6d88ab4
fix(wrap): honor Copilot OAuth wire-api override and model default (#2387)
## Description

`headroom wrap copilot -- --model gpt-5.4` fails with `400 The requested
model is not available for integrator "copilot-language-server"`, even
though the same GitHub account uses GPT-5.4 fine in the native `copilot`
CLI. On the hosted Copilot OAuth path (authenticated via `headroom
copilot-auth`, no `--subscription`), `headroom/cli/wrap.py` forced the
`completions` wire API for every non-`--subscription` launch and ignored
a caller-supplied `COPILOT_PROVIDER_WIRE_API`. GPT-5.x / o-series
reasoning models need the `responses` wire API.

The OAuth path now honors a valid inherited `COPILOT_PROVIDER_WIRE_API`
(`completions` or `responses`) and otherwise uses the model-aware
default via `_copilot_default_wire_api_for_model(selected_model)`, so
GPT-5.x routes to `responses` while GPT-4.1 stays on `completions`. The
`--subscription` path is unchanged.

This supersedes the earlier closed #2243, which mixed the fix with
unrelated proxy/CI changes and hit merge conflicts. This PR ships only
the `headroom/cli/wrap.py` wire-api selection plus regression tests,
rebased clean on `main`.

Closes #2222

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

- Hosted Copilot OAuth launch resolves the wire API from an explicit
`COPILOT_PROVIDER_WIRE_API` env value when it is
`completions`/`responses`, else from
`_copilot_default_wire_api_for_model(selected_model)` instead of a
hardcoded `completions`. The `subscription`-only gating on the
model-aware default is removed so OAuth and subscription paths pick the
same model-correct wire API.

## Testing

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

### Test Output

```text
$ .venv/bin/python -m pytest tests/test_cli/test_wrap_copilot.py tests/test_provider_copilot_wrap.py -q
70 passed in 0.28s
```

New tests cover: the OAuth path honoring an inherited
`COPILOT_PROVIDER_WIRE_API`, the OAuth path resolving GPT-5.4 to
`responses`, and `default_wire_api_for_model("gpt-5.4")` returning
`responses`.

## Real Behavior Proof

- Environment: local checkout, Python 3.14, target tests only.
- Exact command / steps: `.venv/bin/python -m pytest
tests/test_cli/test_wrap_copilot.py tests/test_provider_copilot_wrap.py
-q`
- Observed result: 70 passed; the OAuth-path tests assert
`env["COPILOT_PROVIDER_WIRE_API"] == "responses"` for GPT-5.x and honor
an inherited override.
- Not tested: the end-to-end live Copilot `400` reproduction, which
needs a real Copilot OAuth session plus a GPT-5.x request. The wire-api
selection that caused the `400` is covered by the unit tests 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
- [ ] 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 did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Screenshots (if applicable)

N/A — CLI behavior change covered by the unit tests above.

## Additional Notes

The change is scoped to the wire-api selection line; the
`--subscription` path and the existing explicit `--wire-api` CLI flag
are untouched.

AI was used for assistance.

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-08-12 00:04:42 -05:00
Abhay Singh
c093bf11eb
fix(wrap/claude): keep --1m effective when an explicit --model is passed through
Ensure explicit Claude model arguments retain the 1M context suffix (#2915).
2026-08-11 18:15:40 -07:00
Abhay Singh
ae384862a4
fix(wrap/opencode): verify the opencode binary before mutating config
Verify the OpenCode executable before changing configuration.
2026-08-11 18:15:37 -07:00
Abhay Singh
d7b25ae3bb
fix(wrap/serena): install Serena from the serena-agent PyPI wheel, not the git source
## Description

`headroom/mcp_registry/install.py` (`build_serena_spec`) and the
wrap-time Serena pre-index in `headroom/cli/wrap.py` both ran:

```
uvx --from git+https://github.com/oraios/serena serena ...
```

The git source forces a from-source build. On proot-based filesystems
(Termux + proot-distro on Android, some restricted Linux) `uv` cannot
hardlink build dependencies into a fresh build venv, so the build fails
immediately and Serena's MCP server fails to start on every `headroom
wrap codex` launch:

```
× Failed to download and build `serena-agent @ git+https://github.com/oraios/serena@<commit>`
╰─▶ failed to hardlink file ... Operation not permitted (os error 1)
```

Setting `UV_LINK_MODE=copy` fixes it in an interactive shell, but Codex
strips most env vars from the MCP subprocesses it spawns, so that
workaround does not reliably reach Serena's launch.

Serena publishes the official `serena-agent` package to PyPI with
prebuilt wheels, and it exposes the same `serena` console script
(`serena = "serena.cli:top_level"` in the project's `pyproject.toml`),
so `uvx --from serena-agent serena ...` runs the identical command
without a build step. On platforms where the git build already worked
there is no functional difference.

Fixes #2871

## 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_serena_spec`): `--from
git+https://github.com/oraios/serena` -> `--from serena-agent`.
- `headroom/cli/wrap.py` (Serena `project index` pre-warm): same swap.
- `tests/test_mcp_registry/test_install.py`: updated the spec assertion
and added `test_build_serena_spec_uses_pypi_not_git_source` (asserts
`serena-agent` is used and no `git+` source remains).
- `tests/test_cli/test_wrap_serena_boost.py`: the pre-index test now
asserts `serena-agent` is in the command and the git source is not.

## 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
# Fail-before (source swap stashed, updated tests kept):
tests/test_mcp_registry/test_install.py::test_build_serena_spec_uses_agent_context FAILED
tests/test_mcp_registry/test_install.py::test_build_serena_spec_uses_pypi_not_git_source FAILED
tests/test_cli/test_wrap_serena_boost.py::test_preindex_runs_serena_in_cwd FAILED

# Pass-after:
tests/test_mcp_registry/ tests/test_cli/test_wrap_serena_boost.py
tests/test_cli/test_serena_migrate.py tests/test_cli/test_serena_disable.py   135 passed

# uvx ruff@0.15.17 check  -> All checks passed!
# uvx mypy@1.20.2 headroom/mcp_registry/install.py -> Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12.11, project venv, pytest 9.1.1,
ruff 0.15.17 and mypy 1.20.2 via uvx.
- Exact command / steps: confirmed `serena-agent` exists on PyPI
(v1.6.1, homepage github.com/oraios/serena) and that its
`pyproject.toml` declares `[project.scripts] serena =
"serena.cli:top_level"`, so the `serena start-mcp-server ...` invocation
is unchanged. Swapped both `--from` sources, then fail-before with `git
stash push headroom/mcp_registry/install.py headroom/cli/wrap.py` (the
two production-asserting tests fail on the old git source) and
pass-after with `git stash pop` (135 serena-suite tests pass). Verified
no `git+https://github.com/oraios/serena` references remain in
`headroom/`.
- Observed result: `build_serena_spec` and the pre-index command now
install Serena from the `serena-agent` PyPI wheel, so a proot
environment gets the prebuilt wheel instead of a from-source build that
cannot hardlink. The migration/ledger tests, which use the old git spec
as a deliberately-stale fixture, are unaffected.
- Not tested: a live `headroom wrap codex` on a real proot/Termux device
(not available here). The change is a package-source swap verified
against Serena's own published package metadata and the existing
spec/command 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
- [ ] 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 did **not** edit `CHANGELOG.md`: it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Additional Notes

The git source was unpinned (tracked the repo default branch), so
switching to `serena-agent` from PyPI does not lose a version pin; if
anything it is more reproducible. The issue reporter also noted that
`headroom wrap codex` force-rewrites the Serena block in
`~/.codex/config.toml` from this template on every launch, which is why
the fix has to live in the package source rather than a user config edit
-- this PR puts it there.
2026-08-11 09:10:32 -07:00
Abhay Singh
c49be269a1
fix(wrap): stop the launch cwd from shadowing the installed package in the proxy subprocess (#2843)
## Description

`headroom wrap` starts the proxy via `_start_proxy`, which builds `cmd =
[sys.executable, "-m", "headroom.cli", "proxy", ...]`. A `python -m
<module>` invocation prepends the launch cwd to `sys.path`. So when
`wrap` is run from a directory that contains a `headroom/` folder (most
commonly a clone of this very repo, whose package lives at
`<repo-root>/headroom/`), that raw source tree shadows the installed
wheel in site-packages. The source tree has no compiled `headroom._core`
(the maturin extension only exists in the built wheel), so the proxy
dies with:

```text
Error: Proxy dependencies not installed. Run: pip install headroom-ai[proxy]
Details: No module named 'headroom._core'
```

`wrap` then falls back to launching the client unwrapped, and the "not
installed" hint is misleading: the dependency is installed, it is being
shadowed by cwd.

The fix sets `PYTHONSAFEPATH=1` in the proxy subprocess env. That
disables the cwd/script-dir prepend to `sys.path` (Python 3.11+, and a
harmless no-op on 3.10, so it never breaks the supported floor), which
is exactly what the issue reporter confirmed resolves it:

```console
$ PYTHONSAFEPATH=1 python -c "import headroom._core; print('OK')"   # -> OK
```

The proxy is still launched as `-m headroom.cli`, so nothing about the
invocation changes except that it now always resolves the installed
package.

Fixes #2793

## 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` (`_start_proxy`): set
`proxy_env["PYTHONSAFEPATH"] = "1"` alongside the existing
`PYTHONIOENCODING`, with a comment explaining the cwd-shadow failure
mode.
- `tests/test_cli/test_wrap_claude_vertex_proxy_env.py`: added
`test_start_proxy_sets_pythonsafepath_to_avoid_cwd_shadow`, which drives
`_start_proxy` with a faked `subprocess.Popen` and asserts the
subprocess env carries `PYTHONSAFEPATH=1` while still launching `-m
headroom.cli proxy`.

## 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
# Fail-before (source fix stashed, new test kept):
tests/test_cli/test_wrap_claude_vertex_proxy_env.py::test_start_proxy_sets_pythonsafepath_to_avoid_cwd_shadow FAILED
  assert captured["kwargs"]["env"]["PYTHONSAFEPATH"] == "1"
  KeyError: 'PYTHONSAFEPATH'

# Pass-after (fix applied):
tests/test_cli/test_wrap_claude_vertex_proxy_env.py  18 passed

# Broader wrap suites:
tests/test_cli/test_wrap_claude_vertex_proxy_env.py tests/test_cli_proxy_env.py tests/test_cli/test_wrap_persistent.py
121 passed, 1 skipped

# uvx ruff@0.15.17 check  -> All checks passed!
# uvx mypy@1.20.2 headroom/cli/wrap.py -> Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12.11, project venv, pytest 9.1.1,
ruff 0.15.17 and mypy 1.20.2 via uvx.
- Exact command / steps: confirmed `_start_proxy` builds
`[sys.executable, "-m", "headroom.cli", "proxy", ...]` and constructs
the subprocess env as `proxy_env`, reproduced the shadowing behaviour in
the reporter's terms (`python -m` prepends cwd; a cwd `headroom/`
without `_core` shadows the wheel), fail-before with `git stash push
headroom/cli/wrap.py` and `python -m pytest ... -k pythonsafepath` (the
env lacks the key), then pass-after with `git stash pop` and rerunning
the file (18 passed) plus the broader wrap suites (121 passed, 1
skipped).
- Observed result: the proxy subprocess env now carries
`PYTHONSAFEPATH=1`, which disables the cwd prepend, so `import
headroom._core` resolves the installed wheel instead of a shadowing
local `headroom/` source tree. The proxy command is unchanged otherwise.
- Not tested: an end-to-end `cd <repo-checkout> && headroom wrap claude`
against a real installed wheel (this environment is a source checkout
without a separate installed wheel to shadow). The behaviour is verified
through the spawn env the subprocess inherits, and `PYTHONSAFEPATH` is
the documented, reporter-confirmed switch for this exact failure mode.

## 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 did **not** edit `CHANGELOG.md`: it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Additional Notes

Scoped to the proxy launch, which is the reported, high-impact path (its
failure makes `wrap` fall back to unwrapped). `wrap` spawns one other
`python -m headroom.*` subprocess (the memory-sync helper in the Claude
flow) that shares the same root cause; it is a lower-severity,
unreported path and is left for a follow-up rather than widening this
diff. The misleading "pip install headroom-ai[proxy]" message the
reporter also flagged is a separate error-text concern and is likewise
out of scope here.
2026-08-08 01:31:40 -05:00
JD Davis
13a310a00d
feat(claude): support Claude Code in VS Code (#2752)
## Description Add first-class Headroom support for the official Claude
Code extension in VS Code. The new wrapper starts the local proxy,
configures the Claude Code user settings consumed by the embedded
extension process, preserves authentication and model selection, and
provides a conflict-safe reversible unwrap lifecycle. 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) - [x] Documentation update - [ ] Performance improvement - [ ]
Code refactoring (no functional changes) ## Changes Made - Add `headroom
wrap vscode-claude` and `headroom unwrap vscode-claude`. - Configure
project-scoped `ANTHROPIC_BASE_URL` plus `ENABLE_TOOL_SEARCH=true` in
Claude Code user settings while preserving existing values. - Respect
`CLAUDE_CONFIG_DIR`, macOS/Linux home paths, Windows `USERPROFILE`,
custom `--settings-file`, and `--no-configure`. - Add durable
Headroom-owned restore state and refuse malformed settings or
conflicting user edits. - Add unit, CLI, and Docker-harness e2e coverage
for configuration, real proxy forwarding, and restoration. - Document
setup, remote development, undo, and troubleshooting. ## 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_NO_SYNC=1 uv run pytest -q
tests/test_provider_claude_vscode_config.py
tests/test_cli/test_wrap_vscode_claude.py
tests/test_cli/test_wrap_vscode.py
tests/test_cli/test_wrap_claude_base_url.py
tests/test_provider_copilot_vscode_config.py tests/test_copilot_auth.py
160 passed in 0.45s $ UV_NO_SYNC=1 uv run ruff check . All checks
passed! $ UV_NO_SYNC=1 uv run mypy headroom Success: no issues found in
512 source files $ npm run build # from docs/ Compiled successfully;
generated 155 static pages ``` ## Real Behavior Proof - Environment:
macOS, Python 3.13 editable install, isolated temporary HOME and Claude
settings, local mock Anthropic Messages upstream. - Exact command /
steps: invoked the new `verify_vscode_claude_wrap` e2e function, which
launched real `headroom wrap vscode-claude`, waited for proxy readiness,
POSTed an Anthropic `/v1/messages` request through the generated
project-scoped URL, stopped the wrapper, then ran `headroom unwrap
vscode-claude`. - Observed result: HTTP 200 with the mock Claude
response through Headroom; generated settings retained unrelated values
and enabled tool deferral; unwrap restored the original Claude settings.
- Not tested: real Anthropic account traffic or the full Docker image
locally because Docker Desktop was unavailable. The same e2e function is
wired into the existing Docker wrap CI job. ## 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 - [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 did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this) ## Screenshots (if applicable) Not applicable; this adds CLI
configuration and proxy routing without changing VS Code UI. ##
Additional Notes The wrapper deliberately leaves the endpoint configured
when stopped so requests fail closed instead of silently bypassing
Headroom. `headroom unwrap vscode-claude` restores the exact prior
managed values and preserves unrelated settings.

---------

Co-authored-by: JD Davis <jd@jds-macbook-air.tail2a279.ts.net>
2026-08-03 20:14:13 -07:00
JD Davis
007446c73a
feat(copilot): proxy VS Code models transparently (#2687)
## Description

Make Headroom a transparent proxy for VS Code GitHub Copilot. Users keep
using Copilot's normal model picker—GPT-4.1, Claude Sonnet, Claude Opus,
and other models in their entitlement—while Headroom silently forwards
the selected model instead of registering or requiring a separate
"Headroom" model.

This also fixes GitHub's device OAuth exchange by sending form-encoded
request bodies, matching the endpoint contract.

## Type of Change

- [x] 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 wrap vscode` to start a Copilot-seeded subscription
proxy and safely configure VS Code's shipped Copilot proxy override.
- Add `headroom unwrap vscode` for reversible cleanup.
- Preserve VS Code's selected model by changing only the proxy URL/auth
override; no custom model is registered and no model preference is
written.
- Support stable VS Code settings locations on macOS, Windows, and
Linux, plus `--settings-file` for Insiders, portable, and other
installations.
- Edit JSONC settings with a marker-owned block while preserving
unrelated bytes, comments, ordering, and trailing commas.
- Refuse malformed markers, invalid JSONC, or unmanaged existing Copilot
overrides instead of overwriting user configuration.
- Fix SIGINT cleanup so the managed settings block is removed and normal
shutdown exits successfully.
- Fix Copilot device OAuth start/poll requests to use
`application/x-www-form-urlencoded`.
- Add a compatibility matrix, setup/removal flow, credential behavior,
remote-development guidance, enterprise notes, troubleshooting, and
verification documentation.

## Testing

- [ ] Unit tests pass (`pytest`)
- [ ] 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/pytest -q tests/test_provider_copilot_vscode_config.py tests/test_cli/test_wrap_vscode.py tests/test_cli/test_wrap_helpers.py tests/test_copilot_auth.py tests/test_cli/test_copilot_auth.py tests/test_provider_copilot_wrap.py tests/test_cli/test_wrap_copilot.py tests/test_copilot_provider_label.py tests/test_copilot_subscription_smoke.py
244 passed in 0.59s

$ .venv/bin/ruff check <changed Python files and tests>
All checks passed!

$ .venv/bin/mypy headroom/providers/copilot/vscode.py
Success: no issues found in 1 source file

$ cd docs && npm run types:check
fumadocs-mdx && next typegen && tsc --noEmit
# exited 0

$ git diff --check
# exited 0
```

The full 10,179-test suite was also sampled through approximately 83%,
but was stopped because of its runtime. It exposed existing failures in
`test_recover_codex.py`, `test_wrap_stale_marker.py`, and
`test_proxy_health.py`; therefore the broad `pytest`, repository-wide
Ruff, and repository-wide mypy boxes are intentionally not checked.

## Real Behavior Proof

- Environment: macOS arm64, VS Code 1.131.0, built-in GitHub Copilot
0.59.0, Headroom 0.33.1-dev.
- Exact command / steps:
  1. Completed `headroom copilot login` with GitHub's device flow.
  2. Ran `.venv/bin/headroom wrap vscode --port 8788`.
3. Confirmed VS Code retained its ordinary Copilot model catalog and
made `GET /models` through Headroom with `GitHubCopilotChat/0.59.0` and
`editor-version: vscode/1.131.0`.
4. Sent native Copilot `/p/headroom/chat/completions` requests through
the same endpoint using `gpt-4.1`, `claude-sonnet-4.6`, and
`claude-opus-4.7`.
- Observed result:
  - All three completion requests returned HTTP 200.
- GPT-4.1 resolved upstream to `gpt-4.1-2025-04-14`; Sonnet and Opus
retained their exact selected IDs.
  - All returned the requested exact marker content.
- VS Code's settings contained only the Headroom proxy URL and token
auth override—no Headroom model or model-selection setting.
- The proxy health endpoint remained ready with `openai_api_url` set to
`https://api.githubcopilot.com`.
- Not tested:
- Physical Windows or Linux hosts (their path/config behavior is covered
by unit tests).
- WSL, dev containers, SSH remotes, VS Code Insiders, or enterprise
Copilot deployments end-to-end.
  - Every model in the live Copilot catalog.
- A fully submitted chat from VS Code's UI automation; the real
extension's catalog request and native completion paths were verified
separately.

## Review Readiness

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

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing targeted unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Screenshots (if applicable)

Not applicable; this integration intentionally has no separate UI or
model entry.

## Additional Notes

The integration uses VS Code Copilot's shipped advanced/debug proxy
endpoint seam. The managed settings block is deliberately narrow and
reversible. Remote extension hosts may need their own reachable
proxy/configuration as documented.

---------

Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com>
2026-08-03 04:42:48 -07:00
Raúl
9ce5af02b1
test(recover-codex): bind AF_UNIX socket via short relative path (#2396)
## Description

`test_recovery_records_sockets_and_secures_both_backups` binds a Unix
domain socket at its absolute path under pytest's `tmp_path`. On macOS
the `AF_UNIX` `sun_path` limit (~104 bytes) is shorter than that path,
so `bind()` raises `OSError: AF_UNIX path too long` and the test fails
locally. It stays green on CI Linux only because `/tmp`-rooted temp
paths there are short enough. Bind a short relative name from inside
`source` instead; the socket is still created at `source/codex.sock` and
the recovery scan behaves identically.

Closes #2394 

## Type of Change

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

## Changes Made

- `tests/test_cli/test_recover_codex.py`: in
`test_recovery_records_sockets_and_secures_both_backups`,
`monkeypatch.chdir` into `source` and `bind(socket_path.name)` (a short
relative name) instead of
`bind(str(socket_path))` (a long absolute path). Added the `monkeypatch`
fixture to the signature and a one-line comment explaining the
`sun_path` cap.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy`)
- [x] Manual testing performed

### Test Output

```text
# BEFORE (on this macOS box, at the branch base):
$ uv run pytest -q \
    "tests/test_cli/test_recover_codex.py::test_recovery_records_sockets_and_secures_both_backups" tests/test_cli/test_recover_codex.py:777: in test_recovery_records_sockets_and_secures_both_backups     codex_socket.bind(str(socket_path))
E   OSError: AF_UNIX path too long
1 failed in 0.70s

# AFTER (whole file, no regressions):
$ uv run pytest -q tests/test_cli/test_recover_codex.py
31 passed in 0.72s

$ uv run ruff format --check tests/test_cli/test_recover_codex.py
1 file already formatted
$ uv run ruff check tests/test_cli/test_recover_codex.py
All checks passed!
$ uv run mypy tests/test_cli/test_recover_codex.py
Success: no issues found in 1 source file
```

<img width="1073" height="200" alt="image"
src="https://github.com/user-attachments/assets/82155e76-3005-4c4f-93f4-4802ae5e7405"
/>

## Real Behavior Proof

- Environment: macOS 26.5 (darwin 25.5.0), Python 3.13.7, ruff 0.14.14,
`tempfile.gettempdir()` = `/var/folders/.../T` (48 chars, before the
`pytest-of-*/pytest-N/test_.../headroom-codex-home-broken/codex.sock`
suffix, which pushes the absolute `sun_path` over the macOS ~104-byte
cap).
- Exact command / steps: run the focused test at the branch base (fails
with `AF_UNIX path too long`), apply the one-line relative-bind change,
re-run the whole file.
- Observed result: before = 1 failed; after = 31 passed. `ruff`/`mypy`
clean.
- Not tested: Linux/Windows (the test is `skipif` on win32 / no
`AF_UNIX`; on Linux it already passed pre-change because temp paths are
short). No production code touched, so no proxy/runtime behavior was
re-validated.


## 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 (N/A:
test-only)
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works (this IS the corrected test; it fails before and passes
after)
- [x] New and existing unit tests pass locally with my changes
- [x] I did not edit `CHANGELOG.md`

## Additional Notes

- Pure test-portability fix; no production behavior change. `test:` type
keeps it out of the release-please changelog, which is correct for a
test-only change.
2026-08-02 13:14:31 -07:00
Tejas Chopra
e0ce4b1d48
fix: remove rtk and lean-ctx CLI context tools (#2677)
## Description

Removes both third-party CLI context tools — **rtk** and **lean-ctx** —
and with them the context-tool selector itself. Headroom no longer
downloads, installs or configures either one, and there is no
replacement.

The previous pass (#2344) gated only three entry points inside
`headroom/cli/wrap.py`. That left the feature reachable in practice:

| Gap | Effect |
|---|---|
| `scripts/install.sh:1544`, `install.ps1:1681` | Ran `rtk init --global
--auto-patch` from bash/PowerShell, **bypassing the Python gate
entirely** — `curl \| sh` still wrote a Claude Code `PreToolUse` hook
regardless of `HEADROOM_RTK` |
| `wrap.py` `_setup_context_tool_for_agent` | **`wrap openhands` was
broken by default**: `rtk_required=True` met a gate returning `None` →
`SystemExit(1)`. Invisible because all 8 openhands tests patched
`_ensure_rtk_binary` to a fake path |
| `proxy/helpers.py`, `subscription/tracker.py` | Proxy shelled out to
`rtk gain` from `/stats`, the dashboard and `headroom perf`; the tracker
polled it per contribution (`_RTK_WIRING_DEFAULT = "enabled"`) |
| No cleanup path | Nothing removed artifacts an earlier default had
installed, so a machine that once ran the old default kept rtk in the
loop forever (#1669, #1955) |

Also worth noting: the rtk binary download had **no SHA or signature
verification** — only `rtk --version` as a smoke test.

## Type of Change

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

## Changes Made

**Removed** — `headroom/rtk/` and `headroom/lean_ctx/` packages,
`headroom/cli/wrap_rtk_metrics.py`, `_selected_context_tool` /
`_setup_context_tool_for_agent` / `_VALID_CONTEXT_TOOLS`, the `--rtk` /
`--no-rtk` / `--no-project-rtk` / `--keep-rtk` flags across all 18 wrap
subcommands, `HEADROOM_RTK*`, the proxy-side `rtk gain` polling, the
dashboard CLI-filtering panel (rows + all 8 `cliFiltering*` Alpine
getters), `paths.rtk_path()` / `lean_ctx_path()`, the SDK path helpers,
`benchmarks/rtk_loop_learn_eval.py`, and the `headroom/rtk/**` CI path
filters.

**Fails loudly, not silently** — `--context-tool` / `--no-context-tool`
/ `HEADROOM_CONTEXT_TOOL` are kept solely to error out. They live in
shell profiles, aliases and CI jobs, and accepting them as a no-op would
read as Headroom having quietly stopped working. The installers reject
them too, which matters more than it looks: their arg parsers forward
the first unknown flag **and everything after it** to the wrapped tool,
so a leftover `--no-rtk` would have silently swallowed a following
`--port` and then been ignored downstream.

**New `headroom/context_tool_cleanup.py`** — deleting the code cannot
help a machine that already ran the old default, since the hooks,
binaries and injected guidance are durable on disk.
`purge_context_tool_artifacts()` runs once per `wrap`/`unwrap` and
removes the registered hook entries, the generated hook scripts, the
Headroom-managed `~/.local/bin` symlinks, the vendored
`~/.headroom/bin/{rtk,lean-ctx}` binaries, the `lean-ctx` MCP server
entry and the marker-fenced instruction blocks. Deliberately
conservative: idempotent, **skips** a malformed config rather than
overwriting it, and only unlinks a symlink resolving inside Headroom's
own bin dir so a user's own build is untouched. It reports on
**stderr**, because `wrap/unwrap openclaw --prepare-only` emit
machine-readable JSON on stdout as their entire contract. Skipped for
`wrap selfheal` (runs from a SessionStart hook; must not race Claude
Code's writer for `~/.claude.json`) and for `--help`, which must stay
read-only.

**Client-config hardening** (discovered while investigating a "corrupted
Serena settings file" report) — `wrap.py` reset a settings file to `{}`
when an existing file would not parse, then wrote that back. One
hand-edited typo or a transient `EACCES`/`EINTR` on a valid file
destroyed the user's `permissions`, `env` and `hooks`, on **every
`headroom wrap claude`**. It now refuses to write. Separately,
`fsutil.write_text` is now atomic (temp file + `fsync` + `os.replace`),
fixing all 14 non-atomic client-config writes at once; it follows
symlinks rather than replacing them (dotfile managers) and preserves an
existing file's mode.

**Deliberately kept** — `rtk` stays in the wrapper-peel list in
`transforms/content_router.py`. It sits beside `sudo`/`env`/`timeout` as
shell-command grammar, so `rtk cat f` is still classified as a file read
for anyone running their own rtk install, which the purge intentionally
leaves alone.

## 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/ tests/ e2e/ --exclude headroom/dashboard/templates
All checks passed!

$ ruff format --check headroom/ tests/ e2e/ --exclude headroom/dashboard/templates
1255 files already formatted

$ mypy headroom/
Success: no issues found in 508 source files

$ pytest tests/test_context_tool_cleanup.py -q
11 passed

$ pytest tests/test_fsutil.py -q
12 passed

$ pytest tests/test_cli/test_wrap_codex.py -q            # 89 tests
89 passed in 431.68s
$ pytest tests/test_cli/test_wrap_opencode.py -q
39 passed in 257.46s
$ pytest tests/test_cli/test_wrap_helpers.py -q
45 passed
$ pytest tests/test_paths.py -q
75 passed
$ pytest tests/test_cli/test_unwrap_claude.py -q
14 passed
$ pytest tests/test_proxy_savings_history.py -q
39 passed
$ pytest tests/test_cli/test_wrap_copilot.py -q
27 passed
$ pytest tests/test_cli/test_wrap_zcode.py -q
20 passed
$ pytest tests/test_subscription_tracker.py -q
9 passed
$ pytest tests/test_proxy_dashboard_stats_cache.py -q
5 passed, 1 skipped
```

Repo-wide grep for 14 removed symbols (`headroom.rtk`,
`headroom.lean_ctx`, `_ensure_rtk_binary`, `_selected_context_tool`,
`_get_context_tool_stats`, `rtk_path`, `lean_ctx_path`,
`wrap_rtk_metrics`, `HEADROOM_RTK`, `cli_tokens_avoided`,
`tokens_saved_rtk`, …) across `*.py`, `*.ts`, `*.sh`, `*.ps1`, `*.yml`,
`*.html`: **zero hits**.

Notable test changes: `test_wrap_openhands.py` no longer patches
`_ensure_rtk_binary` and asserts `wrap openhands --prepare-only` exits 0
unpatched — the regression that was previously masked.
`test_wrap_continue.py` and `test_wrap_hintfile_agents.py` were removed
(every test drove RTK instruction injection). A new
`test_subscription_tracker.py::test_load_state_written_before_cli_context_tools_were_removed`
proves a pre-removal `subscription_state.json` still loads.

## Real Behavior Proof

- **Environment:** macOS 15.4 (darwin 25.4.0), Python 3.12.6, Headroom @
this branch, real `~/.headroom` and `~/.claude` on the dev machine.
- **Exact command / steps and observed result:**

```text
# 1. Retired flag fails loudly instead of silently no-op'ing
$ headroom wrap codex --prepare-only --context-tool rtk
Error: CLI context tools (rtk, lean-ctx) have been removed from Headroom: they
rewrote shell commands through a third-party binary Headroom no longer manages.
Drop --context-tool / --no-context-tool and unset HEADROOM_CONTEXT_TOOL;
`headroom wrap` uninstalls what they left behind on first run.

$ HEADROOM_CONTEXT_TOOL=lean-ctx headroom wrap codex --prepare-only
Error: CLI context tools (rtk, lean-ctx) have been removed from Headroom: ...

# 2. install.sh rejects the retired flags (extracted parse_wrap_args harness)
['--no-rtk', '--port', '9999']   rc=1  ERROR: CLI context tools ... Drop --no-rtk
['--context-tool=rtk']           rc=1  ERROR: CLI context tools ... Drop --context-tool
$ bash -n scripts/install.sh   # syntax OK

# 3. Purge ran against the real machine, which had all the orphaned artifacts
$ python -c "from headroom.context_tool_cleanup import purge_context_tool_artifacts; ..."
  removed ~/.headroom/bin/lean-ctx        (51 MB)
  removed ~/.headroom/bin/rtk             (7.7 MB)
  removed ~/.local/bin/rtk                (symlink into ~/.headroom/bin)
  removed ~/.claude/hooks/rtk-rewrite.sh
  removed 8 lean-ctx-* hook scripts
# ~/.claude.json afterwards: 90 top-level keys, 19 projects, mcpServers unchanged
# → ~59 MB reclaimed, no unrelated key touched

# 4. stdout stays machine-readable while the purge reports (planted a fake artifact)
$ headroom wrap openclaw --prepare-only --gateway-provider-id codex >out 2>err
$ cat out
{"enabled":true,"config":{"proxyPort":8787,...}}     # parses as JSON
$ cat err
Retired CLI context tool cleanup: removed /Users/tcms/.headroom/bin/rtk

# 5. --help is inert (planted artifact survives), a real run purges
$ headroom wrap codex --help   → artifact survived: CORRECT
$ headroom wrap openclaw --prepare-only → purged: CORRECT

# 6. MCP purge dry-run against a copy of the real 82 KB ~/.claude.json
top-level keys 90 -> 90;  projects 19 -> 19;  LOST keys: none
all content outside mcpServers byte-identical: True
```

Dashboard rendered via the Playwright test after the panel removal:
"Token Savings" shows only `Proxy 0 (0.0%)` / `Of total wire: 36.86%`,
and "Token Usage" reads Before Compression → Proxy Removed → After
Compression with no "Filtered (this session)" row. Nothing below the
removed panel broke.

- **Not tested:** Windows and Linux (macOS only) — `install.ps1` is
verified by brace-balance and inspection, not executed, since no `pwsh`
is available locally. The wrap e2e suite (`e2e/wrap/run.py`) was updated
but not run; it needs the Docker e2e image. `serena project index`
interaction is exercised in the stacked base PR.

## 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 did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Additional Notes

**Stacked on #2676** (`tejas/serena-config-bootstrap`) — please merge
that first; this PR's base should then be retargeted to `main`, or it
will read as containing that fix too.

**Breaking-change migration for users:**
- Drop `--rtk`, `--no-rtk`, `--no-project-rtk`, `--keep-rtk`,
`--context-tool`, `--no-context-tool` from any alias, script or CI job,
and unset `HEADROOM_RTK*` / `HEADROOM_CONTEXT_TOOL`. They now error
rather than being ignored, so the failure is immediate and
self-explaining.
- Previously-installed artifacts are purged automatically on the next
`wrap`/`unwrap`; no manual cleanup needed.
- `headroom perf --json` no longer carries a `cli_filtering` key, and
`/stats` no longer returns a `context_tool` section.

**Docs:** `docs/rtk-architecture.md` deleted; RTK/lean-ctx removed from
`README.md`,
`docs/content/docs/{configuration,opencode,grok-build,docker-install,filesystem-contract}.mdx`,
`docs/observability.md` and the matching `wiki/` pages.
`REALIGNMENT/09-phase-G-rtk-observability.md` is marked SUPERSEDED
rather than deleted, to keep the planning record.

**Follow-ups not in scope:** `_emit_wrap_interrupted` was deleted as
dead code — its only caller was the `except KeyboardInterrupt` guarding
the binary download, so with no download there is nothing slow left to
interrupt.
2026-07-30 22:59:41 -07:00
Tejas Chopra
759209cff3
fix(wrap/serena): stop creating serena_config.yml, unbricking Serena on fresh installs (#2676)
## Description

`_ensure_serena_dashboard_disabled()` wrote a one-key bootstrap config
(`web_dashboard_open_on_launch: false`) into
`~/.serena/serena_config.yml` when the file was absent, assuming Serena
fills in any key it omits.

Verified against **Serena 1.6.2.dev0**
(`serena/config/serena_config.py`), that holds for every field except
one. Serena autogenerates its own complete config **only when the path
does not exist**:

```python
if not os.path.exists(config_file_path):
    cls._generate_config_file(config_file_path)
```

Once any file is present it validates instead. Every other field falls
back to a dataclass default via `get_value_or_default`, but a missing
`projects` key is fatal (~line 1064):

```
SerenaConfigError: `projects` key not found in Serena configuration.
```

So Headroom's own bootstrap file killed Serena on **every machine
without a pre-existing Serena config**. The MCP server exited during
handshake — surfacing as `connection closed: initialize response` on
Codex and a bare `MCP error -32000: Connection closed` on OpenCode
(#2674) — and `serena project index` failed identically.

Headroom now leaves that file to Serena. That is immune to Serena adding
required keys later; guessing the schema is what caused the outage. The
popup never needed the file anyway: `build_serena_spec` passes
`--open-web-dashboard False`, which Serena applies *after* loading the
config (`serena/mcp.py:361` — `config.web_dashboard_open_on_launch =
open_web_dashboard`), so the flag wins regardless of what is on disk.

An **existing** config is still edited in place — dashboard key flipped,
`projects: []` backfilled to repair machines an affected version already
wrote — preserving a populated `projects` list, other keys and comments.

Closes #2674

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

## Changes Made

- `_ensure_serena_dashboard_disabled()` never creates
`serena_config.yml`; it only edits an existing one, and backfills
`projects: []` there to repair already-broken machines.
- Dropped `_scope_serena_languages` + `_detect_repo_languages` +
`_EXT_TO_SERENA_LANGUAGE` (**−133 lines**). Dead weight: Serena
determines languages itself in `ProjectConfig.autogenerate`
(`_determine_project_language_servers`) and records them under
`language_servers` — `languages`, which Headroom wrote, is a legacy name
Serena migrates via `RENAMED_FIELDS`. Serena's generated file uses a
block-style list, so our single-line-flow regex never matched it: on any
Serena-generated `project.yml` the function was a **verified no-op**.
The only case where it acted was creating the file — the same
partial-config trap — which also skipped the `project.local.yml` sidecar
Serena writes alongside.
- **Test isolation:** the MCP install ledger defaults to
`~/.headroom/mcp_installs.json`, so any test registering a server wrote
into the developer's real ledger (observed adding a live `claude/serena`
entry during a local run). `conftest.py` now redirects it per-test.
- **Repo config:** `.serena/project.yml` carried a stale `project_name`
(`"feature-opencode-wrap"`) and listed only `typescript`, so Serena's
symbol index skipped 1331 Python and 194 Rust files for every
contributor.

## 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_wrap_code_memory.py tests/test_cli/test_wrap_serena_boost.py \
         tests/test_cli/test_serena_migrate.py tests/test_cli/test_serena_disable.py -q
35 passed, 1 skipped in 0.84s

$ SERENA_SRC=<serena checkout> pytest tests/test_wrap_code_memory.py -q
13 passed in 0.62s        # the skipped test runs when a Serena source tree is available

$ ruff check headroom/ tests/ --exclude headroom/dashboard/templates
All checks passed!

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

New tests. The key one asserts the invariant rather than our own key
list, so it stays correct even if Serena adds a required key — a test
pinning `projects: []` would keep passing while users broke again:

- `test_serena_config_is_never_created_by_headroom` — Headroom must not
pre-empt Serena's bootstrap
- `test_serena_dashboard_disabled_repairs_config_missing_projects` —
heals a config an affected version wrote
- `test_serena_dashboard_disabled_preserves_registered_projects` — never
clobbers the real registry; comments kept, no duplicate key
- `test_serena_dashboard_disabled_is_idempotent`
- `test_serena_config_required_keys_match_serena_source` — reads
Serena's real source and pins the two facts this fix rests on
(bootstrap-only-when-absent, `projects` is the sole fatal omission).
Skipped unless `SERENA_SRC` is set; deliberately **not** named
`HEADROOM_*` because `conftest.py` scrubs that namespace, which would
make it silently always-skip.

## Real Behavior Proof

- **Environment:** macOS 15.4 (darwin 25.4.0), Python 3.12.6, Serena
1.6.2.dev0 via `uvx --from git+https://github.com/oraios/serena`, Codex
CLI 0.146.0.
- **Exact command / steps:** a probe doing a real JSON-RPC `initialize`
handshake against the exact command `headroom wrap` registers — i.e.
what Codex/OpenCode actually do — in a throwaway `HOME` per case. (A)
pre-seeded with the one-line config an affected version wrote; (B) no
config; (C) real `headroom wrap codex --prepare-only`, then handshake.
- **Observed result:**

```text
=== A. BROKEN: single-key config (Headroom 0.33.0) ===
  MCP handshake: FAIL — no initialize response (exit=1). stderr tail:
    File ".../serena/config/serena_config.py", line 1064, in from_config_file
      raise SerenaConfigError("`projects` key not found in Serena configuration. ...")
    serena.config.serena_config.SerenaConfigError: `projects` key not found ...
  config after run: 1 lines, has 'projects': False

=== B. FIXED: no config, Serena bootstraps it ===
  MCP handshake: PASS — initialize OK — serverInfo.name='Serena'
  config after run: 213 lines, has 'projects': True

=== C. FULL FLOW: real `headroom wrap codex` then handshake ===
    Serena: no serena_config.yml yet — letting Serena generate it
    Serena MCP: registered (restart OpenAI Codex CLI if it was already running)
    Serena: project pre-indexed (symbol cache warmed)
    serena_config.yml: 213 lines, written by Serena (correct)
    MCP handshake: PASS — initialize OK — serverInfo.name='Serena'

--- verdict ---
  A (broken config)     started: False   <- expected False
  B (fixed, no config)  started: True   <- expected True
  C (after real wrap)   started: True   <- expected True
```

A second `wrap` in the same HOME flips the dashboard without damage:
`true` → `false`, `projects` intact, all 153 comment lines intact. The
writer was isolated against a pristine 213-line Serena config: **delta 0
newlines**.

- **Not tested:** Windows and Linux (macOS only); Serena versions other
than 1.6.2.dev0; the JetBrains language backend. The probe needs network
+ `uvx` (~2 min) so it is a manual verification tool, not wired into CI.

## 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 did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Additional Notes

- **Docs:** N/A — no user-facing docs described the `serena_config.yml`
bootstrap or the language scoping.
- Also fixes the OpenCode report (#2674). The Codex-side report of the
same root cause quotes the `SerenaConfigError` verbatim; OpenCode only
surfaces the generic `-32000`, which is why it read as two different
bugs.
- Users already broken by an affected version are repaired automatically
on their next `headroom wrap` — no manual `serena_config.yml` edit
needed.
- A stacked PR removing the rtk/lean-ctx CLI context tools is based on
this branch; this one is deliberately small so it can land first.
2026-07-30 20:55:47 -07:00
Eyal Mizrachi
0994ea04c8
fix(wrap): skip Serena project setup outside real project roots (#2574)
## Problem

`headroom wrap` runs two per-project Serena steps against the cwd:
`_scope_serena_languages()` (detect languages, pin them into
`.serena/project.yml`) and `_index_serena_project()` (`serena project
index`, to warm the symbol cache). Both assume the cwd *is* a project.

Launched from `$HOME` — an ordinary way to start an agent — that
assumption breaks badly:

- the language scan `os.walk`s the entire home directory: `Downloads/`,
VM images, backup trees, network mounts;
- the pre-index then runs `serena project index` over the same tree and
sits there until its full 300s timeout;
- so the agent appears to **hang for minutes on every launch**, with no
output after the Serena MCP registration line and nothing to suggest
indexing is what's blocking;
- and the scan writes `project.yml` into `~/.serena`, which is Serena's
own config directory rather than a project's `.serena/`.

A linked git worktree hits the same code from the other side: it's an
ephemeral checkout, so it pays for a full cold index at a path that soon
disappears — once per worktree, which adds up under any fan-out
workflow.

## Fix

Add `_serena_project_skip_reason(root)` and gate both steps on it:

- `root == $HOME` → `"$HOME is not a project"`
- top-level `.git` is a **file** rather than a directory → `"linked git
worktree"`
- otherwise `None`, and behavior is exactly as before

The reason is echoed under `--verbose`. Nothing else changes: Serena MCP
is still registered, instructions are still injected, and in the skipped
cases Serena still indexes lazily on demand — so no capability is lost,
only the wasted upfront scan.

## Testing

Five unit tests in `tests/test_cli/test_wrap_serena_boost.py` covering
an ordinary directory, a normal checkout (`.git` dir), `$HOME`, a linked
worktree (`.git` file), and a non-existent root. Full file: 22 passed.
`ruff format --check` and `ruff check` clean.

Verified manually on the reported case: `claude` launched from `$HOME`
now starts immediately instead of stalling on the index.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:31:00 -07:00
Rod Boev
e4076bbe99
fix(grok): preserve business-seat auth while routing only inference (#2514)
## Description

`headroom wrap grok` currently routes the whole session through
`GROK_CLI_CHAT_PROXY_BASE_URL`. xAI's July 21, 2026 enterprise docs say
that host carries both inference and settings, so the wrap displaces the
native settings/auth path along with inference. A Grok account whose
SuperGrok entitlement lives on a business account can then no longer
resolve that seat and falls back to a login screen, even though native
`grok` works for the same account.

This change retargets the Grok provider slice to the narrower
inference-only key, `GROK_MODELS_BASE_URL`, and leaves
`GROK_CLI_CHAT_PROXY_BASE_URL` unset. Headroom still intercepts
inference and model discovery through the existing `/v1/models` and
chat-completions proxy paths, while the native `cli-chat-proxy.grok.com`
settings host and `auth.x.ai` auth path stay intact. Closes #2489.

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

- switch the Grok provider env authority from
`GROK_CLI_CHAT_PROXY_BASE_URL` to `GROK_MODELS_BASE_URL`
- update the Grok wrap and unwrap docstrings to describe inference-only
routing and the preserved native settings/auth path
- update the compatibility matrix entry in `README.md` so the public
docs match the new Grok routing key
- add focused provider and wrap tests that assert the old chat-proxy key
is absent and the project-prefixed inference URL is preserved
- keep `grok_build` and the existing `/v1/models` proxy route unchanged,
using them as preservation boundaries

## Testing

- [x] Unit tests pass (`uv run pytest tests/test_provider_grok.py
tests/test_cli/test_wrap_grok.py tests/test_provider_grok_build.py -q`)
- [x] Linting passes (`uv run ruff check
headroom/providers/grok/runtime.py headroom/cli/wrap.py
tests/test_provider_grok.py tests/test_cli/test_wrap_grok.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_provider_grok.py tests/test_cli/test_wrap_grok.py tests/test_provider_grok_build.py -q
uv run ruff check headroom/providers/grok/runtime.py headroom/cli/wrap.py tests/test_provider_grok.py tests/test_cli/test_wrap_grok.py
uv run ruff format headroom/providers/grok/runtime.py headroom/cli/wrap.py tests/test_provider_grok.py tests/test_cli/test_wrap_grok.py --check
```

## Real Behavior Proof

- Environment: current Grok CLI plus a focused Headroom worktree
- Exact command / steps: capture `grok --version`, re-check xAI's
documented Grok env contract, run the focused Grok provider and wrap
tests, and if a business-seat account is available locally launch
`headroom wrap grok` to confirm the wrapped session no longer falls back
to login
- Observed result: Headroom emits only the inference-routing key, the
old settings/auth key is absent, project prefixing still works, and the
focused Grok tests pass
- Not tested: local business-seat account on this host

## Review Readiness

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

## Checklist

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

## Screenshots (if applicable)

N/A - CLI and provider-routing change only.

## Additional Notes

- `CHANGELOG.md` stays untouched because Headroom's release automation
generates it from conventional commits.
- The issue is reporter-only today, so the proof report records the
validated `grok --version` and whether a real business-seat retest was
reached locally or remains for the reporter.
2026-07-23 15:43:03 -07:00
Tejas Chopra
5d23a0aec2
refactor(wrap): retire tokensave; Serena is the code-memory MCP (#2499)
## Description

`tokensave` was a **downloaded third-party Rust binary**
(`aovestdipaperino/tokensave`) that `headroom wrap` registered as a
code-graph MCP server. This removes it entirely and standardises on
**Serena** as the code-memory MCP — which was already the default in
`wrap`. Serena runs on demand via `uvx`, so Headroom no longer downloads
or executes a binary of its own for code memory.

The change is a *removal + safe transition*, not a behaviour flip:
Serena was already the default, so existing users move over
automatically. This PR also folds in a small README repositioning
(Headroom = the proxy; Serena is the recommended companion; RTK/lean-ctx
are third-party tools we don't control), since it's the same
tooling-stack story.

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

## Changes Made

- **Removed the external tool:** `headroom/graph/tokensave_installer.py`
(the download-and-execute path), `build_tokensave_spec`, and the
`_ensure_tokensave_binary` / `_index_tokensave_project` /
`_setup_tokensave_mcp` helpers; dropped the dead `_setup_code_graph`.
- **`--code-memory`** now offers `serena` (default) or `none` — the
`tokensave` choice is gone. The strands `HeadroomBundle` uses Serena as
its (default-on) code-memory MCP.
- **Tombstone / transition (ledger-verified):** `headroom wrap` **and**
`headroom unwrap` remove a previously Headroom-installed `tokensave` MCP
entry so upgrading users stop launching it, and print that the leftover
`~/.local/bin/tokensave` binary and `.tokensave/` folders are safe to
delete. A user-managed `tokensave` entry is left untouched. Mirrors the
existing `codebase-memory-mcp` retirement.
- **Graceful for existing users:** `HEADROOM_CODE_MEMORY=tokensave` and
`--no-tokensave` resolve to Serena instead of erroring; `--no-serena`
now means "no code memory". **No state migration needed** — both tools'
indexes are regenerable caches of the source, so Serena simply
re-indexes.
- **Docs/README:** replaced the "tokensave binary trust model" section
with a Serena note + an "Upgrading from tokensave?" callout; retired the
RTK "first-class part of our stack" framing.
- **Tests:** deleted the tokensave-only test files
(`test_graph_tokensave.py`, `test_cli/test_tokensave_helpers.py`,
`test_cli/test_tokensave_setup.py`), rewrote `test_wrap_code_memory.py`
for the new resolver/dispatch, fixed a codex test that patched a removed
symbol.

Net: **+102 / −1187 lines.**

## Testing

- [x] Unit tests pass (`pytest`) — targeted to the affected areas
- [x] Linting passes (`ruff check`)
- [x] Type checking passes (`mypy`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ ruff check headroom/cli/wrap.py headroom/mcp_registry/ headroom/integrations/strands/ tests/test_wrap_code_memory.py tests/test_cli/conftest.py tests/test_cli/test_wrap_codex.py
All checks passed!

$ mypy headroom/cli/wrap.py headroom/mcp_registry headroom/integrations/strands/bundle.py
Success: no issues found in 12 source files

$ pytest tests/test_wrap_code_memory.py tests/test_cli/test_wrap_codex.py \
         tests/test_cli/test_wrap_claude_vertex_proxy_env.py \
         tests/test_cli/test_wrap_claude_finally_unbound.py -q
======================== 120 passed in 97.86s (0:01:37) ========================

$ pytest tests/test_cli --collect-only -q
========================= 713 tests collected in 1.82s =========================   # no import errors after symbol removal
```

## Real Behavior Proof

- **Environment:** macOS (darwin), Python 3.12.6, local `.venv`, on
branch `tejas/remove-tokensave`.
- **Exact command / steps:**
- `python -c "from headroom.integrations.strands.bundle import
HeadroomBundle; b=HeadroomBundle(enable_headroom_mcp=False,
enable_serena_mcp=False); print(len(b.tools))"` → confirms the module
imports after `build_tokensave_spec` removal (the import that my change
would otherwise break).
- CliRunner-driven `wrap codex --prepare-only` (in `test_wrap_codex.py`)
writes `[mcp_servers.serena]` (with `command = "uvx"`, `"--context",
"codex"`) to the codex config and **no** tokensave entry.
- `_resolve_code_memory` unit tests confirm: default → `serena`;
`HEADROOM_CODE_MEMORY=tokensave` → `serena`; `--no-serena` → `none`;
`--code-memory bogus` → `ClickException`.
- **Observed result:** import OK (`tools: 0`); Serena registered,
tokensave absent; resolver behaves as above; 120/120 tests pass.
- **Not tested:** a live `headroom wrap` against a real agent on a
machine with a *previously-installed* tokensave MCP entry — the
tombstone-removal path is covered by unit tests with a fake
registrar/ledger, not an end-to-end 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
- [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 did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title

## Additional Notes

- `--no-tokensave` / `--serena` / `--no-serena` are retained as hidden,
deprecated flags (no-ops or mapped) so existing scripts don't break.
- `--code-graph` is unchanged — it's the proxy's live file-watcher flag
and was never the tokensave MCP; only the dead tokensave hook behind it
was removed.
- `CHANGELOG.md` intentionally left untouched.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-22 20:59:24 -07:00
Rod Boev
2eca5ee114
fix(copilot): normalize subscription API routing (#2441) (#2455)
## Description

PR https://github.com/headroomlabs-ai/headroom/pull/2445 added the
missing OpenCode subscription path, but the shared Copilot subscription
resolver still lets Business and Enterprise payload hosts route through
segmented `*.githubcopilot.com` domains and still drops an explicit
`GITHUB_COPILOT_API_URL` pin on two resolution paths. This follow-up
moves the final hosted-route decision back into the shared resolver,
normalizes `api.business.githubcopilot.com` and
`api.enterprise.githubcopilot.com` to the generic host by default, and
makes the explicit pin win on token exchange, explicit API token, and
Copilot-token candidate resolution. Both `headroom wrap copilot
--subscription` and `headroom wrap opencode --copilot-subscription`
inherit the same fix because they already consume the same
`CopilotSubscriptionTokenResolution.api_url`. Refs #2441.

Attribution:
https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026212395
reported and narrowed the Business or Enterprise regression, and
https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026863498
scoped the shared-resolver follow-up that this change implements.

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

- Centralize subscription hosted-route selection so explicit
`GITHUB_COPILOT_API_URL` pins win on token exchange, explicit API token,
and Copilot-token candidate resolution.
- Normalize `api.business.githubcopilot.com` and
`api.enterprise.githubcopilot.com` to `https://api.githubcopilot.com` by
default, extending the existing individual-seat normalization.
- Extend focused auth and wrapper tests so both subscription wrappers
prove the corrected shared resolver output and the private-proxy
isolation contract stays intact.

## 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] Unit tests pass (`uv run pytest
tests/test_cli/test_wrap_opencode.py -q`)
- [x] Unit tests pass (`uv run pytest
tests/test_cli/test_wrap_persistent.py -q`)
- [x] Linting passes (`uv run ruff check .`)
- [x] Formatting check passes (`uv run ruff format . --check`)
- [x] New tests added for new functionality when applicable
- [ ] Manual testing performed

### Test Output

```text
uv run pytest tests/test_copilot_auth.py -q -> 84 passed
uv run pytest tests/test_cli/test_wrap_copilot.py -q -> 31 passed
uv run pytest tests/test_cli/test_wrap_opencode.py -q -> 44 passed in 143.46s
uv run pytest tests/test_cli/test_wrap_persistent.py -q -> 31 passed
uv run ruff check . -> All checks passed!
uv run ruff format . --check -> 1331 files already formatted
```

## Real Behavior Proof

- Environment: Windows
- Exact command / steps: Run the focused auth, Copilot wrapper, OpenCode
wrapper, and persistent-proxy pytest files after implementing the shared
resolver change, then ask lucasp1337 to rerun the Business or Enterprise
`--copilot-subscription` scenario from PR
https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026212395
on a real seat.
- Observed result: Focused auth and wrapper pytest runs passed locally,
including the enterprise-host exchange reproduction row, explicit-pin
precedence on all three producer paths, both subscription wrapper
routes, and the private-proxy isolation regression. Live Business or
Enterprise success stays behind reporter retest.
- Not tested: live Business or Enterprise tenant 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
- [ ] 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

No `CHANGELOG.md` edit is needed because Headroom generates release
notes from conventional commits.

Risk for maintainers: PR
https://github.com/headroomlabs-ai/headroom/pull/641 manually validated
a Business seat against the GitHub-returned hosted domain in June on
`gpt-5.4`, so generic-by-default could affect tenants that genuinely
require a dedicated host. This follow-up keeps the documented escape
hatch intact by making `GITHUB_COPILOT_API_URL` win on every path.

Live-seat proof boundary: lucasp1337 offered to retest on a Business or
Enterprise seat in PR
https://github.com/headroomlabs-ai/headroom/pull/2445#issuecomment-5026212395.
Keep any live success claim behind that rerun.
2026-07-20 17:15:57 -07:00
Rod Boev
9089e7f7d3
feat(opencode): support Copilot subscription backend for headroom models (#2441) (#2445)
## Description

`headroom wrap opencode` currently routes `headroom/*` models only to
ordinary Anthropic or OpenAI backends. A user with a GitHub Copilot
subscription cannot point those `headroom/*` requests at the Copilot
seat while keeping Headroom compression and stats, even though Headroom
already has the validated subscription resolver, the proxy seed path,
and the OpenCode provider route needed to do it.

This PR adds `--copilot-subscription` to the OpenCode wrap command. It
reuses the existing Copilot subscription token resolver, passes the
validated endpoint and token seed into the existing proxy startup path,
rejects unsupported runtime modes, and treats any non-empty Copilot API
token as a private session seed so token-only sessions do not reuse a
shared proxy. The generated OpenCode provider still targets the local
proxy, and subscription secrets stay out of OpenCode config,
environment, and terminal output.

Closes #2441

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

- Add `headroom wrap opencode --copilot-subscription` in
`headroom/cli/wrap.py`.
- Reuse the existing validated Copilot subscription resolver through one
small required-resolution helper shared with the dedicated Copilot
wrapper.
- Pass the resolved endpoint and token seed into `_ensure_proxy()` as
`openai_api_url`, `copilot_api_token`, `copilot_refresh_oauth_token`,
and `copilot_api_token_expires_at`.
- Reject `--copilot-subscription` with `--no-proxy`, `--prepare-only`,
and translated backends before proxy or OpenCode launch.
- Validate subscription mode before snapshotting OpenCode config, so
rejected invocations don't create stale backups.
- Scrub inherited Copilot proxy seed variables from the OpenCode child
environment.
- Treat any non-empty Copilot API token as a private session seed so
token-only sessions do not reuse shared or persistent proxies.
- Add focused OpenCode and persistent-proxy coverage for seed handoff,
guard failures, direct-token isolation, secret non-disclosure, and
unchanged non-subscription behavior.
- Leave `CHANGELOG.md` untouched because Headroom generates changelog
entries from conventional commits.

## Testing

- [x] Unit tests pass (`uv run pytest
tests/test_cli/test_wrap_opencode.py
tests/test_cli/test_wrap_persistent.py
tests/test_cli/test_wrap_copilot.py -q`, `106 passed in 136.65s`)
- [x] Linting passes (`uv run ruff check headroom/cli/wrap.py
tests/test_cli/test_wrap_opencode.py
tests/test_cli/test_wrap_persistent.py`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for new functionality when applicable
- [ ] Manual testing performed

### Test Output

```text
Targeted subscription tests pass: OpenCode `7 passed, 37 deselected in 0.36s`, persistent proxy `2 passed, 29 deselected in 0.34s`, and dedicated Copilot `11 passed, 20 deselected in 0.39s`. Coverage includes inherited resolver-input env scrubbing, HEADROOM_BACKEND rejection, no-backup-on-rejection, OpenCode-only scrub scoping, and private-proxy teardown on config-injection failure.
The full focused command `uv run pytest tests/test_cli/test_wrap_opencode.py tests/test_cli/test_wrap_persistent.py tests/test_cli/test_wrap_copilot.py -q` passed with `106 passed in 136.65s`. Ruff check and format check pass.
```

## Real Behavior Proof

- Environment: Windows, `uv` development environment, local CLI tests,
no live Copilot seat on this host
- Exact command / steps: run the focused OpenCode and persistent-proxy
tests with a mocked `CopilotSubscriptionTokenResolution`, then capture
the proof rows for seed handoff, direct-token isolation, guards, and
secret non-disclosure
- Observed result: Targeted subscription and proxy-seed tests pass,
including OpenCode-only resolver-input env scrubbing and private-proxy
teardown on config-injection failure; the full focused command passed
with `106 passed in 136.65s`; Ruff check and format check pass.
- Not tested: live Copilot subscription seat run on this host

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

## Additional Notes

- Feature approval comes from the open `enhancement` label on
https://github.com/headroomlabs-ai/headroom/issues/2441.
- Keep the final live-backend claim behind manual owner proof. Local CLI
tests can prove config, guard, secret, and proxy-seed behavior, but they
cannot prove a real Copilot seat on this host.
- `CHANGELOG.md` remains untouched because Headroom's release pipeline
generates changelog entries from conventional commits.
2026-07-20 11:02:14 -07:00
Abhay Singh
170b04a74d
fix(install): carry upstream-routing env overrides into supervised deployments (#2429)
## Description

Fixes #2240.

`headroom install apply` builds the persistent deployment's environment
from the `HEADROOM_*` family plus any explicit `--env KEY=VALUE`. It
never captured the provider upstream-routing overrides that the
interactive `headroom proxy` reads from the environment through
`resolve_api_overrides` (`ANTHROPIC_TARGET_API_URL` and its
`*_TARGET_API_URL` siblings).

A supervised runner (launchd, systemd, cron, Windows service/task)
starts from a bare environment, so those exports never reach the
persistent proxy. The result: a user who exports
`ANTHROPIC_TARGET_API_URL` pointing at their gateway and runs `install
apply` gets a proxy that silently forwards to the default Anthropic
endpoint instead. That is both a correctness bug and a routing surprise
(traffic and keys can go to the wrong host).

## Fix

Capture the documented `*_TARGET_API_URL` overrides from the current
environment and merge them into the manifest env underneath the explicit
`--env` map, so an explicit `--env` still wins.

Scope notes:

- Only URL overrides are auto-captured. The `*_TARGET_API_HEADERS`
variables can carry bearer tokens, so those are deliberately left to an
explicit `--env` rather than being persisted into the on-disk manifest
implicitly.
- The proxy already resolves these vars correctly at runtime; this only
makes `install apply` hand them to the supervised process the same way
the interactive proxy would inherit them.
- `headroom deploy` (the Docker path) is left unchanged here; this
targets the exact reported `install apply` flow.

## 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`: add `_PASSTHROUGH_URL_ENV_VARS` and
`_capture_passthrough_env`, and merge the captured overrides under the
parsed `--env` map in `install_apply` before building the manifest.
- `tests/test_cli/test_install_cli.py`: unit test for the capture helper
(skips empty/unrelated vars), plus CliRunner tests that a set
`ANTHROPIC_TARGET_API_URL` reaches `build_manifest`'s env and that an
explicit `--env` overrides the captured value.

## 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
$ python -m pytest tests/test_cli/test_install_cli.py -k "capture or captures or overrides" -q
3 passed
$ uvx ruff@0.15.17 check headroom/cli/install.py tests/test_cli/test_install_cli.py
All checks passed!
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/cli/install.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, project venv (`uv sync --extra
proxy`), `uvx ruff@0.15.17` / `uvx mypy@1.20.2`, pytest in the venv.
- Exact command / steps: ran the new CliRunner tests, which export
`ANTHROPIC_TARGET_API_URL` via monkeypatch, invoke `install apply` with
the supervisor side effects stubbed, and capture the kwargs handed to
`build_manifest`. Also called the real `_capture_passthrough_env` and
real `build_manifest` directly to confirm the value lands in
`manifest.base_env`.
- Observed result: with the var exported, `build_manifest` received it
in `extra_env` and `manifest.base_env["ANTHROPIC_TARGET_API_URL"]` held
the gateway URL; with an explicit `--env ANTHROPIC_TARGET_API_URL=...`
the explicit value won; empty and unrelated vars were skipped. Ran
against the actual modules.
- Not tested: a live launchd/systemd run forwarding to a real gateway.

## 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
2026-07-19 22:18:15 -07:00
Tejas Chopra
fd0e1a8afe
feat(wrap): boost Serena — symbol-first guidance, wrap-time pre-index, repo-language scoping (#2425)
When Serena is the active code-memory engine, `headroom wrap` now does
three things (all best-effort, timeout-guarded, non-fatal, and fully
inert when Serena/uvx are absent — mirroring the existing RTK/tokensave
patterns):

1. **Symbol-first guidance** — injects a marker-guarded, idempotent
block into the agent's hint file (`CLAUDE.md` for Claude; `AGENTS.md`
for Codex/Grok/OpenCode) steering it to prefer Serena's
`get_symbols_overview` / `find_symbol` / `find_referencing_symbols` /
`find_declaration` over whole-file reads. This is the highest-leverage
change — Serena only saves tokens if the agent actually uses it.
2. **Repo-language scoping** — detects the languages present in the repo
(extension scan, pruning `.git`/`node_modules`/`.venv`/etc.) and pins
them into `.serena/project.yml`'s `languages` list, so Serena doesn't
spin up superfluous language servers. Conservative: only rewrites a
single-line flow list or creates a minimal `project.yml`; a
custom/block-style entry is left untouched to avoid corrupting
hand-authored config.
3. **Wrap-time pre-index** — runs `serena project index` so the first
symbol query isn't cold.

Order is inject → scope → index (scope before index so the pre-index
respects the scope). No new env vars, no settings_store drift, no
behavior change outside the Serena path.

The `languages` key and extension→language mapping were verified from
Serena's local source (`project.template.yml`, `ProjectConfig`,
`solidlsp/ls_config.py`), not the web.

## Testing
New `tests/test_cli/test_wrap_serena_boost.py` (16 tests: injection
idempotency + content, language detection incl. ignore-dirs, mocked
pre-index/project.yml write incl. failure/timeout no-op). Updated
`test_serena_migrate.py`'s fixture to neutralize the new side-effecting
calls. Offline: 46 passed; ruff 0.15.17 + mypy clean.
2026-07-19 14:44:37 -07:00
Tejas Chopra
6e4425a6bd
feat(wrap): default code-memory to Serena (dashboard browser off) behind unified --code-memory (#2413)
## What
Two commits:
1. **Unify code-memory MCP selection behind `--code-memory
{tokensave|serena|none}`** (+ `HEADROOM_CODE_MEMORY`), collapsing the
`--serena`/`--no-serena`/`--no-tokensave` flag tangle into one selector.
Old flags remain as hidden deprecated aliases that map into it. Shared
across the code-memory-capable subcommands (claude/codex/grok).
2. **Default the engine to Serena**, with its **dashboard browser
suppressed**.

## Why Serena as default
Serena is a mature, offline, symbol-level code-navigation MCP with broad
language coverage (LSP-backed) — the strongest zero-account default for
reducing tokens by letting the agent query
symbols/definitions/references instead of reading whole files. It
attacks the *protected-reads* volume the proxy deliberately doesn't
compress, so it's complementary to the pipeline compressors.

## Browser suppression (in Serena's own settings)
`_ensure_serena_dashboard_disabled()` sets
`web_dashboard_open_on_launch: false` in `~/.serena/serena_config.yml`
when Serena is set up, so wrapped sessions don't spawn a browser tab.
The dashboard backend stays reachable manually at `localhost:24282`.
This lives in Serena's config (authoritative), not just a startup flag.

## Schema-overhead note
Serena injects tool schemas per request; that cost is deferred by the
tool-search deferral the coding profile already enables
(`HEADROOM_TOOL_SEARCH=1`), so tools load on demand — the navigation
benefit without a standing schema tax on turns that don't navigate.

## Selection / escape hatches
`--code-memory serena` (default) · `tokensave` (lighter/faster) · `none`
(disable). Deprecated `--serena`/`--no-serena`/`--no-tokensave` still
work.

## Testing
Updated the primary/backup policy test to the serena-primary default;
code-memory selector + serena disable/migrate tests pass. Local: 21
passed (policy + code-memory); ruff + mypy clean. Full suite in CI.
2026-07-18 22:39:20 -07:00
Zhenjia ZHOU
f57e959a50
fix(wrap): emit bare dotted keys for Codex --config overrides (#2383)
## Description

`headroom wrap codex` with a custom provider emits `--config` overrides
whose dotted key quotes **every** segment
(`"model_providers"."litellm_prod"."base_url"=…`). Codex's override
parser matches dotted segments literally and silently ignores quoted
ones, so the overrides are dropped, the session keeps the provider's
real `base_url`, and traffic **bypasses Headroom entirely** — the exact
silent-bypass reported in #2358 on Codex 0.144.5.

I reproduced the parser behavior differentially on a local Codex
**0.144.1** (see Real Behavior Proof): a bare key is parsed (Codex
errors on the injected value), the same key quoted produces no error at
all — the override is silently discarded.

Fix: `_codex_dotted_key()` now emits segments **bare** whenever they are
valid bare keys (`[A-Za-z0-9_-]+`, which covers `model_providers`, every
normal provider id, and hyphenated header names like
`X-Headroom-Base-Url`), and quotes only segments where bare emission
would corrupt the dotted path (e.g. a provider id containing a dot).

Fixes #2358.

## Type of Change

- [x] Bug fix (silent proxy bypass for custom-provider Codex wraps)

## Changes Made

- `headroom/cli/wrap.py`: `_codex_dotted_key()` quotes only non-bare-key
segments; docstring explains the observed Codex parser behavior. The
default `openai` path (`openai_base_url=…`, already bare) is unchanged.
- `tests/test_cli/test_wrap_codex.py`: the custom-provider launch test
now pins the bare form for all three overrides (`base_url`,
`supports_websockets`, `env_http_headers.X-Headroom-Base-Url`), plus 2
direct unit tests (bare-when-safe incl. hyphens, quote-only-unsafe).

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff` + `mypy`, CI-pinned settings)
- [x] Reproduced the parser behavior on a real Codex CLI first, then
fixed

### Test Output

```text
$ .venv/bin/python -m pytest tests/test_cli/test_wrap_codex.py -q
93 passed

# Before the fix the updated assertions fail (generated args still fully quoted):
#   FAILED ...::test_codex_session_launch_settings_preserve_custom_provider_identity
#   FAILED ...::test_codex_dotted_key_emits_bare_segments_when_safe
#   FAILED ...::test_codex_dotted_key_quotes_only_unsafe_segments

$ ruff check headroom/cli/wrap.py tests/test_cli/test_wrap_codex.py   # All checks passed!
$ ruff format --check <both>                                          # formatted
$ mypy headroom/cli/wrap.py --ignore-missing-imports                  # Success: no issues
```

## Real Behavior Proof

- Environment: macOS (Darwin), Codex CLI **0.144.1**
(`/opt/homebrew/bin/codex`), empty temp `CODEX_HOME` (no auth, no
network side effects), branch `fix/codex-config-bare-keys` off `main`
(`56c7d4a5`).
- Exact command / steps: differential probe of the override parser —
`codex exec --skip-git-repo-check -c 'profile="__nope__"' 'hi'` (bare
key) vs `codex exec --skip-git-repo-check -c '"profile"="__nope__"'
'hi'` (quoted key), each run once against a fresh empty `CODEX_HOME`.
- Observed result: bare key → Codex **parsed the override** and failed
fast on it (`Error: legacy profile = "__nope__" config is no longer
supported…`); quoted key → **no error referencing the override at all**,
Codex proceeded to start a session (banner printed) — the quoted
override was silently discarded. That is precisely the #2358 bypass
mechanism: every generated custom-provider override was quoted, hence
dropped, hence traffic went straight to the real upstream.
- Not tested: an end-to-end wrapped session against a live LiteLLM
upstream on Codex 0.144.5 (the reporter's exact version); the parser
behavior above is version-adjacent (0.144.1) and the argv shape is
pinned by 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
- [ ] I have made corresponding changes to the documentation — N/A
(docstring added)
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md — N/A

## Additional Notes

- Segments that genuinely need quoting (a provider id with a dot) keep
their quotes: on parsers that ignore quoted segments those overrides
still won't apply, but bare emission would corrupt a *different* key
path, which is strictly worse. Such ids are rare; the common
LiteLLM/custom-provider case is fully bare after this fix.
2026-07-18 09:51:39 -07:00
Tejas Chopra
44136ed042
fix(wrap): make RTK opt-in (off by default) across wrap subcommands (#2344)
## Description

RTK CLI-command filtering was set up **by default** across ~16 `wrap`
subcommands (copilot, codex, aider, cursor, cline, continue, goose,
openhands, opencode, grok, omp, openclaude, vibe, …) via `if not
no_rtk:` — so users got rtk hooks / instruction injection without opting
in. `wrap claude` was the lone exception (already gated on
`--context-tool`).

This makes RTK **opt-in (off by default)** everywhere, so Headroom's own
savings are what's measured unless a user explicitly wants rtk.

Closes #

## Type of Change
- [x] Bug fix (behavior change: default flip)

## Changes Made
- **Central gate** `_rtk_opt_in()` — RTK runs only when explicitly
enabled via `--rtk` or `HEADROOM_RTK=1`. Guards the 3 RTK entry points
(`_setup_rtk`, `_ensure_rtk_binary`, `_inject_rtk_instructions`) so they
no-op by default — one small change instead of editing ~30 call sites.
- **`--rtk` opt-in flag** on all 18 tool subcommands via a shared
eager-callback option (`expose_value=False`, sets `HEADROOM_RTK=1`; no
subcommand signature changes).
- `wrap claude`'s legacy `--context-tool` still opts in (mirrored into
the gate).
- **`--no-rtk` kept** as an accepted, now-redundant no-op (back-compat).
- lean-ctx and all non-RTK behavior untouched.

## Testing
```text
pytest tests/test_wrap_rtk_opt_in.py   -> 4 passed
ruff check / format                    -> clean
mypy headroom                          -> Success: no issues found in 504 source files
```
Verified `--rtk` appears in `wrap {claude,codex,copilot} --help`;
`_rtk_opt_in()` is False by default, True with `HEADROOM_RTK=1`; entry
points no-op + write nothing when off.

## Real Behavior Proof
- Env: local `.venv`, click CliRunner.
- Steps: import wrap; assert gate default-off / env-on; assert
`_setup_rtk`/`_ensure_rtk_binary` return None and
`_inject_rtk_instructions` returns False + writes no file when not opted
in; assert `--rtk` in subcommand help.
- Observed: all pass. Not tested: a live end-to-end wrap launch (proxy
spawn).

## Notes
Part 2 of 3 (RTK opt-in). Separate PRs cover the code-graph MCP engine
and the proxy-option cleanup. No `CHANGELOG.md` edit — release-please
generates it from the PR title (per the changelog guard).

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-17 16:47:19 -07:00
Rod Boev
cf5fa644b6
fix(wrap): stop same-port persistent routing during claude unwrap (#2340) (#2350)
## Description

`headroom unwrap claude` currently removes Claude-local wrap state but
can still leave Claude effectively routed through Headroom when the same
port belongs to a managed persistent deployment. The command already
knows how to discover same-port persistent manifests, but its stop path
only kills the current pid and never uses that deployment metadata. This
patch keeps the existing local settings cleanup, then applies an
ownership-aware same-port audit: Claude-owned deployments are stopped
through the install lifecycle path, while ambiguous same-port residue is
surfaced with exact remediation instead of a false clean-success claim.
Refs #2340.

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

- extend the Claude unwrap stop path in `headroom/cli/wrap.py` to
distinguish local pid stops, Claude-owned same-port persistent
deployments, and ambiguous same-port residue
- reuse the install lifecycle teardown path for Claude-owned persistent
deployments instead of re-implementing supervisor cleanup
- keep the existing Claude-local settings, hook, and base-url cleanup
unchanged
- add focused CLI regressions that prove a matching Claude-owned
deployment is stopped during unwrap, ambiguous same-port residue is
reported truthfully, and different-port manifests stay untouched

## Testing

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

### Test Output

```text
uv run pytest tests/test_cli/test_unwrap_claude.py tests/test_cli/test_wrap_persistent.py -q
============================= 43 passed in 0.51s ==============================

uv run pytest tests/test_cli/test_unwrap_claude.py -q
============================= 13 passed in 0.41s ==============================

uv run pytest tests/test_cli/test_wrap_persistent.py -q
============================= 30 passed in 0.39s ==============================

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

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

## Real Behavior Proof

- Environment: Windows worktree
`D:\Repos\headroom-pr-2340-claude-unwrap-effective-routing` with `uv
sync --extra dev`
- Exact command / steps: run the focused pytest and Ruff commands above,
then run a constructed `CliRunner` replay against both
`D:\Repos\headroom` and this branch with the same same-port Claude-owned
manifest harness
- Observed result: base prints `base: exit=0; local=[8787];
deactivated=[]; stopped=[]` and still routes through the pid-only
helper; head prints `head: exit=0; local=[];
deactivated=['unwrap-2340']; stopped=['unwrap-2340']` and reports
`Stopped Claude-owned persistent deployment 'unwrap-2340' on port 8787.`
- Not tested: a live macOS launchd deployment on this host

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

`CHANGELOG.md` is not applicable because Headroom derives release notes
from conventional commits.

Scope stays below the broader uninstall workflow in open PR `#749`: this
patch makes Claude unwrap truthful and ownership-aware, but it does not
remove install artifacts or introduce a new uninstall command.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-17 16:08:40 -07:00
Tanmay Garg
4e2bbfee3f
fix(opencode): Use opencode.jsonc when present (#1590)
## Description

Fix OpenCode proxy injection so it respects user configurations that use
the `.jsonc` extension, preventing Headroom from creating a duplicate
`.json` file that overrides it.

Closes #1588

## 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 `opencode_config_path` in `paths.py` to check for `.jsonc`
- Updated backup creation in `config.py` to preserve the original
extension

## 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
- [x] Manual testing performed

### Test Output

```text
N/A
```

## Real Behavior Proof

- Environment: local headroom dev
- Exact command / steps: creating a dummy
`.config/opencode/opencode.jsonc` and running `headroom wrap opencode`.
- Observed result: Headroom successfully injects into `.jsonc` and
creates a backup named `opencode.jsonc.headroom-backup`.
- Not tested: N/A

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

## Additional Notes
2026-07-16 13:51:21 -07:00
kaz
eac49656a1
feat(wrap): add headroom wrap kimi for Kimi CLI (#1426)
## Description

Adds `headroom wrap kimi`, routing Kimi CLI through the Headroom proxy.

Kimi CLI speaks an OpenAI-compatible `/chat/completions` API (its
`kosong` backend wraps `AsyncOpenAI`) and lets the base URL be
overridden via `KIMI_BASE_URL`. This wrapper points it at the local
proxy. Kimi's own OAuth bearer is forwarded upstream unchanged, so —
unlike the Copilot subscription path — no extra login or token exchange
is needed.

## Type of Change

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

## Changes Made

- `headroom/providers/kimi/`: new slice; `build_launch_env` sets
`KIMI_BASE_URL` with the per-project base-URL prefix, mirroring the
aider/vibe slices.
- `headroom/cli/wrap.py`: `kimi` subcommand; falls back to the
`kimi-cli` binary when `kimi` is not on `PATH`; `--kimi-api-url`
overrides the upstream coding endpoint (default
`https://api.kimi.com/coding/v1`).
- `tests/test_cli/test_wrap_kimi.py`: 8 tests for the wrap command.
- `README.md`: Kimi CLI row in the agent-compatibility matrix.

## 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
$ pytest tests/test_cli/test_wrap_kimi.py -q
........                                                                 [100%]
8 passed in 0.36s

$ ruff check headroom/providers/kimi headroom/cli/wrap.py tests/test_cli/test_wrap_kimi.py
All checks passed!

$ ruff format --check headroom/providers/kimi headroom/cli/wrap.py tests/test_cli/test_wrap_kimi.py
4 files already formatted
```

## Real Behavior Proof

- Environment: macOS; Kimi CLI (`kimi` / `kimi-cli`); `headroom proxy`
started with `--openai-api-url https://api.kimi.com/coding/v1`.
- Exact command / steps: start `headroom proxy --port 8787
--openai-api-url https://api.kimi.com/coding/v1`, then `curl -s
http://localhost:8787/v1/chat/completions` with the Kimi OAuth bearer
and a one-line `kimi-for-coding` chat request (`"Reply with exactly:
PONG"`).
- Observed result: `HTTP 200`; `choices[0].message.content == "PONG"`
from `kimi-for-coding`; the OAuth bearer was forwarded and accepted
upstream; the per-project path `/p/<name>/v1/chat/completions` also
returned `HTTP 200`.
- Not tested: Windows/Linux PATH discovery; the `--learn` / `--memory`
live paths beyond flag wiring.

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

## Additional Notes

- `ruff check` and `ruff format --check` pass locally; `mypy` was run on
the new `headroom/providers/kimi` slice only (clean), so the full-tree
`mypy headroom` box is left unchecked and is left to CI.
- The slice deliberately reuses `codex.proxy_base_url` and
`with_project_prefix`, identical to the aider/vibe wrappers, so
per-project savings attribution works without Kimi sending custom
headers.
- Kimi's separate search/fetch services are out of scope for
`KIMI_BASE_URL` and continue to hit Kimi directly; only the LLM
`/chat/completions` traffic is compressed.

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 21:40:58 +00:00
Aashish Tamsya
420dc9077b
feat(grok-build): add Grok Build wrap command and MCP integration (#1629)
## Description

Adds first-class Grok Build support to Headroom so Grok CLI sessions can
route through the local proxy for context compression and savings
tracking.

This PR introduces `headroom wrap grok-build` / `headroom unwrap
grok-build`, a `grok_build` provider slice, Grok MCP registrar support,
and install/telemetry wiring so Grok traffic is attributed correctly in
the proxy and dashboard.

Review follow-up (`9368c413`): when users already own
`[model.grok-build]` in `~/.grok/config.toml`, wrap rewrites `base_url`
in that table in place instead of appending a duplicate header (invalid
TOML).

## Type of Change

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

## Changes Made

- Added `headroom/providers/grok_build/` with runtime helpers,
reversible `~/.grok/config.toml` injection, and install env builders.
- Added `headroom wrap grok-build` and `headroom unwrap grok-build` CLI
commands.
- Added `GrokRegistrar` for Headroom MCP registration in Grok config.
- Wired `grok_build` into install planner/registry, agent savings,
telemetry, and proxy client detection (`grok/` user agent).
- **Review fix:** rewrite `base_url` inside an existing user-owned
`[model.grok-build]` table in place (`# was: …` metadata).
- Added regression tests + docs (`grok-build.mdx`, `proxy.mdx`) and
CHANGELOG entry.

## Testing

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

### Test Output

```text
$ pytest -q tests/test_provider_grok_build.py tests/test_mcp_registry/test_grok_registrar.py
============================== 12 passed in 1.13s ==============================
```

See **Screenshots** below for terminal captures (pytest, review-fix
in-place rewrite, proxy `/readyz`, unwrap).

## Real Behavior Proof

- Environment: macOS, Python 3.11.12 venv, feat/grok-build @ `9368c413`,
isolated `GROK_HOME` temp dirs, proxy port 8799
- Exact command / steps: see screenshot evidence (wrap/unwrap, in-place
table rewrite, `/readyz`)
- Observed result: see screenshots — 12 tests pass; single
`[model.grok-build]` table after wrap on pre-existing config; proxy
healthy; unwrap restores backup
- Not tested: Live interactive Grok chat with xAI auth through the proxy

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

Terminal captures from local verification (`9368c413`). Assets hosted on
fork prerelease only — **not** in the source tree.

**1. Pytest — 12 passed (incl. review-fix regression)**

![pytest 12
passed](https://github.com/aashishtamsya/headroom/releases/download/pr-1629-evidence/01-pytest.png)

**2. Review fix — in-place `[model.grok-build]` rewrite (single table,
`# was:` metadata)**

![review fix in-place
rewrite](https://github.com/aashishtamsya/headroom/releases/download/pr-1629-evidence/02-review-fix-in-place.png)

**3. Proxy health — `/readyz` healthy on port 8799**

![proxy readyz
healthy](https://github.com/aashishtamsya/headroom/releases/download/pr-1629-evidence/03-proxy-health.png)

**4. Unwrap — restores pre-wrap backup**

![unwrap restores
backup](https://github.com/aashishtamsya/headroom/releases/download/pr-1629-evidence/04-unwrap.png)

## Additional Notes

Screenshot assets:
https://github.com/aashishtamsya/headroom/releases/tag/pr-1629-evidence
(temporary prerelease; safe to delete after merge).

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 20:51:52 +00:00
Rudimar Ronsoni
dec60de976
fix(codex): preserve wrapped sessions and recover state (#2160)
## Description

Closes #2159.

Codex wrappers currently launch against a disposable `CODEX_HOME`, so
session state created during a wrapped run can disappear when that
temporary directory is removed. This change launches Codex against its
durable home, keeps proxy routing process-local, and adds recovery for
retained temporary homes and pinned recovery sources.

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

- Launch Codex against its durable `CODEX_HOME` and apply routing
through process-local config overrides after the actual proxy port is
resolved.
- Preserve custom provider identity and reject providers that cannot be
redirected safely.
- Detect dangling temporary Codex homes before interactive wraps and
offer recovery.
- Add `headroom recover codex` with automatic discovery, repeatable
`--source`, preview, confirmation, retained backups, and rollback on
failure.
- Search Python's temp root, `$TMPDIR`, `/tmp`, `/private/tmp`, and
macOS `/private/var/folders/*/*/T` for retained `headroom-codex-home-*`
directories.
- Reuse `source-pinned/` copies left by interrupted or failed recovery
attempts after the original temporary home has disappeared.
- Report deleted temporary homes still referenced by SQLite rollout
paths without treating paths pasted into prompts or errors as filesystem
evidence.
- Audit the durable thread index, rollout files, and history when no
source remains, including indexed chat counts and history-only orphan
records.
- Normalize legacy localhost `headroom` providers in both SQLite thread
rows and rollout `session_meta`, including retries after an earlier
broken recovery, while preserving user-defined remote providers named
`headroom`.
- Merge compatible config, JSONL, rollout, SQLite, credential, and
regular-file state without propagating deletions or runtime artifacts.
- Rewrite recovered thread rollout paths to the durable home and restore
legacy Headroom thread providers to the active provider.
- Validate SQLite schemas, SQLx migration checksums, integrity, and
foreign keys, and quarantine malformed JSONL.
- Preserve failed targets with an atomic rename before rollback,
avoiding recursive-deletion races with live SQLite runtime files.
- Document discovery, migration, retained backups, rollback behavior,
and the limits of deleted-source recovery.

## Testing

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

### Test Output

```text
$ uv run pytest tests/test_cli/test_wrap_codex.py tests/test_cli/test_recover_codex.py -q
122 passed

$ uv run ruff check headroom/cli/recover.py headroom/providers/codex/recovery.py tests/test_cli/test_recover_codex.py
All checks passed!

$ uv run ruff format --check headroom/cli/recover.py headroom/providers/codex/recovery.py tests/test_cli/test_recover_codex.py
3 files already formatted

$ uv run mypy headroom/cli/recover.py headroom/providers/codex/recovery.py
Success: no issues found in 2 source files
```

All validation ran in `ghcr.io/astral-sh/uv:python3.12-bookworm` against
a writable disposable copy of a read-only source mount. Codex was not
installed or launched, and no real user Codex state was read or
modified.

The tests cover multi-root discovery, deleted-reference reporting,
retained pinned-source recovery, durable SQLite path relocation, SQLite
and rollout provider normalization, idempotent repair after an earlier
broken recovery, remote provider preservation, unrelated dangling target
rows, backup retention, atomic rollback, malformed-state quarantine,
SQLite validation, and Windows-safe handle closure.

The repository shim E2E was not launched locally because this recovery
work intentionally avoids launching Codex. Upstream CI exercises wrapper
E2E in isolated environments.

## Real Behavior Proof

- Environment: `ghcr.io/astral-sh/uv:python3.12-bookworm`, Python 3.12,
a writable disposable checkout copied from a read-only source mount, at
head `2d89ecec`.
- Exact command / steps: Run `pytest -q
tests/test_cli/test_wrap_codex.py tests/test_cli/test_recover_codex.py`,
then run `ruff check` and `ruff format --check` against
`headroom/cli/wrap.py`, `headroom/cli/recover.py`,
`headroom/providers/codex/recovery.py`,
`tests/test_cli/test_wrap_codex.py`, and
`tests/test_cli/test_recover_codex.py`.
- Observed result: `122 passed in 10.08s`; Ruff reported `All checks
passed!` and `5 files already formatted`.
- Not tested: Launching a real Codex process or modifying a real user
`CODEX_HOME`; these were intentionally excluded to protect live user
state.

## 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 where the behavior is hard to understand
- [x] I have made corresponding documentation changes
- [x] My changes generate no new warnings
- [x] I have added tests that prove the fix is effective
- [x] New and existing focused unit tests pass with my changes
- [x] I have updated `CHANGELOG.md` if applicable

## Additional Notes

The temporary-home behavior was introduced by #1507 in
`ad9d086f43`. Related context: #730, #731,
#961, #1034, #1050, #1349, #1853, #1889, #2103, and #2104.

A temporary home that macOS or `TemporaryDirectory` already deleted
cannot be reconstructed unless a retained `source-pinned/` copy exists.
Recovery identifies genuine dangling SQLite paths, audits surviving
durable history, and recovers any retained pinned source it can find.
Prompt text without a rollout cannot reconstruct a full transcript.

The unchecked changelog item is not applicable because this repository
does not require a changelog entry for this fix.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 19:58:21 +00:00
Zhenjia ZHOU
8537e2cf60
fix(wrap): self-heal a stale ANTHROPIC_BASE_URL left by a dead proxy (#2223)
## Description

`headroom wrap claude` persists `ANTHROPIC_BASE_URL=<proxy>` into
project-local `.claude/settings.local.json`. This is required: Claude
Code's cc-daemon spawn-forks conversation workers that read settings
fresh rather than inherit env, so the URL cannot just live in the child
process env.

When the proxy then dies via a **hard reboot / SIGKILL**, no
signal/atexit cleanup fires, so the stale URL lingers and bricks a later
**bare `claude`** with ConnectionRefused (#2221). #1768's mitigations
(SIGHUP, next-`wrap` self-heal, doctor WARN) don't cover "reboot → bare
`claude`", and — the key gap — `wrap` installed no hook of its own, so
for a user who only ever ran `wrap claude` (never `init claude`) there
was nothing to clean it up.

`wrap claude` now installs a **SessionStart-only** self-heal hook
(removed again on `unwrap`) that clears the persisted base URL **iff the
recorded proxy port fails a retry-hardened liveness probe**. A
responding proxy is never cleared, and the retry (3 attempts ~250 ms
apart, alive on first success) keeps a transient blip from clearing a
live session mid-run. Because workers read settings fresh per
conversation, clearing at session start unblocks the current session
too, not only the next.

## Design note / assumption (for maintainer confirmation)

This relies on **the SessionStart hook completing before the first
cc-daemon conversation worker reads `settings.local.json`**. That
ordering lives in Claude Code, not this repo; it is grounded in the
documented spawn-fresh-read model (the same reason the URL must be
persisted at all). Raised on the issue for confirmation. The truly
launcher-agnostic fix would be upstream — Claude Code falling back to
the real upstream when its configured base URL is unreachable — which
would make any stale local URL harmless.

## Type of Change

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

## Changes Made

- `headroom/cli/wrap.py`:
- `_wrap_proxy_alive(port, attempts=3, delay=0.25)` — retry-hardened
liveness (alive on first success, dead only if all fail).
- `_check_and_clear_dead_wrap_marker` — port is authoritative (survives
PID reuse after reboot); a single probe decides; a responding proxy is
never cleared; falls back to PID staleness only for port-less markers.
- `_ensure_claude_wrap_selfheal_hook` /
`_remove_claude_wrap_selfheal_hook` — install on `wrap claude`, remove
on `unwrap`; **SessionStart-only** (never PreToolUse), idempotent,
preserves the `env` block and unrelated/user hooks.
  - hidden `wrap selfheal` command the hook invokes.

## Testing

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

### Test Output

```text
$ pytest tests/test_cli/test_wrap_dead_marker_selfheal.py
22 passed

$ pytest <related wrap/unwrap suites>
59 passed, 1 failed   # the 1 failure (test_wrap_marker_is_stale_when_pid_reused)
                      # is PRE-EXISTING + unrelated — fails identically on clean main
                      # (macOS _proc_identity returns None); this PR touches neither
                      # _wrap_marker_is_stale nor _identity_mismatch.

$ ruff check / mypy headroom/cli/wrap.py   # clean
```

## Real Behavior Proof

- Environment: macOS (Darwin), Python in a uv venv, branch
`feat/wrap-stale-url-selfheal` off `main`.
- Exact command / steps: `pytest
tests/test_cli/test_wrap_dead_marker_selfheal.py` exercises: `wrap
claude` writes a SessionStart-only self-heal hook into
`settings.local.json` (idempotent, not on PreToolUse); `unwrap` removes
it (keeping unrelated hooks); the `wrap selfheal` command clears a
dead-port marker's base URL; and — bound to a REAL listening socket — a
live proxy's marker is never cleared, including when a single probe
transiently fails but the retry succeeds.
- Observed result: dead-proxy marker → base URL restored to its prior
value; live-proxy marker (real socket) → preserved; no marker / no
settings file / port-less marker → no-op, no exception. All 22 pass.
- Not tested: the actual Claude Code hook-vs-worker execution ordering
(upstream, not in this repo) — see the Design note; the fix is correct
given that documented model.

## 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 — N/A
(internal wrap behavior)
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md — happy to add an entry if
preferred.

## Additional Notes

- Scoped to the `wrap claude` project-local path (the reported
scenario). The opt-in cc-switch reconciler writes `ANTHROPIC_BASE_URL`
into the *global* `~/.claude/settings.json` with no restore today — a
separate, lower-frequency gap I can follow up on if wanted.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 19:58:10 +00:00
LunarECL
fcf455a7eb
feat(wrap): add omp target (Oh My Pi) with models.yml override and unwrap (#1811)
## Description

Adds `headroom wrap omp` / `headroom unwrap omp` — a one-command wrap
for [Oh My Pi](https://www.npmjs.com/package/@oh-my-pi/pi-coding-agent)
(`omp`), the pi-mono-lineage coding agent, as proposed in #1149.

One honest correction to the issue: #1149 proposed reusing the
`ANTHROPIC_BASE_URL` redirect from `wrap claude`. During implementation
I probed that empirically and it turned out to be wrong — omp only reads
`ANTHROPIC_BASE_URL` in its web-search helper; its **chat** endpoint
comes from the model registry (`providers.anthropic.baseUrl` in
`~/.omp/agent/models.yml`). With the env var pointed at a local probe
server, omp's chat traffic still went straight to the real endpoint (0
probe hits); with a `models.yml` same-ID override, every request arrived
at the probe (9/9 hits on `/v1/messages`). A same-ID override keeps
omp's bundled Anthropic model catalog and stored credentials (both keyed
by provider id `anthropic`), so only the endpoint moves.

The wrap therefore injects a marker-fenced `providers.anthropic.baseUrl`
override into `models.yml`, snapshotting the pre-wrap file
**byte-for-byte** first, and `headroom unwrap omp` restores it exactly
(or removes the file when the wrap created it) — the same durable-wrap +
backup + unwrap contract `wrap codex` uses for `config.toml`.

Closes #1149

## 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/providers/omp/` (new provider slice): `models_yml_path()`
(honors `PI_CODING_AGENT_DIR`), `inject_models_override()` (yaml-merge
preserving user providers; pristine byte-for-byte backup, never
re-snapshotted while managed), `restore_models_override()` (`restored` /
`removed` / `noop`; never touches an unmanaged file),
`build_launch_env()`
- `headroom/cli/wrap.py`: `wrap omp` (mirrors the aider/vibe
`_launch_tool` shape; rtk instructions into the project's `AGENTS.md`,
which omp reads natively) and `unwrap omp` (restore models.yml + scrub
rtk block + stop proxy)
- `headroom/telemetry/context.py`: `omp` added to `_KNOWN_WRAP_AGENTS`
so the stack slug reports `wrap_omp` instead of `unknown`
- `README.md` (agent matrix row + unwrap list), `llms.txt`,
`CHANGELOG.md`
- `tests/test_cli/test_wrap_omp.py`: 16 tests (injection
fresh/merge/re-inject, restore statuses incl. unmanaged-file safety, env
passthrough, CLI wiring, unwrap flows)

## Testing

- [ ] Unit tests pass (`pytest`) — all new + `test_cli` tests pass; the
full suite carries **3 pre-existing failures** that reproduce
identically on unmodified `origin/main` (same set, same asserts — see
Test Output and the rebase-validation comment)
- [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 -q                    # post-rebase, base 4f22cbb0
3 failed, 7723 passed, 515 skipped in 262.64s
  FAILED tests/test_cli/test_wrap_claude_base_url.py::test_wrap_marker_is_stale_when_pid_reused
  FAILED tests/test_rtk_session_savings.py::test_rtk_reader_returns_none_on_nonzero_exit
  FAILED tests/test_rtk_session_savings.py::test_lean_ctx_reader_returns_none_on_failure_and_logs
  → all three reproduce identically on unmodified origin/main (4f22cbb0), run the
    same way (same worktree + venv, sources switched): 3 failed, 7707 passed —
    this branch = baseline + the 16 new tests, nothing else changes.
    (The pre-rebase run against e8151f05 showed the same shape: one order-dependent
    flake that also reproduced on its baseline; these are env/order-dependent.)

$ uv run pytest tests/test_cli/ -q     # post-rebase
542 passed + 1 of the pre-existing failures above   # includes the 16 new test_wrap_omp.py tests

$ uv run ruff check . ; echo ruff-check-exit:$?
All checks passed!
ruff-check-exit:0

$ uv run ruff format --check .         # post-rebase
1 pre-existing violation: headroom/proxy/handlers/anthropic.py — flagged identically
on unmodified origin/main (not touched by this PR); every file this PR touches is clean

$ uv run mypy headroom               # post-rebase; output redirected to file; exit captured
Success: no issues found in 409 source files
mypy-exit:0
```

## Real Behavior Proof

- Environment: macOS 15 (arm64, M1 Pro), Python 3.12.13 (uv venv,
editable install incl. Rust `_core`), headroom @ this branch, base
extras only (no `[ml]`), Anthropic account signed into omp. Initial
proof ran on base e8151f05 with omp 16.3.6 (`@oh-my-pi/pi-coding-agent`
via bun); re-validated after the rebase onto 4f22cbb0 with omp 16.3.11 —
fresh numbers in the rebase-validation comment.

- Exact command / steps: four scenarios, run in this order —
1. Mechanism probe (why models.yml, not env): local HTTP probe server on
`127.0.0.1:18999`; ran `omp -p "say ok" --model claude-fable-5
--no-session --no-tools` once with
`ANTHROPIC_BASE_URL=http://127.0.0.1:18999`, once with
`~/.omp/agent/models.yml` containing `providers.anthropic.baseUrl:
http://127.0.0.1:18999`.
2. One-command path: `headroom wrap omp --no-rtk --port 8790 -- -p "Read
CHANGELOG.md and count how many '### Fixed' headings it contains. Answer
with just the number." --model claude-fable-5 --no-session --max-time
180`
3. Routing stats: separate proxy on :8788, wrap with `--no-proxy`, then
`GET /stats`.
4. Restore: `headroom unwrap omp`, plus an isolated
`PI_CODING_AGENT_DIR=/tmp/omp-agent-test` run with a pre-existing user
`models.yml`, then `cmp` against the original.

- Observed result: end-to-end routing through the proxy proven for every
scenario —
- Probe: env-var run → **0 probe hits**, omp answered normally
(bypassed). models.yml run → **9 hits on `/v1/messages?beta=true`** with
real Messages bodies. This is the routing mechanism the wrap uses.
- One-command run: wrap started the proxy ("Proxy ready on
http://127.0.0.1:8790"), wrote the override (`models.yml:
providers.anthropic.baseUrl=http://127.0.0.1:8790/p/headroom-wrap-omp`),
launched omp, and omp answered **"7"** (correct — real `read` tool work
through the proxy). Proxy log for the session (3 requests,
`anthropic_messages` path):
    ```
PERF model=claude-fable-5 msgs=1 tok_before=36 cache_read=0
cache_write=61939 cache_hit_pct=0
PERF model=claude-fable-5 msgs=3 tok_before=796 cache_read=0
cache_write=63308 cache_hit_pct=0
PERF model=claude-fable-5 msgs=5 tok_before=935 cache_read=63308
cache_write=215 cache_hit_pct=100
    ```
    Prompt caching survives the proxy (100% hit on the follow-up turn).
- Routing stats (:8788 session): `requests.total: 2, by_provider:
{"anthropic": 2}, by_model: {"claude-fable-5": 2}`, per-project prefix
`/p/headroom-wrap-omp` attributed.
- Unwrap: `Removed wrap-created models.yml` (file gone); isolated
pre-existing-file run: backup created, user's `my-gw` provider preserved
in the managed file, and after `unwrap omp` the restored file is
**byte-identical** (`cmp` clean).
- Compression: **not observed in this environment** — `tok_saved=0`,
`transforms=router:noop` / `too_small`. Honest reading: omp minimizes
its own tool outputs client-side (a 300-item JSON tool result reached
the proxy at only ~657 tokens) and the `[ml]` text compressor wasn't
installed; small print-mode payloads sit below crush thresholds, and
passthrough-by-default is the documented safety contract. The wrap's
value here is proven at the routing/lifecycle/cache layer; compression
numbers will match whatever the proxy does for a given content mix.

- Not tested: Windows / Linux; lean-ctx mode with omp
(`HEADROOM_CONTEXT_TOOL=lean-ctx` — `lean-ctx init --agent omp` depends
on lean-ctx recognizing the agent; failure degrades with a warning by
design); long interactive (non `-p`) sessions; `--memory` / `--learn` /
`--code-graph` flags combined with omp; OAuth-vs-API-key matrix beyond
my local account.

## 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
- [ ] New and existing unit tests pass locally with my changes — all
except the 3 documented pre-existing failures, which fail identically on
unmodified origin/main
- [x] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A — terminal evidence inline above.

## Additional Notes

- The models.yml override is regenerated from the pristine backup on
every wrap, so re-running with a different `--port` updates the endpoint
idempotently and the backup is never clobbered.
- Scope note from #1149 stands: this routes omp's **Anthropic** provider
family. omp's other providers (OpenAI-direct, Gemini, ...) resolve their
endpoints from their own registry entries; users can already point those
at Headroom with their own custom provider in `models.yml`.
- `headroom/providers/omp/` deliberately contains no install-time / MCP
pieces — this is the thin wrap + unwrap slice only.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-15 19:30:19 +00:00
roman-t3a
cb388f6af2
feat(wrap): add first-class Grok CLI support (#1823)
## Description

Adds first-class Grok CLI integration so Headroom can wrap, compress,
and learn from Grok sessions the same way it does for Claude Code and
Codex.

Grok routes inference through `GROK_CLI_CHAT_PROXY_BASE_URL`; Headroom
sets that to the local proxy so chat traffic is compressed before
forwarding to xAI. MCP retrieval and session learning follow the same
patterns as Codex.

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

## Changes Made

- Add `headroom/providers/grok/` provider slice (`runtime.py`,
`install.py`) with `GROK_CLI_CHAT_PROXY_BASE_URL` routing and project
attribution prefix
- Add `headroom wrap grok` and `headroom unwrap grok` CLI commands (MCP
registration, RTK/context-tool setup, proxy launch)
- Add `GrokRegistrar` for marker-delimited `[mcp_servers.headroom]`
injection in `~/.grok/config.toml`
- Add `headroom learn --agent grok` plugin parsing
`~/.grok/sessions/*/updates.jsonl` and `GrokWriter` targeting `GROK.md`
- Register Grok in install planner, install registry, MCP install list,
and agent savings tracking
- Update README agent compatibility matrix and unwrap support list
- Add unit tests for provider, wrap CLI, MCP registrar, and learn plugin

## 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 tests/test_provider_grok.py tests/test_cli/test_wrap_grok.py tests/test_mcp_registry_grok.py tests/test_learn_grok_plugin.py -q
============================== 10 passed in 0.15s ==============================

$ uv run ruff check headroom/providers/grok headroom/mcp_registry/grok.py headroom/learn/plugins/grok.py headroom/cli/wrap.py headroom/learn/writer.py
All checks passed!

$ uv run mypy headroom/providers/grok headroom/mcp_registry/grok.py headroom/learn/plugins/grok.py
Success: no issues found in 5 source files
```

## Real Behavior Proof

- Environment: macOS (darwin), Python 3.13.14 via `uv`, repo at
`/Users/s/dev/headroom`, Grok CLI at `/Users/s/.grok/bin/grok`
- Exact command / steps: `cd /Users/s/dev/headroom && uv run pytest
tests/test_provider_grok.py tests/test_cli/test_wrap_grok.py
tests/test_mcp_registry_grok.py tests/test_learn_grok_plugin.py -q`; `uv
run ruff check headroom/providers/grok headroom/mcp_registry/grok.py
headroom/learn/plugins/grok.py`; `uv run python -c "from
headroom.providers.grok import build_launch_env; env, display =
build_launch_env(8787, environ={}); print(display[0])"`; `uv run python
-c "from pathlib import Path; import tempfile; from
headroom.mcp_registry.grok import GrokRegistrar; from
headroom.mcp_registry.install import build_headroom_spec;
td=tempfile.mkdtemp(); reg=GrokRegistrar(home_dir=Path(td));
print(reg.register_server(build_headroom_spec('http://127.0.0.1:8787'),
force=True).status.value)"`
- Observed result: pytest reported `10 passed`; ruff reported `All
checks passed!`; `build_launch_env` printed
`GROK_CLI_CHAT_PROXY_BASE_URL=http://127.0.0.1:8787/v1`; MCP registrar
returned `registered` and wrote the Headroom marker block to
`config.toml`
- Not tested: live `headroom wrap grok` session with authenticated Grok
API traffic through a running Headroom proxy (requires maintainer
environment with active `grok login`)

## 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 — CLI/integration change only.

## Additional Notes

- Follows the provider-slice pattern from `b17c6d81` / `93a1f211`
(Codex/Cursor/Aider extraction).
- Routing uses the session env var only (not `config.toml` endpoint
override) so `grok login` session auth continues to work.
- Manual E2E wrap/unwrap with real Grok sessions is left for maintainer
verification.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-15 18:51:38 +00:00
JD Davis
560ffae103
feat(deploy): Add turnkey deploy command (#1404)
## Description

Adds `headroom deploy` as the turnkey, zero-config local deployment
entrypoint. The command chooses the most capable deployment path it can
verify on the current host, configures detected tools through the
existing persistent-install machinery, starts the proxy, and preserves
the existing rollback behavior if an update fails.

The selection order favors performance first: NVIDIA Docker GPU
passthrough when `nvidia-smi` and Docker's NVIDIA runtime are available,
then plain Docker, then native scheduled recovery, then a detached
Python runtime fallback.

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

## Changes Made

- Added the top-level `headroom deploy` command and reused the existing
install manifest/apply/start/rollback path.
- Added conservative runtime selection for GPU Docker, plain Docker,
native schedulers, and detached Python fallback.
- Added Docker runtime support for manifest-driven `--gpus all`
passthrough.
- Added tests for Docker selection, GPU Docker selection, detached
fallback, GPU command rendering, and subprocess wrapper compliance.
- Updated README and persistent-install docs to present the turnkey
deployment flow and performance-first GPU behavior.
- Allowed documented `opencode` targets through `headroom install apply
--target`.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check`)
- [x] Formatting passes (`ruff format --check`)
- [x] Type checking passes in local pre-commit and CI
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
uv run --with pytest --with pytest-asyncio python -m pytest tests/test_cli/test_subprocess_utf8_encoding.py tests/test_cli/test_install_cli.py tests/test_install/test_runtime.py::test_build_runtime_command_for_docker_includes_gpu_passthrough tests/test_install/test_planner.py -q
47 passed in 1.57s

uvx --from ruff==0.15.17 ruff check headroom/cli/install.py headroom/install/runtime.py tests/test_cli/test_install_cli.py tests/test_install/test_runtime.py --output-format concise
All checks passed!

uvx --from ruff==0.15.17 ruff format --check headroom/cli/install.py headroom/install/runtime.py tests/test_cli/test_install_cli.py tests/test_install/test_runtime.py
4 files already formatted
```

GitHub checks are green on the current head.

## Real Behavior Proof

- Environment: Windows local worktree `C:\git\headroom`, Python 3.13 via
`uv`; GitHub Actions Ubuntu/macOS/Windows runners for full PR CI.
- Exact command / steps: Ran the focused deploy/install tests above,
checked the touched Python files with the CI-pinned Ruff version, and
confirmed the current PR head is mergeable with green GitHub checks.
- Observed result: The deploy command, runtime selection, Docker GPU
command rendering, install CLI behavior, and subprocess encoding
coverage all pass locally; the branch is no longer conflicted.
- Not tested: Actual RTX 4090 hardware passthrough on a physical NVIDIA
workstation; the PR tests conservative detection and Docker command
rendering without requiring GPU hardware in CI.

## 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 - CLI/runtime behavior only.

## Additional Notes

CHANGELOG update is not included because this is an unreleased feature
PR and the repository's release tooling owns release notes from
conventional commits.
2026-07-15 18:37:20 +00:00
nangsontay
e5b3a634df
fix(proxy): dedupe Codex WS request logging for accurate mixed-provider dashboards (#2189)
## Description

Running Claude Code (Anthropic) and Codex (OpenAI) against the **same**
Headroom proxy instance on one port produced incorrect, unstable
dashboard data. The proxy core is provider-isolated and
multi-provider-safe by design; the defect was in the observability
layer. The Codex `/v1/responses` **WebSocket** handler was the only path
in the proxy that wrote to the request logger by hand instead of through
the unified `emit_request_outcome` funnel, and it did so twice per
session close: the per-turn funnel record plus an unconditional
cumulative session-summary `RequestLog`. This PR removes the duplicate
summary log so Codex WS emits exactly one request log per turn, matching
the HTTP provider paths.

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

- Dropped the duplicate cumulative session-summary `RequestLog` in the
Codex WS handler while preserving the per-turn `emit_request_outcome`
path.
- Preserved gated `request_messages` and `turn_id` on residual outcomes
so dashboard telemetry keeps the useful attribution without
double-counting tokens.
- Ensured explicit `--anyllm-provider` wins over a leaked
`HEADROOM_ANYLLM_PROVIDER` environment variable.
- Registered retry delay settings that had drifted out of the settings
registry.
- Hardened tests against developer-shell `HEADROOM_*` /
`ANTHROPIC_CUSTOM_HEADERS` leakage and stabilized several focused
proxy/wrap test fixtures.

## 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/pytest tests/ -q -p no:cacheprovider
8529 passed, 537 skipped, 5831 warnings in 276.25s (0:04:36)

$ .venv/bin/ruff check <touched files>
All checks passed!
```

## Real Behavior Proof

- Environment: macOS (Darwin 25.4.0), Python 3.13.14, pytest 9.0.3, ruff
via project venv, branch `fix/multi-provider-runtime`.
- Exact command / steps: Ran the full test suite without pytest cache
provider and Ruff on all touched files; used `git stash` to confirm the
stale fake-config failures pre-existed this change.
- Observed result: Full suite passed with no failures; Ruff passed;
Codex WS now routes end-of-session logging through
`emit_request_outcome`, emitting one request log per turn with the same
accounting model as Anthropic HTTP turns.
- Not tested: Live simultaneous Claude + Codex dashboard run. `mypy
headroom` was not run to completion; a scoped run reported one
pre-existing `settings_store.py:470` coercion error outside this diff.

## Review Readiness

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

## Checklist

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

## Screenshots (if applicable)

N/A - server-side observability fix; no UI markup changed.

## Additional Notes

- The proxy's multi-provider routing, header/auth isolation, and
per-model cache keying are already correct and unchanged here; only the
WS observability write path was double-counting.
- Architectural assessment:
`plans/reports/research-260714-0004-multi-provider-upstream-compression-report.md`;
root-cause + resolution trail:
`plans/reports/debug-assessment-260714-0011-dashboard-instability-mixed-claude-codex-report.md`.
- No live simultaneous Claude + Codex dashboard run was performed;
validation is from test coverage and code review of the WS logging path.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 18:18:34 +00:00
Abhay Singh
daca1dd756
fix(cli/init): fail clearly on a target settings file with invalid JSON (#2227)
## Description

`headroom init` crashes with a raw traceback when a target's settings
file contains invalid JSON.

`_json_file` reads the JSON config files that init read-merge-writes
(Claude's `settings.json`, Codex's `hooks.json`, etc.):

```python
def _json_file(path: Path) -> dict[str, Any]:
    if not path.exists():
        return {}
    content = path.read_text(encoding="utf-8").strip()
    if not content:
        return {}
    payload = json.loads(content)          # unguarded
    return payload if isinstance(payload, dict) else {}
```

These are user-owned files that people hand-edit, so a stray trailing
comma or an unquoted key is entirely plausible. When that happens
`json.loads` raises `json.JSONDecodeError` and it propagates all the way
out, so `headroom init` dies with a Python traceback instead of a usable
message.

Returning `{}` on the error would be worse, not better: every caller
does `payload = _json_file(path)` then `_write_json(path, payload)`, so
an empty dict would make init overwrite the user's real settings with
just the hooks/env block — silent data loss.

## Fix

Guard the parse and convert it into an actionable `ClickException` that
names the file and the parse error, leaving the file untouched:

```python
try:
    payload = json.loads(content)
except json.JSONDecodeError as e:
    raise click.ClickException(
        f"{path} contains invalid JSON ({e}); fix it and re-run, or move it aside."
    ) from e
```

`click.ClickException` is already the project's convention for
user-facing init failures (e.g. the `'claude' not found in PATH`
messages). The user now gets a clear instruction, and their file is
never clobbered.

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`: wrap the `json.loads` in `_json_file` and
raise a `ClickException` on `JSONDecodeError`.
- `tests/test_cli/test_init_cli.py`: new test asserting a malformed file
raises `ClickException` (matching "invalid JSON") and is left
byte-for-byte untouched.
- `CHANGELOG.md`: Bug Fixes entry.

## Testing

- [ ] 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
$ uvx ruff@0.15.17 check headroom/cli/init.py tests/test_cli/test_init_cli.py
All checks passed!
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/cli/init.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17` / `uvx
mypy@1.20.2`. A full `pytest` OOM-kills this box (ML stack import), so I
reproduced the behavior with a dependency-free script mirroring
`_json_file` and left the full pytest to CI.
- Exact command / steps: wrote a settings file containing `{"env": {"A":
"B",}}` (trailing comma), then called the OLD unguarded reader and the
NEW guarded reader; also re-checked a valid file round-trips.
- Observed result: OLD raises a raw `json.JSONDecodeError` (the init
traceback); NEW raises a `ClickException` containing "invalid JSON" and
leaves the file byte-for-byte unchanged; valid JSON still parses to the
same dict.
- Not tested: a full `headroom init` end-to-end run; full local `pytest`
deferred to CI (OOM).

## 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" box is unchecked because the full suite
imports the ML stack, which I can't run here. The new test uses the
existing `_load_init_module` harness (the same one the neighbouring
`test_json_file_*` tests use), so it runs under the normal CI pytest
job; behaviour is additionally verified by the standalone proof above.
2026-07-15 18:15:45 +00:00
jichaowang02-lang
6413cc75a2
fix(wrap): read/write instruction files as UTF-8 on Windows (#1245)
## Description

Fixes #1126. On a Windows (cp1252) locale, `headroom wrap` crashes with
`UnicodeDecodeError` the first time it injects guidance into a user
instruction
file that contains non-ASCII prose (e.g. typographic quotes `“happy
places”` or
an em-dash). `_inject_rtk_instructions` and `_inject_memory_agents_md`
both read
the existing file and append/create it with a bare `read_text()` /
`open()` /
`write_text()`, so the default codec (cp1252, not UTF-8) chokes on the
multi-byte characters.

This is the same bug class already fixed for the `learn` pipeline
(#1202) and
earlier for other wrap paths — here it's the instruction-file injectors.

## 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`: in `_inject_rtk_instructions` and
  `_inject_memory_agents_md`, read the existing instruction file as
  `encoding="utf-8", errors="replace"` and append/create with
`encoding="utf-8"`. The read only feeds the marker-existence check and
the
append doesn't rewrite existing bytes, so replacement can't corrupt the
file.
- `tests/test_cli/test_wrap_encoding.py`: new regression tests.

## 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
$ pytest tests/test_cli/test_wrap_encoding.py tests/test_cli/test_wrap_hintfile_agents.py -q
16 passed

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

The new tests are **red on the old code, green with the fix**: injecting
into a
file with a typographic quote plus a stray `0x9d` byte (undefined in
cp1252 and
invalid UTF-8, so a bare `open()` fails on any locale) — the append and
idempotent paths fail before the fix (4 failed) and pass after (6
passed).

## Real Behavior Proof

- Environment: Windows 11, Python 3.10, against the real
`headroom.cli.wrap`
injectors (no live agent launch; the decode failure is at file read
time).
- Exact command / steps: `write_bytes` an `AGENTS.md` containing
`"Be in “happy places” — really.\n"` plus a stray `0x9d` byte, then call
  `_inject_rtk_instructions(path)` / `_inject_memory_agents_md(path)`.
- Observed result: **before** the fix → `UnicodeDecodeError: 'utf-8'
codec
  can't decode byte 0x9d` (and on a real cp1252 locale, the same on the
typographic quotes alone); **after** → both return `True`, the marker is
present, the pre-existing prose is preserved, and re-running is
idempotent.
- Not tested: a full end-to-end `headroom wrap copilot` against a live
Copilot
CLI (verified at the injector level, which is where the decode crash
lives).

## 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-15 09:26:34 -05:00
Eyal Mizrachi
9e376afabe
fix(mcp): mcp status checks ~/.claude.json, not only ~/.claude/mcp.json (#990)
## Description

`headroom mcp status` only inspected `~/.claude/mcp.json`, but servers
registered via `claude mcp add` (user scope) live in `~/.claude.json`.
So `status` printed `✗ No config file` even when headroom was registered
and `claude mcp list` reported it Connected. This detects the
registration across every location Claude Code uses.

## Type of Change

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

## Changes Made

- Add `find_headroom_registration()` that checks `~/.claude.json`,
`~/.claude/mcp.json`, then `./.mcp.json` (first match wins).
- Use it in `mcp status` for both the "Configured" check and the
proxy-URL lookup.

## Testing

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

### Test Output

```text
$ pytest tests/test_cli/test_mcp_status.py -q
5 passed in 0.13s
```

## Real Behavior Proof

- Environment: Linux, Python 3.13, editable build of this branch
- Exact command / steps: registered headroom in ~/.claude.json, then ran
`headroom mcp status`
- Observed result: prints `✓ Configured` with the ~/.claude.json path
(previously `✗ No config file`)
- Not tested: project-scoped ./.mcp.json discovery in a real multi-repo
workflow

## Review Readiness

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 09:25:52 -05:00