diff --git a/packages/web/server/lib/opencode/env-runtime.js b/packages/web/server/lib/opencode/env-runtime.js index fabfa0a4..7e4cf9d6 100644 --- a/packages/web/server/lib/opencode/env-runtime.js +++ b/packages/web/server/lib/opencode/env-runtime.js @@ -11,6 +11,7 @@ export const createOpenCodeEnvRuntime = (deps) => { readSettingsFromDiskMigrated, } = deps; const runSpawnSync = typeof deps.spawnSync === 'function' ? deps.spawnSync : spawnSync; + const resolveHomeDir = typeof deps.homedir === 'function' ? deps.homedir : () => os.homedir(); const parseNullSeparatedEnvSnapshot = (raw) => { if (typeof raw !== 'string' || raw.length === 0) { @@ -301,6 +302,14 @@ export const createOpenCodeEnvRuntime = (deps) => { return null; }; + const bundledOpenCodeCliFallback = () => { + const bundled = resolveBundledOpenCodeCliPath(); + if (!bundled) return null; + clearWslOpencodeResolution(); + state.resolvedOpencodeBinarySource = 'bundled'; + return bundled; + }; + const clearWslOpencodeResolution = () => { state.useWslForOpencode = false; state.resolvedWslBinary = null; @@ -326,13 +335,9 @@ export const createOpenCodeEnvRuntime = (deps) => { } } - const bundled = resolveBundledOpenCodeCliPath(); - if (bundled) { - clearWslOpencodeResolution(); - state.resolvedOpencodeBinarySource = 'bundled'; - return bundled; - } - + // The bundled CLI is the LAST resort (see bundledOpenCodeCliFallback at the + // exit points below): a user's own OpenCode install — PATH, known install + // locations, or shell-resolved — must win over the pinned bundled copy. const resolvedFromPath = searchPathFor('opencode'); if (resolvedFromPath) { clearWslOpencodeResolution(); @@ -340,7 +345,7 @@ export const createOpenCodeEnvRuntime = (deps) => { return resolvedFromPath; } - const home = os.homedir(); + const home = resolveHomeDir(); const unixFallbacks = [ path.join(home, '.opencode', 'bin', 'opencode'), path.join(home, '.bun', 'bin', 'opencode'), @@ -403,7 +408,7 @@ export const createOpenCodeEnvRuntime = (deps) => { // Do not auto-detect OpenCode from WSL. OpenCode sessions are keyed by // server-visible directories, and mixing Windows paths with WSL paths // creates duplicate/missing project state in the desktop app. - return null; + return bundledOpenCodeCliFallback(); } const shells = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean); @@ -427,7 +432,7 @@ export const createOpenCodeEnvRuntime = (deps) => { } } - return null; + return bundledOpenCodeCliFallback(); }; const resolveNodeCliPath = () => { diff --git a/packages/web/server/lib/opencode/env-runtime.test.js b/packages/web/server/lib/opencode/env-runtime.test.js index f5c5c7c5..8f61b515 100644 --- a/packages/web/server/lib/opencode/env-runtime.test.js +++ b/packages/web/server/lib/opencode/env-runtime.test.js @@ -111,6 +111,7 @@ const createRuntime = (settings, options = {}) => { normalizeDirectoryPath: (value) => value, readSettingsFromDiskMigrated: async () => settings, spawnSync: options.spawnSync, + homedir: options.homedir, }); return { runtime, state }; @@ -149,7 +150,7 @@ describe('OpenCode env runtime', () => { expect(state.resolvedOpencodeBinarySource).toBe('settings'); }); - it('resolves bundled OpenCode CLI before PATH lookup', () => { + it('prefers a user-installed OpenCode from PATH over the bundled CLI', () => { const bundledDir = createTempDir('openchamber-bundled-opencode-'); const bundledBinary = path.join(bundledDir, process.platform === 'win32' ? 'opencode.exe' : 'opencode'); const pathDir = createTempDir('openchamber-path-opencode-'); @@ -165,8 +166,8 @@ describe('OpenCode env runtime', () => { delete process.env.OPENCODE_BINARY; const { runtime, state } = createRuntime({}); - expect(runtime.resolveOpencodeCliPath()).toBe(bundledBinary); - expect(state.resolvedOpencodeBinarySource).toBe('bundled'); + expect(runtime.resolveOpencodeCliPath()).toBe(pathBinary); + expect(state.resolvedOpencodeBinarySource).toBe('path'); }); it('keeps explicit OpenCode binary ahead of bundled CLI', () => { @@ -188,7 +189,7 @@ describe('OpenCode env runtime', () => { expect(state.resolvedOpencodeBinarySource).toBe('env'); }); - it('resolves bundled OpenCode CLI from Electron resourcesPath', () => { + it('falls back to the bundled OpenCode CLI from Electron resourcesPath when nothing else is installed', () => { const resourcesPath = createTempDir('openchamber-resources-'); const bundledDir = path.join(resourcesPath, 'opencode-cli'); const bundledBinary = path.join(bundledDir, process.platform === 'win32' ? 'opencode.exe' : 'opencode'); @@ -204,7 +205,13 @@ describe('OpenCode env runtime', () => { process.env.PATH = createTempDir('openchamber-empty-path-'); delete process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR; delete process.env.OPENCODE_BINARY; - const { runtime, state } = createRuntime({}); + // The bundled CLI is the LAST resort now — hide the machine's own installs + // from the home-directory fallbacks and shell discovery. + const emptyHome = createTempDir('openchamber-empty-home-'); + const { runtime, state } = createRuntime({}, { + spawnSync: () => ({ status: 1, stdout: '', stderr: '' }), + homedir: () => emptyHome, + }); expect(runtime.resolveOpencodeCliPath()).toBe(bundledBinary); expect(state.resolvedOpencodeBinarySource).toBe('bundled');