From 2e54dbd2bec2eddc2a1d6c422359feebd5604642 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Jun 2026 21:41:34 +0000 Subject: [PATCH 1/6] Initial plan From c06dde5cba108a0d138dbaf7aa9b39acc350b602 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Jun 2026 21:51:24 +0000 Subject: [PATCH 2/6] feat(api-proxy): inject custom headers into BYOK provider requests via AWF_BYOK_EXTRA_HEADERS --- containers/api-proxy/README.md | 5 + containers/api-proxy/providers/copilot.js | 73 ++++++++++ containers/api-proxy/server.auth.test.js | 168 ++++++++++++++++++++++ 3 files changed, 246 insertions(+) diff --git a/containers/api-proxy/README.md b/containers/api-proxy/README.md index 644a29cab..e9db9b816 100644 --- a/containers/api-proxy/README.md +++ b/containers/api-proxy/README.md @@ -42,6 +42,11 @@ Required (at least one): Optional: - `COPILOT_API_TARGET` - Target hostname for GitHub Copilot API requests (default: `api.githubcopilot.com`). Useful for GHES deployments. +- `AWF_BYOK_EXTRA_HEADERS` - JSON object of additional headers to inject into upstream requests when the Copilot BYOK API key (`COPILOT_PROVIDER_API_KEY`) is in use. Useful for provider-native observability (e.g. OpenRouter session grouping, Helicone user tracking): + ``` + AWF_BYOK_EXTRA_HEADERS='{"x-session-id":"my-session","HTTP-Referer":"https://example.com"}' + ``` + Auth-critical header names (`authorization`, `x-api-key`, etc.) are rejected. Headers are only sent when the BYOK API key is used; standard GitHub OAuth (`COPILOT_GITHUB_TOKEN`) requests are unaffected. Set by AWF: - `HTTP_PROXY` - Squid proxy URL (http://172.30.0.10:3128) diff --git a/containers/api-proxy/providers/copilot.js b/containers/api-proxy/providers/copilot.js index 5a4dbc78f..b33d5f9e8 100644 --- a/containers/api-proxy/providers/copilot.js +++ b/containers/api-proxy/providers/copilot.js @@ -12,6 +12,10 @@ * Special routing: GET /models (and /models/*) always uses COPILOT_GITHUB_TOKEN * regardless of which auth mode is active, because the /models endpoint only * accepts OAuth tokens, not API keys. + * + * BYOK extra headers: AWF_BYOK_EXTRA_HEADERS (JSON object) injects supplemental + * headers (e.g. x-session-id, HTTP-Referer) into upstream requests when the + * BYOK API key is in use. Auth-critical header names are rejected at parse time. */ const { @@ -24,6 +28,69 @@ const { const { sanitizeNullToolCallTypes } = require('../body-transform'); const { URL } = require('url'); +/** + * Header names that must never be overridden by caller-supplied extra headers. + * These are the auth/proxy headers stripped or injected by the proxy itself. + */ +const PROTECTED_HEADER_NAMES = new Set([ + 'authorization', + 'x-api-key', + 'x-goog-api-key', + 'proxy-authorization', +]); + +/** + * Parse the AWF_BYOK_EXTRA_HEADERS environment variable into a plain header map. + * + * The value must be a JSON object whose keys are valid HTTP header names and + * whose values are strings. Invalid entries are skipped with a console warning; + * the function always returns a (possibly empty) object rather than throwing. + * + * Auth-critical header names (authorization, x-api-key, etc.) are rejected to + * prevent accidental credential injection via this configuration path. + * + * @param {string|undefined} raw - Raw value of AWF_BYOK_EXTRA_HEADERS + * @returns {Record} Validated header map (may be empty) + */ +function parseByokExtraHeaders(raw) { + if (!raw || !raw.trim()) return {}; + + let parsed; + try { + parsed = JSON.parse(raw.trim()); + } catch { + console.warn('AWF_BYOK_EXTRA_HEADERS: invalid JSON; ignoring extra headers'); + return {}; + } + + if (typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null) { + console.warn('AWF_BYOK_EXTRA_HEADERS: expected a JSON object; ignoring extra headers'); + return {}; + } + + const result = {}; + const http = require('http'); + for (const [name, value] of Object.entries(parsed)) { + if (PROTECTED_HEADER_NAMES.has(name.toLowerCase())) { + console.warn(`AWF_BYOK_EXTRA_HEADERS: "${name}" is an auth-critical header and cannot be overridden; skipping`); + continue; + } + try { + http.validateHeaderName(name); + } catch { + console.warn(`AWF_BYOK_EXTRA_HEADERS: "${name}" is not a valid HTTP header name; skipping`); + continue; + } + if (typeof value !== 'string') { + console.warn(`AWF_BYOK_EXTRA_HEADERS: value for "${name}" must be a string; skipping`); + continue; + } + result[name] = value; + } + + return result; +} + // 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 @@ -245,6 +312,10 @@ function createCopilotAdapter(env, deps = {}) { const integrationId = env.COPILOT_INTEGRATION_ID || 'copilot-developer-cli'; const rawTarget = deriveCopilotApiTarget(env); const basePath = normalizeBasePath(env.COPILOT_API_BASE_PATH); + // Extra headers to inject on all requests that use the BYOK API key. + // Only populated when AWF_BYOK_EXTRA_HEADERS is set; ignored for standard + // GitHub OAuth (COPILOT_GITHUB_TOKEN-only) requests. + const byokExtraHeaders = parseByokExtraHeaders(env.AWF_BYOK_EXTRA_HEADERS); const bodyTransform = composeBodyTransforms( deps.bodyTransform || null, @@ -380,6 +451,7 @@ function createCopilotAdapter(env, deps = {}) { return { 'Authorization': `Bearer ${authToken}`, 'Copilot-Integration-Id': integrationId, + ...(apiKey ? byokExtraHeaders : {}), }; }, @@ -425,5 +497,6 @@ module.exports = { deriveGitHubApiBasePath, isGithubCopilotCatalogTarget, COPILOT_PLACEHOLDER_TOKEN, + parseByokExtraHeaders, }, }; diff --git a/containers/api-proxy/server.auth.test.js b/containers/api-proxy/server.auth.test.js index 29817fae5..e549f45d6 100644 --- a/containers/api-proxy/server.auth.test.js +++ b/containers/api-proxy/server.auth.test.js @@ -11,6 +11,7 @@ const { resolveApiKey, stripBearerPrefix, COPILOT_PLACEHOLDER_TOKEN, + parseByokExtraHeaders, }, createCopilotAdapter, } = require('./providers/copilot'); @@ -338,6 +339,173 @@ describe('createCopilotAdapter — BYOK getAuthHeaders', () => { }); }); +// ── parseByokExtraHeaders ───────────────────────────────────────────────────── + +describe('parseByokExtraHeaders', () => { + it('returns empty object for undefined input', () => { + expect(parseByokExtraHeaders(undefined)).toEqual({}); + }); + + it('returns empty object for empty string', () => { + expect(parseByokExtraHeaders('')).toEqual({}); + }); + + it('returns empty object for whitespace-only string', () => { + expect(parseByokExtraHeaders(' ')).toEqual({}); + }); + + it('parses a valid JSON object of string headers', () => { + const result = parseByokExtraHeaders('{"x-session-id":"sess-123","HTTP-Referer":"https://example.com"}'); + expect(result).toEqual({ + 'x-session-id': 'sess-123', + 'HTTP-Referer': 'https://example.com', + }); + }); + + it('returns empty object and warns for invalid JSON', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const result = parseByokExtraHeaders('{not-valid-json}'); + expect(result).toEqual({}); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('invalid JSON')); + warnSpy.mockRestore(); + }); + + it('returns empty object and warns when value is a JSON array (not object)', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const result = parseByokExtraHeaders('["x-session-id","value"]'); + expect(result).toEqual({}); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('expected a JSON object')); + warnSpy.mockRestore(); + }); + + it('returns empty object and warns when value is a JSON string (not object)', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const result = parseByokExtraHeaders('"just-a-string"'); + expect(result).toEqual({}); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('expected a JSON object')); + warnSpy.mockRestore(); + }); + + it('skips auth-critical header "authorization" with a warning', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const result = parseByokExtraHeaders('{"authorization":"******","x-session-id":"sess-1"}'); + expect(result).not.toHaveProperty('authorization'); + expect(result['x-session-id']).toBe('sess-1'); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('auth-critical')); + warnSpy.mockRestore(); + }); + + it('skips auth-critical header "Authorization" (case-insensitive) with a warning', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const result = parseByokExtraHeaders('{"Authorization":"******"}'); + expect(result).not.toHaveProperty('Authorization'); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('auth-critical')); + warnSpy.mockRestore(); + }); + + it('skips auth-critical header "x-api-key" with a warning', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const result = parseByokExtraHeaders('{"x-api-key":"leaked-key"}'); + expect(result).not.toHaveProperty('x-api-key'); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('auth-critical')); + warnSpy.mockRestore(); + }); + + it('skips invalid HTTP header names with a warning', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const result = parseByokExtraHeaders('{"invalid header name":"value","x-valid":"ok"}'); + expect(result).not.toHaveProperty('invalid header name'); + expect(result['x-valid']).toBe('ok'); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('not a valid HTTP header name')); + warnSpy.mockRestore(); + }); + + it('skips entries with non-string values with a warning', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const result = parseByokExtraHeaders('{"x-count":42,"x-session-id":"sess-1"}'); + expect(result).not.toHaveProperty('x-count'); + expect(result['x-session-id']).toBe('sess-1'); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('must be a string')); + warnSpy.mockRestore(); + }); +}); + +// ── createCopilotAdapter — AWF_BYOK_EXTRA_HEADERS injection ────────────────── + +describe('createCopilotAdapter — AWF_BYOK_EXTRA_HEADERS injection', () => { + const fakeReq = { url: '/v1/chat/completions', method: 'POST', headers: {} }; + const fakeModelsReq = { url: '/models', method: 'GET', headers: {} }; + + it('injects extra BYOK headers on inference request when BYOK API key is set', () => { + const adapter = createCopilotAdapter({ + COPILOT_PROVIDER_API_KEY: 'sk-or-v1-abc123', + AWF_BYOK_EXTRA_HEADERS: '{"x-session-id":"sess-42","HTTP-Referer":"https://example.com"}', + }); + const headers = adapter.getAuthHeaders(fakeReq); + expect(headers['x-session-id']).toBe('sess-42'); + expect(headers['HTTP-Referer']).toBe('https://example.com'); + }); + + it('does not override Authorization or Copilot-Integration-Id with extra headers', () => { + const adapter = createCopilotAdapter({ + COPILOT_PROVIDER_API_KEY: 'sk-or-v1-abc123', + AWF_BYOK_EXTRA_HEADERS: '{"x-session-id":"sess-1"}', + }); + const headers = adapter.getAuthHeaders(fakeReq); + expect(headers['Authorization']).toBe('Bearer sk-or-v1-abc123'); + expect(headers['Copilot-Integration-Id']).toBe('copilot-developer-cli'); + }); + + it('does NOT inject extra headers when only GitHub OAuth token is set (no BYOK key)', () => { + const adapter = createCopilotAdapter({ + COPILOT_GITHUB_TOKEN: 'gho_oauth_token', + AWF_BYOK_EXTRA_HEADERS: '{"x-session-id":"sess-42"}', + }); + const headers = adapter.getAuthHeaders(fakeReq); + expect(headers['x-session-id']).toBeUndefined(); + }); + + it('does NOT inject extra headers on /models GET when GitHub OAuth token is available', () => { + const adapter = createCopilotAdapter({ + COPILOT_GITHUB_TOKEN: 'gho_oauth_token', + COPILOT_PROVIDER_API_KEY: 'sk-or-v1-abc123', + AWF_BYOK_EXTRA_HEADERS: '{"x-session-id":"sess-42"}', + }); + // /models GET with GitHub token goes to GitHub Copilot — extra headers must not be sent there + const headers = adapter.getAuthHeaders(fakeModelsReq); + expect(headers['Authorization']).toBe('Bearer gho_oauth_token'); + expect(headers['x-session-id']).toBeUndefined(); + }); + + it('injects extra BYOK headers on /models GET when only BYOK API key is set (no GitHub token)', () => { + const adapter = createCopilotAdapter({ + COPILOT_PROVIDER_API_KEY: 'sk-or-v1-abc123', + AWF_BYOK_EXTRA_HEADERS: '{"x-session-id":"sess-42"}', + }); + // Without a GitHub token, /models GET uses the BYOK key and goes to the BYOK provider + const headers = adapter.getAuthHeaders(fakeModelsReq); + expect(headers['x-session-id']).toBe('sess-42'); + }); + + it('does not inject extra headers when AWF_BYOK_EXTRA_HEADERS is not set', () => { + const adapter = createCopilotAdapter({ COPILOT_PROVIDER_API_KEY: 'sk-or-v1-abc123' }); + const headers = adapter.getAuthHeaders(fakeReq); + expect(Object.keys(headers)).toEqual(['Authorization', 'Copilot-Integration-Id']); + }); + + it('ignores invalid AWF_BYOK_EXTRA_HEADERS JSON and still authenticates normally', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const adapter = createCopilotAdapter({ + COPILOT_PROVIDER_API_KEY: 'sk-or-v1-abc123', + AWF_BYOK_EXTRA_HEADERS: '{bad-json}', + }); + const headers = adapter.getAuthHeaders(fakeReq); + expect(headers['Authorization']).toBe('Bearer sk-or-v1-abc123'); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('invalid JSON')); + warnSpy.mockRestore(); + }); +}); + describe('createAnthropicAdapter — OIDC getAuthHeaders', () => { const fakeReq = { url: '/v1/messages', method: 'POST', headers: {} }; From 6f6e9f93a06992cc26c24d2e1638e84d61c9f3c5 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Wed, 3 Jun 2026 15:23:48 -0700 Subject: [PATCH 3/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- containers/api-proxy/providers/copilot.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/containers/api-proxy/providers/copilot.js b/containers/api-proxy/providers/copilot.js index b33d5f9e8..935cfee7d 100644 --- a/containers/api-proxy/providers/copilot.js +++ b/containers/api-proxy/providers/copilot.js @@ -449,9 +449,9 @@ function createCopilotAdapter(env, deps = {}) { } return { + ...(apiKey ? byokExtraHeaders : {}), 'Authorization': `Bearer ${authToken}`, 'Copilot-Integration-Id': integrationId, - ...(apiKey ? byokExtraHeaders : {}), }; }, From 453a667d517f3fb8d979c7ba2303f7191a12e683 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Wed, 3 Jun 2026 15:24:10 -0700 Subject: [PATCH 4/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- containers/api-proxy/providers/copilot.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/containers/api-proxy/providers/copilot.js b/containers/api-proxy/providers/copilot.js index 935cfee7d..4213a948f 100644 --- a/containers/api-proxy/providers/copilot.js +++ b/containers/api-proxy/providers/copilot.js @@ -71,7 +71,15 @@ function parseByokExtraHeaders(raw) { const result = {}; const http = require('http'); for (const [name, value] of Object.entries(parsed)) { - if (PROTECTED_HEADER_NAMES.has(name.toLowerCase())) { + const lowerName = name.toLowerCase(); + + // Prevent prototype pollution / special keys in header maps. + if (lowerName === '__proto__' || lowerName === 'constructor' || lowerName === 'prototype') { + console.warn(`AWF_BYOK_EXTRA_HEADERS: "${name}" is not an allowed header name; skipping`); + continue; + } + + if (PROTECTED_HEADER_NAMES.has(lowerName)) { console.warn(`AWF_BYOK_EXTRA_HEADERS: "${name}" is an auth-critical header and cannot be overridden; skipping`); continue; } From e02a918e82880fd7747a92540fafa666ea206179 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Wed, 3 Jun 2026 15:24:30 -0700 Subject: [PATCH 5/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- containers/api-proxy/server.auth.test.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/containers/api-proxy/server.auth.test.js b/containers/api-proxy/server.auth.test.js index e549f45d6..dad73eac0 100644 --- a/containers/api-proxy/server.auth.test.js +++ b/containers/api-proxy/server.auth.test.js @@ -447,13 +447,16 @@ describe('createCopilotAdapter — AWF_BYOK_EXTRA_HEADERS injection', () => { }); it('does not override Authorization or Copilot-Integration-Id with extra headers', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); const adapter = createCopilotAdapter({ COPILOT_PROVIDER_API_KEY: 'sk-or-v1-abc123', - AWF_BYOK_EXTRA_HEADERS: '{"x-session-id":"sess-1"}', + AWF_BYOK_EXTRA_HEADERS: '{"Authorization":"malicious","Copilot-Integration-Id":"evil","x-session-id":"sess-1"}', }); const headers = adapter.getAuthHeaders(fakeReq); expect(headers['Authorization']).toBe('Bearer sk-or-v1-abc123'); expect(headers['Copilot-Integration-Id']).toBe('copilot-developer-cli'); + expect(headers['x-session-id']).toBe('sess-1'); + warnSpy.mockRestore(); }); it('does NOT inject extra headers when only GitHub OAuth token is set (no BYOK key)', () => { From 668b229d040e47fa0a25ce74df5f33e8443a2032 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Jun 2026 22:32:54 +0000 Subject: [PATCH 6/6] feat(api-proxy): add copilot BYOK extraHeaders config mapping --- docs/awf-config-spec.md | 1 + docs/awf-config.schema.json | 22 +++++++++++++-- src/awf-config-schema.json | 22 +++++++++++++-- src/commands/build-config.test.ts | 7 +++++ src/commands/build-config.ts | 1 + src/config-file-mapping.test.ts | 3 ++- src/config-file-validation.test.ts | 21 +++++++++++++++ src/config-file.ts | 3 ++- src/schema.test.ts | 27 ++++++++++++++++++- src/services/api-proxy-service-config.ts | 3 +++ .../api-proxy-service-env-forwarding.test.ts | 14 ++++++++++ src/types/api-proxy-options.ts | 13 +++++++++ 12 files changed, 130 insertions(+), 7 deletions(-) diff --git a/docs/awf-config-spec.md b/docs/awf-config-spec.md index b636ac932..67c8194e9 100644 --- a/docs/awf-config-spec.md +++ b/docs/awf-config-spec.md @@ -130,6 +130,7 @@ the corresponding CLI flag. - `apiProxy.auth.anthropicTokenUrl` → *(config-only; maps to `AWF_AUTH_ANTHROPIC_TOKEN_URL`)* - `apiProxy.targets..host` → `---api-target` *(except `antigravity.host`, which maps to the Gemini flag below)* - `apiProxy.targets.antigravity.host` → `--gemini-api-target` +- `apiProxy.targets.copilot.extraHeaders` → *(config-only; non-sensitive supplemental BYOK headers, maps to `AWF_BYOK_EXTRA_HEADERS`)* - `apiProxy.targets.openai.basePath` → `--openai-api-base-path` - `apiProxy.targets.openai.authHeader` → `--openai-api-auth-header` - `apiProxy.targets.anthropic.basePath` → `--anthropic-api-base-path` diff --git a/docs/awf-config.schema.json b/docs/awf-config.schema.json index eee3b3508..7cc22eca0 100644 --- a/docs/awf-config.schema.json +++ b/docs/awf-config.schema.json @@ -143,8 +143,8 @@ "description": "Anthropic API target override." }, "copilot": { - "$ref": "#/$defs/providerHostOnlyTarget", - "description": "GitHub Copilot API target override (basePath not supported)." + "$ref": "#/$defs/copilotTarget", + "description": "GitHub Copilot API target override and BYOK supplemental headers (basePath not supported)." }, "gemini": { "$ref": "#/$defs/providerTarget", @@ -580,6 +580,24 @@ "description": "Override the provider API host." } } + }, + "copilotTarget": { + "type": "object", + "description": "Copilot target override (host only) plus non-sensitive BYOK supplemental headers.", + "additionalProperties": false, + "properties": { + "host": { + "type": "string", + "description": "Override the Copilot API host." + }, + "extraHeaders": { + "type": "object", + "description": "Additional non-sensitive headers to include on Copilot BYOK upstream requests. Values must be strings. Maps to AWF_BYOK_EXTRA_HEADERS in the sidecar.", + "additionalProperties": { + "type": "string" + } + } + } } } } diff --git a/src/awf-config-schema.json b/src/awf-config-schema.json index eee3b3508..7cc22eca0 100644 --- a/src/awf-config-schema.json +++ b/src/awf-config-schema.json @@ -143,8 +143,8 @@ "description": "Anthropic API target override." }, "copilot": { - "$ref": "#/$defs/providerHostOnlyTarget", - "description": "GitHub Copilot API target override (basePath not supported)." + "$ref": "#/$defs/copilotTarget", + "description": "GitHub Copilot API target override and BYOK supplemental headers (basePath not supported)." }, "gemini": { "$ref": "#/$defs/providerTarget", @@ -580,6 +580,24 @@ "description": "Override the provider API host." } } + }, + "copilotTarget": { + "type": "object", + "description": "Copilot target override (host only) plus non-sensitive BYOK supplemental headers.", + "additionalProperties": false, + "properties": { + "host": { + "type": "string", + "description": "Override the Copilot API host." + }, + "extraHeaders": { + "type": "object", + "description": "Additional non-sensitive headers to include on Copilot BYOK upstream requests. Values must be strings. Maps to AWF_BYOK_EXTRA_HEADERS in the sidecar.", + "additionalProperties": { + "type": "string" + } + } + } } } } diff --git a/src/commands/build-config.test.ts b/src/commands/build-config.test.ts index 7f76da241..187bd114a 100644 --- a/src/commands/build-config.test.ts +++ b/src/commands/build-config.test.ts @@ -289,5 +289,12 @@ describe('buildConfig', () => { const config = buildConfig(makeInputs({ resolvedCopilotApiTarget: 'https://copilot.example.com' })); expect(config.copilotApiTarget).toBe('https://copilot.example.com'); }); + + it('should pass through copilotByokExtraHeaders', () => { + const config = buildConfig(makeInputs({ + options: { ...makeInputs().options, copilotByokExtraHeaders: { 'x-session-id': 'run-42' } }, + })); + expect(config.copilotByokExtraHeaders).toEqual({ 'x-session-id': 'run-42' }); + }); }); }); diff --git a/src/commands/build-config.ts b/src/commands/build-config.ts index 28635fbdb..cec5fe228 100644 --- a/src/commands/build-config.ts +++ b/src/commands/build-config.ts @@ -126,6 +126,7 @@ export function buildConfig(inputs: BuildConfigInputs): WrapperConfig { geminiApiKey: process.env.GEMINI_API_KEY, copilotApiTarget: resolvedCopilotApiTarget, copilotApiBasePath: resolvedCopilotApiBasePath, + copilotByokExtraHeaders: options.copilotByokExtraHeaders as Record | undefined, openaiApiTarget: (options.openaiApiTarget as string | undefined) || process.env.OPENAI_API_TARGET, openaiApiBasePath: diff --git a/src/config-file-mapping.test.ts b/src/config-file-mapping.test.ts index 08e3f4ce8..251534909 100644 --- a/src/config-file-mapping.test.ts +++ b/src/config-file-mapping.test.ts @@ -37,7 +37,7 @@ describe('mapAwfFileConfigToCliOptions', () => { apiProxy: { targets: { openai: { host: 'api.openai.com', basePath: '/v1' }, - copilot: { host: 'api.githubcopilot.com' }, + copilot: { host: 'api.githubcopilot.com', extraHeaders: { 'x-session-id': 'run-42' } }, gemini: { host: 'generativelanguage.googleapis.com', basePath: '/v1beta' }, }, }, @@ -46,6 +46,7 @@ describe('mapAwfFileConfigToCliOptions', () => { expect(result.openaiApiTarget).toBe('api.openai.com'); expect(result.openaiApiBasePath).toBe('/v1'); expect(result.copilotApiTarget).toBe('api.githubcopilot.com'); + expect(result.copilotByokExtraHeaders).toEqual({ 'x-session-id': 'run-42' }); expect(result.geminiApiTarget).toBe('generativelanguage.googleapis.com'); expect(result.geminiApiBasePath).toBe('/v1beta'); }); diff --git a/src/config-file-validation.test.ts b/src/config-file-validation.test.ts index a94951b35..6001e5293 100644 --- a/src/config-file-validation.test.ts +++ b/src/config-file-validation.test.ts @@ -170,6 +170,27 @@ describe('validateAwfFileConfig', () => { expect(errors).toContain('config.apiProxy.targets.anthropic.basePath must be a string'); }); + it('accepts copilot extraHeaders as object of string values', () => { + const errors = validateAwfFileConfig({ + apiProxy: { targets: { copilot: { extraHeaders: { 'x-session-id': 'run-42' } } } }, + }); + expect(errors).toEqual([]); + }); + + it('rejects non-object copilot extraHeaders', () => { + const errors = validateAwfFileConfig({ + apiProxy: { targets: { copilot: { extraHeaders: 'invalid' } } }, + }); + expect(errors).toContain('config.apiProxy.targets.copilot.extraHeaders must be an object'); + }); + + it('rejects non-string copilot extraHeaders values', () => { + const errors = validateAwfFileConfig({ + apiProxy: { targets: { copilot: { extraHeaders: { 'x-session-id': 42 } } } }, + }); + expect(errors).toContain('config.apiProxy.targets.copilot.extraHeaders.x-session-id must be a string'); + }); + it('accepts gemini target with host and basePath', () => { const errors = validateAwfFileConfig({ apiProxy: { targets: { gemini: { host: 'generativelanguage.googleapis.com', basePath: '/v1' } } }, diff --git a/src/config-file.ts b/src/config-file.ts index 19e53803c..5382c028b 100644 --- a/src/config-file.ts +++ b/src/config-file.ts @@ -32,7 +32,7 @@ interface AwfFileConfig { targets?: { openai?: { host?: string; basePath?: string; authHeader?: string }; anthropic?: { host?: string; basePath?: string; authHeader?: string }; - copilot?: { host?: string; basePath?: string }; + copilot?: { host?: string; basePath?: string; extraHeaders?: Record }; gemini?: { host?: string; basePath?: string }; antigravity?: { host?: string; basePath?: string }; }; @@ -199,6 +199,7 @@ export function mapAwfFileConfigToCliOptions(config: AwfFileConfig): Record { targets: { openai: { host: 'api.openai.com', basePath: '/v1' }, anthropic: { host: 'api.anthropic.com', basePath: '/v1' }, - copilot: { host: 'api.githubcopilot.com' }, + copilot: { host: 'api.githubcopilot.com', extraHeaders: { 'x-session-id': 'run-42' } }, gemini: { host: 'generativelanguage.googleapis.com', basePath: '/v1beta' }, }, models: { @@ -184,6 +184,31 @@ describe('awf-config.schema.json', () => { expect(validate({ apiProxy: { targets: { copilot: { host: 'api.githubcopilot.com', basePath: '/v1' } } } })).toBe(false); }); + it('accepts copilot extraHeaders as string map', () => { + expect(validate({ + apiProxy: { + targets: { + copilot: { + host: 'api.githubcopilot.com', + extraHeaders: { 'x-session-id': 'run-42' }, + }, + }, + }, + })).toBe(true); + }); + + it('rejects non-string copilot extraHeaders values', () => { + expect(validate({ + apiProxy: { + targets: { + copilot: { + extraHeaders: { 'x-session-id': 42 }, + }, + }, + }, + })).toBe(false); + }); + it('accepts allowHostPorts as string or array of strings', () => { expect(validate({ security: { allowHostPorts: '5432' } })).toBe(true); expect(validate({ security: { allowHostPorts: ['5432', '6379'] } })).toBe(true); diff --git a/src/services/api-proxy-service-config.ts b/src/services/api-proxy-service-config.ts index 1eb06be94..bce770637 100644 --- a/src/services/api-proxy-service-config.ts +++ b/src/services/api-proxy-service-config.ts @@ -49,6 +49,9 @@ function buildProviderTargetEnv(config: WrapperConfig): Record { // Pre-startup model validation (non-sensitive config value) if (config.requestedModel) env.AWF_REQUESTED_MODEL = config.requestedModel; + if (config.copilotByokExtraHeaders !== undefined) { + env.AWF_BYOK_EXTRA_HEADERS = JSON.stringify(config.copilotByokExtraHeaders); + } return env; } diff --git a/src/services/api-proxy-service-env-forwarding.test.ts b/src/services/api-proxy-service-env-forwarding.test.ts index 1a3ece84d..69178d749 100644 --- a/src/services/api-proxy-service-env-forwarding.test.ts +++ b/src/services/api-proxy-service-env-forwarding.test.ts @@ -556,6 +556,20 @@ describe('API proxy sidecar: env var forwarding', () => { expect(env.AWF_REQUESTED_MODEL).toBe('gpt-4o-2024-08-06'); }); + it('should forward copilotByokExtraHeaders as AWF_BYOK_EXTRA_HEADERS', () => { + const configWithProxy = { + ...mockConfig, + enableApiProxy: true, + copilotProviderApiKey: 'sk-test-key', + copilotByokExtraHeaders: { 'x-session-id': 'run-42', 'HTTP-Referer': 'https://example.com' }, + }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const env = result.services['api-proxy'].environment as Record; + expect(env.AWF_BYOK_EXTRA_HEADERS).toBe( + JSON.stringify({ 'x-session-id': 'run-42', 'HTTP-Referer': 'https://example.com' }), + ); + }); + it('should forward modelAliases as AWF_MODEL_ALIASES (JSON-wrapped)', () => { const aliases: Record = { 'gpt-4o': ['azure/gpt-4o-prod'] }; const configWithProxy = { diff --git a/src/types/api-proxy-options.ts b/src/types/api-proxy-options.ts index f643260e6..ce5a6c296 100644 --- a/src/types/api-proxy-options.ts +++ b/src/types/api-proxy-options.ts @@ -167,6 +167,19 @@ export interface ApiProxyOptions { */ copilotApiBasePath?: string; + /** + * Supplemental headers for Copilot BYOK upstream requests (non-sensitive). + * + * When set, these headers are JSON-encoded and passed to the API proxy as + * `AWF_BYOK_EXTRA_HEADERS`. They are only applied by the sidecar when + * `COPILOT_PROVIDER_API_KEY` is in use. + * + * Set via config file path `apiProxy.targets.copilot.extraHeaders`. + * + * @default undefined + */ + copilotByokExtraHeaders?: Record; + /** * Target hostname for OpenAI API requests (used by API proxy sidecar) *