diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index 6a3ef5c06d..b220acaa3b 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -1,3 +1,4 @@ +import { APIError } from '@anthropic-ai/sdk' import { afterEach, beforeEach, expect, mock, test } from 'bun:test' import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../test/sharedMutationLock.js' import { asMockFetch } from '../../test/typedMocks.js' @@ -5,6 +6,8 @@ import { _clearRegistryForTesting, ensureIntegrationsLoaded, registerGateway } f import { applyProviderFlag } from '../../utils/providerFlag.ts' import { applyProviderProfileToProcessEnv } from '../../utils/providerProfiles.ts' import { createOpenAIShimClient } from './openaiShim.ts' +import * as realCodexShim from './codexShim.js' +import * as realGithubModelsCredentials from '../../utils/githubModelsCredentials.js' type FetchType = typeof globalThis.fetch @@ -98,6 +101,12 @@ function makeStreamChunks(chunks: unknown[]): string[] { ] } +function importFreshOpenAIShim( + cacheKey: string, +): Promise { + return import(`./openaiShim.ts?${cacheKey}`) +} + function makeChatCompletionResponse(model: string): Response { return new Response( JSON.stringify({ @@ -7691,3 +7700,479 @@ test('renders tool_reference blocks as text on the chat/completions path', async expect(content).toContain('mcp__example__memory_search') expect(content).toContain('mcp__example__memory_store') }) + +function makeCodexSseResponse(responseData: Record): Response { + const data = JSON.stringify(responseData) + return makeSseResponse([`event: response.completed\ndata: ${data}\n\n`]) +} + +test('GitHub Copilot 401 chat_completions retries with refreshed token', async () => { + const realModule = realGithubModelsCredentials + try { + const refreshSpy = mock(async () => { + process.env.GITHUB_TOKEN = 'refreshed-token' + process.env.OPENAI_API_KEY = 'refreshed-token' + return true + }) + + mock.module('../../utils/githubModelsCredentials.js', () => ({ + ...realModule, + refreshCopilotTokenOn401: refreshSpy, + })) + + process.env.CLAUDE_CODE_USE_GITHUB = '1' + process.env.OPENAI_BASE_URL = 'https://api.githubcopilot.com' + process.env.OPENAI_API_KEY = 'initial-token' + process.env.GITHUB_TOKEN = 'initial-token' + + let fetchCallCount = 0 + let firstAuth: string | undefined + let secondAuth: string | undefined + + globalThis.fetch = ((_input, init) => { + fetchCallCount++ + const headers = init?.headers as Record | undefined + const auth = headers?.Authorization + + if (fetchCallCount === 1) { + firstAuth = auth + return Promise.resolve( + new Response( + JSON.stringify({ error: { message: 'token expired' } }), + { status: 401, headers: { 'Content-Type': 'application/json' } }, + ), + ) + } + + if (fetchCallCount === 2) { + secondAuth = auth + return Promise.resolve(makeChatCompletionResponse('gpt-4')) + } + + throw new Error(`unexpected fetch call #${fetchCallCount}`) + }) as unknown as typeof globalThis.fetch + + const { createOpenAIShimClient: createClient } = + await importFreshOpenAIShim('copilot-401-retry') + + const client = createClient({}) as OpenAIShimClient + + const response = await client.beta.messages.create({ + model: 'gpt-4', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 32, + stream: false, + }) + + expect(refreshSpy).toHaveBeenCalledTimes(1) + expect(process.env.GITHUB_TOKEN).toBe('refreshed-token') + expect(process.env.OPENAI_API_KEY).toBe('refreshed-token') + expect(fetchCallCount).toBe(2) + expect(firstAuth).toBe('Bearer initial-token') + expect(secondAuth).toBe('Bearer refreshed-token') + expect(response).toBeDefined() + } finally { + mock.module('../../utils/githubModelsCredentials.js', () => realModule) + } +}) + +test('GitHub Copilot 401 codex_responses retries with refreshed token', async () => { + const realGithubModule = realGithubModelsCredentials + const realCodexModule = realCodexShim + try { + const refreshSpy = mock(async () => { + process.env.GITHUB_TOKEN = 'refreshed-token' + process.env.OPENAI_API_KEY = 'refreshed-token' + return true + }) + + mock.module('../../utils/githubModelsCredentials.js', () => ({ + ...realGithubModule, + refreshCopilotTokenOn401: refreshSpy, + })) + + let codexCallCount = 0 + let firstAuth: string | undefined + let secondAuth: string | undefined + + mock.module('./codexShim.js', () => ({ + ...realCodexModule, + performCodexRequest: mock(async (opts: { credentials: { apiKey: string } }) => { + codexCallCount++ + const apiKey = opts.credentials?.apiKey + + if (codexCallCount === 1) { + firstAuth = apiKey + throw APIError.generate(401, undefined, 'token expired', new Headers()) + } + + if (codexCallCount === 2) { + secondAuth = apiKey + return makeCodexSseResponse({ + response: { + id: 'resp_test', + output: [{ type: 'message', content: [{ type: 'output_text', text: 'ok' }] }], + model: 'gpt-5', + usage: { input_tokens: 10, output_tokens: 5 }, + }, + }) + } + + throw new Error(`unexpected codex call #${codexCallCount}`) + }), + })) + + process.env.CLAUDE_CODE_USE_GITHUB = '1' + process.env.OPENAI_BASE_URL = 'https://api.githubcopilot.com' + process.env.OPENAI_API_KEY = 'initial-token' + process.env.GITHUB_TOKEN = 'initial-token' + + const { createOpenAIShimClient: createClient } = + await importFreshOpenAIShim('copilot-401-retry-codex') + + const client = createClient({}) as OpenAIShimClient + + const response = await client.beta.messages.create({ + model: 'gpt-5', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 32, + stream: false, + }) + + expect(refreshSpy).toHaveBeenCalledTimes(1) + expect(process.env.GITHUB_TOKEN).toBe('refreshed-token') + expect(process.env.OPENAI_API_KEY).toBe('refreshed-token') + expect(codexCallCount).toBe(2) + expect(firstAuth).toBe('initial-token') + expect(secondAuth).toBe('refreshed-token') + expect(response).toBeDefined() + expect((response as Record).content).toBeDefined() + } finally { + mock.module('../../utils/githubModelsCredentials.js', () => realGithubModule) + mock.module('./codexShim.js', () => realCodexModule) + } +}) + +test('GitHub Copilot 401 with credential pool uses refreshed token not pool key', async () => { + const realGithubModule = realGithubModelsCredentials + try { + const refreshSpy = mock(async () => { + process.env.GITHUB_TOKEN = 'refreshed-token' + process.env.OPENAI_API_KEY = 'refreshed-token' + return true + }) + + mock.module('../../utils/githubModelsCredentials.js', () => ({ + ...realGithubModule, + refreshCopilotTokenOn401: refreshSpy, + })) + + process.env.CLAUDE_CODE_USE_GITHUB = '1' + process.env.OPENAI_BASE_URL = 'https://api.githubcopilot.com' + delete process.env.OPENAI_API_KEY + process.env.OPENAI_API_KEYS = 'initial-token,second-key' + process.env.GITHUB_TOKEN = 'initial-token' + + let fetchCallCount = 0 + let usedAuthHeaders: string[] = [] + + globalThis.fetch = ((_input, init) => { + fetchCallCount++ + const headers = init?.headers as Record | undefined + usedAuthHeaders.push(headers?.Authorization ?? '') + + if (fetchCallCount === 1) { + return Promise.resolve( + new Response( + JSON.stringify({ error: { message: 'token expired' } }), + { status: 401, headers: { 'Content-Type': 'application/json' } }, + ), + ) + } + + return Promise.resolve(makeChatCompletionResponse('gpt-4')) + }) as unknown as typeof globalThis.fetch + + const { createOpenAIShimClient: createClient } = + await importFreshOpenAIShim('copilot-401-pool') + + const client = createClient({}) as OpenAIShimClient + + const response = await client.beta.messages.create({ + model: 'gpt-4', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 32, + stream: false, + }) + + expect(refreshSpy).toHaveBeenCalledTimes(1) + expect(fetchCallCount).toBe(2) + expect(usedAuthHeaders[0]).toBe('Bearer initial-token') + expect(usedAuthHeaders[1]).toBe('Bearer refreshed-token') + expect(response).toBeDefined() + } finally { + mock.module('../../utils/githubModelsCredentials.js', () => realGithubModule) + } +}) + +test('GitHub Copilot 401 with "token has expired" triggers refresh', async () => { + const realGithubModule = realGithubModelsCredentials + try { + const refreshSpy = mock(async () => { + process.env.GITHUB_TOKEN = 'refreshed-token' + process.env.OPENAI_API_KEY = 'refreshed-token' + return true + }) + + mock.module('../../utils/githubModelsCredentials.js', () => ({ + ...realGithubModule, + refreshCopilotTokenOn401: refreshSpy, + })) + + process.env.CLAUDE_CODE_USE_GITHUB = '1' + process.env.OPENAI_BASE_URL = 'https://api.githubcopilot.com' + process.env.OPENAI_API_KEY = 'initial-token' + process.env.GITHUB_TOKEN = 'initial-token' + + let fetchCallCount = 0 + + globalThis.fetch = ((_input, init) => { + fetchCallCount++ + + if (fetchCallCount === 1) { + return Promise.resolve( + new Response( + JSON.stringify({ error: { message: 'token has expired' } }), + { status: 401, headers: { 'Content-Type': 'application/json' } }, + ), + ) + } + + return Promise.resolve(makeChatCompletionResponse('gpt-4')) + }) as unknown as typeof globalThis.fetch + + const { createOpenAIShimClient: createClient } = + await importFreshOpenAIShim('copilot-401-has-expired') + + const client = createClient({}) as OpenAIShimClient + + const response = await client.beta.messages.create({ + model: 'gpt-4', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 32, + stream: false, + }) + + expect(refreshSpy).toHaveBeenCalledTimes(1) + expect(fetchCallCount).toBe(2) + expect(response).toBeDefined() + } finally { + mock.module('../../utils/githubModelsCredentials.js', () => realGithubModule) + } +}) + +test('GitHub Copilot 401 without expired-token message does not trigger refresh', async () => { + const realGithubModule = realGithubModelsCredentials + try { + const refreshSpy = mock(async () => true) + + mock.module('../../utils/githubModelsCredentials.js', () => ({ + ...realGithubModule, + refreshCopilotTokenOn401: refreshSpy, + })) + + process.env.CLAUDE_CODE_USE_GITHUB = '1' + process.env.OPENAI_BASE_URL = 'https://api.githubcopilot.com' + process.env.OPENAI_API_KEY = 'initial-token' + process.env.GITHUB_TOKEN = 'initial-token' + + let fetchCallCount = 0 + + globalThis.fetch = ((_input) => { + fetchCallCount++ + return Promise.resolve( + new Response( + JSON.stringify({ error: { message: 'invalid token' } }), + { status: 401, headers: { 'Content-Type': 'application/json' } }, + ), + ) + }) as unknown as typeof globalThis.fetch + + const { createOpenAIShimClient: createClient } = + await importFreshOpenAIShim('copilot-401-no-refresh') + + const client = createClient({}) as OpenAIShimClient + + await expect( + client.beta.messages.create({ + model: 'gpt-4', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 32, + stream: false, + }), + ).rejects.toThrow() + + expect(refreshSpy).toHaveBeenCalledTimes(0) + expect(fetchCallCount).toBe(1) + } finally { + mock.module('../../utils/githubModelsCredentials.js', () => realGithubModule) + } +}) + +test('GitHub Copilot 401 refresh returning same token does not update auth', async () => { + const realGithubModule = realGithubModelsCredentials + try { + const refreshSpy = mock(async () => { + process.env.GITHUB_TOKEN = 'initial-token' + process.env.OPENAI_API_KEY = 'initial-token' + return true + }) + + mock.module('../../utils/githubModelsCredentials.js', () => ({ + ...realGithubModule, + refreshCopilotTokenOn401: refreshSpy, + })) + + process.env.CLAUDE_CODE_USE_GITHUB = '1' + process.env.OPENAI_BASE_URL = 'https://api.githubcopilot.com' + process.env.OPENAI_API_KEY = 'initial-token' + process.env.GITHUB_TOKEN = 'initial-token' + + let fetchCallCount = 0 + let usedAuthHeaders: string[] = [] + + globalThis.fetch = ((_input, init) => { + fetchCallCount++ + const headers = init?.headers as Record | undefined + usedAuthHeaders.push(headers?.Authorization ?? '') + + return Promise.resolve( + new Response( + JSON.stringify({ error: { message: 'token expired' } }), + { status: 401, headers: { 'Content-Type': 'application/json' } }, + ), + ) + }) as unknown as typeof globalThis.fetch + + const { createOpenAIShimClient: createClient } = + await importFreshOpenAIShim('copilot-401-same-token') + + const client = createClient({}) as OpenAIShimClient + + await expect( + client.beta.messages.create({ + model: 'gpt-4', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 32, + stream: false, + }), + ).rejects.toThrow() + + expect(refreshSpy).toHaveBeenCalledTimes(1) + expect(fetchCallCount).toBeGreaterThanOrEqual(2) + expect(usedAuthHeaders.every(h => h === 'Bearer initial-token')).toBe(true) + } finally { + mock.module('../../utils/githubModelsCredentials.js', () => realGithubModule) + } +}) + +test('GitHub Copilot 401 codex_responses with providerOverride does not trigger refresh', async () => { + const realGithubModule = realGithubModelsCredentials + try { + const refreshSpy = mock(async () => { + process.env.GITHUB_TOKEN = 'refreshed-token' + process.env.OPENAI_API_KEY = 'refreshed-token' + return true + }) + + mock.module('../../utils/githubModelsCredentials.js', () => ({ + ...realGithubModule, + refreshCopilotTokenOn401: refreshSpy, + })) + + process.env.CLAUDE_CODE_USE_GITHUB = '1' + process.env.OPENAI_BASE_URL = 'https://api.githubcopilot.com' + process.env.OPENAI_API_KEY = 'stored-copilot-token' + process.env.GITHUB_TOKEN = 'stored-copilot-token' + + // Mock fetch so performCodexRequest gets a 401 response (no codexShim mock needed) + globalThis.fetch = (() => + Promise.resolve( + new Response( + JSON.stringify({ error: { message: 'token expired' } }), + { status: 401, headers: { 'Content-Type': 'application/json' } }, + ), + )) as unknown as typeof globalThis.fetch + + const { createOpenAIShimClient: createClient } = + await importFreshOpenAIShim('copilot-401-override-codex') + + // providerOverride.apiKey differs from OPENAI_API_KEY → credential source gate blocks refresh + const client = createClient({ + providerOverride: { model: 'gpt-5', baseURL: 'https://api.githubcopilot.com', apiKey: 'override-token' }, + }) as OpenAIShimClient + + await expect( + client.beta.messages.create({ + model: 'gpt-5', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 32, + stream: false, + }), + ).rejects.toThrow() + + expect(refreshSpy).toHaveBeenCalledTimes(0) + } finally { + mock.module('../../utils/githubModelsCredentials.js', () => realGithubModule) + } +}) + +test('GitHub Copilot 401 chat_completions with providerOverride does not trigger refresh', async () => { + const realGithubModule = realGithubModelsCredentials + try { + const refreshSpy = mock(async () => { + process.env.GITHUB_TOKEN = 'refreshed-token' + process.env.OPENAI_API_KEY = 'refreshed-token' + return true + }) + + mock.module('../../utils/githubModelsCredentials.js', () => ({ + ...realGithubModule, + refreshCopilotTokenOn401: refreshSpy, + })) + + process.env.CLAUDE_CODE_USE_GITHUB = '1' + process.env.OPENAI_BASE_URL = 'https://api.githubcopilot.com' + process.env.OPENAI_API_KEY = 'stored-copilot-token' + process.env.GITHUB_TOKEN = 'stored-copilot-token' + + globalThis.fetch = (() => + Promise.resolve( + new Response( + JSON.stringify({ error: { message: 'token expired' } }), + { status: 401, headers: { 'Content-Type': 'application/json' } }, + ), + )) as unknown as typeof globalThis.fetch + + const { createOpenAIShimClient: createClient } = + await importFreshOpenAIShim('copilot-401-override-chat') + + // providerOverride.apiKey differs from OPENAI_API_KEY → credential source gate blocks refresh + const client = createClient({ + providerOverride: { model: 'gpt-4', baseURL: 'https://api.githubcopilot.com', apiKey: 'override-token' }, + }) as OpenAIShimClient + + await expect( + client.beta.messages.create({ + model: 'gpt-4', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 32, + stream: false, + }), + ).rejects.toThrow() + + expect(refreshSpy).toHaveBeenCalledTimes(0) + } finally { + mock.module('../../utils/githubModelsCredentials.js', () => realGithubModule) + } +}) diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 7202097c69..d22e461636 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -40,7 +40,10 @@ import { logForDebugging } from '../../utils/debug.js' import { isBareMode, isEnvTruthy } from '../../utils/envUtils.js' import { resolveGeminiCredential } from '../../utils/geminiAuth.js' import { hydrateGeminiAccessTokenFromSecureStorage } from '../../utils/geminiCredentials.js' -import { hydrateGithubModelsTokenFromSecureStorage } from '../../utils/githubModelsCredentials.js' +import { + hydrateGithubModelsTokenFromSecureStorage, + refreshCopilotTokenOn401, +} from '../../utils/githubModelsCredentials.js' import { resolveXaiAccessToken } from '../../utils/xaiCredentials.js' import { resolveOpenAIShimRuntimeContext } from '../../integrations/runtimeMetadata.js' import { @@ -115,6 +118,11 @@ const COPILOT_HEADERS: Record = { 'Copilot-Integration-Id': 'vscode-chat', } +function isCopilotTokenExpiredError(text: string): boolean { + const lower = text.toLowerCase() + return lower.includes('token expired') || lower.includes('token has expired') +} + function isGithubModelsMode(): boolean { return isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB) } @@ -2340,30 +2348,59 @@ class OpenAIShimMessages { ): Promise { const githubEndpointType = getGithubEndpointType(request.baseUrl) const isGithubMode = isGithubModelsMode() - const isGithubWithCodexTransport = isGithubMode && request.transport === 'codex_responses' + const isGithubCopilotEndpoint = isGithubMode && (githubEndpointType === 'copilot' || githubEndpointType === 'ghe') + const isGithubWithCodexTransport = isGithubCopilotEndpoint && request.transport === 'codex_responses' if (isGithubWithCodexTransport) { - const apiKey = this.providerOverride?.apiKey ?? process.env.OPENAI_API_KEY ?? '' - if (!apiKey) { - throw new Error( - 'GitHub Copilot auth is required. Run /onboard-github to sign in.', - ) - } + let didRefreshCopilotCodexToken = false + let refreshedCopilotCodexToken: string | undefined + for (let attempt = 0; attempt < 2; attempt++) { + const apiKey = refreshedCopilotCodexToken ?? this.providerOverride?.apiKey ?? process.env.OPENAI_API_KEY ?? '' + if (!apiKey) { + throw new Error( + 'GitHub Copilot auth is required. Run /onboard-github to sign in.', + ) + } - return performCodexRequest({ - request, - credentials: { - apiKey, - source: 'env', - }, - params, - defaultHeaders: { - ...this.defaultHeaders, - ...filterAnthropicHeaders(options?.headers), - ...COPILOT_HEADERS, - }, - signal: options?.signal, - }) + try { + return await performCodexRequest({ + request, + credentials: { + apiKey, + source: 'env', + }, + params, + defaultHeaders: { + ...this.defaultHeaders, + ...filterAnthropicHeaders(options?.headers), + ...COPILOT_HEADERS, + }, + signal: options?.signal, + }) + } catch (error) { + if ( + !didRefreshCopilotCodexToken && + error instanceof APIError && + error.status === 401 + ) { + if ( + apiKey === (process.env.OPENAI_API_KEY ?? '') && + isCopilotTokenExpiredError(error.message) + ) { + didRefreshCopilotCodexToken = true + const refreshed = await refreshCopilotTokenOn401() + if (refreshed) { + const newApiKey = process.env.OPENAI_API_KEY?.trim() || '' + if (newApiKey && newApiKey !== apiKey) { + refreshedCopilotCodexToken = newApiKey + continue + } + } + } + } + throw error + } + } } if (request.transport === 'codex_responses' && !isGithubMode) { @@ -3001,6 +3038,7 @@ class OpenAIShimMessages { const headers: Record = { ...baseHeaders } const authValue = explicitCustomAuthHeaderValue || + refreshedCopilotToken || credentialLease?.value || (credentialPool ? '' : singleAuthValue) @@ -3100,6 +3138,8 @@ class OpenAIShimMessages { let requestUrl = buildRequestUrl(activeBaseUrl) const attemptedLocalBaseUrls = new Set([activeBaseUrl]) let didRetryWithoutTools = false + let didRefreshCopilotToken = false + let refreshedCopilotToken: string | undefined const promoteNextLocalBaseUrl = ( reason: 'endpoint_not_found' | 'localhost_resolution_failed', @@ -3444,6 +3484,32 @@ class OpenAIShimMessages { hasImages: bodyContainsImages(), }) + // GitHub Copilot 401 with expired token: force-refresh and retry once. + // Only applies to the Copilot endpoint, not GitHub Models API or custom + // routes, and only when the failing credential is the stored Copilot + // token (not a provider override, route credential, or custom auth). + // The refreshed token is stored in refreshedCopilotToken so the next + // iteration's buildHeadersForAttempt picks it up instead of the stale + // singleAuthValue captured before the loop. + if (isGithubCopilot && response.status === 401 && !didRefreshCopilotToken) { + if (isCopilotTokenExpiredError(errorBody)) { + const oldToken = headers.Authorization?.replace(/^Bearer\s+/i, '') || '' + if (oldToken && oldToken === (process.env.OPENAI_API_KEY ?? '')) { + didRefreshCopilotToken = true + const refreshed = await refreshCopilotTokenOn401() + if (refreshed) { + const newApiKey = process.env.OPENAI_API_KEY?.trim() || '' + if (newApiKey && newApiKey !== oldToken) { + refreshedCopilotToken = newApiKey + } + if (attempt < maxAttempts - 1) { + continue + } + } + } + } + } + const credentialFailureKind = failure.category === 'auth_invalid' && !failure.retryable ? 'auth' diff --git a/src/utils/githubModelsCredentials.refresh.test.ts b/src/utils/githubModelsCredentials.refresh.test.ts index 2087ff0ff5..12dbff7e72 100644 --- a/src/utils/githubModelsCredentials.refresh.test.ts +++ b/src/utils/githubModelsCredentials.refresh.test.ts @@ -138,3 +138,285 @@ describe('refreshGithubModelsTokenIfNeeded', () => { ) }) }) + +type GithubModelsCredentialsModule = + typeof import('./githubModelsCredentials.js') + +function importFreshGithubModelsCredentials( + cacheKey: string, +): Promise { + return import( + `./githubModelsCredentials.js?${cacheKey}` + ) as Promise +} + +const STORED_OAUTH_TOKEN = 'gho_stored_oauth_token_xyz' +const STORED_COPILOT_TOKEN = 'stored-copilot-token-abc' +const FRESH_COPILOT_TOKEN = 'fresh-copilot-token-def' + +describe('refreshCopilotTokenOn401', () => { + const orig = { + GITHUB_COPILOT_KEY: process.env.GITHUB_COPILOT_KEY, + GITHUB_TOKEN: process.env.GITHUB_TOKEN, + GITHUB_ENTERPRISE_URL: process.env.GITHUB_ENTERPRISE_URL, + OPENAI_API_KEY: process.env.OPENAI_API_KEY, + CLAUDE_CODE_SIMPLE: process.env.CLAUDE_CODE_SIMPLE, + } + + beforeEach(async () => { + await acquireSharedMutationLock('utils/githubModelsCredentials.refresh.test.ts') + // Clear early-exit guards so each test starts from a clean baseline + // regardless of prior test failures or CI environment. + delete process.env.GITHUB_COPILOT_KEY + delete process.env.CLAUDE_CODE_SIMPLE + }) + + afterEach(() => { + try { + mock.restore() + for (const [k, v] of Object.entries(orig)) { + if (v === undefined) { + delete process.env[k as keyof typeof orig] + } else { + process.env[k as keyof typeof orig] = v + } + } + } finally { + releaseSharedMutationLock() + } + }) + + test('returns false in bare mode', async () => { + process.env.CLAUDE_CODE_SIMPLE = '1' + + const { refreshCopilotTokenOn401 } = + await importFreshGithubModelsCredentials('refresh=bare-mode') + const result = await refreshCopilotTokenOn401() + expect(result).toBe(false) + }) + + test('returns false when GITHUB_COPILOT_KEY is set', async () => { + process.env.GITHUB_COPILOT_KEY = 'direct-key' + + const { refreshCopilotTokenOn401 } = + await importFreshGithubModelsCredentials('refresh=direct-key-env') + const result = await refreshCopilotTokenOn401() + expect(result).toBe(false) + }) + + test('returns false when no stored credential blob exists', async () => { + process.env.OPENAI_API_KEY = STORED_COPILOT_TOKEN + + mock.module('./secureStorage/index.js', () => ({ + getSecureStorage: () => ({ + read: () => null, + }), + })) + + const { refreshCopilotTokenOn401 } = + await importFreshGithubModelsCredentials('refresh=no-blob') + const result = await refreshCopilotTokenOn401() + expect(result).toBe(false) + }) + + test('returns false when stored credential type is copilot_key', async () => { + process.env.OPENAI_API_KEY = STORED_COPILOT_TOKEN + + mock.module('./secureStorage/index.js', () => ({ + getSecureStorage: () => ({ + read: () => ({ + githubModels: { + accessToken: STORED_COPILOT_TOKEN, + credentialType: 'copilot_key' as const, + }, + }), + }), + })) + + const { refreshCopilotTokenOn401 } = + await importFreshGithubModelsCredentials('refresh=copilot-key-type') + const result = await refreshCopilotTokenOn401() + expect(result).toBe(false) + }) + + test('returns false when current credential does not match stored token', async () => { + process.env.OPENAI_API_KEY = 'some-other-token' + + mock.module('./secureStorage/index.js', () => ({ + getSecureStorage: () => ({ + read: () => ({ + githubModels: { + accessToken: STORED_COPILOT_TOKEN, + oauthAccessToken: STORED_OAUTH_TOKEN, + }, + }), + }), + })) + + const { refreshCopilotTokenOn401 } = + await importFreshGithubModelsCredentials('refresh=cred-mismatch') + const result = await refreshCopilotTokenOn401() + expect(result).toBe(false) + }) + + test('returns false when current credential is empty', async () => { + delete process.env.OPENAI_API_KEY + + mock.module('./secureStorage/index.js', () => ({ + getSecureStorage: () => ({ + read: () => ({ + githubModels: { + accessToken: STORED_COPILOT_TOKEN, + oauthAccessToken: STORED_OAUTH_TOKEN, + }, + }), + }), + })) + + const { refreshCopilotTokenOn401 } = + await importFreshGithubModelsCredentials('refresh=empty-cred') + const result = await refreshCopilotTokenOn401() + expect(result).toBe(false) + }) + + test('returns false when no OAuth token is stored', async () => { + process.env.OPENAI_API_KEY = STORED_COPILOT_TOKEN + + mock.module('./secureStorage/index.js', () => ({ + getSecureStorage: () => ({ + read: () => ({ + githubModels: { + accessToken: STORED_COPILOT_TOKEN, + }, + }), + }), + })) + + const { refreshCopilotTokenOn401 } = + await importFreshGithubModelsCredentials('refresh=no-oauth') + const result = await refreshCopilotTokenOn401() + expect(result).toBe(false) + }) + + test('returns false when exchange fails', async () => { + process.env.OPENAI_API_KEY = STORED_COPILOT_TOKEN + + mock.module('./secureStorage/index.js', () => ({ + getSecureStorage: () => ({ + read: () => ({ + githubModels: { + accessToken: STORED_COPILOT_TOKEN, + oauthAccessToken: STORED_OAUTH_TOKEN, + }, + }), + readAsync: () => + Promise.resolve({ + githubModels: { + accessToken: STORED_COPILOT_TOKEN, + oauthAccessToken: STORED_OAUTH_TOKEN, + }, + }), + update: () => ({ success: true }), + }), + })) + + mock.module('../services/github/deviceFlow.js', () => ({ + exchangeForCopilotToken: () => + Promise.reject(new Error('Exchange failed')), + })) + + const { refreshCopilotTokenOn401 } = + await importFreshGithubModelsCredentials('refresh=exchange-fail') + const result = await refreshCopilotTokenOn401() + expect(result).toBe(false) + }) + + test('returns false when save fails', async () => { + process.env.OPENAI_API_KEY = STORED_COPILOT_TOKEN + + mock.module('./secureStorage/index.js', () => ({ + getSecureStorage: () => ({ + read: () => ({ + githubModels: { + accessToken: STORED_COPILOT_TOKEN, + oauthAccessToken: STORED_OAUTH_TOKEN, + }, + }), + update: () => ({ success: false, warning: 'Storage full' }), + }), + })) + + mock.module('../services/github/deviceFlow.js', () => ({ + exchangeForCopilotToken: () => + Promise.resolve({ token: FRESH_COPILOT_TOKEN, expires_at: 9999999999, refresh_in: 1800, endpoints: { api: 'https://api.githubcopilot.com' } }), + })) + + const { refreshCopilotTokenOn401 } = + await importFreshGithubModelsCredentials('refresh=save-fail') + const result = await refreshCopilotTokenOn401() + expect(result).toBe(false) + }) + + test('successfully refreshes and updates env vars', async () => { + process.env.OPENAI_API_KEY = STORED_COPILOT_TOKEN + + mock.module('./secureStorage/index.js', () => ({ + getSecureStorage: () => ({ + read: () => ({ + githubModels: { + accessToken: STORED_COPILOT_TOKEN, + oauthAccessToken: STORED_OAUTH_TOKEN, + }, + }), + update: () => ({ success: true }), + }), + })) + + mock.module('../services/github/deviceFlow.js', () => ({ + exchangeForCopilotToken: () => + Promise.resolve({ token: FRESH_COPILOT_TOKEN, expires_at: 9999999999, refresh_in: 1800, endpoints: { api: 'https://api.githubcopilot.com' } }), + })) + + const { refreshCopilotTokenOn401 } = + await importFreshGithubModelsCredentials('refresh=success') + const result = await refreshCopilotTokenOn401() + expect(result).toBe(true) + expect(process.env.GITHUB_TOKEN).toBe(FRESH_COPILOT_TOKEN) + expect(process.env.OPENAI_API_KEY).toBe(FRESH_COPILOT_TOKEN) + }) + + test('supports GHE URL when GITHUB_ENTERPRISE_URL is set', async () => { + process.env.OPENAI_API_KEY = STORED_COPILOT_TOKEN + process.env.GITHUB_ENTERPRISE_URL = 'https://github.mycompany.com' + + let capturedGheUrl: string | undefined + mock.module('./secureStorage/index.js', () => ({ + getSecureStorage: () => ({ + read: () => ({ + githubModels: { + accessToken: STORED_COPILOT_TOKEN, + oauthAccessToken: STORED_OAUTH_TOKEN, + }, + }), + update: () => ({ success: true }), + }), + })) + + mock.module('../services/github/deviceFlow.js', () => ({ + exchangeForCopilotToken: ( + _token: string, + _fetchImpl: unknown, + gheUrl?: string, + ) => { + capturedGheUrl = gheUrl + return Promise.resolve({ token: FRESH_COPILOT_TOKEN, expires_at: 9999999999, refresh_in: 1800, endpoints: { api: 'https://github.mycompany.com/api/copilot' } }) + }, + })) + + const { refreshCopilotTokenOn401 } = + await importFreshGithubModelsCredentials('refresh=ghe') + const result = await refreshCopilotTokenOn401() + expect(result).toBe(true) + expect(capturedGheUrl).toBe('https://github.mycompany.com') + }) +}) diff --git a/src/utils/githubModelsCredentials.ts b/src/utils/githubModelsCredentials.ts index 69722b24ca..47b60b9a3c 100644 --- a/src/utils/githubModelsCredentials.ts +++ b/src/utils/githubModelsCredentials.ts @@ -1,6 +1,7 @@ import { isBareMode, isEnvTruthy } from './envUtils.js' import { getSecureStorage } from './secureStorage/index.js' import { exchangeForCopilotToken } from '../services/github/deviceFlow.js' +import { logForDebugging } from './debug.js' /** JSON key in the shared OpenClaude secure storage blob. */ export const GITHUB_MODELS_STORAGE_KEY = 'githubModels' as const @@ -225,6 +226,52 @@ export function saveGithubModelsToken( return secureStorage.update(merged as typeof prev) } +/** + * Force-refresh the Copilot token on 401. + * + * Only refreshes when the failing credential matches the stored Copilot + * token — silently skipping when a provider override, route credential, + * or custom auth header is in use, preventing credential substitution. + * + * Returns true if the refresh succeeded. + */ +export async function refreshCopilotTokenOn401(): Promise { + if (isBareMode()) return false + + // Direct API key users cannot refresh — they need to update the key manually + if (process.env.GITHUB_COPILOT_KEY?.trim()) return false + + try { + const blob = readGithubModelsCredentialBlob() + if (!blob) return false + if (blob.credentialType === 'copilot_key') return false + + // Only refresh when the failing credential IS the stored Copilot token. + // A provider override, route credential, or manually-set GITHUB_TOKEN + // will not match, preventing silent credential substitution. + const currentCredential = process.env.OPENAI_API_KEY?.trim() + if (!currentCredential || currentCredential !== blob.accessToken) return false + + const oauthToken = blob.oauthAccessToken + if (!oauthToken) return false + + const gheUrl = process.env.GITHUB_ENTERPRISE_URL?.trim() || undefined + const refreshed = await exchangeForCopilotToken(oauthToken, undefined, gheUrl) + const saved = saveGithubModelsToken(refreshed.token, oauthToken) + if (!saved.success) return false + + process.env.GITHUB_TOKEN = refreshed.token + process.env.OPENAI_API_KEY = refreshed.token + return true + } catch (error) { + logForDebugging( + `[refreshCopilotTokenOn401] failed: ${error instanceof Error ? error.message : String(error)}`, + { level: 'warn' }, + ) + return false + } +} + export function clearGithubModelsToken(): { success: boolean; warning?: string } { if (isBareMode()) { return { success: true }