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
17 changes: 17 additions & 0 deletions src/services/agent-environment-credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,23 @@ describe('agent environment: credentials', () => {
expect(env.COPILOT_PROVIDER_API_KEY).not.toBe('sk-real-provider-key');
});

it('should mask COPILOT_PROVIDER_API_KEY supplied via additionalEnv (--env path)', () => {
// When the user passes --env COPILOT_PROVIDER_API_KEY=<real-key>, the real key ends
// up in config.additionalEnv. The credential-isolation logic must detect it there
// (not only in config.copilotProviderApiKey) and replace it with the placeholder so
// the real key never reaches the agent environment.
const configWithEnv = {
...mockConfig,
enableApiProxy: true,
additionalEnv: { COPILOT_PROVIDER_API_KEY: 'sk-env-provider-key' },
};
const proxyNetworkConfig = { ...mockNetworkConfig, proxyIp: '172.30.0.30' };
const result = generateDockerCompose(configWithEnv, proxyNetworkConfig);
const env = result.services.agent.environment as Record<string, string>;
expect(env.COPILOT_PROVIDER_API_KEY).toBe('ghu_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
expect(env.COPILOT_PROVIDER_API_KEY).not.toBe('sk-env-provider-key');
});

it('should forward AWF_ONE_SHOT_TOKEN_DEBUG when set', () => {
const original = process.env.AWF_ONE_SHOT_TOKEN_DEBUG;
process.env.AWF_ONE_SHOT_TOKEN_DEBUG = '1';
Expand Down
45 changes: 18 additions & 27 deletions src/services/credentials/anthropic-credential-env.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { logger } from '../../logger';
import { WrapperConfig, API_PROXY_PORTS } from '../../types';
import { getLowerCaseProcessEnvValue } from '../../env-utils';
import { buildProviderCredentialIsolationEnv } from './provider-credential-isolation';

interface AnthropicCredentialEnvParams {
config: WrapperConfig;
Expand All @@ -15,23 +15,6 @@ function shouldProxyAnthropic(config: WrapperConfig): boolean {

export function buildAnthropicCredentialEnv(params: AnthropicCredentialEnvParams): Record<string, string> {
const { config, proxyIp } = params;
if (!shouldProxyAnthropic(config)) {
return {};
}

const anthropicProxyUrl = `http://${proxyIp}:${API_PROXY_PORTS.ANTHROPIC}`;
const agentEnvAdditions: Record<string, string> = {
ANTHROPIC_BASE_URL: anthropicProxyUrl,
};

logger.debug(`Anthropic API will be proxied through sidecar at ${anthropicProxyUrl}`);
if (config.anthropicApiTarget) {
logger.debug(`Anthropic API target overridden to: ${config.anthropicApiTarget}`);
}
if (config.anthropicApiBasePath) {
logger.debug(`Anthropic API base path set to: ${config.anthropicApiBasePath}`);
}

// Set placeholder credentials for Claude Code CLI credential isolation.
// Real authentication happens via ANTHROPIC_BASE_URL pointing to api-proxy.
// Use sk-ant- prefix so Claude Code's key-format validation passes.
Expand All @@ -40,13 +23,21 @@ export function buildAnthropicCredentialEnv(params: AnthropicCredentialEnvParams
// via excluded-vars.ts when enableApiProxy is active. Setting it (even as a
// placeholder) would cause Claude Code to attempt direct auth with it instead
// of routing through ANTHROPIC_BASE_URL.
agentEnvAdditions.ANTHROPIC_AUTH_TOKEN = 'sk-ant-placeholder-key-for-credential-isolation';
logger.debug('ANTHROPIC_AUTH_TOKEN set to placeholder value for credential isolation');

// Set API key helper for Claude Code CLI to use credential isolation
// The helper script returns a placeholder key; real authentication happens via ANTHROPIC_BASE_URL
agentEnvAdditions.CLAUDE_CODE_API_KEY_HELPER = '/usr/local/bin/get-claude-key.sh';
logger.debug('Claude Code API key helper configured: /usr/local/bin/get-claude-key.sh');

return agentEnvAdditions;
return buildProviderCredentialIsolationEnv({
providerName: 'Anthropic',
proxyIp,
port: API_PROXY_PORTS.ANTHROPIC,
enabled: shouldProxyAnthropic(config),
baseUrlVarNames: ['ANTHROPIC_BASE_URL'],
target: config.anthropicApiTarget,
basePath: config.anthropicApiBasePath,
placeholders: {
ANTHROPIC_AUTH_TOKEN: 'sk-ant-placeholder-key-for-credential-isolation',
},
// Set API key helper for Claude Code CLI to use credential isolation.
// The helper script returns a placeholder key; real authentication happens via ANTHROPIC_BASE_URL.
extraEnv: {
CLAUDE_CODE_API_KEY_HELPER: '/usr/local/bin/get-claude-key.sh',
},
});
}
58 changes: 27 additions & 31 deletions src/services/credentials/copilot-credential-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { logger } from '../../logger';
import { WrapperConfig, API_PROXY_PORTS } from '../../types';
import { COPILOT_PLACEHOLDER_TOKEN } from '../../constants/placeholders';
import { getConfigEnvValue } from '../../env-utils';
import { buildProviderCredentialIsolationEnv } from './provider-credential-isolation';

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

const copilotProxyUrl = `http://${proxyIp}:${API_PROXY_PORTS.COPILOT}`;
const agentEnvAdditions: Record<string, string> = {
COPILOT_API_URL: copilotProxyUrl,
COPILOT_TOKEN: COPILOT_PLACEHOLDER_TOKEN,
COPILOT_OFFLINE: 'true',
COPILOT_PROVIDER_BASE_URL: copilotProxyUrl,
};
const env = buildProviderCredentialIsolationEnv({
providerName: 'GitHub Copilot',
proxyIp,
port: API_PROXY_PORTS.COPILOT,
enabled,
// COPILOT_API_URL: sidecar URL for the Copilot token/completion endpoint.
// COPILOT_PROVIDER_BASE_URL: sidecar URL for the BYOK provider endpoint.
baseUrlVarNames: ['COPILOT_API_URL', 'COPILOT_PROVIDER_BASE_URL'],
target: config.copilotApiTarget,
placeholders: {
COPILOT_TOKEN: COPILOT_PLACEHOLDER_TOKEN,
},
// Enable Copilot CLI offline + BYOK mode so it skips the GitHub OAuth handshake
// and talks directly to the sidecar without needing GitHub authentication for inference.
extraEnv: {
COPILOT_OFFLINE: 'true',
},
});

logger.debug(`GitHub Copilot API will be proxied through sidecar at ${copilotProxyUrl}`);
if (config.copilotApiTarget) {
logger.debug(`Copilot API target overridden to: ${config.copilotApiTarget}`);
if (!enabled) {
return env;
}

// Set placeholder token for GitHub Copilot CLI compatibility
// Real authentication happens via COPILOT_API_URL pointing to api-proxy
logger.debug('COPILOT_TOKEN set to placeholder value for credential isolation');

// Credential-isolation placeholders for the BYOK auth variables. These MUST be
// set here (in agentEnvAdditions, applied last in compose-generator) rather than
// only in tool-specific-environment.ts, because `Object.assign(environment,
Expand All @@ -76,14 +81,14 @@ export function buildCopilotCredentialEnv(params: CopilotCredentialEnvParams): R
// placeholders regardless of which env input path (--env / --env-file / --env-all)
// the user used.
if (config.copilotGithubToken) {
agentEnvAdditions.COPILOT_GITHUB_TOKEN = COPILOT_PLACEHOLDER_TOKEN;
env.COPILOT_GITHUB_TOKEN = COPILOT_PLACEHOLDER_TOKEN;
logger.debug('COPILOT_GITHUB_TOKEN set to placeholder value for credential isolation');
}
// Only mask COPILOT_PROVIDER_API_KEY when the user actually supplied one. If
// there is nothing to mask, omit it rather than injecting a placeholder that
// would misleadingly tell Copilot CLI "a key is configured".
if (hasCopilotProviderApiKey) {
agentEnvAdditions.COPILOT_PROVIDER_API_KEY = COPILOT_PLACEHOLDER_TOKEN;
env.COPILOT_PROVIDER_API_KEY = COPILOT_PLACEHOLDER_TOKEN;
logger.debug('COPILOT_PROVIDER_API_KEY set to placeholder value for credential isolation');
}

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

// Enable Copilot CLI offline + BYOK mode so it skips the GitHub OAuth handshake
// and talks directly to the sidecar without needing GitHub authentication for inference.
logger.debug('COPILOT_OFFLINE set to true for offline+BYOK mode');

// Point Copilot CLI's BYOK provider URL at the sidecar. The sidecar then forwards
// either to api.githubcopilot.com (GitHub-token mode) or to the user-supplied
// upstream COPILOT_PROVIDER_BASE_URL (direct-BYOK mode).
logger.debug(`COPILOT_PROVIDER_BASE_URL set to sidecar at ${copilotProxyUrl}`);

return agentEnvAdditions;
return env;
}
40 changes: 15 additions & 25 deletions src/services/credentials/gemini-credential-env.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { logger } from '../../logger';
import { WrapperConfig, API_PROXY_PORTS } from '../../types';
import { buildProviderCredentialIsolationEnv } from './provider-credential-isolation';

interface GeminiCredentialEnvParams {
config: WrapperConfig;
Expand All @@ -12,33 +12,23 @@ export function buildGeminiCredentialEnv(params: GeminiCredentialEnvParams): Rec
// Previously this was unconditional, which caused the Gemini CLI's ~/.gemini
// directory and GEMINI_API_KEY placeholder to appear in non-Gemini runs (e.g.
// Copilot-only runs), producing suspicious-looking log entries.
if (!config.geminiApiKey) {
return {};
}

const geminiProxyUrl = `http://${proxyIp}:${API_PROXY_PORTS.GEMINI}`;
const agentEnvAdditions: Record<string, string> = {
return buildProviderCredentialIsolationEnv({
providerName: 'Google Gemini',
proxyIp,
port: API_PROXY_PORTS.GEMINI,
enabled: !!config.geminiApiKey,
// GOOGLE_GEMINI_BASE_URL is the env var read by the Gemini CLI (google-gemini/gemini-cli)
// when authType === USE_GEMINI. Setting it routes all Gemini CLI traffic through
// the api-proxy sidecar instead of calling generativelanguage.googleapis.com directly.
GOOGLE_GEMINI_BASE_URL: geminiProxyUrl,
// GEMINI_API_BASE_URL is kept for backward compatibility with older SDK versions
// and other tools that may read it (e.g. @google/generative-ai npm package).
GEMINI_API_BASE_URL: geminiProxyUrl,
};

logger.debug(`Google Gemini API will be proxied through sidecar at ${geminiProxyUrl}`);
if (config.geminiApiTarget) {
logger.debug(`Gemini API target overridden to: ${config.geminiApiTarget}`);
}
if (config.geminiApiBasePath) {
logger.debug(`Gemini API base path set to: ${config.geminiApiBasePath}`);
}

// Set placeholder key so Gemini CLI's startup auth check passes (exit code 41).
// Real authentication happens via GOOGLE_GEMINI_BASE_URL / GEMINI_API_BASE_URL pointing to api-proxy.
agentEnvAdditions.GEMINI_API_KEY = 'gemini-api-key-placeholder-for-credential-isolation';
logger.debug('GEMINI_API_KEY set to placeholder value for credential isolation');

return agentEnvAdditions;
baseUrlVarNames: ['GOOGLE_GEMINI_BASE_URL', 'GEMINI_API_BASE_URL'],
target: config.geminiApiTarget,
basePath: config.geminiApiBasePath,
// Set placeholder key so Gemini CLI's startup auth check passes (exit code 41).
// Real authentication happens via GOOGLE_GEMINI_BASE_URL / GEMINI_API_BASE_URL pointing to api-proxy.
placeholders: {
GEMINI_API_KEY: 'gemini-api-key-placeholder-for-credential-isolation',
},
});
}
37 changes: 14 additions & 23 deletions src/services/credentials/openai-credential-env.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { logger } from '../../logger';
import { WrapperConfig, API_PROXY_PORTS } from '../../types';
import { buildProviderCredentialIsolationEnv } from './provider-credential-isolation';

interface OpenAiCredentialEnvParams {
config: WrapperConfig;
Expand All @@ -8,23 +8,6 @@ interface OpenAiCredentialEnvParams {

export function buildOpenAiCredentialEnv(params: OpenAiCredentialEnvParams): Record<string, string> {
const { config, proxyIp } = params;
if (!config.openaiApiKey) {
return {};
}

const openAiProxyUrl = `http://${proxyIp}:${API_PROXY_PORTS.OPENAI}`;
const agentEnvAdditions: Record<string, string> = {
OPENAI_BASE_URL: openAiProxyUrl,
};

logger.debug(`OpenAI API will be proxied through sidecar at ${openAiProxyUrl}`);
if (config.openaiApiTarget) {
logger.debug(`OpenAI API target overridden to: ${config.openaiApiTarget}`);
}
if (config.openaiApiBasePath) {
logger.debug(`OpenAI API base path set to: ${config.openaiApiBasePath}`);
}

// Inject placeholder API keys for OpenAI/Codex credential isolation.
// Codex v0.121+ introduced a CODEX_API_KEY-based WebSocket auth flow: when no
// API key is found in the agent env, Codex bypasses OPENAI_BASE_URL and connects
Expand All @@ -34,9 +17,17 @@ export function buildOpenAiCredentialEnv(params: OpenAiCredentialEnvParams): Rec
// The real keys are held securely in the sidecar; when requests are routed
// through api-proxy, these placeholders are expected to be overwritten by the
// api-proxy's injectHeaders before forwarding upstream.
agentEnvAdditions.OPENAI_API_KEY = 'sk-placeholder-for-api-proxy';
agentEnvAdditions.CODEX_API_KEY = 'sk-placeholder-for-api-proxy';
logger.debug('OPENAI_API_KEY and CODEX_API_KEY set to placeholder values for credential isolation');

return agentEnvAdditions;
return buildProviderCredentialIsolationEnv({
providerName: 'OpenAI',
proxyIp,
port: API_PROXY_PORTS.OPENAI,
enabled: !!config.openaiApiKey,
baseUrlVarNames: ['OPENAI_BASE_URL'],
target: config.openaiApiTarget,
basePath: config.openaiApiBasePath,
placeholders: {
OPENAI_API_KEY: 'sk-placeholder-for-api-proxy',
CODEX_API_KEY: 'sk-placeholder-for-api-proxy',
},
});
}
65 changes: 65 additions & 0 deletions src/services/credentials/provider-credential-isolation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { logger } from '../../logger';

export interface ProviderCredentialIsolationOptions {
/** Human-readable provider name used in debug log messages, e.g. "OpenAI" */
providerName: string;
proxyIp: string;
port: number;
/** When false the provider is not routed through the sidecar; the helper returns {} */
enabled: boolean;
/**
* Names of the env vars that should be set to the sidecar proxy URL
* (e.g. `['OPENAI_BASE_URL']` or `['COPILOT_API_URL', 'COPILOT_PROVIDER_BASE_URL']`).
*/
baseUrlVarNames: string[];
/** Optional target hostname override — logged only, not injected into env */
target?: string;
/** Optional base-path override — logged only, not injected into env */
basePath?: string;
/** Placeholder credential vars to inject into the agent env */
placeholders: Record<string, string>;
/** Any additional provider-specific env vars to merge after placeholders */
extraEnv?: Record<string, string>;
}

/**
* Shared scaffold for all per-provider credential-isolation env builders.
*
* Handles the security-critical flow common to every provider:
* 1. Enabled guard — returns {} when the provider should not be proxied.
* 2. Proxy URL construction — `http://<proxyIp>:<port>`.
* 3. Base-URL env vars — each name in `baseUrlVarNames` is set to the proxy URL.
* 4. Debug logging — proxy URL, optional target override, optional base-path override.
* 5. Placeholder merge — injects credential placeholder vars so real keys stay in the sidecar.
* 6. Extra-env merge — any additional provider-specific vars (e.g. `COPILOT_OFFLINE`).
*
* Provider files keep only their enable-condition logic and any conditional post-processing
* (e.g. Copilot's BYOK placeholders, Wire API env var).
*/
export function buildProviderCredentialIsolationEnv(opts: ProviderCredentialIsolationOptions): Record<string, string> {
if (!opts.enabled) {
return {};
}

const proxyUrl = `http://${opts.proxyIp}:${opts.port}`;
const result: Record<string, string> = {};

for (const envVar of opts.baseUrlVarNames) {
result[envVar] = proxyUrl;
}

logger.debug(`${opts.providerName} API will be proxied through sidecar at ${proxyUrl}`);
if (opts.target) {
logger.debug(`${opts.providerName} API target overridden to: ${opts.target}`);
}
if (opts.basePath) {
logger.debug(`${opts.providerName} API base path set to: ${opts.basePath}`);
}

Object.assign(result, opts.placeholders);
if (opts.extraEnv) {
Object.assign(result, opts.extraEnv);
}

return result;
}
Loading