import path from "node:path"; import { resolveStateDir } from "../config/paths.js"; import { loadJsonFile, saveJsonFile } from "../infra/json-file.js"; const COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token"; export type CachedCopilotToken = { token: string; /** milliseconds since epoch */ expiresAt: number; /** milliseconds since epoch */ updatedAt: number; }; function resolveCopilotTokenCachePath(env: NodeJS.ProcessEnv = process.env) { return path.join( resolveStateDir(env), "credentials", "github-copilot.token.json", ); } function isTokenUsable(cache: CachedCopilotToken, now = Date.now()): boolean { // Keep a small safety margin when checking expiry. return cache.expiresAt - now > 5 * 60 * 1000; } function parseCopilotTokenResponse(value: unknown): { token: string; expiresAt: number; } { if (!value || typeof value !== "object") { throw new Error("Unexpected response from GitHub Copilot token endpoint"); } const asRecord = value as Record; const token = asRecord.token; const expiresAt = asRecord.expires_at; if (typeof token !== "string" || token.trim().length === 0) { throw new Error("Copilot token response missing token"); } // GitHub returns a unix timestamp (seconds), but we defensively accept ms too. let expiresAtMs: number; if (typeof expiresAt === "number" && Number.isFinite(expiresAt)) { expiresAtMs = expiresAt > 10_000_000_000 ? expiresAt : expiresAt * 1000; } else if (typeof expiresAt === "string" && expiresAt.trim().length > 0) { const parsed = Number.parseInt(expiresAt, 10); if (!Number.isFinite(parsed)) { throw new Error("Copilot token response has invalid expires_at"); } expiresAtMs = parsed > 10_000_000_000 ? parsed : parsed * 1000; } else { throw new Error("Copilot token response missing expires_at"); } return { token, expiresAt: expiresAtMs }; } export const DEFAULT_COPILOT_API_BASE_URL = "https://api.individual.githubcopilot.com"; export function deriveCopilotApiBaseUrlFromToken(token: string): string | null { const trimmed = token.trim(); if (!trimmed) return null; // The token returned from the Copilot token endpoint is a semicolon-delimited // set of key/value pairs. One of them is `proxy-ep=...`. const match = trimmed.match(/(?:^|;)\s*proxy-ep=([^;\s]+)/i); const proxyEp = match?.[1]?.trim(); if (!proxyEp) return null; // pi-ai expects converting proxy.* -> api.* // (see upstream getGitHubCopilotBaseUrl). const host = proxyEp.replace(/^https?:\/\//, "").replace(/^proxy\./i, "api."); if (!host) return null; return `https://${host}`; } export async function resolveCopilotApiToken(params: { githubToken: string; env?: NodeJS.ProcessEnv; fetchImpl?: typeof fetch; }): Promise<{ token: string; expiresAt: number; source: string; baseUrl: string; }> { const env = params.env ?? process.env; const cachePath = resolveCopilotTokenCachePath(env); const cached = loadJsonFile(cachePath) as CachedCopilotToken | undefined; if ( cached && typeof cached.token === "string" && typeof cached.expiresAt === "number" ) { if (isTokenUsable(cached)) { return { token: cached.token, expiresAt: cached.expiresAt, source: `cache:${cachePath}`, baseUrl: deriveCopilotApiBaseUrlFromToken(cached.token) ?? DEFAULT_COPILOT_API_BASE_URL, }; } } const fetchImpl = params.fetchImpl ?? fetch; const res = await fetchImpl(COPILOT_TOKEN_URL, { method: "GET", headers: { Accept: "application/json", Authorization: `Bearer ${params.githubToken}`, }, }); if (!res.ok) { throw new Error(`Copilot token exchange failed: HTTP ${res.status}`); } const json = parseCopilotTokenResponse(await res.json()); const payload: CachedCopilotToken = { token: json.token, expiresAt: json.expiresAt, updatedAt: Date.now(), }; saveJsonFile(cachePath, payload); return { token: payload.token, expiresAt: payload.expiresAt, source: `fetched:${COPILOT_TOKEN_URL}`, baseUrl: deriveCopilotApiBaseUrlFromToken(payload.token) ?? DEFAULT_COPILOT_API_BASE_URL, }; }