Repository navigation
Expand file tree
/
Copy pathdelegate-terminal.js
More file actions
199 lines (191 loc) · 8.89 KB
/
Copy pathdelegate-terminal.js
File metadata and controls
199 lines (191 loc) · 8.89 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
'use strict';
/**
* Argument construction for the Delegate feature's "open a terminal and run the
* external agent" step. Extracted from server.js so the platform-specific quoting
* is unit-testable without spawning anything — see test/delegate-terminal.test.js.
*/
/**
* Windows quoting. THREE parsers see this value, in this order:
*
* 1. batch phase 1 (percent expansion) — the command is written into a .bat
* (openTerminal() in server.js), so `%` must be doubled or it is eaten.
* 2. cmd phase 2 — cmd does NOT know `\` as an escape character. It only
* COUNTS double quotes to track whether it is inside a quoted region;
* `& | < > ( ) ^` are operators outside such a region and literal text
* inside it. `^` escapes a metacharacter ONLY outside quotes.
* 3. the launched program's argv parser (MSVC CRT / CommandLineToArgvW):
* 2n backslashes + `"` -> n backslashes and the quote is a delimiter,
* 2n+1 backslashes + `"` -> n backslashes and a LITERAL quote,
* `""` inside a quoted region -> a literal quote.
*
* The rule implemented here: keep the whole value inside ONE balanced cmd quoted
* region, and encode an embedded quote as `""` (never `\"`), doubling any
* backslash run that precedes a quote for layer 3.
*
* Why not the textbook `\"` + `^`-escape-everything pair: `\"` leaves an ODD
* number of quotes behind, which flips cmd out of its quoted region and hands
* the rest of the prompt to the shell as bare text (that was the bug — the real
* sync prompt contains `"| answer"`, so cmd split the line on the `|`). And
* `^`-escaping instead of quoting only survives ONE pass: on Windows `claude`,
* `codex` and `opencode` are npm .cmd shims that re-insert the raw argument text
* via `%*`, so cmd phase 2 runs a second time and the carets are already gone
* (BatBadBut, CVE-2024-1874). `""` is balanced, therefore idempotent under that
* re-parse, and every metacharacter stays inside a quoted region in both passes.
*
* Newlines are folded to spaces: a raw newline ends the .bat line, and cmd has no
* escape for it (`^` + newline is a line continuation, which swallows it).
*
* Not handled: `!` under `setlocal EnableDelayedExpansion`. The generated .bat
* never enables it, and cmd /k inherits the default (off) unless the machine sets
* it in HKCU\Software\Microsoft\Command Processor.
*/
function winEscape(s) {
let out = '"';
let backslashes = 0;
for (const ch of String(s).replace(/\r\n|\r|\n/g, ' ')) {
if (ch === '\\') { backslashes++; continue; }
if (ch === '"') { out += '\\'.repeat(backslashes * 2) + '""'; backslashes = 0; continue; }
out += '\\'.repeat(backslashes) + ch;
backslashes = 0;
}
// A trailing backslash run would eat the closing quote at layer 3, so double it.
out += '\\'.repeat(backslashes * 2) + '"';
return out.replace(/%/g, '%%');
}
/**
* Quote a value for the shell that will run the generated command line.
* @param {string} s
* @param {string} platform - process.platform value
*/
function shellEscape(s, platform) {
if (platform === 'win32') return winEscape(s);
// Unix: single-quote wrapping, replace ' with '\'' (end quote, escaped quote, start quote)
return "'" + String(s).replace(/'/g, "'\\''") + "'";
}
/**
* Build the shell command that cd's into the workdir and launches the agent CLI.
*
* `{model}` and `{effort}` are optional placeholders an agent template may use, e.g.
* `claude --model {model} {prompt}`. They are OMITTED, not blanked, when the caller
* chose nothing: a template that writes `--model {model}` would otherwise become
* `--model ` and the CLI would read the next token as the model name. The whole flag
* has to disappear with the value, so the substitution eats the surrounding run of
* spaces and leaves one.
*
* Every substituted value goes through shellEscape for the same reason `{prompt}`
* does: these come from an HTTP body, and a model name is a string like any other.
*
* @param {{template?: string}} agentConfig
* @param {{model?: string, effort?: string}} [opts]
*/
function buildTerminalCommand(agentConfig, workdir, prompt, platform, opts = {}) {
const template = agentConfig.template || '';
// Token-wise, not regex-wise. A regex over the whole template got three shapes
// wrong at once, and the third was silent corruption rather than a visible break:
//
// --model "{model}" with a value -> --model "'opus'" (double-quoted,
// so the CLI receives 'opus' WITH quotes)
// --model "{model}" with none -> --model "" (empty flag)
// --model={model},{model} with none -> claude, (command destroyed)
//
// Splitting on whitespace makes each of those a local decision: a token either
// contains the placeholder or it does not.
let cmd = template;
for (const [name, value] of [['model', opts.model], ['effort', opts.effort]]) {
const token = `{${name}}`;
if (!cmd.includes(token)) continue;
const parts = cmd.split(/(\s+)/); // keep the separators, so spacing survives
const out = [];
for (let k = 0; k < parts.length; k++) {
const part = parts[k];
if (!part.includes(token)) { out.push(part); continue; }
if (value) {
// Strip quotes the template author wrapped around the placeholder: the value
// is shell-escaped here, and escaping inside quotes nests them.
// Unwrap quotes the template author put AROUND the placeholder before
// substituting: the value is shell-escaped here, so quoting it again nests
// the quotes and the CLI receives them as part of the value.
const bare = part.split(`"${token}"`).join(token).split(`'${token}'`).join(token);
out.push(bare.split(token).join(shellEscape(String(value), platform)));
} else {
// Drop this token, and the flag before it when that flag exists only to
// carry it — `--model {model}` must not leave `--model` to eat the prompt.
while (out.length && /^\s+$/.test(out[out.length - 1])) out.pop();
const prev = out[out.length - 1];
if (prev && /^--?[\w-]+$/.test(prev)) {
out.pop();
while (out.length && /^\s+$/.test(out[out.length - 1])) out.pop();
}
// Skip the separator that followed the dropped token.
if (/^\s+$/.test(parts[k + 1] || '')) k++;
if (out.length) out.push(' ');
}
}
// No global whitespace squeeze: the drop path already inserts exactly one space
// where a token was removed, and collapsing the WHOLE command would rewrite
// spacing the template author meant to keep — `--system "a b"` is a real thing.
cmd = out.join('').trim();
}
cmd = cmd.replace('{prompt}', shellEscape(prompt, platform));
// Not every agent CLI accepts a --cwd flag, so always cd into the workdir first
return `cd ${shellEscape(workdir, platform)} && ${cmd}`;
}
/**
* argv for `cmd.exe` that opens the .bat in a new window.
*
* `start` reads its first argument as the window TITLE only when that argument is
* QUOTED. Bare `start Delegate cmd.exe ...` makes Windows look for a program named
* "Delegate" and fail with "Windows cannot find 'Delegate'" (issue #22).
*
* Must be spawned with windowsVerbatimArguments: true — otherwise Node re-quotes
* '"Delegate"' into '"\"Delegate\""', which `start` again treats as a program name.
* That is also why tmpBat is quoted here: os.tmpdir() embeds the username, which
* may contain spaces.
*/
function winTerminalArgs(tmpBat) {
return ['/c', 'start', '"Delegate"', 'cmd.exe', '/k', `"${tmpBat}"`];
}
/**
* Check whether a command-line executable exists in $PATH.
* Uses POSIX `command -v` via /bin/sh.
*/
function hasCommand(cmd) {
if (typeof cmd !== 'string' || !/^[a-zA-Z0-9._-]+$/.test(cmd)) return false;
try {
const { spawnSync } = require('node:child_process');
const r = spawnSync('/bin/sh', ['-c', `command -v ${cmd}`], { stdio: 'ignore' });
return !r.error && r.status === 0;
} catch {
return false;
}
}
/**
* Check whether the current environment lacks a graphical desktop display.
* On Linux, absence of both $DISPLAY and $WAYLAND_DISPLAY indicates a headless
* server, SSH session without X forwarding, or Docker container.
*/
function isHeadless(env = process.env, platform = process.platform) {
if (platform === 'linux') {
return !env.DISPLAY && !env.WAYLAND_DISPLAY;
}
return false;
}
/**
* Terminal candidate configurations for Linux.
* Each entry specifies the executable name and its argument structure.
*/
function getLinuxTerminalCandidates(shellCommand) {
return [
{ name: 'gnome-terminal', args: ['--', 'bash', '-c', shellCommand] },
{ name: 'xterm', args: ['-e', 'bash', '-c', shellCommand] },
{ name: 'konsole', args: ['-e', 'bash', '-c', shellCommand] },
];
}
module.exports = {
shellEscape,
buildTerminalCommand,
winTerminalArgs,
hasCommand,
isHeadless,
getLinuxTerminalCandidates,
};