From 4b038df0615612eda1b8c08f505969388671abb1 Mon Sep 17 00:00:00 2001 From: Nikita Smirnov Date: Thu, 14 May 2026 14:29:48 +0300 Subject: [PATCH] fix: preserve Windows paths for Codex resume --- src/frontend/app.js | 19 ++++-- src/frontend/detail.js | 32 ++++----- src/terminals.js | 94 +++++++++++++++++++-------- test/frontend-escaping.test.js | 30 +++++++++ test/terminals-windows-launch.test.js | 35 ++++++++++ 5 files changed, 165 insertions(+), 45 deletions(-) create mode 100644 test/frontend-escaping.test.js create mode 100644 test/terminals-windows-launch.test.js diff --git a/src/frontend/app.js b/src/frontend/app.js index 850c2f9..290b710 100644 --- a/src/frontend/app.js +++ b/src/frontend/app.js @@ -184,6 +184,17 @@ function escHtml(s) { .replace(/'/g, '''); } +function escJsString(s) { + if (s === null || s === undefined) return ''; + return escHtml(String(s) + .replace(/\\/g, '\\\\') + .replace(/'/g, "\\'") + .replace(/\r/g, '\\r') + .replace(/\n/g, '\\n') + .replace(/\u2028/g, '\\u2028') + .replace(/\u2029/g, '\\u2029')); +} + function showToast(msg) { const el = document.getElementById('toast'); if (!el) return; @@ -849,8 +860,8 @@ function renderCard(s, idx) { if (s.has_detail) { var btnTitle = sessionTitles[s.id] ? 'Regenerate AI title' : 'Generate AI title'; var btnIcon = sessionTitles[s.id] ? '↻' : '⚛'; - html += ''; - html += ''; + html += ''; + html += ''; } html += ''; // MCP/Skills footer @@ -2042,7 +2053,7 @@ function renderRunningCard(a, s) { html += ''; if (s) { html += ''; - html += ''; + html += ''; } html += ''; html += ''; @@ -2065,7 +2076,7 @@ function renderDoneCard(s) { if (s.last_time) html += '
' + s.last_time.slice(11) + 'ended
'; html += ''; html += '
'; - html += ''; + html += ''; html += '
'; html += ''; return html; diff --git a/src/frontend/detail.js b/src/frontend/detail.js index 3ea57d4..40a96ed 100644 --- a/src/frontend/detail.js +++ b/src/frontend/detail.js @@ -20,11 +20,13 @@ async function openDetail(s) { var aiTitle = sessionTitles[s.id]; var sessionName = s.session_name || ''; var displayName = aiTitle || (sessionName ? sessionName.slice(0, 55) : ''); - var escProject = escHtml(s.project || '').replace(/'/g, "\\'"); + var jsId = escJsString(s.id); + var jsTool = escJsString(s.tool); + var jsProject = escJsString(s.project || ''); if (displayName) { - infoHtml += '
Name' + escHtml(displayName) + '
'; + infoHtml += '
Name' + escHtml(displayName) + '
'; } else if (s.has_detail) { - infoHtml += '
Name
'; + infoHtml += '
Name
'; } var detailToolLabel = getToolLabel(s.tool); infoHtml += '
Tool' + escHtml(detailToolLabel) + '
'; @@ -68,28 +70,28 @@ async function openDetail(s) { infoHtml += '
'; // Tool-specific launch buttons if (s.tool === 'cursor') { - infoHtml += ''; + infoHtml += ''; } else if (s.tool === 'copilot-chat') { - infoHtml += ''; + infoHtml += ''; } else if (activeSessions[s.id]) { - infoHtml += ''; + infoHtml += ''; } else { - infoHtml += ''; + infoHtml += ''; if (s.tool === 'claude') { - infoHtml += ''; + infoHtml += ''; } } - infoHtml += ''; + infoHtml += ''; if (s.has_detail) { - infoHtml += ''; - infoHtml += ''; + infoHtml += ''; + infoHtml += ''; getConvertTargets(s.tool).forEach(function(target) { - infoHtml += ''; + infoHtml += ''; }); - infoHtml += ''; + infoHtml += ''; } - infoHtml += ''; - infoHtml += ''; + infoHtml += ''; + infoHtml += ''; infoHtml += '
'; body.innerHTML = infoHtml + '
Loading messages...
'; diff --git a/src/terminals.js b/src/terminals.js index d18f779..97a549c 100644 --- a/src/terminals.js +++ b/src/terminals.js @@ -192,41 +192,70 @@ function termLog(tag, msg) { console.log(` ${color}${ts} [${tag}]\x1b[0m ${msg}`); } -function openInTerminal(sessionId, tool, flags, projectDir, terminalId, mode) { - const skipPerms = flags.includes('skip-permissions'); +function buildAgentCommand(sessionId, tool, flags, mode) { + const skipPerms = (flags || []).includes('skip-permissions'); const fresh = mode === 'fresh'; - let cmd; if (fresh) { // Start a brand new session in projectDir (no --resume). We map known // tools to their CLI entry point; unrecognised tools fall back to claude // so a misconfigured UI doesn't silently open the wrong agent. switch (tool) { - case 'codex': cmd = 'codex'; break; - case 'qwen': cmd = 'qwen'; break; - case 'kilo': cmd = 'kilo'; break; - case 'kiro': cmd = 'kiro-cli'; break; - case 'opencode': cmd = 'opencode'; break; - case 'cursor': cmd = 'cursor-agent'; break; + case 'codex': return 'codex'; + case 'qwen': return 'qwen'; + case 'kilo': return 'kilo'; + case 'kiro': return 'kiro-cli'; + case 'opencode': return 'opencode'; + case 'cursor': return 'cursor-agent'; case 'copilot': - case 'copilot-chat': cmd = 'gh copilot suggest'; break; + case 'copilot-chat': return 'gh copilot suggest'; default: - cmd = 'claude'; - if (skipPerms) cmd += ' --dangerously-skip-permissions'; + return 'claude' + (skipPerms ? ' --dangerously-skip-permissions' : ''); } - } else if (tool === 'codex') { - cmd = `codex resume ${sessionId}`; - } else if (tool === 'qwen') { - cmd = `qwen -r ${sessionId}`; - } else if (tool === 'kilo') { - cmd = `kilo resume ${sessionId}`; - } else { - cmd = `claude --resume ${sessionId}`; - if (skipPerms) cmd += ' --dangerously-skip-permissions'; } + if (tool === 'codex') return `codex resume ${sessionId}`; + if (tool === 'qwen') return `qwen -r ${sessionId}`; + if (tool === 'kilo') return `kilo resume ${sessionId}`; + return `claude --resume ${sessionId}` + (skipPerms ? ' --dangerously-skip-permissions' : ''); +} + +function buildPosixShellCommand(cmd, projectDir) { const cdPart = projectDir ? `cd ${JSON.stringify(projectDir)} && ` : ''; - const fullCmd = cdPart + cmd; + return cdPart + cmd; +} + +function quotePowerShellSingle(value) { + return "'" + String(value).replace(/'/g, "''") + "'"; +} + +function buildStartProcessArgs(filePath, argList, workingDirectory) { + const script = [ + `Start-Process -FilePath ${quotePowerShellSingle(filePath)}`, + workingDirectory ? `-WorkingDirectory ${quotePowerShellSingle(workingDirectory)}` : '', + `-ArgumentList @(${argList.map(quotePowerShellSingle).join(',')})`, + ].filter(Boolean).join(' '); + return ['-NoProfile', '-Command', script]; +} + +function buildWindowsCmdStartArgs(cmd, projectDir) { + return buildStartProcessArgs('cmd.exe', ['/k', cmd], projectDir); +} + +function buildWindowsPowerShellStartArgs(cmd, projectDir) { + return buildStartProcessArgs('powershell.exe', ['-NoExit', '-NoProfile', '-Command', cmd], projectDir); +} + +function buildWindowsTerminalArgs(cmd, projectDir) { + const args = ['new-tab']; + if (projectDir) args.push('--startingDirectory', projectDir); + args.push('cmd.exe', '/k', cmd); + return args; +} + +function openInTerminal(sessionId, tool, flags, projectDir, terminalId, mode) { + const cmd = buildAgentCommand(sessionId, tool, flags, mode); + const fullCmd = buildPosixShellCommand(cmd, projectDir); const escapedCmd = fullCmd.replace(/"/g, '\\"'); termLog('TERM', `openInTerminal: terminal=${terminalId || 'default'} tool=${tool} cmd="${fullCmd}"`); @@ -407,13 +436,13 @@ function openInTerminal(sessionId, tool, flags, projectDir, terminalId, mode) { } else { switch (terminalId) { case 'powershell': - exec(`start powershell -NoExit -Command "${fullCmd}"`); + execFileSafe('powershell.exe', buildWindowsPowerShellStartArgs(cmd, projectDir)); break; case 'windows-terminal': - exec(`wt new-tab cmd /k "${fullCmd}"`); + execFileSafe('wt.exe', buildWindowsTerminalArgs(cmd, projectDir)); break; default: - exec(`start cmd /k "${fullCmd}"`); + execFileSafe('powershell.exe', buildWindowsCmdStartArgs(cmd, projectDir)); break; } } @@ -1019,4 +1048,17 @@ function focusTerminalByPid(pid, sessionId) { return { ok: false }; } -module.exports = { detectTerminals, openInTerminal, focusTerminalByPid, isWSL }; +module.exports = { + detectTerminals, + openInTerminal, + focusTerminalByPid, + isWSL, + __test: { + buildAgentCommand, + buildPosixShellCommand, + buildWindowsCmdStartArgs, + buildWindowsPowerShellStartArgs, + buildWindowsTerminalArgs, + quotePowerShellSingle, + }, +}; diff --git a/test/frontend-escaping.test.js b/test/frontend-escaping.test.js new file mode 100644 index 0000000..ea62d16 --- /dev/null +++ b/test/frontend-escaping.test.js @@ -0,0 +1,30 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +function loadEscapers() { + const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'frontend', 'app.js'), 'utf8'); + const match = source.match(/function escHtml[\s\S]*?\nfunction showToast/); + assert.ok(match, 'escaper block should be present'); + const block = match[0].replace(/\nfunction showToast[\s\S]*$/, ''); + const ctx = {}; + vm.runInNewContext(block + '\nthis.escJsString = escJsString;', ctx); + return ctx; +} + +test('escJsString preserves Windows backslashes for inline onclick arguments', () => { + const { escJsString } = loadEscapers(); + assert.equal(escJsString('C:\\projects'), 'C:\\\\projects'); + assert.equal(escJsString("C:\\Users\\O'Neil\\repo"), 'C:\\\\Users\\\\O\\'Neil\\\\repo'); +}); + +test('detail resume button uses JS-string escaping for project paths', () => { + const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'frontend', 'detail.js'), 'utf8'); + assert.match(source, /var jsProject = escJsString\(s\.project \|\| ''\);/); + assert.ok( + source.includes("launchSession(\\'' + jsId + '\\',\\'' + jsTool + '\\',\\'' + jsProject + '\\')"), + 'Resume onclick should pass jsProject, not raw escHtml(project)' + ); +}); diff --git a/test/terminals-windows-launch.test.js b/test/terminals-windows-launch.test.js new file mode 100644 index 0000000..be7525e --- /dev/null +++ b/test/terminals-windows-launch.test.js @@ -0,0 +1,35 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { __test } = require('../src/terminals'); + +test('Windows cmd launcher uses WorkingDirectory instead of nested cd quoting', () => { + const cmd = __test.buildAgentCommand('019e2039-7e0d-7582-9116-39d0d2713e36', 'codex', [], 'resume'); + const args = __test.buildWindowsCmdStartArgs(cmd, 'C:\\projects'); + const script = args[2]; + + assert.equal(cmd, 'codex resume 019e2039-7e0d-7582-9116-39d0d2713e36'); + assert.deepEqual(args.slice(0, 2), ['-NoProfile', '-Command']); + assert.match(script, /Start-Process -FilePath 'cmd\.exe'/); + assert.match(script, /-WorkingDirectory 'C:\\projects'/); + assert.match(script, /-ArgumentList @\('\/k','codex resume 019e2039-7e0d-7582-9116-39d0d2713e36'\)/); + assert.equal(script.includes('cd "C:\\projects" &&'), false); +}); + +test('Windows Terminal launcher passes cwd as startingDirectory', () => { + const cmd = __test.buildAgentCommand('abc123', 'codex', [], 'resume'); + assert.deepEqual( + __test.buildWindowsTerminalArgs(cmd, 'C:\\projects\\codbash'), + ['new-tab', '--startingDirectory', 'C:\\projects\\codbash', 'cmd.exe', '/k', 'codex resume abc123'] + ); +}); + +test('Windows PowerShell launcher also uses WorkingDirectory', () => { + const cmd = __test.buildAgentCommand('abc123', 'claude', ['skip-permissions'], 'resume'); + const script = __test.buildWindowsPowerShellStartArgs(cmd, 'C:\\projects\\codbash')[2]; + + assert.match(script, /Start-Process -FilePath 'powershell\.exe'/); + assert.match(script, /-WorkingDirectory 'C:\\projects\\codbash'/); + assert.match(script, /'-NoExit','-NoProfile','-Command','claude --resume abc123 --dangerously-skip-permissions'/); + assert.equal(script.includes('&&'), false); +});