diff --git a/packages/vscode/src/opencode.ts b/packages/vscode/src/opencode.ts index 540a9aba..c7e5cd85 100644 --- a/packages/vscode/src/opencode.ts +++ b/packages/vscode/src/opencode.ts @@ -196,10 +196,30 @@ function isMacOpenCodeAppBundlePath(candidate: string): boolean { return process.platform === 'darwin' && /\/OpenCode\.app\/Contents\/MacOS\/(?:OpenCode|opencode-cli)$/i.test(candidate); } +function isWindowsOpenCodeDesktopAppPath(candidate: string): boolean { + if (process.platform !== 'win32' || typeof candidate !== 'string') { + return false; + } + const localAppData = typeof process.env.LOCALAPPDATA === 'string' && process.env.LOCALAPPDATA.trim() + ? path.resolve(process.env.LOCALAPPDATA).toLowerCase() + : ''; + if (!localAppData) { + return false; + } + const normalized = path.resolve(candidate).toLowerCase(); + return normalized.startsWith(`${localAppData}${path.sep}`) + && normalized.endsWith(`${path.sep}programs${path.sep}opencode${path.sep}opencode.exe`); +} + +function isKnownOpenCodeDesktopAppPath(candidate: string): boolean { + return isMacOpenCodeAppBundlePath(candidate) || isWindowsOpenCodeDesktopAppPath(candidate); +} + function createConfiguredOpencodeBinaryError(raw: string, normalized: string): Error { const messageSuffix = 'OpenChamber needs the standalone opencode CLI. Install it and set openchamber.opencodeBinary to the CLI path, for example ~/.opencode/bin/opencode, or leave the setting empty to use PATH lookup.'; - if (isMacOpenCodeAppBundlePath(raw) || isMacOpenCodeAppBundlePath(normalized)) { - return new Error(`Configured OpenCode binary points at the macOS desktop app bundle, not the CLI: ${normalized}. ${messageSuffix}`); + if (isKnownOpenCodeDesktopAppPath(raw) || isKnownOpenCodeDesktopAppPath(normalized)) { + const platformName = process.platform === 'win32' ? 'Windows desktop app install' : 'macOS desktop app bundle'; + return new Error(`Configured OpenCode binary points at the ${platformName}, not the CLI: ${normalized}. ${messageSuffix}`); } try { @@ -254,7 +274,7 @@ function validateConfiguredOpencodeBinaryForManagedStart(): string | null { return null; } - if (isExecutable(normalized) && !isMacOpenCodeAppBundlePath(normalized)) { + if (isExecutable(normalized) && !isKnownOpenCodeDesktopAppPath(normalized)) { return normalized; } @@ -271,7 +291,7 @@ function resolveOpencodeCliPath(): string | null { } })(); - if (configured && isExecutable(configured) && !isMacOpenCodeAppBundlePath(configured)) { + if (configured && isExecutable(configured) && !isKnownOpenCodeDesktopAppPath(configured)) { return configured; } @@ -288,7 +308,7 @@ function resolveOpencodeCliPath(): string | null { } })(); - if (sharedFromOpenChamber && isExecutable(sharedFromOpenChamber) && !isMacOpenCodeAppBundlePath(sharedFromOpenChamber)) { + if (sharedFromOpenChamber && isExecutable(sharedFromOpenChamber) && !isKnownOpenCodeDesktopAppPath(sharedFromOpenChamber)) { return sharedFromOpenChamber; } @@ -302,13 +322,13 @@ function resolveOpencodeCliPath(): string | null { .filter(Boolean); for (const candidate of explicit) { - if (isExecutable(candidate)) { + if (isExecutable(candidate) && !isKnownOpenCodeDesktopAppPath(candidate)) { return candidate; } } if (cachedDetectedOpencodeCliPath) { - if (isExecutable(cachedDetectedOpencodeCliPath)) { + if (isExecutable(cachedDetectedOpencodeCliPath) && !isKnownOpenCodeDesktopAppPath(cachedDetectedOpencodeCliPath)) { return cachedDetectedOpencodeCliPath; } cachedDetectedOpencodeCliPath = undefined; @@ -327,7 +347,6 @@ function resolveOpencodeCliPath(): string | null { const winFallbacks = (() => { const userProfile = process.env.USERPROFILE || home; const appData = process.env.APPDATA || path.join(userProfile, 'AppData', 'Roaming'); - const localAppData = process.env.LOCALAPPDATA || ''; const programData = process.env.ProgramData || 'C:\\ProgramData'; const npmDir = path.join(appData, 'npm'); @@ -344,14 +363,12 @@ function resolveOpencodeCliPath(): string | null { // Bun global install path.join(userProfile, '.bun', 'bin', 'opencode.exe'), path.join(userProfile, '.bun', 'bin', 'opencode.cmd'), - // Some installers use LocalAppData - localAppData ? path.join(localAppData, 'Programs', 'opencode', 'opencode.exe') : '', ].filter(Boolean); })(); if (process.platform !== 'win32') { const fromPath = findExecutableInPath('opencode'); - if (fromPath) { + if (fromPath && !isKnownOpenCodeDesktopAppPath(fromPath)) { cachedDetectedOpencodeCliPath = fromPath; return fromPath; } @@ -359,7 +376,7 @@ function resolveOpencodeCliPath(): string | null { const fallbacks = process.platform === 'win32' ? winFallbacks : unixFallbacks; for (const candidate of fallbacks) { - if (isExecutable(candidate)) { + if (isExecutable(candidate) && !isKnownOpenCodeDesktopAppPath(candidate)) { cachedDetectedOpencodeCliPath = candidate; return candidate; } @@ -367,7 +384,7 @@ function resolveOpencodeCliPath(): string | null { if (process.platform === 'win32') { const fromPath = findExecutableInPath('opencode'); - if (fromPath) { + if (fromPath && !isKnownOpenCodeDesktopAppPath(fromPath)) { cachedDetectedOpencodeCliPath = fromPath; return fromPath; } @@ -382,7 +399,7 @@ function resolveOpencodeCliPath(): string | null { .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); - const found = lines.find((line) => isExecutable(line)); + const found = lines.find((line) => isExecutable(line) && !isKnownOpenCodeDesktopAppPath(line)); if (found) { cachedDetectedOpencodeCliPath = found; return found; diff --git a/packages/web/server/lib/opencode/env-runtime.js b/packages/web/server/lib/opencode/env-runtime.js index 998128d3..bee62da1 100644 --- a/packages/web/server/lib/opencode/env-runtime.js +++ b/packages/web/server/lib/opencode/env-runtime.js @@ -260,6 +260,20 @@ export const createOpenCodeEnvRuntime = (deps) => { return /(^|[\\/])wsl(\.exe)?$/i.test(trimmed); }; + const isWindowsOpenCodeDesktopAppPath = (candidate) => { + if (process.platform !== 'win32' || typeof candidate !== 'string') { + return false; + } + const normalized = path.resolve(candidate).toLowerCase(); + const localAppData = typeof process.env.LOCALAPPDATA === 'string' && process.env.LOCALAPPDATA.trim() + ? path.resolve(process.env.LOCALAPPDATA).toLowerCase() + : ''; + if (!localAppData || !normalized.startsWith(`${localAppData}${path.sep}`)) { + return false; + } + return normalized.endsWith(`${path.sep}programs${path.sep}opencode${path.sep}opencode.exe`); + }; + const clearWslOpencodeResolution = () => { state.useWslForOpencode = false; state.resolvedWslBinary = null; @@ -278,7 +292,7 @@ export const createOpenCodeEnvRuntime = (deps) => { .filter(Boolean); for (const candidate of explicit) { - if (isExecutable(candidate)) { + if (isExecutable(candidate) && !isWindowsOpenCodeDesktopAppPath(candidate)) { clearWslOpencodeResolution(); state.resolvedOpencodeBinarySource = 'env'; return candidate; @@ -319,7 +333,6 @@ export const createOpenCodeEnvRuntime = (deps) => { path.join(programData, 'chocolatey', 'bin', 'opencode.cmd'), path.join(userProfile, '.bun', 'bin', 'opencode.exe'), path.join(userProfile, '.bun', 'bin', 'opencode.cmd'), - localAppData ? path.join(localAppData, 'Programs', 'opencode', 'opencode.exe') : '', ].filter(Boolean); })(); @@ -344,7 +357,7 @@ export const createOpenCodeEnvRuntime = (deps) => { .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); - const found = lines.find((line) => isExecutable(line)); + const found = lines.find((line) => isExecutable(line) && !isWindowsOpenCodeDesktopAppPath(line)); if (found) { clearWslOpencodeResolution(); state.resolvedOpencodeBinarySource = 'where'; @@ -824,13 +837,17 @@ export const createOpenCodeEnvRuntime = (deps) => { return /\/OpenCode\.app\/Contents\/MacOS\/(?:OpenCode|opencode-cli)$/i.test(candidate); }; + const isKnownOpenCodeDesktopAppPath = (candidate) => isMacOpenCodeAppBundlePath(candidate) + || isWindowsOpenCodeDesktopAppPath(candidate); + const createConfiguredOpencodeBinaryError = (raw, normalized) => { const configured = typeof raw === 'string' ? raw.trim() : ''; const candidate = typeof normalized === 'string' && normalized.trim().length > 0 ? normalized.trim() : configured; const messageSuffix = 'OpenChamber needs the standalone opencode CLI. Install it and set settings.opencodeBinary to the CLI path, for example ~/.opencode/bin/opencode, or leave the setting empty to use PATH lookup.'; const error = (() => { - if (isMacOpenCodeAppBundlePath(candidate) || isMacOpenCodeAppBundlePath(configured)) { - return new Error(`Configured OpenCode binary points at the macOS desktop app bundle, not the CLI: ${candidate}. ${messageSuffix}`); + if (isKnownOpenCodeDesktopAppPath(candidate) || isKnownOpenCodeDesktopAppPath(configured)) { + const platformName = process.platform === 'win32' ? 'Windows desktop app install' : 'macOS desktop app bundle'; + return new Error(`Configured OpenCode binary points at the ${platformName}, not the CLI: ${candidate}. ${messageSuffix}`); } try { @@ -927,7 +944,7 @@ export const createOpenCodeEnvRuntime = (deps) => { return null; } - if (normalized && isExecutable(normalized) && !isMacOpenCodeAppBundlePath(normalized)) { + if (normalized && isExecutable(normalized) && !isKnownOpenCodeDesktopAppPath(normalized)) { clearWslOpencodeResolution(); process.env.OPENCODE_BINARY = normalized; prependToPath(path.dirname(normalized)); diff --git a/packages/web/server/lib/opencode/env-runtime.test.js b/packages/web/server/lib/opencode/env-runtime.test.js index c0ad1e7f..0e432a11 100644 --- a/packages/web/server/lib/opencode/env-runtime.test.js +++ b/packages/web/server/lib/opencode/env-runtime.test.js @@ -7,6 +7,7 @@ import { createOpenCodeEnvRuntime } from './env-runtime.js'; const originalOpencodeBinary = process.env.OPENCODE_BINARY; const originalComSpec = process.env.ComSpec; const originalPath = process.env.PATH; +const originalLocalAppData = process.env.LOCALAPPDATA; const originalSystemRoot = process.env.SystemRoot; const originalWslBinary = process.env.WSL_BINARY; const originalOpenChamberWslBinary = process.env.OPENCHAMBER_WSL_BINARY; @@ -59,6 +60,12 @@ afterEach(() => { delete process.env.SystemRoot; } + if (typeof originalLocalAppData === 'string') { + process.env.LOCALAPPDATA = originalLocalAppData; + } else { + delete process.env.LOCALAPPDATA; + } + if (typeof originalWslBinary === 'string') { process.env.WSL_BINARY = originalWslBinary; } else { @@ -138,6 +145,58 @@ describe('OpenCode env runtime', () => { }); }); + it('rejects known Windows OpenCode desktop app install paths', async () => { + setPlatform('win32'); + const localAppData = createTempDir('openchamber-localappdata-'); + const desktopBinary = path.join(localAppData, 'Programs', 'OpenCode', 'OpenCode.exe'); + fs.mkdirSync(path.dirname(desktopBinary), { recursive: true }); + fs.writeFileSync(desktopBinary, ''); + process.env.LOCALAPPDATA = localAppData; + const { runtime } = createRuntime({ opencodeBinary: desktopBinary }); + + await expect(runtime.applyOpencodeBinaryFromSettings({ strict: true })).rejects.toMatchObject({ + code: 'OPENCODE_BINARY_INVALID', + message: expect.stringContaining('Windows desktop app install'), + }); + }); + + it('does not auto-detect the Windows OpenCode desktop app as a CLI', () => { + setPlatform('win32'); + const localAppData = createTempDir('openchamber-localappdata-'); + const desktopBinary = path.join(localAppData, 'Programs', 'OpenCode', 'OpenCode.exe'); + fs.mkdirSync(path.dirname(desktopBinary), { recursive: true }); + fs.writeFileSync(desktopBinary, ''); + process.env.LOCALAPPDATA = localAppData; + process.env.PATH = createTempDir('openchamber-empty-path-'); + process.env.SystemRoot = createTempDir('openchamber-empty-systemroot-'); + delete process.env.OPENCODE_BINARY; + const { runtime } = createRuntime({}, { + spawnSync: () => ({ status: 1, stdout: '', stderr: '' }), + }); + + expect(runtime.resolveOpencodeCliPath()).toBeNull(); + }); + + it('skips Windows OpenCode desktop app entries returned by where.exe', () => { + setPlatform('win32'); + const localAppData = createTempDir('openchamber-localappdata-'); + const desktopBinary = path.join(localAppData, 'Programs', 'OpenCode', 'OpenCode.exe'); + const cliBinary = path.join(createTempDir('openchamber-cli-'), 'opencode.exe'); + fs.mkdirSync(path.dirname(desktopBinary), { recursive: true }); + fs.writeFileSync(desktopBinary, ''); + fs.writeFileSync(cliBinary, ''); + process.env.LOCALAPPDATA = localAppData; + process.env.PATH = createTempDir('openchamber-empty-path-'); + process.env.SystemRoot = createTempDir('openchamber-empty-systemroot-'); + delete process.env.OPENCODE_BINARY; + const { runtime, state } = createRuntime({}, { + spawnSync: () => ({ status: 0, stdout: `${desktopBinary}\r\n${cliBinary}\r\n`, stderr: '' }), + }); + + expect(runtime.resolveOpencodeCliPath()).toBe(cliBinary); + expect(state.resolvedOpencodeBinarySource).toBe('where'); + }); + it('rejects WSL settings in strict mode', async () => { setPlatform('win32'); const dir = createTempDir('openchamber-no-wsl-');