From 5a17a30e95bad9b805ddd0a405aa238af747aa8b Mon Sep 17 00:00:00 2001 From: xuyunfang Date: Mon, 12 Oct 2026 11:07:33 +0800 Subject: [PATCH 1/2] fix(shell): discover native Git Bash on Windows instead of WSL On Windows, a Git for Windows install in a non-default directory only puts \cmd on PATH, so shell discovery fell through to `where bash.exe` and picked C:\Windows\System32\bash.exe, silently running commands inside WSL. - Look for Git Bash in Program Files, Program Files (x86), %LOCALAPPDATA%\Programs\Git, then relative to each git.exe on PATH, then native bash.exe on PATH. Resolve symlinks and drop the where.exe dependency. - Never select the WSL launcher automatically; an explicit shellPath to it keeps its stdin command transport. - Add the Git installation's bin and usr/bin to the Git Bash child PATH only. - On Windows, pipe background command output into the log file instead of handing the file descriptor to MSYS, which exited 1 with an empty log. - Generate candidates lazily so a standard install returns without scanning PATH on every command or permission check. Fixes #136 Co-authored-by: ZXJC-niusile <2632590648@qq.com> --- packages/coding-agent/docs/windows.md | 21 +- .../coding-agent/src/step/tool-profile.ts | 31 ++- packages/coding-agent/src/utils/shell.ts | 165 ++++++++------ .../coding-agent/test/shell-discovery.test.ts | 204 ++++++++++++++++++ .../test/step-tool-profile.test.ts | 3 +- 5 files changed, 352 insertions(+), 72 deletions(-) create mode 100644 packages/coding-agent/test/shell-discovery.test.ts diff --git a/packages/coding-agent/docs/windows.md b/packages/coding-agent/docs/windows.md index bab2fbf9..fa8e518e 100644 --- a/packages/coding-agent/docs/windows.md +++ b/packages/coding-agent/docs/windows.md @@ -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. diff --git a/packages/coding-agent/src/step/tool-profile.ts b/packages/coding-agent/src/step/tool-profile.ts index 2e06cc1b..0600b72c 100644 --- a/packages/coding-agent/src/step/tool-profile.ts +++ b/packages/coding-agent/src/step/tool-profile.ts @@ -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"; @@ -52,6 +52,7 @@ import { resolvePath } from "../utils/paths.ts"; import { getShellConfig, getShellEnv, + killProcessTree, spawnShellChild, trackDetachedChildPid, untrackDetachedChildPid, @@ -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((resolve, reject) => { child.once("spawn", () => resolve()); child.once("error", (error) => reject(error)); diff --git a/packages/coding-agent/src/utils/shell.ts b/packages/coding-agent/src/utils/shell.ts index 7a27800a..b0436b4b 100644 --- a/packages/coding-agent/src/utils/shell.ts +++ b/packages/coding-agent/src/utils/shell.ts @@ -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"; @@ -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 { + const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path"); + const seen = new Set(); + 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 { + 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.", ); } @@ -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. */ +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(); + 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 @@ -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, }); diff --git a/packages/coding-agent/test/shell-discovery.test.ts b/packages/coding-agent/test/shell-discovery.test.ts new file mode 100644 index 00000000..9009612c --- /dev/null +++ b/packages/coding-agent/test/shell-discovery.test.ts @@ -0,0 +1,204 @@ +import { existsSync, realpathSync, statSync } from "node:fs"; +import { spawn, spawnSync } from "child_process"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("node:fs", () => ({ existsSync: vi.fn(), realpathSync: vi.fn(), statSync: vi.fn() })); +vi.mock("child_process", () => ({ spawn: vi.fn(), spawnSync: vi.fn() })); +vi.mock("../src/config.ts", () => ({ getBinDir: () => "/agent/bin" })); +vi.mock("../src/step/environment.ts", () => ({ + isStepStorageContext: () => false, + resolveStepAgentDir: () => "/agent", +})); + +import { getPowerShellConfig, getShellConfig, spawnShellChild } from "../src/utils/shell.ts"; + +const normalize = (path: unknown) => String(path).replaceAll("\\", "/").toLowerCase(); +const wsl = "C:/Windows/System32/bash.exe"; +let files: Set; +let links: Map; +function add(...paths: string[]) { + for (const path of paths) files.add(normalize(path)); +} +function path(value: string) { + vi.stubEnv("PATH", value); +} +function expectShell(expected: string) { + expect(normalize(getShellConfig().shell)).toBe(normalize(expected)); +} + +describe("Windows shell discovery", () => { + beforeEach(() => { + vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + vi.stubEnv("ProgramFiles", "C:/Program Files"); + vi.stubEnv("ProgramFiles(x86)", "C:/Program Files (x86)"); + vi.stubEnv("LOCALAPPDATA", "C:/Users/test/AppData/Local"); + vi.stubEnv("SystemRoot", "C:/Windows"); + files = new Set(); + links = new Map(); + path("C:/Windows/System32"); + add(wsl); + vi.mocked(existsSync).mockImplementation((p) => files.has(normalize(p))); + vi.mocked(statSync).mockImplementation((p) => { + if (!files.has(normalize(p))) throw new Error("ENOENT"); + return { isFile: () => true } as ReturnType; + }); + vi.mocked(realpathSync).mockImplementation((p) => links.get(normalize(p)) ?? String(p)); + vi.mocked(spawnSync).mockImplementation(() => { + throw new Error("Executable lookup must not spawn where"); + }); + }); + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it.each([ + ["D:/Developer Tools/Git", "cmd"], + ["E:/Portable Git", "bin"], + ["F:/Git", "mingw64/bin"], + ["G:/Git32", "mingw32/bin"], + ])("finds custom Git at %s through %s", (root, directory) => { + add(`${root}/${directory}/git.exe`, `${root}/bin/bash.exe`); + path(`C:/Windows/System32;${root}/${directory}`); + expectShell(`${root}/bin/bash.exe`); + expect(spawnSync).not.toHaveBeenCalled(); + }); + it("preserves explicit WSL selection", () => { + expect(getShellConfig(wsl)).toEqual({ shell: wsl, args: ["-s"], commandTransport: "stdin" }); + }); + it("preserves explicit native shell priority", () => { + add("Z:/custom/bash.exe", "C:/Program Files/Git/bin/bash.exe"); + expect(getShellConfig("Z:/custom/bash.exe").shell).toBe("Z:/custom/bash.exe"); + }); + it("rejects a missing explicit path without silently falling back", () => { + add("C:/Program Files/Git/bin/bash.exe"); + expect(() => getShellConfig("Z:/missing/bash.exe")).toThrow("Custom shell path not found"); + }); + it.each(["C:/Program Files/Git", "C:/Program Files (x86)/Git", "C:/Users/test/AppData/Local/Programs/Git"])( + "finds known installation %s without PATH", + (root) => { + path(""); + add(`${root}/bin/bash.exe`); + expectShell(`${root}/bin/bash.exe`); + }, + ); + it("returns a known installation without scanning PATH", () => { + path("C:/Windows/System32;D:/slow-share/tools;E:/more/tools"); + add("C:/Program Files/Git/bin/bash.exe"); + vi.mocked(statSync).mockClear(); + expectShell("C:/Program Files/Git/bin/bash.exe"); + const probed = vi.mocked(statSync).mock.calls.map(([p]) => normalize(p)); + expect(probed).toEqual(["c:/program files/git/bin/bash.exe"]); + }); + it("stops scanning PATH once a Git installation with Bash is found", () => { + path("D:/Git/cmd;E:/later/tools;F:/even/later"); + add("D:/Git/cmd/git.exe", "D:/Git/bin/bash.exe"); + vi.mocked(statSync).mockClear(); + expectShell("D:/Git/bin/bash.exe"); + const probed = vi.mocked(statSync).mock.calls.map(([p]) => normalize(p)); + expect(probed.some((p) => p.startsWith("e:/") || p.startsWith("f:/"))).toBe(false); + }); + it("continues past a Git shim with no adjacent Bash", () => { + path("C:/shims;E:/real Git/cmd;C:/Windows/System32"); + add("C:/shims/git.exe", "E:/real Git/cmd/git.exe", "E:/real Git/bin/bash.exe"); + expectShell("E:/real Git/bin/bash.exe"); + }); + it("resolves a symlinked Git executable", () => { + path("C:/links"); + add("C:/links/git.exe", "E:/Git/bin/bash.exe"); + links.set(normalize("C:/links/git.exe"), "E:/Git/cmd/git.exe"); + expectShell("E:/Git/bin/bash.exe"); + }); + it("skips WSL and finds native Bash later in quoted PATH", () => { + path('C:/Windows/System32;"E:/MSYS tools/usr/bin"'); + add("E:/MSYS tools/usr/bin/bash.exe"); + expectShell("E:/MSYS tools/usr/bin/bash.exe"); + }); + it("does not select a symlink to the WSL launcher", () => { + path("C:/links"); + add("C:/links/bash.exe"); + links.set(normalize("C:/links/bash.exe"), wsl); + expect(() => getShellConfig()).toThrow("WSL bash.exe is not selected automatically"); + }); + it.each(["System32", "Sysnative", "SysWOW64"])( + "rejects automatic WSL from %s with a custom Windows root", + (directory) => { + vi.stubEnv("SystemRoot", "D:/WINNT"); + path(`D:/WINNT/${directory}`); + add(`D:/WINNT/${directory}/bash.exe`); + expect(() => getShellConfig()).toThrow("No native Bash"); + }, + ); + it("ignores empty and relative PATH entries", () => { + path(";.;relative"); + add("bash.exe", "relative/bash.exe"); + expect(() => getShellConfig()).toThrow("No native Bash"); + }); + it("ignores directories named bash.exe", () => { + path("E:/tools"); + add("E:/tools/bash.exe"); + vi.mocked(statSync).mockReturnValue({ isFile: () => false } as ReturnType); + expect(() => getShellConfig()).toThrow("No native Bash"); + }); + it("finds PowerShell without where.exe", () => { + path("C:/Windows/System32;E:/PowerShell"); + add("E:/PowerShell/pwsh.exe"); + expect(normalize(getPowerShellConfig().shell)).toBe("e:/powershell/pwsh.exe"); + }); + it("initializes Git tools only in the child environment", () => { + const root = "E:/Portable Git"; + add(`${root}/cmd/git.exe`, `${root}/usr/bin/bash.exe`); + vi.mocked(spawn).mockReturnValue({} as ReturnType); + const env = { Path: "C:/node;C:/Windows/System32", KEEP: "yes" }; + spawnShellChild({ shell: `${root}/bin/bash.exe`, args: ["-c"] }, "ls", { + cwd: "E:/project", + env, + stdout: "pipe", + stderr: "pipe", + }); + const options = vi.mocked(spawn).mock.calls.at(-1)?.[2]; + expect(normalize(options?.env?.Path)).toBe( + "e:/portable git/bin;e:/portable git/usr/bin;c:/node;c:/windows/system32", + ); + expect(env.Path).toBe("C:/node;C:/Windows/System32"); + expect(options?.env?.KEEP).toBe("yes"); + }); + it("leaves non-Git shell environments unchanged", () => { + vi.mocked(spawn).mockReturnValue({} as ReturnType); + const env = { PATH: "E:/tools" }; + spawnShellChild({ shell: "E:/msys/bash.exe", args: ["-c"] }, "echo ok", { + cwd: "E:/project", + env, + stdout: "pipe", + stderr: "pipe", + }); + expect(vi.mocked(spawn).mock.calls.at(-1)?.[2]?.env).toBe(env); + }); + it("preserves Unix Bash selection", () => { + vi.spyOn(process, "platform", "get").mockReturnValue("linux"); + add("/bin/bash"); + expect(getShellConfig()).toEqual({ shell: "/bin/bash", args: ["-c"] }); + }); + it("preserves Unix PATH lookup when /bin/bash is absent", () => { + vi.spyOn(process, "platform", "get").mockReturnValue("linux"); + vi.mocked(spawnSync).mockReturnValue({ + status: 0, + stdout: "/opt/tools/bash\n", + stderr: "", + pid: 1, + output: [], + signal: null, + }); + expect(getShellConfig()).toEqual({ shell: "/opt/tools/bash", args: ["-c"] }); + expect(spawnSync).toHaveBeenCalledWith("which", ["bash"], expect.objectContaining({ timeout: 5000 })); + }); + it("preserves Unix sh fallback when Bash lookup fails", () => { + vi.spyOn(process, "platform", "get").mockReturnValue("linux"); + vi.mocked(spawnSync).mockReturnValue({ status: 1, stdout: "", stderr: "", pid: 1, output: [], signal: null }); + expect(getShellConfig()).toEqual({ shell: "sh", args: ["-c"] }); + }); + it("preserves Unix sh fallback when executable lookup throws", () => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + expect(getShellConfig()).toEqual({ shell: "sh", args: ["-c"] }); + }); +}); diff --git a/packages/coding-agent/test/step-tool-profile.test.ts b/packages/coding-agent/test/step-tool-profile.test.ts index 22b5504c..8ed3581c 100644 --- a/packages/coding-agent/test/step-tool-profile.test.ts +++ b/packages/coding-agent/test/step-tool-profile.test.ts @@ -106,7 +106,7 @@ describe("Step tool profile", () => { const startedAt = Date.now(); const result = await run.execute( "bg-1", - { command: "echo started; sleep 0.4; echo finished", run_in_background: true }, + { command: "echo started; echo diagnostic >&2; sleep 0.4; echo finished", run_in_background: true }, undefined, undefined, undefined as never, @@ -127,6 +127,7 @@ describe("Step tool profile", () => { await expect .poll(async () => readFile(details.logPath, "utf8").catch(() => ""), { timeout: 5_000 }) .toContain("finished"); + expect(await readFile(details.logPath, "utf8")).toContain("diagnostic"); } finally { await rm(details.logPath, { force: true }); } From 75b9e26838dcc2f567cc43650028f4fecc7debde Mon Sep 17 00:00:00 2001 From: xuyunfang Date: Mon, 12 Oct 2026 11:47:14 +0800 Subject: [PATCH 2/2] fix(shell): use Git Bash child PATH for config value commands Config values written as `!command` were run with a direct spawnSync on the resolved shell, bypassing spawnShellChild, so on Windows they did not get the Git installation's bin and usr/bin on PATH. Export withGitBashPath and apply it there too so these commands see the same tools as run_command. --- .../src/core/resolve-config-value.ts | 3 ++- packages/coding-agent/src/utils/shell.ts | 2 +- .../test/resolve-config-value.test.ts | 16 ++++++++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/core/resolve-config-value.ts b/packages/coding-agent/src/core/resolve-config-value.ts index 6d75b001..8b6968fa 100644 --- a/packages/coding-agent/src/core/resolve-config-value.ts +++ b/packages/coding-agent/src/core/resolve-config-value.ts @@ -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(); @@ -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, diff --git a/packages/coding-agent/src/utils/shell.ts b/packages/coding-agent/src/utils/shell.ts index b0436b4b..43b72e10 100644 --- a/packages/coding-agent/src/utils/shell.ts +++ b/packages/coding-agent/src/utils/shell.ts @@ -168,7 +168,7 @@ export function getShellEnv(agentDir?: string): NodeJS.ProcessEnv { } /** Add Git's own tools for non-login Bash without loading user startup scripts. */ -function withGitBashPath(shell: string, env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { +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); diff --git a/packages/coding-agent/test/resolve-config-value.test.ts b/packages/coding-agent/test/resolve-config-value.test.ts index b202f1c8..d47aaae7 100644 --- a/packages/coding-agent/test/resolve-config-value.test.ts +++ b/packages/coding-agent/test/resolve-config-value.test.ts @@ -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); + } + }); });