diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index 273763f70..3cf94c387 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -2,10 +2,6 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { - encodePowerShellCommand, - getPowerShellOsc133Bootstrap -} from '../powershell-osc133-bootstrap' import { buildWslInteractiveLoginShellCommand, escapeWslShCommandForWindows @@ -19,6 +15,15 @@ function expectedWslArgs(linuxCwd: string, distro?: string): string[] { return distro ? ['-d', distro, ...shellArgs] : shellArgs } +function decodePowerShellCommand(result: ReturnType): string { + expect(result.shellArgs.slice(0, 3)).toEqual(['-NoLogo', '-NoExit', '-EncodedCommand']) + return Buffer.from(result.shellArgs[3] ?? '', 'base64').toString('utf16le') +} + +function expectedPowerShellRestoreCwdCommand(cwdLiteral: string): string { + return `try { Set-Location -LiteralPath ${cwdLiteral} -ErrorAction Stop } catch { Write-Warning "Failed to restore working directory: $_" }` +} + describe('resolveWindowsShellLaunchArgs', () => { let previousUserDataPath: string | undefined let userDataPath: string @@ -90,14 +95,9 @@ describe('resolveWindowsShellLaunchArgs', () => { 'C:\\Users\\alice', 'C:\\Users\\alice' ) - expect(result.shellArgs).toEqual([ - '-NoLogo', - '-NoExit', - '-EncodedCommand', - encodePowerShellCommand(getPowerShellOsc133Bootstrap()) - ]) + expect(result.shellArgs).toEqual(['-NoLogo', '-NoExit', '-EncodedCommand', expect.any(String)]) - const command = Buffer.from(result.shellArgs[3] ?? '', 'base64').toString('utf16le') + const command = decodePowerShellCommand(result) const outputEncodingIndex = command.indexOf('[Console]::OutputEncoding') const opencodeRestoreIndex = command.indexOf( '$env:OPENCODE_CONFIG_DIR = $env:ORCA_OPENCODE_CONFIG_DIR' @@ -106,6 +106,9 @@ describe('resolveWindowsShellLaunchArgs', () => { const ompExtensionIndex = command.indexOf('--extension $env:ORCA_OMP_STATUS_EXTENSION') const codexRestoreIndex = command.indexOf('$env:CODEX_HOME = $env:ORCA_CODEX_HOME') const promptIndex = command.indexOf('function Global:prompt') + const cwdRestoreIndex = command.indexOf( + expectedPowerShellRestoreCwdCommand("'C:\\Users\\alice'") + ) expect(command).not.toContain('$PROFILE') expect(command).not.toContain('ORCA_PI_CODING_AGENT_DIR') @@ -118,6 +121,7 @@ describe('resolveWindowsShellLaunchArgs', () => { expect(codexRestoreIndex).toBeGreaterThan(outputEncodingIndex) expect(codexRestoreIndex).toBeGreaterThan(ompWrapperIndex) expect(promptIndex).toBeGreaterThan(codexRestoreIndex) + expect(cwdRestoreIndex).toBeGreaterThan(promptIndex) expect(command).toContain('Esc = [char]27') expect(command).toContain('Bel = [char]7') expect(command).toContain(')]133;D;$fakeExitCode$(') @@ -134,6 +138,21 @@ describe('resolveWindowsShellLaunchArgs', () => { expect(result.effectiveCwd).toBe('C:\\Users\\alice\\project') expect(result.validationCwd).toBe('C:\\Users\\alice\\project') + expect(decodePowerShellCommand(result)).toContain( + expectedPowerShellRestoreCwdCommand("'C:\\Users\\alice\\project'") + ) + }) + + it('quotes the PowerShell cwd restore command literally', () => { + const result = resolveWindowsShellLaunchArgs( + 'powershell.exe', + "C:\\Users\\alice\\client's app", + 'C:\\Users\\alice' + ) + + expect(decodePowerShellCommand(result)).toContain( + expectedPowerShellRestoreCwdCommand("'C:\\Users\\alice\\client''s app'") + ) }) it('embeds short PowerShell startup commands after the OSC 133 bootstrap', () => { @@ -146,8 +165,9 @@ describe('resolveWindowsShellLaunchArgs', () => { ) expect(result.startupCommandDeliveredInShellArgs).toBe(true) - const command = Buffer.from(result.shellArgs[3] ?? '', 'base64').toString('utf16le') + const command = decodePowerShellCommand(result) expect(command).toContain('function Global:prompt') + expect(command).toContain(expectedPowerShellRestoreCwdCommand("'C:\\Users\\alice'")) expect(command.trimEnd().endsWith("& 'codex' '--no-alt-screen'")).toBe(true) }) @@ -163,7 +183,7 @@ describe('resolveWindowsShellLaunchArgs', () => { ) expect(result.startupCommandDeliveredInShellArgs).toBe(true) - const command = Buffer.from(result.shellArgs[3] ?? '', 'base64').toString('utf16le') + const command = decodePowerShellCommand(result) expect(command).toContain(`\n${startupCommand}`) expect(command.trimEnd().endsWith(startupCommand)).toBe(true) }) @@ -178,22 +198,16 @@ describe('resolveWindowsShellLaunchArgs', () => { ) expect(result.startupCommandDeliveredInShellArgs).toBeUndefined() - expect(result.shellArgs).toEqual([ - '-NoLogo', - '-NoExit', - '-EncodedCommand', - encodePowerShellCommand(getPowerShellOsc133Bootstrap()) - ]) + expect(result.shellArgs).toEqual(['-NoLogo', '-NoExit', '-EncodedCommand', expect.any(String)]) + expect(decodePowerShellCommand(result)).toContain( + expectedPowerShellRestoreCwdCommand("'C:\\Users\\alice'") + ) }) it('handles pwsh.exe (PowerShell Core) the same as Windows PowerShell', () => { const result = resolveWindowsShellLaunchArgs('pwsh.exe', 'C:\\', 'C:\\Users\\alice') - expect(result.shellArgs).toEqual([ - '-NoLogo', - '-NoExit', - '-EncodedCommand', - encodePowerShellCommand(getPowerShellOsc133Bootstrap()) - ]) + expect(result.shellArgs).toEqual(['-NoLogo', '-NoExit', '-EncodedCommand', expect.any(String)]) + expect(decodePowerShellCommand(result)).toContain(expectedPowerShellRestoreCwdCommand("'C:\\'")) }) it('starts Git Bash as an interactive login shell with UTF-8 console setup', () => { @@ -362,12 +376,7 @@ describe('resolveWindowsShellLaunchArgs', () => { it('is case-insensitive on the shell basename', () => { const result = resolveWindowsShellLaunchArgs('PowerShell.EXE', 'C:\\', 'C:\\') - expect(result.shellArgs).toEqual([ - '-NoLogo', - '-NoExit', - '-EncodedCommand', - encodePowerShellCommand(getPowerShellOsc133Bootstrap()) - ]) + expect(result.shellArgs).toEqual(['-NoLogo', '-NoExit', '-EncodedCommand', expect.any(String)]) }) }) diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index 523aee72a..bde8a872e 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -11,6 +11,7 @@ import { encodePowerShellCommand, getPowerShellOsc133Bootstrap } from '../powershell-osc133-bootstrap' +import { quoteStartupArg } from '../../shared/tui-agent-startup-shell' const CMD_EXE_COMMAND_LINE_MAX_CHARS = 8191 const STARTUP_COMMAND_TEXT_MAX_CHARS = 6000 @@ -80,11 +81,22 @@ function getCmdShellArgStartupCommand(command?: string): string | null { * Short startup commands are appended to the bootstrap and marked as delivered; * large payloads return the bootstrap alone so stdin delivery remains available. */ -function getPowerShellEncodedCommand(startupCommand?: string): { +function getPowerShellRestoreCwdCommand(cwd: string): string { + return [ + '', + '# Profiles can change location; restore the PTY cwd after profile loading.', + `try { Set-Location -LiteralPath ${quoteStartupArg(cwd, 'powershell')} -ErrorAction Stop } catch { Write-Warning "Failed to restore working directory: $_" }` + ].join('\n') +} + +function getPowerShellEncodedCommand( + cwd: string, + startupCommand?: string +): { encodedCommand: string startupCommandDeliveredInShellArgs?: boolean } { - const bootstrap = getPowerShellOsc133Bootstrap() + const bootstrap = `${getPowerShellOsc133Bootstrap()}${getPowerShellRestoreCwdCommand(cwd)}` if (!startupCommand || startupCommand.length > STARTUP_COMMAND_TEXT_MAX_CHARS) { return { encodedCommand: encodePowerShellCommand(bootstrap) } } @@ -168,7 +180,7 @@ export function resolveWindowsShellLaunchArgs( } if (shellBasename === 'powershell.exe' || shellBasename === 'pwsh.exe') { - const powerShellCommand = getPowerShellEncodedCommand(startupCommand) + const powerShellCommand = getPowerShellEncodedCommand(nativeCwd, startupCommand) // Why: foreground-process status on Windows depends on OSC 133 C/D, and // PowerShell needs a prompt/readline bootstrap after profiles finish. return {