File size: 8,369 Bytes
3201ca6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import { describe, expect, it } from "vitest";
import { formatCommand, parseCommand } from "#/utils/acp-command";

describe("parseCommand", () => {
  it("splits a simple npx invocation into argv tokens", () => {
    expect(
      parseCommand("npx -y @agentclientprotocol/claude-agent-acp"),
    ).toEqual(["npx", "-y", "@agentclientprotocol/claude-agent-acp"]);
  });

  it("respects double-quoted segments β€” the headline regression .split fix", () => {
    // The old `.split(/\s+/)` implementation turned this into
    // ``["bash", "-c", "\"echo", "hello", "world\""]`` and the spawn
    // would either misbehave or fail in a confusing place. The
    // quote-aware tokenizer keeps the quoted segment intact.
    expect(parseCommand('bash -c "echo hello world"')).toEqual([
      "bash",
      "-c",
      "echo hello world",
    ]);
  });

  it("respects single-quoted segments and embedded whitespace", () => {
    expect(parseCommand("env FOO='bar baz' npx -y my-acp")).toEqual([
      "env",
      "FOO=bar baz",
      "npx",
      "-y",
      "my-acp",
    ]);
  });

  it("preserves URLs with query strings β€” the headline shell-quote-glob fix", () => {
    // Regression guard for the silent-corruption bug:
    //
    //   node acp.js --endpoint https://example.com/acp?tenant=abc
    //
    // ``shell-quote.parse`` used to read ``?tenant=abc`` as a glob
    // pattern and drop the entire URL token, so the saved
    // ``acp_command`` became ``["node", "acp.js", "--endpoint"]``.
    // The spawn would then fail with a confusing "missing endpoint"
    // error far from the Settings β†’ Agent page that caused it.
    //
    // The custom tokenizer treats ``?`` as a literal β€” same for
    // every other shell metacharacter. The URL round-trips intact.
    expect(
      parseCommand("node acp.js --endpoint https://example.com/acp?tenant=abc"),
    ).toEqual([
      "node",
      "acp.js",
      "--endpoint",
      "https://example.com/acp?tenant=abc",
    ]);
  });

  it("preserves URLs with multiple query params", () => {
    // ``&`` is also literal β€” same reason.
    expect(parseCommand("curl https://x.com?a=1&b=2")).toEqual([
      "curl",
      "https://x.com?a=1&b=2",
    ]);
  });

  it("preserves shell metacharacters as literal argv tokens", () => {
    // Pipes, redirects, semicolons, glob chars, ``$``, backticks,
    // ``#`` all round-trip as literal characters within the surrounding
    // token. The agent-server uses ``subprocess.create_subprocess_exec``
    // (no shell intermediary), so a user typing ``foo | bar`` is
    // configuring two literal argv entries β€” not a shell pipeline.
    // The user's helper text steers them to ``bash -c '…'`` if they
    // actually want shell features.
    expect(parseCommand("foo | bar")).toEqual(["foo", "|", "bar"]);
    expect(parseCommand("foo > log.txt")).toEqual(["foo", ">", "log.txt"]);
    expect(parseCommand("foo *.txt")).toEqual(["foo", "*.txt"]);
    expect(parseCommand("foo $X")).toEqual(["foo", "$X"]);
    expect(parseCommand("foo `bar`")).toEqual(["foo", "`bar`"]);
    expect(parseCommand("foo # comment")).toEqual(["foo", "#", "comment"]);
    expect(parseCommand("foo && bar")).toEqual(["foo", "&&", "bar"]);
    expect(parseCommand("foo; bar")).toEqual(["foo;", "bar"]);
  });

  it("treats blank input as an empty argv", () => {
    expect(parseCommand("")).toEqual([]);
    expect(parseCommand("   \t\n   ")).toEqual([]);
  });

  it("honors backslash escapes outside quotes", () => {
    // ``foo\ bar`` is one token containing a literal space β€” the same
    // contract POSIX shells provide. Lets the user type paths with
    // spaces without reaching for quotes.
    expect(parseCommand("foo\\ bar")).toEqual(["foo bar"]);
    // An escaped quote becomes a literal quote in the token.
    expect(parseCommand('foo\\"bar')).toEqual(['foo"bar']);
  });

  it('honors ``\\\\`` and ``\\"`` inside double-quoted segments', () => {
    expect(parseCommand('bash -c "echo \\"hi\\""')).toEqual([
      "bash",
      "-c",
      'echo "hi"',
    ]);
    expect(parseCommand('"foo\\\\bar"')).toEqual(["foo\\bar"]);
  });

  it("does not env-expand $VAR refs β€” keeps them as literal", () => {
    // The forbidden outcome would be the tokenizer reading
    // ``process.env.ANTHROPIC_API_KEY`` and inlining its value into
    // the persisted ``acp_command`` β€” that would leak a host env var
    // into settings on every save. The tokenizer reads ``$NAME`` as
    // a literal substring of the token, so the user's typed text
    // survives verbatim. Provider credentials belong in the Secrets
    // panel (request.secrets), never inlined into the command.
    const result = parseCommand("npx $ANTHROPIC_API_KEY");
    expect(result).toEqual(["npx", "$ANTHROPIC_API_KEY"]);
    // Pin the no-leak contract: no ``sk-…`` token sneaks through
    // from the host env (which is also unset here, but still).
    expect(result.some((t) => /sk-ant-/.test(t))).toBe(false);
  });

  it("does not run subshells: $(…) and backticks become literal tokens", () => {
    // The forbidden outcome would be executing ``date`` and inlining
    // today's timestamp into the persisted command. The tokenizer
    // never invokes anything; both forms round-trip verbatim.
    expect(parseCommand("echo $(date)")).toEqual(["echo", "$(date)"]);
    expect(parseCommand("echo `date`")).toEqual(["echo", "`date`"]);
  });

  it("survives unterminated quotes without throwing", () => {
    // EOF closes the open quote; the partially-built token gets
    // pushed. A throw here would crash the Settings β†’ Agent page
    // mid-render. The Save button gates on a non-empty argv anyway,
    // so a recoverable miss can't be silently persisted.
    expect(parseCommand('bash -c "unterminated')).toEqual([
      "bash",
      "-c",
      "unterminated",
    ]);
    expect(parseCommand("foo 'unterminated single")).toEqual([
      "foo",
      "unterminated single",
    ]);
  });
});

