Compare commits

...

6 Commits

Author SHA1 Message Date
Mikołaj Kondratek 2477711080 fix(terminal): harden PowerShell command wrapping for standalone shell
`StandaloneTerminalProcess.getShellArgs()` blindly wrapped every
PowerShell command as `["-Command", command]`. That has two
end-user-visible failure modes on Windows, both observed in
cline/cline#10948:

1. The agent's `run_commands` tool call sometimes arrives already
   prefixed with `powershell -Command "…"`. We then spawned
   `powershell.exe -Command 'powershell -Command "…"'`, and the
   outer shell shredded the inner single/double-quote pairs while
   re-parsing its `-Command` argument. The inner pwsh saw
   quote-empty `Test-Path` calls, fell through to the `else` branch
   and reported "File not found" — to ITS stdout, which the outer
   inherited but the file deletion the LLM intended never ran.
2. The user's `$PROFILE` script ran on every spawn, leaking
   non-deterministic noise (e.g.
   `%windir%\System32\REG.exe : The module '%windir%' could not be
   loaded`) into the captured output and confusing the agent.
3. Bonus: the POSIX branch used `["-l", "-c", command]`. The `-l`
   re-sources login files on every command, which is slow and lets
   greeter scripts leak into output.
4. Bonus: the cmd branch used `["/c", command]`. `/d` skips
   AutoRun, `/s` makes the embedded-quote handling deterministic.

Fix:

* PowerShell branch returns
  `["-NoProfile", "-NonInteractive", "-Command", unwrap(command)]`.
  `-NoProfile` suppresses (1) the spurious profile noise that
  contaminated the captured output, and `-NonInteractive` ensures
  the child doesn't deadlock waiting on a prompt no one will answer.
* `unwrapPowerShell(command)` strips a leading
  `powershell|pwsh [.exe] -Command|-c "…"` (or single-quoted)
  wrapper that the LLM sometimes emits, fixing the double-pass
  argument-quoting destruction. If the command does not match the
  exact wrapper shape it is returned verbatim — worst case is "no
  change", preserving pre-fix behavior.
* cmd branch returns `["/d", "/s", "/c", command]`, matching the
  canonical helper in cline/sdk/packages/shared/src/parse/shell.ts.
* POSIX branch returns `["-c", command]`, dropping the unhelpful
  `-l`. Also matches the SDK helper.

Verified on Windows 11 + IntelliJ IDEA 2026.1 + Cline plugin
1.1.59-Internal in combination with the previous "keep Windows
child stdio attached" commit: `Remove-Item CHANGELOG.md` now
deletes the file, the agent's verification `Get-ChildItem CHANGELOG*`
returns nothing, and the profile-load REG.exe error no longer leaks
into captured output.

Refs: cline/cline#10948
2026-05-29 19:25:06 +02:00
Mikołaj Kondratek 64e7651a76 fix(terminal): keep Windows child stdio attached to parent pipes
The non-cmd Windows branch in StandaloneTerminalProcess.run() spawned
the shell (powershell.exe in practice) with `detached: true` and no
`windowsHide`. When cline-core is launched by the JetBrains plugin it
has no console of its own, so Windows CreateProcess allocates a NEW
console for the detached child and the child's stdio routes to that
new console instead of the pipe handles the parent created. From the
parent's point of view the pipes immediately EOF, `close` fires with
`code=0`, and `fullOutput` is 0 bytes — exactly the symptom reported
in cline/cline#10948 ("Command executed successfully (exit code 0)"
with no output and no filesystem effect).

This bug applies to every command the agent runs through the
standalone terminal path on Windows, not just the
double-wrapped-PowerShell cases (verified by re-running a clean
`dir <file>` after the diagnostics from the previous commit landed:
`run() entered` and `spawned pid=<num>` both fired, then `close: code=0
fullOutputLen=0`).

Fix:

* `detached: process.platform !== "win32"` — keep the existing
  POSIX behavior (a separate process group helps `tree-kill`), but
  drop it on Windows where `tree-kill` walks the PID tree with
  `taskkill /T` and doesn't need a process group.
* `windowsHide: true` — matches every other `child_process.spawn`
  call site in cline-core (git, MCP, hooks, browser) and flips on
  `CREATE_NO_WINDOW`, keeping the child attached to our pipes
  without popping a console window.

