fix: preserve user PATH for managed opencode
This commit is contained in:
@@ -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));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user