From 321794a621daa870081295346c4f02705d00592f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 13:18:27 +0000 Subject: [PATCH] Mirror Google API key env aliases into managed OpenCode. OpenCode can mark Google connected via GEMINI_API_KEY while the Generative AI SDK only reads GOOGLE_GENERATIVE_AI_API_KEY, so chat asked for a key that was already present. Alias unset sibling names on managed launch for web and VS Code. Co-authored-by: Serhii Dziupin --- packages/vscode/src/opencode.ts | 26 ++++++++++- .../web/server/lib/opencode/DOCUMENTATION.md | 7 +++ packages/web/server/lib/opencode/lifecycle.js | 5 ++- .../web/server/lib/opencode/lifecycle.test.js | 45 +++++++++++++++++++ .../lib/opencode/provider-env-aliases.js | 35 +++++++++++++++ .../lib/opencode/provider-env-aliases.test.js | 33 ++++++++++++++ 6 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 packages/web/server/lib/opencode/provider-env-aliases.js create mode 100644 packages/web/server/lib/opencode/provider-env-aliases.test.js diff --git a/packages/vscode/src/opencode.ts b/packages/vscode/src/opencode.ts index c1f60f73..9ac5cf0e 100644 --- a/packages/vscode/src/opencode.ts +++ b/packages/vscode/src/opencode.ts @@ -11,6 +11,30 @@ import { normalizeWindowsDriveLetter } from './pathUtils'; import { resolveWorkingDirectoryChange } from './workingDirectoryChange'; import { registerManagedProcess, unregisterManagedProcess, reapOrphanedProcesses } from './opencodeProcessRegistry'; +/** Keep in sync with packages/web/server/lib/opencode/provider-env-aliases.js */ +const GOOGLE_API_KEY_ALIASES = [ + 'GOOGLE_GENERATIVE_AI_API_KEY', + 'GOOGLE_API_KEY', + 'GEMINI_API_KEY', +] as const; + +function applyProviderEnvAliases(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const next: NodeJS.ProcessEnv = { ...env }; + const googleValue = GOOGLE_API_KEY_ALIASES + .map((key) => next[key]) + .find((value) => typeof value === 'string' && value.trim().length > 0); + + if (googleValue) { + for (const key of GOOGLE_API_KEY_ALIASES) { + if (typeof next[key] !== 'string' || next[key]!.trim().length === 0) { + next[key] = googleValue; + } + } + } + + return next; +} + const t = vscode.l10n.t; const READY_CHECK_TIMEOUT_MS = 30000; @@ -667,7 +691,7 @@ async function spawnManagedOpenCodeServer( const launch = resolveWindowsLaunchSpec(binary, ['serve', '--hostname', '127.0.0.1', '--port', String(port)]); const child = spawn(launch.binary, launch.args, { cwd: workingDirectory, - env: { ...process.env }, + env: applyProviderEnvAliases({ ...process.env }), stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }); diff --git a/packages/web/server/lib/opencode/DOCUMENTATION.md b/packages/web/server/lib/opencode/DOCUMENTATION.md index ee125803..f7bbe377 100644 --- a/packages/web/server/lib/opencode/DOCUMENTATION.md +++ b/packages/web/server/lib/opencode/DOCUMENTATION.md @@ -11,6 +11,7 @@ This module provides OpenCode server integration utilities for the web server ru - `packages/web/server/lib/opencode/cli-entry-runtime.js`: CLI entrypoint runtime that detects direct execution, parses CLI options, and starts server bootstrap. - `packages/web/server/lib/opencode/routes.js`: OpenCode/provider settings and auth-related route registration. - `packages/web/server/lib/opencode/lifecycle.js`: OpenCode process lifecycle runtime (startup, restart, readiness, health monitoring). After readiness it warms the most recently used directories (`getWarmupDirectories` dep, sequential and best-effort) because OpenCode initializes each directory lazily on first request and that cost would otherwise be paid by the user's first interactive session open. +- `packages/web/server/lib/opencode/provider-env-aliases.js`: mirrors known provider credential env aliases into the managed OpenCode process environment (for example `GEMINI_API_KEY` → `GOOGLE_GENERATIVE_AI_API_KEY`) so OpenCode connection detection and the upstream AI SDK agree on the same key names. - `packages/web/server/lib/opencode/env-runtime.js`: OpenCode CLI/binary resolution and shell environment runtime. - `packages/web/server/lib/opencode/env-config.js`: OpenCode-related environment variable parsing and validation (host/port/hostname). - `packages/web/server/lib/opencode/hmr-state-runtime.js`: HMR-persistent runtime state initialization, auth-state bootstrap, and HMR sync helpers. @@ -123,6 +124,12 @@ runtime. PATH and `OPENCODE_SERVER_PASSWORD` remain lifecycle-owned and cannot be replaced by injected values. External OpenCode processes receive no OpenChamber tool injection. +Before spawn, `applyProviderEnvAliases` fills unset Google credential aliases +from any present sibling (`GOOGLE_GENERATIVE_AI_API_KEY`, `GOOGLE_API_KEY`, +`GEMINI_API_KEY`) so a shell that only exports `GEMINI_API_KEY` still satisfies +the Generative AI SDK path used at chat time. Existing non-empty values are +never overwritten. + Set `OPENCHAMBER_STARTUP_PERF=1` to emit bounded startup phase records for server listen, managed OpenCode preparation/readiness, and proxy readiness holds. Every OpenCode bootstrap emits one terminal `opencode.bootstrap.ready` or `opencode.bootstrap.error` event, including reused and external server paths. Records contain controlled phase/outcome/route labels and timing values only; they never contain request URLs, runtime keys, directories, session IDs, credentials, or content. macOS `say` voice enumeration starts concurrently with server composition. The server listener and managed OpenCode startup do not wait for it; `/api/tts/say/status` awaits the same authoritative capability promise when queried before enumeration completes. diff --git a/packages/web/server/lib/opencode/lifecycle.js b/packages/web/server/lib/opencode/lifecycle.js index 046a710e..af1976dc 100644 --- a/packages/web/server/lib/opencode/lifecycle.js +++ b/packages/web/server/lib/opencode/lifecycle.js @@ -1,6 +1,7 @@ import { spawn, spawnSync } from 'node:child_process'; import net from 'node:net'; import { registerManagedProcess, unregisterManagedProcess, reapOrphanedProcesses } from './managed-process-registry.js'; +import { applyProviderEnvAliases } from './provider-env-aliases.js'; import { recordStartupPerformance } from './startup-performance.js'; const parsePositiveInt = (value, fallback) => { @@ -518,13 +519,13 @@ export const createOpenCodeLifecycleRuntime = (deps) => { timeout: 30000, cwd: state.openCodeWorkingDirectory, shellEnvKeysCount: Object.keys(shellEnv).length, - env: { + env: applyProviderEnvAliases({ ...shellEnv, ...process.env, ...managedOpenCodeEnv, PATH: envPath, OPENCODE_SERVER_PASSWORD: openCodePassword, - }, + }), }); if (!serverInstance || !serverInstance.url) { diff --git a/packages/web/server/lib/opencode/lifecycle.test.js b/packages/web/server/lib/opencode/lifecycle.test.js index aadb5483..d32a95bf 100644 --- a/packages/web/server/lib/opencode/lifecycle.test.js +++ b/packages/web/server/lib/opencode/lifecycle.test.js @@ -313,6 +313,51 @@ describe('OpenCode lifecycle', () => { await server.close(); }); + it('mirrors Google credential env aliases into the managed OpenCode environment', async () => { + const previousGemini = process.env.GEMINI_API_KEY; + const previousGoogleGen = process.env.GOOGLE_GENERATIVE_AI_API_KEY; + const previousGoogle = process.env.GOOGLE_API_KEY; + process.env.GEMINI_API_KEY = 'AIza-from-gemini'; + delete process.env.GOOGLE_GENERATIVE_AI_API_KEY; + delete process.env.GOOGLE_API_KEY; + + try { + const child = createMockChild(); + spawnMock.mockImplementationOnce(() => { + queueMicrotask(() => { + child.stdout.emit('data', 'opencode server listening on http://127.0.0.1:45678\n'); + }); + return child; + }); + + const runtime = createRuntime(); + const server = await runtime.startOpenCode(); + const [, , options] = spawnMock.mock.calls[0]; + + expect(options.env.GEMINI_API_KEY).toBe('AIza-from-gemini'); + expect(options.env.GOOGLE_API_KEY).toBe('AIza-from-gemini'); + expect(options.env.GOOGLE_GENERATIVE_AI_API_KEY).toBe('AIza-from-gemini'); + + await server.close(); + } finally { + if (typeof previousGemini === 'string') { + process.env.GEMINI_API_KEY = previousGemini; + } else { + delete process.env.GEMINI_API_KEY; + } + if (typeof previousGoogleGen === 'string') { + process.env.GOOGLE_GENERATIVE_AI_API_KEY = previousGoogleGen; + } else { + delete process.env.GOOGLE_GENERATIVE_AI_API_KEY; + } + if (typeof previousGoogle === 'string') { + process.env.GOOGLE_API_KEY = previousGoogle; + } else { + delete process.env.GOOGLE_API_KEY; + } + } + }); + it('falls back to buildAugmentedPath when buildManagedOpenCodePath is not provided', async () => { delete process.env.OPENCODE_BINARY; const child = createMockChild(); diff --git a/packages/web/server/lib/opencode/provider-env-aliases.js b/packages/web/server/lib/opencode/provider-env-aliases.js new file mode 100644 index 00000000..51f70f9a --- /dev/null +++ b/packages/web/server/lib/opencode/provider-env-aliases.js @@ -0,0 +1,35 @@ +/** + * Normalize provider credential env aliases for managed OpenCode. + * + * OpenCode may mark a provider as connected when any listed env key is present + * (e.g. GEMINI_API_KEY), while the upstream AI SDK only reads a different name + * (GOOGLE_GENERATIVE_AI_API_KEY). Mirror known aliases so chat works without + * forcing the user to paste the same key again in Settings. + */ + +const GOOGLE_API_KEY_ALIASES = [ + 'GOOGLE_GENERATIVE_AI_API_KEY', + 'GOOGLE_API_KEY', + 'GEMINI_API_KEY', +]; + +export function applyProviderEnvAliases(env) { + if (!env || typeof env !== 'object') { + return {}; + } + + const next = { ...env }; + const googleValue = GOOGLE_API_KEY_ALIASES + .map((key) => next[key]) + .find((value) => typeof value === 'string' && value.trim().length > 0); + + if (googleValue) { + for (const key of GOOGLE_API_KEY_ALIASES) { + if (typeof next[key] !== 'string' || next[key].trim().length === 0) { + next[key] = googleValue; + } + } + } + + return next; +} diff --git a/packages/web/server/lib/opencode/provider-env-aliases.test.js b/packages/web/server/lib/opencode/provider-env-aliases.test.js new file mode 100644 index 00000000..a6530541 --- /dev/null +++ b/packages/web/server/lib/opencode/provider-env-aliases.test.js @@ -0,0 +1,33 @@ +import { describe, expect, test } from 'bun:test'; + +import { applyProviderEnvAliases } from './provider-env-aliases.js'; + +describe('applyProviderEnvAliases', () => { + test('mirrors GEMINI_API_KEY onto Google Generative AI env names', () => { + expect(applyProviderEnvAliases({ + GEMINI_API_KEY: 'AIza-demo', + PATH: '/usr/bin', + })).toEqual({ + GEMINI_API_KEY: 'AIza-demo', + GOOGLE_API_KEY: 'AIza-demo', + GOOGLE_GENERATIVE_AI_API_KEY: 'AIza-demo', + PATH: '/usr/bin', + }); + }); + + test('does not overwrite an already-set preferred Google key', () => { + expect(applyProviderEnvAliases({ + GEMINI_API_KEY: 'from-gemini', + GOOGLE_GENERATIVE_AI_API_KEY: 'from-google', + })).toEqual({ + GEMINI_API_KEY: 'from-gemini', + GOOGLE_API_KEY: 'from-google', + GOOGLE_GENERATIVE_AI_API_KEY: 'from-google', + }); + }); + + test('returns empty object for invalid input', () => { + expect(applyProviderEnvAliases(null)).toEqual({}); + expect(applyProviderEnvAliases(undefined)).toEqual({}); + }); +});