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 <makeittech@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-08-03 13:18:27 +00:00
co-authored by Serhii Dziupin
parent 9cccc8d667
commit 321794a621
6 changed files with 148 additions and 3 deletions
+25 -1
View File
@@ -11,6 +11,30 @@ import { normalizeWindowsDriveLetter } from './pathUtils';
import { resolveWorkingDirectoryChange } from './workingDirectoryChange'; import { resolveWorkingDirectoryChange } from './workingDirectoryChange';
import { registerManagedProcess, unregisterManagedProcess, reapOrphanedProcesses } from './opencodeProcessRegistry'; 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 t = vscode.l10n.t;
const READY_CHECK_TIMEOUT_MS = 30000; 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 launch = resolveWindowsLaunchSpec(binary, ['serve', '--hostname', '127.0.0.1', '--port', String(port)]);
const child = spawn(launch.binary, launch.args, { const child = spawn(launch.binary, launch.args, {
cwd: workingDirectory, cwd: workingDirectory,
env: { ...process.env }, env: applyProviderEnvAliases({ ...process.env }),
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true, windowsHide: true,
}); });
@@ -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/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/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/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-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/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. - `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 be replaced by injected values. External OpenCode processes receive no
OpenChamber tool injection. 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. 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. 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.
@@ -1,6 +1,7 @@
import { spawn, spawnSync } from 'node:child_process'; import { spawn, spawnSync } from 'node:child_process';
import net from 'node:net'; import net from 'node:net';
import { registerManagedProcess, unregisterManagedProcess, reapOrphanedProcesses } from './managed-process-registry.js'; import { registerManagedProcess, unregisterManagedProcess, reapOrphanedProcesses } from './managed-process-registry.js';
import { applyProviderEnvAliases } from './provider-env-aliases.js';
import { recordStartupPerformance } from './startup-performance.js'; import { recordStartupPerformance } from './startup-performance.js';
const parsePositiveInt = (value, fallback) => { const parsePositiveInt = (value, fallback) => {
@@ -518,13 +519,13 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
timeout: 30000, timeout: 30000,
cwd: state.openCodeWorkingDirectory, cwd: state.openCodeWorkingDirectory,
shellEnvKeysCount: Object.keys(shellEnv).length, shellEnvKeysCount: Object.keys(shellEnv).length,
env: { env: applyProviderEnvAliases({
...shellEnv, ...shellEnv,
...process.env, ...process.env,
...managedOpenCodeEnv, ...managedOpenCodeEnv,
PATH: envPath, PATH: envPath,
OPENCODE_SERVER_PASSWORD: openCodePassword, OPENCODE_SERVER_PASSWORD: openCodePassword,
}, }),
}); });
if (!serverInstance || !serverInstance.url) { if (!serverInstance || !serverInstance.url) {
@@ -313,6 +313,51 @@ describe('OpenCode lifecycle', () => {
await server.close(); 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 () => { it('falls back to buildAugmentedPath when buildManagedOpenCodePath is not provided', async () => {
delete process.env.OPENCODE_BINARY; delete process.env.OPENCODE_BINARY;
const child = createMockChild(); const child = createMockChild();
@@ -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;
}
@@ -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({});
});
});