Verified on Windows 11 + IntelliJ IDEA 2026.1 + Cline plugin
1.1.59-Internal: `dir <path>`-style probes now produce a non-zero
`fullOutputLen` in the close log, and the captured output bytes
match what would have been visible interactively. PowerShell
double-wrapping (the other half of #10948) is handled in a
follow-up commit.

Refs: cline/cline#10948
2026-05-29 19:24:52 +02:00
Mikołaj Kondratek 424a0fd01b test(shared): document conservative unwrapPowerShell non-match
unwrapPowerShell only strips wrappers where the binary is immediately
followed by -Command/-c. Wrappers with an intermediate flag (e.g.
`powershell -NoProfile -Command "dir"`) are intentionally left verbatim
to avoid incorrect rewrites. Add an explicit test for that boundary so it
reads as deliberate rather than an oversight, and spell out the
constraint in the docstring.
2026-05-29 18:22:24 +02:00
Mikołaj Kondratek 973c40d195 refactor(shared): don't export unwrapPowerShell from package entrypoint
unwrapPowerShell is an implementation detail of getShellArgs and has no
external consumer; getShellArgs callers get the unwrapping for free. Keep
it module-exported for the unit test (which imports via the relative
path) but drop it from index.ts / index.browser.ts to avoid widening the
public API surface.
2026-05-29 18:19:48 +02:00
Mikołaj Kondratek fe07211bc4 fix(core): hide child console in bash executor on Windows
The bash executor already drops `detached` on Windows, but did not set
windowsHide on the child spawn. When the host process has no console of
its own (e.g. an IDE-launched server), Windows CreateProcess allocates a
new console for the child and routes its stdio to that console instead of
the pipes the parent created. The parent then sees empty output even
though the command ran.

Set windowsHide:true on the spawn so the child stays attached to our
pipes (and no console window pops). No-op on non-Windows. The taskkill
helper already sets windowsHide; this brings the main spawn in line.
2026-05-29 18:19:48 +02:00
Mikołaj Kondratek 701cac72c5 fix(shared): strip redundant PowerShell wrapper in getShellArgs
Callers (notably LLM-generated commands) sometimes pass a command already
wrapped as `powershell -Command "…"`. getShellArgs then re-wrapped it,
spawning powershell with another powershell invocation as its -Command
argument; the outer shell shredded the inner quote pairs while re-parsing,
so the inner shell saw quote-empty arguments and the intended command
never ran.

Add unwrapPowerShell, called from the PowerShell branch of getShellArgs.
It strips a leading `powershell|pwsh [.exe] -Command|-c "…"` wrapper, but
only when the whole string is exactly one quoted token whose body does not
contain the delimiter (tempered match), so anything else is returned
verbatim and the worst case is "no change" rather than an incorrect
rewrite. Export it from the package entrypoints and cover it with unit
tests, including the nested-quote case and the two shapes the regex must
refuse to unwrap.
2026-05-29 18:19:48 +02:00
4 changed files with 106 additions and 9 deletions
@@ -115,11 +115,15 @@ export class StandaloneTerminalProcess extends EventEmitter<TerminalProcessEvent
// Spawn the process with special handling for "cmd.exe"
this.childProcess = spawn("cmd.exe", shellArgs, shellOptions)
} else {
// Spawn the process with detached: true to create a process group
// This allows us to kill the entire process tree when terminating
// On Windows, detached:true without windowsHide:true allocates a new
// console for the child when the parent (cline-core launched by the
// IDE) has none, routing the child's stdio to that console instead
// of our pipes. Drop detached on win32 (tree-kill handles cleanup)
// and force windowsHide so the child stays attached to our pipes.
this.childProcess = spawn(shell, shellArgs, {
...shellOptions,
detached: true,
detached: process.platform !== "win32",
windowsHide: true,
})
}
@@ -323,12 +327,26 @@ export class StandaloneTerminalProcess extends EventEmitter<TerminalProcessEvent
private getShellArgs(shell: string, command: string): string[] {
if (process.platform === "win32") {
if (shell.toLowerCase().includes("powershell") || shell.toLowerCase().includes("pwsh")) {
return ["-Command", command]
// -NoProfile silences user $PROFILE side effects that otherwise
// leak into the captured output; -NonInteractive avoids prompts.
return ["-NoProfile", "-NonInteractive", "-Command", StandaloneTerminalProcess.unwrapPowerShell(command)]
}
return ["/c", command]
return ["/d", "/s", "/c", command]
}
// Use -l for login shell, -c for command
return ["-l", "-c", command]
return ["-c", command]
}
/**
* Strip a redundant outer `powershell|pwsh [.exe] -Command|-c "…"` wrapper
* that LLMs sometimes emit. Without this we'd spawn powershell.exe with
* another powershell.exe as its -Command argument and the inner shell would
* receive quote-shredded args (issue #10948).
*/
private static unwrapPowerShell(command: string): string {
const match = command.match(
/^\s*(?:powershell|pwsh)(?:\.exe)?\s+-(?:Command|c)\s+(['"])([\s\S]*)\1\s*$/i,
)
return match ? match[2] : command
}
/**
@@ -68,6 +68,10 @@ function spawnAndCollect(
env: { ...process.env, ...config.env },
stdio: ["pipe", "pipe", "pipe"],
detached: !isWindows,
// Without windowsHide, a console-less parent (e.g. an IDE-launched
// host) makes Windows allocate a new console for the child and route
// its stdio there instead of our pipes. No-op on non-Windows.
windowsHide: true,
});
const childPid = child.pid;
+52 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { getDefaultShell, getShellArgs } from "./shell";
import { getDefaultShell, getShellArgs, unwrapPowerShell } from "./shell";
describe("shell helpers", () => {
it("selects PowerShell on Windows and bash elsewhere", () => {
@@ -43,4 +43,55 @@ describe("shell helpers", () => {
getShellArgs("C:\\Program Files\\Git\\bin\\bash.exe", "echo hi"),
).toEqual(["-c", "echo hi"]);
});
it("unwraps a redundant PowerShell wrapper when building args", () => {
expect(
getShellArgs("powershell", `powershell -Command "dir"`),
).toEqual(["-NoProfile", "-NonInteractive", "-Command", "dir"]);
});
});
describe("unwrapPowerShell", () => {
it("strips a double-quoted -Command wrapper", () => {
expect(unwrapPowerShell(`powershell -Command "Write-Output 'hi'"`)).toBe(
"Write-Output 'hi'",
);
});
it("strips a single-quoted -Command wrapper", () => {
expect(unwrapPowerShell(`pwsh -Command 'Get-Date'`)).toBe("Get-Date");
});
it("strips a powershell.exe -c wrapper", () => {
expect(unwrapPowerShell(`powershell.exe -c "dir"`)).toBe("dir");
});
it("preserves inner quotes in a nested-quote command", () => {
const inner = `if (Test-Path 'CHANGELOG.md') { Remove-Item 'CHANGELOG.md' -Force }`;
expect(unwrapPowerShell(`powershell -Command "${inner}"`)).toBe(inner);
});
it("returns a non-wrapped command verbatim", () => {
expect(unwrapPowerShell(`Remove-Item -Path "x" -Force`)).toBe(
`Remove-Item -Path "x" -Force`,
);
});
it("does not unwrap when content remains past the closing quote", () => {
const input = `powershell -Command "foo" "bar"`;
expect(unwrapPowerShell(input)).toBe(input);
});
it("does not unwrap a command that merely mentions powershell", () => {
expect(unwrapPowerShell(`echo powershell -Command "x"`)).toBe(
`echo powershell -Command "x"`,
);
});
it("does not unwrap when intermediate flags sit between binary and -Command", () => {
// Wrappers like "powershell -NoProfile -Command ..." are not stripped;
// they are returned verbatim to avoid incorrect rewrites.
const input = `powershell -NoProfile -Command "dir"`;
expect(unwrapPowerShell(input)).toBe(input);
});
});
+25 -1
View File
@@ -12,6 +12,25 @@ export function getDefaultShell(platform: string): string {
return platform === "win32" ? "powershell" : "/bin/bash";
}
/**
* Strip a redundant outer `powershell|pwsh [.exe] -Command|-c "…"` wrapper that
* callers sometimes emit. Without this we'd spawn powershell with another
* powershell invocation as its -Command argument, and the inner shell would
* receive quote-shredded args.
*
* Deliberately conservative: only unwraps when the binary is immediately
* followed by -Command/-c and the rest is exactly one quoted token whose body
* does not contain the delimiter. Anything else (e.g. an intermediate
* -NoProfile flag) is returned verbatim, so the worst case is "no change"
* rather than an incorrect rewrite.
*/
export function unwrapPowerShell(command: string): string {
const match = command.match(
/^\s*(?:powershell|pwsh)(?:\.exe)?\s+-(?:Command|c)\s+(["'])((?:(?!\1).)*)\1\s*$/i,
);
return match ? match[2] : command;
}
export function getShellArgs(shell: string, command: string): string[] {
const shellName = normalizeShellName(shell);
@@ -21,7 +40,12 @@ export function getShellArgs(shell: string, command: string): string[] {
shellName === "pwsh" ||
shellName === "pwsh.exe"
) {
return ["-NoProfile", "-NonInteractive", "-Command", command];
return [
"-NoProfile",
"-NonInteractive",
"-Command",
unwrapPowerShell(command),
];
}
if (shellName === "cmd" || shellName === "cmd.exe") {