fix(launch-prompt): plan a WSL pane for its own shell; refuse a launch that cannot be staged

Stack QA on 193eeaddff:
- 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.
This commit is contained in:
Brennan Benson
2026-10-02 09:38:20 -07:00
parent 04417b6725
commit d8cfcf397a
25 changed files with 414 additions and 127 deletions
+11 -2
View File
@@ -18,7 +18,10 @@ import { getRecoveredHistorySeedSegments } from './terminal-history-seed-segment
import { AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, type CreateOrAttachResult } from './types' import { AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, type CreateOrAttachResult } from './types'
import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd' import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd'
import { resolveWslSessionContext } from './wsl-session-context' 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 { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd'
import { resolveUnixShellPath } from '../providers/local-pty-utils' import { resolveUnixShellPath } from '../providers/local-pty-utils'
import type { PtySpawnOptions, PtySpawnResult } from '../providers/types' import type { PtySpawnOptions, PtySpawnResult } from '../providers/types'
@@ -258,7 +261,13 @@ export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult {
detectColdRestore detectColdRestore
} }
activeSpawnContext = context 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) { if (result.isNew && !attachOnly) {
// Not awaited: the app-side read behind it can sit on an unanswered macOS folder prompt. // Not awaited: the app-side read behind it can sit on an unanswered macOS folder prompt.
void reportDaemonPtyCwdVerdict({ void reportDaemonPtyCwdVerdict({
@@ -6,7 +6,11 @@ import {
type StartupCommandStaging type StartupCommandStaging
} from '../../shared/startup-command-staging' } from '../../shared/startup-command-staging'
import { resolvePtyOwnerBackend } from '../../shared/pty-owner-backend' 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 { getDaemonSessionResultMetadata } from './daemon-create-or-attach-result'
import { enumerateDirectoryOnce } from './directory-enumeration-probe' import { enumerateDirectoryOnce } from './directory-enumeration-probe'
import { normalizePtySize } from './daemon-pty-size' import { normalizePtySize } from './daemon-pty-size'
@@ -206,11 +210,33 @@ async function spawnAndPublishSession(
throw new TerminalAttachCanceledError(opts.sessionId) 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.sessions.set(opts.sessionId, session)
deps.onSessionCreated(opts.sessionId, opts.agentSessionGeneration, session.isAlive) deps.onSessionCreated(opts.sessionId, opts.agentSessionGeneration, session.isAlive)
const token = session.attachClient(opts.streamClient) const token = session.attachClient(opts.streamClient)
const startupCommandWritten = Boolean(command) && !subprocess.startupCommandDeliveredInShellArgs
// Why: without this, a missing command and a lost one log identically. // Why: without this, a missing command and a lost one log identically.
// Length, never the text -- launches can carry credentials. // Length, never the text -- launches can carry credentials.
try { try {
@@ -225,13 +251,7 @@ async function spawnAndPublishSession(
} catch { } catch {
// Diagnostics must never turn a live PTY into a failed create. // Diagnostics must never turn a live PTY into a failed create.
} }
if (startupCommandWritten && command) { if (staging) {
staging = stageStartupCommand({
command,
shellPath: subprocess.shellPath,
orcaBuiltLine: opts.launchAgent !== undefined,
wslDirectory
})
const notice = startupStagingFailureNotice(staging) const notice = startupStagingFailureNotice(staging)
if (notice) { if (notice) {
session.startupIngress.accept(notice) session.startupIngress.accept(notice)
@@ -99,6 +99,31 @@ describePosix('daemon startup command staging', () => {
expect(sub.write).not.toHaveBeenCalled() 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 () => { it('prints a notice in the terminal when it types a line it could not stage', async () => {
vi.stubEnv('TMPDIR', join(tempDir, 'missing')) vi.stubEnv('TMPDIR', join(tempDir, 'missing'))
const command = `claude '${'x'.repeat(600)}'` const command = `claude '${'x'.repeat(600)}'`
+19 -9
View File
@@ -30,7 +30,10 @@ import {
writeSpawnLaunchFile, writeSpawnLaunchFile,
type WrittenLaunchFile type WrittenLaunchFile
} from '../../shared/launch-file-writing' } from '../../shared/launch-file-writing'
import { resolveSpawnWslLaunchDirectory } from './wsl-launch-directory-resolution' import {
forgetWslLaunchDirectoryAfterRefusal,
resolveSpawnWslLaunchDirectory
} from './wsl-launch-directory-resolution'
export async function spawnLocalPty( export async function spawnLocalPty(
args: PtySpawnOptions, args: PtySpawnOptions,
@@ -55,14 +58,20 @@ export async function spawnLocalPty(
if (args.attachOnly) { if (args.attachOnly) {
throw new SessionNotFoundError(args.sessionId ?? '') throw new SessionNotFoundError(args.sessionId ?? '')
} }
const launchFile = writeSpawnLaunchFile({ let launchFile: WrittenLaunchFile | undefined
launchFile: args.launchFile, try {
command: args.command, launchFile = writeSpawnLaunchFile({
env: args.env, launchFile: args.launchFile,
orcaBuiltLine: args.launchAgent !== undefined, command: args.command,
wslDistro, env: args.env,
wslDirectory: wslLaunchDirectory orcaBuiltLine: args.launchAgent !== undefined,
}) wslDistro,
wslDirectory: wslLaunchDirectory
})
} catch (error) {
forgetWslLaunchDirectoryAfterRefusal(wslLaunchDirectory, error)
throw error
}
args = { args = {
...args, ...args,
...(launchFile ? { command: launchFile.command, env: launchFile.env } : {}), ...(launchFile ? { command: launchFile.command, env: launchFile.env } : {}),
@@ -75,6 +84,7 @@ export async function spawnLocalPty(
return await spawnFreshLocalPty(args, getOptions, reattachId, launchFile) return await spawnFreshLocalPty(args, getOptions, reattachId, launchFile)
} catch (error) { } catch (error) {
removeLaunchFile(launchFile) removeLaunchFile(launchFile)
forgetWslLaunchDirectoryAfterRefusal(wslLaunchDirectory, error)
throw error throw error
} }
} }
@@ -3,6 +3,7 @@ import { createLocalPtyLaunchPlan, resolveLocalPtyWslDistro } from './local-pty-
import type { LocalPtyProviderOptions } from './local-pty-provider-types' import type { LocalPtyProviderOptions } from './local-pty-provider-types'
import type { PtySpawnOptions } from './types' import type { PtySpawnOptions } from './types'
import { import {
forgetWslLaunchDirectoryAfterRefusal,
resolveSpawnWslLaunchDirectory, resolveSpawnWslLaunchDirectory,
resolveWslLaunchDirectory, resolveWslLaunchDirectory,
WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS 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 () => { it('probes only for a spawn that needs the directory', async () => {
runWslProcess.mockResolvedValue(answers('/home/ada\n/bin/bash\n')) runWslProcess.mockResolvedValue(answers('/home/ada\n/bin/bash\n'))
expect( expect(
@@ -3,6 +3,7 @@ import { quotePosixShell, RESOLVE_WSL_LOGIN_SHELL } from '../../shared/wsl-login
import type { WslLaunchDirectory } from '../../shared/wsl-launch-directory' import type { WslLaunchDirectory } from '../../shared/wsl-launch-directory'
import type { LaunchFile } from '../../shared/launch-prompt-file' import type { LaunchFile } from '../../shared/launch-prompt-file'
import { spawnNeedsWslLaunchDirectory } from '../../shared/launch-file-writing' import { spawnNeedsWslLaunchDirectory } from '../../shared/launch-file-writing'
import { isLaunchFileRefusal } from '../../shared/launch-prompt-file'
import type { TuiAgent } from '../../shared/tui-agent' import type { TuiAgent } from '../../shared/tui-agent'
import { toWindowsWslUncPath } from '../../shared/wsl-paths' import { toWindowsWslUncPath } from '../../shared/wsl-paths'
import { runWslProcess } from '../wsl/wsl-runner' 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 * 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 * 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 * 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( export async function resolveWslLaunchDirectory(
distro: string | null | undefined distro: string | null | undefined
@@ -66,6 +68,17 @@ export async function resolveWslLaunchDirectory(
return await probe 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<WslLaunchDirectory | undefined> { async function probeWslLaunchDirectory(distro: string): Promise<WslLaunchDirectory | undefined> {
const script = [ const script = [
...RESOLVE_WSL_LOGIN_SHELL, ...RESOLVE_WSL_LOGIN_SHELL,
@@ -5,6 +5,7 @@ import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resum
import type { ClaudeAgentTeamsMode } from '../../shared/claude-agent-teams-tmux-compat' import type { ClaudeAgentTeamsMode } from '../../shared/claude-agent-teams-tmux-compat'
import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime'
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
import { isWslUncPath } from '../../shared/wsl-paths'
import { getTuiAgentLaunchCommand, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' import { getTuiAgentLaunchCommand, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config'
import { isTuiAgentEnabled } from '../../shared/tui-agent-selection' import { isTuiAgentEnabled } from '../../shared/tui-agent-selection'
@@ -110,7 +111,12 @@ export function getAgentLaunchPlatformForRepo(
if (projectRuntime?.status === 'repair-required') { if (projectRuntime?.status === 'repair-required') {
return projectRuntime.repair.preferredRuntime.kind === 'wsl' ? 'linux' : process.platform 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 'linux'
} }
return process.platform return process.platform
@@ -30,12 +30,12 @@ describe('the PowerShell a launch this Orca runs lands in', () => {
settings, settings,
windowsShellOverride windowsShellOverride
}) })
expect(host().windowsPowerShell).toBeNull() expect(host().windowsPaneShell).toBeNull()
vi.mocked(cachedPwshAvailability).mockReturnValue(false) vi.mocked(cachedPwshAvailability).mockReturnValue(false)
expect(host().windowsPowerShell).toBe('powershell.exe') expect(host().windowsPaneShell).toBe('powershell.exe')
expect(host('pwsh.exe').windowsPowerShell).toBe('pwsh.exe') expect(host('pwsh.exe').windowsPaneShell).toBe('pwsh.exe')
expect( expect(
thisOrcaLaunchHost({ launchPlatform: 'win32', isRemote: true, settings }).windowsPowerShell thisOrcaLaunchHost({ launchPlatform: 'win32', isRemote: true, settings }).windowsPaneShell
).toBeNull() ).toBeNull()
}) })
@@ -47,7 +47,7 @@ describe('the PowerShell a launch this Orca runs lands in', () => {
launchPlatform: 'win32', launchPlatform: 'win32',
isRemote: false, isRemote: false,
settings: { terminalWindowsShell: 'pwsh.exe' } settings: { terminalWindowsShell: 'pwsh.exe' }
}).windowsPowerShell }).windowsPaneShell
).toBeNull() ).toBeNull()
}) })
}) })
+3 -3
View File
@@ -1,7 +1,7 @@
import { cachedPwshAvailability } from '../pwsh' import { cachedPwshAvailability } from '../pwsh'
import { import {
describeLaunchHost, describeLaunchHost,
spawnedWindowsPowerShell, spawnedWindowsShell,
type LaunchHost, type LaunchHost,
type WindowsShellSettings type WindowsShellSettings
} from '../../shared/launch-host' } from '../../shared/launch-host'
@@ -20,8 +20,8 @@ export function thisOrcaLaunchHost(args: {
isRemote: args.isRemote, isRemote: args.isRemote,
hostPlatform: process.platform, hostPlatform: process.platform,
paired: false, paired: false,
windowsPowerShell: local windowsPaneShell: local
? spawnedWindowsPowerShell({ ? spawnedWindowsShell({
settings: args.settings, settings: args.settings,
windowsShellOverride: args.windowsShellOverride, windowsShellOverride: args.windowsShellOverride,
pwshAvailable: cachedPwshAvailability() pwshAvailable: cachedPwshAvailability()
@@ -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')
})
})
@@ -20,7 +20,7 @@ describe('the full composer’s renderer-spawned startup', () => {
paired: false, paired: false,
provesAgentInFront: true, provesAgentInFront: true,
takesLaunchFile: true, takesLaunchFile: true,
windowsPowerShell: null windowsPaneShell: null
}, },
paste: 'when-host-proves-agent' paste: 'when-host-proves-agent'
}), }),
@@ -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'
)
})
})
@@ -1,4 +1,5 @@
import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path' import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path'
import { isWslUncPath } from '../../../shared/wsl-paths'
import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { CLIENT_PLATFORM } from '@/lib/new-workspace'
import type { AppState } from '@/store' import type { AppState } from '@/store'
import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime'
@@ -11,7 +12,12 @@ export function getAgentLaunchPlatformForRepo(
if (projectRuntime?.status === 'repair-required') { if (projectRuntime?.status === 'repair-required') {
return projectRuntime.repair.preferredRuntime.kind === 'wsl' ? 'linux' : CLIENT_PLATFORM 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 'linux'
} }
return CLIENT_PLATFORM return CLIENT_PLATFORM
@@ -34,7 +34,7 @@ describe('the quick composer on a paired host', () => {
paired: true, paired: true,
provesAgentInFront: true, provesAgentInFront: true,
takesLaunchFile: false, takesLaunchFile: false,
windowsPowerShell: null windowsPaneShell: null
}, },
telemetrySource: 'sidebar' telemetrySource: 'sidebar'
}) })
@@ -62,12 +62,12 @@ describe('the PowerShell a local Windows launch from this client lands in', () =
terminalWindowsPowerShellImplementation: 'auto' terminalWindowsPowerShellImplementation: 'auto'
} }
}) })
expect(host().windowsPowerShell).toBeNull() expect(host().windowsPaneShell).toBeNull()
vi.mocked(localPwshAvailability).mockReturnValue(false) vi.mocked(localPwshAvailability).mockReturnValue(false)
expect(host().windowsPowerShell).toBe('powershell.exe') expect(host().windowsPaneShell).toBe('powershell.exe')
vi.mocked(localPwshAvailability).mockReturnValue(true) vi.mocked(localPwshAvailability).mockReturnValue(true)
expect(host().windowsPowerShell).toBe('pwsh.exe') expect(host().windowsPaneShell).toBe('pwsh.exe')
expect(host(true).windowsPowerShell).toBeNull() expect(host(true).windowsPaneShell).toBeNull()
useAppStore.setState({ settings }) useAppStore.setState({ settings })
}) })
}) })
+3 -3
View File
@@ -3,7 +3,7 @@ import { CLIENT_PLATFORM } from '@/lib/new-workspace'
import { useAppStore } from '@/store' import { useAppStore } from '@/store'
import { import {
describeLaunchHost, describeLaunchHost,
spawnedWindowsPowerShell, spawnedWindowsShell,
type LaunchHost type LaunchHost
} from '../../../shared/launch-host' } from '../../../shared/launch-host'
import { isWebClientLocation } from './web-client-location' import { isWebClientLocation } from './web-client-location'
@@ -32,8 +32,8 @@ export function clientLaunchHost(args: {
hostPlatform: CLIENT_PLATFORM, hostPlatform: CLIENT_PLATFORM,
paired, paired,
// Why this client's settings: a local pane is spawned from them, as its shell is. // Why this client's settings: a local pane is spawned from them, as its shell is.
windowsPowerShell: local windowsPaneShell: local
? spawnedWindowsPowerShell({ ? spawnedWindowsShell({
settings: useAppStore.getState().settings, settings: useAppStore.getState().settings,
pwshAvailable: localPwshAvailability() pwshAvailable: localPwshAvailability()
}) })
@@ -88,7 +88,7 @@ describe('buildDirectWorkItemAgentStartupPlan', () => {
paired: false, paired: false,
provesAgentInFront: true, provesAgentInFront: true,
takesLaunchFile: true, takesLaunchFile: true,
windowsPowerShell: null windowsPaneShell: null
}, },
agent: 'codex', agent: 'codex',
draftContent: 'Review issue 42', draftContent: 'Review issue 42',
@@ -108,7 +108,7 @@ describe('buildDirectWorkItemAgentStartupPlan', () => {
paired: false, paired: false,
provesAgentInFront: true, provesAgentInFront: true,
takesLaunchFile: true, takesLaunchFile: true,
windowsPowerShell: null windowsPaneShell: null
}, },
agent: 'codex', agent: 'codex',
draftContent: 'Review issue 42', draftContent: 'Review issue 42',
@@ -162,7 +162,7 @@ describe('buildDirectWorkItemAgentStartupPlan global arguments fallback', () =>
paired: false, paired: false,
provesAgentInFront: true, provesAgentInFront: true,
takesLaunchFile: true, takesLaunchFile: true,
windowsPowerShell: null windowsPaneShell: null
}, },
agent: 'codex', agent: 'codex',
draftContent: 'Fix the broken checks', draftContent: 'Fix the broken checks',
@@ -181,7 +181,7 @@ describe('buildDirectWorkItemAgentStartupPlan global arguments fallback', () =>
paired: false, paired: false,
provesAgentInFront: true, provesAgentInFront: true,
takesLaunchFile: true, takesLaunchFile: true,
windowsPowerShell: null windowsPaneShell: null
}, },
agent: 'codex', agent: 'codex',
agentArgs: '--model gpt-5', agentArgs: '--model gpt-5',
@@ -200,7 +200,7 @@ describe('buildDirectWorkItemAgentStartupPlan global arguments fallback', () =>
describe('buildDirectWorkItemAgentStartupPlan submitted prompts', () => { describe('buildDirectWorkItemAgentStartupPlan submitted prompts', () => {
const submit = (agent: 'claude' | 'gemini', draftContent: string, paired = false) => const submit = (agent: 'claude' | 'gemini', draftContent: string, paired = false) =>
buildDirectWorkItemAgentStartupPlan({ buildDirectWorkItemAgentStartupPlan({
host: { paired, provesAgentInFront: true, takesLaunchFile: !paired, windowsPowerShell: null }, host: { paired, provesAgentInFront: true, takesLaunchFile: !paired, windowsPaneShell: null },
agent, agent,
draftContent, draftContent,
promptDelivery: 'submit-after-ready', promptDelivery: 'submit-after-ready',
+19 -10
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' 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', () => { describe('what a launch host can do with its prompt', () => {
it.each([ it.each([
@@ -20,14 +20,14 @@ describe('what a launch host can do with its prompt', () => {
paired, paired,
provesAgentInFront: proves, provesAgentInFront: proves,
takesLaunchFile, takesLaunchFile,
windowsPowerShell: null windowsPaneShell: null
}) })
} }
) )
}) })
// Why: the PowerShell decides how `"` and a trailing `\` reach the agent, and this Orca picks it. // 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([ it.each([
['the pwsh shell', { terminalWindowsShell: 'pwsh.exe' }, undefined, null, 'pwsh.exe'], ['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,
null null
], ],
['cmd', { terminalWindowsShell: 'cmd.exe' }, undefined, true, null], ['cmd', { terminalWindowsShell: 'cmd.exe' }, undefined, true, 'cmd.exe'],
['Git Bash', { terminalWindowsShell: 'git-bash' }, undefined, true, null] ['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) => { ] as const)('%s', (_label, settings, windowsShellOverride, pwshAvailable, expected) => {
expect(spawnedWindowsPowerShell({ settings, windowsShellOverride, pwshAvailable })).toBe( expect(spawnedWindowsShell({ settings, windowsShellOverride, pwshAvailable })).toBe(expected)
expected
)
}) })
it('is not this machine’s to know for an SSH or paired host', () => { 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, ...where,
launchPlatform: 'win32', launchPlatform: 'win32',
hostPlatform: 'win32', hostPlatform: 'win32',
windowsPowerShell: 'powershell.exe' windowsPaneShell: 'powershell.exe'
}).windowsPowerShell }).windowsPaneShell
).toBeNull() ).toBeNull()
} }
}) })
+31 -17
View File
@@ -1,7 +1,11 @@
import type { GlobalSettings } from './global-settings-types' import type { GlobalSettings } from './global-settings-types'
import { WINDOWS_GIT_BASH_SHELL } from './windows-terminal-shell'
export type WindowsPowerShell = 'powershell.exe' | 'pwsh.exe' 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 = export type WindowsShellSettings =
| Partial< | Partial<
Pick<GlobalSettings, 'terminalWindowsShell' | 'terminalWindowsPowerShellImplementation'> Pick<GlobalSettings, 'terminalWindowsShell' | 'terminalWindowsPowerShellImplementation'>
@@ -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 * 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. */ * cannot see), where it writes none. Such a host gets the line or the paste instead. */
takesLaunchFile: boolean takesLaunchFile: boolean
/** The PowerShell a local Windows pane is spawned as, which decides how it hands `"` and a /** The shell a local Windows pane is spawned as, which the line is judged and quoted by: a WSL
* trailing `\` to the agent. Null where this Orca does not choose it (an SSH or paired host) or * pane runs the distro's POSIX shell whatever the Windows setting, and the PowerShell decides
* has not yet learned whether pwsh is installed. */ * how `"` and a trailing `\` reach the agent. Null where this Orca does not choose it (an SSH or
windowsPowerShell: WindowsPowerShell | null * 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 * The shell this host spawns for a local Windows pane, resolved as the spawn resolves it: the
* shell, then the implementation setting, then whether pwsh.exe is installed. Null for a shell that * requested shell, then the setting; for PowerShell, the implementation setting, then whether
* is not PowerShell, or when that probe has not answered. * 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 settings: WindowsShellSettings
/** The shell this launch asked for, which outranks the setting. */ /** The shell this launch asked for, which outranks the setting. */
windowsShellOverride?: string | null windowsShellOverride?: string | null
pwshAvailable: boolean | null pwshAvailable: boolean | null
}): WindowsPowerShell | null { }): WindowsPaneShell | null {
const shell = (args.windowsShellOverride ?? args.settings?.terminalWindowsShell ?? '').trim() const shell = (args.windowsShellOverride ?? args.settings?.terminalWindowsShell ?? '').trim()
const name = shell.replaceAll('\\', '/').split('/').pop()?.toLowerCase() ?? '' if (shell === WINDOWS_GIT_BASH_SHELL) {
if (name === 'pwsh.exe' || name === 'pwsh') { return 'git-bash'
return 'pwsh.exe'
} }
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 return null
} }
const implementation = args.settings?.terminalWindowsPowerShellImplementation const implementation = args.settings?.terminalWindowsPowerShellImplementation
@@ -64,15 +78,15 @@ export function describeLaunchHost(args: {
/** The platform of the machine this Orca runs on. */ /** The platform of the machine this Orca runs on. */
hostPlatform: NodeJS.Platform hostPlatform: NodeJS.Platform
paired: boolean paired: boolean
/** `spawnedWindowsPowerShell` on this machine, which a launch elsewhere does not use. */ /** `spawnedWindowsShell` on this machine, which a launch elsewhere does not use. */
windowsPowerShell?: WindowsPowerShell | null windowsPaneShell?: WindowsPaneShell | null
}): LaunchHost { }): LaunchHost {
const runsElsewhere = args.isRemote || args.paired const runsElsewhere = args.isRemote || args.paired
return { return {
paired: args.paired, paired: args.paired,
provesAgentInFront: (runsElsewhere ? args.launchPlatform : args.hostPlatform) !== 'win32', provesAgentInFront: (runsElsewhere ? args.launchPlatform : args.hostPlatform) !== 'win32',
takesLaunchFile: !args.paired && !(args.isRemote && args.launchPlatform === 'win32'), takesLaunchFile: !args.paired && !(args.isRemote && args.launchPlatform === 'win32'),
windowsPowerShell: windowsPaneShell:
runsElsewhere || args.launchPlatform !== 'win32' ? null : (args.windowsPowerShell ?? null) runsElsewhere || args.launchPlatform !== 'win32' ? null : (args.windowsPaneShell ?? null)
} }
} }
+2 -2
View File
@@ -29,7 +29,7 @@ describe('the one launch-prompt decision every launch path builds through', () =
paired: true, paired: true,
provesAgentInFront: true, provesAgentInFront: true,
takesLaunchFile: false, takesLaunchFile: false,
windowsPowerShell: null windowsPaneShell: null
} }
}) })
expect(short?.carry).toBe('on-line') expect(short?.carry).toBe('on-line')
@@ -41,7 +41,7 @@ describe('the one launch-prompt decision every launch path builds through', () =
paired: true, paired: true,
provesAgentInFront: true, provesAgentInFront: true,
takesLaunchFile: false, takesLaunchFile: false,
windowsPowerShell: null windowsPaneShell: null
} }
}) })
expect(long?.carry).toBe('paste-after-ready') expect(long?.carry).toBe('paste-after-ready')
+2 -2
View File
@@ -185,7 +185,7 @@ describe('a host that types the line raw', () => {
paired: false, paired: false,
provesAgentInFront: false, provesAgentInFront: false,
takesLaunchFile: true, takesLaunchFile: true,
windowsPowerShell: null windowsPaneShell: null
} }
} }
const prompt = 'fix the build\nthen run the tests' const prompt = 'fix the build\nthen run the tests'
@@ -225,7 +225,7 @@ describe('a host that types the line raw', () => {
paired: true, paired: true,
provesAgentInFront: true, provesAgentInFront: true,
takesLaunchFile: false, takesLaunchFile: false,
windowsPowerShell: null windowsPaneShell: null
} }
} }
expect(plan('claude', 'fix it', extra)?.carry).toBe('on-line') expect(plan('claude', 'fix it', extra)?.carry).toBe('on-line')
+9 -2
View File
@@ -169,9 +169,16 @@ export function carryLaunchPrompt<A extends CarriedPlanArgs, P extends { launchC
if (readsEnv) { if (readsEnv) {
return { carry: 'on-line', plan } return { carry: 'on-line', plan }
} }
const paneShell = args.host.windowsPaneShell
// Why not for a WSL pane: it runs the distro's POSIX shell, which the host stages into.
const windowsLine = const windowsLine =
args.platform === 'win32' args.platform === 'win32' && paneShell !== 'wsl.exe'
? windowsLaunchLineVerdict(text, plan.launchCommand, shell, args.host.windowsPowerShell) ? windowsLaunchLineVerdict(
text,
plan.launchCommand,
shell,
paneShell === 'powershell.exe' || paneShell === 'pwsh.exe' ? paneShell : null
)
: 'exact' : 'exact'
// Why #24257's typed budget: an older paired host may type the line raw, truncated past it. // Why #24257's typed budget: an older paired host may type the line raw, truncated past it.
if ( if (
+30 -17
View File
@@ -104,24 +104,37 @@ describe('tui agent startup plans', () => {
expect(plan?.launchCommand).toBe('claude "fix ""quoted"" & "^%"PATH"^%""') expect(plan?.launchCommand).toBe('claude "fix ""quoted"" & "^%"PATH"^%""')
}) })
it.each(['cmd', 'powershell'] as const)( it('never types a line break into cmd: a multi-line prompt rides a launch file', () => {
'never types a line break into %s: a multi-line prompt rides a launch file', const prompt = 'fix the build\r\n& echo PWNED\nthen run tests'
(shell) => { const plan = planLaunchForTest({
const prompt = 'fix the build\r\n& echo PWNED\nthen run tests' agent: 'claude',
const plan = planLaunchForTest({ prompt,
agent: 'claude', cmdOverrides: {},
prompt, platform: 'win32',
cmdOverrides: {}, shell: 'cmd'
platform: 'win32', })
shell
})
expect(plan?.launchCommand).not.toMatch(/[\r\n]/) expect(plan?.launchCommand).not.toMatch(/[\r\n]/)
expect(plan?.launchCommand).not.toContain('PWNED') expect(plan?.launchCommand).not.toContain('PWNED')
expect(plan?.launchFile).toMatchObject({ content: prompt }) expect(plan?.launchFile).toMatchObject({ content: prompt })
expect(plan?.launchCommand).toContain(plan?.launchFile?.placeholder) 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', () => { it('keeps a multi-line prompt inline for a POSIX shell on a POSIX host, which stages it', () => {
const plan = planLaunchForTest({ const plan = planLaunchForTest({
+42 -23
View File
@@ -37,7 +37,7 @@ function windowsHost(windowsPowerShell: WindowsPowerShell | null) {
isRemote: false, isRemote: false,
hostPlatform: 'win32', hostPlatform: 'win32',
paired: false, 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. // Why per shell (stack QA ptab/pcr): cmd and Git Bash read a Tab or carriage return as a key
it.each<AgentStartupShell>(['cmd', 'powershell', 'posix'])( // (completion, or Enter, which in cmd runs the rest as commands); both PowerShells carried them.
'never types a Tab or a carriage return into %s', it.each<[AgentStartupShell, 'ptab' | 'pcr', 'on-line' | 'launch-file']>([
(shell) => { ['cmd', 'ptab', 'launch-file'],
const planned = (paste: 'once-agent-runs' | 'when-host-proves-agent') => ['cmd', 'pcr', 'launch-file'],
planLaunchPrompt({ ['posix', 'ptab', 'launch-file'],
agent: 'claude', ['posix', 'pcr', 'launch-file'],
prompt: 'before\tmiddle\r& echo PWNED', ['powershell', 'ptab', 'on-line'],
cmdOverrides: {}, ['powershell', 'pcr', 'on-line']
platform: 'win32', ])('types %s %s only where the shell carried it: %s', (shell, row, expected) => {
shell, const prompt =
host: describeLaunchHost({ row === 'ptab'
launchPlatform: 'win32', ? 'QA-STACK PTAB one line with a\ttab inside, inert, do not run any command.'
isRemote: false, : 'QA-STACK PCR one line with a bare\rCR inside, inert, do not run any command.'
hostPlatform: 'win32', for (const windowsPowerShell of ['powershell.exe', 'pwsh.exe'] as const) {
paired: false const planned = planLaunchPrompt({
}), agent: 'claude',
paste prompt,
})?.carry cmdOverrides: {},
expect(planned('once-agent-runs')).toBe('paste-after-ready') platform: 'win32',
expect(planned('when-host-proves-agent')).toBe('launch-file') 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 // 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. // paste; measured damage and the unmeasured both keep main's paste.
+19 -8
View File
@@ -28,16 +28,20 @@ const WINDOWS_POSIX_LINE_DAMAGED_CHARS = 20_551
const encoder = new TextEncoder() const encoder = new TextEncoder()
/** A control byte other than a line feed: every Windows line editor reads it as a key (a carriage /** Control bytes other than a line feed: Tab and carriage return were measured, the rest not. */
* return is Enter, which in cmd runs the rest as commands; Tab completes), so the bytes change. */ function keyBytes(line: string): { tabOrReturn: boolean; unmeasured: boolean } {
function hasKeyByte(line: string): boolean { let tabOrReturn = false
let unmeasured = false
for (let i = 0; i < line.length; i += 1) { for (let i = 0; i < line.length; i += 1) {
const code = line.charCodeAt(i) const code = line.charCodeAt(i)
if ((code < 0x20 && code !== 0x0a) || code === 0x7f) { // Measured: a Tab and a bare carriage return; a CR before a line feed was not.
return true 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 /** 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' 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( export function windowsLaunchLineVerdict(
prompt: string, prompt: string,
line: string, line: string,
@@ -77,10 +85,13 @@ export function windowsLaunchLineVerdict(
/** The PowerShell the pane is spawned as (`LaunchHost.windowsPowerShell`), when known. */ /** The PowerShell the pane is spawned as (`LaunchHost.windowsPowerShell`), when known. */
windowsPowerShell: WindowsPowerShell | null windowsPowerShell: WindowsPowerShell | null
): WindowsLineVerdict { ): 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' return 'damaged'
} }
const verdict = shellVerdict(line, shell) const verdict = withUnmeasuredKeys(shellVerdict(line, shell), keys.unmeasured)
const legacyArgsDamage = prompt.includes('"') || prompt.endsWith('\\') const legacyArgsDamage = prompt.includes('"') || prompt.endsWith('\\')
if (shell === 'cmd' || !launchesThroughCmdShim(line)) { if (shell === 'cmd' || !launchesThroughCmdShim(line)) {
if (shell !== 'powershell' || verdict !== 'exact' || !legacyArgsDamage) { if (shell !== 'powershell' || verdict !== 'exact' || !legacyArgsDamage) {
@@ -99,7 +99,7 @@ describe('a prompt a Windows shell would damage on the launch line', () => {
paired: true, paired: true,
provesAgentInFront: false, provesAgentInFront: false,
takesLaunchFile: false, takesLaunchFile: false,
windowsPowerShell: null windowsPaneShell: null
} }
}) })
expect(startup?.launchFile).toBeUndefined() expect(startup?.launchFile).toBeUndefined()