diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 4be25f39e2b..48892c2d2a2 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -3538,6 +3538,7 @@ describe('OrcaRuntimeService', () => { ...store, getSettings: () => ({ ...store.getSettings(), + terminalWindowsShell: 'cmd.exe', agentCmdOverrides: {} }), getRepos: () => [remoteRepo], @@ -7264,6 +7265,94 @@ describe('OrcaRuntimeService', () => { ) }) + it('quotes local Windows bare agent command defaults for cmd.exe terminal creates', async () => { + setPlatform('win32') + const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) + const runtimeStore = { + ...store, + getSettings: () => ({ + ...store.getSettings(), + disabledTuiAgents: [], + terminalWindowsShell: 'cmd.exe', + agentCmdOverrides: {}, + agentDefaultArgs: { claude: '--dangerously-skip-permissions' }, + agentDefaultEnv: {} + }) + } + const runtime = new OrcaRuntimeService(runtimeStore) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: 'claude', + title: 'worker' + }) + + const spawnCall = spawn.mock.calls[0]?.[0] as { command?: string } | undefined + expect(spawnCall?.command).toBe('claude "--dangerously-skip-permissions"') + }) + + it('does not use the local Windows shell setting for remote Windows bare agent creates', async () => { + const remoteRepo = { + id: TEST_REPO_ID, + path: 'C:/remote/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + } + const remoteStore = { + ...store, + getRepos: () => [remoteRepo], + getRepo: (id: string) => (id === TEST_REPO_ID ? remoteRepo : undefined), + getSettings: () => ({ + ...store.getSettings(), + disabledTuiAgents: [], + terminalWindowsShell: 'cmd.exe', + agentCmdOverrides: {}, + agentDefaultArgs: { claude: '--dangerously-skip-permissions' }, + agentDefaultEnv: {} + }) + } + const provider = { + exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: 'C:/remote/repo', + head: 'abc', + branch: 'main', + isBare: false, + isMainWorktree: true + } + ]) + } + const spawn = vi.fn().mockResolvedValue({ id: 'pty-remote-windows-bare' }) + registerSshGitProvider('ssh-1', provider as never) + const runtime = new OrcaRuntimeService(remoteStore as never) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + try { + await runtime.createTerminal('path:C:/remote/repo', { + command: 'claude', + title: 'worker' + }) + + const spawnCall = spawn.mock.calls[0]?.[0] as { command?: string } | undefined + expect(spawnCall?.command).toBe("claude '--dangerously-skip-permissions'") + } finally { + unregisterSshGitProvider('ssh-1') + } + }) + it('matches canonical bare agent commands when a command override is configured', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) const runtimeStore = { @@ -17184,6 +17273,53 @@ describe('OrcaRuntimeService', () => { }) }) + it('uses cmd.exe quoting for mobile agent launch commands in local Windows host runtimes', async () => { + await withPlatform('win32', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-agent-cmd' }) + const runtime = new OrcaRuntimeService({ + ...store, + getProjects: () => [ + { + id: 'project-1', + displayName: 'repo', + badgeColor: 'blue', + sourceRepoIds: [TEST_REPO_ID], + localWindowsRuntimePreference: { kind: 'windows-host' }, + createdAt: 0, + updatedAt: 0 + } + ], + getSettings: () => ({ + ...store.getSettings(), + disabledTuiAgents: [], + agentCmdOverrides: { 'command-code': 'command-code --profile mobile' }, + agentDefaultArgs: { 'command-code': '--note "can\'t"' }, + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }, + terminalWindowsShell: 'cmd.exe' + }) + } as never) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + + await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + agent: 'command-code' + }) + + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'command-code --profile mobile "--note" "can\'t"', + cwd: TEST_WORKTREE_PATH, + worktreeId: TEST_WORKTREE_ID + }) + ) + }) + }) + it('publishes headless mobile session agent identity with synthesized PTY status', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'pty-agent' }) const runtime = new OrcaRuntimeService({ diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 018646b5b72..2e87e28e681 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -188,6 +188,7 @@ import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' +import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell' import { getTuiAgentLaunchCommand, isTuiAgent, @@ -787,6 +788,7 @@ type RuntimeStore = { agentCmdOverrides?: GlobalSettings['agentCmdOverrides'] agentDefaultArgs?: GlobalSettings['agentDefaultArgs'] agentDefaultEnv?: GlobalSettings['agentDefaultEnv'] + terminalWindowsShell?: GlobalSettings['terminalWindowsShell'] agentStatusHooksEnabled?: GlobalSettings['agentStatusHooksEnabled'] defaultTaskSource?: GlobalSettings['defaultTaskSource'] defaultTaskViewPreset?: GlobalSettings['defaultTaskViewPreset'] @@ -12353,6 +12355,11 @@ export class OrcaRuntimeService { // Linux over SSH. Startup command quoting must target the shell that runs it. const agentLaunchPlatform = this.getAgentLaunchPlatformForRepo(repo) const isRemote = repoIsRemote(repo) + const queuedShell = resolveLocalWindowsAgentStartupShell({ + platform: agentLaunchPlatform, + isRemote, + terminalWindowsShell: settings.terminalWindowsShell + }) const draftLaunchPlan = buildAgentDraftLaunchPlan({ agent, draft: content, @@ -12360,6 +12367,7 @@ export class OrcaRuntimeService { agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv), platform: agentLaunchPlatform, + shell: queuedShell, isRemote }) if (draftLaunchPlan) { @@ -12383,6 +12391,7 @@ export class OrcaRuntimeService { agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv), platform: agentLaunchPlatform, + shell: queuedShell, isRemote, allowEmptyPromptLaunch: true }) @@ -12418,6 +12427,12 @@ export class OrcaRuntimeService { // Why: CLI clients may target SSH runtimes from macOS/Windows, so quote for // the workspace shell rather than the client shell. const agentLaunchPlatform = this.getAgentLaunchPlatformForRepo(repo) + const isRemote = repoIsRemote(repo) + const queuedShell = resolveLocalWindowsAgentStartupShell({ + platform: agentLaunchPlatform, + isRemote, + terminalWindowsShell: settings.terminalWindowsShell + }) const startupPlan = buildAgentStartupPlan({ agent, prompt: prompt ?? '', @@ -12425,7 +12440,8 @@ export class OrcaRuntimeService { agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv), platform: agentLaunchPlatform, - isRemote: repoIsRemote(repo), + shell: queuedShell, + isRemote, allowEmptyPromptLaunch: true }) if (!startupPlan) { @@ -15632,6 +15648,11 @@ export class OrcaRuntimeService { const settings = this.store.getSettings() const platform = this.getAgentLaunchPlatformForWorkspace(workspace) const isRemote = repoIsRemote(workspace.repo) + const queuedShell = resolveLocalWindowsAgentStartupShell({ + platform, + isRemote, + terminalWindowsShell: settings.terminalWindowsShell + }) const agent = resolveBareAgentLaunchCommand({ command: opts.command, settings, @@ -15649,6 +15670,7 @@ export class OrcaRuntimeService { agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv), platform, + shell: queuedShell, isRemote, allowEmptyPromptLaunch: true }) @@ -16193,6 +16215,11 @@ export class OrcaRuntimeService { // Why: an SSH workspace runs the CLI through the relay shim (plain `orca`), // so the Linux-only `orca-ide` rename must not be applied. const isRemote = workspace.repo ? repoIsRemote(workspace.repo) : repoIsRemote(workspace) + const queuedShell = resolveLocalWindowsAgentStartupShell({ + platform, + isRemote, + terminalWindowsShell: settings.terminalWindowsShell + }) const startupPlan = buildAgentStartupPlan({ agent: opts.agent, prompt: '', @@ -16200,6 +16227,7 @@ export class OrcaRuntimeService { agentArgs: resolveTuiAgentLaunchArgs(opts.agent, settings.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(opts.agent, settings.agentDefaultEnv), platform, + shell: queuedShell, isRemote, allowEmptyPromptLaunch: true }) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index 73e72df1fab..fd4dcf004c0 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -25,6 +25,7 @@ const store = { agentDefaultArgs: Record agentDefaultEnv: Record> activeRuntimeEnvironmentId: string | null + terminalWindowsShell?: string experimentalNativeChat?: boolean openAgentTabsInChatByDefault?: boolean }, @@ -409,7 +410,102 @@ describe('launchAgentInNewTab', () => { ) }) + it('quotes local Windows default agent args for cmd.exe empty launches', async () => { + store.settings.terminalWindowsShell = 'cmd.exe' + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ + agent: 'claude', + worktreeId: 'wt-1', + launchPlatform: 'win32' + }) + + expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( + 'tab-1', + expect.objectContaining({ + command: 'claude "--dangerously-skip-permissions"' + }) + ) + }) + + it('keeps PowerShell quoting for local Windows default agent args', async () => { + store.settings.terminalWindowsShell = 'powershell.exe' + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ + agent: 'claude', + worktreeId: 'wt-1', + launchPlatform: 'win32' + }) + + expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( + 'tab-1', + expect.objectContaining({ + command: "claude '--dangerously-skip-permissions'" + }) + ) + }) + + it('quotes local Windows explicit agent args for cmd.exe prompt launches', async () => { + store.settings.terminalWindowsShell = 'cmd.exe' + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ + agent: 'codex', + worktreeId: 'wt-1', + prompt: 'fix the spinner', + agentArgs: '--model gpt-5', + launchPlatform: 'win32' + }) + + expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( + 'tab-1', + expect.objectContaining({ + command: 'codex "--model" "gpt-5" "fix the spinner"' + }) + ) + }) + + it('quotes local Windows draft launches for Git Bash', async () => { + store.settings.terminalWindowsShell = 'git-bash' + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: "review Bob's change", + promptDelivery: 'draft', + launchPlatform: 'win32' + }) + + expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( + 'tab-1', + expect.objectContaining({ + command: "claude '--dangerously-skip-permissions' --prefill 'review Bob'\\''s change'" + }) + ) + }) + + it('does not use the local Windows shell setting for remote Windows launches', async () => { + store.settings.terminalWindowsShell = 'cmd.exe' + store.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: 'C:\\remote\\repo' }] + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ + agent: 'claude', + worktreeId: 'wt-1' + }) + + expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( + 'tab-1', + expect.objectContaining({ + command: "claude '--dangerously-skip-permissions'" + }) + ) + }) + it('uses WSL launch quoting by default for Windows-path projects forced to WSL', async () => { + store.settings.terminalWindowsShell = 'cmd.exe' store.projects = [ { id: 'repo-1', diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 42d577b4d14..2f06dca71a9 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -22,6 +22,7 @@ import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv } from '../../../shared/tui-agent-launch-defaults' +import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell' import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' import { repoIsRemote } from '../../../shared/agent-launch-remote' import { seedCommandCodeSubmittedPromptStatus } from '@/lib/command-code-prompt-status-seed' @@ -119,12 +120,26 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI // Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only // `orca-ide` rename must not be applied for remote launches. const isRemote = repo ? repoIsRemote(repo) : false + const queuedShell = resolveLocalWindowsAgentStartupShell({ + platform: resolvedLaunchPlatform, + isRemote, + terminalWindowsShell: store.settings?.terminalWindowsShell + }) const cmdOverrides = store.settings?.agentCmdOverrides ?? {} const effectiveAgentArgs = agentArgs !== undefined ? agentArgs : resolveTuiAgentLaunchArgs(agent, store.settings?.agentDefaultArgs) const agentEnv = resolveTuiAgentLaunchEnv(agent, store.settings?.agentDefaultEnv) + const startupPlanBase = { + agent, + cmdOverrides, + platform: resolvedLaunchPlatform, + shell: queuedShell, + isRemote, + agentArgs: effectiveAgentArgs, + agentEnv + } const trimmedPrompt = prompt?.trim() ?? '' const hasPrompt = trimmedPrompt.length > 0 const isFollowupPath = TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start' @@ -143,13 +158,8 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI // Why: generated multi-line prompts are too large to echo through a shell // argv/prefill command. Launch cleanly, then paste+submit inside the TUI. startupPlan = buildAgentStartupPlan({ - agent, + ...startupPlanBase, prompt: '', - cmdOverrides, - platform: resolvedLaunchPlatform, - isRemote, - agentArgs: effectiveAgentArgs, - agentEnv, allowEmptyPromptLaunch: true }) pasteDraftAfterLaunch = trimmedPrompt @@ -157,13 +167,8 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI forcePasteAfterLaunch = true } else if (hasPrompt && promptDelivery === 'draft') { const draftLaunchPlan = buildAgentDraftLaunchPlan({ - agent, - draft: trimmedPrompt, - cmdOverrides, - platform: resolvedLaunchPlatform, - isRemote, - agentArgs: effectiveAgentArgs, - agentEnv + ...startupPlanBase, + draft: trimmedPrompt }) if (draftLaunchPlan) { startupPlan = { @@ -179,38 +184,23 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI } } else { startupPlan = buildAgentStartupPlan({ - agent, + ...startupPlanBase, prompt: '', - cmdOverrides, - platform: resolvedLaunchPlatform, - isRemote, - agentArgs: effectiveAgentArgs, - agentEnv, allowEmptyPromptLaunch: true }) pasteDraftAfterLaunch = trimmedPrompt } } else if (hasPrompt && isFollowupPath) { startupPlan = buildAgentStartupPlan({ - agent, + ...startupPlanBase, prompt: '', - cmdOverrides, - platform: resolvedLaunchPlatform, - isRemote, - agentArgs: effectiveAgentArgs, - agentEnv, allowEmptyPromptLaunch: true }) pasteDraftAfterLaunch = trimmedPrompt } else { startupPlan = buildAgentStartupPlan({ - agent, + ...startupPlanBase, prompt: hasPrompt ? trimmedPrompt : '', - cmdOverrides, - platform: resolvedLaunchPlatform, - isRemote, - agentArgs: effectiveAgentArgs, - agentEnv, allowEmptyPromptLaunch: !hasPrompt }) } diff --git a/src/shared/windows-terminal-shell.ts b/src/shared/windows-terminal-shell.ts index 2b64c374eb8..72dda7de71e 100644 --- a/src/shared/windows-terminal-shell.ts +++ b/src/shared/windows-terminal-shell.ts @@ -35,3 +35,16 @@ export function resolveWindowsShellStartupFamily( } return 'powershell' } + +export function resolveLocalWindowsAgentStartupShell(args: { + platform: NodeJS.Platform + isRemote: boolean + terminalWindowsShell?: string | null +}): AgentStartupShell | undefined { + // Why: terminalWindowsShell describes the local host shell; SSH/remote + // targets need their own shell signal before we can safely override quoting. + if (args.platform !== 'win32' || args.isRemote) { + return undefined + } + return resolveWindowsShellStartupFamily(args.terminalWindowsShell) +}