Skip to content

Commit 397803a

Browse files
chopratejasTejas Chopraclaude
authored
fix(copilot): bind the minted token to the integration ID we forward (#3164)
## 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 <tejas@Tejass-MacBook-Pro.local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 45cb1b9 commit 397803a

4 files changed

Lines changed: 372 additions & 32 deletions

File tree

headroom/copilot_auth.py

Lines changed: 117 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import math
1111
import os
1212
import time
13+
from collections.abc import Mapping
1314
from contextvars import ContextVar
1415
from ctypes import wintypes
1516
from dataclasses import dataclass
@@ -902,7 +903,45 @@ def resolve_client_bearer_token() -> str | None:
902903
return read_cached_oauth_token()
903904

904905

905-
def _copilot_chat_header_defaults() -> dict[str, str]:
906+
def _header_value(headers: Mapping[str, str], name: str) -> str | None:
907+
"""Case-insensitive header lookup."""
908+
lowered = name.lower()
909+
for key, value in headers.items():
910+
if key.lower() == lowered:
911+
return value
912+
return None
913+
914+
915+
def resolve_copilot_integration_id(client_value: str | None = None) -> str:
916+
"""Return the integration ID this request's credential must be bound to.
917+
918+
GitHub binds a Copilot API token to the ``Copilot-Integration-Id`` it was
919+
minted under and verifies the pairing with an HMAC. Presenting a token
920+
minted for one integration alongside a header naming another fails with:
921+
922+
401 unauthorized: unable to validate HMAC for the given
923+
Copilot-Integration-ID
924+
925+
Resolution order — the client's own header wins, matching the long-standing
926+
contract that ``GITHUB_COPILOT_INTEGRATION_ID`` configures the DEFAULT this
927+
proxy sends rather than overriding a client that stated its own identity
928+
(pinned by ``test_apply_copilot_api_auth_preserves_existing_copilot_headers``):
929+
930+
1. The client's own header — a Copilot CLI session identifies as something
931+
other than ``vscode-chat``, and minting under its ID keeps GitHub's usage
932+
attribution pointing at the surface that actually made the call.
933+
2. ``GITHUB_COPILOT_INTEGRATION_ID`` — the operator-configured default.
934+
3. The historical built-in default.
935+
"""
936+
if client_value and client_value.strip():
937+
return client_value.strip()
938+
configured = os.environ.get("GITHUB_COPILOT_INTEGRATION_ID", "").strip()
939+
if configured:
940+
return configured
941+
return _DEFAULT_COPILOT_INTEGRATION_ID
942+
943+
944+
def _copilot_chat_header_defaults(integration_id: str | None = None) -> dict[str, str]:
906945
return {
907946
"User-Agent": os.environ.get("GITHUB_COPILOT_USER_AGENT", _DEFAULT_USER_AGENT).strip()
908947
or _DEFAULT_USER_AGENT,
@@ -915,14 +954,26 @@ def _copilot_chat_header_defaults() -> dict[str, str]:
915954
_DEFAULT_EDITOR_PLUGIN_VERSION,
916955
).strip()
917956
or _DEFAULT_EDITOR_PLUGIN_VERSION,
918-
"Copilot-Integration-Id": os.environ.get(
919-
"GITHUB_COPILOT_INTEGRATION_ID",
920-
_DEFAULT_COPILOT_INTEGRATION_ID,
921-
).strip()
922-
or _DEFAULT_COPILOT_INTEGRATION_ID,
957+
"Copilot-Integration-Id": integration_id or resolve_copilot_integration_id(),
923958
}
924959

925960

961+
def _overwrite_header(headers: dict[str, str], name: str, value: str) -> None:
962+
"""Set a header, replacing any case-variant already present.
963+
964+
Writes through the EXISTING key when there is one, so a client that sent
965+
``copilot-integration-id`` does not end up with a second
966+
``Copilot-Integration-Id`` beside it — duplicate case-variants are what
967+
``_set_header_default`` exists to avoid, and the same care applies when
968+
overwriting.
969+
"""
970+
for key in list(headers):
971+
if key.lower() == name.lower():
972+
headers[key] = value
973+
return
974+
headers[name] = value
975+
976+
926977
def _set_header_default(headers: dict[str, str], name: str, value: str) -> None:
927978
"""Set a header default without duplicating case-insensitive equivalents."""
928979

