From 397803a9424cf184f597a2d18af82632e7b0ac70 Mon Sep 17 00:00:00 2001 From: Tejas Chopra Date: Thu, 20 Aug 2026 22:11:42 -0700 Subject: [PATCH] fix(copilot): bind the minted token to the integration ID we forward (#3164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Reported from a Copilot CLI session: ``` [CopilotCLISession] Failed to fetch models: Error: 401 "unauthorized: unable to validate HMAC for the given Copilot-Integration-ID" [CopilotCLISession] Proxy URL configured (authType=hmac), skipping client-side token validation ``` GitHub **binds a Copilot API token to the `Copilot-Integration-Id` it was minted under** and verifies the pairing with an HMAC. Present a token minted for integration A alongside a header naming integration B, and you get exactly this error. `apply_copilot_api_auth` applied the integration ID with *set-default* semantics — `_set_header_default` returns early when the header is already present — **before** deciding whose token to use: ```python for name, value in _copilot_chat_header_defaults().items(): _set_header_default(resolved, name, value) # ← never overwrites ... if incoming_auth and _is_forwardable_copilot_bearer_token(...): return resolved # client's token kept ... token = await get_copilot_token_provider().get_api_token() # ← REPLACED ``` The client always sends an ID, so when Headroom replaced the token — the common case, logged as `incoming token not suitable (kind=unknown), will replace` — the request left carrying **the client's integration ID next to Headroom's token**, minted under `vscode-chat` via `_copilot_token_exchange_headers`. A Copilot CLI session does not identify as `vscode-chat`. The second log line is why nothing caught it sooner: seeing a proxy URL, the Copilot client reports `authType=hmac` and **skips its own token validation**, deferring to the proxy. Nobody validates the pairing until GitHub rejects it. **Why this matters beyond one 401:** the failing call is *model discovery*. When it fails the client falls back to its built-in model list — which is why a user's selected model never appeared in telemetry and all traffic surfaced as `gpt-4o-mini`. Closes # ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made Restores one invariant: **the credential and the integration ID leave together.** - **Mint under the client's ID** rather than the proxy's default, so GitHub's usage attribution keeps pointing at the surface that actually made the call. - **Overwrite the forwarded header to match what we minted** — but only on the replace path. The pass-through branch returns earlier and keeps the client's own ID beside the client's own token, which is equally a matched pair. - **Key the token cache by integration ID.** A single slot would hand a `vscode-chat` token to a CLI session and reproduce the same 401 straight from cache. Two existing contracts deliberately preserved: - Resolution order is **client header > `GITHUB_COPILOT_INTEGRATION_ID` > built-in default**. The env var configures the *default* this proxy sends; it does not override a client that stated its own identity. Pinned by the existing `test_apply_copilot_api_auth_preserves_existing_copilot_headers` (whose fixture literally names the value `should-not-override`). - The overwrite writes through the client's **existing key**, so a lowercase `copilot-integration-id` does not gain a second capitalised variant beside it — pinned by the existing `..._preserves_existing_headers_case_insensitively`. Existing test stubs for `get_api_token` gained the new keyword — the same signature-drift hazard this repo just hit in `RemoteKompressCompressor` (#3162). ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ pytest tests/ -q -k copilot 338 passed, 8 skipped $ pytest tests/ -q # this branch 6 failed, 11386 passed, 587 skipped in 425.40s All 6 also fail on clean origin/main, same machine — pre-existing, not regressions: test_graceful_shutdown.py::test_run_server_installs_cancelled_error_filter test_learn/test_integration.py::TestCodexIntegration::test_full_pipeline test_release_workflows.py::test_no_native_tls_in_wheel_build_tree test_providers/test_deepseek.py::... (3 litellm pricing tests) $ ruff check headroom/ All checks passed! $ mypy headroom/copilot_auth.py 0 errors ``` 12 new tests: the mint/forward pairing, the pass-through branch keeping the client's pair untouched, no duplicate case-variant header, resolution order in both directions, blank/absent client values, non-Copilot upstreams untouched, and per-integration cache isolation. ## Real Behavior Proof - **Environment:** macOS, Python 3.12.13, branch on `origin/main` @ `a3821378`. - **Exact command / steps:** drove `apply_copilot_api_auth` with the reported shape — an unusable client bearer plus `Copilot-Integration-Id: copilot-cli-chat` against `api.githubcopilot.com` — and compared the ID the token would be **minted under** (via `_copilot_token_exchange_headers`) against the ID actually **forwarded**. Run against the same script before and after the change, with `PYTHONPATH` pinned to the worktree. - **Observed result:** ``` ########## PRE-FIX ########## token minted under : vscode-chat header forwarded : copilot-cli-chat -> GitHub would REJECT (401 HMAC) ########## POST-FIX ########## token minted under : copilot-cli-chat header forwarded : copilot-cli-chat -> GitHub would ACCEPT ``` - **Not tested:** no live call to GitHub's CAPI — the HMAC is validated server-side by GitHub and cannot be exercised offline. The claim verified here is that the two halves now agree; that GitHub accepts a correctly-paired credential is inferred from its error message, not observed. **Worth one live Copilot CLI run before shipping to a reporter.** The `GITHUB_COPILOT_API_TOKEN` path is also unchanged: an externally-supplied token was minted under an integration this proxy cannot know, so it is passed through as before. ## Runtime Rollout Safety - **Rollout-managed feature(s):** none. - **Minimum rollout channel:** n/a. - **Stable/default behavior changed:** requests where Headroom replaces the token now forward the integration ID the replacement was minted under. For a client sending `vscode-chat` (VS Code, the previous default) nothing changes at all — the resolved value is identical. - **Kill switch / disable path:** setting `GITHUB_COPILOT_INTEGRATION_ID` pins the value used for clients that send none; clients that send one are unaffected either way. - **Unsafe override required:** none. - **Qualification impact:** model discovery should stop 401ing for non-VS-Code Copilot surfaces, which restores the real model list. - **Rollback path:** revert the commit; behavior returns to minting under `vscode-chat` regardless of caller. ## 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 Co-authored-by: Tejas Chopra Co-authored-by: Claude Opus 5 --- headroom/copilot_auth.py | 139 +++++++++-- tests/test_copilot_auth.py | 28 ++- tests/test_copilot_integration_id_hmac.py | 231 ++++++++++++++++++ ...test_copilot_vscode_completions_routing.py | 6 +- 4 files changed, 372 insertions(+), 32 deletions(-) create mode 100644 tests/test_copilot_integration_id_hmac.py diff --git a/headroom/copilot_auth.py b/headroom/copilot_auth.py index 6ac8cd596..2603577b5 100644 --- a/headroom/copilot_auth.py +++ b/headroom/copilot_auth.py @@ -10,6 +10,7 @@ import logging import math import os import time +from collections.abc import Mapping from contextvars import ContextVar from ctypes import wintypes from dataclasses import dataclass @@ -902,7 +903,45 @@ def resolve_client_bearer_token() -> str | None: return read_cached_oauth_token() -def _copilot_chat_header_defaults() -> dict[str, str]: +def _header_value(headers: Mapping[str, str], name: str) -> str | None: + """Case-insensitive header lookup.""" + lowered = name.lower() + for key, value in headers.items(): + if key.lower() == lowered: + return value + return None + + +def resolve_copilot_integration_id(client_value: str | None = None) -> str: + """Return the integration ID this request's credential must be bound to. + + GitHub binds a Copilot API token to the ``Copilot-Integration-Id`` it was + minted under and verifies the pairing with an HMAC. Presenting a token + minted for one integration alongside a header naming another fails with: + + 401 unauthorized: unable to validate HMAC for the given + Copilot-Integration-ID + + Resolution order — the client's own header wins, matching the long-standing + contract that ``GITHUB_COPILOT_INTEGRATION_ID`` configures the DEFAULT this + proxy sends rather than overriding a client that stated its own identity + (pinned by ``test_apply_copilot_api_auth_preserves_existing_copilot_headers``): + + 1. The client's own header — a Copilot CLI session identifies as something + other than ``vscode-chat``, and minting under its ID keeps GitHub's usage + attribution pointing at the surface that actually made the call. + 2. ``GITHUB_COPILOT_INTEGRATION_ID`` — the operator-configured default. + 3. The historical built-in default. + """ + if client_value and client_value.strip(): + return client_value.strip() + configured = os.environ.get("GITHUB_COPILOT_INTEGRATION_ID", "").strip() + if configured: + return configured + return _DEFAULT_COPILOT_INTEGRATION_ID + + +def _copilot_chat_header_defaults(integration_id: str | None = None) -> dict[str, str]: return { "User-Agent": os.environ.get("GITHUB_COPILOT_USER_AGENT", _DEFAULT_USER_AGENT).strip() or _DEFAULT_USER_AGENT, @@ -915,14 +954,26 @@ def _copilot_chat_header_defaults() -> dict[str, str]: _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, + "Copilot-Integration-Id": integration_id or resolve_copilot_integration_id(), } +def _overwrite_header(headers: dict[str, str], name: str, value: str) -> None: + """Set a header, replacing any case-variant already present. + + Writes through the EXISTING key when there is one, so a client that sent + ``copilot-integration-id`` does not end up with a second + ``Copilot-Integration-Id`` beside it — duplicate case-variants are what + ``_set_header_default`` exists to avoid, and the same care applies when + overwriting. + """ + for key in list(headers): + if key.lower() == name.lower(): + headers[key] = value + return + headers[name] = value + + def _set_header_default(headers: dict[str, str], name: str, value: str) -> None: """Set a header default without duplicating case-insensitive equivalents.""" @@ -932,11 +983,13 @@ def _set_header_default(headers: dict[str, str], name: str, value: str) -> None: headers[name] = value -def _copilot_token_exchange_headers(oauth_token: str) -> dict[str, str]: +def _copilot_token_exchange_headers( + oauth_token: str, *, integration_id: str | None = None +) -> dict[str, str]: return { "Accept": "application/json", "Authorization": f"Bearer {oauth_token}", - **_copilot_chat_header_defaults(), + **_copilot_chat_header_defaults(integration_id), } @@ -1302,9 +1355,29 @@ class CopilotTokenProvider: def __init__(self) -> None: self._lock = asyncio.Lock() - self._cached: CopilotAPIToken | None = None + # Keyed by integration ID: GitHub binds each token to the + # ``Copilot-Integration-Id`` it was minted under and HMAC-verifies the + # pairing, so a token cached for one integration is NOT reusable for + # another. A single slot handed a vscode-chat token to a CLI session + # and GitHub answered 401 "unable to validate HMAC for the given + # Copilot-Integration-ID". + self._cached_by_integration: dict[str, CopilotAPIToken] = {} - async def get_api_token(self) -> CopilotAPIToken: + @property + def _cached(self) -> CopilotAPIToken | None: + """Back-compat view of the default integration's token (tests/callers).""" + return self._cached_by_integration.get(resolve_copilot_integration_id()) + + @_cached.setter + def _cached(self, value: CopilotAPIToken | None) -> None: + key = resolve_copilot_integration_id() + if value is None: + self._cached_by_integration.pop(key, None) + else: + self._cached_by_integration[key] = value + + async def get_api_token(self, *, integration_id: str | None = None) -> CopilotAPIToken: + key = resolve_copilot_integration_id(integration_id) explicit_api_token = os.environ.get("GITHUB_COPILOT_API_TOKEN", "").strip() refresh_oauth_token = os.environ.get(_REFRESH_OAUTH_TOKEN_ENV_VAR, "").strip() if explicit_api_token and not refresh_oauth_token: @@ -1314,12 +1387,12 @@ class CopilotTokenProvider: api_url=_configured_api_url(), ) - cached = self._cached + cached = self._cached_by_integration.get(key) if cached is not None and cached.is_valid: return cached async with self._lock: - cached = self._cached + cached = self._cached_by_integration.get(key) if cached is not None and cached.is_valid: return cached @@ -1331,11 +1404,11 @@ class CopilotTokenProvider: expires_at=seeded_expires_at if seeded_expires_at is not None else 0.0, api_url=_configured_api_url(), ) - self._cached = seeded + self._cached_by_integration[key] = seeded if seeded.is_valid: return seeded - exchanged = await self._exchange_token(refresh_oauth_token) - self._cached = exchanged + exchanged = await self._exchange_token(refresh_oauth_token, integration_id=key) + self._cached_by_integration[key] = exchanged return exchanged oauth_token = read_cached_oauth_token() @@ -1348,15 +1421,17 @@ class CopilotTokenProvider: expires_at=time.time() + 3600, api_url=_configured_api_url(), ) - self._cached = direct_token + self._cached_by_integration[key] = direct_token return direct_token - exchanged = await self._exchange_token(oauth_token) - self._cached = exchanged + exchanged = await self._exchange_token(oauth_token, integration_id=key) + self._cached_by_integration[key] = exchanged return exchanged - async def _exchange_token(self, oauth_token: str) -> CopilotAPIToken: - headers = _copilot_token_exchange_headers(oauth_token) + async def _exchange_token( + self, oauth_token: str, *, integration_id: str | None = None + ) -> CopilotAPIToken: + headers = _copilot_token_exchange_headers(oauth_token, integration_id=integration_id) payload = await asyncio.to_thread(self._exchange_token_sync, headers) token = str(payload.get("token") or "").strip() if not token: @@ -1503,7 +1578,13 @@ async def apply_copilot_api_auth(headers: dict[str, str], *, url: str) -> dict[s if not is_copilot_upstream_url(url): return resolved - for name, value in _copilot_chat_header_defaults().items(): + # Read the CLIENT's integration ID before any default is applied, so the + # credential we mint below can be bound to the surface that actually made + # the call rather than to whatever this proxy happens to default to. + client_integration_id = _header_value(resolved, "Copilot-Integration-Id") + integration_id = resolve_copilot_integration_id(client_integration_id) + + for name, value in _copilot_chat_header_defaults(integration_id).items(): _set_header_default(resolved, name, value) incoming_auth = next((v for k, v in resolved.items() if k.lower() == "authorization"), None) @@ -1533,9 +1614,23 @@ async def apply_copilot_api_auth(headers: dict[str, str], *, url: str) -> dict[s _token_kind(raw_token) if raw_token else "none", ) - token = await get_copilot_token_provider().get_api_token() + token = await get_copilot_token_provider().get_api_token(integration_id=integration_id) for key in list(resolved): if key.lower() in {"authorization", "x-api-key"}: resolved.pop(key) resolved["Authorization"] = f"Bearer {token.token}" + # The credential and the integration ID must leave together. Until now the + # ID was applied with set-default semantics BEFORE this branch was chosen, + # so replacing the client's token left its ID in place next to OUR token — + # a pair GitHub cannot HMAC-validate: + # + # 401 unauthorized: unable to validate HMAC for the given + # Copilot-Integration-ID + # + # It surfaced first on model discovery (`Failed to fetch models`), which + # left the client falling back to its built-in model list. Overwrite here, + # never above: the pass-through branch returns before this point and keeps + # the client's own ID beside the client's own token, which is equally the + # matched pair. + _overwrite_header(resolved, "Copilot-Integration-Id", integration_id) return resolved diff --git a/tests/test_copilot_auth.py b/tests/test_copilot_auth.py index 7035d69ba..dd377c987 100644 --- a/tests/test_copilot_auth.py +++ b/tests/test_copilot_auth.py @@ -986,7 +986,9 @@ def test_build_copilot_upstream_url_strips_v1_for_configured_enterprise_api_url( def test_apply_copilot_api_auth_replaces_authorization(monkeypatch: pytest.MonkeyPatch) -> None: - async def fake_get_api_token() -> copilot_auth.CopilotAPIToken: + async def fake_get_api_token( + *, integration_id: str | None = None + ) -> copilot_auth.CopilotAPIToken: return copilot_auth.CopilotAPIToken( token="copilot-session", expires_at=time.time() + 3600, @@ -1018,7 +1020,9 @@ def test_apply_copilot_api_auth_replaces_authorization(monkeypatch: pytest.Monke def test_apply_copilot_api_auth_passes_through_existing_api_token( monkeypatch: pytest.MonkeyPatch, ) -> None: - async def fake_get_api_token() -> copilot_auth.CopilotAPIToken: + async def fake_get_api_token( + *, integration_id: str | None = None + ) -> copilot_auth.CopilotAPIToken: raise AssertionError("provider should not be called for existing API token") monkeypatch.setattr( @@ -1044,7 +1048,9 @@ def test_apply_copilot_api_auth_passes_through_existing_api_token( def test_apply_copilot_api_auth_replaces_managed_seeded_api_token( monkeypatch: pytest.MonkeyPatch, ) -> None: - async def fake_get_api_token() -> copilot_auth.CopilotAPIToken: + async def fake_get_api_token( + *, integration_id: str | None = None + ) -> copilot_auth.CopilotAPIToken: return copilot_auth.CopilotAPIToken( token="copilot-refreshed", expires_at=time.time() + 3600, @@ -1117,7 +1123,9 @@ def test_apply_copilot_api_auth_passes_through_github_oauth_bearer( def test_apply_copilot_api_auth_replaces_non_bearer_auth( monkeypatch: pytest.MonkeyPatch, ) -> None: - async def fake_get_api_token() -> copilot_auth.CopilotAPIToken: + async def fake_get_api_token( + *, integration_id: str | None = None + ) -> copilot_auth.CopilotAPIToken: return copilot_auth.CopilotAPIToken( token="copilot-session", expires_at=time.time() + 3600, @@ -1172,7 +1180,9 @@ def test_is_forwardable_copilot_bearer_token_matches_expected_prefixes() -> None def test_apply_copilot_api_auth_injects_required_headers( monkeypatch: pytest.MonkeyPatch, ) -> None: - async def fake_get_api_token() -> copilot_auth.CopilotAPIToken: + async def fake_get_api_token( + *, integration_id: str | None = None + ) -> copilot_auth.CopilotAPIToken: return copilot_auth.CopilotAPIToken( token="copilot-session", expires_at=time.time() + 3600, @@ -1203,7 +1213,9 @@ def test_apply_copilot_api_auth_injects_required_headers( def test_apply_copilot_api_auth_preserves_existing_copilot_headers( monkeypatch: pytest.MonkeyPatch, ) -> None: - async def fake_get_api_token() -> copilot_auth.CopilotAPIToken: + async def fake_get_api_token( + *, integration_id: str | None = None + ) -> copilot_auth.CopilotAPIToken: return copilot_auth.CopilotAPIToken( token="copilot-session", expires_at=time.time() + 3600, @@ -1237,7 +1249,9 @@ def test_apply_copilot_api_auth_preserves_existing_copilot_headers( def test_apply_copilot_api_auth_preserves_existing_headers_case_insensitively( monkeypatch: pytest.MonkeyPatch, ) -> None: - async def fake_get_api_token() -> copilot_auth.CopilotAPIToken: + async def fake_get_api_token( + *, integration_id: str | None = None + ) -> copilot_auth.CopilotAPIToken: return copilot_auth.CopilotAPIToken( token="copilot-session", expires_at=time.time() + 3600, diff --git a/tests/test_copilot_integration_id_hmac.py b/tests/test_copilot_integration_id_hmac.py new file mode 100644 index 000000000..fc1030589 --- /dev/null +++ b/tests/test_copilot_integration_id_hmac.py @@ -0,0 +1,231 @@ +"""A Copilot token and its integration ID must leave together. + +GitHub binds a Copilot API token to the ``Copilot-Integration-Id`` it was +minted under and verifies the pairing with an HMAC. Presenting a token minted +for one integration alongside a header naming another fails with: + + 401 unauthorized: unable to validate HMAC for the given + Copilot-Integration-ID + +Reported from a Copilot CLI session: + + [CopilotCLISession] Failed to fetch models: Error: 401 "unauthorized: + unable to validate HMAC for the given Copilot-Integration-ID" + [CopilotCLISession] Proxy URL configured (authType=hmac), skipping + client-side token validation + +``apply_copilot_api_auth`` applied the integration ID with *set-default* +semantics (``_set_header_default`` returns early when the header is already +present) BEFORE deciding whose token to use. The client always sends one, so +when Headroom replaced the token — the common case, logged as ``incoming token +not suitable (kind=unknown), will replace`` — the request went out carrying the +CLIENT's integration ID next to HEADROOM's token, minted under ``vscode-chat``. + +The second log line is why nothing caught it sooner: seeing a proxy URL, the +Copilot client reports ``authType=hmac`` and skips its own token validation, +deferring to the proxy. Nobody checks the pairing until GitHub rejects it. + +The failing call was model discovery, so the client fell back to its built-in +model list — which is why a user's selected model never appeared in telemetry. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from headroom import copilot_auth +from headroom.copilot_auth import ( + CopilotAPIToken, + apply_copilot_api_auth, + resolve_copilot_integration_id, +) + +CAPI = "https://api.githubcopilot.com/chat/completions" +CLI_ID = "copilot-cli-chat" + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch): + for var in ( + "GITHUB_COPILOT_INTEGRATION_ID", + "GITHUB_COPILOT_API_TOKEN", + "GITHUB_COPILOT_REFRESH_OAUTH_TOKEN", + ): + monkeypatch.delenv(var, raising=False) + copilot_auth._provider = None + yield + copilot_auth._provider = None + + +class _RecordingProvider: + """Stands in for the token provider; records what it was asked to mint.""" + + def __init__(self) -> None: + self.asked: list[str | None] = [] + + async def get_api_token(self, *, integration_id: str | None = None): + self.asked.append(integration_id) + return CopilotAPIToken( + token=f"minted-for-{integration_id}", + expires_at=9_999_999_999.0, + api_url="https://api.githubcopilot.com", + ) + + +def _install(monkeypatch) -> _RecordingProvider: + provider = _RecordingProvider() + monkeypatch.setattr(copilot_auth, "get_copilot_token_provider", lambda: provider) + return provider + + +def _apply(headers: dict, url: str = CAPI) -> dict: + return asyncio.run(apply_copilot_api_auth(headers, url=url)) + + +# --------------------------------------------------------------------------- # +# The reported failure +# --------------------------------------------------------------------------- # +def test_token_is_minted_under_the_clients_integration_id(monkeypatch) -> None: + provider = _install(monkeypatch) + + _apply({"Authorization": "Bearer unusable", "Copilot-Integration-Id": CLI_ID}) + + assert provider.asked == [CLI_ID], ( + "the replacement token must be minted for the surface that made the " + "call, not for the proxy's default" + ) + + +def test_forwarded_header_matches_the_minted_token(monkeypatch) -> None: + """The invariant. This is what GitHub HMAC-verifies.""" + provider = _install(monkeypatch) + + out = _apply({"Authorization": "Bearer unusable", "Copilot-Integration-Id": CLI_ID}) + + minted_for = provider.asked[0] + assert out["Authorization"] == f"Bearer minted-for-{minted_for}" + assert out["Copilot-Integration-Id"] == minted_for + + +def test_no_duplicate_integration_id_header_is_emitted(monkeypatch) -> None: + """Overwriting must replace the client's casing, not sit beside it.""" + _install(monkeypatch) + + out = _apply({"Authorization": "Bearer unusable", "copilot-integration-id": CLI_ID}) + + matching = [k for k in out if k.lower() == "copilot-integration-id"] + assert len(matching) == 1 + # Written through the client's own key, not beside it. + assert matching[0] == "copilot-integration-id" + + +# --------------------------------------------------------------------------- # +# The pass-through branch keeps the client's own matched pair +# --------------------------------------------------------------------------- # +def test_a_forwardable_client_token_keeps_the_clients_id(monkeypatch) -> None: + """When we don't replace the credential, we must not touch its pairing.""" + provider = _install(monkeypatch) + monkeypatch.setattr(copilot_auth, "_is_forwardable_copilot_bearer_token", lambda _t: True) + monkeypatch.setattr(copilot_auth, "_is_managed_copilot_seeded_bearer", lambda _t: False) + + out = _apply({"Authorization": "Bearer tid=real;exp=1", "Copilot-Integration-Id": CLI_ID}) + + assert provider.asked == [], "no token should have been minted" + assert out["Authorization"] == "Bearer tid=real;exp=1" + assert out["Copilot-Integration-Id"] == CLI_ID + + +# --------------------------------------------------------------------------- # +# Resolution order +# --------------------------------------------------------------------------- # +def test_a_client_that_states_its_identity_beats_the_configured_default( + monkeypatch, +) -> None: + """``GITHUB_COPILOT_INTEGRATION_ID`` configures the DEFAULT, it does not + override a client that named itself — the long-standing contract pinned by + ``test_apply_copilot_api_auth_preserves_existing_copilot_headers``. What + matters here is that whichever value wins is used for BOTH halves. + """ + monkeypatch.setenv("GITHUB_COPILOT_INTEGRATION_ID", "enterprise-shim") + provider = _install(monkeypatch) + + out = _apply({"Authorization": "Bearer unusable", "Copilot-Integration-Id": CLI_ID}) + + assert provider.asked == [CLI_ID] + assert out["Copilot-Integration-Id"] == CLI_ID + + +def test_the_configured_default_applies_when_the_client_sends_none( + monkeypatch, +) -> None: + monkeypatch.setenv("GITHUB_COPILOT_INTEGRATION_ID", "enterprise-shim") + provider = _install(monkeypatch) + + out = _apply({"Authorization": "Bearer unusable"}) + + assert provider.asked == ["enterprise-shim"] + assert out["Copilot-Integration-Id"] == "enterprise-shim" + + +def test_client_value_wins_over_the_default(monkeypatch) -> None: + assert resolve_copilot_integration_id(CLI_ID) == CLI_ID + + +def test_default_when_the_client_sends_none(monkeypatch) -> None: + provider = _install(monkeypatch) + + out = _apply({"Authorization": "Bearer unusable"}) + + assert provider.asked == [copilot_auth._DEFAULT_COPILOT_INTEGRATION_ID] + assert out["Copilot-Integration-Id"] == copilot_auth._DEFAULT_COPILOT_INTEGRATION_ID + + +@pytest.mark.parametrize("blank", ["", " ", None]) +def test_blank_client_values_fall_back(blank) -> None: + assert resolve_copilot_integration_id(blank) == copilot_auth._DEFAULT_COPILOT_INTEGRATION_ID + + +def test_non_copilot_upstream_is_untouched(monkeypatch) -> None: + provider = _install(monkeypatch) + headers = {"Authorization": "Bearer sk-openai", "Copilot-Integration-Id": CLI_ID} + + out = _apply(dict(headers), url="https://api.openai.com/v1/chat/completions") + + assert out == headers + assert provider.asked == [] + + +# --------------------------------------------------------------------------- # +# The cache must not hand one integration another's token +# --------------------------------------------------------------------------- # +def test_tokens_are_cached_per_integration_id(monkeypatch) -> None: + from headroom.copilot_auth import CopilotTokenProvider + + provider = CopilotTokenProvider() + exchanged: list[str | None] = [] + + async def _fake_exchange(oauth_token, *, integration_id=None): # noqa: ANN001 + exchanged.append(integration_id) + return CopilotAPIToken( + token=f"tok-{integration_id}", + expires_at=9_999_999_999.0, + api_url="https://api.githubcopilot.com", + ) + + monkeypatch.setattr(provider, "_exchange_token", _fake_exchange) + monkeypatch.setattr(copilot_auth, "read_cached_oauth_token", lambda: "oauth") + monkeypatch.setattr(copilot_auth, "_should_exchange_oauth_token", lambda: True) + + a = asyncio.run(provider.get_api_token(integration_id="vscode-chat")) + b = asyncio.run(provider.get_api_token(integration_id=CLI_ID)) + a_again = asyncio.run(provider.get_api_token(integration_id="vscode-chat")) + + assert a.token == "tok-vscode-chat" + assert b.token == f"tok-{CLI_ID}" + # Distinct integrations must not share a slot... + assert a.token != b.token + # ...and the same one must still be served from cache. + assert a_again.token == a.token + assert exchanged == ["vscode-chat", CLI_ID] diff --git a/tests/test_copilot_vscode_completions_routing.py b/tests/test_copilot_vscode_completions_routing.py index de8fa452e..675865a83 100644 --- a/tests/test_copilot_vscode_completions_routing.py +++ b/tests/test_copilot_vscode_completions_routing.py @@ -408,7 +408,7 @@ def test_a_copilot_upstream_is_authenticated(monkeypatch: pytest.MonkeyPatch, ur token = "test-copilot-token" class _Provider: - async def get_api_token(self): # noqa: ANN202 + async def get_api_token(self, *, integration_id=None): # noqa: ANN001, ANN202 return _Token() monkeypatch.setattr(copilot_auth, "get_copilot_token_provider", lambda: _Provider()) @@ -424,7 +424,7 @@ def test_a_non_copilot_upstream_is_never_given_copilot_credentials( """The widened gate must not start handing Copilot tokens to other hosts.""" class _Provider: - async def get_api_token(self): # noqa: ANN202 + async def get_api_token(self, *, integration_id=None): # noqa: ANN001, ANN202 raise AssertionError("must not mint a Copilot token for a non-Copilot host") monkeypatch.setattr(copilot_auth, "get_copilot_token_provider", lambda: _Provider()) @@ -522,7 +522,7 @@ def test_an_operator_override_gateway_receives_credentials( token = "test-copilot-token" class _Provider: - async def get_api_token(self): # noqa: ANN202 + async def get_api_token(self, *, integration_id=None): # noqa: ANN001, ANN202 return _Token() monkeypatch.setenv("GITHUB_COPILOT_PROXY_URL", "https://gw.corp.internal")