Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
21 changes: 18 additions & 3 deletions packages/coding-agent/docs/windows.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,24 @@

Step uses Git Bash by default on Windows. Checked locations (in order):

1. Custom path from `~/.stepcode/agent/settings.json`
2. Git Bash (`C:\Program Files\Git\bin\bash.exe`)
3. `bash.exe` on PATH (Cygwin, MSYS2, WSL)
1. Explicit `shellPath` (including an explicitly selected WSL launcher)
2. Git Bash in Program Files, Program Files (x86), or `%LOCALAPPDATA%\Programs\Git`
3. Git Bash located relative to each `git.exe` on PATH, including custom and portable installations
4. Native `bash.exe` on PATH (for example Cygwin or MSYS2)

PATH discovery does not invoke `where.exe`. It checks all absolute PATH entries,
resolves executable symlinks, and continues past Git shims with no adjacent Bash.
A wrapper that hides a Git installation entirely requires an explicit `shellPath`.

The Windows WSL `bash.exe` launcher is **not selected automatically**. It runs
Linux commands with a different filesystem and toolchain. If only WSL is found,
Step reports how to install/configure native Bash or run Step inside WSL.
An explicit `shellPath` to the WSL launcher remains supported.

For Git Bash, Step adds that installation's `bin` and `usr/bin` to the child
process PATH so standard tools work alongside Windows Node/npm. It does not
change the system PATH or source user login scripts. Bash commands are not
silently run through PowerShell; use the separate PowerShell tool for that dialect.

