From 0b4a4bd4830ecec1bca64c2f62455c4c923d91df Mon Sep 17 00:00:00 2001 From: Joel Belanger <2254841+jbelanger@users.noreply.github.com> Date: Fri, 12 Jun 2026 21:46:38 -0400 Subject: [PATCH] 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/ ``` 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/ 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/` 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 --- CONTRIBUTING.md | 13 +- README.md | 22 +- headroom/cli/__init__.py | 1 + headroom/cli/copilot_auth.py | 81 ++++ headroom/cli/main.py | 1 + headroom/cli/wrap.py | 36 +- headroom/copilot_auth.py | 523 +++++++++++++++++++++-- tests/test_cli/test_copilot_auth.py | 50 +++ tests/test_cli/test_wrap_copilot.py | 77 +++- tests/test_cli_proxy_env.py | 30 ++ tests/test_copilot_auth.py | 327 +++++++++++++- tests/test_copilot_subscription_smoke.py | 52 ++- 12 files changed, 1108 insertions(+), 105 deletions(-) create mode 100644 headroom/cli/copilot_auth.py create mode 100644 tests/test_cli/test_copilot_auth.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8d2698ade..d8c0ffaa6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,10 +73,10 @@ A human maintainer reviews every dep change. PRs that add or bump a package must ## PR workflow 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. 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. 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 python -m venv .venv && source .venv/bin/activate node --version # Node 18+ required for commitlint hooks -pip install -e ".[dev,relevance,proxy]" -pytest +python -m pip install --upgrade pip +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 Two configs ship for VS Code / Codespaces: diff --git a/README.md b/README.md index 52b8e5962..d0896e91d 100644 --- a/README.md +++ b/README.md @@ -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: ```bash +headroom copilot-auth login 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. @@ -302,7 +320,7 @@ Headroom runs **locally**, covers **every** content type, works with every major ```bash 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). diff --git a/headroom/cli/__init__.py b/headroom/cli/__init__.py index 053555d02..9ac5bd81a 100644 --- a/headroom/cli/__init__.py +++ b/headroom/cli/__init__.py @@ -14,6 +14,7 @@ survives that kind of sys.modules mutation. from . import ( # noqa: F401 capture, + copilot_auth, evals, init, install, diff --git a/headroom/cli/copilot_auth.py b/headroom/cli/copilot_auth.py new file mode 100644 index 000000000..f4baed26d --- /dev/null +++ b/headroom/cli/copilot_auth.py @@ -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)}") diff --git a/headroom/cli/main.py b/headroom/cli/main.py index 9581c03f4..b726e7018 100644 --- a/headroom/cli/main.py +++ b/headroom/cli/main.py @@ -38,6 +38,7 @@ def _register_commands() -> None: from . import ( agent_savings, # noqa: F401 capture, # noqa: F401 + copilot_auth, # noqa: F401 evals, # noqa: F401 init, # noqa: F401 install, # noqa: F401 diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index 01e60679d..3041f4fa7 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -45,7 +45,7 @@ from headroom.copilot_auth import ( has_oauth_auth, resolve_client_bearer_token, 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.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. if 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( cmd, @@ -3025,19 +3027,24 @@ def copilot( env = os.environ.copy() openai_api_url: str | None = None copilot_proxy_token: str | None = None + subscription_resolution = None if _should_use_copilot_oauth( backend=effective_backend, provider_type=provider_type, env=env, force_subscription=subscription, ): - client_bearer = ( - resolve_subscription_bearer_token() if subscription else resolve_client_bearer_token() - ) + if subscription: + 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: raise click.ClickException( "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." ) @@ -3075,16 +3082,15 @@ def copilot( else "COPILOT_AUTH_MODE=github-oauth" ), ] - # Resolve the Copilot API host: an explicit GITHUB_COPILOT_API_URL wins, - # otherwise the generic public host (api.githubcopilot.com). This is the - # same policy for --subscription and the implicit OAuth path. The - # account-specific endpoints.api advertised by /copilot_internal/user is - # deliberately NOT used to route — it returns a segmented host (e.g. - # api.individual.githubcopilot.com) that does not serve newer models on - # the responses API (#610), and it is not the host the official Copilot - # client routes with. Accounts that require a dedicated host (enterprise / - # data residency) set GITHUB_COPILOT_API_URL explicitly. - openai_api_url = resolve_copilot_api_url(client_bearer) + # Non-subscription OAuth keeps upstream's generic-host policy from + # #610. Subscription mode can use the endpoint returned by the Copilot + # token exchange, which is how Business accounts advertise their API + # host without requiring users to configure it manually. + openai_api_url = ( + subscription_resolution.api_url + if subscription_resolution is not None + else resolve_copilot_api_url(client_bearer) + ) env["GITHUB_COPILOT_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}") diff --git a/headroom/copilot_auth.py b/headroom/copilot_auth.py index a7720e251..7a6e263ab 100644 --- a/headroom/copilot_auth.py +++ b/headroom/copilot_auth.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio import ctypes +import hashlib import json import logging import os @@ -18,6 +19,7 @@ from urllib import error as urllib_error from urllib import request as urllib_request 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_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_USER_INFO_URL = "https://api.github.com/copilot_internal/user" DEFAULT_GITHUB_HOST = "github.com" +COPILOT_CHAT_OAUTH_CLIENT_ID = "Iv1.b507a08c87ecfe98" _TOKEN_EXPIRY_BUFFER_S = 60 -_DEFAULT_INTEGRATION_ID = "vscode-chat" -_DEFAULT_EDITOR_VERSION = "vscode/1.104.1" -_DEFAULT_USER_AGENT = "GitHubCopilotChat/0.1" +_DEFAULT_EDITOR_VERSION = "vscode/1.107.0" +_DEFAULT_USER_AGENT = "GitHubCopilotChat/0.35.0" +_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 = ( "GITHUB_COPILOT_API_TOKEN", @@ -80,16 +85,137 @@ class CopilotTokenCandidate: 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: 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. and copilot-api. 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: - 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: - 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: @@ -273,6 +399,143 @@ def _entry_expired(entry: dict[str, Any]) -> bool: 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: if _entry_expired(entry): return None @@ -318,6 +581,16 @@ def iter_oauth_token_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: token = os.environ.get(env_var, "").strip() if token: @@ -438,28 +711,182 @@ def resolve_client_bearer_token() -> str | None: return read_cached_oauth_token() -def resolve_subscription_bearer_token() -> str | None: - """Return the first discovered token that GitHub accepts for Copilot subscription APIs.""" +def _copilot_chat_header_defaults() -> dict[str, str]: + 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: token = os.environ.get(env_var, "").strip() - if token and _fetch_copilot_user_info(token) is not None: - return token + if not 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(): if not candidate.validate_for_subscription: 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( - "Using Copilot subscription token from %s (%s)", + "Using exchanged Copilot subscription token from %s (%s)", candidate.source, candidate.confidence, ) - return candidate.token + return exchanged 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: """Return True when existing Copilot auth can be reused.""" @@ -473,7 +900,30 @@ def is_copilot_api_url(url: str | None) -> bool: return False parsed = urlparse(url) 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..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: @@ -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 - override = os.environ.get("GITHUB_COPILOT_API_URL", "").strip() - return override or DEFAULT_API_URL + return _configured_api_url() 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: return None - headers = { - "Authorization": f"Bearer {token}", - "Accept": "application/json", - } + headers = _copilot_token_exchange_headers(token) request = urllib_request.Request(_user_info_url(), headers=headers, method="GET") try: with urllib_request.urlopen(request, timeout=10.0) as response: @@ -545,8 +991,7 @@ class CopilotTokenProvider: return CopilotAPIToken( token=explicit_api_token, expires_at=time.time() + 3600, - api_url=os.environ.get("GITHUB_COPILOT_API_URL", DEFAULT_API_URL).strip() - or DEFAULT_API_URL, + api_url=_configured_api_url(), ) cached = self._cached @@ -566,8 +1011,7 @@ class CopilotTokenProvider: direct_token = CopilotAPIToken( token=oauth_token, expires_at=time.time() + 3600, - api_url=os.environ.get("GITHUB_COPILOT_API_URL", DEFAULT_API_URL).strip() - or DEFAULT_API_URL, + api_url=_configured_api_url(), ) self._cached = direct_token return direct_token @@ -577,23 +1021,18 @@ class CopilotTokenProvider: return exchanged async def _exchange_token(self, oauth_token: str) -> CopilotAPIToken: - headers = { - "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, - } + headers = _copilot_token_exchange_headers(oauth_token) payload = await asyncio.to_thread(self._exchange_token_sync, headers) token = str(payload.get("token") or "").strip() if not token: raise RuntimeError("Copilot token exchange returned an empty token.") expires_at = _parse_expiry(payload.get("expires_at")) or (time.time() + 1800) - raw_endpoints = payload.get("endpoints") - endpoints: dict[str, Any] = raw_endpoints if isinstance(raw_endpoints, dict) else {} - api_url = str(endpoints.get("api") or DEFAULT_API_URL).strip() or DEFAULT_API_URL + api_url = await asyncio.to_thread( + _api_url_from_exchange_payload, + payload, + oauth_token=oauth_token, + ) refresh_in = payload.get("refresh_in") sku = payload.get("sku") 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): return resolved - lower_keys = {k.lower() for k in resolved} - if "copilot-integration-id" not in lower_keys: - 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 - ) + for name, value in _copilot_chat_header_defaults().items(): + _set_header_default(resolved, name, value) incoming_auth = next((v for k, v in resolved.items() if k.lower() == "authorization"), None) 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", _token_kind(raw_token), ) + for key in list(resolved): + if key.lower() == "x-api-key": + resolved.pop(key) return resolved logger.info( "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() for key in list(resolved): - if key.lower() == "authorization": + if key.lower() in {"authorization", "x-api-key"}: resolved.pop(key) resolved["Authorization"] = f"Bearer {token.token}" return resolved diff --git a/tests/test_cli/test_copilot_auth.py b/tests/test_cli/test_copilot_auth.py new file mode 100644 index 000000000..b9a87423a --- /dev/null +++ b/tests/test_cli/test_copilot_auth.py @@ -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 diff --git a/tests/test_cli/test_wrap_copilot.py b/tests/test_cli/test_wrap_copilot.py index cc2f0c173..abad9ace6 100644 --- a/tests/test_cli/test_wrap_copilot.py +++ b/tests/test_cli/test_wrap_copilot.py @@ -14,7 +14,7 @@ import click import pytest 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: @@ -27,6 +27,22 @@ def runner() -> 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 def wrap_modules(monkeypatch: pytest.MonkeyPatch) -> tuple[types.ModuleType, click.Group]: headroom_pkg = sys.modules.get("headroom") @@ -247,7 +263,10 @@ def test_wrap_copilot_subscription_uses_github_auth_without_provider_key( with ( 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._launch_tool", side_effect=fake_launch_tool), ): @@ -284,7 +303,10 @@ def test_wrap_copilot_subscription_defaults_to_responses_for_reasoning_model( with ( 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._launch_tool", side_effect=fake_launch_tool), ): @@ -321,7 +343,10 @@ def test_wrap_copilot_subscription_keeps_gpt4_on_completions( with ( 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._launch_tool", side_effect=fake_launch_tool), ): @@ -351,7 +376,10 @@ def test_wrap_copilot_subscription_allows_explicit_responses_wire_api( with ( 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._launch_tool", side_effect=fake_launch_tool), ): @@ -403,10 +431,9 @@ def test_wrap_copilot_subscription_pins_validated_token_for_proxy( with ( patch("headroom.cli.wrap.shutil.which", return_value="copilot"), patch( - "headroom.cli.wrap.resolve_subscription_bearer_token", - return_value="gho-validated", + "headroom.cli.wrap.resolve_subscription_bearer_token_details", + 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._launch_tool", side_effect=fake_launch_tool), ): @@ -437,12 +464,13 @@ def test_wrap_copilot_subscription_requires_reusable_auth( _wrap_cli, main = wrap_modules with ( 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"]) assert result.exit_code != 0 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( @@ -634,6 +662,8 @@ def _clear_copilot_env(monkeypatch: pytest.MonkeyPatch) -> None: "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GITHUB_COPILOT_API_URL", + "GITHUB_COPILOT_ENTERPRISE_URL", + "GITHUB_COPILOT_ENTERPRISE_DOMAIN", "GITHUB_COPILOT_TOKEN", "GITHUB_COPILOT_GITHUB_TOKEN", "COPILOT_MODEL", @@ -747,17 +777,15 @@ def test_wrap_copilot_byok_never_resolves_copilot_endpoint( 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, wrap_modules: tuple[types.ModuleType, click.Group], monkeypatch: pytest.MonkeyPatch, ) -> None: - """#610 (subscription has the same latent bug): --subscription must route to - 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.""" + """Subscription mode uses the endpoint returned with the resolved token.""" _wrap_cli, main = wrap_modules _clear_copilot_env(monkeypatch) + business_api = "https://api.business.githubcopilot.com" captured: dict[str, object] = {} def fake_launch_tool(**kwargs): # noqa: ANN003 @@ -765,7 +793,10 @@ def test_wrap_copilot_subscription_uses_generic_endpoint_not_account( with ( 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.copilot_auth._fetch_copilot_user_info", return_value=_ACCOUNT_USER_INFO), 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 env = captured["env"] assert isinstance(env, dict) - assert captured["openai_api_url"] == DEFAULT_API_URL - assert env["OPENAI_TARGET_API_URL"] == DEFAULT_API_URL - assert env["COPILOT_PROVIDER_BEARER_TOKEN"] == "gho-sub" + assert captured["openai_api_url"] == business_api + assert env["OPENAI_TARGET_API_URL"] == business_api + assert env["COPILOT_PROVIDER_BEARER_TOKEN"] == "copilot-api" def test_wrap_copilot_subscription_honors_api_url_override( @@ -800,7 +831,15 @@ def test_wrap_copilot_subscription_honors_api_url_override( with ( 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._launch_tool", side_effect=fake_launch_tool), ): diff --git a/tests/test_cli_proxy_env.py b/tests/test_cli_proxy_env.py index 005052e93..6556f311c 100644 --- a/tests/test_cli_proxy_env.py +++ b/tests/test_cli_proxy_env.py @@ -76,6 +76,36 @@ class TestCLIWrapProxyTimeout: assert sleeps == [1] 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): fake_proc = _FakeProxyProcess() sleeps = [] diff --git a/tests/test_copilot_auth.py b/tests/test_copilot_auth.py index e43947df8..0bf251552 100644 --- a/tests/test_copilot_auth.py +++ b/tests/test_copilot_auth.py @@ -12,11 +12,49 @@ import pytest 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: monkeypatch.setenv("GITHUB_COPILOT_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( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -56,6 +94,9 @@ def test_resolve_subscription_bearer_token_skips_invalid_generic_token( ) -> 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", @@ -65,6 +106,38 @@ def test_resolve_subscription_bearer_token_skips_invalid_generic_token( source="env:GITHUB_TOKEN", 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( token="gho-copilot", 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( @@ -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") +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: assert ( 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: async def fake_get_api_token() -> 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( 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", ) ) assert headers["Authorization"] == "Bearer copilot-session" 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( @@ -348,12 +614,16 @@ def test_apply_copilot_api_auth_passes_through_existing_api_token( headers = asyncio.run( 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", ) ) 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( @@ -446,7 +716,8 @@ def test_apply_copilot_api_auth_injects_required_headers( assert headers["Authorization"] == "Bearer copilot-session" 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( @@ -483,6 +754,47 @@ def test_apply_copilot_api_auth_preserves_existing_copilot_headers( 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( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -517,9 +829,11 @@ def test_token_provider_can_exchange_when_enabled(monkeypatch: pytest.MonkeyPatc provider = copilot_auth.CopilotTokenProvider() calls = {"count": 0} + captured: dict[str, str] = {} def fake_exchange(headers: dict[str, str]) -> dict[str, object]: calls["count"] += 1 + captured.update(headers) return { "token": "copilot-api", "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 second.token == "copilot-api" 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: diff --git a/tests/test_copilot_subscription_smoke.py b/tests/test_copilot_subscription_smoke.py index 74fc65e6b..41e3738a1 100644 --- a/tests/test_copilot_subscription_smoke.py +++ b/tests/test_copilot_subscription_smoke.py @@ -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 portably. This suite proves the *portable* contract instead: -1. With an explicit token in the environment, resolution + API-URL discovery - succeed on every platform without touching any secret store. This is the - universal escape hatch (``GITHUB_COPILOT_TOKEN`` etc.) that makes the - feature work anywhere, including headless CI. +1. With an explicit Copilot API token in the environment, resolution + API-URL + discovery succeed on every platform without touching any secret store. This + is the deterministic escape hatch (``GITHUB_COPILOT_API_TOKEN``) for + 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 given OS only that OS's reader can fire, and a missing/foreign secret store 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: """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_macos_keychain_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, ) -> None: _stub_all_secret_stores(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( copilot_auth, "_fetch_copilot_user_info", 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 # 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. 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( @@ -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 -def test_subscription_rejects_token_github_does_not_accept( +def test_subscription_rejects_generic_token_and_accepts_api_token( monkeypatch: pytest.MonkeyPatch, ) -> None: _stub_all_secret_stores(monkeypatch) _clear_token_env(monkeypatch) - # A generic GitHub token is present but GitHub's Copilot API rejects it; - # a valid Copilot token is discoverable behind it. + monkeypatch.setattr( + 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( copilot_auth, "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" ), copilot_auth.CopilotTokenCandidate( - token="gho-real-copilot", + token="tid_real_copilot", source="macos-keychain:copilot-cli", confidence="high", ), @@ -109,10 +117,10 @@ def test_subscription_rejects_token_github_does_not_accept( monkeypatch.setattr( copilot_auth, "_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) monkeypatch.setenv("GITHUB_COPILOT_TOKEN", "gho-seat-token") 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( copilot_auth, "_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_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 # (b) hand-off: the wrapper exports exactly these for the proxy.