diff --git a/src/services/api-proxy-service-config.test.ts b/src/services/api-proxy-service-config.test.ts new file mode 100644 index 00000000..d1797d18 --- /dev/null +++ b/src/services/api-proxy-service-config.test.ts @@ -0,0 +1,239 @@ +import { generateDockerCompose } from '../compose-generator'; +import { WrapperConfig } from '../types'; +import { baseConfig, mockNetworkConfig, useTempWorkDir } from '../test-helpers/docker-test-fixtures.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: service configuration', () => { + useTempWorkDir( + baseConfig, + (config) => { + mockConfig = config; + }, + () => mockConfig + ); + + it('should not include api-proxy service when enableApiProxy is false', () => { + const result = generateDockerCompose(mockConfig, mockNetworkConfigWithProxy); + expect(result.services['api-proxy']).toBeUndefined(); + }); + + it('should not include api-proxy service when enableApiProxy is true but no proxyIp', () => { + const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfig); + expect(result.services['api-proxy']).toBeUndefined(); + }); + + it('should include api-proxy service when enableApiProxy is true with OpenAI key', () => { + const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-openai-key' }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + expect(result.services['api-proxy']).toBeDefined(); + const proxy = result.services['api-proxy']; + expect(proxy.container_name).toBe('awf-api-proxy'); + expect((proxy.networks as any)['awf-net'].ipv4_address).toBe('172.30.0.30'); + }); + + it('should include api-proxy service when enableApiProxy is true with Anthropic key', () => { + const configWithProxy = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-test-key' }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + expect(result.services['api-proxy']).toBeDefined(); + const proxy = result.services['api-proxy']; + expect(proxy.container_name).toBe('awf-api-proxy'); + }); + + it('should include api-proxy service with both keys', () => { + const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-openai-key', anthropicApiKey: 'sk-ant-test-key' }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + expect(result.services['api-proxy']).toBeDefined(); + const proxy = result.services['api-proxy']; + const env = proxy.environment as Record; + expect(env.OPENAI_API_KEY).toBe('sk-test-openai-key'); + expect(env.ANTHROPIC_API_KEY).toBe('sk-ant-test-key'); + }); + + it('should only pass OpenAI key when only OpenAI key is provided', () => { + const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-openai-key' }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const proxy = result.services['api-proxy']; + const env = proxy.environment as Record; + expect(env.OPENAI_API_KEY).toBe('sk-test-openai-key'); + expect(env.ANTHROPIC_API_KEY).toBeUndefined(); + }); + + it('should only pass Anthropic key when only Anthropic key is provided', () => { + const configWithProxy = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-test-key' }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const proxy = result.services['api-proxy']; + const env = proxy.environment as Record; + expect(env.ANTHROPIC_API_KEY).toBe('sk-ant-test-key'); + expect(env.OPENAI_API_KEY).toBeUndefined(); + }); + + it('should use GHCR image by default', () => { + const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key', buildLocal: false }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const proxy = result.services['api-proxy']; + expect(proxy.image).toBe('ghcr.io/github/gh-aw-firewall/api-proxy:latest'); + expect(proxy.build).toBeUndefined(); + }); + + it('should build locally when buildLocal is true', () => { + const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key', buildLocal: true }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const proxy = result.services['api-proxy']; + expect(proxy.build).toBeDefined(); + expect((proxy.build as any).context).toContain('containers/api-proxy'); + expect(proxy.image).toBeUndefined(); + }); + + it('should use custom registry and tag', () => { + const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key', buildLocal: false, imageRegistry: 'my-registry.com', imageTag: 'v1.0.0' }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const proxy = result.services['api-proxy']; + expect(proxy.image).toBe('my-registry.com/api-proxy:v1.0.0'); + }); + + it('should configure healthcheck for api-proxy', () => { + const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const proxy = result.services['api-proxy']; + expect(proxy.healthcheck).toBeDefined(); + const healthcheck = proxy.healthcheck!; + expect(healthcheck.test).toEqual(['CMD', 'curl', '-f', 'http://localhost:10000/health']); + expect(healthcheck.timeout).toBe('3s'); + expect(healthcheck.retries).toBe(15); + expect(healthcheck.start_period).toBe('30s'); + }); + + it('should drop all capabilities', () => { + const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const proxy = result.services['api-proxy']; + expect(proxy.cap_drop).toEqual(['ALL']); + expect(proxy.security_opt).toContain('no-new-privileges:true'); + }); + + it('should set stop_grace_period on api-proxy service', () => { + const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const proxy = result.services['api-proxy'] as any; + expect(proxy.stop_grace_period).toBe('2s'); + }); + + it('should set resource limits', () => { + const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const proxy = result.services['api-proxy']; + expect(proxy.mem_limit).toBe('512m'); + expect(proxy.memswap_limit).toBe('512m'); + expect(proxy.pids_limit).toBe(100); + expect(proxy.cpu_shares).toBe(512); + }); + + it('should update agent depends_on to wait for api-proxy', () => { + const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const agent = result.services.agent; + const dependsOn = agent.depends_on as { [key: string]: { condition: string } }; + expect(dependsOn['api-proxy']).toBeDefined(); + expect(dependsOn['api-proxy'].condition).toBe('service_healthy'); + }); + + it('should set OPENAI_BASE_URL in agent when OpenAI key is provided', () => { + const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const agent = result.services.agent; + const env = agent.environment as Record; + expect(env.OPENAI_BASE_URL).toBe('http://172.30.0.30:10000'); + }); + + it('should configure HTTP_PROXY and HTTPS_PROXY in api-proxy to route through Squid', () => { + const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const proxy = result.services['api-proxy']; + const env = proxy.environment as Record; + expect(env.HTTP_PROXY).toBe('http://172.30.0.10:3128'); + expect(env.HTTPS_PROXY).toBe('http://172.30.0.10:3128'); + }); + + it('should set ANTHROPIC_BASE_URL in agent when Anthropic key is provided', () => { + const configWithProxy = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-test-key' }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const agent = result.services.agent; + const env = agent.environment as Record; + expect(env.ANTHROPIC_BASE_URL).toBe('http://172.30.0.30:10001'); + expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-ant-placeholder-key-for-credential-isolation'); + expect(env.CLAUDE_CODE_API_KEY_HELPER).toBe('/usr/local/bin/get-claude-key.sh'); + }); + + it('should set both ANTHROPIC_BASE_URL and OPENAI_BASE_URL when both keys are provided', () => { + const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-openai-key', anthropicApiKey: 'sk-ant-test-key' }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const agent = result.services.agent; + const env = agent.environment as Record; + expect(env.OPENAI_BASE_URL).toBe('http://172.30.0.30:10000'); + expect(env.ANTHROPIC_BASE_URL).toBe('http://172.30.0.30:10001'); + expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-ant-placeholder-key-for-credential-isolation'); + expect(env.CLAUDE_CODE_API_KEY_HELPER).toBe('/usr/local/bin/get-claude-key.sh'); + }); + + it('should not set OPENAI_BASE_URL in agent when only Anthropic key is provided', () => { + const configWithProxy = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-test-key' }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const agent = result.services.agent; + const env = agent.environment as Record; + expect(env.OPENAI_BASE_URL).toBeUndefined(); + expect(env.ANTHROPIC_BASE_URL).toBe('http://172.30.0.30:10001'); + expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-ant-placeholder-key-for-credential-isolation'); + expect(env.CLAUDE_CODE_API_KEY_HELPER).toBe('/usr/local/bin/get-claude-key.sh'); + }); + + it('should set OPENAI_BASE_URL and not set ANTHROPIC_BASE_URL when only OpenAI key is provided', () => { + const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const agent = result.services.agent; + const env = agent.environment as Record; + expect(env.ANTHROPIC_BASE_URL).toBeUndefined(); + expect(env.OPENAI_BASE_URL).toBe('http://172.30.0.30:10000'); + }); + + it('should set AWF_API_PROXY_IP in agent environment', () => { + const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const agent = result.services.agent; + const env = agent.environment as Record; + expect(env.AWF_API_PROXY_IP).toBe('172.30.0.30'); + }); + + it('should set NO_PROXY to include api-proxy IP', () => { + const configWithProxy = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-test-key' }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const agent = result.services.agent; + const env = agent.environment as Record; + expect(env.NO_PROXY).toContain('172.30.0.30'); + expect(env.no_proxy).toContain('172.30.0.30'); + }); + + it('should set CLAUDE_CODE_API_KEY_HELPER when Anthropic key is provided', () => { + const configWithProxy = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-test-key' }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const agent = result.services.agent; + const env = agent.environment as Record; + expect(env.CLAUDE_CODE_API_KEY_HELPER).toBe('/usr/local/bin/get-claude-key.sh'); + }); + + it('should not set CLAUDE_CODE_API_KEY_HELPER when only OpenAI key is provided', () => { + const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const agent = result.services.agent; + const env = agent.environment as Record; + expect(env.CLAUDE_CODE_API_KEY_HELPER).toBeUndefined(); + }); +}); diff --git a/src/services/api-proxy-service.test.ts b/src/services/api-proxy-service-env-forwarding.test.ts similarity index 57% rename from src/services/api-proxy-service.test.ts rename to src/services/api-proxy-service-env-forwarding.test.ts index 5f07e7fd..04c50678 100644 --- a/src/services/api-proxy-service.test.ts +++ b/src/services/api-proxy-service-env-forwarding.test.ts @@ -1,6 +1,7 @@ import { generateDockerCompose } from '../compose-generator'; import { WrapperConfig } from '../types'; -import { baseConfig, mockNetworkConfig, useTempWorkDir } from '../test-helpers/docker-test-fixtures.test-utils'; +import { baseConfig, useTempWorkDir } from '../test-helpers/docker-test-fixtures.test-utils'; +import { mockNetworkConfigWithProxy } from './api-proxy-service.test-utils'; import * as fs from 'fs'; import * as path from 'path'; @@ -12,7 +13,7 @@ jest.mock('execa', () => require('../test-helpers/mock-execa.test-utils').execaM let mockConfig: WrapperConfig; -describe('API proxy sidecar', () => { +describe('API proxy sidecar: env var forwarding', () => { useTempWorkDir( baseConfig, (config) => { @@ -21,514 +22,6 @@ describe('API proxy sidecar', () => { () => mockConfig ); - const mockNetworkConfigWithProxy = { - ...mockNetworkConfig, - proxyIp: '172.30.0.30', - }; - - it('should not include api-proxy service when enableApiProxy is false', () => { - const result = generateDockerCompose(mockConfig, mockNetworkConfigWithProxy); - expect(result.services['api-proxy']).toBeUndefined(); - }); - - it('should not include api-proxy service when enableApiProxy is true but no proxyIp', () => { - const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfig); - expect(result.services['api-proxy']).toBeUndefined(); - }); - - it('should include api-proxy service when enableApiProxy is true with OpenAI key', () => { - const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-openai-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - expect(result.services['api-proxy']).toBeDefined(); - const proxy = result.services['api-proxy']; - expect(proxy.container_name).toBe('awf-api-proxy'); - expect((proxy.networks as any)['awf-net'].ipv4_address).toBe('172.30.0.30'); - }); - - it('should include api-proxy service when enableApiProxy is true with Anthropic key', () => { - const configWithProxy = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-test-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - expect(result.services['api-proxy']).toBeDefined(); - const proxy = result.services['api-proxy']; - expect(proxy.container_name).toBe('awf-api-proxy'); - }); - - it('should include api-proxy service with both keys', () => { - const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-openai-key', anthropicApiKey: 'sk-ant-test-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - expect(result.services['api-proxy']).toBeDefined(); - const proxy = result.services['api-proxy']; - const env = proxy.environment as Record; - expect(env.OPENAI_API_KEY).toBe('sk-test-openai-key'); - expect(env.ANTHROPIC_API_KEY).toBe('sk-ant-test-key'); - }); - - it('should only pass OpenAI key when only OpenAI key is provided', () => { - const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-openai-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const proxy = result.services['api-proxy']; - const env = proxy.environment as Record; - expect(env.OPENAI_API_KEY).toBe('sk-test-openai-key'); - expect(env.ANTHROPIC_API_KEY).toBeUndefined(); - }); - - it('should only pass Anthropic key when only Anthropic key is provided', () => { - const configWithProxy = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-test-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const proxy = result.services['api-proxy']; - const env = proxy.environment as Record; - expect(env.ANTHROPIC_API_KEY).toBe('sk-ant-test-key'); - expect(env.OPENAI_API_KEY).toBeUndefined(); - }); - - it('should use GHCR image by default', () => { - const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key', buildLocal: false }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const proxy = result.services['api-proxy']; - expect(proxy.image).toBe('ghcr.io/github/gh-aw-firewall/api-proxy:latest'); - expect(proxy.build).toBeUndefined(); - }); - - it('should build locally when buildLocal is true', () => { - const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key', buildLocal: true }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const proxy = result.services['api-proxy']; - expect(proxy.build).toBeDefined(); - expect((proxy.build as any).context).toContain('containers/api-proxy'); - expect(proxy.image).toBeUndefined(); - }); - - it('should use custom registry and tag', () => { - const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key', buildLocal: false, imageRegistry: 'my-registry.com', imageTag: 'v1.0.0' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const proxy = result.services['api-proxy']; - expect(proxy.image).toBe('my-registry.com/api-proxy:v1.0.0'); - }); - - it('should configure healthcheck for api-proxy', () => { - const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const proxy = result.services['api-proxy']; - expect(proxy.healthcheck).toBeDefined(); - const healthcheck = proxy.healthcheck!; - expect(healthcheck.test).toEqual(['CMD', 'curl', '-f', 'http://localhost:10000/health']); - expect(healthcheck.timeout).toBe('3s'); - expect(healthcheck.retries).toBe(15); - expect(healthcheck.start_period).toBe('30s'); - }); - - it('should drop all capabilities', () => { - const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const proxy = result.services['api-proxy']; - expect(proxy.cap_drop).toEqual(['ALL']); - expect(proxy.security_opt).toContain('no-new-privileges:true'); - }); - - it('should set stop_grace_period on api-proxy service', () => { - const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const proxy = result.services['api-proxy'] as any; - expect(proxy.stop_grace_period).toBe('2s'); - }); - - it('should set resource limits', () => { - const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const proxy = result.services['api-proxy']; - expect(proxy.mem_limit).toBe('512m'); - expect(proxy.memswap_limit).toBe('512m'); - expect(proxy.pids_limit).toBe(100); - expect(proxy.cpu_shares).toBe(512); - }); - - it('should update agent depends_on to wait for api-proxy', () => { - const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const agent = result.services.agent; - const dependsOn = agent.depends_on as { [key: string]: { condition: string } }; - expect(dependsOn['api-proxy']).toBeDefined(); - expect(dependsOn['api-proxy'].condition).toBe('service_healthy'); - }); - - it('should set OPENAI_BASE_URL in agent when OpenAI key is provided', () => { - const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const agent = result.services.agent; - const env = agent.environment as Record; - expect(env.OPENAI_BASE_URL).toBe('http://172.30.0.30:10000'); - }); - - it('should configure HTTP_PROXY and HTTPS_PROXY in api-proxy to route through Squid', () => { - const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const proxy = result.services['api-proxy']; - const env = proxy.environment as Record; - expect(env.HTTP_PROXY).toBe('http://172.30.0.10:3128'); - expect(env.HTTPS_PROXY).toBe('http://172.30.0.10:3128'); - }); - - it('should set ANTHROPIC_BASE_URL in agent when Anthropic key is provided', () => { - const configWithProxy = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-test-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const agent = result.services.agent; - const env = agent.environment as Record; - expect(env.ANTHROPIC_BASE_URL).toBe('http://172.30.0.30:10001'); - expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-ant-placeholder-key-for-credential-isolation'); - expect(env.CLAUDE_CODE_API_KEY_HELPER).toBe('/usr/local/bin/get-claude-key.sh'); - }); - - it('should set both ANTHROPIC_BASE_URL and OPENAI_BASE_URL when both keys are provided', () => { - const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-openai-key', anthropicApiKey: 'sk-ant-test-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const agent = result.services.agent; - const env = agent.environment as Record; - expect(env.OPENAI_BASE_URL).toBe('http://172.30.0.30:10000'); - expect(env.ANTHROPIC_BASE_URL).toBe('http://172.30.0.30:10001'); - expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-ant-placeholder-key-for-credential-isolation'); - expect(env.CLAUDE_CODE_API_KEY_HELPER).toBe('/usr/local/bin/get-claude-key.sh'); - }); - - it('should not set OPENAI_BASE_URL in agent when only Anthropic key is provided', () => { - const configWithProxy = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-test-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const agent = result.services.agent; - const env = agent.environment as Record; - expect(env.OPENAI_BASE_URL).toBeUndefined(); - expect(env.ANTHROPIC_BASE_URL).toBe('http://172.30.0.30:10001'); - expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-ant-placeholder-key-for-credential-isolation'); - expect(env.CLAUDE_CODE_API_KEY_HELPER).toBe('/usr/local/bin/get-claude-key.sh'); - }); - - it('should set OPENAI_BASE_URL and not set ANTHROPIC_BASE_URL when only OpenAI key is provided', () => { - const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const agent = result.services.agent; - const env = agent.environment as Record; - expect(env.ANTHROPIC_BASE_URL).toBeUndefined(); - expect(env.OPENAI_BASE_URL).toBe('http://172.30.0.30:10000'); - }); - - it('should set AWF_API_PROXY_IP in agent environment', () => { - const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const agent = result.services.agent; - const env = agent.environment as Record; - expect(env.AWF_API_PROXY_IP).toBe('172.30.0.30'); - }); - - it('should set NO_PROXY to include api-proxy IP', () => { - const configWithProxy = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-test-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const agent = result.services.agent; - const env = agent.environment as Record; - expect(env.NO_PROXY).toContain('172.30.0.30'); - expect(env.no_proxy).toContain('172.30.0.30'); - }); - - it('should set CLAUDE_CODE_API_KEY_HELPER when Anthropic key is provided', () => { - const configWithProxy = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-test-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const agent = result.services.agent; - const env = agent.environment as Record; - expect(env.CLAUDE_CODE_API_KEY_HELPER).toBe('/usr/local/bin/get-claude-key.sh'); - }); - - it('should not set CLAUDE_CODE_API_KEY_HELPER when only OpenAI key is provided', () => { - const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const agent = result.services.agent; - const env = agent.environment as Record; - expect(env.CLAUDE_CODE_API_KEY_HELPER).toBeUndefined(); - }); - - it('should not leak ANTHROPIC_API_KEY to agent when api-proxy is enabled', () => { - // Simulate the key being in process.env (as it would be in real usage) - const origKey = process.env.ANTHROPIC_API_KEY; - process.env.ANTHROPIC_API_KEY = 'sk-ant-secret-key'; - try { - const configWithProxy = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-secret-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const agent = result.services.agent; - const env = agent.environment as Record; - // Agent should NOT have the raw API key — only the sidecar gets it - expect(env.ANTHROPIC_API_KEY).toBeUndefined(); - // Agent should have the BASE_URL to reach the sidecar instead - expect(env.ANTHROPIC_BASE_URL).toBe('http://172.30.0.30:10001'); - // Agent should have placeholder token for Claude Code compatibility - expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-ant-placeholder-key-for-credential-isolation'); - } finally { - if (origKey !== undefined) { - process.env.ANTHROPIC_API_KEY = origKey; - } else { - delete process.env.ANTHROPIC_API_KEY; - } - } - }); - - it('should not leak OPENAI_API_KEY to agent when api-proxy is enabled', () => { - // Simulate the key being in process.env (as it would be in real usage) - const origKey = process.env.OPENAI_API_KEY; - process.env.OPENAI_API_KEY = 'sk-secret-key'; - try { - const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-secret-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const agent = result.services.agent; - const env = agent.environment as Record; - // Agent should NOT have the real API key — only the sidecar holds it. - // A placeholder is injected so Codex/OpenAI clients route through OPENAI_BASE_URL - // (Codex v0.121+ bypasses OPENAI_BASE_URL when no key is present in the env). - expect(env.OPENAI_API_KEY).toBe('sk-placeholder-for-api-proxy'); - expect(env.OPENAI_API_KEY).not.toBe('sk-secret-key'); - // Agent should have OPENAI_BASE_URL to proxy through sidecar - expect(env.OPENAI_BASE_URL).toBe('http://172.30.0.30:10000'); - } finally { - if (origKey !== undefined) { - process.env.OPENAI_API_KEY = origKey; - } else { - delete process.env.OPENAI_API_KEY; - } - } - }); - - it('should not leak CODEX_API_KEY to agent when api-proxy is enabled with envAll', () => { - // Simulate the key being in process.env AND envAll enabled. - // The host's real CODEX_API_KEY must not reach the agent; a placeholder is - // injected instead so Codex routes through OPENAI_BASE_URL (api-proxy). - const origKey = process.env.CODEX_API_KEY; - process.env.CODEX_API_KEY = 'sk-codex-secret'; - try { - const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test', envAll: true }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const agent = result.services.agent; - const env = agent.environment as Record; - // CODEX_API_KEY placeholder is set; the real host key must not be present - expect(env.CODEX_API_KEY).toBe('sk-placeholder-for-api-proxy'); - expect(env.CODEX_API_KEY).not.toBe('sk-codex-secret'); - // OPENAI_BASE_URL should be set when api-proxy is enabled with openaiApiKey - expect(env.OPENAI_BASE_URL).toBe('http://172.30.0.30:10000'); - } finally { - if (origKey !== undefined) { - process.env.CODEX_API_KEY = origKey; - } else { - delete process.env.CODEX_API_KEY; - } - } - }); - - it('should not leak OPENAI_API_KEY to agent when api-proxy is enabled with envAll', () => { - // Simulate envAll scenario (smoke-codex uses --env-all). - // Even with envAll, the real key must not reach the agent; a placeholder is used instead. - const origKey = process.env.OPENAI_API_KEY; - process.env.OPENAI_API_KEY = 'sk-openai-secret'; - try { - const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-openai-secret', envAll: true }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const agent = result.services.agent; - const env = agent.environment as Record; - // Placeholder is set; real key must not be passed to agent - expect(env.OPENAI_API_KEY).toBe('sk-placeholder-for-api-proxy'); - expect(env.OPENAI_API_KEY).not.toBe('sk-openai-secret'); - // Agent should have OPENAI_BASE_URL to proxy through sidecar - expect(env.OPENAI_BASE_URL).toBe('http://172.30.0.30:10000'); - } finally { - if (origKey !== undefined) { - process.env.OPENAI_API_KEY = origKey; - } else { - delete process.env.OPENAI_API_KEY; - } - } - }); - - it('should not leak ANTHROPIC_API_KEY to agent when api-proxy is enabled with envAll', () => { - const origKey = process.env.ANTHROPIC_API_KEY; - process.env.ANTHROPIC_API_KEY = 'sk-ant-secret'; - try { - const configWithProxy = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-secret', envAll: true }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const agent = result.services.agent; - const env = agent.environment as Record; - // Even with envAll, agent should NOT have ANTHROPIC_API_KEY when api-proxy is enabled - expect(env.ANTHROPIC_API_KEY).toBeUndefined(); - expect(env.ANTHROPIC_BASE_URL).toBe('http://172.30.0.30:10001'); - // But should have placeholder token for Claude Code compatibility - expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-ant-placeholder-key-for-credential-isolation'); - } finally { - if (origKey !== undefined) { - process.env.ANTHROPIC_API_KEY = origKey; - } else { - delete process.env.ANTHROPIC_API_KEY; - } - } - }); - - it('should pass GITHUB_API_URL to agent when api-proxy is enabled with envAll', () => { - // GITHUB_API_URL must remain in the agent environment even when api-proxy is enabled. - // The Copilot CLI needs it to locate the GitHub API (token exchange, user info, etc.). - // Copilot-specific calls route through COPILOT_API_URL → api-proxy regardless. - // See: github/gh-aw#20875 - const origUrl = process.env.GITHUB_API_URL; - process.env.GITHUB_API_URL = 'https://api.github.com'; - try { - const configWithProxy = { ...mockConfig, enableApiProxy: true, copilotGithubToken: 'ghp_test_token', envAll: true }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const agent = result.services.agent; - const env = agent.environment as Record; - // GITHUB_API_URL should be passed to agent even when api-proxy is enabled - expect(env.GITHUB_API_URL).toBe('https://api.github.com'); - // COPILOT_API_URL should also be set to route Copilot calls through the api-proxy - expect(env.COPILOT_API_URL).toBe('http://172.30.0.30:10002'); - } finally { - if (origUrl !== undefined) { - process.env.GITHUB_API_URL = origUrl; - } else { - delete process.env.GITHUB_API_URL; - } - } - }); - - it('should pass GITHUB_API_URL to agent when api-proxy is NOT enabled with envAll', () => { - const origUrl = process.env.GITHUB_API_URL; - process.env.GITHUB_API_URL = 'https://api.github.com'; - try { - const configNoProxy = { ...mockConfig, enableApiProxy: false, envAll: true }; - const result = generateDockerCompose(configNoProxy, mockNetworkConfig); - const agent = result.services.agent; - const env = agent.environment as Record; - // When api-proxy is NOT enabled, GITHUB_API_URL should be passed through - expect(env.GITHUB_API_URL).toBe('https://api.github.com'); - } finally { - if (origUrl !== undefined) { - process.env.GITHUB_API_URL = origUrl; - } else { - delete process.env.GITHUB_API_URL; - } - } - }); - - it('should set AWF_RATE_LIMIT env vars when rateLimitConfig is provided', () => { - const configWithRateLimit = { - ...mockConfig, - enableApiProxy: true, - openaiApiKey: 'sk-test-key', - rateLimitConfig: { enabled: true, rpm: 30, rph: 500, bytesPm: 10485760 }, - }; - const result = generateDockerCompose(configWithRateLimit, mockNetworkConfigWithProxy); - const proxy = result.services['api-proxy']; - const env = proxy.environment as Record; - expect(env.AWF_RATE_LIMIT_ENABLED).toBe('true'); - expect(env.AWF_RATE_LIMIT_RPM).toBe('30'); - expect(env.AWF_RATE_LIMIT_RPH).toBe('500'); - expect(env.AWF_RATE_LIMIT_BYTES_PM).toBe('10485760'); - }); - - it('should set AWF_RATE_LIMIT_ENABLED=false when rate limiting is disabled', () => { - const configWithRateLimit = { - ...mockConfig, - enableApiProxy: true, - openaiApiKey: 'sk-test-key', - rateLimitConfig: { enabled: false, rpm: 60, rph: 1000, bytesPm: 52428800 }, - }; - const result = generateDockerCompose(configWithRateLimit, mockNetworkConfigWithProxy); - const proxy = result.services['api-proxy']; - const env = proxy.environment as Record; - expect(env.AWF_RATE_LIMIT_ENABLED).toBe('false'); - }); - - it('should not set rate limit env vars when rateLimitConfig is not provided', () => { - const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const proxy = result.services['api-proxy']; - const env = proxy.environment as Record; - expect(env.AWF_RATE_LIMIT_ENABLED).toBeUndefined(); - expect(env.AWF_RATE_LIMIT_RPM).toBeUndefined(); - expect(env.AWF_RATE_LIMIT_RPH).toBeUndefined(); - expect(env.AWF_RATE_LIMIT_BYTES_PM).toBeUndefined(); - }); - - it('should set effective token guard env vars when configured', () => { - const configWithEtGuard = { - ...mockConfig, - enableApiProxy: true, - openaiApiKey: 'sk-test-key', - maxEffectiveTokens: 5000, - effectiveTokenModelMultipliers: { - 'gpt-4o': 2, - 'claude-sonnet-4': 1.5, - }, - }; - const result = generateDockerCompose(configWithEtGuard, mockNetworkConfigWithProxy); - const proxy = result.services['api-proxy']; - const env = proxy.environment as Record; - expect(env.AWF_MAX_EFFECTIVE_TOKENS).toBe('5000'); - expect(env.AWF_EFFECTIVE_TOKEN_MODEL_MULTIPLIERS).toBe('{"gpt-4o":2,"claude-sonnet-4":1.5}'); - }); - - it('should set AWF_MAX_RUNS in api-proxy when maxRuns is configured', () => { - const configWithMaxRuns = { - ...mockConfig, - enableApiProxy: true, - openaiApiKey: 'sk-test-key', - maxRuns: 25, - }; - const result = generateDockerCompose(configWithMaxRuns, mockNetworkConfigWithProxy); - const proxy = result.services['api-proxy']; - const env = proxy.environment as Record; - expect(env.AWF_MAX_RUNS).toBe('25'); - }); - - it('should not set AWF_MAX_RUNS in api-proxy when maxRuns is not configured', () => { - const result = generateDockerCompose({ ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }, mockNetworkConfigWithProxy); - const proxy = result.services['api-proxy']; - const env = proxy.environment as Record; - expect(env.AWF_MAX_RUNS).toBeUndefined(); - }); - - it('should set AWF_AGENT_TIMEOUT_MINUTES in api-proxy when agentTimeout is configured', () => { - const configWithAgentTimeout = { - ...mockConfig, - enableApiProxy: true, - openaiApiKey: 'sk-test-key', - agentTimeout: 30, - }; - const result = generateDockerCompose(configWithAgentTimeout, mockNetworkConfigWithProxy); - const proxy = result.services['api-proxy']; - const env = proxy.environment as Record; - expect(env.AWF_AGENT_TIMEOUT_MINUTES).toBe('30'); - }); - - it('should not set AWF_AGENT_TIMEOUT_MINUTES in api-proxy when agentTimeout is not configured', () => { - const result = generateDockerCompose({ ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }, mockNetworkConfigWithProxy); - const proxy = result.services['api-proxy']; - const env = proxy.environment as Record; - expect(env.AWF_AGENT_TIMEOUT_MINUTES).toBeUndefined(); - }); - - it('should set AWF_ENABLE_OPENCODE=true in api-proxy when enableOpenCode is true', () => { - const configWithOpenCode = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key', enableOpenCode: true }; - const result = generateDockerCompose(configWithOpenCode, mockNetworkConfigWithProxy); - const proxy = result.services['api-proxy']; - const env = proxy.environment as Record; - expect(env.AWF_ENABLE_OPENCODE).toBe('true'); - }); - - it('should not set AWF_ENABLE_OPENCODE in api-proxy when enableOpenCode is false', () => { - const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key', enableOpenCode: false }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const proxy = result.services['api-proxy']; - const env = proxy.environment as Record; - expect(env.AWF_ENABLE_OPENCODE).toBeUndefined(); - }); - - it('should not set AWF_ENABLE_OPENCODE in api-proxy when enableOpenCode is undefined', () => { - const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const proxy = result.services['api-proxy']; - const env = proxy.environment as Record; - expect(env.AWF_ENABLE_OPENCODE).toBeUndefined(); - }); - describe('OIDC runtime env forwarding', () => { let savedEnv: Record; const oidcVars = [ diff --git a/src/services/api-proxy-service-key-isolation.test.ts b/src/services/api-proxy-service-key-isolation.test.ts new file mode 100644 index 00000000..79d94010 --- /dev/null +++ b/src/services/api-proxy-service-key-isolation.test.ts @@ -0,0 +1,186 @@ +import { generateDockerCompose } from '../compose-generator'; +import { WrapperConfig } from '../types'; +import { baseConfig, mockNetworkConfig, useTempWorkDir } from '../test-helpers/docker-test-fixtures.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: API key isolation', () => { + useTempWorkDir( + baseConfig, + (config) => { + mockConfig = config; + }, + () => mockConfig + ); + + it('should not leak ANTHROPIC_API_KEY to agent when api-proxy is enabled', () => { + // Simulate the key being in process.env (as it would be in real usage) + const origKey = process.env.ANTHROPIC_API_KEY; + process.env.ANTHROPIC_API_KEY = 'sk-ant-secret-key'; + try { + const configWithProxy = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-secret-key' }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const agent = result.services.agent; + const env = agent.environment as Record; + // Agent should NOT have the raw API key — only the sidecar gets it + expect(env.ANTHROPIC_API_KEY).toBeUndefined(); + // Agent should have the BASE_URL to reach the sidecar instead + expect(env.ANTHROPIC_BASE_URL).toBe('http://172.30.0.30:10001'); + // Agent should have placeholder token for Claude Code compatibility + expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-ant-placeholder-key-for-credential-isolation'); + } finally { + if (origKey !== undefined) { + process.env.ANTHROPIC_API_KEY = origKey; + } else { + delete process.env.ANTHROPIC_API_KEY; + } + } + }); + + it('should not leak OPENAI_API_KEY to agent when api-proxy is enabled', () => { + // Simulate the key being in process.env (as it would be in real usage) + const origKey = process.env.OPENAI_API_KEY; + process.env.OPENAI_API_KEY = 'sk-secret-key'; + try { + const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-secret-key' }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const agent = result.services.agent; + const env = agent.environment as Record; + // Agent should NOT have the real API key — only the sidecar holds it. + // A placeholder is injected so Codex/OpenAI clients route through OPENAI_BASE_URL + // (Codex v0.121+ bypasses OPENAI_BASE_URL when no key is present in the env). + expect(env.OPENAI_API_KEY).toBe('sk-placeholder-for-api-proxy'); + expect(env.OPENAI_API_KEY).not.toBe('sk-secret-key'); + // Agent should have OPENAI_BASE_URL to proxy through sidecar + expect(env.OPENAI_BASE_URL).toBe('http://172.30.0.30:10000'); + } finally { + if (origKey !== undefined) { + process.env.OPENAI_API_KEY = origKey; + } else { + delete process.env.OPENAI_API_KEY; + } + } + }); + + it('should not leak CODEX_API_KEY to agent when api-proxy is enabled with envAll', () => { + // Simulate the key being in process.env AND envAll enabled. + // The host's real CODEX_API_KEY must not reach the agent; a placeholder is + // injected instead so Codex routes through OPENAI_BASE_URL (api-proxy). + const origKey = process.env.CODEX_API_KEY; + process.env.CODEX_API_KEY = 'sk-codex-secret'; + try { + const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test', envAll: true }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const agent = result.services.agent; + const env = agent.environment as Record; + // CODEX_API_KEY placeholder is set; the real host key must not be present + expect(env.CODEX_API_KEY).toBe('sk-placeholder-for-api-proxy'); + expect(env.CODEX_API_KEY).not.toBe('sk-codex-secret'); + // OPENAI_BASE_URL should be set when api-proxy is enabled with openaiApiKey + expect(env.OPENAI_BASE_URL).toBe('http://172.30.0.30:10000'); + } finally { + if (origKey !== undefined) { + process.env.CODEX_API_KEY = origKey; + } else { + delete process.env.CODEX_API_KEY; + } + } + }); + + it('should not leak OPENAI_API_KEY to agent when api-proxy is enabled with envAll', () => { + // Simulate envAll scenario (smoke-codex uses --env-all). + // Even with envAll, the real key must not reach the agent; a placeholder is used instead. + const origKey = process.env.OPENAI_API_KEY; + process.env.OPENAI_API_KEY = 'sk-openai-secret'; + try { + const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-openai-secret', envAll: true }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const agent = result.services.agent; + const env = agent.environment as Record; + // Placeholder is set; real key must not be passed to agent + expect(env.OPENAI_API_KEY).toBe('sk-placeholder-for-api-proxy'); + expect(env.OPENAI_API_KEY).not.toBe('sk-openai-secret'); + // Agent should have OPENAI_BASE_URL to proxy through sidecar + expect(env.OPENAI_BASE_URL).toBe('http://172.30.0.30:10000'); + } finally { + if (origKey !== undefined) { + process.env.OPENAI_API_KEY = origKey; + } else { + delete process.env.OPENAI_API_KEY; + } + } + }); + + it('should not leak ANTHROPIC_API_KEY to agent when api-proxy is enabled with envAll', () => { + const origKey = process.env.ANTHROPIC_API_KEY; + process.env.ANTHROPIC_API_KEY = 'sk-ant-secret'; + try { + const configWithProxy = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-secret', envAll: true }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const agent = result.services.agent; + const env = agent.environment as Record; + // Even with envAll, agent should NOT have ANTHROPIC_API_KEY when api-proxy is enabled + expect(env.ANTHROPIC_API_KEY).toBeUndefined(); + expect(env.ANTHROPIC_BASE_URL).toBe('http://172.30.0.30:10001'); + // But should have placeholder token for Claude Code compatibility + expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-ant-placeholder-key-for-credential-isolation'); + } finally { + if (origKey !== undefined) { + process.env.ANTHROPIC_API_KEY = origKey; + } else { + delete process.env.ANTHROPIC_API_KEY; + } + } + }); + + it('should pass GITHUB_API_URL to agent when api-proxy is enabled with envAll', () => { + // GITHUB_API_URL must remain in the agent environment even when api-proxy is enabled. + // The Copilot CLI needs it to locate the GitHub API (token exchange, user info, etc.). + // Copilot-specific calls route through COPILOT_API_URL → api-proxy regardless. + // See: github/gh-aw#20875 + const origUrl = process.env.GITHUB_API_URL; + process.env.GITHUB_API_URL = 'https://api.github.com'; + try { + const configWithProxy = { ...mockConfig, enableApiProxy: true, copilotGithubToken: 'ghp_test_token', envAll: true }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const agent = result.services.agent; + const env = agent.environment as Record; + // GITHUB_API_URL should be passed to agent even when api-proxy is enabled + expect(env.GITHUB_API_URL).toBe('https://api.github.com'); + // COPILOT_API_URL should also be set to route Copilot calls through the api-proxy + expect(env.COPILOT_API_URL).toBe('http://172.30.0.30:10002'); + } finally { + if (origUrl !== undefined) { + process.env.GITHUB_API_URL = origUrl; + } else { + delete process.env.GITHUB_API_URL; + } + } + }); + + it('should pass GITHUB_API_URL to agent when api-proxy is NOT enabled with envAll', () => { + const origUrl = process.env.GITHUB_API_URL; + process.env.GITHUB_API_URL = 'https://api.github.com'; + try { + const configNoProxy = { ...mockConfig, enableApiProxy: false, envAll: true }; + const result = generateDockerCompose(configNoProxy, mockNetworkConfig); + const agent = result.services.agent; + const env = agent.environment as Record; + // When api-proxy is NOT enabled, GITHUB_API_URL should be passed through + expect(env.GITHUB_API_URL).toBe('https://api.github.com'); + } finally { + if (origUrl !== undefined) { + process.env.GITHUB_API_URL = origUrl; + } else { + delete process.env.GITHUB_API_URL; + } + } + }); +}); diff --git a/src/services/api-proxy-service-rate-limit.test.ts b/src/services/api-proxy-service-rate-limit.test.ts new file mode 100644 index 00000000..c29b61d3 --- /dev/null +++ b/src/services/api-proxy-service-rate-limit.test.ts @@ -0,0 +1,144 @@ +import { generateDockerCompose } from '../compose-generator'; +import { WrapperConfig } from '../types'; +import { baseConfig, useTempWorkDir } from '../test-helpers/docker-test-fixtures.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: rate limiting and token guard', () => { + useTempWorkDir( + baseConfig, + (config) => { + mockConfig = config; + }, + () => mockConfig + ); + + it('should set AWF_RATE_LIMIT env vars when rateLimitConfig is provided', () => { + const configWithRateLimit = { + ...mockConfig, + enableApiProxy: true, + openaiApiKey: 'sk-test-key', + rateLimitConfig: { enabled: true, rpm: 30, rph: 500, bytesPm: 10485760 }, + }; + const result = generateDockerCompose(configWithRateLimit, mockNetworkConfigWithProxy); + const proxy = result.services['api-proxy']; + const env = proxy.environment as Record; + expect(env.AWF_RATE_LIMIT_ENABLED).toBe('true'); + expect(env.AWF_RATE_LIMIT_RPM).toBe('30'); + expect(env.AWF_RATE_LIMIT_RPH).toBe('500'); + expect(env.AWF_RATE_LIMIT_BYTES_PM).toBe('10485760'); + }); + + it('should set AWF_RATE_LIMIT_ENABLED=false when rate limiting is disabled', () => { + const configWithRateLimit = { + ...mockConfig, + enableApiProxy: true, + openaiApiKey: 'sk-test-key', + rateLimitConfig: { enabled: false, rpm: 60, rph: 1000, bytesPm: 52428800 }, + }; + const result = generateDockerCompose(configWithRateLimit, mockNetworkConfigWithProxy); + const proxy = result.services['api-proxy']; + const env = proxy.environment as Record; + expect(env.AWF_RATE_LIMIT_ENABLED).toBe('false'); + }); + + it('should not set rate limit env vars when rateLimitConfig is not provided', () => { + const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const proxy = result.services['api-proxy']; + const env = proxy.environment as Record; + expect(env.AWF_RATE_LIMIT_ENABLED).toBeUndefined(); + expect(env.AWF_RATE_LIMIT_RPM).toBeUndefined(); + expect(env.AWF_RATE_LIMIT_RPH).toBeUndefined(); + expect(env.AWF_RATE_LIMIT_BYTES_PM).toBeUndefined(); + }); + + it('should set effective token guard env vars when configured', () => { + const configWithEtGuard = { + ...mockConfig, + enableApiProxy: true, + openaiApiKey: 'sk-test-key', + maxEffectiveTokens: 5000, + effectiveTokenModelMultipliers: { + 'gpt-4o': 2, + 'claude-sonnet-4': 1.5, + }, + }; + const result = generateDockerCompose(configWithEtGuard, mockNetworkConfigWithProxy); + const proxy = result.services['api-proxy']; + const env = proxy.environment as Record; + expect(env.AWF_MAX_EFFECTIVE_TOKENS).toBe('5000'); + expect(env.AWF_EFFECTIVE_TOKEN_MODEL_MULTIPLIERS).toBe('{"gpt-4o":2,"claude-sonnet-4":1.5}'); + }); + + it('should set AWF_MAX_RUNS in api-proxy when maxRuns is configured', () => { + const configWithMaxRuns = { + ...mockConfig, + enableApiProxy: true, + openaiApiKey: 'sk-test-key', + maxRuns: 25, + }; + const result = generateDockerCompose(configWithMaxRuns, mockNetworkConfigWithProxy); + const proxy = result.services['api-proxy']; + const env = proxy.environment as Record; + expect(env.AWF_MAX_RUNS).toBe('25'); + }); + + it('should not set AWF_MAX_RUNS in api-proxy when maxRuns is not configured', () => { + const result = generateDockerCompose({ ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }, mockNetworkConfigWithProxy); + const proxy = result.services['api-proxy']; + const env = proxy.environment as Record; + expect(env.AWF_MAX_RUNS).toBeUndefined(); + }); + + it('should set AWF_AGENT_TIMEOUT_MINUTES in api-proxy when agentTimeout is configured', () => { + const configWithAgentTimeout = { + ...mockConfig, + enableApiProxy: true, + openaiApiKey: 'sk-test-key', + agentTimeout: 30, + }; + const result = generateDockerCompose(configWithAgentTimeout, mockNetworkConfigWithProxy); + const proxy = result.services['api-proxy']; + const env = proxy.environment as Record; + expect(env.AWF_AGENT_TIMEOUT_MINUTES).toBe('30'); + }); + + it('should not set AWF_AGENT_TIMEOUT_MINUTES in api-proxy when agentTimeout is not configured', () => { + const result = generateDockerCompose({ ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }, mockNetworkConfigWithProxy); + const proxy = result.services['api-proxy']; + const env = proxy.environment as Record; + expect(env.AWF_AGENT_TIMEOUT_MINUTES).toBeUndefined(); + }); + + it('should set AWF_ENABLE_OPENCODE=true in api-proxy when enableOpenCode is true', () => { + const configWithOpenCode = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key', enableOpenCode: true }; + const result = generateDockerCompose(configWithOpenCode, mockNetworkConfigWithProxy); + const proxy = result.services['api-proxy']; + const env = proxy.environment as Record; + expect(env.AWF_ENABLE_OPENCODE).toBe('true'); + }); + + it('should not set AWF_ENABLE_OPENCODE in api-proxy when enableOpenCode is false', () => { + const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key', enableOpenCode: false }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const proxy = result.services['api-proxy']; + const env = proxy.environment as Record; + expect(env.AWF_ENABLE_OPENCODE).toBeUndefined(); + }); + + it('should not set AWF_ENABLE_OPENCODE in api-proxy when enableOpenCode is undefined', () => { + const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const proxy = result.services['api-proxy']; + const env = proxy.environment as Record; + expect(env.AWF_ENABLE_OPENCODE).toBeUndefined(); + }); +}); diff --git a/src/services/api-proxy-service.test-utils.ts b/src/services/api-proxy-service.test-utils.ts new file mode 100644 index 00000000..f718a9a9 --- /dev/null +++ b/src/services/api-proxy-service.test-utils.ts @@ -0,0 +1,19 @@ +/** + * Shared test fixtures for api-proxy-service test modules. + * + * Note: `jest.mock('execa', ...)` along with the `mockConfig` let-binding and + * `useTempWorkDir()` call must remain in each individual test file. Jest hoists + * jest.mock() calls to the top of each file before imports are resolved, so the + * factory closure cannot reference variables from an imported module. + */ + +import { mockNetworkConfig } from '../test-helpers/docker-test-fixtures.test-utils'; + +/** + * Standard network configuration for the AWF Docker network with API proxy IP. + * Used across api-proxy-service test modules. + */ +export const mockNetworkConfigWithProxy = { + ...mockNetworkConfig, + proxyIp: '172.30.0.30', +};