Skip to content

Commit 899c2ef

Browse files
committed
fix(copilot): defer keychain auth lookup
1 parent 202c189 commit 899c2ef

3 files changed

Lines changed: 228 additions & 37 deletions

File tree

headroom/copilot_auth.py

Lines changed: 56 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -768,7 +768,9 @@ def read_cached_oauth_token() -> str | None:
768768
return None
769769

770770

771-
def iter_oauth_token_candidates() -> list[CopilotTokenCandidate]:
771+
def iter_oauth_token_candidates(
772+
*, include_platform_secret_stores: bool = True
773+
) -> list[CopilotTokenCandidate]:
772774
"""Return reusable token candidates in safest-first discovery order."""
773775

774776
candidates: list[CopilotTokenCandidate] = []
@@ -804,25 +806,8 @@ def iter_oauth_token_candidates() -> list[CopilotTokenCandidate]:
804806
)
805807
)
806808

807-
macos_copilot_token = _read_macos_keychain_oauth_token()
808-
if macos_copilot_token:
809-
candidates.append(
810-
CopilotTokenCandidate(
811-
token=macos_copilot_token,
812-
source="macos-keychain:copilot-cli",
813-
confidence="high",
814-
)
815-
)
816-
817-
linux_copilot_token = _read_linux_secret_oauth_token()
818-
if linux_copilot_token:
819-
candidates.append(
820-
CopilotTokenCandidate(
821-
token=linux_copilot_token,
822-
source="linux-secret-service:copilot-cli",
823-
confidence="high",
824-
)
825-
)
809+
if include_platform_secret_stores:
810+
candidates.extend(_platform_secret_store_oauth_token_candidates())
826811

827812
candidates.extend(_read_file_oauth_token_candidates())
828813

@@ -850,6 +835,31 @@ def iter_oauth_token_candidates() -> list[CopilotTokenCandidate]:
850835
return _dedupe_token_candidates(candidates)
851836

852837

838+
def _platform_secret_store_oauth_token_candidates() -> list[CopilotTokenCandidate]:
839+
"""Return OAuth candidates from platform credential stores."""
840+
candidates: list[CopilotTokenCandidate] = []
841+
macos_copilot_token = _read_macos_keychain_oauth_token()
842+
if macos_copilot_token:
843+
candidates.append(
844+
CopilotTokenCandidate(
845+
token=macos_copilot_token,
846+
source="macos-keychain:copilot-cli",
847+
confidence="high",
848+
)
849+
)
850+
851+
linux_copilot_token = _read_linux_secret_oauth_token()
852+
if linux_copilot_token:
853+
candidates.append(
854+
CopilotTokenCandidate(
855+
token=linux_copilot_token,
856+
source="linux-secret-service:copilot-cli",
857+
confidence="high",
858+
)
859+
)
860+
return candidates
861+
862+
853863
def _read_file_oauth_token_candidates() -> list[CopilotTokenCandidate]:
854864
"""Return token candidates from Copilot/GitHub credential files."""
855865

@@ -1113,9 +1123,34 @@ def resolve_subscription_bearer_token_details() -> CopilotSubscriptionTokenResol
11131123
api_url=_subscription_api_url_from_user_info_payload(payload),
11141124
)
11151125

1116-
for candidate in iter_oauth_token_candidates():
1126+
attempted_tokens: set[str] = set()
1127+
resolution = _resolve_subscription_oauth_token_candidates(
1128+
iter_oauth_token_candidates(include_platform_secret_stores=False),
1129+
attempted_tokens=attempted_tokens,
1130+
)
1131+
if resolution is not None:
1132+
return resolution
1133+
1134+
return _resolve_subscription_oauth_token_candidates(
1135+
[
1136+
candidate
1137+
for candidate in _platform_secret_store_oauth_token_candidates()
1138+
if candidate.token not in attempted_tokens
1139+
]
1140+
)
1141+
1142+
1143+
def _resolve_subscription_oauth_token_candidates(
1144+
candidates: list[CopilotTokenCandidate],
1145+
*,
1146+
attempted_tokens: set[str] | None = None,
1147+
) -> CopilotSubscriptionTokenResolution | None:
1148+
"""Return the first candidate GitHub accepts for subscription APIs."""
1149+
for candidate in candidates:
11171150
if not candidate.validate_for_subscription:
11181151
continue
1152+
if attempted_tokens is not None:
1153+
attempted_tokens.add(candidate.token)
11191154
if _is_copilot_api_token(candidate.token):
11201155
payload = _fetch_copilot_user_info(candidate.token)
11211156
if payload is not None:

