/ hermes_cli / copilot_auth.py
copilot_auth.py
1 """GitHub Copilot authentication utilities. 2 3 Implements the OAuth device code flow used by the Copilot CLI and handles 4 token validation/exchange for the Copilot API. 5 6 Token type support (per GitHub docs): 7 gho_ OAuth token ✓ (default via copilot login) 8 github_pat_ Fine-grained PAT ✓ (needs Copilot Requests permission) 9 ghu_ GitHub App token ✓ (via environment variable) 10 ghp_ Classic PAT ✗ NOT SUPPORTED 11 12 Credential search order (matching Copilot CLI behaviour): 13 1. COPILOT_GITHUB_TOKEN env var 14 2. GH_TOKEN env var 15 3. GITHUB_TOKEN env var 16 4. gh auth token CLI fallback 17 """ 18 19 from __future__ import annotations 20 21 import json 22 import logging 23 import os 24 import shutil 25 import subprocess 26 import time 27 from pathlib import Path 28 from typing import Optional 29 30 logger = logging.getLogger(__name__) 31 32 # OAuth device code flow constants (same client ID as opencode/Copilot CLI) 33 COPILOT_OAUTH_CLIENT_ID = "Ov23li8tweQw6odWQebz" 34 # Token type prefixes 35 _CLASSIC_PAT_PREFIX = "ghp_" 36 _SUPPORTED_PREFIXES = ("gho_", "github_pat_", "ghu_") 37 38 # Env var search order (matches Copilot CLI) 39 COPILOT_ENV_VARS = ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN") 40 41 # Polling constants 42 _DEVICE_CODE_POLL_INTERVAL = 5 # seconds 43 _DEVICE_CODE_POLL_SAFETY_MARGIN = 3 # seconds 44 45 46 def validate_copilot_token(token: str) -> tuple[bool, str]: 47 """Validate that a token is usable with the Copilot API. 48 49 Returns (valid, message). 50 """ 51 token = token.strip() 52 if not token: 53 return False, "Empty token" 54 55 if token.startswith(_CLASSIC_PAT_PREFIX): 56 return False, ( 57 "Classic Personal Access Tokens (ghp_*) are not supported by the " 58 "Copilot API. Use one of:\n" 59 " → `copilot login` or `hermes model` to authenticate via OAuth\n" 60 " → A fine-grained PAT (github_pat_*) with Copilot Requests permission\n" 61 " → `gh auth login` with the default device code flow (produces gho_* tokens)" 62 ) 63 64 return True, "OK" 65 66 67 def resolve_copilot_token() -> tuple[str, str]: 68 """Resolve a GitHub token suitable for Copilot API use. 69 70 Returns (token, source) where source describes where the token came from. 71 Raises ValueError if only a classic PAT is available. 72 """ 73 # 1. Check env vars in priority order 74 for env_var in COPILOT_ENV_VARS: 75 val = os.getenv(env_var, "").strip() 76 if val: 77 valid, msg = validate_copilot_token(val) 78 if not valid: 79 logger.warning( 80 "Token from %s is not supported: %s", env_var, msg 81 ) 82 continue 83 return val, env_var 84 85 # 2. Fall back to gh auth token 86 token = _try_gh_cli_token() 87 if token: 88 valid, msg = validate_copilot_token(token) 89 if not valid: 90 raise ValueError( 91 f"Token from `gh auth token` is a classic PAT (ghp_*). {msg}" 92 ) 93 return token, "gh auth token" 94 95 return "", "" 96 97 98 def _gh_cli_candidates() -> list[str]: 99 """Return candidate ``gh`` binary paths, including common Homebrew installs.""" 100 candidates: list[str] = [] 101 102 resolved = shutil.which("gh") 103 if resolved: 104 candidates.append(resolved) 105 106 for candidate in ( 107 "/opt/homebrew/bin/gh", 108 "/usr/local/bin/gh", 109 str(Path.home() / ".local" / "bin" / "gh"), 110 ): 111 if candidate in candidates: 112 continue 113 if os.path.isfile(candidate) and os.access(candidate, os.X_OK): 114 candidates.append(candidate) 115 116 return candidates 117 118 119 def _try_gh_cli_token() -> Optional[str]: 120 """Return a token from ``gh auth token`` when the GitHub CLI is available. 121 122 When COPILOT_GH_HOST is set, passes ``--hostname`` so gh returns the 123 correct host's token. Also strips GITHUB_TOKEN / GH_TOKEN from the 124 subprocess environment so ``gh`` reads from its own credential store 125 (hosts.yml) instead of just echoing the env var back. 126 """ 127 hostname = os.getenv("COPILOT_GH_HOST", "").strip() 128 129 # Build a clean env so gh doesn't short-circuit on GITHUB_TOKEN / GH_TOKEN 130 clean_env = {k: v for k, v in os.environ.items() 131 if k not in ("GITHUB_TOKEN", "GH_TOKEN")} 132 133 for gh_path in _gh_cli_candidates(): 134 cmd = [gh_path, "auth", "token"] 135 if hostname: 136 cmd += ["--hostname", hostname] 137 try: 138 result = subprocess.run( 139 cmd, 140 capture_output=True, 141 text=True, 142 timeout=5, 143 env=clean_env, 144 ) 145 except (FileNotFoundError, subprocess.TimeoutExpired) as exc: 146 logger.debug("gh CLI token lookup failed (%s): %s", gh_path, exc) 147 continue 148 if result.returncode == 0 and result.stdout.strip(): 149 return result.stdout.strip() 150 return None 151 152 153 # ─── OAuth Device Code Flow ──────────────────────────────────────────────── 154 155 def copilot_device_code_login( 156 *, 157 host: str = "github.com", 158 timeout_seconds: float = 300, 159 ) -> Optional[str]: 160 """Run the GitHub OAuth device code flow for Copilot. 161 162 Prints instructions for the user, polls for completion, and returns 163 the OAuth access token on success, or None on failure/cancellation. 164 165 This replicates the flow used by opencode and the Copilot CLI. 166 """ 167 import urllib.request 168 import urllib.parse 169 170 domain = host.rstrip("/") 171 device_code_url = f"https://{domain}/login/device/code" 172 access_token_url = f"https://{domain}/login/oauth/access_token" 173 174 # Step 1: Request device code 175 data = urllib.parse.urlencode({ 176 "client_id": COPILOT_OAUTH_CLIENT_ID, 177 "scope": "read:user", 178 }).encode() 179 180 req = urllib.request.Request( 181 device_code_url, 182 data=data, 183 headers={ 184 "Accept": "application/json", 185 "Content-Type": "application/x-www-form-urlencoded", 186 "User-Agent": "HermesAgent/1.0", 187 }, 188 ) 189 190 try: 191 with urllib.request.urlopen(req, timeout=15) as resp: 192 device_data = json.loads(resp.read().decode()) 193 except Exception as exc: 194 logger.error("Failed to initiate device authorization: %s", exc) 195 print(f" ✗ Failed to start device authorization: {exc}") 196 return None 197 198 verification_uri = device_data.get("verification_uri", "https://github.com/login/device") 199 user_code = device_data.get("user_code", "") 200 device_code = device_data.get("device_code", "") 201 interval = max(device_data.get("interval", _DEVICE_CODE_POLL_INTERVAL), 1) 202 203 if not device_code or not user_code: 204 print(" ✗ GitHub did not return a device code.") 205 return None 206 207 # Step 2: Show instructions 208 print() 209 print(f" Open this URL in your browser: {verification_uri}") 210 print(f" Enter this code: {user_code}") 211 print() 212 print(" Waiting for authorization...", end="", flush=True) 213 214 # Step 3: Poll for completion 215 deadline = time.time() + timeout_seconds 216 217 while time.time() < deadline: 218 time.sleep(interval + _DEVICE_CODE_POLL_SAFETY_MARGIN) 219 220 poll_data = urllib.parse.urlencode({ 221 "client_id": COPILOT_OAUTH_CLIENT_ID, 222 "device_code": device_code, 223 "grant_type": "urn:ietf:params:oauth:grant-type:device_code", 224 }).encode() 225 226 poll_req = urllib.request.Request( 227 access_token_url, 228 data=poll_data, 229 headers={ 230 "Accept": "application/json", 231 "Content-Type": "application/x-www-form-urlencoded", 232 "User-Agent": "HermesAgent/1.0", 233 }, 234 ) 235 236 try: 237 with urllib.request.urlopen(poll_req, timeout=10) as resp: 238 result = json.loads(resp.read().decode()) 239 except Exception: 240 print(".", end="", flush=True) 241 continue 242 243 if result.get("access_token"): 244 print(" ✓") 245 return result["access_token"] 246 247 error = result.get("error", "") 248 if error == "authorization_pending": 249 print(".", end="", flush=True) 250 continue 251 elif error == "slow_down": 252 # RFC 8628: add 5 seconds to polling interval 253 server_interval = result.get("interval") 254 if isinstance(server_interval, (int, float)) and server_interval > 0: 255 interval = int(server_interval) 256 else: 257 interval += 5 258 print(".", end="", flush=True) 259 continue 260 elif error == "expired_token": 261 print() 262 print(" ✗ Device code expired. Please try again.") 263 return None 264 elif error == "access_denied": 265 print() 266 print(" ✗ Authorization was denied.") 267 return None 268 elif error: 269 print() 270 print(f" ✗ Authorization failed: {error}") 271 return None 272 273 print() 274 print(" ✗ Timed out waiting for authorization.") 275 return None 276 277 278 # ─── Copilot Token Exchange ──────────────────────────────────────────────── 279 280 # Module-level cache for exchanged Copilot API tokens. 281 # Maps raw_token_fingerprint -> (api_token, expires_at_epoch). 282 _jwt_cache: dict[str, tuple[str, float]] = {} 283 _JWT_REFRESH_MARGIN_SECONDS = 120 # refresh 2 min before expiry 284 285 # Token exchange endpoint and headers (matching VS Code / Copilot CLI) 286 _TOKEN_EXCHANGE_URL = "https://api.github.com/copilot_internal/v2/token" 287 _EDITOR_VERSION = "vscode/1.104.1" 288 _EXCHANGE_USER_AGENT = "GitHubCopilotChat/0.26.7" 289 290 291 def _token_fingerprint(raw_token: str) -> str: 292 """Short fingerprint of a raw token for cache keying (avoids storing full token).""" 293 import hashlib 294 return hashlib.sha256(raw_token.encode()).hexdigest()[:16] 295 296 297 def exchange_copilot_token(raw_token: str, *, timeout: float = 10.0) -> tuple[str, float]: 298 """Exchange a raw GitHub token for a short-lived Copilot API token. 299 300 Calls ``GET https://api.github.com/copilot_internal/v2/token`` with 301 the raw GitHub token and returns ``(api_token, expires_at)``. 302 303 The returned token is a semicolon-separated string (not a standard JWT) 304 used as ``Authorization: Bearer <token>`` for Copilot API requests. 305 306 Results are cached in-process and reused until close to expiry. 307 Raises ``ValueError`` on failure. 308 """ 309 import urllib.request 310 311 fp = _token_fingerprint(raw_token) 312 313 # Check cache first 314 cached = _jwt_cache.get(fp) 315 if cached: 316 api_token, expires_at = cached 317 if time.time() < expires_at - _JWT_REFRESH_MARGIN_SECONDS: 318 return api_token, expires_at 319 320 req = urllib.request.Request( 321 _TOKEN_EXCHANGE_URL, 322 method="GET", 323 headers={ 324 "Authorization": f"token {raw_token}", 325 "User-Agent": _EXCHANGE_USER_AGENT, 326 "Accept": "application/json", 327 "Editor-Version": _EDITOR_VERSION, 328 }, 329 ) 330 331 try: 332 with urllib.request.urlopen(req, timeout=timeout) as resp: 333 data = json.loads(resp.read().decode()) 334 except Exception as exc: 335 raise ValueError(f"Copilot token exchange failed: {exc}") from exc 336 337 api_token = data.get("token", "") 338 expires_at = data.get("expires_at", 0) 339 if not api_token: 340 raise ValueError("Copilot token exchange returned empty token") 341 342 # Convert expires_at to float if needed 343 expires_at = float(expires_at) if expires_at else time.time() + 1800 344 345 _jwt_cache[fp] = (api_token, expires_at) 346 logger.debug( 347 "Copilot token exchanged, expires_at=%s", 348 expires_at, 349 ) 350 return api_token, expires_at 351 352 353 def get_copilot_api_token(raw_token: str) -> str: 354 """Exchange a raw GitHub token for a Copilot API token, with fallback. 355 356 Convenience wrapper: returns the exchanged token on success, or the 357 raw token unchanged if the exchange fails (e.g. network error, unsupported 358 account type). This preserves existing behaviour for accounts that don't 359 need exchange while enabling access to internal-only models for those that do. 360 """ 361 if not raw_token: 362 return raw_token 363 try: 364 api_token, _ = exchange_copilot_token(raw_token) 365 return api_token 366 except Exception as exc: 367 logger.debug("Copilot token exchange failed, using raw token: %s", exc) 368 return raw_token 369 370 371 # ─── Copilot API Headers ─────────────────────────────────────────────────── 372 373 def copilot_request_headers( 374 *, 375 is_agent_turn: bool = True, 376 is_vision: bool = False, 377 ) -> dict[str, str]: 378 """Build the standard headers for Copilot API requests. 379 380 Replicates the header set used by opencode and the Copilot CLI. 381 """ 382 headers: dict[str, str] = { 383 "Editor-Version": "vscode/1.104.1", 384 "User-Agent": "HermesAgent/1.0", 385 "Copilot-Integration-Id": "vscode-chat", 386 "Openai-Intent": "conversation-edits", 387 "x-initiator": "agent" if is_agent_turn else "user", 388 } 389 if is_vision: 390 headers["Copilot-Vision-Request"] = "true" 391 392 return headers