@@ -932,11 +983,13 @@ def _set_header_default(headers: dict[str, str], name: str, value: str) -> None:
932983
headers[name] = value
933984

934985

935-
def _copilot_token_exchange_headers(oauth_token: str) -> dict[str, str]:
986+
def _copilot_token_exchange_headers(
987+
oauth_token: str, *, integration_id: str | None = None
988+
) -> dict[str, str]:
936989
return {
937990
"Accept": "application/json",
938991
"Authorization": f"Bearer {oauth_token}",
939-
**_copilot_chat_header_defaults(),
992+
**_copilot_chat_header_defaults(integration_id),
940993
}
941994

942995

@@ -1302,9 +1355,29 @@ class CopilotTokenProvider:
13021355

13031356
def __init__(self) -> None:
13041357
self._lock = asyncio.Lock()
1305-
self._cached: CopilotAPIToken | None = None
1358+
# Keyed by integration ID: GitHub binds each token to the
1359+
# ``Copilot-Integration-Id`` it was minted under and HMAC-verifies the
1360+
# pairing, so a token cached for one integration is NOT reusable for
1361+
# another. A single slot handed a vscode-chat token to a CLI session
1362+
# and GitHub answered 401 "unable to validate HMAC for the given
1363+
# Copilot-Integration-ID".
1364+
self._cached_by_integration: dict[str, CopilotAPIToken] = {}
13061365

1307-
async def get_api_token(self) -> CopilotAPIToken:
1366+
@property
1367+
def _cached(self) -> CopilotAPIToken | None:
1368+
"""Back-compat view of the default integration's token (tests/callers)."""
1369+
return self._cached_by_integration.get(resolve_copilot_integration_id())
1370+
1371+
@_cached.setter
1372+
def _cached(self, value: CopilotAPIToken | None) -> None:
1373+
key = resolve_copilot_integration_id()
1374+
if value is None:
1375+
self._cached_by_integration.pop(key, None)
1376+
else:
1377+
self._cached_by_integration[key] = value
1378+
1379+
async def get_api_token(self, *, integration_id: str | None = None) -> CopilotAPIToken:
1380+
key = resolve_copilot_integration_id(integration_id)
13081381
explicit_api_token = os.environ.get("GITHUB_COPILOT_API_TOKEN", "").strip()
13091382
refresh_oauth_token = os.environ.get(_REFRESH_OAUTH_TOKEN_ENV_VAR, "").strip()
13101383
if explicit_api_token and not refresh_oauth_token:
@@ -1314,12 +1387,12 @@ async def get_api_token(self) -> CopilotAPIToken:
13141387
api_url=_configured_api_url(),
13151388
)
13161389

1317-
cached = self._cached
1390+
cached = self._cached_by_integration.get(key)
13181391
if cached is not None and cached.is_valid:
13191392
return cached
13201393

13211394
async with self._lock:
1322-
cached = self._cached
1395+
cached = self._cached_by_integration.get(key)
13231396
if cached is not None and cached.is_valid:
13241397
return cached
13251398

@@ -1331,11 +1404,11 @@ async def get_api_token(self) -> CopilotAPIToken:
13311404
expires_at=seeded_expires_at if seeded_expires_at is not None else 0.0,
13321405
api_url=_configured_api_url(),
13331406
)
1334-
self._cached = seeded
1407+
self._cached_by_integration[key] = seeded
13351408
if seeded.is_valid:
13361409
return seeded
1337-
exchanged = await self._exchange_token(refresh_oauth_token)
1338-
self._cached = exchanged
1410+
exchanged = await self._exchange_token(refresh_oauth_token, integration_id=key)
1411+
self._cached_by_integration[key] = exchanged
13391412
return exchanged
13401413

