From f6f497f50c9ce6d26b0e6f0613bf66067f6d59ec Mon Sep 17 00:00:00 2001 From: Andre Cristo <69171647+andrecristodev@users.noreply.github.com> Date: Sat, 11 Jul 2026 04:59:17 -0300 Subject: [PATCH] fix(win): resume quoted cmd.exe startup commands via stdin, not /K (#7978) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(win): resume quoted cmd.exe startup commands via stdin, not /K Resuming an AI Vault session into a cmd.exe tab on Windows failed with "'...' is not recognized as an internal or external command" and never ran the resume. The queued command for a cmd live shell is the self-contained `cmd /d /s /c "cd /d ""cwd"" && claude ""--resume"" ""id"""` form, which is correct when typed into cmd's interactive parser (its "" doubling is cmd's convention). But the local PTY provider embedded it in the `/K` launch argument, where node-pty's C-runtime argv escaping emits backslash-escaped quotes (\") that cmd.exe cannot parse — the command arrived as `\"cd /d \"\"cwd\"\" && ...` and was rejected wholesale. Unlike PowerShell's -EncodedCommand, cmd.exe has no robust argv-quoting path, so any startup command containing a double quote now falls back to stdin delivery, where cmd's interactive parser handles the "" doubling correctly (verified end-to-end against a real ConPTY via node-pty). Quote-free commands keep the `/K` fast path. * fix(win): preserve cmd resume command contracts * fix(win): match copied resume commands to shell --------- Co-authored-by: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> --- src/main/providers/windows-shell-args.test.ts | 14 +++ src/main/providers/windows-shell-args.ts | 5 + .../ai-vault-session-launch-actions.ts | 4 +- .../src/lib/ai-vault-resume-command.test.ts | 95 ++++++++++++------- .../src/lib/ai-vault-resume-command.ts | 51 +++++----- src/shared/ai-vault-resume-command.test.ts | 17 +++- src/shared/ai-vault-types.ts | 24 +++-- 7 files changed, 138 insertions(+), 72 deletions(-) diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index 7d0eeb5b4..41e9b3b94 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -58,6 +58,20 @@ describe('resolveWindowsShellLaunchArgs', () => { expect(result.startupCommandDeliveredInShellArgs).toBe(true) }) + it('keeps quoted cmd.exe startup commands on stdin delivery', () => { + // Why: direct queued cmd commands need normal quotes, which node-pty's + // C-runtime argv escaping corrupts when delivered through `/K`. + const result = resolveWindowsShellLaunchArgs( + 'cmd.exe', + 'C:\\Users\\alice\\repo', + 'C:\\Users\\alice', + undefined, + 'cd /d "C:\\Users\\alice\\repo" && claude "--resume" "session one"' + ) + expect(result.shellArgs).toEqual(['/K', 'chcp 65001 > nul']) + expect(result.startupCommandDeliveredInShellArgs).toBeUndefined() + }) + it('keeps large cmd.exe startup commands on stdin delivery', () => { const result = resolveWindowsShellLaunchArgs( 'cmd.exe', diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index 3889edfc5..4977cc111 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -55,6 +55,11 @@ function getCmdShellArgStartupCommand(command?: string): string | null { if (!command || command.length > STARTUP_COMMAND_TEXT_MAX_CHARS) { return null } + // Why: node-pty's C-runtime argv escaping changes normal cmd quotes in `/K` + // delivery, while the interactive parser preserves them through stdin. + if (command.includes('"')) { + return null + } const commandArg = `${CMD_UTF8_SETUP_COMMAND} & ${command}` if (commandArg.length > CMD_EXE_COMMAND_LINE_MAX_CHARS) { return null diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts index 78daad963..487c973bc 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts @@ -1,7 +1,7 @@ import { useCallback } from 'react' import { toast } from 'sonner' import { - buildAiVaultResumeCommandForWorktree, + buildAiVaultResumeCopyCommandForWorktree, buildAiVaultResumeStartupForWorktree, type AiVaultResumeStartup } from '@/lib/ai-vault-resume-command' @@ -43,7 +43,7 @@ export function useAiVaultSessionLaunchActions({ } { const buildResumeCommand = useCallback( (session: AiVaultSession, worktreeId?: string | null): string => - buildAiVaultResumeCommandForWorktree({ + buildAiVaultResumeCopyCommandForWorktree({ state: useAppStore.getState(), worktreeId: worktreeId ?? activeWorktreeId ?? activeWorktree?.id ?? null, session, diff --git a/src/renderer/src/lib/ai-vault-resume-command.test.ts b/src/renderer/src/lib/ai-vault-resume-command.test.ts index fb7033490..68021efdb 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.test.ts @@ -1,8 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import type { AppState } from '@/store/types' -import { buildAiVaultResumeCommand } from '../../../shared/ai-vault-types' import { - buildAiVaultResumeCommandForWorktree, + buildAiVaultResumeCopyCommandForWorktree, buildAiVaultResumeStartupForWorktree, getAiVaultResumePlatform } from './ai-vault-resume-command' @@ -63,6 +62,12 @@ function makeState(args: { } as unknown as AiVaultResumeCommandState } +function buildQueuedAiVaultResumeCommand( + args: Parameters[0] +): string { + return buildAiVaultResumeStartupForWorktree(args).command +} + describe('ai vault resume command runtime', () => { it('queues a PowerShell-valid command for the default Windows shell', () => { // Why: the queued command is typed into the live tab shell (default @@ -70,7 +75,7 @@ describe('ai vault resume command runtime', () => { const state = makeState({ worktreePath: 'C:\\Users\\alice\\repo' }) expect( - buildAiVaultResumeCommandForWorktree({ + buildQueuedAiVaultResumeCommand({ state, worktreeId: 'repo-1::worktree-1', session: { @@ -83,14 +88,14 @@ describe('ai vault resume command runtime', () => { ).toBe("Set-Location -LiteralPath 'C:\\Users\\alice\\repo'; claude '--resume' 'session one'") }) - it('keeps the cmd wrapper when the configured Windows shell is cmd.exe', () => { + it('queues direct cmd syntax when the configured Windows shell is cmd.exe', () => { const state = makeState({ worktreePath: 'C:\\Users\\alice\\repo', terminalWindowsShell: 'cmd.exe' }) expect( - buildAiVaultResumeCommandForWorktree({ + buildQueuedAiVaultResumeCommand({ state, worktreeId: 'repo-1::worktree-1', session: { @@ -100,7 +105,7 @@ describe('ai vault resume command runtime', () => { codexHome: null } }) - ).toBe('cmd /d /s /c "cd /d ""C:\\Users\\alice\\repo"" && claude ""--resume"" ""session one"""') + ).toBe('cd /d "C:\\Users\\alice\\repo" && claude "--resume" "session one"') }) it('queues a POSIX command for the Git Bash Windows shell', () => { @@ -110,7 +115,7 @@ describe('ai vault resume command runtime', () => { }) expect( - buildAiVaultResumeCommandForWorktree({ + buildQueuedAiVaultResumeCommand({ state, worktreeId: 'repo-1::worktree-1', session: { @@ -129,7 +134,7 @@ describe('ai vault resume command runtime', () => { const state = makeState({ worktreePath: 'C:\\Users\\alice\\repo' }) expect( - buildAiVaultResumeCommandForWorktree({ + buildQueuedAiVaultResumeCommand({ state, worktreeId: 'repo-1::worktree-1', session: { @@ -149,7 +154,7 @@ describe('ai vault resume command runtime', () => { // path, and queued Windows commands must match the live tab shell. const state = makeState({ worktreePath: 'C:\\Users\\alice\\repo' }) - const command = buildAiVaultResumeCommandForWorktree({ + const command = buildQueuedAiVaultResumeCommand({ state, worktreeId: 'repo-1::worktree-1', session: { @@ -167,14 +172,14 @@ describe('ai vault resume command runtime', () => { expect(command).not.toContain('019f27cd-4268-7000-96e7-62f42a55c144') }) - it('keeps cmd quoting for local OMP resume when cmd.exe is configured', () => { + it('queues a direct local OMP resume when cmd.exe is configured', () => { const state = makeState({ worktreePath: 'C:\\Users\\alice\\repo', terminalWindowsShell: 'cmd.exe' }) expect( - buildAiVaultResumeCommandForWorktree({ + buildQueuedAiVaultResumeCommand({ state, worktreeId: 'repo-1::worktree-1', session: { @@ -186,22 +191,45 @@ describe('ai vault resume command runtime', () => { } }) ).toBe( - 'cmd /d /s /c "cd /d ""C:\\Users\\alice\\repo"" && omp --resume ""C:\\Users\\alice\\.omp\\agent\\sessions\\repo\\sess.jsonl"""' + 'cd /d "C:\\Users\\alice\\repo" && omp --resume "C:\\Users\\alice\\.omp\\agent\\sessions\\repo\\sess.jsonl"' ) }) - it('keeps the cmd wrapper for the copy-to-clipboard command on Windows', () => { - // Regression guard: the copy path is self-contained for pasting into cmd.exe - // and must stay cmd-wrapped even though the queued path now follows the shell. + it('copies syntax that matches the configured cmd shell', () => { + const state = makeState({ + worktreePath: 'C:\\Users\\alice\\repo', + terminalWindowsShell: 'cmd.exe' + }) + expect( - buildAiVaultResumeCommand({ - agent: 'claude', - sessionId: 'session one', - cwd: 'C:\\Users\\alice\\repo', - platform: 'win32', - codexHome: null + buildAiVaultResumeCopyCommandForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + session: { + agent: 'claude', + sessionId: 'session one', + cwd: 'C:\\Users\\alice\\repo', + codexHome: null + } }) - ).toBe('cmd /d /s /c "cd /d ""C:\\Users\\alice\\repo"" && claude --resume ""session one"""') + ).toBe('cd /d "C:\\Users\\alice\\repo" && claude "--resume" "session one"') + }) + + it('copies syntax that matches the configured PowerShell shell', () => { + const state = makeState({ worktreePath: 'C:\\Users\\alice\\repo' }) + + expect( + buildAiVaultResumeCopyCommandForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + session: { + agent: 'claude', + sessionId: 'session one', + cwd: 'C:\\Users\\alice\\repo', + codexHome: null + } + }) + ).toBe("Set-Location -LiteralPath 'C:\\Users\\alice\\repo'; claude '--resume' 'session one'") }) it('uses configured agent defaults for resumable session history entries', () => { @@ -246,7 +274,7 @@ describe('ai vault resume command runtime', () => { expect(getAiVaultResumePlatform(state, 'repo-1::worktree-1')).toBe('linux') expect( - buildAiVaultResumeCommandForWorktree({ + buildQueuedAiVaultResumeCommand({ state, worktreeId: 'repo-1::worktree-1', session: { @@ -265,7 +293,7 @@ describe('ai vault resume command runtime', () => { expect(getAiVaultResumePlatform(state, 'repo-1::worktree-1')).toBe('linux') expect( - buildAiVaultResumeCommandForWorktree({ + buildQueuedAiVaultResumeCommand({ state, worktreeId: 'repo-1::worktree-1', session: { @@ -294,7 +322,7 @@ describe('ai vault resume command runtime', () => { expect(getAiVaultResumePlatform(state, 'folder:folder-1')).toBe('linux') expect( - buildAiVaultResumeCommandForWorktree({ + buildQueuedAiVaultResumeCommand({ state, worktreeId: 'folder:folder-1', session: { @@ -322,7 +350,7 @@ describe('ai vault resume command runtime', () => { expect(getAiVaultResumePlatform(state, 'folder:folder-1')).toBe('linux') expect( - buildAiVaultResumeCommandForWorktree({ + buildQueuedAiVaultResumeCommand({ state, worktreeId: 'folder:folder-1', session: { @@ -349,7 +377,7 @@ describe('ai vault resume command runtime', () => { }) expect( - buildAiVaultResumeCommandForWorktree({ + buildQueuedAiVaultResumeCommand({ state, worktreeId: 'repo-1::worktree-1', session: { @@ -387,7 +415,7 @@ describe('ai vault resume command runtime', () => { state.repos = [{ id: 'repo-1', path: '/home/alice/repo', connectionId: 'ssh-1' }] as never expect( - buildAiVaultResumeCommandForWorktree({ + buildQueuedAiVaultResumeCommand({ state, worktreeId: 'repo-1::worktree-1', commandOverride: ' ', @@ -408,7 +436,7 @@ describe('ai vault resume command runtime', () => { state.repos = [{ id: 'repo-1', path: '/home/alice/repo', connectionId: 'ssh-1' }] as never expect( - buildAiVaultResumeCommandForWorktree({ + buildQueuedAiVaultResumeCommand({ state, worktreeId: 'repo-1::worktree-1', commandOverride: 'my-codex', @@ -425,11 +453,14 @@ describe('ai vault resume command runtime', () => { }) it('rebuilds overridden remote commands with the recorded remote host platform', () => { - const state = makeState({ worktreePath: '/home/alice/repo' }) + const state = makeState({ + worktreePath: '/home/alice/repo', + terminalWindowsShell: 'cmd.exe' + }) state.repos = [{ id: 'repo-1', path: '/home/alice/repo', connectionId: 'ssh-1' }] as never expect( - buildAiVaultResumeCommandForWorktree({ + buildQueuedAiVaultResumeCommand({ state, worktreeId: 'repo-1::worktree-1', commandOverride: 'my-codex', @@ -454,7 +485,7 @@ describe('ai vault resume command runtime', () => { state.repos = [{ id: 'repo-1', path: '/home/alice/repo', connectionId: 'ssh-1' }] as never expect( - buildAiVaultResumeCommandForWorktree({ + buildQueuedAiVaultResumeCommand({ state, worktreeId: 'repo-1::worktree-1', session: { diff --git a/src/renderer/src/lib/ai-vault-resume-command.ts b/src/renderer/src/lib/ai-vault-resume-command.ts index f5ea23d3a..09cda526f 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.ts @@ -36,7 +36,7 @@ export type AiVaultResumeStartup = { launchConfig?: SleepingAgentLaunchConfig } -export function buildAiVaultResumeCommandForWorktree(args: { +type AiVaultResumeWorktreeArgs = { state: Pick< AppState, | 'activeRepoId' @@ -51,26 +51,19 @@ export function buildAiVaultResumeCommandForWorktree(args: { worktreeId?: string | null session: AiVaultResumeCommandSession commandOverride?: string | null -}): string { - return buildAiVaultResumeStartupForWorktree(args).command } -export function buildAiVaultResumeStartupForWorktree(args: { - state: Pick< - AppState, - | 'activeRepoId' - | 'activeWorktreeId' - | 'folderWorkspaces' - | 'projectGroups' - | 'projects' - | 'repos' - | 'settings' - | 'worktreesByRepo' - > - worktreeId?: string | null - session: AiVaultResumeCommandSession - commandOverride?: string | null -}): AiVaultResumeStartup { +export function buildAiVaultResumeCopyCommandForWorktree(args: AiVaultResumeWorktreeArgs): string { + return buildAiVaultResumeForWorktree(args).command +} + +export function buildAiVaultResumeStartupForWorktree( + args: AiVaultResumeWorktreeArgs +): AiVaultResumeStartup { + return buildAiVaultResumeForWorktree(args) +} + +function buildAiVaultResumeForWorktree(args: AiVaultResumeWorktreeArgs): AiVaultResumeStartup { if ( args.session.executionHostId && args.session.executionHostId !== LOCAL_EXECUTION_HOST_ID && @@ -86,13 +79,15 @@ export function buildAiVaultResumeStartupForWorktree(args: { ? args.session.executionHostPlatform : getAiVaultResumePlatform(args.state, args.worktreeId) const codexHome = getAiVaultResumeCodexHome(args.session.codexHome, platform) - // Why: the queued command is typed verbatim into the freshly spawned tab whose - // live shell is the configured Windows shell (default PowerShell). Hardcoding - // cmd quoting made PowerShell mis-parse the `""`-doubled wrapper (#6152), so - // resolve the actual shell to quote per-shell instead. - const queuedShell: AgentStartupShell | undefined = + const isLocalSession = + !args.session.executionHostId || args.session.executionHostId === LOCAL_EXECUTION_HOST_ID + // Why: local shell settings do not describe a remote Windows host, whose + // queued resume command uses the remote default PowerShell syntax. + const liveShell: AgentStartupShell | undefined = platform === 'win32' - ? resolveWindowsShellStartupFamily(args.state.settings?.terminalWindowsShell) + ? isLocalSession + ? resolveWindowsShellStartupFamily(args.state.settings?.terminalWindowsShell) + : 'powershell' : undefined if (isResumableTuiAgent(args.session.agent)) { const startupPlan = buildAgentResumeStartupPlan({ @@ -103,7 +98,7 @@ export function buildAiVaultResumeStartupForWorktree(args: { ...(args.commandOverride?.trim() ? { [args.session.agent]: args.commandOverride } : {}) }, platform, - shell: queuedShell, + shell: liveShell, agentArgs: resolveTuiAgentLaunchArgs( args.session.agent, args.state.settings?.agentDefaultArgs @@ -117,7 +112,7 @@ export function buildAiVaultResumeStartupForWorktree(args: { cwd: args.session.cwd, platform, codexHome, - shell: queuedShell + shell: liveShell }), ...(startupPlan.env ? { env: startupPlan.env } : {}), launchConfig: startupPlan.launchConfig @@ -139,7 +134,7 @@ export function buildAiVaultResumeStartupForWorktree(args: { codexHome, // Why: non-resumable agents queue through this fallback too, so it must // quote for the live Windows shell like the startup-plan branch above. - shell: queuedShell + shell: liveShell }) } } diff --git a/src/shared/ai-vault-resume-command.test.ts b/src/shared/ai-vault-resume-command.test.ts index 65fe12d4c..a654dd821 100644 --- a/src/shared/ai-vault-resume-command.test.ts +++ b/src/shared/ai-vault-resume-command.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' import { buildAiVaultResumeCommand } from './ai-vault-types' describe('buildAiVaultResumeCommand', () => { - it('wraps Windows cwd changes in cmd so PowerShell and cmd launch the same resume command', () => { + it('builds a self-contained cmd wrapper when no live shell is known', () => { expect( buildAiVaultResumeCommand({ agent: 'codex', @@ -14,6 +14,21 @@ describe('buildAiVaultResumeCommand', () => { ).toBe('cmd /d /s /c "cd /d ""C:\\Users\\Ada Lovelace\\repo"" && codex resume ""session-1"""') }) + it('builds a direct queued command for a live cmd shell', () => { + expect( + buildAiVaultResumeCommand({ + agent: 'omp', + sessionId: 'session-one', + resumeFilePath: 'C:\\Users\\Ada Lovelace\\.omp\\sessions\\A&B session one.jsonl', + cwd: 'C:\\Users\\Ada Lovelace\\A&B repo', + platform: 'win32', + shell: 'cmd' + }) + ).toBe( + 'cd /d "C:\\Users\\Ada Lovelace\\A&B repo" && omp --resume "C:\\Users\\Ada Lovelace\\.omp\\sessions\\A&B session one.jsonl"' + ) + }) + it('carries non-default Codex homes in copied resume commands', () => { expect( buildAiVaultResumeCommand({ diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index b5f5f8246..fe07aca69 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -188,9 +188,12 @@ export function buildAiVaultResumeCommand(args: { // home) the file was discovered under, where an id-prefix lookup scoped to // the default store would miss it. Falls back to the id if no path is known. const resumeTarget = agent === 'omp' && resumeFilePath?.trim() ? resumeFilePath.trim() : sessionId - const sessionArg = shell - ? quoteStartupArg(resumeTarget, shell) - : quoteShellArg(resumeTarget, platform) + const sessionArg = + shell === 'cmd' + ? quoteWindowsCmdArg(resumeTarget) + : shell + ? quoteStartupArg(resumeTarget, shell) + : quoteShellArg(resumeTarget, platform) const resumeCommand = buildAgentResumeInvocation(agent, baseCommand, sessionArg) return buildAiVaultResumeShellCommand({ @@ -208,16 +211,14 @@ export function buildAiVaultResumeShellCommand(args: { platform: NodeJS.Platform codexHome?: string | null // Why: the QUEUED resume command is typed into the live tab shell, so its - // cd/env prefix must match that shell. The copy-to-clipboard string omits this - // and keeps the self-contained `cmd /d /s /c` wrapper (its documented purpose). + // cd/env prefix must match that shell. Shell-less persisted commands keep the + // legacy self-contained `cmd /d /s /c` wrapper. shell?: AgentStartupShell }): string { const { cwd, platform, codexHome, shell } = args - // Why: on Windows the queued command must target the configured live shell - // (default PowerShell). PowerShell mis-parses the cmd `""`-doubled wrapper and - // reports "operable program or batch file", so only re-wrap with cmd when the - // live shell actually is cmd (or when no shell is given, i.e. the copy path). + // Why: shell-aware commands are parsed by a known running shell, while + // shell-less persisted commands keep the legacy self-contained cmd wrapper. if (platform === 'win32' && shell && shell !== 'cmd') { return buildResumeShellCommandForShell({ resumeCommand: args.resumeCommand, @@ -230,6 +231,11 @@ export function buildAiVaultResumeShellCommand(args: { const resumeCommand = `${codexHomeEnvPrefix(codexHome?.trim() || null, platform)}${ args.resumeCommand }` + if (platform === 'win32' && shell === 'cmd') { + // Why: an interactive cmd splits the doubled quotes required by a nested + // `cmd /s /c` wrapper, so queued commands must use direct cmd syntax. + return cwd ? `cd /d ${quoteWindowsCmdArg(cwd)} && ${resumeCommand}` : resumeCommand + } if (!cwd) { return resumeCommand }