Files
openchamber/packages/web/server/lib/opencode/env-runtime.test.js
T
Bohdan Triapitsyn 7ec45785d9 fix(desktop): prefer the user's OpenCode install over the bundled CLI
The bundled CLI used to outrank PATH and known install locations, so
updating the desktop app silently switched people off their own OpenCode.
It is now the last resort: explicit env/settings, PATH, known install
locations, and shell discovery all win; the bundle only serves machines
with no OpenCode install at all. The env-runtime accepts an injectable
homedir so the fallback test stays hermetic on machines with a real
~/.opencode install.
2026-07-05 10:06:09 +03:00

360 lines
14 KiB
JavaScript

import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
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 originalBundledOpencodeCliDir = process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR;
const originalResourcesPath = process.resourcesPath;
const originalWslBinary = process.env.WSL_BINARY;
const originalOpenChamberWslBinary = process.env.OPENCHAMBER_WSL_BINARY;
const originalPlatform = process.platform;
const tempDirs = [];
const itIf = (condition) => condition ? it : it.skip;
const createTempDir = (prefix) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
tempDirs.push(dir);
return dir;
};
const setPlatform = (platform) => {
Object.defineProperty(process, 'platform', {
value: platform,
});
};
afterEach(() => {
Object.defineProperty(process, 'platform', {
value: originalPlatform,
});
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
if (typeof originalOpencodeBinary === 'string') {
process.env.OPENCODE_BINARY = originalOpencodeBinary;
} else {
delete process.env.OPENCODE_BINARY;
}
if (typeof originalComSpec === 'string') {
process.env.ComSpec = originalComSpec;
} else {
delete process.env.ComSpec;
}
if (typeof originalPath === 'string') {
process.env.PATH = originalPath;
} else {
delete process.env.PATH;
}
if (typeof originalSystemRoot === 'string') {
process.env.SystemRoot = originalSystemRoot;
} else {
delete process.env.SystemRoot;
}
if (typeof originalLocalAppData === 'string') {
process.env.LOCALAPPDATA = originalLocalAppData;
} else {
delete process.env.LOCALAPPDATA;
}
if (typeof originalBundledOpencodeCliDir === 'string') {
process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR = originalBundledOpencodeCliDir;
} else {
delete process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR;
}
Object.defineProperty(process, 'resourcesPath', {
configurable: true,
value: originalResourcesPath,
});
if (typeof originalWslBinary === 'string') {
process.env.WSL_BINARY = originalWslBinary;
} else {
delete process.env.WSL_BINARY;
}
if (typeof originalOpenChamberWslBinary === 'string') {
process.env.OPENCHAMBER_WSL_BINARY = originalOpenChamberWslBinary;
} else {
delete process.env.OPENCHAMBER_WSL_BINARY;
}
});
const createRuntime = (settings, options = {}) => {
const state = {
cachedLoginShellEnvSnapshot: null,
resolvedOpencodeBinary: null,
resolvedOpencodeBinarySource: null,
useWslForOpencode: false,
resolvedWslBinary: null,
resolvedWslOpencodePath: null,
resolvedWslDistro: null,
resolvedNodeBinary: null,
resolvedBunBinary: null,
managedOpenCodeShellEnvSnapshot: null,
};
const runtime = createOpenCodeEnvRuntime({
state,
normalizeDirectoryPath: (value) => value,
readSettingsFromDiskMigrated: async () => settings,
spawnSync: options.spawnSync,
homedir: options.homedir,
});
return { runtime, state };
};
describe('OpenCode env runtime', () => {
it('throws a specific error for a missing configured OpenCode binary in strict mode', async () => {
const { runtime } = createRuntime({ opencodeBinary: '/missing/opencode' });
await expect(runtime.applyOpencodeBinaryFromSettings({ strict: true })).rejects.toMatchObject({
code: 'OPENCODE_BINARY_INVALID',
message: expect.stringContaining('Configured OpenCode binary not found: /missing/opencode'),
});
});
it('throws a specific error for a configured directory without an executable CLI in strict mode', async () => {
const dir = createTempDir('openchamber-opencode-dir-');
const { runtime } = createRuntime({ opencodeBinary: dir });
await expect(runtime.applyOpencodeBinaryFromSettings({ strict: true })).rejects.toMatchObject({
code: 'OPENCODE_BINARY_INVALID',
message: expect.stringContaining('Configured OpenCode binary directory does not contain an executable'),
});
});
it('applies a valid configured executable OpenCode binary', async () => {
const dir = createTempDir('openchamber-opencode-bin-');
const binary = path.join(dir, 'opencode');
fs.writeFileSync(binary, '#!/bin/sh\nexit 0\n');
fs.chmodSync(binary, 0o755);
const { runtime, state } = createRuntime({ opencodeBinary: binary });
await expect(runtime.applyOpencodeBinaryFromSettings({ strict: true })).resolves.toBe(binary);
expect(process.env.OPENCODE_BINARY).toBe(binary);
expect(state.resolvedOpencodeBinary).toBe(binary);
expect(state.resolvedOpencodeBinarySource).toBe('settings');
});
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-');
const pathBinary = path.join(pathDir, process.platform === 'win32' ? 'opencode.exe' : 'opencode');
fs.writeFileSync(bundledBinary, '#!/bin/sh\nexit 0\n');
fs.writeFileSync(pathBinary, '#!/bin/sh\nexit 0\n');
if (process.platform !== 'win32') {
fs.chmodSync(bundledBinary, 0o755);
fs.chmodSync(pathBinary, 0o755);
}
process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR = bundledDir;
process.env.PATH = pathDir;
delete process.env.OPENCODE_BINARY;
const { runtime, state } = createRuntime({});
expect(runtime.resolveOpencodeCliPath()).toBe(pathBinary);
expect(state.resolvedOpencodeBinarySource).toBe('path');
});
it('keeps explicit OpenCode binary ahead of bundled CLI', () => {
const bundledDir = createTempDir('openchamber-bundled-opencode-');
const bundledBinary = path.join(bundledDir, process.platform === 'win32' ? 'opencode.exe' : 'opencode');
const explicitDir = createTempDir('openchamber-explicit-opencode-');
const explicitBinary = path.join(explicitDir, process.platform === 'win32' ? 'opencode.exe' : 'opencode');
fs.writeFileSync(bundledBinary, '#!/bin/sh\nexit 0\n');
fs.writeFileSync(explicitBinary, '#!/bin/sh\nexit 0\n');
if (process.platform !== 'win32') {
fs.chmodSync(bundledBinary, 0o755);
fs.chmodSync(explicitBinary, 0o755);
}
process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR = bundledDir;
process.env.OPENCODE_BINARY = explicitBinary;
const { runtime, state } = createRuntime({});
expect(runtime.resolveOpencodeCliPath()).toBe(explicitBinary);
expect(state.resolvedOpencodeBinarySource).toBe('env');
});
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');
fs.mkdirSync(bundledDir, { recursive: true });
fs.writeFileSync(bundledBinary, '#!/bin/sh\nexit 0\n');
if (process.platform !== 'win32') {
fs.chmodSync(bundledBinary, 0o755);
}
Object.defineProperty(process, 'resourcesPath', {
configurable: true,
value: resourcesPath,
});
process.env.PATH = createTempDir('openchamber-empty-path-');
delete process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR;
delete process.env.OPENCODE_BINARY;
// 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');
});
itIf(process.platform === 'darwin')('rejects known macOS OpenCode app bundle executable paths', async () => {
const { runtime } = createRuntime({ opencodeBinary: '/Applications/OpenCode.app/Contents/MacOS/OpenCode' });
await expect(runtime.applyOpencodeBinaryFromSettings({ strict: true })).rejects.toMatchObject({
code: 'OPENCODE_BINARY_INVALID',
message: expect.stringContaining('macOS desktop app bundle'),
});
});
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-');
process.env.PATH = dir;
process.env.SystemRoot = dir;
process.env.WSL_BINARY = path.join(dir, 'missing-wsl.exe');
process.env.OPENCHAMBER_WSL_BINARY = path.join(dir, 'missing-openchamber-wsl.exe');
const { runtime } = createRuntime({ opencodeBinary: 'wsl:/usr/local/bin/opencode' });
await expect(runtime.applyOpencodeBinaryFromSettings({ strict: true })).rejects.toMatchObject({
message: expect.stringContaining('uses WSL'),
});
});
it('does not auto-detect OpenCode from WSL fallback paths', () => {
setPlatform('win32');
const dir = createTempDir('openchamber-wsl-opencode-');
const wslBinary = path.join(dir, 'wsl.exe');
fs.writeFileSync(wslBinary, '');
process.env.PATH = dir;
process.env.SystemRoot = dir;
process.env.WSL_BINARY = wslBinary;
delete process.env.OPENCODE_BINARY;
const calls = [];
const spawnSyncMock = (command, args) => {
calls.push({ command, args });
if (command === 'where') {
return { status: 1, stdout: '', stderr: '' };
}
if (command === wslBinary) {
return { status: 0, stdout: '/home/alice/.opencode/bin/opencode\n', stderr: '' };
}
return { status: 1, stdout: '', stderr: '' };
};
const { runtime, state } = createRuntime({}, { spawnSync: spawnSyncMock });
expect(runtime.resolveOpencodeCliPath()).toBeNull();
expect(state.useWslForOpencode).toBe(false);
expect(state.resolvedWslBinary).toBeNull();
expect(state.resolvedWslOpencodePath).toBeNull();
expect(state.resolvedOpencodeBinarySource).toBeNull();
const wslCall = calls.find((call) => call.command === wslBinary);
expect(wslCall).toBeUndefined();
});
it('launches Windows cmd shims through cmd call without embedded quotes', () => {
setPlatform('win32');
process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe';
const dir = createTempDir('openchamber-opencode-cmd-');
const shim = path.join(dir, 'opencode.cmd');
fs.writeFileSync(shim, '@echo off\r\nexit /b 0\r\n');
const { runtime } = createRuntime({});
expect(runtime.resolveManagedOpenCodeLaunchSpec(shim)).toEqual({
binary: 'C:\\Windows\\System32\\cmd.exe',
args: ['/d', '/s', '/c', 'call', shim],
wrapperType: 'cmd-wrapper',
});
});
it('resolves npm OpenCode cmd shims to the packaged Windows executable', () => {
setPlatform('win32');
const npmDir = createTempDir('openchamber-opencode-npm-');
const shim = path.join(npmDir, 'opencode.cmd');
const nativeBinary = path.join(npmDir, 'node_modules', 'opencode-ai', 'bin', 'opencode.exe');
fs.mkdirSync(path.dirname(nativeBinary), { recursive: true });
fs.writeFileSync(nativeBinary, '');
fs.writeFileSync(shim, '@ECHO off\r\n"%dp0%\\node_modules\\opencode-ai\\bin\\opencode.exe" %*\r\n');
const { runtime } = createRuntime({});
expect(runtime.resolveManagedOpenCodeLaunchSpec(shim)).toEqual({
binary: nativeBinary,
args: [],
wrapperType: 'native-wrapper',
});
});
});