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>
This commit is contained in:
Joel Belanger 2026-06-12 21:46:38 -04:00 committed by GitHub
parent b08ec15b0d
commit 0b4a4bd483
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1108 additions and 105 deletions

View file

@ -73,10 +73,10 @@ A human maintainer reviews every dep change. PRs that add or bump a package must
## PR workflow ## PR workflow
1. Fork, branch from `main`. 1. Fork, branch from `main`.
2. Install **Node 18+** and run `pip install -e ".[dev]"` then `make install-git-hooks` — installs repo pre-commit checks on every commit, commitlint on every commit message, and ci-precheck on every push. 2. Install **Node 18+** and run `uv sync --extra dev` then `make install-git-hooks` — installs repo pre-commit checks on every commit, commitlint on every commit message, and ci-precheck on every push.
3. One logical change per PR. 3. One logical change per PR.
4. Add tests. 4. Add tests.
5. `pytest` · `ruff check .` · `ruff format .` 5. `uv run pytest` · `uv run ruff check .` · `uv run ruff format .`
6. Update `CHANGELOG.md` for user-facing changes. 6. Update `CHANGELOG.md` for user-facing changes.
7. Open the PR with a clear description + `Real behavior proof` + any spec/justification required, and keep the PR in draft until the `Review Readiness` boxes are complete. 7. Open the PR with a clear description + `Real behavior proof` + any spec/justification required, and keep the PR in draft until the `Review Readiness` boxes are complete.
@ -93,10 +93,15 @@ git clone https://github.com/chopratejas/headroom.git
cd headroom cd headroom
python -m venv .venv && source .venv/bin/activate python -m venv .venv && source .venv/bin/activate
node --version # Node 18+ required for commitlint hooks node --version # Node 18+ required for commitlint hooks
pip install -e ".[dev,relevance,proxy]" python -m pip install --upgrade pip
pytest python -m pip install -e ".[dev,relevance,proxy]"
python -m pytest
``` ```
Headroom uses a `pyproject.toml`/`maturin` build backend. Older `pip`
versions may fail editable installs by looking for `setup.py`; upgrade `pip`
first or use `uv sync --extra dev`.
### Dev Containers ### Dev Containers
Two configs ship for VS Code / Codespaces: Two configs ship for VS Code / Codespaces:

View file

