Skip to content

Commit 61fdc20

Browse files
committed
fix(copilot): defer keychain auth lookup
1 parent 232fb49 commit 61fdc20

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
@@ -647,7 +647,9 @@ def read_cached_oauth_token() -> str | None:
647647
return None
648648

649649

650-
def iter_oauth_token_candidates() -> list[CopilotTokenCandidate]:
650+
def iter_oauth_token_candidates(
651+
*, include_platform_secret_stores: bool = True
652+
) -> list[CopilotTokenCandidate]:
651653
"""Return reusable token candidates in safest-first discovery order."""
652654

653655
candidates: list[CopilotTokenCandidate] = []
@@ -683,25 +685,8 @@ def iter_oauth_token_candidates() -> list[CopilotTokenCandidate]:
683685
)
684686
)
685687

686-
macos_copilot_token = _read_macos_keychain_oauth_token()
687-
if macos_copilot_token:
688-
candidates.append(
689-
CopilotTokenCandidate(
690-
token=macos_copilot_token,
691-
source="macos-keychain:copilot-cli",
692-
confidence="high",
693-
)
694-
)
695-
696-
linux_copilot_token = _read_linux_secret_oauth_token()
697-
if linux_copilot_token:
698-
candidates.append(
699-
CopilotTokenCandidate(
700-
token=linux_copilot_token,
701-
source="linux-secret-service:copilot-cli",
702-
confidence="high",
703-
)
704-
)
688+
if include_platform_secret_stores:
689+
candidates.extend(_platform_secret_store_oauth_token_candidates())
705690

706691
candidates.extend(_read_file_oauth_token_candidates())
707692

@@ -729,6 +714,31 @@ def iter_oauth_token_candidates() -> list[CopilotTokenCandidate]:
729714
return _dedupe_token_candidates(candidates)
730715

731716

717+
def _platform_secret_store_oauth_token_candidates() -> list[CopilotTokenCandidate]:
718+
"""Return OAuth candidates from platform credential stores."""
719+
candidates: list[CopilotTokenCandidate] = []
720+
macos_copilot_token = _read_macos_keychain_oauth_token()
721+
if macos_copilot_token:
722+
candidates.append(
723+
CopilotTokenCandidate(
724+
token=macos_copilot_token,
725+
source="macos-keychain:copilot-cli",
726+
confidence="high",
727+
)
728+
)
729+
730+
linux_copilot_token = _read_linux_secret_oauth_token()
731+
if linux_copilot_token:
732+
candidates.append(
733+
CopilotTokenCandidate(
734+
token=linux_copilot_token,
735+
source="linux-secret-service:copilot-cli",
736+
confidence="high",
737+
)
738+
)
739+
return candidates
740+
741+
732742
def _read_file_oauth_token_candidates() -> list[CopilotTokenCandidate]:
733743
"""Return token candidates from Copilot/GitHub credential files."""
734744

@@ -940,9 +950,34 @@ def resolve_subscription_bearer_token_details() -> CopilotSubscriptionTokenResol
940950
api_url=_subscription_api_url_from_user_info_payload(payload),
941951
)
942952

943-
for candidate in iter_oauth_token_candidates():
953+
attempted_tokens: set[str] = set()
954+
resolution = _resolve_subscription_oauth_token_candidates(
955+
iter_oauth_token_candidates(include_platform_secret_stores=False),
956+
attempted_tokens=attempted_tokens,
957+
)
958+
if resolution is not None:
959+
return resolution
960+
961+
return _resolve_subscription_oauth_token_candidates(
962+
[
963+
candidate
964+
for candidate in _platform_secret_store_oauth_token_candidates()
965+
if candidate.token not in attempted_tokens
966+
]
967+
)
968+
969+
970+
def _resolve_subscription_oauth_token_candidates(
971+
candidates: list[CopilotTokenCandidate],
972+
*,
973+
attempted_tokens: set[str] | None = None,
974+
) -> CopilotSubscriptionTokenResolution | None:
975+
"""Return the first candidate GitHub accepts for subscription APIs."""
976+
for candidate in candidates:
944977
if not candidate.validate_for_subscription:
945978
continue
979+
if attempted_tokens is not None:
980+
attempted_tokens.add(candidate.token)
946981
if _is_copilot_api_token(candidate.token):
947982
payload = _fetch_copilot_user_info(candidate.token)
948983
if payload is not None:

tests/test_copilot_auth.py

