diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index d9671d68..9cff1514 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -715,24 +715,14 @@ const inheritUserShellEnv = () => { if (!shellEnv) return; const homeDir = os.homedir(); - const shellPathSegments = typeof shellEnv.PATH === 'string' ? shellEnv.PATH.split(':') : []; - const processPathSegments = typeof process.env.PATH === 'string' ? process.env.PATH.split(':') : []; - const pathSegments = [ - ...shellPathSegments, - '/opt/homebrew/bin', - '/usr/local/bin', - '/usr/bin', - '/bin', - '/usr/sbin', - '/sbin', - path.join(homeDir, '.opencode', 'bin'), - path.join(homeDir, '.local', 'bin'), - path.join(homeDir, '.bun', 'bin'), - path.join(homeDir, '.cargo', 'bin'), - path.join(homeDir, 'bin'), - ...processPathSegments, - ].filter(Boolean); - const uniquePath = Array.from(new Set(pathSegments)).join(':'); + const currentPath = process.env.PATH || ''; + const currentPathLooksUserConfigured = currentPath.split(':').some((segment) => ( + segment.startsWith(`${homeDir}${path.sep}`) + || segment === homeDir + || segment.startsWith('/opt/homebrew/') + || segment.startsWith('/opt/pkg/') + || segment.startsWith('/opt/pmk/') + )); for (const [key, value] of Object.entries(shellEnv)) { if (key === 'PATH') continue; @@ -740,7 +730,9 @@ const inheritUserShellEnv = () => { process.env[key] = value; } } - process.env.PATH = uniquePath; + if (!currentPathLooksUserConfigured && typeof shellEnv.PATH === 'string' && shellEnv.PATH.length > 0) { + process.env.PATH = shellEnv.PATH; + } }; const spawnLocalServer = async () => { diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 1ba86b41..b5092c64 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -756,6 +756,7 @@ const serverUtilsRuntime = createServerUtilsRuntime({ const setOpenCodePort = (...args) => serverUtilsRuntime.setOpenCodePort(...args); const waitForOpenCodePort = (...args) => serverUtilsRuntime.waitForOpenCodePort(...args); const buildAugmentedPath = (...args) => serverUtilsRuntime.buildAugmentedPath(...args); +const buildManagedOpenCodePath = (...args) => serverUtilsRuntime.buildManagedOpenCodePath(...args); const parseSseDataPayload = (...args) => serverUtilsRuntime.parseSseDataPayload(...args); const staticRoutesRuntime = createStaticRoutesRuntime({ fs, @@ -873,6 +874,8 @@ const openCodeLifecycleRuntime = createOpenCodeLifecycleRuntime({ setupProxy: (...args) => setupProxy(...args), ensureOpenCodeApiPrefix, clearResolvedOpenCodeBinary, + buildAugmentedPath, + buildManagedOpenCodePath, }); const restartOpenCode = (...args) => openCodeLifecycleRuntime.restartOpenCode(...args); diff --git a/packages/web/server/lib/opencode/env-runtime.js b/packages/web/server/lib/opencode/env-runtime.js index 86dd229c..d12e9eb8 100644 --- a/packages/web/server/lib/opencode/env-runtime.js +++ b/packages/web/server/lib/opencode/env-runtime.js @@ -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) => { diff --git a/packages/web/server/lib/opencode/lifecycle.js b/packages/web/server/lib/opencode/lifecycle.js index bf8781c5..0d2cb381 100644 --- a/packages/web/server/lib/opencode/lifecycle.js +++ b/packages/web/server/lib/opencode/lifecycle.js @@ -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, }, }); diff --git a/packages/web/server/lib/opencode/lifecycle.test.js b/packages/web/server/lib/opencode/lifecycle.test.js new file mode 100644 index 00000000..129f854f --- /dev/null +++ b/packages/web/server/lib/opencode/lifecycle.test.js @@ -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(); + }); +}); diff --git a/packages/web/server/lib/opencode/server-utils-runtime.js b/packages/web/server/lib/opencode/server-utils-runtime.js index 940d0c92..472a557f 100644 --- a/packages/web/server/lib/opencode/server-utils-runtime.js +++ b/packages/web/server/lib/opencode/server-utils-runtime.js @@ -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, diff --git a/packages/web/server/lib/opencode/server-utils-runtime.test.js b/packages/web/server/lib/opencode/server-utils-runtime.test.js new file mode 100644 index 00000000..6bcaf829 --- /dev/null +++ b/packages/web/server/lib/opencode/server-utils-runtime.test.js @@ -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)); + }); +});