Skip to content

Commit dccd45c

Browse files
authored
refactor: extract shared credential-isolation scaffold for API proxy providers (#5870)
1 parent 97309fb commit dccd45c

6 files changed

Lines changed: 156 additions & 106 deletions

src/services/agent-environment-credentials.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,23 @@ describe('agent environment: credentials', () => {
4747
expect(env.COPILOT_PROVIDER_API_KEY).not.toBe('sk-real-provider-key');
4848
});
4949

50+
it('should mask COPILOT_PROVIDER_API_KEY supplied via additionalEnv (--env path)', () => {
51+
// When the user passes --env COPILOT_PROVIDER_API_KEY=<real-key>, the real key ends
52+
// up in config.additionalEnv. The credential-isolation logic must detect it there
53+
// (not only in config.copilotProviderApiKey) and replace it with the placeholder so
54+
// the real key never reaches the agent environment.
55+
const configWithEnv = {
56+
...mockConfig,
57+
enableApiProxy: true,
58+
additionalEnv: { COPILOT_PROVIDER_API_KEY: 'sk-env-provider-key' },
59+
};
60+
const proxyNetworkConfig = { ...mockNetworkConfig, proxyIp: '172.30.0.30' };
61+
const result = generateDockerCompose(configWithEnv, proxyNetworkConfig);
62+
const env = result.services.agent.environment as Record<string, string>;
63+
expect(env.COPILOT_PROVIDER_API_KEY).toBe('ghu_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
64+
expect(env.COPILOT_PROVIDER_API_KEY).not.toBe('sk-env-provider-key');
65+
});
66+
5067
it('should forward AWF_ONE_SHOT_TOKEN_DEBUG when set', () => {
5168
const original = process.env.AWF_ONE_SHOT_TOKEN_DEBUG;
5269
process.env.AWF_ONE_SHOT_TOKEN_DEBUG = '1';
Lines changed: 18 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { logger } from '../../logger';
21
import { WrapperConfig, API_PROXY_PORTS } from '../../types';
32
import { getLowerCaseProcessEnvValue } from '../../env-utils';
3+
import { buildProviderCredentialIsolationEnv } from './provider-credential-isolation';
44

55
interface AnthropicCredentialEnvParams {
66
config: WrapperConfig;
@@ -15,23 +15,6 @@ function shouldProxyAnthropic(config: WrapperConfig): boolean {
1515

1616
export function buildAnthropicCredentialEnv(params: AnthropicCredentialEnvParams): Record<string, string> {
1717
const { config, proxyIp } = params;
18-
if (!shouldProxyAnthropic(config)) {
19-
return {};
20-
}
21-
22-
const anthropicProxyUrl = `http://${proxyIp}:${API_PROXY_PORTS.ANTHROPIC}`;
23-
const agentEnvAdditions: Record<string, string> = {
24-
ANTHROPIC_BASE_URL: anthropicProxyUrl,
25-
};
26-
27-
logger.debug(`Anthropic API will be proxied through sidecar at ${anthropicProxyUrl}`);
28-
if (config.anthropicApiTarget) {
29-
logger.debug(`Anthropic API target overridden to: ${config.anthropicApiTarget}`);
30-
}
31-
if (config.anthropicApiBasePath) {
32-
logger.debug(`Anthropic API base path set to: ${config.anthropicApiBasePath}`);
33-
}
34-
3518
// Set placeholder credentials for Claude Code CLI credential isolation.
3619
// Real authentication happens via ANTHROPIC_BASE_URL pointing to api-proxy.
3720
// Use sk-ant- prefix so Claude Code's key-format validation passes.
@@ -40,13 +23,21 @@ export function buildAnthropicCredentialEnv(params: AnthropicCredentialEnvParams
4023
// via excluded-vars.ts when enableApiProxy is active. Setting it (even as a
4124
// placeholder) would cause Claude Code to attempt direct auth with it instead
4225
// of routing through ANTHROPIC_BASE_URL.
43-
agentEnvAdditions.ANTHROPIC_AUTH_TOKEN = 'sk-ant-placeholder-key-for-credential-isolation';
44-
logger.debug('ANTHROPIC_AUTH_TOKEN set to placeholder value for credential isolation');
45-
46-
// Set API key helper for Claude Code CLI to use credential isolation
47-
// The helper script returns a placeholder key; real authentication happens via ANTHROPIC_BASE_URL
48-
agentEnvAdditions.CLAUDE_CODE_API_KEY_HELPER = '/usr/local/bin/get-claude-key.sh';
49-
logger.debug('Claude Code API key helper configured: /usr/local/bin/get-claude-key.sh');
50-
51-
return agentEnvAdditions;
26+
return buildProviderCredentialIsolationEnv({
27+
providerName: 'Anthropic',
28+
proxyIp,
29+
port: API_PROXY_PORTS.ANTHROPIC,
30+
enabled: shouldProxyAnthropic(config),
31+
baseUrlVarNames: ['ANTHROPIC_BASE_URL'],
32+
target: config.anthropicApiTarget,
33+
basePath: config.anthropicApiBasePath,
34+
placeholders: {
35+
ANTHROPIC_AUTH_TOKEN: 'sk-ant-placeholder-key-for-credential-isolation',
36+
},
37+
// Set API key helper for Claude Code CLI to use credential isolation.
38+
// The helper script returns a placeholder key; real authentication happens via ANTHROPIC_BASE_URL.
39+
extraEnv: {
40+
CLAUDE_CODE_API_KEY_HELPER: '/usr/local/bin/get-claude-key.sh',
41+
},
42+
});
5243
}

src/services/credentials/copilot-credential-env.ts

Lines changed: 27 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { logger } from '../../logger';
22
import { WrapperConfig, API_PROXY_PORTS } from '../../types';
33
import { COPILOT_PLACEHOLDER_TOKEN } from '../../constants/placeholders';
44
import { getConfigEnvValue } from '../../env-utils';
5+
import { buildProviderCredentialIsolationEnv } from './provider-credential-isolation';
56

67
interface CopilotCredentialEnvParams {
78
config: WrapperConfig;
@@ -43,29 +44,33 @@ export function buildCopilotCredentialEnv(params: CopilotCredentialEnvParams): R
4344
// letting the real BASE_URL leak into the agent) preserves the credential-isolation
4445
// invariant and surfaces a clear error instead of a silent bypass.
4546
// Reference: https://github.blog/changelog/2026-04-07-copilot-cli-now-supports-byok-and-local-models/
46-
const hasCopilotProviderApiKey = !!config.copilotProviderApiKey;
47+
const hasCopilotProviderApiKey = !!config.copilotProviderApiKey || !!getConfigEnvValue(config, 'COPILOT_PROVIDER_API_KEY');
4748
const hasCopilotProviderBaseUrl = !!config.copilotProviderBaseUrl || !!getConfigEnvValue(config, 'COPILOT_PROVIDER_BASE_URL');
48-
if (!config.copilotGithubToken && !hasCopilotProviderApiKey && !hasCopilotProviderBaseUrl) {
49-
return {};
50-
}
49+
const enabled = !!(config.copilotGithubToken || hasCopilotProviderApiKey || hasCopilotProviderBaseUrl);
5150

52-
const copilotProxyUrl = `http://${proxyIp}:${API_PROXY_PORTS.COPILOT}`;
53-
const agentEnvAdditions: Record<string, string> = {
54-
COPILOT_API_URL: copilotProxyUrl,
55-
COPILOT_TOKEN: COPILOT_PLACEHOLDER_TOKEN,
56-
COPILOT_OFFLINE: 'true',
57-
COPILOT_PROVIDER_BASE_URL: copilotProxyUrl,
58-
};
51+
const env = buildProviderCredentialIsolationEnv({
52+
providerName: 'GitHub Copilot',
53+
proxyIp,
54+
port: API_PROXY_PORTS.COPILOT,
55+
enabled,
56+
// COPILOT_API_URL: sidecar URL for the Copilot token/completion endpoint.
57+
// COPILOT_PROVIDER_BASE_URL: sidecar URL for the BYOK provider endpoint.
58+
baseUrlVarNames: ['COPILOT_API_URL', 'COPILOT_PROVIDER_BASE_URL'],
59+
target: config.copilotApiTarget,
60+
placeholders: {
61+
COPILOT_TOKEN: COPILOT_PLACEHOLDER_TOKEN,
62+
},
63+
// Enable Copilot CLI offline + BYOK mode so it skips the GitHub OAuth handshake
64+
// and talks directly to the sidecar without needing GitHub authentication for inference.
65+
extraEnv: {
66+
COPILOT_OFFLINE: 'true',
67+
},
68+
});
5969

60-
logger.debug(`GitHub Copilot API will be proxied through sidecar at ${copilotProxyUrl}`);
61-
if (config.copilotApiTarget) {
62-
logger.debug(`Copilot API target overridden to: ${config.copilotApiTarget}`);
70+
if (!enabled) {
71+
return env;
6372
}
6473

65-
// Set placeholder token for GitHub Copilot CLI compatibility
66-
// Real authentication happens via COPILOT_API_URL pointing to api-proxy
67-
logger.debug('COPILOT_TOKEN set to placeholder value for credential isolation');
68-
6974
// Credential-isolation placeholders for the BYOK auth variables. These MUST be
7075
// set here (in agentEnvAdditions, applied last in compose-generator) rather than
7176
// only in tool-specific-environment.ts, because `Object.assign(environment,
@@ -76,14 +81,14 @@ export function buildCopilotCredentialEnv(params: CopilotCredentialEnvParams): R
7681
// placeholders regardless of which env input path (--env / --env-file / --env-all)
7782
// the user used.
7883
if (config.copilotGithubToken) {
79-
agentEnvAdditions.COPILOT_GITHUB_TOKEN = COPILOT_PLACEHOLDER_TOKEN;
84+
env.COPILOT_GITHUB_TOKEN = COPILOT_PLACEHOLDER_TOKEN;
8085
logger.debug('COPILOT_GITHUB_TOKEN set to placeholder value for credential isolation');
8186
}
8287
// Only mask COPILOT_PROVIDER_API_KEY when the user actually supplied one. If
8388
// there is nothing to mask, omit it rather than injecting a placeholder that
8489
// would misleadingly tell Copilot CLI "a key is configured".
8590
if (hasCopilotProviderApiKey) {
86-
agentEnvAdditions.COPILOT_PROVIDER_API_KEY = COPILOT_PLACEHOLDER_TOKEN;
91+
env.COPILOT_PROVIDER_API_KEY = COPILOT_PLACEHOLDER_TOKEN;
8792
logger.debug('COPILOT_PROVIDER_API_KEY set to placeholder value for credential isolation');
8893
}
8994

@@ -92,18 +97,9 @@ export function buildCopilotCredentialEnv(params: CopilotCredentialEnvParams): R
9297
// Copilot CLI uses the correct endpoint in both BYOK modes.
9398
const copilotModel = getConfigEnvValue(config, 'COPILOT_MODEL');
9499
if (copilotModel && requiresResponsesWireApi(copilotModel)) {
95-
agentEnvAdditions.COPILOT_PROVIDER_WIRE_API = 'responses';
100+
env.COPILOT_PROVIDER_WIRE_API = 'responses';
96101
logger.debug(`COPILOT_PROVIDER_WIRE_API set to responses for model: ${copilotModel}`);
97102
}
98103

99-
// Enable Copilot CLI offline + BYOK mode so it skips the GitHub OAuth handshake
100-
// and talks directly to the sidecar without needing GitHub authentication for inference.
101-
logger.debug('COPILOT_OFFLINE set to true for offline+BYOK mode');
102-
103-
// Point Copilot CLI's BYOK provider URL at the sidecar. The sidecar then forwards
104-
// either to api.githubcopilot.com (GitHub-token mode) or to the user-supplied
105-
// upstream COPILOT_PROVIDER_BASE_URL (direct-BYOK mode).
106-
logger.debug(`COPILOT_PROVIDER_BASE_URL set to sidecar at ${copilotProxyUrl}`);
107-
108-
return agentEnvAdditions;
104+
return env;
109105
}
Lines changed: 15 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { logger } from '../../logger';
21
import { WrapperConfig, API_PROXY_PORTS } from '../../types';
2+
import { buildProviderCredentialIsolationEnv } from './provider-credential-isolation';
33

44
interface GeminiCredentialEnvParams {
55
config: WrapperConfig;
@@ -12,33 +12,23 @@ export function buildGeminiCredentialEnv(params: GeminiCredentialEnvParams): Rec
1212
// Previously this was unconditional, which caused the Gemini CLI's ~/.gemini
1313
// directory and GEMINI_API_KEY placeholder to appear in non-Gemini runs (e.g.
1414
// Copilot-only runs), producing suspicious-looking log entries.
15-
if (!config.geminiApiKey) {
16-
return {};
17-
}
18-
19-
const geminiProxyUrl = `http://${proxyIp}:${API_PROXY_PORTS.GEMINI}`;
20-
const agentEnvAdditions: Record<string, string> = {
15+
return buildProviderCredentialIsolationEnv({
16+
providerName: 'Google Gemini',
17+
proxyIp,
18+
port: API_PROXY_PORTS.GEMINI,
19+
enabled: !!config.geminiApiKey,
2120
// GOOGLE_GEMINI_BASE_URL is the env var read by the Gemini CLI (google-gemini/gemini-cli)
2221
// when authType === USE_GEMINI. Setting it routes all Gemini CLI traffic through
2322
// the api-proxy sidecar instead of calling generativelanguage.googleapis.com directly.
24-
GOOGLE_GEMINI_BASE_URL: geminiProxyUrl,
2523
// GEMINI_API_BASE_URL is kept for backward compatibility with older SDK versions
2624
// and other tools that may read it (e.g. @google/generative-ai npm package).
27-
GEMINI_API_BASE_URL: geminiProxyUrl,
28-
};
29-
30-
logger.debug(`Google Gemini API will be proxied through sidecar at ${geminiProxyUrl}`);
31-
if (config.geminiApiTarget) {
32-
logger.debug(`Gemini API target overridden to: ${config.geminiApiTarget}`);
33-
}
34-
if (config.geminiApiBasePath) {
35-
logger.debug(`Gemini API base path set to: ${config.geminiApiBasePath}`);
36-
}
37-
38-
// Set placeholder key so Gemini CLI's startup auth check passes (exit code 41).
39-
// Real authentication happens via GOOGLE_GEMINI_BASE_URL / GEMINI_API_BASE_URL pointing to api-proxy.
40-
agentEnvAdditions.GEMINI_API_KEY = 'gemini-api-key-placeholder-for-credential-isolation';
41-
logger.debug('GEMINI_API_KEY set to placeholder value for credential isolation');
42-
43-
return agentEnvAdditions;
25+
baseUrlVarNames: ['GOOGLE_GEMINI_BASE_URL', 'GEMINI_API_BASE_URL'],
26+
target: config.geminiApiTarget,
27+
basePath: config.geminiApiBasePath,
28+
// Set placeholder key so Gemini CLI's startup auth check passes (exit code 41).
29+
// Real authentication happens via GOOGLE_GEMINI_BASE_URL / GEMINI_API_BASE_URL pointing to api-proxy.
30+
placeholders: {
31+
GEMINI_API_KEY: 'gemini-api-key-placeholder-for-credential-isolation',
32+
},
33+
});
4434
}
Lines changed: 14 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { logger } from '../../logger';
21
import { WrapperConfig, API_PROXY_PORTS } from '../../types';
2+
import { buildProviderCredentialIsolationEnv } from './provider-credential-isolation';
33

44
interface OpenAiCredentialEnvParams {
55
config: WrapperConfig;
@@ -8,23 +8,6 @@ interface OpenAiCredentialEnvParams {
88

99
export function buildOpenAiCredentialEnv(params: OpenAiCredentialEnvParams): Record<string, string> {
1010
const { config, proxyIp } = params;
11-
if (!config.openaiApiKey) {
12-
return {};
13-
}
14-
15-
const openAiProxyUrl = `http://${proxyIp}:${API_PROXY_PORTS.OPENAI}`;
16-
const agentEnvAdditions: Record<string, string> = {
17-
OPENAI_BASE_URL: openAiProxyUrl,
18-
};
19-
20-
logger.debug(`OpenAI API will be proxied through sidecar at ${openAiProxyUrl}`);
21-
if (config.openaiApiTarget) {
22-
logger.debug(`OpenAI API target overridden to: ${config.openaiApiTarget}`);
23-
}
24-
if (config.openaiApiBasePath) {
25-
logger.debug(`OpenAI API base path set to: ${config.openaiApiBasePath}`);
26-
}
27-
2811
// Inject placeholder API keys for OpenAI/Codex credential isolation.
2912
// Codex v0.121+ introduced a CODEX_API_KEY-based WebSocket auth flow: when no
3013
// API key is found in the agent env, Codex bypasses OPENAI_BASE_URL and connects
@@ -34,9 +17,17 @@ export function buildOpenAiCredentialEnv(params: OpenAiCredentialEnvParams): Rec
3417
// The real keys are held securely in the sidecar; when requests are routed
3518
// through api-proxy, these placeholders are expected to be overwritten by the
3619
// api-proxy's injectHeaders before forwarding upstream.
37-
agentEnvAdditions.OPENAI_API_KEY = 'sk-placeholder-for-api-proxy';
38-
agentEnvAdditions.CODEX_API_KEY = 'sk-placeholder-for-api-proxy';
39-
logger.debug('OPENAI_API_KEY and CODEX_API_KEY set to placeholder values for credential isolation');
40-
41-
return agentEnvAdditions;
20+
return buildProviderCredentialIsolationEnv({
21+
providerName: 'OpenAI',
22+
proxyIp,
23+
port: API_PROXY_PORTS.OPENAI,
24+
enabled: !!config.openaiApiKey,
25+
baseUrlVarNames: ['OPENAI_BASE_URL'],
26+
target: config.openaiApiTarget,
27+
basePath: config.openaiApiBasePath,
28+
placeholders: {
29+
OPENAI_API_KEY: 'sk-placeholder-for-api-proxy',
30+
CODEX_API_KEY: 'sk-placeholder-for-api-proxy',
31+
},
32+
});
4233
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { logger } from '../../logger';
2+
3+
export interface ProviderCredentialIsolationOptions {
4+
/** Human-readable provider name used in debug log messages, e.g. "OpenAI" */
5+
providerName: string;
6+
proxyIp: string;
7+
port: number;
8+
/** When false the provider is not routed through the sidecar; the helper returns {} */
9+
enabled: boolean;
10+
/**
11+
* Names of the env vars that should be set to the sidecar proxy URL
12+
* (e.g. `['OPENAI_BASE_URL']` or `['COPILOT_API_URL', 'COPILOT_PROVIDER_BASE_URL']`).
13+
*/
14+
baseUrlVarNames: string[];
15+
/** Optional target hostname override — logged only, not injected into env */
16+
target?: string;
17+
/** Optional base-path override — logged only, not injected into env */
18+
basePath?: string;
19+
/** Placeholder credential vars to inject into the agent env */
20+
placeholders: Record<string, string>;
21+
/** Any additional provider-specific env vars to merge after placeholders */
22+
extraEnv?: Record<string, string>;
23+
}
24+
25+
/**
26+
* Shared scaffold for all per-provider credential-isolation env builders.
27+
*
28+
* Handles the security-critical flow common to every provider:
29+
* 1. Enabled guard — returns {} when the provider should not be proxied.
30+
* 2. Proxy URL construction — `http://<proxyIp>:<port>`.
31+
* 3. Base-URL env vars — each name in `baseUrlVarNames` is set to the proxy URL.
32+
* 4. Debug logging — proxy URL, optional target override, optional base-path override.
33+
* 5. Placeholder merge — injects credential placeholder vars so real keys stay in the sidecar.
34+
* 6. Extra-env merge — any additional provider-specific vars (e.g. `COPILOT_OFFLINE`).
35+
*
36+
* Provider files keep only their enable-condition logic and any conditional post-processing
37+
* (e.g. Copilot's BYOK placeholders, Wire API env var).
38+
*/
39+
export function buildProviderCredentialIsolationEnv(opts: ProviderCredentialIsolationOptions): Record<string, string> {
40+
if (!opts.enabled) {
41+
return {};
42+
}
43+
44+
const proxyUrl = `http://${opts.proxyIp}:${opts.port}`;
45+
const result: Record<string, string> = {};
46+
47+
for (const envVar of opts.baseUrlVarNames) {
48+
result[envVar] = proxyUrl;
49+
}
50+
51+
logger.debug(`${opts.providerName} API will be proxied through sidecar at ${proxyUrl}`);
52+
if (opts.target) {
53+
logger.debug(`${opts.providerName} API target overridden to: ${opts.target}`);
54+
}
55+
if (opts.basePath) {
56+
logger.debug(`${opts.providerName} API base path set to: ${opts.basePath}`);
57+
}
58+
59+
Object.assign(result, opts.placeholders);
60+
if (opts.extraEnv) {
61+
Object.assign(result, opts.extraEnv);
62+
}
63+
64+
return result;
65+
}

0 commit comments

Comments
 (0)