diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 8b9d888ce78..d264a717519 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -54,6 +54,7 @@ import { clearMigrationUnsupportedPtysForPaneKey } from '../agent-hooks/migration-unsupported-pty-state' import { parseWslPath } from '../wsl' +import { mergePersistedWindowsPath } from '../pty/windows-environment-path' // ─── Provider Registry ────────────────────────────────────────────── // Routes PTY operations by connectionId. null = local provider. @@ -268,7 +269,7 @@ export type BuildPtyHostEnvOptions = { } function readInheritedPath(baseEnv: Record): string { - return baseEnv.PATH ?? process.env.PATH ?? process.env.Path ?? '' + return baseEnv.PATH ?? baseEnv.Path ?? process.env.PATH ?? process.env.Path ?? '' } function isWslShellName(shellPath: string | undefined): boolean { @@ -380,6 +381,8 @@ export function buildPtyHostEnv( baseEnv: Record, opts: BuildPtyHostEnvOptions ): Record { + mergePersistedWindowsPath(baseEnv) + // Why: the Local path passes a baseEnv that already includes process.env // (LocalPtyProvider.spawn merges it before calling buildSpawnEnv). The // daemon path passes only args.env since process.env propagates to the diff --git a/src/main/pty/windows-environment-path.test.ts b/src/main/pty/windows-environment-path.test.ts new file mode 100644 index 00000000000..cc6b832e9f6 --- /dev/null +++ b/src/main/pty/windows-environment-path.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from 'vitest' +import { + mergePersistedWindowsPath, + readPersistedWindowsPathSegments +} from './windows-environment-path' + +describe('readPersistedWindowsPathSegments', () => { + it('reads machine and user Path values from the Windows registry', () => { + const execFileSync = vi + .fn() + .mockReturnValueOnce( + [ + '', + 'HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment', + ' Path REG_EXPAND_SZ %SystemRoot%\\System32;C:\\Tools', + '' + ].join('\r\n') + ) + .mockReturnValueOnce( + ['', 'HKEY_CURRENT_USER\\Environment', ' Path REG_SZ C:\\Users\\me\\bin', ''].join( + '\r\n' + ) + ) + + const segments = readPersistedWindowsPathSegments({ + platform: 'win32', + execFileSync, + env: { SystemRoot: 'C:\\Windows' } + }) + + expect(segments).toEqual(['C:\\Windows\\System32', 'C:\\Tools', 'C:\\Users\\me\\bin']) + }) + + it('returns an empty list outside Windows', () => { + const execFileSync = vi.fn() + + expect(readPersistedWindowsPathSegments({ platform: 'linux', execFileSync })).toEqual([]) + expect(execFileSync).not.toHaveBeenCalled() + }) +}) + +describe('mergePersistedWindowsPath', () => { + it('appends missing persisted segments without reordering the inherited PATH', () => { + const execFileSync = vi + .fn() + .mockReturnValueOnce( + [ + '', + 'HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment', + ' Path REG_EXPAND_SZ C:\\Windows\\System32;C:\\Existing', + '' + ].join('\r\n') + ) + .mockReturnValueOnce( + [ + '', + 'HKEY_CURRENT_USER\\Environment', + ' Path REG_EXPAND_SZ C:\\Users\\me\\AppData\\Local\\agy\\bin;C:\\Existing', + '' + ].join('\r\n') + ) + const env = { Path: 'C:\\Existing' } + + mergePersistedWindowsPath(env, { platform: 'win32', execFileSync }) + + expect(env.Path).toBe( + 'C:\\Existing;C:\\Windows\\System32;C:\\Users\\me\\AppData\\Local\\agy\\bin' + ) + }) + + it('uses PATH when that is the existing path key', () => { + const execFileSync = vi + .fn() + .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') + .mockReturnValueOnce(' Path REG_SZ C:\\User\r\n') + const env = { PATH: 'C:\\Current' } + + mergePersistedWindowsPath(env, { platform: 'win32', execFileSync }) + + expect(env).toEqual({ PATH: 'C:\\Current;C:\\Machine;C:\\User' }) + }) + + it('keeps the inherited process PATH when the target env has no path key', () => { + const execFileSync = vi + .fn() + .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') + .mockReturnValueOnce(' Path REG_SZ C:\\User\r\n') + const env: Record = {} + + mergePersistedWindowsPath(env, { + platform: 'win32', + execFileSync, + env: { Path: 'C:\\Inherited' } + }) + + expect(env).toEqual({ Path: 'C:\\Inherited;C:\\Machine;C:\\User' }) + }) +}) diff --git a/src/main/pty/windows-environment-path.ts b/src/main/pty/windows-environment-path.ts new file mode 100644 index 00000000000..e17e29fa421 --- /dev/null +++ b/src/main/pty/windows-environment-path.ts @@ -0,0 +1,110 @@ +import { execFileSync } from 'node:child_process' + +type ExecFileSync = typeof execFileSync + +type ReadWindowsPathOptions = { + execFileSync?: ExecFileSync + env?: NodeJS.ProcessEnv + platform?: NodeJS.Platform +} + +const WINDOWS_PATH_REGISTRY_KEYS = [ + ['HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment', 'Path'], + ['HKCU\\Environment', 'Path'] +] as const + +function parseRegistryPathValue(output: string, valueName: string): string | null { + const valuePattern = new RegExp(`^\\s*${valueName}\\s+REG_\\w+\\s+(.*)$`, 'i') + for (const line of output.split(/\r?\n/)) { + const match = valuePattern.exec(line) + if (match) { + return match[1]?.trim() ?? '' + } + } + return null +} + +function expandWindowsEnvironmentVariables(value: string, env: NodeJS.ProcessEnv): string { + return value.replace(/%([^%]+)%/g, (match, rawName: string) => { + const name = rawName.toLowerCase() + const envKey = Object.keys(env).find((key) => key.toLowerCase() === name) + return envKey && env[envKey] ? env[envKey] : match + }) +} + +function getPathDelimiter(platform: NodeJS.Platform): string { + return platform === 'win32' ? ';' : ':' +} + +function splitPathSegments(pathValue: string, pathDelimiter: string): string[] { + return pathValue + .split(pathDelimiter) + .map((segment) => segment.trim()) + .filter(Boolean) +} + +export function readPersistedWindowsPathSegments(options: ReadWindowsPathOptions = {}): string[] { + const platform = options.platform ?? process.platform + if (platform !== 'win32') { + return [] + } + + const run = options.execFileSync ?? execFileSync + const env = options.env ?? process.env + const pathDelimiter = getPathDelimiter(platform) + const segments: string[] = [] + + for (const [key, valueName] of WINDOWS_PATH_REGISTRY_KEYS) { + try { + const output = run('reg.exe', ['query', key, '/v', valueName], { + encoding: 'utf8', + windowsHide: true + }) + const value = parseRegistryPathValue(output, valueName) + if (value) { + segments.push( + ...splitPathSegments(expandWindowsEnvironmentVariables(value, env), pathDelimiter) + ) + } + } catch { + // Registry access can fail in stripped test containers or remote-like + // Windows contexts. Existing PATH remains the fallback in those cases. + } + } + + return segments +} + +export function mergePersistedWindowsPath( + env: Record, + options: ReadWindowsPathOptions = {} +): void { + const platform = options.platform ?? process.platform + if (platform !== 'win32') { + return + } + + const pathKey = env.Path !== undefined ? 'Path' : env.PATH !== undefined ? 'PATH' : 'Path' + const pathDelimiter = getPathDelimiter(platform) + const sourceEnv = options.env ?? process.env + const currentPath = env[pathKey] ?? sourceEnv.PATH ?? sourceEnv.Path ?? '' + const currentSegments = splitPathSegments(currentPath, pathDelimiter) + const existing = new Set(currentSegments.map((segment) => segment.toLowerCase())) + const missing = readPersistedWindowsPathSegments(options).filter((segment) => { + const normalized = segment.toLowerCase() + if (existing.has(normalized)) { + return false + } + existing.add(normalized) + return true + }) + + if (missing.length === 0) { + return + } + + // Why: Windows broadcasts PATH changes to future processes, but a running + // Electron app keeps its old environment. Append the persisted additions so + // newly installed CLIs resolve without unexpectedly reordering existing PATH. + env[pathKey] = [...currentSegments, ...missing].join(pathDelimiter) +} diff --git a/src/renderer/src/components/terminal-quick-commands/terminal-agent-quick-command-presets.test.ts b/src/renderer/src/components/terminal-quick-commands/terminal-agent-quick-command-presets.test.ts index 09e2a3cdc01..c2a5c3a50d9 100644 --- a/src/renderer/src/components/terminal-quick-commands/terminal-agent-quick-command-presets.test.ts +++ b/src/renderer/src/components/terminal-quick-commands/terminal-agent-quick-command-presets.test.ts @@ -9,6 +9,7 @@ describe('terminal agent quick command presets', () => { claude: "claude 'your prompt here'", codex: "codex 'your prompt here'", copilot: "copilot -i 'your prompt here'", + omp: "omp 'your prompt here'", opencode: "opencode --prompt 'your prompt here'", pi: "pi 'your prompt here'", gemini: "gemini --prompt-interactive 'your prompt here'",