fix(wsl): apply the launch hybrid to terminal commands too

Terminal commands (tool_call terminals) still used the nested
`sh -c "<buildWslShellWrapper(innerCommand)>"` construction, which carries
the same fragility that broke agent launch in some environments (e.g.
RHEL8) and double-escaped the command line.

Run terminal commands the same way as agents now:
  wsl.exe [--exec] /bin/sh -c '<launcher>' sh '<commandLine>'
The command line is delivered as a single positional and run under the
user's login shell ($SHELL -l -c "$1"). This keeps full shell parsing
(pipes, redirects, subshells, bash-isms under the user's shell, e.g. bash)
-- behaviorally identical to the previous `$SHELL -l -c <line>` -- while
skipping wsl's default-shell layer (--exec) and removing the doubly-escaped
nested string.

Also, in WSL mode omit the Windows-side spawn cwd for terminals: the
working directory is applied inside the launcher (cd '<wslCwd>'), and a
Linux path as the wsl.exe process cwd would make CreateProcess fail.

buildWslShellWrapper is retained (now used only by paths.ts for the WSL
`which` lookup).

Tests updated/added accordingly (43 cases).
This commit is contained in:
RAIT-09 2026-06-05 13:38:37 +09:00
parent 850e2e1557
commit b91184f012
3 changed files with 102 additions and 19 deletions

View file

@ -110,9 +110,13 @@ export class TerminalManager {
cwd: params.cwd,
});
// Spawn the process
// Spawn the process.
// In WSL mode the working directory is applied inside the launcher
// (cd '<wslCwd>'); the wsl.exe process must NOT receive a Linux path as
// its Windows cwd (CreateProcess would fail), so omit cwd there.
const useWsl = Platform.isWin && this.plugin.settings.windowsWslMode;
const spawnOptions: SpawnOptions = {
cwd: params.cwd || undefined,
cwd: useWsl ? undefined : params.cwd || undefined,
env,
stdio: ["pipe", "pipe", "pipe"],
shell: needsShell,

View file

@ -289,6 +289,10 @@ export function isSameDirectory(pathA: string, pathB: string): boolean {
* $SHELL, and falls back to /bin/sh for non-POSIX shells (fish, elvish,
* nushell, xonsh).
*
* NOTE: agent and terminal launches now use buildWslArgvScript /
* buildWslTerminalScript (--exec + positional argv) instead. This helper is
* currently used only by paths.ts (resolveCommandPathInWsl, the `which` lookup).
*
* IMPORTANT: wsl.exe pre-expands $VAR references using WSL environment
* variables before passing them to the Linux shell. Intermediate variables
* (e.g., s=$SHELL; exec $s) will NOT work because wsl.exe expands $s to
@ -344,6 +348,34 @@ export function buildWslArgvScript(): string {
);
}
/**
* Build the constant launcher script for terminal commands in WSL.
*
* Used as: `wsl.exe [-d dist] --exec /bin/sh -c '<this>' sh '<commandLine>'`
* where the full shell command line is passed as a single positional ($1),
* NEVER baked into this string so there is no nested-quoting fragility. The
* command line is then run under the user's login shell with `-c`, so it is
* parsed by the user's actual shell (e.g. bash): pipes, redirects, subshells and
* bash-specific syntax are preserved, with ~/.profile sourced. Falls back to
* /bin/sh for non-POSIX shells (fish, elvish, nushell, xonsh).
*
* This mirrors buildWslShellWrapper's effective behavior (`$SHELL -l -c
* <commandLine>`) but delivers the command line via an argv positional + `--exec`
* instead of a doubly-escaped nested string, which avoids quoting failures seen
* in some WSL environments (e.g. RHEL8).
*
* IMPORTANT (same caveat as buildWslArgvScript): reference `${SHELL:-/bin/sh}`
* directly; do not use intermediate variables.
*/
export function buildWslTerminalScript(): string {
return (
`case \${SHELL:-/bin/sh} in ` +
`*/fish|*/elvish|*/nushell|*/xonsh) exec /bin/sh -l -c "$1";; ` +
`*) exec \${SHELL:-/bin/sh} -l -c "$1";; ` +
`esac`
);
}
/**
* Wrap a command to run inside WSL using wsl.exe.
* Generates wsl.exe command with proper arguments for executing commands in WSL environment.
@ -416,9 +448,12 @@ export function wrapCommandForWsl(
};
}
// Terminal launch: the command may be a shell line (pipes, redirects, &&),
// so keep the shell-string wrapper and let the login shell parse it.
// Use login shell (-l) to inherit PATH from user's shell profile.
// Terminal launch: the command may be a shell line (pipes, redirects, &&,
// subshells, bash-isms), so it must be parsed by the user's shell. Build the
// same shell command line as before, but deliver it via --exec + a single
// positional (no nested escaped string) so it survives WSL quoting, and run
// it under the user's login shell ($SHELL -l -c "$1"). Behaviorally identical
// to the previous `$SHELL -l -c <innerCommand>`, just delivered robustly.
const escapedArgs = args.map(escapeShellArgBash).join(" ");
const argsString = escapedArgs.length > 0 ? ` ${escapedArgs}` : "";
@ -431,7 +466,14 @@ export function wrapCommandForWsl(
}
const innerCommand = `${pathPrefix}cd ${escapeShellArgBash(wslCwd)} && ${command}${argsString}`;
wslArgs.push("sh", "-c", buildWslShellWrapper(innerCommand));
wslArgs.push(
"--exec",
"/bin/sh",
"-c",
buildWslTerminalScript(),
"sh",
innerCommand,
);
return {
command: "C:\\Windows\\System32\\wsl.exe",

View file

@ -6,6 +6,7 @@ import {
escapeShellArgBash,
escapeShellArgWindows,
buildWslArgvScript,
buildWslTerminalScript,
wrapCommandForWsl,
prepareShellCommand,
buildWslEnv,
@ -180,8 +181,8 @@ describe("wrapCommandForWsl — agent (useArgvExec)", () => {
});
});
describe("wrapCommandForWsl — terminal (shell string)", () => {
it("uses sh -c wrapper and lets the shell parse the command (pipes)", () => {
describe("wrapCommandForWsl — terminal (shell string via hybrid)", () => {
it("uses --exec + login shell, command line as a single positional (pipes preserved)", () => {
const { command, args } = wrapCommandForWsl(
"ls -la | grep foo",
[],
@ -191,14 +192,48 @@ describe("wrapCommandForWsl — terminal (shell string)", () => {
false,
);
expect(command).toBe(WSL_EXE);
expect(args).toContain("sh");
expect(args).toContain("-c");
const wrapper = args[args.indexOf("-c") + 1];
// login shell + profile sourcing preserved
expect(wrapper).toContain(". ~/.profile");
expect(wrapper).toContain(" -l ");
// command is left raw so the shell parses the pipe
expect(wrapper).toContain("ls -la | grep foo");
// header: --exec /bin/sh -c <launcher script>
expect(args.slice(0, 3)).toEqual(["--exec", "/bin/sh", "-c"]);
// launcher runs the command line under the user's login shell
const script = args[3];
expect(script).toContain(" -l ");
expect(script).toContain("${SHELL:-/bin/sh}");
expect(script).toContain('-c "$1"');
// positionals: sh <innerCommand> — the command line is ONE intact element
expect(args).toHaveLength(6);
expect(args[4]).toBe("sh");
const innerCommand = args[5];
expect(innerCommand).toContain("ls -la | grep foo"); // raw pipe preserved
expect(innerCommand).toContain("cd '/mnt/c/vault'");
});
it("includes the PATH export and cd in the command line when additionalPath is set", () => {
const { args } = wrapCommandForWsl(
"node x.js",
[],
"C:\\v",
undefined,
"C:\\node\\bin",
false,
);
const innerCommand = args[args.length - 1];
expect(innerCommand).toContain('export PATH="/mnt/c/node/bin:$PATH"');
expect(innerCommand).toContain("node x.js");
});
});
describe("buildWslTerminalScript", () => {
const s = buildWslTerminalScript();
it("runs the command line under the user's login shell via -c", () => {
expect(s).toContain(" -l ");
expect(s).toContain('-c "$1"');
expect(s).toContain("${SHELL:-/bin/sh}");
});
it("falls back to /bin/sh for non-POSIX shells", () => {
expect(s).toContain("*/fish");
});
it("is a pure constant (no baked-in user data)", () => {
expect(s).not.toContain("undefined");
});
});
@ -227,15 +262,17 @@ describe("prepareShellCommand", () => {
expect(r.args).toContain("--exec");
});
it("WSL terminal: wsl.exe via sh -c wrapper", () => {
it("WSL terminal: wsl.exe via --exec + login shell, command line as positional", () => {
Platform.isWin = true;
const r = prepareShellCommand("ls | grep x", [], "C:\\vault", {
wslMode: true,
alwaysEscape: false,
});
expect(r.command).toBe(WSL_EXE);
expect(r.args).toContain("sh");
expect(r.args).not.toContain("--exec");
expect(r.needsShell).toBe(false);
expect(r.args.slice(0, 3)).toEqual(["--exec", "/bin/sh", "-c"]);
// the command line (with the pipe) is the last positional, intact
expect(r.args[r.args.length - 1]).toContain("ls | grep x");
});
it("macOS: wraps in a login shell (-l -c)", () => {