openclaw/extensions/github-copilot/runtime-auth.ts

123 lines
4 KiB
TypeScript

// GitHub Copilot source-token validation and account endpoint resolution.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import { PUBLIC_GITHUB_COPILOT_DOMAIN, resolveGithubCopilotDomain } from "./domain.js";
import { CopilotRuntimeAuthError } from "./runtime-auth-error.js";
export const DEFAULT_COPILOT_API_BASE_URL = "https://api.individual.githubcopilot.com";
const COPILOT_RUNTIME_AUTH_TIMEOUT_MS = 30_000;
function copilotUserUrl(domain: string): string {
return `https://api.${domain}/copilot_internal/user`;
}
function copilotApiBaseFallback(domain: string): string {
return domain === PUBLIC_GITHUB_COPILOT_DOMAIN
? DEFAULT_COPILOT_API_BASE_URL
: `https://copilot-api.${domain}`;
}
function isTrustedCopilotApiHost(host: string, domain: string): boolean {
if (host === "copilot-proxy.githubusercontent.com" || host.endsWith(".githubcopilot.com")) {
return true;
}
return (
domain !== PUBLIC_GITHUB_COPILOT_DOMAIN && (host === domain || host.endsWith(`.${domain}`))
);
}
function parseCopilotApiBaseUrl(value: unknown, domain: string): string {
if (!value || typeof value !== "object") {
throw new Error("Unexpected response from GitHub Copilot user endpoint");
}
const endpoints = (value as { endpoints?: unknown }).endpoints;
const api =
endpoints && typeof endpoints === "object" ? (endpoints as { api?: unknown }).api : undefined;
if (api === undefined || api === null || api === "") {
return copilotApiBaseFallback(domain);
}
if (typeof api !== "string" || !api.trim()) {
throw new Error("GitHub Copilot user response has an invalid endpoints.api URL");
}
let url: URL;
try {
url = new URL(api);
} catch {
throw new Error("GitHub Copilot user response has an invalid endpoints.api URL");
}
const host = url.hostname.toLowerCase();
if (
url.protocol !== "https:" ||
url.username ||
url.password ||
url.search ||
url.hash ||
!isTrustedCopilotApiHost(host, domain)
) {
throw new Error("GitHub Copilot user response has an untrusted endpoints.api URL");
}
return url.href.replace(/\/+$/, "");
}
async function cancelUnreadResponseBody(response: Response): Promise<void> {
if (!response.bodyUsed) {
await response.body?.cancel().catch(() => undefined);
}
}
export async function resolveCopilotRuntimeAuth(params: {
githubToken: string;
env?: NodeJS.ProcessEnv;
fetchImpl?: typeof fetch;
githubDomain?: string;
config?: OpenClawConfig;
}): Promise<{
apiKey: string;
source: string;
baseUrl: string;
}> {
const env = params.env ?? process.env;
const domain = resolveGithubCopilotDomain({
env,
explicit: params.githubDomain,
config: params.config,
});
const userUrl = copilotUserUrl(domain);
const fetchImpl = params.fetchImpl ?? fetch;
const signal = AbortSignal.timeout(COPILOT_RUNTIME_AUTH_TIMEOUT_MS);
try {
const response = await fetchImpl(userUrl, {
method: "GET",
headers: {
Accept: "application/json",
Authorization: `Bearer ${params.githubToken}`,
},
signal,
});
if (!response.ok) {
await cancelUnreadResponseBody(response);
throw new CopilotRuntimeAuthError({ reason: "http_error", status: response.status });
}
const baseUrl = parseCopilotApiBaseUrl(
await readProviderJsonResponse(response, "github-copilot.user"),
domain,
);
// The current Copilot CLI/SDK resolves account metadata through `/user`,
// then sends this original GitHub token to CAPI. The retired `/v2/token`
// exchange rejects supported fine-grained PATs before inference.
return {
apiKey: params.githubToken,
source: `validated:${userUrl}`,
baseUrl,
};
} catch (error) {
if (signal.aborted) {
throw new CopilotRuntimeAuthError({
reason: "timeout",
timeoutMs: COPILOT_RUNTIME_AUTH_TIMEOUT_MS,
cause: error,
});
}
throw error;
}
}