From 88c22f8e3dce84b27c9112c12e458ebffd7e3e4c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 02:39:27 +0000 Subject: [PATCH 1/4] Initial plan From 95faa45470f9017c6bbe7799d8ed1214277b550f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 02:51:06 +0000 Subject: [PATCH 2/4] fix(api-proxy): route gpt-5 family models to /responses regardless of auth path - In api-proxy-service.ts, decouple COPILOT_PROVIDER_WIRE_API=responses from the copilotApiKey branch: it is now set based solely on the detected model via requiresResponsesWireApi() whenever any Copilot credential is configured (copilotGithubToken OR copilotApiKey). This fixes the 400 error reported when using gpt-5.4-mini with COPILOT_GITHUB_TOKEN in strict AWF mode. - In copilot.js, introduce resolveApiKey() which filters out the AWF placeholder token (ghu_aaa...) so the sidecar never treats it as a real BYOK credential. createCopilotAdapter now uses resolveApiKey() for the apiKey variable so that when AWF injects the placeholder as COPILOT_API_KEY, the sidecar falls back to COPILOT_GITHUB_TOKEN as the sole auth source and BYOK-specific code paths (e.g. model fetching from custom providers) are not triggered. - Add tests covering the GitHub token wire-API path (gpt-5, o3-mini, gpt-5.4-mini) and the placeholder-key skipping behavior in the sidecar adapter. Fixes: api-proxy: gpt-5.4-mini routed to /chat/completions instead of /responses --- containers/api-proxy/providers/copilot.js | 28 ++++++++++++- containers/api-proxy/server.auth.test.js | 49 ++++++++++++++++++++++- src/services/api-proxy-service.test.ts | 26 ++++++++++++ src/services/api-proxy-service.ts | 15 ++++--- 4 files changed, 109 insertions(+), 9 deletions(-) diff --git a/containers/api-proxy/providers/copilot.js b/containers/api-proxy/providers/copilot.js index a660c464f..ef5646ef8 100644 --- a/containers/api-proxy/providers/copilot.js +++ b/containers/api-proxy/providers/copilot.js @@ -23,6 +23,12 @@ const { } = require('../proxy-utils'); const { URL } = require('url'); +// AWF injects this placeholder into the agent environment for credential isolation. +// 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 +44,25 @@ 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 if it is the known AWF placeholder sentinel. + * + * @param {Record} env - Environment variables to inspect + * @returns {string|undefined} + */ +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 +72,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 +218,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 +400,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..1b7a98449 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 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', () => { @@ -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 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/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 From 2260a52dbe511a116efce1913f994fd1e492227c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 02:53:00 +0000 Subject: [PATCH 3/4] fix(api-proxy): improve comments and JSDoc for placeholder sentinel in copilot.js --- containers/api-proxy/providers/copilot.js | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/containers/api-proxy/providers/copilot.js b/containers/api-proxy/providers/copilot.js index ef5646ef8..85ea7151b 100644 --- a/containers/api-proxy/providers/copilot.js +++ b/containers/api-proxy/providers/copilot.js @@ -23,10 +23,13 @@ const { } = require('../proxy-utils'); const { URL } = require('url'); -// AWF injects this placeholder into the agent environment for credential isolation. -// 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. +// 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'; /** @@ -46,10 +49,13 @@ function stripBearerPrefix(value) { /** * Returns the COPILOT_API_KEY value from env if it is a real BYOK credential, - * or undefined if it is the known AWF placeholder sentinel. + * 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} + * @returns {string|undefined} The real BYOK key, or undefined when absent or placeholder. */ function resolveApiKey(env) { const key = stripBearerPrefix(env.COPILOT_API_KEY); From df2468ad4c1c7fd972a7e214c1ddfc5cfc7921b9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 03:11:57 +0000 Subject: [PATCH 4/4] fix(api-proxy): add cross-copy consistency test for placeholder and fix test descriptions - Add a test in placeholders.test.ts asserting that the hardcoded COPILOT_PLACEHOLDER_TOKEN in copilot.js matches the canonical TS constant, mirroring the existing health-check shell script consistency check. - Rename three test cases in server.auth.test.js from "no GITHUB_TOKEN" to "no COPILOT_GITHUB_TOKEN" to match the actual environment variable name. --- containers/api-proxy/server.auth.test.js | 6 +++--- src/constants/placeholders.test.ts | 8 ++++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/containers/api-proxy/server.auth.test.js b/containers/api-proxy/server.auth.test.js index 1b7a98449..f280a1c0e 100644 --- a/containers/api-proxy/server.auth.test.js +++ b/containers/api-proxy/server.auth.test.js @@ -137,7 +137,7 @@ describe('resolveCopilotAuthToken', () => { })).toBe('gho_abc123'); }); - it('treats AWF placeholder COPILOT_API_KEY as absent when no GITHUB_TOKEN is set', () => { + 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(); }); @@ -254,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'); @@ -265,7 +265,7 @@ describe('createCopilotAdapter — BYOK getAuthHeaders', () => { expect(adapter.isEnabled()).toBe(true); }); - it('is disabled when COPILOT_API_KEY is the AWF placeholder and no GITHUB_TOKEN is set', () => { + 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); }); 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); + }); });