diff --git a/containers/agent/api-proxy-health-check.sh b/containers/agent/api-proxy-health-check.sh index dc993bec..363905a8 100755 --- a/containers/agent/api-proxy-health-check.sh +++ b/containers/agent/api-proxy-health-check.sh @@ -63,8 +63,23 @@ if [ -n "$OPENAI_BASE_URL" ]; then API_PROXY_CONFIGURED=true echo "[health-check] Checking OpenAI API proxy configuration..." - # Verify credentials are NOT in agent environment - if [ -n "$OPENAI_API_KEY" ] || [ -n "$CODEX_API_KEY" ] || [ -n "$OPENAI_KEY" ]; then + # Verify credentials are NOT in agent environment (real keys must stay in api-proxy sidecar). + # A placeholder value is intentionally injected so clients like Codex v0.121+ (which bypass + # OPENAI_BASE_URL when no key is present) still route through the sidecar. The placeholder + # is never sent upstream — the api-proxy replaces it with the real key before forwarding. + AWF_PLACEHOLDER="sk-placeholder-for-api-proxy" + REAL_KEY_PRESENT=false + if [ -n "$OPENAI_API_KEY" ] && [ "$OPENAI_API_KEY" != "$AWF_PLACEHOLDER" ]; then + REAL_KEY_PRESENT=true + fi + if [ -n "$CODEX_API_KEY" ] && [ "$CODEX_API_KEY" != "$AWF_PLACEHOLDER" ]; then + REAL_KEY_PRESENT=true + fi + if [ -n "$OPENAI_KEY" ] && [ "$OPENAI_KEY" != "$AWF_PLACEHOLDER" ]; then + REAL_KEY_PRESENT=true + fi + + if [ "$REAL_KEY_PRESENT" = "true" ]; then echo "[health-check][ERROR] OpenAI/Codex API key found in agent environment!" echo "[health-check][ERROR] Credential isolation failed - keys should only be in api-proxy container" echo "[health-check][ERROR] OPENAI_API_KEY=${OPENAI_API_KEY:+}" @@ -72,7 +87,12 @@ if [ -n "$OPENAI_BASE_URL" ]; then echo "[health-check][ERROR] OPENAI_KEY=${OPENAI_KEY:+}" exit 1 fi - echo "[health-check] ✓ OpenAI/Codex credentials NOT in agent environment (correct)" + + if [ -n "$OPENAI_API_KEY" ] || [ -n "$CODEX_API_KEY" ]; then + echo "[health-check] ✓ OpenAI/Codex placeholder key in agent environment (credential isolation active)" + else + echo "[health-check] ✓ OpenAI/Codex credentials NOT in agent environment (correct)" + fi # Perform health check using BASE_URL echo "[health-check] Testing connectivity to OpenAI API proxy at $OPENAI_BASE_URL..." diff --git a/src/docker-manager.test.ts b/src/docker-manager.test.ts index eb52ca07..7e3722ab 100644 --- a/src/docker-manager.test.ts +++ b/src/docker-manager.test.ts @@ -2526,8 +2526,11 @@ describe('docker-manager', () => { 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.OPENAI_API_KEY).toBeUndefined(); + // 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 { @@ -2540,8 +2543,9 @@ describe('docker-manager', () => { }); 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 - // CODEX_API_KEY is now excluded when api-proxy is enabled for credential isolation + // 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 { @@ -2549,8 +2553,9 @@ describe('docker-manager', () => { const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); const agent = result.services.agent; const env = agent.environment as Record; - // CODEX_API_KEY should NOT be passed to agent when api-proxy is enabled - expect(env.CODEX_API_KEY).toBeUndefined(); + // 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 { @@ -2563,7 +2568,8 @@ describe('docker-manager', () => { }); it('should not leak OPENAI_API_KEY to agent when api-proxy is enabled with envAll', () => { - // Simulate envAll scenario (smoke-codex uses --env-all) + // 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 { @@ -2571,8 +2577,9 @@ describe('docker-manager', () => { const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); const agent = result.services.agent; const env = agent.environment as Record; - // Even with envAll, agent should NOT have OPENAI_API_KEY when api-proxy is enabled - expect(env.OPENAI_API_KEY).toBeUndefined(); + // 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 { diff --git a/src/docker-manager.ts b/src/docker-manager.ts index e903aad0..18c405b8 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -1821,6 +1821,19 @@ export function generateDockerCompose( if (config.openaiApiBasePath) { logger.debug(`OpenAI API base path set to: ${config.openaiApiBasePath}`); } + + // Inject placeholder API keys for OpenAI/Codex credential isolation. + // Codex v0.121+ introduced a CODEX_API_KEY-based WebSocket auth flow: when no + // API key is found in the agent env, Codex bypasses OPENAI_BASE_URL and connects + // directly to api.openai.com for OAuth, getting a 401. With a placeholder key + // present, Codex routes API calls through OPENAI_BASE_URL (the api-proxy sidecar), + // which replaces the Authorization header with the real key before forwarding. + // The real keys are held securely in the sidecar; when requests are routed + // through api-proxy, these placeholders are expected to be overwritten by the + // api-proxy's injectHeaders before forwarding upstream. + environment.OPENAI_API_KEY = 'sk-placeholder-for-api-proxy'; + environment.CODEX_API_KEY = 'sk-placeholder-for-api-proxy'; + logger.debug('OPENAI_API_KEY and CODEX_API_KEY set to placeholder values for credential isolation'); } if (config.anthropicApiKey) { environment.ANTHROPIC_BASE_URL = `http://${networkConfig.proxyIp}:${API_PROXY_PORTS.ANTHROPIC}`;