fix: exchange GitHub token for Copilot session token before API calls#7730
fix: exchange GitHub token for Copilot session token before API calls#7730emma-clawdbot wants to merge 6 commits into
Conversation
96d2dff to
a491cdc
Compare
Code ReviewOverall: The fix is correct and necessary — sending raw GitHub OAuth tokens to the Copilot inference API is the root cause of 403s. The implementation follows the same exchange pattern as opencode/openclaw/VS Code. Good test coverage and well-documented PR. Several items worth addressing before merge: Issues1. TOCTOU race on token cache file permissions ( cache_path.write_text(json.dumps(payload), encoding="utf-8")
try:
cache_path.chmod(0o600)
except OSError:
passThe file is created with default permissions (often 2.
3. Duplicate The 4. Redundant import inside from urllib.parse import urlparse # inside function body
5. Hardcoded "Editor-Version": "vscode/1.104.1",Fragile. If GitHub validates or rate-limits by editor version, this breaks silently. Consider making it a module-level constant with a comment, or using the actual Hermes version string. 6. except RuntimeError as exc:
logger.error("Copilot token exchange failed: %s", exc)
return "", ""Returning Minor
What's Good
|
|
All 6 issues and 3 minor items addressed in 8dc572c4: Issue 1 — TOCTOU race: Issue 2 — base_url not returned: Issue 3 — Duplicate normalization: Extracted Issue 4 — Redundant import: Removed Issue 5 — Hardcoded Editor-Version: Extracted to Issue 6 — Silent error swallowing: Minor items:
Tests updated: 40 copilot_auth tests + 127 api_key_providers tests all pass. |
…API calls Hermes was sending raw GitHub OAuth tokens (gho_*, ghu_*, github_pat_*) directly to api.githubcopilot.com, which returns 403. The Copilot API requires a session token (tid=...) obtained by exchanging the GitHub token with https://api.github.com/copilot_internal/v2/token. This is the same flow that opencode, openclaw, and VS Code all perform. Changes: - Add exchange_github_token_for_copilot_session() to call the internal token endpoint and obtain a Copilot session token - Add session token caching with 5-minute expiry margin - Add _derive_base_url_from_token() to extract the correct API base URL from the proxy-ep field (supports individual, enterprise, etc.) - Modify resolve_copilot_token() to perform the full chain: resolve GitHub token -> exchange -> return session token - Update tests for the new exchange flow and add tests for caching, base URL derivation, and error handling
- Fix TOCTOU race in _save_session_token: use os.open with O_CREAT|O_WRONLY and mode 0o600 so the file is never world-readable between creation and chmod - Return base_url from resolve_copilot_token as 3-tuple (token, base_url, source) so callers get enterprise-specific API endpoints without re-parsing the session token - Extract _normalize_expiry helper to deduplicate the ms-to-seconds conversion used in both cache loading and exchange response parsing - Remove redundant 'from urllib.parse import urlparse' inside _derive_base_url_from_token (already imported at module level) - Extract _EDITOR_VERSION module constant from hardcoded header values - Improve error logging in resolve_copilot_token: log warning with github_source context when exchange fails, making exchange failure distinguishable from no-token-found - Cache hermes_home in _get_token_cache_path to avoid repeated import attempts - Add comment explaining cache-before-validation ordering in exchange_github_token_for_copilot_session - Update tests: 3-tuple unpacking, new _normalize_expiry tests, mock resolve_copilot_token instead of _try_gh_cli_token for tests that don't need to test the exchange itself
8dc572c to
9883544
Compare
|
Rebased onto latest main ( Tests: 40 copilot_auth + 14 copilot provider tests pass. |
The session token's proxy-ep field determines the correct API endpoint (e.g. api.enterprise.githubcopilot.com for enterprise Copilot). Previously _resolve_api_key_provider_secret discarded this URL, so the client always hit the individual-tier endpoint (api.githubcopilot.com), causing 400 'model not supported' for enterprise-only models like claude-opus-4.6. _resolve_api_key_provider_secret now returns a 3-tuple with the optional base_url_override. Both resolve_api_key_provider_credentials and get_api_key_provider_status use it when no explicit env/config override is set.
Two issues caused copilot to use the wrong API endpoint after the session token expired: 1. The credential pool stores the raw GitHub token and the default base_url (api.githubcopilot.com). When the pool path selected a copilot entry, it used this stale URL instead of the enterprise endpoint derived from the session token's proxy-ep field. Fix: in _resolve_runtime_from_pool_entry, re-call resolve_copilot_token() for copilot to get the session token and the correct enterprise base_url. 2. config.yaml persists 'base_url: https://api.githubcopilot.com' from initial setup. resolve_runtime_provider honoured this saved value over the session-derived enterprise URL. Fix: skip cfg_base_url override for copilot provider since the session token is the authoritative source for the endpoint. E2E verified: hermes chat --provider copilot -m claude-opus-4.6 returns correct response via api.enterprise.githubcopilot.com.
All 10 checks used "api.githubcopilot.com" in url which does NOT match "api.enterprise.githubcopilot.com" (the enterprise endpoint). Changed to "githubcopilot.com" in url which matches both individual and enterprise endpoints. Without this, the enterprise endpoint was missing Editor-Version and other required Copilot headers, causing 400 "missing Editor-Version header for IDE auth".
…ug logging
F4: Add logger.debug for copilot session exchange failures in pool path
(was silent except Exception: pass)
F9: Validate proxy-ep hostname contains a dot (must be FQDN) before
constructing API URL. Prevents javascript:, empty, or single-label
hostnames from producing invalid base URLs. Added negative tests for
malformed proxy-ep values.
Code-eval verdict: approve-with-notes (0 CRITICAL, 0 HIGH, 1 MEDIUM
formatting noise, 5 LOW, 3 INFO)
|
Thanks for the contribution @emma-clawdbot. Closing as superseded — the same token-exchange feature (GitHub OAuth token → Copilot API JWT via Your PR was submitted first and solved the same problem. Both contributors are credited in the salvage PR body. The 6500-line diff made a direct salvage impractical, but the feature you identified is now in main. Merged: d7ad07d fix(copilot): exchange raw GitHub token for Copilot API JWT |
Summary
gho_*,ghu_*,github_pat_*) directly toapi.githubcopilot.com, which returns 403 Forbidden ("not authorized to use this Copilot feature")tid=...) obtained by exchanging the GitHub token withhttps://api.github.com/copilot_internal/v2/token— the same flow that opencode, openclaw, and VS Code all performcopilot_auth.pyWhat Changed
hermes_cli/copilot_auth.py:exchange_github_token_for_copilot_session()— callsGET https://api.github.com/copilot_internal/v2/tokenwith the GitHub token asAuthorization: Bearer, receives a short-lived Copilot session tokenCopilotSessionTokendataclass for structured token metadata~/.hermes/credentials/github-copilot.token.jsonwith 5-minute expiry margin (compatible with openclaw's cache format)_derive_base_url_from_token()— extracts theproxy-epfield from the session token and derives the correct API base URL (e.g.,proxy.enterprise.githubcopilot.com→api.enterprise.githubcopilot.com). Supports individual, enterprise, and other Copilot tiersresolve_copilot_token()to perform the full chain: resolve GitHub token → exchange for session token → return session tokenBefore / After
gho_*OAuth tokentid=...session tokenproxy-ep)Testing
Tested with enterprise Copilot license (
copilot_enterprise_seat_quotaSKU) usingghu_*token viaCOPILOT_GITHUB_TOKENenv var. Verified:tid=...session tokenapi.enterprise.githubcopilot.comclaude-opus-4.6model works through the enterprise endpointgithub-copilot.token.json