fix(win): resume quoted cmd.exe startup commands via stdin, not /K (#7978)

* 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>
This commit is contained in:
Andre Cristo 2026-07-11 04:59:17 -03:00 committed by GitHub
parent 9f078567e5
commit f6f497f50c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 138 additions and 72 deletions

View File

@ -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',

View File

@ -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

View File

@ -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,

View File

@ -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<typeof buildAiVaultResumeStartupForWorktree>[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: {

View File

@ -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
})
}
}

View File

@ -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({

View File

@ -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
}