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(" ");
}