From 596bdd9afe495c5e4eb9a24a7e8dfe679a40775f Mon Sep 17 00:00:00 2001 From: Orca Worker Date: Thu, 12 Nov 2026 23:00:18 -0800 Subject: [PATCH] fix(windows): find PowerShell 7 wherever it is installed and say so when it is missing Search every place PowerShell 7 can be installed (Program Files, per-user, the terminal PATH, App Paths, the Store package) off the main thread, cache the result per shell setting, and retry after a launch failure or a timed-out search. When the shell setting asks for PowerShell 7 and none is found, the fallback shell prints a one-line notice. Main translates the notice in the UI language and passes it with the spawn options, because the terminal daemon has no UI language; English is used when a client does not send one. The cmd notice is caret-escaped so translated text is always printed literally. --- config/main-sync-fs-baseline.txt | 1 - src/main/daemon/daemon-entry.ts | 2 + .../daemon/protocol/daemon-rpc-contract.ts | 2 + .../adapter/daemon-pty-spawn-request.ts | 3 + .../daemon/pty-subprocess/native-pty-spawn.ts | 2 + .../daemon/pty-subprocess/pty-subprocess.ts | 4 +- .../pty-subprocess/shell-launch-plan.ts | 14 +- ...-foreground-process-git-bash.win32.test.ts | 2 +- .../pty-subprocess-handle-lifecycle.test.ts | 2 - .../server/daemon-terminal-admission.ts | 1 + .../terminal-host-create-contract.ts | 2 + .../terminal-host/terminal-host-options.ts | 2 + .../terminal-host-session-publish.ts | 1 + src/main/ipc/pty/spawn-options-assembly.ts | 4 + .../local-pty/local-pty-executable-spawn.ts | 4 + .../local-pty/local-pty-launch-plan.ts | 30 +- .../local-pty-provider-shutdown.test.ts | 2 - .../local-pty-provider-spawn-session.test.ts | 2 - .../providers/local-pty/local-pty-spawn.ts | 2 +- src/main/providers/pty/provider-contract.ts | 3 + .../windows/powershell-executable.test.ts | 366 ++++++++++++++++++ .../windows/powershell-executable.ts | 211 ++-------- .../shell-launch/windows/powershell-search.ts | 139 +++++++ .../shell-launch/windows/pwsh-discovery.ts | 275 +++++++++++++ .../windows/pwsh-not-found-notice.ts | 20 + .../shell-launch/windows/shell-args.ts | 23 +- .../windows/shell-fallback-chain.ts | 59 ++- .../windows-shell-path-hydration.ts | 31 +- src/renderer/src/i18n/locales/de.json | 6 +- src/renderer/src/i18n/locales/en.json | 4 + src/renderer/src/i18n/locales/es.json | 4 + src/renderer/src/i18n/locales/fr.json | 4 + src/renderer/src/i18n/locales/ja.json | 4 + src/renderer/src/i18n/locales/ko.json | 4 + src/renderer/src/i18n/locales/pt.json | 6 +- src/renderer/src/i18n/locales/ru.json | 6 +- src/renderer/src/i18n/locales/tr.json | 6 +- src/renderer/src/i18n/locales/zh-Hant.json | 6 +- src/renderer/src/i18n/locales/zh.json | 4 + 39 files changed, 1033 insertions(+), 230 deletions(-) create mode 100644 src/main/providers/shell-launch/windows/powershell-executable.test.ts create mode 100644 src/main/providers/shell-launch/windows/powershell-search.ts create mode 100644 src/main/providers/shell-launch/windows/pwsh-discovery.ts create mode 100644 src/main/providers/shell-launch/windows/pwsh-not-found-notice.ts diff --git a/config/main-sync-fs-baseline.txt b/config/main-sync-fs-baseline.txt index 4952f5ff34b..b15baaae703 100644 --- a/config/main-sync-fs-baseline.txt +++ b/config/main-sync-fs-baseline.txt @@ -443,7 +443,6 @@ 1 src/main/providers/local-pty/local-pty-shell-ready-wrapper-root.ts 1 src/main/providers/shell-launch/macos/login-session-pty-probe.ts 3 src/main/providers/shell-launch/macos/tcc-login-shell.ts -3 src/main/providers/shell-launch/windows/powershell-executable.ts 2 src/main/providers/working-directory/validation.ts 4 src/main/pty/history/fish-history-session.ts 4 src/main/pty/history/terminal-history-deletion.ts diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts index 7fcbbd801d3..9a409bb0327 100644 --- a/src/main/daemon/daemon-entry.ts +++ b/src/main/daemon/daemon-entry.ts @@ -5,6 +5,7 @@ import { startDaemon, type DaemonHandle } from './server/daemon-main' import { createPtySubprocess } from './pty-subprocess/pty-subprocess' import { warmWindowsConptyOnce } from './pty-subprocess/windows-conpty-warmup' import { warmPwshAvailabilityCache } from '../pty/shell-startup/pwsh' +import { warmWindowsPwshSearch } from '../providers/shell-launch/windows/powershell-search' import { createDaemonFileLog, createNoopDaemonFileLog } from './observability/daemon-file-log' import { PROTOCOL_VERSION } from './protocol/daemon-protocol-version' import { detectOwnCgroupScopeUnit, ownFreshDaemonScope } from './process-scopes/daemon-cgroup-scope' @@ -60,6 +61,7 @@ async function main(): Promise { const processCeiling = initializeTerminalScopes(daemonScope, log) startDaemonScopeDeathWatch({ daemonScope, log }) void warmPwshAvailabilityCache() + warmWindowsPwshSearch() // Detached stderr is unavailable; retain TCC preflight failures in diagnostic logs. const runMacosLoginPreflight = async (): Promise => { diff --git a/src/main/daemon/protocol/daemon-rpc-contract.ts b/src/main/daemon/protocol/daemon-rpc-contract.ts index 583c1fb41d5..cdbbfdca9c4 100644 --- a/src/main/daemon/protocol/daemon-rpc-contract.ts +++ b/src/main/daemon/protocol/daemon-rpc-contract.ts @@ -26,6 +26,7 @@ import type * as HistorySeedProtocol from '../history/seed-transfer/terminal-his import type * as PtyOwnerQueryProtocol from './daemon-pty-owner-query-protocol' import type { TerminalSnapshot } from './terminal-snapshot' import type { SetLaunchNoteRequest } from './daemon-launch-note-protocol' +import type { WindowsPwshNotFoundNotices } from '../../providers/shell-launch/windows/pwsh-not-found-notice' export type SessionState = 'created' | 'spawning' | 'running' | 'exiting' | 'exited' @@ -53,6 +54,7 @@ export type CreateOrAttachRequest = { terminalWindowsWslDistro?: string | null /** Preserve the selected PowerShell implementation so daemon and local spawns resolve the same executable. */ terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' + terminalWindowsPwshNotFoundNotices?: WindowsPwshNotFoundNotices shellReadySupported?: boolean shellReadyTimeoutMs?: number /** Server-side fence that prevents a client timeout from publishing an orphan PTY. */ diff --git a/src/main/daemon/providers/adapter/daemon-pty-spawn-request.ts b/src/main/daemon/providers/adapter/daemon-pty-spawn-request.ts index 17767fce783..e8770bfb6cc 100644 --- a/src/main/daemon/providers/adapter/daemon-pty-spawn-request.ts +++ b/src/main/daemon/providers/adapter/daemon-pty-spawn-request.ts @@ -133,6 +133,9 @@ export abstract class DaemonPtySpawnRequest extends DaemonPtyRuntimeState { terminalWindowsPowerShellImplementation: context.attachOnly ? undefined : opts.terminalWindowsPowerShellImplementation, + terminalWindowsPwshNotFoundNotices: context.attachOnly + ? undefined + : opts.terminalWindowsPwshNotFoundNotices, shellReadySupported: context.attachOnly ? false : context.shellReadySupported, ...(!context.attachOnly && context.shellReadyTimeoutMs !== undefined ? { shellReadyTimeoutMs: context.shellReadyTimeoutMs } diff --git a/src/main/daemon/pty-subprocess/native-pty-spawn.ts b/src/main/daemon/pty-subprocess/native-pty-spawn.ts index 31fd15e7d67..be8bfa14952 100644 --- a/src/main/daemon/pty-subprocess/native-pty-spawn.ts +++ b/src/main/daemon/pty-subprocess/native-pty-spawn.ts @@ -9,6 +9,7 @@ import { wrapShellSpawnForMacosTccAttribution } from '../../providers/shell-launch/macos/tcc-login-shell' import type { WindowsShellSpawnAttempt } from '../../providers/shell-launch/windows/shell-fallback-chain' +import { forgetWindowsPowerShellAfterLaunchFailure } from '../../providers/shell-launch/windows/powershell-search' import { assignHostProcessToKillOnCloseJob } from '../../windows/windows-pty-job' import { wrapPtySpawnInTerminalScope } from '../process-scopes/terminal-scopes-runtime' import { spawnConptyWithInputReader, type ConptyStartupEvents } from './conpty-startup-input-reader' @@ -149,6 +150,7 @@ export async function spawnNativeDaemonPty(args: { if (process.platform !== 'win32') { throw primaryErr } + forgetWindowsPowerShellAfterLaunchFailure(args.shellPath) for (const attempt of args.windowsFallbackAttempts.slice(1)) { try { const spawned = await spawnAt( diff --git a/src/main/daemon/pty-subprocess/pty-subprocess.ts b/src/main/daemon/pty-subprocess/pty-subprocess.ts index 4fce510696a..470adeab18d 100644 --- a/src/main/daemon/pty-subprocess/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess/pty-subprocess.ts @@ -15,6 +15,7 @@ import { import { createDaemonPtySubprocessHandle } from './subprocess-handle' import type { StartupCommandDelivery } from '../../../shared/agent-launch/startup/codex-startup-delivery' import type { TuiAgent } from '../../../shared/agent-launch/catalog/tui-agent' +import type { WindowsPwshNotFoundNotices } from '../../providers/shell-launch/windows/pwsh-not-found-notice' const PTY_SPAWN_HEALTH_RETRY_ATTEMPTS = 2 @@ -33,6 +34,7 @@ export type PtySubprocessOptions = { terminalShellArgs?: string[] terminalWindowsWslDistro?: string | null terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' + terminalWindowsPwshNotFoundNotices?: WindowsPwshNotFoundNotices onCreationDisposition?: (disposition: PtyCreationDisposition) => void assertCreateAllowed?: () => void isCanceled?: () => boolean @@ -69,7 +71,7 @@ export async function createPtySubprocess(opts: PtySubprocessOptions): Promise -): PtyShellLaunchPlan { +): Promise { const resolvedWslContext = resolveWslSessionContext(opts) let shellPath = resolvedWslContext ? 'wsl.exe' : opts.shellOverride || resolvePtyShellPath(env) + const requestedShell = shellPath let shellArgs: string[] let startupCommandDeliveredInShellArgs = false let windowsFallbackAttempts: WindowsShellSpawnAttempt[] = [] @@ -111,12 +112,17 @@ export function createPtyShellLaunchPlan( ) { env[ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV] = '"' } - windowsFallbackAttempts = buildWindowsPowerShellSpawnAttempts({ + windowsFallbackAttempts = await buildWindowsPowerShellSpawnAttempts({ shellPath, cwd: spawnCwd, defaultCwd: resolveSafePtyDefaultCwd(), wslContext: resolvedWslContext, - startupCommand: opts.command + startupCommand: opts.command, + // The daemon outlives app restarts; the terminal env carries main's current PATH. + env, + requestedShell, + powerShellImplementation: opts.terminalWindowsPowerShellImplementation, + pwshNotFoundNotices: opts.terminalWindowsPwshNotFoundNotices }) const primaryAttempt = windowsFallbackAttempts[0] if (primaryAttempt) { diff --git a/src/main/daemon/pty-subprocess/testing/agent-foreground-process-git-bash.win32.test.ts b/src/main/daemon/pty-subprocess/testing/agent-foreground-process-git-bash.win32.test.ts index 35691e9686f..82eec51db27 100644 --- a/src/main/daemon/pty-subprocess/testing/agent-foreground-process-git-bash.win32.test.ts +++ b/src/main/daemon/pty-subprocess/testing/agent-foreground-process-git-bash.win32.test.ts @@ -108,7 +108,7 @@ describeOnWindows("Git Bash launcher shell proof with Orca's real launch", () => env: extraEnv } const env = createDaemonPtyEnvironment(opts) - const plan = createPtyShellLaunchPlan(opts, env) + const plan = await createPtyShellLaunchPlan(opts, env) expect(isGitForWindowsBashLauncherPath(plan.shellPath)).toBe(true) expect(plan.shellArgs.join(' ')).toContain('exec "$BASH"') // Launcher, exec stub, interactive bash: the shape that a size-1 or size-2 rule never matches. diff --git a/src/main/daemon/pty-subprocess/testing/pty-subprocess-handle-lifecycle.test.ts b/src/main/daemon/pty-subprocess/testing/pty-subprocess-handle-lifecycle.test.ts index 1301b59fafd..291be5dd337 100644 --- a/src/main/daemon/pty-subprocess/testing/pty-subprocess-handle-lifecycle.test.ts +++ b/src/main/daemon/pty-subprocess/testing/pty-subprocess-handle-lifecycle.test.ts @@ -44,8 +44,6 @@ const PWSH7_ABS = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' const WINDOWS_POWERSHELL_ABS = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' const CMD_ABS = 'C:\\Windows\\System32\\cmd.exe' vi.mock('../../../providers/shell-launch/windows/powershell-executable', () => ({ - resolveWindowsPowerShellExecutablePath: (family: 'pwsh.exe' | 'powershell.exe') => - family === 'pwsh.exe' ? PWSH7_ABS : WINDOWS_POWERSHELL_ABS, resolveWindowsPowerShellSpawnChain: (family: 'pwsh.exe' | 'powershell.exe') => family === 'pwsh.exe' ? [PWSH7_ABS, WINDOWS_POWERSHELL_ABS, CMD_ABS] diff --git a/src/main/daemon/server/daemon-terminal-admission.ts b/src/main/daemon/server/daemon-terminal-admission.ts index 39812c791a1..659672375cd 100644 --- a/src/main/daemon/server/daemon-terminal-admission.ts +++ b/src/main/daemon/server/daemon-terminal-admission.ts @@ -126,6 +126,7 @@ export class DaemonTerminalAdmission { terminalShellArgs: payload.terminalShellArgs, terminalWindowsWslDistro: payload.terminalWindowsWslDistro, terminalWindowsPowerShellImplementation: payload.terminalWindowsPowerShellImplementation, + terminalWindowsPwshNotFoundNotices: payload.terminalWindowsPwshNotFoundNotices, shellReadySupported: payload.shellReadySupported, historySeedChunks, startupIngress: parsePtyStartupIngressIntent(payload.startupIngress), diff --git a/src/main/daemon/terminal-host/terminal-host-create-contract.ts b/src/main/daemon/terminal-host/terminal-host-create-contract.ts index c90d5109dc4..2ac0559ecfa 100644 --- a/src/main/daemon/terminal-host/terminal-host-create-contract.ts +++ b/src/main/daemon/terminal-host/terminal-host-create-contract.ts @@ -11,6 +11,7 @@ import type { } from '../../../shared/agent-session/agent-session-host-authority' import type { PtyIncarnationId } from '../../../shared/terminal/pty/lifecycle/pty-incarnation' import type { TerminalExitCause } from '../../../shared/terminal/pty/lifecycle/terminal-exit-cause' +import type { WindowsPwshNotFoundNotices } from '../../providers/shell-launch/windows/pwsh-not-found-notice' export type CreateOrAttachOptions = { sessionId: string @@ -28,6 +29,7 @@ export type CreateOrAttachOptions = { terminalShellArgs?: string[] terminalWindowsWslDistro?: string | null terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' + terminalWindowsPwshNotFoundNotices?: WindowsPwshNotFoundNotices shellReadySupported?: boolean shellReadyTimeoutMs?: number historySeedChunks?: readonly string[] diff --git a/src/main/daemon/terminal-host/terminal-host-options.ts b/src/main/daemon/terminal-host/terminal-host-options.ts index aa3ceb6999f..73458c575fe 100644 --- a/src/main/daemon/terminal-host/terminal-host-options.ts +++ b/src/main/daemon/terminal-host/terminal-host-options.ts @@ -5,6 +5,7 @@ import type { SubprocessHandle } from '../sessions/session-subprocess-handle' import type { TakePendingOutputResult } from '../protocol/daemon-rpc-contract' import type { TerminalSnapshot } from '../protocol/terminal-snapshot' import type { TerminalSpawnGate } from '../admission/terminal-spawn-gate' +import type { WindowsPwshNotFoundNotices } from '../../providers/shell-launch/windows/pwsh-not-found-notice' export type TerminalHostOptions = { spawnSubprocess: (opts: { @@ -21,6 +22,7 @@ export type TerminalHostOptions = { terminalShellArgs?: string[] terminalWindowsWslDistro?: string | null terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' + terminalWindowsPwshNotFoundNotices?: WindowsPwshNotFoundNotices onCreationDisposition?: (disposition: PtyCreationDisposition) => void assertCreateAllowed?: () => void isCanceled?: () => boolean diff --git a/src/main/daemon/terminal-host/terminal-host-session-publish.ts b/src/main/daemon/terminal-host/terminal-host-session-publish.ts index 0a43d2d9c80..29bfba0a8fb 100644 --- a/src/main/daemon/terminal-host/terminal-host-session-publish.ts +++ b/src/main/daemon/terminal-host/terminal-host-session-publish.ts @@ -72,6 +72,7 @@ export async function spawnAndPublishSession( terminalShellArgs: opts.terminalShellArgs, terminalWindowsWslDistro: opts.terminalWindowsWslDistro, terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation, + terminalWindowsPwshNotFoundNotices: opts.terminalWindowsPwshNotFoundNotices, isCanceled: opts.isCanceled, assertCreateAllowed: deps.assertCreateAllowed, ...(opts.cancelSignal ? { cancelSignal: opts.cancelSignal } : {}) diff --git a/src/main/ipc/pty/spawn-options-assembly.ts b/src/main/ipc/pty/spawn-options-assembly.ts index 2715d717136..468ff1e72a1 100644 --- a/src/main/ipc/pty/spawn-options-assembly.ts +++ b/src/main/ipc/pty/spawn-options-assembly.ts @@ -12,6 +12,8 @@ import { PI_PROCESS_OWNER_ENV_KEYS } from '../../pty/shell-startup/pi-process-ow import { planCodexNoDaemonLaunch } from '../../pty/shell-startup/codex-no-daemon-launch-command' import { applyAgentWorkspaceTrustToSpawn } from '../../agent-launch/trust/agent-workspace-trust-spawn' import type { PtySpawnOptions } from '../../providers/pty/provider-contract' +import { buildWindowsPwshNotFoundNotices } from '../../providers/shell-launch/windows/pwsh-not-found-notice' +import { translateMain } from '../../i18n/main-i18n' import type { CodexAccountSelectionTarget } from '../../../shared/codex-selection-lane' import { getStartupTerminalIngressIntent } from '../terminal-startup-color-query-replies' import { CODEX_HOME_ENV_KEYS } from './host-env/codex-home' @@ -249,6 +251,8 @@ export async function assemblePtySpawnLaunchOptions(input: PtySpawnLaunchOptions spawnOptions.terminalWindowsPowerShellImplementation = input.getSettings ? (input.getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') : undefined + spawnOptions.terminalWindowsPwshNotFoundNotices = + buildWindowsPwshNotFoundNotices(translateMain) } } return { spawnOptions, env, launchCommand } diff --git a/src/main/providers/local-pty/local-pty-executable-spawn.ts b/src/main/providers/local-pty/local-pty-executable-spawn.ts index 93bece9cb1b..dd27795e75b 100644 --- a/src/main/providers/local-pty/local-pty-executable-spawn.ts +++ b/src/main/providers/local-pty/local-pty-executable-spawn.ts @@ -11,6 +11,7 @@ import { wrapShellSpawnForMacosTccAttribution } from '../shell-launch/macos/tcc-login-shell' import { formatLocalPtyEnvironmentDiag } from '../working-directory/validation' +import { forgetWindowsPowerShellAfterLaunchFailure } from '../shell-launch/windows/powershell-search' let didEnsureSpawnHelperExecutable = false @@ -144,6 +145,9 @@ function spawnWindowsFallbackChain( ): ShellSpawnResult | null { const { termName = 'xterm-256color', cols, rows, env, ptySpawn } = params const attempts = params.windowsFallbackAttempts ?? [] + if (attempts.length > 0) { + forgetWindowsPowerShellAfterLaunchFailure(params.shellPath) + } // Skip the first entry: it is the primary that already failed above. for (const attempt of attempts.slice(1)) { try { diff --git a/src/main/providers/local-pty/local-pty-launch-plan.ts b/src/main/providers/local-pty/local-pty-launch-plan.ts index fa1bd9471cf..6864449a985 100644 --- a/src/main/providers/local-pty/local-pty-launch-plan.ts +++ b/src/main/providers/local-pty/local-pty-launch-plan.ts @@ -18,7 +18,10 @@ import { shouldProbeWindowsPowerShellAvailability, type WindowsPowerShellShellFamily } from '../shell-launch/windows/powershell' -import { buildWindowsPowerShellSpawnAttempts } from '../shell-launch/windows/shell-fallback-chain' +import { + buildWindowsPowerShellSpawnAttempts, + type WindowsShellSpawnAttempt +} from '../shell-launch/windows/shell-fallback-chain' import { resolveWindowsShellLaunchArgs } from '../shell-launch/windows/shell-args' import { assertSafeAgentStartupCwd } from '../working-directory/default-cwd' import type { PtySpawnOptions } from '../pty/provider-contract' @@ -48,7 +51,7 @@ export type LocalPtyLaunchPlan = { effectiveCwd: string validationCwd: string startupCommandDeliveredInShellArgs: boolean - windowsFallbackAttempts: ReturnType + windowsFallbackAttempts: WindowsShellSpawnAttempt[] shellReadyLaunch: ReturnType | null getFallbackShellReadyConfig: | ((shell: string) => ReturnType) @@ -62,7 +65,7 @@ export type LocalPtyLaunchPlan = { export class DeferredLocalPtyLaunchPlan { constructor( readonly availability: boolean | Promise, - readonly finish: (available: boolean) => LocalPtyLaunchPlan + readonly finish: (available: boolean) => Promise ) {} } @@ -74,7 +77,7 @@ function finalizeLocalPtyLaunchPlan( effectiveCwd: string validationCwd: string startupCommandDeliveredInShellArgs?: boolean - windowsFallbackAttempts?: ReturnType + windowsFallbackAttempts?: WindowsShellSpawnAttempt[] } ): LocalPtyLaunchPlan { ensureNodePtySpawnHelperExecutable() @@ -133,7 +136,7 @@ function createWindowsLocalPtyLaunchPlan( }) const shouldResolvePowerShellFamily = powerShellImplementation !== undefined || pathWin32.basename(shellFamily) === shellFamily - const finish = (pwshAvailable: boolean): LocalPtyLaunchPlan => { + const finish = async (pwshAvailable: boolean): Promise => { let shellPath: string if (resolvedGitBashPath) { shellPath = resolvedGitBashPath @@ -149,12 +152,17 @@ function createWindowsLocalPtyLaunchPlan( : shellFamily } // Bare pwsh.exe may resolve to a failing Store alias; use absolute executable/fallback attempts. - const windowsFallbackAttempts = buildWindowsPowerShellSpawnAttempts({ + const windowsFallbackAttempts = await buildWindowsPowerShellSpawnAttempts({ shellPath, cwd, defaultCwd, wslContext: seed.launchWslContext, - startupCommand: args.command + startupCommand: args.command, + // Main's env plus the spawn's own; PATH here is what the shell will see. + env: args.env ? { ...process.env, ...args.env } : process.env, + requestedShell: shellFamily, + powerShellImplementation, + pwshNotFoundNotices: args.terminalWindowsPwshNotFoundNotices }) const primaryAttempt = windowsFallbackAttempts[0] if (primaryAttempt) { @@ -183,9 +191,11 @@ function createWindowsLocalPtyLaunchPlan( windowsFallbackAttempts }) } - return shouldProbePwsh - ? new DeferredLocalPtyLaunchPlan(getOptions().pwshAvailable?.() ?? false, finish) - : finish(false) + // Locating PowerShell is async, so every Windows plan defers. + return new DeferredLocalPtyLaunchPlan( + shouldProbePwsh ? (getOptions().pwshAvailable?.() ?? false) : false, + finish + ) } export function createLocalPtyLaunchPlan( diff --git a/src/main/providers/local-pty/local-pty-provider-shutdown.test.ts b/src/main/providers/local-pty/local-pty-provider-shutdown.test.ts index 1b38279902a..ebbbcdf7918 100644 --- a/src/main/providers/local-pty/local-pty-provider-shutdown.test.ts +++ b/src/main/providers/local-pty/local-pty-provider-shutdown.test.ts @@ -86,8 +86,6 @@ const WINDOWS_POWERSHELL_ABS = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\ const PWSH7_ABS = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' const CMD_ABS = 'C:\\Windows\\System32\\cmd.exe' vi.mock('../shell-launch/windows/powershell-executable', () => ({ - resolveWindowsPowerShellExecutablePath: (family: 'pwsh.exe' | 'powershell.exe') => - family === 'pwsh.exe' ? PWSH7_ABS : WINDOWS_POWERSHELL_ABS, resolveWindowsPowerShellSpawnChain: (family: 'pwsh.exe' | 'powershell.exe') => family === 'pwsh.exe' ? [PWSH7_ABS, WINDOWS_POWERSHELL_ABS, CMD_ABS] diff --git a/src/main/providers/local-pty/local-pty-provider-spawn-session.test.ts b/src/main/providers/local-pty/local-pty-provider-spawn-session.test.ts index 58595d543bf..9915ad0d367 100644 --- a/src/main/providers/local-pty/local-pty-provider-spawn-session.test.ts +++ b/src/main/providers/local-pty/local-pty-provider-spawn-session.test.ts @@ -86,8 +86,6 @@ const WINDOWS_POWERSHELL_ABS = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\ const PWSH7_ABS = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' const CMD_ABS = 'C:\\Windows\\System32\\cmd.exe' vi.mock('../shell-launch/windows/powershell-executable', () => ({ - resolveWindowsPowerShellExecutablePath: (family: 'pwsh.exe' | 'powershell.exe') => - family === 'pwsh.exe' ? PWSH7_ABS : WINDOWS_POWERSHELL_ABS, resolveWindowsPowerShellSpawnChain: (family: 'pwsh.exe' | 'powershell.exe') => family === 'pwsh.exe' ? [PWSH7_ABS, WINDOWS_POWERSHELL_ABS, CMD_ABS] diff --git a/src/main/providers/local-pty/local-pty-spawn.ts b/src/main/providers/local-pty/local-pty-spawn.ts index 5d9e928077a..63f9043fe17 100644 --- a/src/main/providers/local-pty/local-pty-spawn.ts +++ b/src/main/providers/local-pty/local-pty-spawn.ts @@ -64,7 +64,7 @@ export async function spawnLocalPty( if (plan instanceof DeferredLocalPtyLaunchPlan) { const available = await plan.availability throwIfCanceled() - plan = plan.finish(available) + plan = await plan.finish(available) } throwIfCanceled() // Daemon-off (the daemon failed to start) a restore spawns here: same E9 hold as the daemon's. diff --git a/src/main/providers/pty/provider-contract.ts b/src/main/providers/pty/provider-contract.ts index 19510b49323..d7d1cce5594 100644 --- a/src/main/providers/pty/provider-contract.ts +++ b/src/main/providers/pty/provider-contract.ts @@ -21,6 +21,7 @@ import type { PtyProcessInfo } from './process-info' import type { TerminalExitCause } from '../../../shared/terminal/pty/lifecycle/terminal-exit-cause' import type { TerminalOwner } from '../../../shared/terminal/pty/lifecycle/terminal-owner' import type { WriteSettlement } from '../../../shared/terminal/pty/lifecycle/pty-write-settlement' +import type { WindowsPwshNotFoundNotices } from '../shell-launch/windows/pwsh-not-found-notice' export type PtyProviderBufferSnapshot = { data: string @@ -86,6 +87,8 @@ export type PtySpawnOptions = { terminalWindowsWslDistro?: string | null /** Persisted inbox/pwsh choice keeps local and daemon PowerShell-family launches aligned. */ terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' + /** Translated by main so a fallback shell can say PowerShell 7 was not found in the UI language. */ + terminalWindowsPwshNotFoundNotices?: WindowsPwshNotFoundNotices /** Fresh-spawn-only source authority installed before any PTY output is released. */ startupIngress?: PtyStartupIngressIntent /** Why the caller is starting this terminal; presence lets the host defer an automatic restore. */ diff --git a/src/main/providers/shell-launch/windows/powershell-executable.test.ts b/src/main/providers/shell-launch/windows/powershell-executable.test.ts new file mode 100644 index 00000000000..748f4d921a7 --- /dev/null +++ b/src/main/providers/shell-launch/windows/powershell-executable.test.ts @@ -0,0 +1,366 @@ +import { win32 as pathWin32 } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { resolveWindowsPowerShellSpawnChain } from './powershell-executable' +import { forgetWindowsPowerShellAfterLaunchFailure } from './powershell-search' +import { buildWindowsPowerShellSpawnAttempts } from './shell-fallback-chain' + +// A simulated machine: real installs are never touched. +type SimulatedAppPaths = { machine?: string; user?: string } + +const machine = vi.hoisted(() => { + const appPaths: SimulatedAppPaths = {} + return { + files: new Map(), + aliases: new Map(), + // Paths whose stat never settles, like an unreachable network share. + unresponsive: new Set(), + appPaths + } +}) + +function key(path: string): string { + return pathWin32.normalize(path).toLowerCase() +} + +function enoent(path: string): Error { + return Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' }) +} + +vi.mock('node:fs/promises', () => ({ + stat: async (path: string) => { + if (machine.unresponsive.has(key(path))) { + return new Promise(() => {}) + } + if (!machine.files.has(key(path))) { + throw enoent(path) + } + return { isFile: () => true, size: 4096 } + }, + readlink: async (path: string) => { + const target = machine.aliases.get(key(path)) + if (!target) { + throw enoent(path) + } + return target + }, + readdir: async (dir: string) => { + const prefix = `${key(dir)}\\` + const names = new Set() + for (const original of [...machine.files.values(), ...machine.aliases.keys()]) { + const normalized = pathWin32.normalize(original) + if (normalized.toLowerCase().startsWith(prefix)) { + names.add(normalized.slice(prefix.length).split('\\')[0]) + } + } + if (names.size === 0) { + throw enoent(dir) + } + return [...names] + } +})) + +vi.mock('../../../windows/windows-native-registry', async (importOriginal) => ({ + ...(await importOriginal()), + loadWindowsNativeRegistry: () => ({ + HK: { CU: 1, LM: 2 }, + getRegistryKey: (root: number) => { + const value = root === 2 ? machine.appPaths.machine : machine.appPaths.user + return value === undefined ? null : { '': { name: '', type: 2, value } } + } + }) +})) + +const ENV: NodeJS.ProcessEnv = { + ProgramW6432: 'C:\\Program Files', + ProgramFiles: 'C:\\Program Files', + 'ProgramFiles(x86)': 'C:\\Program Files (x86)', + LOCALAPPDATA: 'C:\\Users\\dev\\AppData\\Local', + USERPROFILE: 'C:\\Users\\dev', + SystemRoot: 'C:\\Windows', + ComSpec: 'C:\\Windows\\System32\\cmd.exe', + Path: 'C:\\Windows\\System32' +} +const WINDOWS_POWERSHELL = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' +const CMD = 'C:\\Windows\\System32\\cmd.exe' +const WINDOWS_APPS = 'C:\\Users\\dev\\AppData\\Local\\Microsoft\\WindowsApps' +const STORE_EXE = + 'C:\\Program Files\\WindowsApps\\Microsoft.PowerShell_7.6.3.0_x64__8wekyb3d8bbwe\\pwsh.exe' + +function install(...paths: string[]): void { + for (const path of paths) { + machine.files.set(key(path), path) + } +} + +function uninstall(path: string): void { + machine.files.delete(key(path)) +} + +function alias(path: string, target: string): void { + machine.aliases.set(key(path), target) +} + +let searchId = 0 +// Each test gets its own setting so one test's cached search never answers another's. +function freshSetting(): string { + searchId += 1 + return `pwsh.exe|test-${searchId}` +} + +async function findPwsh( + options: { env?: NodeJS.ProcessEnv; settingKey?: string } = {} +): Promise { + const chain = await resolveWindowsPowerShellSpawnChain('pwsh.exe', { + platform: 'win32', + env: options.env ?? ENV, + settingKey: options.settingKey ?? freshSetting() + }) + return chain[0] +} + +beforeEach(() => { + machine.files.clear() + machine.aliases.clear() + machine.unresponsive.clear() + machine.appPaths = {} + install(WINDOWS_POWERSHELL, CMD) + // This process's own PATH is the stale one; tests put fresh entries on the terminal env. + vi.stubEnv('PATH', 'C:\\Windows\\System32') + vi.stubEnv('Path', 'C:\\Windows\\System32') +}) + +afterEach(() => { + vi.unstubAllEnvs() +}) + +describe('PowerShell 7 discovery sources', () => { + it('picks the highest Program Files version across 64-bit and 32-bit roots', async () => { + install( + 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', + 'C:\\Program Files (x86)\\PowerShell\\6\\pwsh.exe', + 'C:\\Program Files (x86)\\PowerShell\\8\\pwsh.exe' + ) + expect(await findPwsh()).toBe('C:\\Program Files (x86)\\PowerShell\\8\\pwsh.exe') + }) + + it('finds a per-user install', async () => { + install('C:\\Users\\dev\\AppData\\Local\\Microsoft\\PowerShell\\7\\pwsh.exe') + expect(await findPwsh()).toBe( + 'C:\\Users\\dev\\AppData\\Local\\Microsoft\\PowerShell\\7\\pwsh.exe' + ) + }) + + it('finds pwsh on the terminal PATH when this process PATH is stale', async () => { + install('D:\\Tools\\pwsh\\pwsh.exe') + const env = { ...ENV, Path: 'relative-tools;"D:\\Tools\\pwsh";C:\\Windows\\System32' } + expect(await findPwsh({ env })).toBe('D:\\Tools\\pwsh\\pwsh.exe') + }) + + it('reads both PATH spellings when a merged env carries PATH and Path', async () => { + install('D:\\Second\\pwsh.exe') + const env = { ...ENV, PATH: 'C:\\Windows\\System32', Path: 'D:\\Second' } + expect(await findPwsh({ env })).toBe('D:\\Second\\pwsh.exe') + }) + + it('stops waiting on an unresponsive PATH entry and uses the install it found', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + try { + install('C:\\Program Files\\PowerShell\\7\\pwsh.exe') + machine.unresponsive.add(key('\\\\slow-share\\tools\\pwsh.exe')) + const found = findPwsh({ env: { ...ENV, Path: '\\\\slow-share\\tools' } }) + await vi.advanceTimersByTimeAsync(1_000) + expect(await found).toBe('C:\\Program Files\\PowerShell\\7\\pwsh.exe') + } finally { + vi.useRealTimers() + } + }) + + it('searches again once a timed-out only install starts responding', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + try { + const settingKey = freshSetting() + const env = { ...ENV, Path: '\\\\slow-share\\tools' } + install('\\\\slow-share\\tools\\pwsh.exe') + machine.unresponsive.add(key('\\\\slow-share\\tools\\pwsh.exe')) + const first = findPwsh({ env, settingKey }) + await vi.advanceTimersByTimeAsync(1_000) + expect(await first).toBe(WINDOWS_POWERSHELL) + + machine.unresponsive.clear() + expect(await findPwsh({ env, settingKey })).toBe('\\\\slow-share\\tools\\pwsh.exe') + } finally { + vi.useRealTimers() + } + }) + + it('reads the App Paths registration, machine before user', async () => { + install('C:\\Program Files\\Custom PS\\pwsh.exe', 'D:\\User PS\\pwsh.exe') + machine.appPaths = { + machine: '"%ProgramFiles%\\Custom PS\\pwsh.exe"', + user: 'D:\\User PS\\pwsh.exe' + } + expect(await findPwsh()).toBe('C:\\Program Files\\Custom PS\\pwsh.exe') + }) + + it('resolves the Store install when WindowsApps is not on PATH', async () => { + install(STORE_EXE) + alias(`${WINDOWS_APPS}\\Microsoft.PowerShell_8wekyb3d8bbwe\\pwsh.exe`, STORE_EXE) + expect(await findPwsh()).toBe(STORE_EXE) + }) + + it.each([ + 'C:\\Users\\dev\\.dotnet\\tools\\pwsh.exe', + 'C:\\Users\\dev\\scoop\\apps\\pwsh\\current\\pwsh.exe' + ])('finds the user tool install %s', async (path) => { + install(path) + expect(await findPwsh()).toBe(path) + }) + + it('never launches the zero-byte Store alias itself', async () => { + install(`${WINDOWS_APPS}\\pwsh.exe`) + expect(await findPwsh()).toBe(WINDOWS_POWERSHELL) + }) + + it.each(['pwsh.exe', `${WINDOWS_APPS}\\pwsh.exe`, 'C:\\Tools\\powershell.exe'])( + 'rejects unsafe Store alias target %s', + async (target) => { + install(target) + alias(`${WINDOWS_APPS}\\pwsh.exe`, target) + expect(await findPwsh()).toBe(WINDOWS_POWERSHELL) + } + ) + + it('uses a preview build only when no stable build exists', async () => { + const preview = 'C:\\Program Files\\PowerShell\\7-preview\\pwsh.exe' + install(preview) + expect(await findPwsh()).toBe(preview) + + install('C:\\Users\\dev\\.dotnet\\tools\\pwsh.exe') + expect(await findPwsh()).toBe('C:\\Users\\dev\\.dotnet\\tools\\pwsh.exe') + }) +}) + +describe('PowerShell 7 search cache', () => { + const programFilesPwsh = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' + const perUserPwsh = 'C:\\Users\\dev\\AppData\\Local\\Microsoft\\PowerShell\\7\\pwsh.exe' + + it('reuses the found path until the shell setting changes', async () => { + const settingKey = freshSetting() + install(programFilesPwsh) + expect(await findPwsh({ settingKey })).toBe(programFilesPwsh) + + uninstall(programFilesPwsh) + install(perUserPwsh) + expect(await findPwsh({ settingKey })).toBe(programFilesPwsh) + expect(await findPwsh({ settingKey: freshSetting() })).toBe(perUserPwsh) + }) + + it('searches again after the cached path fails to launch', async () => { + const settingKey = freshSetting() + install(programFilesPwsh) + expect(await findPwsh({ settingKey })).toBe(programFilesPwsh) + + uninstall(programFilesPwsh) + install(perUserPwsh) + forgetWindowsPowerShellAfterLaunchFailure(programFilesPwsh) + expect(await findPwsh({ settingKey })).toBe(perUserPwsh) + }) + + it('keeps a miss for the same PATH but searches again once the terminal PATH changes', async () => { + const settingKey = freshSetting() + expect(await findPwsh({ settingKey })).toBe(WINDOWS_POWERSHELL) + + install('D:\\New\\pwsh.exe') + expect(await findPwsh({ settingKey })).toBe(WINDOWS_POWERSHELL) + const env = { ...ENV, Path: `${ENV.Path};D:\\New` } + expect(await findPwsh({ settingKey, env })).toBe('D:\\New\\pwsh.exe') + }) +}) + +describe('PowerShell 7 search warm-up', () => { + it('lets the first terminal find pwsh on its PATH after a warm-up saw a stale PATH', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')! + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + vi.resetModules() + const search = await import('./powershell-search') + install('D:\\Fresh\\pwsh.exe') + search.warmWindowsPwshSearch() + const env = { ...ENV, Path: 'D:\\Fresh;C:\\Windows\\System32' } + expect(await search.findWindowsPwsh({ env, settingKey: 'powershell.exe|pwsh.exe' })).toBe( + 'D:\\Fresh\\pwsh.exe' + ) + } finally { + Object.defineProperty(process, 'platform', originalPlatform) + } + }) +}) + +describe('PowerShell 7 spawn attempts', () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')! + + beforeEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', originalPlatform) + }) + + function attempts( + requestedShell: string, + implementation: string, + pwshNotFoundNotices?: { windowsPowerShell: string; commandPrompt: string } + ) { + return buildWindowsPowerShellSpawnAttempts({ + pwshNotFoundNotices, + shellPath: 'pwsh.exe', + cwd: 'C:\\repo', + defaultCwd: 'C:\\repo', + env: ENV, + requestedShell, + powerShellImplementation: implementation + }) + } + + // The fallback shell prints the notice itself, so it rides ConPTY like any other output. + function startupScript(attempt: { shellPath: string; shellArgs: string[] }): string { + return attempt.shellPath === CMD + ? attempt.shellArgs[1] + : Buffer.from(attempt.shellArgs[3], 'base64').toString('utf16le') + } + + it('has each fallback shell announce that PowerShell 7 was not found', async () => { + const plan = await attempts('powershell.exe', `missing-${freshSetting()}`) + expect(plan.map((attempt) => attempt.shellPath)).toEqual([WINDOWS_POWERSHELL, CMD]) + expect(startupScript(plan[0])).toMatch(/^Write-Host '\[Orca .*\]' -ForegroundColor Yellow\n/) + expect(startupScript(plan[1])).toMatch(/ & echo \[Orca .*\]$/) + }) + + it('prints the notice main translated, kept literal in cmd', async () => { + const plan = await attempts('powershell.exe', `translated-${freshSetting()}`, { + windowsPowerShell: 'PowerShell 7 fehlt, Windows PowerShell geöffnet.', + commandPrompt: 'PowerShell 7 fehlt & Eingabeaufforderung (cmd) geöffnet.' + }) + expect(startupScript(plan[0])).toMatch( + /^Write-Host '\[PowerShell 7 fehlt, Windows PowerShell geöffnet\.\]' -ForegroundColor Yellow\n/ + ) + expect(startupScript(plan[1])).toMatch( + / & echo \[PowerShell 7 fehlt \^& Eingabeaufforderung \^\(cmd\^\) geöffnet\.\]$/ + ) + }) + + it('opens PowerShell 7 without a notice when it is found', async () => { + install('C:\\Program Files\\PowerShell\\7\\pwsh.exe') + const plan = await attempts('powershell.exe', `found-${freshSetting()}`) + expect(plan[0].shellPath).toBe('C:\\Program Files\\PowerShell\\7\\pwsh.exe') + expect(plan.map(startupScript).join('\n')).not.toContain('[Orca') + }) + + it('prefers a preview build when the shell setting points at one', async () => { + const preview = 'C:\\Program Files\\PowerShell\\7-preview\\pwsh.exe' + install('C:\\Program Files\\PowerShell\\7\\pwsh.exe', preview) + const plan = await attempts(preview, `preview-${freshSetting()}`) + expect(plan[0].shellPath).toBe(preview) + }) +}) diff --git a/src/main/providers/shell-launch/windows/powershell-executable.ts b/src/main/providers/shell-launch/windows/powershell-executable.ts index e96e577e2cb..d223affc8a6 100644 --- a/src/main/providers/shell-launch/windows/powershell-executable.ts +++ b/src/main/providers/shell-launch/windows/powershell-executable.ts @@ -1,196 +1,49 @@ -import { existsSync, readlinkSync, statSync } from 'node:fs' import { win32 as pathWin32 } from 'node:path' +import { readWindowsEnv } from './pwsh-discovery' +import { + findWindowsPowerShell, + findWindowsPwsh, + type WindowsPowerShellResolveOptions +} from './powershell-search' -export type WindowsPowerShellResolveOptions = { - env?: NodeJS.ProcessEnv - /** Returns true when the candidate path is a real, non-empty executable. */ - isRealExecutable?: (path: string) => boolean - /** Resolves a Store App Execution Alias to its package executable. */ - resolveAppExecutionAlias?: (path: string) => string | null - platform?: NodeJS.Platform -} - -/** Store alias reparse stubs may launch through execFile PATH lookup but fail ConPTY absolute CreateProcessW (5). */ -function isWindowsAppExecutionAliasPath(candidate: string): boolean { - return /[\\/]Microsoft[\\/]WindowsApps[\\/]/i.test(pathWin32.normalize(candidate)) -} - -/** Reject zero-byte alias stubs even if future Windows versions move them outside WindowsApps. */ -function defaultIsRealExecutable(candidate: string): boolean { - if (isWindowsAppExecutionAliasPath(candidate)) { - return false - } - try { - if (!existsSync(candidate)) { - return false - } - const stat = statSync(candidate) - return stat.isFile() && stat.size > 0 - } catch { - return false - } -} - -function defaultResolveAppExecutionAlias(candidate: string): string | null { - try { - const target = readlinkSync(candidate) - return pathWin32.resolve(pathWin32.dirname(candidate), target) - } catch { - return null - } -} - -function readEnv(env: NodeJS.ProcessEnv, names: string[]): string | undefined { - for (const name of names) { - const value = env[name] - if (value) { - return value - } - } - return undefined -} - -function getPathExecutableCandidates(env: NodeJS.ProcessEnv, executable: string): string[] { - const pathValue = readEnv(env, ['PATH', 'Path', 'path']) - if (!pathValue) { - return [] - } - - const candidates: string[] = [] - const seen = new Set() - for (const rawPart of pathValue.split(pathWin32.delimiter)) { - const dir = rawPart.trim().replace(/^"|"$/g, '') - // Relative PATH entries enable repo-local shell hijacking; accept only absolute directories. - if (!pathWin32.isAbsolute(dir)) { - continue - } - pushUniqueCandidate(candidates, seen, pathWin32.join(dir, executable)) - } - return candidates -} - -function pushUniqueCandidate( - candidates: string[], - seen: Set, - candidate: string | undefined -): void { - if (!candidate) { - return - } - const normalized = pathWin32.normalize(candidate) - const key = normalized.toLowerCase() - if (!seen.has(key)) { - seen.add(key) - candidates.push(normalized) - } -} - -function getPwshCandidatePaths(env: NodeJS.ProcessEnv): string[] { - const candidates: string[] = [] - const seen = new Set() - const programFilesRoots = [ - readEnv(env, ['ProgramW6432', 'PROGRAMW6432']), - readEnv(env, ['ProgramFiles', 'PROGRAMFILES']), - readEnv(env, ['ProgramFiles(x86)', 'PROGRAMFILES(X86)']) - ] - for (const root of programFilesRoots) { - if (!root) { - continue - } - // Prefer major 7 while probing adjacent 8/6 install directories for compatibility. - for (const major of ['7', '8', '6']) { - pushUniqueCandidate(candidates, seen, pathWin32.join(root, 'PowerShell', major, 'pwsh.exe')) - } - } - const localAppData = readEnv(env, ['LOCALAPPDATA', 'LocalAppData']) - if (localAppData) { - for (const major of ['7', '8', '6']) { - pushUniqueCandidate( - candidates, - seen, - pathWin32.join(localAppData, 'Microsoft', 'PowerShell', major, 'pwsh.exe') - ) - } - } - for (const candidate of getPathExecutableCandidates(env, 'pwsh.exe')) { - pushUniqueCandidate(candidates, seen, candidate) - } - return candidates -} - -function getWindowsPowerShellPath(env: NodeJS.ProcessEnv): string { - const systemRoot = readEnv(env, ['SystemRoot', 'WINDIR', 'windir']) || 'C:\\Windows' - return pathWin32.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') -} +export type { WindowsPowerShellResolveOptions } export function getWindowsCmdPath(env: NodeJS.ProcessEnv = process.env): string { - const comspec = readEnv(env, ['ComSpec', 'COMSPEC']) + const comspec = readWindowsEnv([env], ['ComSpec', 'COMSPEC']) if (comspec) { return comspec } - const systemRoot = readEnv(env, ['SystemRoot', 'WINDIR', 'windir']) || 'C:\\Windows' + const systemRoot = readWindowsEnv([env], ['SystemRoot', 'WINDIR', 'windir']) || 'C:\\Windows' return pathWin32.join(systemRoot, 'System32', 'cmd.exe') } -/** Null when neither a real executable nor safe Store package target resolves; callers try the next shell. */ -export function resolveWindowsPowerShellExecutablePath( - family: 'pwsh.exe' | 'powershell.exe', - options: WindowsPowerShellResolveOptions = {} -): string | null { - const platform = options.platform ?? process.platform - if (platform !== 'win32') { - return null - } - const env = options.env ?? process.env - const isRealExecutable = options.isRealExecutable ?? defaultIsRealExecutable - const resolveAppExecutionAlias = - options.resolveAppExecutionAlias ?? defaultResolveAppExecutionAlias - - if (family === 'powershell.exe') { - const windowsPowerShell = getWindowsPowerShellPath(env) - return isRealExecutable(windowsPowerShell) ? windowsPowerShell : null - } - - for (const candidate of getPwshCandidatePaths(env)) { - if (isWindowsAppExecutionAliasPath(candidate)) { - const target = resolveAppExecutionAlias(candidate) - // ConPTY cannot launch the alias itself; its real package target preserves the requested pwsh. - if ( - target && - pathWin32.isAbsolute(target) && - pathWin32.basename(target).toLowerCase() === 'pwsh.exe' && - !isWindowsAppExecutionAliasPath(target) && - isRealExecutable(target) - ) { - return pathWin32.normalize(target) - } - continue - } - if (isRealExecutable(candidate)) { - return candidate - } - } - return null -} - /** Deduplicated requested PowerShell → inbox PowerShell → cmd.exe fallback paths. */ -export function resolveWindowsPowerShellSpawnChain( +export async function resolveWindowsPowerShellSpawnChain( requestedFamily: 'pwsh.exe' | 'powershell.exe', options: WindowsPowerShellResolveOptions = {} -): string[] { +): Promise { const env = options.env ?? process.env - const chain: string[] = [] - const seen = new Set() - const add = (candidate: string | null): void => { - if (candidate) { - pushUniqueCandidate(chain, seen, candidate) - } - } - - add(resolveWindowsPowerShellExecutablePath(requestedFamily, options)) - // Inbox PowerShell avoids Store alias launch hazards. - add(resolveWindowsPowerShellExecutablePath('powershell.exe', options)) + const isWindows = (options.platform ?? process.platform) === 'win32' + const [pwsh, windowsPowerShell] = isWindows + ? await Promise.all([ + requestedFamily === 'pwsh.exe' ? findWindowsPwsh(options) : null, + // Inbox PowerShell avoids Store alias launch hazards. + findWindowsPowerShell(env) + ]) + : [null, null] // cmd.exe remains the last attempt when policy blocks both PowerShell variants. - pushUniqueCandidate(chain, seen, getWindowsCmdPath(env)) + const chain = [pwsh, windowsPowerShell, getWindowsCmdPath(env)].filter( + (candidate): candidate is string => Boolean(candidate) + ) + const seen = new Set() return chain + .map((candidate) => pathWin32.normalize(candidate)) + .filter((candidate) => { + const key = candidate.toLowerCase() + if (seen.has(key)) { + return false + } + seen.add(key) + return true + }) } diff --git a/src/main/providers/shell-launch/windows/powershell-search.ts b/src/main/providers/shell-launch/windows/powershell-search.ts new file mode 100644 index 00000000000..b3d9641829b --- /dev/null +++ b/src/main/providers/shell-launch/windows/powershell-search.ts @@ -0,0 +1,139 @@ +import { win32 as pathWin32 } from 'node:path' +import { + discoverWindowsPwshInstalls, + isRealWindowsExecutable, + readWindowsEnv, + readWindowsPathValue, + selectWindowsPwsh, + type WindowsPwshInstall +} from './pwsh-discovery' + +export type WindowsPowerShellResolveOptions = { + /** The environment the terminal will get. */ + env?: NodeJS.ProcessEnv + /** The requested shell setting; a different value searches again. */ + settingKey?: string + preferPreview?: boolean + platform?: NodeJS.Platform +} + +type PwshSearch = { + /** Null for a warm-up search, which the first request's setting adopts. */ + settingKey: string | null + searchedPath: string + installs: Promise + settled?: WindowsPwshInstall[] + /** False when a probe missed its deadline: an empty result is then not a confirmed miss. */ + complete?: boolean + missLogged?: boolean +} + +// Cached per process (main and the terminal daemon each keep one) so tab opens never search the disk. +let pwshSearch: PwshSearch | null = null +let windowsPowerShellFound: string | null = null + +function readPath(env: NodeJS.ProcessEnv): string { + return readWindowsPathValue(env) +} + +function startPwshSearch( + settingKey: string | null, + options: WindowsPowerShellResolveOptions +): PwshSearch { + const env = options.env ?? process.env + const search: PwshSearch = { + settingKey, + searchedPath: readPath(env), + installs: discoverWindowsPwshInstalls(env).then( + (discovery) => { + search.settled = discovery.installs + search.complete = discovery.complete + return discovery.installs + }, + (error: unknown) => { + // A failed search must not pin the result; the next spawn searches again. + if (pwshSearch === search) { + pwshSearch = null + } + console.warn('[pty] PowerShell 7 (pwsh.exe) search failed:', error) + return [] + } + ) + } + return search +} + +/** Searches once per shell setting; a miss is searched again only when the terminal's PATH differs. */ +export async function findWindowsPwsh( + options: WindowsPowerShellResolveOptions +): Promise { + const settingKey = options.settingKey ?? '' + const requestPath = readPath(options.env ?? process.env) + if (pwshSearch?.settingKey === null) { + pwshSearch.settingKey = settingKey + } + let search = pwshSearch + if (!search || search.settingKey !== settingKey) { + search = startPwshSearch(settingKey, options) + pwshSearch = search + } + let installs = await search.installs + // A daemon started before an install sees a stale PATH; the terminal's PATH may know better. + if (installs.length === 0 && search.searchedPath !== requestPath) { + if (pwshSearch === search || pwshSearch?.settingKey !== settingKey) { + pwshSearch = startPwshSearch(settingKey, options) + } + search = pwshSearch + installs = await search.installs + } + if (installs.length === 0 && search.complete === false) { + // Only an unanswered probe kept this empty; the next terminal searches again. + if (pwshSearch === search) { + pwshSearch = null + } + } else if (installs.length === 0 && !search.missLogged) { + search.missLogged = true + console.warn( + '[pty] PowerShell 7 (pwsh.exe) was not found in Program Files, the per-user install folder, PATH, App Paths, the Store install, or user tool folders' + ) + } + return selectWindowsPwsh(installs, options.preferPreview === true) +} + +/** Starts the search before the first terminal asks, so that terminal opens no slower. */ +export function warmWindowsPwshSearch(): void { + if (process.platform === 'win32' && !pwshSearch) { + pwshSearch = startPwshSearch(null, {}) + } +} + +export async function findWindowsPowerShell(env: NodeJS.ProcessEnv): Promise { + const systemRoot = readWindowsEnv([env], ['SystemRoot', 'WINDIR', 'windir']) || 'C:\\Windows' + const candidate = pathWin32.join( + systemRoot, + 'System32', + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe' + ) + if (windowsPowerShellFound === candidate) { + return candidate + } + // Only a hit is cached: a missing inbox shell is rare and must not stick. + if (!(await isRealWindowsExecutable(candidate))) { + return null + } + windowsPowerShellFound = candidate + return candidate +} + +/** A launch of a cached shell failed: search again on the next spawn. */ +export function forgetWindowsPowerShellAfterLaunchFailure(shellPath: string): void { + const failed = pathWin32.normalize(shellPath).toLowerCase() + if (pwshSearch?.settled?.some((install) => install.path.toLowerCase() === failed)) { + pwshSearch = null + } + if (windowsPowerShellFound?.toLowerCase() === failed) { + windowsPowerShellFound = null + } +} diff --git a/src/main/providers/shell-launch/windows/pwsh-discovery.ts b/src/main/providers/shell-launch/windows/pwsh-discovery.ts new file mode 100644 index 00000000000..3849833733d --- /dev/null +++ b/src/main/providers/shell-launch/windows/pwsh-discovery.ts @@ -0,0 +1,275 @@ +import { readdir, readlink, stat } from 'node:fs/promises' +import { homedir } from 'node:os' +import { win32 as pathWin32 } from 'node:path' +import { expandWindowsEnvironmentVariables } from '../../../../shared/windows-environment-expansion' +import { + loadWindowsNativeRegistry, + WINDOWS_REG_EXPAND_SZ, + WINDOWS_REG_SZ +} from '../../../windows/windows-native-registry' + +export type WindowsPwshInstall = { path: string; preview: boolean } + +/** `complete` is false when a probe missed the deadline, so an empty result is not a confirmed miss. */ +export type WindowsPwshDiscovery = { installs: WindowsPwshInstall[]; complete: boolean } + +type Candidate = { path: string; alias: boolean } + +const APP_PATHS_PWSH_KEY = 'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\pwsh.exe' +const VERSION_DIRECTORY = /^(\d+)(-preview)?$/i +const STORE_PACKAGE_DIRECTORY = /^Microsoft\.PowerShell(Preview)?_/i +// Local probes finish in milliseconds; this bounds only unreachable network PATH entries. +const PROBE_DEADLINE_MS = 1_000 + +/** Store alias reparse stubs may launch through execFile PATH lookup but fail ConPTY absolute CreateProcessW (5). */ +export function isWindowsAppExecutionAliasPath(candidate: string): boolean { + return /[\\/]Microsoft[\\/]WindowsApps[\\/]/i.test(pathWin32.normalize(candidate)) +} + +export function isPreviewPwshPath(candidate: string): boolean { + return /[\\/]\d+-preview[\\/]|[\\/]Microsoft\.PowerShellPreview_/i.test(candidate) +} + +export function readWindowsEnv( + envs: readonly NodeJS.ProcessEnv[], + names: readonly string[] +): string | undefined { + for (const env of envs) { + for (const name of names) { + const value = env[name] + if (value) { + return value + } + } + } + return undefined +} + +export async function isRealWindowsExecutable(candidate: string): Promise { + // Zero-byte alias stubs are never launchable even if a future Windows moves them. + if (isWindowsAppExecutionAliasPath(candidate)) { + return false + } + try { + const info = await stat(candidate) + return info.isFile() && info.size > 0 + } catch { + return false + } +} + +async function resolveAppExecutionAlias(candidate: string): Promise { + try { + const target = await readlink(candidate) + return pathWin32.resolve(pathWin32.dirname(candidate), target) + } catch { + return null + } +} + +async function listDirectory(directory: string): Promise { + try { + return await readdir(directory) + } catch { + return [] + } +} + +function readAppPathValues(): string[] { + let registry: ReturnType + try { + registry = loadWindowsNativeRegistry() + } catch { + return [] + } + const values: string[] = [] + for (const root of [registry.HK.LM, registry.HK.CU]) { + try { + const entry = registry.getRegistryKey(root, APP_PATHS_PWSH_KEY)?.[''] + if ( + entry && + (entry.type === WINDOWS_REG_SZ || entry.type === WINDOWS_REG_EXPAND_SZ) && + typeof entry.value === 'string' + ) { + values.push(entry.value) + } + } catch { + // An unreadable hive only removes this source. + } + } + return values +} + +function toCandidate(path: string): Candidate { + return { path, alias: isWindowsAppExecutionAliasPath(path) } +} + +/** `\\pwsh.exe` for every numbered install, highest major first. */ +async function versionedInstallCandidates(roots: (string | undefined)[]): Promise { + const uniqueRoots = [...new Set(roots.filter((root): root is string => Boolean(root)))] + const listings = await Promise.all(uniqueRoots.map((root) => listDirectory(root))) + const installs = listings.flatMap((names, rootIndex) => + names.flatMap((name) => { + const match = VERSION_DIRECTORY.exec(name) + return match + ? [ + { + major: Number(match[1]), + path: pathWin32.join(uniqueRoots[rootIndex], name, 'pwsh.exe') + } + ] + : [] + }) + ) + // Stable sort keeps 64-bit roots ahead of 32-bit ones for the same major. + installs.sort((a, b) => b.major - a.major) + return installs.map((install) => toCandidate(install.path)) +} + +/** Every PATH spelling in key order: a merged env object can hold both `PATH` and `Path`. */ +export function readWindowsPathValue(env: NodeJS.ProcessEnv): string { + return Object.keys(env) + .filter((name) => name.toLowerCase() === 'path') + .map((name) => env[name]) + .filter((value): value is string => Boolean(value)) + .join(pathWin32.delimiter) +} + +function pathDirectoryCandidates(envs: readonly NodeJS.ProcessEnv[]): Candidate[] { + const candidates: Candidate[] = [] + for (const env of envs) { + for (const rawPart of readWindowsPathValue(env).split(pathWin32.delimiter)) { + const dir = rawPart.trim().replace(/^"|"$/g, '') + // Relative PATH entries enable repo-local shell hijacking; accept only absolute directories. + if (pathWin32.isAbsolute(dir)) { + candidates.push(toCandidate(pathWin32.join(dir, 'pwsh.exe'))) + } + } + } + return candidates +} + +function appPathCandidates(terminalEnv: NodeJS.ProcessEnv): Candidate[] { + const expansionEnv = { ...process.env, ...terminalEnv } + return readAppPathValues() + .map((value) => expandWindowsEnvironmentVariables(value.trim(), expansionEnv)) + .map((value) => value.replace(/^"|"$/g, '')) + .filter((value) => pathWin32.isAbsolute(value)) + .map(toCandidate) +} + +async function storeCandidates(windowsApps: string | undefined): Promise { + if (!windowsApps) { + return [] + } + const packageDirs = (await listDirectory(windowsApps)).filter((name) => + STORE_PACKAGE_DIRECTORY.test(name) + ) + return [ + pathWin32.join(windowsApps, 'pwsh.exe'), + ...packageDirs.flatMap((name) => [ + pathWin32.join(windowsApps, name, 'pwsh.exe'), + pathWin32.join(windowsApps, name, 'pwsh-preview.exe') + ]) + ].map(toCandidate) +} + +async function probeCandidate(candidate: Candidate): Promise { + if (!candidate.alias) { + return (await isRealWindowsExecutable(candidate.path)) ? candidate.path : null + } + const target = await resolveAppExecutionAlias(candidate.path) + // ConPTY cannot launch the alias itself; its real package target preserves the requested pwsh. + if ( + target && + pathWin32.isAbsolute(target) && + pathWin32.basename(target).toLowerCase() === 'pwsh.exe' && + !isWindowsAppExecutionAliasPath(target) && + (await isRealWindowsExecutable(target)) + ) { + return pathWin32.normalize(target) + } + return null +} + +/** Every launchable pwsh.exe, best first. Reads only; never starts pwsh. */ +export async function discoverWindowsPwshInstalls( + /** The environment the terminal will get; its PATH outranks this process's possibly stale one. */ + terminalEnv: NodeJS.ProcessEnv +): Promise { + const envs = [terminalEnv, process.env] + const programFilesRoots = [ + ['ProgramW6432', 'PROGRAMW6432'], + ['ProgramFiles', 'PROGRAMFILES'], + ['ProgramFiles(x86)', 'PROGRAMFILES(X86)'] + ].map((names) => { + const root = readWindowsEnv(envs, names) + return root ? pathWin32.join(root, 'PowerShell') : undefined + }) + const localAppData = readWindowsEnv(envs, ['LOCALAPPDATA', 'LocalAppData']) + const home = readWindowsEnv(envs, ['USERPROFILE', 'UserProfile']) ?? homedir() + const [programFiles, perUser, store] = await Promise.all([ + versionedInstallCandidates(programFilesRoots), + versionedInstallCandidates([ + localAppData ? pathWin32.join(localAppData, 'Microsoft', 'PowerShell') : undefined + ]), + storeCandidates( + localAppData ? pathWin32.join(localAppData, 'Microsoft', 'WindowsApps') : undefined + ) + ]) + const ordered = [ + ...programFiles, + ...perUser, + ...pathDirectoryCandidates(envs), + ...appPathCandidates(terminalEnv), + ...store, + toCandidate(pathWin32.join(home, '.dotnet', 'tools', 'pwsh.exe')), + toCandidate(pathWin32.join(home, 'scoop', 'apps', 'pwsh', 'current', 'pwsh.exe')) + ] + const seenCandidates = new Set() + const candidates = ordered.filter((candidate) => { + const key = pathWin32.normalize(candidate.path).toLowerCase() + if (seenCandidates.has(key)) { + return false + } + seenCandidates.add(key) + return true + }) + // A PATH entry on an unresponsive share can stall stat; stop waiting rather than hold the tab. + let timer: ReturnType | undefined + const timedOut = Symbol('timed-out') + const deadline = new Promise((resolve) => { + timer = setTimeout(() => resolve(timedOut), PROBE_DEADLINE_MS) + timer.unref?.() + }) + const resolved = await Promise.all( + candidates.map((candidate) => Promise.race([probeCandidate(candidate), deadline])) + ).finally(() => clearTimeout(timer)) + const installs: WindowsPwshInstall[] = [] + const seenInstalls = new Set() + resolved.forEach((path, index) => { + if (path === timedOut) { + return + } + const key = path?.toLowerCase() + if (!path || !key || seenInstalls.has(key)) { + return + } + seenInstalls.add(key) + installs.push({ + path, + preview: isPreviewPwshPath(path) || isPreviewPwshPath(candidates[index].path) + }) + }) + return { installs, complete: !resolved.includes(timedOut) } +} + +/** Stable unless only a preview exists; the reverse when the setting asks for a preview build. */ +export function selectWindowsPwsh( + installs: readonly WindowsPwshInstall[], + preferPreview: boolean +): string | null { + return ( + (installs.find((install) => install.preview === preferPreview) ?? installs[0])?.path ?? null + ) +} diff --git a/src/main/providers/shell-launch/windows/pwsh-not-found-notice.ts b/src/main/providers/shell-launch/windows/pwsh-not-found-notice.ts new file mode 100644 index 00000000000..b9cd8adf037 --- /dev/null +++ b/src/main/providers/shell-launch/windows/pwsh-not-found-notice.ts @@ -0,0 +1,20 @@ +/** Per fallback shell; translated in main because the terminal daemon has no UI language. */ +export type WindowsPwshNotFoundNotices = { + windowsPowerShell: string + commandPrompt: string +} + +export function buildWindowsPwshNotFoundNotices( + translate: (key: string, fallback: string) => string = (_key, fallback) => fallback +): WindowsPwshNotFoundNotices { + return { + windowsPowerShell: translate( + 'terminal.windowsPwshNotFound.windowsPowerShell', + 'Orca could not find PowerShell 7 (pwsh.exe), so this terminal opened Windows PowerShell instead.' + ), + commandPrompt: translate( + 'terminal.windowsPwshNotFound.commandPrompt', + 'Orca could not find PowerShell 7 (pwsh.exe), so this terminal opened Command Prompt instead.' + ) + } +} diff --git a/src/main/providers/shell-launch/windows/shell-args.ts b/src/main/providers/shell-launch/windows/shell-args.ts index be9e0d81161..d0079a0afab 100644 --- a/src/main/providers/shell-launch/windows/shell-args.ts +++ b/src/main/providers/shell-launch/windows/shell-args.ts @@ -81,14 +81,26 @@ function getPowerShellRestoreCwdCommand(cwd: string): string { ].join('\n') } +/** Translated text must stay one literal echo: caret-escape cmd operators and drop quotes and line breaks. */ +function escapeCmdEchoText(text: string): string { + return text + .replace(/[\r\n]+/g, ' ') + .replace(/"/g, "'") + .replace(/([\^&|<>()%!])/g, '^$1') +} + function getPowerShellEncodedCommand( cwd: string, - startupCommand?: string + startupCommand?: string, + startupNotice?: string ): { encodedCommand: string startupCommandDeliveredInShellArgs?: boolean } { - const bootstrap = `${getPowerShellOsc133Bootstrap()}${getPowerShellRestoreCwdCommand(cwd)}` + const notice = startupNotice + ? `Write-Host ${quoteStartupArg(startupNotice, 'powershell')} -ForegroundColor Yellow\n` + : '' + const bootstrap = `${notice}${getPowerShellOsc133Bootstrap()}${getPowerShellRestoreCwdCommand(cwd)}` if (!startupCommand || startupCommand.length > STARTUP_COMMAND_TEXT_MAX_CHARS) { return { encodedCommand: encodePowerShellCommand(bootstrap) } } @@ -126,7 +138,9 @@ export function resolveWindowsShellLaunchArgs( defaultCwd: string, wslContext?: WindowsShellWslContext, startupCommand?: string, - codexLaunchPreflightCommand?: string + codexLaunchPreflightCommand?: string, + /** Printed by the shell itself so ConPTY's screen model stays in sync. */ + startupNotice?: string ): WindowsShellLaunchArgs { const shellBasename = pathWin32.basename(shellPath).toLowerCase() const nativeCwd = msysDrivePathToWindows(cwd) @@ -135,6 +149,7 @@ export function resolveWindowsShellLaunchArgs( const shellArgStartupCommand = getCmdShellArgStartupCommand(startupCommand) const startupCommands = [ CMD_UTF8_SETUP_COMMAND, + ...(startupNotice ? [`echo ${escapeCmdEchoText(startupNotice)}`] : []), ...(codexLaunchPreflightCommand ? [CMD_CODEX_LAUNCH_PREFLIGHT] : []), ...(shellArgStartupCommand ? [shellArgStartupCommand] : []) ] @@ -147,7 +162,7 @@ export function resolveWindowsShellLaunchArgs( } if (shellBasename === 'powershell.exe' || shellBasename === 'pwsh.exe') { - const powerShellCommand = getPowerShellEncodedCommand(nativeCwd, startupCommand) + const powerShellCommand = getPowerShellEncodedCommand(nativeCwd, startupCommand, startupNotice) // Windows status requires OSC 133 prompt/readline hooks after profiles; encoded delivery follows powershell-osc133-bootstrap.ts MDE review. return { shellArgs: ['-NoLogo', '-NoExit', '-EncodedCommand', powerShellCommand.encodedCommand], diff --git a/src/main/providers/shell-launch/windows/shell-fallback-chain.ts b/src/main/providers/shell-launch/windows/shell-fallback-chain.ts index 967bd0d451f..5dab511a369 100644 --- a/src/main/providers/shell-launch/windows/shell-fallback-chain.ts +++ b/src/main/providers/shell-launch/windows/shell-fallback-chain.ts @@ -1,10 +1,12 @@ import { win32 as pathWin32 } from 'node:path' import { resolveWindowsShellLaunchArgs } from './shell-args' import type { WindowsShellWslContext } from './shell-args' +import { resolveWindowsPowerShellSpawnChain } from './powershell-executable' +import { isPreviewPwshPath } from './pwsh-discovery' import { - resolveWindowsPowerShellSpawnChain, - type WindowsPowerShellResolveOptions -} from './powershell-executable' + buildWindowsPwshNotFoundNotices, + type WindowsPwshNotFoundNotices +} from './pwsh-not-found-notice' export type WindowsShellSpawnAttempt = { shellPath: string @@ -19,14 +21,17 @@ function toAttempt( cwd: string, defaultCwd: string, wslContext: WindowsShellWslContext | undefined, - startupCommand: string | undefined + startupCommand: string | undefined, + startupNotice?: string ): WindowsShellSpawnAttempt { const resolved = resolveWindowsShellLaunchArgs( shellPath, cwd, defaultCwd, wslContext, - startupCommand + startupCommand, + undefined, + startupNotice ) return { shellPath, @@ -37,22 +42,56 @@ function toAttempt( } } +function pwshNotFoundNotice( + fallbackShellPath: string, + notices: WindowsPwshNotFoundNotices +): string { + const notice = + pathWin32.basename(fallbackShellPath).toLowerCase() === 'powershell.exe' + ? notices.windowsPowerShell + : notices.commandPrompt + return `[${notice}]` +} + /** Requested PowerShell → inbox PowerShell → cmd.exe, with absolute ConPTY-safe paths and per-shell argv. * Store alias stubs fail CreateProcessW (5); non-PowerShell requests retain their single-shell path. */ -export function buildWindowsPowerShellSpawnAttempts(args: { +export async function buildWindowsPowerShellSpawnAttempts(args: { shellPath: string cwd: string defaultCwd: string wslContext?: WindowsShellWslContext startupCommand?: string - resolveOptions?: WindowsPowerShellResolveOptions -}): WindowsShellSpawnAttempt[] { + /** The environment the terminal will get, so PATH lookups see what the shell will see. */ + env: NodeJS.ProcessEnv + /** The shell setting as requested (name or path) and the PowerShell version setting. */ + requestedShell: string + powerShellImplementation: string | undefined + /** Translated by main; English when an older client did not send them. */ + pwshNotFoundNotices?: WindowsPwshNotFoundNotices +}): Promise { const basename = pathWin32.basename(args.shellPath).toLowerCase() if (basename !== 'pwsh.exe' && basename !== 'powershell.exe') { return [] } - const chain = resolveWindowsPowerShellSpawnChain(basename, args.resolveOptions) + const chain = await resolveWindowsPowerShellSpawnChain(basename, { + env: args.env, + settingKey: `${args.requestedShell}|${args.powerShellImplementation ?? ''}`, + preferPreview: isPreviewPwshPath(args.requestedShell) + }) + // A missing PowerShell 7 is announced by the fallback shell itself. + const pwshMissing = + basename === 'pwsh.exe' && pathWin32.basename(chain[0] ?? '').toLowerCase() !== 'pwsh.exe' + const notices = pwshMissing + ? (args.pwshNotFoundNotices ?? buildWindowsPwshNotFoundNotices()) + : undefined return chain.map((candidate) => - toAttempt(candidate, args.cwd, args.defaultCwd, args.wslContext, args.startupCommand) + toAttempt( + candidate, + args.cwd, + args.defaultCwd, + args.wslContext, + args.startupCommand, + notices ? pwshNotFoundNotice(candidate, notices) : undefined + ) ) } diff --git a/src/main/startup/shell-environment/windows-shell-path-hydration.ts b/src/main/startup/shell-environment/windows-shell-path-hydration.ts index 85dc0c2dd90..80bbf1b0b4d 100644 --- a/src/main/startup/shell-environment/windows-shell-path-hydration.ts +++ b/src/main/startup/shell-environment/windows-shell-path-hydration.ts @@ -22,7 +22,10 @@ type WindowsShellPathHydrationOptions = { hydrate?: () => Promise merge?: (segments: string[]) => string[] resolveGitBashPath?: (shell: string) => string | null - resolvePowerShellChain?: (family: 'powershell.exe' | 'pwsh.exe') => string[] + resolvePowerShellChain?: ( + family: 'powershell.exe' | 'pwsh.exe', + settingKey: string + ) => string[] | Promise warn?: (error: unknown) => void } @@ -32,7 +35,9 @@ export function createWindowsShellPathHydration(options: WindowsShellPathHydrati const merge = options.merge ?? mergePathSegments const resolveGitBashPath = options.resolveGitBashPath ?? resolveWindowsGitBashShellPath const resolvePowerShellChain = - options.resolvePowerShellChain ?? resolveWindowsPowerShellSpawnChain + options.resolvePowerShellChain ?? + ((family: 'powershell.exe' | 'pwsh.exe', settingKey: string) => + resolveWindowsPowerShellSpawnChain(family, { settingKey })) const warn = options.warn ?? ((error: unknown) => { @@ -41,10 +46,12 @@ export function createWindowsShellPathHydration(options: WindowsShellPathHydrati let generation = 0 let ready = Promise.resolve() - const setConfiguredShell = ( + // PowerShell paths come from an async cached search; a newer request wins over a slower one. + const setConfiguredShell = async ( shell: string | null | undefined, - implementation?: WindowsPowerShellImplementation - ): void => { + implementation: WindowsPowerShellImplementation | undefined, + requestGeneration: number + ): Promise => { const requestedShell = shell?.trim() || 'powershell.exe' const basename = pathWin32.basename(requestedShell).toLowerCase() const shellFamily: WindowsPowerShellShellFamily = @@ -58,7 +65,13 @@ export function createWindowsShellPathHydration(options: WindowsShellPathHydrati configure(requestedShell, resolveGitBashPath(requestedShell), null) return } - const spawnChain = resolvePowerShellChain(effectivePowerShell) + const spawnChain = await resolvePowerShellChain( + effectivePowerShell, + `${requestedShell}|${implementation ?? ''}` + ) + if (requestGeneration !== generation) { + return + } const profileShell = spawnChain[0] ?? 'cmd.exe' const fallbackShell = spawnChain.slice(1).find((candidate) => { const candidateBasename = pathWin32.basename(candidate).toLowerCase() @@ -70,13 +83,15 @@ export function createWindowsShellPathHydration(options: WindowsShellPathHydrati return { configure: (shell, implementation?: WindowsPowerShellImplementation) => { generation += 1 - setConfiguredShell(shell, implementation) + const configured = setConfiguredShell(shell, implementation, generation) + ready = ready.then(() => configured) }, hydrate: (shell, implementation?: WindowsPowerShellImplementation) => { generation += 1 const requestGeneration = generation - setConfiguredShell(shell, implementation) + const configured = setConfiguredShell(shell, implementation, requestGeneration) ready = ready.then(async () => { + await configured if (requestGeneration !== generation) { return } diff --git a/src/renderer/src/i18n/locales/de.json b/src/renderer/src/i18n/locales/de.json index 062bab6b7c7..4c85407125f 100644 --- a/src/renderer/src/i18n/locales/de.json +++ b/src/renderer/src/i18n/locales/de.json @@ -21820,7 +21820,11 @@ "count_other": "{{count}} laufende Terminals" }, "restartedInputNotSent": "Das Terminal wurde während der Eingabe neu gestartet. Daher hat es möglicherweise einen Teil der Eingabe nicht erhalten. Die neue Shell prüfen, bevor die Eingabe wiederholt wird.", - "typingBeforeStartNotSent": "Einige Tasteneingaben während des Terminalstarts wurden nicht gesendet." + "typingBeforeStartNotSent": "Einige Tasteneingaben während des Terminalstarts wurden nicht gesendet.", + "windowsPwshNotFound": { + "windowsPowerShell": "Orca konnte PowerShell 7 (pwsh.exe) nicht finden, daher wurde in diesem Terminal stattdessen Windows PowerShell geöffnet.", + "commandPrompt": "Orca konnte PowerShell 7 (pwsh.exe) nicht finden, daher wurde in diesem Terminal stattdessen die Eingabeaufforderung geöffnet." + } }, "fileExplorer": { "root": { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a5efbd1c672..d284a1de399 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -23324,6 +23324,10 @@ "decline": "No thanks", "failed": "Could not turn on Claude Code theme sync", "failedDescription": "Turn it on in Settings." + }, + "windowsPwshNotFound": { + "windowsPowerShell": "Orca could not find PowerShell 7 (pwsh.exe), so this terminal opened Windows PowerShell instead.", + "commandPrompt": "Orca could not find PowerShell 7 (pwsh.exe), so this terminal opened Command Prompt instead." } }, "fileExplorer": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index c85e0fedcdd..94526446fdb 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -22026,6 +22026,10 @@ "gotIt": "Entendido", "body": "Para ocultarlo, escriba /diff en Claude Code. Claude recuerda esa elección para sesiones posteriores.", "title": "Claude Code puede abrir su propio panel de diferencias al lado del chat en terminales anchas. Ese panel es de Claude, no de Orca." + }, + "windowsPwshNotFound": { + "windowsPowerShell": "Orca no encontró PowerShell 7 (pwsh.exe), así que este terminal abrió Windows PowerShell en su lugar.", + "commandPrompt": "Orca no encontró PowerShell 7 (pwsh.exe), así que este terminal abrió el Símbolo del sistema en su lugar." } }, "quickOpen": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 91e2377e60a..36d72a4b062 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -22405,6 +22405,10 @@ "gotIt": "J'ai compris", "title": "Claude Code peut ouvrir son propre panneau de différences à côté du chat dans les terminaux larges. Ce panneau appartient à Claude, pas à Orca.", "body": "Pour le masquer, tapez /diff dans Claude Code. Claude se souvient de ce choix pour les sessions ultérieures." + }, + "windowsPwshNotFound": { + "windowsPowerShell": "Orca n'a pas trouvé PowerShell 7 (pwsh.exe), ce terminal a donc ouvert Windows PowerShell à la place.", + "commandPrompt": "Orca n'a pas trouvé PowerShell 7 (pwsh.exe), ce terminal a donc ouvert l'invite de commandes à la place." } }, "fileSearch": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 511abc5dd2d..4dd16790dd4 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -21726,6 +21726,10 @@ "gotIt": "了解", "title": "Claude Code は、ワイドターミナルでチャットの横に独自の差分パネルを開くことができます。そのパネルは Claude のものであり、Orca のものではありません。", "body": "非表示にするには、Claude Code で /diff と入力してください。Claude はこの選択を今後のセッションでも記憶します。" + }, + "windowsPwshNotFound": { + "windowsPowerShell": "Orca は PowerShell 7 (pwsh.exe) を見つけられなかったため、このターミナルでは代わりに Windows PowerShell を開きました。", + "commandPrompt": "Orca は PowerShell 7 (pwsh.exe) を見つけられなかったため、このターミナルでは代わりにコマンド プロンプトを開きました。" } }, "fileSearch": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index e15a1ef2e38..59819cec7e3 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -21718,6 +21718,10 @@ "gotIt": "확인", "body": "숨기려면 Claude Code에 /diff를 입력하세요. Claude는 이후 세션을 위해 해당 선택을 기억합니다.", "title": "Claude Code는 넓은 터미널에서 채팅 옆에 자체 diff 패널을 열 수 있습니다. 이 패널은 Orca가 아닌 Claude의 패널입니다." + }, + "windowsPwshNotFound": { + "windowsPowerShell": "Orca에서 PowerShell 7(pwsh.exe)을 찾을 수 없어 이 터미널에서 대신 Windows PowerShell을 열었습니다.", + "commandPrompt": "Orca에서 PowerShell 7(pwsh.exe)을 찾을 수 없어 이 터미널에서 대신 명령 프롬프트를 열었습니다." } }, "fileSearch": { diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index e6a95271fa6..2f12f3f096f 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -22797,7 +22797,11 @@ "count_one": "{{count}} terminal em execução" }, "restartedInputNotSent": "O terminal reiniciou enquanto você digitava, então parte da entrada pode não ter chegado a ele. Verifique o novo shell antes de digitar novamente.", - "typingBeforeStartNotSent": "Algumas teclas digitadas enquanto o terminal iniciava não foram enviadas." + "typingBeforeStartNotSent": "Algumas teclas digitadas enquanto o terminal iniciava não foram enviadas.", + "windowsPwshNotFound": { + "windowsPowerShell": "O Orca não encontrou o PowerShell 7 (pwsh.exe), então este terminal abriu o Windows PowerShell no lugar.", + "commandPrompt": "O Orca não encontrou o PowerShell 7 (pwsh.exe), então este terminal abriu o Prompt de Comando no lugar." + } }, "fileExplorer": { "root": { diff --git a/src/renderer/src/i18n/locales/ru.json b/src/renderer/src/i18n/locales/ru.json index 2a19b39d005..def8993a0c7 100644 --- a/src/renderer/src/i18n/locales/ru.json +++ b/src/renderer/src/i18n/locales/ru.json @@ -22521,7 +22521,11 @@ "count_many": "{{count}} работающих терминалов" }, "restartedInputNotSent": "Терминал перезапустился во время ввода, поэтому часть текста могла не дойти. Проверьте новую оболочку, прежде чем вводить текст снова.", - "typingBeforeStartNotSent": "Часть клавиш, нажатых во время запуска терминала, не была отправлена." + "typingBeforeStartNotSent": "Часть клавиш, нажатых во время запуска терминала, не была отправлена.", + "windowsPwshNotFound": { + "windowsPowerShell": "Orca не удалось найти PowerShell 7 (pwsh.exe), поэтому в этом терминале вместо него открыт Windows PowerShell.", + "commandPrompt": "Orca не удалось найти PowerShell 7 (pwsh.exe), поэтому в этом терминале вместо него открыта командная строка." + } }, "fileExplorer": { "root": { diff --git a/src/renderer/src/i18n/locales/tr.json b/src/renderer/src/i18n/locales/tr.json index 84d6397c7f8..e26babbc97f 100644 --- a/src/renderer/src/i18n/locales/tr.json +++ b/src/renderer/src/i18n/locales/tr.json @@ -21790,7 +21790,11 @@ "count_other": "Çalışan {{count}} terminal" }, "restartedInputNotSent": "Siz yazarken terminal yeniden başlatıldı, bu nedenle girdilerinizin bir kısmı terminale ulaşmamış olabilir. Yeniden yazmadan önce yeni kabuğu kontrol edin.", - "typingBeforeStartNotSent": "Terminal başlatılırken yazılan bazı tuşlar gönderilmedi." + "typingBeforeStartNotSent": "Terminal başlatılırken yazılan bazı tuşlar gönderilmedi.", + "windowsPwshNotFound": { + "windowsPowerShell": "Orca, PowerShell 7'yi (pwsh.exe) bulamadı, bu nedenle bu terminal bunun yerine Windows PowerShell'i açtı.", + "commandPrompt": "Orca, PowerShell 7'yi (pwsh.exe) bulamadı, bu nedenle bu terminal bunun yerine Komut İstemi'ni açtı." + } }, "fileExplorer": { "root": { diff --git a/src/renderer/src/i18n/locales/zh-Hant.json b/src/renderer/src/i18n/locales/zh-Hant.json index 9a250e4ea66..634dab1bac0 100644 --- a/src/renderer/src/i18n/locales/zh-Hant.json +++ b/src/renderer/src/i18n/locales/zh-Hant.json @@ -21496,7 +21496,11 @@ "count_other": "{{count}} 個正在執行的終端機" }, "restartedInputNotSent": "終端機在您輸入時重新啟動,部分輸入可能未送達。請先檢視新的 shell,再重新輸入。", - "typingBeforeStartNotSent": "終端機啟動期間輸入的部分按鍵未送出。" + "typingBeforeStartNotSent": "終端機啟動期間輸入的部分按鍵未送出。", + "windowsPwshNotFound": { + "windowsPowerShell": "Orca 找不到 PowerShell 7 (pwsh.exe),因此此終端機改為開啟 Windows PowerShell。", + "commandPrompt": "Orca 找不到 PowerShell 7 (pwsh.exe),因此此終端機改為開啟命令提示字元。" + } }, "fileExplorer": { "root": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 2d291616a23..96e67d30888 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -22066,6 +22066,10 @@ "gotIt": "知道了", "title": "Claude Code 在较宽的终端中会在聊天旁打开自己的差异面板,那个面板属于 Claude,而不是 Orca", "body": "要隐藏它,请在 Claude Code 中输入 /diff,Claude 会在之后的会话中记住这一选择" + }, + "windowsPwshNotFound": { + "windowsPowerShell": "Orca 未找到 PowerShell 7 (pwsh.exe),因此此终端改为打开了 Windows PowerShell。", + "commandPrompt": "Orca 未找到 PowerShell 7 (pwsh.exe),因此此终端改为打开了命令提示符。" } }, "fileSearch": {