13411414
oauth_token = read_cached_oauth_token()
@@ -1348,15 +1421,17 @@ async def get_api_token(self) -> CopilotAPIToken:
13481421
expires_at=time.time() + 3600,
13491422
api_url=_configured_api_url(),
13501423
)
1351-
self._cached = direct_token
1424+
self._cached_by_integration[key] = direct_token
13521425
return direct_token
13531426

1354-
exchanged = await self._exchange_token(oauth_token)
1355-
self._cached = exchanged
1427+
exchanged = await self._exchange_token(oauth_token, integration_id=key)
1428+
self._cached_by_integration[key] = exchanged
13561429
return exchanged
13571430

1358-
async def _exchange_token(self, oauth_token: str) -> CopilotAPIToken:
1359-
headers = _copilot_token_exchange_headers(oauth_token)
1431+
async def _exchange_token(
1432+
self, oauth_token: str, *, integration_id: str | None = None
1433+
) -> CopilotAPIToken:
1434+
headers = _copilot_token_exchange_headers(oauth_token, integration_id=integration_id)
13601435
payload = await asyncio.to_thread(self._exchange_token_sync, headers)
13611436
token = str(payload.get("token") or "").strip()
13621437
if not token:
@@ -1503,7 +1578,13 @@ async def apply_copilot_api_auth(headers: dict[str, str], *, url: str) -> dict[s
15031578
if not is_copilot_upstream_url(url):
15041579
return resolved
15051580

1506-
for name, value in _copilot_chat_header_defaults().items():
1581+
# Read the CLIENT's integration ID before any default is applied, so the
1582+
# credential we mint below can be bound to the surface that actually made
1583+
# the call rather than to whatever this proxy happens to default to.
1584+
client_integration_id = _header_value(resolved, "Copilot-Integration-Id")
1585+
integration_id = resolve_copilot_integration_id(client_integration_id)
1586+
1587+
for name, value in _copilot_chat_header_defaults(integration_id).items():
15071588
_set_header_default(resolved, name, value)
15081589

15091590
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
15331614
_token_kind(raw_token) if raw_token else "none",
15341615
)
15351616

1536-
token = await get_copilot_token_provider().get_api_token()
1617+
token = await get_copilot_token_provider().get_api_token(integration_id=integration_id)
15371618
for key in list(resolved):
15381619
if key.lower() in {"authorization", "x-api-key"}:
15391620
resolved.pop(key)
15401621
resolved["Authorization"] = f"Bearer {token.token}"
1622+
# The credential and the integration ID must leave together. Until now the
1623+
# ID was applied with set-default semantics BEFORE this branch was chosen,
1624+
# so replacing the client's token left its ID in place next to OUR token —
1625+
# a pair GitHub cannot HMAC-validate:
1626+
#
1627+
# 401 unauthorized: unable to validate HMAC for the given
1628+
# Copilot-Integration-ID
1629+
#
1630+
# It surfaced first on model discovery (`Failed to fetch models`), which
1631+
# left the client falling back to its built-in model list. Overwrite here,
1632+
# never above: the pass-through branch returns before this point and keeps
1633+
# the client's own ID beside the client's own token, which is equally the
1634+
# matched pair.
1635+
_overwrite_header(resolved, "Copilot-Integration-Id", integration_id)
15411636
return resolved

