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.