openhands / src /utils /acp-command.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
a070805 verified
Raw
History Blame Contribute Delete
7.94 kB
// ``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(" ");
}