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