fix(agent-resume): quote desktop-built commands for the host's own OS and shell (#35995)

Cold restore, sleeping-agent wake and account restart on a paired Orca
server quoted the resume line for this client's own OS, so a Mac client
quoted POSIX for a Windows server and a Windows client quoted PowerShell
for a Linux one. Fresh desktop-built launches knew the server's OS but
assumed PowerShell on Windows.

One resolver, resolveHostAgentStartupShell, now decides the shell a
desktop-built command runs in: a tab's override, then the executing host's
own Windows shell (this client's setting locally, the server's reported
terminalWindowsShell remotely), then the platform default. Fresh launches
and all three resume paths use it, with the server's reported OS. A nested
SSH workspace on a server quotes for its SSH target. A host that has not
reported its OS gets POSIX quoting with OS-specific agent flags off.
This commit is contained in:
Neil
2026-10-29 09:23:56 -07:00
committed by GitHub
parent d527f971cd
commit 89705dbf85
11 changed files with 347 additions and 78 deletions
@@ -1,8 +1,15 @@
import {
getConnectionIdForWorktreeCopy,
getConnectionIdFromState
} from '@/lib/connection-owner-resolution'
import { normalizeExecutionHostId } from '../../../../../shared/execution-host'
import { parseWorkspaceKey } from '../../../../../shared/workspace-scope'
import { useAppStore } from '@/store'
import { createBrowserUuid } from '@/lib/browser-uuid'
import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup'
import {
resolveRuntimeHostReportedPlatform,
resolveRuntimeHostTerminalWindowsShell,
resolveSshHostReportedPlatform,
resolveAgentResumeLaunchTarget
} from '@/lib/agent-resume-launch-target'
@@ -77,9 +84,16 @@ export function bindBuildColdRestoreAgentResumeStartup(session: ConnectPanePtySe
(useLiveEntry && entry ? state.getAgentLaunchConfigForStatusEntry(entry) : undefined) ??
matchingSleepingLaunchConfig
// Quote resume for the live pane’s effective Windows shell rather than the platform’s PowerShell default.
// Why: a nested-SSH pane on a server runs on its SSH target; the pane's host names a worktree
// copy, while a folder's own record carries its connection.
const paneHostId = normalizeExecutionHostId(session.executionHostId)
const connectionId =
(paneHostId && parseWorkspaceKey(session.deps.worktreeId)?.type !== 'folder'
? getConnectionIdForWorktreeCopy(state, session.deps.worktreeId, paneHostId)
: getConnectionIdFromState(state, session.deps.worktreeId)) ?? null
const resumeTarget = resolveAgentResumeLaunchTarget({
projectRuntime: session.projectRuntime,
connectionId: null,
connectionId,
executionHostId: session.executionHostId,
worktreePath: session.worktree?.path,
terminalWindowsShell: state.settings?.terminalWindowsShell,
@@ -88,9 +102,14 @@ export function bindBuildColdRestoreAgentResumeStartup(session: ConnectPanePtySe
remotePlatform: resolveSshHostReportedPlatform(
state,
session.executionHostId,
session.transport.getRuntimeEnvironmentId?.() ?? null
session.transport.getRuntimeEnvironmentId?.() ?? null,
connectionId
),
runtimeHostPlatform: resolveRuntimeHostReportedPlatform(state, session.executionHostId)
runtimeHostPlatform: resolveRuntimeHostReportedPlatform(state, session.executionHostId),
runtimeHostTerminalWindowsShell: resolveRuntimeHostTerminalWindowsShell(
state,
session.executionHostId
)
})
const startupPlan = buildAgentResumeStartupPlan({
agent,
@@ -0,0 +1,2 @@
/** Whether a paired server's panes spawn in a tab's own shell override; resume quoting reads the same fact. */
export const REMOTE_RUNTIME_PTY_HONOURS_TAB_SHELL_OVERRIDE = false
@@ -1,4 +1,5 @@
import { parseTerminalAttachmentLaunchResult } from '../../../../shared/terminal-attachment-launch-result'
import { REMOTE_RUNTIME_PTY_HONOURS_TAB_SHELL_OVERRIDE } from './remote-runtime-pty-shell'
import { terminalLaunchFacts } from '../../../../shared/agent-session-resume'
import { isRuntimeCompatBlockError } from '@/runtime/runtime-protocol-compat'
import { runtimeEnvironmentSupportsCapability } from '@/runtime/runtime-rpc-client'
@@ -3520,6 +3521,9 @@ export function createRemoteRuntimePtyTransport(
...originOptions,
...(launchTokenToSend !== undefined ? { launchToken: launchTokenToSend } : {}),
...(launchAgentToSend !== undefined ? { launchAgent: launchAgentToSend } : {}),
...(REMOTE_RUNTIME_PTY_HONOURS_TAB_SHELL_OVERRIDE && opts.shellOverride
? { shell: opts.shellOverride }
: {}),
...spawnIntentParams,
...terminalCreateThemeParams(terminalColorQueryReplies),
...(terminalKittyKeyboardProtocol === true ? { terminalKittyKeyboardProtocol: true } : {}),
@@ -1,7 +1,9 @@
import { getConnectionIdFromState } from '@/lib/connection-owner-resolution'
import { useAppStore, type AppState } from '@/store'
import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup'
import {
resolveRuntimeHostReportedPlatform,
resolveRuntimeHostTerminalWindowsShell,
resolveSshHostReportedPlatform,
resolveAgentResumeLaunchTarget,
type AgentResumeLaunchTarget
@@ -170,9 +172,11 @@ function resolveAccountRestartLaunchTarget(
const repo = worktree
? state.repos.find((entry) => repoMatchesHostIdentity(entry, worktree.repoId, executionHostId))
: null
// Why: a folder or nested-SSH workspace on a server runs on its SSH target, not the server.
const connectionId = repo?.connectionId ?? getConnectionIdFromState(state, args.worktreeId)
return resolveAgentResumeLaunchTarget({
projectRuntime,
connectionId: repo?.connectionId,
connectionId,
executionHostId,
worktreePath: worktree?.path,
terminalWindowsShell: state.settings?.terminalWindowsShell,
@@ -180,8 +184,10 @@ function resolveAccountRestartLaunchTarget(
remotePlatform: resolveSshHostReportedPlatform(
state,
executionHostId,
runtimeEnvironmentIdUnlessTwin(state, args.worktreeId)
runtimeEnvironmentIdUnlessTwin(state, args.worktreeId),
connectionId
),
runtimeHostPlatform: resolveRuntimeHostReportedPlatform(state, executionHostId)
runtimeHostPlatform: resolveRuntimeHostReportedPlatform(state, executionHostId),
runtimeHostTerminalWindowsShell: resolveRuntimeHostTerminalWindowsShell(state, executionHostId)
})
}
@@ -163,6 +163,13 @@ describe('resolveAgentResumeLaunchTarget on a Windows client', () => {
expect(target.agentHostPlatform).toBeNull()
})
// Why: an older server omits its OS, but its drive-letter workspace path still proves Windows.
it('keeps Windows quoting for an older Windows server that does not report its OS', async () => {
await expect(
resolveWith({ executionHostId: 'runtime:prod-box', worktreePath: 'C:\\srv\\repo' })
).resolves.toEqual({ platform: 'win32', shell: undefined })
})
it('does not describe a remote runtime host with the local Windows shell setting', async () => {
await expect(
resolveWith({
@@ -172,6 +179,13 @@ describe('resolveAgentResumeLaunchTarget on a Windows client', () => {
).resolves.toEqual({ platform: 'win32', shell: undefined })
})
// Why POSIX: a server that reports no OS and no Windows path gets the quoting a fresh launch would use.
it('quotes POSIX for a server that reports no OS for a POSIX path', async () => {
await expect(
resolveWith({ executionHostId: 'runtime:prod-box', worktreePath: '/srv/repo' })
).resolves.toEqual({ platform: 'linux', shell: undefined })
})
it('keeps POSIX quoting for a WSL UNC worktree', async () => {
await expect(
resolveWith({
@@ -224,6 +238,42 @@ describe('resolveAgentResumeLaunchTarget off Windows', () => {
).resolves.toEqual({ platform: 'win32', shell: 'cmd' })
})
// Why: a paired server's resume quotes for the OS it reported, as its fresh launches do.
it.each([
[{}, { platform: 'win32', shell: undefined }],
// The server's own terminal shell decides quoting; its panes never receive a tab's override.
[{ runtimeHostTerminalWindowsShell: 'cmd.exe' }, { platform: 'win32', shell: 'cmd' }],
[{ runtimeHostTerminalWindowsShell: 'git-bash' }, { platform: 'win32', shell: 'posix' }],
[{ tabShellOverride: 'cmd.exe' }, { platform: 'win32', shell: undefined }],
[
{ runtimeHostTerminalWindowsShell: 'cmd.exe', tabShellOverride: 'powershell.exe' },
{ platform: 'win32', shell: 'cmd' }
]
] as const)(
'quotes for a Windows paired server from a mac client (%o)',
async (tab, expected) => {
await expect(
resolveWith({
executionHostId: 'runtime:prod-box',
worktreePath: 'C:\\srv\\repo',
runtimeHostPlatform: 'win32',
...tab
})
).resolves.toEqual(expected)
}
)
it('quotes a nested SSH target on a Windows paired server for that target, not the server', async () => {
await expect(
resolveWith({
connectionId: 'ssh-nested',
executionHostId: 'runtime:prod-box',
worktreePath: '/home/neil/repo',
runtimeHostPlatform: 'win32'
})
).resolves.toEqual({ platform: 'linux', shell: undefined })
})
it('ignores a stale Windows shell setting on a mac client', async () => {
await expect(
resolveWith({
@@ -233,3 +283,40 @@ describe('resolveAgentResumeLaunchTarget off Windows', () => {
).resolves.toEqual({ platform: 'darwin', shell: undefined })
})
})
// Why: a paired server's nested SSH target runs the command, so its own reported OS decides quoting.
describe('resolveSshHostReportedPlatform for a nested SSH target', () => {
it('reads the target the server reaches, not the server', async () => {
const { resolveSshHostReportedPlatform } = await import('./agent-resume-launch-target')
const state = {
sshConnectionStates: new Map(),
sshStateByEnvironment: new Map([
[
'prod-box',
{
connectionStates: new Map([
[
'ssh-nested',
{
targetId: 'ssh-nested',
status: 'connected' as const,
error: null,
reconnectAttempt: 0,
remotePlatform: 'win32' as const
}
]
]),
targetLabels: new Map(),
targetGenerations: new Map(),
removedTargetLabels: new Map(),
targetsHydrated: true
}
]
])
}
expect(resolveSshHostReportedPlatform(state, 'runtime:prod-box', null, 'ssh-nested')).toBe(
'win32'
)
expect(resolveSshHostReportedPlatform(state, 'runtime:prod-box', null, null)).toBeNull()
})
})
@@ -1,20 +1,17 @@
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
import { resolveWindowsShellOverride } from '@/lib/pane-manager/windows-pty-compatibility'
import { REMOTE_RUNTIME_PTY_HONOURS_TAB_SHELL_OVERRIDE } from '@/components/terminal-pane/remote-runtime-pty-shell'
import { parseExecutionHostId } from '../../../shared/execution-host'
import { lastVerifiedRuntimeStatus } from '../../../shared/runtime-host-status'
import { isWslUncPath } from '../../../shared/wsl-paths'
import {
canonicalizeWindowsShellOverride,
resolveLocalWindowsAgentStartupShell,
resolveWindowsShellStartupFamily
} from '../../../shared/windows-terminal-shell'
import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path'
import { resolveHostAgentStartupShell } from '../../../shared/windows-terminal-shell'
import type { AppState } from '@/store/types'
import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime'
import type { AgentStartupShell } from '../../../shared/tui-agent-startup-shell'
export type AgentResumeLaunchTarget = {
platform: NodeJS.Platform
/** OS that runs the agent; `platform` keeps a paired server's prior quoting. null when unreported. */
/** OS that runs the agent; null when its remote host has not reported one. */
agentHostPlatform: NodeJS.Platform | null
/** undefined keeps the platform default: PowerShell on win32, POSIX elsewhere. */
shell: AgentStartupShell | undefined
@@ -34,20 +31,30 @@ export type AgentResumeLaunchTargetArgs = {
remotePlatform?: NodeJS.Platform | null
/** OS a paired Orca server reported; null/undefined when unknown. */
runtimeHostPlatform?: NodeJS.Platform | null
/** The Windows shell a paired server spawns its terminals in; null/undefined when unreported. */
runtimeHostTerminalWindowsShell?: string | null
}
function isRemoteResumeTarget(args: AgentResumeLaunchTargetArgs): boolean {
return Boolean(args.connectionId) || parseExecutionHostId(args.executionHostId)?.kind !== 'local'
}
/** Use reported OS only for SSH; paired servers choose Windows shell settings this client cannot see, preserving their prior resume quoting. */
/** The OS an SSH target reported, else null: a direct `ssh:` host, or one a paired server reaches. */
export function resolveSshHostReportedPlatform(
state: Pick<AppState, 'sshConnectionStates' | 'sshStateByEnvironment'>,
executionHostId: string | null,
/** Paired server that owns the SSH target, when it is not this client's own connection. */
sshOwnerEnvironmentId: string | null
sshOwnerEnvironmentId: string | null,
/** A `runtime:` host's nested SSH target, which runs the command instead of the server. */
nestedTargetId?: string | null
): NodeJS.Platform | null {
const host = parseExecutionHostId(executionHostId)
if (host?.kind === 'runtime' && nestedTargetId) {
return (
state.sshStateByEnvironment.get(host.environmentId)?.connectionStates.get(nestedTargetId)
?.remotePlatform ?? null
)
}
if (host?.kind !== 'ssh') {
return null
}
@@ -57,19 +64,40 @@ export function resolveSshHostReportedPlatform(
return sshState?.remotePlatform ?? null
}
function lastRuntimeHostStatus(
state: Pick<AppState, 'runtimeStatusByEnvironmentId'>,
executionHostId: string | null
) {
const host = parseExecutionHostId(executionHostId)
return host?.kind === 'runtime'
? lastVerifiedRuntimeStatus(state.runtimeStatusByEnvironmentId.get(host.environmentId))
: null
}
/** OS a paired Orca server last reported, for `runtime:` hosts only. */
export function resolveRuntimeHostReportedPlatform(
state: Pick<AppState, 'runtimeStatusByEnvironmentId'>,
executionHostId: string | null
): NodeJS.Platform | null {
const host = parseExecutionHostId(executionHostId)
if (host?.kind !== 'runtime') {
return null
return lastRuntimeHostStatus(state, executionHostId)?.hostPlatform ?? null
}
/** The Windows shell a paired Orca server last reported for its terminals, for `runtime:` hosts only. */
export function resolveRuntimeHostTerminalWindowsShell(
state: Pick<AppState, 'runtimeStatusByEnvironmentId'>,
executionHostId: string | null
): string | null {
return lastRuntimeHostStatus(state, executionHostId)?.terminalWindowsShell ?? null
}
/** The OS a remote host reported: SSH from its connection, a paired server from its status. */
function reportedRemotePlatform(args: AgentResumeLaunchTargetArgs): NodeJS.Platform | null {
if (args.remotePlatform) {
return args.remotePlatform
}
return (
lastVerifiedRuntimeStatus(state.runtimeStatusByEnvironmentId.get(host.environmentId))
?.hostPlatform ?? null
)
return !args.connectionId && parseExecutionHostId(args.executionHostId)?.kind === 'runtime'
? (args.runtimeHostPlatform ?? null)
: null
}
function resolveResumeLaunchPlatform(args: AgentResumeLaunchTargetArgs): NodeJS.Platform {
@@ -79,13 +107,31 @@ function resolveResumeLaunchPlatform(args: AgentResumeLaunchTargetArgs): NodeJS.
if (args.projectRuntime?.status === 'resolved' && args.projectRuntime.runtime.kind === 'wsl') {
return 'linux'
}
if (args.remotePlatform && isRemoteResumeTarget(args)) {
const reported = reportedRemotePlatform(args)
if (reported && isRemoteResumeTarget(args)) {
// Why: a WSL UNC cwd on a Windows host still launches inside that distro.
return args.remotePlatform === 'win32' && args.worktreePath && isWslUncPath(args.worktreePath)
return reported === 'win32' && args.worktreePath && isWslUncPath(args.worktreePath)
? 'linux'
: args.remotePlatform
: reported
}
if (args.connectionId || (args.worktreePath && isWslUncPath(args.worktreePath))) {
const hostKind = parseExecutionHostId(args.executionHostId)?.kind
// Why: an older server omits its OS, but a drive-letter workspace path still proves Windows.
if (
hostKind === 'runtime' &&
!args.connectionId &&
args.worktreePath &&
isWindowsAbsolutePathLike(args.worktreePath) &&
!isWslUncPath(args.worktreePath)
) {
return 'win32'
}
// Why POSIX when a remote host has not reported its OS: such hosts are almost always POSIX, as launches assume.
if (
args.connectionId ||
hostKind === 'ssh' ||
hostKind === 'runtime' ||
(args.worktreePath && isWslUncPath(args.worktreePath))
) {
return 'linux'
}
return CLIENT_PLATFORM
@@ -96,19 +142,8 @@ function resolveResumeAgentHostPlatform(
args: AgentResumeLaunchTargetArgs,
platform: NodeJS.Platform
): NodeJS.Platform | null {
if (!isRemoteResumeTarget(args) || args.remotePlatform) {
return platform
}
// Why: an SSH host that has not reported its OS may be Windows; leave it unknown.
if (args.connectionId || parseExecutionHostId(args.executionHostId)?.kind !== 'runtime') {
return null
}
// Why: a WSL UNC workspace on a Windows server runs its agents inside that distro.
return args.runtimeHostPlatform === 'win32' &&
args.worktreePath &&
isWslUncPath(args.worktreePath)
? 'linux'
: (args.runtimeHostPlatform ?? null)
// Why: a remote host that has not reported its OS may be Windows; leave it unknown.
return !isRemoteResumeTarget(args) || reportedRemotePlatform(args) ? platform : null
}
export function resolveAgentResumeLaunchTarget(
@@ -117,27 +152,22 @@ export function resolveAgentResumeLaunchTarget(
const platform = resolveResumeLaunchPlatform(args)
const agentHostPlatform = resolveResumeAgentHostPlatform(args, platform)
const isRemote = isRemoteResumeTarget(args)
if (isRemote && platform === 'win32' && args.remotePlatform === 'win32') {
// Client shell settings do not describe remote hosts; a remote tab’s explicit shell override does.
const override = args.tabShellOverride
? canonicalizeWindowsShellOverride(args.tabShellOverride.trim())
: undefined
return {
platform,
agentHostPlatform,
shell: override ? resolveWindowsShellStartupFamily(override) : undefined
}
}
const spawnsInServerShell =
!REMOTE_RUNTIME_PTY_HONOURS_TAB_SHELL_OVERRIDE &&
parseExecutionHostId(args.executionHostId)?.kind === 'runtime'
return {
platform,
agentHostPlatform,
shell: resolveLocalWindowsAgentStartupShell({
// Why: a remote host's own shell decides quoting; this client's setting describes only itself.
shell: resolveHostAgentStartupShell({
platform,
isRemote,
terminalWindowsShell: resolveWindowsShellOverride(
args.tabShellOverride,
args.terminalWindowsShell
)
tabShellOverride: spawnsInServerShell ? null : args.tabShellOverride,
hostTerminalWindowsShell: isRemote
? args.connectionId
? null
: args.runtimeHostTerminalWindowsShell
: args.terminalWindowsShell
})
}
}
@@ -1,6 +1,10 @@
import { describe, expect, it, vi } from 'vitest'
import type { RuntimeEnvironmentStatus } from '../../../shared/runtime-host-status'
import { resolveRepoWorktreeLaunchPlatform } from './execution-host-facts'
import {
resolveExecutionHostAgentStartupShell,
resolveRepoExecutionHostPlatform,
resolveRepoWorktreeLaunchPlatform
} from './execution-host-facts'
import { getDefaultSettings } from '../../../shared/constants'
vi.mock('./new-workspace', async (importOriginal) => ({
@@ -110,3 +114,48 @@ describe('resolveRepoWorktreeLaunchPlatform from a Windows client', () => {
).toBeUndefined()
})
})
// Why: a desktop-built command runs in the server's own Windows shell, as resume quoting assumes.
describe('resolveExecutionHostAgentStartupShell for a paired Windows server', () => {
it.each([
['cmd.exe', 'cmd'],
['git-bash', 'posix'],
[undefined, undefined]
] as const)('quotes for a server whose shell is %s as %s', (serverShell, expected) => {
const fact = {
kind: 'known' as const,
platform: 'win32' as const,
hostKind: 'runtime' as const,
...(serverShell ? { terminalWindowsShell: serverShell } : {})
}
expect(resolveExecutionHostAgentStartupShell(fact, 'powershell.exe')).toBe(expected)
})
it('reads the shell the server reported into its host fact', () => {
const windowsServer = new Map(
[...linuxServer()].map(([id, entry]) => [
id,
{
...entry,
status: entry.status && {
...entry.status,
hostPlatform: 'win32' as const,
terminalWindowsShell: 'cmd.exe'
}
}
])
)
expect(
resolveRepoExecutionHostPlatform(
{ ...state(), runtimeStatusByEnvironmentId: windowsServer },
serverRepo,
() => undefined
)
).toEqual({
kind: 'known',
platform: 'win32',
hostKind: 'runtime',
terminalWindowsShell: 'cmd.exe'
})
})
})
+18 -6
View File
@@ -6,7 +6,7 @@ import {
import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime'
import { lastVerifiedRuntimeStatus } from '../../../shared/runtime-host-status'
import type { AgentStartupShell } from '../../../shared/tui-agent-startup-shell'
import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell'
import { resolveHostAgentStartupShell } from '../../../shared/windows-terminal-shell'
import { isWslUncPath } from '../../../shared/wsl-paths'
import type { AppState } from '@/store/types'
import { getConnectionIdFromState } from './connection-owner-resolution'
@@ -19,7 +19,13 @@ import {
/** Withhold unreported execution OS; quoting with client/endpoint guesses would target the wrong shell and must stop launch. */
export type ExecutionHostPlatformFact =
| { kind: 'known'; platform: NodeJS.Platform; hostKind: ParsedExecutionHost['kind'] }
| {
kind: 'known'
platform: NodeJS.Platform
hostKind: ParsedExecutionHost['kind']
/** A paired server's own Windows terminal shell, when it reported one. */
terminalWindowsShell?: string | null
}
| { kind: 'withheld'; reason: string }
type HostFactsState = Pick<
@@ -109,7 +115,12 @@ function resolveHostPlatform(
hostPlatform === 'win32' && args.workspacePath && isWslUncPath(args.workspacePath)
? 'linux'
: hostPlatform
return { kind: 'known', platform, hostKind: 'runtime' }
return {
kind: 'known',
platform,
hostKind: 'runtime',
...(status?.terminalWindowsShell ? { terminalWindowsShell: status.terminalWindowsShell } : {})
}
}
/** Read the repo owner’s execution platform; workspacePath refines it for host-local namespaces such as WSL, defaulting to repo path. */
@@ -194,16 +205,17 @@ export function requireExecutionHostPlatform(fact: ExecutionHostPlatformFact): N
return fact.platform
}
/** `terminalWindowsShell` describes this client's shell, so it only shapes local launches. */
/** `terminalWindowsShell` is this client's setting, so a remote host uses only the shell it reported. */
export function resolveExecutionHostAgentStartupShell(
fact: ExecutionHostPlatformFact,
terminalWindowsShell: string | null | undefined
): AgentStartupShell | undefined {
return fact.kind === 'known'
? resolveLocalWindowsAgentStartupShell({
? resolveHostAgentStartupShell({
platform: fact.platform,
isRemote: fact.hostKind !== 'local',
terminalWindowsShell
hostTerminalWindowsShell:
fact.hostKind === 'local' ? terminalWindowsShell : fact.terminalWindowsShell
})
: undefined
}
@@ -2,9 +2,15 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
import type * as ConnectionOwnerResolution from '@/lib/connection-owner-resolution'
const mockCreateTab = vi.fn()
type TestRepo = { id: string; connectionId: string | null; executionHostId?: string; path: string }
const INITIAL_REPOS: TestRepo[] = [
{ id: 'repo-1', connectionId: null, path: 'C:\\Users\\neil\\repo' }
]
const store = {
agentStatusByPaneKey: {},
settings: {
@@ -19,13 +25,7 @@ const store = {
activeRuntimeEnvironmentId: string | null
terminalWindowsShell?: string
},
repos: [
{
id: 'repo-1',
connectionId: null as string | null,
path: 'C:\\Users\\neil\\repo'
}
],
repos: INITIAL_REPOS,
worktreesByRepo: {
'repo-1': [
{
@@ -42,7 +42,7 @@ const store = {
.find((worktree) => worktree.id === id),
sshConnectionStates: new Map<string, { remotePlatform?: NodeJS.Platform }>(),
sshStateByEnvironment: new Map(),
runtimeStatusByEnvironmentId: new Map(),
runtimeStatusByEnvironmentId: new Map<string, { status: { hostPlatform: NodeJS.Platform } }>(),
tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] },
openFiles: [] as { id: string; worktreeId: string }[],
browserTabsByWorktree: {} as Record<string, { id: string }[]>,
@@ -55,6 +55,17 @@ const store = {
}
vi.mock('@/store', () => ({ useAppStore: { getState: () => store } }))
const connectionOwner = vi.hoisted((): { nestedConnectionId: string | undefined } => ({
nestedConnectionId: undefined
}))
vi.mock('@/lib/connection-owner-resolution', async (importOriginal) => {
const actual = await importOriginal<typeof ConnectionOwnerResolution>()
return {
...actual,
getConnectionIdFromState: (...args: Parameters<typeof actual.getConnectionIdFromState>) =>
connectionOwner.nestedConnectionId ?? actual.getConnectionIdFromState(...args)
}
})
vi.mock('@/lib/new-workspace', () => ({ CLIENT_PLATFORM: 'win32' }))
vi.mock('sonner', () => ({ toast: { message: vi.fn(), error: vi.fn() } }))
vi.mock('@/lib/telemetry', () => ({
@@ -110,9 +121,32 @@ describe('launchSleepingAgentSession Windows shell quoting', () => {
}
]
}
store.runtimeStatusByEnvironmentId = new Map()
connectionOwner.nestedConnectionId = undefined
mockCreateTab.mockReturnValue({ id: 'tab-1' })
})
// Why: a folder or nested-SSH workspace on a server runs on its SSH target, so the server's OS must not quote it.
it('quotes a nested SSH workspace on a Windows server for its SSH target', async () => {
store.repos = [
{
id: 'repo-1',
connectionId: null,
executionHostId: 'runtime:env-1',
path: '/home/neil/repo'
}
]
store.worktreesByRepo = {
'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/home/neil/repo', displayName: 'feature' }]
}
store.runtimeStatusByEnvironmentId = new Map([['env-1', { status: { hostPlatform: 'win32' } }]])
connectionOwner.nestedConnectionId = 'ssh-nested'
await expect(
launch({ ...record, providerSession: { key: 'session_id', id: "s'1" } })
).resolves.toContain(`'resume' 's'"'"'1'`)
})
it('quotes the resume argv for a cmd.exe tab', async () => {
store.settings.terminalWindowsShell = 'cmd.exe'
@@ -1,3 +1,4 @@
import { getConnectionIdFromState } from '@/lib/connection-owner-resolution'
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup'
@@ -5,6 +6,7 @@ import { tuiAgentToAgentKind } from '@/lib/telemetry'
import { persistAgentLaunchTabOrder } from '@/lib/launch-agent-tab-order'
import {
resolveRuntimeHostReportedPlatform,
resolveRuntimeHostTerminalWindowsShell,
resolveSshHostReportedPlatform,
resolveAgentResumeLaunchTarget,
type AgentResumeLaunchTarget
@@ -31,18 +33,22 @@ function getResumeLaunchTarget(worktreeId: string): AgentResumeLaunchTarget {
const repo = worktree ? state.repos.find((entry) => entry.id === worktree.repoId) : null
// The resume tab is created without a shell override, so the global Windows shell wins.
const executionHostId = getExecutionHostIdForWorktree(state, worktreeId)
// Why: a folder or nested-SSH workspace on a server runs on its SSH target, not the server.
const connectionId = repo?.connectionId ?? getConnectionIdFromState(state, worktreeId)
return resolveAgentResumeLaunchTarget({
projectRuntime: getLocalProjectExecutionRuntimeContext(state, worktreeId),
connectionId: repo?.connectionId,
connectionId,
executionHostId,
worktreePath: worktree?.path,
terminalWindowsShell: state.settings?.terminalWindowsShell,
remotePlatform: resolveSshHostReportedPlatform(
state,
executionHostId,
runtimeEnvironmentIdUnlessTwin(state, worktreeId)
runtimeEnvironmentIdUnlessTwin(state, worktreeId),
connectionId
),
runtimeHostPlatform: resolveRuntimeHostReportedPlatform(state, executionHostId)
runtimeHostPlatform: resolveRuntimeHostReportedPlatform(state, executionHostId),
runtimeHostTerminalWindowsShell: resolveRuntimeHostTerminalWindowsShell(state, executionHostId)
})
}
+25 -5
View File
@@ -35,16 +35,36 @@ export function resolveWindowsShellStartupFamily(
return 'powershell'
}
/** The shell a command typed into a new pane runs in on its host: the tab's override, then that
* host's own Windows shell setting, then the platform default (undefined for a remote host). */
export function resolveHostAgentStartupShell(args: {
platform: NodeJS.Platform
isRemote: boolean
tabShellOverride?: string | null
/** The executing host's setting: this client's for a local host, the reported one for a remote host. */
hostTerminalWindowsShell?: string | null
}): AgentStartupShell | undefined {
if (args.platform !== 'win32') {
return undefined
}
const shell = args.tabShellOverride?.trim() || args.hostTerminalWindowsShell?.trim()
if (!shell) {
return args.isRemote ? undefined : resolveWindowsShellStartupFamily(undefined)
}
return resolveWindowsShellStartupFamily(canonicalizeWindowsShellOverride(shell) ?? shell)
}
export function resolveLocalWindowsAgentStartupShell(args: {
platform: NodeJS.Platform
isRemote: boolean
terminalWindowsShell?: string | null
}): AgentStartupShell | undefined {
// Local shell settings cannot choose safe quoting for SSH/remote execution hosts.
if (args.platform !== 'win32' || args.isRemote) {
return undefined
}
return resolveWindowsShellStartupFamily(args.terminalWindowsShell)
// Local shell settings cannot describe SSH or paired hosts.
return resolveHostAgentStartupShell({
platform: args.platform,
isRemote: args.isRemote,
hostTerminalWindowsShell: args.isRemote ? undefined : args.terminalWindowsShell
})
}
/** Share accepted Windows shell overrides across RPC validation and relay spawn.