Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs-site/src/content/docs/reference/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ awf [options] -- <command>
| `--copilot-api-target <host>` | string | `api.githubcopilot.com` | Target hostname for Copilot API requests |
| `--openai-api-target <host>` | string | `api.openai.com` | Target hostname for OpenAI API requests |
| `--openai-api-base-path <path>` | string | — | Base path prefix for OpenAI API requests |
| `--api-proxy-ca-cert <path>` | string | — | Additional CA certificate for api-proxy upstream TLS verification |
| `--anthropic-api-target <host>` | string | `api.anthropic.com` | Target hostname for Anthropic API requests |
| `--anthropic-api-base-path <path>` | string | — | Base path prefix for Anthropic API requests |
| `--gemini-api-target <host>` | string | `generativelanguage.googleapis.com` | Target hostname for Gemini API requests |
Expand Down Expand Up @@ -824,6 +825,21 @@ sudo -E awf --enable-api-proxy \
-- command
```

### `--api-proxy-ca-cert <path>`

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 <host>`

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.
Expand Down
1 change: 1 addition & 0 deletions docs/api-proxy-sidecar.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
1 change: 1 addition & 0 deletions docs/awf-config-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` *(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 <path>` *(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>`
Expand Down
4 changes: 4 additions & 0 deletions docs/awf-config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
4 changes: 4 additions & 0 deletions src/awf-config-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
4 changes: 4 additions & 0 deletions src/cli-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,10 @@ program
'--vertex-api-base-path <path>',
'Base path prefix for Vertex API requests',
)
.option(
'--api-proxy-ca-cert <path>',
'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' +
Expand Down
7 changes: 7 additions & 0 deletions src/commands/build-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
1 change: 1 addition & 0 deletions src/commands/build-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions src/config-file-mapping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
9 changes: 9 additions & 0 deletions src/config-file-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
Expand Down
1 change: 1 addition & 0 deletions src/config-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface AwfFileConfig {
};
apiProxy?: {
enabled?: boolean;
caCert?: string;
enableTokenSteering?: boolean;
anthropicAutoCache?: boolean;
anthropicCacheTailTtl?: string;
Expand Down
1 change: 1 addition & 0 deletions src/config-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export function mapAwfFileConfigToCliOptions(config: AwfFileConfig): Record<stri
// apiProxy.enabled is ignored — API proxy is always on (see #6207).
// We deliberately don't map it to enableApiProxy to avoid triggering
// CLI deprecation warnings/errors from config-file values.
apiProxyCaCert: config.apiProxy?.caCert,
enableTokenSteering: config.apiProxy?.enableTokenSteering,
anthropicAutoCache: config.apiProxy?.anthropicAutoCache,
anthropicCacheTailTtl: config.apiProxy?.anthropicCacheTailTtl as '5m' | '1h' | undefined,
Expand Down
1 change: 1 addition & 0 deletions src/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ describe('awf-config.schema.json', () => {
},
apiProxy: {
enabled: true,
caCert: '/path/to/api-proxy-ca.crt',
anthropicAutoCache: true,
anthropicCacheTailTtl: '5m',
maxEffectiveTokens: 100000,
Expand Down
2 changes: 2 additions & 0 deletions src/services/api-proxy-env-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -333,6 +334,7 @@ function buildOidcEnv(config: WrapperConfig): Record<string, string> {

export function buildApiProxyBaseEnv(config: WrapperConfig, networkConfig: NetworkConfig): Record<string, string> {
return {
...(config.apiProxyCaCert && { NODE_EXTRA_CA_CERTS: API_PROXY_UPSTREAM_CA_CERT_CONTAINER_PATH }),
...buildCredentialEnv(config),
...buildProviderRoutingEnv(config),
...buildProxyRoutingEnv(networkConfig),
Expand Down
42 changes: 42 additions & 0 deletions src/services/api-proxy-service-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
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);
Expand Down
30 changes: 29 additions & 1 deletion src/services/api-proxy-service-config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import * as fs from 'fs';
import * as path from 'path';
import {
API_PROXY_CONTAINER_NAME,
} from '../constants';
Expand All @@ -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 {
Expand All @@ -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) {
Expand All @@ -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,
Expand All @@ -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,
),
Expand Down
2 changes: 2 additions & 0 deletions src/types/api-proxy-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ 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/*'] },
debugTokens: true,
};

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);
Expand Down
16 changes: 16 additions & 0 deletions src/types/api-proxy-routing-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>`
*
* @default undefined
* @example '/tmp/awf/upstream-ca.crt'
*/
apiProxyCaCert?: string;

/**
* Copilot BYOK provider type hint forwarded to the API proxy sidecar.
*
Expand Down
Loading