import z from "zod"import { spawn } from "child_process"import { Tool } from "./tool"import path from "path"import DESCRIPTION from "./bash.txt"import { Log } from "../util/log"import { Instance } from "../project/instance"import { lazy } from "@/util/lazy"import { Language } from "web-tree-sitter" import { $ } from "bun"import { Filesystem } from "@/util/filesystem"import { fileURLToPath } from "url"import { Flag } from "@/flag/flag.ts"import { Shell } from "@/shell/shell" import { BashArity } from "@/permission/arity"import { Truncate } from "./truncation"import { OpenScience } from "@/openscience" const MAX_METADATA_LENGTH = 30_000const DEFAULT_TIMEOUT = Flag.OPENSCIENCE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 0 export const log = Log.create({ service: "bash-tool" }) const resolveWasm = (asset: string) => { if (asset.startsWith("file://")) return fileURLToPath(asset) if (asset.startsWith("/") || /^[a-z]:/i.test(asset)) return asset const url = new URL(asset, import.meta.url) return fileURLToPath(url)} const parser = lazy(async () => { const { Parser } = await import("web-tree-sitter") const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, { with: { type: "wasm" }, }) const treePath = resolveWasm(treeWasm) await Parser.init({ locateFile() { return treePath }, }) const { default: bashWasm } = await import("tree-sitter-bash/tree-sitter-bash.wasm" as string, { with: { type: "wasm" }, }) const bashPath = resolveWasm(bashWasm) const bashLanguage = await Language.load(bashPath) const p = new Parser() p.setLanguage(bashLanguage) return p}) // TODO: we may wanna rename this tool so it works better on other shellsexport const BashTool = Tool.define("bash", async () => { const shell = Shell.acceptable() log.info("bash tool using shell", { shell }) return { description: DESCRIPTION.replaceAll("${directory}", Instance.directory) .replaceAll("${maxLines}", String(Truncate.MAX_LINES)) .replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)), parameters: z.object({ command: z.string().describe("The command to execute"), timeout: z.number().describe("Optional timeout in milliseconds").optional(), workdir: z .string() .describe( `The working directory to run the command in. Defaults to ${Instance.directory}. Use this instead of 'cd' commands.`, ) .optional(), description: z .string() .describe( "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", ), }), async execute(params, ctx) { const cwd = params.workdir || Instance.directory try { const { existsSync, mkdirSync } = await import("fs") if (!existsSync(cwd)) { mkdirSync(cwd, { recursive: true }) } } catch {} 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)) if (!tree) { throw new Error("Failed to parse command") } const directories = new Set<string>() if (!Instance.containsPath(cwd)) directories.add(cwd) const patterns = new Set<string>() const always = new Set<string>() for (const node of tree.rootNode.descendantsOfType("command")) { if (!node) continue const command = [] for (let i = 0; i < node.childCount; i++) { const child = node.child(i) if (!child) continue if ( child.type !== "command_name" && child.type !== "word" && child.type !== "string" && child.type !== "raw_string" && child.type !== "concatenation" ) { continue } command.push(child.text) } // not an exhaustive list, but covers most common cases if (["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown", "cat"].includes(command[0])) { for (const arg of command.slice(1)) { if (arg.startsWith("-") || (command[0] === "chmod" && arg.startsWith("+"))) continue const resolved = await $`realpath ${arg}` .cwd(cwd) .quiet() .nothrow() .text() .then((x) => x.trim()) log.info("resolved path", { arg, resolved }) if (resolved) { // Git Bash on Windows returns Unix-style paths like /c/Users/... const normalized = process.platform === "win32" && resolved.match(/^\/[a-z]\//) ? resolved.replace(/^\/([a-z])\//, (_, drive) => `${drive.toUpperCase()}:\\`).replace(/\//g, "\\") : resolved if (!Instance.containsPath(normalized)) directories.add(normalized) } } } // cd covered by above check if (command.length && command[0] !== "cd") { patterns.add(command.join(" ")) always.add(BashArity.prefix(command).join(" ") + "*") } } if (directories.size > 0) { await ctx.ask({ permission: "external_directory", patterns: Array.from(directories), always: Array.from(directories).map((x) => path.dirname(x) + "*"), metadata: {}, }) } if (patterns.size > 0) { await ctx.ask({ permission: "bash", patterns: Array.from(patterns), always: Array.from(always), metadata: {}, }) } // Seed the BYOK secret cache so redact() below masks the user's own // provider keys (auth.json + shell env), not just synced managed ones. await OpenScience.refreshByokSecrets(process.env).catch(() => {}) const proc = spawn(params.command, { shell, cwd, env: await OpenScience.subprocessEnv(process.env), stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32", }) let output = "" // Initialize metadata with empty output ctx.metadata({ metadata: { output: "", description: params.description, }, }) const redact = (text: string) => { try { return OpenScience.redactSecrets(text) } catch { return text } } const append = (chunk: Buffer) => { output += chunk.toString() const redacted = redact(output) ctx.metadata({ metadata: { output: redacted.length > MAX_METADATA_LENGTH ? redacted.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : redacted, description: params.description, }, }) } proc.stdout?.on("data", append) proc.stderr?.on("data", append) let timedOut = false let aborted = false let exited = false const kill = () => Shell.killTree(proc, { exited: () => exited }) if (ctx.abort.aborted) { aborted = true await kill() } const abortHandler = () => { aborted = true void kill() } ctx.abort.addEventListener("abort", abortHandler, { once: true }) const timeoutTimer = timeout > 0 ? setTimeout(() => { timedOut = true void kill() }, timeout + 100) : undefined await new Promise<void>((resolve, reject) => { const cleanup = () => { if (timeoutTimer) clearTimeout(timeoutTimer) ctx.abort.removeEventListener("abort", abortHandler) } proc.once("exit", () => { exited = true cleanup() resolve() }) proc.once("error", (error) => { exited = true cleanup() reject(error) }) }) const resultMetadata: string[] = [] if (timedOut) { resultMetadata.push(`bash tool terminated command after exceeding timeout ${timeout} ms`) } if (aborted) { resultMetadata.push("User aborted the command") } if (resultMetadata.length > 0) { output += "\n\n<bash_metadata>\n" + resultMetadata.join("\n") + "\n</bash_metadata>" } const redactedOutput = redact(output) return { title: params.description, metadata: { output: redactedOutput.length > MAX_METADATA_LENGTH ? redactedOutput.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : redactedOutput, exit: proc.exitCode, description: params.description, }, output: redactedOutput, } }, }})
import z from "zod"
import { spawn } from "child_process"
import { Tool } from "./tool"
import path from "path"
import DESCRIPTION from "./bash.txt"
import { Log } from "../util/log"
import { Instance } from "../project/instance"
import { lazy } from "@/util/lazy"
import { Language } from "web-tree-sitter"
import { $ } from "bun"
import { Filesystem } from "@/util/filesystem"
import { fileURLToPath } from "url"
import { Flag } from "@/flag/flag.ts"
import { Shell } from "@/shell/shell"
import { BashArity } from "@/permission/arity"
import { Truncate } from "./truncation"
import { OpenScience } from "@/openscience"
const MAX_METADATA_LENGTH = 30_000
const DEFAULT_TIMEOUT = Flag.OPENSCIENCE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 0
export const log = Log.create({ service: "bash-tool" })
const resolveWasm = (asset: string) => {
if (asset.startsWith("file://")) return fileURLToPath(asset)
if (asset.startsWith("/") || /^[a-z]:/i.test(asset)) return asset
const url = new URL(asset, import.meta.url)
return fileURLToPath(url)
}
const parser = lazy(async () => {
const { Parser } = await import("web-tree-sitter")
const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, {
with: { type: "wasm" },
})
const treePath = resolveWasm(treeWasm)
await Parser.init({
locateFile() {
return treePath
},
})
const { default: bashWasm } = await import("tree-sitter-bash/tree-sitter-bash.wasm" as string, {
with: { type: "wasm" },
})
const bashPath = resolveWasm(bashWasm)
const bashLanguage = await Language.load(bashPath)
const p = new Parser()
p.setLanguage(bashLanguage)
return p
})
// 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()
log.info("bash tool using shell", { shell })
return {
description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
parameters: z.object({
command: z.string().describe("The command to execute"),
timeout: z.number().describe("Optional timeout in milliseconds").optional(),
workdir: z
.string()
.describe(
`The working directory to run the command in. Defaults to ${Instance.directory}. Use this instead of 'cd' commands.`,
)
.optional(),
description: z
.string()
.describe(
"Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'",
),
}),
async execute(params, ctx) {
const cwd = params.workdir || Instance.directory
try {
const { existsSync, mkdirSync } = await import("fs")
if (!existsSync(cwd)) {
mkdirSync(cwd, { recursive: true })
}
} catch {}
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))
if (!tree) {
throw new Error("Failed to parse command")
}
const directories = new Set<string>()
if (!Instance.containsPath(cwd)) directories.add(cwd)
const patterns = new Set<string>()
const always = new Set<string>()
for (const node of tree.rootNode.descendantsOfType("command")) {
if (!node) continue
const command = []
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i)
if (!child) continue
if (
child.type !== "command_name" &&
child.type !== "word" &&
child.type !== "string" &&
child.type !== "raw_string" &&
child.type !== "concatenation"
) {
continue
}
command.push(child.text)
}
// not an exhaustive list, but covers most common cases
if (["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown", "cat"].includes(command[0])) {
for (const arg of command.slice(1)) {
if (arg.startsWith("-") || (command[0] === "chmod" && arg.startsWith("+"))) continue
const resolved = await $`realpath ${arg}`
.cwd(cwd)
.quiet()
.nothrow()
.text()
.then((x) => x.trim())
log.info("resolved path", { arg, resolved })
if (resolved) {
// Git Bash on Windows returns Unix-style paths like /c/Users/...
const normalized =
process.platform === "win32" && resolved.match(/^\/[a-z]\//)
? resolved.replace(/^\/([a-z])\//, (_, drive) => `${drive.toUpperCase()}:\\`).replace(/\//g, "\\")
: resolved
if (!Instance.containsPath(normalized)) directories.add(normalized)
}
}
}
// cd covered by above check
if (command.length && command[0] !== "cd") {
patterns.add(command.join(" "))
always.add(BashArity.prefix(command).join(" ") + "*")
}
}
if (directories.size > 0) {
await ctx.ask({
permission: "external_directory",
patterns: Array.from(directories),
always: Array.from(directories).map((x) => path.dirname(x) + "*"),
metadata: {},
})
}
if (patterns.size > 0) {
await ctx.ask({
permission: "bash",
patterns: Array.from(patterns),
always: Array.from(always),
metadata: {},
})
}
// Seed the BYOK secret cache so redact() below masks the user's own
// provider keys (auth.json + shell env), not just synced managed ones.
await OpenScience.refreshByokSecrets(process.env).catch(() => {})
const proc = spawn(params.command, {
shell,
cwd,
env: await OpenScience.subprocessEnv(process.env),
stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32",
})
let output = ""
// Initialize metadata with empty output
ctx.metadata({
metadata: {
output: "",
description: params.description,
},
})
const redact = (text: string) => {
try {
return OpenScience.redactSecrets(text)
} catch {
return text
}
}
const append = (chunk: Buffer) => {
output += chunk.toString()
const redacted = redact(output)
ctx.metadata({
metadata: {
output:
redacted.length > MAX_METADATA_LENGTH ? redacted.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : redacted,
description: params.description,
},
})
}
proc.stdout?.on("data", append)
proc.stderr?.on("data", append)
let timedOut = false
let aborted = false
let exited = false
const kill = () => Shell.killTree(proc, { exited: () => exited })
if (ctx.abort.aborted) {
aborted = true
await kill()
}
const abortHandler = () => {
aborted = true
void kill()
}
ctx.abort.addEventListener("abort", abortHandler, { once: true })
const timeoutTimer =
timeout > 0
? setTimeout(() => {
timedOut = true
void kill()
}, timeout + 100)
: undefined
await new Promise<void>((resolve, reject) => {
const cleanup = () => {
if (timeoutTimer) clearTimeout(timeoutTimer)
ctx.abort.removeEventListener("abort", abortHandler)
}
proc.once("exit", () => {
exited = true
cleanup()
resolve()
})
proc.once("error", (error) => {
exited = true
cleanup()
reject(error)
})
})
const resultMetadata: string[] = []
if (timedOut) {
resultMetadata.push(`bash tool terminated command after exceeding timeout ${timeout} ms`)
}
if (aborted) {
resultMetadata.push("User aborted the command")
}
if (resultMetadata.length > 0) {
output += "\n\n<bash_metadata>\n" + resultMetadata.join("\n") + "\n</bash_metadata>"
}
const redactedOutput = redact(output)
return {
title: params.description,
metadata: {
output:
redactedOutput.length > MAX_METADATA_LENGTH
? redactedOutput.slice(0, MAX_METADATA_LENGTH) + "\n\n..."
: redactedOutput,
exit: proc.exitCode,
description: params.description,
},
output: redactedOutput,
}
},
}
})