mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 16:02:56 +00:00
Fix Windows terminal PATH refresh (#2810)
This commit is contained in:
+4
-1
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
+1
@@ -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'",
|
||||
|
||||
Reference in New Issue
Block a user