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.