|
| 1 | +#!/usr/bin/env bun |
| 2 | +/** |
| 3 | + * The agent command line, EXECUTED on this runner's real platform (Seq 1737). |
| 4 | + * |
| 5 | + * Windows agent launch was dead for every Claude session: the command line was |
| 6 | + * POSIX-quoted (`'\''` for an apostrophe) and then handed to |
| 7 | + * `Invoke-Expression`, where `\` ends the string literal. The dev3 system prompt |
| 8 | + * says "the task's title", so every launch died with a PowerShell ParserError |
| 9 | + * before the agent binary was even looked up. The dialect record predicted this |
| 10 | + * ("complex quoting will need a separate normalisation pass"). |
| 11 | + * |
| 12 | + * A pure test can pin the text, and it does — but the text is only half the |
| 13 | + * question. Two parsers stand between dev3 and the agent's `argv`: PowerShell |
| 14 | + * builds a raw command line, then the callee's C runtime splits it again. What |
| 15 | + * is proved here is the whole chain: every argument dev3 writes arrives at the |
| 16 | + * binary BYTE-IDENTICAL, including the real `CLAUDE_SKILL_BODY` and a battery of |
| 17 | + * strings picked to break one parser or the other. |
| 18 | + * |
| 19 | + * The POSIX legs are the control that none of this changed macOS/Linux. |
| 20 | + * |
| 21 | + * The "agent binary" is this runner's own `bun`, with a probe script as its |
| 22 | + * first argument — a real executable with ordinary C-runtime argument parsing, |
| 23 | + * which a `.cmd` shim would not be (cmd.exe parses its own way and would prove |
| 24 | + * something else). One leg copies that binary into a directory with a space in |
| 25 | + * its name, which is the `C:\Users\John Smith\...` case. |
| 26 | + * |
| 27 | + * Run: bun run test:agent-launch-args-e2e (all three platforms) |
| 28 | + */ |
| 29 | + |
| 30 | +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs"; |
| 31 | +import { tmpdir } from "node:os"; |
| 32 | +import { join } from "node:path"; |
| 33 | +import { spawn } from "../spawn"; |
| 34 | +import { CLAUDE_SKILL_BODY } from "../../shared/agent-skill-content"; |
| 35 | +import { commandToken, shellEscape } from "../../shared/agent-adapters/shell"; |
| 36 | +import { buildCmdScript, generatedScriptLaunch, generatedScriptName, writeLaunchScript } from "../rpc-handlers/shared-pure"; |
| 37 | + |
| 38 | +let failures = 0; |
| 39 | +function check(condition: boolean, message: string): void { |
| 40 | + if (condition) console.log(` ok - ${message}`); |
| 41 | + else { |
| 42 | + failures++; |
| 43 | + console.error(` FAIL - ${message}`); |
| 44 | + } |
| 45 | +} |
| 46 | + |
| 47 | +const root = mkdtempSync(join(tmpdir(), "dev3-agent-args-e2e-")); |
| 48 | +const RECORD = join(root, "argv.json"); |
| 49 | + |
| 50 | +/** The "agent": records the arguments it was given and exits 0. */ |
| 51 | +const PROBE = join(root, "probe.mjs"); |
| 52 | +writeFileSync( |
| 53 | + PROBE, |
| 54 | + [ |
| 55 | + "import { writeFileSync } from 'node:fs';", |
| 56 | + `writeFileSync(${JSON.stringify(RECORD)}, JSON.stringify(process.argv.slice(2)));`, |
| 57 | + ].join("\n"), |
| 58 | + "utf8", |
| 59 | +); |
| 60 | + |
| 61 | +/** |
| 62 | + * Arguments that break exactly one parser each. Everything here is a value dev3 |
| 63 | + * really can emit: the system prompt is quoted verbatim, task titles and prompts |
| 64 | + * are the user's own text, and `--settings` carries a Windows path. |
| 65 | + */ |
| 66 | +const CASES: Array<[name: string, value: string]> = [ |
| 67 | + ["the real dev3 system prompt", CLAUDE_SKILL_BODY], |
| 68 | + ["an apostrophe (the reported break)", "the task's title"], |
| 69 | + ["a POSIX escape sequence, literally", "before '\\'' after"], |
| 70 | + ["double quotes", 'he said "no" and left'], |
| 71 | + ["an unbalanced double quote", 'a" b'], |
| 72 | + ["a quote with no whitespace around it", 'x"y'], |
| 73 | + ["backslashes before a quote", 'path\\\\"quoted"'], |
| 74 | + ["a trailing backslash", "ends with a backslash \\"], |
| 75 | + ["a lone backslash, no whitespace", "no-space\\"], |
| 76 | + ["a PowerShell variable and subexpression", "$env:PATH and $(Get-Date)"], |
| 77 | + ["a PowerShell backtick escape", "a `n b"], |
| 78 | + ["a cmd-style variable reference", "%PATH% and %%"], |
| 79 | + ["shell operators", "a && b || c ; d | e > f"], |
| 80 | + ["a Windows path", "C:\\Users\\user\\.dev3.0\\data\\settings.json"], |
| 81 | + ["newlines and tabs", "first\nsecond\tthird"], |
| 82 | + ["non-ASCII", "Так, на винде — «агент» 🙂"], |
| 83 | + ["an empty argument", ""], |
| 84 | +]; |
| 85 | + |
| 86 | +/** |
| 87 | + * Run the command line exactly the way a task pane does: assembled the way an |
| 88 | + * adapter assembles it, wrapped by `buildCmdScript`, written through |
| 89 | + * `writeLaunchScript` (a Windows `.ps1` needs its byte-order mark) and launched |
| 90 | + * through `generatedScriptLaunch`. |
| 91 | + * |
| 92 | + * stdin is closed on purpose: the failure branch hands the view to an |
| 93 | + * interactive shell, and a wrapper that blocks there hangs the pane the same way |
| 94 | + * it would hang this run. |
| 95 | + */ |
| 96 | +async function runAgent(binary: string, values: string[]): Promise<{ code: number; output: string }> { |
| 97 | + rmSync(RECORD, { force: true }); |
| 98 | + const command = [commandToken(binary), shellEscape(PROBE), ...values.map(shellEscape)].join(" "); |
| 99 | + const scriptPath = join(root, generatedScriptName("run")); |
| 100 | + await writeLaunchScript(scriptPath, buildCmdScript(command)); |
| 101 | + const launch = generatedScriptLaunch(scriptPath); |
| 102 | + const proc = spawn([launch.executable, ...launch.argv], { |
| 103 | + cwd: root, |
| 104 | + stdin: "ignore", |
| 105 | + stdout: "pipe", |
| 106 | + stderr: "pipe", |
| 107 | + }); |
| 108 | + const [out, err, code] = await Promise.all([ |
| 109 | + new Response(proc.stdout).text(), |
| 110 | + new Response(proc.stderr).text(), |
| 111 | + proc.exited, |
| 112 | + ]); |
| 113 | + return { code, output: `${out}\n${err}` }; |
| 114 | +} |
| 115 | + |
| 116 | +function received(): string[] | null { |
| 117 | + if (!existsSync(RECORD)) return null; |
| 118 | + try { |
| 119 | + return JSON.parse(readFileSync(RECORD, "utf8")) as string[]; |
| 120 | + } catch { |
| 121 | + return null; |
| 122 | + } |
| 123 | +} |
| 124 | + |
| 125 | +/** A short, quotable rendering of a value that may be 30 KB of system prompt. */ |
| 126 | +function brief(value: string): string { |
| 127 | + const json = JSON.stringify(value); |
| 128 | + return json.length > 120 ? `${json.slice(0, 117)}…` : json; |
| 129 | +} |
| 130 | + |
| 131 | +console.log(`agent command line on ${process.platform} (${process.execPath})`); |
| 132 | + |
| 133 | +try { |
| 134 | + console.log("\nper-argument — each value arrives at the binary byte-identical"); |
| 135 | + for (const [name, value] of CASES) { |
| 136 | + const { code, output } = await runAgent(process.execPath, [value]); |
| 137 | + const args = received(); |
| 138 | + if (args === null) { |
| 139 | + check(false, `${name}: the binary never ran (exit ${code}) — ${JSON.stringify(output.trim().slice(-400))}`); |
| 140 | + continue; |
| 141 | + } |
| 142 | + check( |
| 143 | + args.length === 1 && args[0] === value, |
| 144 | + `${name}: got ${args.length} arg(s), ${brief(args[0] ?? "")}`, |
| 145 | + ); |
| 146 | + } |
| 147 | + |
| 148 | + console.log("\nall at once — argument boundaries survive a full command line"); |
| 149 | + { |
| 150 | + const values = CASES.map(([, value]) => value); |
| 151 | + const { code, output } = await runAgent(process.execPath, values); |
| 152 | + const args = received(); |
| 153 | + if (args === null) { |
| 154 | + check(false, `the binary never ran (exit ${code}) — ${JSON.stringify(output.trim().slice(-400))}`); |
| 155 | + } else { |
| 156 | + check(args.length === values.length, `argument count preserved (${args.length} of ${values.length})`); |
| 157 | + const firstDrift = values.findIndex((value, index) => args[index] !== value); |
| 158 | + check( |
| 159 | + firstDrift === -1, |
| 160 | + firstDrift === -1 |
| 161 | + ? "every argument matches" |
| 162 | + : `argument ${firstDrift} (${CASES[firstDrift]?.[0]}) drifted: ${brief(args[firstDrift] ?? "")}`, |
| 163 | + ); |
| 164 | + } |
| 165 | + } |
| 166 | + |
| 167 | + console.log("\nbinary path with a space — the C:\\Users\\John Smith case"); |
| 168 | + { |
| 169 | + const dir = join(root, "Program Files", "coding agent"); |
| 170 | + mkdirSync(dir, { recursive: true }); |
| 171 | + const binary = join(dir, process.platform === "win32" ? "agent.exe" : "agent"); |
| 172 | + copyFileSync(process.execPath, binary); |
| 173 | + const { code, output } = await runAgent(binary, ["the task's title"]); |
| 174 | + const args = received(); |
| 175 | + check( |
| 176 | + args !== null && args.length === 1 && args[0] === "the task's title", |
| 177 | + args === null |
| 178 | + ? `the binary never ran (exit ${code}) — ${JSON.stringify(output.trim().slice(-400))}` |
| 179 | + : `the spaced-path binary ran and its argument survived (${brief(args[0] ?? "")})`, |
| 180 | + ); |
| 181 | + } |
| 182 | +} finally { |
| 183 | + rmSync(root, { recursive: true, force: true }); |
| 184 | +} |
| 185 | + |
| 186 | +console.log(failures === 0 ? "\nALL CHECKS PASSED" : `\n${failures} CHECK(S) FAILED`); |
| 187 | +process.exit(failures === 0 ? 0 : 1); |
0 commit comments