diff --git a/containers/api-proxy/Dockerfile b/containers/api-proxy/Dockerfile index 521446842..a5055b858 100644 --- a/containers/api-proxy/Dockerfile +++ b/containers/api-proxy/Dockerfile @@ -55,7 +55,8 @@ ENV AWF_VERSION=${AWF_VERSION} # 10001 - Anthropic API proxy # 10002 - GitHub Copilot API proxy # 10003 - Google Gemini API proxy -EXPOSE 10000 10001 10002 10003 +# 10004 - Google Vertex AI API proxy +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/README.md b/containers/api-proxy/README.md index 3acb454e3..35b8c07d8 100644 --- a/containers/api-proxy/README.md +++ b/containers/api-proxy/README.md @@ -30,6 +30,7 @@ api.openai.com or api.anthropic.com - **10001**: Anthropic API proxy (api.anthropic.com) - **10002**: GitHub Copilot API proxy (api.githubcopilot.com) - **10003**: Google Gemini API proxy (generativelanguage.googleapis.com) +- **10004**: Google Vertex AI API proxy (aiplatform.googleapis.com) ## Environment Variables @@ -39,9 +40,12 @@ Required (at least one): - `COPILOT_GITHUB_TOKEN` - GitHub token for Copilot authentication - `COPILOT_PROVIDER_API_KEY` - Direct upstream provider key for Copilot BYOK mode - `GEMINI_API_KEY` - Google Gemini API key for authentication +- `GOOGLE_API_KEY` - Google Vertex AI API key for authentication Optional: - `COPILOT_API_TARGET` - Target hostname for GitHub Copilot API requests (default: `api.githubcopilot.com`). Useful for GHES deployments. +- `VERTEX_API_TARGET` - Target hostname for Vertex API requests (default: `aiplatform.googleapis.com`) +- `VERTEX_API_BASE_PATH` - Base path prefix for Vertex API requests - `AWF_BYOK_EXTRA_HEADERS` - JSON object of additional headers to inject into upstream requests when the Copilot BYOK API key (`COPILOT_PROVIDER_API_KEY`) is in use. Useful for provider-native observability (e.g. OpenRouter session grouping, Helicone user tracking): ``` AWF_BYOK_EXTRA_HEADERS='{"x-session-id":"my-session","HTTP-Referer":"https://example.com"}' @@ -57,6 +61,10 @@ Set by AWF: - `HTTP_PROXY` - Squid proxy URL (http://172.30.0.10:3128) - `HTTPS_PROXY` - Squid proxy URL (http://172.30.0.10:3128) +Agent-side routing for Vertex mode: +- `GOOGLE_VERTEX_BASE_URL=http://api-proxy:10004` +- `GOOGLE_API_KEY=google-api-key-placeholder-for-credential-isolation` (placeholder; real key remains in sidecar) + ## Security - Runs as non-root user (apiproxy) diff --git a/containers/api-proxy/provider-env-constants.json b/containers/api-proxy/provider-env-constants.json index 6fe198606..3a07e6c15 100644 --- a/containers/api-proxy/provider-env-constants.json +++ b/containers/api-proxy/provider-env-constants.json @@ -23,5 +23,10 @@ "PROVIDER_BASE_URL": "COPILOT_PROVIDER_BASE_URL", "API_TARGET": "COPILOT_API_TARGET", "API_BASE_PATH": "COPILOT_API_BASE_PATH" + }, + "VERTEX_ENV": { + "KEY": "GOOGLE_API_KEY", + "TARGET": "VERTEX_API_TARGET", + "BASE_PATH": "VERTEX_API_BASE_PATH" } } diff --git a/containers/api-proxy/providers/index.js b/containers/api-proxy/providers/index.js index e15599060..4a6326b93 100644 --- a/containers/api-proxy/providers/index.js +++ b/containers/api-proxy/providers/index.js @@ -13,6 +13,7 @@ const { createOpenAIAdapter } = require('./openai'); const { createAnthropicAdapter } = require('./anthropic'); const { createCopilotAdapter } = require('./copilot'); const { createGeminiAdapter } = require('./gemini'); +const { createVertexAdapter } = require('./vertex'); /** * @typedef {Object} ProbeConfig @@ -92,7 +93,7 @@ const { createGeminiAdapter } = require('./gemini'); * which providers appear in /reflect and models.json output. * * @param {Record} env - Environment variables (typically process.env) - * @param {{ openaiBodyTransform, anthropicBodyTransform, copilotBodyTransform, geminiBodyTransform }} deps + * @param {{ openaiBodyTransform, anthropicBodyTransform, copilotBodyTransform, geminiBodyTransform, vertexBodyTransform }} deps * Body-transform functions produced by server.js (to avoid circular dependencies). * @returns {ProviderAdapter[]} */ @@ -101,8 +102,9 @@ function createAllAdapters(env, deps = {}) { const anthropic = createAnthropicAdapter(env, { bodyTransform: deps.anthropicBodyTransform || null }); const copilot = createCopilotAdapter(env, { bodyTransform: deps.copilotBodyTransform || null }); const gemini = createGeminiAdapter(env, { bodyTransform: deps.geminiBodyTransform || null }); + const vertex = createVertexAdapter(env, { bodyTransform: deps.vertexBodyTransform || null }); - return [openai, anthropic, copilot, gemini]; + return [openai, anthropic, copilot, gemini, vertex]; } module.exports = { diff --git a/containers/api-proxy/providers/vertex.js b/containers/api-proxy/providers/vertex.js new file mode 100644 index 000000000..5b9fb6b8c --- /dev/null +++ b/containers/api-proxy/providers/vertex.js @@ -0,0 +1,76 @@ +'use strict'; + +/** + * Google Vertex AI provider adapter. + * + * Port: 10004 (always bound — returns 503 when no key is configured) + * Auth: x-goog-api-key header + * Credentials: GOOGLE_API_KEY + * Target: VERTEX_API_TARGET (default: aiplatform.googleapis.com) + * Base path: VERTEX_API_BASE_PATH + * + * Used by the Gemini CLI (google-gemini/gemini-cli) when authType === USE_VERTEX + * (i.e. GOOGLE_GENAI_USE_VERTEXAI=true). Setting GOOGLE_VERTEX_BASE_URL routes + * all Vertex AI traffic through the api-proxy sidecar instead of calling + * aiplatform.googleapis.com directly, enabling credential isolation. + */ + +const { makeUnconfiguredHealthResponse } = require('../proxy-utils'); +const { createProviderAuthScaffold, createAdapterMethods, buildProviderAdapter } = require('../adapter-factory'); +const { VERTEX_ENV } = require('../provider-env-constants'); + +/** + * Create the Google Vertex AI provider adapter. + * + * @param {Record} env - Environment variables + * @param {{ bodyTransform: ((body: Buffer) => Buffer|null)|null }} deps - Injected dependencies + * @returns {import('./index').ProviderAdapter} + */ +function createVertexAdapter(env, deps = {}) { + const { apiKey, rawTarget, basePath, bodyTransform } = createProviderAuthScaffold(env, deps, { + keyEnvVar: VERTEX_ENV.KEY, + targetEnvVar: VERTEX_ENV.TARGET, + basePathEnvVar: VERTEX_ENV.BASE_PATH, + defaultTarget: 'aiplatform.googleapis.com', + }); + const buildAuthHeaders = () => ({ 'x-goog-api-key': apiKey }); + + const adapterMethods = createAdapterMethods({ + apiKey, + rawTarget, + basePath, + provider: 'vertex', + port: 10004, + defaultTarget: 'aiplatform.googleapis.com', + validationPath: '/v1/projects', + validationHeaders: buildAuthHeaders, + modelsPath: null, + modelsFetchHeaders: null, + }); + + return buildProviderAdapter({ + name: 'vertex', + port: 10004, + isManagementPort: false, + alwaysBind: true, + adapterMethods, + getAuthHeaders() { + return buildAuthHeaders(); + }, + bodyTransform, + isEnabled() { return !!apiKey; }, + /** Response returned for all requests when no GOOGLE_API_KEY is configured. */ + getUnconfiguredResponse() { + return { + statusCode: 503, + body: { error: 'Vertex AI proxy not configured (no GOOGLE_API_KEY). Set GOOGLE_API_KEY in the AWF runner environment to enable credential isolation.' }, + }; + }, + /** /health response when not configured. */ + getUnconfiguredHealthResponse() { + return makeUnconfiguredHealthResponse('awf-api-proxy-vertex', 'GOOGLE_API_KEY not configured in api-proxy sidecar'); + }, + }); +} + +module.exports = { createVertexAdapter }; diff --git a/containers/api-proxy/server.models.test.js b/containers/api-proxy/server.models.test.js index d41f5591b..2e473cc29 100644 --- a/containers/api-proxy/server.models.test.js +++ b/containers/api-proxy/server.models.test.js @@ -359,17 +359,17 @@ describe('buildModelsJson', () => { expect(result).toHaveProperty('model_aliases'); }); - it('should include all four providers', () => { + it('should include all five providers', () => { const result = buildModelsJson(); const providerKeys = Object.keys(result.providers); - expect(providerKeys).toHaveLength(4); - expect(providerKeys).toEqual(expect.arrayContaining(['openai', 'anthropic', 'copilot', 'gemini'])); + expect(providerKeys).toHaveLength(5); + expect(providerKeys).toEqual(expect.arrayContaining(['openai', 'anthropic', 'copilot', 'gemini', 'vertex'])); }); it('should set models to null for uncached providers', () => { const result = buildModelsJson(); // Without populating cachedModels, all models fields should be null - for (const provider of ['openai', 'anthropic', 'copilot', 'gemini']) { + for (const provider of ['openai', 'anthropic', 'copilot', 'gemini', 'vertex']) { expect(result.providers[provider].models).toBeNull(); } }); @@ -505,8 +505,8 @@ describe('writeModelsJson', () => { expect(typeof data.timestamp).toBe('string'); expect(typeof data.providers).toBe('object'); const providerKeys = Object.keys(data.providers); - expect(providerKeys).toHaveLength(4); - expect(providerKeys).toEqual(expect.arrayContaining(['openai', 'anthropic', 'copilot', 'gemini'])); + expect(providerKeys).toHaveLength(5); + expect(providerKeys).toEqual(expect.arrayContaining(['openai', 'anthropic', 'copilot', 'gemini', 'vertex'])); expect(data).toHaveProperty('model_aliases'); }); diff --git a/containers/api-proxy/server.network.test.js b/containers/api-proxy/server.network.test.js index e366f1ac7..5d5e4eaaf 100644 --- a/containers/api-proxy/server.network.test.js +++ b/containers/api-proxy/server.network.test.js @@ -376,15 +376,15 @@ describe('reflectEndpoints', () => { resetModelCacheState(); }); - it('should return an array of 4 endpoints', () => { + it('should return an array of 5 endpoints', () => { const result = reflectEndpoints(); - expect(result.endpoints).toHaveLength(4); + expect(result.endpoints).toHaveLength(5); }); it('should include all expected providers', () => { const result = reflectEndpoints(); const providers = result.endpoints.map((e) => e.provider); - expect(providers).toEqual(['openai', 'anthropic', 'copilot', 'gemini']); + expect(providers).toEqual(['openai', 'anthropic', 'copilot', 'gemini', 'vertex']); }); it('should report models_fetch_complete false before fetch runs', () => { @@ -404,6 +404,7 @@ describe('reflectEndpoints', () => { anthropic: { enabled: true, strategy: 'middle_power', excludeEngines: [], suppressed: false }, copilot: { enabled: false, strategy: 'middle_power', excludeEngines: [], suppressed: true, suppression_reason: 'copilot_standard_authoritative' }, gemini: { enabled: true, strategy: 'middle_power', excludeEngines: [], suppressed: false }, + vertex: { enabled: true, strategy: 'middle_power', excludeEngines: [], suppressed: false }, }); }); @@ -559,6 +560,7 @@ describe('reflectEndpoints', () => { anthropic: 10001, copilot: 10002, gemini: 10003, + vertex: 10004, }); }); @@ -569,5 +571,6 @@ describe('reflectEndpoints', () => { expect(urlMap.anthropic).toBe('http://api-proxy:10001/v1/models'); expect(urlMap.copilot).toBe('http://api-proxy:10002/models'); expect(urlMap.gemini).toBe('http://api-proxy:10003/v1beta/models'); + expect(urlMap.vertex).toBeNull(); }); }); diff --git a/docs/api-proxy-sidecar.md b/docs/api-proxy-sidecar.md index 6e17770ba..53c6f67c5 100644 --- a/docs/api-proxy-sidecar.md +++ b/docs/api-proxy-sidecar.md @@ -126,6 +126,7 @@ The API proxy sidecar receives **real credentials** and routing configuration: | `COPILOT_PROVIDER_API_KEY` | Real API key | `--enable-api-proxy` and env set | BYOK provider API key (e.g. Azure / OpenRouter) injected into upstream requests. **Independently** triggers Copilot sidecar routing (no `COPILOT_GITHUB_TOKEN` required); typically combined with `COPILOT_PROVIDER_BASE_URL` to point at an arbitrary upstream. | | `COPILOT_PROVIDER_BASE_URL` | Real upstream URL | `--enable-api-proxy` and env set | User-supplied upstream URL for direct-BYOK mode; sidecar forwards Copilot CLI requests there instead of `api.githubcopilot.com`. | | `GEMINI_API_KEY` | Real API key | `--enable-api-proxy` and env set | Google Gemini API key (injected into requests) | +| `GOOGLE_API_KEY` | Real API key | `--enable-api-proxy` and env set | Google Vertex AI API key (injected into `x-goog-api-key` header) | | `HTTP_PROXY` | `http://172.30.0.10:3128` | Always | Routes through Squid for domain filtering | | `HTTPS_PROXY` | `http://172.30.0.10:3128` | Always | Routes through Squid for domain filtering | @@ -152,6 +153,8 @@ The agent container receives **redacted placeholders** and proxy URLs: | `GOOGLE_GEMINI_BASE_URL` | `http://172.30.0.30:10003` | `GEMINI_API_KEY` provided to host | Redirects Gemini CLI to proxy (primary var read by Gemini CLI) | | `GEMINI_API_BASE_URL` | `http://172.30.0.30:10003` | `GEMINI_API_KEY` provided to host | Redirects Gemini SDK to proxy (kept for backward compatibility) | | `GEMINI_API_KEY` | `gemini-api-key-placeholder-for-credential-isolation` | `GEMINI_API_KEY` provided to host | Placeholder so Gemini CLI auth check passes (real key in sidecar) | +| `GOOGLE_VERTEX_BASE_URL` | `http://172.30.0.30:10004` | `GOOGLE_API_KEY` provided to host | Redirects Vertex AI requests to proxy | +| `GOOGLE_API_KEY` | `google-api-key-placeholder-for-credential-isolation` | `GOOGLE_API_KEY` provided to host | Placeholder so Vertex mode auth checks pass (real key in sidecar) | | `OPENAI_API_KEY` | Not set | `--enable-api-proxy` | Excluded from agent (held in api-proxy) | | `ANTHROPIC_API_KEY` | Not set | `--enable-api-proxy` | Excluded from agent (held in api-proxy) | | `HTTP_PROXY` | `http://172.30.0.10:3128` | Always | Routes through Squid proxy | @@ -161,7 +164,7 @@ The agent container receives **redacted placeholders** and proxy URLs: | `AWF_ONE_SHOT_TOKENS` | `COPILOT_GITHUB_TOKEN,GITHUB_TOKEN,...` | Always | Tokens protected by one-shot-token library | :::note[Gemini setup is conditional] -`GOOGLE_GEMINI_BASE_URL`, `GEMINI_API_BASE_URL`, the `GEMINI_API_KEY` placeholder, the `~/.gemini` home directory mount, and the `AWF_GEMINI_ENABLED` signal are only configured when `GEMINI_API_KEY` is provided to the host AWF process. This avoids spurious log entries and unnecessary directory setup in non-Gemini runs (e.g. Copilot-only workflows). +`GOOGLE_GEMINI_BASE_URL`, `GEMINI_API_BASE_URL`, the `GEMINI_API_KEY` placeholder, the `GOOGLE_VERTEX_BASE_URL`/`GOOGLE_API_KEY` placeholders, the `~/.gemini` home directory mount, and the `AWF_GEMINI_ENABLED` signal are only configured when `GEMINI_API_KEY` or `GOOGLE_API_KEY` is provided to the host AWF process. This avoids spurious log entries and unnecessary directory setup in non-Gemini runs (e.g. Copilot-only workflows). `GOOGLE_GEMINI_BASE_URL` is the primary variable read by the Gemini CLI (`google-gemini/gemini-cli`). `GEMINI_API_BASE_URL` is kept for backward compatibility with older SDK versions. @@ -288,6 +291,7 @@ sudo awf --enable-api-proxy [OPTIONS] -- COMMAND - `OPENAI_API_KEY` — OpenAI API key - `ANTHROPIC_API_KEY` — Anthropic API key - `GEMINI_API_KEY` — Google Gemini API key +- `GOOGLE_API_KEY` — Google Vertex AI API key - `COPILOT_GITHUB_TOKEN` — GitHub Copilot access token. Sidecar routes Copilot CLI to `api.githubcopilot.com` (CAPI BYOK / offline mode). - `COPILOT_PROVIDER_API_KEY` — BYOK provider API key (Azure Foundry / OpenRouter / custom OpenAI-compatible upstream). **Independently** enables Copilot sidecar routing without `COPILOT_GITHUB_TOKEN`; typically combined with `COPILOT_PROVIDER_BASE_URL` to point at an arbitrary upstream. @@ -317,6 +321,8 @@ If the key is present only in `secrets.*` but not exported into the step's `env: | `--openai-api-target ` | `api.openai.com` | Custom upstream for OpenAI API requests (e.g. Azure OpenAI or an internal LLM router). Can also be set via `OPENAI_API_TARGET` env var. | | `--anthropic-api-target ` | `api.anthropic.com` | Custom upstream for Anthropic API requests (e.g. an internal Claude router). Can also be set via `ANTHROPIC_API_TARGET` env var. | | `--copilot-api-target ` | auto-derived | Custom upstream for GitHub Copilot API requests (useful for GHES). Can also be set via `COPILOT_API_TARGET` env var. | +| `--vertex-api-target ` | `aiplatform.googleapis.com` | Custom upstream for Vertex API requests. Can also be set via `VERTEX_API_TARGET` env var. | +| `--vertex-api-base-path ` | empty | Base path prefix for Vertex API requests. Can also be set via `VERTEX_API_BASE_PATH` env var. | > **Important**: When using a custom `--openai-api-target` or `--anthropic-api-target`, you must add the target domain to `--allow-domains` so the firewall permits outbound traffic. AWF will emit a warning if a custom target is set but not in the allowlist. @@ -367,7 +373,7 @@ The sidecar container: - **Image**: `ghcr.io/github/gh-aw-firewall/api-proxy:latest` - **Base**: `node:22-alpine` - **Network**: `awf-net` at `172.30.0.30` -- **Ports**: 10000 (OpenAI), 10001 (Anthropic), 10002 (GitHub Copilot), 10003 (Google Gemini) +- **Ports**: 10000 (OpenAI), 10001 (Anthropic), 10002 (GitHub Copilot), 10003 (Google Gemini), 10004 (Google Vertex AI) - **Proxy**: Routes via Squid at `http://172.30.0.10:3128` ## Model Fallback diff --git a/src/api-proxy-config-domains.test.ts b/src/api-proxy-config-domains.test.ts index ba7e40983..dcc00730f 100644 --- a/src/api-proxy-config-domains.test.ts +++ b/src/api-proxy-config-domains.test.ts @@ -157,6 +157,28 @@ describe('resolveApiTargetsToAllowedDomains', () => { expect(domains).toContain('https://flag.gemini.internal'); expect(domains).not.toContain('https://env.gemini.internal'); }); + + it('should add vertex-api-target option to allowed domains', () => { + const domains: string[] = ['github.com']; + resolveApiTargetsToAllowedDomains({ vertexApiTarget: 'custom.vertex.internal' }, domains); + expect(domains).not.toContain('custom.vertex.internal'); + expect(domains).toContain('https://custom.vertex.internal'); + }); + + it('should read VERTEX_API_TARGET from env when flag not set', () => { + const domains: string[] = []; + const env = { VERTEX_API_TARGET: 'env.vertex.internal' }; + resolveApiTargetsToAllowedDomains({}, domains, env); + expect(domains).toContain('https://env.vertex.internal'); + }); + + it('should prefer vertexApiTarget option over VERTEX_API_TARGET env var', () => { + const domains: string[] = []; + const env = { VERTEX_API_TARGET: 'env.vertex.internal' }; + resolveApiTargetsToAllowedDomains({ vertexApiTarget: 'flag.vertex.internal' }, domains, env); + expect(domains).toContain('https://flag.vertex.internal'); + expect(domains).not.toContain('https://env.vertex.internal'); + }); }); describe('extractGhesDomainsFromEngineApiTarget (via resolveApiTargetsToAllowedDomains)', () => { diff --git a/src/api-proxy-config-domains.ts b/src/api-proxy-config-domains.ts index 908ecbabd..1206d91b5 100644 --- a/src/api-proxy-config-domains.ts +++ b/src/api-proxy-config-domains.ts @@ -113,6 +113,7 @@ export function resolveApiTargetsToAllowedDomains( openaiApiTarget?: string; anthropicApiTarget?: string; geminiApiTarget?: string; + vertexApiTarget?: string; }, allowedDomains: string[], env: Record = process.env, @@ -144,6 +145,12 @@ export function resolveApiTargetsToAllowedDomains( apiTargets.push(env['GEMINI_API_TARGET']); } + if (options.vertexApiTarget) { + apiTargets.push(options.vertexApiTarget); + } else if (env['VERTEX_API_TARGET']) { + apiTargets.push(env['VERTEX_API_TARGET']); + } + // Auto-populate GHEC domains when GITHUB_SERVER_URL points to a *.ghe.com tenant const ghecDomains = extractGhecDomainsFromServerUrl(env); if (ghecDomains.length > 0) { diff --git a/src/api-proxy-config-validation.ts b/src/api-proxy-config-validation.ts index 2a2b0747c..8dd2d1ecf 100644 --- a/src/api-proxy-config-validation.ts +++ b/src/api-proxy-config-validation.ts @@ -20,6 +20,7 @@ export interface ApiProxyValidationResult { * @param hasCopilotKey - Whether a GitHub Copilot API key is present * @param hasGeminiKey - Whether a Google Gemini API key is present * @param hasAnthropicWif - Whether Anthropic WIF (GitHub OIDC) auth is configured + * @param hasGoogleApiKey - Whether a Google API key for Vertex AI is present * @returns ApiProxyValidationResult with warnings and debug messages */ export function validateApiProxyConfig( @@ -29,6 +30,7 @@ export function validateApiProxyConfig( hasCopilotKey?: boolean, hasGeminiKey?: boolean, hasAnthropicWif?: boolean, + hasGoogleApiKey?: boolean, ): ApiProxyValidationResult { if (!enableApiProxy) { return { enabled: false, warnings: [], debugMessages: [] }; @@ -37,9 +39,9 @@ export function validateApiProxyConfig( const warnings: string[] = []; const debugMessages: string[] = []; - if (!hasOpenaiKey && !hasAnthropicKey && !hasCopilotKey && !hasGeminiKey && !hasAnthropicWif) { + if (!hasOpenaiKey && !hasAnthropicKey && !hasCopilotKey && !hasGeminiKey && !hasAnthropicWif && !hasGoogleApiKey) { warnings.push('⚠️ API proxy enabled but no API keys found in environment'); - warnings.push(' Set OPENAI_API_KEY, ANTHROPIC_API_KEY, COPILOT_GITHUB_TOKEN, COPILOT_PROVIDER_API_KEY, or GEMINI_API_KEY to use the proxy'); + warnings.push(' Set OPENAI_API_KEY, ANTHROPIC_API_KEY, COPILOT_GITHUB_TOKEN, COPILOT_PROVIDER_API_KEY, GEMINI_API_KEY, or GOOGLE_API_KEY to use the proxy'); } if (hasOpenaiKey) { debugMessages.push('OpenAI API key detected - will be held securely in sidecar'); @@ -56,6 +58,9 @@ export function validateApiProxyConfig( if (hasGeminiKey) { debugMessages.push('Google Gemini API key detected - will be held securely in sidecar'); } + if (hasGoogleApiKey) { + debugMessages.push('Google API key (Vertex AI) detected - will be held securely in sidecar'); + } return { enabled: true, warnings, debugMessages }; } diff --git a/src/api-proxy-config-warnings.test.ts b/src/api-proxy-config-warnings.test.ts index a93bcaeed..c3c1dda34 100644 --- a/src/api-proxy-config-warnings.test.ts +++ b/src/api-proxy-config-warnings.test.ts @@ -159,7 +159,28 @@ describe('emitApiProxyTargetWarnings', () => { expect(warnings).toHaveLength(0); }); - it('should emit warnings for all four custom targets when none are in allowed domains', () => { + it('should emit warning for custom Vertex target not in allowed domains', () => { + const warnings: string[] = []; + emitApiProxyTargetWarnings( + { enableApiProxy: true, vertexApiTarget: 'custom.vertex-router.internal' }, + ['github.com'], + (msg) => warnings.push(msg) + ); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('--vertex-api-target=custom.vertex-router.internal'); + }); + + it('should emit no warnings when custom Vertex target is in allowed domains', () => { + const warnings: string[] = []; + emitApiProxyTargetWarnings( + { enableApiProxy: true, vertexApiTarget: 'vertex.example.com' }, + ['example.com'], + (msg) => warnings.push(msg) + ); + expect(warnings).toHaveLength(0); + }); + + it('should emit warnings for all five custom targets when none are in allowed domains', () => { const warnings: string[] = []; emitApiProxyTargetWarnings( { @@ -168,12 +189,14 @@ describe('emitApiProxyTargetWarnings', () => { anthropicApiTarget: 'anthropic.internal', copilotApiTarget: 'copilot.internal', geminiApiTarget: 'gemini.internal', + vertexApiTarget: 'vertex.internal', }, ['github.com'], (msg) => warnings.push(msg) ); - expect(warnings).toHaveLength(4); + expect(warnings).toHaveLength(5); expect(warnings[3]).toContain('--gemini-api-target=gemini.internal'); + expect(warnings[4]).toContain('--vertex-api-target=vertex.internal'); }); }); diff --git a/src/api-proxy-config-warnings.ts b/src/api-proxy-config-warnings.ts index 867ef0c9b..8048acaa2 100644 --- a/src/api-proxy-config-warnings.ts +++ b/src/api-proxy-config-warnings.ts @@ -3,6 +3,7 @@ import { DEFAULT_ANTHROPIC_API_TARGET, DEFAULT_COPILOT_API_TARGET, DEFAULT_GEMINI_API_TARGET, + DEFAULT_VERTEX_API_TARGET, } from './domain-utils'; /** @@ -47,7 +48,7 @@ function validateApiTargetInAllowedDomains( * @param warn - Function to emit a warning message */ export function emitApiProxyTargetWarnings( - config: { enableApiProxy?: boolean; openaiApiTarget?: string; anthropicApiTarget?: string; copilotApiTarget?: string; geminiApiTarget?: string }, + config: { enableApiProxy?: boolean; openaiApiTarget?: string; anthropicApiTarget?: string; copilotApiTarget?: string; geminiApiTarget?: string; vertexApiTarget?: string }, allowedDomains: string[], warn: (msg: string) => void ): void { @@ -92,6 +93,16 @@ export function emitApiProxyTargetWarnings( if (geminiTargetWarning) { warn(`⚠️ ${geminiTargetWarning}`); } + + const vertexTargetWarning = validateApiTargetInAllowedDomains( + config.vertexApiTarget ?? DEFAULT_VERTEX_API_TARGET, + DEFAULT_VERTEX_API_TARGET, + '--vertex-api-target', + allowedDomains + ); + if (vertexTargetWarning) { + warn(`⚠️ ${vertexTargetWarning}`); + } } /** diff --git a/src/api-proxy-env-constants-sync.test.ts b/src/api-proxy-env-constants-sync.test.ts index d8318df8d..a70b9d553 100644 --- a/src/api-proxy-env-constants-sync.test.ts +++ b/src/api-proxy-env-constants-sync.test.ts @@ -1,4 +1,4 @@ -import { ANTHROPIC_ENV, COPILOT_ENV, GEMINI_ENV, OPENAI_ENV } from './api-proxy-env-constants'; +import { ANTHROPIC_ENV, COPILOT_ENV, GEMINI_ENV, OPENAI_ENV, VERTEX_ENV } from './api-proxy-env-constants'; // eslint-disable-next-line @typescript-eslint/no-require-imports const providerEnvConstants = require('../containers/api-proxy/provider-env-constants.js') as { @@ -6,6 +6,7 @@ const providerEnvConstants = require('../containers/api-proxy/provider-env-const ANTHROPIC_ENV: typeof ANTHROPIC_ENV; GEMINI_ENV: typeof GEMINI_ENV; COPILOT_ENV: typeof COPILOT_ENV; + VERTEX_ENV: typeof VERTEX_ENV; }; describe('API proxy provider env constants', () => { @@ -14,5 +15,6 @@ describe('API proxy provider env constants', () => { expect(providerEnvConstants.ANTHROPIC_ENV).toEqual(ANTHROPIC_ENV); expect(providerEnvConstants.GEMINI_ENV).toEqual(GEMINI_ENV); expect(providerEnvConstants.COPILOT_ENV).toEqual(COPILOT_ENV); + expect(providerEnvConstants.VERTEX_ENV).toEqual(VERTEX_ENV); }); }); diff --git a/src/api-proxy-env-constants.ts b/src/api-proxy-env-constants.ts index 8932daf37..e8a9362b6 100644 --- a/src/api-proxy-env-constants.ts +++ b/src/api-proxy-env-constants.ts @@ -14,6 +14,8 @@ export const ANTHROPIC_ENV = providerEnvConstants.ANTHROPIC_ENV; export const GEMINI_ENV = providerEnvConstants.GEMINI_ENV; /** Environment variable names for the Copilot provider adapter. */ export const COPILOT_ENV = providerEnvConstants.COPILOT_ENV; +/** Environment variable names for the Vertex AI provider adapter. */ +export const VERTEX_ENV = providerEnvConstants.VERTEX_ENV; /** * OIDC authentication env var mappings. diff --git a/src/cli-options.test.ts b/src/cli-options.test.ts index 9da3a3ed2..e34f69e67 100644 --- a/src/cli-options.test.ts +++ b/src/cli-options.test.ts @@ -71,6 +71,16 @@ describe('cli-options program', () => { expect(parseMount('/a:/b:ro')).toEqual(['/a:/b:ro']); }); + it('parses vertex API routing flags', () => { + program.parse( + ['node', 'awf', '--vertex-api-target', 'vertex.internal', '--vertex-api-base-path', '/v1beta1', '--', 'true'], + { from: 'node' }, + ); + const opts = program.opts(); + expect(opts.vertexApiTarget).toBe('vertex.internal'); + expect(opts.vertexApiBasePath).toBe('/v1beta1'); + }); + describe('custom formatHelp', () => { it('generates help output containing usage and options sections', () => { const help = program.helpInformation(); diff --git a/src/cli-options.ts b/src/cli-options.ts index df7771335..e7593b814 100644 --- a/src/cli-options.ts +++ b/src/cli-options.ts @@ -317,6 +317,14 @@ program '--gemini-api-base-path ', 'Base path prefix for Gemini API requests', ) + .option( + '--vertex-api-target ', + 'Target hostname for Vertex API requests (default: aiplatform.googleapis.com)', + ) + .option( + '--vertex-api-base-path ', + 'Base path prefix for Vertex API requests', + ) .option( '--anthropic-auto-cache', 'Enable Anthropic prompt-cache optimizations in the API proxy (requires --enable-api-proxy).\n' + diff --git a/src/commands/build-config.test.ts b/src/commands/build-config.test.ts index 2e75d4530..4b53100a1 100644 --- a/src/commands/build-config.test.ts +++ b/src/commands/build-config.test.ts @@ -56,6 +56,7 @@ const ENV_KEYS = [ 'COPILOT_PROVIDER_TYPE', 'COPILOT_PROVIDER_BASE_URL', 'GEMINI_API_KEY', + 'GOOGLE_API_KEY', 'GITHUB_TOKEN', 'GH_TOKEN', 'AWF_AUDIT_DIR', @@ -190,6 +191,12 @@ describe('buildConfig', () => { expect(config.geminiApiKey).toBe('gemini-key'); }); + it('should read GOOGLE_API_KEY from process.env', () => { + process.env.GOOGLE_API_KEY = 'google-key'; + const config = buildConfig(makeInputs()); + expect(config.googleApiKey).toBe('google-key'); + }); + it('should read COPILOT_PROVIDER_API_KEY from process.env', () => { process.env.COPILOT_PROVIDER_API_KEY = 'sk-byok-provider'; const config = buildConfig(makeInputs()); diff --git a/src/commands/main-action.ts b/src/commands/main-action.ts index 9fae0698d..8d796cf7a 100644 --- a/src/commands/main-action.ts +++ b/src/commands/main-action.ts @@ -34,6 +34,7 @@ const SENSITIVE_CONFIG_KEYS = new Set([ 'copilotGithubToken', 'copilotProviderApiKey', 'geminiApiKey', + 'googleApiKey', 'githubToken', ]); diff --git a/src/commands/resolve-credentials.test.ts b/src/commands/resolve-credentials.test.ts index 82e64d1dd..3cdeddffa 100644 --- a/src/commands/resolve-credentials.test.ts +++ b/src/commands/resolve-credentials.test.ts @@ -8,6 +8,7 @@ const ENV_KEYS = [ 'COPILOT_PROVIDER_TYPE', 'COPILOT_PROVIDER_BASE_URL', 'GEMINI_API_KEY', + 'GOOGLE_API_KEY', 'OPENAI_API_TARGET', 'OPENAI_API_BASE_PATH', 'ANTHROPIC_API_TARGET', @@ -22,6 +23,8 @@ const ENV_KEYS = [ 'AWF_AUTH_GCP_SCOPE', 'GEMINI_API_TARGET', 'GEMINI_API_BASE_PATH', + 'VERTEX_API_TARGET', + 'VERTEX_API_BASE_PATH', 'GITHUB_TOKEN', 'GH_TOKEN', ] as const; @@ -53,6 +56,7 @@ describe('resolveApiCredentials', () => { process.env.COPILOT_GITHUB_TOKEN = 'gh-copilot'; process.env.COPILOT_PROVIDER_API_KEY = 'sk-provider'; process.env.GEMINI_API_KEY = 'sk-gemini'; + process.env.GOOGLE_API_KEY = 'sk-google'; const credentials = resolveApiCredentials({}); @@ -61,6 +65,7 @@ describe('resolveApiCredentials', () => { expect(credentials.copilotGithubToken).toBe('gh-copilot'); expect(credentials.copilotProviderApiKey).toBe('sk-provider'); expect(credentials.geminiApiKey).toBe('sk-gemini'); + expect(credentials.googleApiKey).toBe('sk-google'); }); it('prefers explicit options over environment fallbacks', () => { @@ -101,16 +106,19 @@ describe('resolveApiCredentials', () => { process.env.OPENAI_API_BASE_PATH = '/env-base-path'; process.env.ANTHROPIC_API_BASE_PATH = '/env-anthropic-base'; process.env.GEMINI_API_BASE_PATH = '/env-gemini-base'; + process.env.VERTEX_API_BASE_PATH = '/env-vertex-base'; const credentials = resolveApiCredentials({ openaiApiBasePath: '', anthropicApiBasePath: '', geminiApiBasePath: '', + vertexApiBasePath: '', }); expect(credentials.openaiApiBasePath).toBe(''); expect(credentials.anthropicApiBasePath).toBe(''); expect(credentials.geminiApiBasePath).toBe(''); + expect(credentials.vertexApiBasePath).toBe(''); }); it('passes through resolved copilot endpoints and github token precedence', () => { diff --git a/src/commands/resolve-credentials.ts b/src/commands/resolve-credentials.ts index 020a6b9b4..71eedf8ea 100644 --- a/src/commands/resolve-credentials.ts +++ b/src/commands/resolve-credentials.ts @@ -1,4 +1,4 @@ -import { OPENAI_ENV, ANTHROPIC_ENV, GEMINI_ENV, COPILOT_ENV, OIDC_AUTH_ENV_MAPPING } from '../api-proxy-env-constants'; +import { OPENAI_ENV, ANTHROPIC_ENV, GEMINI_ENV, COPILOT_ENV, VERTEX_ENV, OIDC_AUTH_ENV_MAPPING } from '../api-proxy-env-constants'; import type { WrapperConfig } from '../types'; interface ResolveApiCredentialsInputs { @@ -16,6 +16,7 @@ type ApiCredentials = Pick; @@ -60,6 +63,7 @@ export function resolveApiCredentials( COPILOT_ENV.PROVIDER_BASE_URL ), geminiApiKey: process.env[GEMINI_ENV.KEY], + googleApiKey: process.env[VERTEX_ENV.KEY], copilotApiTarget: inputs.resolvedCopilotApiTarget, copilotApiBasePath: inputs.resolvedCopilotApiBasePath, openaiApiTarget: resolveOptionOrEnv(options, 'openaiApiTarget', OPENAI_ENV.TARGET), @@ -80,6 +84,8 @@ export function resolveApiCredentials( ...oidcCredentials, geminiApiTarget: resolveOptionOrEnv(options, 'geminiApiTarget', GEMINI_ENV.TARGET), geminiApiBasePath: resolveOptionOrEnv(options, 'geminiApiBasePath', GEMINI_ENV.BASE_PATH), + vertexApiTarget: resolveOptionOrEnv(options, 'vertexApiTarget', VERTEX_ENV.TARGET), + vertexApiBasePath: resolveOptionOrEnv(options, 'vertexApiBasePath', VERTEX_ENV.BASE_PATH), githubToken: process.env.GITHUB_TOKEN || process.env.GH_TOKEN, }; } diff --git a/src/commands/validators/config-assembly.ts b/src/commands/validators/config-assembly.ts index 7854feb08..8d3b5efea 100644 --- a/src/commands/validators/config-assembly.ts +++ b/src/commands/validators/config-assembly.ts @@ -220,6 +220,7 @@ function validateApiProxyOptions( !!config.copilotGithubToken || copilotByokDirect, !!config.geminiApiKey, hasAnthropicWif, + !!config.googleApiKey, ); // Log API proxy status at info level for visibility @@ -235,7 +236,7 @@ function validateApiProxyOptions( ? 'true (wif)' : 'false'; logger.info( - `API proxy enabled: OpenAI=${!!config.openaiApiKey}, Anthropic=${anthropicStatus}, Copilot=${copilotStatus}, Gemini=${!!config.geminiApiKey}`, + `API proxy enabled: OpenAI=${!!config.openaiApiKey}, Anthropic=${anthropicStatus}, Copilot=${copilotStatus}, Gemini=${!!config.geminiApiKey}, Vertex=${!!config.googleApiKey}`, ); } diff --git a/src/domain-utils.ts b/src/domain-utils.ts index 7a7898702..f03d48aa8 100644 --- a/src/domain-utils.ts +++ b/src/domain-utils.ts @@ -220,5 +220,7 @@ export const DEFAULT_OPENAI_API_TARGET = 'api.openai.com'; 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 Google Vertex AI API requests in the api-proxy sidecar */ +export const DEFAULT_VERTEX_API_TARGET = 'aiplatform.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'; diff --git a/src/security-module-coverage.test.ts b/src/security-module-coverage.test.ts index 8644e98b8..79129574c 100644 --- a/src/security-module-coverage.test.ts +++ b/src/security-module-coverage.test.ts @@ -7,6 +7,7 @@ * - src/services/credentials/copilot-credential-env.ts * - src/services/credentials/gemini-credential-env.ts * - src/services/credentials/openai-credential-env.ts + * - src/services/credentials/vertex-credential-env.ts */ jest.mock('./logger', () => ({ @@ -29,6 +30,7 @@ import { buildAnthropicCredentialEnv } from './services/credentials/anthropic-cr import { buildCopilotCredentialEnv } from './services/credentials/copilot-credential-env'; import { buildGeminiCredentialEnv } from './services/credentials/gemini-credential-env'; import { buildOpenAiCredentialEnv } from './services/credentials/openai-credential-env'; +import { buildVertexCredentialEnv } from './services/credentials/vertex-credential-env'; import type { WrapperConfig } from './types'; import { getLowerCaseProcessEnvValue, getConfigEnvValue } from './env-utils'; @@ -605,3 +607,33 @@ describe('buildOpenAiCredentialEnv', () => { expect(result.OPENAI_BASE_URL).toMatch(/:10000$/); }); }); + +// ==================================================== +// src/services/credentials/vertex-credential-env.ts +// ==================================================== +describe('buildVertexCredentialEnv', () => { + it('returns empty object when googleApiKey is not set', () => { + const result = buildVertexCredentialEnv({ config: baseConfig, proxyIp }); + expect(result).toEqual({}); + }); + + it('returns env additions when googleApiKey is set', () => { + const config = { ...baseConfig, googleApiKey: 'AIza-test-key' } as WrapperConfig; + const result = buildVertexCredentialEnv({ config, proxyIp }); + expect(result.GOOGLE_VERTEX_BASE_URL).toBe(`http://${proxyIp}:10004`); + expect(result.GOOGLE_API_KEY).toBe('google-api-key-placeholder-for-credential-isolation'); + }); + + it('real googleApiKey is NOT present in agent env (credential isolation)', () => { + const realKey = 'AIza-real-vertex-secret-key'; + const config = { ...baseConfig, googleApiKey: realKey } as WrapperConfig; + const result = buildVertexCredentialEnv({ config, proxyIp }); + expect(Object.values(result)).not.toContain(realKey); + }); + + it('routes to Vertex AI port 10004 specifically', () => { + const config = { ...baseConfig, googleApiKey: 'AIza-test' } as WrapperConfig; + const result = buildVertexCredentialEnv({ config, proxyIp }); + expect(result.GOOGLE_VERTEX_BASE_URL).toMatch(/:10004$/); + }); +}); diff --git a/src/services/agent-environment/excluded-vars.test.ts b/src/services/agent-environment/excluded-vars.test.ts index 5af59532d..c9feac339 100644 --- a/src/services/agent-environment/excluded-vars.test.ts +++ b/src/services/agent-environment/excluded-vars.test.ts @@ -97,6 +97,14 @@ describe('buildExclusionSet', () => { it('should exclude GEMINI_API_BASE_URL', () => { expect(buildExclusionSet(config).has('GEMINI_API_BASE_URL')).toBe(true); }); + + it('should exclude GOOGLE_API_KEY (Vertex AI credential)', () => { + expect(buildExclusionSet(config).has('GOOGLE_API_KEY')).toBe(true); + }); + + it('should exclude GOOGLE_VERTEX_BASE_URL (Vertex AI base URL)', () => { + expect(buildExclusionSet(config).has('GOOGLE_VERTEX_BASE_URL')).toBe(true); + }); }); describe('when enableApiProxy is false', () => { diff --git a/src/services/agent-environment/excluded-vars.ts b/src/services/agent-environment/excluded-vars.ts index 59030b8ac..277c6615c 100644 --- a/src/services/agent-environment/excluded-vars.ts +++ b/src/services/agent-environment/excluded-vars.ts @@ -32,6 +32,8 @@ export function buildExclusionSet(config: WrapperConfig): Set { excludedEnvVars.add('GEMINI_API_KEY'); excludedEnvVars.add('GOOGLE_GEMINI_BASE_URL'); excludedEnvVars.add('GEMINI_API_BASE_URL'); + excludedEnvVars.add('GOOGLE_API_KEY'); + excludedEnvVars.add('GOOGLE_VERTEX_BASE_URL'); } if (config.difcProxyHost) { diff --git a/src/services/agent-volumes/home-strategy.ts b/src/services/agent-volumes/home-strategy.ts index 449facf92..60167c1b8 100644 --- a/src/services/agent-volumes/home-strategy.ts +++ b/src/services/agent-volumes/home-strategy.ts @@ -49,7 +49,7 @@ function buildToolDirectoryMounts(params: HomeMountsParams): string[] { mounts.push(`${effectiveHome}/.anthropic:/host${effectiveHome}/.anthropic:rw`); mounts.push(`${effectiveHome}/.claude:/host${effectiveHome}/.claude:rw`); - if (config.geminiApiKey) { + if (config.geminiApiKey || config.googleApiKey) { mounts.push(`${effectiveHome}/.gemini:/host${effectiveHome}/.gemini:rw`); } diff --git a/src/services/api-proxy-credential-env.ts b/src/services/api-proxy-credential-env.ts index 8474748ca..5fd54f4cc 100644 --- a/src/services/api-proxy-credential-env.ts +++ b/src/services/api-proxy-credential-env.ts @@ -4,6 +4,7 @@ import { buildOpenAiCredentialEnv } from './credentials/openai-credential-env'; import { buildAnthropicCredentialEnv } from './credentials/anthropic-credential-env'; import { buildCopilotCredentialEnv } from './credentials/copilot-credential-env'; import { buildGeminiCredentialEnv } from './credentials/gemini-credential-env'; +import { buildVertexCredentialEnv } from './credentials/vertex-credential-env'; interface ApiProxyCredentialEnvParams { config: WrapperConfig; @@ -27,6 +28,7 @@ export function buildAgentCredentialEnv(params: ApiProxyCredentialEnvParams): Re Object.assign(agentEnvAdditions, buildAnthropicCredentialEnv({ config, proxyIp: networkConfig.proxyIp })); Object.assign(agentEnvAdditions, buildCopilotCredentialEnv({ config, proxyIp: networkConfig.proxyIp })); Object.assign(agentEnvAdditions, buildGeminiCredentialEnv({ config, proxyIp: networkConfig.proxyIp })); + Object.assign(agentEnvAdditions, buildVertexCredentialEnv({ config, proxyIp: networkConfig.proxyIp })); return agentEnvAdditions; } diff --git a/src/services/api-proxy-env-config.ts b/src/services/api-proxy-env-config.ts index 35033305b..9a2693024 100644 --- a/src/services/api-proxy-env-config.ts +++ b/src/services/api-proxy-env-config.ts @@ -2,7 +2,7 @@ import { SQUID_PORT } from '../constants'; import { stripScheme } from '../host-env'; import { WrapperConfig } from '../types'; import { getConfigEnvValue, getLowerCaseProcessEnvValue, pickEnvVars } from '../env-utils'; -import { OPENAI_ENV, ANTHROPIC_ENV, GEMINI_ENV, COPILOT_ENV, OIDC_AUTH_ENV_VARS, OIDC_AUTH_ENV_MAPPING } from '../api-proxy-env-constants'; +import { OPENAI_ENV, ANTHROPIC_ENV, GEMINI_ENV, COPILOT_ENV, VERTEX_ENV, OIDC_AUTH_ENV_VARS, OIDC_AUTH_ENV_MAPPING } from '../api-proxy-env-constants'; import { NetworkConfig } from './squid-service'; /** @@ -22,6 +22,7 @@ export function buildProviderTargetEnv(config: WrapperConfig): Record { expect(agentEnvAdditions.GEMINI_API_BASE_URL).toBe('http://172.30.0.30:10003'); expect(agentEnvAdditions.GEMINI_API_KEY).toBe('gemini-api-key-placeholder-for-credential-isolation'); }); + + it('buildAgentCredentialEnv sets Vertex AI proxy URL and placeholder key when googleApiKey is present', () => { + const agentEnvAdditions = buildAgentCredentialEnv({ + config: { + ...baseConfig, + workDir: '/tmp/awf-test', + enableApiProxy: true, + googleApiKey: 'google-real-key', + }, + networkConfig, + }); + + expect(agentEnvAdditions.GOOGLE_VERTEX_BASE_URL).toBe('http://172.30.0.30:10004'); + expect(agentEnvAdditions.GOOGLE_API_KEY).toBe('google-api-key-placeholder-for-credential-isolation'); + }); }); diff --git a/src/services/credentials/vertex-credential-env.ts b/src/services/credentials/vertex-credential-env.ts new file mode 100644 index 000000000..6f2269eef --- /dev/null +++ b/src/services/credentials/vertex-credential-env.ts @@ -0,0 +1,29 @@ +import { WrapperConfig, API_PROXY_PORTS } from '../../types'; +import { buildProviderCredentialIsolationEnv } from './provider-credential-isolation'; + +interface VertexCredentialEnvParams { + config: WrapperConfig; + proxyIp: string; +} + +export function buildVertexCredentialEnv(params: VertexCredentialEnvParams): Record { + const { config, proxyIp } = params; + // Only configure Vertex proxy routing when a Google API key is provided. + // GOOGLE_VERTEX_BASE_URL is the env var read by the Gemini CLI (google-gemini/gemini-cli) + // when authType === USE_VERTEX. Setting it routes all Vertex AI traffic through + // the api-proxy sidecar instead of calling aiplatform.googleapis.com directly. + return buildProviderCredentialIsolationEnv({ + providerName: 'Google Vertex AI', + proxyIp, + port: API_PROXY_PORTS.VERTEX, + enabled: !!config.googleApiKey, + baseUrlVarNames: ['GOOGLE_VERTEX_BASE_URL'], + target: config.vertexApiTarget, + basePath: config.vertexApiBasePath, + // Set placeholder key so Gemini CLI's Vertex auth check passes. + // Real authentication happens via GOOGLE_VERTEX_BASE_URL pointing to api-proxy. + placeholders: { + GOOGLE_API_KEY: 'google-api-key-placeholder-for-credential-isolation', + }, + }); +} diff --git a/src/types/api-proxy-credential-options.ts b/src/types/api-proxy-credential-options.ts index b0584cd4a..c2fd5fd3e 100644 --- a/src/types/api-proxy-credential-options.ts +++ b/src/types/api-proxy-credential-options.ts @@ -17,6 +17,7 @@ export interface ApiProxyCredentialOptions { * - http://api-proxy:10001 - Anthropic API proxy (for Claude) {@link API_PROXY_PORTS.ANTHROPIC} * - http://api-proxy:10002 - GitHub Copilot API proxy {@link API_PROXY_PORTS.COPILOT} * - http://api-proxy:10003 - Google Gemini API proxy {@link API_PROXY_PORTS.GEMINI} + * - http://api-proxy:10004 - Google Vertex AI API proxy {@link API_PROXY_PORTS.VERTEX} * * When the corresponding API key is provided, the following environment * variables are set in the agent container: @@ -24,6 +25,7 @@ export interface ApiProxyCredentialOptions { * - ANTHROPIC_BASE_URL=http://api-proxy:10001 (set when ANTHROPIC_API_KEY is provided, or when AWF_AUTH_TYPE=github-oidc and AWF_AUTH_PROVIDER=anthropic) * - COPILOT_API_URL=http://api-proxy:10002 (set when COPILOT_GITHUB_TOKEN is provided) * - CLAUDE_CODE_API_KEY_HELPER=/usr/local/bin/get-claude-key.sh (set when ANTHROPIC_API_KEY is provided, or when AWF_AUTH_TYPE=github-oidc and AWF_AUTH_PROVIDER=anthropic) + * - GOOGLE_VERTEX_BASE_URL=http://api-proxy:10004 (set when GOOGLE_API_KEY is provided) * * API keys are passed via environment variables: * - OPENAI_API_KEY - Optional OpenAI API key for Codex @@ -31,6 +33,7 @@ export interface ApiProxyCredentialOptions { * - COPILOT_GITHUB_TOKEN - Optional GitHub token for Copilot * - COPILOT_PROVIDER_API_KEY - Optional upstream BYOK API key for Copilot-compatible providers * - GEMINI_API_KEY - Optional Google Gemini API key + * - GOOGLE_API_KEY - Optional Google API key for Vertex AI * * @default false * @example @@ -114,6 +117,22 @@ export interface ApiProxyCredentialOptions { */ geminiApiKey?: string; + /** + * Google API key for Vertex AI (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 aiplatform.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 Vertex auth check passes. + * + * Corresponds to the `GOOGLE_API_KEY` environment variable used by the Gemini CLI + * in Vertex AI mode (`GOOGLE_GENAI_USE_VERTEXAI=true`). + * + * @default undefined + */ + googleApiKey?: string; + /** * Custom auth header name for OpenAI API requests (used by API proxy sidecar) * diff --git a/src/types/api-proxy-routing-options.ts b/src/types/api-proxy-routing-options.ts index dc94bc57b..31e6850ef 100644 --- a/src/types/api-proxy-routing-options.ts +++ b/src/types/api-proxy-routing-options.ts @@ -222,4 +222,35 @@ export interface ApiProxyRoutingOptions { * @default '' */ geminiApiBasePath?: string; + + /** + * Target hostname for Google Vertex AI API requests (used by API proxy sidecar) + * + * Overrides the default `aiplatform.googleapis.com` target when set. Useful + * for region-specific endpoints or private API Gateway endpoints. + * + * Can be set via: + * - CLI flag: `--vertex-api-target ` + * - Environment variable: `VERTEX_API_TARGET` + * + * @default 'aiplatform.googleapis.com' + * @example + * ```bash + * awf --enable-api-proxy --vertex-api-target us-central1-aiplatform.googleapis.com -- command + * ``` + */ + vertexApiTarget?: string; + + /** + * Base path prefix for Google Vertex AI API requests (used by API proxy sidecar) + * + * When set, this path is prepended to every upstream request path. + * + * Can be set via: + * - CLI flag: `--vertex-api-base-path ` + * - Environment variable: `VERTEX_API_BASE_PATH` + * + * @default '' + */ + vertexApiBasePath?: string; } diff --git a/src/types/ports.ts b/src/types/ports.ts index a0b0a7475..5caeaf6d5 100644 --- a/src/types/ports.ts +++ b/src/types/ports.ts @@ -39,6 +39,12 @@ export const API_PROXY_PORTS = { */ GEMINI: 10003, + /** + * Google Vertex AI API proxy port + * @see containers/api-proxy/server.js + */ + VERTEX: 10004, + } as const; /** diff --git a/src/workdir-setup.test.ts b/src/workdir-setup.test.ts index 81112f490..5b4354850 100644 --- a/src/workdir-setup.test.ts +++ b/src/workdir-setup.test.ts @@ -213,6 +213,32 @@ describe('prepareWorkDirectories', () => { expect(fs.existsSync(geminiDir)).toBe(true); }); + it('creates .gemini directory when googleApiKey is provided', () => { + const geminiDir = path.join(fixture.tempDir, '.gemini'); + if (fs.existsSync(geminiDir)) { + fs.rmSync(geminiDir, { recursive: true, force: true }); + } + + const config = buildConfig({ googleApiKey: 'test-key' }); + const logPaths = resolveLogPaths(config); + + prepareWorkDirectories(config, logPaths); + + expect(fs.existsSync(geminiDir)).toBe(true); + }); + + it('repairs ownership on existing .gemini directory for vertex runs', () => { + const geminiDir = path.join(fixture.tempDir, '.gemini'); + fs.mkdirSync(geminiDir, { recursive: true }); + + const config = buildConfig({ googleApiKey: 'test-key' }); + const logPaths = resolveLogPaths(config); + + prepareWorkDirectories(config, logPaths); + + expect(fs.chownSync).toHaveBeenCalledWith(geminiDir, 1000, 1000); + }); + it('does not create .gemini directory when geminiApiKey is not provided', () => { const geminiDir = path.join(fixture.tempDir, '.gemini'); if (fs.existsSync(geminiDir)) { diff --git a/src/workdir-setup.ts b/src/workdir-setup.ts index 91cbbb9e4..0c043cc37 100644 --- a/src/workdir-setup.ts +++ b/src/workdir-setup.ts @@ -232,7 +232,7 @@ function prepareChrootHomeMounts(config: WrapperConfig): void { const hostHomeMountSourceDirs = [ '.copilot', '.cache', '.config', '.local', '.anthropic', '.claude', '.cargo', '.rustup', '.npm', '.nvm', - ...(config.geminiApiKey ? ['.gemini'] : []), + ...(config.geminiApiKey || config.googleApiKey ? ['.gemini'] : []), ]; for (const dir of hostHomeMountSourceDirs) { const dirPath = path.join(effectiveHome, dir); @@ -240,6 +240,11 @@ function prepareChrootHomeMounts(config: WrapperConfig): void { fs.mkdirSync(dirPath, { recursive: true }); fs.chownSync(dirPath, uid, gid); logger.debug(`Created host home subdirectory: ${dirPath} (${uid}:${gid})`); + } else if (dir === '.gemini') { + // Repair existing .gemini ownership for Gemini/Vertex runs where prior + // root-owned bind mounts can break atomic writes in the CLI. + fs.chownSync(dirPath, uid, gid); + logger.debug(`Fixed host home subdirectory ownership: ${dirPath} (${uid}:${gid})`); } }