diff --git a/docs-site/src/content/docs/reference/cli-reference.md b/docs-site/src/content/docs/reference/cli-reference.md index 210645190..b14eb027e 100644 --- a/docs-site/src/content/docs/reference/cli-reference.md +++ b/docs-site/src/content/docs/reference/cli-reference.md @@ -60,6 +60,7 @@ awf [options] -- | `--copilot-api-target ` | string | `api.githubcopilot.com` | Target hostname for Copilot API requests | | `--openai-api-target ` | string | `api.openai.com` | Target hostname for OpenAI API requests | | `--openai-api-base-path ` | string | — | Base path prefix for OpenAI API requests | +| `--api-proxy-ca-cert ` | string | — | Additional CA certificate for api-proxy upstream TLS verification | | `--anthropic-api-target ` | string | `api.anthropic.com` | Target hostname for Anthropic API requests | | `--anthropic-api-base-path ` | string | — | Base path prefix for Anthropic API requests | | `--gemini-api-target ` | string | `generativelanguage.googleapis.com` | Target hostname for Gemini API requests | @@ -824,6 +825,21 @@ sudo -E awf --enable-api-proxy \ -- command ``` +### `--api-proxy-ca-cert ` + +Path to an additional CA certificate used by the api-proxy sidecar when verifying TLS for custom upstream provider targets. AWF bind-mounts the file read-only into the sidecar and sets `NODE_EXTRA_CA_CERTS`; Node's built-in roots remain trusted. + +- **Default:** none +- **Requires:** API proxy sidecar + +```bash +sudo -E awf \ + --openai-api-target llm-router.internal.example.com \ + --api-proxy-ca-cert /etc/ssl/certs/corporate-ca.crt \ + --allow-domains llm-router.internal.example.com \ + -- command +``` + ### `--anthropic-api-target ` Target hostname for Anthropic API requests. Useful for custom Anthropic-compatible endpoints such as internal LLM routers. Can also be set via the `ANTHROPIC_API_TARGET` environment variable. diff --git a/docs/api-proxy-sidecar.md b/docs/api-proxy-sidecar.md index 08810a7c0..497b3d343 100644 --- a/docs/api-proxy-sidecar.md +++ b/docs/api-proxy-sidecar.md @@ -151,6 +151,7 @@ The API proxy sidecar receives **real credentials** and routing configuration: | `GOOGLE_API_KEY` | Real API key | env set on host | Google Vertex AI API key (injected into `x-goog-api-key` header) | | `GITHUB_RUN_ID` | Forwarded GitHub Actions value | `GITHUB_RUN_ID` set on host (GitHub Actions runs) | Combined with `GITHUB_RUN_ATTEMPT` to derive a stable per-run `X-Interaction-Id` for Copilot API requests (CAPI prompt-cache key); see [Prompt-cache and attribution headers](./auth-matrix.md#prompt-cache-and-attribution-headers-githubcopilotcom-only) in the auth matrix. | | `GITHUB_RUN_ATTEMPT` | Forwarded GitHub Actions value | `GITHUB_RUN_ATTEMPT` set on host (GitHub Actions runs) | Paired with `GITHUB_RUN_ID` for the `X-Interaction-Id` derivation above; defaults to `1` if unset. | +| `NODE_EXTRA_CA_CERTS` | `/usr/local/share/ca-certificates/awf-upstream-ca.crt` | `apiProxy.caCert` / `--api-proxy-ca-cert` set | Extends Node's trusted roots for private or corporate upstream gateways. | | `HTTP_PROXY` | `http://172.30.0.10:3128` | Always | Routes through Squid; sidecar traffic is exempt from domain ACLs | | `HTTPS_PROXY` | `http://172.30.0.10:3128` | Always | Routes through Squid; sidecar traffic is exempt from domain ACLs | diff --git a/docs/awf-config-spec.md b/docs/awf-config-spec.md index c9132e185..24ae8325d 100644 --- a/docs/awf-config-spec.md +++ b/docs/awf-config-spec.md @@ -167,6 +167,7 @@ AWF settings MAY be supplied via config files, including stdin (`--config -`). - `network.isolation` → `--network-isolation` *(experimental; enforces egress via Docker network topology instead of host iptables)* - `network.topologyAttach[]` → `--topology-attach ` *(repeatable; requires `network.isolation: true`)* - `apiProxy.enabled` → `--enable-api-proxy` *([DEPRECATED] API proxy is always enabled; this flag is ignored)* +- `apiProxy.caCert` → `--api-proxy-ca-cert ` *(mounts an additional CA certificate into the api-proxy sidecar and sets `NODE_EXTRA_CA_CERTS` for upstream TLS verification)* - `apiProxy.enableTokenSteering` → `--enable-token-steering` *(maps to `AWF_ENABLE_TOKEN_STEERING`; omit or set to `false` to opt out)* - `apiProxy.anthropicAutoCache` → `--anthropic-auto-cache` - `apiProxy.anthropicCacheTailTtl` → `--anthropic-cache-tail-ttl <5m|1h>` diff --git a/docs/awf-config.schema.json b/docs/awf-config.schema.json index b36d25da7..c035a1405 100644 --- a/docs/awf-config.schema.json +++ b/docs/awf-config.schema.json @@ -97,6 +97,10 @@ "deprecated": true, "description": "[DEPRECATED] The API proxy sidecar is always enabled; this field is ignored. Source credentials (OPENAI_API_KEY, ANTHROPIC_API_KEY, COPILOT_GITHUB_TOKEN, COPILOT_PROVIDER_API_KEY, GEMINI_API_KEY) are always held exclusively in the sidecar and excluded from the agent environment. The negated CLI form --no-enable-api-proxy is rejected at runtime. See docs/awf-config-spec.md §9.3 for details." }, + "caCert": { + "type": "string", + "description": "Host path to an additional CA certificate for api-proxy upstream TLS verification. The file is bind-mounted read-only into the sidecar and exposed via NODE_EXTRA_CA_CERTS." + }, "enableTokenSteering": { "type": "boolean", "description": "Enable effective token budget steering. When true, the proxy injects budget-warning system messages at 80%, 90%, 95%, and 99% usage to nudge the agent to wrap up, and sets AWF_ENABLE_TOKEN_STEERING=true in the api-proxy sidecar. Set to false (or omit) to opt out. Requires maxEffectiveTokens. Default: false." diff --git a/src/awf-config-schema.json b/src/awf-config-schema.json index b36d25da7..c035a1405 100644 --- a/src/awf-config-schema.json +++ b/src/awf-config-schema.json @@ -97,6 +97,10 @@ "deprecated": true, "description": "[DEPRECATED] The API proxy sidecar is always enabled; this field is ignored. Source credentials (OPENAI_API_KEY, ANTHROPIC_API_KEY, COPILOT_GITHUB_TOKEN, COPILOT_PROVIDER_API_KEY, GEMINI_API_KEY) are always held exclusively in the sidecar and excluded from the agent environment. The negated CLI form --no-enable-api-proxy is rejected at runtime. See docs/awf-config-spec.md §9.3 for details." }, + "caCert": { + "type": "string", + "description": "Host path to an additional CA certificate for api-proxy upstream TLS verification. The file is bind-mounted read-only into the sidecar and exposed via NODE_EXTRA_CA_CERTS." + }, "enableTokenSteering": { "type": "boolean", "description": "Enable effective token budget steering. When true, the proxy injects budget-warning system messages at 80%, 90%, 95%, and 99% usage to nudge the agent to wrap up, and sets AWF_ENABLE_TOKEN_STEERING=true in the api-proxy sidecar. Set to false (or omit) to opt out. Requires maxEffectiveTokens. Default: false." diff --git a/src/cli-options.ts b/src/cli-options.ts index bc8dae9e1..4d847982e 100644 --- a/src/cli-options.ts +++ b/src/cli-options.ts @@ -388,6 +388,10 @@ program '--vertex-api-base-path ', 'Base path prefix for Vertex API requests', ) + .option( + '--api-proxy-ca-cert ', + 'Path to an additional CA certificate for api-proxy upstream TLS verification', + ) .option( '--anthropic-auto-cache', 'Enable Anthropic prompt-cache optimizations in the API proxy (requires --enable-api-proxy).\n' + diff --git a/src/commands/build-config.test.ts b/src/commands/build-config.test.ts index 08cb8863e..04587859f 100644 --- a/src/commands/build-config.test.ts +++ b/src/commands/build-config.test.ts @@ -145,6 +145,13 @@ describe('buildConfig', () => { expect(config.agentCommand).toBe('curl https://api.github.com'); }); + it('should pass through apiProxyCaCert', () => { + const config = buildConfig(makeInputs({ + options: { ...makeInputs().options, apiProxyCaCert: '/tmp/upstream-ca.crt' }, + })); + expect(config.apiProxyCaCert).toBe('/tmp/upstream-ca.crt'); + }); + it('should set logLevel from inputs', () => { const config = buildConfig(makeInputs({ logLevel: 'debug' })); expect(config.logLevel).toBe('debug'); diff --git a/src/commands/build-config.ts b/src/commands/build-config.ts index abdcedaec..27cb387dc 100644 --- a/src/commands/build-config.ts +++ b/src/commands/build-config.ts @@ -176,6 +176,7 @@ export function buildConfig(inputs: BuildConfigInputs): WrapperConfig { legacySecurity: resolveLegacySecurity(options), allowedUrls, enableApiProxy: options.enableApiProxy as boolean | undefined, + apiProxyCaCert: options.apiProxyCaCert as string | undefined, modelFallback: options.modelFallback as { enabled?: boolean; strategy?: 'middle_power' } | undefined, requestedModel: options.requestedModel as string | undefined, diff --git a/src/config-file-mapping.test.ts b/src/config-file-mapping.test.ts index 04e9b3fd5..8d7e5ec38 100644 --- a/src/config-file-mapping.test.ts +++ b/src/config-file-mapping.test.ts @@ -313,6 +313,16 @@ describe('mapAwfFileConfigToCliOptions', () => { expect(result.difcProxyCaCert).toBe('/path/ca.crt'); }); + it('maps apiProxy.caCert', () => { + const result = mapAwfFileConfigToCliOptions({ + apiProxy: { + caCert: '/path/api-proxy-ca.crt', + }, + }); + + expect(result.apiProxyCaCert).toBe('/path/api-proxy-ca.crt'); + }); + it('maps security.legacySecurity boolean', () => { const result = mapAwfFileConfigToCliOptions({ security: { legacySecurity: true }, diff --git a/src/config-file-validation.test.ts b/src/config-file-validation.test.ts index fc6d9e4a6..1e6b0b9c8 100644 --- a/src/config-file-validation.test.ts +++ b/src/config-file-validation.test.ts @@ -76,6 +76,15 @@ describe('validateAwfFileConfig', () => { expect(errors).toContain('config.apiProxy.enableOpenCode is not supported'); }); + it('accepts string apiProxy.caCert', () => { + expect(validateAwfFileConfig({ apiProxy: { caCert: '/path/to/api-proxy-ca.crt' } })).toEqual([]); + }); + + it('rejects non-string apiProxy.caCert', () => { + const errors = validateAwfFileConfig({ apiProxy: { caCert: 456 } }); + expect(errors).toContain('config.apiProxy.caCert must be a string'); + }); + it('accepts boolean apiProxy.enableTokenSteering', () => { expect(validateAwfFileConfig({ apiProxy: { enableTokenSteering: true } })).toEqual([]); expect(validateAwfFileConfig({ apiProxy: { enableTokenSteering: false } })).toEqual([]); diff --git a/src/config-file.ts b/src/config-file.ts index c225e6563..003a57b10 100644 --- a/src/config-file.ts +++ b/src/config-file.ts @@ -22,6 +22,7 @@ export interface AwfFileConfig { }; apiProxy?: { enabled?: boolean; + caCert?: string; enableTokenSteering?: boolean; anthropicAutoCache?: boolean; anthropicCacheTailTtl?: string; diff --git a/src/config-mapper.ts b/src/config-mapper.ts index 280054641..3afa8afc1 100644 --- a/src/config-mapper.ts +++ b/src/config-mapper.ts @@ -31,6 +31,7 @@ export function mapAwfFileConfigToCliOptions(config: AwfFileConfig): Record { }, apiProxy: { enabled: true, + caCert: '/path/to/api-proxy-ca.crt', anthropicAutoCache: true, anthropicCacheTailTtl: '5m', maxEffectiveTokens: 100000, diff --git a/src/services/api-proxy-env-config.ts b/src/services/api-proxy-env-config.ts index f15f60e13..b59264bda 100644 --- a/src/services/api-proxy-env-config.ts +++ b/src/services/api-proxy-env-config.ts @@ -8,6 +8,7 @@ import { buildNoProxyEnv } from './no-proxy-utils'; import { resolveOpenAiBaseUrlFromEnv } from '../openai-base-url-env'; const DEFAULT_API_PROXY_SHUTDOWN_TIMEOUT_MS = 8000; +export const API_PROXY_UPSTREAM_CA_CERT_CONTAINER_PATH = '/usr/local/share/ca-certificates/awf-upstream-ca.crt'; /** * Builds provider API target/basePath environment variables for the api-proxy container. @@ -333,6 +334,7 @@ function buildOidcEnv(config: WrapperConfig): Record { export function buildApiProxyBaseEnv(config: WrapperConfig, networkConfig: NetworkConfig): Record { return { + ...(config.apiProxyCaCert && { NODE_EXTRA_CA_CERTS: API_PROXY_UPSTREAM_CA_CERT_CONTAINER_PATH }), ...buildCredentialEnv(config), ...buildProviderRoutingEnv(config), ...buildProxyRoutingEnv(networkConfig), diff --git a/src/services/api-proxy-service-config.test.ts b/src/services/api-proxy-service-config.test.ts index de1822bab..a2cbda5e6 100644 --- a/src/services/api-proxy-service-config.test.ts +++ b/src/services/api-proxy-service-config.test.ts @@ -175,6 +175,48 @@ describe('API proxy sidecar: service configuration', () => { expect(env.HTTPS_PROXY).toBe('http://172.30.0.10:3128'); }); + it('should mount API proxy CA cert read-only and set NODE_EXTRA_CA_CERTS', () => { + const configWithProxy = { + ...mockConfig, + enableApiProxy: true, + openaiApiKey: 'sk-test-key', + apiProxyCaCert: '/etc/hosts', + }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const proxy = result.services['api-proxy']; + const env = proxy.environment as Record; + const caPath = '/usr/local/share/ca-certificates/awf-upstream-ca.crt'; + expect(env.NODE_EXTRA_CA_CERTS).toBe(caPath); + expect(proxy.volumes).toContain(`/etc/hosts:${caPath}:ro`); + }); + + it('should resolve relative CA cert paths from the invocation directory', () => { + const configWithProxy = { + ...mockConfig, + enableApiProxy: true, + openaiApiKey: 'sk-test-key', + apiProxyCaCert: 'src/services/api-proxy-service-config.test.ts', + }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + expect(result.services['api-proxy'].volumes).toContain( + `${process.cwd()}/src/services/api-proxy-service-config.test.ts:/usr/local/share/ca-certificates/awf-upstream-ca.crt:ro`, + ); + }); + + it.each([ + ['', 'must be a non-empty path'], + ['/path/that/does/not/exist', 'file does not exist'], + ['/tmp', 'must refer to a file'], + ])('should reject invalid CA cert path %s', (caCert, message) => { + const configWithProxy = { + ...mockConfig, + enableApiProxy: true, + openaiApiKey: 'sk-test-key', + apiProxyCaCert: caCert, + }; + expect(() => generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy)).toThrow(message); + }); + 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); diff --git a/src/services/api-proxy-service-config.ts b/src/services/api-proxy-service-config.ts index 1513ca16b..b0f95cedd 100644 --- a/src/services/api-proxy-service-config.ts +++ b/src/services/api-proxy-service-config.ts @@ -1,3 +1,5 @@ +import * as fs from 'fs'; +import * as path from 'path'; import { API_PROXY_CONTAINER_NAME, } from '../constants'; @@ -7,7 +9,11 @@ import { getSafeHostGid, getSafeHostUid } from '../host-identity'; import { NetworkConfig, ImageBuildConfig } from './squid-service'; import { applyHostPathPrefixToVolumes } from './host-path-prefix'; import { buildContainerSecurityHardening } from './service-security'; -import { buildApiProxyBaseEnv, resolveApiProxyShutdownTimeoutMs } from './api-proxy-env-config'; +import { + API_PROXY_UPSTREAM_CA_CERT_CONTAINER_PATH, + buildApiProxyBaseEnv, + resolveApiProxyShutdownTimeoutMs, +} from './api-proxy-env-config'; import { buildApiProxyLifecycleConfig } from './api-proxy-lifecycle-config'; interface ApiProxyServiceConfigParams { @@ -17,6 +23,24 @@ interface ApiProxyServiceConfigParams { imageConfig: ImageBuildConfig; } +function resolveApiProxyCaCertPath(source: string): string { + if (!source.trim()) { + throw new Error('apiProxy.caCert must be a non-empty path'); + } + + const resolvedPath = path.resolve(process.cwd(), source); + let stat: fs.Stats; + try { + stat = fs.statSync(resolvedPath); + } catch { + throw new Error(`apiProxy.caCert file does not exist: ${resolvedPath}`); + } + if (!stat.isFile()) { + throw new Error(`apiProxy.caCert must refer to a file: ${resolvedPath}`); + } + return resolvedPath; +} + export function buildApiProxyServiceConfig(params: ApiProxyServiceConfigParams): any { const { config, networkConfig, apiProxyLogsPath, imageConfig } = params; if (!networkConfig.proxyIp) { @@ -25,6 +49,9 @@ export function buildApiProxyServiceConfig(params: ApiProxyServiceConfigParams): const { useGHCR, registry, parsedTag, projectRoot, resolveImage } = imageConfig; const shutdownTimeoutMs = resolveApiProxyShutdownTimeoutMs(config); const stopGracePeriodSeconds = Math.ceil((shutdownTimeoutMs + 2000) / 1000); + const apiProxyCaCertPath = config.apiProxyCaCert === undefined + ? undefined + : resolveApiProxyCaCertPath(config.apiProxyCaCert); const proxyService: any = { container_name: API_PROXY_CONTAINER_NAME, @@ -34,6 +61,7 @@ export function buildApiProxyServiceConfig(params: ApiProxyServiceConfigParams): [ // Mount log directory for api-proxy logs `${apiProxyLogsPath}:/var/log/api-proxy:rw`, + ...(apiProxyCaCertPath ? [`${apiProxyCaCertPath}:${API_PROXY_UPSTREAM_CA_CERT_CONTAINER_PATH}:ro`] : []), ], config.dockerHostPathPrefix, ), diff --git a/src/types/api-proxy-options.test.ts b/src/types/api-proxy-options.test.ts index 30f67ba21..974b5a6b4 100644 --- a/src/types/api-proxy-options.test.ts +++ b/src/types/api-proxy-options.test.ts @@ -4,6 +4,7 @@ describe('ApiProxyOptions', () => { it('composes fields from credential, routing, model, and diagnostics options', () => { const options: ApiProxyOptions = { enableApiProxy: true, + apiProxyCaCert: '/tmp/upstream-ca.crt', openaiApiKey: 'test-key', openaiApiTarget: 'api.openai.com', modelAliases: { default: ['openai/*'] }, @@ -11,6 +12,7 @@ describe('ApiProxyOptions', () => { }; expect(options.enableApiProxy).toBe(true); + expect(options.apiProxyCaCert).toBe('/tmp/upstream-ca.crt'); expect(options.openaiApiTarget).toBe('api.openai.com'); expect(options.modelAliases).toEqual({ default: ['openai/*'] }); expect(options.debugTokens).toBe(true); diff --git a/src/types/api-proxy-routing-options.ts b/src/types/api-proxy-routing-options.ts index df08a6dbd..6cb324292 100644 --- a/src/types/api-proxy-routing-options.ts +++ b/src/types/api-proxy-routing-options.ts @@ -3,6 +3,22 @@ */ export interface ApiProxyRoutingOptions { + /** + * Host path to an additional CA certificate for api-proxy upstream TLS. + * + * When set, the file is mounted read-only into the api-proxy sidecar and + * exposed through `NODE_EXTRA_CA_CERTS` so Node.js trusts private or + * corporate CAs in addition to its built-in root store. + * + * Can be set via: + * - Config path: `apiProxy.caCert` + * - CLI flag: `--api-proxy-ca-cert ` + * + * @default undefined + * @example '/tmp/awf/upstream-ca.crt' + */ + apiProxyCaCert?: string; + /** * Copilot BYOK provider type hint forwarded to the API proxy sidecar. *