From b384dac4b7995d3ae7d222537b7610d8c2b5747f Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:04:09 +1000 Subject: [PATCH 01/10] fix(windows): prefer PowerShell defaults for shell tools Use pwsh and powershell before Git Bash when SHELL is unset on Windows so the default shell matches native expectations more closely. Surface the active OS and shell in the bash tool definition so agents can reason about the runtime they are executing in. --- packages/opencode/src/shell/shell.ts | 15 ++++++++++++++- packages/opencode/src/tool/bash.ts | 3 +++ packages/opencode/src/tool/bash.txt | 2 ++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/shell/shell.ts b/packages/opencode/src/shell/shell.ts index e7b7cdb3e4d..ab2ec47dc08 100644 --- a/packages/opencode/src/shell/shell.ts +++ b/packages/opencode/src/shell/shell.ts @@ -36,6 +36,11 @@ export namespace Shell { } const BLACKLIST = new Set(["fish", "nu"]) + function base(file: string) { + if (process.platform === "win32") return path.win32.basename(file, ".exe") + return path.basename(file) + } + function fallback() { if (process.platform === "win32") { if (Flag.OPENCODE_GIT_BASH_PATH) return Flag.OPENCODE_GIT_BASH_PATH @@ -57,12 +62,20 @@ export namespace Shell { export const preferred = lazy(() => { const s = process.env.SHELL if (s) return s + if (process.platform === "win32") { + if (Bun.which("pwsh")) return "pwsh" + if (Bun.which("powershell")) return "powershell" + } return fallback() }) export const acceptable = lazy(() => { const s = process.env.SHELL - if (s && !BLACKLIST.has(process.platform === "win32" ? path.win32.basename(s) : path.basename(s))) return s + if (s && !BLACKLIST.has(base(s))) return s + if (process.platform === "win32") { + if (Bun.which("pwsh")) return "pwsh" + if (Bun.which("powershell")) return "powershell" + } return fallback() }) } diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 0751f789b7d..a35cacf9c40 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -54,10 +54,13 @@ const parser = lazy(async () => { // TODO: we may wanna rename this tool so it works better on other shells export const BashTool = Tool.define("bash", async () => { const shell = Shell.acceptable() + const name = process.platform === "win32" ? path.win32.basename(shell, ".exe") : path.basename(shell) log.info("bash tool using shell", { shell }) return { description: DESCRIPTION.replaceAll("${directory}", Instance.directory) + .replaceAll("${os}", process.platform) + .replaceAll("${shell}", name) .replaceAll("${maxLines}", String(Truncate.MAX_LINES)) .replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)), parameters: z.object({ diff --git a/packages/opencode/src/tool/bash.txt b/packages/opencode/src/tool/bash.txt index baafb00810a..a5626cbac8c 100644 --- a/packages/opencode/src/tool/bash.txt +++ b/packages/opencode/src/tool/bash.txt @@ -1,5 +1,7 @@ Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures. +Be aware: OS: ${os}, Shell: ${shell} + All commands run in ${directory} by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd && ` patterns - use `workdir` instead. IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead. From 45adf549048eb9775cf9b82e3776a34aca4271ec Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:10:23 +1000 Subject: [PATCH 02/10] refactor(windows): inline shell blacklist check Remove the one-off helper used for shell name normalization and keep the Windows blacklist check inline. This keeps the shell selection logic simpler without changing behavior. --- packages/opencode/src/shell/shell.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/opencode/src/shell/shell.ts b/packages/opencode/src/shell/shell.ts index ab2ec47dc08..caec2c0b872 100644 --- a/packages/opencode/src/shell/shell.ts +++ b/packages/opencode/src/shell/shell.ts @@ -36,11 +36,6 @@ export namespace Shell { } const BLACKLIST = new Set(["fish", "nu"]) - function base(file: string) { - if (process.platform === "win32") return path.win32.basename(file, ".exe") - return path.basename(file) - } - function fallback() { if (process.platform === "win32") { if (Flag.OPENCODE_GIT_BASH_PATH) return Flag.OPENCODE_GIT_BASH_PATH @@ -71,7 +66,7 @@ export namespace Shell { export const acceptable = lazy(() => { const s = process.env.SHELL - if (s && !BLACKLIST.has(base(s))) return s + if (s && !BLACKLIST.has(process.platform === "win32" ? path.win32.basename(s, ".exe") : path.basename(s))) return s if (process.platform === "win32") { if (Bun.which("pwsh")) return "pwsh" if (Bun.which("powershell")) return "powershell" From 515687074e7ab352981947576ab95646cf221b4d Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:38:03 +1000 Subject: [PATCH 03/10] test(windows): avoid POSIX-only bash fixtures --- packages/opencode/test/tool/bash.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index ac93016927a..91970451b01 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -20,6 +20,8 @@ const ctx = { } const projectRoot = path.join(__dirname, "../..") +const emit = (n: number) => `bun -e "console.log(Array.from({ length: ${n} }, (_, i) => i + 1).join('\\n'))"` +const blob = (n: number) => `bun -e "process.stdout.write('a'.repeat(${n}))"` describe("tool.bash", () => { test("basic", async () => { @@ -322,7 +324,7 @@ describe("tool.bash truncation", () => { const lineCount = Truncate.MAX_LINES + 500 const result = await bash.execute( { - command: `seq 1 ${lineCount}`, + command: emit(lineCount), description: "Generate lines exceeding limit", }, ctx, @@ -342,7 +344,7 @@ describe("tool.bash truncation", () => { const byteCount = Truncate.MAX_BYTES + 10000 const result = await bash.execute( { - command: `head -c ${byteCount} /dev/zero | tr '\\0' 'a'`, + command: blob(byteCount), description: "Generate bytes exceeding limit", }, ctx, @@ -381,7 +383,7 @@ describe("tool.bash truncation", () => { const lineCount = Truncate.MAX_LINES + 100 const result = await bash.execute( { - command: `seq 1 ${lineCount}`, + command: emit(lineCount), description: "Generate lines for file check", }, ctx, From be7e4f5813e323c9d9557ac98d168c724238d1c8 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:51:05 +1000 Subject: [PATCH 04/10] fix(windows): resolve shell paths and adjust bash hints --- packages/opencode/src/shell/shell.ts | 27 +++++++++++++++++++++------ packages/opencode/src/tool/bash.ts | 5 +++++ packages/opencode/src/tool/bash.txt | 2 +- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/shell/shell.ts b/packages/opencode/src/shell/shell.ts index caec2c0b872..b0c66e81f63 100644 --- a/packages/opencode/src/shell/shell.ts +++ b/packages/opencode/src/shell/shell.ts @@ -36,6 +36,19 @@ export namespace Shell { } const BLACKLIST = new Set(["fish", "nu"]) + function full(file: string) { + if (process.platform !== "win32") return file + if (path.win32.dirname(file) !== ".") return file + return Bun.which(file) || file + } + + function pick() { + const pwsh = Bun.which("pwsh") + if (pwsh) return pwsh + const powershell = Bun.which("powershell") + if (powershell) return powershell + } + function fallback() { if (process.platform === "win32") { if (Flag.OPENCODE_GIT_BASH_PATH) return Flag.OPENCODE_GIT_BASH_PATH @@ -56,20 +69,22 @@ export namespace Shell { export const preferred = lazy(() => { const s = process.env.SHELL - if (s) return s + if (s) return full(s) if (process.platform === "win32") { - if (Bun.which("pwsh")) return "pwsh" - if (Bun.which("powershell")) return "powershell" + const shell = pick() + if (shell) return shell } return fallback() }) export const acceptable = lazy(() => { const s = process.env.SHELL - if (s && !BLACKLIST.has(process.platform === "win32" ? path.win32.basename(s, ".exe") : path.basename(s))) return s + if (s && !BLACKLIST.has(process.platform === "win32" ? path.win32.basename(s, ".exe") : path.basename(s))) { + return full(s) + } if (process.platform === "win32") { - if (Bun.which("pwsh")) return "pwsh" - if (Bun.which("powershell")) return "powershell" + const shell = pick() + if (shell) return shell } return fallback() }) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index a35cacf9c40..671829c0f8e 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -55,12 +55,17 @@ const parser = lazy(async () => { export const BashTool = Tool.define("bash", async () => { const shell = Shell.acceptable() const name = process.platform === "win32" ? path.win32.basename(shell, ".exe") : path.basename(shell) + const chain = + name.toLowerCase() === "powershell" + ? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success." + : "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead." log.info("bash tool using shell", { shell }) return { description: DESCRIPTION.replaceAll("${directory}", Instance.directory) .replaceAll("${os}", process.platform) .replaceAll("${shell}", name) + .replaceAll("${chaining}", chain) .replaceAll("${maxLines}", String(Truncate.MAX_LINES)) .replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)), parameters: z.object({ diff --git a/packages/opencode/src/tool/bash.txt b/packages/opencode/src/tool/bash.txt index a5626cbac8c..8d53c90ab4e 100644 --- a/packages/opencode/src/tool/bash.txt +++ b/packages/opencode/src/tool/bash.txt @@ -37,7 +37,7 @@ Usage notes: - Communication: Output text directly (NOT echo/printf) - When issuing multiple commands: - If the commands are independent and can run in parallel, make multiple Bash tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two Bash tool calls in parallel. - - If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m "message" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead. + - ${chaining} - Use ';' only when you need to run commands sequentially but don't care if earlier commands fail - DO NOT use newlines to separate commands (newlines are ok in quoted strings) - AVOID using `cd && `. Use the `workdir` parameter to change directories instead. From 2a098f68ed2669b0fb74ccffb73686234cbac1e7 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:00:47 +1000 Subject: [PATCH 05/10] test(windows): stabilize bash truncation fixtures --- packages/opencode/test/tool/bash.test.ts | 12 +++++------- packages/opencode/test/tool/fixtures/output.ts | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 7 deletions(-) create mode 100644 packages/opencode/test/tool/fixtures/output.ts diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 91970451b01..b227b2b3eec 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -20,8 +20,7 @@ const ctx = { } const projectRoot = path.join(__dirname, "../..") -const emit = (n: number) => `bun -e "console.log(Array.from({ length: ${n} }, (_, i) => i + 1).join('\\n'))"` -const blob = (n: number) => `bun -e "process.stdout.write('a'.repeat(${n}))"` +const fill = (mode: "lines" | "bytes", n: number) => `bun test/tool/fixtures/output.ts ${mode} ${n}` describe("tool.bash", () => { test("basic", async () => { @@ -324,7 +323,7 @@ describe("tool.bash truncation", () => { const lineCount = Truncate.MAX_LINES + 500 const result = await bash.execute( { - command: emit(lineCount), + command: fill("lines", lineCount), description: "Generate lines exceeding limit", }, ctx, @@ -344,7 +343,7 @@ describe("tool.bash truncation", () => { const byteCount = Truncate.MAX_BYTES + 10000 const result = await bash.execute( { - command: blob(byteCount), + command: fill("bytes", byteCount), description: "Generate bytes exceeding limit", }, ctx, @@ -369,8 +368,7 @@ describe("tool.bash truncation", () => { ctx, ) expect((result.metadata as any).truncated).toBe(false) - const eol = process.platform === "win32" ? "\r\n" : "\n" - expect(result.output).toBe(`hello${eol}`) + expect(["hello\n", "hello\r\n"]).toContain(result.output) }, }) }) @@ -383,7 +381,7 @@ describe("tool.bash truncation", () => { const lineCount = Truncate.MAX_LINES + 100 const result = await bash.execute( { - command: emit(lineCount), + command: fill("lines", lineCount), description: "Generate lines for file check", }, ctx, diff --git a/packages/opencode/test/tool/fixtures/output.ts b/packages/opencode/test/tool/fixtures/output.ts new file mode 100644 index 00000000000..5d5d0dd1aab --- /dev/null +++ b/packages/opencode/test/tool/fixtures/output.ts @@ -0,0 +1,14 @@ +const mode = Bun.argv[2] +const n = Number(Bun.argv[3]) + +if (mode === "lines") { + console.log(Array.from({ length: n }, (_, i) => i + 1).join("\n")) + process.exit(0) +} + +if (mode === "bytes") { + process.stdout.write("a".repeat(n)) + process.exit(0) +} + +throw new Error(`unknown mode: ${mode}`) From 50188cdce3c354bebf4241681945fc63642ca4c4 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:08:23 +1000 Subject: [PATCH 06/10] test(windows): use absolute bun path in bash fixtures --- packages/opencode/test/tool/bash.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index b227b2b3eec..4972e352855 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -20,7 +20,8 @@ const ctx = { } const projectRoot = path.join(__dirname, "../..") -const fill = (mode: "lines" | "bytes", n: number) => `bun test/tool/fixtures/output.ts ${mode} ${n}` +const bin = process.execPath.replaceAll("\\", "/") +const fill = (mode: "lines" | "bytes", n: number) => `${bin} test/tool/fixtures/output.ts ${mode} ${n}` describe("tool.bash", () => { test("basic", async () => { From 807b8784e15959395866a91aa0a899cbb6c0bede Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:20:45 +1000 Subject: [PATCH 07/10] test(windows): use absolute fixture path in bash tests --- packages/opencode/test/tool/bash.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 4972e352855..1fab77d3601 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -21,7 +21,8 @@ const ctx = { const projectRoot = path.join(__dirname, "../..") const bin = process.execPath.replaceAll("\\", "/") -const fill = (mode: "lines" | "bytes", n: number) => `${bin} test/tool/fixtures/output.ts ${mode} ${n}` +const file = path.join(projectRoot, "test/tool/fixtures/output.ts").replaceAll("\\", "/") +const fill = (mode: "lines" | "bytes", n: number) => `${bin} ${file} ${mode} ${n}` describe("tool.bash", () => { test("basic", async () => { From 81037a4e30b1b3d38337f703a8d28f028cedcee2 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:45:29 +1000 Subject: [PATCH 08/10] test(windows): cover bash tool shells --- packages/opencode/test/tool/bash.test.ts | 69 ++++++++++++++++++------ 1 file changed, 53 insertions(+), 16 deletions(-) diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 1fab77d3601..ad4441af9ba 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import os from "os" import path from "path" +import { Shell } from "../../src/shell/shell" import { BashTool } from "../../src/tool/bash" import { Instance } from "../../src/project/instance" import { Filesystem } from "../../src/util/filesystem" @@ -23,9 +24,45 @@ const projectRoot = path.join(__dirname, "../..") const bin = process.execPath.replaceAll("\\", "/") const file = path.join(projectRoot, "test/tool/fixtures/output.ts").replaceAll("\\", "/") const fill = (mode: "lines" | "bytes", n: number) => `${bin} ${file} ${mode} ${n}` +const shells = (() => { + if (process.platform !== "win32") { + const shell = process.env.SHELL || Bun.which("bash") || "/bin/sh" + return [{ label: path.basename(shell), shell }] + } + + const list = [ + { label: "git bash", shell: process.env.SHELL || Bun.which("bash") }, + { label: "pwsh", shell: Bun.which("pwsh") }, + { label: "powershell", shell: Bun.which("powershell") }, + { label: "cmd", shell: process.env.COMSPEC || Bun.which("cmd.exe") }, + ].filter((item): item is { label: string; shell: string } => Boolean(item.shell)) + + return list.filter((item, i) => list.findIndex((x) => x.shell.toLowerCase() === item.shell.toLowerCase()) === i) +})() + +const withShell = (shell: string, fn: () => Promise) => async () => { + const prev = process.env.SHELL + process.env.SHELL = shell + Shell.acceptable.reset() + Shell.preferred.reset() + try { + await fn() + } finally { + if (prev === undefined) delete process.env.SHELL + else process.env.SHELL = prev + Shell.acceptable.reset() + Shell.preferred.reset() + } +} + +const each = (name: string, fn: () => Promise) => { + for (const item of shells) { + test(`${name} [${item.label}]`, withShell(item.shell, fn)) + } +} describe("tool.bash", () => { - test("basic", async () => { + each("basic", async () => { await Instance.provide({ directory: projectRoot, fn: async () => { @@ -45,7 +82,7 @@ describe("tool.bash", () => { }) describe("tool.bash permissions", () => { - test("asks for bash permission with correct pattern", async () => { + each("asks for bash permission with correct pattern", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, @@ -72,7 +109,7 @@ describe("tool.bash permissions", () => { }) }) - test("asks for bash permission with multiple commands", async () => { + each("asks for bash permission with multiple commands", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, @@ -100,7 +137,7 @@ describe("tool.bash permissions", () => { }) }) - test("asks for external_directory permission when cd to parent", async () => { + each("asks for external_directory permission when cd to parent", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, @@ -126,7 +163,7 @@ describe("tool.bash permissions", () => { }) }) - test("asks for external_directory permission when workdir is outside project", async () => { + each("asks for external_directory permission when workdir is outside project", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, @@ -154,7 +191,7 @@ describe("tool.bash permissions", () => { }) }) - test("asks for external_directory permission when file arg is outside project", async () => { + each("asks for external_directory permission when file arg is outside project", async () => { await using outerTmp = await tmpdir({ init: async (dir) => { await Bun.write(path.join(dir, "outside.txt"), "x") @@ -189,7 +226,7 @@ describe("tool.bash permissions", () => { }) }) - test("does not ask for external_directory permission when rm inside project", async () => { + each("does not ask for external_directory permission when rm inside project", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, @@ -219,7 +256,7 @@ describe("tool.bash permissions", () => { }) }) - test("includes always patterns for auto-approval", async () => { + each("includes always patterns for auto-approval", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, @@ -246,7 +283,7 @@ describe("tool.bash permissions", () => { }) }) - test("does not ask for bash permission when command is cd only", async () => { + each("does not ask for bash permission when command is cd only", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, @@ -272,7 +309,7 @@ describe("tool.bash permissions", () => { }) }) - test("matches redirects in permission pattern", async () => { + each("matches redirects in permission pattern", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, @@ -293,7 +330,7 @@ describe("tool.bash permissions", () => { }) }) - test("always pattern has space before wildcard to not include different commands", async () => { + each("always pattern has space before wildcard to not include different commands", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, @@ -317,7 +354,7 @@ describe("tool.bash permissions", () => { }) describe("tool.bash truncation", () => { - test("truncates output exceeding line limit", async () => { + each("truncates output exceeding line limit", async () => { await Instance.provide({ directory: projectRoot, fn: async () => { @@ -337,7 +374,7 @@ describe("tool.bash truncation", () => { }) }) - test("truncates output exceeding byte limit", async () => { + each("truncates output exceeding byte limit", async () => { await Instance.provide({ directory: projectRoot, fn: async () => { @@ -357,7 +394,7 @@ describe("tool.bash truncation", () => { }) }) - test("does not truncate small output", async () => { + each("does not truncate small output", async () => { await Instance.provide({ directory: projectRoot, fn: async () => { @@ -370,12 +407,12 @@ describe("tool.bash truncation", () => { ctx, ) expect((result.metadata as any).truncated).toBe(false) - expect(["hello\n", "hello\r\n"]).toContain(result.output) + expect(result.output).toContain("hello") }, }) }) - test("full output is saved to file when truncated", async () => { + each("full output is saved to file when truncated", async () => { await Instance.provide({ directory: projectRoot, fn: async () => { From 3392f89559427dabcbbf15405e35825d38c6cc1f Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:54:06 +1000 Subject: [PATCH 09/10] fix(windows): stabilize bash tool powershell execution --- packages/opencode/src/tool/bash.ts | 27 +++++++++++++++++------- packages/opencode/test/tool/bash.test.ts | 18 ++++++++++++---- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 671829c0f8e..19e8330f620 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -177,16 +177,23 @@ export const BashTool = Tool.define("bash", async () => { { cwd, sessionID: ctx.sessionID, callID: ctx.callID }, { env: {} }, ) - const proc = spawn(params.command, { - shell, + const env = { + ...process.env, + ...shellEnv.env, + } + const opts = { cwd, - env: { - ...process.env, - ...shellEnv.env, - }, - stdio: ["ignore", "pipe", "pipe"], + env, + stdio: ["ignore", "pipe", "pipe"] as const, detached: process.platform !== "win32", - }) + } + const proc = + process.platform === "win32" && ["pwsh", "powershell"].includes(name.toLowerCase()) + ? spawn(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", params.command], opts) + : spawn(params.command, { + ...opts, + shell, + }) let output = "" @@ -243,6 +250,10 @@ export const BashTool = Tool.define("bash", async () => { proc.once("exit", () => { exited = true + }) + + proc.once("close", () => { + exited = true cleanup() resolve() }) diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index ad4441af9ba..62e7048d128 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -23,7 +23,14 @@ const ctx = { const projectRoot = path.join(__dirname, "../..") const bin = process.execPath.replaceAll("\\", "/") const file = path.join(projectRoot, "test/tool/fixtures/output.ts").replaceAll("\\", "/") -const fill = (mode: "lines" | "bytes", n: number) => `${bin} ${file} ${mode} ${n}` +const kind = () => path.win32.basename(process.env.SHELL || "", ".exe").toLowerCase() +const fill = (mode: "lines" | "bytes", n: number) => { + if (["pwsh", "powershell"].includes(kind())) { + if (mode === "lines") return `1..${n} | ForEach-Object { $_ }` + return `Write-Output ('a' * ${n})` + } + return `${bin} ${file} ${mode} ${n}` +} const shells = (() => { if (process.platform !== "win32") { const shell = process.env.SHELL || Bun.which("bash") || "/bin/sh" @@ -322,10 +329,13 @@ describe("tool.bash permissions", () => { requests.push(req) }, } - await bash.execute({ command: "cat > /tmp/output.txt", description: "Redirect ls output" }, testCtx) + const command = ["pwsh", "powershell"].includes(kind()) + ? "Write-Output test > output.txt" + : "cat > /tmp/output.txt" + await bash.execute({ command, description: "Redirect ls output" }, testCtx) const bashReq = requests.find((r) => r.permission === "bash") expect(bashReq).toBeDefined() - expect(bashReq!.patterns).toContain("cat > /tmp/output.txt") + expect(bashReq!.patterns).toContain(command) }, }) }) @@ -431,7 +441,7 @@ describe("tool.bash truncation", () => { expect(filepath).toBeTruthy() const saved = await Filesystem.readText(filepath) - const lines = saved.trim().split("\n") + const lines = saved.trim().split(/\r?\n/) expect(lines.length).toBe(lineCount) expect(lines[0]).toBe("1") expect(lines[lineCount - 1]).toBe(String(lineCount)) From 6b00f4cb58d4848941613cba2ad2ced303057cf7 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:56:11 +1000 Subject: [PATCH 10/10] fix(windows): use typed powershell spawn options --- packages/opencode/src/tool/bash.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 19e8330f620..902661f8d44 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -181,18 +181,20 @@ export const BashTool = Tool.define("bash", async () => { ...process.env, ...shellEnv.env, } - const opts = { - cwd, - env, - stdio: ["ignore", "pipe", "pipe"] as const, - detached: process.platform !== "win32", - } const proc = process.platform === "win32" && ["pwsh", "powershell"].includes(name.toLowerCase()) - ? spawn(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", params.command], opts) + ? spawn(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", params.command], { + cwd, + env, + stdio: ["ignore", "pipe", "pipe"], + detached: process.platform !== "win32", + }) : spawn(params.command, { - ...opts, shell, + cwd, + env, + stdio: ["ignore", "pipe", "pipe"], + detached: process.platform !== "win32", }) let output = ""