refactor(sdk): retire deprecated Copilot token exchange (#163587)

* refactor(sdk): retire deprecated Copilot token exchange

* chore(sdk): remove stale provider auth test exemption

* chore(sdk): prune retired Copilot assertion allowances
This commit is contained in:
Peter Steinberger 2026-10-02 11:51:09 -07:00 • committed by GitHub
parent c430435303
commit f2de06b38d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 17 additions and 1286 deletions

View file

@ -2551,11 +2551,9 @@ src/plugin-sdk/memory-host-core.ts 2
src/plugin-sdk/migration-runtime.ts 2
src/plugin-sdk/migration.ts 8
src/plugin-sdk/plugin-test-api.ts 1
src/plugin-sdk/provider-auth-copilot-cache.ts 1
src/plugin-sdk/provider-auth-login-flow-runtime.ts 2
src/plugin-sdk/provider-auth-result.ts 3
src/plugin-sdk/provider-auth-runtime.ts 1
src/plugin-sdk/provider-auth.ts 1
src/plugin-sdk/provider-catalog-shared.ts 1
src/plugin-sdk/provider-entry.ts 3
src/plugin-sdk/provider-onboard.ts 3

View file

@ -544,7 +544,6 @@ src/node-host/invoke-system-run.test.ts
src/node-host/invoke-system-run.ts
src/node-host/invoke.ts
src/plugin-sdk/core.ts
src/plugin-sdk/provider-auth.test.ts
src/plugin-sdk/test-helpers/plugin-runtime-mock.ts
src/plugin-sdk/test-helpers/provider-discovery-contract.ts
src/plugin-sdk/test-helpers/provider-runtime-contract.ts

View file

@ -320,6 +320,17 @@ while those migrations remain unverified; their original dates are unchanged.
| `plugin-provider-manifest-compat-aliases` | Manifest-owned kind/setup metadata and model catalog registration | Providers no longer publish runtime kind or legacy catalog hooks. |
| `agent-harness-credential-prompt-string-argument` | Options object `{ controlToolsAvailable }` | Deprecated and warnings start 2026-09-09; supported through 2026-11-30. Remove after that date once callers migrate. |
The deprecated GitHub Copilot token-exchange exports have been removed from
`provider-auth`: `DEFAULT_COPILOT_API_BASE_URL`, `deriveCopilotApiBaseUrlFromToken`,
`resolveCopilotApiToken`, and `CachedCopilotToken`. The GitHub Copilot plugin owns
authentication and account endpoint resolution; provider integrations should
use their registered auth hooks instead of the retired `/v2/token` exchange.
`normalizeGithubCopilotDomain` remains available.
Update plugins that import the removed exports before updating the host. This
removal does not rewrite credentials, delete existing cache entries, or change
the current GitHub Copilot plugin's authentication flow.
The unused private memory-host `loadConfig` re-exports have been removed.
Memory implementations use `getRuntimeConfig` or caller-provided config;
custom-table migration behavior remains intact.

View file

@ -170,7 +170,7 @@ const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({
// +2: shipped Slack and Discord setup helpers retained through their package migration window.
"setup-runtime": 2,
"reply-history": 6,
"provider-auth": 19,
"provider-auth": 15,
} satisfies Record<string, number>);
export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env) {
@ -185,19 +185,19 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env
// +1: createChannelSecretContract consolidates seven channel secret contracts (approved by Peter, 2026-10-01).
// +1: createSessionHeaderLink shares plugin-owned conversation navigation (PR #158742).
// +4: owner-approved replay V2 types on core and plugin-entry (2026-10-01).
3635,
3631,
env,
),
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS",
// +1: createChannelSecretContract consolidates seven channel secret contracts (approved by Peter, 2026-10-01).
// +1: createSessionHeaderLink shares plugin-owned conversation navigation (PR #158742).
2109,
2107,
env,
),
publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS",
141,
137,
env,
),
publicWildcardReexports: readPluginSdkSurfaceBudgetEnv(

View file

@ -1,58 +0,0 @@
import {
DEFAULT_GITHUB_COPILOT_DOMAIN,
isSupportedGithubCopilotDomain,
normalizeGithubCopilotDomain,
} from "./github-copilot-domain.js";
export type GithubCopilotTokenEndpointResolution = {
hasProxyEndpoint: boolean;
baseUrl: string | null;
};
function isSupportedGithubCopilotApiHost(host: string, enterpriseDomain?: string): boolean {
if (host === "copilot-proxy.githubusercontent.com" || host.endsWith(".githubcopilot.com")) {
return true;
}
if (
!enterpriseDomain ||
!isSupportedGithubCopilotDomain(enterpriseDomain) ||
normalizeGithubCopilotDomain(enterpriseDomain) === DEFAULT_GITHUB_COPILOT_DOMAIN
) {
return false;
}
const tenant = normalizeGithubCopilotDomain(enterpriseDomain);
return host === tenant || host.endsWith(`.${tenant}`);
}
/**
* Resolves the optional `proxy-ep` hint embedded in a Copilot API token.
* The hint is untrusted credential data: only GitHub-owned Copilot hosts, or
* service hosts below the credential's validated GHE.com tenant, may receive it.
*/
export function resolveGithubCopilotTokenEndpoint(
token: string,
enterpriseDomain?: string,
): GithubCopilotTokenEndpointResolution {
const match = token.trim().match(/(?:^|;)\s*proxy-ep=([^;\s]+)/i);
const proxyEndpoint = match?.[1]?.trim();
if (!proxyEndpoint) {
return { hasProxyEndpoint: false, baseUrl: null };
}
const urlText = /^https?:\/\//i.test(proxyEndpoint) ? proxyEndpoint : `https://${proxyEndpoint}`;
try {
const url = new URL(urlText);
if (url.protocol !== "http:" && url.protocol !== "https:") {
return { hasProxyEndpoint: true, baseUrl: null };
}
const apiHost = url.hostname.toLowerCase().replace(/^proxy\./, "api.");
return {
hasProxyEndpoint: true,
baseUrl: isSupportedGithubCopilotApiHost(apiHost, enterpriseDomain)
? `https://${apiHost}`
: null,
};
} catch {
return { hasProxyEndpoint: true, baseUrl: null };
}
}

View file

@ -1,107 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { COPILOT_INTEGRATION_ID } from "../agents/copilot-dynamic-headers.js";
import * as pluginState from "../plugin-state/plugin-state-store.js";
import { closePluginStateDatabase } from "../plugin-state/plugin-state-store.sqlite.js";
import { createDeferredCore } from "../shared/deferred.js";
import { withStateDirEnv } from "../test-helpers/state-dir-env.js";
import {
fingerprintCopilotSourceCredential,
resolveCopilotTokenCache,
} from "./provider-auth-copilot-cache.js";
import { resolveCopilotApiToken } from "./provider-auth.js";
afterEach(() => {
vi.restoreAllMocks();
pluginState.resetPluginStateStoreForTests();
});
describe("Copilot cache completion", () => {
it.each(
(["load", "save"] as const).flatMap((operation) =>
(["resolve", "reject"] as const).map((outcome) => ({ operation, outcome })),
),
)("awaits cache $operation through $outcome", async ({ operation, outcome }) => {
await withStateDirEnv("openclaw-copilot-async-", async ({ tempRoot }) => {
const env = { OPENCLAW_STATE_DIR: tempRoot, COPILOT_GITHUB_DOMAIN: "github.com" };
const githubToken = "synthetic-github-credential";
const cache = await resolveCopilotTokenCache({
env,
domain: "github.com",
sourceCredentialFingerprint: fingerprintCopilotSourceCredential(githubToken),
});
if (operation === "load") {
await cache.save({
token: "synthetic-cached-token",
expiresAt: Date.now() + 3_600_000,
updatedAt: Date.now(),
integrationId: COPILOT_INTEGRATION_ID,
sourceCredentialFingerprint: fingerprintCopilotSourceCredential(githubToken),
domain: "github.com",
});
}
const entered = createDeferredCore();
const release = createDeferredCore();
const createStore = pluginState.createCorePluginStateKeyedStore;
vi.spyOn(pluginState, "createCorePluginStateKeyedStore").mockImplementation((options) => {
const store = createStore(options);
return {
...store,
lookup: async (...args) => {
if (operation === "load") {
entered.resolve();
await release.promise;
}
return store.lookup(...args);
},
register: async (...args) => {
if (operation === "save") {
entered.resolve();
await release.promise;
}
return store.register(...args);
},
};
});
const fetchImpl = vi.fn<typeof fetch>(
async () =>
new Response(
JSON.stringify({
token: "synthetic-exchanged-token",
expires_at: Math.floor(Date.now() / 1_000) + 3_600,
}),
),
);
const pending = resolveCopilotApiToken({ env, githubToken, fetchImpl });
let completed = false;
const settle = () => {
completed = true;
return "settled";
};
const settled = pending.then(settle, settle);
try {
expect(await Promise.race([entered.promise.then(() => "waiting"), settled])).toBe(
"waiting",
);
expect(completed).toBe(false);
expect(fetchImpl).toHaveBeenCalledTimes(operation === "load" ? 0 : 1);
if (outcome === "reject") {
const error = new Error("synthetic cache rejection");
release.reject(error);
await expect(pending).rejects.toBe(error);
} else {
release.resolve();
await expect(pending).resolves.toMatchObject({
token: operation === "load" ? "synthetic-cached-token" : "synthetic-exchanged-token",
});
closePluginStateDatabase();
await expect(cache.load()).resolves.toMatchObject({
token: operation === "load" ? "synthetic-cached-token" : "synthetic-exchanged-token",
});
}
} finally {
release.resolve();
await settled;
}
});
});
});

View file

@ -1,112 +0,0 @@
import { createHash } from "node:crypto";
import path from "node:path";
import { asDateTimestampMs } from "../../packages/normalization-core/src/number-coercion.js";
import { COPILOT_INTEGRATION_ID } from "../agents/copilot-dynamic-headers.js";
import { resolveStateDir } from "../config/paths.js";
import { loadJsonFileThroughSymlink, writeJsonTarget } from "../infra/json-file.js";
import { DEFAULT_GITHUB_COPILOT_DOMAIN } from "./github-copilot-domain.js";
const COPILOT_CACHE_NAMESPACE = "github-copilot-token";
// Retain ordinary multi-account rotation without letting credential-derived
// bearer tokens grow unbounded in shared state.
const COPILOT_TOKEN_CACHE_MAX_ENTRIES = 8;
/** @deprecated GitHub Copilot provider-owned helper; do not use from third-party plugins. */
export type CachedCopilotToken = {
/** Copilot API token returned by GitHub's internal exchange endpoint. */
token: string;
/** Absolute epoch milliseconds when the Copilot API token expires. */
expiresAt: number;
/** Absolute epoch milliseconds when this cache entry was written. */
updatedAt: number;
/** Copilot integration id that produced this cached token. */
integrationId?: string;
/** SHA-256 fingerprint of the GitHub credential exchanged for this token. */
sourceCredentialFingerprint?: string;
/**
* GitHub host this token was minted for. Guards against reusing a public
* `github.com` Copilot token against a `*.ghe.com` tenant host (or vice
* versa) after a domain switch. Shipped caches predate this field and were
* only ever minted for public github.com, so a missing value means
* `github.com` (keeps valid public entries usable across upgrade).
*/
domain?: string;
};
function resolveLegacyCopilotTokenCachePath(env: NodeJS.ProcessEnv): string {
return path.join(resolveStateDir(env), "credentials", "github-copilot.token.json");
}
export function fingerprintCopilotSourceCredential(githubToken: string): string {
return createHash("sha256").update(githubToken).digest("hex");
}
export function isCopilotTokenUsable(params: {
cache: CachedCopilotToken;
domain: string;
sourceCredentialFingerprint: string;
now?: number;
}): boolean {
const expiresAt = asDateTimestampMs(params.cache.expiresAt);
// Legacy entries (pre domain-stamp) could only have been minted for public
// github.com; defaulting keeps them usable across upgrade while tenant
// requests still force a re-exchange.
const cacheDomain = params.cache.domain ?? DEFAULT_GITHUB_COPILOT_DOMAIN;
return (
params.cache.integrationId === COPILOT_INTEGRATION_ID &&
cacheDomain === params.domain &&
params.cache.sourceCredentialFingerprint === params.sourceCredentialFingerprint &&
expiresAt !== undefined &&
expiresAt - (params.now ?? Date.now()) > 5 * 60 * 1000
);
}
type CopilotTokenCache = {
path: string;
load(): Promise<CachedCopilotToken | undefined>;
save(value: CachedCopilotToken): Promise<void>;
};
export async function resolveCopilotTokenCache(params: {
env: NodeJS.ProcessEnv;
domain: string;
sourceCredentialFingerprint: string;
cachePath?: string;
loadJsonFileImpl?: (path: string) => unknown;
saveJsonFileImpl?: (path: string, value: CachedCopilotToken) => void;
}): Promise<CopilotTokenCache> {
const usesLegacyCacheAdapter =
params.cachePath !== undefined ||
params.loadJsonFileImpl !== undefined ||
params.saveJsonFileImpl !== undefined;
if (usesLegacyCacheAdapter) {
// Kept only for the deprecated public helper's explicit cache adapters.
// Normal runtime state uses the bounded SQLite namespace below.
const cachePath = params.cachePath?.trim() || resolveLegacyCopilotTokenCachePath(params.env);
const loadJsonFileFn = params.loadJsonFileImpl ?? loadJsonFileThroughSymlink;
const saveJsonFileFn = params.saveJsonFileImpl ?? writeJsonTarget;
return {
path: cachePath,
load: async () => loadJsonFileFn(cachePath) as CachedCopilotToken | undefined,
save: async (value) => saveJsonFileFn(cachePath, value),
};
}
const { createCorePluginStateKeyedStore } = await import("../plugin-state/plugin-state-store.js");
const store = createCorePluginStateKeyedStore<CachedCopilotToken>({
ownerId: "core:provider-auth",
namespace: COPILOT_CACHE_NAMESPACE,
maxEntries: COPILOT_TOKEN_CACHE_MAX_ENTRIES,
overflowPolicy: "evict-oldest",
env: params.env,
});
const key = `${params.domain}:${params.sourceCredentialFingerprint}`;
return {
path: "plugin-state",
load: () => store.lookup(key),
save: (value) =>
store.register(key, value, {
ttlMs: Math.max(1, value.expiresAt - Date.now()),
}),
};
}

View file

@ -14,20 +14,11 @@ import type { AuthProfileCredential, AuthProfileStore } from "../agents/auth-pro
import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../config/config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
COPILOT_INTEGRATION_ID,
deriveCopilotApiBaseUrlFromToken,
isProviderApiKeyConfigured,
normalizeGithubCopilotDomain,
readClaudeCliCredentialsCached,
resolveCopilotApiToken,
} from "./provider-auth.js";
const TEST_GITHUB_TOKEN = ["github", "token"].join("-");
const TEST_CACHED_COPILOT_TOKEN = [
"cached",
["proxy-ep", "proxy.individual.githubcopilot.com"].join("="),
].join(";");
const TEST_GITHUB_TOKEN_FINGERPRINT = createHash("sha256").update(TEST_GITHUB_TOKEN).digest("hex");
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
function claudeCredentialJson(
@ -324,31 +315,6 @@ describe("provider auth public SDK", () => {
});
});
async function withPartialCopilotResponse(run: (port: number) => Promise<void>): Promise<void> {
const { once } = await import("node:events");
const http = await import("node:http");
const server = http.createServer((_req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.write('{"token":"partial');
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("expected server address");
}
try {
await run(address.port);
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
server.closeAllConnections();
});
}
}
type FallbackStoreCaseResult = {
profileIds: string[];
resolvedKey: string | undefined;
@ -974,532 +940,9 @@ describe("provider auth profile helpers", () => {
{ externalCli },
);
});
it("rejects malformed Copilot proxy hints", () => {
expect(
deriveCopilotApiBaseUrlFromToken("copilot-token;proxy-ep=javascript:alert(1);"),
).toBeNull();
expect(deriveCopilotApiBaseUrlFromToken("copilot-token;proxy-ep=://bad;")).toBeNull();
expect(
deriveCopilotApiBaseUrlFromToken("copilot-token;proxy-ep=proxy.attacker.example;"),
).toBeNull();
});
it("rejects Copilot token expiry values outside the supported date range", async () => {
const fetchImpl = vi.fn(
async () =>
new Response(
JSON.stringify({
token: "token;proxy-ep=proxy.individual.githubcopilot.com",
expires_at: Number.MAX_SAFE_INTEGER,
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
await expect(
resolveCopilotApiToken({
githubToken: "github-token",
fetchImpl,
cachePath: "/tmp/copilot-token.json",
loadJsonFileImpl: () => undefined,
saveJsonFileImpl: () => {
throw new Error("should not save invalid token");
},
}),
).rejects.toThrow("Copilot token response has invalid expires_at");
});
it("cancels Copilot token exchange error bodies", async () => {
const response = new Response("bad credentials", { status: 401 });
const cancel = vi.spyOn(response.body!, "cancel").mockResolvedValue(undefined);
const fetchImpl = vi.fn(async () => response);
await expect(
resolveCopilotApiToken({
githubToken: "github-token",
fetchImpl,
cachePath: "/tmp/copilot-token.json",
loadJsonFileImpl: () => undefined,
saveJsonFileImpl: () => {
throw new Error("should not save failed token");
},
}),
).rejects.toThrow("Copilot token exchange failed: HTTP 401");
expect(cancel).toHaveBeenCalledOnce();
});
it("bounds oversized Copilot token success body and cancels the stream", async () => {
const chunk = new Uint8Array(1024 * 1024); // 1 MiB chunk
let readCount = 0;
let canceled = false;
// 64 chunks × 1 MiB = 64 MiB — far exceeds the 16 MiB cap
const oversizedBody = new ReadableStream<Uint8Array>({
pull(controller) {
if (readCount >= 64) {
controller.close();
return;
}
readCount += 1;
controller.enqueue(chunk);
},
cancel() {
canceled = true;
},
});
const response = new Response(oversizedBody, {
status: 200,
headers: { "Content-Type": "application/json" },
});
const fetchImpl = vi.fn(async () => response);
await expect(
resolveCopilotApiToken({
githubToken: "github-token",
fetchImpl,
cachePath: "/tmp/copilot-token.json",
loadJsonFileImpl: () => undefined,
saveJsonFileImpl: () => {
throw new Error("should not save oversized token");
},
}),
).rejects.toThrow("github-copilot.token");
// Stream must be cancelled before all 64 chunks are consumed
expect(readCount).toBeLessThan(64);
expect(canceled).toBe(true);
});
it("bounds oversized Copilot token success body over HTTP transport", async () => {
const http = await import("node:http");
const { once } = await import("node:events");
const MiB = 1024 * 1024;
let bytesWritten = 0;
const server = http.createServer((_req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
const chunk = Buffer.alloc(MiB, 120);
const header = Buffer.from('{"token":"');
res.write(header);
bytesWritten += header.length;
let chunksSent = 0;
const writeNext = () => {
if (chunksSent >= 18) {
const tail = Buffer.from('","expires_at":9999999999}');
res.write(tail);
bytesWritten += tail.length;
res.end();
return;
}
const ok = res.write(chunk);
bytesWritten += chunk.length;
chunksSent += 1;
if (ok) {
setImmediate(writeNext);
} else {
res.once("drain", writeNext);
}
};
writeNext();
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("expected server address");
}
try {
const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) =>
fetch(`http://127.0.0.1:${address.port}/token`, init),
);
await expect(
resolveCopilotApiToken({
githubToken: "github-token",
fetchImpl: fetchImpl as typeof fetch,
cachePath: "/tmp/copilot-token-http-proof.json",
loadJsonFileImpl: () => undefined,
saveJsonFileImpl: () => {
throw new Error("should not save oversized token");
},
}),
).rejects.toThrow("github-copilot.token");
expect(bytesWritten).toBeGreaterThan(17 * MiB);
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
});
it("accepts a normal Copilot token success body over HTTP transport", async () => {
const http = await import("node:http");
const { once } = await import("node:events");
const body = JSON.stringify({
token: "gho_abc;proxy-ep=proxy.individual.githubcopilot.com",
expires_at: "+2000000000",
});
const server = http.createServer((request, res) => {
expect(request.headers).toMatchObject({
accept: "application/json",
authorization: "Bearer github-token",
"copilot-integration-id": "vscode-chat",
});
res.writeHead(200, {
"Content-Type": "application/json",
"Content-Length": String(Buffer.byteLength(body)),
});
res.end(body);
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("expected server address");
}
try {
const saved: unknown[] = [];
const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) =>
fetch(`http://127.0.0.1:${address.port}/token`, init),
);
const result = await resolveCopilotApiToken({
githubToken: "github-token",
fetchImpl: fetchImpl as typeof fetch,
cachePath: "/tmp/copilot-token-http-happy.json",
loadJsonFileImpl: () => undefined,
saveJsonFileImpl: (cachePath, value) => {
saved.push({ path: cachePath, value });
},
});
expect(result.token).toContain("proxy-ep=proxy.individual.githubcopilot.com");
expect(result.expiresAt).toBe(2_000_000_000_000);
expect(saved).toEqual([
{
path: "/tmp/copilot-token-http-happy.json",
value: expect.objectContaining({
expiresAt: 2_000_000_000_000,
sourceCredentialFingerprint: TEST_GITHUB_TOKEN_FINGERPRINT,
token: "gho_abc;proxy-ep=proxy.individual.githubcopilot.com",
}),
},
]);
expect(fetchImpl.mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal);
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
});
it("refreshes cached Copilot tokens with out-of-range expiry values", async () => {
const saved: unknown[] = [];
const fetchImpl = vi.fn(
async () =>
new Response(
JSON.stringify({
token: "fresh;proxy-ep=proxy.individual.githubcopilot.com",
expires_at: "+2000000000",
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const result = await resolveCopilotApiToken({
githubToken: "github-token",
fetchImpl,
cachePath: "/tmp/copilot-token.json",
loadJsonFileImpl: () => ({
token: "cached;proxy-ep=proxy.individual.githubcopilot.com",
expiresAt: Number.MAX_SAFE_INTEGER,
updatedAt: Date.now(),
integrationId: COPILOT_INTEGRATION_ID,
sourceCredentialFingerprint: TEST_GITHUB_TOKEN_FINGERPRINT,
}),
saveJsonFileImpl: (_path, value) => saved.push(value),
});
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(result.source).toBe("fetched:https://api.github.com/copilot_internal/v2/token");
expect(result.token).toBe("fresh;proxy-ep=proxy.individual.githubcopilot.com");
expect(saved).toEqual([
expect.objectContaining({
expiresAt: 2_000_000_000_000,
token: "fresh;proxy-ep=proxy.individual.githubcopilot.com",
}),
]);
});
it("aborts hung Copilot token exchange over HTTP transport", async () => {
const http = await import("node:http");
const { once } = await import("node:events");
let connections = 0;
const server = http.createServer((_req, _res) => {
connections += 1;
// Intentionally never write headers/body so fetch stays pending until abort.
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("expected server address");
}
try {
// Keep the real fetch/undici abort path while shortening the production
// deadline for this loopback transport test.
const realTimeout = AbortSignal.timeout.bind(AbortSignal);
vi.spyOn(AbortSignal, "timeout").mockImplementation((timeoutMs) => {
expect(timeoutMs).toBe(30_000);
return realTimeout(250);
});
const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => {
expect(init?.signal).toBeInstanceOf(AbortSignal);
return await fetch(`http://127.0.0.1:${address.port}/token`, init);
});
const startedAt = Date.now();
await expect(
resolveCopilotApiToken({
githubToken: "github-token",
fetchImpl: fetchImpl as typeof fetch,
cachePath: "/tmp/copilot-token-http-hang.json",
loadJsonFileImpl: () => undefined,
saveJsonFileImpl: () => {
throw new Error("should not save timed-out token");
},
}),
).rejects.toThrow("Copilot token exchange failed: timed out after 30000ms");
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(200);
expect(Date.now() - startedAt).toBeLessThan(5_000);
expect(connections).toBe(1);
expect(fetchImpl.mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal);
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
server.closeAllConnections();
});
}
});
for (const errorName of ["AbortError", "TimeoutError"] as const) {
it(`preserves a foreign ${errorName} by identity`, async () => {
const ownedReason = new DOMException("owned deadline", "TimeoutError");
vi.spyOn(AbortSignal, "timeout").mockReturnValue(AbortSignal.abort(ownedReason));
const foreignError = new DOMException("foreign failure", errorName);
const fetchImpl = vi.fn(async () => {
throw foreignError;
});
await expect(
resolveCopilotApiToken({
githubToken: TEST_GITHUB_TOKEN,
fetchImpl,
cachePath: "/tmp/copilot-token-foreign-error.json",
loadJsonFileImpl: () => undefined,
saveJsonFileImpl: () => {},
}),
).rejects.toBe(foreignError);
});
}
it("returns a valid cached token without creating a deadline or fetching", async () => {
const timeout = vi.spyOn(AbortSignal, "timeout");
const fetchImpl = vi.fn();
const cachedValue = TEST_CACHED_COPILOT_TOKEN;
const result = await resolveCopilotApiToken({
githubToken: TEST_GITHUB_TOKEN,
fetchImpl: fetchImpl as typeof fetch,
cachePath: "/tmp/copilot-token-cache-hit.json",
loadJsonFileImpl: () => ({
token: cachedValue,
expiresAt: Date.now() + 60 * 60 * 1000,
updatedAt: Date.now(),
integrationId: COPILOT_INTEGRATION_ID,
sourceCredentialFingerprint: TEST_GITHUB_TOKEN_FINGERPRINT,
}),
saveJsonFileImpl: () => {},
});
expect(result.source).toBe("cache:/tmp/copilot-token-cache-hit.json");
expect(timeout).not.toHaveBeenCalled();
expect(fetchImpl).not.toHaveBeenCalled();
});
it("does not reuse a cached Copilot token from another GitHub credential", async () => {
const saved: unknown[] = [];
const fetchImpl = vi.fn(async () =>
Response.json({
token: "fresh;proxy-ep=proxy.individual.githubcopilot.com",
expires_at: "+2000000000",
}),
);
const result = await resolveCopilotApiToken({
githubToken: TEST_GITHUB_TOKEN,
fetchImpl: fetchImpl as typeof fetch,
cachePath: "/tmp/copilot-token-cache-profile-mismatch.json",
loadJsonFileImpl: () => ({
token: TEST_CACHED_COPILOT_TOKEN,
expiresAt: Date.now() + 60 * 60 * 1000,
updatedAt: Date.now(),
integrationId: COPILOT_INTEGRATION_ID,
sourceCredentialFingerprint: createHash("sha256").update("different-token").digest("hex"),
}),
saveJsonFileImpl: (_path, value) => saved.push(value),
});
expect(result.source).toContain("fetched:");
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(saved).toEqual([
expect.objectContaining({
sourceCredentialFingerprint: TEST_GITHUB_TOKEN_FINGERPRINT,
token: "fresh;proxy-ep=proxy.individual.githubcopilot.com",
}),
]);
});
it("retains valid Copilot exchanges across A to B to A profile rotation", async () => {
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-copilot-cache-"));
try {
const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => {
const authorization = new Headers(init?.headers).get("authorization");
const sourceFixture = authorization?.replace(/^Bearer\s+/u, "") ?? "";
let exchangeFixture = "test-token-placeholder";
if (sourceFixture === "test-auth-token") {
exchangeFixture = "test-auth-token";
}
return new Response(
JSON.stringify(
Object.fromEntries([
["token", exchangeFixture],
["expires_at", Date.now() + 60 * 60 * 1000],
]),
),
{ status: 200, headers: { "content-type": "application/json" } },
);
});
const env = { OPENCLAW_STATE_DIR: stateDir } as NodeJS.ProcessEnv;
const firstA = await resolveCopilotApiToken({
githubToken: "test-auth-token",
env,
fetchImpl: fetchImpl as typeof fetch,
});
const firstB = await resolveCopilotApiToken({
githubToken: "test-token-placeholder",
env,
fetchImpl: fetchImpl as typeof fetch,
});
const secondA = await resolveCopilotApiToken({
githubToken: "test-auth-token",
env,
fetchImpl: fetchImpl as typeof fetch,
});
expect(fetchImpl).toHaveBeenCalledTimes(2);
expect(firstA.token).toBe("test-auth-token");
expect(firstB.token).toBe("test-token-placeholder");
expect(secondA.token).toBe(firstA.token);
expect(secondA.source).toBe("cache:plugin-state");
} finally {
const { resetPluginStateStoreForTests } =
await import("../plugin-state/plugin-state-store.js");
resetPluginStateStoreForTests();
await fs.rm(stateDir, { recursive: true, force: true });
}
});
it("times out while reading a stalled HTTP response body", async () => {
const realTimeout = AbortSignal.timeout.bind(AbortSignal);
vi.spyOn(AbortSignal, "timeout").mockImplementation((timeoutMs) => {
expect(timeoutMs).toBe(30_000);
return realTimeout(250);
});
await withPartialCopilotResponse(async (port) => {
const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => {
return await fetch(`http://127.0.0.1:${port}/token`, init);
});
let rejection: unknown;
try {
await resolveCopilotApiToken({
githubToken: TEST_GITHUB_TOKEN,
fetchImpl: fetchImpl as typeof fetch,
cachePath: "/tmp/copilot-token-partial-body.json",
loadJsonFileImpl: () => undefined,
saveJsonFileImpl: () => {
throw new Error("should not save timed-out token");
},
});
} catch (error) {
rejection = error;
}
const signal = fetchImpl.mock.calls[0]?.[1]?.signal;
expect(rejection).toMatchObject({
message: "Copilot token exchange failed: timed out after 30000ms",
});
expect((rejection as Error & { cause?: unknown }).cause).toBe(signal?.reason);
expect(signal?.aborted).toBe(true);
});
});
});
describe("Copilot data-residency domain resolution", () => {
afterEach(() => {
delete process.env.COPILOT_GITHUB_DOMAIN;
});
it("warns once when a configured domain is rejected during token resolution", async () => {
vi.resetModules();
const logWarn = vi.fn();
vi.doMock("../logger.js", async () => {
const actual = await vi.importActual<typeof import("../logger.js")>("../logger.js");
return { ...actual, logWarn };
});
const { resolveCopilotApiToken: resolveCopilotApiTokenWithLoggerMock } =
await import("./provider-auth.js");
const fetchImpl = vi.fn(async () => Response.json({ token: "tok", expires_at: "+2000000000" }));
const withDomain = (githubDomain: string) =>
({
models: { providers: { "github-copilot": { params: { githubDomain } } } },
}) as never;
const resolveWithConfigDomain = (githubDomain: string) =>
resolveCopilotApiTokenWithLoggerMock({
githubToken: "github-token",
env: {},
config: withDomain(githubDomain),
fetchImpl,
cachePath: "/tmp/copilot-token-warn.json",
loadJsonFileImpl: () => undefined,
saveJsonFileImpl: () => {},
});
// Valid tenant + explicit public host never warn.
await resolveWithConfigDomain("acme.ghe.com");
await resolveWithConfigDomain("github.com");
expect(logWarn).not.toHaveBeenCalled();
// Typo (`.co`) fails the allowlist -> silent fallback -> warn once, not twice.
await resolveWithConfigDomain("acme.ghe.co");
await resolveWithConfigDomain("acme.ghe.co");
expect(logWarn).toHaveBeenCalledTimes(1);
expect(logWarn).toHaveBeenCalledWith(expect.stringContaining("acme.ghe.co"));
vi.doUnmock("../logger.js");
});
describe("Copilot domain normalization", () => {
it("rejects unsafe hostnames and falls back to github.com", () => {
expect(normalizeGithubCopilotDomain("https://evil.com/login")).toBe("github.com");
expect(normalizeGithubCopilotDomain("user@host")).toBe("github.com");
@ -1526,163 +969,4 @@ describe("Copilot data-residency domain resolution", () => {
expect(normalizeGithubCopilotDomain("evilghe.com")).toBe("github.com");
expect(normalizeGithubCopilotDomain("acme.ghe.com.evil.com")).toBe("github.com");
});
it("targets the tenant token endpoint and copilot-api fallback for a GHE domain", async () => {
const fetchImpl = vi.fn(async () =>
// GHE data-residency tokens carry a stamp but no proxy-ep hint.
Response.json({ token: "ghe;st=prod-sdc-01", expires_at: "+2000000000" }),
);
const result = await resolveCopilotApiToken({
githubToken: "github-token",
env: {},
githubDomain: "acme.ghe.com",
fetchImpl,
cachePath: "/tmp/copilot-token-ghe.json",
loadJsonFileImpl: () => undefined,
saveJsonFileImpl: () => {},
});
const [url] = fetchImpl.mock.calls[0] as unknown as [string];
expect(url).toBe("https://api.acme.ghe.com/copilot_internal/v2/token");
expect(result.source).toBe("fetched:https://api.acme.ghe.com/copilot_internal/v2/token");
expect(result.baseUrl).toBe("https://copilot-api.acme.ghe.com");
});
it("lets COPILOT_GITHUB_DOMAIN override the caller-provided domain", async () => {
const fetchImpl = vi.fn(async () =>
Response.json({ token: "ghe;st=prod-sdc-01", expires_at: "+2000000000" }),
);
const result = await resolveCopilotApiToken({
githubToken: "github-token",
env: { COPILOT_GITHUB_DOMAIN: "env.ghe.com" },
githubDomain: "config.ghe.com",
fetchImpl,
cachePath: "/tmp/copilot-token-env.json",
loadJsonFileImpl: () => undefined,
saveJsonFileImpl: () => {},
});
const [url] = fetchImpl.mock.calls[0] as unknown as [string];
expect(url).toBe("https://api.env.ghe.com/copilot_internal/v2/token");
expect(result.baseUrl).toBe("https://copilot-api.env.ghe.com");
});
it("does not reuse a cached token minted for a different domain", async () => {
const saved: unknown[] = [];
const fetchImpl = vi.fn(async () =>
Response.json({ token: "ghe;st=prod-sdc-01", expires_at: "+2000000000" }),
);
// A valid, unexpired public-github.com token sits in the cache, but the
// request targets a GHE tenant, so it must be re-exchanged rather than
// sending a github.com token to api.acme.ghe.com.
const result = await resolveCopilotApiToken({
githubToken: "github-token",
env: {},
githubDomain: "acme.ghe.com",
fetchImpl,
cachePath: "/tmp/copilot-token-cross.json",
loadJsonFileImpl: () => ({
token: "public;proxy-ep=proxy.individual.githubcopilot.com",
expiresAt: Date.now() + 60 * 60 * 1000,
updatedAt: Date.now(),
integrationId: COPILOT_INTEGRATION_ID,
sourceCredentialFingerprint: TEST_GITHUB_TOKEN_FINGERPRINT,
domain: "github.com",
}),
saveJsonFileImpl: (_path, value) => saved.push(value),
});
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(result.source).toBe("fetched:https://api.acme.ghe.com/copilot_internal/v2/token");
expect(saved).toEqual([expect.objectContaining({ domain: "acme.ghe.com" })]);
});
it("re-exchanges legacy cache entries without a source credential fingerprint", async () => {
const saved: unknown[] = [];
const fetchImpl = vi.fn(async () =>
Response.json({
token: "fresh-public;proxy-ep=proxy.individual.githubcopilot.com",
expires_at: "+2000000000",
}),
);
const result = await resolveCopilotApiToken({
githubToken: "github-token",
env: {},
fetchImpl: fetchImpl as unknown as typeof fetch,
cachePath: "/tmp/copilot-token-legacy.json",
loadJsonFileImpl: () => ({
token: "legacy-public;proxy-ep=proxy.individual.githubcopilot.com",
expiresAt: Date.now() + 60 * 60 * 1000,
updatedAt: Date.now(),
integrationId: COPILOT_INTEGRATION_ID,
// no domain or source credential fingerprint
}),
saveJsonFileImpl: (_path, value) => saved.push(value),
});
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(result.source).toBe("fetched:https://api.github.com/copilot_internal/v2/token");
expect(saved).toEqual([
expect.objectContaining({
domain: "github.com",
sourceCredentialFingerprint: TEST_GITHUB_TOKEN_FINGERPRINT,
}),
]);
});
it("does not reuse a legacy pre-domain cache entry for a tenant domain", async () => {
const saved: unknown[] = [];
const fetchImpl = vi.fn(async () =>
Response.json({ token: "ghe;st=prod-sdc-01", expires_at: "+2000000000" }),
);
const result = await resolveCopilotApiToken({
githubToken: "github-token",
env: {},
githubDomain: "acme.ghe.com",
fetchImpl,
cachePath: "/tmp/copilot-token-legacy-tenant.json",
loadJsonFileImpl: () => ({
token: "legacy-public;proxy-ep=proxy.individual.githubcopilot.com",
expiresAt: Date.now() + 60 * 60 * 1000,
updatedAt: Date.now(),
integrationId: COPILOT_INTEGRATION_ID,
sourceCredentialFingerprint: TEST_GITHUB_TOKEN_FINGERPRINT,
// no domain field — implies github.com, so a tenant request must miss
}),
saveJsonFileImpl: (_path, value) => saved.push(value),
});
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(result.source).toBe("fetched:https://api.acme.ghe.com/copilot_internal/v2/token");
expect(saved).toEqual([expect.objectContaining({ domain: "acme.ghe.com" })]);
});
it("reuses a cached token minted for the same domain", async () => {
const fetchImpl = vi.fn();
const result = await resolveCopilotApiToken({
githubToken: "github-token",
env: {},
githubDomain: "acme.ghe.com",
fetchImpl: fetchImpl as unknown as typeof fetch,
cachePath: "/tmp/copilot-token-same.json",
loadJsonFileImpl: () => ({
token: "tenant-cached;st=prod-sdc-01",
expiresAt: Date.now() + 60 * 60 * 1000,
updatedAt: Date.now(),
integrationId: COPILOT_INTEGRATION_ID,
sourceCredentialFingerprint: TEST_GITHUB_TOKEN_FINGERPRINT,
domain: "acme.ghe.com",
}),
saveJsonFileImpl: () => {},
});
expect(fetchImpl).not.toHaveBeenCalled();
expect(result.source).toBe("cache:/tmp/copilot-token-same.json");
expect(result.baseUrl).toBe("https://copilot-api.acme.ghe.com");
});
});
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */

View file

@ -1,33 +1,9 @@
// Provider auth helpers define auth methods, credential resolution, and setup status contracts.
import {
asDateTimestampMs,
resolveExpiresAtMsFromEpochSeconds,
parseStrictNonNegativeInteger,
} from "../../packages/normalization-core/src/number-coercion.js";
import { resolveAuthProfileOrder } from "../agents/auth-profiles/order.js";
import {
COPILOT_INTEGRATION_ID,
buildCopilotIdeHeaders,
} from "../agents/copilot-dynamic-headers.js";
import { readProviderJsonResponse } from "../agents/provider-http-errors.js";
import type { OpenClawConfig } from "../config/config.js";
import { cancelUnreadResponseBody } from "../infra/http-body.js";
import { logWarn } from "../logger.js";
import {
DEFAULT_GITHUB_COPILOT_DOMAIN,
normalizeGithubCopilotDomain,
} from "./github-copilot-domain.js";
import { resolveGithubCopilotTokenEndpoint } from "./github-copilot-token-endpoint.js";
import {
fingerprintCopilotSourceCredential,
isCopilotTokenUsable,
resolveCopilotTokenCache,
type CachedCopilotToken,
} from "./provider-auth-copilot-cache.js";
import { normalizeGithubCopilotDomain } from "./github-copilot-domain.js";
export { resolveNonEnvSecretRefApiKeyMarker } from "../secrets/provider-credential-values.js";
export type { OpenClawConfig } from "../config/config.js";
export type { CachedCopilotToken } from "./provider-auth-copilot-cache.js";
export type { SecretInput } from "../config/types.secrets.js";
export type { SecretInputMode } from "../plugins/provider-auth-types.js";
export type { ProviderAuthResult } from "../plugins/types.js";
@ -120,262 +96,6 @@ export {
buildCopilotIdeHeaders,
} from "../agents/copilot-dynamic-headers.js";
/** @deprecated GitHub Copilot provider-owned helper; do not use from third-party plugins. */
export const DEFAULT_COPILOT_API_BASE_URL = "https://api.individual.githubcopilot.com";
/**
* Data-residency GitHub Enterprise (`*.ghe.com`) support.
*
* Copilot on a data-residency GHE tenant lives at `<domain>` / `api.<domain>` /
* `copilot-api.<domain>` rather than the public github.com endpoints. The host
* is resolved (in priority order) from the `COPILOT_GITHUB_DOMAIN` env override,
* the persisted `models.providers.github-copilot.params.githubDomain` config, and
* finally public `github.com`.
*/
const COPILOT_PROVIDER_ID = "github-copilot";
const COPILOT_TOKEN_EXCHANGE_TIMEOUT_MS = 30_000;
function readGithubCopilotDomainFromConfig(config?: OpenClawConfig): string | undefined {
const params = config?.models?.providers?.[COPILOT_PROVIDER_ID]?.params;
const value = params && typeof params === "object" ? params.githubDomain : undefined;
if (typeof value !== "string" || value.trim().length === 0) {
return undefined;
}
const trimmed = value.trim();
warnOnceOnRejectedConfigDomain(trimmed);
return trimmed;
}
// Configured `githubDomain` values that fail the allowlist fall back to public
// github.com (fail-closed for the token). That silent fallback turns a typo like
// `acme.ghe.co` into an opaque 401 (tenant token vs public endpoint), so warn the
// user loudly — once per distinct bad value — that their config was ignored.
const warnedRejectedConfigDomains = new Set<string>();
function warnOnceOnRejectedConfigDomain(configured: string): void {
const lowered = configured.toLowerCase();
if (lowered === DEFAULT_GITHUB_COPILOT_DOMAIN) {
return;
}
if (normalizeGithubCopilotDomain(configured) !== DEFAULT_GITHUB_COPILOT_DOMAIN) {
return;
}
if (warnedRejectedConfigDomains.has(lowered)) {
return;
}
warnedRejectedConfigDomains.add(lowered);
logWarn(
`Ignoring configured GitHub Copilot domain "${configured}": only github.com and *.ghe.com tenants are accepted. Falling back to github.com.`,
);
}
// Provider-internal host resolver (env > explicit caller value > persisted
// config), always passed through the fail-closed allowlist. Not exported: the
// provider extension owns its own copy so the SDK surface stays minimal.
function resolveGithubCopilotDomain(params?: {
env?: NodeJS.ProcessEnv;
explicit?: string;
config?: OpenClawConfig;
}): string {
const env = params?.env ?? process.env;
const fromEnv = env.COPILOT_GITHUB_DOMAIN?.trim();
if (fromEnv) {
return normalizeGithubCopilotDomain(fromEnv);
}
if (params?.explicit) {
return normalizeGithubCopilotDomain(params.explicit);
}
return normalizeGithubCopilotDomain(readGithubCopilotDomainFromConfig(params?.config));
}
/**
* Data-residency GHE Copilot tokens carry no `proxy-ep`, so the completions base
* URL cannot be derived from the token. Point it at the tenant Copilot proxy
* (`copilot-api.<domain>`) instead of the public individual endpoint.
*/
function copilotTokenUrl(domain: string): string {
return `https://api.${domain}/copilot_internal/v2/token`;
}
function copilotApiBaseFallback(domain: string): string {
return domain === DEFAULT_GITHUB_COPILOT_DOMAIN
? DEFAULT_COPILOT_API_BASE_URL
: `https://copilot-api.${domain}`;
}
function resolveCopilotTokenExpiresAtMs(expiresAt: unknown): number | undefined {
const parsed =
typeof expiresAt === "number" && Number.isFinite(expiresAt)
? expiresAt
: typeof expiresAt === "string" && expiresAt.trim().length > 0
? parseStrictNonNegativeInteger(expiresAt)
: undefined;
if (parsed === undefined) {
return undefined;
}
return parsed < 100_000_000_000
? resolveExpiresAtMsFromEpochSeconds(parsed)
: asDateTimestampMs(parsed);
}
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");
}
const expiresAtMs = resolveCopilotTokenExpiresAtMs(expiresAt);
if (
expiresAt === undefined ||
expiresAt === null ||
(typeof expiresAt === "string" && expiresAt.trim().length === 0)
) {
throw new Error("Copilot token response missing expires_at");
}
if (expiresAtMs === undefined) {
throw new Error("Copilot token response has invalid expires_at");
}
return { token, expiresAt: expiresAtMs };
}
/** @deprecated GitHub Copilot provider-owned helper; do not use from third-party plugins. */
export function deriveCopilotApiBaseUrlFromToken(
/** Copilot API token text that may contain a `proxy-ep` attribute. */
token: string,
): string | null {
return resolveGithubCopilotTokenEndpoint(token).baseUrl;
}
/**
* @deprecated GitHub Copilot provider-owned helper; do not use from third-party plugins.
*/
export async function resolveCopilotApiToken(params: {
/** GitHub OAuth token exchanged for a Copilot API token. */
githubToken: string;
/** Environment used to resolve the default token cache path. */
env?: NodeJS.ProcessEnv;
/** Fetch implementation used for the Copilot token exchange. */
fetchImpl?: typeof fetch;
/** Explicit cache file path for the exchanged Copilot token. */
cachePath?: string;
/** Cache reader override for tests and alternate storage backends. */
loadJsonFileImpl?: (path: string) => unknown;
/** Cache writer override for tests and alternate storage backends. */
saveJsonFileImpl?: (path: string, value: CachedCopilotToken) => void;
/**
* Data-residency GitHub Enterprise host (e.g. `acme.ghe.com`). Resolved from
* config by callers that have it; the `COPILOT_GITHUB_DOMAIN` env override
* still wins. Defaults to `github.com`.
*/
githubDomain?: string;
/**
* OpenClaw config used to resolve the persisted `githubDomain` provider
* param when an explicit `githubDomain` is not supplied. Precedence is
* `COPILOT_GITHUB_DOMAIN` env > explicit `githubDomain` > config.
*/
config?: OpenClawConfig;
}): Promise<{
/** Copilot API token, from cache or fresh exchange. */
token: string;
/** Absolute epoch milliseconds when the Copilot API token expires. */
expiresAt: number;
/** Source marker identifying cache path or exchange endpoint. */
source: string;
/** Copilot API base URL derived from token metadata or default endpoint. */
baseUrl: string;
}> {
const env = params.env ?? process.env;
const domain = resolveGithubCopilotDomain({
env,
explicit: params.githubDomain,
config: params.config,
});
const tokenUrl = copilotTokenUrl(domain);
const apiBaseFallback = copilotApiBaseFallback(domain);
const sourceCredentialFingerprint = fingerprintCopilotSourceCredential(params.githubToken);
const cache = await resolveCopilotTokenCache({
env,
domain,
sourceCredentialFingerprint,
...(params.cachePath !== undefined ? { cachePath: params.cachePath } : {}),
...(params.loadJsonFileImpl ? { loadJsonFileImpl: params.loadJsonFileImpl } : {}),
...(params.saveJsonFileImpl ? { saveJsonFileImpl: params.saveJsonFileImpl } : {}),
});
const cachePath = cache.path;
const cached = await cache.load();
if (cached && typeof cached.token === "string" && typeof cached.expiresAt === "number") {
// Token cache entries are scoped to the current Copilot integration id and
// GitHub host so stale tokens from older editor identities or a different
// domain are exchanged again.
if (isCopilotTokenUsable({ cache: cached, domain, sourceCredentialFingerprint })) {
return {
token: cached.token,
expiresAt: cached.expiresAt,
source: `cache:${cachePath}`,
baseUrl: deriveCopilotApiBaseUrlFromToken(cached.token) ?? apiBaseFallback,
};
}
}
const fetchImpl = params.fetchImpl ?? fetch;
const signal = AbortSignal.timeout(COPILOT_TOKEN_EXCHANGE_TIMEOUT_MS);
let json: ReturnType<typeof parseCopilotTokenResponse>;
try {
const res = await fetchImpl(tokenUrl, {
method: "GET",
headers: {
Accept: "application/json",
Authorization: `Bearer ${params.githubToken}`,
"Copilot-Integration-Id": COPILOT_INTEGRATION_ID,
...buildCopilotIdeHeaders({ includeApiVersion: true }),
},
signal,
});
if (!res.ok) {
await cancelUnreadResponseBody(res);
throw new Error(`Copilot token exchange failed: HTTP ${res.status}`);
}
json = parseCopilotTokenResponse(await readProviderJsonResponse(res, "github-copilot.token"));
} catch (error) {
// Normalize only the deadline owned by this exchange. Callers still need
// transport aborts and provider failures unchanged for correct recovery.
if (signal.aborted && error === signal.reason) {
throw new Error(
`Copilot token exchange failed: timed out after ${COPILOT_TOKEN_EXCHANGE_TIMEOUT_MS}ms`,
{ cause: error },
);
}
throw error;
}
const payload: CachedCopilotToken = {
token: json.token,
expiresAt: json.expiresAt,
updatedAt: Date.now(),
integrationId: COPILOT_INTEGRATION_ID,
sourceCredentialFingerprint,
domain,
};
await cache.save(payload);
return {
token: payload.token,
expiresAt: payload.expiresAt,
source: `fetched:${tokenUrl}`,
baseUrl: deriveCopilotApiBaseUrlFromToken(payload.token) ?? apiBaseFallback,
};
}
export {
isProviderApiKeyConfigured,
isProviderAuthProfileConfigured,

View file

@ -113,10 +113,6 @@ export const DEPRECATION_MARKING_COMPAT_RECORDS = [
"openclaw/plugin-sdk/provider-model-shared ANTHROPIC_BY_MODEL_REPLAY_HOOKS",
"openclaw/plugin-sdk/provider-model-shared NATIVE_ANTHROPIC_REPLAY_HOOKS",
"openclaw/plugin-sdk/provider-model-shared PASSTHROUGH_GEMINI_REPLAY_HOOKS",
"openclaw/plugin-sdk/provider-auth DEFAULT_COPILOT_API_BASE_URL",
"openclaw/plugin-sdk/provider-auth deriveCopilotApiBaseUrlFromToken",
"openclaw/plugin-sdk/provider-auth resolveCopilotApiToken",
"openclaw/plugin-sdk/provider-auth CachedCopilotToken",
"openclaw/plugin-sdk/provider-auth toFormUrlEncoded",
"openclaw/plugin-sdk/provider-auth generatePkceVerifierChallenge",
"openclaw/plugin-sdk/provider-oauth-runtime OAuthProvider",