Skip to content
Open
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
3 changes: 2 additions & 1 deletion containers/api-proxy/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ USER apiproxy
# 10000 - OpenAI API proxy (also serves as health check endpoint)
# 10001 - Anthropic API proxy
# 10002 - GitHub Copilot API proxy
# 10003 - Google Gemini API proxy
# 10004 - OpenCode API proxy (routes to Anthropic)
EXPOSE 10000 10001 10002 10004
EXPOSE 10000 10001 10002 10003 10004

# Use exec form so node is PID 1 and receives SIGTERM directly
CMD ["node", "server.js"]
35 changes: 35 additions & 0 deletions containers/api-proxy/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,12 @@ function shouldStripHeader(name) {
const OPENAI_API_KEY = (process.env.OPENAI_API_KEY || '').trim() || undefined;
const ANTHROPIC_API_KEY = (process.env.ANTHROPIC_API_KEY || '').trim() || undefined;
const COPILOT_GITHUB_TOKEN = (process.env.COPILOT_GITHUB_TOKEN || '').trim() || undefined;
const GEMINI_API_KEY = (process.env.GEMINI_API_KEY || '').trim() || undefined;

// Configurable API target hosts (supports custom endpoints / internal LLM routers)
const OPENAI_API_TARGET = process.env.OPENAI_API_TARGET || 'api.openai.com';
const ANTHROPIC_API_TARGET = process.env.ANTHROPIC_API_TARGET || 'api.anthropic.com';
const GEMINI_API_TARGET = process.env.GEMINI_API_TARGET || 'generativelanguage.googleapis.com';

/**
* Normalizes a base path for use as a URL path prefix.
Expand Down Expand Up @@ -115,6 +117,7 @@ function buildUpstreamPath(reqUrl, targetHost, basePath) {
// Optional base path prefixes for API targets (e.g. /serving-endpoints for Databricks)
const OPENAI_API_BASE_PATH = normalizeBasePath(process.env.OPENAI_API_BASE_PATH);
const ANTHROPIC_API_BASE_PATH = normalizeBasePath(process.env.ANTHROPIC_API_BASE_PATH);
const GEMINI_API_BASE_PATH = normalizeBasePath(process.env.GEMINI_API_BASE_PATH);

// Configurable Copilot API target host (supports GHES/GHEC / custom endpoints)
// Priority: COPILOT_API_TARGET env var > auto-derive from GITHUB_SERVER_URL > default
Expand Down Expand Up @@ -160,15 +163,18 @@ logRequest('info', 'startup', {
api_targets: {
openai: OPENAI_API_TARGET,
anthropic: ANTHROPIC_API_TARGET,
gemini: GEMINI_API_TARGET,
copilot: COPILOT_API_TARGET,
},
api_base_paths: {
openai: OPENAI_API_BASE_PATH || '(none)',
anthropic: ANTHROPIC_API_BASE_PATH || '(none)',
gemini: GEMINI_API_BASE_PATH || '(none)',
},
providers: {
openai: !!OPENAI_API_KEY,
anthropic: !!ANTHROPIC_API_KEY,
gemini: !!GEMINI_API_KEY,
copilot: !!COPILOT_GITHUB_TOKEN,
},
});
Expand Down Expand Up @@ -707,6 +713,7 @@ function healthResponse() {
providers: {
openai: !!OPENAI_API_KEY,
anthropic: !!ANTHROPIC_API_KEY,
gemini: !!GEMINI_API_KEY,
copilot: !!COPILOT_GITHUB_TOKEN,
},
metrics_summary: metrics.getSummary(),
Expand Down Expand Up @@ -840,6 +847,34 @@ if (require.main === module) {
});
}

// Google Gemini API proxy (port 10003)
if (GEMINI_API_KEY) {
const geminiServer = http.createServer((req, res) => {
if (req.url === '/health' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'healthy', service: 'gemini-proxy' }));
return;
}

const contentLength = parseInt(req.headers['content-length'], 10) || 0;
if (checkRateLimit(req, res, 'gemini', contentLength)) return;

proxyRequest(req, res, GEMINI_API_TARGET, {
'x-goog-api-key': GEMINI_API_KEY,
}, 'gemini', GEMINI_API_BASE_PATH);
});
Comment on lines +861 to +865
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gemini auth is injected via x-goog-api-key, but the proxy’s auth-safety/logging paths are still oriented around authorization/x-api-key. Consider adding x-goog-api-key to the stripped header set and including it in the auth_inject debug logic so client-supplied Gemini keys are consistently removed/overwritten and injection can be observed in logs.

Copilot uses AI. Check for mistakes.

geminiServer.on('upgrade', (req, socket, head) => {
proxyWebSocket(req, socket, head, GEMINI_API_TARGET, {
'x-goog-api-key': GEMINI_API_KEY,
}, 'gemini', GEMINI_API_BASE_PATH);
});

geminiServer.listen(10003, '0.0.0.0', () => {
logRequest('info', 'server_start', { message: 'Google Gemini proxy listening on port 10003', target: GEMINI_API_TARGET });
});
}

// OpenCode API proxy (port 10004) — routes to Anthropic (default BYOK provider)
// OpenCode gets a separate port from Claude (10001) for per-engine rate limiting,
// metrics isolation, and future provider routing (OpenCode is BYOK and may route
Expand Down
32 changes: 31 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,8 @@ export function processAgentImageOption(
export const DEFAULT_OPENAI_API_TARGET = 'api.openai.com';
/** Default upstream hostname for Anthropic API requests in the api-proxy sidecar */
export const DEFAULT_ANTHROPIC_API_TARGET = 'api.anthropic.com';
/** Default upstream hostname for Google Gemini API requests in the api-proxy sidecar */
export const DEFAULT_GEMINI_API_TARGET = 'generativelanguage.googleapis.com';
/** Default upstream hostname for GitHub Copilot API requests in the api-proxy sidecar (when running on github.com) */
export const DEFAULT_COPILOT_API_TARGET = 'api.githubcopilot.com';

Expand Down Expand Up @@ -344,7 +346,7 @@ export function validateApiTargetInAllowedDomains(
* @param warn - Function to emit a warning message
*/
export function emitApiProxyTargetWarnings(
config: { enableApiProxy?: boolean; openaiApiTarget?: string; anthropicApiTarget?: string; copilotApiTarget?: string },
config: { enableApiProxy?: boolean; openaiApiTarget?: string; anthropicApiTarget?: string; copilotApiTarget?: string; geminiApiTarget?: string },
allowedDomains: string[],
warn: (msg: string) => void
): void {
Expand Down Expand Up @@ -379,6 +381,16 @@ export function emitApiProxyTargetWarnings(
if (copilotTargetWarning) {
warn(`⚠️ ${copilotTargetWarning}`);
}

const geminiTargetWarning = validateApiTargetInAllowedDomains(
config.geminiApiTarget ?? DEFAULT_GEMINI_API_TARGET,
DEFAULT_GEMINI_API_TARGET,
'--gemini-api-target',
allowedDomains
);
if (geminiTargetWarning) {
warn(`⚠️ ${geminiTargetWarning}`);
}
}

/**
Expand Down Expand Up @@ -494,6 +506,7 @@ export function resolveApiTargetsToAllowedDomains(
copilotApiTarget?: string;
openaiApiTarget?: string;
anthropicApiTarget?: string;
geminiApiTarget?: string;
},
allowedDomains: string[],
env: Record<string, string | undefined> = process.env,
Expand All @@ -519,6 +532,12 @@ export function resolveApiTargetsToAllowedDomains(
apiTargets.push(env['ANTHROPIC_API_TARGET']);
}

if (options.geminiApiTarget) {
apiTargets.push(options.geminiApiTarget);
} else if (env['GEMINI_API_TARGET']) {
apiTargets.push(env['GEMINI_API_TARGET']);
}

// Auto-populate GHEC domains when GITHUB_SERVER_URL points to a *.ghe.com tenant
const ghecDomains = extractGhecDomainsFromServerUrl(env);
if (ghecDomains.length > 0) {
Expand Down Expand Up @@ -1370,6 +1389,14 @@ program
'--anthropic-api-base-path <path>',
'Base path prefix for Anthropic API requests (e.g. /anthropic)',
)
.option(
'--gemini-api-target <host>',
'Target hostname for Gemini API requests (default: generativelanguage.googleapis.com)',
)
.option(
'--gemini-api-base-path <path>',
'Base path prefix for Gemini API requests',
)
.option(
'--rate-limit-rpm <n>',
'Max requests per minute per provider (requires --enable-api-proxy)',
Expand Down Expand Up @@ -1750,11 +1777,14 @@ program
openaiApiKey: process.env.OPENAI_API_KEY,
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
copilotGithubToken: process.env.COPILOT_GITHUB_TOKEN,
geminiApiKey: process.env.GEMINI_API_KEY,
copilotApiTarget: options.copilotApiTarget || process.env.COPILOT_API_TARGET,
Comment on lines 1777 to 1781
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

geminiApiKey is now being added to the runtime config object, but the later redaction logic only excludes openaiApiKey, anthropicApiKey, and copilotGithubToken. This will cause GEMINI_API_KEY to be logged in debug output via redactedConfig (secret disclosure). Add geminiApiKey to the redaction skip list (and any other secret-handling paths).

This issue also appears on line 1779 of the same file.

Copilot uses AI. Check for mistakes.
openaiApiTarget: options.openaiApiTarget || process.env.OPENAI_API_TARGET,
openaiApiBasePath: options.openaiApiBasePath || process.env.OPENAI_API_BASE_PATH,
anthropicApiTarget: options.anthropicApiTarget || process.env.ANTHROPIC_API_TARGET,
anthropicApiBasePath: options.anthropicApiBasePath || process.env.ANTHROPIC_API_BASE_PATH,
geminiApiTarget: options.geminiApiTarget || process.env.GEMINI_API_TARGET,
geminiApiBasePath: options.geminiApiBasePath || process.env.GEMINI_API_BASE_PATH,
};

// Parse and validate --agent-timeout
Expand Down
116 changes: 115 additions & 1 deletion src/docker-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -784,6 +784,7 @@ describe('docker-manager', () => {
// CLI state directories
expect(volumes).toContain(`${homeDir}/.claude:/host${homeDir}/.claude:rw`);
expect(volumes).toContain(`${homeDir}/.anthropic:/host${homeDir}/.anthropic:rw`);
expect(volumes).toContain(`${homeDir}/.gemini:/host${homeDir}/.gemini:rw`);
// ~/.copilot is mounted from host, with session-state and logs overlaid from AWF workDir
expect(volumes).toContain(`${homeDir}/.copilot:/host${homeDir}/.copilot:rw`);
expect(volumes).toContain(`/tmp/awf-test/agent-session-state:/host${homeDir}/.copilot/session-state:rw`);
Expand Down Expand Up @@ -2404,6 +2405,119 @@ describe('docker-manager', () => {
const env = proxy.environment as Record<string, string>;
expect(env.COPILOT_API_TARGET).toBeUndefined();
});

it('should include api-proxy service when enableApiProxy is true with Gemini key', () => {
const configWithProxy = { ...mockConfig, enableApiProxy: true, geminiApiKey: 'AIza-test-gemini-key' };
const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy);
expect(result.services['api-proxy']).toBeDefined();
const proxy = result.services['api-proxy'];
expect(proxy.container_name).toBe('awf-api-proxy');
});

it('should pass GEMINI_API_KEY to api-proxy env when geminiApiKey is provided', () => {
const configWithProxy = { ...mockConfig, enableApiProxy: true, geminiApiKey: 'AIza-test-gemini-key' };
const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy);
const proxy = result.services['api-proxy'];
const env = proxy.environment as Record<string, string>;
expect(env.GEMINI_API_KEY).toBe('AIza-test-gemini-key');
});

it('should set GEMINI_API_BASE_URL in agent when geminiApiKey is provided', () => {
const configWithProxy = { ...mockConfig, enableApiProxy: true, geminiApiKey: 'AIza-test-gemini-key' };
const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy);
const agent = result.services.agent;
const env = agent.environment as Record<string, string>;
expect(env.GEMINI_API_BASE_URL).toBe('http://172.30.0.30:10003');
});

it('should set GEMINI_API_KEY placeholder in agent when geminiApiKey is provided', () => {
const configWithProxy = { ...mockConfig, enableApiProxy: true, geminiApiKey: 'AIza-test-gemini-key' };
const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy);
const agent = result.services.agent;
const env = agent.environment as Record<string, string>;
expect(env.GEMINI_API_KEY).toBe('gemini-api-key-placeholder-for-credential-isolation');
});

it('should not set GEMINI_API_BASE_URL in agent when geminiApiKey is not provided', () => {
const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' };
const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy);
const agent = result.services.agent;
const env = agent.environment as Record<string, string>;
expect(env.GEMINI_API_BASE_URL).toBeUndefined();
});

it('should not leak GEMINI_API_KEY to agent when api-proxy is enabled', () => {
const origKey = process.env.GEMINI_API_KEY;
process.env.GEMINI_API_KEY = 'AIza-secret-gemini-key';
try {
const configWithProxy = { ...mockConfig, enableApiProxy: true, geminiApiKey: 'AIza-secret-gemini-key' };
const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy);
const agent = result.services.agent;
const env = agent.environment as Record<string, string>;
// Agent should NOT have the real API key — only the sidecar gets it
expect(env.GEMINI_API_KEY).toBe('gemini-api-key-placeholder-for-credential-isolation');
// Agent should have GEMINI_API_BASE_URL to proxy through sidecar
expect(env.GEMINI_API_BASE_URL).toBe('http://172.30.0.30:10003');
} finally {
if (origKey !== undefined) {
process.env.GEMINI_API_KEY = origKey;
} else {
delete process.env.GEMINI_API_KEY;
}
}
});

it('should not leak GEMINI_API_KEY to agent when api-proxy is enabled with envAll', () => {
const origKey = process.env.GEMINI_API_KEY;
process.env.GEMINI_API_KEY = 'AIza-secret-gemini-key';
try {
const configWithProxy = { ...mockConfig, enableApiProxy: true, geminiApiKey: 'AIza-secret-gemini-key', envAll: true };
const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy);
const agent = result.services.agent;
const env = agent.environment as Record<string, string>;
// Even with envAll, agent should NOT have the real GEMINI_API_KEY
expect(env.GEMINI_API_KEY).toBe('gemini-api-key-placeholder-for-credential-isolation');
expect(env.GEMINI_API_BASE_URL).toBe('http://172.30.0.30:10003');
} finally {
if (origKey !== undefined) {
process.env.GEMINI_API_KEY = origKey;
} else {
delete process.env.GEMINI_API_KEY;
}
}
});

it('should set GEMINI_API_TARGET in api-proxy when geminiApiTarget is provided', () => {
const configWithProxy = { ...mockConfig, enableApiProxy: true, geminiApiKey: 'AIza-test-key', geminiApiTarget: 'custom.gemini-router.internal' };
const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy);
const proxy = result.services['api-proxy'];
const env = proxy.environment as Record<string, string>;
expect(env.GEMINI_API_TARGET).toBe('custom.gemini-router.internal');
});

it('should not set GEMINI_API_TARGET in api-proxy when geminiApiTarget is not provided', () => {
const configWithProxy = { ...mockConfig, enableApiProxy: true, geminiApiKey: 'AIza-test-key' };
const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy);
const proxy = result.services['api-proxy'];
const env = proxy.environment as Record<string, string>;
expect(env.GEMINI_API_TARGET).toBeUndefined();
});

it('should set GEMINI_API_BASE_PATH in api-proxy when geminiApiBasePath is provided', () => {
const configWithProxy = { ...mockConfig, enableApiProxy: true, geminiApiKey: 'AIza-test-key', geminiApiBasePath: '/v1beta' };
const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy);
const proxy = result.services['api-proxy'];
const env = proxy.environment as Record<string, string>;
expect(env.GEMINI_API_BASE_PATH).toBe('/v1beta');
});

it('should not set GEMINI_API_BASE_PATH in api-proxy when geminiApiBasePath is not provided', () => {
const configWithProxy = { ...mockConfig, enableApiProxy: true, geminiApiKey: 'AIza-test-key' };
const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy);
const proxy = result.services['api-proxy'];
const env = proxy.environment as Record<string, string>;
expect(env.GEMINI_API_BASE_PATH).toBeUndefined();
});
});

describe('DNS-over-HTTPS proxy sidecar', () => {
Expand Down Expand Up @@ -2793,7 +2907,7 @@ describe('docker-manager', () => {
// Verify chroot home subdirectories were created
const expectedDirs = [
'.copilot', '.cache', '.config', '.local',
'.anthropic', '.claude', '.cargo', '.rustup', '.npm',
'.anthropic', '.claude', '.gemini', '.cargo', '.rustup', '.npm',
];
for (const dir of expectedDirs) {
expect(fs.existsSync(path.join(fakeHome, dir))).toBe(true);
Expand Down
25 changes: 24 additions & 1 deletion src/docker-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,7 @@ export function generateDockerCompose(
EXCLUDED_ENV_VARS.add('CODEX_API_KEY');
EXCLUDED_ENV_VARS.add('ANTHROPIC_API_KEY');
EXCLUDED_ENV_VARS.add('CLAUDE_API_KEY');
EXCLUDED_ENV_VARS.add('GEMINI_API_KEY');
// COPILOT_GITHUB_TOKEN gets a placeholder (not excluded), protected by one-shot-token
// GITHUB_API_URL is intentionally NOT excluded: the Copilot CLI needs it to know the
// GitHub API base URL. Copilot-specific API calls (inference and token exchange) go
Expand Down Expand Up @@ -867,6 +868,10 @@ export function generateDockerCompose(
// This is safe as ~/.claude contains only Claude-specific state, not credentials
agentVolumes.push(`${effectiveHome}/.claude:/host${effectiveHome}/.claude:rw`);

// Mount ~/.gemini for Gemini CLI state and project registry
// This is safe as ~/.gemini contains only Gemini-specific state, not credentials
agentVolumes.push(`${effectiveHome}/.gemini:/host${effectiveHome}/.gemini:rw`);

// NOTE: ~/.claude.json is NOT bind-mounted as a file. File bind mounts on Linux
// prevent atomic writes (temp file + rename), which Claude Code requires.
// The writable home volume provides a writable $HOME, and entrypoint.sh
Expand Down Expand Up @@ -1394,12 +1399,15 @@ export function generateDockerCompose(
...(config.openaiApiKey && { OPENAI_API_KEY: config.openaiApiKey }),
...(config.anthropicApiKey && { ANTHROPIC_API_KEY: config.anthropicApiKey }),
...(config.copilotGithubToken && { COPILOT_GITHUB_TOKEN: config.copilotGithubToken }),
...(config.geminiApiKey && { GEMINI_API_KEY: config.geminiApiKey }),
// Configurable API targets (for GHES/GHEC / custom endpoints)
...(config.copilotApiTarget && { COPILOT_API_TARGET: config.copilotApiTarget }),
...(config.openaiApiTarget && { OPENAI_API_TARGET: config.openaiApiTarget }),
...(config.openaiApiBasePath && { OPENAI_API_BASE_PATH: config.openaiApiBasePath }),
...(config.anthropicApiTarget && { ANTHROPIC_API_TARGET: config.anthropicApiTarget }),
...(config.anthropicApiBasePath && { ANTHROPIC_API_BASE_PATH: config.anthropicApiBasePath }),
...(config.geminiApiTarget && { GEMINI_API_TARGET: config.geminiApiTarget }),
...(config.geminiApiBasePath && { GEMINI_API_BASE_PATH: config.geminiApiBasePath }),
// Forward GITHUB_SERVER_URL so api-proxy can auto-derive enterprise endpoints
...(process.env.GITHUB_SERVER_URL && { GITHUB_SERVER_URL: process.env.GITHUB_SERVER_URL }),
// Route through Squid to respect domain whitelisting
Expand Down Expand Up @@ -1505,6 +1513,21 @@ export function generateDockerCompose(
// Note: COPILOT_GITHUB_TOKEN placeholder is set early (before --env-all)
// to prevent override by host environment variable
}
if (config.geminiApiKey) {
environment.GEMINI_API_BASE_URL = `http://${networkConfig.proxyIp}:${API_PROXY_PORTS.GEMINI}`;
logger.debug(`Google Gemini API will be proxied through sidecar at http://${networkConfig.proxyIp}:${API_PROXY_PORTS.GEMINI}`);
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 GEMINI_API_BASE_URL pointing to api-proxy.
environment.GEMINI_API_KEY = 'gemini-api-key-placeholder-for-credential-isolation';
logger.debug('GEMINI_API_KEY set to placeholder value for credential isolation');
}

logger.info('API proxy sidecar enabled - API keys will be held securely in sidecar container');
logger.info('API proxy will route through Squid to respect domain whitelisting');
Expand Down Expand Up @@ -1696,7 +1719,7 @@ export async function writeConfigs(config: WrapperConfig): Promise<void> {
// Ensure source directories for subdirectory mounts exist with correct ownership
const chrootHomeDirs = [
'.copilot', '.cache', '.config', '.local',
'.anthropic', '.claude', '.cargo', '.rustup', '.npm',
'.anthropic', '.claude', '.gemini', '.cargo', '.rustup', '.npm',
];
for (const dir of chrootHomeDirs) {
const dirPath = path.join(effectiveHome, dir);
Expand Down
Loading
Loading