Skip to content
Open
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
23 changes: 13 additions & 10 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<typeof ShellInput>
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)
Expand Down Expand Up @@ -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,
},
},
}
Expand All @@ -1569,10 +1571,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the

const invocations: Record<string, { args: string[] }> = {
nu: {
args: ["-c", input.command],
args: ["-c", command],
},
fish: {
args: ["-c", input.command],
args: ["-c", command],
},
zsh: {
args: [
Expand All @@ -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)}
`,
],
},
Expand All @@ -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}`],
},
}

Expand All @@ -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 = ""

Expand Down
9 changes: 6 additions & 3 deletions packages/opencode/src/tool/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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 = ""

Expand Down
9 changes: 9 additions & 0 deletions packages/opencode/src/util/redirection.ts
Original file line number Diff line number Diff line change
@@ -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")
}
16 changes: 16 additions & 0 deletions packages/opencode/test/util/redirection.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
Loading