diff --git a/containers/api-proxy/providers/copilot.js b/containers/api-proxy/providers/copilot.js index a660c464f..85ea7151b 100644 --- a/containers/api-proxy/providers/copilot.js +++ b/containers/api-proxy/providers/copilot.js @@ -23,6 +23,15 @@ const { } = require('../proxy-utils'); const { URL } = require('url'); +// AWF injects this sentinel value into the agent environment for credential isolation. +// The ghu_ prefix is intentional: it matches the GitHub token shape that Copilot CLI +// auth pre-checks expect, but the 36 repeated 'a' characters make it unambiguous as +// a non-real placeholder. It is defined in src/constants/placeholders.ts and must +// stay in sync. When this value appears as COPILOT_API_KEY in the sidecar environment +// it means the sidecar received a dummy key (not a real BYOK credential) and should +// fall back to COPILOT_GITHUB_TOKEN as the sole auth source. +const COPILOT_PLACEHOLDER_TOKEN = 'ghu_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + /** * Strip any accidental "Bearer " prefix from a raw credential value and trim * surrounding whitespace. Returns undefined when the result is empty so that @@ -38,10 +47,28 @@ function stripBearerPrefix(value) { return ((value || '').replace(/^\s*Bearer\s+/i, '').trim()) || undefined; } +/** + * Returns the COPILOT_API_KEY value from env if it is a real BYOK credential, + * or undefined in two cases: + * 1. COPILOT_API_KEY is not set (or is empty/whitespace-only). + * 2. COPILOT_API_KEY equals the known AWF placeholder sentinel — it was injected + * by AWF for credential isolation and is not a usable BYOK credential. + * + * @param {Record} env - Environment variables to inspect + * @returns {string|undefined} The real BYOK key, or undefined when absent or placeholder. + */ +function resolveApiKey(env) { + const key = stripBearerPrefix(env.COPILOT_API_KEY); + return key === COPILOT_PLACEHOLDER_TOKEN ? undefined : key; +} + /** * Resolves the Copilot auth token from environment variables. * COPILOT_GITHUB_TOKEN (GitHub OAuth) takes precedence over COPILOT_API_KEY (direct key). * + * The AWF placeholder token is treated as absent so that when AWF injects it as + * a dummy COPILOT_API_KEY the sidecar still uses COPILOT_GITHUB_TOKEN for auth. + * * Any accidental "Bearer " prefix is stripped via stripBearerPrefix so that * the injected Authorization header is exactly "Bearer " rather than * the double-prefixed "Bearer Bearer " that would be rejected by @@ -51,7 +78,7 @@ function stripBearerPrefix(value) { * @returns {string|undefined} The resolved auth token, or undefined if neither is set */ function resolveCopilotAuthToken(env = process.env) { - return stripBearerPrefix(env.COPILOT_GITHUB_TOKEN) || stripBearerPrefix(env.COPILOT_API_KEY); + return stripBearerPrefix(env.COPILOT_GITHUB_TOKEN) || resolveApiKey(env); } /** @@ -197,7 +224,8 @@ function normalizeNullTypeToolCalls(body) { */ function createCopilotAdapter(env, deps = {}) { const githubToken = stripBearerPrefix(env.COPILOT_GITHUB_TOKEN); - const apiKey = stripBearerPrefix(env.COPILOT_API_KEY); + // resolveApiKey filters out the AWF placeholder so it is never used as a real BYOK credential. + const apiKey = resolveApiKey(env); const authToken = resolveCopilotAuthToken(env); const integrationId = env.COPILOT_INTEGRATION_ID || 'copilot-developer-cli'; const rawTarget = deriveCopilotApiTarget(env); @@ -378,10 +406,12 @@ module.exports = { // Exported for unit-test access only; not part of the public API. _testing: { resolveCopilotAuthToken, + resolveApiKey, stripBearerPrefix, deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath, normalizeNullTypeToolCalls, + COPILOT_PLACEHOLDER_TOKEN, }, }; diff --git a/containers/api-proxy/server.auth.test.js b/containers/api-proxy/server.auth.test.js index 4c700be13..f280a1c0e 100644 --- a/containers/api-proxy/server.auth.test.js +++ b/containers/api-proxy/server.auth.test.js @@ -6,7 +6,7 @@ const { shouldStripHeader } = require('./proxy-utils'); const { - _testing: { resolveCopilotAuthToken, stripBearerPrefix, normalizeNullTypeToolCalls }, + _testing: { resolveCopilotAuthToken, resolveApiKey, stripBearerPrefix, normalizeNullTypeToolCalls, COPILOT_PLACEHOLDER_TOKEN }, createCopilotAdapter, } = require('./providers/copilot'); @@ -136,6 +136,31 @@ describe('resolveCopilotAuthToken', () => { COPILOT_API_KEY: 'Bearer sk-byok-key', })).toBe('gho_abc123'); }); + + it('treats AWF placeholder COPILOT_API_KEY as absent when no COPILOT_GITHUB_TOKEN is set', () => { + expect(resolveCopilotAuthToken({ COPILOT_API_KEY: COPILOT_PLACEHOLDER_TOKEN })).toBeUndefined(); + }); + + it('uses COPILOT_GITHUB_TOKEN when COPILOT_API_KEY is the AWF placeholder', () => { + expect(resolveCopilotAuthToken({ + COPILOT_GITHUB_TOKEN: 'gho_real_token', + COPILOT_API_KEY: COPILOT_PLACEHOLDER_TOKEN, + })).toBe('gho_real_token'); + }); +}); + +describe('resolveApiKey', () => { + it('returns the API key when it is a real credential', () => { + expect(resolveApiKey({ COPILOT_API_KEY: 'sk-byok-key' })).toBe('sk-byok-key'); + }); + + it('returns undefined when COPILOT_API_KEY is the AWF placeholder', () => { + expect(resolveApiKey({ COPILOT_API_KEY: COPILOT_PLACEHOLDER_TOKEN })).toBeUndefined(); + }); + + it('returns undefined when COPILOT_API_KEY is not set', () => { + expect(resolveApiKey({})).toBeUndefined(); + }); }); describe('normalizeNullTypeToolCalls', () => { @@ -229,7 +254,7 @@ describe('createCopilotAdapter — BYOK getAuthHeaders', () => { expect(headers['Authorization']).toBe('Bearer gho_oauth_token'); }); - it('uses API key for /models GET when no GITHUB_TOKEN is set (BYOK-only mode)', () => { + it('uses API key for /models GET when no COPILOT_GITHUB_TOKEN is set (BYOK-only mode)', () => { const adapter = createCopilotAdapter({ COPILOT_API_KEY: 'sk-or-v1-abc123' }); const headers = adapter.getAuthHeaders(fakeModelsReq); expect(headers['Authorization']).toBe('Bearer sk-or-v1-abc123'); @@ -240,6 +265,28 @@ describe('createCopilotAdapter — BYOK getAuthHeaders', () => { expect(adapter.isEnabled()).toBe(true); }); + it('is disabled when COPILOT_API_KEY is the AWF placeholder and no COPILOT_GITHUB_TOKEN is set', () => { + const adapter = createCopilotAdapter({ COPILOT_API_KEY: COPILOT_PLACEHOLDER_TOKEN }); + expect(adapter.isEnabled()).toBe(false); + }); + + it('is enabled when COPILOT_API_KEY is the AWF placeholder but COPILOT_GITHUB_TOKEN is set', () => { + const adapter = createCopilotAdapter({ + COPILOT_GITHUB_TOKEN: 'gho_real_token', + COPILOT_API_KEY: COPILOT_PLACEHOLDER_TOKEN, + }); + expect(adapter.isEnabled()).toBe(true); + }); + + it('uses COPILOT_GITHUB_TOKEN for inference when COPILOT_API_KEY is the AWF placeholder', () => { + const adapter = createCopilotAdapter({ + COPILOT_GITHUB_TOKEN: 'gho_real_token', + COPILOT_API_KEY: COPILOT_PLACEHOLDER_TOKEN, + }); + const headers = adapter.getAuthHeaders(fakeReq); + expect(headers['Authorization']).toBe('Bearer gho_real_token'); + }); + it('uses custom COPILOT_INTEGRATION_ID when set', () => { const adapter = createCopilotAdapter({ COPILOT_API_KEY: 'sk-or-v1-abc123', diff --git a/src/constants/placeholders.test.ts b/src/constants/placeholders.test.ts index 0b96329fc..465504288 100644 --- a/src/constants/placeholders.test.ts +++ b/src/constants/placeholders.test.ts @@ -10,4 +10,12 @@ describe('COPILOT_PLACEHOLDER_TOKEN', () => { expect(match?.[1]).toBe(COPILOT_PLACEHOLDER_TOKEN); }); + + it('matches the api-proxy copilot.js placeholder value', () => { + const copilotJsPath = path.resolve(__dirname, '../../containers/api-proxy/providers/copilot.js'); + const scriptContent = fs.readFileSync(copilotJsPath, 'utf8'); + const match = scriptContent.match(/COPILOT_PLACEHOLDER_TOKEN\s*=\s*'([^']+)'/); + + expect(match?.[1]).toBe(COPILOT_PLACEHOLDER_TOKEN); + }); }); diff --git a/src/services/api-proxy-service.test.ts b/src/services/api-proxy-service.test.ts index 95feaf1f4..3cede6629 100644 --- a/src/services/api-proxy-service.test.ts +++ b/src/services/api-proxy-service.test.ts @@ -901,6 +901,32 @@ describe('API proxy sidecar', () => { expect(env.COPILOT_PROVIDER_BASE_URL).toBeUndefined(); }); + it.each(['gpt-5', 'openai/o3-mini', 'gpt-5.4-mini', 'GPT-5', 'O3'])('should set COPILOT_PROVIDER_WIRE_API=responses in GitHub token mode when COPILOT_MODEL is %s', (copilotModel) => { + const configWithProxy = { + ...mockConfig, + enableApiProxy: true, + copilotGithubToken: 'ghu_test_token', + additionalEnv: { COPILOT_MODEL: copilotModel }, + }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const agent = result.services.agent; + const env = agent.environment as Record; + expect(env.COPILOT_PROVIDER_WIRE_API).toBe('responses'); + }); + + it.each(['gpt-4o', 'o30', 'o3x'])('should not set COPILOT_PROVIDER_WIRE_API in GitHub token mode when COPILOT_MODEL=%s does not require responses API', (copilotModel) => { + const configWithProxy = { + ...mockConfig, + enableApiProxy: true, + copilotGithubToken: 'ghu_test_token', + additionalEnv: { COPILOT_MODEL: copilotModel }, + }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const agent = result.services.agent; + const env = agent.environment as Record; + expect(env.COPILOT_PROVIDER_WIRE_API).toBeUndefined(); + }); + it('should include COPILOT_PROVIDER_API_KEY in AWF_ONE_SHOT_TOKENS', () => { const configWithProxy = { ...mockConfig, enableApiProxy: true, copilotApiKey: 'cpat_test_byok_key' }; const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); diff --git a/src/services/api-proxy-service.ts b/src/services/api-proxy-service.ts index 0ce9d642b..2703d0f14 100644 --- a/src/services/api-proxy-service.ts +++ b/src/services/api-proxy-service.ts @@ -275,6 +275,15 @@ export function buildApiProxyService(params: ApiProxyServiceParams): ApiProxyBui // Note: COPILOT_GITHUB_TOKEN and COPILOT_API_KEY placeholders are set early (before --env-all) // to prevent override by host environment variable + + // Set the wire API based solely on the model, regardless of which auth path is active. + // GPT-5-family models must use the /responses endpoint; setting this here ensures the + // Copilot CLI uses the correct endpoint even when only copilotGithubToken is provided. + const copilotModel = getCopilotModel(config); + if (copilotModel && requiresResponsesWireApi(copilotModel)) { + agentEnvAdditions.COPILOT_PROVIDER_WIRE_API = 'responses'; + logger.debug(`COPILOT_PROVIDER_WIRE_API set to responses for model: ${copilotModel}`); + } } if (config.copilotApiKey) { // Enable Copilot CLI offline + BYOK mode so it skips the GitHub OAuth handshake @@ -291,12 +300,6 @@ export function buildApiProxyService(params: ApiProxyServiceParams): ApiProxyBui // COPILOT_PROVIDER_API_KEY placeholder: real key is held by the sidecar, never exposed to agent. // Set early placeholder (before this block) already handled above. logger.debug('COPILOT_PROVIDER_API_KEY placeholder set for credential isolation'); - - const copilotModel = getCopilotModel(config); - if (copilotModel && requiresResponsesWireApi(copilotModel)) { - agentEnvAdditions.COPILOT_PROVIDER_WIRE_API = 'responses'; - logger.debug(`COPILOT_PROVIDER_WIRE_API set to responses for model: ${copilotModel}`); - } } // Only configure Gemini proxy routing when a Gemini API key is provided. // Previously this was unconditional, which caused the Gemini CLI's ~/.gemini