openclaw/src/providers/github-copilot-token.ts
RebelSyntax c800a686e7 fix(github-copilot): use gho_ tokens directly without exchange
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
2026-01-29 23:36:52 -05:00

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,
};
}