Automated cleanup via pyflakes + autoflake with manual review.
Changes:
- Removed unused stdlib imports (os, sys, json, pathlib.Path, etc.)
- Removed unused typing imports (List, Dict, Any, Optional, Tuple, Set, etc.)
- Removed unused internal imports (hermes_cli.auth, hermes_cli.config, etc.)
- Fixed cli.py: removed 8 shadowed banner imports (imported from hermes_cli.banner
then immediately redefined locally — only build_welcome_banner is actually used)
- Added noqa comments to imports that appear unused but serve a purpose:
- Re-exports (gateway/session.py SessionResetPolicy, tools/terminal_tool.py
is_interrupted/_interrupt_event)
- SDK presence checks in try/except (daytona, fal_client, discord)
- Test mock targets (auxiliary_client.py Path, mcp_config.py get_hermes_home)
Zero behavioral changes. Full test suite passes (6162/6162, 2 pre-existing
streaming test failures unrelated to this change).
295 lines
9.6 KiB
Python
295 lines
9.6 KiB
Python
"""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 shutil
|
|
import subprocess
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# OAuth device code flow constants (same client ID as opencode/Copilot CLI)
|
|
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 API constants
|
|
COPILOT_TOKEN_EXCHANGE_URL = "https://api.github.com/copilot_internal/v2/token"
|
|
COPILOT_API_BASE_URL = "https://api.githubcopilot.com"
|
|
|
|
# Token type prefixes
|
|
_CLASSIC_PAT_PREFIX = "ghp_"
|
|
_SUPPORTED_PREFIXES = ("gho_", "github_pat_", "ghu_")
|
|
|
|
# Env var search order (matches Copilot CLI)
|
|
COPILOT_ENV_VARS = ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN")
|
|
|
|
# Polling constants
|
|
_DEVICE_CODE_POLL_INTERVAL = 5 # seconds
|
|
_DEVICE_CODE_POLL_SAFETY_MARGIN = 3 # seconds
|
|
|
|
|
|
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.
|
|
"""
|
|
# 1. Check env vars in priority order
|
|
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
|
|
|
|
# 2. Fall back to gh auth token
|
|
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
|
|
|
|
|
|
# ─── OAuth Device Code Flow ────────────────────────────────────────────────
|
|
|
|
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"
|
|
|
|
# Step 1: Request device code
|
|
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
|
|
|
|
# Step 2: Show instructions
|
|
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)
|
|
|
|
# Step 3: Poll for completion
|
|
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":
|
|
# RFC 8628: add 5 seconds to polling interval
|
|
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
|
|
|
|
|
|
# ─── Copilot API Headers ───────────────────────────────────────────────────
|
|
|
|
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
|