From 934f22c4537b6b01f17145d9d20d5cf793cdc88d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 6 Jun 2026 15:47:03 +0000 Subject: [PATCH 1/2] Initial plan From d831be8ca7c8935544feac49c990a0f7d29d5a77 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 6 Jun 2026 15:58:46 +0000 Subject: [PATCH 2/2] refactor: split api-proxy env-forwarding tests by scenario --- .../api-proxy-service-anthropic-flags.test.ts | 159 +++++ ... => api-proxy-service-api-targets.test.ts} | 562 +----------------- src/services/api-proxy-service-byok.test.ts | 227 +++++++ .../api-proxy-service-misc-forwarding.test.ts | 190 ++++++ src/services/api-proxy-service-oidc.test.ts | 70 +++ 5 files changed, 648 insertions(+), 560 deletions(-) create mode 100644 src/services/api-proxy-service-anthropic-flags.test.ts rename src/services/{api-proxy-service-env-forwarding.test.ts => api-proxy-service-api-targets.test.ts} (50%) create mode 100644 src/services/api-proxy-service-byok.test.ts create mode 100644 src/services/api-proxy-service-misc-forwarding.test.ts create mode 100644 src/services/api-proxy-service-oidc.test.ts diff --git a/src/services/api-proxy-service-anthropic-flags.test.ts b/src/services/api-proxy-service-anthropic-flags.test.ts new file mode 100644 index 000000000..e89792292 --- /dev/null +++ b/src/services/api-proxy-service-anthropic-flags.test.ts @@ -0,0 +1,159 @@ +import { generateDockerCompose, WrapperConfig, baseConfig, useTempWorkDir } from './service-test-setup.test-utils'; +import { mockNetworkConfigWithProxy } from './api-proxy-service.test-utils'; + +// Create mock functions (must remain per-file — jest.mock() is hoisted before imports) + +// Mock execa module +// eslint-disable-next-line @typescript-eslint/no-require-imports +jest.mock('execa', () => require('../test-helpers/mock-execa.test-utils').execaMockFactory()); + +let mockConfig: WrapperConfig; + + +describe('API proxy sidecar: Anthropic env forwarding', () => { + useTempWorkDir( + baseConfig, + (config) => { + mockConfig = config; + }, + () => mockConfig + ); + + describe('AWF_ANTHROPIC_* env var forwarding', () => { + let savedEnv: Record; + const anthropicVars = [ + 'AWF_ANTHROPIC_AUTO_CACHE', + 'AWF_ANTHROPIC_CACHE_TAIL_TTL', + 'AWF_ANTHROPIC_DROP_TOOLS', + 'AWF_ANTHROPIC_STRIP_ANSI', + ]; + + beforeEach(() => { + savedEnv = {}; + for (const key of anthropicVars) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of anthropicVars) { + if (savedEnv[key] !== undefined) { + process.env[key] = savedEnv[key]; + } else { + delete process.env[key]; + } + } + }); + + it('should forward AWF_ANTHROPIC_AUTO_CACHE to api-proxy when set', () => { + process.env.AWF_ANTHROPIC_AUTO_CACHE = '1'; + const config = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-test' }; + const result = generateDockerCompose(config, mockNetworkConfigWithProxy); + const env = result.services['api-proxy'].environment as Record; + expect(env.AWF_ANTHROPIC_AUTO_CACHE).toBe('1'); + }); + + it('should not set AWF_ANTHROPIC_AUTO_CACHE when env var is not set', () => { + const config = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-test' }; + const result = generateDockerCompose(config, mockNetworkConfigWithProxy); + const env = result.services['api-proxy'].environment as Record; + expect(env.AWF_ANTHROPIC_AUTO_CACHE).toBeUndefined(); + }); + + it('should forward AWF_ANTHROPIC_CACHE_TAIL_TTL to api-proxy when set', () => { + process.env.AWF_ANTHROPIC_CACHE_TAIL_TTL = '1h'; + const config = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-test' }; + const result = generateDockerCompose(config, mockNetworkConfigWithProxy); + const env = result.services['api-proxy'].environment as Record; + expect(env.AWF_ANTHROPIC_CACHE_TAIL_TTL).toBe('1h'); + }); + + it('should forward AWF_ANTHROPIC_DROP_TOOLS to api-proxy when set', () => { + process.env.AWF_ANTHROPIC_DROP_TOOLS = 'NotebookEdit,CronCreate'; + const config = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-test' }; + const result = generateDockerCompose(config, mockNetworkConfigWithProxy); + const env = result.services['api-proxy'].environment as Record; + expect(env.AWF_ANTHROPIC_DROP_TOOLS).toBe('NotebookEdit,CronCreate'); + }); + + it('should forward AWF_ANTHROPIC_STRIP_ANSI to api-proxy when set', () => { + process.env.AWF_ANTHROPIC_STRIP_ANSI = '1'; + const config = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-test' }; + const result = generateDockerCompose(config, mockNetworkConfigWithProxy); + const env = result.services['api-proxy'].environment as Record; + expect(env.AWF_ANTHROPIC_STRIP_ANSI).toBe('1'); + }); + + it('should not set any AWF_ANTHROPIC_* vars when none are set in host env', () => { + const config = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-test' }; + const result = generateDockerCompose(config, mockNetworkConfigWithProxy); + const env = result.services['api-proxy'].environment as Record; + for (const key of anthropicVars) { + expect(env[key]).toBeUndefined(); + } + }); + }); + + describe('AWF_AUTH_ANTHROPIC_* WIF env var forwarding', () => { + let savedEnv: Record; + const wifVars = [ + 'AWF_AUTH_ANTHROPIC_FEDERATION_RULE_ID', + 'AWF_AUTH_ANTHROPIC_ORGANIZATION_ID', + 'AWF_AUTH_ANTHROPIC_SERVICE_ACCOUNT_ID', + 'AWF_AUTH_ANTHROPIC_WORKSPACE_ID', + 'AWF_AUTH_ANTHROPIC_TOKEN_URL', + ]; + + beforeEach(() => { + savedEnv = {}; + for (const key of wifVars) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of wifVars) { + if (savedEnv[key] !== undefined) { + process.env[key] = savedEnv[key]; + } else { + delete process.env[key]; + } + } + }); + + it('should forward all Anthropic WIF vars to api-proxy when set', () => { + process.env.AWF_AUTH_ANTHROPIC_FEDERATION_RULE_ID = 'fdrl_test'; + process.env.AWF_AUTH_ANTHROPIC_ORGANIZATION_ID = 'org-uuid-test'; + process.env.AWF_AUTH_ANTHROPIC_SERVICE_ACCOUNT_ID = 'svac_test'; + process.env.AWF_AUTH_ANTHROPIC_WORKSPACE_ID = 'wrkspc_test'; + process.env.AWF_AUTH_ANTHROPIC_TOKEN_URL = 'https://anthropic.internal.example/v1/oauth/token'; + const config = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-openai-test' }; + const result = generateDockerCompose(config, mockNetworkConfigWithProxy); + const env = result.services['api-proxy'].environment as Record; + expect(env.AWF_AUTH_ANTHROPIC_FEDERATION_RULE_ID).toBe('fdrl_test'); + expect(env.AWF_AUTH_ANTHROPIC_ORGANIZATION_ID).toBe('org-uuid-test'); + expect(env.AWF_AUTH_ANTHROPIC_SERVICE_ACCOUNT_ID).toBe('svac_test'); + expect(env.AWF_AUTH_ANTHROPIC_WORKSPACE_ID).toBe('wrkspc_test'); + expect(env.AWF_AUTH_ANTHROPIC_TOKEN_URL).toBe('https://anthropic.internal.example/v1/oauth/token'); + }); + + it('should forward AWF_AUTH_ANTHROPIC_WORKSPACE_ID independently when only it is set', () => { + process.env.AWF_AUTH_ANTHROPIC_WORKSPACE_ID = 'wrkspc_solo'; + const config = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-openai-test' }; + const result = generateDockerCompose(config, mockNetworkConfigWithProxy); + const env = result.services['api-proxy'].environment as Record; + expect(env.AWF_AUTH_ANTHROPIC_WORKSPACE_ID).toBe('wrkspc_solo'); + }); + + it('should not forward Anthropic WIF vars when none are set', () => { + const config = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-openai-test' }; + const result = generateDockerCompose(config, mockNetworkConfigWithProxy); + const env = result.services['api-proxy'].environment as Record; + for (const key of wifVars) { + expect(env[key]).toBeUndefined(); + } + }); + }); +}); diff --git a/src/services/api-proxy-service-env-forwarding.test.ts b/src/services/api-proxy-service-api-targets.test.ts similarity index 50% rename from src/services/api-proxy-service-env-forwarding.test.ts rename to src/services/api-proxy-service-api-targets.test.ts index 5f7d1f686..d430096c3 100644 --- a/src/services/api-proxy-service-env-forwarding.test.ts +++ b/src/services/api-proxy-service-api-targets.test.ts @@ -11,7 +11,8 @@ jest.mock('execa', () => require('../test-helpers/mock-execa.test-utils').execaM let mockConfig: WrapperConfig; -describe('API proxy sidecar: env var forwarding', () => { + +describe('API proxy sidecar: API targets and auth forwarding', () => { useTempWorkDir( baseConfig, (config) => { @@ -20,193 +21,6 @@ describe('API proxy sidecar: env var forwarding', () => { () => mockConfig ); - describe('OIDC runtime env forwarding', () => { - let savedEnv: Record; - const oidcVars = [ - 'AWF_AUTH_TYPE', - 'ACTIONS_ID_TOKEN_REQUEST_URL', - 'ACTIONS_ID_TOKEN_REQUEST_TOKEN', - ]; - - beforeEach(() => { - savedEnv = {}; - for (const key of oidcVars) { - savedEnv[key] = process.env[key]; - delete process.env[key]; - } - }); - - afterEach(() => { - for (const key of oidcVars) { - if (savedEnv[key] !== undefined) { - process.env[key] = savedEnv[key]; - } else { - delete process.env[key]; - } - } - }); - - it('should forward ACTIONS_ID_TOKEN_REQUEST_* when AWF_AUTH_TYPE normalizes to github-oidc', () => { - process.env.AWF_AUTH_TYPE = ' GitHub-OIDC '; - process.env.ACTIONS_ID_TOKEN_REQUEST_URL = 'https://actions.local/token'; - process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'runtime-token'; - const config = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-openai-test' }; - const result = generateDockerCompose(config, mockNetworkConfigWithProxy); - const env = result.services['api-proxy'].environment as Record; - expect(env.ACTIONS_ID_TOKEN_REQUEST_URL).toBe('https://actions.local/token'); - expect(env.ACTIONS_ID_TOKEN_REQUEST_TOKEN).toBe('runtime-token'); - }); - - it('should not forward ACTIONS_ID_TOKEN_REQUEST_* when AWF_AUTH_TYPE is not github-oidc', () => { - process.env.AWF_AUTH_TYPE = 'api-key'; - process.env.ACTIONS_ID_TOKEN_REQUEST_URL = 'https://actions.local/token'; - process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'runtime-token'; - const config = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-openai-test' }; - const result = generateDockerCompose(config, mockNetworkConfigWithProxy); - const env = result.services['api-proxy'].environment as Record; - expect(env.ACTIONS_ID_TOKEN_REQUEST_URL).toBeUndefined(); - expect(env.ACTIONS_ID_TOKEN_REQUEST_TOKEN).toBeUndefined(); - }); - }); - - describe('AWF_ANTHROPIC_* env var forwarding', () => { - let savedEnv: Record; - const anthropicVars = [ - 'AWF_ANTHROPIC_AUTO_CACHE', - 'AWF_ANTHROPIC_CACHE_TAIL_TTL', - 'AWF_ANTHROPIC_DROP_TOOLS', - 'AWF_ANTHROPIC_STRIP_ANSI', - ]; - - beforeEach(() => { - savedEnv = {}; - for (const key of anthropicVars) { - savedEnv[key] = process.env[key]; - delete process.env[key]; - } - }); - - afterEach(() => { - for (const key of anthropicVars) { - if (savedEnv[key] !== undefined) { - process.env[key] = savedEnv[key]; - } else { - delete process.env[key]; - } - } - }); - - it('should forward AWF_ANTHROPIC_AUTO_CACHE to api-proxy when set', () => { - process.env.AWF_ANTHROPIC_AUTO_CACHE = '1'; - const config = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-test' }; - const result = generateDockerCompose(config, mockNetworkConfigWithProxy); - const env = result.services['api-proxy'].environment as Record; - expect(env.AWF_ANTHROPIC_AUTO_CACHE).toBe('1'); - }); - - it('should not set AWF_ANTHROPIC_AUTO_CACHE when env var is not set', () => { - const config = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-test' }; - const result = generateDockerCompose(config, mockNetworkConfigWithProxy); - const env = result.services['api-proxy'].environment as Record; - expect(env.AWF_ANTHROPIC_AUTO_CACHE).toBeUndefined(); - }); - - it('should forward AWF_ANTHROPIC_CACHE_TAIL_TTL to api-proxy when set', () => { - process.env.AWF_ANTHROPIC_CACHE_TAIL_TTL = '1h'; - const config = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-test' }; - const result = generateDockerCompose(config, mockNetworkConfigWithProxy); - const env = result.services['api-proxy'].environment as Record; - expect(env.AWF_ANTHROPIC_CACHE_TAIL_TTL).toBe('1h'); - }); - - it('should forward AWF_ANTHROPIC_DROP_TOOLS to api-proxy when set', () => { - process.env.AWF_ANTHROPIC_DROP_TOOLS = 'NotebookEdit,CronCreate'; - const config = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-test' }; - const result = generateDockerCompose(config, mockNetworkConfigWithProxy); - const env = result.services['api-proxy'].environment as Record; - expect(env.AWF_ANTHROPIC_DROP_TOOLS).toBe('NotebookEdit,CronCreate'); - }); - - it('should forward AWF_ANTHROPIC_STRIP_ANSI to api-proxy when set', () => { - process.env.AWF_ANTHROPIC_STRIP_ANSI = '1'; - const config = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-test' }; - const result = generateDockerCompose(config, mockNetworkConfigWithProxy); - const env = result.services['api-proxy'].environment as Record; - expect(env.AWF_ANTHROPIC_STRIP_ANSI).toBe('1'); - }); - - it('should not set any AWF_ANTHROPIC_* vars when none are set in host env', () => { - const config = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-test' }; - const result = generateDockerCompose(config, mockNetworkConfigWithProxy); - const env = result.services['api-proxy'].environment as Record; - for (const key of anthropicVars) { - expect(env[key]).toBeUndefined(); - } - }); - }); - - describe('AWF_AUTH_ANTHROPIC_* WIF env var forwarding', () => { - let savedEnv: Record; - const wifVars = [ - 'AWF_AUTH_ANTHROPIC_FEDERATION_RULE_ID', - 'AWF_AUTH_ANTHROPIC_ORGANIZATION_ID', - 'AWF_AUTH_ANTHROPIC_SERVICE_ACCOUNT_ID', - 'AWF_AUTH_ANTHROPIC_WORKSPACE_ID', - 'AWF_AUTH_ANTHROPIC_TOKEN_URL', - ]; - - beforeEach(() => { - savedEnv = {}; - for (const key of wifVars) { - savedEnv[key] = process.env[key]; - delete process.env[key]; - } - }); - - afterEach(() => { - for (const key of wifVars) { - if (savedEnv[key] !== undefined) { - process.env[key] = savedEnv[key]; - } else { - delete process.env[key]; - } - } - }); - - it('should forward all Anthropic WIF vars to api-proxy when set', () => { - process.env.AWF_AUTH_ANTHROPIC_FEDERATION_RULE_ID = 'fdrl_test'; - process.env.AWF_AUTH_ANTHROPIC_ORGANIZATION_ID = 'org-uuid-test'; - process.env.AWF_AUTH_ANTHROPIC_SERVICE_ACCOUNT_ID = 'svac_test'; - process.env.AWF_AUTH_ANTHROPIC_WORKSPACE_ID = 'wrkspc_test'; - process.env.AWF_AUTH_ANTHROPIC_TOKEN_URL = 'https://anthropic.internal.example/v1/oauth/token'; - const config = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-openai-test' }; - const result = generateDockerCompose(config, mockNetworkConfigWithProxy); - const env = result.services['api-proxy'].environment as Record; - expect(env.AWF_AUTH_ANTHROPIC_FEDERATION_RULE_ID).toBe('fdrl_test'); - expect(env.AWF_AUTH_ANTHROPIC_ORGANIZATION_ID).toBe('org-uuid-test'); - expect(env.AWF_AUTH_ANTHROPIC_SERVICE_ACCOUNT_ID).toBe('svac_test'); - expect(env.AWF_AUTH_ANTHROPIC_WORKSPACE_ID).toBe('wrkspc_test'); - expect(env.AWF_AUTH_ANTHROPIC_TOKEN_URL).toBe('https://anthropic.internal.example/v1/oauth/token'); - }); - - it('should forward AWF_AUTH_ANTHROPIC_WORKSPACE_ID independently when only it is set', () => { - process.env.AWF_AUTH_ANTHROPIC_WORKSPACE_ID = 'wrkspc_solo'; - const config = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-openai-test' }; - const result = generateDockerCompose(config, mockNetworkConfigWithProxy); - const env = result.services['api-proxy'].environment as Record; - expect(env.AWF_AUTH_ANTHROPIC_WORKSPACE_ID).toBe('wrkspc_solo'); - }); - - it('should not forward Anthropic WIF vars when none are set', () => { - const config = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-openai-test' }; - const result = generateDockerCompose(config, mockNetworkConfigWithProxy); - const env = result.services['api-proxy'].environment as Record; - for (const key of wifVars) { - expect(env[key]).toBeUndefined(); - } - }); - }); - it('should set OPENAI_API_TARGET in api-proxy when openaiApiTarget is provided', () => { const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key', openaiApiTarget: 'custom.openai-router.internal' }; const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); @@ -400,378 +214,6 @@ describe('API proxy sidecar: env var forwarding', () => { expect(env.COPILOT_PROVIDER_BASE_URL).toBe('https://example-resource.openai.azure.com/openai/deployments/test'); }); - describe('config-driven modelRouter.baseUrl triggers agent-side BYOK routing', () => { - // When apiProxy.modelRouter.baseUrl is set in AWF config (stored as - // config.copilotProviderBaseUrl), the agent must be routed through the sidecar - // the same way it would be when COPILOT_PROVIDER_BASE_URL is supplied via - // --env / --env-file / --env-all. Without this wiring, the sidecar env is - // configured but COPILOT_OFFLINE / agent COPILOT_PROVIDER_BASE_URL are never - // set, so Copilot CLI would bypass the proxy entirely. - - it('should set agent COPILOT_PROVIDER_BASE_URL to sidecar URL', () => { - const configWithProxy = { - ...mockConfig, - enableApiProxy: true, - copilotProviderBaseUrl: 'https://example-resource.openai.azure.com/openai/deployments/my-router', - }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const env = result.services.agent.environment as Record; - expect(env.COPILOT_PROVIDER_BASE_URL).toBe('http://172.30.0.30:10002'); - }); - - it('should set agent COPILOT_OFFLINE=true', () => { - const configWithProxy = { - ...mockConfig, - enableApiProxy: true, - copilotProviderBaseUrl: 'https://example-resource.openai.azure.com/openai/deployments/my-router', - }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const env = result.services.agent.environment as Record; - expect(env.COPILOT_OFFLINE).toBe('true'); - }); - - it('should forward the real baseUrl to the sidecar', () => { - const configWithProxy = { - ...mockConfig, - enableApiProxy: true, - copilotProviderBaseUrl: 'https://example-resource.openai.azure.com/openai/deployments/my-router', - }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const proxyEnv = result.services['api-proxy'].environment as Record; - expect(proxyEnv.COPILOT_PROVIDER_BASE_URL).toBe('https://example-resource.openai.azure.com/openai/deployments/my-router'); - }); - - it('should NOT inject a COPILOT_PROVIDER_API_KEY placeholder when no key was supplied', () => { - const configWithProxy = { - ...mockConfig, - enableApiProxy: true, - copilotProviderBaseUrl: 'https://example-resource.openai.azure.com/openai/deployments/my-router', - }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const env = result.services.agent.environment as Record; - expect(env.COPILOT_PROVIDER_API_KEY).toBeUndefined(); - }); - }); - - describe('direct-BYOK mode (user-supplied COPILOT_PROVIDER_API_KEY without COPILOT_GITHUB_TOKEN)', () => { - // When the user points Copilot CLI at an arbitrary upstream (Azure Foundry, - // OpenRouter, etc.) via COPILOT_PROVIDER_BASE_URL + COPILOT_PROVIDER_API_KEY, - // AWF must still: - // 1. Route the agent's Copilot CLI through the sidecar (set agent - // COPILOT_PROVIDER_BASE_URL=http://sidecar, COPILOT_OFFLINE=true). - // 2. Forward the user's real COPILOT_PROVIDER_BASE_URL to the sidecar and the - // real COPILOT_PROVIDER_API_KEY from config (so it knows the real upstream - // and credential). - // 3. Replace COPILOT_PROVIDER_API_KEY in the agent env with a placeholder so - // the real key never reaches the agent. - - it('should set agent COPILOT_PROVIDER_BASE_URL to sidecar URL', () => { - const configWithProxy = { - ...mockConfig, - enableApiProxy: true, - copilotProviderApiKey: 'azure-byok-key', - additionalEnv: { - COPILOT_PROVIDER_BASE_URL: 'https://example-resource.openai.azure.com/openai/deployments/test', - }, - }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const env = result.services.agent.environment as Record; - expect(env.COPILOT_PROVIDER_BASE_URL).toBe('http://172.30.0.30:10002'); - }); - - it('should set agent COPILOT_OFFLINE=true', () => { - const configWithProxy = { - ...mockConfig, - enableApiProxy: true, - copilotProviderApiKey: 'azure-byok-key', - }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const env = result.services.agent.environment as Record; - expect(env.COPILOT_OFFLINE).toBe('true'); - }); - - it('should mask real COPILOT_PROVIDER_API_KEY in agent env with placeholder', () => { - const configWithProxy = { - ...mockConfig, - enableApiProxy: true, - copilotProviderApiKey: 'azure-byok-key', - }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const env = result.services.agent.environment as Record; - expect(env.COPILOT_PROVIDER_API_KEY).toBe('ghu_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); - expect(env.COPILOT_PROVIDER_API_KEY).not.toBe('azure-byok-key'); - }); - - it('should still forward the real COPILOT_PROVIDER_API_KEY to the sidecar', () => { - const configWithProxy = { - ...mockConfig, - enableApiProxy: true, - copilotProviderApiKey: 'azure-byok-key', - }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const proxyEnv = result.services['api-proxy'].environment as Record; - expect(proxyEnv.COPILOT_PROVIDER_API_KEY).toBe('azure-byok-key'); - }); - - it('should NOT set agent COPILOT_GITHUB_TOKEN placeholder when no GitHub token was provided', () => { - const configWithProxy = { - ...mockConfig, - enableApiProxy: true, - copilotProviderApiKey: 'azure-byok-key', - }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const env = result.services.agent.environment as Record; - // GitHub token placeholder is only set when the user actually supplied a token - // to mask. In direct-BYOK mode there is nothing to mask. - expect(env.COPILOT_GITHUB_TOKEN).toBeUndefined(); - }); - }); - - describe('COPILOT_PROVIDER_BASE_URL-only trigger (defense-in-depth)', () => { - // When the user supplies only COPILOT_PROVIDER_BASE_URL (no key, no GitHub - // token), AWF still routes the agent's Copilot CLI through the sidecar so - // the real BASE_URL never leaks into the agent env. The sidecar itself does - // not yet support unauthenticated upstreams, but routing here preserves the - // credential-isolation invariant and produces a clear 503 from the sidecar - // rather than a silent direct connection. - - it('should set agent COPILOT_PROVIDER_BASE_URL to sidecar URL', () => { - const configWithProxy = { - ...mockConfig, - enableApiProxy: true, - additionalEnv: { - COPILOT_PROVIDER_BASE_URL: 'http://intranet-llm.example/v1', - }, - }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const env = result.services.agent.environment as Record; - expect(env.COPILOT_PROVIDER_BASE_URL).toBe('http://172.30.0.30:10002'); - }); - - it('should set agent COPILOT_OFFLINE=true', () => { - const configWithProxy = { - ...mockConfig, - enableApiProxy: true, - additionalEnv: { - COPILOT_PROVIDER_BASE_URL: 'http://intranet-llm.example/v1', - }, - }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const env = result.services.agent.environment as Record; - expect(env.COPILOT_OFFLINE).toBe('true'); - }); - - it('should NOT inject a COPILOT_PROVIDER_API_KEY placeholder when no key was supplied', () => { - const configWithProxy = { - ...mockConfig, - enableApiProxy: true, - additionalEnv: { - COPILOT_PROVIDER_BASE_URL: 'http://intranet-llm.example/v1', - }, - }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const env = result.services.agent.environment as Record; - // Nothing to mask → no placeholder. Injecting one would falsely tell - // Copilot CLI a key is configured. - expect(env.COPILOT_PROVIDER_API_KEY).toBeUndefined(); - }); - - it('should forward the real COPILOT_PROVIDER_BASE_URL to the sidecar', () => { - const configWithProxy = { - ...mockConfig, - enableApiProxy: true, - additionalEnv: { - COPILOT_PROVIDER_BASE_URL: 'http://intranet-llm.example/v1', - }, - }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const proxyEnv = result.services['api-proxy'].environment as Record; - expect(proxyEnv.COPILOT_PROVIDER_BASE_URL).toBe('http://intranet-llm.example/v1'); - }); - - it('should fire from envFile (not just additionalEnv)', () => { - const envFilePath = path.join(mockConfig.workDir, '.env.copilot-base-url-only'); - fs.writeFileSync(envFilePath, 'COPILOT_PROVIDER_BASE_URL=http://intranet-llm.example/v1\n'); - const configWithProxy = { - ...mockConfig, - enableApiProxy: true, - envFile: envFilePath, - }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const env = result.services.agent.environment as Record; - expect(env.COPILOT_PROVIDER_BASE_URL).toBe('http://172.30.0.30:10002'); - expect(env.COPILOT_OFFLINE).toBe('true'); - }); - }); - - describe('Optional WrapperConfig fields propagate to api-proxy env', () => { - // These cover branches in api-proxy-service-config.ts that were previously - // untested. Each conditional spread (e.g. `...(config.X && { ... })`) becomes - // an istanbul branch; setting the field exercises the truthy arm. Without - // these tests, only the falsy arm is covered and overall branch coverage - // erodes whenever unrelated covered statements are removed elsewhere in the - // file (a math-by-construction percentage drop). - it('should forward requestedModel as AWF_REQUESTED_MODEL', () => { - const configWithProxy = { - ...mockConfig, - enableApiProxy: true, - openaiApiKey: 'sk-test-key', - requestedModel: 'gpt-4o-2024-08-06', - }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const env = result.services['api-proxy'].environment as Record; - 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 copilotByokExtraBodyFields as AWF_BYOK_EXTRA_BODY_FIELDS', () => { - const configWithProxy = { - ...mockConfig, - enableApiProxy: true, - copilotProviderApiKey: 'sk-test-key', - copilotByokExtraBodyFields: { session_id: 'run-42', user_id: 'octocat' }, - }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const env = result.services['api-proxy'].environment as Record; - expect(env.AWF_BYOK_EXTRA_BODY_FIELDS).toBe( - JSON.stringify({ session_id: 'run-42', user_id: 'octocat' }), - ); - }); - - describe('AWF_PROVIDER_SESSION_ID forwarding', () => { - const sessionIdVars = ['AWF_PROVIDER_SESSION_ID', 'GH_AW_GITHUB_RUN_ID', 'GITHUB_RUN_ID']; - let savedSessionEnv: Record; - - beforeEach(() => { - savedSessionEnv = {}; - for (const key of sessionIdVars) { - savedSessionEnv[key] = process.env[key]; - delete process.env[key]; - } - }); - - afterEach(() => { - for (const key of sessionIdVars) { - if (savedSessionEnv[key] !== undefined) process.env[key] = savedSessionEnv[key]; - else delete process.env[key]; - } - }); - - it('should not forward AWF_PROVIDER_SESSION_ID when only GITHUB_RUN_ID is set (auto-derivation removed)', () => { - process.env.GITHUB_RUN_ID = '123456789'; - const result = generateDockerCompose({ ...mockConfig, enableApiProxy: true }, mockNetworkConfigWithProxy); - const env = result.services['api-proxy'].environment as Record; - expect(env.AWF_PROVIDER_SESSION_ID).toBeUndefined(); - }); - - it('should forward AWF_PROVIDER_SESSION_ID from explicit copilotByokSessionId config', () => { - const result = generateDockerCompose( - { ...mockConfig, enableApiProxy: true, copilotByokSessionId: 'explicit-run-42' }, - mockNetworkConfigWithProxy, - ); - const env = result.services['api-proxy'].environment as Record; - expect(env.AWF_PROVIDER_SESSION_ID).toBe('explicit-run-42'); - }); - - it('should forward AWF_PROVIDER_SESSION_ID from explicit process.env when no config value is set', () => { - process.env.AWF_PROVIDER_SESSION_ID = 'explicit-from-env'; - const result = generateDockerCompose({ ...mockConfig, enableApiProxy: true }, mockNetworkConfigWithProxy); - const env = result.services['api-proxy'].environment as Record; - expect(env.AWF_PROVIDER_SESSION_ID).toBe('explicit-from-env'); - }); - }); - - it('should forward modelAliases as AWF_MODEL_ALIASES (JSON-wrapped)', () => { - const aliases: Record = { 'gpt-4o': ['azure/gpt-4o-prod'] }; - const configWithProxy = { - ...mockConfig, - enableApiProxy: true, - openaiApiKey: 'sk-test-key', - modelAliases: aliases, - }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const env = result.services['api-proxy'].environment as Record; - expect(env.AWF_MODEL_ALIASES).toBe(JSON.stringify({ models: aliases })); - }); - - it('should forward modelFallback as AWF_MODEL_FALLBACK (JSON-stringified)', () => { - const fallback = { enabled: true, strategy: 'middle_power' as const }; - const configWithProxy = { - ...mockConfig, - enableApiProxy: true, - openaiApiKey: 'sk-test-key', - modelFallback: fallback, - }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const env = result.services['api-proxy'].environment as Record; - expect(env.AWF_MODEL_FALLBACK).toBe(JSON.stringify(fallback)); - }); - - it('should forward enableTokenSteering as AWF_ENABLE_TOKEN_STEERING=true', () => { - const configWithProxy = { - ...mockConfig, - enableApiProxy: true, - openaiApiKey: 'sk-test-key', - enableTokenSteering: true, - }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const env = result.services['api-proxy'].environment as Record; - expect(env.AWF_ENABLE_TOKEN_STEERING).toBe('true'); - }); - - it('should forward debugTokens and tokenLogDir as AWF_DEBUG_TOKENS / AWF_TOKEN_LOG_DIR', () => { - const configWithProxy = { - ...mockConfig, - enableApiProxy: true, - openaiApiKey: 'sk-test-key', - debugTokens: true, - tokenLogDir: '/var/log/awf/tokens', - }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const env = result.services['api-proxy'].environment as Record; - expect(env.AWF_DEBUG_TOKENS).toBe('1'); - expect(env.AWF_TOKEN_LOG_DIR).toBe('/var/log/awf/tokens'); - }); - - it('should forward anthropicAutoCache with cache-tail-ttl as AWF_ANTHROPIC_AUTO_CACHE / TTL', () => { - const configWithProxy = { - ...mockConfig, - enableApiProxy: true, - anthropicApiKey: 'sk-ant-test', - anthropicAutoCache: true, - anthropicCacheTailTtl: '5m' as const, - }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const env = result.services['api-proxy'].environment as Record; - expect(env.AWF_ANTHROPIC_AUTO_CACHE).toBe('1'); - expect(env.AWF_ANTHROPIC_CACHE_TAIL_TTL).toBe('5m'); - }); - - it('should forward anthropicTokenUrl as AWF_AUTH_ANTHROPIC_TOKEN_URL', () => { - const configWithProxy = { - ...mockConfig, - enableApiProxy: true, - anthropicApiKey: 'sk-ant-test', - anthropicTokenUrl: 'https://auth.anthropic.example/oauth/token', - }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const env = result.services['api-proxy'].environment as Record; - expect(env.AWF_AUTH_ANTHROPIC_TOKEN_URL).toBe('https://auth.anthropic.example/oauth/token'); - }); - }); 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 = { diff --git a/src/services/api-proxy-service-byok.test.ts b/src/services/api-proxy-service-byok.test.ts new file mode 100644 index 000000000..7b883dd3d --- /dev/null +++ b/src/services/api-proxy-service-byok.test.ts @@ -0,0 +1,227 @@ +import { generateDockerCompose, WrapperConfig, baseConfig, useTempWorkDir } from './service-test-setup.test-utils'; +import { mockNetworkConfigWithProxy } from './api-proxy-service.test-utils'; +import * as fs from 'fs'; +import * as path from 'path'; + +// Create mock functions (must remain per-file — jest.mock() is hoisted before imports) + +// Mock execa module +// eslint-disable-next-line @typescript-eslint/no-require-imports +jest.mock('execa', () => require('../test-helpers/mock-execa.test-utils').execaMockFactory()); + +let mockConfig: WrapperConfig; + + +describe('API proxy sidecar: BYOK env forwarding', () => { + useTempWorkDir( + baseConfig, + (config) => { + mockConfig = config; + }, + () => mockConfig + ); + + describe('config-driven modelRouter.baseUrl triggers agent-side BYOK routing', () => { + // When apiProxy.modelRouter.baseUrl is set in AWF config (stored as + // config.copilotProviderBaseUrl), the agent must be routed through the sidecar + // the same way it would be when COPILOT_PROVIDER_BASE_URL is supplied via + // --env / --env-file / --env-all. Without this wiring, the sidecar env is + // configured but COPILOT_OFFLINE / agent COPILOT_PROVIDER_BASE_URL are never + // set, so Copilot CLI would bypass the proxy entirely. + + it('should set agent COPILOT_PROVIDER_BASE_URL to sidecar URL', () => { + const configWithProxy = { + ...mockConfig, + enableApiProxy: true, + copilotProviderBaseUrl: 'https://example-resource.openai.azure.com/openai/deployments/my-router', + }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const env = result.services.agent.environment as Record; + expect(env.COPILOT_PROVIDER_BASE_URL).toBe('http://172.30.0.30:10002'); + }); + + it('should set agent COPILOT_OFFLINE=true', () => { + const configWithProxy = { + ...mockConfig, + enableApiProxy: true, + copilotProviderBaseUrl: 'https://example-resource.openai.azure.com/openai/deployments/my-router', + }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const env = result.services.agent.environment as Record; + expect(env.COPILOT_OFFLINE).toBe('true'); + }); + + it('should forward the real baseUrl to the sidecar', () => { + const configWithProxy = { + ...mockConfig, + enableApiProxy: true, + copilotProviderBaseUrl: 'https://example-resource.openai.azure.com/openai/deployments/my-router', + }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const proxyEnv = result.services['api-proxy'].environment as Record; + expect(proxyEnv.COPILOT_PROVIDER_BASE_URL).toBe('https://example-resource.openai.azure.com/openai/deployments/my-router'); + }); + + it('should NOT inject a COPILOT_PROVIDER_API_KEY placeholder when no key was supplied', () => { + const configWithProxy = { + ...mockConfig, + enableApiProxy: true, + copilotProviderBaseUrl: 'https://example-resource.openai.azure.com/openai/deployments/my-router', + }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const env = result.services.agent.environment as Record; + expect(env.COPILOT_PROVIDER_API_KEY).toBeUndefined(); + }); + }); + + describe('direct-BYOK mode (user-supplied COPILOT_PROVIDER_API_KEY without COPILOT_GITHUB_TOKEN)', () => { + // When the user points Copilot CLI at an arbitrary upstream (Azure Foundry, + // OpenRouter, etc.) via COPILOT_PROVIDER_BASE_URL + COPILOT_PROVIDER_API_KEY, + // AWF must still: + // 1. Route the agent's Copilot CLI through the sidecar (set agent + // COPILOT_PROVIDER_BASE_URL=http://sidecar, COPILOT_OFFLINE=true). + // 2. Forward the user's real COPILOT_PROVIDER_BASE_URL to the sidecar and the + // real COPILOT_PROVIDER_API_KEY from config (so it knows the real upstream + // and credential). + // 3. Replace COPILOT_PROVIDER_API_KEY in the agent env with a placeholder so + // the real key never reaches the agent. + + it('should set agent COPILOT_PROVIDER_BASE_URL to sidecar URL', () => { + const configWithProxy = { + ...mockConfig, + enableApiProxy: true, + copilotProviderApiKey: 'azure-byok-key', + additionalEnv: { + COPILOT_PROVIDER_BASE_URL: 'https://example-resource.openai.azure.com/openai/deployments/test', + }, + }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const env = result.services.agent.environment as Record; + expect(env.COPILOT_PROVIDER_BASE_URL).toBe('http://172.30.0.30:10002'); + }); + + it('should set agent COPILOT_OFFLINE=true', () => { + const configWithProxy = { + ...mockConfig, + enableApiProxy: true, + copilotProviderApiKey: 'azure-byok-key', + }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const env = result.services.agent.environment as Record; + expect(env.COPILOT_OFFLINE).toBe('true'); + }); + + it('should mask real COPILOT_PROVIDER_API_KEY in agent env with placeholder', () => { + const configWithProxy = { + ...mockConfig, + enableApiProxy: true, + copilotProviderApiKey: 'azure-byok-key', + }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const env = result.services.agent.environment as Record; + expect(env.COPILOT_PROVIDER_API_KEY).toBe('ghu_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); + expect(env.COPILOT_PROVIDER_API_KEY).not.toBe('azure-byok-key'); + }); + + it('should still forward the real COPILOT_PROVIDER_API_KEY to the sidecar', () => { + const configWithProxy = { + ...mockConfig, + enableApiProxy: true, + copilotProviderApiKey: 'azure-byok-key', + }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const proxyEnv = result.services['api-proxy'].environment as Record; + expect(proxyEnv.COPILOT_PROVIDER_API_KEY).toBe('azure-byok-key'); + }); + + it('should NOT set agent COPILOT_GITHUB_TOKEN placeholder when no GitHub token was provided', () => { + const configWithProxy = { + ...mockConfig, + enableApiProxy: true, + copilotProviderApiKey: 'azure-byok-key', + }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const env = result.services.agent.environment as Record; + // GitHub token placeholder is only set when the user actually supplied a token + // to mask. In direct-BYOK mode there is nothing to mask. + expect(env.COPILOT_GITHUB_TOKEN).toBeUndefined(); + }); + }); + + describe('COPILOT_PROVIDER_BASE_URL-only trigger (defense-in-depth)', () => { + // When the user supplies only COPILOT_PROVIDER_BASE_URL (no key, no GitHub + // token), AWF still routes the agent's Copilot CLI through the sidecar so + // the real BASE_URL never leaks into the agent env. The sidecar itself does + // not yet support unauthenticated upstreams, but routing here preserves the + // credential-isolation invariant and produces a clear 503 from the sidecar + // rather than a silent direct connection. + + it('should set agent COPILOT_PROVIDER_BASE_URL to sidecar URL', () => { + const configWithProxy = { + ...mockConfig, + enableApiProxy: true, + additionalEnv: { + COPILOT_PROVIDER_BASE_URL: 'http://intranet-llm.example/v1', + }, + }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const env = result.services.agent.environment as Record; + expect(env.COPILOT_PROVIDER_BASE_URL).toBe('http://172.30.0.30:10002'); + }); + + it('should set agent COPILOT_OFFLINE=true', () => { + const configWithProxy = { + ...mockConfig, + enableApiProxy: true, + additionalEnv: { + COPILOT_PROVIDER_BASE_URL: 'http://intranet-llm.example/v1', + }, + }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const env = result.services.agent.environment as Record; + expect(env.COPILOT_OFFLINE).toBe('true'); + }); + + it('should NOT inject a COPILOT_PROVIDER_API_KEY placeholder when no key was supplied', () => { + const configWithProxy = { + ...mockConfig, + enableApiProxy: true, + additionalEnv: { + COPILOT_PROVIDER_BASE_URL: 'http://intranet-llm.example/v1', + }, + }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const env = result.services.agent.environment as Record; + // Nothing to mask → no placeholder. Injecting one would falsely tell + // Copilot CLI a key is configured. + expect(env.COPILOT_PROVIDER_API_KEY).toBeUndefined(); + }); + + it('should forward the real COPILOT_PROVIDER_BASE_URL to the sidecar', () => { + const configWithProxy = { + ...mockConfig, + enableApiProxy: true, + additionalEnv: { + COPILOT_PROVIDER_BASE_URL: 'http://intranet-llm.example/v1', + }, + }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const proxyEnv = result.services['api-proxy'].environment as Record; + expect(proxyEnv.COPILOT_PROVIDER_BASE_URL).toBe('http://intranet-llm.example/v1'); + }); + + it('should fire from envFile (not just additionalEnv)', () => { + const envFilePath = path.join(mockConfig.workDir, '.env.copilot-base-url-only'); + fs.writeFileSync(envFilePath, 'COPILOT_PROVIDER_BASE_URL=http://intranet-llm.example/v1\n'); + const configWithProxy = { + ...mockConfig, + enableApiProxy: true, + envFile: envFilePath, + }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const env = result.services.agent.environment as Record; + expect(env.COPILOT_PROVIDER_BASE_URL).toBe('http://172.30.0.30:10002'); + expect(env.COPILOT_OFFLINE).toBe('true'); + }); + }); +}); diff --git a/src/services/api-proxy-service-misc-forwarding.test.ts b/src/services/api-proxy-service-misc-forwarding.test.ts new file mode 100644 index 000000000..1efecccd7 --- /dev/null +++ b/src/services/api-proxy-service-misc-forwarding.test.ts @@ -0,0 +1,190 @@ +import { generateDockerCompose, WrapperConfig, baseConfig, useTempWorkDir } from './service-test-setup.test-utils'; +import { mockNetworkConfigWithProxy } from './api-proxy-service.test-utils'; + +// Create mock functions (must remain per-file — jest.mock() is hoisted before imports) + +// Mock execa module +// eslint-disable-next-line @typescript-eslint/no-require-imports +jest.mock('execa', () => require('../test-helpers/mock-execa.test-utils').execaMockFactory()); + +let mockConfig: WrapperConfig; + + +describe('API proxy sidecar: miscellaneous env forwarding', () => { + useTempWorkDir( + baseConfig, + (config) => { + mockConfig = config; + }, + () => mockConfig + ); + + describe('Optional WrapperConfig fields propagate to api-proxy env', () => { + // These cover branches in api-proxy-service-config.ts that were previously + // untested. Each conditional spread (e.g. `...(config.X && { ... })`) becomes + // an istanbul branch; setting the field exercises the truthy arm. Without + // these tests, only the falsy arm is covered and overall branch coverage + // erodes whenever unrelated covered statements are removed elsewhere in the + // file (a math-by-construction percentage drop). + it('should forward requestedModel as AWF_REQUESTED_MODEL', () => { + const configWithProxy = { + ...mockConfig, + enableApiProxy: true, + openaiApiKey: 'sk-test-key', + requestedModel: 'gpt-4o-2024-08-06', + }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const env = result.services['api-proxy'].environment as Record; + 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 copilotByokExtraBodyFields as AWF_BYOK_EXTRA_BODY_FIELDS', () => { + const configWithProxy = { + ...mockConfig, + enableApiProxy: true, + copilotProviderApiKey: 'sk-test-key', + copilotByokExtraBodyFields: { session_id: 'run-42', user_id: 'octocat' }, + }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const env = result.services['api-proxy'].environment as Record; + expect(env.AWF_BYOK_EXTRA_BODY_FIELDS).toBe( + JSON.stringify({ session_id: 'run-42', user_id: 'octocat' }), + ); + }); + + describe('AWF_PROVIDER_SESSION_ID forwarding', () => { + const sessionIdVars = ['AWF_PROVIDER_SESSION_ID', 'GH_AW_GITHUB_RUN_ID', 'GITHUB_RUN_ID']; + let savedSessionEnv: Record; + + beforeEach(() => { + savedSessionEnv = {}; + for (const key of sessionIdVars) { + savedSessionEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of sessionIdVars) { + if (savedSessionEnv[key] !== undefined) process.env[key] = savedSessionEnv[key]; + else delete process.env[key]; + } + }); + + it('should not forward AWF_PROVIDER_SESSION_ID when only GITHUB_RUN_ID is set (auto-derivation removed)', () => { + process.env.GITHUB_RUN_ID = '123456789'; + const result = generateDockerCompose({ ...mockConfig, enableApiProxy: true }, mockNetworkConfigWithProxy); + const env = result.services['api-proxy'].environment as Record; + expect(env.AWF_PROVIDER_SESSION_ID).toBeUndefined(); + }); + + it('should forward AWF_PROVIDER_SESSION_ID from explicit copilotByokSessionId config', () => { + const result = generateDockerCompose( + { ...mockConfig, enableApiProxy: true, copilotByokSessionId: 'explicit-run-42' }, + mockNetworkConfigWithProxy, + ); + const env = result.services['api-proxy'].environment as Record; + expect(env.AWF_PROVIDER_SESSION_ID).toBe('explicit-run-42'); + }); + + it('should forward AWF_PROVIDER_SESSION_ID from explicit process.env when no config value is set', () => { + process.env.AWF_PROVIDER_SESSION_ID = 'explicit-from-env'; + const result = generateDockerCompose({ ...mockConfig, enableApiProxy: true }, mockNetworkConfigWithProxy); + const env = result.services['api-proxy'].environment as Record; + expect(env.AWF_PROVIDER_SESSION_ID).toBe('explicit-from-env'); + }); + }); + + it('should forward modelAliases as AWF_MODEL_ALIASES (JSON-wrapped)', () => { + const aliases: Record = { 'gpt-4o': ['azure/gpt-4o-prod'] }; + const configWithProxy = { + ...mockConfig, + enableApiProxy: true, + openaiApiKey: 'sk-test-key', + modelAliases: aliases, + }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const env = result.services['api-proxy'].environment as Record; + expect(env.AWF_MODEL_ALIASES).toBe(JSON.stringify({ models: aliases })); + }); + + it('should forward modelFallback as AWF_MODEL_FALLBACK (JSON-stringified)', () => { + const fallback = { enabled: true, strategy: 'middle_power' as const }; + const configWithProxy = { + ...mockConfig, + enableApiProxy: true, + openaiApiKey: 'sk-test-key', + modelFallback: fallback, + }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const env = result.services['api-proxy'].environment as Record; + expect(env.AWF_MODEL_FALLBACK).toBe(JSON.stringify(fallback)); + }); + + it('should forward enableTokenSteering as AWF_ENABLE_TOKEN_STEERING=true', () => { + const configWithProxy = { + ...mockConfig, + enableApiProxy: true, + openaiApiKey: 'sk-test-key', + enableTokenSteering: true, + }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const env = result.services['api-proxy'].environment as Record; + expect(env.AWF_ENABLE_TOKEN_STEERING).toBe('true'); + }); + + it('should forward debugTokens and tokenLogDir as AWF_DEBUG_TOKENS / AWF_TOKEN_LOG_DIR', () => { + const configWithProxy = { + ...mockConfig, + enableApiProxy: true, + openaiApiKey: 'sk-test-key', + debugTokens: true, + tokenLogDir: '/var/log/awf/tokens', + }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const env = result.services['api-proxy'].environment as Record; + expect(env.AWF_DEBUG_TOKENS).toBe('1'); + expect(env.AWF_TOKEN_LOG_DIR).toBe('/var/log/awf/tokens'); + }); + + it('should forward anthropicAutoCache with cache-tail-ttl as AWF_ANTHROPIC_AUTO_CACHE / TTL', () => { + const configWithProxy = { + ...mockConfig, + enableApiProxy: true, + anthropicApiKey: 'sk-ant-test', + anthropicAutoCache: true, + anthropicCacheTailTtl: '5m' as const, + }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const env = result.services['api-proxy'].environment as Record; + expect(env.AWF_ANTHROPIC_AUTO_CACHE).toBe('1'); + expect(env.AWF_ANTHROPIC_CACHE_TAIL_TTL).toBe('5m'); + }); + + it('should forward anthropicTokenUrl as AWF_AUTH_ANTHROPIC_TOKEN_URL', () => { + const configWithProxy = { + ...mockConfig, + enableApiProxy: true, + anthropicApiKey: 'sk-ant-test', + anthropicTokenUrl: 'https://auth.anthropic.example/oauth/token', + }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const env = result.services['api-proxy'].environment as Record; + expect(env.AWF_AUTH_ANTHROPIC_TOKEN_URL).toBe('https://auth.anthropic.example/oauth/token'); + }); + }); +}); diff --git a/src/services/api-proxy-service-oidc.test.ts b/src/services/api-proxy-service-oidc.test.ts new file mode 100644 index 000000000..da30be2e9 --- /dev/null +++ b/src/services/api-proxy-service-oidc.test.ts @@ -0,0 +1,70 @@ +import { generateDockerCompose, WrapperConfig, baseConfig, useTempWorkDir } from './service-test-setup.test-utils'; +import { mockNetworkConfigWithProxy } from './api-proxy-service.test-utils'; + +// Create mock functions (must remain per-file — jest.mock() is hoisted before imports) + +// Mock execa module +// eslint-disable-next-line @typescript-eslint/no-require-imports +jest.mock('execa', () => require('../test-helpers/mock-execa.test-utils').execaMockFactory()); + +let mockConfig: WrapperConfig; + + +describe('API proxy sidecar: OIDC env forwarding', () => { + useTempWorkDir( + baseConfig, + (config) => { + mockConfig = config; + }, + () => mockConfig + ); + + describe('OIDC runtime env forwarding', () => { + let savedEnv: Record; + const oidcVars = [ + 'AWF_AUTH_TYPE', + 'ACTIONS_ID_TOKEN_REQUEST_URL', + 'ACTIONS_ID_TOKEN_REQUEST_TOKEN', + ]; + + beforeEach(() => { + savedEnv = {}; + for (const key of oidcVars) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of oidcVars) { + if (savedEnv[key] !== undefined) { + process.env[key] = savedEnv[key]; + } else { + delete process.env[key]; + } + } + }); + + it('should forward ACTIONS_ID_TOKEN_REQUEST_* when AWF_AUTH_TYPE normalizes to github-oidc', () => { + process.env.AWF_AUTH_TYPE = ' GitHub-OIDC '; + process.env.ACTIONS_ID_TOKEN_REQUEST_URL = 'https://actions.local/token'; + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'runtime-token'; + const config = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-openai-test' }; + const result = generateDockerCompose(config, mockNetworkConfigWithProxy); + const env = result.services['api-proxy'].environment as Record; + expect(env.ACTIONS_ID_TOKEN_REQUEST_URL).toBe('https://actions.local/token'); + expect(env.ACTIONS_ID_TOKEN_REQUEST_TOKEN).toBe('runtime-token'); + }); + + it('should not forward ACTIONS_ID_TOKEN_REQUEST_* when AWF_AUTH_TYPE is not github-oidc', () => { + process.env.AWF_AUTH_TYPE = 'api-key'; + process.env.ACTIONS_ID_TOKEN_REQUEST_URL = 'https://actions.local/token'; + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'runtime-token'; + const config = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-openai-test' }; + const result = generateDockerCompose(config, mockNetworkConfigWithProxy); + const env = result.services['api-proxy'].environment as Record; + expect(env.ACTIONS_ID_TOKEN_REQUEST_URL).toBeUndefined(); + expect(env.ACTIONS_ID_TOKEN_REQUEST_TOKEN).toBeUndefined(); + }); + }); +});