From d8cfcf397a40e9e7fb63d16a64d4838a0a453855 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:38:20 -0700 Subject: [PATCH] fix(launch-prompt): plan a WSL pane for its own shell; refuse a launch that cannot be staged Stack QA on 193eeaddff3: - A WSL workspace added by its \\wsl.localhost path was planned as a Windows launch, so the Windows shell setting decided its line: under cmd, Claude's pointer was cmd-quoted and bash ran the backticked path, losing the prompt; the carry also changed with the setting. A WSL path now plans as the distro (POSIX quoting, WSL staging up to 100,000 bytes), and a pane whose Windows shell is wsl.exe is a host fact (windowsPaneShell) the rule reads. - A Tab or bare carriage return was measured exact on both PowerShells and is no longer counted as damage there; cmd and Git Bash still damage it, and an unmeasured control byte does what main does on that path. - The WSL folder probe kept a success forever. A refused spawn now forgets it, and the daemon refuses an agent launch whose line it could not stage (launch_file_unavailable) instead of typing it in full. --- src/main/daemon/daemon-pty-session-spawn.ts | 13 ++- .../daemon/terminal-host-session-create.ts | 38 +++++++-- .../terminal-host-startup-staging.test.ts | 25 ++++++ src/main/providers/local-pty-spawn.ts | 28 +++++-- .../wsl-launch-directory-resolution.test.ts | 18 ++++ .../wsl-launch-directory-resolution.ts | 15 +++- .../runtime-agent-launch-resolution.ts | 8 +- .../runtime/this-orca-launch-host.test.ts | 10 +-- src/main/runtime/this-orca-launch-host.ts | 6 +- .../runtime/wsl-pane-launch-carry.test.ts | 84 +++++++++++++++++++ .../full-creation-startup.test.ts | 2 +- .../src/lib/agent-launch-platform.test.ts | 23 +++++ src/renderer/src/lib/agent-launch-platform.ts | 8 +- src/renderer/src/lib/launch-file-host.test.ts | 10 +-- src/renderer/src/lib/launch-file-host.ts | 6 +- .../lib/launch-work-item-direct-agent.test.ts | 10 +-- src/shared/launch-host.test.ts | 29 ++++--- src/shared/launch-host.ts | 48 +++++++---- src/shared/launch-prompt-carrier.test.ts | 4 +- src/shared/launch-prompt-carry.test.ts | 4 +- src/shared/launch-prompt-carry.ts | 11 ++- src/shared/tui-agent-startup.test.ts | 47 +++++++---- src/shared/windows-launch-line.test.ts | 65 +++++++++----- src/shared/windows-launch-line.ts | 27 ++++-- .../windows-shell-prompt-damage.test.ts | 2 +- 25 files changed, 414 insertions(+), 127 deletions(-) create mode 100644 src/main/runtime/wsl-pane-launch-carry.test.ts create mode 100644 src/renderer/src/lib/agent-launch-platform.test.ts diff --git a/src/main/daemon/daemon-pty-session-spawn.ts b/src/main/daemon/daemon-pty-session-spawn.ts index 16e66ded0f2..04044013701 100644 --- a/src/main/daemon/daemon-pty-session-spawn.ts +++ b/src/main/daemon/daemon-pty-session-spawn.ts @@ -18,7 +18,10 @@ import { getRecoveredHistorySeedSegments } from './terminal-history-seed-segment import { AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, type CreateOrAttachResult } from './types' import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd' import { resolveWslSessionContext } from './wsl-session-context' -import { resolveSpawnWslLaunchDirectory } from '../providers/wsl-launch-directory-resolution' +import { + forgetWslLaunchDirectoryAfterRefusal, + resolveSpawnWslLaunchDirectory +} from '../providers/wsl-launch-directory-resolution' import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd' import { resolveUnixShellPath } from '../providers/local-pty-utils' import type { PtySpawnOptions, PtySpawnResult } from '../providers/types' @@ -258,7 +261,13 @@ export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult { detectColdRestore } activeSpawnContext = context - const result = await this.createOrAttachSpawn(context, context.historySeedSegments) + let result + try { + result = await this.createOrAttachSpawn(context, context.historySeedSegments) + } catch (error) { + forgetWslLaunchDirectoryAfterRefusal(wslLaunchDirectory, error) + throw error + } if (result.isNew && !attachOnly) { // Not awaited: the app-side read behind it can sit on an unanswered macOS folder prompt. void reportDaemonPtyCwdVerdict({ diff --git a/src/main/daemon/terminal-host-session-create.ts b/src/main/daemon/terminal-host-session-create.ts index bcf0abddb66..fd0a3b8c97d 100644 --- a/src/main/daemon/terminal-host-session-create.ts +++ b/src/main/daemon/terminal-host-session-create.ts @@ -6,7 +6,11 @@ import { type StartupCommandStaging } from '../../shared/startup-command-staging' import { resolvePtyOwnerBackend } from '../../shared/pty-owner-backend' -import { removeLaunchFile, writeSpawnLaunchFile } from '../../shared/launch-file-writing' +import { + LaunchFileUnavailableError, + removeLaunchFile, + writeSpawnLaunchFile +} from '../../shared/launch-file-writing' import { getDaemonSessionResultMetadata } from './daemon-create-or-attach-result' import { enumerateDirectoryOnce } from './directory-enumeration-probe' import { normalizePtySize } from './daemon-pty-size' @@ -206,11 +210,33 @@ async function spawnAndPublishSession( throw new TerminalAttachCanceledError(opts.sessionId) } + const startupCommandWritten = Boolean(command) && !subprocess.startupCommandDeliveredInShellArgs + if (startupCommandWritten && command) { + staging = stageStartupCommand({ + command, + shellPath: subprocess.shellPath, + orcaBuiltLine: opts.launchAgent !== undefined, + wslDirectory + }) + // Why refuse: typed in full, a long agent line can leave the shell at a quote prompt with the + // prompt lost while the launch reports success; the refusal hands the user the prompt instead. + if (staging.failure !== undefined && opts.launchAgent !== undefined) { + deps.sessions.set(opts.sessionId, session) + await session.forceKillAndDisposeSubprocess() + removeLaunchFile(launchFile) + if (deps.sessions.get(opts.sessionId) === session) { + session.dispose() + deps.sessions.delete(opts.sessionId) + deps.onDeadSessionRemoved(opts.sessionId) + } + throw new LaunchFileUnavailableError(staging.failure, 'staged-line') + } + } + deps.sessions.set(opts.sessionId, session) deps.onSessionCreated(opts.sessionId, opts.agentSessionGeneration, session.isAlive) const token = session.attachClient(opts.streamClient) - const startupCommandWritten = Boolean(command) && !subprocess.startupCommandDeliveredInShellArgs // Why: without this, a missing command and a lost one log identically. // Length, never the text -- launches can carry credentials. try { @@ -225,13 +251,7 @@ async function spawnAndPublishSession( } catch { // Diagnostics must never turn a live PTY into a failed create. } - if (startupCommandWritten && command) { - staging = stageStartupCommand({ - command, - shellPath: subprocess.shellPath, - orcaBuiltLine: opts.launchAgent !== undefined, - wslDirectory - }) + if (staging) { const notice = startupStagingFailureNotice(staging) if (notice) { session.startupIngress.accept(notice) diff --git a/src/main/daemon/terminal-host-startup-staging.test.ts b/src/main/daemon/terminal-host-startup-staging.test.ts index f7ae1d5b786..80fda3d9c5a 100644 --- a/src/main/daemon/terminal-host-startup-staging.test.ts +++ b/src/main/daemon/terminal-host-startup-staging.test.ts @@ -99,6 +99,31 @@ describePosix('daemon startup command staging', () => { expect(sub.write).not.toHaveBeenCalled() }) + // Why (stack QA P8-2): typed in full, a long agent line left the shell at a quote prompt, the + // prompt lost while agent.launch reported success; refusing hands the user the prompt instead. + it('refuses an agent launch it could not stage, typing nothing and keeping no session', async () => { + vi.stubEnv('TMPDIR', join(tempDir, 'missing')) + const sub = mockSubprocess('/bin/zsh') + vi.mocked(sub.forceKill).mockImplementation(() => exitSubprocess?.(1)) + vi.mocked(sub.kill).mockImplementation(() => exitSubprocess?.(1)) + const host = new TerminalHost({ spawnSubprocess: () => sub }) + await expect( + host.createOrAttach({ + sessionId: 's-agent-stage-failed', + cols: 80, + rows: 24, + command: `claude '${'x'.repeat(600)}'`, + launchAgent: 'claude', + shellReadySupported: false, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + ).rejects.toThrow(/launch_file_unavailable/) + expect(sub.write).not.toHaveBeenCalled() + expect(host.listSessions().map((session) => session.sessionId)).not.toContain( + 's-agent-stage-failed' + ) + }) + it('prints a notice in the terminal when it types a line it could not stage', async () => { vi.stubEnv('TMPDIR', join(tempDir, 'missing')) const command = `claude '${'x'.repeat(600)}'` diff --git a/src/main/providers/local-pty-spawn.ts b/src/main/providers/local-pty-spawn.ts index d7604864075..909e4585a2c 100644 --- a/src/main/providers/local-pty-spawn.ts +++ b/src/main/providers/local-pty-spawn.ts @@ -30,7 +30,10 @@ import { writeSpawnLaunchFile, type WrittenLaunchFile } from '../../shared/launch-file-writing' -import { resolveSpawnWslLaunchDirectory } from './wsl-launch-directory-resolution' +import { + forgetWslLaunchDirectoryAfterRefusal, + resolveSpawnWslLaunchDirectory +} from './wsl-launch-directory-resolution' export async function spawnLocalPty( args: PtySpawnOptions, @@ -55,14 +58,20 @@ export async function spawnLocalPty( if (args.attachOnly) { throw new SessionNotFoundError(args.sessionId ?? '') } - const launchFile = writeSpawnLaunchFile({ - launchFile: args.launchFile, - command: args.command, - env: args.env, - orcaBuiltLine: args.launchAgent !== undefined, - wslDistro, - wslDirectory: wslLaunchDirectory - }) + let launchFile: WrittenLaunchFile | undefined + try { + launchFile = writeSpawnLaunchFile({ + launchFile: args.launchFile, + command: args.command, + env: args.env, + orcaBuiltLine: args.launchAgent !== undefined, + wslDistro, + wslDirectory: wslLaunchDirectory + }) + } catch (error) { + forgetWslLaunchDirectoryAfterRefusal(wslLaunchDirectory, error) + throw error + } args = { ...args, ...(launchFile ? { command: launchFile.command, env: launchFile.env } : {}), @@ -75,6 +84,7 @@ export async function spawnLocalPty( return await spawnFreshLocalPty(args, getOptions, reattachId, launchFile) } catch (error) { removeLaunchFile(launchFile) + forgetWslLaunchDirectoryAfterRefusal(wslLaunchDirectory, error) throw error } } diff --git a/src/main/providers/wsl-launch-directory-resolution.test.ts b/src/main/providers/wsl-launch-directory-resolution.test.ts index e1da82879de..946e6a558e2 100644 --- a/src/main/providers/wsl-launch-directory-resolution.test.ts +++ b/src/main/providers/wsl-launch-directory-resolution.test.ts @@ -3,6 +3,7 @@ import { createLocalPtyLaunchPlan, resolveLocalPtyWslDistro } from './local-pty- import type { LocalPtyProviderOptions } from './local-pty-provider-types' import type { PtySpawnOptions } from './types' import { + forgetWslLaunchDirectoryAfterRefusal, resolveSpawnWslLaunchDirectory, resolveWslLaunchDirectory, WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS @@ -81,6 +82,23 @@ describe('resolveWslLaunchDirectory', () => { } }) + // Why (stack QA P8-2): a folder found once and broken later must not be written into forever. + it('asks the distro again after a write into the found folder was refused', async () => { + runWslProcess.mockResolvedValue(answers('/home/ada\n/bin/bash\n')) + const found = await resolveWslLaunchDirectory('Void') + forgetWslLaunchDirectoryAfterRefusal(found, new Error('EACCES')) + await resolveWslLaunchDirectory('Void') + expect(runWslProcess).toHaveBeenCalledTimes(1) + forgetWslLaunchDirectoryAfterRefusal( + found, + new Error( + 'Orca could not write it (EEXIST), so the agent was not started. [launch_file_unavailable]' + ) + ) + await resolveWslLaunchDirectory('Void') + expect(runWslProcess).toHaveBeenCalledTimes(2) + }) + it('probes only for a spawn that needs the directory', async () => { runWslProcess.mockResolvedValue(answers('/home/ada\n/bin/bash\n')) expect( diff --git a/src/main/providers/wsl-launch-directory-resolution.ts b/src/main/providers/wsl-launch-directory-resolution.ts index 7fe043f70ed..a7bab41972b 100644 --- a/src/main/providers/wsl-launch-directory-resolution.ts +++ b/src/main/providers/wsl-launch-directory-resolution.ts @@ -3,6 +3,7 @@ import { quotePosixShell, RESOLVE_WSL_LOGIN_SHELL } from '../../shared/wsl-login import type { WslLaunchDirectory } from '../../shared/wsl-launch-directory' import type { LaunchFile } from '../../shared/launch-prompt-file' import { spawnNeedsWslLaunchDirectory } from '../../shared/launch-file-writing' +import { isLaunchFileRefusal } from '../../shared/launch-prompt-file' import type { TuiAgent } from '../../shared/tui-agent' import { toWindowsWslUncPath } from '../../shared/wsl-paths' import { runWslProcess } from '../wsl/wsl-runner' @@ -40,7 +41,8 @@ export function resolveSpawnWslLaunchDirectory( * The distro directory a WSL spawn's staged line and launch file are written to, and the login * shell the pane runs, which decides how a staged line is sourced; undefined when the distro cannot * be asked, and the write site then refuses a launch that needs one. Probed once per distro; a - * success is kept, a failure for `WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS`. + * success is kept until a write into it fails (`forgetWslLaunchDirectoryAfterRefusal`), a failure + * for `WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS`. */ export async function resolveWslLaunchDirectory( distro: string | null | undefined @@ -66,6 +68,17 @@ export async function resolveWslLaunchDirectory( return await probe } +/** A spawn into this directory was refused: forget it, so the next launch asks the distro again + * rather than writing into a folder that stopped working. */ +export function forgetWslLaunchDirectoryAfterRefusal( + directory: WslLaunchDirectory | undefined, + error: unknown +): void { + if (directory && isLaunchFileRefusal(error)) { + probes.delete(directory.distro) + } +} + async function probeWslLaunchDirectory(distro: string): Promise { const script = [ ...RESOLVE_WSL_LOGIN_SHELL, diff --git a/src/main/runtime/runtime-agent-launch-resolution.ts b/src/main/runtime/runtime-agent-launch-resolution.ts index 9f5ddd4bd0a..2ace8f5c464 100644 --- a/src/main/runtime/runtime-agent-launch-resolution.ts +++ b/src/main/runtime/runtime-agent-launch-resolution.ts @@ -5,6 +5,7 @@ import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resum import type { ClaudeAgentTeamsMode } from '../../shared/claude-agent-teams-tmux-compat' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' +import { isWslUncPath } from '../../shared/wsl-paths' import { getTuiAgentLaunchCommand, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' import { isTuiAgentEnabled } from '../../shared/tui-agent-selection' @@ -110,7 +111,12 @@ export function getAgentLaunchPlatformForRepo( if (projectRuntime?.status === 'repair-required') { return projectRuntime.repair.preferredRuntime.kind === 'wsl' ? 'linux' : process.platform } - if (projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl') { + // Why a WSL path: the pane spawns in the distro's POSIX shell, whatever the Windows shell + // setting, so its line is judged and quoted for that shell. + if ( + (projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl') || + isWslUncPath(repo.path) + ) { return 'linux' } return process.platform diff --git a/src/main/runtime/this-orca-launch-host.test.ts b/src/main/runtime/this-orca-launch-host.test.ts index dce8b2be36e..73965d0cc6a 100644 --- a/src/main/runtime/this-orca-launch-host.test.ts +++ b/src/main/runtime/this-orca-launch-host.test.ts @@ -30,12 +30,12 @@ describe('the PowerShell a launch this Orca runs lands in', () => { settings, windowsShellOverride }) - expect(host().windowsPowerShell).toBeNull() + expect(host().windowsPaneShell).toBeNull() vi.mocked(cachedPwshAvailability).mockReturnValue(false) - expect(host().windowsPowerShell).toBe('powershell.exe') - expect(host('pwsh.exe').windowsPowerShell).toBe('pwsh.exe') + expect(host().windowsPaneShell).toBe('powershell.exe') + expect(host('pwsh.exe').windowsPaneShell).toBe('pwsh.exe') expect( - thisOrcaLaunchHost({ launchPlatform: 'win32', isRemote: true, settings }).windowsPowerShell + thisOrcaLaunchHost({ launchPlatform: 'win32', isRemote: true, settings }).windowsPaneShell ).toBeNull() }) @@ -47,7 +47,7 @@ describe('the PowerShell a launch this Orca runs lands in', () => { launchPlatform: 'win32', isRemote: false, settings: { terminalWindowsShell: 'pwsh.exe' } - }).windowsPowerShell + }).windowsPaneShell ).toBeNull() }) }) diff --git a/src/main/runtime/this-orca-launch-host.ts b/src/main/runtime/this-orca-launch-host.ts index 187b93e242e..d3aa8813f89 100644 --- a/src/main/runtime/this-orca-launch-host.ts +++ b/src/main/runtime/this-orca-launch-host.ts @@ -1,7 +1,7 @@ import { cachedPwshAvailability } from '../pwsh' import { describeLaunchHost, - spawnedWindowsPowerShell, + spawnedWindowsShell, type LaunchHost, type WindowsShellSettings } from '../../shared/launch-host' @@ -20,8 +20,8 @@ export function thisOrcaLaunchHost(args: { isRemote: args.isRemote, hostPlatform: process.platform, paired: false, - windowsPowerShell: local - ? spawnedWindowsPowerShell({ + windowsPaneShell: local + ? spawnedWindowsShell({ settings: args.settings, windowsShellOverride: args.windowsShellOverride, pwshAvailable: cachedPwshAvailability() diff --git a/src/main/runtime/wsl-pane-launch-carry.test.ts b/src/main/runtime/wsl-pane-launch-carry.test.ts new file mode 100644 index 00000000000..0c1c00017fb --- /dev/null +++ b/src/main/runtime/wsl-pane-launch-carry.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' +import { describeLaunchHost } from '../../shared/launch-host' +import { planLaunchPrompt } from '../../shared/tui-agent-startup' +import { getAgentLaunchPlatformForRepo } from './runtime-agent-launch-resolution' + +// The stack QA's WSL workspace: a folder repo added by its distro path (P8-6). +const WSL_REPO = { connectionId: null, path: '\\\\wsl.localhost\\qasfwin\\root\\fixture' } +const P25K = 'QA-STACK P25K one line, inert. '.padEnd(25_600, 'x') + +function plan(prompt: string, terminalWindowsShell: string) { + const platform = getAgentLaunchPlatformForRepo(WSL_REPO) + return planLaunchPrompt({ + ...resolveAgentStartupPlanInputs({ + agent: 'claude', + settings: { terminalWindowsShell }, + platform, + isRemote: false + }), + prompt, + host: describeLaunchHost({ + launchPlatform: platform, + isRemote: false, + hostPlatform: 'win32', + paired: false + }), + paste: 'when-host-proves-agent' + }) +} + +// Why: the pane runs the distro's POSIX shell, so the Windows shell setting must not decide how the +// line is judged or quoted; quoted for cmd, bash ran the pointer's backticked path and lost it. +describe('a launch into a WSL workspace, whatever the Windows shell setting', () => { + it('is planned for the distro', () => { + expect(getAgentLaunchPlatformForRepo(WSL_REPO)).toBe('linux') + }) + + it.each(['powershell.exe', 'git-bash', 'cmd.exe'])( + 'stages a 25 KB prompt on a POSIX line under %s', + (terminalWindowsShell) => { + const planned = plan(P25K, terminalWindowsShell) + expect(planned?.carry).toBe('on-line') + expect(planned?.carry === 'on-line' && planned.plan.launchCommand).toMatch( + new RegExp(`^claude .* '${P25K}'$`) + ) + } + ) + + it.each(['powershell.exe', 'cmd.exe'])( + 'keeps $ and backticks literal on the line under %s', + (terminalWindowsShell) => { + const prompt = 'print $HOME and `id` as text' + const planned = plan(prompt, terminalWindowsShell) + // POSIX single quotes: bash expands neither `$HOME` nor the backticks inside them. + expect(planned?.carry === 'on-line' && planned.plan.launchCommand).toMatch( + /^claude .* 'print \$HOME and `id` as text'$/ + ) + } + ) +}) + +// Why: a pane whose Windows shell is WSL runs the same POSIX shell, staged by the host. +describe('a WSL pane on a Windows workspace', () => { + it('judges its line by the distro, not by Git Bash', () => { + const planned = planLaunchPrompt({ + ...resolveAgentStartupPlanInputs({ + agent: 'claude', + settings: { terminalWindowsShell: 'wsl.exe' }, + platform: 'win32', + isRemote: false + }), + prompt: P25K, + host: describeLaunchHost({ + launchPlatform: 'win32', + isRemote: false, + hostPlatform: 'win32', + paired: false, + windowsPaneShell: 'wsl.exe' + }), + paste: 'when-host-proves-agent' + }) + expect(planned?.carry).toBe('on-line') + }) +}) diff --git a/src/renderer/src/hooks/composer-state/full-creation-startup.test.ts b/src/renderer/src/hooks/composer-state/full-creation-startup.test.ts index 6d5db20674d..a7ed8f7bae6 100644 --- a/src/renderer/src/hooks/composer-state/full-creation-startup.test.ts +++ b/src/renderer/src/hooks/composer-state/full-creation-startup.test.ts @@ -20,7 +20,7 @@ describe('the full composer’s renderer-spawned startup', () => { paired: false, provesAgentInFront: true, takesLaunchFile: true, - windowsPowerShell: null + windowsPaneShell: null }, paste: 'when-host-proves-agent' }), diff --git a/src/renderer/src/lib/agent-launch-platform.test.ts b/src/renderer/src/lib/agent-launch-platform.test.ts new file mode 100644 index 00000000000..e2f5fd54ddd --- /dev/null +++ b/src/renderer/src/lib/agent-launch-platform.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from 'vitest' +import { getAgentLaunchPlatformForRepo } from './agent-launch-platform' + +vi.mock('@/lib/new-workspace', () => ({ CLIENT_PLATFORM: 'win32' })) + +// Why (stack QA P8-6): a pane in a WSL path runs the distro's POSIX shell, so the desktop must plan +// its line for that shell, not for the Windows shell setting. +describe('the platform a desktop launch is planned for', () => { + it('is the distro for a workspace in a WSL path', () => { + expect( + getAgentLaunchPlatformForRepo({ + connectionId: null, + path: '\\\\wsl.localhost\\qasfwin\\root\\fixture' + }) + ).toBe('linux') + }) + + it('stays this machine for a native path', () => { + expect(getAgentLaunchPlatformForRepo({ connectionId: null, path: 'C:\\src\\repo' })).toBe( + 'win32' + ) + }) +}) diff --git a/src/renderer/src/lib/agent-launch-platform.ts b/src/renderer/src/lib/agent-launch-platform.ts index 0788610892b..1d63585c998 100644 --- a/src/renderer/src/lib/agent-launch-platform.ts +++ b/src/renderer/src/lib/agent-launch-platform.ts @@ -1,4 +1,5 @@ import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path' +import { isWslUncPath } from '../../../shared/wsl-paths' import { CLIENT_PLATFORM } from '@/lib/new-workspace' import type { AppState } from '@/store' import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' @@ -11,7 +12,12 @@ export function getAgentLaunchPlatformForRepo( if (projectRuntime?.status === 'repair-required') { return projectRuntime.repair.preferredRuntime.kind === 'wsl' ? 'linux' : CLIENT_PLATFORM } - if (projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl') { + // Why a WSL path: the pane spawns in the distro's POSIX shell, whatever the Windows shell + // setting, so its line is judged and quoted for that shell. + if ( + (projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl') || + isWslUncPath(repo.path) + ) { return 'linux' } return CLIENT_PLATFORM diff --git a/src/renderer/src/lib/launch-file-host.test.ts b/src/renderer/src/lib/launch-file-host.test.ts index 167a04742b1..95717c94f37 100644 --- a/src/renderer/src/lib/launch-file-host.test.ts +++ b/src/renderer/src/lib/launch-file-host.test.ts @@ -34,7 +34,7 @@ describe('the quick composer on a paired host', () => { paired: true, provesAgentInFront: true, takesLaunchFile: false, - windowsPowerShell: null + windowsPaneShell: null }, telemetrySource: 'sidebar' }) @@ -62,12 +62,12 @@ describe('the PowerShell a local Windows launch from this client lands in', () = terminalWindowsPowerShellImplementation: 'auto' } }) - expect(host().windowsPowerShell).toBeNull() + expect(host().windowsPaneShell).toBeNull() vi.mocked(localPwshAvailability).mockReturnValue(false) - expect(host().windowsPowerShell).toBe('powershell.exe') + expect(host().windowsPaneShell).toBe('powershell.exe') vi.mocked(localPwshAvailability).mockReturnValue(true) - expect(host().windowsPowerShell).toBe('pwsh.exe') - expect(host(true).windowsPowerShell).toBeNull() + expect(host().windowsPaneShell).toBe('pwsh.exe') + expect(host(true).windowsPaneShell).toBeNull() useAppStore.setState({ settings }) }) }) diff --git a/src/renderer/src/lib/launch-file-host.ts b/src/renderer/src/lib/launch-file-host.ts index 59830ce5ac9..9badd87cd04 100644 --- a/src/renderer/src/lib/launch-file-host.ts +++ b/src/renderer/src/lib/launch-file-host.ts @@ -3,7 +3,7 @@ import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { useAppStore } from '@/store' import { describeLaunchHost, - spawnedWindowsPowerShell, + spawnedWindowsShell, type LaunchHost } from '../../../shared/launch-host' import { isWebClientLocation } from './web-client-location' @@ -32,8 +32,8 @@ export function clientLaunchHost(args: { hostPlatform: CLIENT_PLATFORM, paired, // Why this client's settings: a local pane is spawned from them, as its shell is. - windowsPowerShell: local - ? spawnedWindowsPowerShell({ + windowsPaneShell: local + ? spawnedWindowsShell({ settings: useAppStore.getState().settings, pwshAvailable: localPwshAvailability() }) diff --git a/src/renderer/src/lib/launch-work-item-direct-agent.test.ts b/src/renderer/src/lib/launch-work-item-direct-agent.test.ts index ef9e68760ed..b1f5c3c364b 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent.test.ts @@ -88,7 +88,7 @@ describe('buildDirectWorkItemAgentStartupPlan', () => { paired: false, provesAgentInFront: true, takesLaunchFile: true, - windowsPowerShell: null + windowsPaneShell: null }, agent: 'codex', draftContent: 'Review issue 42', @@ -108,7 +108,7 @@ describe('buildDirectWorkItemAgentStartupPlan', () => { paired: false, provesAgentInFront: true, takesLaunchFile: true, - windowsPowerShell: null + windowsPaneShell: null }, agent: 'codex', draftContent: 'Review issue 42', @@ -162,7 +162,7 @@ describe('buildDirectWorkItemAgentStartupPlan global arguments fallback', () => paired: false, provesAgentInFront: true, takesLaunchFile: true, - windowsPowerShell: null + windowsPaneShell: null }, agent: 'codex', draftContent: 'Fix the broken checks', @@ -181,7 +181,7 @@ describe('buildDirectWorkItemAgentStartupPlan global arguments fallback', () => paired: false, provesAgentInFront: true, takesLaunchFile: true, - windowsPowerShell: null + windowsPaneShell: null }, agent: 'codex', agentArgs: '--model gpt-5', @@ -200,7 +200,7 @@ describe('buildDirectWorkItemAgentStartupPlan global arguments fallback', () => describe('buildDirectWorkItemAgentStartupPlan submitted prompts', () => { const submit = (agent: 'claude' | 'gemini', draftContent: string, paired = false) => buildDirectWorkItemAgentStartupPlan({ - host: { paired, provesAgentInFront: true, takesLaunchFile: !paired, windowsPowerShell: null }, + host: { paired, provesAgentInFront: true, takesLaunchFile: !paired, windowsPaneShell: null }, agent, draftContent, promptDelivery: 'submit-after-ready', diff --git a/src/shared/launch-host.test.ts b/src/shared/launch-host.test.ts index 2c34b50232b..1e69289ceaa 100644 --- a/src/shared/launch-host.test.ts +++ b/src/shared/launch-host.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { describeLaunchHost, spawnedWindowsPowerShell } from './launch-host' +import { describeLaunchHost, spawnedWindowsShell } from './launch-host' describe('what a launch host can do with its prompt', () => { it.each([ @@ -20,14 +20,14 @@ describe('what a launch host can do with its prompt', () => { paired, provesAgentInFront: proves, takesLaunchFile, - windowsPowerShell: null + windowsPaneShell: null }) } ) }) // Why: the PowerShell decides how `"` and a trailing `\` reach the agent, and this Orca picks it. -describe('the PowerShell a local Windows pane is spawned as', () => { +describe('the shell a local Windows pane is spawned as', () => { it.each([ ['the pwsh shell', { terminalWindowsShell: 'pwsh.exe' }, undefined, null, 'pwsh.exe'], [ @@ -68,12 +68,21 @@ describe('the PowerShell a local Windows pane is spawned as', () => { null, null ], - ['cmd', { terminalWindowsShell: 'cmd.exe' }, undefined, true, null], - ['Git Bash', { terminalWindowsShell: 'git-bash' }, undefined, true, null] + ['cmd', { terminalWindowsShell: 'cmd.exe' }, undefined, true, 'cmd.exe'], + ['Git Bash', { terminalWindowsShell: 'git-bash' }, undefined, true, 'git-bash'], + // The distro's POSIX shell, whatever PowerShell would have been. + ['WSL', { terminalWindowsShell: 'wsl.exe' }, undefined, true, 'wsl.exe'], + [ + 'a requested WSL over a cmd setting', + { terminalWindowsShell: 'cmd.exe' }, + 'wsl', + null, + 'wsl.exe' + ], + // Why unknown: System32's bash.exe is WSL, Git's is Git Bash. + ['a bare bash.exe', { terminalWindowsShell: 'bash.exe' }, undefined, true, null] ] as const)('%s', (_label, settings, windowsShellOverride, pwshAvailable, expected) => { - expect(spawnedWindowsPowerShell({ settings, windowsShellOverride, pwshAvailable })).toBe( - expected - ) + expect(spawnedWindowsShell({ settings, windowsShellOverride, pwshAvailable })).toBe(expected) }) it('is not this machine’s to know for an SSH or paired host', () => { @@ -86,8 +95,8 @@ describe('the PowerShell a local Windows pane is spawned as', () => { ...where, launchPlatform: 'win32', hostPlatform: 'win32', - windowsPowerShell: 'powershell.exe' - }).windowsPowerShell + windowsPaneShell: 'powershell.exe' + }).windowsPaneShell ).toBeNull() } }) diff --git a/src/shared/launch-host.ts b/src/shared/launch-host.ts index 7440e5e273e..3eb96f71b61 100644 --- a/src/shared/launch-host.ts +++ b/src/shared/launch-host.ts @@ -1,7 +1,11 @@ import type { GlobalSettings } from './global-settings-types' +import { WINDOWS_GIT_BASH_SHELL } from './windows-terminal-shell' export type WindowsPowerShell = 'powershell.exe' | 'pwsh.exe' +/** The shell a local Windows pane runs; `wsl.exe` is the distro's POSIX login shell. */ +export type WindowsPaneShell = WindowsPowerShell | 'cmd.exe' | 'git-bash' | 'wsl.exe' + export type WindowsShellSettings = | Partial< Pick @@ -21,29 +25,39 @@ export type LaunchHost = { * SSH Windows host's relay may run its panes in WSL (its OpenSSH default shell, which this client * cannot see), where it writes none. Such a host gets the line or the paste instead. */ takesLaunchFile: boolean - /** The PowerShell a local Windows pane is spawned as, which decides how it hands `"` and a - * trailing `\` to the agent. Null where this Orca does not choose it (an SSH or paired host) or - * has not yet learned whether pwsh is installed. */ - windowsPowerShell: WindowsPowerShell | null + /** The shell a local Windows pane is spawned as, which the line is judged and quoted by: a WSL + * pane runs the distro's POSIX shell whatever the Windows setting, and the PowerShell decides + * how `"` and a trailing `\` reach the agent. Null where this Orca does not choose it (an SSH or + * paired host), or has not yet learned whether pwsh is installed. */ + windowsPaneShell: WindowsPaneShell | null } /** - * The PowerShell this host spawns for a pane, resolved as the spawn resolves it: the requested - * shell, then the implementation setting, then whether pwsh.exe is installed. Null for a shell that - * is not PowerShell, or when that probe has not answered. + * The shell this host spawns for a local Windows pane, resolved as the spawn resolves it: the + * requested shell, then the setting; for PowerShell, the implementation setting, then whether + * pwsh.exe is installed. Null for a shell it cannot name (a custom path, or `bash.exe`, which may be + * Git Bash or WSL), or a PowerShell before that probe answers. */ -export function spawnedWindowsPowerShell(args: { +export function spawnedWindowsShell(args: { settings: WindowsShellSettings /** The shell this launch asked for, which outranks the setting. */ windowsShellOverride?: string | null pwshAvailable: boolean | null -}): WindowsPowerShell | null { +}): WindowsPaneShell | null { const shell = (args.windowsShellOverride ?? args.settings?.terminalWindowsShell ?? '').trim() - const name = shell.replaceAll('\\', '/').split('/').pop()?.toLowerCase() ?? '' - if (name === 'pwsh.exe' || name === 'pwsh') { - return 'pwsh.exe' + if (shell === WINDOWS_GIT_BASH_SHELL) { + return 'git-bash' } - if (name !== '' && name !== 'powershell.exe' && name !== 'powershell') { + const name = shell + .replaceAll('\\', '/') + .split('/') + .pop() + ?.toLowerCase() + .replace(/\.exe$/, '') + if (name === 'pwsh' || name === 'cmd' || name === 'wsl') { + return `${name}.exe` + } + if (name !== '' && name !== 'powershell') { return null } const implementation = args.settings?.terminalWindowsPowerShellImplementation @@ -64,15 +78,15 @@ export function describeLaunchHost(args: { /** The platform of the machine this Orca runs on. */ hostPlatform: NodeJS.Platform paired: boolean - /** `spawnedWindowsPowerShell` on this machine, which a launch elsewhere does not use. */ - windowsPowerShell?: WindowsPowerShell | null + /** `spawnedWindowsShell` on this machine, which a launch elsewhere does not use. */ + windowsPaneShell?: WindowsPaneShell | null }): LaunchHost { const runsElsewhere = args.isRemote || args.paired return { paired: args.paired, provesAgentInFront: (runsElsewhere ? args.launchPlatform : args.hostPlatform) !== 'win32', takesLaunchFile: !args.paired && !(args.isRemote && args.launchPlatform === 'win32'), - windowsPowerShell: - runsElsewhere || args.launchPlatform !== 'win32' ? null : (args.windowsPowerShell ?? null) + windowsPaneShell: + runsElsewhere || args.launchPlatform !== 'win32' ? null : (args.windowsPaneShell ?? null) } } diff --git a/src/shared/launch-prompt-carrier.test.ts b/src/shared/launch-prompt-carrier.test.ts index 02bbcc43d90..bcaf986c2ab 100644 --- a/src/shared/launch-prompt-carrier.test.ts +++ b/src/shared/launch-prompt-carrier.test.ts @@ -29,7 +29,7 @@ describe('the one launch-prompt decision every launch path builds through', () = paired: true, provesAgentInFront: true, takesLaunchFile: false, - windowsPowerShell: null + windowsPaneShell: null } }) expect(short?.carry).toBe('on-line') @@ -41,7 +41,7 @@ describe('the one launch-prompt decision every launch path builds through', () = paired: true, provesAgentInFront: true, takesLaunchFile: false, - windowsPowerShell: null + windowsPaneShell: null } }) expect(long?.carry).toBe('paste-after-ready') diff --git a/src/shared/launch-prompt-carry.test.ts b/src/shared/launch-prompt-carry.test.ts index 48dd1b18e65..7619ff4bc53 100644 --- a/src/shared/launch-prompt-carry.test.ts +++ b/src/shared/launch-prompt-carry.test.ts @@ -185,7 +185,7 @@ describe('a host that types the line raw', () => { paired: false, provesAgentInFront: false, takesLaunchFile: true, - windowsPowerShell: null + windowsPaneShell: null } } const prompt = 'fix the build\nthen run the tests' @@ -225,7 +225,7 @@ describe('a host that types the line raw', () => { paired: true, provesAgentInFront: true, takesLaunchFile: false, - windowsPowerShell: null + windowsPaneShell: null } } expect(plan('claude', 'fix it', extra)?.carry).toBe('on-line') diff --git a/src/shared/launch-prompt-carry.ts b/src/shared/launch-prompt-carry.ts index f5816cda0dc..b5162e059e0 100644 --- a/src/shared/launch-prompt-carry.ts +++ b/src/shared/launch-prompt-carry.ts @@ -169,9 +169,16 @@ export function carryLaunchPrompt { expect(plan?.launchCommand).toBe('claude "fix ""quoted"" & "^%"PATH"^%""') }) - it.each(['cmd', 'powershell'] as const)( - 'never types a line break into %s: a multi-line prompt rides a launch file', - (shell) => { - const prompt = 'fix the build\r\n& echo PWNED\nthen run tests' - const plan = planLaunchForTest({ - agent: 'claude', - prompt, - cmdOverrides: {}, - platform: 'win32', - shell - }) + it('never types a line break into cmd: a multi-line prompt rides a launch file', () => { + const prompt = 'fix the build\r\n& echo PWNED\nthen run tests' + const plan = planLaunchForTest({ + agent: 'claude', + prompt, + cmdOverrides: {}, + platform: 'win32', + shell: 'cmd' + }) - expect(plan?.launchCommand).not.toMatch(/[\r\n]/) - expect(plan?.launchCommand).not.toContain('PWNED') - expect(plan?.launchFile).toMatchObject({ content: prompt }) - expect(plan?.launchCommand).toContain(plan?.launchFile?.placeholder) - } - ) + expect(plan?.launchCommand).not.toMatch(/[\r\n]/) + expect(plan?.launchCommand).not.toContain('PWNED') + expect(plan?.launchFile).toMatchObject({ content: prompt }) + expect(plan?.launchCommand).toContain(plan?.launchFile?.placeholder) + }) + + // Why: PowerShell keeps a line break inside a single-quoted argument (measured), so `&` there is + // text, never a command; a CR before a line feed was not measured, so it is typed as main typed it. + it('keeps a PowerShell prompt with line breaks inside its one quoted argument', () => { + const prompt = 'fix the build\r\n& echo PWNED\nthen run tests' + const plan = planLaunchForTest({ + agent: 'claude', + prompt, + cmdOverrides: {}, + platform: 'win32', + shell: 'powershell' + }) + + expect(plan?.launchFile).toBeUndefined() + expect(plan?.launchCommand).toBe(`claude '${prompt}'`) + }) it('keeps a multi-line prompt inline for a POSIX shell on a POSIX host, which stages it', () => { const plan = planLaunchForTest({ diff --git a/src/shared/windows-launch-line.test.ts b/src/shared/windows-launch-line.test.ts index a7b1f28fc2c..ecea6a950a4 100644 --- a/src/shared/windows-launch-line.test.ts +++ b/src/shared/windows-launch-line.test.ts @@ -37,7 +37,7 @@ function windowsHost(windowsPowerShell: WindowsPowerShell | null) { isRemote: false, hostPlatform: 'win32', paired: false, - windowsPowerShell + windowsPaneShell: windowsPowerShell }) } @@ -132,29 +132,48 @@ describe('an agent.launch prompt on a Windows host, per measured shell', () => { ) }) - // Why: a typed carriage return is Enter (in cmd the rest runs as commands) and a Tab completes. - it.each(['cmd', 'powershell', 'posix'])( - 'never types a Tab or a carriage return into %s', - (shell) => { - const planned = (paste: 'once-agent-runs' | 'when-host-proves-agent') => - planLaunchPrompt({ - agent: 'claude', - prompt: 'before\tmiddle\r& echo PWNED', - cmdOverrides: {}, - platform: 'win32', - shell, - host: describeLaunchHost({ - launchPlatform: 'win32', - isRemote: false, - hostPlatform: 'win32', - paired: false - }), - paste - })?.carry - expect(planned('once-agent-runs')).toBe('paste-after-ready') - expect(planned('when-host-proves-agent')).toBe('launch-file') + // Why per shell (stack QA ptab/pcr): cmd and Git Bash read a Tab or carriage return as a key + // (completion, or Enter, which in cmd runs the rest as commands); both PowerShells carried them. + it.each<[AgentStartupShell, 'ptab' | 'pcr', 'on-line' | 'launch-file']>([ + ['cmd', 'ptab', 'launch-file'], + ['cmd', 'pcr', 'launch-file'], + ['posix', 'ptab', 'launch-file'], + ['posix', 'pcr', 'launch-file'], + ['powershell', 'ptab', 'on-line'], + ['powershell', 'pcr', 'on-line'] + ])('types %s %s only where the shell carried it: %s', (shell, row, expected) => { + const prompt = + row === 'ptab' + ? 'QA-STACK PTAB one line with a\ttab inside, inert, do not run any command.' + : 'QA-STACK PCR one line with a bare\rCR inside, inert, do not run any command.' + for (const windowsPowerShell of ['powershell.exe', 'pwsh.exe'] as const) { + const planned = planLaunchPrompt({ + agent: 'claude', + prompt, + cmdOverrides: {}, + platform: 'win32', + shell, + host: windowsHost(windowsPowerShell), + paste: 'when-host-proves-agent' + }) + expect(planned?.carry).toBe(expected) } - ) + }) + + it('leaves another control byte, never measured, to main’s delivery', () => { + const planned = (paste: 'once-agent-runs' | 'when-host-proves-agent') => + planLaunchPrompt({ + agent: 'claude', + prompt: 'before\x1bafter', + cmdOverrides: {}, + platform: 'win32', + shell: 'powershell', + host: windowsHost('pwsh.exe'), + paste + })?.carry + expect(planned('once-agent-runs')).toBe('paste-after-ready') + expect(planned('when-host-proves-agent')).toBe('on-line') + }) // Why: an AI button's prompt was pasted on main, so only a line measured exact replaces that // paste; measured damage and the unmeasured both keep main's paste. diff --git a/src/shared/windows-launch-line.ts b/src/shared/windows-launch-line.ts index 528a57c18ab..b9a37239c30 100644 --- a/src/shared/windows-launch-line.ts +++ b/src/shared/windows-launch-line.ts @@ -28,16 +28,20 @@ const WINDOWS_POSIX_LINE_DAMAGED_CHARS = 20_551 const encoder = new TextEncoder() -/** A control byte other than a line feed: every Windows line editor reads it as a key (a carriage - * return is Enter, which in cmd runs the rest as commands; Tab completes), so the bytes change. */ -function hasKeyByte(line: string): boolean { +/** Control bytes other than a line feed: Tab and carriage return were measured, the rest not. */ +function keyBytes(line: string): { tabOrReturn: boolean; unmeasured: boolean } { + let tabOrReturn = false + let unmeasured = false for (let i = 0; i < line.length; i += 1) { const code = line.charCodeAt(i) - if ((code < 0x20 && code !== 0x0a) || code === 0x7f) { - return true + // Measured: a Tab and a bare carriage return; a CR before a line feed was not. + if (code === 0x09 || (code === 0x0d && line.charCodeAt(i + 1) !== 0x0a)) { + tabOrReturn = true + } else if ((code < 0x20 && code !== 0x0a) || code === 0x7f) { + unmeasured = true } } - return false + return { tabOrReturn, unmeasured } } /** An agent spelled as a `.cmd`/`.bat` shim, whose cmd.exe re-reads its arguments. A bare name @@ -70,6 +74,10 @@ function shellVerdict(line: string, shell: AgentStartupShell): WindowsLineVerdic return line.length >= WINDOWS_POSIX_LINE_DAMAGED_CHARS ? 'damaged' : 'uncertain' } +function withUnmeasuredKeys(verdict: WindowsLineVerdict, unmeasured: boolean): WindowsLineVerdict { + return unmeasured && verdict === 'exact' ? 'uncertain' : verdict +} + export function windowsLaunchLineVerdict( prompt: string, line: string, @@ -77,10 +85,13 @@ export function windowsLaunchLineVerdict( /** The PowerShell the pane is spawned as (`LaunchHost.windowsPowerShell`), when known. */ windowsPowerShell: WindowsPowerShell | null ): WindowsLineVerdict { - if (hasKeyByte(line)) { + const keys = keyBytes(line) + // Measured: cmd and Git Bash read a Tab or carriage return as a key (completion, or Enter, which + // in cmd runs the rest as commands); both PowerShells carried them exactly. + if (keys.tabOrReturn && shell !== 'powershell') { return 'damaged' } - const verdict = shellVerdict(line, shell) + const verdict = withUnmeasuredKeys(shellVerdict(line, shell), keys.unmeasured) const legacyArgsDamage = prompt.includes('"') || prompt.endsWith('\\') if (shell === 'cmd' || !launchesThroughCmdShim(line)) { if (shell !== 'powershell' || verdict !== 'exact' || !legacyArgsDamage) { diff --git a/src/shared/windows-shell-prompt-damage.test.ts b/src/shared/windows-shell-prompt-damage.test.ts index 0ad862939a8..f7cb9f1e050 100644 --- a/src/shared/windows-shell-prompt-damage.test.ts +++ b/src/shared/windows-shell-prompt-damage.test.ts @@ -99,7 +99,7 @@ describe('a prompt a Windows shell would damage on the launch line', () => { paired: true, provesAgentInFront: false, takesLaunchFile: false, - windowsPowerShell: null + windowsPaneShell: null } }) expect(startup?.launchFile).toBeUndefined()