Lines changed: 148 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ def test_resolve_subscription_bearer_token_skips_invalid_generic_token(
196196
monkeypatch.setattr(
197197
copilot_auth,
198198
"iter_oauth_token_candidates",
199-
lambda: [
199+
lambda **_kwargs: [
200200
copilot_auth.CopilotTokenCandidate(
201201
token="ghp-generic",
202202
source="env:GITHUB_TOKEN",
@@ -233,7 +233,7 @@ def test_resolve_subscription_bearer_token_does_not_fallback_to_unexchanged_oaut
233233
monkeypatch.setattr(
234234
copilot_auth,
235235
"iter_oauth_token_candidates",
236-
lambda: [
236+
lambda **_kwargs: [
237237
copilot_auth.CopilotTokenCandidate(
238238
token="gho-copilot",
239239
source="macos-keychain:copilot-cli",
@@ -254,6 +254,94 @@ def test_resolve_subscription_bearer_token_does_not_fallback_to_unexchanged_oaut
254254
assert copilot_auth.resolve_subscription_bearer_token() is None
255255

256256

257+
def test_resolve_subscription_bearer_token_defers_keychain_when_saved_token_resolves(
258+
monkeypatch: pytest.MonkeyPatch,
259+
) -> None:
260+
copilot_auth.save_headroom_copilot_oauth_token("gho-saved")
261+
monkeypatch.setattr(copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: None)
262+
monkeypatch.setattr(
263+
copilot_auth,
264+
"_read_macos_keychain_oauth_token",
265+
lambda: pytest.fail("Keychain must not be read when saved OAuth resolves"),
266+
)
267+
monkeypatch.setattr(copilot_auth, "_read_linux_secret_oauth_token", lambda: None)
268+
monkeypatch.setattr(copilot_auth, "_read_file_oauth_token_candidates", lambda: [])
269+
monkeypatch.setattr(copilot_auth, "_read_gh_cli_oauth_token", lambda: None)
270+
monkeypatch.setattr(
271+
copilot_auth,
272+
"_subscription_resolution_from_token_exchange",
273+
lambda candidate: (
274+
copilot_auth._subscription_resolution(
275+
token="copilot-api",
276+
source=f"{candidate.source}:token-exchange",
277+
confidence="copilot-token-exchange",
278+
api_url=copilot_auth.DEFAULT_API_URL,
279+
refresh_oauth_token=candidate.token,
280+
)
281+
if candidate.token == "gho-saved"
282+
else None
283+
),
284+
)
285+
286+
assert copilot_auth.resolve_subscription_bearer_token() == "copilot-api"
287+
288+
289+
def test_resolve_subscription_bearer_token_reads_keychain_after_noninteractive_rejection(
290+
monkeypatch: pytest.MonkeyPatch,
291+
) -> None:
292+
copilot_auth.save_headroom_copilot_oauth_token("gho-rejected")
293+
monkeypatch.setattr(copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: None)
294+
monkeypatch.setattr(
295+
copilot_auth,
296+
"_read_macos_keychain_oauth_token",
297+
lambda: "gho-keychain",
298+
)
299+
monkeypatch.setattr(copilot_auth, "_read_linux_secret_oauth_token", lambda: None)
300+
monkeypatch.setattr(copilot_auth, "_read_file_oauth_token_candidates", lambda: [])
301+
monkeypatch.setattr(copilot_auth, "_read_gh_cli_oauth_token", lambda: None)
302+
monkeypatch.setattr(
303+
copilot_auth,
304+
"_subscription_resolution_from_token_exchange",
305+
lambda candidate: (
306+
copilot_auth._subscription_resolution(
307+
token="copilot-api",
308+
source=f"{candidate.source}:token-exchange",
309+
confidence="copilot-token-exchange",
310+
api_url=copilot_auth.DEFAULT_API_URL,
311+
refresh_oauth_token=candidate.token,
312+
)
313+
if candidate.token == "gho-keychain"
314+
else None
315+
),
316+
)
317+
318+
assert copilot_auth.resolve_subscription_bearer_token() == "copilot-api"
319+
320+
321+
def test_resolve_subscription_bearer_token_skips_duplicate_keychain_candidate(
322+
monkeypatch: pytest.MonkeyPatch,
323+
) -> None:
324+
copilot_auth.save_headroom_copilot_oauth_token("gho-duplicate")
325+
monkeypatch.setattr(copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: None)
326+
monkeypatch.setattr(
327+
copilot_auth,
328+
"_read_macos_keychain_oauth_token",
329+
lambda: "gho-duplicate",
330+
)
331+
monkeypatch.setattr(copilot_auth, "_read_linux_secret_oauth_token", lambda: None)
332+
monkeypatch.setattr(copilot_auth, "_read_file_oauth_token_candidates", lambda: [])
333+
monkeypatch.setattr(copilot_auth, "_read_gh_cli_oauth_token", lambda: None)
334+
attempted_tokens: list[str] = []
335+
monkeypatch.setattr(
336+
copilot_auth,
337+
"_subscription_resolution_from_token_exchange",
338+
lambda candidate: attempted_tokens.append(candidate.token) or None,
339+
)
340+
341+
assert copilot_auth.resolve_subscription_bearer_token() is None
342+
assert attempted_tokens == ["gho-duplicate"]
343+
344+
257345
def test_subscription_enterprise_host_repro(
258346
monkeypatch: pytest.MonkeyPatch,
259347
) -> None:
@@ -268,7 +356,7 @@ def test_subscription_enterprise_host_repro(
268356
monkeypatch.setattr(
269357
copilot_auth,
270358
"iter_oauth_token_candidates",
271-
lambda: [
359+
lambda **_kwargs: [
272360
copilot_auth.CopilotTokenCandidate(
273361
token="gho-oauth",
274362
source="headroom-copilot-auth:/tmp/copilot_auth.json",
@@ -324,7 +412,7 @@ def test_resolve_subscription_exchange_uses_cloud_enterprise_advertised_api(
324412
monkeypatch.setattr(
325413
copilot_auth,
326414
"iter_oauth_token_candidates",
327-
lambda: [
415+
lambda **_kwargs: [
328416
copilot_auth.CopilotTokenCandidate(
329417
token="gho-oauth",
330418
source="env:GITHUB_COPILOT_TOKEN",
@@ -391,7 +479,7 @@ def _resolve_subscription_producer_path(
391479
patch.setattr(
392480
copilot_auth,
393481
"iter_oauth_token_candidates",
394-
lambda: [
482+
lambda **_kwargs: [
395483
copilot_auth.CopilotTokenCandidate(
396484
token="gho-oauth", source="test", confidence="test"
397485
)
@@ -409,7 +497,7 @@ def _resolve_subscription_producer_path(
409497
patch.setattr(
410498
copilot_auth,
411499
"iter_oauth_token_candidates",
412-
lambda: [
500+
lambda **_kwargs: [
413501
copilot_auth.CopilotTokenCandidate(
414502
token="tid_api", source="test", confidence="test"
415503
)
@@ -1565,3 +1653,57 @@ def __exit__(self, *args):
15651653
)
15661654

15671655
assert result == payload
1656+
1657+
1658+
def test_iter_oauth_token_candidates_includes_linux_secret_service_token(
1659+
monkeypatch: pytest.MonkeyPatch,
1660+
) -> None:
1661+
monkeypatch.delenv("GITHUB_COPILOT_GITHUB_TOKEN", raising=False)
1662+
monkeypatch.delenv("GITHUB_COPILOT_TOKEN", raising=False)
1663+
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
1664+
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
1665+
monkeypatch.delenv("GH_TOKEN", raising=False)
1666+
monkeypatch.setattr(copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: None)
1667+
monkeypatch.setattr(copilot_auth, "_read_macos_keychain_oauth_token", lambda: None)
1668+
monkeypatch.setattr(copilot_auth, "_read_linux_secret_oauth_token", lambda: "gho-linux")
1669+
monkeypatch.setattr(copilot_auth, "_read_file_oauth_token_candidates", lambda: [])
1670+
monkeypatch.setattr(copilot_auth, "_read_gh_cli_oauth_token", lambda: None)
1671+
1672+
candidates = copilot_auth.iter_oauth_token_candidates()
1673+
1674+
assert [(candidate.source, candidate.token) for candidate in candidates] == [
1675+
("linux-secret-service:copilot-cli", "gho-linux"),
1676+
]
1677+
1678+
1679+
def test_iter_oauth_token_candidates_skips_platform_secret_stores_when_disabled(
1680+
monkeypatch: pytest.MonkeyPatch,
1681+
) -> None:
1682+
monkeypatch.delenv("GITHUB_COPILOT_GITHUB_TOKEN", raising=False)
1683+
monkeypatch.delenv("GITHUB_COPILOT_TOKEN", raising=False)
1684+
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
1685+
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
1686+
monkeypatch.delenv("GH_TOKEN", raising=False)
1687+
monkeypatch.setattr(copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: None)
1688+
monkeypatch.setattr(
1689+
copilot_auth,
1690+
"_platform_secret_store_oauth_token_candidates",
1691+
lambda: pytest.fail("platform secret stores should not be read"),
1692+
)
1693+
monkeypatch.setattr(copilot_auth, "_read_file_oauth_token_candidates", lambda: [])
1694+
monkeypatch.setattr(copilot_auth, "_read_gh_cli_oauth_token", lambda: None)
1695+
1696+
assert copilot_auth.iter_oauth_token_candidates(include_platform_secret_stores=False) == []
1697+
1698+
1699+
def test_platform_secret_store_candidates_include_macos_keychain_token(
1700+
monkeypatch: pytest.MonkeyPatch,
1701+
) -> None:
1702+
monkeypatch.setattr(copilot_auth, "_read_macos_keychain_oauth_token", lambda: "gho-macos")
1703+
monkeypatch.setattr(copilot_auth, "_read_linux_secret_oauth_token", lambda: None)
1704+
1705+
candidates = copilot_auth._platform_secret_store_oauth_token_candidates()
1706+
1707+
assert [(candidate.source, candidate.token) for candidate in candidates] == [
1708+
("macos-keychain:copilot-cli", "gho-macos"),
1709+
]

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)