fix: preserve user PATH for managed opencode

This commit is contained in:
Bohdan Triapitsyn
2026-04-25 20:44:07 +03:00
parent d40041fccd
commit 836d1b1b3b
7 changed files with 318 additions and 49 deletions
+17 -18
View File
@@ -162,24 +162,19 @@ export const createOpenCodeEnvRuntime = (deps) => {
return null;
};
const mergePathValues = (preferred, fallback) => {
const merged = new Set();
const pathLooksUserConfigured = (value) => {
if (typeof value !== 'string' || !value) {
return false;
}
const addSegments = (value) => {
if (typeof value !== 'string' || !value) {
return;
}
for (const segment of value.split(path.delimiter)) {
if (segment) {
merged.add(segment);
}
}
};
addSegments(preferred);
addSegments(fallback);
return Array.from(merged).join(path.delimiter);
const home = os.homedir();
return value.split(path.delimiter).some((segment) => (
segment.startsWith(home + path.sep)
|| segment === home
|| segment.startsWith('/opt/homebrew/')
|| segment.startsWith('/opt/pkg/')
|| segment.startsWith('/opt/pmk/')
));
};
const applyLoginShellEnvSnapshot = () => {
@@ -200,7 +195,11 @@ export const createOpenCodeEnvRuntime = (deps) => {
process.env[key] = value;
}
process.env.PATH = mergePathValues(snapshot.PATH || '', process.env.PATH || '');
const currentPath = process.env.PATH || '';
const shellPath = snapshot.PATH || '';
if (!pathLooksUserConfigured(currentPath) && shellPath) {
process.env.PATH = shellPath;
}
};
const isWslExecutableValue = (value) => {
@@ -22,6 +22,8 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
setupProxy,
ensureOpenCodeApiPrefix,
clearResolvedOpenCodeBinary,
buildAugmentedPath,
buildManagedOpenCodePath,
} = deps;
const killProcessOnPort = (port) => {
@@ -389,6 +391,11 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
await applyOpencodeBinaryFromSettings();
ensureOpencodeCliEnv();
const openCodePassword = await ensureLocalOpenCodeServerPassword({ rotateManaged: true });
const envPath = typeof buildManagedOpenCodePath === 'function'
? buildManagedOpenCodePath()
: typeof buildAugmentedPath === 'function'
? buildAugmentedPath()
: process.env.PATH;
try {
const serverInstance = await createManagedOpenCodeServerProcess({
@@ -398,6 +405,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
cwd: state.openCodeWorkingDirectory,
env: {
...process.env,
PATH: envPath,
OPENCODE_SERVER_PASSWORD: openCodePassword,
},
});
@@ -0,0 +1,119 @@
import { EventEmitter } from 'node:events';
import { afterEach, describe, expect, it, vi } from 'vitest';
const spawnMock = vi.fn();
vi.mock('node:child_process', () => ({
spawn: spawnMock,
spawnSync: vi.fn(),
}));
const { createOpenCodeLifecycleRuntime } = await import('./lifecycle.js');
const originalOpencodeBinary = process.env.OPENCODE_BINARY;
afterEach(() => {
spawnMock.mockReset();
if (typeof originalOpencodeBinary === 'string') {
process.env.OPENCODE_BINARY = originalOpencodeBinary;
return;
}
delete process.env.OPENCODE_BINARY;
});
const createMockChild = () => {
const child = new EventEmitter();
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.exitCode = null;
child.signalCode = null;
child.pid = 12345;
child.kill = vi.fn(() => {
child.signalCode = 'SIGTERM';
queueMicrotask(() => child.emit('close', null, 'SIGTERM'));
return true;
});
return child;
};
const createRuntime = (overrides = {}) => {
const state = {
openCodeWorkingDirectory: '/tmp/project',
openCodeProcess: null,
openCodePort: null,
openCodeBaseUrl: null,
currentRestartPromise: null,
isRestartingOpenCode: false,
openCodeApiPrefix: '',
openCodeApiPrefixDetected: false,
openCodeApiDetectionTimer: null,
lastOpenCodeError: null,
isOpenCodeReady: false,
openCodeNotReadySince: 0,
isExternalOpenCode: false,
isShuttingDown: false,
healthCheckInterval: null,
expressApp: null,
useWslForOpencode: false,
resolvedWslBinary: null,
resolvedWslOpencodePath: null,
resolvedWslDistro: null,
};
return createOpenCodeLifecycleRuntime({
state,
env: {
ENV_CONFIGURED_OPENCODE_PORT: 45678,
ENV_CONFIGURED_OPENCODE_HOST: null,
ENV_EFFECTIVE_PORT: 3001,
ENV_CONFIGURED_OPENCODE_HOSTNAME: '127.0.0.1',
ENV_SKIP_OPENCODE_START: false,
},
syncToHmrState: vi.fn(),
syncFromHmrState: vi.fn(),
getOpenCodeAuthHeaders: () => ({}),
buildOpenCodeUrl: (route) => `http://127.0.0.1:45678${route}`,
waitForReady: vi.fn(async () => true),
normalizeApiPrefix: vi.fn(() => ''),
applyOpencodeBinaryFromSettings: vi.fn(async () => null),
ensureOpencodeCliEnv: vi.fn(),
ensureLocalOpenCodeServerPassword: vi.fn(async () => 'password'),
buildWslExecArgs: vi.fn((args) => args),
resolveWslExecutablePath: vi.fn(),
resolveManagedOpenCodeLaunchSpec: vi.fn((binary) => ({ binary, args: [], wrapperType: null })),
setOpenCodePort: vi.fn((port) => {
state.openCodePort = port;
}),
setDetectedOpenCodeApiPrefix: vi.fn(),
setupProxy: vi.fn(),
ensureOpenCodeApiPrefix: vi.fn(),
clearResolvedOpenCodeBinary: vi.fn(),
buildAugmentedPath: vi.fn(() => '/home/user/.bun/bin:/usr/local/bin:/usr/bin'),
buildManagedOpenCodePath: vi.fn(() => '/home/user/.bun/bin:/usr/local/bin:/usr/bin'),
...overrides,
});
};
describe('OpenCode lifecycle', () => {
it('launches managed OpenCode with the managed PATH', async () => {
delete process.env.OPENCODE_BINARY;
const child = createMockChild();
spawnMock.mockImplementationOnce(() => {
queueMicrotask(() => {
child.stdout.emit('data', 'opencode server listening on http://127.0.0.1:45678\n');
});
return child;
});
const runtime = createRuntime();
const server = await runtime.startOpenCode();
const [binary, args, options] = spawnMock.mock.calls[0];
expect(binary).toBe('opencode');
expect(args).toEqual(['serve', '--hostname', '127.0.0.1', '--port', '45678']);
expect(options.env.PATH).toBe('/home/user/.bun/bin:/usr/local/bin:/usr/bin');
expect(options.env.OPENCODE_SERVER_PASSWORD).toBe('password');
await server.close();
});
});
@@ -45,6 +45,21 @@ export const createServerUtilsRuntime = (dependencies) => {
clearLastOpenCodeError();
};
const pathLooksUserConfigured = (value) => {
if (typeof value !== 'string' || !value) {
return false;
}
const home = os.homedir();
return value.split(path.delimiter).some((segment) => (
segment.startsWith(home + path.sep)
|| segment === home
|| segment.startsWith('/opt/homebrew/')
|| segment.startsWith('/opt/pkg/')
|| segment.startsWith('/opt/pmk/')
));
};
const waitForOpenCodePort = async (timeoutMs = 15000) => {
if (getOpenCodePort() !== null) {
return getOpenCodePort();
@@ -62,23 +77,40 @@ export const createServerUtilsRuntime = (dependencies) => {
};
const buildAugmentedPath = () => {
const augmented = new Set();
const home = os.homedir();
const currentPath = process.env.PATH || '';
const loginShellPath = getLoginShellPath();
if (loginShellPath) {
for (const segment of loginShellPath.split(path.delimiter)) {
if (segment) {
augmented.add(segment);
const currentPathLooksUserConfigured = pathLooksUserConfigured(currentPath);
const primaryPath = currentPathLooksUserConfigured ? currentPath : loginShellPath;
const fallbackPath = currentPathLooksUserConfigured ? loginShellPath : currentPath;
const seen = new Set();
const augmented = [];
const addSegments = (value) => {
if (typeof value !== 'string' || !value) {
return;
}
for (const segment of value.split(path.delimiter)) {
if (segment && !seen.has(segment)) {
seen.add(segment);
augmented.push(segment);
}
}
};
addSegments(primaryPath);
addSegments(fallbackPath);
return augmented.join(path.delimiter);
};
const buildManagedOpenCodePath = () => {
const currentPath = process.env.PATH || '';
if (pathLooksUserConfigured(currentPath)) {
return currentPath;
}
const current = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
for (const segment of current) {
augmented.add(segment);
}
return Array.from(augmented).join(path.delimiter);
return getLoginShellPath() || currentPath;
};
const parseSseDataPayload = (block) => {
@@ -159,6 +191,7 @@ export const createServerUtilsRuntime = (dependencies) => {
setOpenCodePort,
waitForOpenCodePort,
buildAugmentedPath,
buildManagedOpenCodePath,
parseSseDataPayload,
fetchAgentsSnapshot,
fetchProvidersSnapshot,
@@ -0,0 +1,115 @@
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { createServerUtilsRuntime } from './server-utils-runtime.js';
const originalPath = process.env.PATH;
afterEach(() => {
process.env.PATH = originalPath;
});
const createRuntime = (loginShellPath) => createServerUtilsRuntime({
fs: {},
os,
path,
process,
openCodeReadyGraceMs: 0,
longRequestTimeoutMs: 0,
getRuntime: () => ({}),
getOpenCodeAuthHeaders: () => ({}),
buildOpenCodeUrl: (route) => route,
ensureOpenCodeApiPrefix: () => {},
getUiNotificationClients: () => new Set(),
getOpenCodePort: () => null,
setOpenCodePortState: () => {},
syncToHmrState: () => {},
markOpenCodeNotReady: () => {},
setOpenCodeNotReadySince: () => {},
clearLastOpenCodeError: () => {},
getLoginShellPath: () => loginShellPath,
});
describe('server utils runtime', () => {
it('keeps managed OpenCode PATH literal when process PATH is user-configured', () => {
const home = os.homedir();
const currentPath = [
path.join(home, '.opencode', 'bin'),
path.join(home, '.bun', 'bin'),
path.join(home, 'Library', 'pnpm'),
'/opt/homebrew/bin',
'/usr/bin',
].join(path.delimiter);
process.env.PATH = currentPath;
const runtime = createRuntime([
path.join(home, '.opencode', 'bin'),
path.join(home, '.bun', 'bin'),
'/opt/homebrew/bin',
'/usr/bin',
path.join(home, '.cargo', 'bin'),
].join(path.delimiter));
expect(runtime.buildManagedOpenCodePath()).toBe(currentPath);
});
it('uses login shell PATH for managed OpenCode when process PATH is minimal', () => {
const home = os.homedir();
const loginShellPath = [
path.join(home, '.opencode', 'bin'),
path.join(home, '.bun', 'bin'),
'/opt/homebrew/bin',
'/usr/bin',
].join(path.delimiter);
process.env.PATH = ['/usr/local/bin', '/usr/bin', '/bin'].join(path.delimiter);
const runtime = createRuntime(loginShellPath);
expect(runtime.buildManagedOpenCodePath()).toBe(loginShellPath);
});
it('preserves user-configured process PATH order before appending shell-only entries', () => {
const home = os.homedir();
process.env.PATH = [
path.join(home, '.bun', 'bin'),
path.join(home, 'Library', 'pnpm'),
'/opt/homebrew/bin',
'/usr/bin',
].join(path.delimiter);
const runtime = createRuntime([
path.join(home, '.bun', 'bin'),
'/opt/homebrew/bin',
path.join(home, '.cargo', 'bin'),
'/usr/bin',
].join(path.delimiter));
expect(runtime.buildAugmentedPath()).toBe([
path.join(home, '.bun', 'bin'),
path.join(home, 'Library', 'pnpm'),
'/opt/homebrew/bin',
'/usr/bin',
path.join(home, '.cargo', 'bin'),
].join(path.delimiter));
});
it('prefers login shell PATH when current process PATH is minimal', () => {
const home = os.homedir();
process.env.PATH = ['/usr/local/bin', '/usr/bin', '/bin'].join(path.delimiter);
const runtime = createRuntime([
path.join(home, '.bun', 'bin'),
'/opt/homebrew/bin',
'/usr/bin',
].join(path.delimiter));
expect(runtime.buildAugmentedPath()).toBe([
path.join(home, '.bun', 'bin'),
'/opt/homebrew/bin',
'/usr/bin',
'/usr/local/bin',
'/bin',
].join(path.delimiter));
});
});