diff --git a/packages/web/server/lib/opencode/env-runtime.js b/packages/web/server/lib/opencode/env-runtime.js index 003c97f4..e662de5b 100644 --- a/packages/web/server/lib/opencode/env-runtime.js +++ b/packages/web/server/lib/opencode/env-runtime.js @@ -5,6 +5,12 @@ import path from 'node:path'; import { clearAppImageArgv0FromProcessEnv } from '../inherited-env.js'; import { mergePathValues } from './path-utils.js'; +// Login-shell probes source the user's rc files. A slow or interactive rc +// (nvm, pyenv, a prompt waiting for input) must not hold server startup +// hostage: a probe that overruns is abandoned and resolution falls through +// to the next candidate. Electron's own login-shell probe uses the same bound. +const SHELL_PROBE_TIMEOUT_MS = 5_000; + export const createOpenCodeEnvRuntime = (deps) => { const { state, @@ -208,6 +214,7 @@ export const createOpenCodeEnvRuntime = (deps) => { stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 10 * 1024 * 1024, windowsHide: true, + timeout: SHELL_PROBE_TIMEOUT_MS, }); if (result.status !== 0) { @@ -460,6 +467,7 @@ export const createOpenCodeEnvRuntime = (deps) => { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, + timeout: SHELL_PROBE_TIMEOUT_MS, }); if (result.status === 0) { const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; @@ -527,6 +535,7 @@ export const createOpenCodeEnvRuntime = (deps) => { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, + timeout: SHELL_PROBE_TIMEOUT_MS, }); if (result.status === 0) { const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; @@ -608,6 +617,7 @@ export const createOpenCodeEnvRuntime = (deps) => { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, + timeout: SHELL_PROBE_TIMEOUT_MS, }); if (result.status === 0) { const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; diff --git a/packages/web/server/lib/opencode/env-runtime.test.js b/packages/web/server/lib/opencode/env-runtime.test.js index d02529a4..56c10268 100644 --- a/packages/web/server/lib/opencode/env-runtime.test.js +++ b/packages/web/server/lib/opencode/env-runtime.test.js @@ -334,6 +334,29 @@ describe('OpenCode env runtime', () => { }); }); + it('bounds every login-shell probe and falls through when one overruns', () => { + setPlatform('darwin'); + process.env.PATH = createTempDir('openchamber-empty-path-'); + process.env.SHELL = '/bin/zsh'; + delete process.env.OPENCODE_BINARY; + const shellCalls = []; + const { runtime } = createRuntime({}, { + homedir: () => createTempDir('openchamber-empty-home-'), + spawnSync: (command, args, options) => { + shellCalls.push({ command, args, options }); + // What spawnSync reports when `timeout` fires: no status, an error. + return { status: null, signal: 'SIGTERM', error: new Error('spawnSync ETIMEDOUT'), stdout: '', stderr: '' }; + }, + }); + + expect(runtime.resolveOpencodeCliPath()).toBeNull(); + expect(shellCalls.length).toBeGreaterThan(0); + for (const call of shellCalls) { + expect(call.args).toContain('-lic'); + expect(call.options.timeout).toBe(5_000); + } + }); + it('does not auto-detect the Windows OpenCode desktop app as a CLI', () => { setPlatform('win32'); const localAppData = createTempDir('openchamber-localappdata-');