fix(opencode): detect nvm runtime installs

This commit is contained in:
777genius 2026-05-21 13:38:40 +03:00
parent 8511d6af6e
commit ec7b1dbd18
5 changed files with 469 additions and 39 deletions

View file

@ -2,11 +2,15 @@ import { execCli } from '@main/utils/childProcess';
import { buildMergedCliPath } from '@main/utils/cliPathMerge';
import { getAppDataPath } from '@main/utils/pathDecoder';
import { safeSendToRenderer } from '@main/utils/safeWebContentsSend';
import { getCachedShellEnv, resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv';
import {
getCachedShellEnv,
getShellPreferredHome,
resolveInteractiveShellEnvBestEffort,
} from '@main/utils/shellEnv';
import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger';
import { createHash, randomUUID } from 'crypto';
import { existsSync, promises as fsp, readFileSync, statSync } from 'fs';
import { existsSync, promises as fsp, readdirSync, readFileSync, statSync } from 'fs';
import path from 'path';
import { gunzipSync } from 'zlib';
@ -136,17 +140,32 @@ function splitPathEnv(pathValue: string | undefined): string[] {
.filter(Boolean);
}
function resolvePathOpenCodeBinary(
additionalEnvSources: (NodeJS.ProcessEnv | null | undefined)[] = []
): string | null {
function collectPathOpenCodeBinaryCandidates(
additionalEnvSources: (NodeJS.ProcessEnv | null | undefined)[] = [],
options: { includeFallbackPathEntries?: boolean } = {}
): string[] {
const shellEnv = getCachedShellEnv() ?? {};
const pathEntries = [
const directPathEntries = [
...additionalEnvSources.flatMap((env) => splitPathEnv(env?.PATH)),
...splitPathEnv(shellEnv.PATH),
...splitPathEnv(buildMergedCliPath()),
...splitPathEnv(process.env.PATH),
];
const fallbackPathEntries =
options.includeFallbackPathEntries === false
? []
: [...splitPathEnv(buildMergedCliPath()), ...splitPathEnv(process.env.PATH)];
const seen = new Set<string>();
return [
...collectOpenCodeBinariesFromPathEntries(directPathEntries, seen),
...collectNvmOpenCodeBinaryCandidates().filter(isAbsoluteExistingFile),
...collectOpenCodeBinariesFromPathEntries(fallbackPathEntries, seen),
];
}
function collectOpenCodeBinariesFromPathEntries(
pathEntries: string[],
seen: Set<string>
): string[] {
const results: string[] = [];
for (const entry of pathEntries) {
const normalizedEntry = path.resolve(entry);
if (seen.has(normalizedEntry)) {
@ -156,17 +175,56 @@ function resolvePathOpenCodeBinary(
for (const executableName of getPathExecutableNames()) {
const candidate = path.join(normalizedEntry, executableName);
if (isAbsoluteExistingFile(candidate)) {
return candidate;
results.push(candidate);
}
}
}
return null;
return results;
}
function collectNvmOpenCodeBinaryCandidates(): string[] {
if (process.platform === 'win32') {
const appdata = process.env.APPDATA;
if (!appdata) {
return [];
}
return collectVersionedOpenCodeBinaryCandidates(path.join(appdata, 'nvm'));
}
return collectVersionedOpenCodeBinaryCandidates(
path.join(getShellPreferredHome(), '.nvm', 'versions', 'node'),
'bin'
);
}
function collectVersionedOpenCodeBinaryCandidates(rootPath: string, binSegment = ''): string[] {
let versions: string[];
try {
versions = readdirSync(rootPath);
} catch {
return [];
}
return versions
.toSorted((left, right) => right.localeCompare(left, undefined, { numeric: true }))
.flatMap((version) => {
const versionPath = binSegment
? path.join(rootPath, version, binSegment)
: path.join(rootPath, version);
return getPathExecutableNames().map((executableName) =>
path.join(versionPath, executableName)
);
});
}
type OpenCodeBinaryVersionProbe =
| { ok: true; version: string | null }
| { ok: false; error: string };
type VerifiedOpenCodeBinaryProbe =
| { ok: true; binaryPath: string; version: string | null }
| { ok: false; firstFailure: { binaryPath: string; error: string } | null };
async function probeOpenCodeBinaryVersion(binaryPath: string): Promise<OpenCodeBinaryVersionProbe> {
try {
const { stdout } = await execCli(binaryPath, ['--version'], {
@ -179,30 +237,79 @@ async function probeOpenCodeBinaryVersion(binaryPath: string): Promise<OpenCodeB
}
}
async function resolvePathOpenCodeBinaryWithBestEffortEnv(
options: { shellEnvTimeoutMs?: number } = {}
): Promise<string | null> {
const cachedCandidate = resolvePathOpenCodeBinary();
if (cachedCandidate) {
return cachedCandidate;
function normalizeBinaryCandidateForCompare(binaryPath: string): string {
const normalized = path.resolve(binaryPath);
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
}
async function probeFirstWorkingOpenCodeBinaryCandidate(
candidates: string[],
seen: Set<string>,
firstFailure: { binaryPath: string; error: string } | null
): Promise<VerifiedOpenCodeBinaryProbe> {
let nextFirstFailure = firstFailure;
for (const binaryPath of candidates) {
const normalized = normalizeBinaryCandidateForCompare(binaryPath);
if (seen.has(normalized)) {
continue;
}
seen.add(normalized);
const version = await probeOpenCodeBinaryVersion(binaryPath);
if (version.ok) {
return { ok: true, binaryPath, version: version.version };
}
nextFirstFailure ??= { binaryPath, error: version.error };
}
return { ok: false, firstFailure: nextFirstFailure };
}
async function probeFirstWorkingPathOpenCodeBinary(
options: { shellEnvTimeoutMs?: number } = {}
): Promise<VerifiedOpenCodeBinaryProbe> {
const seenCandidates = new Set<string>();
let firstFailure: { binaryPath: string; error: string } | null = null;
const cachedProbe = await probeFirstWorkingOpenCodeBinaryCandidate(
collectPathOpenCodeBinaryCandidates([], {
includeFallbackPathEntries: false,
}),
seenCandidates,
firstFailure
);
if (cachedProbe.ok) {
return cachedProbe;
}
firstFailure = cachedProbe.firstFailure;
const shellEnv = await resolveInteractiveShellEnvBestEffort({
timeoutMs: options.shellEnvTimeoutMs ?? PATH_SHELL_ENV_TIMEOUT_MS,
fallbackEnv: process.env,
});
return resolvePathOpenCodeBinary([shellEnv]);
const shellProbe = await probeFirstWorkingOpenCodeBinaryCandidate(
collectPathOpenCodeBinaryCandidates([shellEnv], {
includeFallbackPathEntries: false,
}),
seenCandidates,
firstFailure
);
if (shellProbe.ok) {
return shellProbe;
}
firstFailure = shellProbe.firstFailure;
return probeFirstWorkingOpenCodeBinaryCandidate(
collectPathOpenCodeBinaryCandidates([shellEnv]),
seenCandidates,
firstFailure
);
}
async function resolveVerifiedPathOpenCodeBinaryPath(
options: { shellEnvTimeoutMs?: number } = {}
): Promise<string | null> {
const binaryPath = await resolvePathOpenCodeBinaryWithBestEffortEnv(options);
if (!binaryPath) {
return null;
}
return (await probeOpenCodeBinaryVersion(binaryPath)).ok ? binaryPath : null;
const result = await probeFirstWorkingPathOpenCodeBinary(options);
return result.ok ? result.binaryPath : null;
}
export async function resolveVerifiedOpenCodeRuntimeBinaryPath(
@ -526,26 +633,25 @@ export class OpenCodeRuntimeInstallerService {
}
private async getPathStatus(): Promise<OpenCodeRuntimeStatus> {
const binaryPath = await resolvePathOpenCodeBinaryWithBestEffortEnv();
if (!binaryPath) {
return { installed: false, source: 'missing', state: 'idle' };
}
const version = await probeOpenCodeBinaryVersion(binaryPath);
if (!version.ok) {
const result = await probeFirstWorkingPathOpenCodeBinary();
if (result.ok) {
return {
installed: false,
binaryPath,
installed: true,
binaryPath: result.binaryPath,
version: result.version ?? undefined,
source: 'path',
state: 'failed',
error: version.error,
state: 'ready',
};
}
if (!result.firstFailure) {
return { installed: false, source: 'missing', state: 'idle' };
}
return {
installed: true,
binaryPath,
version: version.version ?? undefined,
installed: false,
binaryPath: result.firstFailure.binaryPath,
source: 'path',
state: 'ready',
state: 'failed',
error: result.firstFailure.error,
};
}

View file

@ -134,6 +134,10 @@ export class OpenCodeBehaviorSourceScanner {
kind: 'global_config',
targetPath: path.join(this.homePath, '.config/opencode/opencode.json'),
},
{
kind: 'global_config',
targetPath: path.join(this.homePath, '.config/opencode/opencode.jsonc'),
},
{ kind: 'project_config', targetPath: path.join(projectPath, 'opencode.json') },
{ kind: 'project_config', targetPath: path.join(projectPath, 'opencode.jsonc') },
{
@ -150,6 +154,7 @@ export class OpenCodeBehaviorSourceScanner {
async readDeclaredMcpNames(projectPath: string): Promise<Set<string>> {
const configPaths = [
path.join(this.homePath, '.config/opencode/opencode.json'),
path.join(this.homePath, '.config/opencode/opencode.jsonc'),
path.join(projectPath, 'opencode.json'),
path.join(projectPath, 'opencode.jsonc'),
path.join(projectPath, '.opencode/opencode.json'),

View file

@ -8,6 +8,7 @@ import { gzipSync } from 'zlib';
const execCliMock = vi.hoisted(() => vi.fn());
const buildMergedCliPathMock = vi.hoisted(() => vi.fn());
const getCachedShellEnvMock = vi.hoisted(() => vi.fn());
const getShellPreferredHomeMock = vi.hoisted(() => vi.fn());
const resolveInteractiveShellEnvBestEffortMock = vi.hoisted(() => vi.fn());
vi.mock('@main/utils/childProcess', () => ({
@ -20,6 +21,7 @@ vi.mock('@main/utils/cliPathMerge', () => ({
vi.mock('@main/utils/shellEnv', () => ({
getCachedShellEnv: () => getCachedShellEnvMock(),
getShellPreferredHome: () => getShellPreferredHomeMock(),
resolveInteractiveShellEnvBestEffort: (
...args: Parameters<typeof resolveInteractiveShellEnvBestEffortMock>
) => resolveInteractiveShellEnvBestEffortMock(...args),
@ -88,6 +90,8 @@ describe('OpenCodeRuntimeInstallerService resolver', () => {
buildMergedCliPathMock.mockReturnValue('');
getCachedShellEnvMock.mockReset();
getCachedShellEnvMock.mockReturnValue(null);
getShellPreferredHomeMock.mockReset();
getShellPreferredHomeMock.mockReturnValue(os.homedir());
resolveInteractiveShellEnvBestEffortMock.mockReset();
resolveInteractiveShellEnvBestEffortMock.mockResolvedValue(process.env);
});
@ -218,12 +222,53 @@ describe('OpenCodeRuntimeInstallerService resolver', () => {
});
});
it('returns a verified OpenCode binary from the merged CLI PATH without interactive shell env resolution', async () => {
it('returns a verified OpenCode binary from the merged CLI PATH after zero-wait shell fallback', async () => {
const binaryPath = path.join(tempRoot!, 'merged-cli-path', 'bin', 'opencode');
await mkdir(path.dirname(binaryPath), { recursive: true });
await writeFile(binaryPath, 'binary', { mode: 0o755 });
buildMergedCliPathMock.mockReturnValue(path.dirname(binaryPath));
await expect(resolveVerifiedOpenCodeRuntimeBinaryPath({ shellEnvTimeoutMs: 0 })).resolves.toBe(
binaryPath
);
expect(resolveInteractiveShellEnvBestEffortMock).toHaveBeenCalledWith(
expect.objectContaining({
timeoutMs: 0,
fallbackEnv: process.env,
})
);
expect(execCliMock).toHaveBeenCalledWith(binaryPath, ['--version'], {
timeout: 10_000,
windowsHide: true,
});
});
it('returns a verified OpenCode binary from nvm when desktop PATH misses npm globals', async () => {
const olderBinaryPath = path.join(
tempRoot!,
'.nvm',
'versions',
'node',
'v20.10.0',
'bin',
'opencode'
);
const binaryPath = path.join(
tempRoot!,
'.nvm',
'versions',
'node',
'v22.22.1',
'bin',
'opencode'
);
await mkdir(path.dirname(olderBinaryPath), { recursive: true });
await mkdir(path.dirname(binaryPath), { recursive: true });
await writeFile(olderBinaryPath, 'older binary', { mode: 0o755 });
await writeFile(binaryPath, 'binary', { mode: 0o755 });
getCachedShellEnvMock.mockReturnValue({ HOME: tempRoot! });
getShellPreferredHomeMock.mockReturnValue(tempRoot!);
await expect(resolveVerifiedOpenCodeRuntimeBinaryPath({ shellEnvTimeoutMs: 0 })).resolves.toBe(
binaryPath
);
@ -234,6 +279,130 @@ describe('OpenCodeRuntimeInstallerService resolver', () => {
});
});
it('returns a verified OpenCode cmd shim from nvm-windows when desktop PATH misses npm globals', async () => {
const originalPlatform = process.platform;
const originalAppData = process.env.APPDATA;
try {
Object.defineProperty(process, 'platform', {
value: 'win32',
configurable: true,
writable: true,
});
process.env.APPDATA = tempRoot!;
const olderBinaryPath = path.join(tempRoot!, 'nvm', 'v20.10.0', 'opencode.cmd');
const binaryPath = path.join(tempRoot!, 'nvm', 'v22.22.1', 'opencode.cmd');
await mkdir(path.dirname(olderBinaryPath), { recursive: true });
await mkdir(path.dirname(binaryPath), { recursive: true });
await writeFile(olderBinaryPath, 'older binary', { mode: 0o755 });
await writeFile(binaryPath, 'binary', { mode: 0o755 });
await expect(
resolveVerifiedOpenCodeRuntimeBinaryPath({ shellEnvTimeoutMs: 0 })
).resolves.toBe(binaryPath);
expect(resolveInteractiveShellEnvBestEffortMock).not.toHaveBeenCalled();
expect(execCliMock).toHaveBeenCalledWith(binaryPath, ['--version'], {
timeout: 10_000,
windowsHide: true,
});
} finally {
Object.defineProperty(process, 'platform', {
value: originalPlatform,
configurable: true,
writable: true,
});
if (originalAppData === undefined) {
delete process.env.APPDATA;
} else {
process.env.APPDATA = originalAppData;
}
}
});
it('skips a broken newer nvm OpenCode binary and reports the next working install', async () => {
const brokenBinaryPath = path.join(
tempRoot!,
'.nvm',
'versions',
'node',
'v23.0.0',
'bin',
'opencode'
);
const workingBinaryPath = path.join(
tempRoot!,
'.nvm',
'versions',
'node',
'v22.22.1',
'bin',
'opencode'
);
await mkdir(path.dirname(brokenBinaryPath), { recursive: true });
await mkdir(path.dirname(workingBinaryPath), { recursive: true });
await writeFile(brokenBinaryPath, 'broken binary', { mode: 0o755 });
await writeFile(workingBinaryPath, 'working binary', { mode: 0o755 });
getCachedShellEnvMock.mockReturnValue({ HOME: tempRoot! });
getShellPreferredHomeMock.mockReturnValue(tempRoot!);
execCliMock.mockImplementation(async (binaryPath: string) => {
if (binaryPath === brokenBinaryPath) {
throw new Error('broken nvm runtime');
}
return { stdout: 'opencode 1.15.6\n', stderr: '' };
});
await expect(resolveVerifiedOpenCodeRuntimeBinaryPath({ shellEnvTimeoutMs: 0 })).resolves.toBe(
workingBinaryPath
);
await expect(new OpenCodeRuntimeInstallerService().getStatus()).resolves.toMatchObject({
installed: true,
source: 'path',
state: 'ready',
binaryPath: workingBinaryPath,
version: 'opencode 1.15.6',
});
});
it('falls through to shell PATH when all fast nvm candidates are broken', async () => {
const brokenBinaryPath = path.join(
tempRoot!,
'.nvm',
'versions',
'node',
'v23.0.0',
'bin',
'opencode'
);
const shellBinaryPath = path.join(tempRoot!, 'custom-npm-prefix', 'bin', 'opencode');
await mkdir(path.dirname(brokenBinaryPath), { recursive: true });
await mkdir(path.dirname(shellBinaryPath), { recursive: true });
await writeFile(brokenBinaryPath, 'broken binary', { mode: 0o755 });
await writeFile(shellBinaryPath, 'working binary', { mode: 0o755 });
getCachedShellEnvMock.mockReturnValue({ HOME: tempRoot! });
getShellPreferredHomeMock.mockReturnValue(tempRoot!);
resolveInteractiveShellEnvBestEffortMock.mockResolvedValue({
PATH: path.dirname(shellBinaryPath),
HOME: tempRoot!,
});
execCliMock.mockImplementation(async (binaryPath: string) => {
if (binaryPath === brokenBinaryPath) {
throw new Error('broken nvm runtime');
}
return { stdout: 'opencode 1.15.6\n', stderr: '' };
});
await expect(resolveVerifiedOpenCodeRuntimeBinaryPath({ shellEnvTimeoutMs: 0 })).resolves.toBe(
shellBinaryPath
);
expect(resolveInteractiveShellEnvBestEffortMock).toHaveBeenCalledWith(
expect.objectContaining({
timeoutMs: 0,
fallbackEnv: process.env,
})
);
});
it('reports PATH-installed OpenCode as installed after best-effort shell env resolution', async () => {
const binaryPath = path.join(tempRoot!, 'homebrew', 'bin', 'opencode');
await mkdir(path.dirname(binaryPath), { recursive: true });

View file

@ -0,0 +1,122 @@
// @vitest-environment node
/* eslint-disable security/detect-non-literal-fs-filename */
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
OpenCodeRuntimeInstallerService,
resolveVerifiedOpenCodeRuntimeBinaryPath,
} from '../../../../src/main/services/infrastructure/OpenCodeRuntimeInstallerService';
import { ensureOpenCodeBridgeRuntimeBinaryEnv } from '../../../../src/main/services/runtime/openCodeBridgeRuntimeEnv';
import { execCli } from '../../../../src/main/utils/childProcess';
import { setAppDataBasePath } from '../../../../src/main/utils/pathDecoder';
import { clearShellEnvCache } from '../../../../src/main/utils/shellEnv';
const describePosix = process.platform === 'win32' ? describe.skip : describe;
describePosix('OpenCode nvm runtime resolution safe e2e', () => {
let tempDir: string | null = null;
let originalHome: string | undefined;
let originalPath: string | undefined;
let originalShell: string | undefined;
beforeEach(async () => {
tempDir = await mkdtemp(path.join(os.tmpdir(), 'opencode-nvm-resolution-e2e-'));
setAppDataBasePath(path.join(tempDir, 'app-data'));
clearShellEnvCache();
originalHome = process.env.HOME;
originalPath = process.env.PATH;
originalShell = process.env.SHELL;
process.env.HOME = tempDir;
process.env.PATH = '';
process.env.SHELL = path.join(tempDir, 'missing-shell');
});
afterEach(async () => {
clearShellEnvCache();
setAppDataBasePath(null);
restoreEnvValue('HOME', originalHome);
restoreEnvValue('PATH', originalPath);
restoreEnvValue('SHELL', originalShell);
if (tempDir) {
await rm(tempDir, { recursive: true, force: true });
tempDir = null;
}
});
it('reports and launches an npm global OpenCode binary installed under nvm when GUI PATH is empty', async () => {
await createFakeNvmOpenCodeBinary('v23.0.0', { broken: true });
const binaryPath = await createFakeNvmOpenCodeBinary('v22.22.1');
const binDir = path.dirname(binaryPath);
await expect(resolveVerifiedOpenCodeRuntimeBinaryPath({ shellEnvTimeoutMs: 0 })).resolves.toBe(
binaryPath
);
await expect(new OpenCodeRuntimeInstallerService().getStatus()).resolves.toMatchObject({
installed: true,
source: 'path',
state: 'ready',
binaryPath,
version: 'opencode 1.15.6',
});
const bridgeEnv: NodeJS.ProcessEnv = { PATH: '' };
await ensureOpenCodeBridgeRuntimeBinaryEnv({
targetEnv: bridgeEnv,
bridgeEnv,
resolveVerifiedOpenCodeRuntimeBinaryPath,
});
expect(bridgeEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(binaryPath);
expect(bridgeEnv.OPENCODE_BIN_PATH).toBe(binaryPath);
expect(bridgeEnv.PATH?.split(path.delimiter)[0]).toBe(binDir);
const version = await execCli('opencode', ['--version'], {
env: bridgeEnv,
timeout: 2_000,
windowsHide: true,
});
expect(version.stdout.trim()).toBe('opencode 1.15.6');
});
async function createFakeNvmOpenCodeBinary(
version: string,
options: { broken?: boolean } = {}
): Promise<string> {
const binDir = path.join(tempDir!, '.nvm', 'versions', 'node', version, 'bin');
const binaryPath = path.join(binDir, 'opencode');
await mkdir(binDir, { recursive: true });
await writeFile(
binaryPath,
options.broken
? ['#!/bin/sh', 'echo "broken opencode" >&2', 'exit 2'].join('\n')
: [
'#!/bin/sh',
'if [ "$1" = "--version" ]; then',
' echo "opencode 1.15.6"',
' exit 0',
'fi',
'echo "unexpected opencode args: $*" >&2',
'exit 2',
].join('\n'),
'utf8'
);
await chmod(binaryPath, 0o755);
return binaryPath;
}
});
function restoreEnvValue(name: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[name];
} else {
process.env[name] = value;
}
}

View file

@ -1,7 +1,6 @@
import { promises as fs } from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
@ -154,6 +153,35 @@ describe('OpenCodeManagedOverlay', () => {
);
});
it('reads JSONC global config as a user-owned behavior source', async () => {
await fs.mkdir(path.join(homePath, '.config/opencode'), { recursive: true });
await fs.writeFile(
path.join(homePath, '.config/opencode/opencode.jsonc'),
`{
// global user-owned MCP must be observed, not overwritten
"mcp": {
"agent-teams": { "type": "local", "command": "custom", "enabled": true }
}
}`,
'utf8'
);
const scanner = new OpenCodeBehaviorSourceScanner({ homePath });
await expect(scanner.readDeclaredMcpNames(projectPath)).resolves.toEqual(
new Set(['agent-teams'])
);
const sources = await scanner.scan(projectPath);
expect(sources).toContainEqual(
expect.objectContaining({
kind: 'global_config',
exists: true,
fileCount: 1,
fingerprint: expect.stringMatching(/^[a-f0-9]{64}$/),
})
);
});
it('rejects managed overlays that would shadow user behavior keys', () => {
expect(() =>
assertManagedOverlayDoesNotShadowUserConfig({