|
|
@@ -1,4 +1,5 @@
|
|
|
import z from "zod"
|
|
|
+import os from "os"
|
|
|
import { spawn } from "child_process"
|
|
|
import { Tool } from "./tool"
|
|
|
import path from "path"
|
|
|
@@ -6,12 +7,12 @@ 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 fs from "fs/promises"
|
|
|
+import { Language, type Node } from "web-tree-sitter"
|
|
|
|
|
|
import { Filesystem } from "@/util/filesystem"
|
|
|
+import { Process } from "@/util/process"
|
|
|
import { fileURLToPath } from "url"
|
|
|
-import { Flag } from "@/flag/flag.ts"
|
|
|
+import { Flag } from "@/flag/flag"
|
|
|
import { Shell } from "@/shell/shell"
|
|
|
|
|
|
import { BashArity } from "@/permission/arity"
|
|
|
@@ -20,6 +21,43 @@ import { Plugin } from "@/plugin"
|
|
|
|
|
|
const MAX_METADATA_LENGTH = 30_000
|
|
|
const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
|
|
|
+const PS = new Set(["powershell", "pwsh"])
|
|
|
+const CWD = new Set(["cd", "push-location", "set-location"])
|
|
|
+const FILES = new Set([
|
|
|
+ ...CWD,
|
|
|
+ "rm",
|
|
|
+ "cp",
|
|
|
+ "mv",
|
|
|
+ "mkdir",
|
|
|
+ "touch",
|
|
|
+ "chmod",
|
|
|
+ "chown",
|
|
|
+ "cat",
|
|
|
+ // Leave PowerShell aliases out for now. Common ones like cat/cp/mv/rm/mkdir
|
|
|
+ // already hit the entries above, and alias normalization should happen in one
|
|
|
+ // place later so we do not risk double-prompting.
|
|
|
+ "get-content",
|
|
|
+ "set-content",
|
|
|
+ "add-content",
|
|
|
+ "copy-item",
|
|
|
+ "move-item",
|
|
|
+ "remove-item",
|
|
|
+ "new-item",
|
|
|
+ "rename-item",
|
|
|
+])
|
|
|
+const FLAGS = new Set(["-destination", "-literalpath", "-path"])
|
|
|
+const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"])
|
|
|
+
|
|
|
+type Part = {
|
|
|
+ type: string
|
|
|
+ text: string
|
|
|
+}
|
|
|
+
|
|
|
+type Scan = {
|
|
|
+ dirs: Set<string>
|
|
|
+ patterns: Set<string>
|
|
|
+ always: Set<string>
|
|
|
+}
|
|
|
|
|
|
export const log = Log.create({ service: "bash-tool" })
|
|
|
|
|
|
@@ -30,6 +68,350 @@ const resolveWasm = (asset: string) => {
|
|
|
return fileURLToPath(url)
|
|
|
}
|
|
|
|
|
|
+function parts(node: Node) {
|
|
|
+ const out: Part[] = []
|
|
|
+ for (let i = 0; i < node.childCount; i++) {
|
|
|
+ const child = node.child(i)
|
|
|
+ if (!child) continue
|
|
|
+ if (child.type === "command_elements") {
|
|
|
+ for (let j = 0; j < child.childCount; j++) {
|
|
|
+ const item = child.child(j)
|
|
|
+ if (!item || item.type === "command_argument_sep" || item.type === "redirection") continue
|
|
|
+ out.push({ type: item.type, text: item.text })
|
|
|
+ }
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ if (
|
|
|
+ child.type !== "command_name" &&
|
|
|
+ child.type !== "command_name_expr" &&
|
|
|
+ child.type !== "word" &&
|
|
|
+ child.type !== "string" &&
|
|
|
+ child.type !== "raw_string" &&
|
|
|
+ child.type !== "concatenation"
|
|
|
+ ) {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ out.push({ type: child.type, text: child.text })
|
|
|
+ }
|
|
|
+ return out
|
|
|
+}
|
|
|
+
|
|
|
+function source(node: Node) {
|
|
|
+ return (node.parent?.type === "redirected_statement" ? node.parent.text : node.text).trim()
|
|
|
+}
|
|
|
+
|
|
|
+function commands(node: Node) {
|
|
|
+ return node.descendantsOfType("command").filter((child): child is Node => Boolean(child))
|
|
|
+}
|
|
|
+
|
|
|
+function unquote(text: string) {
|
|
|
+ if (text.length < 2) return text
|
|
|
+ const first = text[0]
|
|
|
+ const last = text[text.length - 1]
|
|
|
+ if ((first === '"' || first === "'") && first === last) return text.slice(1, -1)
|
|
|
+ return text
|
|
|
+}
|
|
|
+
|
|
|
+function home(text: string) {
|
|
|
+ if (text === "~") return os.homedir()
|
|
|
+ if (text.startsWith("~/") || text.startsWith("~\\")) return path.join(os.homedir(), text.slice(2))
|
|
|
+ return text
|
|
|
+}
|
|
|
+
|
|
|
+function envValue(key: string) {
|
|
|
+ if (process.platform !== "win32") return process.env[key]
|
|
|
+ const name = Object.keys(process.env).find((item) => item.toLowerCase() === key.toLowerCase())
|
|
|
+ return name ? process.env[name] : undefined
|
|
|
+}
|
|
|
+
|
|
|
+function auto(key: string, cwd: string, shell: string) {
|
|
|
+ const name = key.toUpperCase()
|
|
|
+ if (name === "HOME") return os.homedir()
|
|
|
+ if (name === "PWD") return cwd
|
|
|
+ if (name === "PSHOME") return path.dirname(shell)
|
|
|
+}
|
|
|
+
|
|
|
+function expand(text: string, cwd: string, shell: string) {
|
|
|
+ const out = unquote(text)
|
|
|
+ .replace(/\$\{env:([^}]+)\}/gi, (_, key: string) => envValue(key) || "")
|
|
|
+ .replace(/\$env:([A-Za-z_][A-Za-z0-9_]*)/gi, (_, key: string) => envValue(key) || "")
|
|
|
+ .replace(/\$(HOME|PWD|PSHOME)(?=$|[\\/])/gi, (_, key: string) => auto(key, cwd, shell) || "")
|
|
|
+ return home(out)
|
|
|
+}
|
|
|
+
|
|
|
+function provider(text: string) {
|
|
|
+ const match = text.match(/^([A-Za-z]+)::(.*)$/)
|
|
|
+ if (match) {
|
|
|
+ if (match[1].toLowerCase() !== "filesystem") return
|
|
|
+ return match[2]
|
|
|
+ }
|
|
|
+ const prefix = text.match(/^([A-Za-z]+):(.*)$/)
|
|
|
+ if (!prefix) return text
|
|
|
+ if (prefix[1].length === 1) return text
|
|
|
+ return
|
|
|
+}
|
|
|
+
|
|
|
+function dynamic(text: string, ps: boolean) {
|
|
|
+ if (text.startsWith("(") || text.startsWith("@(")) return true
|
|
|
+ if (text.includes("$(") || text.includes("${") || text.includes("`")) return true
|
|
|
+ if (ps) return /\$(?!env:)/i.test(text)
|
|
|
+ return text.includes("$")
|
|
|
+}
|
|
|
+
|
|
|
+function prefix(text: string) {
|
|
|
+ const match = /[?*\[]/.exec(text)
|
|
|
+ if (!match) return text
|
|
|
+ if (match.index === 0) return
|
|
|
+ return text.slice(0, match.index)
|
|
|
+}
|
|
|
+
|
|
|
+async function cygpath(shell: string, text: string) {
|
|
|
+ const out = await Process.text([shell, "-lc", 'cygpath -w -- "$1"', "_", text], { nothrow: true })
|
|
|
+ if (out.code !== 0) return
|
|
|
+ const file = out.text.trim()
|
|
|
+ if (!file) return
|
|
|
+ return Filesystem.normalizePath(file)
|
|
|
+}
|
|
|
+
|
|
|
+async function resolvePath(text: string, root: string, shell: string) {
|
|
|
+ if (process.platform === "win32") {
|
|
|
+ if (Shell.posix(shell) && text.startsWith("/") && Filesystem.windowsPath(text) === text) {
|
|
|
+ const file = await cygpath(shell, text)
|
|
|
+ if (file) return file
|
|
|
+ }
|
|
|
+ return Filesystem.normalizePath(path.resolve(root, Filesystem.windowsPath(text)))
|
|
|
+ }
|
|
|
+ return path.resolve(root, text)
|
|
|
+}
|
|
|
+
|
|
|
+async function argPath(arg: string, cwd: string, ps: boolean, shell: string) {
|
|
|
+ const text = ps ? expand(arg, cwd, shell) : home(unquote(arg))
|
|
|
+ const file = text && prefix(text)
|
|
|
+ if (!file || dynamic(file, ps)) return
|
|
|
+ const next = ps ? provider(file) : file
|
|
|
+ if (!next) return
|
|
|
+ return resolvePath(next, cwd, shell)
|
|
|
+}
|
|
|
+
|
|
|
+function pathArgs(list: Part[], ps: boolean) {
|
|
|
+ if (!ps) {
|
|
|
+ return list
|
|
|
+ .slice(1)
|
|
|
+ .filter((item) => !item.text.startsWith("-") && !(list[0]?.text === "chmod" && item.text.startsWith("+")))
|
|
|
+ .map((item) => item.text)
|
|
|
+ }
|
|
|
+
|
|
|
+ const out: string[] = []
|
|
|
+ let want = false
|
|
|
+ for (const item of list.slice(1)) {
|
|
|
+ if (want) {
|
|
|
+ out.push(item.text)
|
|
|
+ want = false
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ if (item.type === "command_parameter") {
|
|
|
+ const flag = item.text.toLowerCase()
|
|
|
+ if (SWITCHES.has(flag)) continue
|
|
|
+ want = FLAGS.has(flag)
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ out.push(item.text)
|
|
|
+ }
|
|
|
+ return out
|
|
|
+}
|
|
|
+
|
|
|
+async function collect(root: Node, cwd: string, ps: boolean, shell: string): Promise<Scan> {
|
|
|
+ const scan: Scan = {
|
|
|
+ dirs: new Set<string>(),
|
|
|
+ patterns: new Set<string>(),
|
|
|
+ always: new Set<string>(),
|
|
|
+ }
|
|
|
+
|
|
|
+ for (const node of commands(root)) {
|
|
|
+ const command = parts(node)
|
|
|
+ const tokens = command.map((item) => item.text)
|
|
|
+ const cmd = ps ? tokens[0]?.toLowerCase() : tokens[0]
|
|
|
+
|
|
|
+ if (cmd && FILES.has(cmd)) {
|
|
|
+ for (const arg of pathArgs(command, ps)) {
|
|
|
+ const resolved = await argPath(arg, cwd, ps, shell)
|
|
|
+ log.info("resolved path", { arg, resolved })
|
|
|
+ if (!resolved || Instance.containsPath(resolved)) continue
|
|
|
+ const dir = (await Filesystem.isDir(resolved)) ? resolved : path.dirname(resolved)
|
|
|
+ scan.dirs.add(dir)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (tokens.length && (!cmd || !CWD.has(cmd))) {
|
|
|
+ scan.patterns.add(source(node))
|
|
|
+ scan.always.add(BashArity.prefix(tokens).join(" ") + " *")
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return scan
|
|
|
+}
|
|
|
+
|
|
|
+function preview(text: string) {
|
|
|
+ if (text.length <= MAX_METADATA_LENGTH) return text
|
|
|
+ return text.slice(0, MAX_METADATA_LENGTH) + "\n\n..."
|
|
|
+}
|
|
|
+
|
|
|
+async function parse(command: string, ps: boolean) {
|
|
|
+ const tree = await parser().then((p) => (ps ? p.ps : p.bash).parse(command))
|
|
|
+ if (!tree) throw new Error("Failed to parse command")
|
|
|
+ return tree.rootNode
|
|
|
+}
|
|
|
+
|
|
|
+async function ask(ctx: Tool.Context, scan: Scan) {
|
|
|
+ if (scan.dirs.size > 0) {
|
|
|
+ const globs = Array.from(scan.dirs).map((dir) => {
|
|
|
+ if (process.platform === "win32") return Filesystem.normalizePathPattern(path.join(dir, "*"))
|
|
|
+ return path.join(dir, "*")
|
|
|
+ })
|
|
|
+ await ctx.ask({
|
|
|
+ permission: "external_directory",
|
|
|
+ patterns: globs,
|
|
|
+ always: globs,
|
|
|
+ metadata: {},
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ if (scan.patterns.size === 0) return
|
|
|
+ await ctx.ask({
|
|
|
+ permission: "bash",
|
|
|
+ patterns: Array.from(scan.patterns),
|
|
|
+ always: Array.from(scan.always),
|
|
|
+ metadata: {},
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+async function shellEnv(ctx: Tool.Context, cwd: string) {
|
|
|
+ const extra = await Plugin.trigger("shell.env", { cwd, sessionID: ctx.sessionID, callID: ctx.callID }, { env: {} })
|
|
|
+ return {
|
|
|
+ ...process.env,
|
|
|
+ ...extra.env,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function launch(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
|
|
|
+ if (process.platform === "win32" && PS.has(name)) {
|
|
|
+ return spawn(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], {
|
|
|
+ cwd,
|
|
|
+ env,
|
|
|
+ stdio: ["ignore", "pipe", "pipe"],
|
|
|
+ detached: false,
|
|
|
+ windowsHide: true,
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ return spawn(command, {
|
|
|
+ shell,
|
|
|
+ cwd,
|
|
|
+ env,
|
|
|
+ stdio: ["ignore", "pipe", "pipe"],
|
|
|
+ detached: process.platform !== "win32",
|
|
|
+ windowsHide: process.platform === "win32",
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+async function run(
|
|
|
+ input: {
|
|
|
+ shell: string
|
|
|
+ name: string
|
|
|
+ command: string
|
|
|
+ cwd: string
|
|
|
+ env: NodeJS.ProcessEnv
|
|
|
+ timeout: number
|
|
|
+ description: string
|
|
|
+ },
|
|
|
+ ctx: Tool.Context,
|
|
|
+) {
|
|
|
+ const proc = launch(input.shell, input.name, input.command, input.cwd, input.env)
|
|
|
+ let output = ""
|
|
|
+
|
|
|
+ ctx.metadata({
|
|
|
+ metadata: {
|
|
|
+ output: "",
|
|
|
+ description: input.description,
|
|
|
+ },
|
|
|
+ })
|
|
|
+
|
|
|
+ const append = (chunk: Buffer) => {
|
|
|
+ output += chunk.toString()
|
|
|
+ ctx.metadata({
|
|
|
+ metadata: {
|
|
|
+ output: preview(output),
|
|
|
+ description: input.description,
|
|
|
+ },
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ proc.stdout?.on("data", append)
|
|
|
+ proc.stderr?.on("data", append)
|
|
|
+
|
|
|
+ let expired = false
|
|
|
+ let aborted = false
|
|
|
+ let exited = false
|
|
|
+
|
|
|
+ const kill = () => Shell.killTree(proc, { exited: () => exited })
|
|
|
+
|
|
|
+ if (ctx.abort.aborted) {
|
|
|
+ aborted = true
|
|
|
+ await kill()
|
|
|
+ }
|
|
|
+
|
|
|
+ const abort = () => {
|
|
|
+ aborted = true
|
|
|
+ void kill()
|
|
|
+ }
|
|
|
+
|
|
|
+ ctx.abort.addEventListener("abort", abort, { once: true })
|
|
|
+ const timer = setTimeout(() => {
|
|
|
+ expired = true
|
|
|
+ void kill()
|
|
|
+ }, input.timeout + 100)
|
|
|
+
|
|
|
+ await new Promise<void>((resolve, reject) => {
|
|
|
+ const cleanup = () => {
|
|
|
+ clearTimeout(timer)
|
|
|
+ ctx.abort.removeEventListener("abort", abort)
|
|
|
+ }
|
|
|
+
|
|
|
+ proc.once("exit", () => {
|
|
|
+ exited = true
|
|
|
+ })
|
|
|
+
|
|
|
+ proc.once("close", () => {
|
|
|
+ exited = true
|
|
|
+ cleanup()
|
|
|
+ resolve()
|
|
|
+ })
|
|
|
+
|
|
|
+ proc.once("error", (error) => {
|
|
|
+ exited = true
|
|
|
+ cleanup()
|
|
|
+ reject(error)
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ const metadata: string[] = []
|
|
|
+ if (expired) metadata.push(`bash tool terminated command after exceeding timeout ${input.timeout} ms`)
|
|
|
+ if (aborted) metadata.push("User aborted the command")
|
|
|
+ if (metadata.length > 0) {
|
|
|
+ output += "\n\n<bash_metadata>\n" + metadata.join("\n") + "\n</bash_metadata>"
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ title: input.description,
|
|
|
+ metadata: {
|
|
|
+ output: preview(output),
|
|
|
+ exit: proc.exitCode,
|
|
|
+ description: input.description,
|
|
|
+ },
|
|
|
+ output,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
const parser = lazy(async () => {
|
|
|
const { Parser } = await import("web-tree-sitter")
|
|
|
const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, {
|
|
|
@@ -44,23 +426,36 @@ const parser = lazy(async () => {
|
|
|
const { default: bashWasm } = await import("tree-sitter-bash/tree-sitter-bash.wasm" as string, {
|
|
|
with: { type: "wasm" },
|
|
|
})
|
|
|
+ const { default: psWasm } = await import("tree-sitter-powershell/tree-sitter-powershell.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
|
|
|
+ const psPath = resolveWasm(psWasm)
|
|
|
+ const [bashLanguage, psLanguage] = await Promise.all([Language.load(bashPath), Language.load(psPath)])
|
|
|
+ const bash = new Parser()
|
|
|
+ bash.setLanguage(bashLanguage)
|
|
|
+ const ps = new Parser()
|
|
|
+ ps.setLanguage(psLanguage)
|
|
|
+ return { bash, ps }
|
|
|
})
|
|
|
|
|
|
// 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 = Shell.name(shell)
|
|
|
+ const chain =
|
|
|
+ name === "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("${maxLines}", String(Truncate.MAX_LINES)).replaceAll(
|
|
|
- "${maxBytes}",
|
|
|
- String(Truncate.MAX_BYTES),
|
|
|
- ),
|
|
|
+ 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({
|
|
|
command: z.string().describe("The command to execute"),
|
|
|
timeout: z.number().describe("Optional timeout in milliseconds").optional(),
|
|
|
@@ -77,195 +472,29 @@ export const BashTool = Tool.define("bash", async () => {
|
|
|
),
|
|
|
}),
|
|
|
async execute(params, ctx) {
|
|
|
- const cwd = params.workdir || Instance.directory
|
|
|
+ const cwd = params.workdir ? await resolvePath(params.workdir, Instance.directory, shell) : 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))
|
|
|
- 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
|
|
|
-
|
|
|
- // Get full command text including redirects if present
|
|
|
- let commandText = node.parent?.type === "redirected_statement" ? node.parent.text : node.text
|
|
|
-
|
|
|
- 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 fs.realpath(path.resolve(cwd, arg)).catch(() => "")
|
|
|
- log.info("resolved path", { arg, resolved })
|
|
|
- if (resolved) {
|
|
|
- const normalized =
|
|
|
- process.platform === "win32" ? Filesystem.windowsPath(resolved).replace(/\//g, "\\") : resolved
|
|
|
- if (!Instance.containsPath(normalized)) {
|
|
|
- const dir = (await Filesystem.isDir(normalized)) ? normalized : path.dirname(normalized)
|
|
|
- directories.add(dir)
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // cd covered by above check
|
|
|
- if (command.length && command[0] !== "cd") {
|
|
|
- patterns.add(commandText)
|
|
|
- always.add(BashArity.prefix(command).join(" ") + " *")
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- if (directories.size > 0) {
|
|
|
- const globs = Array.from(directories).map((dir) => {
|
|
|
- // Preserve POSIX-looking paths with /s, even on Windows
|
|
|
- if (dir.startsWith("/")) return `${dir.replace(/[\\/]+$/, "")}/*`
|
|
|
- return path.join(dir, "*")
|
|
|
- })
|
|
|
- await ctx.ask({
|
|
|
- permission: "external_directory",
|
|
|
- patterns: globs,
|
|
|
- always: globs,
|
|
|
- metadata: {},
|
|
|
- })
|
|
|
- }
|
|
|
-
|
|
|
- if (patterns.size > 0) {
|
|
|
- await ctx.ask({
|
|
|
- permission: "bash",
|
|
|
- patterns: Array.from(patterns),
|
|
|
- always: Array.from(always),
|
|
|
- metadata: {},
|
|
|
- })
|
|
|
- }
|
|
|
-
|
|
|
- const shellEnv = await Plugin.trigger(
|
|
|
- "shell.env",
|
|
|
- { cwd, sessionID: ctx.sessionID, callID: ctx.callID },
|
|
|
- { env: {} },
|
|
|
- )
|
|
|
- const proc = spawn(params.command, {
|
|
|
- shell,
|
|
|
- cwd,
|
|
|
- env: {
|
|
|
- ...process.env,
|
|
|
- ...shellEnv.env,
|
|
|
- },
|
|
|
- stdio: ["ignore", "pipe", "pipe"],
|
|
|
- detached: process.platform !== "win32",
|
|
|
- windowsHide: process.platform === "win32",
|
|
|
- })
|
|
|
-
|
|
|
- let output = ""
|
|
|
-
|
|
|
- // Initialize metadata with empty output
|
|
|
- ctx.metadata({
|
|
|
- metadata: {
|
|
|
- output: "",
|
|
|
+ const ps = PS.has(name)
|
|
|
+ const root = await parse(params.command, ps)
|
|
|
+ const scan = await collect(root, cwd, ps, shell)
|
|
|
+ if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
|
|
|
+ await ask(ctx, scan)
|
|
|
+
|
|
|
+ return run(
|
|
|
+ {
|
|
|
+ shell,
|
|
|
+ name,
|
|
|
+ command: params.command,
|
|
|
+ cwd,
|
|
|
+ env: await shellEnv(ctx, cwd),
|
|
|
+ timeout,
|
|
|
description: params.description,
|
|
|
},
|
|
|
- })
|
|
|
-
|
|
|
- const append = (chunk: Buffer) => {
|
|
|
- output += chunk.toString()
|
|
|
- ctx.metadata({
|
|
|
- metadata: {
|
|
|
- // truncate the metadata to avoid GIANT blobs of data (has nothing to do w/ what agent can access)
|
|
|
- output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output,
|
|
|
- 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 = setTimeout(() => {
|
|
|
- timedOut = true
|
|
|
- void kill()
|
|
|
- }, timeout + 100)
|
|
|
-
|
|
|
- await new Promise<void>((resolve, reject) => {
|
|
|
- const cleanup = () => {
|
|
|
- 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>"
|
|
|
- }
|
|
|
-
|
|
|
- return {
|
|
|
- title: params.description,
|
|
|
- metadata: {
|
|
|
- output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output,
|
|
|
- exit: proc.exitCode,
|
|
|
- description: params.description,
|
|
|
- },
|
|
|
- output,
|
|
|
- }
|
|
|
+ ctx,
|
|
|
+ )
|
|
|
},
|
|
|
}
|
|
|
})
|