For most users, [Git for Windows](https://git-scm.com/download/win) is sufficient.

Expand Down
3 changes: 2 additions & 1 deletion packages/coding-agent/src/core/resolve-config-value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/

import { execSync, spawnSync } from "child_process";
import { getShellConfig } from "../utils/shell.ts";
import { getShellConfig, withGitBashPath } from "../utils/shell.ts";

// Cache for shell command results (persists for process lifetime)
const commandResultCache = new Map<string, string | undefined>();
Expand Down Expand Up @@ -157,6 +157,7 @@ function executeWithConfiguredShell(command: string): { executed: boolean; value
const result = spawnSync(shell, commandFromStdin ? args : [...args, command], {
encoding: "utf-8",
input: commandFromStdin ? command : undefined,
env: withGitBashPath(shell, process.env),
timeout: 10000,
stdio: [commandFromStdin ? "pipe" : "ignore", "pipe", "ignore"],
shell: false,
Expand Down
31 changes: 26 additions & 5 deletions packages/coding-agent/src/step/tool-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

import type { ChildProcess } from "node:child_process";
import { randomUUID } from "node:crypto";
import { closeSync, openSync } from "node:fs";
import { closeSync, createWriteStream, openSync } from "node:fs";
import { mkdir as fsMkdir, readdir as fsReaddir, stat as fsStat, readFile, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
Expand Down Expand Up @@ -52,6 +52,7 @@ import { resolvePath } from "../utils/paths.ts";
import {
getShellConfig,
getShellEnv,
killProcessTree,
spawnShellChild,
trackDetachedChildPid,
untrackDetachedChildPid,
Expand Down Expand Up @@ -1374,17 +1375,37 @@ async function startBackgroundCommand(
const logPath = path.join(os.tmpdir(), `step-run-bg-${process.pid}-${randomUUID().slice(0, 8)}.log`);
const logFd = openSync(logPath, "a", 0o600);
let child: ChildProcess;
const pipeLog = process.platform === "win32";
try {
child = spawnShellChild(shellConfig, command, {
cwd: commandCwd,
env: getShellEnv(options.agentDir),
stdout: logFd,
stderr: logFd,
stdout: pipeLog ? "pipe" : logFd,
stderr: pipeLog ? "pipe" : logFd,
});
} finally {
// The child holds its own duplicated descriptors after spawn.
} catch (error) {
closeSync(logFd);
throw error;
}
if (pipeLog) {
// MSYS/Git Bash can fail when Windows file descriptors are inherited directly.
// Drain pipes into a parent-owned stream; preserve backpressure and close only
// after both child output streams have finished.
const log = createWriteStream(logPath, { fd: logFd, autoClose: true });
log.on("error", () => {
if (child.pid !== undefined) killProcessTree(child.pid);
child.stdout?.destroy();
child.stderr?.destroy();
});
child.stdout?.pipe(log, { end: false });
child.stderr?.pipe(log, { end: false });
child.once("close", () => log.end());
child.once("error", () => log.destroy());
} else {
// Unix children hold their own duplicated descriptors after spawn.
closeSync(logFd);
}

await new Promise<void>((resolve, reject) => {
child.once("spawn", () => resolve());
child.once("error", (error) => reject(error));
Expand Down
165 changes: 102 additions & 63 deletions packages/coding-agent/src/utils/shell.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { delimiter, join } from "node:path";
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
import { delimiter, join, win32 } from "node:path";
import { type ChildProcess, spawn, spawnSync } from "child_process";
import { getBinDir } from "../config.ts";
import { isStepStorageContext, resolveStepAgentDir } from "../step/environment.ts";
Expand All @@ -10,100 +10,116 @@ export interface ShellConfig {
commandTransport?: "argv" | "stdin";
}

/**
* Find bash executable on PATH (cross-platform)
*/
/** Identify Windows' WSL launcher, including non-default Windows directories. */
function isLegacyWslBashPath(path: string): boolean {
const normalized = path.replace(/\//g, "\\").toLowerCase();
return /^[a-z]:\\windows\\(?:system32|sysnative)\\bash\.exe$/.test(normalized);
const normalized = win32.normalize(path).toLowerCase();
const windows = process.env.SystemRoot ?? process.env.WINDIR ?? "C:\\Windows";
return ["System32", "Sysnative", "SysWOW64"].some(
(directory) => normalized === win32.join(windows, directory, "bash.exe").toLowerCase(),
);
}

function getBashShellConfig(shell: string): ShellConfig {
return isLegacyWslBashPath(shell) ? { shell, args: ["-s"], commandTransport: "stdin" } : { shell, args: ["-c"] };
}

function findExecutableOnPath(executable: string): string | null {
if (process.platform === "win32") {
// Windows: Use 'where' and verify file exists (where can return non-existent paths)
try {
const result = spawnSync("where", [executable], {
encoding: "utf-8",
timeout: 5000,
windowsHide: true,
});
if (result.status === 0 && result.stdout) {
const firstMatch = result.stdout.trim().split(/\r?\n/)[0];
if (firstMatch && existsSync(firstMatch)) {
return firstMatch;
}
}
} catch {
// Ignore errors
function isFile(path: string): boolean {
try {
return statSync(path).isFile();
} catch {
return false;
}
}

function resolveExecutable(path: string): string {
try {
return realpathSync(path);
} catch {
return path;
}
}

/** Search every absolute PATH entry lazily, without starting where.exe or searching cwd. */
function* findWindowsExecutables(executable: string): Generator<string> {
const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path");
const seen = new Set<string>();
for (const entry of (pathKey ? (process.env[pathKey] ?? "") : "").split(";")) {
const directory = entry.trim().replace(/^"(.*)"$/, "$1");
if (!win32.isAbsolute(directory)) continue;
const candidate = win32.join(directory, executable);
if (!isFile(candidate)) continue;
const resolved = resolveExecutable(candidate);
const key = resolved.toLowerCase();
if (!seen.has(key)) {
seen.add(key);
yield resolved;
}
return null;
}
}

// Unix: Use 'which' and trust its output (handles Termux and special filesystems)
function findExecutableOnPath(executable: string): string | null {
if (process.platform === "win32") return findWindowsExecutables(executable).next().value ?? null;
// Unix: preserve which behavior for Termux and special filesystems.
try {
const result = spawnSync("which", [executable], { encoding: "utf-8", timeout: 5000 });
if (result.status === 0 && result.stdout) {
const firstMatch = result.stdout.trim().split(/\r?\n/)[0];
if (firstMatch) {
return firstMatch;
}
}
if (result.status === 0 && result.stdout) return result.stdout.trim().split(/\r?\n/)[0] || null;
} catch {
// Ignore errors
/* Ignore errors. */
}
return null;
}

function gitRootFromExecutable(executable: string): string {
let root = win32.dirname(win32.dirname(executable));
if (/^mingw(?:32|64)$/i.test(win32.basename(root))) root = win32.dirname(root);
return root;
}

/**
* Yield Windows Bash candidates in priority order. Lazy so the common case (Git in a
* known location) returns after a few stats instead of scanning PATH on every command.
*/
function* windowsBashCandidates(): Generator<string> {
for (const directory of [process.env.ProgramFiles, process.env["ProgramFiles(x86)"]]) {
if (directory) yield win32.join(directory, "Git", "bin", "bash.exe");
}
if (process.env.LOCALAPPDATA) yield win32.join(process.env.LOCALAPPDATA, "Programs", "Git", "bin", "bash.exe");
for (const git of findWindowsExecutables("git.exe")) {
yield win32.join(gitRootFromExecutable(git), "bin", "bash.exe");
}
yield* findWindowsExecutables("bash.exe");
}

/**
* Resolve shell configuration based on platform and an optional explicit shell path.
* Resolution order:
* 1. User-specified shellPath
* 2. On Windows: Git Bash in known locations, then bash on PATH
* 2. On Windows: known Git locations, Git installations on PATH, then native Bash on PATH
* 3. On Unix: /bin/bash, then bash on PATH, then fallback to sh
*/
export function getShellConfig(customShellPath?: string): ShellConfig {
// 1. Check user-specified shell path
if (customShellPath) {
if (existsSync(customShellPath)) {
return getBashShellConfig(customShellPath);
// Preserve explicitly selected legacy WSL launchers before canonicalization.
// Their stdin transport is part of the existing shellPath contract.
if (isLegacyWslBashPath(customShellPath)) return getBashShellConfig(customShellPath);
return getBashShellConfig(process.platform === "win32" ? resolveExecutable(customShellPath) : customShellPath);
}
throw new Error(`Custom shell path not found: ${customShellPath}`);
}

if (process.platform === "win32") {
// 2. Try Git Bash in known locations
const paths: string[] = [];
const programFiles = process.env.ProgramFiles;
if (programFiles) {
paths.push(`${programFiles}\\Git\\bin\\bash.exe`);
}
const programFilesX86 = process.env["ProgramFiles(x86)"];
if (programFilesX86) {
paths.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
for (const candidate of windowsBashCandidates()) {
if (!isFile(candidate)) continue;
const resolved = resolveExecutable(candidate);
if (!isLegacyWslBashPath(resolved)) return getBashShellConfig(resolved);
}

for (const path of paths) {
if (existsSync(path)) {
return getBashShellConfig(path);
}
}

// 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.)
const bashOnPath = findExecutableOnPath("bash.exe");
if (bashOnPath) {
return getBashShellConfig(bashOnPath);
}

throw new Error(
`No bash shell found. Options:\n` +
` 1. Install Git for Windows: https://git-scm.com/download/win\n` +
` 2. Add your bash to PATH (Cygwin, MSYS2, etc.)\n` +
" 3. Set shellPath in config.toml\n\n" +
`Searched Git Bash in:\n${paths.map((p) => ` ${p}`).join("\n")}`,
"No native Bash shell found on Windows. Install Git for Windows, add Git or a native Bash to PATH, " +
"or set shellPath to your Bash executable. WSL bash.exe is not selected automatically: " +
"it runs Linux commands with a different toolchain. To use WSL, start Step inside WSL " +
"or explicitly configure shellPath to the WSL bash.exe launcher.",
);
}

Expand Down Expand Up @@ -151,6 +167,29 @@ export function getShellEnv(agentDir?: string): NodeJS.ProcessEnv {
};
}

/** Add Git's own tools for non-login Bash without loading user startup scripts. */
export function withGitBashPath(shell: string, env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
if (process.platform !== "win32" || win32.basename(shell).toLowerCase() !== "bash.exe") return env;
let root = win32.dirname(win32.dirname(shell));
if (win32.basename(root).toLowerCase() === "usr") root = win32.dirname(root);
const usrBin = win32.join(root, "usr", "bin");
if (!isFile(win32.join(root, "cmd", "git.exe")) || !isFile(win32.join(usrBin, "bash.exe"))) return env;
const pathKey = Object.keys(env).find((key) => key.toLowerCase() === "path") ?? "PATH";
const entries = [win32.join(root, "bin"), usrBin, ...(env[pathKey] ?? "").split(";")];
const seen = new Set<string>();
return {
...env,
[pathKey]: entries
.filter((entry) => {
const key = win32.normalize(entry).toLowerCase();
if (!entry || seen.has(key)) return false;
seen.add(key);
return true;
})
.join(";"),
};
}

/**
* Spawn a shell child from a resolved ShellConfig, centralizing the command
* transport (argv vs stdin) so call sites do not re-derive it. Callers own the
Expand All @@ -166,7 +205,7 @@ export function spawnShellChild(
const child = spawn(shellConfig.shell, commandFromStdin ? shellConfig.args : [...shellConfig.args, command], {
cwd: options.cwd,
detached: process.platform !== "win32",
env: options.env,
env: withGitBashPath(shellConfig.shell, options.env),
stdio: [commandFromStdin ? "pipe" : "ignore", options.stdout, options.stderr],
windowsHide: true,
});
Expand Down
16 changes: 16 additions & 0 deletions packages/coding-agent/test/resolve-config-value.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,4 +118,20 @@ describe("resolveConfigValue", () => {
if (platformDescriptor) Object.defineProperty(process, "platform", platformDescriptor);
}
});

test("runs Windows commands with the Git Bash child environment", () => {
if (process.platform === "win32") return;
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
vi.spyOn(shellModule, "getShellConfig").mockReturnValue({ shell: "/bin/bash", args: ["-c"] });
const withGitBashPath = vi
.spyOn(shellModule, "withGitBashPath")
.mockImplementation((_shell, env) => ({ ...env, STEP_TEST_GIT_BASH_ENV: "git-bash" }));
try {
Object.defineProperty(process, "platform", { configurable: true, value: "win32" });
expect(resolveConfigValueUncached("!echo $STEP_TEST_GIT_BASH_ENV")).toBe("git-bash");
expect(withGitBashPath).toHaveBeenCalledWith("/bin/bash", process.env);
} finally {
if (platformDescriptor) Object.defineProperty(process, "platform", platformDescriptor);
}
});
});
Loading
Loading