tests/test_copilot_auth.py

Lines changed: 148 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ def test_resolve_subscription_bearer_token_skips_invalid_generic_token(
232232
monkeypatch.setattr(
233233
copilot_auth,
234234
"iter_oauth_token_candidates",
235-
lambda: [
235+
lambda **_kwargs: [
236236
copilot_auth.CopilotTokenCandidate(
237237
token="ghp-generic",
238238
source="env:GITHUB_TOKEN",
@@ -269,7 +269,7 @@ def test_resolve_subscription_bearer_token_does_not_fallback_to_unexchanged_oaut
269269
monkeypatch.setattr(
270270
copilot_auth,
271271
"iter_oauth_token_candidates",
272-
lambda: [
272+
lambda **_kwargs: [
273273
copilot_auth.CopilotTokenCandidate(
274274
token="gho-copilot",
275275
source="macos-keychain:copilot-cli",
@@ -290,6 +290,94 @@ def test_resolve_subscription_bearer_token_does_not_fallback_to_unexchanged_oaut
290290
assert copilot_auth.resolve_subscription_bearer_token() is None
291291

292292

293+
def test_resolve_subscription_bearer_token_defers_keychain_when_saved_token_resolves(
294+
monkeypatch: pytest.MonkeyPatch,
295+
) -> None:
296+
copilot_auth.save_headroom_copilot_oauth_token("gho-saved")
297+
monkeypatch.setattr(copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: None)
298+
monkeypatch.setattr(
299+
copilot_auth,
300+
"_read_macos_keychain_oauth_token",
301+
lambda: pytest.fail("Keychain must not be read when saved OAuth resolves"),
302+
)
303+
monkeypatch.setattr(copilot_auth, "_read_linux_secret_oauth_token", lambda: None)
304+
monkeypatch.setattr(copilot_auth, "_read_file_oauth_token_candidates", lambda: [])
305+
monkeypatch.setattr(copilot_auth, "_read_gh_cli_oauth_token", lambda: None)
306+
monkeypatch.setattr(
307+
copilot_auth,
308+
"_subscription_resolution_from_token_exchange",
309+
lambda candidate: (
310+
copilot_auth._subscription_resolution(
311+
token="copilot-api",
312+
source=f"{candidate.source}:token-exchange",
313+
confidence="copilot-token-exchange",
314+
api_url=copilot_auth.DEFAULT_API_URL,
315+
refresh_oauth_token=candidate.token,
316+
)
317+
if candidate.token == "gho-saved"
318+
else None
319+
),
320+
)
321+
322+
assert copilot_auth.resolve_subscription_bearer_token() == "copilot-api"
323+
324+
325+
def test_resolve_subscription_bearer_token_reads_keychain_after_noninteractive_rejection(
326+
monkeypatch: pytest.MonkeyPatch,
327+
) -> None:
328+
copilot_auth.save_headroom_copilot_oauth_token("gho-rejected")
329+
monkeypatch.setattr(copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: None)
330+
monkeypatch.setattr(
331+
copilot_auth,
332+
"_read_macos_keychain_oauth_token",
333+
lambda: "gho-keychain",
334+
)
335+
monkeypatch.setattr(copilot_auth, "_read_linux_secret_oauth_token", lambda: None)
336+
monkeypatch.setattr(copilot_auth, "_read_file_oauth_token_candidates", lambda: [])
337+
monkeypatch.setattr(copilot_auth, "_read_gh_cli_oauth_token", lambda: None)
338+
monkeypatch.setattr(
339+
copilot_auth,
340+
"_subscription_resolution_from_token_exchange",
341+
lambda candidate: (
342+
copilot_auth._subscription_resolution(
343+
token="copilot-api",
344+
source=f"{candidate.source}:token-exchange",
345+
confidence="copilot-token-exchange",
346+
api_url=copilot_auth.DEFAULT_API_URL,
347+
refresh_oauth_token=candidate.token,
348+
)
349+
if candidate.token == "gho-keychain"
350+
else None
351+
),
352+
)
353+
354+
assert copilot_auth.resolve_subscription_bearer_token() == "copilot-api"
355+
356+
357+
def test_resolve_subscription_bearer_token_skips_duplicate_keychain_candidate(
358+
monkeypatch: pytest.MonkeyPatch,
359+
) -> None:
360+
copilot_auth.save_headroom_copilot_oauth_token("gho-duplicate")
361+
monkeypatch.setattr(copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: None)
362+
monkeypatch.setattr(
363+
copilot_auth,
364+
"_read_macos_keychain_oauth_token",
365+
lambda: "gho-duplicate",
366+
)
367+
monkeypatch.setattr(copilot_auth, "_read_linux_secret_oauth_token", lambda: None)
368+
monkeypatch.setattr(copilot_auth, "_read_file_oauth_token_candidates", lambda: [])
369+
monkeypatch.setattr(copilot_auth, "_read_gh_cli_oauth_token", lambda: None)
370+
attempted_tokens: list[str] = []
371+
monkeypatch.setattr(
372+
copilot_auth,
373+
"_subscription_resolution_from_token_exchange",
374+
lambda candidate: attempted_tokens.append(candidate.token) or None,
375+
)
376+
377+
assert copilot_auth.resolve_subscription_bearer_token() is None
378+
assert attempted_tokens == ["gho-duplicate"]
379+
380+
293381
def test_subscription_enterprise_host_repro(
294382
monkeypatch: pytest.MonkeyPatch,
295383
) -> None:
@@ -304,7 +392,7 @@ def test_subscription_enterprise_host_repro(
304392
monkeypatch.setattr(
305393
copilot_auth,
306394
"iter_oauth_token_candidates",
307-
lambda: [
395+
lambda **_kwargs: [
308396
copilot_auth.CopilotTokenCandidate(
309397
token="gho-oauth",
310398
source="headroom-copilot-auth:/tmp/copilot_auth.json",
@@ -360,7 +448,7 @@ def test_resolve_subscription_exchange_uses_cloud_enterprise_advertised_api(
360448
monkeypatch.setattr(
361449
copilot_auth,
362450
"iter_oauth_token_candidates",
363-
lambda: [
451+
lambda **_kwargs: [
364452
copilot_auth.CopilotTokenCandidate(
365453
token="gho-oauth",
366454
source="env:GITHUB_COPILOT_TOKEN",
@@ -427,7 +515,7 @@ def _resolve_subscription_producer_path(
427515
patch.setattr(
428516
copilot_auth,
429517
"iter_oauth_token_candidates",
430-
lambda: [
518+
lambda **_kwargs: [
431519
copilot_auth.CopilotTokenCandidate(
432520
token="gho-oauth", source="test", confidence="test"
433521
)
@@ -445,7 +533,7 @@ def _resolve_subscription_producer_path(
445533
patch.setattr(
446534
copilot_auth,
447535
"iter_oauth_token_candidates",
448-
lambda: [
536+
lambda **_kwargs: [
449537
copilot_auth.CopilotTokenCandidate(
450538
token="tid_api", source="test", confidence="test"
451539
)
@@ -1615,3 +1703,57 @@ def __exit__(self, *args):
16151703
)
16161704

16171705
assert result == payload
1706+
1707+
1708+
def test_iter_oauth_token_candidates_includes_linux_secret_service_token(
1709+
monkeypatch: pytest.MonkeyPatch,
1710+
) -> None:
1711+
monkeypatch.delenv("GITHUB_COPILOT_GITHUB_TOKEN", raising=False)
1712+
monkeypatch.delenv("GITHUB_COPILOT_TOKEN", raising=False)
1713+
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
1714+
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
1715+
monkeypatch.delenv("GH_TOKEN", raising=False)
1716+
monkeypatch.setattr(copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: None)
1717+
monkeypatch.setattr(copilot_auth, "_read_macos_keychain_oauth_token", lambda: None)
1718+
monkeypatch.setattr(copilot_auth, "_read_linux_secret_oauth_token", lambda: "gho-linux")
1719+
monkeypatch.setattr(copilot_auth, "_read_file_oauth_token_candidates", lambda: [])
1720+
monkeypatch.setattr(copilot_auth, "_read_gh_cli_oauth_token", lambda: None)
1721+
1722+
candidates = copilot_auth.iter_oauth_token_candidates()
1723+
1724+
assert [(candidate.source, candidate.token) for candidate in candidates] == [
1725+
("linux-secret-service:copilot-cli", "gho-linux"),
1726+
]
1727+
1728+
1729+
def test_iter_oauth_token_candidates_skips_platform_secret_stores_when_disabled(
1730+
monkeypatch: pytest.MonkeyPatch,
1731+
) -> None:
1732+
monkeypatch.delenv("GITHUB_COPILOT_GITHUB_TOKEN", raising=False)
1733+
monkeypatch.delenv("GITHUB_COPILOT_TOKEN", raising=False)
1734+
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
1735+
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
1736+
monkeypatch.delenv("GH_TOKEN", raising=False)
1737+
monkeypatch.setattr(copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: None)
1738+
monkeypatch.setattr(
1739+
copilot_auth,
1740+
"_platform_secret_store_oauth_token_candidates",
1741+
lambda: pytest.fail("platform secret stores should not be read"),
1742+
)
1743+
monkeypatch.setattr(copilot_auth, "_read_file_oauth_token_candidates", lambda: [])
1744+
monkeypatch.setattr(copilot_auth, "_read_gh_cli_oauth_token", lambda: None)
1745+
1746+
assert copilot_auth.iter_oauth_token_candidates(include_platform_secret_stores=False) == []
1747+
1748+
1749+
def test_platform_secret_store_candidates_include_macos_keychain_token(
1750+
monkeypatch: pytest.MonkeyPatch,
1751+
) -> None:
1752+
monkeypatch.setattr(copilot_auth, "_read_macos_keychain_oauth_token", lambda: "gho-macos")
1753+
monkeypatch.setattr(copilot_auth, "_read_linux_secret_oauth_token", lambda: None)
1754+
1755+
candidates = copilot_auth._platform_secret_store_oauth_token_candidates()
1756+
1757+
assert [(candidate.source, candidate.token) for candidate in candidates] == [
1758+
("macos-keychain:copilot-cli", "gho-macos"),
1759+
]

tests/test_copilot_subscription_smoke.py

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -100,19 +100,33 @@ def test_subscription_rejects_generic_token_and_accepts_api_token(
100100
)
101101
# A generic GitHub token is present but cannot be exchanged for a Copilot
102102
# API token; a valid Copilot API token is discoverable behind it.
103+
non_platform_candidates = [
104+
copilot_auth.CopilotTokenCandidate(
105+
token="ghp-generic-pat",
106+
source="env:GITHUB_TOKEN",
107+
confidence="generic-github",
108+
)
109+
]
110+
platform_candidates = [
111+
copilot_auth.CopilotTokenCandidate(
112+
token="tid_real_copilot",
113+
source="macos-keychain:copilot-cli",
114+
confidence="high",
115+
)
116+
]
103117
monkeypatch.setattr(
104118
copilot_auth,
105119
"iter_oauth_token_candidates",
106-
lambda: [
107-
copilot_auth.CopilotTokenCandidate(
108-
token="ghp-generic-pat", source="env:GITHUB_TOKEN", confidence="generic-github"
109-
),
110-
copilot_auth.CopilotTokenCandidate(
111-
token="tid_real_copilot",
112-
source="macos-keychain:copilot-cli",
113-
confidence="high",
114-
),
115-
],
120+
lambda *, include_platform_secret_stores=True: (
121+
platform_candidates + non_platform_candidates
122+
if include_platform_secret_stores
123+
else non_platform_candidates
124+
),
125+
)
126+
monkeypatch.setattr(
127+
copilot_auth,
128+
"_platform_secret_store_oauth_token_candidates",
129+
lambda: platform_candidates,
116130
)
117131
monkeypatch.setattr(
118132
copilot_auth,

0 commit comments

Comments
 (0)