mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
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:
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}'`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<WslLaunchDirectory | undefined> {
|
||||
const script = [
|
||||
...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 { 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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
provesAgentInFront: true,
|
||||
takesLaunchFile: true,
|
||||
windowsPowerShell: null
|
||||
windowsPaneShell: null
|
||||
},
|
||||
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 { 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
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
+31
-17
@@ -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<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
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -169,9 +169,16 @@ export function carryLaunchPrompt<A extends CarriedPlanArgs, P extends { launchC
|
||||
if (readsEnv) {
|
||||
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 =
|
||||
args.platform === 'win32'
|
||||
? windowsLaunchLineVerdict(text, plan.launchCommand, shell, args.host.windowsPowerShell)
|
||||
args.platform === 'win32' && paneShell !== 'wsl.exe'
|
||||
? windowsLaunchLineVerdict(
|
||||
text,
|
||||
plan.launchCommand,
|
||||
shell,
|
||||
paneShell === 'powershell.exe' || paneShell === 'pwsh.exe' ? paneShell : null
|
||||
)
|
||||
: 'exact'
|
||||
// Why #24257's typed budget: an older paired host may type the line raw, truncated past it.
|
||||
if (
|
||||
|
||||
@@ -104,24 +104,37 @@ describe('tui agent startup plans', () => {
|
||||
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({
|
||||
|
||||
@@ -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<AgentStartupShell>(['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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user