diff --git a/extensions/github-copilot/connection-bound-ids.live.test.ts b/extensions/github-copilot/connection-bound-ids.live.test.ts index f89cf5abb5ad..00b9c470a454 100644 --- a/extensions/github-copilot/connection-bound-ids.live.test.ts +++ b/extensions/github-copilot/connection-bound-ids.live.test.ts @@ -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 = { diff --git a/extensions/github-copilot/dynamic-models.ts b/extensions/github-copilot/dynamic-models.ts index b8020b5a09ab..a1ad6fadce36 100644 --- a/extensions/github-copilot/dynamic-models.ts +++ b/extensions/github-copilot/dynamic-models.ts @@ -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; } diff --git a/extensions/github-copilot/embeddings.test.ts b/extensions/github-copilot/embeddings.test.ts index 3d8d1c41dbf2..5d430271a108 100644 --- a/extensions/github-copilot/embeddings.test.ts +++ b/extensions/github-copilot/embeddings.test.ts @@ -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); diff --git a/extensions/github-copilot/embeddings.transport.test.ts b/extensions/github-copilot/embeddings.transport.test.ts index 92d458516647..d56daecfa66e 100644 --- a/extensions/github-copilot/embeddings.transport.test.ts +++ b/extensions/github-copilot/embeddings.transport.test.ts @@ -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 () => { diff --git a/extensions/github-copilot/embeddings.ts b/extensions/github-copilot/embeddings.ts index 11ecdd7bfcdb..1736a31fde55 100644 --- a/extensions/github-copilot/embeddings.ts +++ b/extensions/github-copilot/embeddings.ts @@ -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 = { "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; 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; }> { - 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, diff --git a/extensions/github-copilot/index.test.ts b/extensions/github-copilot/index.test.ts index 4e37a30ed19f..2dc2489057f5 100644 --- a/extensions/github-copilot/index.test.ts +++ b/extensions/github-copilot/index.test.ts @@ -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(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; resolveDynamicModel: NonNullable; preferRuntimeResolvedModel: NonNullable; + prepareRuntimeAuth: NonNullable; resolveThinkingProfile: NonNullable; }; type GithubCopilotTestModelCatalogProvider = { @@ -178,31 +174,31 @@ function registerProviderWithPluginConfig(pluginConfig: Record) } 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", diff --git a/extensions/github-copilot/index.ts b/extensions/github-copilot/index.ts index 49b7cff243e6..e9cc8d7d8232 100644 --- a/extensions/github-copilot/index.ts +++ b/extensions/github-copilot/index.ts @@ -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 | undefined; - const openTokenCacheStore = () => { - tokenCacheStore ??= api.runtime.state.openSyncKeyedStore({ - 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(), diff --git a/extensions/github-copilot/login.ts b/extensions/github-copilot/login.ts index 82e68653c44c..48e957ae9119 100644 --- a/extensions/github-copilot/login.ts +++ b/extensions/github-copilot/login.ts @@ -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.. diff --git a/extensions/github-copilot/models.test.ts b/extensions/github-copilot/models.test.ts index 1ad6f8605008..553330ab51e0 100644 --- a/extensions/github-copilot/models.test.ts +++ b/extensions/github-copilot/models.test.ts @@ -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)["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(); - 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; - 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({ + 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(); + } }); }); diff --git a/extensions/github-copilot/models.ts b/extensions/github-copilot/models.ts index f181c455c035..ea0610f50ddd 100644 --- a/extensions/github-copilot/models.ts +++ b/extensions/github-copilot/models.ts @@ -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, }); diff --git a/extensions/github-copilot/register.runtime.ts b/extensions/github-copilot/register.runtime.ts index bc9ce3d80f95..c3b269d22f2d 100644 --- a/extensions/github-copilot/register.runtime.ts +++ b/extensions/github-copilot/register.runtime.ts @@ -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, diff --git a/extensions/github-copilot/token-exchange-error.ts b/extensions/github-copilot/runtime-auth-error.ts similarity index 56% rename from extensions/github-copilot/token-exchange-error.ts rename to extensions/github-copilot/runtime-auth-error.ts index ce17e2e9da50..6b21c9c63028 100644 --- a/extensions/github-copilot/token-exchange-error.ts +++ b/extensions/github-copilot/runtime-auth-error.ts @@ -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; diff --git a/extensions/github-copilot/runtime-auth.ts b/extensions/github-copilot/runtime-auth.ts new file mode 100644 index 000000000000..d1486b096c0d --- /dev/null +++ b/extensions/github-copilot/runtime-auth.ts @@ -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 { + 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; + } +} diff --git a/extensions/github-copilot/runtime-identity.ts b/extensions/github-copilot/runtime-identity.ts new file mode 100644 index 000000000000..3363c056f41a --- /dev/null +++ b/extensions/github-copilot/runtime-identity.ts @@ -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"; diff --git a/extensions/github-copilot/stream.test.ts b/extensions/github-copilot/stream.test.ts index ebe317d4da42..25e467ba114e 100644 --- a/extensions/github-copilot/stream.test.ts +++ b/extensions/github-copilot/stream.test.ts @@ -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) { @@ -30,7 +31,7 @@ function buildExpectedCopilotHeaders( ): Record { 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" } : {}), diff --git a/extensions/github-copilot/stream.ts b/extensions/github-copilot/stream.ts index 9681e2f7a85b..a758aaf64d6f 100644 --- a/extensions/github-copilot/stream.ts +++ b/extensions/github-copilot/stream.ts @@ -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[2]; @@ -52,7 +53,7 @@ function buildCopilotDynamicHeaders(params: { }): Record { 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" } : {}), diff --git a/extensions/github-copilot/token-cache.ts b/extensions/github-copilot/token-cache.ts deleted file mode 100644 index 14c195bb5f6d..000000000000 --- a/extensions/github-copilot/token-cache.ts +++ /dev/null @@ -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; - 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()), - }), - }; -} diff --git a/extensions/github-copilot/token.ts b/extensions/github-copilot/token.ts deleted file mode 100644 index 206a5c5b854c..000000000000 --- a/extensions/github-copilot/token.ts +++ /dev/null @@ -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) | undefined; - -/** Bind provider-scoped SQLite state when the bundled plugin registers. */ -export function configureCopilotTokenCacheStore( - openCacheStore: () => PluginStateSyncKeyedStore, -): 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; - 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 { - 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; - 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; - 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, - }; -} diff --git a/src/agents/agent-hooks/compaction-safeguard.test.ts b/src/agents/agent-hooks/compaction-safeguard.test.ts index 20eb5f03eaab..9699aaa33f38 100644 --- a/src/agents/agent-hooks/compaction-safeguard.test.ts +++ b/src/agents/agent-hooks/compaction-safeguard.test.ts @@ -1695,7 +1695,7 @@ describe("compaction-safeguard recent-turn preservation", () => { const summaryCall = latestMockCallArg(mockSummarizeInStages) as { headers?: Record; }; - 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"); diff --git a/src/agents/copilot-dynamic-headers.ts b/src/agents/copilot-dynamic-headers.ts index 2cf7091e3220..c23753215dca 100644 --- a/src/agents/copilot-dynamic-headers.ts +++ b/src/agents/copilot-dynamic-headers.ts @@ -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 { 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" } : {}), diff --git a/src/agents/embedded-agent-runner/model.test.ts b/src/agents/embedded-agent-runner/model.test.ts index 7efda181efa5..e663daeee987 100644 --- a/src/agents/embedded-agent-runner/model.test.ts +++ b/src/agents/embedded-agent-runner/model.test.ts @@ -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", }); }); diff --git a/src/agents/provider-request-config.ts b/src/agents/provider-request-config.ts index 40664cc101e2..f33342a10ddf 100644 --- a/src/agents/provider-request-config.ts +++ b/src/agents/provider-request-config.ts @@ -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", }; } diff --git a/src/llm/utils/oauth/github-copilot.test.ts b/src/llm/utils/oauth/github-copilot.test.ts index 342de30aeaed..35c0864f1fa9 100644 --- a/src/llm/utils/oauth/github-copilot.test.ts +++ b/src/llm/utils/oauth/github-copilot.test.ts @@ -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, diff --git a/src/llm/utils/oauth/github-copilot.ts b/src/llm/utils/oauth/github-copilot.ts index 6ccefcd04763..f022eca1a318 100644 --- a/src/llm/utils/oauth/github-copilot.ts +++ b/src/llm/utils/oauth/github-copilot.ts @@ -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[] { diff --git a/src/media-understanding/image.runtime-profile.test.ts b/src/media-understanding/image.runtime-profile.test.ts index 7723b7ba1313..512de0f3c2bc 100644 --- a/src/media-understanding/image.runtime-profile.test.ts +++ b/src/media-understanding/image.runtime-profile.test.ts @@ -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", diff --git a/src/media-understanding/image.runtime-timeout.test.ts b/src/media-understanding/image.runtime-timeout.test.ts index f8307d8e9709..4e494f4fb134 100644 --- a/src/media-understanding/image.runtime-timeout.test.ts +++ b/src/media-understanding/image.runtime-timeout.test.ts @@ -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()); diff --git a/src/media-understanding/image.test.ts b/src/media-understanding/image.test.ts index 1feacfc39850..262de6b05e75 100644 --- a/src/media-understanding/image.test.ts +++ b/src/media-understanding/image.test.ts @@ -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()); diff --git a/src/media-understanding/image.ts b/src/media-understanding/image.ts index 17338f41c249..c3d77fc6ed09 100644 --- a/src/media-understanding/image.ts +++ b/src/media-understanding/image.ts @@ -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 | 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",