| """GitHub Copilot authentication utilities. |
| |
| Implements the OAuth device code flow used by the Copilot CLI and handles |
| token validation/exchange for the Copilot API. |
| |
| Token type support (per GitHub docs): |
| gho_ OAuth token β (default via copilot login) |
| github_pat_ Fine-grained PAT β (needs Copilot Requests permission) |
| ghu_ GitHub App token β (via environment variable) |
| ghp_ Classic PAT β NOT SUPPORTED |
| |
| Credential search order (matching Copilot CLI behaviour): |
| 1. COPILOT_GITHUB_TOKEN env var |
| 2. GH_TOKEN env var |
| 3. GITHUB_TOKEN env var |
| 4. gh auth token CLI fallback |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| import logging |
| import os |
| import re |
| import shutil |
| import subprocess |
| import time |
| from pathlib import Path |
| from typing import Any, Optional |
|
|
| logger = logging.getLogger(__name__) |
|
|
| |
| COPILOT_OAUTH_CLIENT_ID = "Ov23li8tweQw6odWQebz" |
| COPILOT_DEVICE_CODE_URL = "https://github.com/login/device/code" |
| COPILOT_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token" |
|
|
| |
| COPILOT_TOKEN_EXCHANGE_URL = "https://api.github.com/copilot_internal/v2/token" |
| COPILOT_API_BASE_URL = "https://api.githubcopilot.com" |
|
|
| |
| _CLASSIC_PAT_PREFIX = "ghp_" |
| _SUPPORTED_PREFIXES = ("gho_", "github_pat_", "ghu_") |
|
|
| |
| COPILOT_ENV_VARS = ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN") |
|
|
| |
| _DEVICE_CODE_POLL_INTERVAL = 5 |
| _DEVICE_CODE_POLL_SAFETY_MARGIN = 3 |
|
|
|
|
| def is_classic_pat(token: str) -> bool: |
| """Check if a token is a classic PAT (ghp_*), which Copilot doesn't support.""" |
| return token.strip().startswith(_CLASSIC_PAT_PREFIX) |
|
|
|
|
| def validate_copilot_token(token: str) -> tuple[bool, str]: |
| """Validate that a token is usable with the Copilot API. |
| |
| Returns (valid, message). |
| """ |
| token = token.strip() |
| if not token: |
| return False, "Empty token" |
|
|
| if token.startswith(_CLASSIC_PAT_PREFIX): |
| return False, ( |
| "Classic Personal Access Tokens (ghp_*) are not supported by the " |
| "Copilot API. Use one of:\n" |
| " β `copilot login` or `hermes model` to authenticate via OAuth\n" |
| " β A fine-grained PAT (github_pat_*) with Copilot Requests permission\n" |
| " β `gh auth login` with the default device code flow (produces gho_* tokens)" |
| ) |
|
|
| return True, "OK" |
|
|
|
|
| def resolve_copilot_token() -> tuple[str, str]: |
| """Resolve a GitHub token suitable for Copilot API use. |
| |
| Returns (token, source) where source describes where the token came from. |
| Raises ValueError if only a classic PAT is available. |
| """ |
| |
| for env_var in COPILOT_ENV_VARS: |
| val = os.getenv(env_var, "").strip() |
| if val: |
| valid, msg = validate_copilot_token(val) |
| if not valid: |
| logger.warning( |
| "Token from %s is not supported: %s", env_var, msg |
| ) |
| continue |
| return val, env_var |
|
|
| |
| token = _try_gh_cli_token() |
| if token: |
| valid, msg = validate_copilot_token(token) |
| if not valid: |
| raise ValueError( |
| f"Token from `gh auth token` is a classic PAT (ghp_*). {msg}" |
| ) |
| return token, "gh auth token" |
|
|
| return "", "" |
|
|
|
|
| def _gh_cli_candidates() -> list[str]: |
| """Return candidate ``gh`` binary paths, including common Homebrew installs.""" |
| candidates: list[str] = [] |
|
|
| resolved = shutil.which("gh") |
| if resolved: |
| candidates.append(resolved) |
|
|
| for candidate in ( |
| "/opt/homebrew/bin/gh", |
| "/usr/local/bin/gh", |
| str(Path.home() / ".local" / "bin" / "gh"), |
| ): |
| if candidate in candidates: |
| continue |
| if os.path.isfile(candidate) and os.access(candidate, os.X_OK): |
| candidates.append(candidate) |
|
|
| return candidates |
|
|
|
|
| def _try_gh_cli_token() -> Optional[str]: |
| """Return a token from ``gh auth token`` when the GitHub CLI is available.""" |
| for gh_path in _gh_cli_candidates(): |
| try: |
| result = subprocess.run( |
| [gh_path, "auth", "token"], |
| capture_output=True, |
| text=True, |
| timeout=5, |
| ) |
| except (FileNotFoundError, subprocess.TimeoutExpired) as exc: |
| logger.debug("gh CLI token lookup failed (%s): %s", gh_path, exc) |
| continue |
| if result.returncode == 0 and result.stdout.strip(): |
| return result.stdout.strip() |
| return None |
|
|
|
|
| |
|
|
| def copilot_device_code_login( |
| *, |
| host: str = "github.com", |
| timeout_seconds: float = 300, |
| ) -> Optional[str]: |
| """Run the GitHub OAuth device code flow for Copilot. |
| |
| Prints instructions for the user, polls for completion, and returns |
| the OAuth access token on success, or None on failure/cancellation. |
| |
| This replicates the flow used by opencode and the Copilot CLI. |
| """ |
| import urllib.request |
| import urllib.parse |
|
|
| domain = host.rstrip("/") |
| device_code_url = f"https://{domain}/login/device/code" |
| access_token_url = f"https://{domain}/login/oauth/access_token" |
|
|
| |
| data = urllib.parse.urlencode({ |
| "client_id": COPILOT_OAUTH_CLIENT_ID, |
| "scope": "read:user", |
| }).encode() |
|
|
| req = urllib.request.Request( |
| device_code_url, |
| data=data, |
| headers={ |
| "Accept": "application/json", |
| "Content-Type": "application/x-www-form-urlencoded", |
| "User-Agent": "HermesAgent/1.0", |
| }, |
| ) |
|
|
| try: |
| with urllib.request.urlopen(req, timeout=15) as resp: |
| device_data = json.loads(resp.read().decode()) |
| except Exception as exc: |
| logger.error("Failed to initiate device authorization: %s", exc) |
| print(f" β Failed to start device authorization: {exc}") |
| return None |
|
|
| verification_uri = device_data.get("verification_uri", "https://github.com/login/device") |
| user_code = device_data.get("user_code", "") |
| device_code = device_data.get("device_code", "") |
| interval = max(device_data.get("interval", _DEVICE_CODE_POLL_INTERVAL), 1) |
|
|
| if not device_code or not user_code: |
| print(" β GitHub did not return a device code.") |
| return None |
|
|
| |
| print() |
| print(f" Open this URL in your browser: {verification_uri}") |
| print(f" Enter this code: {user_code}") |
| print() |
| print(" Waiting for authorization...", end="", flush=True) |
|
|
| |
| deadline = time.time() + timeout_seconds |
|
|
| while time.time() < deadline: |
| time.sleep(interval + _DEVICE_CODE_POLL_SAFETY_MARGIN) |
|
|
| poll_data = urllib.parse.urlencode({ |
| "client_id": COPILOT_OAUTH_CLIENT_ID, |
| "device_code": device_code, |
| "grant_type": "urn:ietf:params:oauth:grant-type:device_code", |
| }).encode() |
|
|
| poll_req = urllib.request.Request( |
| access_token_url, |
| data=poll_data, |
| headers={ |
| "Accept": "application/json", |
| "Content-Type": "application/x-www-form-urlencoded", |
| "User-Agent": "HermesAgent/1.0", |
| }, |
| ) |
|
|
| try: |
| with urllib.request.urlopen(poll_req, timeout=10) as resp: |
| result = json.loads(resp.read().decode()) |
| except Exception: |
| print(".", end="", flush=True) |
| continue |
|
|
| if result.get("access_token"): |
| print(" β") |
| return result["access_token"] |
|
|
| error = result.get("error", "") |
| if error == "authorization_pending": |
| print(".", end="", flush=True) |
| continue |
| elif error == "slow_down": |
| |
| server_interval = result.get("interval") |
| if isinstance(server_interval, (int, float)) and server_interval > 0: |
| interval = int(server_interval) |
| else: |
| interval += 5 |
| print(".", end="", flush=True) |
| continue |
| elif error == "expired_token": |
| print() |
| print(" β Device code expired. Please try again.") |
| return None |
| elif error == "access_denied": |
| print() |
| print(" β Authorization was denied.") |
| return None |
| elif error: |
| print() |
| print(f" β Authorization failed: {error}") |
| return None |
|
|
| print() |
| print(" β Timed out waiting for authorization.") |
| return None |
|
|
|
|
| |
|
|
| def copilot_request_headers( |
| *, |
| is_agent_turn: bool = True, |
| is_vision: bool = False, |
| ) -> dict[str, str]: |
| """Build the standard headers for Copilot API requests. |
| |
| Replicates the header set used by opencode and the Copilot CLI. |
| """ |
| headers: dict[str, str] = { |
| "Editor-Version": "vscode/1.104.1", |
| "User-Agent": "HermesAgent/1.0", |
| "Openai-Intent": "conversation-edits", |
| "x-initiator": "agent" if is_agent_turn else "user", |
| } |
| if is_vision: |
| headers["Copilot-Vision-Request"] = "true" |
|
|
| return headers |
|
|