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
2 changes: 1 addition & 1 deletion containers/api-proxy/adapter-factory.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ function createAdapterMethods(opts) {
if (skip) return skip;
if (!credentialConfigured) return null;
if (defaultTarget && rawTarget !== defaultTarget) {
return { skip: true, reason: `Custom target ${rawTarget}; validation skipped` };
return { skip: true, reason: 'Custom target; validation skipped' };
}
return {
url: `https://${rawTarget}${validationPath}`,
Expand Down
10 changes: 9 additions & 1 deletion containers/api-proxy/logging.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,19 @@ function sanitizeForLog(str, maxLen = 200) {
* @param {object} [fields] - Additional key/value pairs merged into the log line
*/
function logRequest(level, event, fields = {}) {
const sensitiveTarget = (process.env.AWF_SENSITIVE_OPENAI_TARGET || '').trim();
const redact = (value) => {
if (!sensitiveTarget || typeof value !== 'string') return value;
return value.replace(
new RegExp(sensitiveTarget.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'),
'[REDACTED]'
);
};
const line = {
timestamp: new Date().toISOString(),
level,
event,
...fields,
...Object.fromEntries(Object.entries(fields).map(([key, value]) => [key, redact(value)])),
};
// Single JSON line to stdout — tee handles file persistence
process.stdout.write(JSON.stringify(line) + '\n');
Expand Down
17 changes: 17 additions & 0 deletions containers/api-proxy/logging.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,23 @@ describe('logging', () => {
expect(parsed.provider).toBe('openai');
});

it('redacts the sensitive OpenAI target from every logged string field', () => {
const previous = process.env.AWF_SENSITIVE_OPENAI_TARGET;
process.env.AWF_SENSITIVE_OPENAI_TARGET = ['lb', 'secret', 'example', 'com'].join('.');
try {
logRequest('info', 'request_complete', {
upstream_host: 'LB.Secret.Example.Com',
message: 'Custom target lb.secret.example.com; validation skipped',
});
const parsed = JSON.parse(stdoutSpy.mock.calls[0][0]);
expect(JSON.stringify(parsed)).not.toContain('secret.example.com');
expect(parsed.upstream_host).toBe('[REDACTED]');
} finally {
if (previous === undefined) delete process.env.AWF_SENSITIVE_OPENAI_TARGET;
else process.env.AWF_SENSITIVE_OPENAI_TARGET = previous;
}
});

it('should not include undefined fields', () => {
logRequest('info', 'test', { a: undefined, b: 'value' });
const parsed = JSON.parse(stdoutSpy.mock.calls[0][0]);
Expand Down
18 changes: 18 additions & 0 deletions docs-site/src/content/docs/reference/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -791,6 +791,23 @@ sudo -E awf --enable-api-proxy \
-- command
```

### `--openai-base-url-env <name>`

Name of a **runner** environment variable (typically bound to a secret) whose value is the base URL of a private OpenAI-compatible endpoint. AWF reads and validates the URL on the runner before any container starts, derives the upstream host and base path for the api-proxy sidecar, adds the host to the Squid policy, excludes the variable from the agent environment, and redacts the URL/host/`host:port` forms from logs and audit artifacts.

The value must be an absolute `https://` URL without credentials, query string, fragment, or a non-default port. Invalid or missing values fail before agent startup with an error that does not echo the value.

Config path: `apiProxy.targets.openai.baseUrlEnv`. Takes precedence over `--openai-api-target` / `--openai-api-base-path`.

- **Default:** none
- **Requires:** `--enable-api-proxy`

```bash
sudo -E awf --enable-api-proxy \
--openai-base-url-env CODEX_LB_BASE_URL \
-- codex exec "..."
```

### `--openai-api-base-path <path>`

Base path prefix prepended to every upstream OpenAI API request path. Use this when the upstream endpoint requires a URL prefix (e.g., Databricks serving endpoints, Azure OpenAI deployments). Can also be set via the `OPENAI_API_BASE_PATH` environment variable.
Expand Down Expand Up @@ -1057,6 +1074,7 @@ These variables provide an alternative to the corresponding CLI flags for config
| `COPILOT_API_TARGET` | `api.githubcopilot.com` | Copilot API endpoint override |
| `OPENAI_API_TARGET` | `api.openai.com` | OpenAI API endpoint override |
| `OPENAI_API_BASE_PATH` | _(empty)_ | OpenAI API base path (e.g., `/serving-endpoints`) |
| `OPENAI_BASE_URL_ENV` | _(unset)_ | Name of the runner variable holding a secret OpenAI-compatible base URL (see `--openai-base-url-env`) |
| `ANTHROPIC_API_TARGET` | `api.anthropic.com` | Anthropic API endpoint override |
| `ANTHROPIC_API_BASE_PATH` | _(empty)_ | Anthropic API base path |

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 @@ -355,6 +355,7 @@ These entries document and constrain agent-originated traffic. They do not const
| Flag | Default | Description |
|------|---------|-------------|
| `--openai-api-target <host>` | `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 (or `OPENAI_ENDPOINT_OVERRIDE` for runtime secret-backed endpoint injection). |
| `--openai-base-url-env <name>` | none | Name of a runner environment variable (typically a secret) holding the full OpenAI-compatible base URL. AWF validates it on the runner, derives the sidecar target and base path, allows the host through Squid, hides the variable from the agent, and redacts it from logs and audit artifacts. Config path: `apiProxy.targets.openai.baseUrlEnv`. |
| `--anthropic-api-target <host>` | `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 <host>` | auto-derived | Custom upstream for GitHub Copilot API requests (useful for GHES). Can also be set via `COPILOT_API_TARGET` env var. |
| `--gemini-api-target <host>` | `generativelanguage.googleapis.com` | Custom upstream for Gemini API requests. Can also be set via `GEMINI_API_TARGET` env var. |
Expand Down
45 changes: 45 additions & 0 deletions docs/awf-config-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ AWF settings MAY be supplied via config files, including stdin (`--config -`).
- `apiProxy.targets.copilot.sessionId` → *(config-only; opt-in `x-session-id` header / `session_id` body field for Copilot BYOK requests, maps to `AWF_PROVIDER_SESSION_ID`. Never auto-derived from `GITHUB_RUN_ID`.)*
- `apiProxy.targets.openai.basePath` → `--openai-api-base-path`
- `apiProxy.targets.openai.authHeader` → `--openai-api-auth-header`
- `apiProxy.targets.openai.baseUrlEnv` → `--openai-base-url-env` *(names a runner environment variable holding a secret OpenAI-compatible base URL; see [§9.7 Secret-Backed OpenAI Target](#97-secret-backed-openai-target))*
- `apiProxy.targets.anthropic.basePath` → `--anthropic-api-base-path`
- `apiProxy.targets.anthropic.authHeader` → `--anthropic-api-auth-header`
- `apiProxy.targets.gemini.basePath` → `--gemini-api-base-path`
Expand Down Expand Up @@ -664,6 +665,50 @@ host and port and never receives `GITHUB_TOKEN` or `GH_TOKEN`. When the DIFC
proxy is an attached sibling container already reachable on `awf-net`, no relay
is created.

### 9.7 Secret-Backed OpenAI Target

`apiProxy.targets.openai.baseUrlEnv` names a **runner** environment variable
(typically bound to `${{ secrets.* }}` by the gh-aw compiler) whose value is the
base URL of a private OpenAI-compatible endpoint. This allows `engine: codex`
workflows to route through a sensitive endpoint without writing the URL into
workflow source or generated lockfiles.

```json
{
"apiProxy": {
"targets": {
"openai": {
"baseUrlEnv": "CODEX_LB_BASE_URL"
}
}
}
}
```

A conforming implementation:

1. MUST read the named variable only in runner-side configuration code, before
any container starts.
2. MUST require an absolute `https://` URL and MUST reject embedded credentials
(`user:pass@`), query strings, fragments, malformed hosts, unsupported
schemes, and non-default ports (the sidecar connects on port 443).
3. MUST derive the host, `host:port`, and optional base path from the URL.
4. MUST add the derived destination to the effective Squid policy without
persisting it in repository configuration, and MUST keep it out of
`allowedDomains` (it is carried in the sensitive allowlist instead).
5. MUST configure the OpenAI api-proxy adapter with the derived host
(`OPENAI_API_TARGET`) and base path (`OPENAI_API_BASE_PATH`); the derived
values take precedence over `apiProxy.targets.openai.host`/`basePath`.
6. MUST exclude the named variable from the primary agent environment, including
under `--env-all`.
7. MUST redact the URL, host, and `host:port` forms from logs, diagnostics, and
uploaded audit artifacts (`squid.conf`, `docker-compose.redacted.yml`).
8. MUST fail before agent startup, with an error message that does not contain
the value, when the variable is unset or invalid.

The same rules apply to agent and detection phases, which share this
configuration path.

## 10. Effective Token Budget Enforcement

*This section is normative.*
Expand Down
26 changes: 25 additions & 1 deletion docs/awf-config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@
"additionalProperties": false,
"properties": {
"openai": {
"$ref": "#/$defs/providerTarget",
"$ref": "#/$defs/openaiTarget",
"description": "OpenAI API target override."
},
"anthropic": {
Expand Down Expand Up @@ -1135,6 +1135,30 @@
}
}
},
"openaiTarget": {
"type": "object",
"description": "OpenAI API target override, optionally resolved from a runner environment variable at runtime.",
"additionalProperties": false,
"properties": {
"host": {
"type": "string",
"description": "Override the OpenAI API host."
},
"basePath": {
"type": "string",
"description": "Override the OpenAI API base path."
},
"authHeader": {
"type": "string",
"description": "Override the auth header name used for API requests. Replaces 'Authorization: ******' with '<authHeader>: <key>'."
},
"baseUrlEnv": {
"type": "string",
"pattern": "^[A-Za-z_][A-Za-z0-9_]*$",
"description": "Name of a runner environment variable (typically bound to a secret) holding the OpenAI-compatible base URL. AWF resolves and validates it before containers start, derives the host for network policy, keeps it out of the agent environment, and redacts it from logs and artifacts. Takes precedence over host/basePath."
}
}
},
"providerHostOnlyTarget": {
"type": "object",
"description": "API provider target override (host only; basePath not supported).",
Expand Down
15 changes: 15 additions & 0 deletions src/api-proxy-config-domains.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,21 @@ describe('resolveApiTargetsToAllowedDomains', () => {
expect(sensitive).toContain('https://secret.openai.internal');
});

it('prefers a secret-backed OpenAI base URL over configured and environment targets', () => {
const domains: string[] = [];
const sensitive: string[] = [];
resolveApiTargetsToAllowedDomains(
{ openaiBaseUrl: 'https://secret.openai.internal/v1', openaiApiTarget: 'configured.openai.com' },
domains,
{ OPENAI_API_TARGET: 'env.openai.com' },
() => {},
sensitive,
);
expect(sensitive).toContain('https://secret.openai.internal');
expect(domains).not.toContain('https://configured.openai.com');
expect(domains).not.toContain('https://env.openai.com');
});

it('should use pre-resolved openaiEndpointOverride param over env fallback', () => {
const domains: string[] = [];
const sensitive: string[] = [];
Expand Down
6 changes: 5 additions & 1 deletion src/api-proxy-config-domains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ function extractGhesDomainsFromEngineApiTarget(
export function resolveApiTargetsToAllowedDomains(
options: {
copilotApiTarget?: string;
/** Secret-backed OpenAI endpoint, which takes precedence over every other target source. */
openaiBaseUrl?: string;
openaiApiTarget?: string;
anthropicApiTarget?: string;
geminiApiTarget?: string;
Expand All @@ -136,7 +138,9 @@ export function resolveApiTargetsToAllowedDomains(
apiTargets.push({ value: env['COPILOT_API_TARGET'] });
}

if (options.openaiApiTarget) {
if (options.openaiBaseUrl) {
apiTargets.push({ value: options.openaiBaseUrl, sensitive: true });
} else if (options.openaiApiTarget) {
apiTargets.push({ value: options.openaiApiTarget });
} else if (env['OPENAI_API_TARGET']) {
apiTargets.push({ value: env['OPENAI_API_TARGET'] });
Expand Down
26 changes: 25 additions & 1 deletion src/awf-config-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@
"additionalProperties": false,
"properties": {
"openai": {
"$ref": "#/$defs/providerTarget",
"$ref": "#/$defs/openaiTarget",
"description": "OpenAI API target override."
},
"anthropic": {
Expand Down Expand Up @@ -1135,6 +1135,30 @@
}
}
},
"openaiTarget": {
"type": "object",
"description": "OpenAI API target override, optionally resolved from a runner environment variable at runtime.",
"additionalProperties": false,
"properties": {
"host": {
"type": "string",
"description": "Override the OpenAI API host."
},
"basePath": {
"type": "string",
"description": "Override the OpenAI API base path."
},
"authHeader": {
"type": "string",
"description": "Override the auth header name used for API requests. Replaces 'Authorization: ******' with '<authHeader>: <key>'."
},
"baseUrlEnv": {
"type": "string",
"pattern": "^[A-Za-z_][A-Za-z0-9_]*$",
"description": "Name of a runner environment variable (typically bound to a secret) holding the OpenAI-compatible base URL. AWF resolves and validates it before containers start, derives the host for network policy, keeps it out of the agent environment, and redacts it from logs and artifacts. Takes precedence over host/basePath."
}
}
},
"providerHostOnlyTarget": {
"type": "object",
"description": "API provider target override (host only; basePath not supported).",
Expand Down
4 changes: 4 additions & 0 deletions src/cli-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,10 @@ program
'--openai-api-target <host>',
'Target hostname for OpenAI API requests (default: api.openai.com)',
)
.option(
'--openai-base-url-env <name>',
'Name of a runner environment variable holding a secret OpenAI-compatible base URL',
)
.option(
'--openai-api-base-path <path>',
'Base path prefix for OpenAI API requests (e.g. /serving-endpoints for Databricks)',
Expand Down
60 changes: 60 additions & 0 deletions src/commands/preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,66 @@ describe('resolveAllowedDomains', () => {
);
});

it('passes the secret-backed base URL from openaiBaseUrlEnv to resolveApiTargetsToAllowedDomains', () => {
const saved = process.env.CODEX_LB_BASE_URL;
process.env.CODEX_LB_BASE_URL = 'https://lb.internal.example.com/v1';
try {
resolveAllowedDomains({ openaiBaseUrlEnv: 'CODEX_LB_BASE_URL' });
expect(mockedApiProxyConfig.resolveApiTargetsToAllowedDomains).toHaveBeenCalledWith(
expect.objectContaining({ openaiBaseUrl: 'https://lb.internal.example.com/v1' }),
expect.any(Array),
expect.any(Object),
expect.any(Function),
expect.any(Array),
undefined,
);
} finally {
if (saved !== undefined) process.env.CODEX_LB_BASE_URL = saved;
else delete process.env.CODEX_LB_BASE_URL;
}
});

it('uses OPENAI_BASE_URL_ENV as the secret-backed URL variable-name fallback', () => {
const saved = {
envName: process.env.OPENAI_BASE_URL_ENV,
baseUrl: process.env.CODEX_LB_BASE_URL,
};
process.env.OPENAI_BASE_URL_ENV = 'CODEX_LB_BASE_URL';
process.env.CODEX_LB_BASE_URL = 'https://lb.internal.example.com/v1';
try {
resolveAllowedDomains({});
expect(mockedApiProxyConfig.resolveApiTargetsToAllowedDomains).toHaveBeenCalledWith(
expect.objectContaining({ openaiBaseUrl: 'https://lb.internal.example.com/v1' }),
expect.any(Array),
expect.any(Object),
expect.any(Function),
expect.any(Array),
undefined,
);
} finally {
if (saved.envName !== undefined) process.env.OPENAI_BASE_URL_ENV = saved.envName;
else delete process.env.OPENAI_BASE_URL_ENV;
if (saved.baseUrl !== undefined) process.env.CODEX_LB_BASE_URL = saved.baseUrl;
else delete process.env.CODEX_LB_BASE_URL;
}
});

it('exits before startup when the openaiBaseUrlEnv value is invalid', () => {
const saved = process.env.CODEX_LB_BASE_URL;
process.env.CODEX_LB_BASE_URL = 'ftp://lb.internal.example.com';
const exitSpy = jest.spyOn(process, 'exit').mockImplementation(((): never => {
throw new Error('process.exit called');
}) as never);
try {
expect(() => resolveAllowedDomains({ openaiBaseUrlEnv: 'CODEX_LB_BASE_URL' })).toThrow('process.exit called');
expect(exitSpy).toHaveBeenCalledWith(1);
} finally {
exitSpy.mockRestore();
if (saved !== undefined) process.env.CODEX_LB_BASE_URL = saved;
else delete process.env.CODEX_LB_BASE_URL;
}
});

it('passes undefined openaiEndpointOverride when not set in any source', () => {
resolveAllowedDomains({});
expect(mockedApiProxyConfig.resolveApiTargetsToAllowedDomains).toHaveBeenCalledWith(
Expand Down
Loading
Loading