From 89705dbf85584f05add7b072729f72a3411213e5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:23:56 -0700 Subject: [PATCH] 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. --- .../cold-restore-resume-startup.ts | 25 +++- .../terminal-pane/remote-runtime-pty-shell.ts | 2 + .../remote-runtime-pty-transport.ts | 4 + .../src/lib/agent-account-restart-startup.ts | 12 +- .../lib/agent-resume-launch-target.test.ts | 87 ++++++++++++ .../src/lib/agent-resume-launch-target.ts | 128 +++++++++++------- .../src/lib/execution-host-facts.test.ts | 51 ++++++- src/renderer/src/lib/execution-host-facts.ts | 24 +++- ...ent-session-launch-windows-quoting.test.ts | 50 +++++-- .../src/lib/sleeping-agent-session-launch.ts | 12 +- src/shared/windows-terminal-shell.ts | 30 +++- 11 files changed, 347 insertions(+), 78 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/remote-runtime-pty-shell.ts diff --git a/src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts b/src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts index 091ae36b71b..168e6a50420 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts @@ -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, diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-shell.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-shell.ts new file mode 100644 index 00000000000..38a3c36e3e0 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-shell.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 9b845338985..4ed4f5f454e 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -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 } : {}), diff --git a/src/renderer/src/lib/agent-account-restart-startup.ts b/src/renderer/src/lib/agent-account-restart-startup.ts index f8f320d12db..0ddd3ac7c59 100644 --- a/src/renderer/src/lib/agent-account-restart-startup.ts +++ b/src/renderer/src/lib/agent-account-restart-startup.ts @@ -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) }) } diff --git a/src/renderer/src/lib/agent-resume-launch-target.test.ts b/src/renderer/src/lib/agent-resume-launch-target.test.ts index 5902cfdfcb8..fbe630b8f63 100644 --- a/src/renderer/src/lib/agent-resume-launch-target.test.ts +++ b/src/renderer/src/lib/agent-resume-launch-target.test.ts @@ -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() + }) +}) diff --git a/src/renderer/src/lib/agent-resume-launch-target.ts b/src/renderer/src/lib/agent-resume-launch-target.ts index af4b64a7427..93c4570a272 100644 --- a/src/renderer/src/lib/agent-resume-launch-target.ts +++ b/src/renderer/src/lib/agent-resume-launch-target.ts @@ -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, 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, + 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, 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, + 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 }) } } diff --git a/src/renderer/src/lib/execution-host-facts.test.ts b/src/renderer/src/lib/execution-host-facts.test.ts index 46824937b48..2598f251931 100644 --- a/src/renderer/src/lib/execution-host-facts.test.ts +++ b/src/renderer/src/lib/execution-host-facts.test.ts @@ -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' + }) + }) +}) diff --git a/src/renderer/src/lib/execution-host-facts.ts b/src/renderer/src/lib/execution-host-facts.ts index 8236cd26b36..484180f1f91 100644 --- a/src/renderer/src/lib/execution-host-facts.ts +++ b/src/renderer/src/lib/execution-host-facts.ts @@ -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 } diff --git a/src/renderer/src/lib/sleeping-agent-session-launch-windows-quoting.test.ts b/src/renderer/src/lib/sleeping-agent-session-launch-windows-quoting.test.ts index c39a3b4fba2..bb646d7ac89 100644 --- a/src/renderer/src/lib/sleeping-agent-session-launch-windows-quoting.test.ts +++ b/src/renderer/src/lib/sleeping-agent-session-launch-windows-quoting.test.ts @@ -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(), sshStateByEnvironment: new Map(), - runtimeStatusByEnvironmentId: new Map(), + runtimeStatusByEnvironmentId: new Map(), tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] }, openFiles: [] as { id: string; worktreeId: string }[], browserTabsByWorktree: {} as Record, @@ -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() + return { + ...actual, + getConnectionIdFromState: (...args: Parameters) => + 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' diff --git a/src/renderer/src/lib/sleeping-agent-session-launch.ts b/src/renderer/src/lib/sleeping-agent-session-launch.ts index 9ee3291a68e..665ff516b36 100644 --- a/src/renderer/src/lib/sleeping-agent-session-launch.ts +++ b/src/renderer/src/lib/sleeping-agent-session-launch.ts @@ -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) }) } diff --git a/src/shared/windows-terminal-shell.ts b/src/shared/windows-terminal-shell.ts index 5f47b8dba18..8aebeb06f2c 100644 --- a/src/shared/windows-terminal-shell.ts +++ b/src/shared/windows-terminal-shell.ts @@ -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.