@ -147,10 +147,28 @@ Any OpenAI-compatible client works via `headroom proxy`. MCP-native: `headroom m
Headroom can route GitHub Copilot CLI subscription traffic through the local proxy: Headroom can route GitHub Copilot CLI subscription traffic through the local proxy:
```bash ```bash
headroom copilot-auth login
headroom wrap copilot --subscription -- --model gpt-4o headroom wrap copilot --subscription -- --model gpt-4o
``` ```
This lets Headroom intercept OpenAI-compatible Copilot CLI requests and apply the same proxy compression pipeline before forwarding to GitHub Copilot's hosted API. The wrapper resolves the account-specific Copilot API endpoint and prints it as `COPILOT_PROVIDER_API_URL=...` during launch. This lets Headroom intercept OpenAI-compatible Copilot CLI requests and apply the same proxy compression pipeline before forwarding to GitHub Copilot's hosted API. The wrapper exchanges Headroom's reusable GitHub OAuth token for Copilot's short-lived API token and prints the upstream endpoint as `COPILOT_PROVIDER_API_URL=...` during launch.
`headroom copilot-auth login` stores a Headroom-specific Copilot OAuth token.
This avoids relying on generic GitHub or Copilot CLI tokens that can read
Copilot account metadata but may still be rejected by Copilot's token-exchange
endpoint.
For GitHub Enterprise Server or custom-domain Copilot deployments, set the
deployment domain before launching:
```bash
export GITHUB_COPILOT_ENTERPRISE_DOMAIN=ghe.example.com
```
For GitHub.com Enterprise Cloud URLs such as
`github.com/enterprises/your-enterprise`, do not set an enterprise-domain
override. Headroom uses GitHub's normal token-exchange endpoint and the Copilot
API endpoint advertised for the signed-in account.
Platform support note: macOS auth reuse via Copilot CLI Keychain storage has been smoke-tested. Windows Credential Manager, Linux Secret Service / `secret-tool`, and Docker/CI token-injection paths are implemented or planned as auth-discovery paths, but still need real OS validation before they should be considered fully vetted. For Docker and CI, prefer passing an explicit `GITHUB_COPILOT_TOKEN` or `GITHUB_COPILOT_GITHUB_TOKEN` rather than relying on host keychain access. Platform support note: macOS auth reuse via Copilot CLI Keychain storage has been smoke-tested. Windows Credential Manager, Linux Secret Service / `secret-tool`, and Docker/CI token-injection paths are implemented or planned as auth-discovery paths, but still need real OS validation before they should be considered fully vetted. For Docker and CI, prefer passing an explicit `GITHUB_COPILOT_TOKEN` or `GITHUB_COPILOT_GITHUB_TOKEN` rather than relying on host keychain access.
@ -302,7 +320,7 @@ Headroom runs **locally**, covers **every** content type, works with every major
```bash ```bash
git clone https://github.com/chopratejas/headroom.git && cd headroom git clone https://github.com/chopratejas/headroom.git && cd headroom
pip install -e ".[dev]" && pytest uv sync --extra dev && uv run pytest
``` ```
Devcontainers in `.devcontainer/` (default + `memory-stack` with Qdrant & Neo4j). See [CONTRIBUTING.md](CONTRIBUTING.md). Devcontainers in `.devcontainer/` (default + `memory-stack` with Qdrant & Neo4j). See [CONTRIBUTING.md](CONTRIBUTING.md).

View file

@ -14,6 +14,7 @@ survives that kind of sys.modules mutation.
from . import ( # noqa: F401 from . import ( # noqa: F401
capture, capture,
copilot_auth,
evals, evals,
init, init,
install, install,

View file

@ -0,0 +1,81 @@
"""GitHub Copilot authentication commands."""
from __future__ import annotations
import click
from headroom.cli.main import main
from headroom.copilot_auth import (
DEFAULT_GITHUB_HOST,
headroom_copilot_auth_path,
poll_copilot_device_authorization,
read_headroom_copilot_oauth_token,
save_headroom_copilot_oauth_token,
start_copilot_device_authorization,
token_fingerprint,
)
@main.group("copilot-auth")
def copilot_auth() -> None:
"""Manage Headroom's GitHub Copilot OAuth token."""
@copilot_auth.command("login")
@click.option(
"--domain",
default=DEFAULT_GITHUB_HOST,
show_default=True,
help=(
"GitHub login domain. Use github.com for GitHub.com Enterprise Cloud; "
"only pass a custom hostname for GitHub Enterprise Server."
),
)
def login(domain: str) -> None:
"""Sign in with GitHub's Copilot OAuth device-code flow."""
try:
device = start_copilot_device_authorization(domain=domain)
except Exception as exc:
raise click.ClickException(f"Unable to start GitHub device login: {exc}") from exc
verification_uri = str(device.get("verification_uri") or "").strip()
user_code = str(device.get("user_code") or "").strip()
device_code = str(device.get("device_code") or "").strip()
interval = int(device.get("interval") or 5)
expires_in = int(device.get("expires_in") or 900)
if not verification_uri or not user_code or not device_code:
raise click.ClickException("GitHub device login returned an incomplete response.")
click.echo("GitHub Copilot OAuth login")
click.echo(f" Open: {verification_uri}")
click.echo(f" Code: {user_code}")
click.echo(" Waiting for authorization...")
try:
token = poll_copilot_device_authorization(
device_code,
domain=domain,
interval=interval,
expires_in=expires_in,
)
except Exception as exc:
raise click.ClickException(f"GitHub device login failed: {exc}") from exc
path = save_headroom_copilot_oauth_token(token, domain=domain)
click.echo(f" Saved: {path}")
click.echo(f" Token fingerprint: {token_fingerprint(token)}")
@copilot_auth.command("status")
def status() -> None:
"""Show whether Headroom has a saved Copilot OAuth token."""
token = read_headroom_copilot_oauth_token()
path = headroom_copilot_auth_path()
click.echo(f"Auth file: {path}")
if not token:
click.echo("Status: not logged in")
return
click.echo("Status: logged in")
click.echo(f"Token fingerprint: {token_fingerprint(token)}")

View file

@ -38,6 +38,7 @@ def _register_commands() -> None:
from . import ( from . import (
agent_savings, # noqa: F401 agent_savings, # noqa: F401
capture, # noqa: F401 capture, # noqa: F401
copilot_auth, # noqa: F401
evals, # noqa: F401 evals, # noqa: F401
init, # noqa: F401 init, # noqa: F401
install, # noqa: F401 install, # noqa: F401

View file

@ -45,7 +45,7 @@ from headroom.copilot_auth import (
has_oauth_auth, has_oauth_auth,
resolve_client_bearer_token, resolve_client_bearer_token,
resolve_copilot_api_url, resolve_copilot_api_url,
resolve_subscription_bearer_token, resolve_subscription_bearer_token_details,
) )
from headroom.providers.aider import build_launch_env as _build_aider_launch_env from headroom.providers.aider import build_launch_env as _build_aider_launch_env
from headroom.providers.claude import proxy_base_url as _claude_proxy_base_url from headroom.providers.claude import proxy_base_url as _claude_proxy_base_url
@ -385,6 +385,8 @@ def _start_proxy(
# GITHUB_COPILOT_API_TOKEN directly, making upstream auth deterministic. # GITHUB_COPILOT_API_TOKEN directly, making upstream auth deterministic.
if copilot_api_token: if copilot_api_token:
proxy_env["GITHUB_COPILOT_API_TOKEN"] = copilot_api_token proxy_env["GITHUB_COPILOT_API_TOKEN"] = copilot_api_token
if openai_api_url:
proxy_env["GITHUB_COPILOT_API_URL"] = openai_api_url
proc = subprocess.Popen( proc = subprocess.Popen(
cmd, cmd,
@ -3025,19 +3027,24 @@ def copilot(
env = os.environ.copy() env = os.environ.copy()
openai_api_url: str | None = None openai_api_url: str | None = None
copilot_proxy_token: str | None = None copilot_proxy_token: str | None = None
subscription_resolution = None
if _should_use_copilot_oauth( if _should_use_copilot_oauth(
backend=effective_backend, backend=effective_backend,
provider_type=provider_type, provider_type=provider_type,
env=env, env=env,
force_subscription=subscription, force_subscription=subscription,
): ):
client_bearer = ( if subscription:
resolve_subscription_bearer_token() if subscription else resolve_client_bearer_token() subscription_resolution = resolve_subscription_bearer_token_details()
) client_bearer = (
subscription_resolution.token if subscription_resolution is not None else None
)
else:
client_bearer = resolve_client_bearer_token()
if not client_bearer: if not client_bearer:
raise click.ClickException( raise click.ClickException(
"GitHub Copilot subscription mode requires a reusable GitHub/Copilot bearer " "GitHub Copilot subscription mode requires a reusable GitHub/Copilot bearer "
"token, but none could be resolved. Run `copilot auth login` first, or set " "token, but none could be resolved. Run `headroom copilot-auth login` first, or set "
"GITHUB_COPILOT_TOKEN / GITHUB_COPILOT_GITHUB_TOKEN." "GITHUB_COPILOT_TOKEN / GITHUB_COPILOT_GITHUB_TOKEN."
) )
@ -3075,16 +3082,15 @@ def copilot(
else "COPILOT_AUTH_MODE=github-oauth" else "COPILOT_AUTH_MODE=github-oauth"
), ),
] ]
# Resolve the Copilot API host: an explicit GITHUB_COPILOT_API_URL wins, # Non-subscription OAuth keeps upstream's generic-host policy from
# otherwise the generic public host (api.githubcopilot.com). This is the # #610. Subscription mode can use the endpoint returned by the Copilot
# same policy for --subscription and the implicit OAuth path. The # token exchange, which is how Business accounts advertise their API
# account-specific endpoints.api advertised by /copilot_internal/user is # host without requiring users to configure it manually.
# deliberately NOT used to route — it returns a segmented host (e.g. openai_api_url = (
# api.individual.githubcopilot.com) that does not serve newer models on subscription_resolution.api_url
# the responses API (#610), and it is not the host the official Copilot if subscription_resolution is not None
# client routes with. Accounts that require a dedicated host (enterprise / else resolve_copilot_api_url(client_bearer)
# data residency) set GITHUB_COPILOT_API_URL explicitly. )
openai_api_url = resolve_copilot_api_url(client_bearer)
env["GITHUB_COPILOT_API_URL"] = openai_api_url env["GITHUB_COPILOT_API_URL"] = openai_api_url
env["OPENAI_TARGET_API_URL"] = openai_api_url env["OPENAI_TARGET_API_URL"] = openai_api_url
env_vars_display.append(f"COPILOT_PROVIDER_API_URL={openai_api_url}") env_vars_display.append(f"COPILOT_PROVIDER_API_URL={openai_api_url}")

View file

@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
import ctypes import ctypes
import hashlib
import json import json
import logging import logging
import os import os
@ -18,6 +19,7 @@ from urllib import error as urllib_error
from urllib import request as urllib_request from urllib import request as urllib_request
from urllib.parse import urlparse from urllib.parse import urlparse
from headroom import paths
from headroom.copilot_linux_secret import read_copilot_oauth_token as read_linux_secret_token from headroom.copilot_linux_secret import read_copilot_oauth_token as read_linux_secret_token
from headroom.copilot_macos_keychain import read_copilot_oauth_token as read_macos_keychain_token from headroom.copilot_macos_keychain import read_copilot_oauth_token as read_macos_keychain_token
@ -27,10 +29,13 @@ DEFAULT_API_URL = "https://api.githubcopilot.com"
DEFAULT_TOKEN_EXCHANGE_URL = "https://api.github.com/copilot_internal/v2/token" DEFAULT_TOKEN_EXCHANGE_URL = "https://api.github.com/copilot_internal/v2/token"
DEFAULT_USER_INFO_URL = "https://api.github.com/copilot_internal/user" DEFAULT_USER_INFO_URL = "https://api.github.com/copilot_internal/user"
DEFAULT_GITHUB_HOST = "github.com" DEFAULT_GITHUB_HOST = "github.com"
COPILOT_CHAT_OAUTH_CLIENT_ID = "Iv1.b507a08c87ecfe98"
_TOKEN_EXPIRY_BUFFER_S = 60 _TOKEN_EXPIRY_BUFFER_S = 60
_DEFAULT_INTEGRATION_ID = "vscode-chat" _DEFAULT_EDITOR_VERSION = "vscode/1.107.0"
_DEFAULT_EDITOR_VERSION = "vscode/1.104.1" _DEFAULT_USER_AGENT = "GitHubCopilotChat/0.35.0"
_DEFAULT_USER_AGENT = "GitHubCopilotChat/0.1" _DEFAULT_EDITOR_PLUGIN_VERSION = "copilot-chat/0.35.0"
_DEFAULT_COPILOT_INTEGRATION_ID = "vscode-chat"
_DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
_API_TOKEN_ENV_VARS = ( _API_TOKEN_ENV_VARS = (
"GITHUB_COPILOT_API_TOKEN", "GITHUB_COPILOT_API_TOKEN",
@ -80,16 +85,137 @@ class CopilotTokenCandidate:
validate_for_subscription: bool = True validate_for_subscription: bool = True
@dataclass(frozen=True)
class CopilotSubscriptionTokenResolution:
"""A Copilot subscription token plus safe routing metadata."""
token: str
source: str
confidence: str
api_url: str
token_fingerprint: str
def token_fingerprint(token: str) -> str:
"""Return a stable non-secret fingerprint for comparing token handoffs."""
digest = hashlib.sha256(token.encode("utf-8", errors="ignore")).hexdigest()
return f"sha256:{digest[:12]}"
def _github_host() -> str: def _github_host() -> str:
return (os.environ.get("GITHUB_COPILOT_HOST") or DEFAULT_GITHUB_HOST).strip().lower() return (os.environ.get("GITHUB_COPILOT_HOST") or DEFAULT_GITHUB_HOST).strip().lower()
def headroom_copilot_auth_path() -> Path:
"""Return the path where Headroom stores its Copilot OAuth token."""
override = os.environ.get("HEADROOM_COPILOT_AUTH_FILE", "").strip()
if override:
return Path(override).expanduser()
return paths.workspace_dir() / "copilot_auth.json"
def normalize_copilot_enterprise_url(enterprise_url: str) -> str:
"""Normalize a GitHub Enterprise URL or domain."""
return enterprise_url.strip().replace("https://", "").replace("http://", "").rstrip("/")
def _enterprise_hostname(enterprise_url: str) -> str:
normalized = normalize_copilot_enterprise_url(enterprise_url)
if not normalized:
return ""
parsed = urlparse(f"https://{normalized}")
return (parsed.hostname or normalized.split("/", 1)[0]).lower()
def _copilot_subdomain_enterprise_host(enterprise_url: str) -> str | None:
"""Return a host that supports api.<host> and copilot-api.<host> URLs.
GitHub.com Enterprise Cloud URLs such as ``github.com/enterprises/acme``
identify an account, not an API hostname.
"""
host = _enterprise_hostname(enterprise_url)
for prefix in ("copilot-api.", "api."):
if host.startswith(prefix):
host = host[len(prefix) :]
break
if not host or host in {"github.com", "www.github.com", "api.github.com"}:
return None
return host
def copilot_api_url_from_enterprise_url(enterprise_url: str) -> str:
"""Return a Copilot API base for GitHub Enterprise Server/custom domains."""
host = _copilot_subdomain_enterprise_host(enterprise_url)
if host is None:
return DEFAULT_API_URL
return f"https://copilot-api.{host}"
def _configured_enterprise_domain() -> str | None:
enterprise_url = (
os.environ.get("GITHUB_COPILOT_ENTERPRISE_URL", "").strip()
or os.environ.get("GITHUB_COPILOT_ENTERPRISE_DOMAIN", "").strip()
)
if not enterprise_url:
return None
return _copilot_subdomain_enterprise_host(enterprise_url)
def _configured_api_url() -> str:
api_url = os.environ.get("GITHUB_COPILOT_API_URL", "").strip()
if api_url:
return api_url.rstrip("/")
enterprise_domain = _configured_enterprise_domain()
if enterprise_domain:
return copilot_api_url_from_enterprise_url(enterprise_domain).rstrip("/")
return DEFAULT_API_URL
def _github_oauth_domain(domain: str | None = None) -> str:
raw = (domain or DEFAULT_GITHUB_HOST).strip()
if not raw:
return DEFAULT_GITHUB_HOST
host = _enterprise_hostname(raw)
return host or DEFAULT_GITHUB_HOST
def _github_oauth_urls(domain: str) -> dict[str, str]:
normalized = _github_oauth_domain(domain)
return {
"device_code": f"https://{normalized}/login/device/code",
"access_token": f"https://{normalized}/login/oauth/access_token",
}
def _token_exchange_url() -> str: def _token_exchange_url() -> str:
return os.environ.get("GITHUB_COPILOT_TOKEN_EXCHANGE_URL", DEFAULT_TOKEN_EXCHANGE_URL).strip() override = os.environ.get("GITHUB_COPILOT_TOKEN_EXCHANGE_URL", "").strip()
if override:
return override
enterprise_domain = _configured_enterprise_domain()
if enterprise_domain:
return f"https://api.{enterprise_domain}/copilot_internal/v2/token"
return DEFAULT_TOKEN_EXCHANGE_URL
def _user_info_url() -> str: def _user_info_url() -> str:
return os.environ.get("GITHUB_COPILOT_USER_INFO_URL", DEFAULT_USER_INFO_URL).strip() override = os.environ.get("GITHUB_COPILOT_USER_INFO_URL", "").strip()
if override:
return override
enterprise_domain = _configured_enterprise_domain()
if enterprise_domain:
return f"https://api.{enterprise_domain}/copilot_internal/user"
return DEFAULT_USER_INFO_URL
def _should_exchange_oauth_token() -> bool: def _should_exchange_oauth_token() -> bool:
@ -273,6 +399,143 @@ def _entry_expired(entry: dict[str, Any]) -> bool:
return False return False
def read_headroom_copilot_oauth_token() -> str | None:
"""Return Headroom's saved Copilot OAuth token, if one is available."""
try:
payload = json.loads(headroom_copilot_auth_path().read_text(encoding="utf-8"))
except FileNotFoundError:
return None
except Exception as exc:
logger.debug("Unable to read Headroom Copilot auth file: %s", exc)
return None
if not isinstance(payload, dict) or payload.get("type") != "oauth":
return None
token = payload.get("refresh")
return token.strip() if isinstance(token, str) and token.strip() else None
def save_headroom_copilot_oauth_token(
token: str,
*,
domain: str = DEFAULT_GITHUB_HOST,
) -> Path:
"""Persist the Copilot OAuth token returned by GitHub device login."""
token = token.strip()
if not token:
raise ValueError("Copilot OAuth token must not be empty.")
path = headroom_copilot_auth_path()
path.parent.mkdir(parents=True, exist_ok=True)
body: dict[str, Any] = {
"type": "oauth",
"provider": "github-copilot",
"refresh": token,
"domain": _github_oauth_domain(domain),
"created_at": int(time.time()),
}
path.write_text(json.dumps(body, indent=2, sort_keys=True) + "\n", encoding="utf-8")
try:
path.chmod(0o600)
except OSError:
pass
return path
def start_copilot_device_authorization(
*,
domain: str = DEFAULT_GITHUB_HOST,
timeout: float = 10.0,
) -> dict[str, Any]:
"""Start the GitHub Copilot OAuth device-code flow."""
urls = _github_oauth_urls(domain)
body = json.dumps(
{
"client_id": COPILOT_CHAT_OAUTH_CLIENT_ID,
"scope": "read:user",
},
separators=(",", ":"),
).encode("utf-8")
request = urllib_request.Request(
urls["device_code"],
data=body,
headers={
"Accept": "application/json",
"Content-Type": "application/json",
"User-Agent": _DEFAULT_USER_AGENT,
},
method="POST",
)
with urllib_request.urlopen(request, timeout=timeout) as response:
payload = json.loads(response.read().decode("utf-8", errors="replace"))
if not isinstance(payload, dict):
raise RuntimeError("GitHub device authorization returned an invalid response.")
return payload
def poll_copilot_device_authorization(
device_code: str,
*,
domain: str = DEFAULT_GITHUB_HOST,
interval: int = 5,
expires_in: int = 900,
timeout: float = 10.0,
) -> str:
"""Poll GitHub until the device-code OAuth flow returns an access token."""
urls = _github_oauth_urls(domain)
deadline = time.time() + max(1, expires_in)
poll_interval = max(1, interval)
while time.time() < deadline:
body = json.dumps(
{
"client_id": COPILOT_CHAT_OAUTH_CLIENT_ID,
"device_code": device_code,
"grant_type": _DEVICE_CODE_GRANT_TYPE,
},
separators=(",", ":"),
).encode("utf-8")
request = urllib_request.Request(
urls["access_token"],
data=body,
headers={
"Accept": "application/json",
"Content-Type": "application/json",
"User-Agent": _DEFAULT_USER_AGENT,
},
method="POST",
)
with urllib_request.urlopen(request, timeout=timeout) as response:
payload = json.loads(response.read().decode("utf-8", errors="replace"))
if not isinstance(payload, dict):
raise RuntimeError("GitHub device authorization returned an invalid response.")
access_token = payload.get("access_token")
if isinstance(access_token, str) and access_token.strip():
return access_token.strip()
error = str(payload.get("error") or "").strip()
if error == "authorization_pending":
time.sleep(poll_interval)
continue
if error == "slow_down":
poll_interval += 5
time.sleep(poll_interval)
continue
if error == "expired_token":
raise RuntimeError("GitHub device authorization expired.")
if error:
description = str(payload.get("error_description") or error).strip()
raise RuntimeError(f"GitHub device authorization failed: {description}")
time.sleep(poll_interval)
raise RuntimeError("GitHub device authorization expired.")
def _extract_oauth_token(entry: dict[str, Any]) -> str | None: def _extract_oauth_token(entry: dict[str, Any]) -> str | None:
if _entry_expired(entry): if _entry_expired(entry):
return None return None
@ -318,6 +581,16 @@ def iter_oauth_token_candidates() -> list[CopilotTokenCandidate]:
candidates: list[CopilotTokenCandidate] = [] candidates: list[CopilotTokenCandidate] = []
headroom_copilot_token = read_headroom_copilot_oauth_token()
if headroom_copilot_token:
candidates.append(
CopilotTokenCandidate(
token=headroom_copilot_token,
source=f"headroom-copilot-auth:{headroom_copilot_auth_path()}",
confidence="copilot-oauth",
)
)
for env_var in _COPILOT_OAUTH_TOKEN_ENV_VARS: for env_var in _COPILOT_OAUTH_TOKEN_ENV_VARS:
token = os.environ.get(env_var, "").strip() token = os.environ.get(env_var, "").strip()
if token: if token:
@ -438,28 +711,182 @@ def resolve_client_bearer_token() -> str | None:
return read_cached_oauth_token() return read_cached_oauth_token()
def resolve_subscription_bearer_token() -> str | None: def _copilot_chat_header_defaults() -> dict[str, str]:
"""Return the first discovered token that GitHub accepts for Copilot subscription APIs.""" return {
"User-Agent": os.environ.get("GITHUB_COPILOT_USER_AGENT", _DEFAULT_USER_AGENT).strip()
or _DEFAULT_USER_AGENT,
"Editor-Version": os.environ.get(
"GITHUB_COPILOT_EDITOR_VERSION", _DEFAULT_EDITOR_VERSION
).strip()
or _DEFAULT_EDITOR_VERSION,
"Editor-Plugin-Version": os.environ.get(
"GITHUB_COPILOT_EDITOR_PLUGIN_VERSION",
_DEFAULT_EDITOR_PLUGIN_VERSION,
).strip()
or _DEFAULT_EDITOR_PLUGIN_VERSION,
"Copilot-Integration-Id": os.environ.get(
"GITHUB_COPILOT_INTEGRATION_ID",
_DEFAULT_COPILOT_INTEGRATION_ID,
).strip()
or _DEFAULT_COPILOT_INTEGRATION_ID,
}
def _set_header_default(headers: dict[str, str], name: str, value: str) -> None:
"""Set a header default without duplicating case-insensitive equivalents."""
name_lower = name.lower()
if any(existing.lower() == name_lower for existing in headers):
return
headers[name] = value
def _copilot_token_exchange_headers(oauth_token: str) -> dict[str, str]:
return {
"Accept": "application/json",
"Authorization": f"Bearer {oauth_token}",
**_copilot_chat_header_defaults(),
}
def _api_url_from_payload(payload: dict[str, Any] | None) -> str | None:
endpoints = payload.get("endpoints") if isinstance(payload, dict) else None
api_url = endpoints.get("api") if isinstance(endpoints, dict) else None
if isinstance(api_url, str) and api_url.strip():
return api_url.strip().rstrip("/")
return None
def _subscription_api_url_from_user_info_payload(payload: dict[str, Any] | None) -> str:
api_url = _api_url_from_payload(payload)
if not api_url:
return _configured_api_url()
host = urlparse(api_url).netloc.lower()
if host in {"api.githubcopilot.com", "api.individual.githubcopilot.com"}:
return _configured_api_url()
if host.endswith(".githubcopilot.com"):
return api_url
return _configured_api_url()
def _subscription_api_url_from_user_info(oauth_token: str) -> str:
return _subscription_api_url_from_user_info_payload(_fetch_copilot_user_info(oauth_token))
def _api_url_from_exchange_payload(payload: dict[str, Any], *, oauth_token: str) -> str:
configured = _configured_api_url()
if configured != DEFAULT_API_URL:
return configured
api_url = _api_url_from_payload(payload)
if api_url:
return api_url
return _subscription_api_url_from_user_info(oauth_token)
def _subscription_resolution(
*,
token: str,
source: str,
confidence: str,
api_url: str,
) -> CopilotSubscriptionTokenResolution:
return CopilotSubscriptionTokenResolution(
token=token,
source=source,
confidence=confidence,
api_url=api_url,
token_fingerprint=token_fingerprint(token),
)
def _subscription_resolution_from_token_exchange(
candidate: CopilotTokenCandidate,
) -> CopilotSubscriptionTokenResolution | None:
"""Exchange a reusable GitHub OAuth token for a Copilot API token."""
try:
payload = CopilotTokenProvider._exchange_token_sync(
_copilot_token_exchange_headers(candidate.token)
)
except Exception as exc:
logger.debug(
"Unable to exchange Copilot OAuth token from %s via %s: %s",
candidate.source,
_token_exchange_url(),
exc,
)
return None
token = str(payload.get("token") or "").strip()
if not token:
logger.debug("Copilot token exchange from %s returned no token", candidate.source)
return None
return _subscription_resolution(
token=token,
source=f"{candidate.source}:token-exchange",
confidence="copilot-token-exchange",
api_url=_api_url_from_exchange_payload(payload, oauth_token=candidate.token),
)
def resolve_subscription_bearer_token_details() -> CopilotSubscriptionTokenResolution | None:
"""Return the first discovered token that GitHub accepts for subscription APIs."""
for env_var in _API_TOKEN_ENV_VARS: for env_var in _API_TOKEN_ENV_VARS:
token = os.environ.get(env_var, "").strip() token = os.environ.get(env_var, "").strip()
if token and _fetch_copilot_user_info(token) is not None: if not token:
return token continue
payload = _fetch_copilot_user_info(token)
if payload is not None:
return _subscription_resolution(
token=token,
source=f"env:{env_var}",
confidence="explicit-api-token",
api_url=_subscription_api_url_from_user_info_payload(payload),
)
for candidate in iter_oauth_token_candidates(): for candidate in iter_oauth_token_candidates():
if not candidate.validate_for_subscription: if not candidate.validate_for_subscription:
continue continue
if _fetch_copilot_user_info(candidate.token) is not None: if _is_copilot_api_token(candidate.token):
payload = _fetch_copilot_user_info(candidate.token)
if payload is not None:
logger.debug(
"Using Copilot API subscription token from %s (%s)",
candidate.source,
candidate.confidence,
)
return _subscription_resolution(
token=candidate.token,
source=candidate.source,
confidence=candidate.confidence,
api_url=_subscription_api_url_from_user_info_payload(payload),
)
continue
exchanged = _subscription_resolution_from_token_exchange(candidate)
if exchanged is not None:
logger.debug( logger.debug(
"Using Copilot subscription token from %s (%s)", "Using exchanged Copilot subscription token from %s (%s)",
candidate.source, candidate.source,
candidate.confidence, candidate.confidence,
) )
return candidate.token return exchanged
return None return None
def resolve_subscription_bearer_token() -> str | None:
"""Return the first discovered token that GitHub accepts for Copilot subscription APIs."""
resolution = resolve_subscription_bearer_token_details()
return resolution.token if resolution is not None else None
def has_oauth_auth() -> bool: def has_oauth_auth() -> bool:
"""Return True when existing Copilot auth can be reused.""" """Return True when existing Copilot auth can be reused."""
@ -473,7 +900,30 @@ def is_copilot_api_url(url: str | None) -> bool:
return False return False
parsed = urlparse(url) parsed = urlparse(url)
host = parsed.netloc.lower() or parsed.path.lower() host = parsed.netloc.lower() or parsed.path.lower()
return "githubcopilot.com" in host configured_host = urlparse(_configured_api_url()).netloc.lower()
if configured_host and host == configured_host:
return True
hostname = (parsed.hostname or host.split("/", 1)[0]).lower()
return _is_public_copilot_api_host(hostname) or _is_ghe_copilot_api_host(hostname)
def _is_public_copilot_api_host(host: str) -> bool:
"""Return True for GitHub-hosted Copilot API domains."""
return host == "githubcopilot.com" or host.endswith(".githubcopilot.com")
def _is_ghe_copilot_api_host(host: str) -> bool:
"""Return True for GitHub Enterprise Copilot API hosts.
GHE Copilot deployments use hosts like ``copilot-api.<tenant>.ghe.com``.
Restrict this to the Copilot API subdomain so unrelated GHE hosts do not
receive Copilot auth headers or Copilot-specific path normalization.
"""
return host == "copilot-api.ghe.com" or (
host.startswith("copilot-api.") and host.endswith(".ghe.com")
)
def build_copilot_upstream_url(base_url: str, path: str) -> str: def build_copilot_upstream_url(base_url: str, path: str) -> str:
@ -506,8 +956,7 @@ def resolve_copilot_api_url(oauth_token: str | None = None) -> str:
""" """
del oauth_token # reserved; routing no longer depends on a user-info lookup del oauth_token # reserved; routing no longer depends on a user-info lookup
override = os.environ.get("GITHUB_COPILOT_API_URL", "").strip() return _configured_api_url()
return override or DEFAULT_API_URL
def _fetch_copilot_user_info(token: str) -> dict[str, Any] | None: def _fetch_copilot_user_info(token: str) -> dict[str, Any] | None:
@ -517,10 +966,7 @@ def _fetch_copilot_user_info(token: str) -> dict[str, Any] | None:
if not token: if not token:
return None return None
headers = { headers = _copilot_token_exchange_headers(token)
"Authorization": f"Bearer {token}",
"Accept": "application/json",
}
request = urllib_request.Request(_user_info_url(), headers=headers, method="GET") request = urllib_request.Request(_user_info_url(), headers=headers, method="GET")
try: try:
with urllib_request.urlopen(request, timeout=10.0) as response: with urllib_request.urlopen(request, timeout=10.0) as response:
@ -545,8 +991,7 @@ class CopilotTokenProvider:
return CopilotAPIToken( return CopilotAPIToken(
token=explicit_api_token, token=explicit_api_token,
expires_at=time.time() + 3600, expires_at=time.time() + 3600,
api_url=os.environ.get("GITHUB_COPILOT_API_URL", DEFAULT_API_URL).strip() api_url=_configured_api_url(),
or DEFAULT_API_URL,
) )
cached = self._cached cached = self._cached
@ -566,8 +1011,7 @@ class CopilotTokenProvider:
direct_token = CopilotAPIToken( direct_token = CopilotAPIToken(
token=oauth_token, token=oauth_token,
expires_at=time.time() + 3600, expires_at=time.time() + 3600,
api_url=os.environ.get("GITHUB_COPILOT_API_URL", DEFAULT_API_URL).strip() api_url=_configured_api_url(),
or DEFAULT_API_URL,
) )
self._cached = direct_token self._cached = direct_token
return direct_token return direct_token
@ -577,23 +1021,18 @@ class CopilotTokenProvider:
return exchanged return exchanged
async def _exchange_token(self, oauth_token: str) -> CopilotAPIToken: async def _exchange_token(self, oauth_token: str) -> CopilotAPIToken:
headers = { headers = _copilot_token_exchange_headers(oauth_token)
"Authorization": f"Bearer {oauth_token}",
"Accept": "application/json",
"Editor-Version": os.environ.get(
"GITHUB_COPILOT_EDITOR_VERSION", _DEFAULT_EDITOR_VERSION
),
"User-Agent": _DEFAULT_USER_AGENT,
}
payload = await asyncio.to_thread(self._exchange_token_sync, headers) payload = await asyncio.to_thread(self._exchange_token_sync, headers)
token = str(payload.get("token") or "").strip() token = str(payload.get("token") or "").strip()
if not token: if not token:
raise RuntimeError("Copilot token exchange returned an empty token.") raise RuntimeError("Copilot token exchange returned an empty token.")
expires_at = _parse_expiry(payload.get("expires_at")) or (time.time() + 1800) expires_at = _parse_expiry(payload.get("expires_at")) or (time.time() + 1800)
raw_endpoints = payload.get("endpoints") api_url = await asyncio.to_thread(
endpoints: dict[str, Any] = raw_endpoints if isinstance(raw_endpoints, dict) else {} _api_url_from_exchange_payload,
api_url = str(endpoints.get("api") or DEFAULT_API_URL).strip() or DEFAULT_API_URL payload,
oauth_token=oauth_token,
)
refresh_in = payload.get("refresh_in") refresh_in = payload.get("refresh_in")
sku = payload.get("sku") sku = payload.get("sku")
return CopilotAPIToken( return CopilotAPIToken(
@ -667,15 +1106,8 @@ async def apply_copilot_api_auth(headers: dict[str, str], *, url: str) -> dict[s
if not is_copilot_api_url(url): if not is_copilot_api_url(url):
return resolved return resolved
lower_keys = {k.lower() for k in resolved} for name, value in _copilot_chat_header_defaults().items():
if "copilot-integration-id" not in lower_keys: _set_header_default(resolved, name, value)
resolved["Copilot-Integration-Id"] = os.environ.get(
"GITHUB_COPILOT_INTEGRATION_ID", _DEFAULT_INTEGRATION_ID
)
if "editor-version" not in lower_keys:
resolved["editor-version"] = os.environ.get(
"GITHUB_COPILOT_EDITOR_VERSION", _DEFAULT_EDITOR_VERSION
)
incoming_auth = next((v for k, v in resolved.items() if k.lower() == "authorization"), None) incoming_auth = next((v for k, v in resolved.items() if k.lower() == "authorization"), None)
if incoming_auth: if incoming_auth:
@ -685,6 +1117,9 @@ async def apply_copilot_api_auth(headers: dict[str, str], *, url: str) -> dict[s
"apply_copilot_api_auth: passing through client token kind=%s", "apply_copilot_api_auth: passing through client token kind=%s",
_token_kind(raw_token), _token_kind(raw_token),
) )
for key in list(resolved):
if key.lower() == "x-api-key":
resolved.pop(key)
return resolved return resolved
logger.info( logger.info(
"apply_copilot_api_auth: incoming token not suitable (kind=%s), will replace", "apply_copilot_api_auth: incoming token not suitable (kind=%s), will replace",
@ -693,7 +1128,7 @@ async def apply_copilot_api_auth(headers: dict[str, str], *, url: str) -> dict[s
token = await get_copilot_token_provider().get_api_token() token = await get_copilot_token_provider().get_api_token()
for key in list(resolved): for key in list(resolved):
if key.lower() == "authorization": if key.lower() in {"authorization", "x-api-key"}:
resolved.pop(key) resolved.pop(key)
resolved["Authorization"] = f"Bearer {token.token}" resolved["Authorization"] = f"Bearer {token.token}"
return resolved return resolved

View file

@ -0,0 +1,50 @@
from __future__ import annotations
from pathlib import Path
import pytest
from click.testing import CliRunner
from headroom.cli import main
def test_copilot_auth_login_saves_token(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
auth_file = tmp_path / "copilot_auth.json"
monkeypatch.setenv("HEADROOM_COPILOT_AUTH_FILE", str(auth_file))
monkeypatch.setattr(
"headroom.cli.copilot_auth.start_copilot_device_authorization",
lambda domain: {
"verification_uri": "https://github.com/login/device",
"user_code": "ABCD-1234",
"device_code": "device-code",
"interval": 1,
"expires_in": 900,
},
)
monkeypatch.setattr(
"headroom.cli.copilot_auth.poll_copilot_device_authorization",
lambda device_code, *, domain, interval, expires_in: "gho-headroom",
)
result = CliRunner().invoke(main, ["copilot-auth", "login"])
assert result.exit_code == 0, result.output
assert "https://github.com/login/device" in result.output
assert "ABCD-1234" in result.output
assert "gho-headroom" not in result.output
assert auth_file.exists()
def test_copilot_auth_status_reports_missing_login(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.setenv("HEADROOM_COPILOT_AUTH_FILE", str(tmp_path / "missing.json"))
result = CliRunner().invoke(main, ["copilot-auth", "status"])
assert result.exit_code == 0, result.output
assert "Status: not logged in" in result.output

View file

@ -14,7 +14,7 @@ import click
import pytest import pytest
from click.testing import CliRunner from click.testing import CliRunner
from headroom.copilot_auth import DEFAULT_API_URL from headroom.copilot_auth import DEFAULT_API_URL, CopilotSubscriptionTokenResolution
def _expected_project_prefix() -> str: def _expected_project_prefix() -> str:
@ -27,6 +27,22 @@ def runner() -> CliRunner:
return CliRunner() return CliRunner()
def _subscription_resolution(
token: str = "gho-existing",
*,
api_url: str = DEFAULT_API_URL,
source: str = "headroom-copilot-auth:/tmp/copilot_auth.json:token-exchange",
confidence: str = "copilot-token-exchange",
) -> CopilotSubscriptionTokenResolution:
return CopilotSubscriptionTokenResolution(
token=token,
source=source,
confidence=confidence,
api_url=api_url,
token_fingerprint="sha256:0123456789ab",
)
@pytest.fixture @pytest.fixture
def wrap_modules(monkeypatch: pytest.MonkeyPatch) -> tuple[types.ModuleType, click.Group]: def wrap_modules(monkeypatch: pytest.MonkeyPatch) -> tuple[types.ModuleType, click.Group]:
headroom_pkg = sys.modules.get("headroom") headroom_pkg = sys.modules.get("headroom")
@ -247,7 +263,10 @@ def test_wrap_copilot_subscription_uses_github_auth_without_provider_key(
with ( with (
patch("headroom.cli.wrap.shutil.which", return_value="copilot"), patch("headroom.cli.wrap.shutil.which", return_value="copilot"),
patch("headroom.cli.wrap.resolve_subscription_bearer_token", return_value="gho-existing"), patch(
"headroom.cli.wrap.resolve_subscription_bearer_token_details",
return_value=_subscription_resolution(),
),
patch("headroom.cli.wrap.has_oauth_auth", return_value=False), patch("headroom.cli.wrap.has_oauth_auth", return_value=False),
patch("headroom.cli.wrap._launch_tool", side_effect=fake_launch_tool), patch("headroom.cli.wrap._launch_tool", side_effect=fake_launch_tool),
): ):
@ -284,7 +303,10 @@ def test_wrap_copilot_subscription_defaults_to_responses_for_reasoning_model(
with ( with (
patch("headroom.cli.wrap.shutil.which", return_value="copilot"), patch("headroom.cli.wrap.shutil.which", return_value="copilot"),
patch("headroom.cli.wrap.resolve_subscription_bearer_token", return_value="gho-existing"), patch(
"headroom.cli.wrap.resolve_subscription_bearer_token_details",
return_value=_subscription_resolution("gho-existing"),
),
patch("headroom.cli.wrap.has_oauth_auth", return_value=False), patch("headroom.cli.wrap.has_oauth_auth", return_value=False),
patch("headroom.cli.wrap._launch_tool", side_effect=fake_launch_tool), patch("headroom.cli.wrap._launch_tool", side_effect=fake_launch_tool),
): ):
@ -321,7 +343,10 @@ def test_wrap_copilot_subscription_keeps_gpt4_on_completions(
with ( with (
patch("headroom.cli.wrap.shutil.which", return_value="copilot"), patch("headroom.cli.wrap.shutil.which", return_value="copilot"),
patch("headroom.cli.wrap.resolve_subscription_bearer_token", return_value="gho-existing"), patch(
"headroom.cli.wrap.resolve_subscription_bearer_token_details",
return_value=_subscription_resolution("gho-existing"),
),
patch("headroom.cli.wrap.has_oauth_auth", return_value=False), patch("headroom.cli.wrap.has_oauth_auth", return_value=False),
patch("headroom.cli.wrap._launch_tool", side_effect=fake_launch_tool), patch("headroom.cli.wrap._launch_tool", side_effect=fake_launch_tool),
): ):
@ -351,7 +376,10 @@ def test_wrap_copilot_subscription_allows_explicit_responses_wire_api(
with ( with (
patch("headroom.cli.wrap.shutil.which", return_value="copilot"), patch("headroom.cli.wrap.shutil.which", return_value="copilot"),
patch("headroom.cli.wrap.resolve_subscription_bearer_token", return_value="gho-existing"), patch(
"headroom.cli.wrap.resolve_subscription_bearer_token_details",
return_value=_subscription_resolution("gho-existing"),
),
patch("headroom.cli.wrap.has_oauth_auth", return_value=False), patch("headroom.cli.wrap.has_oauth_auth", return_value=False),
patch("headroom.cli.wrap._launch_tool", side_effect=fake_launch_tool), patch("headroom.cli.wrap._launch_tool", side_effect=fake_launch_tool),
): ):
@ -403,10 +431,9 @@ def test_wrap_copilot_subscription_pins_validated_token_for_proxy(
with ( with (
patch("headroom.cli.wrap.shutil.which", return_value="copilot"), patch("headroom.cli.wrap.shutil.which", return_value="copilot"),
patch( patch(
"headroom.cli.wrap.resolve_subscription_bearer_token", "headroom.cli.wrap.resolve_subscription_bearer_token_details",
return_value="gho-validated", return_value=_subscription_resolution("gho-validated", api_url=business_api),
), ),
patch("headroom.cli.wrap.resolve_copilot_api_url", return_value=business_api),
patch("headroom.cli.wrap.has_oauth_auth", return_value=False), patch("headroom.cli.wrap.has_oauth_auth", return_value=False),
patch("headroom.cli.wrap._launch_tool", side_effect=fake_launch_tool), patch("headroom.cli.wrap._launch_tool", side_effect=fake_launch_tool),
): ):
@ -437,12 +464,13 @@ def test_wrap_copilot_subscription_requires_reusable_auth(
_wrap_cli, main = wrap_modules _wrap_cli, main = wrap_modules
with ( with (
patch("headroom.cli.wrap.shutil.which", return_value="copilot"), patch("headroom.cli.wrap.shutil.which", return_value="copilot"),
patch("headroom.cli.wrap.resolve_subscription_bearer_token", return_value=None), patch("headroom.cli.wrap.resolve_subscription_bearer_token_details", return_value=None),
): ):
result = runner.invoke(main, ["wrap", "copilot", "--subscription", "--no-rtk"]) result = runner.invoke(main, ["wrap", "copilot", "--subscription", "--no-rtk"])
assert result.exit_code != 0 assert result.exit_code != 0
assert "subscription mode requires a reusable GitHub/Copilot bearer token" in result.output assert "subscription mode requires a reusable GitHub/Copilot bearer token" in result.output
assert "headroom copilot-auth login" in result.output
def test_wrap_copilot_subscription_rejects_translated_backend( def test_wrap_copilot_subscription_rejects_translated_backend(
@ -634,6 +662,8 @@ def _clear_copilot_env(monkeypatch: pytest.MonkeyPatch) -> None:
"OPENAI_API_KEY", "OPENAI_API_KEY",
"ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY",
"GITHUB_COPILOT_API_URL", "GITHUB_COPILOT_API_URL",
"GITHUB_COPILOT_ENTERPRISE_URL",
"GITHUB_COPILOT_ENTERPRISE_DOMAIN",
"GITHUB_COPILOT_TOKEN", "GITHUB_COPILOT_TOKEN",
"GITHUB_COPILOT_GITHUB_TOKEN", "GITHUB_COPILOT_GITHUB_TOKEN",
"COPILOT_MODEL", "COPILOT_MODEL",
@ -747,17 +777,15 @@ def test_wrap_copilot_byok_never_resolves_copilot_endpoint(
assert env["COPILOT_PROVIDER_TYPE"] == "openai" assert env["COPILOT_PROVIDER_TYPE"] == "openai"
def test_wrap_copilot_subscription_uses_generic_endpoint_not_account( def test_wrap_copilot_subscription_uses_resolved_subscription_endpoint(
runner: CliRunner, runner: CliRunner,
wrap_modules: tuple[types.ModuleType, click.Group], wrap_modules: tuple[types.ModuleType, click.Group],
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
"""#610 (subscription has the same latent bug): --subscription must route to """Subscription mode uses the endpoint returned with the resolved token."""
the generic host too, even when /copilot_internal/user advertises an
account-specific host. The segmented host does not serve newer models on the
responses API, and it is not the host the official Copilot client uses."""
_wrap_cli, main = wrap_modules _wrap_cli, main = wrap_modules
_clear_copilot_env(monkeypatch) _clear_copilot_env(monkeypatch)
business_api = "https://api.business.githubcopilot.com"
captured: dict[str, object] = {} captured: dict[str, object] = {}
def fake_launch_tool(**kwargs): # noqa: ANN003 def fake_launch_tool(**kwargs): # noqa: ANN003
@ -765,7 +793,10 @@ def test_wrap_copilot_subscription_uses_generic_endpoint_not_account(
with ( with (
patch("headroom.cli.wrap.shutil.which", return_value="copilot"), patch("headroom.cli.wrap.shutil.which", return_value="copilot"),
patch("headroom.cli.wrap.resolve_subscription_bearer_token", return_value="gho-sub"), patch(
"headroom.cli.wrap.resolve_subscription_bearer_token_details",
return_value=_subscription_resolution("copilot-api", api_url=business_api),
),
patch("headroom.cli.wrap.has_oauth_auth", return_value=True), patch("headroom.cli.wrap.has_oauth_auth", return_value=True),
patch("headroom.copilot_auth._fetch_copilot_user_info", return_value=_ACCOUNT_USER_INFO), patch("headroom.copilot_auth._fetch_copilot_user_info", return_value=_ACCOUNT_USER_INFO),
patch("headroom.cli.wrap._launch_tool", side_effect=fake_launch_tool), patch("headroom.cli.wrap._launch_tool", side_effect=fake_launch_tool),
@ -778,9 +809,9 @@ def test_wrap_copilot_subscription_uses_generic_endpoint_not_account(
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
env = captured["env"] env = captured["env"]
assert isinstance(env, dict) assert isinstance(env, dict)
assert captured["openai_api_url"] == DEFAULT_API_URL assert captured["openai_api_url"] == business_api
assert env["OPENAI_TARGET_API_URL"] == DEFAULT_API_URL assert env["OPENAI_TARGET_API_URL"] == business_api
assert env["COPILOT_PROVIDER_BEARER_TOKEN"] == "gho-sub" assert env["COPILOT_PROVIDER_BEARER_TOKEN"] == "copilot-api"
def test_wrap_copilot_subscription_honors_api_url_override( def test_wrap_copilot_subscription_honors_api_url_override(
@ -800,7 +831,15 @@ def test_wrap_copilot_subscription_honors_api_url_override(
with ( with (
patch("headroom.cli.wrap.shutil.which", return_value="copilot"), patch("headroom.cli.wrap.shutil.which", return_value="copilot"),
patch("headroom.cli.wrap.resolve_subscription_bearer_token", return_value="gho-sub"), patch(
"headroom.cli.wrap.resolve_subscription_bearer_token_details",
return_value=_subscription_resolution(
"gho-sub",
api_url="https://api.enterprise.example.com",
source="env:GITHUB_COPILOT_API_TOKEN",
confidence="explicit-api-token",
),
),
patch("headroom.cli.wrap.has_oauth_auth", return_value=True), patch("headroom.cli.wrap.has_oauth_auth", return_value=True),
patch("headroom.cli.wrap._launch_tool", side_effect=fake_launch_tool), patch("headroom.cli.wrap._launch_tool", side_effect=fake_launch_tool),
): ):

View file

@ -76,6 +76,36 @@ class TestCLIWrapProxyTimeout:
assert sleeps == [1] assert sleeps == [1]
assert fake_proc.killed is False assert fake_proc.killed is False
def test_start_proxy_passes_resolved_copilot_api_url_to_proxy(self, monkeypatch, tmp_path):
fake_proc = _FakeProxyProcess()
captured: dict[str, object] = {}
monkeypatch.delenv(wrap_mod._WRAP_PROXY_TIMEOUT_ENV, raising=False)
monkeypatch.setattr(wrap_mod, "_ml_wrap_extras_detected", lambda: False)
monkeypatch.setattr(wrap_mod, "_get_log_path", lambda: tmp_path / "proxy.log")
monkeypatch.setattr(wrap_mod, "_check_proxy", lambda _port: True)
monkeypatch.setattr(wrap_mod.time, "sleep", lambda _seconds: None)
def fake_popen(*args, **kwargs): # noqa: ANN002, ANN003
captured["args"] = args
captured["kwargs"] = kwargs
return fake_proc
monkeypatch.setattr(wrap_mod.subprocess, "Popen", fake_popen)
proc = wrap_mod._start_proxy(
8787,
agent_type="copilot",
openai_api_url="https://copilot-api.acme.ghe.com",
copilot_api_token="copilot-api-token",
)
assert proc is fake_proc
env = captured["kwargs"]["env"]
assert env["OPENAI_TARGET_API_URL"] == "https://copilot-api.acme.ghe.com"
assert env["GITHUB_COPILOT_API_URL"] == "https://copilot-api.acme.ghe.com"
assert env["GITHUB_COPILOT_API_TOKEN"] == "copilot-api-token"
def test_env_timeout_allows_slow_start_proxy_to_succeed(self, monkeypatch, tmp_path): def test_env_timeout_allows_slow_start_proxy_to_succeed(self, monkeypatch, tmp_path):
fake_proc = _FakeProxyProcess() fake_proc = _FakeProxyProcess()
sleeps = [] sleeps = []

View file

@ -12,11 +12,49 @@ import pytest
from headroom import copilot_auth from headroom import copilot_auth
@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)
def test_read_cached_oauth_token_prefers_env(monkeypatch: pytest.MonkeyPatch) -> None: def test_read_cached_oauth_token_prefers_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("GITHUB_COPILOT_TOKEN", "gho-env") monkeypatch.setenv("GITHUB_COPILOT_TOKEN", "gho-env")
assert copilot_auth.read_cached_oauth_token() == "gho-env" assert copilot_auth.read_cached_oauth_token() == "gho-env"
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"
def test_read_cached_oauth_token_prefers_copilot_cli_before_generic_github_token( def test_read_cached_oauth_token_prefers_copilot_cli_before_generic_github_token(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
@ -56,6 +94,9 @@ def test_resolve_subscription_bearer_token_skips_invalid_generic_token(
) -> None: ) -> None:
monkeypatch.delenv("GITHUB_COPILOT_API_TOKEN", raising=False) monkeypatch.delenv("GITHUB_COPILOT_API_TOKEN", raising=False)
monkeypatch.delenv("COPILOT_PROVIDER_BEARER_TOKEN", raising=False) monkeypatch.delenv("COPILOT_PROVIDER_BEARER_TOKEN", raising=False)
monkeypatch.setattr(
copilot_auth, "_subscription_resolution_from_token_exchange", lambda _: None
)
monkeypatch.setattr( monkeypatch.setattr(
copilot_auth, copilot_auth,
"iter_oauth_token_candidates", "iter_oauth_token_candidates",
@ -65,6 +106,38 @@ def test_resolve_subscription_bearer_token_skips_invalid_generic_token(
source="env:GITHUB_TOKEN", source="env:GITHUB_TOKEN",
confidence="generic-github", confidence="generic-github",
), ),
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: [
copilot_auth.CopilotTokenCandidate( copilot_auth.CopilotTokenCandidate(
token="gho-copilot", token="gho-copilot",
source="macos-keychain:copilot-cli", source="macos-keychain:copilot-cli",
@ -82,7 +155,148 @@ def test_resolve_subscription_bearer_token_skips_invalid_generic_token(
), ),
) )
assert copilot_auth.resolve_subscription_bearer_token() == "gho-copilot" 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"
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
)
def test_should_exchange_oauth_token_supports_truthy_values( def test_should_exchange_oauth_token_supports_truthy_values(
@ -292,6 +506,22 @@ def test_is_copilot_api_url_matches_expected_hosts() -> None:
assert not copilot_auth.is_copilot_api_url("https://api.openai.com/v1/chat/completions") assert not copilot_auth.is_copilot_api_url("https://api.openai.com/v1/chat/completions")
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")
def test_build_copilot_upstream_url_strips_v1_only_for_copilot_hosts() -> None: def test_build_copilot_upstream_url_strips_v1_only_for_copilot_hosts() -> None:
assert ( assert (
copilot_auth.build_copilot_upstream_url( copilot_auth.build_copilot_upstream_url(
@ -309,6 +539,37 @@ def test_build_copilot_upstream_url_strips_v1_only_for_copilot_hosts() -> None:
) )
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"
)
def test_apply_copilot_api_auth_replaces_authorization(monkeypatch: pytest.MonkeyPatch) -> None: def test_apply_copilot_api_auth_replaces_authorization(monkeypatch: pytest.MonkeyPatch) -> None:
async def fake_get_api_token() -> copilot_auth.CopilotAPIToken: async def fake_get_api_token() -> copilot_auth.CopilotAPIToken:
return copilot_auth.CopilotAPIToken( return copilot_auth.CopilotAPIToken(
@ -325,13 +586,18 @@ def test_apply_copilot_api_auth_replaces_authorization(monkeypatch: pytest.Monke
headers = asyncio.run( headers = asyncio.run(
copilot_auth.apply_copilot_api_auth( copilot_auth.apply_copilot_api_auth(
{"authorization": "Bearer downstream-token"}, {"authorization": "Bearer downstream-token", "x-api-key": "sk-downstream"},
url="https://api.githubcopilot.com/v1/chat/completions", url="https://api.githubcopilot.com/v1/chat/completions",
) )
) )
assert headers["Authorization"] == "Bearer copilot-session" assert headers["Authorization"] == "Bearer copilot-session"
assert "authorization" not in headers assert "authorization" not in headers
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"
def test_apply_copilot_api_auth_passes_through_existing_api_token( def test_apply_copilot_api_auth_passes_through_existing_api_token(
@ -348,12 +614,16 @@ def test_apply_copilot_api_auth_passes_through_existing_api_token(
headers = asyncio.run( headers = asyncio.run(
copilot_auth.apply_copilot_api_auth( copilot_auth.apply_copilot_api_auth(
{"authorization": "Bearer tid_existing_copilot_token"}, {
"authorization": "Bearer tid_existing_copilot_token",
"x-api-key": "sk-downstream",
},
url="https://api.githubcopilot.com/v1/chat/completions", url="https://api.githubcopilot.com/v1/chat/completions",
) )
) )
assert headers["authorization"] == "Bearer tid_existing_copilot_token" assert headers["authorization"] == "Bearer tid_existing_copilot_token"
assert "x-api-key" not in headers
def test_apply_copilot_api_auth_replaces_github_oauth_bearer( def test_apply_copilot_api_auth_replaces_github_oauth_bearer(
@ -446,7 +716,8 @@ def test_apply_copilot_api_auth_injects_required_headers(
assert headers["Authorization"] == "Bearer copilot-session" assert headers["Authorization"] == "Bearer copilot-session"
assert headers["Copilot-Integration-Id"] == "vscode-chat" assert headers["Copilot-Integration-Id"] == "vscode-chat"
assert headers["editor-version"] == "vscode/1.104.1" assert headers["Editor-Version"] == "vscode/1.107.0"
assert headers["Editor-Plugin-Version"] == "copilot-chat/0.35.0"
def test_apply_copilot_api_auth_preserves_existing_copilot_headers( def test_apply_copilot_api_auth_preserves_existing_copilot_headers(
@ -483,6 +754,47 @@ def test_apply_copilot_api_auth_preserves_existing_copilot_headers(
assert headers["Authorization"] == "Bearer copilot-session" assert headers["Authorization"] == "Bearer copilot-session"
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
def test_token_provider_reuses_oauth_token_without_exchange( def test_token_provider_reuses_oauth_token_without_exchange(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
@ -517,9 +829,11 @@ def test_token_provider_can_exchange_when_enabled(monkeypatch: pytest.MonkeyPatc
provider = copilot_auth.CopilotTokenProvider() provider = copilot_auth.CopilotTokenProvider()
calls = {"count": 0} calls = {"count": 0}
captured: dict[str, str] = {}
def fake_exchange(headers: dict[str, str]) -> dict[str, object]: def fake_exchange(headers: dict[str, str]) -> dict[str, object]:
calls["count"] += 1 calls["count"] += 1
captured.update(headers)
return { return {
"token": "copilot-api", "token": "copilot-api",
"expires_at": int(time.time()) + 3600, "expires_at": int(time.time()) + 3600,
@ -536,6 +850,11 @@ def test_token_provider_can_exchange_when_enabled(monkeypatch: pytest.MonkeyPatc
assert first.token == "copilot-api" assert first.token == "copilot-api"
assert second.token == "copilot-api" assert second.token == "copilot-api"
assert calls["count"] == 1 assert calls["count"] == 1
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"
def test_token_provider_prefers_explicit_api_token(monkeypatch: pytest.MonkeyPatch) -> None: def test_token_provider_prefers_explicit_api_token(monkeypatch: pytest.MonkeyPatch) -> None:

View file

@ -5,10 +5,11 @@ The subscription flow has to behave identically on macOS, Linux, and Windows
Copilot CLI token from the platform secret store is impossible to exercise Copilot CLI token from the platform secret store is impossible to exercise
portably. This suite proves the *portable* contract instead: portably. This suite proves the *portable* contract instead:
1. With an explicit token in the environment, resolution + API-URL discovery 1. With an explicit Copilot API token in the environment, resolution + API-URL
succeed on every platform without touching any secret store. This is the discovery succeed on every platform without touching any secret store. This
universal escape hatch (``GITHUB_COPILOT_TOKEN`` etc.) that makes the is the deterministic escape hatch (``GITHUB_COPILOT_API_TOKEN``) for
feature work anywhere, including headless CI. headless CI. OAuth tokens still need successful token exchange before
subscription mode can use them.
2. Each OS-specific secret reader is inert on a foreign platform so on any 2. Each OS-specific secret reader is inert on a foreign platform so on any
given OS only that OS's reader can fire, and a missing/foreign secret store given OS only that OS's reader can fire, and a missing/foreign secret store
degrades to ``None`` rather than crashing. degrades to ``None`` rather than crashing.
@ -33,6 +34,7 @@ BUSINESS_API = "https://api.business.githubcopilot.com"
def _stub_all_secret_stores(monkeypatch: pytest.MonkeyPatch) -> None: def _stub_all_secret_stores(monkeypatch: pytest.MonkeyPatch) -> None:
"""Simulate 'no OS secret store / not logged in' on every platform.""" """Simulate 'no OS secret store / not logged in' on every platform."""
monkeypatch.setattr(copilot_auth, "read_headroom_copilot_oauth_token", lambda: None)
monkeypatch.setattr(copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: None) monkeypatch.setattr(copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: None)
monkeypatch.setattr(copilot_auth, "_read_macos_keychain_oauth_token", lambda: None) monkeypatch.setattr(copilot_auth, "_read_macos_keychain_oauth_token", lambda: None)
monkeypatch.setattr(copilot_auth, "_read_linux_secret_oauth_token", lambda: None) monkeypatch.setattr(copilot_auth, "_read_linux_secret_oauth_token", lambda: None)
@ -50,28 +52,31 @@ def _clear_token_env(monkeypatch: pytest.MonkeyPatch) -> None:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 1. The env-var path resolves on any platform with no secret store. # 1. The explicit API-token env path resolves on any platform with no secret store.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_env_token_resolves_subscription_without_secret_store( def test_api_token_env_resolves_subscription_without_secret_store(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
_stub_all_secret_stores(monkeypatch) _stub_all_secret_stores(monkeypatch)
_clear_token_env(monkeypatch) _clear_token_env(monkeypatch)
monkeypatch.setenv("GITHUB_COPILOT_TOKEN", "gho-env-universal") monkeypatch.setenv("GITHUB_COPILOT_API_TOKEN", "tid_env_universal")
monkeypatch.setattr(
copilot_auth, "_subscription_resolution_from_token_exchange", lambda _: None
)
monkeypatch.setattr( monkeypatch.setattr(
copilot_auth, copilot_auth,
"_fetch_copilot_user_info", "_fetch_copilot_user_info",
lambda token: ( lambda token: (
{"endpoints": {"api": BUSINESS_API}} if token == "gho-env-universal" else None {"endpoints": {"api": BUSINESS_API}} if token == "tid_env_universal" else None
), ),
) )
assert copilot_auth.resolve_subscription_bearer_token() == "gho-env-universal" assert copilot_auth.resolve_subscription_bearer_token() == "tid_env_universal"
# Routing is override -> generic; the account host advertised by user-info is # Routing is override -> generic; the account host advertised by user-info is
# NOT used (it regressed newer models on the responses API, #610). With no # NOT used (it regressed newer models on the responses API, #610). With no
# GITHUB_COPILOT_API_URL pin set, the generic public host is returned. # GITHUB_COPILOT_API_URL pin set, the generic public host is returned.
monkeypatch.delenv("GITHUB_COPILOT_API_URL", raising=False) monkeypatch.delenv("GITHUB_COPILOT_API_URL", raising=False)
assert copilot_auth.resolve_copilot_api_url("gho-env-universal") == copilot_auth.DEFAULT_API_URL assert copilot_auth.resolve_copilot_api_url("tid_env_universal") == copilot_auth.DEFAULT_API_URL
def test_api_url_falls_back_to_default_when_user_info_unavailable( def test_api_url_falls_back_to_default_when_user_info_unavailable(
@ -85,13 +90,16 @@ def test_api_url_falls_back_to_default_when_user_info_unavailable(
assert copilot_auth.resolve_copilot_api_url("gho-anything") == copilot_auth.DEFAULT_API_URL assert copilot_auth.resolve_copilot_api_url("gho-anything") == copilot_auth.DEFAULT_API_URL
def test_subscription_rejects_token_github_does_not_accept( def test_subscription_rejects_generic_token_and_accepts_api_token(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
_stub_all_secret_stores(monkeypatch) _stub_all_secret_stores(monkeypatch)
_clear_token_env(monkeypatch) _clear_token_env(monkeypatch)
# A generic GitHub token is present but GitHub's Copilot API rejects it; monkeypatch.setattr(
# a valid Copilot token is discoverable behind it. copilot_auth, "_subscription_resolution_from_token_exchange", lambda _: None
)
# A generic GitHub token is present but cannot be exchanged for a Copilot
# API token; a valid Copilot API token is discoverable behind it.
monkeypatch.setattr( monkeypatch.setattr(
copilot_auth, copilot_auth,
"iter_oauth_token_candidates", "iter_oauth_token_candidates",
@ -100,7 +108,7 @@ def test_subscription_rejects_token_github_does_not_accept(
token="ghp-generic-pat", source="env:GITHUB_TOKEN", confidence="generic-github" token="ghp-generic-pat", source="env:GITHUB_TOKEN", confidence="generic-github"
), ),
copilot_auth.CopilotTokenCandidate( copilot_auth.CopilotTokenCandidate(
token="gho-real-copilot", token="tid_real_copilot",
source="macos-keychain:copilot-cli", source="macos-keychain:copilot-cli",
confidence="high", confidence="high",
), ),
@ -109,10 +117,10 @@ def test_subscription_rejects_token_github_does_not_accept(
monkeypatch.setattr( monkeypatch.setattr(
copilot_auth, copilot_auth,
"_fetch_copilot_user_info", "_fetch_copilot_user_info",
lambda token: {"endpoints": {"api": BUSINESS_API}} if token == "gho-real-copilot" else None, lambda token: {"endpoints": {"api": BUSINESS_API}} if token == "tid_real_copilot" else None,
) )
assert copilot_auth.resolve_subscription_bearer_token() == "gho-real-copilot" assert copilot_auth.resolve_subscription_bearer_token() == "tid_real_copilot"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -182,6 +190,16 @@ def test_end_to_end_subscription_chain(monkeypatch: pytest.MonkeyPatch) -> None:
_clear_token_env(monkeypatch) _clear_token_env(monkeypatch)
monkeypatch.setenv("GITHUB_COPILOT_TOKEN", "gho-seat-token") monkeypatch.setenv("GITHUB_COPILOT_TOKEN", "gho-seat-token")
monkeypatch.setenv("GITHUB_COPILOT_API_URL", BUSINESS_API) monkeypatch.setenv("GITHUB_COPILOT_API_URL", BUSINESS_API)
monkeypatch.setattr(
copilot_auth,
"_subscription_resolution_from_token_exchange",
lambda _candidate: copilot_auth._subscription_resolution(
token="tid-seat-token",
source="env:GITHUB_COPILOT_TOKEN:token-exchange",
confidence="copilot-token-exchange",
api_url=BUSINESS_API,
),
)
monkeypatch.setattr( monkeypatch.setattr(
copilot_auth, copilot_auth,
"_fetch_copilot_user_info", "_fetch_copilot_user_info",
@ -193,7 +211,7 @@ def test_end_to_end_subscription_chain(monkeypatch: pytest.MonkeyPatch) -> None:
) )
resolved_token = copilot_auth.resolve_subscription_bearer_token() resolved_token = copilot_auth.resolve_subscription_bearer_token()
resolved_url = copilot_auth.resolve_copilot_api_url(resolved_token) resolved_url = copilot_auth.resolve_copilot_api_url(resolved_token)
assert resolved_token == "gho-seat-token" assert resolved_token == "tid-seat-token"
assert resolved_url == BUSINESS_API # the pin wins; the user-info host is ignored assert resolved_url == BUSINESS_API # the pin wins; the user-info host is ignored
# (b) hand-off: the wrapper exports exactly these for the proxy. # (b) hand-off: the wrapper exports exactly these for the proxy.