describe("formatCommand", () => {
  it("renders package-style tokens verbatim, no escaping of @ or /", () => {
    // The textarea is the only consumer of formatCommand. Escaping the
    // ``@`` in ``@org/pkg`` produces a hostile read-back (the user
    // copies their existing command, the textarea now shows
    // ``\@org/pkg``, they think we corrupted it). The agent-server
    // execs argv directly so the escape isn't load-bearing for
    // behaviour β€” only for display.
    expect(
      formatCommand(["npx", "-y", "@agentclientprotocol/claude-agent-acp"]),
    ).toBe("npx -y @agentclientprotocol/claude-agent-acp");
  });

  it("shell-quotes tokens that contain whitespace", () => {
    expect(formatCommand(["bash", "-c", "echo hello world"])).toBe(
      "bash -c 'echo hello world'",
    );
  });

  it("round-trips arbitrary argv arrays through parseCommand", () => {
    const cases: string[][] = [
      ["npx", "-y", "@agentclientprotocol/claude-agent-acp"],
      ["npx", "-y", "@zed-industries/codex-acp"],
      ["npx", "-y", "@google/gemini-cli", "--acp"],
      ["bash", "-c", "echo hello world"],
      ["env", "FOO=bar baz", "npx", "-y", "my-acp"],
      ["./bin/my-agent", "--flag=value"],
      // URL with query string β€” the headline silent-corruption case.
      ["node", "acp.js", "--endpoint", "https://example.com/acp?tenant=abc"],
      // URL with multiple params.
      ["curl", "https://x.com?a=1&b=2"],
      // Empty-string tokens are rare but valid (some CLIs treat an
      // empty positional as "no argument supplied" rather than missing).
      // Without explicit quoting in formatCommand they round-trip back
      // as fewer tokens, silently dropping the empty slot.
      ["bash", "-c", ""],
      ["program", "", "--flag"],
    ];
    for (const argv of cases) {
      expect(parseCommand(formatCommand(argv))).toEqual(argv);
    }
  });

  it("renders an empty argv as an empty string", () => {
    expect(formatCommand([])).toBe("");
  });

  it("explicitly quotes empty-string tokens so they survive the round trip", () => {
    // Direct assertion on the rendered form β€” without this rule,
    // formatCommand(["bash","-c",""]) would render ``"bash -c "`` and
    // parseCommand would return ``["bash", "-c"]``, losing the empty arg.
    expect(formatCommand(["bash", "-c", ""])).toBe("bash -c ''");
  });
});