Tokens from the GitHub Copilot CLI (prefixed with `gho_`) can be used directly with the Copilot API without requiring a token exchange via /copilot_internal/v2/token (which returns HTTP 404 for these tokens). This fixes authentication for users who: - Installed the Copilot CLI (`npm install -g @github/copilot`) - Authenticated via `copilot auth login` - Set COPILOT_GITHUB_TOKEN or GH_TOKEN to their gho_ token The fix detects gho_ prefixed tokens and skips the exchange, caching them with an 8-hour TTL (matching typical OAuth token lifetimes). Also adds COPILOT_API_BASE_URL env var support for enterprise users whose proxy blocks api.individual.githubcopilot.com. Set this to your enterprise endpoint (e.g., https://api.business.githubcopilot.com). [AI-assisted] Tested with GitHub Copilot CLI 0.0.399 on WSL2. Closes #3437
180 lines
5.9 KiB
TypeScript
180 lines
5.9 KiB
TypeScript
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<string, unknown>;
|
|
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}`;
|
|
}
|
|
|
|
/**
|
|
* Check if a token is a GitHub OAuth token from the Copilot CLI.
|
|
* These tokens (prefixed with `gho_`) can be used directly with the Copilot API
|
|
* without requiring a token exchange via /copilot_internal/v2/token.
|
|
*/
|
|
function isCopilotCliToken(token: string): boolean {
|
|
return token.startsWith("gho_");
|
|
}
|
|
|
|
/**
|
|
* Default cache TTL for Copilot CLI OAuth tokens (prefixed with `gho_`).
|
|
*
|
|
* As of this writing, the Copilot CLI issues `gho_` tokens with an 8-hour lifetime,
|
|
* so we cache them for the same duration. GitHub OAuth token lifetimes can be
|
|
* configured and may change over time; this value is therefore a best-effort
|
|
* approximation for cache expiry rather than a guaranteed reflection of the
|
|
* server-side token lifetime.
|
|
*/
|
|
const COPILOT_CLI_TOKEN_TTL_MS = 8 * 60 * 60 * 1000;
|
|
|
|
/**
|
|
* Resolve the Copilot API base URL.
|
|
* Checks COPILOT_API_BASE_URL env var first (for enterprise/custom endpoints),
|
|
* then falls back to the default individual endpoint.
|
|
*/
|
|
function resolveCopilotApiBaseUrl(env: NodeJS.ProcessEnv = process.env): string {
|
|
const envUrl = env.COPILOT_API_BASE_URL?.trim();
|
|
if (envUrl) {
|
|
return envUrl;
|
|
}
|
|
return DEFAULT_COPILOT_API_BASE_URL;
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Copilot CLI tokens (gho_*) can be used directly with the Copilot API.
|
|
// These tokens are already authenticated and don't need the /v2/token exchange.
|
|
// See: https://docs.github.com/en/copilot/using-github-copilot/using-github-copilot-in-the-command-line
|
|
if (isCopilotCliToken(params.githubToken)) {
|
|
const now = Date.now();
|
|
const payload: CachedCopilotToken = {
|
|
token: params.githubToken,
|
|
expiresAt: now + COPILOT_CLI_TOKEN_TTL_MS,
|
|
updatedAt: now,
|
|
};
|
|
saveJsonFile(cachePath, payload);
|
|
return {
|
|
token: payload.token,
|
|
expiresAt: payload.expiresAt,
|
|
source: "copilot-cli:direct",
|
|
baseUrl: resolveCopilotApiBaseUrl(env),
|
|
};
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|