diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 2a6bbbb1e4..ddcdebeceb 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -285,7 +285,7 @@ export namespace ACP { const content: ToolCallContent[] = [] if (output) { const hash = Hash.fast(output) - if (part.tool === "bash") { + if (part.tool === "bash" || part.tool === "pwsh" || part.tool === "powershell") { if (this.bashSnapshots.get(part.callID) === hash) { await this.connection .sessionUpdate({ @@ -1081,7 +1081,7 @@ export namespace ACP { } private bashOutput(part: ToolPart) { - if (part.tool !== "bash") return + if (part.tool !== "bash" && part.tool !== "pwsh" && part.tool !== "powershell") return if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return const output = part.state.metadata["output"] if (typeof output !== "string") return @@ -1484,6 +1484,8 @@ export namespace ACP { const tool = toolName.toLocaleLowerCase() switch (tool) { case "bash": + case "pwsh": + case "powershell": return "execute" case "webfetch": return "fetch" @@ -1519,6 +1521,8 @@ export namespace ACP { case "grep": return input["path"] ? [{ path: input["path"] }] : [] case "bash": + case "pwsh": + case "powershell": return [] case "list": return input["path"] ? [{ path: input["path"] }] : [] diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 53c655d1b3..7ce4daf8db 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -167,6 +167,8 @@ export namespace Agent { glob: "allow", list: "allow", bash: "allow", + pwsh: "allow", + powershell: "allow", webfetch: "allow", websearch: "allow", codesearch: "allow", diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index 70082c8e2e..a8e73c90b7 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -14,7 +14,20 @@ import type { Argv } from "yargs" type AgentMode = "all" | "primary" | "subagent" -const AVAILABLE_TOOLS = ["bash", "read", "write", "edit", "list", "glob", "grep", "webfetch", "task", "todowrite"] +const AVAILABLE_TOOLS = [ + "bash", + "pwsh", + "powershell", + "read", + "write", + "edit", + "list", + "glob", + "grep", + "webfetch", + "task", + "todowrite", +] const AgentCreateCommand = cmd({ command: "create", diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 0aeb864e86..615cbf6494 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -24,7 +24,7 @@ import { CodeSearchTool } from "../../tool/codesearch" import { WebSearchTool } from "../../tool/websearch" import { TaskTool } from "../../tool/task" import { SkillTool } from "../../tool/skill" -import { BashTool } from "../../tool/bash" +import { BashTool } from "../../tool/shell/bash" import { TodoWriteTool } from "../../tool/todo" import { Locale } from "../../util/locale" @@ -411,7 +411,8 @@ export const RunCommand = cmd({ async function execute(sdk: OpencodeClient) { function tool(part: ToolPart) { try { - if (part.tool === "bash") return bash(props(part)) + if (part.tool === "bash" || part.tool === "pwsh" || part.tool === "powershell") + return bash(props(part)) if (part.tool === "glob") return glob(props(part)) if (part.tool === "grep") return grep(props(part)) if (part.tool === "list") return list(props(part)) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index fb62de9acf..14282e825a 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -35,7 +35,7 @@ import { Locale } from "@/util/locale" import type { Tool } from "@/tool/tool" import type { ReadTool } from "@/tool/read" import type { WriteTool } from "@/tool/write" -import { BashTool } from "@/tool/bash" +import { BashTool } from "@/tool/shell/bash" import type { GlobTool } from "@/tool/glob" import { TodoWriteTool } from "@/tool/todo" import type { GrepTool } from "@/tool/grep" @@ -1514,7 +1514,7 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess return ( - + diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index a50cd96fc8..e8a5b4c0ef 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -280,7 +280,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { } } - if (permission === "bash") { + if (permission === "bash" || permission === "pwsh" || permission === "powershell") { const title = typeof data.description === "string" && data.description ? data.description : "Shell command" const command = typeof data.command === "string" ? data.command : "" diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 3cbb341623..f1374e0686 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -639,6 +639,10 @@ export namespace Config { // write, edit, patch, multiedit all map to edit permission if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") { permission.edit = action + } else if (tool === "bash") { + permission.bash = action + permission.pwsh = action + permission.powershell = action } else { permission[tool] = action } diff --git a/packages/opencode/src/permission/arity.ts b/packages/opencode/src/permission/arity.ts deleted file mode 100644 index 948841c8e7..0000000000 --- a/packages/opencode/src/permission/arity.ts +++ /dev/null @@ -1,163 +0,0 @@ -export namespace BashArity { - export function prefix(tokens: string[]) { - for (let len = tokens.length; len > 0; len--) { - const prefix = tokens.slice(0, len).join(" ") - const arity = ARITY[prefix] - if (arity !== undefined) return tokens.slice(0, arity) - } - if (tokens.length === 0) return [] - return tokens.slice(0, 1) - } - - /* Generated with following prompt: -You are generating a dictionary of command-prefix arities for bash-style commands. -This dictionary is used to identify the "human-understandable command" from an input shell command.### **RULES (follow strictly)**1. Each entry maps a **command prefix string → number**, representing how many **tokens** define the command. -2. **Flags NEVER count as tokens**. Only subcommands count. -3. **Longest matching prefix wins**. -4. **Only include a longer prefix if its arity is different from what the shorter prefix already implies**. * Example: If `git` is 2, then do **not** include `git checkout`, `git commit`, etc. unless they require *different* arity. -5. The output must be a **single JSON object**. Each entry should have a comment with an example real world matching command. DO NOT MAKE ANY OTHER COMMENTS. Should be alphabetical -6. Include the **most commonly used commands** across many stacks and languages. More is better.### **Semantics examples*** `touch foo.txt` → `touch` (arity 1, explicitly listed) -* `git checkout main` → `git checkout` (because `git` has arity 2) -* `npm install` → `npm install` (because `npm` has arity 2) -* `npm run dev` → `npm run dev` (because `npm run` has arity 3) -* `python script.py` → `python script.py` (default: whole input, not in dictionary)### **Now generate the dictionary.** -*/ - const ARITY: Record = { - cat: 1, // cat file.txt - cd: 1, // cd /path/to/dir - chmod: 1, // chmod 755 script.sh - chown: 1, // chown user:group file.txt - cp: 1, // cp source.txt dest.txt - echo: 1, // echo "hello world" - env: 1, // env - export: 1, // export PATH=/usr/bin - grep: 1, // grep pattern file.txt - kill: 1, // kill 1234 - killall: 1, // killall process - ln: 1, // ln -s source target - ls: 1, // ls -la - mkdir: 1, // mkdir new-dir - mv: 1, // mv old.txt new.txt - ps: 1, // ps aux - pwd: 1, // pwd - rm: 1, // rm file.txt - rmdir: 1, // rmdir empty-dir - sleep: 1, // sleep 5 - source: 1, // source ~/.bashrc - tail: 1, // tail -f log.txt - touch: 1, // touch file.txt - unset: 1, // unset VAR - which: 1, // which node - aws: 3, // aws s3 ls - az: 3, // az storage blob list - bazel: 2, // bazel build - brew: 2, // brew install node - bun: 2, // bun install - "bun run": 3, // bun run dev - "bun x": 3, // bun x vite - cargo: 2, // cargo build - "cargo add": 3, // cargo add tokio - "cargo run": 3, // cargo run main - cdk: 2, // cdk deploy - cf: 2, // cf push app - cmake: 2, // cmake build - composer: 2, // composer require laravel - consul: 2, // consul members - "consul kv": 3, // consul kv get config/app - crictl: 2, // crictl ps - deno: 2, // deno run server.ts - "deno task": 3, // deno task dev - doctl: 3, // doctl kubernetes cluster list - docker: 2, // docker run nginx - "docker builder": 3, // docker builder prune - "docker compose": 3, // docker compose up - "docker container": 3, // docker container ls - "docker image": 3, // docker image prune - "docker network": 3, // docker network inspect - "docker volume": 3, // docker volume ls - eksctl: 2, // eksctl get clusters - "eksctl create": 3, // eksctl create cluster - firebase: 2, // firebase deploy - flyctl: 2, // flyctl deploy - gcloud: 3, // gcloud compute instances list - gh: 3, // gh pr list - git: 2, // git checkout main - "git config": 3, // git config user.name - "git remote": 3, // git remote add origin - "git stash": 3, // git stash pop - go: 2, // go build - gradle: 2, // gradle build - helm: 2, // helm install mychart - heroku: 2, // heroku logs - hugo: 2, // hugo new site blog - ip: 2, // ip link show - "ip addr": 3, // ip addr show - "ip link": 3, // ip link set eth0 up - "ip netns": 3, // ip netns exec foo bash - "ip route": 3, // ip route add default via 1.1.1.1 - kind: 2, // kind delete cluster - "kind create": 3, // kind create cluster - kubectl: 2, // kubectl get pods - "kubectl kustomize": 3, // kubectl kustomize overlays/dev - "kubectl rollout": 3, // kubectl rollout restart deploy/api - kustomize: 2, // kustomize build . - make: 2, // make build - mc: 2, // mc ls myminio - "mc admin": 3, // mc admin info myminio - minikube: 2, // minikube start - mongosh: 2, // mongosh test - mysql: 2, // mysql -u root - mvn: 2, // mvn compile - ng: 2, // ng generate component home - npm: 2, // npm install - "npm exec": 3, // npm exec vite - "npm init": 3, // npm init vue - "npm run": 3, // npm run dev - "npm view": 3, // npm view react version - nvm: 2, // nvm use 18 - nx: 2, // nx build - openssl: 2, // openssl genrsa 2048 - "openssl req": 3, // openssl req -new -key key.pem - "openssl x509": 3, // openssl x509 -in cert.pem - pip: 2, // pip install numpy - pipenv: 2, // pipenv install flask - pnpm: 2, // pnpm install - "pnpm dlx": 3, // pnpm dlx create-next-app - "pnpm exec": 3, // pnpm exec vite - "pnpm run": 3, // pnpm run dev - poetry: 2, // poetry add requests - podman: 2, // podman run alpine - "podman container": 3, // podman container ls - "podman image": 3, // podman image prune - psql: 2, // psql -d mydb - pulumi: 2, // pulumi up - "pulumi stack": 3, // pulumi stack output - pyenv: 2, // pyenv install 3.11 - python: 2, // python -m venv env - rake: 2, // rake db:migrate - rbenv: 2, // rbenv install 3.2.0 - "redis-cli": 2, // redis-cli ping - rustup: 2, // rustup update - serverless: 2, // serverless invoke - sfdx: 3, // sfdx force:org:list - skaffold: 2, // skaffold dev - sls: 2, // sls deploy - sst: 2, // sst deploy - swift: 2, // swift build - systemctl: 2, // systemctl restart nginx - terraform: 2, // terraform apply - "terraform workspace": 3, // terraform workspace select prod - tmux: 2, // tmux new -s dev - turbo: 2, // turbo run build - ufw: 2, // ufw allow 22 - vault: 2, // vault login - "vault auth": 3, // vault auth list - "vault kv": 3, // vault kv get secret/api - vercel: 2, // vercel deploy - volta: 2, // volta install node - wp: 2, // wp plugin install - yarn: 2, // yarn add react - "yarn dlx": 3, // yarn dlx create-react-app - "yarn run": 3, // yarn run dev - } -} diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 1a7bd2c610..c4909566ad 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -278,6 +278,36 @@ export namespace Permission { export function fromConfig(permission: Config.Permission) { const ruleset: Ruleset = [] for (const [key, value] of Object.entries(permission)) { + if (key === "bash") { + if (typeof value === "string") { + ruleset.push({ permission: "bash", action: value, pattern: "*" }) + ruleset.push({ permission: "pwsh", action: value, pattern: "*" }) + ruleset.push({ permission: "powershell", action: value, pattern: "*" }) + } else { + ruleset.push( + ...Object.entries(value).map(([pattern, action]) => ({ + permission: "bash", + pattern: expand(pattern), + action, + })), + ) + ruleset.push( + ...Object.entries(value).map(([pattern, action]) => ({ + permission: "pwsh", + pattern: expand(pattern), + action, + })), + ) + ruleset.push( + ...Object.entries(value).map(([pattern, action]) => ({ + permission: "powershell", + pattern: expand(pattern), + action, + })), + ) + } + continue + } if (typeof value === "string") { ruleset.push({ permission: key, action: value, pattern: "*" }) continue diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index a9edf838ca..ad265b1483 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1629,12 +1629,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the providerID: model.providerID, } await Session.updateMessage(msg) + const shell = Shell.preferred() + const shellName = Shell.name(shell) const part: MessageV2.Part = { type: "tool", id: PartID.ascending(), messageID: msg.id, sessionID: input.sessionID, - tool: "bash", + tool: shellName === "pwsh" ? "pwsh" : shellName === "powershell" ? "powershell" : "bash", callID: ulid(), state: { status: "running", @@ -1647,8 +1649,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the }, } await Session.updatePart(part) - const shell = Shell.preferred() - const shellName = Shell.name(shell) const invocations: Record = { nu: { diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts deleted file mode 100644 index 50aa9e14ad..0000000000 --- a/packages/opencode/src/tool/bash.ts +++ /dev/null @@ -1,500 +0,0 @@ -import z from "zod" -import os from "os" -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, 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" -import { Shell } from "@/shell/shell" - -import { BashArity } from "@/permission/arity" -import { Truncate } from "./truncate" -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 - patterns: Set - always: Set -} - -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) -} - -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 { - const scan: Scan = { - dirs: new Set(), - patterns: new Set(), - always: new Set(), - } - - 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((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\n" + metadata.join("\n") + "\n" - } - - 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, { - 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 { default: psWasm } = await import("tree-sitter-powershell/tree-sitter-powershell.wasm" as string, { - with: { type: "wasm" }, - }) - const bashPath = resolveWasm(bashWasm) - 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("${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(), - 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 ? 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 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, - }, - ctx, - ) - }, - } -}) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index eeb7334806..8c187cdbfc 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -1,6 +1,5 @@ import { PlanExitTool } from "./plan" import { QuestionTool } from "./question" -import { BashTool } from "./bash" import { EditTool } from "./edit" import { GlobTool } from "./glob" import { GrepTool } from "./grep" @@ -32,6 +31,10 @@ import { pathToFileURL } from "url" import { Effect, Layer, ServiceMap } from "effect" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" +import { BashTool } from "./shell/bash" +import { PwshTool } from "./shell/pwsh" +import { PowershellTool } from "./shell/powershell" +import { Shell } from "@/shell/shell" export namespace ToolRegistry { const log = Log.create({ service: "tool.registry" }) @@ -115,10 +118,13 @@ export namespace ToolRegistry { const cfg = yield* config.get() const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL + const shellName = Shell.name(Shell.acceptable()) + const ActiveShellTool = shellName === "pwsh" ? PwshTool : shellName === "powershell" ? PowershellTool : BashTool + return [ InvalidTool, ...(question ? [QuestionTool] : []), - BashTool, + ActiveShellTool, ReadTool, GlobTool, GrepTool, diff --git a/packages/opencode/src/tool/shell/arity.ts b/packages/opencode/src/tool/shell/arity.ts new file mode 100644 index 0000000000..3ec40b6646 --- /dev/null +++ b/packages/opencode/src/tool/shell/arity.ts @@ -0,0 +1,157 @@ +export namespace ShellArity { + export function prefix(tokens: string[], shellType: "bash" | "pwsh" | "powershell") { + if ( + (shellType === "pwsh" || shellType === "powershell") && + tokens.length > 0 && + /^[a-z]+-[a-z]+$/i.test(tokens[0]) + ) { + return [tokens[0]] + } + for (let len = tokens.length; len > 0; len--) { + const prefix = tokens.slice(0, len).join(" ") + const arity = ARITY[prefix] + if (arity !== undefined) return tokens.slice(0, arity) + } + if (tokens.length === 0) return [] + return tokens.slice(0, 1) + } + + const ARITY: Record = { + cat: 1, + cd: 1, + chmod: 1, + chown: 1, + cp: 1, + echo: 1, + env: 1, + export: 1, + grep: 1, + kill: 1, + killall: 1, + ln: 1, + ls: 1, + mkdir: 1, + mv: 1, + ps: 1, + pwd: 1, + rm: 1, + rmdir: 1, + sleep: 1, + source: 1, + tail: 1, + touch: 1, + unset: 1, + which: 1, + aws: 3, + az: 3, + bazel: 2, + brew: 2, + bun: 2, + "bun run": 3, + "bun x": 3, + cargo: 2, + "cargo add": 3, + "cargo run": 3, + cdk: 2, + cf: 2, + cmake: 2, + composer: 2, + consul: 2, + "consul kv": 3, + crictl: 2, + deno: 2, + "deno task": 3, + doctl: 3, + docker: 2, + "docker builder": 3, + "docker compose": 3, + "docker container": 3, + "docker image": 3, + "docker network": 3, + "docker volume": 3, + eksctl: 2, + "eksctl create": 3, + firebase: 2, + flyctl: 2, + gcloud: 3, + gh: 3, + git: 2, + "git config": 3, + "git remote": 3, + "git stash": 3, + go: 2, + gradle: 2, + helm: 2, + heroku: 2, + hugo: 2, + ip: 2, + "ip addr": 3, + "ip link": 3, + "ip netns": 3, + "ip route": 3, + kind: 2, + "kind create": 3, + kubectl: 2, + "kubectl kustomize": 3, + "kubectl rollout": 3, + kustomize: 2, + make: 2, + mc: 2, + "mc admin": 3, + minikube: 2, + mongosh: 2, + mysql: 2, + mvn: 2, + ng: 2, + npm: 2, + "npm exec": 3, + "npm init": 3, + "npm run": 3, + "npm view": 3, + nvm: 2, + nx: 2, + openssl: 2, + "openssl req": 3, + "openssl x509": 3, + pip: 2, + pipenv: 2, + pnpm: 2, + "pnpm dlx": 3, + "pnpm exec": 3, + "pnpm run": 3, + poetry: 2, + podman: 2, + "podman container": 3, + "podman image": 3, + psql: 2, + pulumi: 2, + "pulumi stack": 3, + pyenv: 2, + python: 2, + rake: 2, + rbenv: 2, + "redis-cli": 2, + rustup: 2, + serverless: 2, + sfdx: 3, + skaffold: 2, + sls: 2, + sst: 2, + swift: 2, + systemctl: 2, + terraform: 2, + "terraform workspace": 3, + tmux: 2, + turbo: 2, + ufw: 2, + vault: 2, + "vault auth": 3, + "vault kv": 3, + vercel: 2, + volta: 2, + wp: 2, + yarn: 2, + "yarn dlx": 3, + "yarn run": 3, + } +} diff --git a/packages/opencode/src/tool/shell/bash.ts b/packages/opencode/src/tool/shell/bash.ts new file mode 100644 index 0000000000..98e05c82d6 --- /dev/null +++ b/packages/opencode/src/tool/shell/bash.ts @@ -0,0 +1,76 @@ +import z from "zod" +import { Tool } from "../tool" +import DESCRIPTION from "./shell.txt" +import { Log } from "@/util/log" +import { Instance } from "@/project/instance" +import { Flag } from "@/flag/flag" +import { Shell } from "@/shell/shell" +import { resolvePath, formatShellDescription, askPermission } from "./util" +import { ShellParser } from "./parser" +import { ShellRunner } from "./runner" + +export const log = Log.create({ service: "bash-tool" }) + +const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 + +const NAME = "bash" + +export const BashTool = Tool.define(NAME, async () => { + const shell = Shell.acceptable() + const name = Shell.name(shell) + log.info("bash tool using shell", { shell, name }) + + return { + description: formatShellDescription(DESCRIPTION, { + name, + shellName: "Bash", + chaining: + "use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`).", + }), + 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 ? 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 scan = await ShellParser.collect({ + command: params.command, + cwd, + shell, + shellType: NAME, + }) + if (!Instance.containsPath(cwd)) scan.dirs.add(cwd) + + await askPermission(ctx, scan, NAME) + + return ShellRunner.run( + { + shell, + name, + command: params.command, + cwd, + env: await ShellRunner.shellEnv(ctx, cwd), + timeout, + description: params.description, + }, + ctx, + ) + }, + } +}) diff --git a/packages/opencode/src/tool/shell/parser.ts b/packages/opencode/src/tool/shell/parser.ts new file mode 100644 index 0000000000..c1507553eb --- /dev/null +++ b/packages/opencode/src/tool/shell/parser.ts @@ -0,0 +1,203 @@ +import { Language, Parser, type Node } from "web-tree-sitter" +import { lazy } from "@/util/lazy" +import { resolveWasm, resolvePath, unquote, home, expand, type Scan, type Part } from "./util" +import { Instance } from "@/project/instance" +import { Filesystem } from "@/util/filesystem" +import path from "path" +import { ShellArity } from "./arity" +import { Log } from "@/util/log" + +const log = Log.create({ service: "shell-parser" }) + +const CWD = new Set(["cd", "push-location", "set-location"]) +const FILES_BASE = new Set(["rm", "cp", "mv", "mkdir", "touch", "chmod", "chown", "cat"]) +const FILES_PWSH = new Set([ + "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"]) + +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 dynamic(text: string, isPwsh: boolean) { + if (text.startsWith("(") || text.startsWith("@(")) return true + if (text.includes("$(") || text.includes("${") || text.includes("`")) return true + if (isPwsh) 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) +} + +function provider(text: string) { + const match = text.match(/^([A-Za-z]+)::(.*)$/) + if (match) { + if (match[1].toLowerCase() !== "filesystem") return + return match[2] + } + const pre = text.match(/^([A-Za-z]+):(.*)$/) + if (!pre) return text + if (pre[1].length === 1) return text + return +} + +async function argPath(arg: string, cwd: string, shell: string, isPwsh: boolean) { + const text = isPwsh ? expand(arg, cwd, shell) : home(unquote(arg)) + const file = text && prefix(text) + if (!file || dynamic(file, isPwsh)) return + const next = isPwsh ? provider(file) : file + if (!next) return + return resolvePath(next, cwd, shell) +} + +function pathArgs(list: Part[], isPwsh: boolean) { + if (!isPwsh) { + 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 +} + +export namespace ShellParser { + const getParser = 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 { default: psWasm } = await import("tree-sitter-powershell/tree-sitter-powershell.wasm" as string, { + with: { type: "wasm" }, + }) + const bashPath = resolveWasm(bashWasm) + const psPath = resolveWasm(psWasm) + const bashLanguage = await Language.load(bashPath) + const psLanguage = await Language.load(psPath) + + const bash = new Parser() + bash.setLanguage(bashLanguage) + + const ps = new Parser() + ps.setLanguage(psLanguage) + + return { bash, ps } + }) + + export async function collect(opts: { + command: string + cwd: string + shell: string + shellType: "bash" | "pwsh" | "powershell" + }): Promise { + const isPwsh = opts.shellType === "pwsh" || opts.shellType === "powershell" + const parsers = await getParser() + const parser = isPwsh ? parsers.ps : parsers.bash + + const tree = parser.parse(opts.command) + if (!tree) throw new Error("Failed to parse command") + const root = tree.rootNode + + const scan: Scan = { + dirs: new Set(), + patterns: new Set(), + always: new Set(), + } + + const filesSet = new Set([...CWD, ...FILES_BASE, ...(isPwsh ? FILES_PWSH : [])]) + + for (const node of commands(root)) { + const commandParts = parts(node) + const tokens = commandParts.map((item) => item.text) + const cmd = isPwsh ? tokens[0]?.toLowerCase() : tokens[0] + + if (cmd && filesSet.has(cmd)) { + for (const arg of pathArgs(commandParts, isPwsh)) { + const resolved = await argPath(arg, opts.cwd, opts.shell, isPwsh) + 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(ShellArity.prefix(tokens, opts.shellType).join(" ") + " *") + } + } + + return scan + } +} diff --git a/packages/opencode/src/tool/shell/powershell.ts b/packages/opencode/src/tool/shell/powershell.ts new file mode 100644 index 0000000000..cc63ac2c02 --- /dev/null +++ b/packages/opencode/src/tool/shell/powershell.ts @@ -0,0 +1,76 @@ +import z from "zod" +import { Tool } from "../tool" +import DESCRIPTION from "./shell.txt" +import { Log } from "@/util/log" +import { Instance } from "@/project/instance" +import { Flag } from "@/flag/flag" +import { Shell } from "@/shell/shell" +import { resolvePath, formatShellDescription, askPermission } from "./util" +import { ShellParser } from "./parser" +import { ShellRunner } from "./runner" + +export const log = Log.create({ service: "powershell-tool" }) + +const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 + +const NAME = "powershell" + +export const PowershellTool = Tool.define(NAME, async () => { + const shell = Shell.acceptable() + const name = Shell.name(shell) + log.info("powershell tool using shell", { shell, name }) + + return { + description: formatShellDescription(DESCRIPTION, { + name, + shellName: "Windows PowerShell", + chaining: + "avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }`", + }), + 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 ? 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 scan = await ShellParser.collect({ + command: params.command, + cwd, + shell, + shellType: NAME, + }) + if (!Instance.containsPath(cwd)) scan.dirs.add(cwd) + + await askPermission(ctx, scan, NAME) + + return ShellRunner.run( + { + shell, + name, + command: params.command, + cwd, + env: await ShellRunner.shellEnv(ctx, cwd), + timeout, + description: params.description, + }, + ctx, + ) + }, + } +}) diff --git a/packages/opencode/src/tool/shell/pwsh.ts b/packages/opencode/src/tool/shell/pwsh.ts new file mode 100644 index 0000000000..525c9d4be3 --- /dev/null +++ b/packages/opencode/src/tool/shell/pwsh.ts @@ -0,0 +1,76 @@ +import z from "zod" +import { Tool } from "../tool" +import DESCRIPTION from "./shell.txt" +import { Log } from "@/util/log" +import { Instance } from "@/project/instance" +import { Flag } from "@/flag/flag" +import { Shell } from "@/shell/shell" +import { resolvePath, formatShellDescription, askPermission } from "./util" +import { ShellParser } from "./parser" +import { ShellRunner } from "./runner" + +export const log = Log.create({ service: "pwsh-tool" }) + +const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 + +const NAME = "pwsh" + +export const PwshTool = Tool.define(NAME, async () => { + const shell = Shell.acceptable() + const name = Shell.name(shell) + log.info("pwsh tool using shell", { shell, name }) + + return { + description: formatShellDescription(DESCRIPTION, { + name, + shellName: "PowerShell Core", + chaining: + "use a single PowerShell call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`).", + }), + 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 ? 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 scan = await ShellParser.collect({ + command: params.command, + cwd, + shell, + shellType: NAME, + }) + if (!Instance.containsPath(cwd)) scan.dirs.add(cwd) + + await askPermission(ctx, scan, NAME) + + return ShellRunner.run( + { + shell, + name, + command: params.command, + cwd, + env: await ShellRunner.shellEnv(ctx, cwd), + timeout, + description: params.description, + }, + ctx, + ) + }, + } +}) diff --git a/packages/opencode/src/tool/shell/runner.ts b/packages/opencode/src/tool/shell/runner.ts new file mode 100644 index 0000000000..0576040764 --- /dev/null +++ b/packages/opencode/src/tool/shell/runner.ts @@ -0,0 +1,140 @@ +import { spawn } from "child_process" +import { Shell } from "@/shell/shell" +import { Tool } from "../tool" +import { Plugin } from "@/plugin" + +const MAX_METADATA_LENGTH = 30_000 + +export function preview(text: string) { + if (text.length <= MAX_METADATA_LENGTH) return text + return text.slice(0, MAX_METADATA_LENGTH) + "\n\n..." +} + +export namespace ShellRunner { + export 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, + } + } + + export function launch(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) { + if (process.platform === "win32" && (name === "powershell" || name === "pwsh")) { + 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", + }) + } + + export 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((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\n" + metadata.join("\n") + "\n" + } + + return { + title: input.description, + metadata: { + output: preview(output), + exit: proc.exitCode, + description: input.description, + }, + output, + } + } +} diff --git a/packages/opencode/src/tool/bash.txt b/packages/opencode/src/tool/shell/shell.txt similarity index 77% rename from packages/opencode/src/tool/bash.txt rename to packages/opencode/src/tool/shell/shell.txt index 8d53c90ab4..7ef2ca73fb 100644 --- a/packages/opencode/src/tool/bash.txt +++ b/packages/opencode/src/tool/shell/shell.txt @@ -1,16 +1,16 @@ -Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures. +Executes a given shell command in a persistent ${shellName} session with optional timeout, ensuring proper handling and security measures. Be aware: OS: ${os}, Shell: ${shell} -All commands run in ${directory} by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd && ` patterns - use `workdir` instead. +All commands run in ${directory} by default. Use the \`workdir\` parameter if you need to run a command in a different directory. AVOID using \`cd && \` patterns - use \`workdir\` instead. IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead. Before executing the command, please follow these steps: 1. Directory Verification: - - If the command will create new directories or files, first use `ls` to verify the parent directory exists and is the correct location - - For example, before running "mkdir foo/bar", first use `ls foo` to check that "foo" exists and is the intended parent directory + - If the command will create new directories or files, first use \`ls\` to verify the parent directory exists and is the correct location + - For example, before running "mkdir foo/bar", first use \`ls foo\` to check that "foo" exists and is the intended parent directory 2. Command Execution: - Always quote file paths that contain spaces with double quotes (e.g., rm "path with spaces/file.txt") @@ -26,9 +26,9 @@ Usage notes: - The command argument is required. - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - - If the output exceeds ${maxLines} lines or ${maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use `head`, `tail`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. + - If the output exceeds ${maxLines} lines or ${maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`head\`, \`tail\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. - - Avoid using Bash with the `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - Avoid using Bash with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: - File search: Use Glob (NOT find or ls) - Content search: Use Grep (NOT grep or rg) - Read files: Use Read (NOT cat/head/tail) @@ -36,11 +36,11 @@ Usage notes: - Write files: Use Write (NOT echo >/cat < && `. Use the `workdir` parameter to change directories instead. + - AVOID using \`cd && \`. Use the \`workdir\` parameter to change directories instead. Use workdir="/foo/bar" with command: pytest tests @@ -65,7 +65,7 @@ Git Safety Protocol: - CRITICAL: If you already pushed to remote, NEVER amend unless user explicitly requests it (requires force push) - NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive. -1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel, each using the Bash tool: +1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands in parallel: - Run a git status command to see all untracked files. - Run a git diff command to see both staged and unstaged changes that will be committed. - Run a git log command to see recent commit messages, so that you can follow this repository's commit message style. @@ -89,15 +89,15 @@ Important notes: - If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit # Creating pull requests -Use the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a GitHub URL use the gh command to get the information needed. +Use the gh command for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a GitHub URL use the gh command to get the information needed. IMPORTANT: When the user asks you to create a pull request, follow these steps carefully: -1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel using the Bash tool, in order to understand the current state of the branch since it diverged from the main branch: +1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands in parallel, in order to understand the current state of the branch since it diverged from the main branch: - Run a git status command to see all untracked files - Run a git diff command to see both staged and unstaged changes that will be committed - Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote - - Run a git log command and `git diff [base-branch]...HEAD` to understand the full commit history for the current branch (from the time it diverged from the base branch) + - Run a git log command and \`git diff [base-branch]...HEAD\` to understand the full commit history for the current branch (from the time it diverged from the base branch) 2. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (NOT just the latest commit, but ALL commits that will be included in the pull request!!!), and draft a pull request summary 3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands in parallel: - Create new branch if needed @@ -114,4 +114,4 @@ Important: - Return the PR URL when you're done, so the user can see it # Other common operations -- View comments on a GitHub PR: gh api repos/foo/bar/pulls/123/comments +- View comments on a GitHub PR: gh api repos/foo/bar/pulls/123/comments \ No newline at end of file diff --git a/packages/opencode/src/tool/shell/util.ts b/packages/opencode/src/tool/shell/util.ts new file mode 100644 index 0000000000..0091ff8e8c --- /dev/null +++ b/packages/opencode/src/tool/shell/util.ts @@ -0,0 +1,115 @@ +import path from "path" +import os from "os" +import { Filesystem } from "@/util/filesystem" +import { Process } from "@/util/process" +import { Shell } from "@/shell/shell" +import { Truncate } from "../truncate" +import { Instance } from "@/project/instance" +import { Tool } from "../tool" +import { fileURLToPath } from "url" + +export type Part = { + type: string + text: string +} + +export type Scan = { + dirs: Set + patterns: Set + always: Set +} + +export function 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) +} + +export 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 +} + +export function home(text: string) { + if (text === "~") return os.homedir() + if (text.startsWith("~/") || text.startsWith("~\\")) return path.join(os.homedir(), text.slice(2)) + return text +} + +export 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 +} + +export 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) +} + +export 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) +} + +export 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) +} + +export 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) +} + +export function formatShellDescription(template: string, opts: { name: string; shellName: string; chaining: string }) { + return template + .replaceAll("${directory}", Instance.directory) + .replaceAll("${os}", process.platform) + .replaceAll("${shell}", opts.name) + .replaceAll("${shellName}", opts.shellName) + .replaceAll("${chaining}", opts.chaining) + .replaceAll("${maxLines}", String(Truncate.MAX_LINES)) + .replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)) +} + +export async function askPermission(ctx: Tool.Context, scan: Scan, permissionName: string = "bash") { + 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: permissionName, + patterns: Array.from(scan.patterns), + always: Array.from(scan.always), + metadata: {}, + }) +} diff --git a/packages/opencode/test/permission/arity.test.ts b/packages/opencode/test/permission/arity.test.ts index 634e41e724..5e2af7afc1 100644 --- a/packages/opencode/test/permission/arity.test.ts +++ b/packages/opencode/test/permission/arity.test.ts @@ -1,33 +1,39 @@ import { test, expect } from "bun:test" -import { BashArity } from "../../src/permission/arity" +import { ShellArity } from "../../src/tool/shell/arity" test("arity 1 - unknown commands default to first token", () => { - expect(BashArity.prefix(["unknown", "command", "subcommand"])).toEqual(["unknown"]) - expect(BashArity.prefix(["touch", "foo.txt"])).toEqual(["touch"]) + expect(ShellArity.prefix(["unknown", "command", "subcommand"], "bash")).toEqual(["unknown"]) + expect(ShellArity.prefix(["touch", "foo.txt"], "bash")).toEqual(["touch"]) }) test("arity 2 - two token commands", () => { - expect(BashArity.prefix(["git", "checkout", "main"])).toEqual(["git", "checkout"]) - expect(BashArity.prefix(["docker", "run", "nginx"])).toEqual(["docker", "run"]) + expect(ShellArity.prefix(["git", "checkout", "main"], "bash")).toEqual(["git", "checkout"]) + expect(ShellArity.prefix(["docker", "run", "nginx"], "bash")).toEqual(["docker", "run"]) }) test("arity 3 - three token commands", () => { - expect(BashArity.prefix(["aws", "s3", "ls", "my-bucket"])).toEqual(["aws", "s3", "ls"]) - expect(BashArity.prefix(["npm", "run", "dev", "script"])).toEqual(["npm", "run", "dev"]) + expect(ShellArity.prefix(["aws", "s3", "ls", "my-bucket"], "bash")).toEqual(["aws", "s3", "ls"]) + expect(ShellArity.prefix(["npm", "run", "dev", "script"], "bash")).toEqual(["npm", "run", "dev"]) }) test("longest match wins - nested prefixes", () => { - expect(BashArity.prefix(["docker", "compose", "up", "service"])).toEqual(["docker", "compose", "up"]) - expect(BashArity.prefix(["consul", "kv", "get", "config"])).toEqual(["consul", "kv", "get"]) + expect(ShellArity.prefix(["docker", "compose", "up", "service"], "bash")).toEqual(["docker", "compose", "up"]) + expect(ShellArity.prefix(["consul", "kv", "get", "config"], "bash")).toEqual(["consul", "kv", "get"]) }) test("exact length matches", () => { - expect(BashArity.prefix(["git", "checkout"])).toEqual(["git", "checkout"]) - expect(BashArity.prefix(["npm", "run", "dev"])).toEqual(["npm", "run", "dev"]) + expect(ShellArity.prefix(["git", "checkout"], "bash")).toEqual(["git", "checkout"]) + expect(ShellArity.prefix(["npm", "run", "dev"], "bash")).toEqual(["npm", "run", "dev"]) }) test("edge cases", () => { - expect(BashArity.prefix([])).toEqual([]) - expect(BashArity.prefix(["single"])).toEqual(["single"]) - expect(BashArity.prefix(["git"])).toEqual(["git"]) + expect(ShellArity.prefix([], "bash")).toEqual([]) + expect(ShellArity.prefix(["single"], "bash")).toEqual(["single"]) + expect(ShellArity.prefix(["git"], "bash")).toEqual(["git"]) +}) + +test("powershell verb-noun structures", () => { + expect(ShellArity.prefix(["Get-Content", "file.txt"], "pwsh")).toEqual(["Get-Content"]) + expect(ShellArity.prefix(["Remove-Item", "-Recurse", "dir"], "powershell")).toEqual(["Remove-Item"]) + expect(ShellArity.prefix(["git", "checkout", "main"], "pwsh")).toEqual(["git", "checkout"]) }) diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 043e3257b6..7e48fe33ff 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -34,7 +34,11 @@ async function waitForPending(count: number) { test("fromConfig - string value becomes wildcard rule", () => { const result = Permission.fromConfig({ bash: "allow" }) - expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }]) + expect(result).toEqual([ + { permission: "bash", pattern: "*", action: "allow" }, + { permission: "pwsh", pattern: "*", action: "allow" }, + { permission: "powershell", pattern: "*", action: "allow" }, + ]) }) test("fromConfig - object value converts to rules array", () => { @@ -42,6 +46,10 @@ test("fromConfig - object value converts to rules array", () => { expect(result).toEqual([ { permission: "bash", pattern: "*", action: "allow" }, { permission: "bash", pattern: "rm", action: "deny" }, + { permission: "pwsh", pattern: "*", action: "allow" }, + { permission: "pwsh", pattern: "rm", action: "deny" }, + { permission: "powershell", pattern: "*", action: "allow" }, + { permission: "powershell", pattern: "rm", action: "deny" }, ]) }) @@ -54,6 +62,10 @@ test("fromConfig - mixed string and object values", () => { expect(result).toEqual([ { permission: "bash", pattern: "*", action: "allow" }, { permission: "bash", pattern: "rm", action: "deny" }, + { permission: "pwsh", pattern: "*", action: "allow" }, + { permission: "pwsh", pattern: "rm", action: "deny" }, + { permission: "powershell", pattern: "*", action: "allow" }, + { permission: "powershell", pattern: "rm", action: "deny" }, { permission: "edit", pattern: "*", action: "allow" }, { permission: "webfetch", pattern: "*", action: "ask" }, ]) diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/shell.test.ts similarity index 92% rename from packages/opencode/test/tool/bash.test.ts rename to packages/opencode/test/tool/shell.test.ts index 0ea8ea073a..82dc5ca515 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -2,7 +2,9 @@ import { describe, expect, test } from "bun:test" import os from "os" import path from "path" import { Shell } from "../../src/shell/shell" -import { BashTool } from "../../src/tool/bash" +import { BashTool } from "../../src/tool/shell/bash" +import { PwshTool } from "../../src/tool/shell/pwsh" +import { PowershellTool } from "../../src/tool/shell/powershell" import { Instance } from "../../src/project/instance" import { Filesystem } from "../../src/util/filesystem" import { tmpdir } from "../fixture/fixture" @@ -86,6 +88,20 @@ const withShell = (item: { label: string; shell: string }, fn: () => Promise { + const name = sh() + if (name === "pwsh") return "pwsh" + if (name === "powershell") return "powershell" + return "bash" +} + +const getTool = async () => { + const name = sh() + if (name === "pwsh") return await PwshTool.init() + if (name === "powershell") return await PowershellTool.init() + return await BashTool.init() +} + const each = (name: string, fn: (item: { label: string; shell: string }) => Promise) => { for (const item of shells) { test( @@ -113,12 +129,12 @@ const mustTruncate = (result: { ) } -describe("tool.bash", () => { +describe("tool.shell", () => { each("basic", async () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const result = await bash.execute( { command: "echo test", @@ -133,13 +149,13 @@ describe("tool.bash", () => { }) }) -describe("tool.bash permissions", () => { +describe("tool.shell permissions", () => { each("asks for bash permission with correct pattern", async () => { await using tmp = await tmpdir() await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const requests: Array> = [] await bash.execute( { @@ -149,7 +165,7 @@ describe("tool.bash permissions", () => { capture(requests), ) expect(requests.length).toBe(1) - expect(requests[0].permission).toBe("bash") + expect(requests[0].permission).toBe(expectedPermission()) expect(requests[0].patterns).toContain("echo hello") }, }) @@ -160,7 +176,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const requests: Array> = [] await bash.execute( { @@ -170,7 +186,7 @@ describe("tool.bash permissions", () => { capture(requests), ) expect(requests.length).toBe(1) - expect(requests[0].permission).toBe("bash") + expect(requests[0].permission).toBe(expectedPermission()) expect(requests[0].patterns).toContain("echo foo") expect(requests[0].patterns).toContain("echo bar") }, @@ -184,7 +200,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const requests: Array> = [] await bash.execute( { @@ -193,7 +209,7 @@ describe("tool.bash permissions", () => { }, capture(requests), ) - const bashReq = requests.find((r) => r.permission === "bash") + const bashReq = requests.find((r) => r.permission === expectedPermission()) expect(bashReq).toBeDefined() expect(bashReq!.patterns).toContain("Write-Host foo") expect(bashReq!.patterns).toContain("Write-Host bar") @@ -208,7 +224,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] const file = process.platform === "win32" ? `${process.env.WINDIR!.replaceAll("\\", "/")}/*` : "/etc/*" @@ -242,7 +258,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const file = path.join(outerTmp.path, "outside.txt").replaceAll("\\", "/") const requests: Array> = [] await bash.execute( @@ -253,7 +269,7 @@ describe("tool.bash permissions", () => { capture(requests), ) const extDirReq = requests.find((r) => r.permission === "external_directory") - const bashReq = requests.find((r) => r.permission === "bash") + const bashReq = requests.find((r) => r.permission === expectedPermission()) expect(extDirReq).toBeDefined() expect(extDirReq!.patterns).toContain(glob(path.join(outerTmp.path, "*"))) expect(bashReq).toBeDefined() @@ -273,7 +289,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -301,7 +317,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const requests: Array> = [] const file = `${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini` await bash.execute( @@ -312,7 +328,7 @@ describe("tool.bash permissions", () => { capture(requests), ) const extDirReq = requests.find((r) => r.permission === "external_directory") - const bashReq = requests.find((r) => r.permission === "bash") + const bashReq = requests.find((r) => r.permission === expectedPermission()) expect(extDirReq).toBeDefined() expect(extDirReq!.patterns).toContain(glob(path.join(process.env.WINDIR!, "*"))) expect(bashReq).toBeDefined() @@ -331,7 +347,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -359,7 +375,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -388,7 +404,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -416,7 +432,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -448,7 +464,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] const root = path.parse(process.env.WINDIR!).root.replace(/[\\/]+$/, "") @@ -481,7 +497,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const requests: Array> = [] await bash.execute( { @@ -508,7 +524,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -538,7 +554,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -568,7 +584,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const requests: Array> = [] await bash.execute( { @@ -578,7 +594,7 @@ describe("tool.bash permissions", () => { capture(requests), ) const extDirReq = requests.find((r) => r.permission === "external_directory") - const bashReq = requests.find((r) => r.permission === "bash") + const bashReq = requests.find((r) => r.permission === expectedPermission()) expect(extDirReq).toBeDefined() expect(extDirReq!.patterns).toContain( Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")), @@ -597,7 +613,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const requests: Array> = [] await bash.execute( { @@ -606,7 +622,7 @@ describe("tool.bash permissions", () => { }, capture(requests), ) - const bashReq = requests.find((r) => r.permission === "bash") + const bashReq = requests.find((r) => r.permission === expectedPermission()) expect(bashReq).toBeDefined() expect(bashReq!.patterns).not.toContain("a * 3") expect(bashReq!.always).not.toContain("a *") @@ -622,7 +638,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -645,7 +661,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -673,7 +689,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const want = Filesystem.normalizePathPattern(path.join(outerTmp.path, "*")) for (const dir of forms(outerTmp.path)) { @@ -707,7 +723,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] const want = glob(path.join(os.tmpdir(), "*")) @@ -737,7 +753,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] const want = glob(path.join(os.tmpdir(), "*")) @@ -772,7 +788,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] const filepath = path.join(outerTmp.path, "outside.txt") @@ -803,7 +819,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const requests: Array> = [] await bash.execute( { @@ -823,7 +839,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const requests: Array> = [] await bash.execute( { @@ -844,7 +860,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const requests: Array> = [] await bash.execute( { @@ -853,7 +869,7 @@ describe("tool.bash permissions", () => { }, capture(requests), ) - const bashReq = requests.find((r) => r.permission === "bash") + const bashReq = requests.find((r) => r.permission === expectedPermission()) expect(bashReq).toBeUndefined() }, }) @@ -864,7 +880,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -873,7 +889,7 @@ describe("tool.bash permissions", () => { capture(requests, err), ), ).rejects.toThrow(err.message) - const bashReq = requests.find((r) => r.permission === "bash") + const bashReq = requests.find((r) => r.permission === expectedPermission()) expect(bashReq).toBeDefined() expect(bashReq!.patterns).toContain("echo test > output.txt") }, @@ -885,10 +901,10 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const requests: Array> = [] await bash.execute({ command: "ls -la", description: "List" }, capture(requests)) - const bashReq = requests.find((r) => r.permission === "bash") + const bashReq = requests.find((r) => r.permission === expectedPermission()) expect(bashReq).toBeDefined() expect(bashReq!.always[0]).toBe("ls *") }, @@ -896,12 +912,12 @@ describe("tool.bash permissions", () => { }) }) -describe("tool.bash truncation", () => { - test("truncates output exceeding line limit", async () => { +describe("tool.shell truncation", () => { + each("truncates output exceeding line limit", async () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const lineCount = Truncate.MAX_LINES + 500 const result = await bash.execute( { @@ -917,11 +933,11 @@ describe("tool.bash truncation", () => { }) }) - test("truncates output exceeding byte limit", async () => { + each("truncates output exceeding byte limit", async () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const byteCount = Truncate.MAX_BYTES + 10000 const result = await bash.execute( { @@ -937,11 +953,11 @@ describe("tool.bash truncation", () => { }) }) - test("does not truncate small output", async () => { + each("does not truncate small output", async () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const result = await bash.execute( { command: "echo hello", @@ -955,11 +971,11 @@ describe("tool.bash truncation", () => { }) }) - test("full output is saved to file when truncated", async () => { + each("full output is saved to file when truncated", async () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const lineCount = Truncate.MAX_LINES + 100 const result = await bash.execute( {