Skip to content

Commit de630d5

Browse files
committed
Quote agent command lines in the platform's launch dialect
The launch command is text a generated wrapper re-parses, but every argument was quoted for a POSIX shell. On Windows the apostrophe in the dev3 system prompt arrived as '\'' inside a PowerShell literal, so Invoke-Expression parsed the rest of the prompt as code and every Claude launch died with a ParserError before the binary was looked up. Add nativeArg and commandToken to LaunchDialect. POSIX keeps the existing spelling byte-for-byte; Windows writes a single-quoted literal with the callee's C-runtime escapes baked in, because PowerShell 5.1 escapes nothing between its own parser and the command line it hands to CreateProcess. An absolute binary path that needs quoting is now spelled as a command, which also fixes a path containing a space on every platform. Proved by executing the wrapper against a probe binary on the runner's real platform and comparing its argv byte-for-byte.
1 parent 841447d commit de630d5

11 files changed

Lines changed: 568 additions & 9 deletions

File tree

.github/workflows/windows-conpty-package.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,26 @@ jobs:
8383
if: always()
8484
run: bun run test:agent-spawn-shell
8585

86+
# Seq 1737: the agent command line was POSIX-quoted and then re-parsed by the
87+
# wrapper, so the apostrophe in "the task's title" reached PowerShell as `'\''`
88+
# and every Claude launch on Windows died with a ParserError before the binary
89+
# was looked up. Text is only half the question: PowerShell builds a raw command
90+
# line and the callee's C runtime splits it again, and 5.1 escapes nothing on the
91+
# way through. So the E2E RUNS the wrapper on this runner against a probe binary
92+
# and compares its argv byte-for-byte, including the real system prompt and a
93+
# binary whose path holds a space. The pure test pins both dialects and covers the
94+
# call site, which an E2E calling the helper cannot.
95+
# `always()`: a step that runs only when an unrelated one passes is evidence
96+
# about that step, not about this one.
97+
- name: Agent command line quoting (pure, both dialects)
98+
if: always()
99+
run: bunx vitest run --config vitest.config.bun.ts src/bun/__tests__/agent-launch-args.test.ts src/bun/__tests__/agent-command-golden.test.ts
100+
101+
- name: Agent command line executed against a real binary (${{ matrix.os }})
102+
if: always()
103+
timeout-minutes: 5
104+
run: bun run test:agent-launch-args-e2e
105+
86106
# Seq 1547: the git-operation panes (rebase, push, merge) were hand-written
87107
# `#!/bin/bash` launched through `/bin/bash`, so they were dead on Windows.
88108
# These scripts rewrite history and touch the remote, so "the pane opened"
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Short: Agents launch again on Windows
2+
3+
Starting or resuming an agent on Windows failed with a PowerShell ParserError: the command line was quoted for a POSIX shell, so the apostrophe in the dev3 system prompt ended the string literal and the rest was parsed as code. Command lines are now quoted in the platform's launch dialect, and a binary whose path contains a space (`C:\Users\John Smith\...`) is launched correctly on every platform.

