File size: 7,942 Bytes
a070805 | 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 | // ``shell-quote`` is a CJS module that does ``module.exports = { parse, quote }``;
// Vite's ESM interop can resolve a default/namespace import but not named
// imports against that shape (the dev server crashes with "does not provide
// an export named 'parse'"). Namespace import works on both the dev server
// and the Rollup-based prod build. Used only for the ``quote`` direction β
// see ``parseCommand`` below for why we don't use ``shell-quote.parse``.
import * as shellQuote from "shell-quote";
const { quote } = shellQuote;
/**
* Parse a single-string command into argv tokens for ``acp_command``.
*
* Used by the Settings β Agent textarea β the user types one human-readable
* command (e.g. ``bash -c "echo hello world"``) and we convert it into the
* ``string[]`` shape that the agent-server's ``ACPAgent.acp_command``
* expects. The agent-server passes that array straight to
* ``subprocess.create_subprocess_exec``; no shell is involved on the spawn
* side, so this parser only needs to handle argv-style word splitting
* with quote/escape support β *not* shell metasyntax.
*
* Why a custom tokenizer and **not** ``shell-quote.parse``:
*
* ``shell-quote.parse`` treats ``?``, ``*``, ``$VAR``, redirects, and
* comments as shell syntax and emits non-string AST nodes for them.
* Filtering to strings would silently drop entire argv tokens. The
* concrete data-corruption case is a URL with a query string β
*
* node acp.js --endpoint https://example.com/acp?tenant=abc
*
* ``shell-quote`` reads ``?tenant=abc`` as a glob pattern and returns
* ``["node","acp.js","--endpoint",{op:"glob",β¦}]``, so the saved
* ``acp_command`` becomes ``["node","acp.js","--endpoint"]`` β the URL
* vanishes. The agent-server then spawns a broken command and the user
* gets a confusing runtime error far from the configuration UI.
*
* The replacement tokenizer treats every non-whitespace, non-quote
* character as part of the current token: ``?``, ``*``, ``$``, ``|``,
* ``>``, ``#``, backticks all round-trip verbatim. Shell-only constructs
* (pipes, redirects, env-var expansion, command substitution) would
* land as literal argv entries β which is what the user typed and what
* ``subprocess.create_subprocess_exec`` will see. That's correct: a
* user who types ``foo | bar`` into the Settings β Agent textarea is
* configuring a literal command, not a shell pipeline; ``foo`` doesn't
* actually pipe into ``bar``, but neither does it silently disappear.
*
* Quoting rules supported:
* - whitespace separates tokens
* - single quotes: literal until the next ``'`` (no escapes inside,
* matching POSIX shell)
* - double quotes: literal until the next ``"`` (with ``\\"`` and
* ``\\\\`` honored as escapes; no $-expansion)
* - backslash outside quotes: escapes the next character (whitespace,
* quote, or anything else β turns it into a literal)
* - explicit empty quoted segments (``""`` / ``''``) produce an
* empty-string token, matching the round-trip rule in
* ``formatCommand``
*
* Unterminated quotes are tolerated: the current token closes at EOF
* with whatever was accumulated. A throw here would crash the
* Settings β Agent page mid-render; the Save button is already gated
* on a non-empty argv so a recoverable miss can't be silently saved
* either way.
*/
export function parseCommand(value: string): string[] {
const tokens: string[] = [];
let i = 0;
let current = "";
let inToken = false;
while (i < value.length) {
const ch = value[i];
if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") {
if (inToken) {
tokens.push(current);
current = "";
inToken = false;
}
i += 1;
continue;
}
if (ch === "'") {
// Single-quoted segment: literal until the next single quote.
// No escapes inside (POSIX shell semantics).
inToken = true;
i += 1;
while (i < value.length && value[i] !== "'") {
current += value[i];
i += 1;
}
// Skip the closing quote if present. Unterminated β EOF closes.
if (i < value.length) i += 1;
continue;
}
if (ch === '"') {
// Double-quoted segment: literal with backslash escapes for
// ``\\"`` and ``\\\\``. We intentionally do NOT expand $VAR
// (so a user typing ``"--key=$X"`` keeps it literal β the
// agent-server doesn't run a shell anyway). Other backslash
// sequences pass through verbatim (matches what most users
// expect when copying paths with backslashes from Windows
// examples; corner-case differences from POSIX aren't worth
// the complexity here).
inToken = true;
i += 1;
while (i < value.length && value[i] !== '"') {
if (value[i] === "\\" && i + 1 < value.length) {
const next = value[i + 1];
if (next === '"' || next === "\\") {
current += next;
i += 2;
continue;
}
}
current += value[i];
i += 1;
}
if (i < value.length) i += 1;
continue;
}
if (ch === "\\" && i + 1 < value.length) {
// Unquoted backslash escapes the next character (whitespace,
// quote, glob char, anything). Useful for typing literal spaces
// in a path or escaping a literal quote.
inToken = true;
current += value[i + 1];
i += 2;
continue;
}
// Every other character β including ``?``, ``*``, ``$``, ``|``,
// ``>``, ``#``, ``&``, ``;``, ``(``, ``)`` β is a literal part of
// the current token. Shell-metasyntax filtering happens at the
// OS/shell boundary, which we don't cross.
inToken = true;
current += ch;
i += 1;
}
if (inToken) {
tokens.push(current);
}
return tokens;
}
// Tokens that need shell-quoting when rendering back to a string β
// whitespace, quotes, backslashes, redirects/pipes/globs, and the
// command-separators. ``@``, ``/``, ``-``, ``.``, ``+``, ``=`` and
// other punctuation that's common in package names and URLs are
// safe in argv-only contexts (the agent-server execs the array, no
// shell intermediary), so we leave them alone β otherwise
// ``npx -y @org/pkg`` would render as ``npx -y \@org/pkg`` and that's
// a hostile read-back in the textarea.
//
// Note: ``?``, ``*``, ``$``, ``|``, ``>``, ``#``, ``&``, ``;``, ``(``,
// ``)`` no longer carry shell meaning in {@link parseCommand} (which
// is purely an argv tokenizer), so a token containing them would
// round-trip fine without quoting. We still quote on output because
// users frequently switch between this textarea and an actual shell
// (copy-paste workflows), and quoting matches the conservative
// expectation "if it would need quoting in a shell, show it quoted."
const SHELL_UNSAFE = /[\s"'\\$`&|;<>(){}*?#!~[\]]/;
/**
* Render a ``string[]`` argv back into a single string the textarea
* can display. Tokens that *would* need shell quoting (whitespace,
* quotes, redirects, β¦) go through ``shell-quote.quote`` for correct
* escaping; tokens that are already shell-safe (the overwhelming
* majority of package names and CLI flags) round-trip verbatim. The
* output remains a valid input to {@link parseCommand}.
*/
export function formatCommand(command: readonly string[]): string {
return command
.map((tok) =>
// Quote any token that:
// * contains a shell-significant character (whitespace, quotes,
// redirects, β¦), so it doesn't get re-split on parse, OR
// * is the empty string β without explicit quoting,
// ``["bash", "-c", ""]`` would render as ``"bash -c "`` and
// round-trip back to ``["bash", "-c"]``, silently dropping
// the (rare but valid) empty argument.
SHELL_UNSAFE.test(tok) || tok === "" ? quote([tok]) : tok,
)
.join(" ");
}
|