tests/test_copilot_auth.py

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -986,7 +986,9 @@ def test_build_copilot_upstream_url_strips_v1_for_configured_enterprise_api_url(
986986

987987

988988
def test_apply_copilot_api_auth_replaces_authorization(monkeypatch: pytest.MonkeyPatch) -> None:
989-
async def fake_get_api_token() -> copilot_auth.CopilotAPIToken:
989+
async def fake_get_api_token(
990+
*, integration_id: str | None = None
991+
) -> copilot_auth.CopilotAPIToken:
990992
return copilot_auth.CopilotAPIToken(
991993
token="copilot-session",
992994
expires_at=time.time() + 3600,
@@ -1018,7 +1020,9 @@ async def fake_get_api_token() -> copilot_auth.CopilotAPIToken:
10181020
def test_apply_copilot_api_auth_passes_through_existing_api_token(
10191021
monkeypatch: pytest.MonkeyPatch,
10201022
) -> None:
1021-
async def fake_get_api_token() -> copilot_auth.CopilotAPIToken:
1023+
async def fake_get_api_token(
1024+
*, integration_id: str | None = None
1025+
) -> copilot_auth.CopilotAPIToken:
10221026
raise AssertionError("provider should not be called for existing API token")
10231027

10241028
monkeypatch.setattr(
@@ -1044,7 +1048,9 @@ async def fake_get_api_token() -> copilot_auth.CopilotAPIToken:
10441048
def test_apply_copilot_api_auth_replaces_managed_seeded_api_token(
10451049
monkeypatch: pytest.MonkeyPatch,
10461050
) -> None:
1047-
async def fake_get_api_token() -> copilot_auth.CopilotAPIToken:
1051+
async def fake_get_api_token(
1052+
*, integration_id: str | None = None
1053+
) -> copilot_auth.CopilotAPIToken:
10481054
return copilot_auth.CopilotAPIToken(
10491055
token="copilot-refreshed",
10501056
expires_at=time.time() + 3600,
@@ -1117,7 +1123,9 @@ async def fail_if_called() -> copilot_auth.CopilotAPIToken:
11171123
def test_apply_copilot_api_auth_replaces_non_bearer_auth(
11181124
monkeypatch: pytest.MonkeyPatch,
11191125
) -> None:
1120-
async def fake_get_api_token() -> copilot_auth.CopilotAPIToken:
1126+
async def fake_get_api_token(
1127+
*, integration_id: str | None = None
1128+
) -> copilot_auth.CopilotAPIToken:
11211129
return copilot_auth.CopilotAPIToken(
11221130
token="copilot-session",
11231131
expires_at=time.time() + 3600,
@@ -1172,7 +1180,9 @@ def test_is_forwardable_copilot_bearer_token_matches_expected_prefixes() -> None
11721180
def test_apply_copilot_api_auth_injects_required_headers(
11731181
monkeypatch: pytest.MonkeyPatch,
11741182
) -> None:
1175-
async def fake_get_api_token() -> copilot_auth.CopilotAPIToken:
1183+
async def fake_get_api_token(
1184+
*, integration_id: str | None = None
1185+
) -> copilot_auth.CopilotAPIToken:
11761186
return copilot_auth.CopilotAPIToken(
11771187
token="copilot-session",
11781188
expires_at=time.time() + 3600,
@@ -1203,7 +1213,9 @@ async def fake_get_api_token() -> copilot_auth.CopilotAPIToken:
12031213
def test_apply_copilot_api_auth_preserves_existing_copilot_headers(
12041214
monkeypatch: pytest.MonkeyPatch,
12051215
) -> None:
1206-
async def fake_get_api_token() -> copilot_auth.CopilotAPIToken:
1216+
async def fake_get_api_token(
1217+
*, integration_id: str | None = None
1218+
) -> copilot_auth.CopilotAPIToken:
12071219
return copilot_auth.CopilotAPIToken(
12081220
token="copilot-session",
12091221
expires_at=time.time() + 3600,
@@ -1237,7 +1249,9 @@ async def fake_get_api_token() -> copilot_auth.CopilotAPIToken:
12371249
def test_apply_copilot_api_auth_preserves_existing_headers_case_insensitively(
12381250
monkeypatch: pytest.MonkeyPatch,
12391251
) -> None:
1240-
async def fake_get_api_token() -> copilot_auth.CopilotAPIToken:
1252+
async def fake_get_api_token(
1253+
*, integration_id: str | None = None
1254+
) -> copilot_auth.CopilotAPIToken:
12411255
return copilot_auth.CopilotAPIToken(
12421256
token="copilot-session",
12431257
expires_at=time.time() + 3600,

0 commit comments

Comments
 (0)