diff --git a/.github/workflows/smoke-claude.lock.yml b/.github/workflows/smoke-claude.lock.yml index 9c660c74f..9400b4657 100644 --- a/.github/workflows/smoke-claude.lock.yml +++ b/.github/workflows/smoke-claude.lock.yml @@ -785,7 +785,7 @@ jobs: timeout-minutes: 10 run: | set -o pipefail - sudo -E awf --tty --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains '*.githubusercontent.com,anthropic.com,api.anthropic.com,api.github.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,cdn.playwright.dev,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,files.pythonhosted.org,ghcr.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,playwright.download.prss.microsoft.com,ppa.launchpad.net,pypi.org,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sentry.io,statsig.anthropic.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com' --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --build-local \ + sudo -E awf --tty --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains '*.githubusercontent.com,anthropic.com,api.anthropic.com,api.github.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,cdn.playwright.dev,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,files.pythonhosted.org,ghcr.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,playwright.download.prss.microsoft.com,ppa.launchpad.net,pypi.org,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sentry.io,statsig.anthropic.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com' --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --enable-api-proxy --build-local \ -- /bin/bash -c 'export PATH="$(find /opt/hostedtoolcache -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && claude --print --disable-slash-commands --no-chrome --max-turns 15 --mcp-config /tmp/gh-aw/mcp-config/mcp-servers.json --allowed-tools '\''Bash,BashOutput,Edit,Edit(/tmp/gh-aw/cache-memory/*),ExitPlanMode,Glob,Grep,KillBash,LS,MultiEdit,MultiEdit(/tmp/gh-aw/cache-memory/*),NotebookEdit,NotebookRead,Read,Read(/tmp/gh-aw/cache-memory/*),Task,TodoWrite,Write,Write(/tmp/gh-aw/cache-memory/*),mcp__github__download_workflow_run_artifact,mcp__github__get_code_scanning_alert,mcp__github__get_commit,mcp__github__get_dependabot_alert,mcp__github__get_discussion,mcp__github__get_discussion_comments,mcp__github__get_file_contents,mcp__github__get_job_logs,mcp__github__get_label,mcp__github__get_latest_release,mcp__github__get_me,mcp__github__get_notification_details,mcp__github__get_pull_request,mcp__github__get_pull_request_comments,mcp__github__get_pull_request_diff,mcp__github__get_pull_request_files,mcp__github__get_pull_request_review_comments,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,mcp__github__get_release_by_tag,mcp__github__get_secret_scanning_alert,mcp__github__get_tag,mcp__github__get_workflow_run,mcp__github__get_workflow_run_logs,mcp__github__get_workflow_run_usage,mcp__github__issue_read,mcp__github__list_branches,mcp__github__list_code_scanning_alerts,mcp__github__list_commits,mcp__github__list_dependabot_alerts,mcp__github__list_discussion_categories,mcp__github__list_discussions,mcp__github__list_issue_types,mcp__github__list_issues,mcp__github__list_label,mcp__github__list_notifications,mcp__github__list_pull_requests,mcp__github__list_releases,mcp__github__list_secret_scanning_alerts,mcp__github__list_starred_repositories,mcp__github__list_tags,mcp__github__list_workflow_jobs,mcp__github__list_workflow_run_artifacts,mcp__github__list_workflow_runs,mcp__github__list_workflows,mcp__github__pull_request_read,mcp__github__search_code,mcp__github__search_issues,mcp__github__search_orgs,mcp__github__search_pull_requests,mcp__github__search_repositories,mcp__github__search_users,mcp__playwright__browser_click,mcp__playwright__browser_close,mcp__playwright__browser_console_messages,mcp__playwright__browser_drag,mcp__playwright__browser_evaluate,mcp__playwright__browser_file_upload,mcp__playwright__browser_fill_form,mcp__playwright__browser_handle_dialog,mcp__playwright__browser_hover,mcp__playwright__browser_install,mcp__playwright__browser_navigate,mcp__playwright__browser_navigate_back,mcp__playwright__browser_network_requests,mcp__playwright__browser_press_key,mcp__playwright__browser_resize,mcp__playwright__browser_select_option,mcp__playwright__browser_snapshot,mcp__playwright__browser_tabs,mcp__playwright__browser_take_screenshot,mcp__playwright__browser_type,mcp__playwright__browser_wait_for'\'' --debug-file /tmp/gh-aw/agent-stdio.log --verbose --permission-mode bypassPermissions --output-format stream-json "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"${GH_AW_MODEL_AGENT_CLAUDE:+ --model "$GH_AW_MODEL_AGENT_CLAUDE"}' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} diff --git a/containers/agent/setup-iptables.sh b/containers/agent/setup-iptables.sh index 6214b69da..7a15ab031 100644 --- a/containers/agent/setup-iptables.sh +++ b/containers/agent/setup-iptables.sh @@ -127,6 +127,13 @@ fi echo "[iptables] Allow traffic to Squid proxy (${SQUID_IP}:${SQUID_PORT})..." iptables -t nat -A OUTPUT -d "$SQUID_IP" -j RETURN +# Allow traffic to API proxy sidecar (when enabled) +# AWF_API_PROXY_IP is set by docker-manager.ts when --enable-api-proxy is used +if [ -n "$AWF_API_PROXY_IP" ]; then + echo "[iptables] Allow traffic to API proxy sidecar (${AWF_API_PROXY_IP})..." + iptables -t nat -A OUTPUT -d "$AWF_API_PROXY_IP" -j RETURN +fi + # Bypass Squid for host.docker.internal when host access is enabled. # MCP gateway traffic to host.docker.internal gets DNAT'd to Squid, # where Squid fails with "Invalid URL" because rmcp sends relative URLs. @@ -263,6 +270,11 @@ iptables -A OUTPUT -p tcp -d 127.0.0.11 --dport 53 -j ACCEPT # Allow traffic to Squid proxy (after NAT redirection) iptables -A OUTPUT -p tcp -d "$SQUID_IP" -j ACCEPT +# Allow traffic to API proxy sidecar (when enabled) +if [ -n "$AWF_API_PROXY_IP" ]; then + iptables -A OUTPUT -p tcp -d "$AWF_API_PROXY_IP" -j ACCEPT +fi + # Drop all other TCP traffic (default deny policy) # This ensures that only explicitly allowed ports can be accessed echo "[iptables] Drop all non-redirected TCP traffic (default deny)..." diff --git a/containers/api-proxy/Dockerfile b/containers/api-proxy/Dockerfile index 4e62e688a..7f8459f64 100644 --- a/containers/api-proxy/Dockerfile +++ b/containers/api-proxy/Dockerfile @@ -11,8 +11,8 @@ WORKDIR /app # Copy package files COPY package*.json ./ -# Install dependencies -RUN npm ci --only=production +# Install dependencies from lockfile (deterministic) +RUN npm ci --omit=dev # Copy application files COPY server.js ./ diff --git a/containers/api-proxy/package-lock.json b/containers/api-proxy/package-lock.json new file mode 100644 index 000000000..b85d4bea9 --- /dev/null +++ b/containers/api-proxy/package-lock.json @@ -0,0 +1,63 @@ +{ + "name": "awf-api-proxy", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "awf-api-proxy", + "version": "1.0.0", + "dependencies": { + "https-proxy-agent": "^7.0.6" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + } + } +} diff --git a/containers/api-proxy/package.json b/containers/api-proxy/package.json index f5d48013a..dfd8824f7 100644 --- a/containers/api-proxy/package.json +++ b/containers/api-proxy/package.json @@ -7,8 +7,7 @@ "start": "node server.js" }, "dependencies": { - "express": "^4.18.2", - "http-proxy-middleware": "^2.0.6" + "https-proxy-agent": "^7.0.6" }, "engines": { "node": ">=18.0.0" diff --git a/containers/api-proxy/server.js b/containers/api-proxy/server.js index 4dc1d7f32..bf5dbb523 100644 --- a/containers/api-proxy/server.js +++ b/containers/api-proxy/server.js @@ -10,19 +10,46 @@ * 4. Respects domain whitelisting enforced by Squid */ -const express = require('express'); -const { createProxyMiddleware } = require('http-proxy-middleware'); +const http = require('http'); +const https = require('https'); +const { URL } = require('url'); +const { HttpsProxyAgent } = require('https-proxy-agent'); + +// Max request body size (10 MB) to prevent DoS via large payloads +const MAX_BODY_SIZE = 10 * 1024 * 1024; + +// Headers that must never be forwarded from the client. +// The proxy controls authentication — client-supplied auth/proxy headers are stripped. +const STRIPPED_HEADERS = new Set([ + 'host', + 'authorization', + 'proxy-authorization', + 'x-api-key', + 'forwarded', + 'via', +]); + +/** Returns true if the header name should be stripped (case-insensitive). */ +function shouldStripHeader(name) { + const lower = name.toLowerCase(); + return STRIPPED_HEADERS.has(lower) || lower.startsWith('x-forwarded-'); +} + +/** Sanitize a string for safe logging (strip control chars, limit length). */ +function sanitizeForLog(str) { + if (typeof str !== 'string') return ''; + // eslint-disable-next-line no-control-regex + return str.replace(/[\x00-\x1f\x7f]/g, '').slice(0, 200); +} // Read API keys from environment (set by docker-compose) const OPENAI_API_KEY = process.env.OPENAI_API_KEY; const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; // Squid proxy configuration (set via HTTP_PROXY/HTTPS_PROXY in docker-compose) -const HTTP_PROXY = process.env.HTTP_PROXY; -const HTTPS_PROXY = process.env.HTTPS_PROXY; +const HTTPS_PROXY = process.env.HTTPS_PROXY || process.env.HTTP_PROXY; console.log('[API Proxy] Starting AWF API proxy sidecar...'); -console.log(`[API Proxy] HTTP_PROXY: ${HTTP_PROXY}`); console.log(`[API Proxy] HTTPS_PROXY: ${HTTPS_PROXY}`); if (OPENAI_API_KEY) { console.log('[API Proxy] OpenAI API key configured'); @@ -31,72 +58,174 @@ if (ANTHROPIC_API_KEY) { console.log('[API Proxy] Anthropic API key configured'); } -// Create Express app -const app = express(); - -// Health check endpoint -app.get('/health', (req, res) => { - res.status(200).json({ - status: 'healthy', - service: 'awf-api-proxy', - squid_proxy: HTTP_PROXY || 'not configured', - providers: { - openai: !!OPENAI_API_KEY, - anthropic: !!ANTHROPIC_API_KEY +// Create proxy agent for routing through Squid +const proxyAgent = HTTPS_PROXY ? new HttpsProxyAgent(HTTPS_PROXY) : undefined; +if (!proxyAgent) { + console.warn('[API Proxy] WARNING: No HTTPS_PROXY configured, requests will go direct'); +} + +/** + * Forward a request to the target API, injecting auth headers and routing through Squid. + */ +function proxyRequest(req, res, targetHost, injectHeaders) { + // Validate that req.url is a relative path (prevent open-redirect / SSRF) + if (!req.url || !req.url.startsWith('/')) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Bad Request', message: 'URL must be a relative path' })); + return; + } + + // Build target URL + const targetUrl = new URL(req.url, `https://${targetHost}`); + + // Handle client-side errors (e.g. aborted connections) + req.on('error', (err) => { + console.error(`[API Proxy] Client request error: ${sanitizeForLog(err.message)}`); + if (!res.headersSent) { + res.writeHead(400, { 'Content-Type': 'application/json' }); } + res.end(JSON.stringify({ error: 'Client error', message: err.message })); }); -}); + + // Read the request body with size limit + const chunks = []; + let totalBytes = 0; + let rejected = false; + + req.on('data', chunk => { + if (rejected) return; + totalBytes += chunk.length; + if (totalBytes > MAX_BODY_SIZE) { + rejected = true; + if (!res.headersSent) { + res.writeHead(413, { 'Content-Type': 'application/json' }); + } + res.end(JSON.stringify({ error: 'Payload Too Large', message: 'Request body exceeds 10 MB limit' })); + return; + } + chunks.push(chunk); + }); + + req.on('end', () => { + if (rejected) return; + const body = Buffer.concat(chunks); + + // Copy incoming headers, stripping sensitive/proxy headers, then inject auth + const headers = {}; + for (const [name, value] of Object.entries(req.headers)) { + if (!shouldStripHeader(name)) { + headers[name] = value; + } + } + Object.assign(headers, injectHeaders); + + const options = { + hostname: targetHost, + port: 443, + path: targetUrl.pathname + targetUrl.search, + method: req.method, + headers, + agent: proxyAgent, // Route through Squid + }; + + const proxyReq = https.request(options, (proxyRes) => { + // Handle response stream errors + proxyRes.on('error', (err) => { + console.error(`[API Proxy] Response stream error from ${targetHost}: ${sanitizeForLog(err.message)}`); + if (!res.headersSent) { + res.writeHead(502, { 'Content-Type': 'application/json' }); + } + res.end(JSON.stringify({ error: 'Response stream error', message: err.message })); + }); + + res.writeHead(proxyRes.statusCode, proxyRes.headers); + proxyRes.pipe(res); + }); + + proxyReq.on('error', (err) => { + console.error(`[API Proxy] Error proxying to ${targetHost}: ${sanitizeForLog(err.message)}`); + if (!res.headersSent) { + res.writeHead(502, { 'Content-Type': 'application/json' }); + } + res.end(JSON.stringify({ error: 'Proxy error', message: err.message })); + }); + + if (body.length > 0) { + proxyReq.write(body); + } + proxyReq.end(); + }); +} + +// Health port is always 10000 — this is what Docker healthcheck hits +const HEALTH_PORT = 10000; // OpenAI API proxy (port 10000) if (OPENAI_API_KEY) { - app.use(createProxyMiddleware({ - target: 'https://api.openai.com', - changeOrigin: true, - secure: true, - onProxyReq: (proxyReq, req, res) => { - // Inject Authorization header - proxyReq.setHeader('Authorization', `Bearer ${OPENAI_API_KEY}`); - console.log(`[OpenAI Proxy] ${req.method} ${req.url}`); - }, - onError: (err, req, res) => { - console.error(`[OpenAI Proxy] Error: ${err.message}`); - res.status(502).json({ error: 'Proxy error', message: err.message }); + const server = http.createServer((req, res) => { + if (req.url === '/health' && req.method === 'GET') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'healthy', + service: 'awf-api-proxy', + squid_proxy: HTTPS_PROXY || 'not configured', + providers: { openai: true, anthropic: !!ANTHROPIC_API_KEY }, + })); + return; + } + + console.log(`[OpenAI Proxy] ${sanitizeForLog(req.method)} ${sanitizeForLog(req.url)}`); + proxyRequest(req, res, 'api.openai.com', { + 'Authorization': `Bearer ${OPENAI_API_KEY}`, + }); + }); + + server.listen(HEALTH_PORT, '0.0.0.0', () => { + console.log(`[API Proxy] OpenAI proxy listening on port ${HEALTH_PORT}`); + }); +} else { + // No OpenAI key — still need a health endpoint on port 10000 for Docker healthcheck + const server = http.createServer((req, res) => { + if (req.url === '/health' && req.method === 'GET') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'healthy', + service: 'awf-api-proxy', + squid_proxy: HTTPS_PROXY || 'not configured', + providers: { openai: false, anthropic: !!ANTHROPIC_API_KEY }, + })); + return; } - })); - app.listen(10000, '0.0.0.0', () => { - console.log('[API Proxy] OpenAI proxy listening on port 10000'); - console.log('[API Proxy] Routing through Squid to api.openai.com'); + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'OpenAI proxy not configured (no OPENAI_API_KEY)' })); + }); + + server.listen(HEALTH_PORT, '0.0.0.0', () => { + console.log(`[API Proxy] Health endpoint listening on port ${HEALTH_PORT} (OpenAI not configured)`); }); } // Anthropic API proxy (port 10001) if (ANTHROPIC_API_KEY) { - const anthropicApp = express(); - - anthropicApp.get('/health', (req, res) => { - res.status(200).json({ status: 'healthy', service: 'anthropic-proxy' }); - }); + const server = http.createServer((req, res) => { + if (req.url === '/health' && req.method === 'GET') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ status: 'healthy', service: 'anthropic-proxy' })); + return; + } - anthropicApp.use(createProxyMiddleware({ - target: 'https://api.anthropic.com', - changeOrigin: true, - secure: true, - onProxyReq: (proxyReq, req, res) => { - // Inject Anthropic authentication headers - proxyReq.setHeader('x-api-key', ANTHROPIC_API_KEY); - proxyReq.setHeader('anthropic-version', '2023-06-01'); - console.log(`[Anthropic Proxy] ${req.method} ${req.url}`); - }, - onError: (err, req, res) => { - console.error(`[Anthropic Proxy] Error: ${err.message}`); - res.status(502).json({ error: 'Proxy error', message: err.message }); + console.log(`[Anthropic Proxy] ${sanitizeForLog(req.method)} ${sanitizeForLog(req.url)}`); + // Only set anthropic-version as default; preserve agent-provided version + const anthropicHeaders = { 'x-api-key': ANTHROPIC_API_KEY }; + if (!req.headers['anthropic-version']) { + anthropicHeaders['anthropic-version'] = '2023-06-01'; } - })); + proxyRequest(req, res, 'api.anthropic.com', anthropicHeaders); + }); - anthropicApp.listen(10001, '0.0.0.0', () => { + server.listen(10001, '0.0.0.0', () => { console.log('[API Proxy] Anthropic proxy listening on port 10001'); - console.log('[API Proxy] Routing through Squid to api.anthropic.com'); }); } diff --git a/src/cli-workflow.ts b/src/cli-workflow.ts index 200798729..e61bc6230 100644 --- a/src/cli-workflow.ts +++ b/src/cli-workflow.ts @@ -43,8 +43,10 @@ export async function runMainWorkflow( logger.info('Setting up host-level firewall network and iptables rules...'); const networkConfig = await dependencies.ensureFirewallNetwork(); const dnsServers = config.dnsServers || ['8.8.8.8', '8.8.4.4']; - // API proxy (when enabled) does NOT get a firewall exemption - it routes through Squid - await dependencies.setupHostIptables(networkConfig.squidIp, 3128, dnsServers); + // When API proxy is enabled, allow agent→sidecar traffic at the host level. + // The sidecar itself routes through Squid, so domain whitelisting is still enforced. + const apiProxyIp = config.enableApiProxy ? networkConfig.proxyIp : undefined; + await dependencies.setupHostIptables(networkConfig.squidIp, 3128, dnsServers, apiProxyIp); onHostIptablesSetup?.(); // Step 1: Write configuration files diff --git a/src/docker-manager.test.ts b/src/docker-manager.test.ts index 7b1150217..0b91f7f3d 100644 --- a/src/docker-manager.test.ts +++ b/src/docker-manager.test.ts @@ -1550,7 +1550,7 @@ describe('docker-manager', () => { const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); const agent = result.services.agent; const env = agent.environment as Record; - expect(env.OPENAI_BASE_URL).toBe('http://api-proxy:10000'); + 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', () => { @@ -1567,7 +1567,7 @@ describe('docker-manager', () => { const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); const agent = result.services.agent; const env = agent.environment as Record; - expect(env.ANTHROPIC_BASE_URL).toBe('http://api-proxy:10001'); + expect(env.ANTHROPIC_BASE_URL).toBe('http://172.30.0.30:10001'); }); it('should set both BASE_URL variables when both keys are provided', () => { @@ -1575,8 +1575,8 @@ describe('docker-manager', () => { const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); const agent = result.services.agent; const env = agent.environment as Record; - expect(env.OPENAI_BASE_URL).toBe('http://api-proxy:10000'); - expect(env.ANTHROPIC_BASE_URL).toBe('http://api-proxy:10001'); + expect(env.OPENAI_BASE_URL).toBe('http://172.30.0.30:10000'); + expect(env.ANTHROPIC_BASE_URL).toBe('http://172.30.0.30:10001'); }); it('should not set OPENAI_BASE_URL in agent when only Anthropic key is provided', () => { @@ -1585,7 +1585,7 @@ describe('docker-manager', () => { const agent = result.services.agent; const env = agent.environment as Record; expect(env.OPENAI_BASE_URL).toBeUndefined(); - expect(env.ANTHROPIC_BASE_URL).toBe('http://api-proxy:10001'); + expect(env.ANTHROPIC_BASE_URL).toBe('http://172.30.0.30:10001'); }); it('should not set ANTHROPIC_BASE_URL in agent when only OpenAI key is provided', () => { @@ -1594,7 +1594,46 @@ describe('docker-manager', () => { const agent = result.services.agent; const env = agent.environment as Record; expect(env.ANTHROPIC_BASE_URL).toBeUndefined(); - expect(env.OPENAI_BASE_URL).toBe('http://api-proxy:10000'); + 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 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'); + } finally { + if (origKey !== undefined) { + process.env.ANTHROPIC_API_KEY = origKey; + } else { + delete process.env.ANTHROPIC_API_KEY; + } + } }); }); }); diff --git a/src/docker-manager.ts b/src/docker-manager.ts index 4e5bbe3e3..68d855077 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -344,6 +344,17 @@ export function generateDockerCompose( environment.no_proxy = environment.NO_PROXY; } + // When API proxy is enabled, bypass HTTP_PROXY for the api-proxy IP + // so the agent can reach the sidecar directly without going through Squid + if (config.enableApiProxy && networkConfig.proxyIp) { + if (environment.NO_PROXY) { + environment.NO_PROXY += `,${networkConfig.proxyIp}`; + } else { + environment.NO_PROXY = `localhost,127.0.0.1,${networkConfig.proxyIp}`; + } + environment.no_proxy = environment.NO_PROXY; + } + // Pass the host's actual PATH and tool directories so the entrypoint can use them // This ensures toolcache paths (Python, Node, Go, Rust, Java) are correctly resolved if (process.env.PATH) { @@ -389,8 +400,9 @@ export function generateDockerCompose( if (process.env.GITHUB_TOKEN) environment.GITHUB_TOKEN = process.env.GITHUB_TOKEN; if (process.env.GH_TOKEN) environment.GH_TOKEN = process.env.GH_TOKEN; if (process.env.GITHUB_PERSONAL_ACCESS_TOKEN) environment.GITHUB_PERSONAL_ACCESS_TOKEN = process.env.GITHUB_PERSONAL_ACCESS_TOKEN; - // Anthropic API key for Claude Code - if (process.env.ANTHROPIC_API_KEY) environment.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; + // Anthropic API key for Claude Code — skip when api-proxy is enabled + // (the sidecar holds the key; the agent uses ANTHROPIC_BASE_URL instead) + if (process.env.ANTHROPIC_API_KEY && !config.enableApiProxy) environment.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; if (process.env.USER) environment.USER = process.env.USER; if (process.env.TERM) environment.TERM = process.env.TERM; if (process.env.XDG_CONFIG_HOME) environment.XDG_CONFIG_HOME = process.env.XDG_CONFIG_HOME; @@ -952,13 +964,17 @@ export function generateDockerCompose( }; // Set environment variables in agent to use the proxy + // AWF_API_PROXY_IP is used by setup-iptables.sh to allow agent→api-proxy traffic + // Use IP address instead of hostname for BASE_URLs since Docker DNS may not resolve + // container names in chroot mode + environment.AWF_API_PROXY_IP = networkConfig.proxyIp; if (config.openaiApiKey) { - environment.OPENAI_BASE_URL = `http://api-proxy:10000`; - logger.debug('OpenAI API will be proxied through sidecar at http://api-proxy:10000'); + environment.OPENAI_BASE_URL = `http://${networkConfig.proxyIp}:10000`; + logger.debug(`OpenAI API will be proxied through sidecar at http://${networkConfig.proxyIp}:10000`); } if (config.anthropicApiKey) { - environment.ANTHROPIC_BASE_URL = `http://api-proxy:10001`; - logger.debug('Anthropic API will be proxied through sidecar at http://api-proxy:10001'); + environment.ANTHROPIC_BASE_URL = `http://${networkConfig.proxyIp}:10001`; + logger.debug(`Anthropic API will be proxied through sidecar at http://${networkConfig.proxyIp}:10001`); } logger.info('API proxy sidecar enabled - API keys will be held securely in sidecar container'); diff --git a/src/host-iptables.ts b/src/host-iptables.ts index 30ad419dc..4a8a7c680 100644 --- a/src/host-iptables.ts +++ b/src/host-iptables.ts @@ -160,7 +160,7 @@ async function setupIpv6Chain(bridgeName: string): Promise { * @param squidPort - Port number of the Squid proxy * @param dnsServers - Array of trusted DNS server IP addresses (DNS traffic is ONLY allowed to these servers) */ -export async function setupHostIptables(squidIp: string, squidPort: number, dnsServers: string[]): Promise { +export async function setupHostIptables(squidIp: string, squidPort: number, dnsServers: string[], apiProxyIp?: string): Promise { logger.info('Setting up host-level iptables rules...'); // Get the bridge interface name @@ -441,6 +441,18 @@ export async function setupHostIptables(squidIp: string, squidPort: number, dnsS '-j', 'ACCEPT', ]); + // 5b. Allow traffic to API proxy sidecar (when enabled) + // Only allow ports 10000 (OpenAI) and 10001 (Anthropic) — nothing else. + // The sidecar itself routes through Squid, so domain whitelisting is still enforced. + if (apiProxyIp) { + logger.debug(`Allowing traffic to API proxy sidecar at ${apiProxyIp}:10000-10001`); + await execa('iptables', [ + '-t', 'filter', '-A', CHAIN_NAME, + '-p', 'tcp', '-d', apiProxyIp, '--dport', '10000:10001', + '-j', 'ACCEPT', + ]); + } + // 6. Block multicast and link-local traffic await execa('iptables', [ '-t', 'filter', '-A', CHAIN_NAME, diff --git a/tests/fixtures/awf-runner.ts b/tests/fixtures/awf-runner.ts index 5028fee54..8efe42abe 100644 --- a/tests/fixtures/awf-runner.ts +++ b/tests/fixtures/awf-runner.ts @@ -18,6 +18,7 @@ export interface AwfOptions { dnsServers?: string[]; // DNS servers to use (e.g., ['8.8.8.8', '2001:4860:4860::8888']) allowHostPorts?: string; // Ports or port ranges to allow for host access (e.g., '3000' or '3000-8000') allowFullFilesystemAccess?: boolean; // Allow full filesystem access (disables selective mounting security) + enableApiProxy?: boolean; // Enable API proxy sidecar for LLM credential management } export interface AwfResult { @@ -104,6 +105,11 @@ export class AwfRunner { args.push('--allow-full-filesystem-access'); } + // Add enable-api-proxy flag + if (options.enableApiProxy) { + args.push('--enable-api-proxy'); + } + // Add -- separator before command args.push('--'); @@ -253,6 +259,11 @@ export class AwfRunner { args.push('--allow-full-filesystem-access'); } + // Add enable-api-proxy flag + if (options.enableApiProxy) { + args.push('--enable-api-proxy'); + } + // Add -- separator before command args.push('--'); diff --git a/tests/integration/api-proxy.test.ts b/tests/integration/api-proxy.test.ts new file mode 100644 index 000000000..475fcb985 --- /dev/null +++ b/tests/integration/api-proxy.test.ts @@ -0,0 +1,156 @@ +/** + * API Proxy Sidecar Integration Tests + * + * Tests that the --enable-api-proxy flag correctly starts the API proxy sidecar + * and routes requests through Squid. + */ + +/// + +import { describe, test, expect, beforeAll, afterAll } from '@jest/globals'; +import { createRunner, AwfRunner } from '../fixtures/awf-runner'; +import { cleanup } from '../fixtures/cleanup'; + +// The API proxy sidecar is at this fixed IP on the awf-net network +const API_PROXY_IP = '172.30.0.30'; + +describe('API Proxy Sidecar', () => { + let runner: AwfRunner; + + beforeAll(async () => { + await cleanup(false); + runner = createRunner(); + }); + + afterAll(async () => { + await cleanup(false); + }); + + test('should start api-proxy sidecar with Anthropic key and pass healthcheck', async () => { + const result = await runner.runWithSudo( + `curl -s http://${API_PROXY_IP}:10001/health`, + { + allowDomains: ['api.anthropic.com'], + enableApiProxy: true, + buildLocal: true, + logLevel: 'debug', + timeout: 120000, + env: { + ANTHROPIC_API_KEY: 'sk-ant-fake-test-key-12345', + }, + } + ); + + expect(result).toSucceed(); + expect(result.stdout).toContain('"status":"healthy"'); + expect(result.stdout).toContain('anthropic-proxy'); + }, 180000); + + test('should start api-proxy sidecar with OpenAI key and pass healthcheck', async () => { + const result = await runner.runWithSudo( + `curl -s http://${API_PROXY_IP}:10000/health`, + { + allowDomains: ['api.openai.com'], + enableApiProxy: true, + buildLocal: true, + logLevel: 'debug', + timeout: 120000, + env: { + OPENAI_API_KEY: 'sk-fake-test-key-12345', + }, + } + ); + + expect(result).toSucceed(); + expect(result.stdout).toContain('"status":"healthy"'); + expect(result.stdout).toContain('awf-api-proxy'); + }, 180000); + + test('should set ANTHROPIC_BASE_URL in agent when Anthropic key is provided', async () => { + const result = await runner.runWithSudo( + 'bash -c "echo ANTHROPIC_BASE_URL=$ANTHROPIC_BASE_URL"', + { + allowDomains: ['api.anthropic.com'], + enableApiProxy: true, + buildLocal: true, + logLevel: 'debug', + timeout: 120000, + env: { + ANTHROPIC_API_KEY: 'sk-ant-fake-test-key-12345', + }, + } + ); + + expect(result).toSucceed(); + expect(result.stdout).toContain(`ANTHROPIC_BASE_URL=http://${API_PROXY_IP}:10001`); + }, 180000); + + test('should set OPENAI_BASE_URL in agent when OpenAI key is provided', async () => { + const result = await runner.runWithSudo( + 'bash -c "echo OPENAI_BASE_URL=$OPENAI_BASE_URL"', + { + allowDomains: ['api.openai.com'], + enableApiProxy: true, + buildLocal: true, + logLevel: 'debug', + timeout: 120000, + env: { + OPENAI_API_KEY: 'sk-fake-test-key-12345', + }, + } + ); + + expect(result).toSucceed(); + expect(result.stdout).toContain(`OPENAI_BASE_URL=http://${API_PROXY_IP}:10000`); + }, 180000); + + test('should route Anthropic API requests through Squid', async () => { + // Use a fake API key — the request will reach api.anthropic.com via Squid + // and get an auth error (401), but that proves the proxy routes through Squid. + const result = await runner.runWithSudo( + `bash -c "curl -s -X POST http://${API_PROXY_IP}:10001/v1/messages -H 'Content-Type: application/json' -d '{\"model\":\"claude-3-haiku-20240307\",\"max_tokens\":10,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}'"`, + { + allowDomains: ['api.anthropic.com'], + enableApiProxy: true, + buildLocal: true, + logLevel: 'debug', + timeout: 120000, + env: { + ANTHROPIC_API_KEY: 'sk-ant-fake-test-key-12345', + }, + } + ); + + // The request should succeed (curl exits 0) even though Anthropic rejects the fake key. + // The response will contain an authentication error from Anthropic, proving the + // request was routed through Squid to api.anthropic.com. + expect(result).toSucceed(); + // Anthropic returns an error about the invalid API key — this proves end-to-end routing works + expect(result.stdout).toMatch(/authentication_error|invalid.*api.key|invalid_api_key|error/i); + }, 180000); + + test('should set both health and Anthropic endpoints with Anthropic key only', async () => { + // When only Anthropic key is provided, port 10000 should still serve /health + // (needed for Docker healthcheck) and port 10001 should serve the Anthropic proxy + const result = await runner.runWithSudo( + `bash -c "curl -s http://${API_PROXY_IP}:10000/health && echo && curl -s http://${API_PROXY_IP}:10001/health"`, + { + allowDomains: ['api.anthropic.com'], + enableApiProxy: true, + buildLocal: true, + logLevel: 'debug', + timeout: 120000, + env: { + ANTHROPIC_API_KEY: 'sk-ant-fake-test-key-12345', + }, + } + ); + + expect(result).toSucceed(); + // Port 10000 health should report openai: false, anthropic: true + expect(result.stdout).toContain('"openai":false'); + expect(result.stdout).toContain('"anthropic":true'); + // Port 10001 should also be healthy + expect(result.stdout).toContain('anthropic-proxy'); + }, 180000); +});