diff --git a/.github/workflows/ci-doctor.lock.yml b/.github/workflows/ci-doctor.lock.yml index 0d4e84bd..cfc6d836 100644 --- a/.github/workflows/ci-doctor.lock.yml +++ b/.github/workflows/ci-doctor.lock.yml @@ -1263,10 +1263,6 @@ jobs: env: GH_AW_WORKFLOW_ID_SANITIZED: cidoctor steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@v0.50.5 - with: - destination: /opt/gh-aw/actions - name: Download cache-memory artifact (default) id: download_cache_default uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 diff --git a/.github/workflows/issue-duplication-detector.lock.yml b/.github/workflows/issue-duplication-detector.lock.yml index 5944b60e..69363428 100644 --- a/.github/workflows/issue-duplication-detector.lock.yml +++ b/.github/workflows/issue-duplication-detector.lock.yml @@ -1115,10 +1115,6 @@ jobs: runs-on: ubuntu-latest permissions: {} steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@v0.47.0 - with: - destination: /opt/gh-aw/actions - name: Download cache-memory artifact (default) id: download_cache_default uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 diff --git a/.github/workflows/pelis-agent-factory-advisor.lock.yml b/.github/workflows/pelis-agent-factory-advisor.lock.yml index 89c24961..2c1d8220 100644 --- a/.github/workflows/pelis-agent-factory-advisor.lock.yml +++ b/.github/workflows/pelis-agent-factory-advisor.lock.yml @@ -1142,10 +1142,6 @@ jobs: runs-on: ubuntu-latest permissions: {} steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@v0.47.0 - with: - destination: /opt/gh-aw/actions - name: Download cache-memory artifact (default) id: download_cache_default uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 diff --git a/.github/workflows/secret-digger-claude.lock.yml b/.github/workflows/secret-digger-claude.lock.yml index 26765e71..e765b8e4 100644 --- a/.github/workflows/secret-digger-claude.lock.yml +++ b/.github/workflows/secret-digger-claude.lock.yml @@ -1193,10 +1193,6 @@ jobs: runs-on: ubuntu-latest permissions: {} steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@v0.47.0 - with: - destination: /opt/gh-aw/actions - name: Download cache-memory artifact (default) id: download_cache_default uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 diff --git a/.github/workflows/secret-digger-codex.lock.yml b/.github/workflows/secret-digger-codex.lock.yml index 1fa5eb00..3464d452 100644 --- a/.github/workflows/secret-digger-codex.lock.yml +++ b/.github/workflows/secret-digger-codex.lock.yml @@ -1130,10 +1130,6 @@ jobs: runs-on: ubuntu-latest permissions: {} steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@v0.47.0 - with: - destination: /opt/gh-aw/actions - name: Download cache-memory artifact (default) id: download_cache_default uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 diff --git a/.github/workflows/secret-digger-copilot.lock.yml b/.github/workflows/secret-digger-copilot.lock.yml index c15d4167..b3ce66e7 100644 --- a/.github/workflows/secret-digger-copilot.lock.yml +++ b/.github/workflows/secret-digger-copilot.lock.yml @@ -1129,10 +1129,6 @@ jobs: runs-on: ubuntu-latest permissions: {} steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@v0.47.0 - with: - destination: /opt/gh-aw/actions - name: Download cache-memory artifact (default) id: download_cache_default uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 diff --git a/.github/workflows/security-guard.lock.yml b/.github/workflows/security-guard.lock.yml index d3314362..9fde4b25 100644 --- a/.github/workflows/security-guard.lock.yml +++ b/.github/workflows/security-guard.lock.yml @@ -45,7 +45,7 @@ run-name: "Security Guard" jobs: activation: - if: (github.event_name != 'pull_request') || (github.event.pull_request.head.repo.id == github.repository_id) + if: (github.event_name != 'pull_request' || (github.event.pull_request.head.repo.id == github.repository_id && github.event.sender.type != 'Bot')) runs-on: ubuntu-slim permissions: contents: read diff --git a/.github/workflows/security-review.lock.yml b/.github/workflows/security-review.lock.yml index d27c4aff..ffe1cc29 100644 --- a/.github/workflows/security-review.lock.yml +++ b/.github/workflows/security-review.lock.yml @@ -1143,10 +1143,6 @@ jobs: runs-on: ubuntu-latest permissions: {} steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@v0.47.0 - with: - destination: /opt/gh-aw/actions - name: Download cache-memory artifact (default) id: download_cache_default uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 diff --git a/.github/workflows/smoke-chroot.lock.yml b/.github/workflows/smoke-chroot.lock.yml index 948426d5..979dec96 100644 --- a/.github/workflows/smoke-chroot.lock.yml +++ b/.github/workflows/smoke-chroot.lock.yml @@ -50,7 +50,7 @@ run-name: "Smoke Chroot" jobs: activation: - if: (github.event_name != 'pull_request') || (github.event.pull_request.head.repo.id == github.repository_id) + if: (github.event_name != 'pull_request' || (github.event.pull_request.head.repo.id == github.repository_id && github.event.sender.type != 'Bot')) runs-on: ubuntu-slim permissions: contents: read diff --git a/.github/workflows/smoke-claude.lock.yml b/.github/workflows/smoke-claude.lock.yml index 8f156957..64bb7008 100644 --- a/.github/workflows/smoke-claude.lock.yml +++ b/.github/workflows/smoke-claude.lock.yml @@ -51,7 +51,7 @@ run-name: "Smoke Claude" jobs: activation: - if: (github.event_name != 'pull_request') || (github.event.pull_request.head.repo.id == github.repository_id) + if: (github.event_name != 'pull_request' || (github.event.pull_request.head.repo.id == github.repository_id && github.event.sender.type != 'Bot')) runs-on: ubuntu-slim permissions: contents: read @@ -1256,10 +1256,6 @@ jobs: runs-on: ubuntu-latest permissions: {} steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@v0.47.0 - with: - destination: /opt/gh-aw/actions - name: Download cache-memory artifact (default) id: download_cache_default uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml index 3b40056b..83a7a0ea 100644 --- a/.github/workflows/smoke-codex.lock.yml +++ b/.github/workflows/smoke-codex.lock.yml @@ -54,7 +54,7 @@ run-name: "Smoke Codex" jobs: activation: - if: (github.event_name != 'pull_request') || (github.event.pull_request.head.repo.id == github.repository_id) + if: (github.event_name != 'pull_request' || (github.event.pull_request.head.repo.id == github.repository_id && github.event.sender.type != 'Bot')) runs-on: ubuntu-slim permissions: contents: read @@ -1836,10 +1836,6 @@ jobs: runs-on: ubuntu-latest permissions: {} steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@v0.47.0 - with: - destination: /opt/gh-aw/actions - name: Download cache-memory artifact (default) id: download_cache_default uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml index ab1a4fea..1589b624 100644 --- a/.github/workflows/smoke-copilot.lock.yml +++ b/.github/workflows/smoke-copilot.lock.yml @@ -47,7 +47,7 @@ run-name: "Smoke Copilot" jobs: activation: - if: (github.event_name != 'pull_request') || (github.event.pull_request.head.repo.id == github.repository_id) + if: (github.event_name != 'pull_request' || (github.event.pull_request.head.repo.id == github.repository_id && github.event.sender.type != 'Bot')) runs-on: ubuntu-slim permissions: contents: read @@ -1186,10 +1186,6 @@ jobs: runs-on: ubuntu-latest permissions: {} steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@v0.47.0 - with: - destination: /opt/gh-aw/actions - name: Download cache-memory artifact (default) id: download_cache_default uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 diff --git a/.github/workflows/test-coverage.yml b/.github/workflows/test-coverage.yml index 2d1ba829..95cd76f9 100644 --- a/.github/workflows/test-coverage.yml +++ b/.github/workflows/test-coverage.yml @@ -124,6 +124,7 @@ jobs: - name: Comment PR with coverage comparison if: github.event_name == 'pull_request' + continue-on-error: true uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v7 with: github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/containers/api-proxy/server.js b/containers/api-proxy/server.js index 9c6acc4f..a1edafbc 100644 --- a/containers/api-proxy/server.js +++ b/containers/api-proxy/server.js @@ -46,6 +46,10 @@ const OPENAI_API_KEY = process.env.OPENAI_API_KEY; const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; const COPILOT_GITHUB_TOKEN = process.env.COPILOT_GITHUB_TOKEN; +// Configurable API target hosts (supports custom endpoints / internal LLM routers) +const OPENAI_API_TARGET = process.env.OPENAI_API_TARGET || 'api.openai.com'; +const ANTHROPIC_API_TARGET = process.env.ANTHROPIC_API_TARGET || 'api.anthropic.com'; + // Configurable Copilot API target host (supports GHES/GHEC / custom endpoints) // Priority: COPILOT_API_TARGET env var > auto-derive from GITHUB_SERVER_URL > default function deriveCopilotApiTarget() { @@ -76,7 +80,11 @@ const HTTPS_PROXY = process.env.HTTPS_PROXY || process.env.HTTP_PROXY; logRequest('info', 'startup', { message: 'Starting AWF API proxy sidecar', squid_proxy: HTTPS_PROXY || 'not configured', - copilot_api_target: COPILOT_API_TARGET, + api_targets: { + openai: OPENAI_API_TARGET, + anthropic: ANTHROPIC_API_TARGET, + copilot: COPILOT_API_TARGET, + }, providers: { openai: !!OPENAI_API_KEY, anthropic: !!ANTHROPIC_API_KEY, @@ -397,13 +405,13 @@ if (OPENAI_API_KEY) { const contentLength = parseInt(req.headers['content-length'], 10) || 0; if (checkRateLimit(req, res, 'openai', contentLength)) return; - proxyRequest(req, res, 'api.openai.com', { + proxyRequest(req, res, OPENAI_API_TARGET, { 'Authorization': `Bearer ${OPENAI_API_KEY}`, }, 'openai'); }); server.listen(HEALTH_PORT, '0.0.0.0', () => { - logRequest('info', 'server_start', { message: `OpenAI proxy listening on port ${HEALTH_PORT}` }); + logRequest('info', 'server_start', { message: `OpenAI proxy listening on port ${HEALTH_PORT}`, target: OPENAI_API_TARGET }); }); } else { // No OpenAI key — still need a health endpoint on port 10000 for Docker healthcheck @@ -436,11 +444,11 @@ if (ANTHROPIC_API_KEY) { if (!req.headers['anthropic-version']) { anthropicHeaders['anthropic-version'] = '2023-06-01'; } - proxyRequest(req, res, 'api.anthropic.com', anthropicHeaders, 'anthropic'); + proxyRequest(req, res, ANTHROPIC_API_TARGET, anthropicHeaders, 'anthropic'); }); server.listen(10001, '0.0.0.0', () => { - logRequest('info', 'server_start', { message: 'Anthropic proxy listening on port 10001' }); + logRequest('info', 'server_start', { message: 'Anthropic proxy listening on port 10001', target: ANTHROPIC_API_TARGET }); }); } @@ -488,11 +496,11 @@ if (ANTHROPIC_API_KEY) { if (!req.headers['anthropic-version']) { anthropicHeaders['anthropic-version'] = '2023-06-01'; } - proxyRequest(req, res, 'api.anthropic.com', anthropicHeaders); + proxyRequest(req, res, ANTHROPIC_API_TARGET, anthropicHeaders); }); opencodeServer.listen(10004, '0.0.0.0', () => { - console.log('[API Proxy] OpenCode proxy listening on port 10004 (-> Anthropic)'); + console.log(`[API Proxy] OpenCode proxy listening on port 10004 (-> Anthropic at ${ANTHROPIC_API_TARGET})`); }); } diff --git a/docs/api-proxy-sidecar.md b/docs/api-proxy-sidecar.md index dc9b5c81..22e23b6c 100644 --- a/docs/api-proxy-sidecar.md +++ b/docs/api-proxy-sidecar.md @@ -262,6 +262,16 @@ sudo awf --enable-api-proxy [OPTIONS] -- COMMAND - `api.openai.com` — for OpenAI/Codex - `api.anthropic.com` — for Anthropic/Claude +**Optional flags for custom upstream endpoints**: + +| Flag | Default | Description | +|------|---------|-------------| +| `--openai-api-target ` | `api.openai.com` | Custom upstream for OpenAI API requests (e.g. Azure OpenAI or an internal LLM router). Can also be set via `OPENAI_API_TARGET` env var. | +| `--anthropic-api-target ` | `api.anthropic.com` | Custom upstream for Anthropic API requests (e.g. an internal Claude router). Can also be set via `ANTHROPIC_API_TARGET` env var. | +| `--copilot-api-target ` | auto-derived | Custom upstream for GitHub Copilot API requests (useful for GHES). Can also be set via `COPILOT_API_TARGET` env var. | + +> **Important**: When using a custom `--openai-api-target` or `--anthropic-api-target`, you must add the target domain to `--allow-domains` so the firewall permits outbound traffic. AWF will emit a warning if a custom target is set but not in the allowlist. + ### Container configuration The sidecar container: diff --git a/scripts/ci/postprocess-smoke-workflows.ts b/scripts/ci/postprocess-smoke-workflows.ts index 5d34745c..3f2711dd 100644 --- a/scripts/ci/postprocess-smoke-workflows.ts +++ b/scripts/ci/postprocess-smoke-workflows.ts @@ -87,6 +87,14 @@ const shallowDepthRegex = /^(\s+)depth: 1\n/gm; // instead of pre-built GHCR images that may be stale. const imageTagRegex = /--image-tag\s+[0-9.]+\s+--skip-pull/g; +// Remove the "Setup Scripts" step from update_cache_memory jobs. +// This step downloads the private github/gh-aw action but is never used in +// update_cache_memory (no subsequent steps reference /opt/gh-aw/actions/). +// With permissions: {} on these jobs, downloading the private action fails +// with 401 Unauthorized. +const updateCacheSetupScriptRegex = + /^(\s+)- name: Setup Scripts\n\1 uses: github\/gh-aw\/actions\/setup@v[\d.]+\n\1 with:\n\1 destination: \/opt\/gh-aw\/actions\n(\1- name: Download cache-memory artifact)/gm; + for (const workflowPath of workflowPaths) { let content = fs.readFileSync(workflowPath, 'utf-8'); let modified = false; @@ -132,6 +140,18 @@ for (const workflowPath of workflowPaths) { console.log(` Replaced ${imageTagMatches.length} --image-tag/--skip-pull with --build-local`); } + // Remove unused "Setup Scripts" step from update_cache_memory jobs. + // The step downloads a private action but is never used in these jobs, + // causing 401 Unauthorized failures when permissions: {} is set. + const updateCacheSetupMatches = content.match(updateCacheSetupScriptRegex); + if (updateCacheSetupMatches) { + content = content.replace(updateCacheSetupScriptRegex, '$2'); + modified = true; + console.log( + ` Removed ${updateCacheSetupMatches.length} unused Setup Scripts step(s) from update_cache_memory` + ); + } + if (modified) { fs.writeFileSync(workflowPath, content); console.log(`Updated ${workflowPath}`); diff --git a/src/cli.test.ts b/src/cli.test.ts index 91290b49..2a86396e 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,5 +1,5 @@ import { Command } from 'commander'; -import { parseEnvironmentVariables, parseDomains, parseDomainsFile, escapeShellArg, joinShellArgs, parseVolumeMounts, isValidIPv4, isValidIPv6, parseDnsServers, validateAgentImage, isAgentImagePreset, AGENT_IMAGE_PRESETS, processAgentImageOption, processLocalhostKeyword, validateSkipPullWithBuildLocal, validateAllowHostPorts, parseMemoryLimit, validateFormat, validateApiProxyConfig, buildRateLimitConfig, validateRateLimitFlags } from './cli'; +import { parseEnvironmentVariables, parseDomains, parseDomainsFile, escapeShellArg, joinShellArgs, parseVolumeMounts, isValidIPv4, isValidIPv6, parseDnsServers, validateAgentImage, isAgentImagePreset, AGENT_IMAGE_PRESETS, processAgentImageOption, processLocalhostKeyword, validateSkipPullWithBuildLocal, validateAllowHostPorts, parseMemoryLimit, validateFormat, validateApiProxyConfig, buildRateLimitConfig, validateRateLimitFlags, validateApiTargetInAllowedDomains, DEFAULT_OPENAI_API_TARGET, DEFAULT_ANTHROPIC_API_TARGET, emitApiProxyTargetWarnings } from './cli'; import { redactSecrets } from './redact-secrets'; import * as fs from 'fs'; import * as path from 'path'; @@ -1561,4 +1561,175 @@ describe('cli', () => { expect(parseMemoryLimit('0g')).toHaveProperty('error'); }); }); + + describe('DEFAULT_OPENAI_API_TARGET and DEFAULT_ANTHROPIC_API_TARGET', () => { + it('should have correct default values', () => { + expect(DEFAULT_OPENAI_API_TARGET).toBe('api.openai.com'); + expect(DEFAULT_ANTHROPIC_API_TARGET).toBe('api.anthropic.com'); + }); + }); + + describe('validateApiTargetInAllowedDomains', () => { + it('should return null when using the default host', () => { + const result = validateApiTargetInAllowedDomains( + 'api.openai.com', + 'api.openai.com', + '--openai-api-target', + ['example.com'] + ); + expect(result).toBeNull(); + }); + + it('should return null when custom host is in allowed domains', () => { + const result = validateApiTargetInAllowedDomains( + 'custom.example.com', + 'api.openai.com', + '--openai-api-target', + ['custom.example.com', 'other.com'] + ); + expect(result).toBeNull(); + }); + + it('should return null when custom host matches a parent domain in allowed list', () => { + const result = validateApiTargetInAllowedDomains( + 'llm-router.internal.example.com', + 'api.openai.com', + '--openai-api-target', + ['example.com'] + ); + expect(result).toBeNull(); + }); + + it('should return null when custom host matches a dotted parent domain in allowed list', () => { + const result = validateApiTargetInAllowedDomains( + 'api.example.com', + 'api.openai.com', + '--openai-api-target', + ['.example.com'] + ); + expect(result).toBeNull(); + }); + + it('should return a warning when custom host is not in allowed domains', () => { + const result = validateApiTargetInAllowedDomains( + 'custom.llm-router.internal', + 'api.openai.com', + '--openai-api-target', + ['github.com', 'api.openai.com'] + ); + expect(result).not.toBeNull(); + expect(result).toContain('--openai-api-target=custom.llm-router.internal'); + expect(result).toContain('--allow-domains'); + }); + + it('should return a warning with the correct flag name and host', () => { + const result = validateApiTargetInAllowedDomains( + 'custom.anthropic-router.com', + 'api.anthropic.com', + '--anthropic-api-target', + [] + ); + expect(result).not.toBeNull(); + expect(result).toContain('--anthropic-api-target=custom.anthropic-router.com'); + }); + + it('should return null when allowed domains list is empty and using default host', () => { + const result = validateApiTargetInAllowedDomains( + 'api.anthropic.com', + 'api.anthropic.com', + '--anthropic-api-target', + [] + ); + expect(result).toBeNull(); + }); + }); + + describe('emitApiProxyTargetWarnings', () => { + it('should emit no warnings when api proxy is disabled', () => { + const warnings: string[] = []; + emitApiProxyTargetWarnings( + { enableApiProxy: false, openaiApiTarget: 'custom.example.com', anthropicApiTarget: 'custom2.example.com' }, + ['other.com'], + (msg) => warnings.push(msg) + ); + expect(warnings).toHaveLength(0); + }); + + it('should emit no warnings when api proxy is not set', () => { + const warnings: string[] = []; + emitApiProxyTargetWarnings( + {}, + ['other.com'], + (msg) => warnings.push(msg) + ); + expect(warnings).toHaveLength(0); + }); + + it('should emit no warnings when using default targets', () => { + const warnings: string[] = []; + emitApiProxyTargetWarnings( + { enableApiProxy: true }, + ['github.com'], + (msg) => warnings.push(msg) + ); + expect(warnings).toHaveLength(0); + }); + + it('should emit warning for custom OpenAI target not in allowed domains', () => { + const warnings: string[] = []; + emitApiProxyTargetWarnings( + { enableApiProxy: true, openaiApiTarget: 'custom.openai-router.internal' }, + ['github.com'], + (msg) => warnings.push(msg) + ); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('--openai-api-target=custom.openai-router.internal'); + }); + + it('should emit warning for custom Anthropic target not in allowed domains', () => { + const warnings: string[] = []; + emitApiProxyTargetWarnings( + { enableApiProxy: true, anthropicApiTarget: 'custom.anthropic-router.internal' }, + ['github.com'], + (msg) => warnings.push(msg) + ); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('--anthropic-api-target=custom.anthropic-router.internal'); + }); + + it('should emit warnings for both custom targets when neither is in allowed domains', () => { + const warnings: string[] = []; + emitApiProxyTargetWarnings( + { enableApiProxy: true, openaiApiTarget: 'openai.internal', anthropicApiTarget: 'anthropic.internal' }, + ['github.com'], + (msg) => warnings.push(msg) + ); + expect(warnings).toHaveLength(2); + expect(warnings[0]).toContain('--openai-api-target=openai.internal'); + expect(warnings[1]).toContain('--anthropic-api-target=anthropic.internal'); + }); + + it('should emit no warnings when custom targets are in allowed domains', () => { + const warnings: string[] = []; + emitApiProxyTargetWarnings( + { enableApiProxy: true, openaiApiTarget: 'openai.example.com', anthropicApiTarget: 'anthropic.example.com' }, + ['example.com'], + (msg) => warnings.push(msg) + ); + expect(warnings).toHaveLength(0); + }); + + it('should use default targets when openaiApiTarget and anthropicApiTarget are undefined', () => { + const warnings: string[] = []; + emitApiProxyTargetWarnings( + { enableApiProxy: true, openaiApiTarget: undefined, anthropicApiTarget: undefined }, + ['github.com'], + (msg) => warnings.push(msg) + ); + // Default targets are not in 'github.com' allowed domains, but since they ARE the defaults, + // validateApiTargetInAllowedDomains returns null for default==default check + expect(warnings).toHaveLength(0); + }); + }); + }); diff --git a/src/cli.ts b/src/cli.ts index 5814c309..75b58a73 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -243,6 +243,11 @@ export function processAgentImageOption( }; } +/** Default upstream hostname for OpenAI API requests in the api-proxy sidecar */ +export const DEFAULT_OPENAI_API_TARGET = 'api.openai.com'; +/** Default upstream hostname for Anthropic API requests in the api-proxy sidecar */ +export const DEFAULT_ANTHROPIC_API_TARGET = 'api.anthropic.com'; + /** * Result of validating API proxy configuration */ @@ -295,6 +300,71 @@ export function validateApiProxyConfig( return { enabled: true, warnings, debugMessages }; } +/** + * Validates that a custom API proxy target hostname is covered by the allowed domains list. + * Returns a warning message if the target domain is not in allowed domains, otherwise null. + * @param targetHost - The custom target hostname (e.g. "custom.example.com") + * @param defaultHost - The default target hostname for this provider (e.g. "api.openai.com") + * @param flagName - The CLI flag name for use in the warning message (e.g. "--openai-api-target") + * @param allowedDomains - The list of domains allowed through the firewall + */ +export function validateApiTargetInAllowedDomains( + targetHost: string, + defaultHost: string, + flagName: string, + allowedDomains: string[] +): string | null { + // No warning needed if using the default host + if (targetHost === defaultHost) return null; + + // Check if the hostname or any of its parent domains is explicitly allowed + const isDomainAllowed = allowedDomains.some(d => { + const domain = d.startsWith('.') ? d.slice(1) : d; + return targetHost === domain || targetHost.endsWith('.' + domain); + }); + + if (!isDomainAllowed) { + return `${flagName}=${targetHost} is not in --allow-domains. Add "${targetHost}" to --allow-domains or outbound traffic to this host will be blocked by the firewall.`; + } + + return null; +} + +/** + * Emits warnings for custom API proxy target hostnames that are not in the allowed domains list. + * Checks both OpenAI and Anthropic targets when the API proxy is enabled. + * @param config - Partial wrapper config with API proxy settings + * @param allowedDomains - The list of domains allowed through the firewall + * @param warn - Function to emit a warning message + */ +export function emitApiProxyTargetWarnings( + config: { enableApiProxy?: boolean; openaiApiTarget?: string; anthropicApiTarget?: string }, + allowedDomains: string[], + warn: (msg: string) => void +): void { + if (!config.enableApiProxy) return; + + const openaiTargetWarning = validateApiTargetInAllowedDomains( + config.openaiApiTarget ?? DEFAULT_OPENAI_API_TARGET, + DEFAULT_OPENAI_API_TARGET, + '--openai-api-target', + allowedDomains + ); + if (openaiTargetWarning) { + warn(`⚠️ ${openaiTargetWarning}`); + } + + const anthropicTargetWarning = validateApiTargetInAllowedDomains( + config.anthropicApiTarget ?? DEFAULT_ANTHROPIC_API_TARGET, + DEFAULT_ANTHROPIC_API_TARGET, + '--anthropic-api-target', + allowedDomains + ); + if (anthropicTargetWarning) { + warn(`⚠️ ${anthropicTargetWarning}`); + } +} + /** * Builds a RateLimitConfig from parsed CLI options. */ @@ -847,6 +917,20 @@ program ' Defaults to api.githubcopilot.com. Useful for GHES deployments.\n' + ' Can also be set via COPILOT_API_TARGET env var.', ) + .option( + '--openai-api-target ', + 'Target hostname for OpenAI API requests in the api-proxy sidecar.\n' + + ' Defaults to api.openai.com. Useful for custom OpenAI-compatible endpoints.\n' + + ' When using a custom domain, you must also add it to --allow-domains so the firewall permits outbound traffic.\n' + + ' Can also be set via OPENAI_API_TARGET env var.', + ) + .option( + '--anthropic-api-target ', + 'Target hostname for Anthropic API requests in the api-proxy sidecar.\n' + + ' Defaults to api.anthropic.com. Useful for custom Anthropic-compatible endpoints.\n' + + ' When using a custom domain, you must also add it to --allow-domains so the firewall permits outbound traffic.\n' + + ' Can also be set via ANTHROPIC_API_TARGET env var.', + ) .option( '--rate-limit-rpm ', 'Enable rate limiting: max requests per minute per provider (requires --enable-api-proxy)', @@ -1136,6 +1220,8 @@ program anthropicApiKey: process.env.ANTHROPIC_API_KEY, copilotGithubToken: process.env.COPILOT_GITHUB_TOKEN, copilotApiTarget: options.copilotApiTarget || process.env.COPILOT_API_TARGET, + openaiApiTarget: options.openaiApiTarget || process.env.OPENAI_API_TARGET, + anthropicApiTarget: options.anthropicApiTarget || process.env.ANTHROPIC_API_TARGET, }; // Build rate limit config when API proxy is enabled @@ -1209,6 +1295,9 @@ program logger.debug(msg); } + // Warn if custom API targets are not in --allow-domains + emitApiProxyTargetWarnings(config, allowedDomains, logger.warn.bind(logger)); + // Log config with redacted secrets - remove API keys entirely // to prevent sensitive data from flowing to logger (CodeQL sensitive data logging) const redactedConfig: Record = {}; diff --git a/src/docker-manager.test.ts b/src/docker-manager.test.ts index 2e88a2af..d334c308 100644 --- a/src/docker-manager.test.ts +++ b/src/docker-manager.test.ts @@ -1893,6 +1893,38 @@ describe('docker-manager', () => { expect(env.AWF_RATE_LIMIT_RPH).toBeUndefined(); expect(env.AWF_RATE_LIMIT_BYTES_PM).toBeUndefined(); }); + + it('should set OPENAI_API_TARGET in api-proxy when openaiApiTarget is provided', () => { + const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key', openaiApiTarget: 'custom.openai-router.internal' }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const proxy = result.services['api-proxy']; + const env = proxy.environment as Record; + expect(env.OPENAI_API_TARGET).toBe('custom.openai-router.internal'); + }); + + it('should not set OPENAI_API_TARGET in api-proxy when openaiApiTarget 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.OPENAI_API_TARGET).toBeUndefined(); + }); + + it('should set ANTHROPIC_API_TARGET in api-proxy when anthropicApiTarget is provided', () => { + const configWithProxy = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-test-key', anthropicApiTarget: 'custom.anthropic-router.internal' }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const proxy = result.services['api-proxy']; + const env = proxy.environment as Record; + expect(env.ANTHROPIC_API_TARGET).toBe('custom.anthropic-router.internal'); + }); + + it('should not set ANTHROPIC_API_TARGET in api-proxy when anthropicApiTarget is not 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_TARGET).toBeUndefined(); + }); }); }); diff --git a/src/docker-manager.ts b/src/docker-manager.ts index 7014c74d..9c7d1338 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -1006,8 +1006,10 @@ export function generateDockerCompose( ...(config.openaiApiKey && { OPENAI_API_KEY: config.openaiApiKey }), ...(config.anthropicApiKey && { ANTHROPIC_API_KEY: config.anthropicApiKey }), ...(config.copilotGithubToken && { COPILOT_GITHUB_TOKEN: config.copilotGithubToken }), - // Configurable Copilot API target (for GHES/GHEC support) + // Configurable API targets (for GHES/GHEC / custom endpoints) ...(config.copilotApiTarget && { COPILOT_API_TARGET: config.copilotApiTarget }), + ...(config.openaiApiTarget && { OPENAI_API_TARGET: config.openaiApiTarget }), + ...(config.anthropicApiTarget && { ANTHROPIC_API_TARGET: config.anthropicApiTarget }), // Forward GITHUB_SERVER_URL so api-proxy can auto-derive enterprise endpoints ...(process.env.GITHUB_SERVER_URL && { GITHUB_SERVER_URL: process.env.GITHUB_SERVER_URL }), // Route through Squid to respect domain whitelisting @@ -1065,10 +1067,16 @@ export function generateDockerCompose( if (config.openaiApiKey) { environment.OPENAI_BASE_URL = `http://${networkConfig.proxyIp}:${API_PROXY_PORTS.OPENAI}/v1`; logger.debug(`OpenAI API will be proxied through sidecar at http://${networkConfig.proxyIp}:${API_PROXY_PORTS.OPENAI}/v1`); + if (config.openaiApiTarget) { + logger.debug(`OpenAI API target overridden to: ${config.openaiApiTarget}`); + } } if (config.anthropicApiKey) { environment.ANTHROPIC_BASE_URL = `http://${networkConfig.proxyIp}:${API_PROXY_PORTS.ANTHROPIC}`; logger.debug(`Anthropic API will be proxied through sidecar at http://${networkConfig.proxyIp}:${API_PROXY_PORTS.ANTHROPIC}`); + if (config.anthropicApiTarget) { + logger.debug(`Anthropic API target overridden to: ${config.anthropicApiTarget}`); + } // Set placeholder token for Claude Code CLI compatibility // Real authentication happens via ANTHROPIC_BASE_URL pointing to api-proxy diff --git a/src/pid-tracker.test.ts b/src/pid-tracker.test.ts index 25a6c188..a765d012 100644 --- a/src/pid-tracker.test.ts +++ b/src/pid-tracker.test.ts @@ -538,7 +538,9 @@ describe('pid-tracker', () => { const pid = process.pid; const info = getProcessInfo(pid); expect(info).not.toBeNull(); - expect(info!.comm).toContain('node'); + // Modern Node.js (v17+) sets comm to 'MainThread' instead of 'node' + // via prctl(PR_SET_NAME); check cmdline instead, which reliably contains the node binary + expect(info!.cmdline).toContain('node'); }); } }); diff --git a/src/types.ts b/src/types.ts index b32f31ac..239b19cd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -506,6 +506,50 @@ export interface WrapperConfig { * ``` */ copilotApiTarget?: string; + + /** + * Target hostname for OpenAI API requests (used by API proxy sidecar) + * + * When enableApiProxy is true, this hostname is passed to the Node.js sidecar + * as `OPENAI_API_TARGET`. The proxy will forward OpenAI API requests to this host + * instead of the default `api.openai.com`. + * + * Useful for custom OpenAI-compatible endpoints (e.g., Azure OpenAI, internal + * LLM routers, vLLM, TGI) where the API endpoint differs from the public default. + * + * Can be set via: + * - CLI flag: `--openai-api-target ` + * - Environment variable: `OPENAI_API_TARGET` + * + * @default 'api.openai.com' + * @example + * ```bash + * awf --enable-api-proxy --openai-api-target llm-router.internal.example.com -- command + * ``` + */ + openaiApiTarget?: string; + + /** + * Target hostname for Anthropic API requests (used by API proxy sidecar) + * + * When enableApiProxy is true, this hostname is passed to the Node.js sidecar + * as `ANTHROPIC_API_TARGET`. The proxy will forward Anthropic API requests to this host + * instead of the default `api.anthropic.com`. + * + * Useful for custom Anthropic-compatible endpoints (e.g., internal LLM routers) + * where the API endpoint differs from the public default. + * + * Can be set via: + * - CLI flag: `--anthropic-api-target ` + * - Environment variable: `ANTHROPIC_API_TARGET` + * + * @default 'api.anthropic.com' + * @example + * ```bash + * awf --enable-api-proxy --anthropic-api-target llm-router.internal.example.com -- command + * ``` + */ + anthropicApiTarget?: string; } /**