2026-04-21 22:11:30 -05:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-04-21 22:44:38 -05:00
|
|
|
import asyncio
|
2026-04-21 22:11:30 -05:00
|
|
|
import json
|
|
|
|
|
import time
|
|
|
|
|
from pathlib import Path
|
2026-04-22 00:23:28 -05:00
|
|
|
from types import SimpleNamespace
|
|
|
|
|
from urllib import error as urllib_error
|
2026-04-21 22:11:30 -05:00
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from headroom import copilot_auth
|
|
|
|
|
|
|
|
|
|
|
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 21:46:38 -04:00
|
|
|
@pytest.fixture(autouse=True)
|
|
|
|
|
def _isolated_copilot_auth(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
|
|
|
"""Keep Copilot auth tests away from user secret stores and real auth files."""
|
|
|
|
|
|
|
|
|
|
for var in (
|
|
|
|
|
"GITHUB_COPILOT_API_TOKEN",
|
|
|
|
|
"COPILOT_PROVIDER_BEARER_TOKEN",
|
|
|
|
|
"GITHUB_COPILOT_GITHUB_TOKEN",
|
|
|
|
|
"GITHUB_COPILOT_TOKEN",
|
|
|
|
|
"COPILOT_GITHUB_TOKEN",
|
|
|
|
|
"GH_TOKEN",
|
|
|
|
|
"GITHUB_TOKEN",
|
|
|
|
|
"GITHUB_COPILOT_API_URL",
|
|
|
|
|
"GITHUB_COPILOT_ENTERPRISE_URL",
|
|
|
|
|
"GITHUB_COPILOT_ENTERPRISE_DOMAIN",
|
|
|
|
|
"GITHUB_COPILOT_TOKEN_EXCHANGE_URL",
|
|
|
|
|
"GITHUB_COPILOT_USER_INFO_URL",
|
|
|
|
|
"GITHUB_COPILOT_USER_AGENT",
|
|
|
|
|
"GITHUB_COPILOT_EDITOR_VERSION",
|
|
|
|
|
"GITHUB_COPILOT_EDITOR_PLUGIN_VERSION",
|
|
|
|
|
"GITHUB_COPILOT_INTEGRATION_ID",
|
|
|
|
|
):
|
|
|
|
|
monkeypatch.delenv(var, raising=False)
|
|
|
|
|
monkeypatch.setattr(copilot_auth, "_provider", None)
|
|
|
|
|
monkeypatch.setenv("HEADROOM_COPILOT_AUTH_FILE", str(tmp_path / "copilot_auth.json"))
|
|
|
|
|
monkeypatch.setattr(copilot_auth, "read_macos_keychain_token", lambda *, host: None)
|
|
|
|
|
monkeypatch.setattr(copilot_auth, "read_linux_secret_token", lambda *, host: None)
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 22:11:30 -05:00
|
|
|
def test_read_cached_oauth_token_prefers_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
monkeypatch.setenv("GITHUB_COPILOT_TOKEN", "gho-env")
|
|
|
|
|
assert copilot_auth.read_cached_oauth_token() == "gho-env"
|
|
|
|
|
|
|
|
|
|
|
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 21:46:38 -04:00
|
|
|
def test_read_cached_oauth_token_prefers_headroom_login(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
monkeypatch.setenv("GITHUB_COPILOT_TOKEN", "gho-env")
|
|
|
|
|
copilot_auth.save_headroom_copilot_oauth_token("gho-headroom")
|
|
|
|
|
|
|
|
|
|
assert copilot_auth.read_cached_oauth_token() == "gho-headroom"
|
|
|
|
|
|
|
|
|
|
|
2026-06-02 21:24:47 -07:00
|
|
|
def test_read_cached_oauth_token_prefers_copilot_cli_before_generic_github_token(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
monkeypatch.delenv("GITHUB_COPILOT_GITHUB_TOKEN", raising=False)
|
|
|
|
|
monkeypatch.delenv("GITHUB_COPILOT_TOKEN", raising=False)
|
|
|
|
|
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
|
|
|
|
|
monkeypatch.setenv("GITHUB_TOKEN", "ghp-generic")
|
|
|
|
|
monkeypatch.setattr(copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: None)
|
|
|
|
|
monkeypatch.setattr(copilot_auth, "_read_macos_keychain_oauth_token", lambda: "gho-keychain")
|
|
|
|
|
monkeypatch.setattr(copilot_auth, "_read_gh_cli_oauth_token", lambda: None)
|
|
|
|
|
|
|
|
|
|
assert copilot_auth.read_cached_oauth_token() == "gho-keychain"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_iter_oauth_token_candidates_preserves_sources(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
monkeypatch.delenv("GITHUB_COPILOT_GITHUB_TOKEN", raising=False)
|
|
|
|
|
monkeypatch.delenv("GITHUB_COPILOT_TOKEN", raising=False)
|
|
|
|
|
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
|
|
|
|
|
monkeypatch.setenv("GITHUB_TOKEN", "ghp-generic")
|
|
|
|
|
monkeypatch.setattr(copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: None)
|
|
|
|
|
monkeypatch.setattr(copilot_auth, "_read_macos_keychain_oauth_token", lambda: "gho-keychain")
|
|
|
|
|
monkeypatch.setattr(copilot_auth, "_read_file_oauth_token_candidates", lambda: [])
|
|
|
|
|
monkeypatch.setattr(copilot_auth, "_read_gh_cli_oauth_token", lambda: None)
|
|
|
|
|
|
|
|
|
|
candidates = copilot_auth.iter_oauth_token_candidates()
|
|
|
|
|
|
|
|
|
|
assert [(candidate.source, candidate.token) for candidate in candidates] == [
|
|
|
|
|
("macos-keychain:copilot-cli", "gho-keychain"),
|
|
|
|
|
("env:GITHUB_TOKEN", "ghp-generic"),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_resolve_subscription_bearer_token_skips_invalid_generic_token(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
monkeypatch.delenv("GITHUB_COPILOT_API_TOKEN", raising=False)
|
|
|
|
|
monkeypatch.delenv("COPILOT_PROVIDER_BEARER_TOKEN", raising=False)
|
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 21:46:38 -04:00
|
|
|
monkeypatch.setattr(
|
|
|
|
|
copilot_auth, "_subscription_resolution_from_token_exchange", lambda _: None
|
|
|
|
|
)
|
2026-06-02 21:24:47 -07:00
|
|
|
monkeypatch.setattr(
|
|
|
|
|
copilot_auth,
|
|
|
|
|
"iter_oauth_token_candidates",
|
|
|
|
|
lambda: [
|
|
|
|
|
copilot_auth.CopilotTokenCandidate(
|
|
|
|
|
token="ghp-generic",
|
|
|
|
|
source="env:GITHUB_TOKEN",
|
|
|
|
|
confidence="generic-github",
|
|
|
|
|
),
|
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 21:46:38 -04:00
|
|
|
copilot_auth.CopilotTokenCandidate(
|
|
|
|
|
token="tid_copilot",
|
|
|
|
|
source="macos-keychain:copilot-cli",
|
|
|
|
|
confidence="high",
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
copilot_auth,
|
|
|
|
|
"_fetch_copilot_user_info",
|
|
|
|
|
lambda token: (
|
|
|
|
|
{"endpoints": {"api": "https://api.individual.githubcopilot.com"}}
|
|
|
|
|
if token == "tid_copilot"
|
|
|
|
|
else None
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert copilot_auth.resolve_subscription_bearer_token() == "tid_copilot"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_resolve_subscription_bearer_token_does_not_fallback_to_unexchanged_oauth(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
monkeypatch.delenv("GITHUB_COPILOT_API_TOKEN", raising=False)
|
|
|
|
|
monkeypatch.delenv("COPILOT_PROVIDER_BEARER_TOKEN", raising=False)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
copilot_auth, "_subscription_resolution_from_token_exchange", lambda _: None
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
copilot_auth,
|
|
|
|
|
"iter_oauth_token_candidates",
|
|
|
|
|
lambda: [
|
2026-06-02 21:24:47 -07:00
|
|
|
copilot_auth.CopilotTokenCandidate(
|
|
|
|
|
token="gho-copilot",
|
|
|
|
|
source="macos-keychain:copilot-cli",
|
|
|
|
|
confidence="high",
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
copilot_auth,
|
|
|
|
|
"_fetch_copilot_user_info",
|
2026-06-04 00:22:04 -07:00
|
|
|
lambda token: (
|
|
|
|
|
{"endpoints": {"api": "https://api.individual.githubcopilot.com"}}
|
|
|
|
|
if token == "gho-copilot"
|
|
|
|
|
else None
|
|
|
|
|
),
|
2026-06-02 21:24:47 -07:00
|
|
|
)
|
|
|
|
|
|
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 21:46:38 -04:00
|
|
|
assert copilot_auth.resolve_subscription_bearer_token() is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_resolve_subscription_bearer_token_details_exchanges_oauth_candidate(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
monkeypatch.delenv("GITHUB_COPILOT_API_TOKEN", raising=False)
|
|
|
|
|
monkeypatch.delenv("COPILOT_PROVIDER_BEARER_TOKEN", raising=False)
|
|
|
|
|
monkeypatch.delenv("GITHUB_COPILOT_API_URL", raising=False)
|
|
|
|
|
monkeypatch.delenv("GITHUB_COPILOT_ENTERPRISE_URL", raising=False)
|
|
|
|
|
monkeypatch.delenv("GITHUB_COPILOT_ENTERPRISE_DOMAIN", raising=False)
|
|
|
|
|
monkeypatch.delenv("GITHUB_COPILOT_TOKEN_EXCHANGE_URL", raising=False)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
copilot_auth,
|
|
|
|
|
"iter_oauth_token_candidates",
|
|
|
|
|
lambda: [
|
|
|
|
|
copilot_auth.CopilotTokenCandidate(
|
|
|
|
|
token="gho-oauth",
|
|
|
|
|
source="headroom-copilot-auth:/tmp/copilot_auth.json",
|
|
|
|
|
confidence="copilot-oauth",
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
captured: dict[str, str] = {}
|
|
|
|
|
|
|
|
|
|
def fake_exchange(headers: dict[str, str]) -> dict[str, object]:
|
|
|
|
|
captured.update(headers)
|
|
|
|
|
return {
|
|
|
|
|
"token": "copilot-api",
|
|
|
|
|
"expires_at": int(time.time()) + 3600,
|
|
|
|
|
"endpoints": {"api": "https://api.business.githubcopilot.com"},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
copilot_auth.CopilotTokenProvider,
|
|
|
|
|
"_exchange_token_sync",
|
|
|
|
|
staticmethod(fake_exchange),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
resolution = copilot_auth.resolve_subscription_bearer_token_details()
|
|
|
|
|
|
|
|
|
|
assert resolution is not None
|
|
|
|
|
assert resolution.token == "copilot-api"
|
|
|
|
|
assert resolution.source == "headroom-copilot-auth:/tmp/copilot_auth.json:token-exchange"
|
|
|
|
|
assert resolution.confidence == "copilot-token-exchange"
|
|
|
|
|
assert resolution.api_url == "https://api.business.githubcopilot.com"
|
|
|
|
|
assert resolution.token_fingerprint == copilot_auth.token_fingerprint("copilot-api")
|
|
|
|
|
assert captured == {
|
|
|
|
|
"Accept": "application/json",
|
|
|
|
|
"Authorization": "Bearer gho-oauth",
|
|
|
|
|
"User-Agent": "GitHubCopilotChat/0.35.0",
|
|
|
|
|
"Editor-Version": "vscode/1.107.0",
|
|
|
|
|
"Editor-Plugin-Version": "copilot-chat/0.35.0",
|
|
|
|
|
"Copilot-Integration-Id": "vscode-chat",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_resolve_subscription_exchange_uses_cloud_enterprise_advertised_api(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
monkeypatch.delenv("GITHUB_COPILOT_API_TOKEN", raising=False)
|
|
|
|
|
monkeypatch.delenv("COPILOT_PROVIDER_BEARER_TOKEN", raising=False)
|
|
|
|
|
monkeypatch.delenv("GITHUB_COPILOT_API_URL", raising=False)
|
|
|
|
|
monkeypatch.delenv("GITHUB_COPILOT_ENTERPRISE_DOMAIN", raising=False)
|
|
|
|
|
monkeypatch.delenv("GITHUB_COPILOT_TOKEN_EXCHANGE_URL", raising=False)
|
|
|
|
|
monkeypatch.setenv("GITHUB_COPILOT_ENTERPRISE_URL", "github.com/enterprises/cbcrc")
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
copilot_auth,
|
|
|
|
|
"iter_oauth_token_candidates",
|
|
|
|
|
lambda: [
|
|
|
|
|
copilot_auth.CopilotTokenCandidate(
|
|
|
|
|
token="gho-oauth",
|
|
|
|
|
source="env:GITHUB_COPILOT_TOKEN",
|
|
|
|
|
confidence="explicit",
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
copilot_auth.CopilotTokenProvider,
|
|
|
|
|
"_exchange_token_sync",
|
|
|
|
|
staticmethod(lambda _headers: {"token": "copilot-api"}),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
copilot_auth,
|
|
|
|
|
"_fetch_copilot_user_info",
|
|
|
|
|
lambda _token: {"endpoints": {"api": "https://api.business.githubcopilot.com"}},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
resolution = copilot_auth.resolve_subscription_bearer_token_details()
|
|
|
|
|
|
|
|
|
|
assert resolution is not None
|
|
|
|
|
assert resolution.api_url == "https://api.business.githubcopilot.com"
|
|
|
|
|
assert copilot_auth._token_exchange_url() == "https://api.github.com/copilot_internal/v2/token"
|
|
|
|
|
|
|
|
|
|
|
2026-06-29 00:26:38 +02:00
|
|
|
def test_api_url_from_exchange_payload_rejects_non_copilot_host(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
monkeypatch.delenv("GITHUB_COPILOT_API_URL", raising=False)
|
|
|
|
|
monkeypatch.delenv("GITHUB_COPILOT_ENTERPRISE_URL", raising=False)
|
|
|
|
|
monkeypatch.delenv("GITHUB_COPILOT_ENTERPRISE_DOMAIN", raising=False)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
copilot_auth,
|
|
|
|
|
"_fetch_copilot_user_info",
|
|
|
|
|
lambda _token: {"endpoints": {"api": "https://api.business.githubcopilot.com"}},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
resolved = copilot_auth._api_url_from_exchange_payload(
|
|
|
|
|
{"endpoints": {"api": "https://api.openai.com/v1"}},
|
|
|
|
|
oauth_token="gho-oauth",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert resolved == "https://api.business.githubcopilot.com"
|
|
|
|
|
|
|
|
|
|
|
2026-07-06 09:23:48 -04:00
|
|
|
def test_api_url_from_exchange_payload_normalizes_individual_public_host(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
monkeypatch.delenv("GITHUB_COPILOT_API_URL", raising=False)
|
|
|
|
|
monkeypatch.delenv("GITHUB_COPILOT_ENTERPRISE_URL", raising=False)
|
|
|
|
|
monkeypatch.delenv("GITHUB_COPILOT_ENTERPRISE_DOMAIN", raising=False)
|
|
|
|
|
|
|
|
|
|
resolved = copilot_auth._api_url_from_exchange_payload(
|
|
|
|
|
{"endpoints": {"api": "https://api.individual.githubcopilot.com"}},
|
|
|
|
|
oauth_token="gho-oauth",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert resolved == copilot_auth.DEFAULT_API_URL
|
|
|
|
|
|
|
|
|
|
|
2026-06-29 00:26:38 +02:00
|
|
|
def test_api_url_from_exchange_payload_rejects_non_copilot_host_without_user_info(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
monkeypatch.delenv("GITHUB_COPILOT_API_URL", raising=False)
|
|
|
|
|
monkeypatch.delenv("GITHUB_COPILOT_ENTERPRISE_URL", raising=False)
|
|
|
|
|
monkeypatch.delenv("GITHUB_COPILOT_ENTERPRISE_DOMAIN", raising=False)
|
|
|
|
|
monkeypatch.setattr(copilot_auth, "_fetch_copilot_user_info", lambda _token: None)
|
|
|
|
|
|
|
|
|
|
resolved = copilot_auth._api_url_from_exchange_payload(
|
|
|
|
|
{"endpoints": {"api": "https://api.openai.com/v1"}},
|
|
|
|
|
oauth_token="gho-oauth",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert resolved == copilot_auth.DEFAULT_API_URL
|
|
|
|
|
|
|
|
|
|
|
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 21:46:38 -04:00
|
|
|
def test_enterprise_domain_routes_token_exchange_and_user_info_together(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
monkeypatch.delenv("GITHUB_COPILOT_TOKEN_EXCHANGE_URL", raising=False)
|
|
|
|
|
monkeypatch.delenv("GITHUB_COPILOT_USER_INFO_URL", raising=False)
|
|
|
|
|
monkeypatch.delenv("GITHUB_COPILOT_ENTERPRISE_URL", raising=False)
|
|
|
|
|
monkeypatch.setenv("GITHUB_COPILOT_ENTERPRISE_DOMAIN", "ghe.example.com")
|
|
|
|
|
|
|
|
|
|
assert (
|
|
|
|
|
copilot_auth._token_exchange_url()
|
|
|
|
|
== "https://api.ghe.example.com/copilot_internal/v2/token"
|
|
|
|
|
)
|
|
|
|
|
assert copilot_auth._user_info_url() == "https://api.ghe.example.com/copilot_internal/user"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_user_info_url_override_wins_over_enterprise_domain(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
monkeypatch.setenv("GITHUB_COPILOT_ENTERPRISE_DOMAIN", "ghe.example.com")
|
|
|
|
|
monkeypatch.setenv(
|
|
|
|
|
"GITHUB_COPILOT_USER_INFO_URL",
|
|
|
|
|
"https://custom.example.com/copilot_internal/user",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert copilot_auth._user_info_url() == "https://custom.example.com/copilot_internal/user"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_copilot_api_url_from_enterprise_url_supports_enterprise_server_domain() -> None:
|
|
|
|
|
assert (
|
|
|
|
|
copilot_auth.copilot_api_url_from_enterprise_url("https://ghe.example.com/")
|
|
|
|
|
== "https://copilot-api.ghe.example.com"
|
|
|
|
|
)
|
|
|
|
|
assert (
|
|
|
|
|
copilot_auth.copilot_api_url_from_enterprise_url("https://api.ghe.example.com/")
|
|
|
|
|
== "https://copilot-api.ghe.example.com"
|
|
|
|
|
)
|
|
|
|
|
assert (
|
|
|
|
|
copilot_auth.copilot_api_url_from_enterprise_url("https://copilot-api.ghe.example.com/")
|
|
|
|
|
== "https://copilot-api.ghe.example.com"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_copilot_api_url_from_enterprise_url_ignores_github_cloud_enterprise_path() -> None:
|
|
|
|
|
assert (
|
|
|
|
|
copilot_auth.copilot_api_url_from_enterprise_url("https://github.com/enterprises/cbcrc/")
|
|
|
|
|
== copilot_auth.DEFAULT_API_URL
|
|
|
|
|
)
|
2026-06-02 21:24:47 -07:00
|
|
|
|
|
|
|
|
|
2026-04-22 00:23:28 -05:00
|
|
|
def test_should_exchange_oauth_token_supports_truthy_values(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
for raw in ("1", "true", "YES", "On"):
|
|
|
|
|
monkeypatch.setenv("GITHUB_COPILOT_USE_TOKEN_EXCHANGE", raw)
|
|
|
|
|
assert copilot_auth._should_exchange_oauth_token() is True
|
|
|
|
|
|
|
|
|
|
monkeypatch.setenv("GITHUB_COPILOT_USE_TOKEN_EXCHANGE", "off")
|
|
|
|
|
assert copilot_auth._should_exchange_oauth_token() is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_resolve_token_file_paths_prefers_override(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
monkeypatch.setenv("GITHUB_COPILOT_TOKEN_FILE", "~/custom-token.json")
|
|
|
|
|
|
|
|
|
|
paths = copilot_auth._resolve_token_file_paths()
|
|
|
|
|
|
|
|
|
|
assert paths == [Path("~/custom-token.json").expanduser()]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_resolve_token_file_paths_includes_localappdata_and_config(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
monkeypatch.delenv("GITHUB_COPILOT_TOKEN_FILE", raising=False)
|
|
|
|
|
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path / "local"))
|
|
|
|
|
monkeypatch.setattr(copilot_auth.Path, "home", staticmethod(lambda: tmp_path / "home"))
|
|
|
|
|
|
|
|
|
|
paths = copilot_auth._resolve_token_file_paths()
|
|
|
|
|
|
|
|
|
|
assert paths == [
|
|
|
|
|
tmp_path / "local" / "github-copilot" / "apps.json",
|
|
|
|
|
tmp_path / "local" / "github-copilot" / "hosts.json",
|
|
|
|
|
tmp_path / "home" / ".config" / "github-copilot" / "apps.json",
|
|
|
|
|
tmp_path / "home" / ".config" / "github-copilot" / "hosts.json",
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 22:44:38 -05:00
|
|
|
def test_read_cached_oauth_token_falls_back_to_gh_cli(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
monkeypatch.delenv("GITHUB_COPILOT_GITHUB_TOKEN", raising=False)
|
|
|
|
|
monkeypatch.delenv("GITHUB_COPILOT_TOKEN", raising=False)
|
|
|
|
|
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
|
|
|
|
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
|
|
|
|
|
monkeypatch.setattr(copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: None)
|
2026-06-02 21:24:47 -07:00
|
|
|
monkeypatch.setattr(copilot_auth, "_read_macos_keychain_oauth_token", lambda: None)
|
2026-04-21 22:44:38 -05:00
|
|
|
monkeypatch.setattr(copilot_auth, "_read_gh_cli_oauth_token", lambda: "gho-gh-cli")
|
|
|
|
|
|
|
|
|
|
assert copilot_auth.read_cached_oauth_token() == "gho-gh-cli"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_read_cached_oauth_token_prefers_copilot_cli_windows_token(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
monkeypatch.delenv("GITHUB_COPILOT_GITHUB_TOKEN", raising=False)
|
|
|
|
|
monkeypatch.delenv("GITHUB_COPILOT_TOKEN", raising=False)
|
|
|
|
|
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
|
|
|
|
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: "gho-copilot"
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(copilot_auth, "_read_gh_cli_oauth_token", lambda: "gho-gh-cli")
|
|
|
|
|
|
|
|
|
|
assert copilot_auth.read_cached_oauth_token() == "gho-copilot"
|
|
|
|
|
|
|
|
|
|
|
2026-06-02 21:24:47 -07:00
|
|
|
def test_read_cached_oauth_token_prefers_macos_keychain_before_gh(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
monkeypatch.delenv("GITHUB_COPILOT_GITHUB_TOKEN", raising=False)
|
|
|
|
|
monkeypatch.delenv("GITHUB_COPILOT_TOKEN", raising=False)
|
|
|
|
|
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
|
|
|
|
|
monkeypatch.delenv("GH_TOKEN", raising=False)
|
|
|
|
|
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
|
|
|
|
monkeypatch.setattr(copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: None)
|
|
|
|
|
monkeypatch.setattr(copilot_auth, "_read_macos_keychain_oauth_token", lambda: "gho-keychain")
|
|
|
|
|
monkeypatch.setattr(copilot_auth, "_read_gh_cli_oauth_token", lambda: "gho-gh-cli")
|
|
|
|
|
|
|
|
|
|
assert copilot_auth.read_cached_oauth_token() == "gho-keychain"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_read_macos_keychain_oauth_token_uses_security(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
calls: list[str] = []
|
|
|
|
|
|
|
|
|
|
def fake_read(*, host: str) -> str:
|
|
|
|
|
calls.append(host)
|
|
|
|
|
return "gho-keychain"
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(copilot_auth, "read_macos_keychain_token", fake_read)
|
|
|
|
|
assert copilot_auth._read_macos_keychain_oauth_token() == "gho-keychain"
|
|
|
|
|
assert calls == ["github.com"]
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 22:11:30 -05:00
|
|
|
def test_read_cached_oauth_token_reads_hosts_file(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
hosts = tmp_path / "hosts.json"
|
|
|
|
|
hosts.write_text(
|
|
|
|
|
json.dumps(
|
|
|
|
|
{
|
|
|
|
|
"github.com": {
|
|
|
|
|
"oauth_token": "gho-file",
|
|
|
|
|
"expires_at": "2999-01-01T00:00:00Z",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
),
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.delenv("GITHUB_COPILOT_TOKEN", raising=False)
|
|
|
|
|
monkeypatch.setenv("GITHUB_COPILOT_TOKEN_FILE", str(hosts))
|
2026-04-21 22:44:38 -05:00
|
|
|
monkeypatch.setattr(copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: None)
|
2026-06-02 21:24:47 -07:00
|
|
|
monkeypatch.setattr(copilot_auth, "_read_macos_keychain_oauth_token", lambda: None)
|
2026-04-21 22:44:38 -05:00
|
|
|
monkeypatch.setattr(copilot_auth, "_read_gh_cli_oauth_token", lambda: None)
|
2026-04-21 22:11:30 -05:00
|
|
|
|
|
|
|
|
assert copilot_auth.read_cached_oauth_token() == "gho-file"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_read_cached_oauth_token_skips_expired_entries(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
hosts = tmp_path / "hosts.json"
|
|
|
|
|
hosts.write_text(
|
|
|
|
|
json.dumps({"github.com": {"oauthToken": "gho-old", "expiresAt": 1}}),
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setenv("GITHUB_COPILOT_TOKEN_FILE", str(hosts))
|
2026-04-21 22:44:38 -05:00
|
|
|
monkeypatch.setattr(copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: None)
|
2026-06-02 21:24:47 -07:00
|
|
|
monkeypatch.setattr(copilot_auth, "_read_macos_keychain_oauth_token", lambda: None)
|
2026-04-21 22:44:38 -05:00
|
|
|
monkeypatch.setattr(copilot_auth, "_read_gh_cli_oauth_token", lambda: None)
|
2026-04-21 22:11:30 -05:00
|
|
|
|
|
|
|
|
assert copilot_auth.read_cached_oauth_token() is None
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 22:44:38 -05:00
|
|
|
def test_read_gh_cli_oauth_token_uses_hostname(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
calls: list[list[str]] = []
|
|
|
|
|
|
|
|
|
|
class CompletedProcess:
|
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
self.returncode = 0
|
|
|
|
|
self.stdout = "gho-gh-cli\n"
|
|
|
|
|
|
|
|
|
|
def fake_run(*args: object, **kwargs: object) -> CompletedProcess:
|
|
|
|
|
calls.append(list(args[0]))
|
|
|
|
|
assert kwargs["capture_output"] is True
|
|
|
|
|
assert kwargs["check"] is False
|
|
|
|
|
return CompletedProcess()
|
|
|
|
|
|
|
|
|
|
monkeypatch.setenv("GITHUB_COPILOT_HOST", "example.ghe.com")
|
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 19:52:49 +02:00
|
|
|
monkeypatch.setattr(copilot_auth, "run", fake_run)
|
2026-04-21 22:44:38 -05:00
|
|
|
|
|
|
|
|
assert copilot_auth._read_gh_cli_oauth_token() == "gho-gh-cli"
|
|
|
|
|
assert calls == [["gh", "auth", "token", "--hostname", "example.ghe.com"]]
|
|
|
|
|
|
|
|
|
|
|
2026-04-22 00:23:28 -05:00
|
|
|
def test_read_gh_cli_oauth_token_returns_none_when_invocation_fails(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
def fake_run(*args: object, **kwargs: object) -> None: # noqa: ANN002, ANN003
|
|
|
|
|
raise OSError("gh missing")
|
|
|
|
|
|
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 19:52:49 +02:00
|
|
|
monkeypatch.setattr(copilot_auth, "run", fake_run)
|
2026-04-22 00:23:28 -05:00
|
|
|
|
|
|
|
|
assert copilot_auth._read_gh_cli_oauth_token() is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_read_gh_cli_oauth_token_returns_none_for_nonzero_exit(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
monkeypatch.setattr(
|
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 19:52:49 +02:00
|
|
|
copilot_auth,
|
2026-04-22 00:23:28 -05:00
|
|
|
"run",
|
|
|
|
|
lambda *args, **kwargs: SimpleNamespace(returncode=1, stdout="ignored"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert copilot_auth._read_gh_cli_oauth_token() is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_read_gh_cli_oauth_token_returns_none_for_blank_stdout(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
monkeypatch.setattr(
|
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 19:52:49 +02:00
|
|
|
copilot_auth,
|
2026-04-22 00:23:28 -05:00
|
|
|
"run",
|
|
|
|
|
lambda *args, **kwargs: SimpleNamespace(returncode=0, stdout=" \n"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert copilot_auth._read_gh_cli_oauth_token() is None
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 22:11:30 -05:00
|
|
|
def test_resolve_client_bearer_token_prefers_api_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
monkeypatch.setenv("GITHUB_COPILOT_API_TOKEN", "copilot-api")
|
|
|
|
|
monkeypatch.setenv("GITHUB_COPILOT_TOKEN", "gho-oauth")
|
|
|
|
|
|
|
|
|
|
assert copilot_auth.resolve_client_bearer_token() == "copilot-api"
|
|
|
|
|
|
|
|
|
|
|
2026-04-22 00:23:28 -05:00
|
|
|
def test_has_oauth_auth_false_when_no_tokens(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
monkeypatch.setattr(copilot_auth, "resolve_client_bearer_token", lambda: None)
|
|
|
|
|
|
|
|
|
|
assert copilot_auth.has_oauth_auth() is False
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 22:11:30 -05:00
|
|
|
def test_is_copilot_api_url_matches_expected_hosts() -> None:
|
|
|
|
|
assert copilot_auth.is_copilot_api_url("https://api.githubcopilot.com/v1/chat/completions")
|
|
|
|
|
assert copilot_auth.is_copilot_api_url("wss://api.githubcopilot.com/v1/responses")
|
|
|
|
|
assert not copilot_auth.is_copilot_api_url("https://api.openai.com/v1/chat/completions")
|
|
|
|
|
|
|
|
|
|
|
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 21:46:38 -04:00
|
|
|
def test_is_copilot_api_url_matches_ghe_copilot_hosts() -> None:
|
|
|
|
|
assert copilot_auth.is_copilot_api_url("https://copilot-api.acme.ghe.com/v1/responses")
|
|
|
|
|
assert copilot_auth.is_copilot_api_url("https://copilot-api.ghe.com/v1/chat/completions")
|
|
|
|
|
assert not copilot_auth.is_copilot_api_url("https://api.acme.ghe.com/v1/responses")
|
|
|
|
|
assert not copilot_auth.is_copilot_api_url("https://not-copilot-api.acme.ghe.com/v1/responses")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_is_copilot_api_url_trusts_configured_enterprise_api_url(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
monkeypatch.setenv("GITHUB_COPILOT_ENTERPRISE_DOMAIN", "ghe.example.com")
|
|
|
|
|
|
|
|
|
|
assert copilot_auth.is_copilot_api_url("https://copilot-api.ghe.example.com/v1/responses")
|
|
|
|
|
assert not copilot_auth.is_copilot_api_url("https://copilot-api.other.example.com/v1/responses")
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 22:44:38 -05:00
|
|
|
def test_build_copilot_upstream_url_strips_v1_only_for_copilot_hosts() -> None:
|
|
|
|
|
assert (
|
|
|
|
|
copilot_auth.build_copilot_upstream_url(
|
|
|
|
|
"https://api.githubcopilot.com",
|
|
|
|
|
"/v1/chat/completions",
|
|
|
|
|
)
|
|
|
|
|
== "https://api.githubcopilot.com/chat/completions"
|
|
|
|
|
)
|
|
|
|
|
assert (
|
|
|
|
|
copilot_auth.build_copilot_upstream_url(
|
|
|
|
|
"https://api.openai.com",
|
|
|
|
|
"/v1/chat/completions",
|
|
|
|
|
)
|
|
|
|
|
== "https://api.openai.com/v1/chat/completions"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
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 21:46:38 -04:00
|
|
|
def test_build_copilot_upstream_url_strips_v1_for_ghe_copilot_hosts() -> None:
|
|
|
|
|
assert (
|
|
|
|
|
copilot_auth.build_copilot_upstream_url(
|
|
|
|
|
"https://copilot-api.acme.ghe.com",
|
|
|
|
|
"/v1/responses",
|
|
|
|
|
)
|
|
|
|
|
== "https://copilot-api.acme.ghe.com/responses"
|
|
|
|
|
)
|
|
|
|
|
assert (
|
|
|
|
|
copilot_auth.build_copilot_upstream_url(
|
|
|
|
|
"https://api.acme.ghe.com",
|
|
|
|
|
"/v1/responses",
|
|
|
|
|
)
|
|
|
|
|
== "https://api.acme.ghe.com/v1/responses"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_build_copilot_upstream_url_strips_v1_for_configured_enterprise_api_url(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
monkeypatch.setenv("GITHUB_COPILOT_ENTERPRISE_DOMAIN", "ghe.example.com")
|
|
|
|
|
|
|
|
|
|
assert (
|
|
|
|
|
copilot_auth.build_copilot_upstream_url(
|
|
|
|
|
"https://copilot-api.ghe.example.com",
|
|
|
|
|
"/v1/responses",
|
|
|
|
|
)
|
|
|
|
|
== "https://copilot-api.ghe.example.com/responses"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 22:44:38 -05:00
|
|
|
def test_apply_copilot_api_auth_replaces_authorization(monkeypatch: pytest.MonkeyPatch) -> None:
|
2026-04-21 22:11:30 -05:00
|
|
|
async def fake_get_api_token() -> copilot_auth.CopilotAPIToken:
|
|
|
|
|
return copilot_auth.CopilotAPIToken(
|
|
|
|
|
token="copilot-session",
|
|
|
|
|
expires_at=time.time() + 3600,
|
|
|
|
|
api_url=copilot_auth.DEFAULT_API_URL,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
copilot_auth.get_copilot_token_provider(),
|
|
|
|
|
"get_api_token",
|
|
|
|
|
fake_get_api_token,
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-21 22:44:38 -05:00
|
|
|
headers = asyncio.run(
|
|
|
|
|
copilot_auth.apply_copilot_api_auth(
|
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 21:46:38 -04:00
|
|
|
{"authorization": "Bearer downstream-token", "x-api-key": "sk-downstream"},
|
2026-04-21 22:44:38 -05:00
|
|
|
url="https://api.githubcopilot.com/v1/chat/completions",
|
|
|
|
|
)
|
2026-04-21 22:11:30 -05:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert headers["Authorization"] == "Bearer copilot-session"
|
|
|
|
|
assert "authorization" not in headers
|
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 21:46:38 -04:00
|
|
|
assert "x-api-key" not in headers
|
|
|
|
|
assert headers["User-Agent"] == "GitHubCopilotChat/0.35.0"
|
|
|
|
|
assert headers["Editor-Version"] == "vscode/1.107.0"
|
|
|
|
|
assert headers["Editor-Plugin-Version"] == "copilot-chat/0.35.0"
|
|
|
|
|
assert headers["Copilot-Integration-Id"] == "vscode-chat"
|
2026-04-21 22:11:30 -05:00
|
|
|
|
|
|
|
|
|
2026-06-11 23:27:48 +05:30
|
|
|
def test_apply_copilot_api_auth_passes_through_existing_api_token(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
async def fake_get_api_token() -> copilot_auth.CopilotAPIToken:
|
|
|
|
|
raise AssertionError("provider should not be called for existing API token")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
copilot_auth.get_copilot_token_provider(),
|
|
|
|
|
"get_api_token",
|
|
|
|
|
fake_get_api_token,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
headers = asyncio.run(
|
|
|
|
|
copilot_auth.apply_copilot_api_auth(
|
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 21:46:38 -04:00
|
|
|
{
|
|
|
|
|
"authorization": "Bearer tid_existing_copilot_token",
|
|
|
|
|
"x-api-key": "sk-downstream",
|
|
|
|
|
},
|
2026-06-11 23:27:48 +05:30
|
|
|
url="https://api.githubcopilot.com/v1/chat/completions",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert headers["authorization"] == "Bearer tid_existing_copilot_token"
|
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 21:46:38 -04:00
|
|
|
assert "x-api-key" not in headers
|
2026-06-11 23:27:48 +05:30
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_apply_copilot_api_auth_replaces_github_oauth_bearer(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
async def fake_get_api_token() -> copilot_auth.CopilotAPIToken:
|
|
|
|
|
return copilot_auth.CopilotAPIToken(
|
|
|
|
|
token="copilot-session",
|
|
|
|
|
expires_at=time.time() + 3600,
|
|
|
|
|
api_url=copilot_auth.DEFAULT_API_URL,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
copilot_auth.get_copilot_token_provider(),
|
|
|
|
|
"get_api_token",
|
|
|
|
|
fake_get_api_token,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
headers = asyncio.run(
|
|
|
|
|
copilot_auth.apply_copilot_api_auth(
|
|
|
|
|
{"authorization": "Bearer gho_downstream_oauth"},
|
|
|
|
|
url="https://api.githubcopilot.com/v1/chat/completions",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert headers["Authorization"] == "Bearer copilot-session"
|
|
|
|
|
assert "authorization" not in headers
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_apply_copilot_api_auth_replaces_non_bearer_auth(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
async def fake_get_api_token() -> copilot_auth.CopilotAPIToken:
|
|
|
|
|
return copilot_auth.CopilotAPIToken(
|
|
|
|
|
token="copilot-session",
|
|
|
|
|
expires_at=time.time() + 3600,
|
|
|
|
|
api_url=copilot_auth.DEFAULT_API_URL,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
copilot_auth.get_copilot_token_provider(),
|
|
|
|
|
"get_api_token",
|
|
|
|
|
fake_get_api_token,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
headers = asyncio.run(
|
|
|
|
|
copilot_auth.apply_copilot_api_auth(
|
|
|
|
|
{"authorization": "Basic abc123"},
|
|
|
|
|
url="https://api.githubcopilot.com/v1/chat/completions",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert headers["Authorization"] == "Bearer copilot-session"
|
|
|
|
|
assert "authorization" not in headers
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_is_copilot_api_token_matches_expected_prefixes() -> None:
|
|
|
|
|
assert copilot_auth._is_copilot_api_token("tid_session_token") is True
|
|
|
|
|
assert copilot_auth._is_copilot_api_token("gho_oauth") is False
|
|
|
|
|
assert copilot_auth._is_copilot_api_token("ghs_oauth") is False
|
|
|
|
|
assert copilot_auth._is_copilot_api_token("ghp_oauth") is False
|
|
|
|
|
assert copilot_auth._is_copilot_api_token("github_pat_example") is False
|
|
|
|
|
assert copilot_auth._is_copilot_api_token("Bearer maybe") is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_apply_copilot_api_auth_injects_required_headers(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
async def fake_get_api_token() -> copilot_auth.CopilotAPIToken:
|
|
|
|
|
return copilot_auth.CopilotAPIToken(
|
|
|
|
|
token="copilot-session",
|
|
|
|
|
expires_at=time.time() + 3600,
|
|
|
|
|
api_url=copilot_auth.DEFAULT_API_URL,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
copilot_auth.get_copilot_token_provider(),
|
|
|
|
|
"get_api_token",
|
|
|
|
|
fake_get_api_token,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.delenv("GITHUB_COPILOT_INTEGRATION_ID", raising=False)
|
|
|
|
|
monkeypatch.delenv("GITHUB_COPILOT_EDITOR_VERSION", raising=False)
|
|
|
|
|
|
|
|
|
|
headers = asyncio.run(
|
|
|
|
|
copilot_auth.apply_copilot_api_auth(
|
|
|
|
|
{},
|
|
|
|
|
url="https://api.githubcopilot.com/v1/chat/completions",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert headers["Authorization"] == "Bearer copilot-session"
|
|
|
|
|
assert headers["Copilot-Integration-Id"] == "vscode-chat"
|
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 21:46:38 -04:00
|
|
|
assert headers["Editor-Version"] == "vscode/1.107.0"
|
|
|
|
|
assert headers["Editor-Plugin-Version"] == "copilot-chat/0.35.0"
|
2026-06-11 23:27:48 +05:30
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_apply_copilot_api_auth_preserves_existing_copilot_headers(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
async def fake_get_api_token() -> copilot_auth.CopilotAPIToken:
|
|
|
|
|
return copilot_auth.CopilotAPIToken(
|
|
|
|
|
token="copilot-session",
|
|
|
|
|
expires_at=time.time() + 3600,
|
|
|
|
|
api_url=copilot_auth.DEFAULT_API_URL,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
copilot_auth.get_copilot_token_provider(),
|
|
|
|
|
"get_api_token",
|
|
|
|
|
fake_get_api_token,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setenv("GITHUB_COPILOT_INTEGRATION_ID", "should-not-override")
|
|
|
|
|
monkeypatch.setenv("GITHUB_COPILOT_EDITOR_VERSION", "should-not-override")
|
|
|
|
|
|
|
|
|
|
headers = asyncio.run(
|
|
|
|
|
copilot_auth.apply_copilot_api_auth(
|
|
|
|
|
{
|
|
|
|
|
"Authorization": "Bearer downstream-token",
|
|
|
|
|
"Copilot-Integration-Id": "custom-integration",
|
|
|
|
|
"Editor-Version": "custom-editor",
|
|
|
|
|
},
|
|
|
|
|
url="https://api.githubcopilot.com/v1/chat/completions",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert headers["Copilot-Integration-Id"] == "custom-integration"
|
|
|
|
|
assert headers["Editor-Version"] == "custom-editor"
|
|
|
|
|
assert headers["Authorization"] == "Bearer copilot-session"
|
|
|
|
|
|
|
|
|
|
|
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 21:46:38 -04:00
|
|
|
def test_apply_copilot_api_auth_preserves_existing_headers_case_insensitively(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
async def fake_get_api_token() -> copilot_auth.CopilotAPIToken:
|
|
|
|
|
return copilot_auth.CopilotAPIToken(
|
|
|
|
|
token="copilot-session",
|
|
|
|
|
expires_at=time.time() + 3600,
|
|
|
|
|
api_url=copilot_auth.DEFAULT_API_URL,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
copilot_auth.get_copilot_token_provider(),
|
|
|
|
|
"get_api_token",
|
|
|
|
|
fake_get_api_token,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
headers = asyncio.run(
|
|
|
|
|
copilot_auth.apply_copilot_api_auth(
|
|
|
|
|
{
|
|
|
|
|
"authorization": "Bearer downstream-token",
|
|
|
|
|
"user-agent": "custom-agent",
|
|
|
|
|
"editor-version": "custom-editor",
|
|
|
|
|
"editor-plugin-version": "custom-plugin",
|
|
|
|
|
"copilot-integration-id": "custom-integration",
|
|
|
|
|
},
|
|
|
|
|
url="https://api.githubcopilot.com/v1/chat/completions",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert headers["Authorization"] == "Bearer copilot-session"
|
|
|
|
|
assert "authorization" not in headers
|
|
|
|
|
assert headers["user-agent"] == "custom-agent"
|
|
|
|
|
assert headers["editor-version"] == "custom-editor"
|
|
|
|
|
assert headers["editor-plugin-version"] == "custom-plugin"
|
|
|
|
|
assert headers["copilot-integration-id"] == "custom-integration"
|
|
|
|
|
assert "User-Agent" not in headers
|
|
|
|
|
assert "Editor-Version" not in headers
|
|
|
|
|
assert "Editor-Plugin-Version" not in headers
|
|
|
|
|
assert "Copilot-Integration-Id" not in headers
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 22:44:38 -05:00
|
|
|
def test_token_provider_reuses_oauth_token_without_exchange(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
monkeypatch.setenv("GITHUB_COPILOT_TOKEN", "gho-oauth")
|
|
|
|
|
|
|
|
|
|
provider = copilot_auth.CopilotTokenProvider()
|
|
|
|
|
calls = {"count": 0}
|
|
|
|
|
|
|
|
|
|
def fake_exchange(headers: dict[str, str]) -> dict[str, object]:
|
|
|
|
|
calls["count"] += 1
|
|
|
|
|
return {
|
|
|
|
|
"token": "copilot-api",
|
|
|
|
|
"expires_at": int(time.time()) + 3600,
|
|
|
|
|
"refresh_in": 1200,
|
|
|
|
|
"endpoints": {"api": "https://api.githubcopilot.com"},
|
|
|
|
|
"sku": "copilot_individual",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(provider, "_exchange_token_sync", staticmethod(fake_exchange))
|
|
|
|
|
|
|
|
|
|
first = asyncio.run(provider.get_api_token())
|
|
|
|
|
second = asyncio.run(provider.get_api_token())
|
|
|
|
|
|
|
|
|
|
assert first.token == "gho-oauth"
|
|
|
|
|
assert second.token == "gho-oauth"
|
|
|
|
|
assert calls["count"] == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_token_provider_can_exchange_when_enabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
2026-04-21 22:11:30 -05:00
|
|
|
monkeypatch.setenv("GITHUB_COPILOT_TOKEN", "gho-oauth")
|
2026-04-21 22:44:38 -05:00
|
|
|
monkeypatch.setenv("GITHUB_COPILOT_USE_TOKEN_EXCHANGE", "true")
|
2026-04-21 22:11:30 -05:00
|
|
|
|
|
|
|
|
provider = copilot_auth.CopilotTokenProvider()
|
|
|
|
|
calls = {"count": 0}
|
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 21:46:38 -04:00
|
|
|
captured: dict[str, str] = {}
|
2026-04-21 22:11:30 -05:00
|
|
|
|
|
|
|
|
def fake_exchange(headers: dict[str, str]) -> dict[str, object]:
|
|
|
|
|
calls["count"] += 1
|
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 21:46:38 -04:00
|
|
|
captured.update(headers)
|
2026-04-21 22:11:30 -05:00
|
|
|
return {
|
|
|
|
|
"token": "copilot-api",
|
|
|
|
|
"expires_at": int(time.time()) + 3600,
|
|
|
|
|
"refresh_in": 1200,
|
|
|
|
|
"endpoints": {"api": "https://api.githubcopilot.com"},
|
|
|
|
|
"sku": "copilot_individual",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(provider, "_exchange_token_sync", staticmethod(fake_exchange))
|
|
|
|
|
|
2026-04-21 22:44:38 -05:00
|
|
|
first = asyncio.run(provider.get_api_token())
|
|
|
|
|
second = asyncio.run(provider.get_api_token())
|
2026-04-21 22:11:30 -05:00
|
|
|
|
|
|
|
|
assert first.token == "copilot-api"
|
|
|
|
|
assert second.token == "copilot-api"
|
|
|
|
|
assert calls["count"] == 1
|
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 21:46:38 -04:00
|
|
|
assert captured["Authorization"] == "Bearer gho-oauth"
|
|
|
|
|
assert captured["User-Agent"] == "GitHubCopilotChat/0.35.0"
|
|
|
|
|
assert captured["Editor-Version"] == "vscode/1.107.0"
|
|
|
|
|
assert captured["Editor-Plugin-Version"] == "copilot-chat/0.35.0"
|
|
|
|
|
assert captured["Copilot-Integration-Id"] == "vscode-chat"
|
2026-04-22 00:23:28 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_token_provider_prefers_explicit_api_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
monkeypatch.setenv("GITHUB_COPILOT_API_TOKEN", "copilot-api")
|
|
|
|
|
monkeypatch.setenv("GITHUB_COPILOT_API_URL", "https://api.githubcopilot.com")
|
|
|
|
|
|
|
|
|
|
token = asyncio.run(copilot_auth.CopilotTokenProvider().get_api_token())
|
|
|
|
|
|
|
|
|
|
assert token.token == "copilot-api"
|
|
|
|
|
assert token.api_url == "https://api.githubcopilot.com"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_token_provider_raises_without_oauth_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
monkeypatch.delenv("GITHUB_COPILOT_API_TOKEN", raising=False)
|
|
|
|
|
monkeypatch.setattr(copilot_auth, "read_cached_oauth_token", lambda: None)
|
|
|
|
|
|
|
|
|
|
with pytest.raises(RuntimeError, match="No GitHub Copilot OAuth token"):
|
|
|
|
|
asyncio.run(copilot_auth.CopilotTokenProvider().get_api_token())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_exchange_token_raises_when_exchange_returns_empty_token(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
provider = copilot_auth.CopilotTokenProvider()
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
provider,
|
|
|
|
|
"_exchange_token_sync",
|
|
|
|
|
staticmethod(lambda headers: {"token": "", "expires_at": int(time.time()) + 1}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
with pytest.raises(RuntimeError, match="empty token"):
|
|
|
|
|
asyncio.run(provider._exchange_token("gho-oauth"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_exchange_token_sync_raises_for_http_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
class DummyResponse:
|
|
|
|
|
def read(self) -> bytes:
|
|
|
|
|
return b'{"message":"Not Found"}'
|
|
|
|
|
|
|
|
|
|
def close(self) -> None:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
def fake_urlopen(request, timeout: float): # noqa: ANN001, ANN202
|
|
|
|
|
raise urllib_error.HTTPError(
|
|
|
|
|
url=request.full_url,
|
|
|
|
|
code=404,
|
|
|
|
|
msg="Not Found",
|
|
|
|
|
hdrs=None,
|
|
|
|
|
fp=DummyResponse(),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(copilot_auth.urllib_request, "urlopen", fake_urlopen)
|
|
|
|
|
|
|
|
|
|
with pytest.raises(RuntimeError, match="HTTP 404"):
|
|
|
|
|
copilot_auth.CopilotTokenProvider._exchange_token_sync({"Authorization": "token test"})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_apply_copilot_api_auth_returns_original_headers_for_non_copilot_url() -> None:
|
|
|
|
|
headers = asyncio.run(
|
|
|
|
|
copilot_auth.apply_copilot_api_auth(
|
|
|
|
|
{"authorization": "Bearer downstream-token"},
|
|
|
|
|
url="https://api.openai.com/v1/chat/completions",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert headers == {"authorization": "Bearer downstream-token"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_read_windows_copilot_cli_oauth_token_returns_none_without_windll(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
monkeypatch.setattr(copilot_auth.os, "name", "nt")
|
|
|
|
|
monkeypatch.delattr(copilot_auth.ctypes, "WinDLL", raising=False)
|
|
|
|
|
|
|
|
|
|
assert copilot_auth._read_windows_copilot_cli_oauth_token() is None
|
2026-06-11 23:27:48 +05:30
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_is_copilot_api_token_returns_false_for_empty_string() -> None:
|
|
|
|
|
assert copilot_auth._is_copilot_api_token("") is False
|
|
|
|
|
assert copilot_auth._is_copilot_api_token(" ") is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_token_kind_returns_known_prefixes() -> None:
|
|
|
|
|
assert copilot_auth._token_kind("tid_x") == "tid_***" # noqa: S105
|
|
|
|
|
assert copilot_auth._token_kind("gho_x") == "gho_***" # noqa: S105
|
|
|
|
|
assert copilot_auth._token_kind("ghs_x") == "ghs_***" # noqa: S105
|
|
|
|
|
assert copilot_auth._token_kind("ghp_x") == "ghp_***" # noqa: S105
|
|
|
|
|
assert copilot_auth._token_kind("github_pat_x") == "github_pat_***" # noqa: S105
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_token_kind_returns_unknown_for_unrecognised_token() -> None:
|
|
|
|
|
assert copilot_auth._token_kind("some_random_token") == "unknown"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_token_kind_returns_empty_for_blank_token() -> None:
|
|
|
|
|
assert copilot_auth._token_kind("") == "empty"
|
|
|
|
|
assert copilot_auth._token_kind(" ") == "empty"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_exchange_token_sync_returns_payload_on_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
payload = {"token": "copilot-api", "expires_at": int(time.time()) + 3600}
|
|
|
|
|
|
|
|
|
|
class FakeResponse:
|
|
|
|
|
def read(self) -> bytes:
|
|
|
|
|
return json.dumps(payload).encode()
|
|
|
|
|
|
|
|
|
|
def __enter__(self):
|
|
|
|
|
return self
|
|
|
|
|
|
|
|
|
|
def __exit__(self, *args):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
copilot_auth.urllib_request,
|
|
|
|
|
"urlopen",
|
|
|
|
|
lambda *args, **kwargs: FakeResponse(),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = copilot_auth.CopilotTokenProvider._exchange_token_sync(
|
|
|
|
|
{"Authorization": "Bearer gho_test"} # noqa: S105
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result == payload
|