Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 */}
74 changes: 62 additions & 12 deletions src/node/runtime/LocalBaseRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@ 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";
import { log } from "@/node/services/log";
import {
checkInitHookExists,
getInitHookPath,
Expand Down Expand Up @@ -67,18 +68,39 @@ 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,
} = getPreferredSpawnConfig(command, cwd);

// Debug logging for Windows WSL issues (skip noisy git status commands)
const isGitStatusCmd = command.includes("git status") || command.includes("show-branch") || command.includes("PRIMARY_BRANCH");
if (!isGitStatusCmd) {
log.info(`[LocalBaseRuntime.exec] Original command: ${command.substring(0, 100)}${command.length > 100 ? "..." : ""}`);
log.info(`[LocalBaseRuntime.exec] Spawn command: ${bashCommand}`);
log.info(`[LocalBaseRuntime.exec] Spawn args: ${JSON.stringify(bashArgs).substring(0, 200)}...`);
}

// 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;

// On Windows with PowerShell wrapper, detached:true creates a separate console
// which interferes with output capture. Only use detached on non-Windows.
// On Windows, PowerShell's -WindowStyle Hidden handles console hiding.
const useDetached = !isWindows;

const childProcess = spawn(spawnCommand, spawnArgs, {
cwd,
cwd: spawnCwd,
env: {
...process.env,
...(options.env ?? {}),
Expand All @@ -90,7 +112,8 @@ export abstract class LocalBaseRuntime implements Runtime {
// the entire process group (including all backgrounded children) via process.kill(-pid).
// NOTE: detached:true does NOT cause bash to wait for background jobs when using 'exit' event
// instead of 'close' event. The 'exit' event fires when bash exits, ignoring background children.
detached: true,
// WINDOWS NOTE: detached:true causes issues with PowerShell wrapper output capture.
detached: useDetached,
// Prevent console window from appearing on Windows (WSL bash spawns steal focus otherwise)
windowsHide: true,
});
Expand All @@ -110,6 +133,18 @@ export abstract class LocalBaseRuntime implements Runtime {
let timedOut = false;
let aborted = false;

// Debug: log raw stdout/stderr from the child process (only for non-git-status commands)
let debugStdout = "";
let debugStderr = "";
if (!isGitStatusCmd) {
childProcess.stdout?.on("data", (chunk: Buffer) => {
debugStdout += chunk.toString();
});
childProcess.stderr?.on("data", (chunk: Buffer) => {
debugStderr += chunk.toString();
});
}

// Create promises for exit code and duration
// Uses special exit codes (EXIT_CODE_ABORTED, EXIT_CODE_TIMEOUT) for expected error conditions
const exitCode = new Promise<number>((resolve, reject) => {
Expand All @@ -118,6 +153,14 @@ export abstract class LocalBaseRuntime implements Runtime {
// which causes hangs when users spawn background processes like servers.
// The 'exit' event fires when the main bash process exits, which is what we want.
childProcess.on("exit", (code) => {
if (!isGitStatusCmd) {
log.info(`[LocalBaseRuntime.exec] Process exited with code: ${code}`);
log.info(`[LocalBaseRuntime.exec] stdout length: ${debugStdout.length}`);
log.info(`[LocalBaseRuntime.exec] stdout: ${debugStdout.substring(0, 500)}${debugStdout.length > 500 ? "..." : ""}`);
if (debugStderr) {
log.info(`[LocalBaseRuntime.exec] stderr: ${debugStderr.substring(0, 500)}${debugStderr.length > 500 ? "..." : ""}`);
}
}
// Clean up any background processes (process group cleanup)
// This prevents zombie processes when scripts spawn background tasks
if (childProcess.pid !== undefined) {
Expand Down Expand Up @@ -367,9 +410,16 @@ 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,
// 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,
} = getPreferredSpawnConfig(`"${hookPath}"`, workspacePath);

const proc = spawn(bashCommand, bashArgs, {
cwd: spawnCwd,
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
Expand All @@ -379,11 +429,11 @@ export abstract class LocalBaseRuntime implements Runtime {
windowsHide: true,
});

proc.stdout.on("data", (data: Buffer) => {
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
20 changes: 14 additions & 6 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,17 +121,25 @@ 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,
} = 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"],
// Spawn as detached process group leader to prevent zombie processes
Expand Down
33 changes: 31 additions & 2 deletions src/node/utils/disposableExec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { exec } from "child_process";
import { spawn } from "child_process";
import type { ChildProcess } from "child_process";
import { getPreferredSpawnConfig } from "@/node/utils/main/bashPath";
import { log } from "@/node/services/log";

/**
* Disposable wrapper for child processes that ensures immediate cleanup.
Expand Down Expand Up @@ -117,12 +119,32 @@ 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 } = getPreferredSpawnConfig(command);

// Debug logging for Windows WSL issues
log.info(`[execAsync] Original command: ${command}`);
log.info(`[execAsync] Spawn command: ${bashCmd}`);
log.info(`[execAsync] Spawn args: ${JSON.stringify(args)}`);

const child = spawn(bashCmd, args, {
stdio: ["ignore", "pipe", "pipe"],
// Prevent console window from appearing on Windows
windowsHide: true,
});

log.info(`[execAsync] Spawned process PID: ${child.pid}`);

const promise = new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
let stdout = "";
let stderr = "";
Expand All @@ -141,9 +163,16 @@ export function execAsync(command: string): DisposableExec {
child.on("exit", (code, signal) => {
exitCode = code;
exitSignal = signal;
log.info(`[execAsync] Process exited with code: ${code}, signal: ${signal}`);
});

child.on("close", () => {
log.info(`[execAsync] Process closed. stdout length: ${stdout.length}, stderr length: ${stderr.length}`);
log.info(`[execAsync] stdout: ${stdout.substring(0, 500)}${stdout.length > 500 ? "..." : ""}`);
if (stderr) {
log.info(`[execAsync] stderr: ${stderr.substring(0, 500)}${stderr.length > 500 ? "..." : ""}`);
}

// Only resolve if process exited cleanly (code 0, no signal)
if (exitCode === 0 && exitSignal === null) {
resolve({ stdout, stderr });
Expand Down
Loading
Loading