mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-24 16:08:03 +00:00
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:
parent
c430435303
commit
f2de06b38d
10 changed files with 17 additions and 1286 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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()),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue