Fix Windows terminal PATH refresh (#2810)

This commit is contained in:
Jinwoo Hong
2026-05-25 22:07:31 -04:00
committed by GitHub
parent add481978e
commit ebb6bc8f71
4 changed files with 213 additions and 1 deletions
+4 -1
View File
@@ -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, string>): 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<string, string>,
opts: BuildPtyHostEnvOptions
): Record<string, string> {
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
@@ -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<string, string> = {}
mergePersistedWindowsPath(env, {
platform: 'win32',
execFileSync,
env: { Path: 'C:\\Inherited' }
})
expect(env).toEqual({ Path: 'C:\\Inherited;C:\\Machine;C:\\User' })
})
})
+110
View File
@@ -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<string, string>,
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)
}
@@ -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'",