mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(github-copilot): authenticate fine-grained tokens
This commit is contained in:
@@ -9,8 +9,8 @@ import {
|
||||
import { Type } from "typebox";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveFirstGithubToken } from "./auth.js";
|
||||
import { resolveCopilotRuntimeAuth } from "./runtime-auth.js";
|
||||
import { wrapCopilotProviderStream } from "./stream.js";
|
||||
import { resolveCopilotApiToken } from "./token.js";
|
||||
|
||||
const LIVE =
|
||||
process.env.OPENCLAW_LIVE_TEST === "1" ||
|
||||
@@ -26,9 +26,8 @@ const LIVE_MODEL_ID = process.env.OPENCLAW_LIVE_GITHUB_COPILOT_MODEL?.trim() ||
|
||||
const describeLive = LIVE ? describe : describe.skip;
|
||||
const TOOL_ARGUMENT_MARKER = `copilot-stream-arguments-${"x".repeat(128)}`;
|
||||
|
||||
type CopilotApiToken = {
|
||||
token: string;
|
||||
expiresAt: number;
|
||||
type CopilotRuntimeAuth = {
|
||||
apiKey: string;
|
||||
source: string;
|
||||
baseUrl: string;
|
||||
};
|
||||
@@ -145,31 +144,29 @@ describeLive("github-copilot connection-bound Responses IDs live", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
let token: CopilotApiToken | undefined;
|
||||
let token: CopilotRuntimeAuth | undefined;
|
||||
const failures: string[] = [];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
logProgress(`exchanging ${candidate.source} GitHub token for Copilot token`);
|
||||
logProgress(`validating ${candidate.source} GitHub token for Copilot`);
|
||||
token = await withTimeout(
|
||||
"Copilot token exchange",
|
||||
resolveCopilotApiToken({
|
||||
"Copilot authentication",
|
||||
resolveCopilotRuntimeAuth({
|
||||
githubToken: candidate.token,
|
||||
fetchImpl: fetchWithTimeout,
|
||||
}),
|
||||
15_000,
|
||||
);
|
||||
logProgress(
|
||||
`token ok via ${candidate.source} (${token.source.startsWith("cache:") ? "cache" : "fetched"})`,
|
||||
);
|
||||
logProgress(`token ok via ${candidate.source}`);
|
||||
break;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
failures.push(`${candidate.source}: ${message}`);
|
||||
logProgress(`token exchange failed via ${candidate.source} (${message})`);
|
||||
logProgress(`token validation failed via ${candidate.source} (${message})`);
|
||||
}
|
||||
}
|
||||
if (!token) {
|
||||
throw new Error(`Copilot token exchange failed for all candidates: ${failures.join("; ")}`);
|
||||
throw new Error(`Copilot authentication failed for all candidates: ${failures.join("; ")}`);
|
||||
}
|
||||
|
||||
const model = buildModel(token.baseUrl);
|
||||
@@ -206,7 +203,7 @@ describeLive("github-copilot connection-bound Responses IDs live", () => {
|
||||
model as never,
|
||||
context as never,
|
||||
{
|
||||
apiKey: token.token,
|
||||
apiKey: token.apiKey,
|
||||
maxTokens: 256,
|
||||
onPayload: (payload: unknown) => {
|
||||
capturedPayload = {
|
||||
|
||||
@@ -51,7 +51,7 @@ export function createGithubCopilotDynamicModelHooks(params: {
|
||||
if (!params.discoveryEnabled(ctx.config)) {
|
||||
return null;
|
||||
}
|
||||
const { DEFAULT_COPILOT_API_BASE_URL, resolveCopilotApiToken } =
|
||||
const { DEFAULT_COPILOT_API_BASE_URL, resolveCopilotRuntimeAuth } =
|
||||
await loadGithubCopilotRuntime();
|
||||
const { githubToken, hasProfile } = await resolveFirstGithubToken({
|
||||
agentDir: ctx.agentDir,
|
||||
@@ -67,13 +67,13 @@ export function createGithubCopilotDynamicModelHooks(params: {
|
||||
let copilotApiToken: string | undefined;
|
||||
if (githubToken) {
|
||||
try {
|
||||
const token = await resolveCopilotApiToken({
|
||||
const auth = await resolveCopilotRuntimeAuth({
|
||||
githubToken,
|
||||
env: ctx.env,
|
||||
githubDomain: resolveGithubCopilotDomain({ env: ctx.env, config: ctx.config }),
|
||||
});
|
||||
baseUrl = token.baseUrl;
|
||||
copilotApiToken = token.token;
|
||||
baseUrl = auth.baseUrl;
|
||||
copilotApiToken = auth.apiKey;
|
||||
} catch {
|
||||
baseUrl = DEFAULT_COPILOT_API_BASE_URL;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// Github Copilot tests cover embeddings plugin behavior.
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CopilotTokenExchangeError } from "./token-exchange-error.js";
|
||||
import { CopilotRuntimeAuthError } from "./runtime-auth-error.js";
|
||||
|
||||
const resolveFirstGithubTokenMock = vi.hoisted(() => vi.fn());
|
||||
const resolveCopilotApiTokenMock = vi.hoisted(() => vi.fn());
|
||||
const resolveCopilotRuntimeAuthMock = vi.hoisted(() => vi.fn());
|
||||
const resolveConfiguredSecretInputStringMock = vi.hoisted(() => vi.fn());
|
||||
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
@@ -15,9 +15,9 @@ vi.mock("openclaw/plugin-sdk/secret-input-runtime", () => ({
|
||||
resolveConfiguredSecretInputString: resolveConfiguredSecretInputStringMock,
|
||||
}));
|
||||
|
||||
vi.mock("./token.js", () => ({
|
||||
vi.mock("./runtime-auth.js", () => ({
|
||||
DEFAULT_COPILOT_API_BASE_URL: "https://example.test",
|
||||
resolveCopilotApiToken: resolveCopilotApiTokenMock,
|
||||
resolveCopilotRuntimeAuth: resolveCopilotRuntimeAuthMock,
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
|
||||
@@ -29,7 +29,7 @@ import { githubCopilotMemoryEmbeddingProviderAdapter } from "./embeddings.js";
|
||||
afterAll(() => {
|
||||
vi.doUnmock("./auth.js");
|
||||
vi.doUnmock("openclaw/plugin-sdk/secret-input-runtime");
|
||||
vi.doUnmock("./token.js");
|
||||
vi.doUnmock("./runtime-auth.js");
|
||||
vi.doUnmock("openclaw/plugin-sdk/ssrf-runtime");
|
||||
vi.resetModules();
|
||||
});
|
||||
@@ -98,14 +98,14 @@ function defaultCreateOptions() {
|
||||
};
|
||||
}
|
||||
|
||||
function firstCopilotApiTokenRequest() {
|
||||
const [call] = resolveCopilotApiTokenMock.mock.calls;
|
||||
function firstCopilotRuntimeAuthRequest() {
|
||||
const [call] = resolveCopilotRuntimeAuthMock.mock.calls;
|
||||
if (!call) {
|
||||
throw new Error("expected resolveCopilotApiToken call");
|
||||
throw new Error("expected resolveCopilotRuntimeAuth call");
|
||||
}
|
||||
const [request] = call;
|
||||
if (!request || typeof request !== "object") {
|
||||
throw new Error("expected resolveCopilotApiToken request");
|
||||
throw new Error("expected resolveCopilotRuntimeAuth request");
|
||||
}
|
||||
return request as { env?: typeof process.env; githubToken?: string };
|
||||
}
|
||||
@@ -132,9 +132,8 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
|
||||
githubToken: "test-token-placeholder",
|
||||
hasProfile: false,
|
||||
});
|
||||
resolveCopilotApiTokenMock.mockResolvedValue({
|
||||
token: "test-token-placeholder",
|
||||
expiresAt: Date.now() + 3_600_000,
|
||||
resolveCopilotRuntimeAuthMock.mockResolvedValue({
|
||||
apiKey: "test-token-placeholder",
|
||||
source: "test",
|
||||
baseUrl: TEST_BASE_URL,
|
||||
});
|
||||
@@ -145,7 +144,7 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
|
||||
vi.unstubAllGlobals();
|
||||
resolveConfiguredSecretInputStringMock.mockReset();
|
||||
resolveFirstGithubTokenMock.mockReset();
|
||||
resolveCopilotApiTokenMock.mockReset();
|
||||
resolveCopilotRuntimeAuthMock.mockReset();
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
});
|
||||
|
||||
@@ -169,7 +168,7 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
|
||||
const result = await githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions());
|
||||
|
||||
expect(result.provider?.model).toBe("text-embedding-3-small");
|
||||
expect(firstCopilotApiTokenRequest().githubToken).toBe("test-token-placeholder");
|
||||
expect(firstCopilotRuntimeAuthRequest().githubToken).toBe("test-token-placeholder");
|
||||
});
|
||||
|
||||
it("matches embedding-capable models when supported_endpoints is missing or malformed", async () => {
|
||||
@@ -268,6 +267,7 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
|
||||
expect(caught?.message.length).toBeLessThan(8_300);
|
||||
expect(tracked.wasCanceled()).toBe(true);
|
||||
expect(textSpy).not.toHaveBeenCalled();
|
||||
expect(resolveCopilotRuntimeAuthMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("bounds embeddings error bodies", async () => {
|
||||
@@ -299,6 +299,7 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
|
||||
expect(caught?.message.length).toBeLessThan(8_300);
|
||||
expect(tracked.wasCanceled()).toBe(true);
|
||||
expect(textSpy).not.toHaveBeenCalled();
|
||||
expect(resolveCopilotRuntimeAuthMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("honors remote overrides when creating the provider", async () => {
|
||||
@@ -320,15 +321,28 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
|
||||
|
||||
expect(resolveFirstGithubTokenMock).not.toHaveBeenCalled();
|
||||
expect(resolveConfiguredSecretInputStringMock).not.toHaveBeenCalled();
|
||||
expect(firstCopilotApiTokenRequest().env).toBe(process.env);
|
||||
expect(firstCopilotApiTokenRequest().githubToken).toBe("test-token-placeholder");
|
||||
expect(resolveCopilotRuntimeAuthMock).not.toHaveBeenCalled();
|
||||
|
||||
const discoveryCall = firstDiscoveryRequest();
|
||||
expect(discoveryCall.url).toBe("https://proxy.example/v1/models");
|
||||
expect(discoveryCall.init.headers["Accept-Encoding"]).toBe("identity");
|
||||
expect(discoveryCall.init.headers["Copilot-Integration-Id"]).toBe("copilot-developer-cli");
|
||||
expect(discoveryCall.init.headers["X-Proxy-Token"]).toBe("test-token-placeholder");
|
||||
});
|
||||
|
||||
it("does not forward a stored GitHub token to a custom remote endpoint", async () => {
|
||||
await expect(
|
||||
githubCopilotMemoryEmbeddingProviderAdapter.create({
|
||||
...defaultCreateOptions(),
|
||||
remote: { baseUrl: "https://proxy.example/v1" },
|
||||
} as never),
|
||||
).rejects.toThrow("custom baseUrl requires an explicit memory.search.remote.apiKey");
|
||||
|
||||
expect(resolveFirstGithubTokenMock).not.toHaveBeenCalled();
|
||||
expect(resolveCopilotRuntimeAuthMock).not.toHaveBeenCalled();
|
||||
expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an unresolved remote ref without falling back to another profile", async () => {
|
||||
await expect(
|
||||
githubCopilotMemoryEmbeddingProviderAdapter.create({
|
||||
@@ -342,7 +356,7 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
|
||||
path: "memory.search.remote.apiKey",
|
||||
});
|
||||
expect(resolveFirstGithubTokenMock).not.toHaveBeenCalled();
|
||||
expect(resolveCopilotApiTokenMock).not.toHaveBeenCalled();
|
||||
expect(resolveCopilotRuntimeAuthMock).not.toHaveBeenCalled();
|
||||
expect(resolveConfiguredSecretInputStringMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -366,13 +380,13 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("treats token parsing and discovery failures as auto-fallback errors", () => {
|
||||
expect(shouldContinueAutoSelection(new Error("Copilot token response missing token"))).toBe(
|
||||
true,
|
||||
);
|
||||
it("treats authentication and discovery failures as auto-fallback errors", () => {
|
||||
expect(
|
||||
shouldContinueAutoSelection(new Error("Copilot user response missing endpoints.api")),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldContinueAutoSelection(
|
||||
new Error("Unexpected response from GitHub Copilot token endpoint"),
|
||||
new Error("Unexpected response from GitHub Copilot user endpoint"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
@@ -382,7 +396,7 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldContinueAutoSelection(
|
||||
new CopilotTokenExchangeError({ reason: "timeout", timeoutMs: 30_000 }),
|
||||
new CopilotRuntimeAuthError({ reason: "timeout", timeoutMs: 30_000 }),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(shouldContinueAutoSelection(new Error("Network timeout"))).toBe(false);
|
||||
|
||||
@@ -10,15 +10,15 @@ import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const resolveFirstGithubTokenMock = vi.hoisted(() => vi.fn());
|
||||
const resolveCopilotApiTokenMock = vi.hoisted(() => vi.fn());
|
||||
const resolveCopilotRuntimeAuthMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./auth.js", () => ({
|
||||
resolveFirstGithubToken: resolveFirstGithubTokenMock,
|
||||
}));
|
||||
|
||||
vi.mock("./token.js", () => ({
|
||||
vi.mock("./runtime-auth.js", () => ({
|
||||
DEFAULT_COPILOT_API_BASE_URL: "https://api.githubcopilot.test",
|
||||
resolveCopilotApiToken: resolveCopilotApiTokenMock,
|
||||
resolveCopilotRuntimeAuth: resolveCopilotRuntimeAuthMock,
|
||||
}));
|
||||
|
||||
// Intentionally NOT mocked: openclaw/plugin-sdk/ssrf-runtime, global fetch, and
|
||||
@@ -76,9 +76,8 @@ async function startCopilotServer(handle: {
|
||||
}
|
||||
|
||||
function pointTokenAt(baseUrl: string): void {
|
||||
resolveCopilotApiTokenMock.mockResolvedValue({
|
||||
token: "copilot_test_token_abc",
|
||||
expiresAt: Date.now() + 3_600_000,
|
||||
resolveCopilotRuntimeAuthMock.mockResolvedValue({
|
||||
apiKey: "copilot_test_token_abc",
|
||||
source: "test",
|
||||
baseUrl,
|
||||
});
|
||||
@@ -123,7 +122,7 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter real transport", () => {
|
||||
const pending = servers.splice(0);
|
||||
await Promise.all(pending.map((server) => server.close()));
|
||||
resolveFirstGithubTokenMock.mockReset();
|
||||
resolveCopilotApiTokenMock.mockReset();
|
||||
resolveCopilotRuntimeAuthMock.mockReset();
|
||||
});
|
||||
|
||||
it("redacts credential-shaped text in model discovery errors over real transport", async () => {
|
||||
|
||||
@@ -16,8 +16,9 @@ import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-i
|
||||
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { resolveFirstGithubToken } from "./auth.js";
|
||||
import { resolveGithubCopilotDomain } from "./domain.js";
|
||||
import { CopilotTokenExchangeError } from "./token-exchange-error.js";
|
||||
import { DEFAULT_COPILOT_API_BASE_URL, resolveCopilotApiToken } from "./token.js";
|
||||
import { CopilotRuntimeAuthError } from "./runtime-auth-error.js";
|
||||
import { DEFAULT_COPILOT_API_BASE_URL, resolveCopilotRuntimeAuth } from "./runtime-auth.js";
|
||||
import { COPILOT_RUNTIME_INTEGRATION_ID } from "./runtime-identity.js";
|
||||
|
||||
const COPILOT_EMBEDDING_PROVIDER_ID = "github-copilot";
|
||||
|
||||
@@ -33,6 +34,7 @@ const PREFERRED_MODELS = [
|
||||
const COPILOT_HEADERS_STATIC: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...buildCopilotIdeHeaders(),
|
||||
"Copilot-Integration-Id": COPILOT_RUNTIME_INTEGRATION_ID,
|
||||
};
|
||||
const COPILOT_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
|
||||
const COPILOT_EMBEDDINGS_RESPONSE_MAX_BYTES = 64 * 1024 * 1024;
|
||||
@@ -57,6 +59,7 @@ type CopilotModelEntry = {
|
||||
type GitHubCopilotEmbeddingClient = {
|
||||
githubToken: string;
|
||||
model: string;
|
||||
runtimeAuth?: { apiKey: string; baseUrl: string };
|
||||
baseUrl?: string;
|
||||
headers?: Record<string, string>;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
@@ -65,7 +68,7 @@ type GitHubCopilotEmbeddingClient = {
|
||||
};
|
||||
|
||||
function isCopilotSetupError(err: unknown): boolean {
|
||||
if (err instanceof CopilotTokenExchangeError) {
|
||||
if (err instanceof CopilotRuntimeAuthError) {
|
||||
return true;
|
||||
}
|
||||
if (!(err instanceof Error)) {
|
||||
@@ -73,16 +76,16 @@ function isCopilotSetupError(err: unknown): boolean {
|
||||
}
|
||||
// All Copilot-specific setup failures should allow auto-selection to
|
||||
// fall through to the next provider (e.g. OpenAI). This covers: missing
|
||||
// GitHub token, token exchange failures, no embedding models on the plan,
|
||||
// GitHub token, authentication failures, no embedding models on the plan,
|
||||
// model discovery errors, and user-pinned model not available on Copilot.
|
||||
return (
|
||||
err.message.includes("No GitHub token available") ||
|
||||
err.message.includes("Copilot token response") ||
|
||||
err.message.includes("Copilot user response") ||
|
||||
err.message.includes("No embedding models available") ||
|
||||
err.message.includes("GitHub Copilot model discovery") ||
|
||||
err.message.includes("github-copilot.model-discovery") ||
|
||||
err.message.includes("GitHub Copilot embedding model") ||
|
||||
err.message.includes("Unexpected response from GitHub Copilot token endpoint")
|
||||
err.message.includes("Unexpected response from GitHub Copilot user endpoint")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -213,19 +216,21 @@ async function resolveGitHubCopilotEmbeddingSession(client: GitHubCopilotEmbeddi
|
||||
baseUrl: string;
|
||||
headers: Record<string, string>;
|
||||
}> {
|
||||
const token = await resolveCopilotApiToken({
|
||||
githubToken: client.githubToken,
|
||||
env: client.env,
|
||||
fetchImpl: client.fetchImpl,
|
||||
githubDomain: client.githubDomain,
|
||||
});
|
||||
const baseUrl = client.baseUrl?.trim() || token.baseUrl || DEFAULT_COPILOT_API_BASE_URL;
|
||||
const auth =
|
||||
client.runtimeAuth ??
|
||||
(await resolveCopilotRuntimeAuth({
|
||||
githubToken: client.githubToken,
|
||||
env: client.env,
|
||||
fetchImpl: client.fetchImpl,
|
||||
githubDomain: client.githubDomain,
|
||||
}));
|
||||
const baseUrl = client.baseUrl?.trim() || auth.baseUrl || DEFAULT_COPILOT_API_BASE_URL;
|
||||
return {
|
||||
baseUrl,
|
||||
headers: {
|
||||
...COPILOT_HEADERS_STATIC,
|
||||
...client.headers,
|
||||
Authorization: `Bearer ${token.token}`,
|
||||
Authorization: `Bearer ${auth.apiKey}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -240,16 +245,15 @@ async function createGitHubCopilotEmbeddingProvider(
|
||||
return [];
|
||||
}
|
||||
|
||||
const session = await resolveGitHubCopilotEmbeddingSession(client);
|
||||
const url = `${session.baseUrl.replace(/\/$/, "")}/embeddings`;
|
||||
const url = `${initialSession.baseUrl.replace(/\/$/, "")}/embeddings`;
|
||||
return await withRemoteHttpResponse({
|
||||
url,
|
||||
fetchImpl: client.fetchImpl,
|
||||
ssrfPolicy: buildRemoteBaseUrlPolicy(session.baseUrl),
|
||||
ssrfPolicy: buildRemoteBaseUrlPolicy(initialSession.baseUrl),
|
||||
signal,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: session.headers,
|
||||
headers: initialSession.headers,
|
||||
body: JSON.stringify({ model: client.model, input }),
|
||||
},
|
||||
onResponse: async (response) => {
|
||||
@@ -297,6 +301,17 @@ export const githubCopilotMemoryEmbeddingProviderAdapter: MemoryEmbeddingProvide
|
||||
value: options.remote?.apiKey,
|
||||
path: "memory.search.remote.apiKey",
|
||||
});
|
||||
const customBaseUrl = options.remote?.baseUrl?.trim();
|
||||
const customRuntimeAuth = customBaseUrl
|
||||
? (() => {
|
||||
if (!explicitValue) {
|
||||
throw new Error(
|
||||
"GitHub Copilot memory custom baseUrl requires an explicit memory.search.remote.apiKey",
|
||||
);
|
||||
}
|
||||
return { apiKey: explicitValue, baseUrl: customBaseUrl };
|
||||
})()
|
||||
: undefined;
|
||||
const value = explicitValue
|
||||
? explicitValue
|
||||
: (
|
||||
@@ -314,13 +329,16 @@ export const githubCopilotMemoryEmbeddingProviderAdapter: MemoryEmbeddingProvide
|
||||
env: process.env,
|
||||
config: options.config,
|
||||
});
|
||||
const { token: copilotToken, baseUrl: resolvedBaseUrl } = await resolveCopilotApiToken({
|
||||
githubToken: value,
|
||||
env: process.env,
|
||||
githubDomain,
|
||||
});
|
||||
const baseUrl =
|
||||
options.remote?.baseUrl?.trim() || resolvedBaseUrl || DEFAULT_COPILOT_API_BASE_URL;
|
||||
// A custom endpoint owns its own explicit credential. Never resolve a
|
||||
// durable GitHub token and then forward it to an operator-supplied host.
|
||||
const runtimeAuth =
|
||||
customRuntimeAuth ??
|
||||
(await resolveCopilotRuntimeAuth({
|
||||
githubToken: value,
|
||||
env: process.env,
|
||||
githubDomain,
|
||||
}));
|
||||
const baseUrl = runtimeAuth.baseUrl || DEFAULT_COPILOT_API_BASE_URL;
|
||||
const ssrfPolicy = buildSsrfPolicy(baseUrl);
|
||||
|
||||
// Always discover models even when the user pins one: this validates
|
||||
@@ -328,7 +346,7 @@ export const githubCopilotMemoryEmbeddingProviderAdapter: MemoryEmbeddingProvide
|
||||
// we attempt any embedding requests.
|
||||
const availableModels = await discoverEmbeddingModels({
|
||||
baseUrl,
|
||||
copilotToken,
|
||||
copilotToken: runtimeAuth.apiKey,
|
||||
headers: options.remote?.headers,
|
||||
ssrfPolicy,
|
||||
});
|
||||
@@ -341,6 +359,7 @@ export const githubCopilotMemoryEmbeddingProviderAdapter: MemoryEmbeddingProvide
|
||||
env: process.env,
|
||||
fetchImpl: fetch,
|
||||
githubToken: value,
|
||||
runtimeAuth,
|
||||
githubDomain,
|
||||
headers: options.remote?.headers,
|
||||
model,
|
||||
|
||||
@@ -28,8 +28,7 @@ const mocks = vi.hoisted(() => ({
|
||||
finalUrl: params.url,
|
||||
release: vi.fn(async () => {}),
|
||||
})),
|
||||
resolveCopilotApiToken: vi.fn(),
|
||||
configureCopilotTokenCacheStore: vi.fn<(openStore: () => unknown) => void>(),
|
||||
resolveCopilotRuntimeAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
function requireAuthMethod<T>(methods: readonly T[], index: number): T {
|
||||
@@ -48,15 +47,11 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", async () => {
|
||||
|
||||
vi.mock("./register.runtime.js", () => ({
|
||||
DEFAULT_COPILOT_API_BASE_URL: "https://api.githubcopilot.test",
|
||||
resolveCopilotApiToken: mocks.resolveCopilotApiToken,
|
||||
resolveCopilotRuntimeAuth: mocks.resolveCopilotRuntimeAuth,
|
||||
githubCopilotLoginCommand: mocks.githubCopilotLoginCommand,
|
||||
fetchCopilotUsage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./token.js", () => ({
|
||||
configureCopilotTokenCacheStore: mocks.configureCopilotTokenCacheStore,
|
||||
}));
|
||||
|
||||
import plugin from "./index.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
@@ -75,6 +70,7 @@ type GithubCopilotTestProvider = RegisteredProvider & {
|
||||
prepareDynamicModel: NonNullable<RegisteredProvider["prepareDynamicModel"]>;
|
||||
resolveDynamicModel: NonNullable<RegisteredProvider["resolveDynamicModel"]>;
|
||||
preferRuntimeResolvedModel: NonNullable<RegisteredProvider["preferRuntimeResolvedModel"]>;
|
||||
prepareRuntimeAuth: NonNullable<RegisteredProvider["prepareRuntimeAuth"]>;
|
||||
resolveThinkingProfile: NonNullable<RegisteredProvider["resolveThinkingProfile"]>;
|
||||
};
|
||||
type GithubCopilotTestModelCatalogProvider = {
|
||||
@@ -178,31 +174,31 @@ function registerProviderWithPluginConfig(pluginConfig: Record<string, unknown>)
|
||||
}
|
||||
|
||||
describe("github-copilot plugin", () => {
|
||||
it("binds a lazy provider-scoped token store", () => {
|
||||
const store = {};
|
||||
const openSyncKeyedStore = vi.fn(() => store);
|
||||
plugin.register(
|
||||
createTestPluginApi({
|
||||
id: "github-copilot",
|
||||
name: "GitHub Copilot",
|
||||
source: "test",
|
||||
config: {},
|
||||
runtime: { state: { openSyncKeyedStore } } as never,
|
||||
}),
|
||||
);
|
||||
it("preserves the source token supplied by the auth layer for runtime auth", async () => {
|
||||
mocks.resolveCopilotRuntimeAuth.mockResolvedValueOnce({
|
||||
apiKey: "github-source-token",
|
||||
baseUrl: "https://api.individual.githubcopilot.com",
|
||||
});
|
||||
const provider = registerProviderWithPluginConfig({});
|
||||
|
||||
const openStore = requireFirstMockArg<() => unknown>(
|
||||
mocks.configureCopilotTokenCacheStore,
|
||||
"token cache store binding",
|
||||
);
|
||||
expect(openSyncKeyedStore).not.toHaveBeenCalled();
|
||||
expect(openStore()).toBe(store);
|
||||
expect(openStore()).toBe(store);
|
||||
expect(openSyncKeyedStore).toHaveBeenCalledOnce();
|
||||
expect(openSyncKeyedStore).toHaveBeenCalledWith({
|
||||
namespace: "token",
|
||||
maxEntries: 8,
|
||||
overflowPolicy: "evict-oldest",
|
||||
const prepared = await provider.prepareRuntimeAuth({
|
||||
config: {},
|
||||
env: {},
|
||||
provider: "github-copilot",
|
||||
modelId: "gpt-5-mini",
|
||||
model: { id: "gpt-5-mini", provider: "github-copilot" },
|
||||
apiKey: "github-source-token",
|
||||
authMode: "oauth",
|
||||
} as never);
|
||||
|
||||
expect(mocks.resolveCopilotRuntimeAuth).toHaveBeenCalledWith({
|
||||
githubToken: "github-source-token",
|
||||
env: {},
|
||||
githubDomain: "github.com",
|
||||
});
|
||||
expect(prepared).toEqual({
|
||||
apiKey: "github-source-token",
|
||||
baseUrl: "https://api.individual.githubcopilot.com",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -287,7 +283,7 @@ describe("github-copilot plugin", () => {
|
||||
} as never);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mocks.resolveCopilotApiToken).not.toHaveBeenCalled();
|
||||
expect(mocks.resolveCopilotRuntimeAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("exposes xhigh thinking for catalog-supported Copilot reasoning efforts", () => {
|
||||
@@ -358,8 +354,8 @@ describe("github-copilot plugin", () => {
|
||||
});
|
||||
|
||||
it("uses live plugin config to re-enable discovery after startup disable", async () => {
|
||||
mocks.resolveCopilotApiToken.mockResolvedValueOnce({
|
||||
token: "copilot_api_token",
|
||||
mocks.resolveCopilotRuntimeAuth.mockResolvedValueOnce({
|
||||
apiKey: "gh_test_token",
|
||||
baseUrl: "https://api.githubcopilot.live",
|
||||
});
|
||||
const provider = registerProviderWithPluginConfig({ discovery: { enabled: false } });
|
||||
@@ -381,7 +377,7 @@ describe("github-copilot plugin", () => {
|
||||
resolveProviderApiKey: () => ({ apiKey: "gh_test_token" }),
|
||||
} as never);
|
||||
|
||||
expect(mocks.resolveCopilotApiToken).toHaveBeenCalledWith({
|
||||
expect(mocks.resolveCopilotRuntimeAuth).toHaveBeenCalledWith({
|
||||
githubToken: "gh_test_token",
|
||||
env: { GH_TOKEN: "gh_test_token" },
|
||||
githubDomain: "github.com",
|
||||
@@ -395,8 +391,8 @@ describe("github-copilot plugin", () => {
|
||||
});
|
||||
|
||||
it("dual-publishes unified live catalog rows with existing discovery semantics", async () => {
|
||||
mocks.resolveCopilotApiToken.mockResolvedValueOnce({
|
||||
token: "copilot_api_token",
|
||||
mocks.resolveCopilotRuntimeAuth.mockResolvedValueOnce({
|
||||
apiKey: "gh_test_token",
|
||||
baseUrl: "https://api.githubcopilot.live",
|
||||
});
|
||||
const { modelCatalogProvider } = registerProviderAndCatalogWithPluginConfig({
|
||||
@@ -425,7 +421,7 @@ describe("github-copilot plugin", () => {
|
||||
}),
|
||||
} as never);
|
||||
|
||||
expect(mocks.resolveCopilotApiToken).toHaveBeenCalledWith({
|
||||
expect(mocks.resolveCopilotRuntimeAuth).toHaveBeenCalledWith({
|
||||
githubToken: "gh_test_token",
|
||||
env: { GH_TOKEN: "gh_test_token" },
|
||||
githubDomain: "github.com",
|
||||
@@ -500,13 +496,13 @@ describe("github-copilot plugin", () => {
|
||||
agentDir,
|
||||
{ filterExternalAuthProfiles: false, syncExternalCli: false },
|
||||
);
|
||||
mocks.resolveCopilotApiToken
|
||||
mocks.resolveCopilotRuntimeAuth
|
||||
.mockResolvedValueOnce({
|
||||
token: "alpha",
|
||||
apiKey: "chosen",
|
||||
baseUrl: "https://api.githubcopilot.live",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
token: "beta",
|
||||
apiKey: "first",
|
||||
baseUrl: "https://api.githubcopilot.first",
|
||||
});
|
||||
const catalogResponse = (contextWindow: number, promptTokens: number) =>
|
||||
@@ -560,12 +556,12 @@ describe("github-copilot plugin", () => {
|
||||
await provider.prepareDynamicModel(selectedContext);
|
||||
await provider.prepareDynamicModel(firstContext);
|
||||
|
||||
expect(mocks.resolveCopilotApiToken).toHaveBeenNthCalledWith(1, {
|
||||
expect(mocks.resolveCopilotRuntimeAuth).toHaveBeenNthCalledWith(1, {
|
||||
githubToken: "chosen",
|
||||
env: process.env,
|
||||
githubDomain: "github.com",
|
||||
});
|
||||
expect(mocks.resolveCopilotApiToken).toHaveBeenNthCalledWith(2, {
|
||||
expect(mocks.resolveCopilotRuntimeAuth).toHaveBeenNthCalledWith(2, {
|
||||
githubToken: "first",
|
||||
env: process.env,
|
||||
githubDomain: "github.com",
|
||||
@@ -605,13 +601,13 @@ describe("github-copilot plugin", () => {
|
||||
agentDir,
|
||||
{ filterExternalAuthProfiles: false, syncExternalCli: false },
|
||||
);
|
||||
mocks.resolveCopilotApiToken
|
||||
mocks.resolveCopilotRuntimeAuth
|
||||
.mockResolvedValueOnce({
|
||||
token: "test-auth-token",
|
||||
apiKey: "test-auth-token",
|
||||
baseUrl: "https://api.githubcopilot.profile",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
token: "test-token-placeholder",
|
||||
apiKey: "test-token-placeholder",
|
||||
baseUrl: "https://api.githubcopilot.direct",
|
||||
});
|
||||
const catalogResponse = (contextWindow: number, promptTokens: number) =>
|
||||
@@ -675,12 +671,12 @@ describe("github-copilot plugin", () => {
|
||||
await provider.prepareDynamicModel(profileContext);
|
||||
await provider.prepareDynamicModel(directContext);
|
||||
|
||||
expect(mocks.resolveCopilotApiToken).toHaveBeenNthCalledWith(1, {
|
||||
expect(mocks.resolveCopilotRuntimeAuth).toHaveBeenNthCalledWith(1, {
|
||||
githubToken: "test-auth-token",
|
||||
env: process.env,
|
||||
githubDomain: "github.com",
|
||||
});
|
||||
expect(mocks.resolveCopilotApiToken).toHaveBeenNthCalledWith(2, {
|
||||
expect(mocks.resolveCopilotRuntimeAuth).toHaveBeenNthCalledWith(2, {
|
||||
githubToken: "test-token-placeholder",
|
||||
env: process.env,
|
||||
githubDomain: "github.com",
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
type UnifiedModelCatalogEntry,
|
||||
type UnifiedModelCatalogProviderContext,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import {
|
||||
applyAuthProfileConfig,
|
||||
coerceSecretRef,
|
||||
@@ -30,12 +29,6 @@ import {
|
||||
sanitizeGithubCopilotReplayHistory,
|
||||
} from "./replay-policy.js";
|
||||
import { wrapCopilotProviderStream } from "./stream.js";
|
||||
import {
|
||||
COPILOT_TOKEN_CACHE_MAX_ENTRIES,
|
||||
COPILOT_TOKEN_CACHE_NAMESPACE,
|
||||
type CachedCopilotToken,
|
||||
} from "./token-cache.js";
|
||||
import { configureCopilotTokenCacheStore } from "./token.js";
|
||||
|
||||
const COPILOT_ENV_VARS: [string, string, string] = [
|
||||
"COPILOT_GITHUB_TOKEN",
|
||||
@@ -340,17 +333,6 @@ export default definePluginEntry({
|
||||
description: "Bundled GitHub Copilot provider plugin",
|
||||
register(api) {
|
||||
const startupPluginConfig = (api.pluginConfig ?? {}) as GithubCopilotPluginConfig;
|
||||
let tokenCacheStore: PluginStateSyncKeyedStore<CachedCopilotToken> | undefined;
|
||||
const openTokenCacheStore = () => {
|
||||
tokenCacheStore ??= api.runtime.state.openSyncKeyedStore<CachedCopilotToken>({
|
||||
namespace: COPILOT_TOKEN_CACHE_NAMESPACE,
|
||||
maxEntries: COPILOT_TOKEN_CACHE_MAX_ENTRIES,
|
||||
overflowPolicy: "evict-oldest",
|
||||
});
|
||||
return tokenCacheStore;
|
||||
};
|
||||
configureCopilotTokenCacheStore(openTokenCacheStore);
|
||||
|
||||
function resolveCurrentPluginConfig(config?: OpenClawConfig): GithubCopilotPluginConfig {
|
||||
const runtimePluginConfig = resolvePluginConfigObject(config, "github-copilot");
|
||||
if (runtimePluginConfig) {
|
||||
@@ -635,16 +617,15 @@ export default definePluginEntry({
|
||||
};
|
||||
},
|
||||
prepareRuntimeAuth: async (ctx) => {
|
||||
const { resolveCopilotApiToken } = await loadGithubCopilotRuntime();
|
||||
const token = await resolveCopilotApiToken({
|
||||
const { resolveCopilotRuntimeAuth } = await loadGithubCopilotRuntime();
|
||||
const auth = await resolveCopilotRuntimeAuth({
|
||||
githubToken: ctx.apiKey,
|
||||
env: ctx.env,
|
||||
githubDomain: resolveGithubCopilotDomain({ env: ctx.env, config: ctx.config }),
|
||||
});
|
||||
return {
|
||||
apiKey: token.token,
|
||||
baseUrl: token.baseUrl,
|
||||
expiresAt: token.expiresAt,
|
||||
apiKey: auth.apiKey,
|
||||
baseUrl: auth.baseUrl,
|
||||
};
|
||||
},
|
||||
resolveUsageAuth: async (ctx) => await ctx.resolveOAuthToken(),
|
||||
|
||||
@@ -27,7 +27,7 @@ const CLIENT_ID = "Iv1.b507a08c87ecfe98";
|
||||
const GITHUB_DEVICE_FLOW_REQUEST_TIMEOUT_MS = 30_000;
|
||||
const GITHUB_DEVICE_FLOW_DEFAULT_INTERVAL_MS = 5_000;
|
||||
const GITHUB_DEVICE_FLOW_SLOW_DOWN_INCREMENT_MS = 5_000;
|
||||
// Data-residency GitHub Enterprise support: the device flow, token exchange, and
|
||||
// Data-residency GitHub Enterprise support: the device flow, runtime auth, and
|
||||
// completions endpoints all live under the tenant host (e.g. "acme.ghe.com")
|
||||
// instead of github.com. The host is threaded in from the selected auth flow so
|
||||
// the SSRF allowlist and every request target stay consistent for one login.
|
||||
@@ -391,7 +391,7 @@ export async function githubCopilotLoginCommand(
|
||||
}
|
||||
|
||||
// Mint against the same host the runtime will route to. resolveGithubCopilotDomain
|
||||
// is env-authoritative (COPILOT_GITHUB_DOMAIN wins), and runtime token exchange
|
||||
// is env-authoritative (COPILOT_GITHUB_DOMAIN wins), and runtime authentication
|
||||
// uses the same resolver, so honoring it here keeps the minted token and the
|
||||
// runtime endpoint on the same tenant instead of minting a public token that
|
||||
// then 401s against api.<tenant>.
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
// Github Copilot tests cover models plugin behavior.
|
||||
import { createHash } from "node:crypto";
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import { deriveCopilotApiBaseUrlFromToken } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { createProviderUsageFetch, makeResponse } from "openclaw/plugin-sdk/test-env";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CachedCopilotToken } from "./token-cache.js";
|
||||
import { CopilotTokenExchangeError } from "./token-exchange-error.js";
|
||||
import { resolveCopilotApiToken } from "./token.js";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { CopilotRuntimeAuthError } from "./runtime-auth-error.js";
|
||||
import { resolveCopilotRuntimeAuth } from "./runtime-auth.js";
|
||||
import { fetchCopilotUsage } from "./usage.js";
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/provider-model-shared", async (importOriginal) => ({
|
||||
@@ -20,16 +16,6 @@ vi.mock("openclaw/plugin-sdk/provider-model-shared", async (importOriginal) => (
|
||||
}),
|
||||
}));
|
||||
|
||||
const jsonStoreMocks = vi.hoisted(() => ({
|
||||
loadJsonFile: vi.fn(),
|
||||
saveJsonFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/json-store", () => ({
|
||||
loadJsonFile: jsonStoreMocks.loadJsonFile,
|
||||
saveJsonFile: jsonStoreMocks.saveJsonFile,
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/state-paths", () => ({
|
||||
resolveStateDir: () => "/tmp/openclaw-state",
|
||||
}));
|
||||
@@ -59,10 +45,6 @@ function requireResolvedModel(ctx: ProviderResolveDynamicModelContext) {
|
||||
return result;
|
||||
}
|
||||
|
||||
function copilotCredentialFixture(proxyHost: string): string {
|
||||
return `test-token-placeholder;proxy-ep=${proxyHost};`;
|
||||
}
|
||||
|
||||
describe("resolveCopilotForwardCompatModel", () => {
|
||||
it("returns undefined for empty modelId", () => {
|
||||
expect(resolveCopilotForwardCompatModel(createMockCtx(""))).toBeUndefined();
|
||||
@@ -359,144 +341,144 @@ describe("fetchCopilotUsage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("github-copilot token", () => {
|
||||
const cachePath = "/tmp/openclaw-state/credentials/github-copilot.token.json";
|
||||
describe("github-copilot runtime auth", () => {
|
||||
it("validates and preserves the source token while resolving the account API endpoint", async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({ endpoints: { api: "https://api.individual.githubcopilot.com/" } }),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jsonStoreMocks.loadJsonFile.mockReset();
|
||||
jsonStoreMocks.saveJsonFile.mockReset();
|
||||
});
|
||||
|
||||
it("derives baseUrl only from trusted Copilot token hosts", () => {
|
||||
expect(deriveCopilotApiBaseUrlFromToken("token;proxy-ep=proxy.example.com;")).toBeNull();
|
||||
expect(deriveCopilotApiBaseUrlFromToken("token;proxy-ep=https://proxy.foo.bar;")).toBeNull();
|
||||
expect(
|
||||
deriveCopilotApiBaseUrlFromToken(
|
||||
copilotCredentialFixture("proxy.individual.githubcopilot.com"),
|
||||
),
|
||||
).toBe("https://api.individual.githubcopilot.com");
|
||||
});
|
||||
|
||||
it("uses cache when token is still valid", async () => {
|
||||
const now = Date.now();
|
||||
jsonStoreMocks.loadJsonFile.mockReturnValue({
|
||||
token: "cached;proxy-ep=proxy.example.com;",
|
||||
expiresAt: now + 60 * 60 * 1000,
|
||||
updatedAt: now,
|
||||
integrationId: "vscode-chat",
|
||||
sourceCredentialFingerprint: createHash("sha256").update("gh").digest("hex"),
|
||||
domain: "github.com",
|
||||
const auth = await resolveCopilotRuntimeAuth({
|
||||
githubToken: "github-source-token",
|
||||
fetchImpl: fetchImpl as typeof fetch,
|
||||
});
|
||||
|
||||
const fetchImpl = vi.fn();
|
||||
const res = await resolveCopilotApiToken({
|
||||
githubToken: "gh",
|
||||
cachePath,
|
||||
loadJsonFileImpl: jsonStoreMocks.loadJsonFile,
|
||||
saveJsonFileImpl: jsonStoreMocks.saveJsonFile,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
expect(auth).toEqual({
|
||||
apiKey: "github-source-token",
|
||||
baseUrl: "https://api.individual.githubcopilot.com",
|
||||
source: "validated:https://api.github.com/copilot_internal/user",
|
||||
});
|
||||
|
||||
expect(res.token).toBe("cached;proxy-ep=proxy.example.com;");
|
||||
expect(res.baseUrl).toBe("https://api.individual.githubcopilot.com");
|
||||
expect(res.source).toContain("cache:");
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches and stores token when cache is missing", async () => {
|
||||
jsonStoreMocks.loadJsonFile.mockReturnValue(undefined);
|
||||
|
||||
const fetchImpl = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
token: "fresh;proxy-ep=https://proxy.contoso.test;",
|
||||
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
"https://api.github.com/copilot_internal/user",
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: "Bearer github-source-token",
|
||||
},
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await resolveCopilotApiToken({
|
||||
githubToken: "gh",
|
||||
cachePath,
|
||||
loadJsonFileImpl: jsonStoreMocks.loadJsonFile,
|
||||
saveJsonFileImpl: jsonStoreMocks.saveJsonFile,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(res.token).toBe("fresh;proxy-ep=https://proxy.contoso.test;");
|
||||
expect(res.baseUrl).toBe("https://api.individual.githubcopilot.com");
|
||||
const [, calledInit] = fetchImpl.mock.calls[0] ?? [];
|
||||
expect(((calledInit as RequestInit).headers as Record<string, string>)["Accept-Encoding"]).toBe(
|
||||
"identity",
|
||||
);
|
||||
expect(jsonStoreMocks.saveJsonFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("explains how to recover from a forbidden token exchange", async () => {
|
||||
jsonStoreMocks.loadJsonFile.mockReturnValue(undefined);
|
||||
const fetchImpl = vi.fn().mockResolvedValue(new Response(null, { status: 403 }));
|
||||
it("accepts an account endpoint under the configured data-residency tenant", async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ endpoints: { api: "https://copilot-api.acme.ghe.com" } }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const rejection = resolveCopilotApiToken({
|
||||
githubToken: "gh",
|
||||
cachePath,
|
||||
loadJsonFileImpl: jsonStoreMocks.loadJsonFile,
|
||||
saveJsonFileImpl: jsonStoreMocks.saveJsonFile,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
const auth = await resolveCopilotRuntimeAuth({
|
||||
githubToken: "tenant-source-token",
|
||||
githubDomain: "acme.ghe.com",
|
||||
fetchImpl: fetchImpl as typeof fetch,
|
||||
});
|
||||
|
||||
await expect(rejection).rejects.toBeInstanceOf(CopilotTokenExchangeError);
|
||||
expect(auth.baseUrl).toBe("https://copilot-api.acme.ghe.com");
|
||||
expect(fetchImpl.mock.calls[0]?.[0]).toBe("https://api.acme.ghe.com/copilot_internal/user");
|
||||
});
|
||||
|
||||
it("uses a domain-safe fallback when account metadata omits the API endpoint", async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ copilot_plan: "individual" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
resolveCopilotRuntimeAuth({
|
||||
githubToken: "github-source-token",
|
||||
fetchImpl: fetchImpl as typeof fetch,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
apiKey: "github-source-token",
|
||||
baseUrl: "https://api.individual.githubcopilot.com",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
"http://api.individual.githubcopilot.com",
|
||||
"https://api.individual.githubcopilot.com.attacker.test",
|
||||
"https://user@api.individual.githubcopilot.com",
|
||||
])("rejects an untrusted account endpoint: %s", async (api) => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ endpoints: { api } }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
resolveCopilotRuntimeAuth({
|
||||
githubToken: "github-source-token",
|
||||
fetchImpl: fetchImpl as typeof fetch,
|
||||
}),
|
||||
).rejects.toThrow("untrusted endpoints.api URL");
|
||||
});
|
||||
|
||||
it("explains how to recover from forbidden authentication", async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue(new Response(null, { status: 403 }));
|
||||
const rejection = resolveCopilotRuntimeAuth({
|
||||
githubToken: "github-source-token",
|
||||
fetchImpl: fetchImpl as typeof fetch,
|
||||
});
|
||||
|
||||
await expect(rejection).rejects.toBeInstanceOf(CopilotRuntimeAuthError);
|
||||
await expect(rejection).rejects.toMatchObject({
|
||||
code: "github_copilot_token_exchange_failed",
|
||||
code: "github_copilot_auth_failed",
|
||||
reason: "http_error",
|
||||
status: 403,
|
||||
message: expect.stringContaining("login-github-copilot"),
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps exchanges per source credential in plugin state", async () => {
|
||||
const values = new Map<string, CachedCopilotToken>();
|
||||
const register = vi.fn((key: string, value: CachedCopilotToken) => {
|
||||
values.set(key, value);
|
||||
});
|
||||
const store = {
|
||||
lookup: vi.fn((key: string) => values.get(key)),
|
||||
register,
|
||||
} as unknown as PluginStateSyncKeyedStore<CachedCopilotToken>;
|
||||
const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
const source = new Headers(init?.headers).get("authorization")?.replace(/^Bearer\s+/u, "");
|
||||
const exchange = `exchange-${source};proxy-ep=proxy.individual.githubcopilot.com;`;
|
||||
it("maps a stalled response body to a runtime-auth timeout", async () => {
|
||||
const controller = new AbortController();
|
||||
const timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockImplementation(() => controller.signal);
|
||||
const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const signal = init?.signal;
|
||||
return new Response(
|
||||
JSON.stringify(
|
||||
Object.fromEntries([
|
||||
["token", exchange],
|
||||
["expires_at", 2_000_000_000],
|
||||
]),
|
||||
),
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(streamController) {
|
||||
signal?.addEventListener("abort", () => streamController.error(signal.reason), {
|
||||
once: true,
|
||||
});
|
||||
queueMicrotask(() => controller.abort(new DOMException("timed out", "TimeoutError")));
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
});
|
||||
const resolve = (githubToken: string) =>
|
||||
resolveCopilotApiToken({
|
||||
githubToken,
|
||||
fetchImpl: fetchImpl as typeof fetch,
|
||||
openCacheStore: () => store,
|
||||
|
||||
try {
|
||||
await expect(
|
||||
resolveCopilotRuntimeAuth({
|
||||
githubToken: "github-source-token",
|
||||
fetchImpl: fetchImpl as typeof fetch,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
name: "CopilotRuntimeAuthError",
|
||||
reason: "timeout",
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
|
||||
const firstA = await resolve("source-a");
|
||||
const firstB = await resolve("source-b");
|
||||
const secondA = await resolve("source-a");
|
||||
|
||||
expect(firstA.token).toContain("exchange-source-a");
|
||||
expect(firstB.token).toContain("exchange-source-b");
|
||||
expect(secondA.token).toBe(firstA.token);
|
||||
expect(secondA.source).toBe("cache:plugin-state");
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
expect(register).toHaveBeenCalledTimes(2);
|
||||
expect(values.size).toBe(2);
|
||||
} finally {
|
||||
timeoutSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
ProviderResolveDynamicModelContext,
|
||||
ProviderRuntimeModel,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
import { buildCopilotIdeHeaders, COPILOT_INTEGRATION_ID } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { buildCopilotIdeHeaders } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { readProviderJsonArrayFieldResponse } from "openclaw/plugin-sdk/provider-http";
|
||||
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import {
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
resolveCopilotTransportApi,
|
||||
resolveStaticCopilotModelOverride,
|
||||
} from "./model-metadata.js";
|
||||
import { COPILOT_RUNTIME_INTEGRATION_ID } from "./runtime-identity.js";
|
||||
|
||||
export const PROVIDER_ID = "github-copilot";
|
||||
|
||||
@@ -231,7 +232,7 @@ function asCopilotApiModelEntry(value: unknown): CopilotApiModelEntry {
|
||||
}
|
||||
|
||||
type FetchCopilotModelCatalogParams = {
|
||||
/** Short-lived Copilot API token (from `resolveCopilotApiToken`). */
|
||||
/** GitHub source token accepted by the account's Copilot API endpoint. */
|
||||
copilotApiToken: string;
|
||||
/** Resolved baseUrl from the same token-exchange response. */
|
||||
baseUrl: string;
|
||||
@@ -274,7 +275,7 @@ export async function fetchCopilotModelCatalog(
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${params.copilotApiToken}`,
|
||||
...buildCopilotIdeHeaders(),
|
||||
"Copilot-Integration-Id": COPILOT_INTEGRATION_ID,
|
||||
"Copilot-Integration-Id": COPILOT_RUNTIME_INTEGRATION_ID,
|
||||
},
|
||||
signal: params.signal ?? controller?.signal,
|
||||
});
|
||||
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
} from "openclaw/plugin-sdk/provider-auth";
|
||||
import { githubCopilotLoginCommand } from "./login.js";
|
||||
import { PROVIDER_ID, resolveCopilotForwardCompatModel } from "./models.js";
|
||||
import { DEFAULT_COPILOT_API_BASE_URL, resolveCopilotRuntimeAuth } from "./runtime-auth.js";
|
||||
import { wrapCopilotAnthropicStream, wrapCopilotProviderStream } from "./stream.js";
|
||||
import { DEFAULT_COPILOT_API_BASE_URL, resolveCopilotApiToken } from "./token.js";
|
||||
import { fetchCopilotUsage } from "./usage.js";
|
||||
|
||||
export {
|
||||
@@ -18,7 +18,7 @@ export {
|
||||
githubCopilotLoginCommand,
|
||||
listProfilesForProvider,
|
||||
PROVIDER_ID,
|
||||
resolveCopilotApiToken,
|
||||
resolveCopilotRuntimeAuth,
|
||||
resolveCopilotForwardCompatModel,
|
||||
wrapCopilotAnthropicStream,
|
||||
wrapCopilotProviderStream,
|
||||
|
||||
+11
-11
@@ -1,13 +1,13 @@
|
||||
// GitHub Copilot token exchange errors shared by runtime and fallback policy.
|
||||
type CopilotTokenExchangeFailure =
|
||||
// GitHub Copilot runtime-auth errors shared by provider setup and fallback policy.
|
||||
type CopilotRuntimeAuthFailure =
|
||||
| { reason: "http_error"; status: number }
|
||||
| { reason: "timeout"; timeoutMs: number; cause?: unknown };
|
||||
|
||||
function buildCopilotTokenExchangeMessage(failure: CopilotTokenExchangeFailure): string {
|
||||
function buildCopilotRuntimeAuthMessage(failure: CopilotRuntimeAuthFailure): string {
|
||||
if (failure.reason === "timeout") {
|
||||
return `Copilot token exchange failed: timed out after ${failure.timeoutMs}ms`;
|
||||
return `Copilot authentication failed: timed out after ${failure.timeoutMs}ms`;
|
||||
}
|
||||
const message = `Copilot token exchange failed: HTTP ${failure.status}`;
|
||||
const message = `Copilot authentication failed: HTTP ${failure.status}`;
|
||||
if (failure.status !== 403) {
|
||||
return message;
|
||||
}
|
||||
@@ -18,18 +18,18 @@ function buildCopilotTokenExchangeMessage(failure: CopilotTokenExchangeFailure):
|
||||
);
|
||||
}
|
||||
|
||||
export class CopilotTokenExchangeError extends Error {
|
||||
readonly code = "github_copilot_token_exchange_failed";
|
||||
readonly reason: CopilotTokenExchangeFailure["reason"];
|
||||
export class CopilotRuntimeAuthError extends Error {
|
||||
readonly code = "github_copilot_auth_failed";
|
||||
readonly reason: CopilotRuntimeAuthFailure["reason"];
|
||||
readonly status?: number;
|
||||
readonly timeoutMs?: number;
|
||||
|
||||
constructor(failure: CopilotTokenExchangeFailure) {
|
||||
constructor(failure: CopilotRuntimeAuthFailure) {
|
||||
super(
|
||||
buildCopilotTokenExchangeMessage(failure),
|
||||
buildCopilotRuntimeAuthMessage(failure),
|
||||
failure.reason === "timeout" ? { cause: failure.cause } : undefined,
|
||||
);
|
||||
this.name = "CopilotTokenExchangeError";
|
||||
this.name = "CopilotRuntimeAuthError";
|
||||
this.reason = failure.reason;
|
||||
if (failure.reason === "http_error") {
|
||||
this.status = failure.status;
|
||||
@@ -0,0 +1,123 @@
|
||||
// GitHub Copilot source-token validation and account endpoint resolution.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
|
||||
import { PUBLIC_GITHUB_COPILOT_DOMAIN, resolveGithubCopilotDomain } from "./domain.js";
|
||||
import { CopilotRuntimeAuthError } from "./runtime-auth-error.js";
|
||||
|
||||
export const DEFAULT_COPILOT_API_BASE_URL = "https://api.individual.githubcopilot.com";
|
||||
const COPILOT_RUNTIME_AUTH_TIMEOUT_MS = 30_000;
|
||||
|
||||
function copilotUserUrl(domain: string): string {
|
||||
return `https://api.${domain}/copilot_internal/user`;
|
||||
}
|
||||
|
||||
function copilotApiBaseFallback(domain: string): string {
|
||||
return domain === PUBLIC_GITHUB_COPILOT_DOMAIN
|
||||
? DEFAULT_COPILOT_API_BASE_URL
|
||||
: `https://copilot-api.${domain}`;
|
||||
}
|
||||
|
||||
function isTrustedCopilotApiHost(host: string, domain: string): boolean {
|
||||
if (host === "copilot-proxy.githubusercontent.com" || host.endsWith(".githubcopilot.com")) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
domain !== PUBLIC_GITHUB_COPILOT_DOMAIN && (host === domain || host.endsWith(`.${domain}`))
|
||||
);
|
||||
}
|
||||
|
||||
function parseCopilotApiBaseUrl(value: unknown, domain: string): string {
|
||||
if (!value || typeof value !== "object") {
|
||||
throw new Error("Unexpected response from GitHub Copilot user endpoint");
|
||||
}
|
||||
const endpoints = (value as { endpoints?: unknown }).endpoints;
|
||||
const api =
|
||||
endpoints && typeof endpoints === "object" ? (endpoints as { api?: unknown }).api : undefined;
|
||||
if (api === undefined || api === null || api === "") {
|
||||
return copilotApiBaseFallback(domain);
|
||||
}
|
||||
if (typeof api !== "string" || !api.trim()) {
|
||||
throw new Error("GitHub Copilot user response has an invalid endpoints.api URL");
|
||||
}
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(api);
|
||||
} catch {
|
||||
throw new Error("GitHub Copilot user response has an invalid endpoints.api URL");
|
||||
}
|
||||
const host = url.hostname.toLowerCase();
|
||||
if (
|
||||
url.protocol !== "https:" ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.search ||
|
||||
url.hash ||
|
||||
!isTrustedCopilotApiHost(host, domain)
|
||||
) {
|
||||
throw new Error("GitHub Copilot user response has an untrusted endpoints.api URL");
|
||||
}
|
||||
return url.href.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
async function cancelUnreadResponseBody(response: Response): Promise<void> {
|
||||
if (!response.bodyUsed) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveCopilotRuntimeAuth(params: {
|
||||
githubToken: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
fetchImpl?: typeof fetch;
|
||||
githubDomain?: string;
|
||||
config?: OpenClawConfig;
|
||||
}): Promise<{
|
||||
apiKey: string;
|
||||
source: string;
|
||||
baseUrl: string;
|
||||
}> {
|
||||
const env = params.env ?? process.env;
|
||||
const domain = resolveGithubCopilotDomain({
|
||||
env,
|
||||
explicit: params.githubDomain,
|
||||
config: params.config,
|
||||
});
|
||||
const userUrl = copilotUserUrl(domain);
|
||||
const fetchImpl = params.fetchImpl ?? fetch;
|
||||
const signal = AbortSignal.timeout(COPILOT_RUNTIME_AUTH_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetchImpl(userUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${params.githubToken}`,
|
||||
},
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
await cancelUnreadResponseBody(response);
|
||||
throw new CopilotRuntimeAuthError({ reason: "http_error", status: response.status });
|
||||
}
|
||||
const baseUrl = parseCopilotApiBaseUrl(
|
||||
await readProviderJsonResponse(response, "github-copilot.user"),
|
||||
domain,
|
||||
);
|
||||
// The current Copilot CLI/SDK resolves account metadata through `/user`,
|
||||
// then sends this original GitHub token to CAPI. The retired `/v2/token`
|
||||
// exchange rejects supported fine-grained PATs before inference.
|
||||
return {
|
||||
apiKey: params.githubToken,
|
||||
source: `validated:${userUrl}`,
|
||||
baseUrl,
|
||||
};
|
||||
} catch (error) {
|
||||
if (signal.aborted) {
|
||||
throw new CopilotRuntimeAuthError({
|
||||
reason: "timeout",
|
||||
timeoutMs: COPILOT_RUNTIME_AUTH_TIMEOUT_MS,
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// GitHub's current fine-grained PAT contract is the Copilot CLI identity.
|
||||
// Keep this provider-owned instead of changing the legacy public SDK constant.
|
||||
export const COPILOT_RUNTIME_INTEGRATION_ID = "copilot-developer-cli";
|
||||
@@ -1,7 +1,8 @@
|
||||
// Github Copilot tests cover stream plugin behavior.
|
||||
import type { Context } from "openclaw/plugin-sdk/llm";
|
||||
import { buildCopilotIdeHeaders, COPILOT_INTEGRATION_ID } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { buildCopilotIdeHeaders } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { COPILOT_RUNTIME_INTEGRATION_ID } from "./runtime-identity.js";
|
||||
import { wrapCopilotAnthropicStream, wrapCopilotProviderStream } from "./stream.js";
|
||||
|
||||
function requireStreamFn(streamFn: ReturnType<typeof wrapCopilotProviderStream>) {
|
||||
@@ -30,7 +31,7 @@ function buildExpectedCopilotHeaders(
|
||||
): Record<string, string> {
|
||||
return {
|
||||
...buildCopilotIdeHeaders(),
|
||||
"Copilot-Integration-Id": COPILOT_INTEGRATION_ID,
|
||||
"Copilot-Integration-Id": COPILOT_RUNTIME_INTEGRATION_ID,
|
||||
"Openai-Organization": "github-copilot",
|
||||
"x-initiator": initiator,
|
||||
...(hasImages ? { "Copilot-Vision-Request": "true" } : {}),
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
|
||||
import type { Context } from "openclaw/plugin-sdk/llm";
|
||||
import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { buildCopilotIdeHeaders, COPILOT_INTEGRATION_ID } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { buildCopilotIdeHeaders } from "openclaw/plugin-sdk/provider-auth";
|
||||
import {
|
||||
applyAnthropicEphemeralCacheControlMarkers,
|
||||
streamWithPayloadPatch,
|
||||
} from "openclaw/plugin-sdk/provider-stream-shared";
|
||||
import { sanitizeCopilotReplayResponsePayload } from "./connection-bound-ids.js";
|
||||
import { stripCopilotAssistantThinkingMessages } from "./replay-policy.js";
|
||||
import { COPILOT_RUNTIME_INTEGRATION_ID } from "./runtime-identity.js";
|
||||
|
||||
type StreamOptions = Parameters<StreamFn>[2];
|
||||
|
||||
@@ -52,7 +53,7 @@ function buildCopilotDynamicHeaders(params: {
|
||||
}): Record<string, string> {
|
||||
return {
|
||||
...buildCopilotIdeHeaders(),
|
||||
"Copilot-Integration-Id": COPILOT_INTEGRATION_ID,
|
||||
"Copilot-Integration-Id": COPILOT_RUNTIME_INTEGRATION_ID,
|
||||
"Openai-Organization": "github-copilot",
|
||||
"x-initiator": inferCopilotInitiator(params.messages),
|
||||
...(params.hasImages ? { "Copilot-Vision-Request": "true" } : {}),
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { asDateTimestampMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import { COPILOT_INTEGRATION_ID } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { PUBLIC_GITHUB_COPILOT_DOMAIN } from "./domain.js";
|
||||
|
||||
export const COPILOT_TOKEN_CACHE_NAMESPACE = "token";
|
||||
export const COPILOT_TOKEN_CACHE_MAX_ENTRIES = 8;
|
||||
|
||||
export type CachedCopilotToken = {
|
||||
token: string;
|
||||
expiresAt: number;
|
||||
updatedAt: number;
|
||||
integrationId?: string;
|
||||
sourceCredentialFingerprint?: string;
|
||||
domain?: string;
|
||||
};
|
||||
|
||||
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);
|
||||
// Pre-domain cache entries were public-only. Keep public upgrades warm while
|
||||
// forcing tenant requests to exchange a tenant-scoped token.
|
||||
const cacheDomain = params.cache.domain ?? PUBLIC_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(): CachedCopilotToken | undefined;
|
||||
save(value: CachedCopilotToken): void;
|
||||
};
|
||||
|
||||
export function resolveCopilotTokenCache(params: {
|
||||
domain: string;
|
||||
sourceCredentialFingerprint: string;
|
||||
openCacheStore?: () => PluginStateSyncKeyedStore<CachedCopilotToken>;
|
||||
cachePath?: string;
|
||||
loadJsonFileImpl?: (path: string) => unknown;
|
||||
saveJsonFileImpl?: (path: string, value: CachedCopilotToken) => void;
|
||||
}): CopilotTokenCache {
|
||||
const usesExplicitCacheAdapter =
|
||||
params.cachePath !== undefined ||
|
||||
params.loadJsonFileImpl !== undefined ||
|
||||
params.saveJsonFileImpl !== undefined;
|
||||
if (usesExplicitCacheAdapter) {
|
||||
// Explicit file adapters are test/compat seams only. Runtime state is SQLite.
|
||||
const cachePath = params.cachePath?.trim() || "explicit-cache";
|
||||
const loadJsonFileFn = params.loadJsonFileImpl ?? (() => undefined);
|
||||
const saveJsonFileFn = params.saveJsonFileImpl ?? (() => undefined);
|
||||
return {
|
||||
path: cachePath,
|
||||
load: () => loadJsonFileFn(cachePath) as CachedCopilotToken | undefined,
|
||||
save: (value) => saveJsonFileFn(cachePath, value),
|
||||
};
|
||||
}
|
||||
|
||||
const store = params.openCacheStore?.();
|
||||
if (!store) {
|
||||
// Direct live/tests may call the provider helper without plugin registration.
|
||||
// They exchange normally but do not persist runtime state outside SQLite.
|
||||
return {
|
||||
path: "uncached",
|
||||
load: () => undefined,
|
||||
save: () => undefined,
|
||||
};
|
||||
}
|
||||
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()),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
// GitHub Copilot credential exchange and cache policy.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
parseStrictNonNegativeInteger,
|
||||
resolveExpiresAtMsFromEpochSeconds,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import {
|
||||
buildCopilotIdeHeaders,
|
||||
COPILOT_INTEGRATION_ID,
|
||||
deriveCopilotApiBaseUrlFromToken,
|
||||
} from "openclaw/plugin-sdk/provider-auth";
|
||||
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
|
||||
import { PUBLIC_GITHUB_COPILOT_DOMAIN, resolveGithubCopilotDomain } from "./domain.js";
|
||||
import {
|
||||
fingerprintCopilotSourceCredential,
|
||||
isCopilotTokenUsable,
|
||||
resolveCopilotTokenCache,
|
||||
type CachedCopilotToken,
|
||||
} from "./token-cache.js";
|
||||
import { CopilotTokenExchangeError } from "./token-exchange-error.js";
|
||||
export const DEFAULT_COPILOT_API_BASE_URL = "https://api.individual.githubcopilot.com";
|
||||
const COPILOT_TOKEN_EXCHANGE_TIMEOUT_MS = 30_000;
|
||||
let openConfiguredCacheStore: (() => PluginStateSyncKeyedStore<CachedCopilotToken>) | undefined;
|
||||
|
||||
/** Bind provider-scoped SQLite state when the bundled plugin registers. */
|
||||
export function configureCopilotTokenCacheStore(
|
||||
openCacheStore: () => PluginStateSyncKeyedStore<CachedCopilotToken>,
|
||||
): void {
|
||||
openConfiguredCacheStore = openCacheStore;
|
||||
}
|
||||
|
||||
function copilotTokenUrl(domain: string): string {
|
||||
return `https://api.${domain}/copilot_internal/v2/token`;
|
||||
}
|
||||
|
||||
function copilotApiBaseFallback(domain: string): string {
|
||||
return domain === PUBLIC_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 record = value as Record<string, unknown>;
|
||||
const { token: credential, expires_at: expiresAt } = record;
|
||||
if (typeof credential !== "string" || credential.trim().length === 0) {
|
||||
throw new Error("Copilot token response missing token");
|
||||
}
|
||||
if (
|
||||
expiresAt === undefined ||
|
||||
expiresAt === null ||
|
||||
(typeof expiresAt === "string" && expiresAt.trim().length === 0)
|
||||
) {
|
||||
throw new Error("Copilot token response missing expires_at");
|
||||
}
|
||||
const expiresAtMs = resolveCopilotTokenExpiresAtMs(expiresAt);
|
||||
if (expiresAtMs === undefined) {
|
||||
throw new Error("Copilot token response has invalid expires_at");
|
||||
}
|
||||
return { token: credential, expiresAt: expiresAtMs };
|
||||
}
|
||||
|
||||
async function cancelUnreadResponseBody(response: Response): Promise<void> {
|
||||
if (!response.bodyUsed) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveCopilotApiToken(params: {
|
||||
githubToken: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
fetchImpl?: typeof fetch;
|
||||
cachePath?: string;
|
||||
loadJsonFileImpl?: (path: string) => unknown;
|
||||
saveJsonFileImpl?: (path: string, value: CachedCopilotToken) => void;
|
||||
openCacheStore?: () => PluginStateSyncKeyedStore<CachedCopilotToken>;
|
||||
githubDomain?: string;
|
||||
config?: OpenClawConfig;
|
||||
}): Promise<{
|
||||
token: string;
|
||||
expiresAt: number;
|
||||
source: string;
|
||||
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 = resolveCopilotTokenCache({
|
||||
domain,
|
||||
sourceCredentialFingerprint,
|
||||
...(params.openCacheStore || openConfiguredCacheStore
|
||||
? { openCacheStore: params.openCacheStore ?? openConfiguredCacheStore }
|
||||
: {}),
|
||||
...(params.cachePath !== undefined ? { cachePath: params.cachePath } : {}),
|
||||
...(params.loadJsonFileImpl ? { loadJsonFileImpl: params.loadJsonFileImpl } : {}),
|
||||
...(params.saveJsonFileImpl ? { saveJsonFileImpl: params.saveJsonFileImpl } : {}),
|
||||
});
|
||||
const cached = cache.load();
|
||||
if (
|
||||
cached &&
|
||||
typeof cached.token === "string" &&
|
||||
typeof cached.expiresAt === "number" &&
|
||||
isCopilotTokenUsable({ cache: cached, domain, sourceCredentialFingerprint })
|
||||
) {
|
||||
const { token: credential } = cached;
|
||||
return {
|
||||
token: credential,
|
||||
expiresAt: cached.expiresAt,
|
||||
source: `cache:${cache.path}`,
|
||||
baseUrl: deriveCopilotApiBaseUrlFromToken(cached.token) ?? apiBaseFallback,
|
||||
};
|
||||
}
|
||||
|
||||
const fetchImpl = params.fetchImpl ?? fetch;
|
||||
const signal = AbortSignal.timeout(COPILOT_TOKEN_EXCHANGE_TIMEOUT_MS);
|
||||
let payload: ReturnType<typeof parseCopilotTokenResponse>;
|
||||
try {
|
||||
const response = await fetchImpl(tokenUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${params.githubToken}`,
|
||||
"Copilot-Integration-Id": COPILOT_INTEGRATION_ID,
|
||||
...buildCopilotIdeHeaders({ includeApiVersion: true }),
|
||||
},
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
await cancelUnreadResponseBody(response);
|
||||
throw new CopilotTokenExchangeError({ reason: "http_error", status: response.status });
|
||||
}
|
||||
payload = parseCopilotTokenResponse(
|
||||
await readProviderJsonResponse(response, "github-copilot.token"),
|
||||
);
|
||||
} catch (error) {
|
||||
if (signal.aborted && error === signal.reason) {
|
||||
throw new CopilotTokenExchangeError({
|
||||
reason: "timeout",
|
||||
timeoutMs: COPILOT_TOKEN_EXCHANGE_TIMEOUT_MS,
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const cachedPayload: CachedCopilotToken = {
|
||||
token: payload.token,
|
||||
expiresAt: payload.expiresAt,
|
||||
updatedAt: Date.now(),
|
||||
integrationId: COPILOT_INTEGRATION_ID,
|
||||
sourceCredentialFingerprint,
|
||||
domain,
|
||||
};
|
||||
cache.save(cachedPayload);
|
||||
const { token: credential } = cachedPayload;
|
||||
return {
|
||||
token: credential,
|
||||
expiresAt: cachedPayload.expiresAt,
|
||||
source: `fetched:${tokenUrl}`,
|
||||
baseUrl: deriveCopilotApiBaseUrlFromToken(cachedPayload.token) ?? apiBaseFallback,
|
||||
};
|
||||
}
|
||||
@@ -1695,7 +1695,7 @@ describe("compaction-safeguard recent-turn preservation", () => {
|
||||
const summaryCall = latestMockCallArg(mockSummarizeInStages) as {
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
expect(summaryCall.headers?.["Copilot-Integration-Id"]).toBe("vscode-chat");
|
||||
expect(summaryCall.headers?.["Copilot-Integration-Id"]).toBe("copilot-developer-cli");
|
||||
expect(summaryCall.headers?.["Editor-Plugin-Version"]).toBe("copilot-chat/0.35.0");
|
||||
expect(summaryCall.headers?.["Openai-Organization"]).toBe("github-copilot");
|
||||
expect(summaryCall.headers?.["User-Agent"]).toBe("GitHubCopilotChat/0.35.0");
|
||||
|
||||
@@ -13,6 +13,9 @@ export const COPILOT_EDITOR_PLUGIN_VERSION = "copilot-chat/0.35.0";
|
||||
export const COPILOT_GITHUB_API_VERSION = "2025-04-01";
|
||||
/** @deprecated GitHub Copilot provider-owned helper; do not use from third-party plugins. */
|
||||
export const COPILOT_INTEGRATION_ID = "vscode-chat";
|
||||
// Current GitHub fine-grained PATs are accepted by CAPI under the Copilot CLI
|
||||
// identity. Keep this private from the plugin SDK's legacy VS Code contract.
|
||||
export const COPILOT_RUNTIME_INTEGRATION_ID = "copilot-developer-cli";
|
||||
|
||||
/** @deprecated GitHub Copilot provider-owned helper; do not use from third-party plugins. */
|
||||
export function buildCopilotIdeHeaders(
|
||||
@@ -71,7 +74,7 @@ export function buildCopilotDynamicHeaders(params: {
|
||||
}): Record<string, string> {
|
||||
return {
|
||||
...buildCopilotIdeHeaders(),
|
||||
"Copilot-Integration-Id": COPILOT_INTEGRATION_ID,
|
||||
"Copilot-Integration-Id": COPILOT_RUNTIME_INTEGRATION_ID,
|
||||
"Openai-Organization": "github-copilot",
|
||||
"x-initiator": inferCopilotInitiator(params.messages),
|
||||
...(params.hasImages ? { "Copilot-Vision-Request": "true" } : {}),
|
||||
|
||||
@@ -229,7 +229,10 @@ vi.mock("./openrouter-model-capabilities.js", () => ({
|
||||
}));
|
||||
|
||||
import type { OpenClawConfig, OpenClawConfigInput } from "../../config/config.js";
|
||||
import { COPILOT_INTEGRATION_ID, buildCopilotIdeHeaders } from "../copilot-dynamic-headers.js";
|
||||
import {
|
||||
buildCopilotIdeHeaders,
|
||||
COPILOT_RUNTIME_INTEGRATION_ID,
|
||||
} from "../copilot-dynamic-headers.js";
|
||||
import { getModelProviderLocalService } from "../provider-local-service.js";
|
||||
import { getModelProviderRequestTransport } from "../provider-request-config.js";
|
||||
import { buildForwardCompatTemplate } from "./model.forward-compat.test-support.js";
|
||||
@@ -2440,7 +2443,7 @@ describe("resolveModel", () => {
|
||||
|
||||
expect(model.headers).toEqual({
|
||||
...buildCopilotIdeHeaders(),
|
||||
"Copilot-Integration-Id": COPILOT_INTEGRATION_ID,
|
||||
"Copilot-Integration-Id": COPILOT_RUNTIME_INTEGRATION_ID,
|
||||
"Openai-Organization": "github-copilot",
|
||||
});
|
||||
});
|
||||
@@ -2463,7 +2466,7 @@ describe("resolveModel", () => {
|
||||
|
||||
expect(model.headers).toEqual({
|
||||
...buildCopilotIdeHeaders(),
|
||||
"Copilot-Integration-Id": COPILOT_INTEGRATION_ID,
|
||||
"Copilot-Integration-Id": COPILOT_RUNTIME_INTEGRATION_ID,
|
||||
"Openai-Organization": "github-copilot",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,10 @@ import type {
|
||||
import { assertSecretInputResolved } from "../config/types.secrets.js";
|
||||
import type { PinnedDispatcherPolicy } from "../infra/net/ssrf.js";
|
||||
import type { Api } from "../llm/types.js";
|
||||
import { COPILOT_INTEGRATION_ID, buildCopilotIdeHeaders } from "./copilot-dynamic-headers.js";
|
||||
import {
|
||||
buildCopilotIdeHeaders,
|
||||
COPILOT_RUNTIME_INTEGRATION_ID,
|
||||
} from "./copilot-dynamic-headers.js";
|
||||
import type {
|
||||
ProviderRequestCapabilities,
|
||||
ProviderRequestCapability,
|
||||
@@ -427,7 +430,7 @@ function resolveProviderDefaultRequestHeaders(
|
||||
}
|
||||
return {
|
||||
...buildCopilotIdeHeaders(),
|
||||
"Copilot-Integration-Id": COPILOT_INTEGRATION_ID,
|
||||
"Copilot-Integration-Id": COPILOT_RUNTIME_INTEGRATION_ID,
|
||||
"Openai-Organization": "github-copilot",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -295,6 +295,10 @@ describe("GitHub Copilot OAuth model routing", () => {
|
||||
} as OAuthCredentials;
|
||||
}
|
||||
|
||||
it("exposes the durable GitHub token to provider runtime auth", () => {
|
||||
expect(githubCopilotOAuthProvider.getApiKey(credential({}))).toBe("refresh-token");
|
||||
});
|
||||
|
||||
it("drops github-copilot models for an unsupported persisted enterprise domain", () => {
|
||||
const result = githubCopilotOAuthProvider.modifyModels?.(
|
||||
models,
|
||||
|
||||
@@ -573,7 +573,9 @@ export const githubCopilotOAuthProvider: OAuthProviderInterface = {
|
||||
},
|
||||
|
||||
getApiKey(credentials: OAuthCredentials): string {
|
||||
return credentials.access;
|
||||
// The provider runtime now authenticates directly with the durable GitHub
|
||||
// credential; the short-lived Copilot access token is legacy profile state.
|
||||
return credentials.refresh;
|
||||
},
|
||||
|
||||
modifyModels(models: Model[], credentials: OAuthCredentials): Model[] {
|
||||
|
||||
@@ -170,7 +170,7 @@ vi.mock("../plugin-sdk/provider-auth.js", () => ({
|
||||
"Editor-Version": "vscode/1.107.0",
|
||||
"User-Agent": "GitHubCopilotChat/0.35.0",
|
||||
}),
|
||||
COPILOT_INTEGRATION_ID: "vscode-chat",
|
||||
COPILOT_INTEGRATION_ID: "copilot-developer-cli",
|
||||
}));
|
||||
|
||||
const imageTestFetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
|
||||
@@ -502,7 +502,7 @@ describe("describeImageWithModel", () => {
|
||||
expect(completionModel.baseUrl).toBe("https://api.githubcopilot.com");
|
||||
expect(options.apiKey).toBe(storedValue);
|
||||
expect(options.headers).toMatchObject({
|
||||
"Copilot-Integration-Id": "vscode-chat",
|
||||
"Copilot-Integration-Id": "copilot-developer-cli",
|
||||
"Copilot-Vision-Request": "true",
|
||||
"Editor-Version": "vscode/1.107.0",
|
||||
"User-Agent": "GitHubCopilotChat/0.35.0",
|
||||
|
||||
@@ -159,7 +159,7 @@ vi.mock("../plugin-sdk/provider-auth.js", () => ({
|
||||
"Editor-Version": "vscode/1.107.0",
|
||||
"User-Agent": "GitHubCopilotChat/0.35.0",
|
||||
}),
|
||||
COPILOT_INTEGRATION_ID: "vscode-chat",
|
||||
COPILOT_INTEGRATION_ID: "copilot-developer-cli",
|
||||
}));
|
||||
|
||||
const imageTestFetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
@@ -173,7 +173,7 @@ vi.mock("../plugin-sdk/provider-auth.js", () => ({
|
||||
"Editor-Version": "vscode/1.107.0",
|
||||
"User-Agent": "GitHubCopilotChat/0.35.0",
|
||||
}),
|
||||
COPILOT_INTEGRATION_ID: "vscode-chat",
|
||||
COPILOT_INTEGRATION_ID: "copilot-developer-cli",
|
||||
}));
|
||||
|
||||
const imageTestFetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// provider hook.
|
||||
import { clampPositiveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { COPILOT_RUNTIME_INTEGRATION_ID } from "../agents/copilot-dynamic-headers.js";
|
||||
import { isMinimaxVlmModel, minimaxUnderstandImage } from "../agents/minimax-vlm.js";
|
||||
import { requireApiKey, resolveApiKeyForProvider } from "../agents/model-auth.js";
|
||||
import { resolveProviderRequestCapabilities } from "../agents/provider-attribution.js";
|
||||
@@ -18,7 +19,7 @@ import {
|
||||
import { isSecretRef } from "../config/types.secrets.js";
|
||||
import { complete } from "../llm/stream.js";
|
||||
import type { AssistantMessage, Context, Model, ProviderStreamOptions } from "../llm/types.js";
|
||||
import { buildCopilotIdeHeaders, COPILOT_INTEGRATION_ID } from "../plugin-sdk/provider-auth.js";
|
||||
import { buildCopilotIdeHeaders } from "../plugin-sdk/provider-auth.js";
|
||||
import { getResolvedImageRuntimeContext, resolveImageRuntime } from "./image-model-runtime.js";
|
||||
import { normalizeMediaProviderId } from "./provider-id.js";
|
||||
import type {
|
||||
@@ -177,7 +178,7 @@ function buildImageRequestHeaders(model: Model): Record<string, string> | undefi
|
||||
}
|
||||
return {
|
||||
...buildCopilotIdeHeaders(),
|
||||
"Copilot-Integration-Id": COPILOT_INTEGRATION_ID,
|
||||
"Copilot-Integration-Id": COPILOT_RUNTIME_INTEGRATION_ID,
|
||||
"Openai-Organization": "github-copilot",
|
||||
"x-initiator": "user",
|
||||
"Copilot-Vision-Request": "true",
|
||||
|
||||
Reference in New Issue
Block a user