diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 75bd3c9dfac..101fd148a24 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -45,6 +45,7 @@ import { LLM } from "./llm" import { iife } from "@/util/iife" import { Shell } from "@/shell/shell" import { Truncate } from "@/tool/truncation" +import { normalizeNul } from "@/util/redirection" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -1471,6 +1472,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the }) export type ShellInput = z.infer export async function shell(input: ShellInput) { + const command = normalizeNul(input.command, process.platform) const abort = start(input.sessionID) if (!abort) { throw new Session.BusyError(input.sessionID) @@ -1557,7 +1559,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the start: Date.now(), }, input: { - command: input.command, + command, }, }, } @@ -1569,10 +1571,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the const invocations: Record = { nu: { - args: ["-c", input.command], + args: ["-c", command], }, fish: { - args: ["-c", input.command], + args: ["-c", command], }, zsh: { args: [ @@ -1581,7 +1583,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the ` [[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true [[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true - eval ${JSON.stringify(input.command)} + eval ${JSON.stringify(command)} `, ], }, @@ -1592,25 +1594,25 @@ NOTE: At any point in time through this workflow you should feel free to ask the ` shopt -s expand_aliases [[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true - eval ${JSON.stringify(input.command)} + eval ${JSON.stringify(command)} `, ], }, // Windows cmd cmd: { - args: ["/c", input.command], + args: ["/c", command], }, // Windows PowerShell powershell: { - args: ["-NoProfile", "-Command", input.command], + args: ["-NoProfile", "-Command", command], }, pwsh: { - args: ["-NoProfile", "-Command", input.command], + args: ["-NoProfile", "-Command", command], }, // Fallback: any shell that doesn't match those above // - No -l, for max compatibility "": { - args: ["-c", `${input.command}`], + args: ["-c", `${command}`], }, } @@ -1626,13 +1628,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the const proc = spawn(shell, args, { cwd, detached: process.platform !== "win32", - stdio: ["ignore", "pipe", "pipe"], + stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, ...shellEnv.env, TERM: "dumb", }, }) + proc.stdin?.end() let output = "" diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 0751f789b7d..a580df9d952 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -17,6 +17,7 @@ import { Shell } from "@/shell/shell" import { BashArity } from "@/permission/arity" import { Truncate } from "./truncation" import { Plugin } from "@/plugin" +import { normalizeNul } from "@/util/redirection" const MAX_METADATA_LENGTH = 30_000 const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 @@ -76,12 +77,13 @@ export const BashTool = Tool.define("bash", async () => { ), }), async execute(params, ctx) { + const command = normalizeNul(params.command, process.platform) const cwd = params.workdir || Instance.directory if (params.timeout !== undefined && params.timeout < 0) { throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) } const timeout = params.timeout ?? DEFAULT_TIMEOUT - const tree = await parser().then((p) => p.parse(params.command)) + const tree = await parser().then((p) => p.parse(command)) if (!tree) { throw new Error("Failed to parse command") } @@ -169,16 +171,17 @@ export const BashTool = Tool.define("bash", async () => { { cwd, sessionID: ctx.sessionID, callID: ctx.callID }, { env: {} }, ) - const proc = spawn(params.command, { + const proc = spawn(command, { shell, cwd, env: { ...process.env, ...shellEnv.env, }, - stdio: ["ignore", "pipe", "pipe"], + stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32", }) + proc.stdin?.end() let output = "" diff --git a/packages/opencode/src/util/redirection.ts b/packages/opencode/src/util/redirection.ts new file mode 100644 index 00000000000..f48e68b8c3f --- /dev/null +++ b/packages/opencode/src/util/redirection.ts @@ -0,0 +1,9 @@ +const OPS = "(?:\\d+>>?|>>|>|&>|>&)" + +export function normalizeNul(command: string, platform: string) { + if (platform === "win32") return command + + return command + .replace(new RegExp(`(^|[\\s;|&(])(${OPS}\\s*)["']nul["'](?=($|[\\s;|&)]))`, "gi"), "$1$2/dev/null") + .replace(new RegExp(`(^|[\\s;|&(])(${OPS}\\s*)nul(?=($|[\\s;|&)]))`, "gi"), "$1$2/dev/null") +} diff --git a/packages/opencode/test/util/redirection.test.ts b/packages/opencode/test/util/redirection.test.ts new file mode 100644 index 00000000000..b9d087b08bf --- /dev/null +++ b/packages/opencode/test/util/redirection.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "bun:test" +import { normalizeNul } from "../../src/util/redirection" + +describe("util.redirection", () => { + test("rewrites nul redirect targets on non-windows", () => { + expect(normalizeNul("dir /s *.dll >nul 2>&1", "linux")).toBe("dir /s *.dll >/dev/null 2>&1") + expect(normalizeNul("echo hi 2> NUL", "linux")).toBe("echo hi 2> /dev/null") + expect(normalizeNul("echo hi >> 'nul'", "linux")).toBe("echo hi >> /dev/null") + expect(normalizeNul("dir /s *.dll >nul 2>&1", "win32")).toBe("dir /s *.dll >nul 2>&1") + }) + + test("does not touch ordinary file paths", () => { + expect(normalizeNul("cat ./nul.txt", "linux")).toBe("cat ./nul.txt") + expect(normalizeNul("echo hi > ./nul", "linux")).toBe("echo hi > ./nul") + }) +})