From 375f9e7e207a2a53f37ea21586fa2b9490ec310a Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Thu, 2 Apr 2026 22:42:16 -0700 Subject: [PATCH] feat: add Google Gemini API proxy support (port 10003) Add full Gemini API proxy support to the AWF api-proxy sidecar, matching the pattern of existing OpenAI, Anthropic, and Copilot providers. This enables Gemini CLI to work inside the AWF sandbox without requiring workarounds in gh-aw. Changes: - Add GEMINI port 10003 to API_PROXY_PORTS - Add geminiApiKey, geminiApiTarget, geminiApiBasePath to WrapperConfig - Add --gemini-api-target and --gemini-api-base-path CLI flags - Add GEMINI_API_KEY to excluded env vars when api-proxy is enabled - Set placeholder GEMINI_API_KEY in agent container (Gemini CLI v0.65.0+ exits 41 without auth when GEMINI_API_BASE_URL is set) - Set GEMINI_API_BASE_URL pointing to sidecar in agent env - Add .gemini to whitelisted home subdirectories (bind mount + chroot) - Add Gemini proxy server in server.js using x-goog-api-key header - Expose port 10003 in Dockerfile Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- containers/api-proxy/Dockerfile | 3 +- containers/api-proxy/server.js | 35 ++++++++++++++++++++++ src/cli.ts | 32 +++++++++++++++++++- src/docker-manager.test.ts | 2 +- src/docker-manager.ts | 25 +++++++++++++++- src/types.ts | 52 +++++++++++++++++++++++++++++++++ 6 files changed, 145 insertions(+), 4 deletions(-) diff --git a/containers/api-proxy/Dockerfile b/containers/api-proxy/Dockerfile index 2fac6f97..2a8422db 100644 --- a/containers/api-proxy/Dockerfile +++ b/containers/api-proxy/Dockerfile @@ -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"] diff --git a/containers/api-proxy/server.js b/containers/api-proxy/server.js index 02fd98af..76a83b47 100644 --- a/containers/api-proxy/server.js +++ b/containers/api-proxy/server.js @@ -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. @@ -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 @@ -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, }, }); @@ -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(), @@ -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); + }); + + 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 diff --git a/src/cli.ts b/src/cli.ts index 0ec93c9c..51882f85 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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'; @@ -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 { @@ -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}`); + } } /** @@ -494,6 +506,7 @@ export function resolveApiTargetsToAllowedDomains( copilotApiTarget?: string; openaiApiTarget?: string; anthropicApiTarget?: string; + geminiApiTarget?: string; }, allowedDomains: string[], env: Record = process.env, @@ -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) { @@ -1370,6 +1389,14 @@ program '--anthropic-api-base-path ', 'Base path prefix for Anthropic API requests (e.g. /anthropic)', ) + .option( + '--gemini-api-target ', + 'Target hostname for Gemini API requests (default: generativelanguage.googleapis.com)', + ) + .option( + '--gemini-api-base-path ', + 'Base path prefix for Gemini API requests', + ) .option( '--rate-limit-rpm ', 'Max requests per minute per provider (requires --enable-api-proxy)', @@ -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, 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 diff --git a/src/docker-manager.test.ts b/src/docker-manager.test.ts index b193c7ce..b9e911f3 100644 --- a/src/docker-manager.test.ts +++ b/src/docker-manager.test.ts @@ -2793,7 +2793,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); diff --git a/src/docker-manager.ts b/src/docker-manager.ts index 0bff71f7..0461df5c 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -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 @@ -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 @@ -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 @@ -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'); @@ -1696,7 +1719,7 @@ export async function writeConfigs(config: WrapperConfig): Promise { // 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); diff --git a/src/types.ts b/src/types.ts index 0f78e523..78c16ae7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -33,6 +33,12 @@ export const API_PROXY_PORTS = { */ COPILOT: 10002, + /** + * Google Gemini API proxy port + * @see containers/api-proxy/server.js + */ + GEMINI: 10003, + /** * OpenCode API proxy port (routes to Anthropic by default) * OpenCode is BYOK — defaults to Anthropic as the primary provider @@ -619,6 +625,19 @@ export interface WrapperConfig { */ copilotGithubToken?: string; + /** + * Google Gemini API key (used by API proxy sidecar) + * + * When enableApiProxy is true, this key is injected into the Node.js sidecar + * container and used to authenticate requests to generativelanguage.googleapis.com. + * + * The key is NOT exposed to the agent container - only the proxy URL is provided. + * The agent receives a placeholder value so Gemini CLI's startup auth check passes. + * + * @default undefined + */ + geminiApiKey?: string; + /** * Target hostname for GitHub Copilot API requests (used by API proxy sidecar) * @@ -717,6 +736,39 @@ export interface WrapperConfig { */ anthropicApiBasePath?: string; + /** + * Target hostname for Google Gemini API requests (used by API proxy sidecar) + * + * When enableApiProxy is true, this hostname is passed to the Node.js sidecar + * as `GEMINI_API_TARGET`. The proxy will forward Gemini API requests to this host + * instead of the default `generativelanguage.googleapis.com`. + * + * Can be set via: + * - CLI flag: `--gemini-api-target ` + * - Environment variable: `GEMINI_API_TARGET` + * + * @default 'generativelanguage.googleapis.com' + * @example + * ```bash + * awf --enable-api-proxy --gemini-api-target custom-gemini-endpoint.example.com -- command + * ``` + */ + geminiApiTarget?: string; + + /** + * Base path prefix for Google Gemini API requests (used by API proxy sidecar) + * + * When set, this path is prepended to every upstream request path so that + * endpoints which require a URL prefix work correctly. + * + * Can be set via: + * - CLI flag: `--gemini-api-base-path ` + * - Environment variable: `GEMINI_API_BASE_PATH` + * + * @default '' + */ + geminiApiBasePath?: string; + /** * Enable Data Loss Prevention (DLP) scanning *