decisions/2026/07/26/platform-launch-dialect.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,13 @@ structural primitives (`header`, `envLines`, `print`/`style`, `captureExitCode`,
3939
existing macOS/Linux task terminal does on the next launch.
4040
`src/bun/__tests__/platform-launch-posix-golden.test.ts` pins the full rendered
4141
text of all wrappers against the pre-dialect output.
42-
- The PowerShell flavour is unexecuted on POSIX CI. Agent command lines are still
43-
produced as POSIX-quoted strings elsewhere and are run through
44-
`Invoke-Expression`; simple forms (`claude 'task'`) parse in PowerShell, complex
45-
quoting will need a separate normalisation pass.
42+
- The PowerShell flavour is unexecuted on POSIX CI. Agent command lines were still
43+
produced as POSIX-quoted strings elsewhere and run through `Invoke-Expression`;
44+
simple forms (`claude 'task'`) parsed in PowerShell, and anything with an
45+
apostrophe did not — every Claude launch on Windows died on it. That
46+
normalisation pass landed on 2026-08-28 as
47+
`decisions/2026/08/28/agent-command-lines-quote-in-the-launch-dialect.md`
48+
(`dialect.nativeArg` / `dialect.commandToken`).
4649

4750
## Alternatives considered
4851

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# Agent command lines quote in the launch dialect
2+
3+
## Context
4+
5+
Starting or resuming any Claude session on Windows died before the binary was
6+
looked up:
7+
8+
```
9+
Invoke-Expression : Missing expression after unary operator '-'.
10+
+ - Do NOT run `dev3 task update`, `dev3 overview set`/`clear`, ...
11+
```
12+
13+
`resolveAgentCommand` assembles the launch as one string and `buildCmdScript`
14+
hands that string to the wrapper, which re-parses it — `Invoke-Expression` on
15+
Windows. Every argument was quoted by `shellEscape`, which spells an apostrophe
16+
the POSIX way (`'\''`). In PowerShell `'` closes the literal and `\` is not an
17+
escape, so the dev3 system prompt — which says "the task's title" — turned the
18+
rest of the prompt into code. `decisions/2026/07/26/platform-launch-dialect.md`
19+
predicted exactly this and left it open ("complex quoting will need a separate
20+
normalisation pass"); this is that pass.
21+
22+
## Investigation
23+
24+
A second parser sits behind the first, and it is the one that is easy to miss.
25+
PowerShell does not hand argv to the callee: it builds a raw command line, and
26+
the callee's C runtime splits it again. Windows PowerShell 5.1 escapes nothing on
27+
the way through (`about_Parsing`: quotes meant for a native command must be
28+
escaped by hand), so the C runtime's escapes have to be present in the string
29+
dev3 writes. The dev3 system prompt contains double quotes and backslashes, so
30+
this is not a corner case for it.
31+
32+
That behaviour is a claim about a PowerShell version this repo cannot run on
33+
macOS, so it is not asserted from reading: `src/bun/__tests__/agent-launch-args.bun-e2e.ts`
34+
runs the real wrapper on the runner's real platform against a probe binary and
35+
compares its `argv` byte-for-byte — the real `CLAUDE_SKILL_BODY` plus seventeen
36+
values picked to break one parser or the other. It runs in the packaged Windows
37+
proof, with the POSIX legs as the control.
38+
39+
## Decision
40+
41+
`LaunchDialect` grew two members next to `quote`
42+
(`src/shared/platform-launch.ts`):
43+
44+
- `nativeArg(value)` — one argument for a native executable through a re-parsed
45+
command line. POSIX reuses `posixShellQuote` byte-for-byte (the shell hands the
46+
word to `execve`). Windows uses `powerShellNativeArg`: a single-quoted literal
47+
with `'` doubled, `"` written `\"`, the backslashes in front of a quote
48+
doubled, and a trailing backslash run doubled only when PowerShell will append
49+
a closing quote for it to eat.
50+
- `commandToken(value)` — the first token. An absolute path that is not a bare
51+
word is quoted (`& '...'` on Windows, where `&` is what makes a quoted string a
52+
command). Anything else is untouched, so `npx claude` stays shell text.
53+
54+
`shellEscape`/`commandToken` in `src/shared/agent-adapters/shell.ts` delegate to
55+
the dialect, so all six adapters follow the platform without knowing it exists.
56+
The command token is applied once at the boundary — `resolveAgentCommand` and
57+
`buildResumeCommand` in `src/bun/agents.ts` — because only the boundary knows the
58+
string is about to be re-parsed.
59+
60+
## Risks
61+
62+
- **POSIX regression** is the whole risk: `nativeArg` on POSIX is the same
63+
function `shellEscape` always was, and `commandToken` changes only an absolute
64+
path that needs quoting. `agent-command-golden.test.ts` and
65+
`platform-launch-posix-golden.test.ts` pin the existing output.
66+
- The 5.1 escaping model above is a model. It is pinned by the executed E2E
67+
rather than by belief, and that E2E is in `WINDOWS_SCOPE_PATHS`, so an edit to
68+
the adapters re-dispatches the Windows proof.
69+
- A user's `baseCommand` that is several words stays unquoted on purpose. A path
70+
with a space typed there still breaks, exactly as it did before — dev3 cannot
71+
tell that from a command with arguments.
72+
73+
## Alternatives considered
74+
75+
- **Pass the command as argv instead of a re-parsed string** — the agent command
76+
is a user-editable string that may carry shell operators; turning it into argv
77+
changes what a user's own preset means.
78+
- **`--%` (PowerShell's stop-parsing token)** — it would hand dev3 full control of
79+
the raw command line, but it reads to the end of the LINE, and the system prompt
80+
contains newlines.
81+
- **`[Diagnostics.Process]::Start` with a hand-built command line** — full control
82+
of both parsers, at the cost of the wrapper no longer running the user's command
83+
as shell text at all.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"test:git-op-pane-e2e": "bun src/bun/__tests__/git-op-pane.bun-e2e.ts",
4949
"test:dev-server-script": "bunx vitest run --config vitest.config.bun.ts src/bun/__tests__/dev-server-script.test.ts src/bun/rpc-handlers/__tests__/dev-server-pane-launch.test.ts",
5050
"test:dev-server-pane-e2e": "bun src/bun/__tests__/dev-server-pane.bun-e2e.ts",
51+
"test:agent-launch-args-e2e": "bun src/bun/__tests__/agent-launch-args.bun-e2e.ts",
5152
"test:windows-update-e2e": "bun src/bun/windows-update/__tests__/swap.win-e2e.ts",
5253
"test:native-shell-launch": "bunx vitest run --config vitest.config.bun.ts src/bun/native-terminal-registry/__tests__/shell-launch.test.ts src/bun/native-terminal-registry/__tests__/shell-probe.test.ts src/bun/native-terminal-registry/__tests__/windows-shell-runner.test.ts src/bun/native-terminal-registry/__tests__/windows-shell-evidence.test.ts src/bun/native-terminal-registry/__tests__/windows-shell-matrix-support.test.ts src/bun/native-terminal-registry/__tests__/host-config.test.ts src/bun/native-terminal-registry/__tests__/protocol.test.ts src/bun/native-terminal-registry/__tests__/registry.test.ts src/bun/native-terminal-registry/__tests__/recovery.test.ts src/bun/native-terminal-registry/__tests__/client.test.ts src/bun/native-terminal-registry/__tests__/isolation.test.ts",
5354
"test:native-live-parser-e2e": "bun src/bun/native-terminal-registry/__tests__/live-parser.bun-e2e.ts",
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
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

Comments
 (0)