Commit graph

18 commits

Author SHA1 Message Date
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
Rod Boev
4a8157fa0a
fix(copilot): derive GHE credential host from API URL (#800) (#2511)
## Description

GHE Copilot credential discovery falls back straight to `github.com`
when `GITHUB_COPILOT_HOST` is unset, even if the documented
`GITHUB_COPILOT_API_URL` points at an enterprise host. This change keeps
explicit-host precedence, then reuses the configured enterprise domain
or a normalized custom API URL hostname for credential lookup, so
Windows, macOS, Linux, GH CLI, and credential-file discovery search the
same custom host instead of the public default.

Closes #800.

Attribution:
https://github.com/headroomlabs-ai/headroom/issues/800#issuecomment-5044382263
narrowed the shared credential-host mismatch.

## Type of Change

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

## Changes Made

- Preserve explicit-host precedence, then fall back to the configured
enterprise domain or a normalized custom API URL hostname only when the
configured value is usable.
- Normalize `api.` and `copilot-api.` prefixes before routing credential
lookup, while keeping exact and segmented GitHub-hosted public domains
plus public enterprise or malformed enterprise or API configuration
fallback on `github.com`.
- Add focused coverage for the base/head reproduction, explicit-host
precedence, configured-enterprise precedence, public-enterprise,
malformed-enterprise, and invalid-port fallback, prefixed-host
normalization, adjacent-host exclusion, and GH CLI plus keychain
forwarding.

## Testing

- [x] Unit tests pass (`uv run pytest tests/test_copilot_auth.py -q`)
- [x] Linting passes
- [x] New tests added for new functionality when applicable
- [ ] Manual testing performed

### Test Output

```text
uv run pytest tests/test_copilot_auth.py -q
105 passed in 0.58s

uvx --from ruff==0.15.17 ruff check headroom/copilot_auth.py tests/test_copilot_auth.py
All checks passed!

uvx --from ruff==0.15.17 ruff format --check headroom/copilot_auth.py tests/test_copilot_auth.py
2 files already formatted

git diff --check
clean
```

## Real Behavior Proof

- Environment: Windows, isolated temporary credential file, local
`origin/main` checkout plus this branch
- Exact command / steps: With only
`GITHUB_COPILOT_API_URL=https://api.ghe.example.com:8443/copilot` set
and all other token sources disabled, run the same credential-file
discovery reproduction against `origin/main` and this branch.
- Observed result: `origin/main` selected `github.com` and resolved no
token; the review branch selected `ghe.example.com` and resolved
`gho-ghe`.
- Not tested: live GitHub Enterprise Copilot tenant

## 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 own 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`; Headroom generates release notes
from the PR title

## Additional Notes

The change does not alter API routing, token exchange, discovery order,
or credential matching breadth, and it keeps the live tenant claim out
of the PR body until an enterprise user reruns it.
2026-07-23 15:44:33 -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
Abhay Singh
c400f90810
fix(copilot): preserve /v1 for the Anthropic /v1/messages endpoint (#2409) (#2414)
## Description

Fixes #2409.

GitHub Copilot Claude requests routed through Headroom return `404 page
not found`. Copilot serves Claude models at `/v1/messages`, but Headroom
forwards them to `/messages`, so the upstream 404s (observed on
OpenCode's GitHub Copilot provider for `claude-haiku-4.5` /
`claude-sonnet-4.6`, Headroom 0.32.0).

## Root cause

`build_copilot_upstream_url` strips the `/v1` prefix from every Copilot
path:

```python
if normalized_path.startswith("/v1/"):
    normalized_path = normalized_path[3:]
```

That is correct for Copilot's **OpenAI-compatible** surface, which has
no `/v1` (`/chat/completions`, `/responses`, `/embeddings`). But
Copilot's **Anthropic** surface for Claude models is `/v1/messages` —
with the `/v1`. Stripping it produces
`https://api.githubcopilot.com/messages`, which 404s. Confirmed against
the current code:

```text
build_copilot_upstream_url("https://api.githubcopilot.com", "/v1/messages")
  -> "https://api.githubcopilot.com/messages"   # 404
```

## Fix

Keep `/v1` for the messages endpoint; still strip it for the OpenAI
paths:

```python
if normalized_path.startswith("/v1/") and not normalized_path.startswith("/v1/messages"):
    normalized_path = normalized_path[3:]
```

Now `/v1/messages` (and `/v1/messages/batches`) route to
`.../v1/messages`, while `/v1/chat/completions` -> `/chat/completions`
and `/v1/responses` -> `/responses` are unchanged, on both the public
and GHE Copilot hosts. Non-Copilot upstreams are untouched.

## 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/copilot_auth.py`: exclude `/v1/messages` from the
`/v1`-strip in `build_copilot_upstream_url`.
- `tests/test_copilot_auth.py`: assert `/v1/messages` (+ batches, + GHE
host) keep `/v1` while the OpenAI paths still strip it.

## 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
$ uvx ruff@0.15.17 check headroom/copilot_auth.py tests/test_copilot_auth.py
All checks passed!
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/copilot_auth.py
Success: no issues found in 1 source file
# copilot_auth is import-light, so I ran the real function in the project venv
# (uv sync): /v1/messages -> .../v1/messages, /v1/chat/completions -> /chat/completions.
```

## 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`.
- Exact command / steps: called the real `build_copilot_upstream_url`
before and after the change for `/v1/messages`, `/v1/messages/batches`,
`/v1/chat/completions`, `/v1/responses`, `/v1/embeddings`, and a
non-Copilot host.
- Observed result: before, `/v1/messages` -> `.../messages` (the 404);
after, `.../v1/messages`. Batches keep `/v1` too; the OpenAI paths still
strip `/v1` (`/chat/completions`, `/responses`, `/embeddings`);
`https://api.anthropic.com/v1/messages` is unchanged. Ran against the
actual module.
- Not tested: a live OpenCode -> Copilot Claude round trip; the added
unit tests assert the URL construction directly.

## Review Readiness

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

## Checklist

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

## Additional Notes

`copilot_auth` is a light module, so I verified the fix against the real
function in the venv (output above) in addition to the unit tests. Scope
is limited to the messages endpoint (the reported 404); every other
Copilot path is byte-identical to before.
2026-07-19 22:19:32 -07: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
Noam Asor
5dbe3314a1
fix(auth): support GitHub Enterprise Copilot OAuth domain (#2192)
## Description

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

Closes #1152

## Type of Change

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

## Changes Made

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

## Testing

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

### Test Output

```text
uv run pytest tests/test_copilot_auth.py tests/test_cli/test_copilot_auth.py -q
69 passed in 1.05s

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

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

## Real Behavior Proof

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

## Review Readiness

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

## Checklist

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

## Screenshots (if applicable)

N/A - CLI/auth behavior and README update.

## Additional Notes

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

---------

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

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

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

Closes #2156.

## Type of Change

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

## Changes Made

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

## Testing

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

### Test Output

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

```text
All checks passed!
```

```text
6 files already formatted
```

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

## Real Behavior Proof

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

## Review Readiness

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

## Checklist

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

## Additional Notes

- Scope stays provider-local. The fix remains inside
`headroom/copilot_auth.py` and the Copilot wrap handoff in
`headroom/cli/wrap.py`; it does not add generic 401 retry logic to
provider-neutral proxy layers.
- The line that disables token exchange for the Copilot CLI child env is
unchanged because it never reached the proxy env and was not the root
cause.
- Subscription-seeded sessions now get a dedicated local proxy whenever
the requested port already belongs to a shared or persistent proxy;
existing shared proxies are left alone to avoid disrupting attached
wrappers.
- Live provider confirmation still needs maintainer or reporter
validation because that truth is owned by GitHub's real subscription
APIs, not by local stubs.
- Headroom's release pipeline generates changelog entries from
conventional commits, so `CHANGELOG.md` is intentionally untouched.
2026-07-14 11:52:43 -04:00
yehsuf
f52ca19db1
fix(copilot-auth): stop discarding the caller's valid Copilot auth token (#1879)
## Description

`apply_copilot_api_auth()` treats any incoming
`gho_`/`ghs_`/`ghp_`/`github_pat_`-prefixed GitHub OAuth bearer token as
"not a suitable Copilot API token" (`_is_copilot_api_token()`) and
silently replaces it with a token independently fetched/exchanged by
Headroom itself, even when the caller already sent its own valid,
correctly-entitled Copilot credential.

This closes headroomlabs-ai/headroom#1813, which reports the exact same
defect from OpenCode's native GitHub Copilot integration: Headroom
replaces its native `gho_` bearer token, changing the effective
client/integrator lane Copilot's backend sees and breaking model
discovery/inference parity with native (non-proxied) behavior.

I hit the same root cause independently, through a different trigger
path, while building a new dedicated GitHub Copilot CLI redirect
integration (so Copilot CLI traffic gets the same full Headroom pipeline
treatment Claude Code already gets): a live Copilot CLI session's own
`gho_`-prefixed token worked end-to-end for model `claude-sonnet-5` when
sent directly to Copilot's real API, but the exact same request got `400
model_not_supported` once routed through Headroom, because Headroom
silently swapped in a different, independently-exchanged token with
different (lesser) model entitlements.

Closes #1813

## 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 `_is_forwardable_copilot_bearer_token()` in
`headroom/copilot_auth.py`: a new, narrowly-scoped helper used only by
`apply_copilot_api_auth()` (the chat-completion/inference auth path)
that accepts both short-lived Copilot API tokens (`tid_`) AND GitHub
OAuth tokens (`gho_`/`ghs_`/`ghp_`/`github_pat_`) as forwardable, since
live evidence (this PR + #1813) shows both are valid, correctly-entitled
Copilot bearer credentials when supplied directly by a Copilot-aware
client.
- **Deliberately did NOT touch `_is_copilot_api_token()`** — that helper
is used by a separate, unrelated code path
(`resolve_subscription_bearer_token_details()`, for Copilot
subscription/user-info API resolution) with different endpoint
requirements. Broadening it in place would have changed
subscription-resolution behavior as an unintended side effect; the new
helper keeps that concern isolated.
- `apply_copilot_api_auth()` now calls
`_is_forwardable_copilot_bearer_token()` instead of
`_is_copilot_api_token()` when deciding whether to forward the caller's
existing bearer token unchanged. Non-Copilot-shaped or
blank/whitespace-only tokens still fall through to Headroom's own
fetch/exchange, unchanged.
- Fixed a companion `/v1/responses` WebSocket ordering bug in
`headroom/proxy/handlers/openai.py` (filed as
headroomlabs-ai/headroom#1880): the `OPENAI_API_KEY` fallback used a
stale `_lower_headers` snapshot computed before
`apply_copilot_api_auth()` ran, and ran *before* it. Once the upstream
is known, Copilot auth is now resolved first, and the fallback checks
live header state instead — this matters more now that a
correctly-forwarded Copilot bearer token must not be shadowed by an
unrelated OpenAI-key fallback injected earlier in the flow.

## 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_copilot_auth.py tests/test_proxy_copilot_auth_hooks.py tests/test_provider_copilot_wrap.py -k "not falls_back_to_gh_cli" -q
============================= test session starts ==============================
platform darwin -- Python 3.12.12, pytest-9.0.3, pluggy-1.6.0
collected 95 items / 1 deselected / 94 selected

tests/test_copilot_auth.py ............................................. [ 47%]
............                                                             [ 60%]
tests/test_proxy_copilot_auth_hooks.py ...                               [ 63%]
tests/test_provider_copilot_wrap.py ..................................   [100%]

======================= 94 passed, 1 deselected in 0.52s =======================

# The 1 deselected test (test_read_cached_oauth_token_falls_back_to_gh_cli) is a
# pre-existing, environment-dependent failure unrelated to this change: it leaks a
# real gho_ token from this machine's own authenticated `gh` CLI keychain instead of
# using the mocked one. Confirmed identical on main before this change.

$ pytest tests/test_openai_codex_ws_lifecycle.py tests/test_openai_codex_routing.py tests/test_proxy_openai_responses_integration.py tests/test_codex_ws_compression_scheduler.py tests/test_openai_codex_ws_timings.py -q
================== 53 passed, 15 skipped, 1 warning in 16.19s ==================
# (15 skips are pre-existing, environment-gated, unrelated to this change)

$ ruff check headroom/copilot_auth.py headroom/proxy/handlers/openai.py tests/test_copilot_auth.py
All checks passed!
```

## Real Behavior Proof

- Environment: real GitHub Copilot CLI session (`gho_`-prefixed OAuth
token), routed through a dedicated Headroom redirect-proxy integration
(new, currently opt-in/staging-only work, not part of this PR) that
forwards Copilot CLI's `/v1/messages` and `/chat/completions` traffic
through Headroom.
- Exact command / steps: live Copilot CLI request for model
`claude-sonnet-5`, sent once directly to
`api.business.githubcopilot.com` and once routed through Headroom with
this fix applied.
- Observed result: both paths return `200 OK` with identical token/cost
usage. Before this fix, the proxied path returned `400
model_not_supported` for the exact same request, because Headroom
substituted a different, less-entitled token for the client's own valid
`gho_` token.
- Not tested: the companion WS-ordering fix (`/v1/responses` fallback
ordering) was validated via the existing WS lifecycle/routing/timing
unit test suites (see Test Output) rather than a live WebSocket session
— I don't have a live client that exercises that exact
`OPENAI_API_KEY`-fallback-into-Copilot-upstream path end-to-end.

## Review Readiness

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

## Checklist

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

## Screenshots (if applicable)

N/A

## Additional Notes

This PR was rebased onto current `main` after it turned out
`_is_copilot_api_token()` had already been introduced upstream (#557,
merged before this PR was opened) with the opposite assumption — that
`gho_`/`ghs_`/`ghp_`/`github_pat_` tokens always need exchanging and
must not be forwarded. That assumption is exactly what #1813 reports as
broken. Rather than reverting that classification wholesale (my original
approach in this branch), this version keeps it intact for its original
caller and adds a second, narrowly-scoped helper for the
inference/chat-completion auth path specifically — see "Changes Made"
above for the reasoning.

mypy is left unchecked above only because I didn't have it
configured/runnable in my local environment for this pass — happy to run
it if a maintainer flags a concern, or CI will catch it.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-09 12:46:35 -05:00
Rod Boev
afd9cbdfaf
fix(copilot): normalize subscription routing host (#1836)
## Description

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

## Type of Change

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

## Changes Made

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

## Testing

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

### Test Output

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

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

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

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

## Real Behavior Proof

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

## Review Readiness

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

## Checklist

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

## Additional Notes

This is intentionally scoped to host selection for exchanged Copilot
subscription tokens. It does not change token discovery, token pinning,
or non-subscription OAuth routing.
2026-07-06 06:23:48 -07:00
Ralf Escher
16c638bc21
fix: recover persistent proxy feature checks and reject non-Copilot exchange URL (#1465)
## Description

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

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

Related: #488

## Type of Change

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

## Changes Made

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

## Testing

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

### Test Output

```text
$ python -m pytest -q tests/test_copilot_auth.py tests/test_cli/test_wrap_persistent.py
============================= test session starts =============================
platform win32 -- Python 3.12.8, pytest-9.1.1, pluggy-1.6.0
rootdir: C:\Users\ralf.escher\Documents\headroom
collected 82 items

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

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

## Real Behavior Proof

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

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

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

- Not tested:
  - Full repository test suite
  - Full lint/typecheck across entire project
  - Non-Windows runtime verification in this run

## Review Readiness

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

## Checklist

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

## Additional Notes

- This PR intentionally excludes incidental local edits to
`.github/copilot-instructions.md`.
- Scope is limited to this bug fix and regression coverage; linked as
related work to #488.
2026-06-28 15:26:38 -07:00
Parideboy
d633e8172c
fix(windows): pin UTF-8 encoding on text-mode subprocess calls (#1311)
Fixes #1310.

## Description

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

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

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

## Type of Change

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

## Changes Made

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

## Testing

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

### Test Output

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

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

## Real Behavior Proof

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

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

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

## Review Readiness

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 12:52:49 -05:00
Joel Belanger
0b4a4bd483
fix: support Copilot Business subscription auth (#641)
## Description

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

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

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

## Type of Change

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

## Changes Made

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

## Testing

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

### Test Output

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

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

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

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

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

## Manual Validation

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

The Enterprise Cloud value I tested was in the form:

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

The tested flow was:

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

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

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

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

## Real Behavior Proof

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

## Review Readiness

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

## Checklist

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

## Screenshots (if applicable)

N/A

## Additional Notes

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

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

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

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

---------

Co-authored-by: jbelanger <your-username@users.noreply.github.com>
2026-06-12 20:46:38 -05:00
Abhinav Kaurav
6b0c09ffd5
fix: harden Copilot API auth token handling (#557)
## Description
Improve Copilot API authentication behavior by correctly handling
incoming bearer tokens and ensuring required Copilot headers are
present.

## 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 token classification logic to distinguish short-lived Copilot
API tokens (`tid_`) from GitHub OAuth tokens.
- Updated auth flow to pass through valid existing Copilot API bearer
tokens and replace unsuitable bearer tokens.
- Added default `Copilot-Integration-Id` and `editor-version` headers
when missing.
- Improved Windows credential lookup to consider both GitHub CLI (`gh:`)
and Copilot CLI credential target prefixes.
- Added regression tests for pass-through, replacement, header
injection, and token prefix classification.

## Testing

Describe the tests you ran to verify your changes:

- [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 -q tests/test_copilot_auth.py
29 passed in 0.40s
```

## Checklist

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

## Screenshots (if applicable)

N/A

## Additional Notes

This change is intentionally scoped to auth behavior and tests in:
- `headroom/copilot_auth.py`
- `tests/test_copilot_auth.py`

---------

Co-authored-by: Abhinav Kaurav <abhinav.kaurav@e2open.com>
2026-06-11 12:57:48 -05:00
Tejas Chopra
6ed43027b7 style(copilot): ruff-format test_copilot_auth.py
Parenthesize a multi-line conditional lambda so 'ruff format --check .'
passes (the original commit added it unformatted).
2026-06-04 00:22:04 -07:00
Tejas Chopra
ff4a0c6bc6 fix(copilot): support subscription auth through Headroom
Route GitHub Copilot CLI subscription traffic through the Headroom
OpenAI-compatible proxy path and resolve the account-specific Copilot API
endpoint before launch.

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

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

Tests: .venv/bin/python -m pytest tests/test_copilot_auth.py
tests/test_copilot_macos_keychain.py tests/test_copilot_linux_secret.py
tests/test_cli/test_wrap_copilot.py tests/test_cli/test_wrap_persistent.py
tests/test_proxy_copilot_auth_hooks.py
2026-06-02 21:24:47 -07:00
JerrettDavis
dcb3e8bdcb test: cover copilot auth branches
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-22 00:23:28 -05:00
JerrettDavis
d60cf7914c fix: support live copilot oauth runtime
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-21 22:48:57 -05:00
JerrettDavis
7989581350 fix: support copilot oauth sessions
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-21 22:11:30 -05:00