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
2 changes: 1 addition & 1 deletion src/docker-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
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
52 changes: 52 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
*
Expand Down Expand Up @@ -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 <host>`
* - 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 <path>`
* - Environment variable: `GEMINI_API_BASE_PATH`
*
* @default ''
*/
geminiApiBasePath?: string;

/**
* Enable Data Loss Prevention (DLP) scanning
*
Expand Down
Loading