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 { 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)}'`
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,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',
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user