Skip to content
28 changes: 27 additions & 1 deletion packages/opencode/src/tool/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const BashTool = Tool.define("bash", {
parameters: z.object({
command: z.string().describe("The command to execute"),
timeout: z.number().describe("Optional timeout in milliseconds").optional(),
cwd: z.string().describe("The working directory for the command. Must be within the project directory. If not specified, uses the project root directory.").optional(),
description: z
.string()
.describe(
Expand All @@ -56,6 +57,30 @@ export const BashTool = Tool.define("bash", {
)
}
const timeout = Math.min(params.timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT)

// Validate and resolve cwd parameter
let workingDirectory = Instance.directory
if (params.cwd) {
const resolvedCwd = await $`realpath ${params.cwd}`
.cwd(Instance.directory)
.quiet()
.nothrow()
.text()
.then((x) => x.trim())

if (!resolvedCwd) {
throw new Error(`Invalid working directory: ${params.cwd}`)
}

if (!Filesystem.contains(Instance.directory, resolvedCwd)) {
throw new Error(
`Working directory ${resolvedCwd} is outside of project directory ${Instance.directory}`,
)
}

workingDirectory = resolvedCwd
}

const tree = await parser().then((p) => p.parse(params.command))
if (!tree) {
throw new Error("Failed to parse command")
Expand Down Expand Up @@ -86,6 +111,7 @@ export const BashTool = Tool.define("bash", {
for (const arg of command.slice(1)) {
if (arg.startsWith("-") || (command[0] === "chmod" && arg.startsWith("+"))) continue
const resolved = await $`realpath ${arg}`
.cwd(workingDirectory)
.quiet()
.nothrow()
.text()
Expand Down Expand Up @@ -143,7 +169,7 @@ export const BashTool = Tool.define("bash", {

const proc = spawn(params.command, {
shell: true,
cwd: Instance.directory,
cwd: workingDirectory,
stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32",
})
Expand Down
6 changes: 5 additions & 1 deletion packages/opencode/src/tool/bash.txt
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,19 @@ Before executing the command, please follow these steps:
Usage notes:
- The command argument is required.
- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 120000ms (2 minutes).
- You can specify an optional working directory with the cwd parameter. The directory must be within the project directory. If not specified, uses the project root directory.
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
- If the output exceeds 30000 characters, output will be truncated before being returned to you.
- VERY IMPORTANT: You MUST avoid using search commands like `find` and `grep`. Instead use Grep, Glob, or Task to search. You MUST avoid read tools like `cat`, `head`, `tail`, and `ls`, and use Read and List to read files.
- If you _still_ need to run `grep`, STOP. ALWAYS USE ripgrep at `rg` (or /usr/bin/rg) first, which all opencode users have pre-installed.
- When issuing multiple commands, use the ';' or '&&' operator to separate them. DO NOT use newlines (newlines are ok in quoted strings).
- Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of `cd`. You may use `cd` if the User explicitly requests it.
- Try to maintain your current working directory throughout the session by using absolute paths, the cwd parameter, and avoiding usage of `cd`. You may use `cd` if the User explicitly requests it.
<good-example>
pytest /foo/bar/tests
</good-example>
<good-example>
Run command in specific directory: command="npm test" cwd="/foo/bar"
</good-example>
<bad-example>
cd /foo/bar && pytest tests
</bad-example>
Expand Down
71 changes: 71 additions & 0 deletions packages/opencode/test/tool/bash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,75 @@ describe("tool.bash", () => {
},
})
})

test("cwd parameter with valid directory", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const result = await bash.execute(
{
command: "pwd",
cwd: projectRoot,
description: "Get current working directory",
},
ctx,
)
expect(result.metadata.exit).toBe(0)
expect(result.metadata.output).toContain(projectRoot)
},
})
})

test("cwd parameter outside project should fail", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
expect(
bash.execute(
{
command: "pwd",
cwd: "/tmp",
description: "Try to use cwd outside project",
},
ctx,
),
).rejects.toThrow("Working directory")
},
})
})

test("cwd parameter with relative path", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const result = await bash.execute(
{
command: "pwd",
cwd: path.join(projectRoot, "src"),
description: "Use absolute path for cwd",
},
ctx,
)
expect(result.metadata.exit).toBe(0)
expect(result.metadata.output).toContain("/src")
},
})
})

test("default behavior without cwd parameter", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const result = await bash.execute(
{
command: "pwd",
description: "Get default working directory",
},
ctx,
)
expect(result.metadata.exit).toBe(0)
expect(result.metadata.output).toContain(projectRoot)
},
})
})
})