fix: reject OpenCode desktop app as CLI
This commit is contained in:
@@ -196,10 +196,30 @@ function isMacOpenCodeAppBundlePath(candidate: string): boolean {
|
|||||||
return process.platform === 'darwin' && /\/OpenCode\.app\/Contents\/MacOS\/(?:OpenCode|opencode-cli)$/i.test(candidate);
|
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 {
|
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.';
|
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)) {
|
if (isKnownOpenCodeDesktopAppPath(raw) || isKnownOpenCodeDesktopAppPath(normalized)) {
|
||||||
return new Error(`Configured OpenCode binary points at the macOS desktop app bundle, not the CLI: ${normalized}. ${messageSuffix}`);
|
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 {
|
try {
|
||||||
@@ -254,7 +274,7 @@ function validateConfiguredOpencodeBinaryForManagedStart(): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isExecutable(normalized) && !isMacOpenCodeAppBundlePath(normalized)) {
|
if (isExecutable(normalized) && !isKnownOpenCodeDesktopAppPath(normalized)) {
|
||||||
return 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;
|
return configured;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,7 +308,7 @@ function resolveOpencodeCliPath(): string | null {
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
if (sharedFromOpenChamber && isExecutable(sharedFromOpenChamber) && !isMacOpenCodeAppBundlePath(sharedFromOpenChamber)) {
|
if (sharedFromOpenChamber && isExecutable(sharedFromOpenChamber) && !isKnownOpenCodeDesktopAppPath(sharedFromOpenChamber)) {
|
||||||
return sharedFromOpenChamber;
|
return sharedFromOpenChamber;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,13 +322,13 @@ function resolveOpencodeCliPath(): string | null {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
for (const candidate of explicit) {
|
for (const candidate of explicit) {
|
||||||
if (isExecutable(candidate)) {
|
if (isExecutable(candidate) && !isKnownOpenCodeDesktopAppPath(candidate)) {
|
||||||
return candidate;
|
return candidate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cachedDetectedOpencodeCliPath) {
|
if (cachedDetectedOpencodeCliPath) {
|
||||||
if (isExecutable(cachedDetectedOpencodeCliPath)) {
|
if (isExecutable(cachedDetectedOpencodeCliPath) && !isKnownOpenCodeDesktopAppPath(cachedDetectedOpencodeCliPath)) {
|
||||||
return cachedDetectedOpencodeCliPath;
|
return cachedDetectedOpencodeCliPath;
|
||||||
}
|
}
|
||||||
cachedDetectedOpencodeCliPath = undefined;
|
cachedDetectedOpencodeCliPath = undefined;
|
||||||
@@ -327,7 +347,6 @@ function resolveOpencodeCliPath(): string | null {
|
|||||||
const winFallbacks = (() => {
|
const winFallbacks = (() => {
|
||||||
const userProfile = process.env.USERPROFILE || home;
|
const userProfile = process.env.USERPROFILE || home;
|
||||||
const appData = process.env.APPDATA || path.join(userProfile, 'AppData', 'Roaming');
|
const appData = process.env.APPDATA || path.join(userProfile, 'AppData', 'Roaming');
|
||||||
const localAppData = process.env.LOCALAPPDATA || '';
|
|
||||||
const programData = process.env.ProgramData || 'C:\\ProgramData';
|
const programData = process.env.ProgramData || 'C:\\ProgramData';
|
||||||
const npmDir = path.join(appData, 'npm');
|
const npmDir = path.join(appData, 'npm');
|
||||||
|
|
||||||
@@ -344,14 +363,12 @@ function resolveOpencodeCliPath(): string | null {
|
|||||||
// Bun global install
|
// Bun global install
|
||||||
path.join(userProfile, '.bun', 'bin', 'opencode.exe'),
|
path.join(userProfile, '.bun', 'bin', 'opencode.exe'),
|
||||||
path.join(userProfile, '.bun', 'bin', 'opencode.cmd'),
|
path.join(userProfile, '.bun', 'bin', 'opencode.cmd'),
|
||||||
// Some installers use LocalAppData
|
|
||||||
localAppData ? path.join(localAppData, 'Programs', 'opencode', 'opencode.exe') : '',
|
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
if (process.platform !== 'win32') {
|
if (process.platform !== 'win32') {
|
||||||
const fromPath = findExecutableInPath('opencode');
|
const fromPath = findExecutableInPath('opencode');
|
||||||
if (fromPath) {
|
if (fromPath && !isKnownOpenCodeDesktopAppPath(fromPath)) {
|
||||||
cachedDetectedOpencodeCliPath = fromPath;
|
cachedDetectedOpencodeCliPath = fromPath;
|
||||||
return fromPath;
|
return fromPath;
|
||||||
}
|
}
|
||||||
@@ -359,7 +376,7 @@ function resolveOpencodeCliPath(): string | null {
|
|||||||
|
|
||||||
const fallbacks = process.platform === 'win32' ? winFallbacks : unixFallbacks;
|
const fallbacks = process.platform === 'win32' ? winFallbacks : unixFallbacks;
|
||||||
for (const candidate of fallbacks) {
|
for (const candidate of fallbacks) {
|
||||||
if (isExecutable(candidate)) {
|
if (isExecutable(candidate) && !isKnownOpenCodeDesktopAppPath(candidate)) {
|
||||||
cachedDetectedOpencodeCliPath = candidate;
|
cachedDetectedOpencodeCliPath = candidate;
|
||||||
return candidate;
|
return candidate;
|
||||||
}
|
}
|
||||||
@@ -367,7 +384,7 @@ function resolveOpencodeCliPath(): string | null {
|
|||||||
|
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
const fromPath = findExecutableInPath('opencode');
|
const fromPath = findExecutableInPath('opencode');
|
||||||
if (fromPath) {
|
if (fromPath && !isKnownOpenCodeDesktopAppPath(fromPath)) {
|
||||||
cachedDetectedOpencodeCliPath = fromPath;
|
cachedDetectedOpencodeCliPath = fromPath;
|
||||||
return fromPath;
|
return fromPath;
|
||||||
}
|
}
|
||||||
@@ -382,7 +399,7 @@ function resolveOpencodeCliPath(): string | null {
|
|||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const found = lines.find((line) => isExecutable(line));
|
const found = lines.find((line) => isExecutable(line) && !isKnownOpenCodeDesktopAppPath(line));
|
||||||
if (found) {
|
if (found) {
|
||||||
cachedDetectedOpencodeCliPath = found;
|
cachedDetectedOpencodeCliPath = found;
|
||||||
return found;
|
return found;
|
||||||
|
|||||||
@@ -260,6 +260,20 @@ export const createOpenCodeEnvRuntime = (deps) => {
|
|||||||
return /(^|[\\/])wsl(\.exe)?$/i.test(trimmed);
|
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 = () => {
|
const clearWslOpencodeResolution = () => {
|
||||||
state.useWslForOpencode = false;
|
state.useWslForOpencode = false;
|
||||||
state.resolvedWslBinary = null;
|
state.resolvedWslBinary = null;
|
||||||
@@ -278,7 +292,7 @@ export const createOpenCodeEnvRuntime = (deps) => {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
for (const candidate of explicit) {
|
for (const candidate of explicit) {
|
||||||
if (isExecutable(candidate)) {
|
if (isExecutable(candidate) && !isWindowsOpenCodeDesktopAppPath(candidate)) {
|
||||||
clearWslOpencodeResolution();
|
clearWslOpencodeResolution();
|
||||||
state.resolvedOpencodeBinarySource = 'env';
|
state.resolvedOpencodeBinarySource = 'env';
|
||||||
return candidate;
|
return candidate;
|
||||||
@@ -319,7 +333,6 @@ export const createOpenCodeEnvRuntime = (deps) => {
|
|||||||
path.join(programData, 'chocolatey', 'bin', 'opencode.cmd'),
|
path.join(programData, 'chocolatey', 'bin', 'opencode.cmd'),
|
||||||
path.join(userProfile, '.bun', 'bin', 'opencode.exe'),
|
path.join(userProfile, '.bun', 'bin', 'opencode.exe'),
|
||||||
path.join(userProfile, '.bun', 'bin', 'opencode.cmd'),
|
path.join(userProfile, '.bun', 'bin', 'opencode.cmd'),
|
||||||
localAppData ? path.join(localAppData, 'Programs', 'opencode', 'opencode.exe') : '',
|
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -344,7 +357,7 @@ export const createOpenCodeEnvRuntime = (deps) => {
|
|||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const found = lines.find((line) => isExecutable(line));
|
const found = lines.find((line) => isExecutable(line) && !isWindowsOpenCodeDesktopAppPath(line));
|
||||||
if (found) {
|
if (found) {
|
||||||
clearWslOpencodeResolution();
|
clearWslOpencodeResolution();
|
||||||
state.resolvedOpencodeBinarySource = 'where';
|
state.resolvedOpencodeBinarySource = 'where';
|
||||||
@@ -824,13 +837,17 @@ export const createOpenCodeEnvRuntime = (deps) => {
|
|||||||
return /\/OpenCode\.app\/Contents\/MacOS\/(?:OpenCode|opencode-cli)$/i.test(candidate);
|
return /\/OpenCode\.app\/Contents\/MacOS\/(?:OpenCode|opencode-cli)$/i.test(candidate);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isKnownOpenCodeDesktopAppPath = (candidate) => isMacOpenCodeAppBundlePath(candidate)
|
||||||
|
|| isWindowsOpenCodeDesktopAppPath(candidate);
|
||||||
|
|
||||||
const createConfiguredOpencodeBinaryError = (raw, normalized) => {
|
const createConfiguredOpencodeBinaryError = (raw, normalized) => {
|
||||||
const configured = typeof raw === 'string' ? raw.trim() : '';
|
const configured = typeof raw === 'string' ? raw.trim() : '';
|
||||||
const candidate = typeof normalized === 'string' && normalized.trim().length > 0 ? normalized.trim() : configured;
|
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 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 = (() => {
|
const error = (() => {
|
||||||
if (isMacOpenCodeAppBundlePath(candidate) || isMacOpenCodeAppBundlePath(configured)) {
|
if (isKnownOpenCodeDesktopAppPath(candidate) || isKnownOpenCodeDesktopAppPath(configured)) {
|
||||||
return new Error(`Configured OpenCode binary points at the macOS desktop app bundle, not the CLI: ${candidate}. ${messageSuffix}`);
|
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 {
|
try {
|
||||||
@@ -927,7 +944,7 @@ export const createOpenCodeEnvRuntime = (deps) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalized && isExecutable(normalized) && !isMacOpenCodeAppBundlePath(normalized)) {
|
if (normalized && isExecutable(normalized) && !isKnownOpenCodeDesktopAppPath(normalized)) {
|
||||||
clearWslOpencodeResolution();
|
clearWslOpencodeResolution();
|
||||||
process.env.OPENCODE_BINARY = normalized;
|
process.env.OPENCODE_BINARY = normalized;
|
||||||
prependToPath(path.dirname(normalized));
|
prependToPath(path.dirname(normalized));
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { createOpenCodeEnvRuntime } from './env-runtime.js';
|
|||||||
const originalOpencodeBinary = process.env.OPENCODE_BINARY;
|
const originalOpencodeBinary = process.env.OPENCODE_BINARY;
|
||||||
const originalComSpec = process.env.ComSpec;
|
const originalComSpec = process.env.ComSpec;
|
||||||
const originalPath = process.env.PATH;
|
const originalPath = process.env.PATH;
|
||||||
|
const originalLocalAppData = process.env.LOCALAPPDATA;
|
||||||
const originalSystemRoot = process.env.SystemRoot;
|
const originalSystemRoot = process.env.SystemRoot;
|
||||||
const originalWslBinary = process.env.WSL_BINARY;
|
const originalWslBinary = process.env.WSL_BINARY;
|
||||||
const originalOpenChamberWslBinary = process.env.OPENCHAMBER_WSL_BINARY;
|
const originalOpenChamberWslBinary = process.env.OPENCHAMBER_WSL_BINARY;
|
||||||
@@ -59,6 +60,12 @@ afterEach(() => {
|
|||||||
delete process.env.SystemRoot;
|
delete process.env.SystemRoot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof originalLocalAppData === 'string') {
|
||||||
|
process.env.LOCALAPPDATA = originalLocalAppData;
|
||||||
|
} else {
|
||||||
|
delete process.env.LOCALAPPDATA;
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof originalWslBinary === 'string') {
|
if (typeof originalWslBinary === 'string') {
|
||||||
process.env.WSL_BINARY = originalWslBinary;
|
process.env.WSL_BINARY = originalWslBinary;
|
||||||
} else {
|
} 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 () => {
|
it('rejects WSL settings in strict mode', async () => {
|
||||||
setPlatform('win32');
|
setPlatform('win32');
|
||||||
const dir = createTempDir('openchamber-no-wsl-');
|
const dir = createTempDir('openchamber-no-wsl-');
|
||||||
|
|||||||
Reference in New Issue
Block a user