Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
7e61d95
🤖 feat: improve Windows bash runtime detection with WSL support
ibetitsmike Dec 5, 2025
8053346
🤖 feat: use PowerShell + stdin for WSL to avoid escaping issues
ibetitsmike Dec 5, 2025
1eaf990
🤖 fix: use [Console]::In.ReadToEnd() for PowerShell stdin
ibetitsmike Dec 5, 2025
3a51bab
🤖 fix: handle quoted Windows paths with spaces in translation
ibetitsmike Dec 5, 2025
e2e7f3f
🤖 fix: use base64 encoding instead of stdin for PowerShell WSL wrapper
ibetitsmike Dec 5, 2025
accb21f
🤖 debug: add logging to execAsync to trace WSL command execution
ibetitsmike Dec 5, 2025
dcc7f77
🤖 fix: use bash -c instead of stdin piping to capture WSL output
ibetitsmike Dec 5, 2025
c0fde48
🤖 fix: quote $s variable when passing to bash -c
ibetitsmike Dec 5, 2025
45e8ec1
🤖 fix: have bash decode base64 to avoid all quoting issues
ibetitsmike Dec 5, 2025
fc250b6
🤖 debug: add logging to LocalBaseRuntime.exec for WSL tracing
ibetitsmike Dec 5, 2025
6171fef
🤖 fix: disable detached mode on Windows for PowerShell wrapper
ibetitsmike Dec 5, 2025
6830ba3
🤖 debug: add exit/output logging to LocalBaseRuntime.exec
ibetitsmike Dec 5, 2025
5abbe09
🤖 debug: filter out noisy git status commands from logging
ibetitsmike Dec 5, 2025
7a5288a
🤖 fix: use eval with command substitution to avoid stdin issues
ibetitsmike Dec 5, 2025
a9aa9b8
🤖 fix: use process substitution to avoid quoting issues
ibetitsmike Dec 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion docs/system-prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,5 +62,4 @@ You are in a git worktree at ${workspacePath}
}
```


{/* END SYSTEM_PROMPT_DOCS */}
54 changes: 42 additions & 12 deletions src/node/runtime/LocalBaseRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import type {
} from "./Runtime";
import { RuntimeError as RuntimeErrorClass } from "./Runtime";
import { NON_INTERACTIVE_ENV_VARS } from "@/common/constants/env";
import { getBashPath } from "@/node/utils/main/bashPath";
import { getPreferredSpawnConfig } from "@/node/utils/main/bashPath";
import { EXIT_CODE_ABORTED, EXIT_CODE_TIMEOUT } from "@/common/constants/exitCodes";
import { DisposableProcess } from "@/node/utils/disposableExec";
import { expandTilde } from "./tildeExpansion";
Expand Down Expand Up @@ -67,18 +67,27 @@ export abstract class LocalBaseRuntime implements Runtime {
);
}

// Get spawn config for the preferred bash runtime
// This handles Git for Windows, WSL, and Unix/macOS automatically
// For WSL, paths in the command and cwd are translated to /mnt/... format
const {
command: bashCommand,
args: bashArgs,
cwd: spawnCwd,
stdin: stdinContent,
} = getPreferredSpawnConfig(command, cwd);

// If niceness is specified on Unix/Linux, spawn nice directly to avoid escaping issues
// Windows doesn't have nice command, so just spawn bash directly
const isWindows = process.platform === "win32";
const bashPath = getBashPath();
const spawnCommand = options.niceness !== undefined && !isWindows ? "nice" : bashPath;
const spawnCommand = options.niceness !== undefined && !isWindows ? "nice" : bashCommand;
const spawnArgs =
options.niceness !== undefined && !isWindows
? ["-n", options.niceness.toString(), bashPath, "-c", command]
: ["-c", command];
? ["-n", options.niceness.toString(), bashCommand, ...bashArgs]
: bashArgs;

const childProcess = spawn(spawnCommand, spawnArgs, {
cwd,
cwd: spawnCwd,
env: {
...process.env,
...(options.env ?? {}),
Expand All @@ -95,6 +104,12 @@ export abstract class LocalBaseRuntime implements Runtime {
windowsHide: true,
});

// Write stdin content if provided (used for WSL via PowerShell to avoid escaping issues)
if (stdinContent) {
childProcess.stdin?.write(stdinContent);
childProcess.stdin?.end();
}

// Wrap in DisposableProcess for automatic cleanup
const disposable = new DisposableProcess(childProcess);

Expand Down Expand Up @@ -367,10 +382,19 @@ export abstract class LocalBaseRuntime implements Runtime {
const loggers = createLineBufferedLoggers(initLogger);

return new Promise<void>((resolve) => {
const bashPath = getBashPath();
const proc = spawn(bashPath, ["-c", `"${hookPath}"`], {
cwd: workspacePath,
stdio: ["ignore", "pipe", "pipe"],
// Get spawn config for the preferred bash runtime
// For WSL, the hook path and cwd are translated to /mnt/... format
const {
command: bashCommand,
args: bashArgs,
cwd: spawnCwd,
stdin: stdinContent,
} = getPreferredSpawnConfig(`"${hookPath}"`, workspacePath);

const proc = spawn(bashCommand, bashArgs, {
cwd: spawnCwd,
// Use pipe for stdin if we need to send input (for WSL via PowerShell)
stdio: [stdinContent ? "pipe" : "ignore", "pipe", "pipe"],
env: {
...process.env,
...getInitHookEnv(projectPath, runtimeType),
Expand All @@ -379,11 +403,17 @@ export abstract class LocalBaseRuntime implements Runtime {
windowsHide: true,
});

proc.stdout.on("data", (data: Buffer) => {
// Write stdin content if provided (used for WSL via PowerShell to avoid escaping issues)
if (stdinContent && proc.stdin) {
proc.stdin.write(stdinContent);
proc.stdin.end();
}

proc.stdout?.on("data", (data: Buffer) => {
loggers.stdout.append(data.toString());
});

proc.stderr.on("data", (data: Buffer) => {
proc.stderr?.on("data", (data: Buffer) => {
loggers.stderr.append(data.toString());
});

Expand Down
30 changes: 23 additions & 7 deletions src/node/services/bashExecutionService.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { spawn } from "child_process";
import type { ChildProcess } from "child_process";
import { log } from "./log";
import { getBashPath } from "@/node/utils/main/bashPath";
import { getPreferredSpawnConfig } from "@/node/utils/main/bashPath";

/**
* Configuration for bash execution
Expand Down Expand Up @@ -121,19 +121,29 @@ export class BashExecutionService {
`BashExecutionService: Script: ${script.substring(0, 100)}${script.length > 100 ? "..." : ""}`
);

// Get spawn config for the preferred bash runtime
// This handles Git for Windows, WSL, and Unix/macOS automatically
// For WSL, paths in the script and cwd are translated to /mnt/... format
const {
command: bashCommand,
args: bashArgs,
cwd: spawnCwd,
stdin: stdinContent,
} = getPreferredSpawnConfig(script, config.cwd);

// Windows doesn't have nice command, so just spawn bash directly
const isWindows = process.platform === "win32";
const bashPath = getBashPath();
const spawnCommand = config.niceness !== undefined && !isWindows ? "nice" : bashPath;
const spawnCommand = config.niceness !== undefined && !isWindows ? "nice" : bashCommand;
const spawnArgs =
config.niceness !== undefined && !isWindows
? ["-n", config.niceness.toString(), bashPath, "-c", script]
: ["-c", script];
? ["-n", config.niceness.toString(), bashCommand, ...bashArgs]
: bashArgs;

const child = spawn(spawnCommand, spawnArgs, {
cwd: config.cwd,
cwd: spawnCwd,
env: this.createBashEnvironment(config.secrets),
stdio: ["ignore", "pipe", "pipe"],
// Use pipe for stdin if we need to send input (for WSL via PowerShell)
stdio: [stdinContent ? "pipe" : "ignore", "pipe", "pipe"],
// Spawn as detached process group leader to prevent zombie processes
// When bash spawns background processes, detached:true allows killing
// the entire group via process.kill(-pid)
Expand All @@ -142,6 +152,12 @@ export class BashExecutionService {
windowsHide: true,
});

// Write stdin content if provided (used for WSL via PowerShell to avoid escaping issues)
if (stdinContent && child.stdin) {
child.stdin.write(stdinContent);
child.stdin.end();
}

log.debug(`BashExecutionService: Spawned process with PID ${child.pid ?? "unknown"}`);

// Line-by-line streaming with incremental buffers
Expand Down
23 changes: 21 additions & 2 deletions src/node/utils/disposableExec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { exec } from "child_process";
import { spawn } from "child_process";
import type { ChildProcess } from "child_process";
import { getPreferredSpawnConfig } from "@/node/utils/main/bashPath";

/**
* Disposable wrapper for child processes that ensures immediate cleanup.
Expand Down Expand Up @@ -117,12 +118,30 @@ class DisposableExec implements Disposable {
* Execute command with automatic cleanup via `using` declaration.
* Prevents zombie processes by ensuring child is reaped even on error.
*
* Commands are always wrapped in `bash -c` for consistent behavior across platforms.
* On Windows, this uses the detected bash runtime (Git for Windows or WSL).
* For WSL, Windows paths in the command are automatically translated.
*
* @example
* using proc = execAsync("git status");
* const { stdout } = await proc.result;
*/
export function execAsync(command: string): DisposableExec {
const child = exec(command);
// Wrap command in bash -c for consistent cross-platform behavior
// For WSL, this also translates Windows paths to /mnt/... format
const { command: bashCmd, args, stdin } = getPreferredSpawnConfig(command);
const child = spawn(bashCmd, args, {
// Use pipe for stdin if we need to send input (for WSL via PowerShell)
stdio: [stdin ? "pipe" : "ignore", "pipe", "pipe"],
// Prevent console window from appearing on Windows
windowsHide: true,
});

// Write stdin content if provided (used for WSL via PowerShell to avoid escaping issues)
if (stdin && child.stdin) {
child.stdin.write(stdin);
child.stdin.end();
}
const promise = new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
let stdout = "";
let stderr = "";
Expand Down
Loading
Loading