From 3f459819ba6d3224eef6bbc88b4f239fa89491af Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Sun, 3 May 2026 09:18:48 +1000 Subject: feat: refactor bash tool with shell-aware prompts for bash, pwsh+powershell, and cmd (#20039) --- packages/opencode/src/acp/agent.ts | 36 +- packages/opencode/src/cli/cmd/github.ts | 2 +- packages/opencode/src/cli/cmd/run.ts | 7 +- .../src/cli/cmd/tui/routes/session/index.tsx | 9 +- .../src/cli/cmd/tui/routes/session/permission.tsx | 3 +- packages/opencode/src/session/prompt.ts | 3 +- packages/opencode/src/tool/bash.ts | 635 ---------- packages/opencode/src/tool/bash.txt | 119 -- packages/opencode/src/tool/registry.ts | 8 +- packages/opencode/src/tool/shell.ts | 618 ++++++++++ packages/opencode/src/tool/shell/id.ts | 19 + packages/opencode/src/tool/shell/prompt.ts | 297 +++++ packages/opencode/src/tool/shell/shell.txt | 77 ++ packages/opencode/test/session/message-v2.test.ts | 6 +- packages/opencode/test/tool/bash.test.ts | 1224 ------------------- packages/opencode/test/tool/parameters.test.ts | 16 +- packages/opencode/test/tool/shell.test.ts | 1288 ++++++++++++++++++++ 17 files changed, 2348 insertions(+), 2019 deletions(-) delete mode 100644 packages/opencode/src/tool/bash.ts delete mode 100644 packages/opencode/src/tool/bash.txt create mode 100644 packages/opencode/src/tool/shell.ts create mode 100644 packages/opencode/src/tool/shell/id.ts create mode 100644 packages/opencode/src/tool/shell/prompt.ts create mode 100644 packages/opencode/src/tool/shell/shell.txt delete mode 100644 packages/opencode/test/tool/bash.test.ts create mode 100644 packages/opencode/test/tool/shell.test.ts diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index af16cba11..8bbc2427f 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -51,6 +51,7 @@ import { LoadAPIKeyError } from "ai" import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2" import { applyPatch } from "diff" import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { ShellID } from "@/tool/shell/id" type ModeOption = { id: string; name: string; description?: string } type ModelOption = { modelId: string; name: string } @@ -144,7 +145,7 @@ export class Agent implements ACPAgent { private sessionManager: ACPSessionManager private eventAbort = new AbortController() private eventStarted = false - private bashSnapshots = new Map() + private shellSnapshots = new Map() private toolStarts = new Set() private permissionQueues = new Map>() private permissionOptions: PermissionOption[] = [ @@ -283,16 +284,16 @@ export class Agent implements ACPAgent { switch (part.state.status) { case "pending": - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) return case "running": - const output = this.bashOutput(part) + const output = this.shellOutput(part) const content: ToolCallContent[] = [] if (output) { const hash = Hash.fast(output) - if (part.tool === "bash") { - if (this.bashSnapshots.get(part.callID) === hash) { + if (part.tool === ShellID.ToolID) { + if (this.shellSnapshots.get(part.callID) === hash) { await this.connection .sessionUpdate({ sessionId, @@ -311,7 +312,7 @@ export class Agent implements ACPAgent { }) return } - this.bashSnapshots.set(part.callID, hash) + this.shellSnapshots.set(part.callID, hash) } content.push({ type: "content", @@ -342,7 +343,7 @@ export class Agent implements ACPAgent { case "completed": { this.toolStarts.delete(part.callID) - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) const kind = toToolKind(part.tool) const content: ToolCallContent[] = [ { @@ -423,7 +424,7 @@ export class Agent implements ACPAgent { } case "error": this.toolStarts.delete(part.callID) - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) await this.connection .sessionUpdate({ sessionId, @@ -837,10 +838,10 @@ export class Agent implements ACPAgent { await this.toolStart(sessionId, part) switch (part.state.status) { case "pending": - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) break case "running": - const output = this.bashOutput(part) + const output = this.shellOutput(part) const runningContent: ToolCallContent[] = [] if (output) { runningContent.push({ @@ -871,7 +872,7 @@ export class Agent implements ACPAgent { break case "completed": this.toolStarts.delete(part.callID) - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) const kind = toToolKind(part.tool) const content: ToolCallContent[] = [ { @@ -951,7 +952,7 @@ export class Agent implements ACPAgent { break case "error": this.toolStarts.delete(part.callID) - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) await this.connection .sessionUpdate({ sessionId, @@ -1105,8 +1106,8 @@ export class Agent implements ACPAgent { } } - private bashOutput(part: ToolPart) { - if (part.tool !== "bash") return + private shellOutput(part: ToolPart) { + if (part.tool !== ShellID.ToolID) 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 @@ -1549,9 +1550,11 @@ export class Agent implements ACPAgent { function toToolKind(toolName: string): ToolKind { const tool = toolName.toLocaleLowerCase() + switch (tool) { - case "bash": + case ShellID.ToolID: return "execute" + case "webfetch": return "fetch" @@ -1576,6 +1579,7 @@ function toToolKind(toolName: string): ToolKind { function toLocations(toolName: string, input: Record): { path: string }[] { const tool = toolName.toLocaleLowerCase() + switch (tool) { case "read": case "edit": @@ -1584,7 +1588,7 @@ function toLocations(toolName: string, input: Record): { path: stri case "glob": case "grep": return input["path"] ? [{ path: input["path"] }] : [] - case "bash": + case ShellID.ToolID: return [] default: return [] diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index 106d48466..a75dc3163 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -879,7 +879,7 @@ export const GithubRunCommand = cmd({ function subscribeSessionEvents() { const TOOL: Record = { todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD], - bash: ["Bash", UI.Style.TEXT_DANGER_BOLD], + bash: ["Shell", UI.Style.TEXT_DANGER_BOLD], edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD], glob: ["Glob", UI.Style.TEXT_INFO_BOLD], grep: ["Grep", UI.Style.TEXT_INFO_BOLD], diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index c94e96203..f73ca6717 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -22,7 +22,8 @@ import { WriteTool } from "../../tool/write" import { WebSearchTool } from "../../tool/websearch" import { TaskTool } from "../../tool/task" import { SkillTool } from "../../tool/skill" -import { BashTool } from "../../tool/bash" +import { ShellTool } from "../../tool/shell" +import { ShellID } from "../../tool/shell/id" import { TodoWriteTool } from "../../tool/todo" import { Locale } from "@/util/locale" import { AppRuntime } from "@/effect/app-runtime" @@ -175,7 +176,7 @@ function skill(info: ToolProps) { }) } -function bash(info: ToolProps) { +function shell(info: ToolProps) { const output = info.part.state.status === "completed" ? info.part.state.output?.trim() : undefined block( { @@ -400,7 +401,7 @@ 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 === ShellID.ToolID) return shell(props(part)) if (part.tool === "glob") return glob(props(part)) if (part.tool === "grep") return grep(props(part)) if (part.tool === "read") return read(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 8855338d1..d43edd2dd 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -37,7 +37,8 @@ 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 { ShellTool } from "@/tool/shell" +import { ShellID } from "@/tool/shell/id" import type { GlobTool } from "@/tool/glob" import { TodoWriteTool } from "@/tool/todo" import type { GrepTool } from "@/tool/grep" @@ -1552,8 +1553,8 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess return ( - - + + @@ -1784,7 +1785,7 @@ function BlockTool(props: { ) } -function Bash(props: ToolProps) { +function Shell(props: ToolProps) { const { theme } = useTheme() const sync = useSync() const isRunning = createMemo(() => props.part.state.status === "running") 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 720a05ff7..e7e4c7cea 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -15,6 +15,7 @@ import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import { Keybind } from "@/util/keybind" import { Locale } from "@/util/locale" import { Global } from "@opencode-ai/core/global" +import { ShellID } from "@/tool/shell/id" import { useDialog } from "../../ui/dialog" import { getScrollAcceleration } from "../../util/scroll" import { useTuiConfig } from "../../context/tui-config" @@ -287,7 +288,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { } } - if (permission === "bash") { + if (permission === ShellID.ToolID) { 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/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 80c47d3ce..9f1420388 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -41,6 +41,7 @@ import { Permission } from "@/permission" import { SessionStatus } from "./status" import { LLM } from "./llm" import { Shell } from "@/shell/shell" +import { ShellID } from "@/tool/shell/id" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Truncate } from "@/tool/truncate" import { decodeDataUrl } from "@/util/data-url" @@ -789,7 +790,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the id: PartID.ascending(), messageID: msg.id, sessionID: input.sessionID, - tool: "bash", + tool: ShellID.ToolID, callID: ulid(), state: { status: "running", diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts deleted file mode 100644 index bf0008250..000000000 --- a/packages/opencode/src/tool/bash.ts +++ /dev/null @@ -1,635 +0,0 @@ -import { Schema } from "effect" -import { PositiveInt } from "@/util/schema" -import os from "os" -import { createWriteStream } from "node:fs" -import * as Tool from "./tool" -import path from "path" -import DESCRIPTION from "./bash.txt" -import * as Log from "@opencode-ai/core/util/log" -import { containsPath, type InstanceContext } from "../project/instance-context" -import { lazy } from "@/util/lazy" -import { Language, type Node } from "web-tree-sitter" - -import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { fileURLToPath } from "url" -import { Config } from "@/config/config" -import { Flag } from "@opencode-ai/core/flag/flag" -import { Global } from "@opencode-ai/core/global" -import { Shell } from "@/shell/shell" - -import { BashArity } from "@/permission/arity" -import * as Truncate from "./truncate" -import { Plugin } from "@/plugin" -import { Effect, Stream } from "effect" -import { ChildProcess } from "effect/unstable/process" -import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" -import { InstanceState } from "@/effect/instance-state" - -const MAX_METADATA_LENGTH = 30_000 -const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 -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"]) - -export const Parameters = Schema.Struct({ - command: Schema.String.annotate({ description: "The command to execute" }), - timeout: Schema.optional(PositiveInt).annotate({ description: "Optional timeout in milliseconds" }), - workdir: Schema.optional(Schema.String).annotate({ - description: `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`, - }), - description: Schema.String.annotate({ - description: - "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'", - }), -}) - -type Part = { - type: string - text: string -} - -type Scan = { - dirs: Set - patterns: Set - always: Set -} - -type Chunk = { - text: string - size: number -} - -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) -} - -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 -} - -function preview(text: string) { - if (text.length <= MAX_METADATA_LENGTH) return text - return "...\n\n" + text.slice(-MAX_METADATA_LENGTH) -} - -function tail(text: string, maxLines: number, maxBytes: number) { - const lines = text.split("\n") - if (lines.length <= maxLines && Buffer.byteLength(text, "utf-8") <= maxBytes) { - return { - text, - cut: false, - } - } - - const out: string[] = [] - let bytes = 0 - for (let i = lines.length - 1; i >= 0 && out.length < maxLines; i--) { - const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0) - if (bytes + size > maxBytes) { - if (out.length === 0) { - const buf = Buffer.from(lines[i], "utf-8") - let start = buf.length - maxBytes - if (start < 0) start = 0 - while (start < buf.length && (buf[start] & 0xc0) === 0x80) start++ - out.unshift(buf.subarray(start).toString("utf-8")) - } - break - } - out.unshift(lines[i]) - bytes += size - } - return { - text: out.join("\n"), - cut: true, - } -} - -const parse = Effect.fn("BashTool.parse")(function* (command: string, ps: boolean) { - const tree = yield* Effect.promise(() => parser().then((p) => (ps ? p.ps : p.bash).parse(command))) - if (!tree) throw new Error("Failed to parse command") - return tree -}) - -const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan) { - if (scan.dirs.size > 0) { - const globs = Array.from(scan.dirs).map((dir) => { - if (process.platform === "win32") return AppFileSystem.normalizePathPattern(path.join(dir, "*")) - return path.join(dir, "*") - }) - yield* ctx.ask({ - permission: "external_directory", - patterns: globs, - always: globs, - metadata: {}, - }) - } - - if (scan.patterns.size === 0) return - yield* ctx.ask({ - permission: "bash", - patterns: Array.from(scan.patterns), - always: Array.from(scan.always), - metadata: {}, - }) -}) - -function cmd(shell: string, command: string, cwd: string, env: NodeJS.ProcessEnv) { - if (process.platform === "win32" && Shell.ps(shell)) { - return ChildProcess.make(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], { - cwd, - env, - stdin: "ignore", - detached: false, - }) - } - - return ChildProcess.make(command, [], { - shell, - cwd, - env, - stdin: "ignore", - detached: process.platform !== "win32", - }) -} -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", - Effect.gen(function* () { - const config = yield* Config.Service - const spawner = yield* ChildProcessSpawner - const fs = yield* AppFileSystem.Service - const trunc = yield* Truncate.Service - const plugin = yield* Plugin.Service - - const cygpath = Effect.fn("BashTool.cygpath")(function* (shell: string, text: string) { - const lines = yield* spawner - .lines(ChildProcess.make(shell, ["-lc", 'cygpath -w -- "$1"', "_", text])) - .pipe(Effect.catch(() => Effect.succeed([] as string[]))) - const file = lines[0]?.trim() - if (!file) return - return AppFileSystem.normalizePath(file) - }) - - const resolvePath = Effect.fn("BashTool.resolvePath")(function* (text: string, root: string, shell: string) { - if (process.platform === "win32") { - if (Shell.posix(shell) && text.startsWith("/") && AppFileSystem.windowsPath(text) === text) { - const file = yield* cygpath(shell, text) - if (file) return file - } - return AppFileSystem.normalizePath(path.resolve(root, AppFileSystem.windowsPath(text))) - } - return path.resolve(root, text) - }) - - const argPath = Effect.fn("BashTool.argPath")(function* (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 yield* resolvePath(next, cwd, shell) - }) - - const collect = Effect.fn("BashTool.collect")(function* ( - root: Node, - cwd: string, - ps: boolean, - shell: string, - instance: InstanceContext, - ) { - 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 = yield* argPath(arg, cwd, ps, shell) - log.info("resolved path", { arg, resolved }) - if (!resolved || containsPath(resolved, instance)) continue - const dir = (yield* fs.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 - }) - - const shellEnv = Effect.fn("BashTool.shellEnv")(function* (ctx: Tool.Context, cwd: string) { - const extra = yield* plugin.trigger( - "shell.env", - { cwd, sessionID: ctx.sessionID, callID: ctx.callID }, - { env: {} }, - ) - return { - ...process.env, - ...extra.env, - } - }) - - const run = Effect.fn("BashTool.run")(function* ( - input: { - shell: string - command: string - cwd: string - env: NodeJS.ProcessEnv - timeout: number - description: string - }, - ctx: Tool.Context, - ) { - const limits = yield* trunc.limits() - const keep = limits.maxBytes * 2 - let full = "" - let last = "" - const list: Chunk[] = [] - let used = 0 - let file = "" - let sink: ReturnType | undefined - let cut = false - let expired = false - let aborted = false - - yield* ctx.metadata({ - metadata: { - output: "", - description: input.description, - }, - }) - - const code: number | null = yield* Effect.scoped( - Effect.gen(function* () { - const handle = yield* spawner.spawn(cmd(input.shell, input.command, input.cwd, input.env)) - - yield* Effect.forkScoped( - Stream.runForEach(Stream.decodeText(handle.all), (chunk) => { - const size = Buffer.byteLength(chunk, "utf-8") - list.push({ text: chunk, size }) - used += size - while (used > keep && list.length > 1) { - const item = list.shift() - if (!item) break - used -= item.size - cut = true - } - - last = preview(last + chunk) - - if (file) { - sink?.write(chunk) - } else { - full += chunk - if (Buffer.byteLength(full, "utf-8") > limits.maxBytes) { - return trunc.write(full).pipe( - Effect.andThen((next) => - Effect.sync(() => { - file = next - cut = true - sink = createWriteStream(next, { flags: "a" }) - full = "" - }), - ), - Effect.andThen( - ctx.metadata({ - metadata: { - output: last, - description: input.description, - }, - }), - ), - ) - } - } - - return ctx.metadata({ - metadata: { - output: last, - description: input.description, - }, - }) - }), - ) - - const abort = Effect.callback((resume) => { - if (ctx.abort.aborted) return resume(Effect.void) - const handler = () => resume(Effect.void) - ctx.abort.addEventListener("abort", handler, { once: true }) - return Effect.sync(() => ctx.abort.removeEventListener("abort", handler)) - }) - - const timeout = Effect.sleep(`${input.timeout + 100} millis`) - - const exit = yield* Effect.raceAll([ - handle.exitCode.pipe(Effect.map((code) => ({ kind: "exit" as const, code }))), - abort.pipe(Effect.map(() => ({ kind: "abort" as const, code: null }))), - timeout.pipe(Effect.map(() => ({ kind: "timeout" as const, code: null }))), - ]) - - if (exit.kind === "abort") { - aborted = true - yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie) - } - if (exit.kind === "timeout") { - expired = true - yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie) - } - - return exit.kind === "exit" ? exit.code : null - }), - ).pipe(Effect.orDie) - - const meta: string[] = [] - if (expired) { - meta.push( - `bash tool terminated command after exceeding timeout ${input.timeout} ms. If this command is expected to take longer and is not waiting for interactive input, retry with a larger timeout value in milliseconds.`, - ) - } - if (aborted) meta.push("User aborted the command") - const raw = list.map((item) => item.text).join("") - const end = tail(raw, limits.maxLines, limits.maxBytes) - if (end.cut) cut = true - if (!file && end.cut) { - file = yield* trunc.write(raw) - } - - let output = end.text - if (!output) output = "(no output)" - - if (cut && file) { - output = `...output truncated...\n\nFull output saved to: ${file}\n\n` + output - } - - if (meta.length > 0) { - output += "\n\n\n" + meta.join("\n") + "\n" - } - if (sink) { - const stream = sink - yield* Effect.promise( - () => - new Promise((resolve) => { - stream.end(() => resolve()) - stream.on("error", () => resolve()) - }), - ) - } - - return { - title: input.description, - metadata: { - output: last || preview(output), - exit: code, - description: input.description, - truncated: cut, - ...(cut && file ? { outputPath: file } : {}), - }, - output, - } - }) - - return () => - Effect.gen(function* () { - const cfg = yield* config.get() - const shell = Shell.acceptable(cfg.shell) - 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 }) - - const limits = yield* trunc.limits() - const instance = yield* InstanceState.context - - return { - description: DESCRIPTION.replaceAll("${directory}", instance.directory) - .replaceAll("${tmp}", Global.Path.tmp) - .replaceAll("${os}", process.platform) - .replaceAll("${shell}", name) - .replaceAll("${chaining}", chain) - .replaceAll("${maxLines}", String(limits.maxLines)) - .replaceAll("${maxBytes}", String(limits.maxBytes)), - parameters: Parameters, - execute: (params: Schema.Schema.Type, ctx: Tool.Context) => - Effect.gen(function* () { - const executeInstance = yield* InstanceState.context - const cwd = params.workdir - ? yield* resolvePath(params.workdir, executeInstance.directory, shell) - : executeInstance.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 = Shell.ps(shell) - yield* Effect.scoped( - Effect.gen(function* () { - const tree = yield* Effect.acquireRelease(parse(params.command, ps), (tree) => - Effect.sync(() => tree.delete()), - ) - const scan = yield* collect(tree.rootNode, cwd, ps, shell, executeInstance) - if (!containsPath(cwd, executeInstance)) scan.dirs.add(cwd) - yield* ask(ctx, scan) - }), - ) - - return yield* run( - { - shell, - command: params.command, - cwd, - env: yield* shellEnv(ctx, cwd), - timeout, - description: params.description, - }, - ctx, - ) - }), - } - }) - }), -) diff --git a/packages/opencode/src/tool/bash.txt b/packages/opencode/src/tool/bash.txt deleted file mode 100644 index a131ed7e6..000000000 --- a/packages/opencode/src/tool/bash.txt +++ /dev/null @@ -1,119 +0,0 @@ -Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures. - -Be aware: OS: ${os}, Shell: ${shell} - -All commands run in the current working 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. - -Use `${tmp}` for temporary work outside the workspace. This directory has already been created, already exists, and is pre-approved for external directory access. - -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 - -2. Command Execution: - - Always quote file paths that contain spaces with double quotes (e.g., rm "path with spaces/file.txt") - - Examples of proper quoting: - - mkdir "/Users/name/My Documents" (correct) - - mkdir /Users/name/My Documents (incorrect - will fail) - - python "/path/with spaces/script.py" (correct) - - python /path/with spaces/script.py (incorrect - will fail) - - After ensuring proper quoting, execute the command. - - Capture the output of the command. - -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. - - - 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) - - Edit files: Use Edit (NOT sed/awk) - - Write files: Use Write (NOT echo >/cat < && `. Use the `workdir` parameter to change directories instead. - - Use workdir="/foo/bar" with command: pytest tests - - - cd /foo/bar && pytest tests - - -# Committing changes with git - -Only create commits when requested by the user. If unclear, ask first. When the user asks you to create a new git commit, follow these steps carefully: - -Git Safety Protocol: -- NEVER update the git config -- NEVER run destructive/irreversible git commands (like push --force, hard reset, etc) unless the user explicitly requests them -- NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it -- NEVER run force push to main/master, warn the user if they request it -- Avoid git commit --amend. ONLY use --amend when ALL conditions are met: - (1) User explicitly requested amend, OR the commit succeeded and pre-commit hooks auto-modified files that need including — verify by checking `git log` that HEAD is the new commit before amending - (2) HEAD commit was created by you in this conversation (verify: git log -1 --format='%an %ae') - (3) Commit has NOT been pushed to remote (verify: git status shows "Your branch is ahead") -- CRITICAL: If commit FAILED or was REJECTED by hook, NEVER amend - fix the issue and create a NEW commit -- 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: - - 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. -2. Analyze all staged changes (both previously staged and newly added) and draft a commit message: - - Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.). Ensure the message accurately reflects the changes and their purpose (i.e. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.). - - Do not commit files that likely contain secrets (.env, credentials.json, etc.). Warn the user if they specifically request to commit those files - - Draft a concise (1-2 sentences) commit message that focuses on the "why" rather than the "what" - - Ensure it accurately reflects the changes and their purpose -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: - - Add relevant untracked files to the staging area. - - Create the commit with a message - - Run git status after the commit completes to verify success. - Note: git status depends on the commit completing, so run it sequentially after the commit. -4. If the commit fails due to pre-commit hook, fix the issue and create a NEW commit (see amend rules above) - -Important notes: -- NEVER run additional commands to read or explore code, besides git bash commands -- NEVER use the TodoWrite or Task tools -- DO NOT push to the remote repository unless the user explicitly asks you to do so -- IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported. -- 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. - -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: - - 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) -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 - - Push to remote with -u flag if needed - - Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting. - -gh pr create --title "the pr title" --body "$(cat <<'EOF' -## Summary -<1-3 bullet points> - - -Important: -- DO NOT use the TodoWrite or Task tools -- 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 diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index ebe3bb530..a4eb31acc 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -1,7 +1,7 @@ import { PlanExitTool } from "./plan" import { Session } from "@/session/session" import { QuestionTool } from "./question" -import { BashTool } from "./bash" +import { ShellTool } from "./shell" import { EditTool } from "./edit" import { GlobTool } from "./glob" import { GrepTool } from "./grep" @@ -106,7 +106,7 @@ export const layer: Layer.Layer< const plan = yield* PlanExitTool const webfetch = yield* WebFetchTool const websearch = yield* WebSearchTool - const bash = yield* BashTool + const shell = yield* ShellTool const globtool = yield* GlobTool const writetool = yield* WriteTool const edit = yield* EditTool @@ -195,7 +195,7 @@ export const layer: Layer.Layer< const tool = yield* Effect.all({ invalid: Tool.init(invalid), - bash: Tool.init(bash), + shell: Tool.init(shell), read: Tool.init(read), glob: Tool.init(globtool), grep: Tool.init(greptool), @@ -217,7 +217,7 @@ export const layer: Layer.Layer< builtin: [ tool.invalid, ...(questionEnabled ? [tool.question] : []), - tool.bash, + tool.shell, tool.read, tool.glob, tool.grep, diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts new file mode 100644 index 000000000..bb2e4e58d --- /dev/null +++ b/packages/opencode/src/tool/shell.ts @@ -0,0 +1,618 @@ +import { Effect, Stream } from "effect" +import os from "os" +import { createWriteStream } from "node:fs" +import * as Tool from "./tool" +import path from "path" +import * as Log from "@opencode-ai/core/util/log" +import { containsPath, type InstanceContext } from "../project/instance-context" +import { InstanceState } from "@/effect/instance-state" +import { lazy } from "@/util/lazy" +import { Language, type Node } from "web-tree-sitter" + +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { fileURLToPath } from "url" +import { Config } from "@/config/config" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Shell } from "@/shell/shell" +import { ShellID } from "./shell/id" + +import * as Truncate from "./truncate" +import { Plugin } from "@/plugin" +import { ChildProcess } from "effect/unstable/process" +import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" +import { ShellPrompt, type Parameters } from "./shell/prompt" +import { BashArity } from "@/permission/arity" + +export { Parameters } from "./shell/prompt" + +const MAX_METADATA_LENGTH = 30_000 +const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 +const CWD = new Set(["cd", "chdir", "popd", "pushd", "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 CMD_FILES = new Set(["copy", "del", "dir", "erase", "md", "mkdir", "move", "rd", "ren", "rename", "rmdir", "type"]) +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 +} + +type Chunk = { + text: string + size: number +} + +export const log = Log.create({ service: "shell-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) +} + +function pathArgs(list: Part[], ps: boolean, cmd = false) { + if (!ps) { + return list + .slice(1) + .filter( + (item) => + !item.text.startsWith("-") && + !(cmd && 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 +} + +function preview(text: string) { + if (text.length <= MAX_METADATA_LENGTH) return text + return "...\n\n" + text.slice(-MAX_METADATA_LENGTH) +} + +function tail(text: string, maxLines: number, maxBytes: number) { + const lines = text.split("\n") + if (lines.length <= maxLines && Buffer.byteLength(text, "utf-8") <= maxBytes) { + return { + text, + cut: false, + } + } + + const out: string[] = [] + let bytes = 0 + for (let i = lines.length - 1; i >= 0 && out.length < maxLines; i--) { + const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0) + if (bytes + size > maxBytes) { + if (out.length === 0) { + const buf = Buffer.from(lines[i], "utf-8") + let start = buf.length - maxBytes + if (start < 0) start = 0 + while (start < buf.length && (buf[start] & 0xc0) === 0x80) start++ + out.unshift(buf.subarray(start).toString("utf-8")) + } + break + } + out.unshift(lines[i]) + bytes += size + } + return { + text: out.join("\n"), + cut: true, + } +} + +const parse = Effect.fn("ShellTool.parse")(function* (command: string, ps: boolean) { + const tree = yield* Effect.promise(() => parser().then((p) => (ps ? p.ps : p.bash).parse(command))) + if (!tree) throw new Error("Failed to parse command") + return tree +}) + +const ask = Effect.fn("ShellTool.ask")(function* (ctx: Tool.Context, scan: Scan) { + if (scan.dirs.size > 0) { + const globs = Array.from(scan.dirs).map((dir) => { + if (process.platform === "win32") return AppFileSystem.normalizePathPattern(path.join(dir, "*")) + return path.join(dir, "*") + }) + yield* ctx.ask({ + permission: "external_directory", + patterns: globs, + always: globs, + metadata: {}, + }) + } + + if (scan.patterns.size === 0) return + yield* ctx.ask({ + permission: ShellID.ToolID, + patterns: Array.from(scan.patterns), + always: Array.from(scan.always), + metadata: {}, + }) +}) + +function cmd(shell: string, command: string, cwd: string, env: NodeJS.ProcessEnv) { + if (process.platform === "win32" && Shell.ps(shell)) { + return ChildProcess.make(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], { + cwd, + env, + stdin: "ignore", + detached: false, + }) + } + + return ChildProcess.make(command, [], { + shell, + cwd, + env, + stdin: "ignore", + detached: process.platform !== "win32", + }) +} +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 } +}) + +export const ShellTool = Tool.define( + ShellID.ToolID, + Effect.gen(function* () { + const config = yield* Config.Service + const spawner = yield* ChildProcessSpawner + const fs = yield* AppFileSystem.Service + const trunc = yield* Truncate.Service + const plugin = yield* Plugin.Service + + const cygpath = Effect.fn("ShellTool.cygpath")(function* (shell: string, text: string) { + const lines = yield* spawner + .lines(ChildProcess.make(shell, ["-lc", 'cygpath -w -- "$1"', "_", text])) + .pipe(Effect.catch(() => Effect.succeed([] as string[]))) + const file = lines[0]?.trim() + if (!file) return + return AppFileSystem.normalizePath(file) + }) + + const resolvePath = Effect.fn("ShellTool.resolvePath")(function* (text: string, root: string, shell: string) { + if (process.platform === "win32") { + if (Shell.posix(shell) && text.startsWith("/") && AppFileSystem.windowsPath(text) === text) { + const file = yield* cygpath(shell, text) + if (file) return file + } + return AppFileSystem.normalizePath(path.resolve(root, AppFileSystem.windowsPath(text))) + } + return path.resolve(root, text) + }) + + const argPath = Effect.fn("ShellTool.argPath")(function* (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 yield* resolvePath(next, cwd, shell) + }) + + const collect = Effect.fn("ShellTool.collect")(function* ( + root: Node, + cwd: string, + ps: boolean, + shell: string, + instance: InstanceContext, + ) { + const scan: Scan = { + dirs: new Set(), + patterns: new Set(), + always: new Set(), + } + const shellKind = ShellID.toKind(Shell.name(shell)) + + for (const node of commands(root)) { + const command = parts(node) + const tokens = command.map((item) => item.text) + const cmd = ps || shellKind === "cmd" ? tokens[0]?.toLowerCase() : tokens[0] + + if (cmd && (FILES.has(cmd) || (shellKind === "cmd" && CMD_FILES.has(cmd)))) { + for (const arg of pathArgs(command, ps, shellKind === "cmd")) { + const resolved = yield* argPath(arg, cwd, ps, shell) + log.info("resolved path", { arg, resolved }) + if (!resolved || containsPath(resolved, instance)) continue + const dir = (yield* fs.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 + }) + + const shellEnv = Effect.fn("ShellTool.shellEnv")(function* (ctx: Tool.Context, cwd: string) { + const extra = yield* plugin.trigger( + "shell.env", + { cwd, sessionID: ctx.sessionID, callID: ctx.callID }, + { env: {} }, + ) + return { + ...process.env, + ...extra.env, + } + }) + + const run = Effect.fn("ShellTool.run")(function* ( + input: { + shell: string + command: string + cwd: string + env: NodeJS.ProcessEnv + timeout: number + description: string + }, + ctx: Tool.Context, + ) { + const limits = yield* trunc.limits() + const keep = limits.maxBytes * 2 + let full = "" + let last = "" + const list: Chunk[] = [] + let used = 0 + let file = "" + let sink: ReturnType | undefined + let cut = false + let expired = false + let aborted = false + + yield* ctx.metadata({ + metadata: { + output: "", + description: input.description, + }, + }) + + const code: number | null = yield* Effect.scoped( + Effect.gen(function* () { + const handle = yield* spawner.spawn(cmd(input.shell, input.command, input.cwd, input.env)) + + yield* Effect.forkScoped( + Stream.runForEach(Stream.decodeText(handle.all), (chunk) => { + const size = Buffer.byteLength(chunk, "utf-8") + list.push({ text: chunk, size }) + used += size + while (used > keep && list.length > 1) { + const item = list.shift() + if (!item) break + used -= item.size + cut = true + } + + last = preview(last + chunk) + + if (file) { + sink?.write(chunk) + } else { + full += chunk + if (Buffer.byteLength(full, "utf-8") > limits.maxBytes) { + return trunc.write(full).pipe( + Effect.andThen((next) => + Effect.sync(() => { + file = next + cut = true + sink = createWriteStream(next, { flags: "a" }) + full = "" + }), + ), + Effect.andThen( + ctx.metadata({ + metadata: { + output: last, + description: input.description, + }, + }), + ), + ) + } + } + + return ctx.metadata({ + metadata: { + output: last, + description: input.description, + }, + }) + }), + ) + + const abort = Effect.callback((resume) => { + if (ctx.abort.aborted) return resume(Effect.void) + const handler = () => resume(Effect.void) + ctx.abort.addEventListener("abort", handler, { once: true }) + return Effect.sync(() => ctx.abort.removeEventListener("abort", handler)) + }) + + const timeout = Effect.sleep(`${input.timeout + 100} millis`) + + const exit = yield* Effect.raceAll([ + handle.exitCode.pipe(Effect.map((code) => ({ kind: "exit" as const, code }))), + abort.pipe(Effect.map(() => ({ kind: "abort" as const, code: null }))), + timeout.pipe(Effect.map(() => ({ kind: "timeout" as const, code: null }))), + ]) + + if (exit.kind === "abort") { + aborted = true + yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie) + } + if (exit.kind === "timeout") { + expired = true + yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie) + } + + return exit.kind === "exit" ? exit.code : null + }), + ).pipe(Effect.orDie) + + const meta: string[] = [] + if (expired) { + meta.push( + `shell tool terminated command after exceeding timeout ${input.timeout} ms. If this command is expected to take longer and is not waiting for interactive input, retry with a larger timeout value in milliseconds.`, + ) + } + if (aborted) meta.push("User aborted the command") + const raw = list.map((item) => item.text).join("") + const end = tail(raw, limits.maxLines, limits.maxBytes) + if (end.cut) cut = true + if (!file && end.cut) { + file = yield* trunc.write(raw) + } + + let output = end.text + if (!output) output = "(no output)" + + if (cut && file) { + output = `...output truncated...\n\nFull output saved to: ${file}\n\n` + output + } + + if (meta.length > 0) { + output += "\n\n\n" + meta.join("\n") + "\n" + } + if (sink) { + const stream = sink + yield* Effect.promise( + () => + new Promise((resolve) => { + stream.end(() => resolve()) + stream.on("error", () => resolve()) + }), + ) + } + + return { + title: input.description, + metadata: { + output: last || preview(output), + exit: code, + description: input.description, + truncated: cut, + ...(cut && file ? { outputPath: file } : {}), + }, + output, + } + }) + + return () => + Effect.gen(function* () { + const cfg = yield* config.get() + const shell = Shell.acceptable(cfg.shell) + const name = Shell.name(shell) + const limits = yield* trunc.limits() + const prompt = ShellPrompt.render(name, process.platform, limits) + log.info("shell tool using shell", { shell }) + + return { + description: prompt.description, + parameters: prompt.parameters, + execute: (params: Parameters, ctx: Tool.Context) => + Effect.gen(function* () { + const executeInstance = yield* InstanceState.context + const cwd = params.workdir + ? yield* resolvePath(params.workdir, executeInstance.directory, shell) + : executeInstance.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 = Shell.ps(shell) + yield* Effect.scoped( + Effect.gen(function* () { + const tree = yield* Effect.acquireRelease(parse(params.command, ps), (tree) => + Effect.sync(() => tree.delete()), + ) + const scan = yield* collect(tree.rootNode, cwd, ps, shell, executeInstance) + if (!containsPath(cwd, executeInstance)) scan.dirs.add(cwd) + yield* ask(ctx, scan) + }), + ) + + return yield* run( + { + shell, + command: params.command, + cwd, + env: yield* shellEnv(ctx, cwd), + timeout, + description: params.description, + }, + ctx, + ) + }), + } + }) + }), +) diff --git a/packages/opencode/src/tool/shell/id.ts b/packages/opencode/src/tool/shell/id.ts new file mode 100644 index 000000000..061253f8f --- /dev/null +++ b/packages/opencode/src/tool/shell/id.ts @@ -0,0 +1,19 @@ +const kinds = ["bash", "pwsh", "powershell", "cmd"] as const +export type Kind = (typeof kinds)[number] + +const shellKinds = new Set(kinds) + +function isKind(value: string): value is Kind { + return shellKinds.has(value) +} + +export function toKind(value: string): Kind { + return isKind(value) ? value : "bash" +} + +// Keep the exposed tool ID and permission key as "bash" for compatibility with +// existing plugins, users, and saved permissions. Rename with opencode 2.0. +export const ToolID = "bash" +export type ToolID = typeof ToolID + +export * as ShellID from "./id" diff --git a/packages/opencode/src/tool/shell/prompt.ts b/packages/opencode/src/tool/shell/prompt.ts new file mode 100644 index 000000000..77d0f4b5e --- /dev/null +++ b/packages/opencode/src/tool/shell/prompt.ts @@ -0,0 +1,297 @@ +import { Schema } from "effect" +import DESCRIPTION from "./shell.txt" +import { PositiveInt } from "@/util/schema" +import { Global } from "@opencode-ai/core/global" +import { ShellID } from "./id" + +const PS = new Set(["powershell", "pwsh"]) +const CMD = new Set(["cmd"]) + +const descriptions = { + bash: + "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'", + powershell: + 'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: Get-ChildItem -LiteralPath "."\nOutput: Lists current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: New-Item -ItemType Directory -Path "tmp"\nOutput: Creates directory tmp', + cmd: + 'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: dir\nOutput: Lists current directory\n\nInput: if exist "package.json" type "package.json"\nOutput: Prints package.json when it exists\n\nInput: mkdir tmp\nOutput: Creates directory tmp', +} + +export type Limits = { + maxLines: number + maxBytes: number +} + +export function parameterSchema(description: string) { + return Schema.Struct({ + command: Schema.String.annotate({ description: "The command to execute" }), + timeout: Schema.optional(PositiveInt).annotate({ description: "Optional timeout in milliseconds" }), + workdir: Schema.optional(Schema.String).annotate({ + description: `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`, + }), + description: Schema.String.annotate({ description }), + }) +} + +export const Parameters = parameterSchema(descriptions.bash) +export type Parameters = Schema.Schema.Type + +function renderPrompt(template: string, values: Record) { + return template.replace(/\$\{(\w+)\}/g, (_, key: string) => { + const value = values[key] + if (value === undefined) throw new Error(`Missing shell prompt value: ${key}`) + return value + }) +} + +function shellDisplayName(name: string) { + if (name === "pwsh") return "PowerShell (7+)" + if (name === "powershell") return "Windows PowerShell (5.1)" + if (name === "cmd") return "cmd.exe" + return name +} + +function powershellNotes(name: string) { + if (name === "pwsh") { + return `# PowerShell (7+) shell notes +- This cross-platform shell supports pipeline chain operators (\`&&\` and \`||\`). +- Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. +- Prefer full cmdlet names like \`Get-ChildItem\`, \`Set-Content\`, \`Remove-Item\`, and \`New-Item\` over aliases. +- Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. +- To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. +- Escape special characters with the PowerShell backtick character.` + } + if (name === "powershell") { + return `# Windows PowerShell (5.1) shell notes +- Use \`cmd1; if ($?) { cmd2 }\` to chain dependent commands. +- Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. +- Prefer full cmdlet names like \`Get-ChildItem\`, \`Set-Content\`, \`Remove-Item\`, and \`New-Item\` over aliases. +- Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. +- To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. +- Escape special characters with the PowerShell backtick character.` + } + return "" +} + +function chainGuidance(name: string) { + if (name === "powershell") { + return "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 (PS.has(name)) { + return "If the commands depend on each other and must run sequentially, use a single bash tool 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 New-Item before Copy-Item, Write before bash for git operations, or git add before git commit), run these operations sequentially instead." + } + if (CMD.has(name)) { + return "If the commands depend on each other and must run sequentially, use a single bash tool call with `&&` to chain them together (e.g., `mkdir out && dir out`). For instance, if one operation must complete before another starts, run these operations sequentially instead." + } + return "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." +} + +function bashCommandSection(chain: string, limits: Limits) { + return `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 + +2. Command Execution: + - Always quote file paths that contain spaces with double quotes (e.g., rm "path with spaces/file.txt") + - Examples of proper quoting: + - mkdir "/Users/name/My Documents" (correct) + - mkdir /Users/name/My Documents (incorrect - will fail) + - python "/path/with spaces/script.py" (correct) + - python /path/with spaces/script.py (incorrect - will fail) + - After ensuring proper quoting, execute the command. + - Capture the output of the command. + +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 ${limits.maxLines} lines or ${limits.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: + - File search: Use Glob (NOT find or ls) + - Content search: Use Grep (NOT grep or rg) + - Read files: Use Read (NOT cat/head/tail) + - Edit files: Use Edit (NOT sed/awk) + - Write files: Use Write (NOT echo >/cat < && \`. Use the \`workdir\` parameter to change directories instead. + + Use workdir="/foo/bar" with command: pytest tests + + + cd /foo/bar && pytest tests + ` +} + +function powershellCommandSection(name: string, chain: string, pathSep: string, limits: Limits) { + return `${powershellNotes(name)} + +Before executing the command, please follow these steps: + +1. Directory Verification: + - If the command will create new directories or files, first use \`Test-Path -LiteralPath \` to verify the parent directory exists and is the correct location + - For example, before creating \`foo${pathSep}bar\`, first use \`Test-Path -LiteralPath "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., Remove-Item -LiteralPath "path with spaces${pathSep}file.txt") + - Examples of proper quoting: + - New-Item -ItemType Directory -Path "My Documents" (correct) + - New-Item -ItemType Directory -Path My Documents (incorrect - path is split) + - & "path with spaces${pathSep}script.ps1" (correct) + - path with spaces${pathSep}script.ps1 (incorrect - path is split and not invoked) + - After ensuring proper quoting, execute the command. + - Capture the output of the command. + +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 ${limits.maxLines} lines or ${limits.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 \`Select-Object -First\`, \`Select-Object -Last\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. + + - Avoid using Shell with PowerShell file/content cmdlets unless explicitly instructed or when these cmdlets are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - File search: Use Glob (NOT Get-ChildItem) + - Content search: Use Grep (NOT Select-String) + - Read files: Use Read (NOT Get-Content) + - Edit files: Use Edit (NOT Set-Content) + - Write files: Use Write (NOT Set-Content/Out-File or here-strings) + - Communication: Output text directly (NOT Write-Output/Write-Host) + - When issuing multiple commands: + - If the commands are independent and can run in parallel, make multiple bash tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two bash tool calls in parallel. + - ${chain} + - Use \`;\` only when you need to run commands sequentially but don't care if earlier commands fail + - DO NOT use newlines to separate commands (newlines are ok in quoted strings) + - AVOID changing directories inside the command. Use the \`workdir\` parameter to change directories instead. + + Use workdir="project${pathSep}subdir" with command: pytest tests + + + ${name === "powershell" ? `Set-Location -LiteralPath "project${pathSep}subdir"; if ($?) { pytest tests }` : `Set-Location -LiteralPath "project${pathSep}subdir" && pytest tests`} + ` +} + +function cmdCommandSection(chain: string, limits: Limits) { + return `# cmd.exe shell notes +- Use double quotes for paths with spaces. +- Use %VAR% for environment variables. +- Use \`if exist\` for existence checks. +- Use \`call\` when invoking batch files from another batch-style command. + +Before executing the command, please follow these steps: + +1. Directory Verification: + - If the command will create new directories or files, first use \`if exist\` to verify the parent directory exists and is the correct location + - For example, before creating \`foo\\bar\`, first use \`if exist "foo\\" dir "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., del "path with spaces\\file.txt") + - Examples of proper quoting: + - mkdir "My Documents" (correct) + - mkdir My Documents (incorrect - path is split) + - call "path with spaces\\script.bat" (correct) + - path with spaces\\script.bat (incorrect - path is split and not invoked correctly) + - After ensuring proper quoting, execute the command. + - Capture the output of the command. + +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 ${limits.maxLines} lines or ${limits.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 \`more\` or other pagination commands to limit output; the full output will already be captured to a file for more precise searching. + + - Avoid using Shell with cmd.exe file/content 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 dir /s) + - Content search: Use Grep (NOT findstr) + - Read files: Use Read (NOT type) + - Edit files: Use Edit (NOT copy) + - Write files: Use Write (NOT echo > file) + - Communication: Output text directly (NOT echo) + - When issuing multiple commands: + - If the commands are independent and can run in parallel, make multiple bash tool calls in a single message. For example, if you need to run "dir" and "where cmd", send a single message with two bash tool calls in parallel. + - ${chain} + - Use \`&\` only when you need to run commands sequentially but don't care if earlier commands fail + - DO NOT use newlines to separate commands (newlines are ok in quoted strings) + - AVOID changing directories inside the command. Use the \`workdir\` parameter to change directories instead. + + Use workdir="project\\subdir" with command: dir + + + cd /d "project\\subdir" && dir + ` +} + +function profile(name: string, platform: NodeJS.Platform, limits: Limits) { + const isPowerShell = PS.has(name) + const chain = chainGuidance(name) + if (CMD.has(name)) { + return { + intro: `Executes a given ${shellDisplayName(name)} command with optional timeout, ensuring proper handling and security measures.`, + workdirSection: + "All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID changing directories inside the command - use `workdir` instead.", + commandSection: cmdCommandSection(chain, limits), + gitCommands: "git commands", + gitCommandRestriction: "git commands", + createPrInstruction: "Create PR using a temporary body file so cmd.exe quoting stays simple.", + createPrExample: `(\n echo ## Summary\n echo - ^<1-3 bullet points^>\n) > pr-body.txt\ngh pr create --title "the pr title" --body-file pr-body.txt`, + parameterDescription: descriptions.cmd, + } + } + if (isPowerShell) { + return { + intro: `Executes a given ${shellDisplayName(name)} command with optional timeout, ensuring proper handling and security measures.`, + workdirSection: + "All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID changing directories inside the command - use `workdir` instead.", + commandSection: powershellCommandSection(name, chain, platform === "win32" ? "\\" : "/", limits), + gitCommands: "git commands", + gitCommandRestriction: "git commands", + createPrInstruction: "Create PR using gh pr create with a PowerShell here-string to pass the body correctly.", + createPrExample: `gh pr create --title "the pr title" --body @' +## Summary +- <1-3 bullet points> +'@`, + parameterDescription: descriptions.powershell, + } + } + return { + intro: + "Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.", + workdirSection: + "All commands run in the current working 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.", + commandSection: bashCommandSection(chain, limits), + gitCommands: "bash commands", + gitCommandRestriction: "git bash commands", + createPrInstruction: + "Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.", + createPrExample: `gh pr create --title "the pr title" --body "$(cat <<'EOF' +## Summary +<1-3 bullet points>`, + parameterDescription: descriptions.bash, + } +} + +export function render(name: string, platform: NodeJS.Platform, limits: Limits) { + const selected = profile(name, platform, limits) + return { + description: renderPrompt(DESCRIPTION, { + intro: selected.intro, + os: platform, + shell: name, + tmp: Global.Path.tmp, + workdirSection: selected.workdirSection, + commandSection: selected.commandSection, + gitCommands: selected.gitCommands, + toolName: ShellID.ToolID, + gitCommandRestriction: selected.gitCommandRestriction, + createPrInstruction: selected.createPrInstruction, + createPrExample: selected.createPrExample, + }), + parameters: parameterSchema(selected.parameterDescription), + } +} + +export * as ShellPrompt from "./prompt" diff --git a/packages/opencode/src/tool/shell/shell.txt b/packages/opencode/src/tool/shell/shell.txt new file mode 100644 index 000000000..5cba07805 --- /dev/null +++ b/packages/opencode/src/tool/shell/shell.txt @@ -0,0 +1,77 @@ +${intro} + +Be aware: OS: ${os}, Shell: ${shell} + +${workdirSection} + +Use `${tmp}` for temporary work outside the workspace. This directory has already been created, already exists, and is pre-approved for external directory access. + +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. + +${commandSection} + +# Committing changes with git + +Only create commits when requested by the user. If unclear, ask first. When the user asks you to create a new git commit, follow these steps carefully: + +Git Safety Protocol: +- NEVER update the git config +- NEVER run destructive/irreversible git commands (like push --force, hard reset, etc) unless the user explicitly requests them +- NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it +- NEVER run force push to main/master, warn the user if they request it +- Avoid git commit --amend. ONLY use --amend when ALL conditions are met: + (1) User explicitly requested amend, OR the commit succeeded and pre-commit hooks auto-modified files that need including — verify by checking `git log` that HEAD is the new commit before amending + (2) HEAD commit was created by you in this conversation (verify: git log -1 --format='%an %ae') + (3) Commit has NOT been pushed to remote (verify: git status shows "Your branch is ahead") +- CRITICAL: If commit FAILED or was REJECTED by hook, NEVER amend - fix the issue and create a NEW commit +- 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 ${gitCommands} in parallel, each using the ${toolName} tool: + - 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. +2. Analyze all staged changes (both previously staged and newly added) and draft a commit message: + - Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.). Ensure the message accurately reflects the changes and their purpose (i.e. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.). + - Do not commit files that likely contain secrets (.env, credentials.json, etc.). Warn the user if they specifically request to commit those files + - Draft a concise (1-2 sentences) commit message that focuses on the "why" rather than the "what" + - Ensure it accurately reflects the changes and their purpose +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: + - Add relevant untracked files to the staging area. + - Create the commit with a message + - Run git status after the commit completes to verify success. + Note: git status depends on the commit completing, so run it sequentially after the commit. +4. If the commit fails due to pre-commit hook, fix the issue and create a NEW commit (see amend rules above) + +Important notes: +- NEVER run additional commands to read or explore code, besides ${gitCommandRestriction} +- NEVER use the TodoWrite or Task tools +- DO NOT push to the remote repository unless the user explicitly asks you to do so +- IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported. +- 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 ${toolName} 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. + +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 ${gitCommands} in parallel using the ${toolName} tool, 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) +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 + - Push to remote with -u flag if needed + - ${createPrInstruction} + +${createPrExample} + + +Important: +- DO NOT use the TodoWrite or Task tools +- 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 diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index afd24e7e1..a7853be0b 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -620,7 +620,7 @@ describe("session.message-v2.toModelMessage", () => { status: "completed", input: { cmd: "ls" }, output: "abcdefghij", - title: "Bash", + title: "Shell", metadata: {}, time: { start: 0, end: 1 }, }, @@ -740,9 +740,9 @@ describe("session.message-v2.toModelMessage", () => { "12179", "4575", "", - "", + "", "User aborted the command", - "", + "", ].join("\n") const input: MessageV2.WithParts[] = [ diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts deleted file mode 100644 index 513cfa18e..000000000 --- a/packages/opencode/test/tool/bash.test.ts +++ /dev/null @@ -1,1224 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { Effect, Layer, ManagedRuntime } from "effect" -import os from "os" -import path from "path" -import { Config } from "@/config/config" -import { Shell } from "../../src/shell/shell" -import { BashTool } from "../../src/tool/bash" -import { Instance } from "../../src/project/instance" -import { Filesystem } from "@/util/filesystem" -import { tmpdir } from "../fixture/fixture" -import type { Permission } from "../../src/permission" -import { Agent } from "../../src/agent/agent" -import { Truncate } from "@/tool/truncate" -import { SessionID, MessageID } from "../../src/session/schema" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { Plugin } from "../../src/plugin" - -const runtime = ManagedRuntime.make( - Layer.mergeAll( - CrossSpawnSpawner.defaultLayer, - AppFileSystem.defaultLayer, - Plugin.defaultLayer, - Truncate.defaultLayer, - Config.defaultLayer, - Agent.defaultLayer, - ), -) - -function initBash() { - return runtime.runPromise(BashTool.pipe(Effect.flatMap((info) => info.init()))) -} - -const ctx = { - sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), - callID: "", - agent: "build", - abort: AbortSignal.any([]), - messages: [], - metadata: () => Effect.void, - ask: () => Effect.void, -} - -Shell.acceptable.reset() -const quote = (text: string) => `"${text}"` -const squote = (text: string) => `'${text}'` -const projectRoot = path.join(__dirname, "../..") -const bin = quote(process.execPath.replaceAll("\\", "/")) -const bash = (() => { - const shell = Shell.acceptable() - if (Shell.name(shell) === "bash") return shell - return Shell.gitbash() -})() -const shells = (() => { - if (process.platform !== "win32") { - const shell = Shell.acceptable() - return [{ label: Shell.name(shell), shell }] - } - - const list = [bash, Bun.which("pwsh"), Bun.which("powershell"), process.env.COMSPEC || Bun.which("cmd.exe")] - .filter((shell): shell is string => Boolean(shell)) - .map((shell) => ({ label: Shell.name(shell), shell })) - - return list.filter( - (item, i) => list.findIndex((other) => other.shell.toLowerCase() === item.shell.toLowerCase()) === i, - ) -})() -const PS = new Set(["pwsh", "powershell"]) -const ps = shells.filter((item) => PS.has(item.label)) - -const sh = () => Shell.name(Shell.acceptable()) -const evalarg = (text: string) => (sh() === "cmd" ? quote(text) : squote(text)) - -const fill = (mode: "lines" | "bytes", n: number) => { - const code = - mode === "lines" - ? "console.log(Array.from({length:Number(Bun.argv[1])},(_,i)=>i+1).join(String.fromCharCode(10)))" - : "process.stdout.write(String.fromCharCode(97).repeat(Number(Bun.argv[1])))" - const text = `${bin} -e ${evalarg(code)} ${n}` - if (PS.has(sh())) return `& ${text}` - return text -} -const glob = (p: string) => - process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/") - -const forms = (dir: string) => { - if (process.platform !== "win32") return [dir] - const full = Filesystem.normalizePath(dir) - const slash = full.replaceAll("\\", "/") - const root = slash.replace(/^[A-Za-z]:/, "") - return Array.from(new Set([full, slash, root, root.toLowerCase()])) -} - -const withShell = (item: { label: string; shell: string }, fn: () => Promise) => async () => { - const prev = process.env.SHELL - process.env.SHELL = item.shell - Shell.acceptable.reset() - Shell.preferred.reset() - try { - await fn() - } finally { - if (prev === undefined) delete process.env.SHELL - else process.env.SHELL = prev - Shell.acceptable.reset() - Shell.preferred.reset() - } -} - -const each = (name: string, fn: (item: { label: string; shell: string }) => Promise) => { - for (const item of shells) { - test( - `${name} [${item.label}]`, - withShell(item, () => fn(item)), - ) - } -} - -const capture = (requests: Array>, stop?: Error) => ({ - ...ctx, - ask: (req: Omit) => - Effect.sync(() => { - requests.push(req) - if (stop) throw stop - }), -}) - -const mustTruncate = (result: { - metadata: { truncated?: boolean; exit?: number | null } & Record - output: string -}) => { - if (result.metadata.truncated) return - throw new Error( - [`shell: ${process.env.SHELL || ""}`, `exit: ${String(result.metadata.exit)}`, "output:", result.output].join("\n"), - ) -} - -describe("tool.bash", () => { - each("basic", async () => { - await Instance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initBash() - const result = await Effect.runPromise( - bash.execute( - { - command: "echo test", - description: "Echo test message", - }, - ctx, - ), - ) - expect(result.metadata.exit).toBe(0) - expect(result.metadata.output).toContain("test") - }, - }) - }) - - test("falls back from terminal-only configured shell", async () => { - await using tmp = await tmpdir({ - config: { shell: "fish" }, - }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initBash() - const fallback = Shell.name(Shell.acceptable("fish")) - expect(fallback).not.toBe("fish") - expect(bash.description).toContain(fallback) - - const result = await Effect.runPromise( - bash.execute( - { - command: "echo fallback", - description: "Echo fallback text", - }, - ctx, - ), - ) - expect(result.metadata.exit).toBe(0) - expect(result.output).toContain("fallback") - }, - }) - }) -}) - -describe("tool.bash 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 initBash() - const requests: Array> = [] - await Effect.runPromise( - bash.execute( - { - command: "echo hello", - description: "Echo hello", - }, - capture(requests), - ), - ) - expect(requests.length).toBe(1) - expect(requests[0].permission).toBe("bash") - expect(requests[0].patterns).toContain("echo hello") - }, - }) - }) - - each("asks for bash permission with multiple commands", async () => { - await using tmp = await tmpdir() - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initBash() - const requests: Array> = [] - await Effect.runPromise( - bash.execute( - { - command: "echo foo && echo bar", - description: "Echo twice", - }, - capture(requests), - ), - ) - expect(requests.length).toBe(1) - expect(requests[0].permission).toBe("bash") - expect(requests[0].patterns).toContain("echo foo") - expect(requests[0].patterns).toContain("echo bar") - }, - }) - }) - - for (const item of ps) { - test( - `parses PowerShell conditionals for permission prompts [${item.label}]`, - withShell(item, async () => { - await Instance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initBash() - const requests: Array> = [] - await Effect.runPromise( - bash.execute( - { - command: "Write-Host foo; if ($?) { Write-Host bar }", - description: "Check PowerShell conditional", - }, - capture(requests), - ), - ) - const bashReq = requests.find((r) => r.permission === "bash") - expect(bashReq).toBeDefined() - expect(bashReq!.patterns).toContain("Write-Host foo") - expect(bashReq!.patterns).toContain("Write-Host bar") - expect(bashReq!.always).toContain("Write-Host *") - }, - }) - }), - ) - } - - each("asks for external_directory permission for wildcard external paths", async () => { - await Instance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initBash() - const err = new Error("stop after permission") - const requests: Array> = [] - const file = process.platform === "win32" ? `${process.env.WINDIR!.replaceAll("\\", "/")}/*` : "/etc/*" - const want = process.platform === "win32" ? glob(path.join(process.env.WINDIR!, "*")) : "/etc/*" - await expect( - Effect.runPromise( - bash.execute( - { - command: `cat ${file}`, - description: "Read wildcard path", - }, - capture(requests, err), - ), - ), - ).rejects.toThrow(err.message) - const extDirReq = requests.find((r) => r.permission === "external_directory") - expect(extDirReq).toBeDefined() - expect(extDirReq!.patterns).toContain(want) - }, - }) - }) - - if (process.platform === "win32") { - if (bash) { - test( - "asks for nested bash command permissions [bash]", - withShell({ label: "bash", shell: bash }, async () => { - await using outerTmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "outside.txt"), "x") - }, - }) - await Instance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initBash() - const file = path.join(outerTmp.path, "outside.txt").replaceAll("\\", "/") - const requests: Array> = [] - await Effect.runPromise( - bash.execute( - { - command: `echo $(cat "${file}")`, - description: "Read nested bash file", - }, - capture(requests), - ), - ) - const extDirReq = requests.find((r) => r.permission === "external_directory") - const bashReq = requests.find((r) => r.permission === "bash") - expect(extDirReq).toBeDefined() - expect(extDirReq!.patterns).toContain(glob(path.join(outerTmp.path, "*"))) - expect(bashReq).toBeDefined() - expect(bashReq!.patterns).toContain(`cat "${file}"`) - }, - }) - }), - ) - } - } - - if (process.platform === "win32") { - for (const item of ps) { - test( - `asks for external_directory permission for PowerShell paths after switches [${item.label}]`, - withShell(item, async () => { - await Instance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initBash() - const err = new Error("stop after permission") - const requests: Array> = [] - await expect( - Effect.runPromise( - bash.execute( - { - command: `Copy-Item -PassThru "${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini" ./out`, - description: "Copy Windows ini", - }, - capture(requests, err), - ), - ), - ).rejects.toThrow(err.message) - const extDirReq = requests.find((r) => r.permission === "external_directory") - expect(extDirReq).toBeDefined() - expect(extDirReq!.patterns).toContain(glob(path.join(process.env.WINDIR!, "*"))) - }, - }) - }), - ) - } - - for (const item of ps) { - test( - `asks for nested PowerShell command permissions [${item.label}]`, - withShell(item, async () => { - await Instance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initBash() - const requests: Array> = [] - const file = `${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini` - await Effect.runPromise( - bash.execute( - { - command: `Write-Output $(Get-Content ${file})`, - description: "Read nested PowerShell file", - }, - capture(requests), - ), - ) - const extDirReq = requests.find((r) => r.permission === "external_directory") - const bashReq = requests.find((r) => r.permission === "bash") - expect(extDirReq).toBeDefined() - expect(extDirReq!.patterns).toContain(glob(path.join(process.env.WINDIR!, "*"))) - expect(bashReq).toBeDefined() - expect(bashReq!.patterns).toContain(`Get-Content ${file}`) - }, - }) - }), - ) - } - - for (const item of ps) { - test( - `asks for external_directory permission for drive-relative PowerShell paths [${item.label}]`, - withShell(item, async () => { - await using tmp = await tmpdir() - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initBash() - const err = new Error("stop after permission") - const requests: Array> = [] - await expect( - Effect.runPromise( - bash.execute( - { - command: 'Get-Content "C:../outside.txt"', - description: "Read drive-relative file", - }, - capture(requests, err), - ), - ), - ).rejects.toThrow(err.message) - expect(requests[0]?.permission).toBe("external_directory") - if (requests[0]?.permission !== "external_directory") return - expect(requests[0].patterns).toContain(glob(path.join(path.dirname(tmp.path), "*"))) - }, - }) - }), - ) - } - - for (const item of ps) { - test( - `asks for external_directory permission for $HOME PowerShell paths [${item.label}]`, - withShell(item, async () => { - await Instance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initBash() - const err = new Error("stop after permission") - const requests: Array> = [] - await expect( - Effect.runPromise( - bash.execute( - { - command: 'Get-Content "$HOME/.ssh/config"', - description: "Read home config", - }, - capture(requests, err), - ), - ), - ).rejects.toThrow(err.message) - expect(requests[0]?.permission).toBe("external_directory") - if (requests[0]?.permission !== "external_directory") return - expect(requests[0].patterns).toContain(glob(path.join(os.homedir(), ".ssh", "*"))) - }, - }) - }), - ) - } - - for (const item of ps) { - test( - `asks for external_directory permission for $PWD PowerShell paths [${item.label}]`, - withShell(item, async () => { - await using tmp = await tmpdir() - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initBash() - const err = new Error("stop after permission") - const requests: Array> = [] - await expect( - Effect.runPromise( - bash.execute( - { - command: 'Get-Content "$PWD/../outside.txt"', - description: "Read pwd-relative file", - }, - capture(requests, err), - ), - ), - ).rejects.toThrow(err.message) - expect(requests[0]?.permission).toBe("external_directory") - if (requests[0]?.permission !== "external_directory") return - expect(requests[0].patterns).toContain(glob(path.join(path.dirname(tmp.path), "*"))) - }, - }) - }), - ) - } - - for (const item of ps) { - test( - `asks for external_directory permission for $PSHOME PowerShell paths [${item.label}]`, - withShell(item, async () => { - await Instance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initBash() - const err = new Error("stop after permission") - const requests: Array> = [] - await expect( - Effect.runPromise( - bash.execute( - { - command: 'Get-Content "$PSHOME/outside.txt"', - description: "Read pshome file", - }, - capture(requests, err), - ), - ), - ).rejects.toThrow(err.message) - expect(requests[0]?.permission).toBe("external_directory") - if (requests[0]?.permission !== "external_directory") return - expect(requests[0].patterns).toContain(glob(path.join(path.dirname(item.shell), "*"))) - }, - }) - }), - ) - } - - for (const item of ps) { - test( - `asks for external_directory permission for missing PowerShell env paths [${item.label}]`, - withShell(item, async () => { - const key = "OPENCODE_TEST_MISSING" - const prev = process.env[key] - delete process.env[key] - try { - await Instance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initBash() - const err = new Error("stop after permission") - const requests: Array> = [] - const root = path.parse(process.env.WINDIR!).root.replace(/[\\/]+$/, "") - await expect( - Effect.runPromise( - bash.execute( - { - command: `Get-Content -Path "${root}$env:${key}\\Windows\\win.ini"`, - description: "Read Windows ini with missing env", - }, - capture(requests, err), - ), - ), - ).rejects.toThrow(err.message) - const extDirReq = requests.find((r) => r.permission === "external_directory") - expect(extDirReq).toBeDefined() - expect(extDirReq!.patterns).toContain(glob(path.join(process.env.WINDIR!, "*"))) - }, - }) - } finally { - if (prev === undefined) delete process.env[key] - else process.env[key] = prev - } - }), - ) - } - - for (const item of ps) { - test( - `asks for external_directory permission for PowerShell env paths [${item.label}]`, - withShell(item, async () => { - await Instance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initBash() - const requests: Array> = [] - await Effect.runPromise( - bash.execute( - { - command: "Get-Content $env:WINDIR/win.ini", - description: "Read Windows ini from env", - }, - capture(requests), - ), - ) - const extDirReq = requests.find((r) => r.permission === "external_directory") - expect(extDirReq).toBeDefined() - expect(extDirReq!.patterns).toContain( - Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")), - ) - }, - }) - }), - ) - } - - for (const item of ps) { - test( - `asks for external_directory permission for PowerShell FileSystem paths [${item.label}]`, - withShell(item, async () => { - await Instance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initBash() - const err = new Error("stop after permission") - const requests: Array> = [] - await expect( - Effect.runPromise( - bash.execute( - { - command: `Get-Content -Path FileSystem::${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`, - description: "Read Windows ini from FileSystem provider", - }, - capture(requests, err), - ), - ), - ).rejects.toThrow(err.message) - expect(requests[0]?.permission).toBe("external_directory") - if (requests[0]?.permission !== "external_directory") return - expect(requests[0].patterns).toContain( - Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")), - ) - }, - }) - }), - ) - } - - for (const item of ps) { - test( - `asks for external_directory permission for braced PowerShell env paths [${item.label}]`, - withShell(item, async () => { - await Instance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initBash() - const err = new Error("stop after permission") - const requests: Array> = [] - await expect( - Effect.runPromise( - bash.execute( - { - command: "Get-Content ${env:WINDIR}/win.ini", - description: "Read Windows ini from braced env", - }, - capture(requests, err), - ), - ), - ).rejects.toThrow(err.message) - expect(requests[0]?.permission).toBe("external_directory") - if (requests[0]?.permission !== "external_directory") return - expect(requests[0].patterns).toContain( - Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")), - ) - }, - }) - }), - ) - } - - for (const item of ps) { - test( - `treats Set-Location like cd for permissions [${item.label}]`, - withShell(item, async () => { - await Instance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initBash() - const requests: Array> = [] - await Effect.runPromise( - bash.execute( - { - command: "Set-Location C:/Windows", - description: "Change location", - }, - capture(requests), - ), - ) - const extDirReq = requests.find((r) => r.permission === "external_directory") - const bashReq = requests.find((r) => r.permission === "bash") - expect(extDirReq).toBeDefined() - expect(extDirReq!.patterns).toContain( - Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")), - ) - expect(bashReq).toBeUndefined() - }, - }) - }), - ) - } - - for (const item of ps) { - test( - `does not add nested PowerShell expressions to permission prompts [${item.label}]`, - withShell(item, async () => { - await Instance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initBash() - const requests: Array> = [] - await Effect.runPromise( - bash.execute( - { - command: "Write-Output ('a' * 3)", - description: "Write repeated text", - }, - capture(requests), - ), - ) - const bashReq = requests.find((r) => r.permission === "bash") - expect(bashReq).toBeDefined() - expect(bashReq!.patterns).not.toContain("a * 3") - expect(bashReq!.always).not.toContain("a *") - }, - }) - }), - ) - } - } - - each("asks for external_directory permission when cd to parent", async () => { - await using tmp = await tmpdir() - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initBash() - const err = new Error("stop after permission") - const requests: Array> = [] - await expect( - Effect.runPromise( - bash.execute( - { - command: "cd ../", - description: "Change to parent directory", - }, - capture(requests, err), - ), - ), - ).rejects.toThrow(err.message) - const extDirReq = requests.find((r) => r.permission === "external_directory") - expect(extDirReq).toBeDefined() - }, - }) - }) - - each("asks for external_directory permission when workdir is outside project", async () => { - await using tmp = await tmpdir() - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initBash() - const err = new Error("stop after permission") - const requests: Array> = [] - await expect( - Effect.runPromise( - bash.execute( - { - command: "echo ok", - workdir: os.tmpdir(), - description: "Echo from temp dir", - }, - capture(requests, err), - ), - ), - ).rejects.toThrow(err.message) - const extDirReq = requests.find((r) => r.permission === "external_directory") - expect(extDirReq).toBeDefined() - expect(extDirReq!.patterns).toContain(glob(path.join(os.tmpdir(), "*"))) - }, - }) - }) - - if (process.platform === "win32") { - test("normalizes external_directory workdir variants on Windows", async () => { - const err = new Error("stop after permission") - await using outerTmp = await tmpdir() - await using tmp = await tmpdir() - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initBash() - const want = Filesystem.normalizePathPattern(path.join(outerTmp.path, "*")) - - for (const dir of forms(outerTmp.path)) { - const requests: Array> = [] - await expect( - Effect.runPromise( - bash.execute( - { - command: "echo ok", - workdir: dir, - description: "Echo from external dir", - }, - capture(requests, err), - ), - ), - ).rejects.toThrow(err.message) - - const extDirReq = requests.find((r) => r.permission === "external_directory") - expect({ dir, patterns: extDirReq?.patterns, always: extDirReq?.always }).toEqual({ - dir, - patterns: [want], - always: [want], - }) - } - }, - }) - }) - - if (bash) { - test( - "uses Git Bash /tmp semantics for external workdir", - withShell({ label: "bash", shell: bash }, async () => { - await Instance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initBash() - const err = new Error("stop after permission") - const requests: Array> = [] - const want = glob(path.join(os.tmpdir(), "*")) - await expect( - Effect.runPromise( - bash.execute( - { - command: "echo ok", - workdir: "/tmp", - description: "Echo from Git Bash tmp", - }, - capture(requests, err), - ), - ), - ).rejects.toThrow(err.message) - expect(requests[0]).toMatchObject({ - permission: "external_directory", - patterns: [want], - always: [want], - }) - }, - }) - }), - ) - - test( - "uses Git Bash /tmp semantics for external file paths", - withShell({ label: "bash", shell: bash }, async () => { - await Instance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initBash() - const err = new Error("stop after permission") - const requests: Array> = [] - const want = glob(path.join(os.tmpdir(), "*")) - await expect( - Effect.runPromise( - bash.execute( - { - command: "cat /tmp/opencode-does-not-exist", - description: "Read Git Bash tmp file", - }, - capture(requests, err), - ), - ), - ).rejects.toThrow(err.message) - expect(requests[0]).toMatchObject({ - permission: "external_directory", - patterns: [want], - always: [want], - }) - }, - }) - }), - ) - } - } - - each("asks for external_directory permission when file arg is outside project", async () => { - await using outerTmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "outside.txt"), "x") - }, - }) - await using tmp = await tmpdir() - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initBash() - const err = new Error("stop after permission") - const requests: Array> = [] - const filepath = path.join(outerTmp.path, "outside.txt") - await expect( - Effect.runPromise( - bash.execute( - { - command: `cat ${filepath}`, - description: "Read external file", - }, - capture(requests, err), - ), - ), - ).rejects.toThrow(err.message) - const extDirReq = requests.find((r) => r.permission === "external_directory") - const expected = glob(path.join(outerTmp.path, "*")) - expect(extDirReq).toBeDefined() - expect(extDirReq!.patterns).toContain(expected) - expect(extDirReq!.always).toContain(expected) - }, - }) - }) - - each("does not ask for external_directory permission when rm inside project", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "tmpfile"), "x") - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initBash() - const requests: Array> = [] - await Effect.runPromise( - bash.execute( - { - command: `rm -rf ${path.join(tmp.path, "nested")}`, - description: "Remove nested dir", - }, - capture(requests), - ), - ) - const extDirReq = requests.find((r) => r.permission === "external_directory") - expect(extDirReq).toBeUndefined() - }, - }) - }) - - each("includes always patterns for auto-approval", async () => { - await using tmp = await tmpdir() - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initBash() - const requests: Array> = [] - await Effect.runPromise( - bash.execute( - { - command: "git log --oneline -5", - description: "Git log", - }, - capture(requests), - ), - ) - expect(requests.length).toBe(1) - expect(requests[0].always.length).toBeGreaterThan(0) - expect(requests[0].always.some((item) => item.endsWith("*"))).toBe(true) - }, - }) - }) - - each("does not ask for bash permission when command is cd only", async () => { - await using tmp = await tmpdir() - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initBash() - const requests: Array> = [] - await Effect.runPromise( - bash.execute( - { - command: "cd .", - description: "Stay in current directory", - }, - capture(requests), - ), - ) - const bashReq = requests.find((r) => r.permission === "bash") - expect(bashReq).toBeUndefined() - }, - }) - }) - - each("matches redirects in permission pattern", async () => { - await using tmp = await tmpdir() - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initBash() - const err = new Error("stop after permission") - const requests: Array> = [] - await expect( - Effect.runPromise( - bash.execute( - { command: "echo test > output.txt", description: "Redirect test output" }, - capture(requests, err), - ), - ), - ).rejects.toThrow(err.message) - const bashReq = requests.find((r) => r.permission === "bash") - expect(bashReq).toBeDefined() - expect(bashReq!.patterns).toContain("echo test > output.txt") - }, - }) - }) - - each("always pattern has space before wildcard to not include different commands", async () => { - await using tmp = await tmpdir() - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initBash() - const requests: Array> = [] - await Effect.runPromise(bash.execute({ command: "ls -la", description: "List" }, capture(requests))) - const bashReq = requests.find((r) => r.permission === "bash") - expect(bashReq).toBeDefined() - expect(bashReq!.always[0]).toBe("ls *") - }, - }) - }) -}) - -describe("tool.bash abort", () => { - test("preserves output when aborted", async () => { - await Instance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initBash() - const controller = new AbortController() - const collected: string[] = [] - const res = await Effect.runPromise( - bash.execute( - { - command: `echo before && sleep 30`, - description: "Long running command", - }, - { - ...ctx, - abort: controller.signal, - metadata: (input) => - Effect.sync(() => { - const output = (input.metadata as { output?: string })?.output - if (output && output.includes("before") && !controller.signal.aborted) { - collected.push(output) - controller.abort() - } - }), - }, - ), - ) - expect(res.output).toContain("before") - expect(res.output).toContain("User aborted the command") - expect(collected.length).toBeGreaterThan(0) - }, - }) - }, 15_000) - - test("terminates command on timeout", async () => { - await Instance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initBash() - const result = await Effect.runPromise( - bash.execute( - { - command: `echo started && sleep 60`, - description: "Timeout test", - timeout: 500, - }, - ctx, - ), - ) - expect(result.output).toContain("started") - expect(result.output).toContain("bash tool terminated command after exceeding timeout") - expect(result.output).toContain("retry with a larger timeout value in milliseconds") - }, - }) - }, 15_000) - - test.skipIf(process.platform === "win32")("captures stderr in output", async () => { - await Instance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initBash() - const result = await Effect.runPromise( - bash.execute( - { - command: `echo stdout_msg && echo stderr_msg >&2`, - description: "Stderr test", - }, - ctx, - ), - ) - expect(result.output).toContain("stdout_msg") - expect(result.output).toContain("stderr_msg") - expect(result.metadata.exit).toBe(0) - }, - }) - }) - - test("returns non-zero exit code", async () => { - await Instance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initBash() - const result = await Effect.runPromise( - bash.execute( - { - command: `exit 42`, - description: "Non-zero exit", - }, - ctx, - ), - ) - expect(result.metadata.exit).toBe(42) - }, - }) - }) - - test("streams metadata updates progressively", async () => { - await Instance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initBash() - const updates: string[] = [] - const result = await Effect.runPromise( - bash.execute( - { - command: `echo first && sleep 0.1 && echo second`, - description: "Streaming test", - }, - { - ...ctx, - metadata: (input) => - Effect.sync(() => { - const output = (input.metadata as { output?: string })?.output - if (output) updates.push(output) - }), - }, - ), - ) - expect(result.output).toContain("first") - expect(result.output).toContain("second") - expect(updates.length).toBeGreaterThan(1) - }, - }) - }) -}) - -describe("tool.bash truncation", () => { - test("truncates output exceeding line limit", async () => { - await Instance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initBash() - const lineCount = Truncate.MAX_LINES + 500 - const result = await Effect.runPromise( - bash.execute( - { - command: fill("lines", lineCount), - description: "Generate lines exceeding limit", - }, - ctx, - ), - ) - mustTruncate(result) - expect(result.output).toMatch(/\.\.\.output truncated\.\.\./) - expect(result.output).toMatch(/Full output saved to:\s+\S+/) - }, - }) - }) - - test("truncates output exceeding byte limit", async () => { - await Instance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initBash() - const byteCount = Truncate.MAX_BYTES + 10000 - const result = await Effect.runPromise( - bash.execute( - { - command: fill("bytes", byteCount), - description: "Generate bytes exceeding limit", - }, - ctx, - ), - ) - mustTruncate(result) - expect(result.output).toMatch(/\.\.\.output truncated\.\.\./) - expect(result.output).toMatch(/Full output saved to:\s+\S+/) - }, - }) - }) - - test("does not truncate small output", async () => { - await Instance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initBash() - const result = await Effect.runPromise( - bash.execute( - { - command: "echo hello", - description: "Echo hello", - }, - ctx, - ), - ) - expect((result.metadata as { truncated?: boolean }).truncated).toBe(false) - expect(result.output).toContain("hello") - }, - }) - }) - - test("full output is saved to file when truncated", async () => { - await Instance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initBash() - const lineCount = Truncate.MAX_LINES + 100 - const result = await Effect.runPromise( - bash.execute( - { - command: fill("lines", lineCount), - description: "Generate lines for file check", - }, - ctx, - ), - ) - mustTruncate(result) - - const filepath = (result.metadata as { outputPath?: string }).outputPath - expect(filepath).toBeTruthy() - - const saved = await Filesystem.readText(filepath!) - const lines = saved.trim().split(/\r?\n/) - expect(lines.length).toBe(lineCount) - expect(lines[0]).toBe("1") - expect(lines[lineCount - 1]).toBe(String(lineCount)) - }, - }) - }) -}) diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts index bc42b0324..9f6a0617e 100644 --- a/packages/opencode/test/tool/parameters.test.ts +++ b/packages/opencode/test/tool/parameters.test.ts @@ -10,7 +10,6 @@ import { toJsonSchema } from "../../src/util/effect-zod" // byte-identical regardless of whether a tool has migrated from zod to Schema. import { Parameters as ApplyPatch } from "../../src/tool/apply_patch" -import { Parameters as Bash } from "../../src/tool/bash" import { Parameters as Edit } from "../../src/tool/edit" import { Parameters as Glob } from "../../src/tool/glob" import { Parameters as Grep } from "../../src/tool/grep" @@ -19,6 +18,7 @@ import { Parameters as Lsp } from "../../src/tool/lsp" import { Parameters as Plan } from "../../src/tool/plan" import { Parameters as Question } from "../../src/tool/question" import { Parameters as Read } from "../../src/tool/read" +import { Parameters as Shell } from "../../src/tool/shell" import { Parameters as Skill } from "../../src/tool/skill" import { Parameters as Task } from "../../src/tool/task" import { Parameters as Todo } from "../../src/tool/todo" @@ -35,7 +35,7 @@ const accepts = (schema: Schema.Decoder, input: unknown): boolean => describe("tool parameters", () => { describe("JSON Schema (wire shape)", () => { test("apply_patch", () => expect(toJsonSchema(ApplyPatch)).toMatchSnapshot()) - test("bash", () => expect(toJsonSchema(Bash)).toMatchSnapshot()) + test("bash", () => expect(toJsonSchema(Shell)).toMatchSnapshot()) test("edit", () => expect(toJsonSchema(Edit)).toMatchSnapshot()) test("glob", () => expect(toJsonSchema(Glob)).toMatchSnapshot()) test("grep", () => expect(toJsonSchema(Grep)).toMatchSnapshot()) @@ -66,20 +66,20 @@ describe("tool parameters", () => { }) }) - describe("bash", () => { + describe("shell", () => { test("accepts minimum: command + description", () => { - expect(parse(Bash, { command: "ls", description: "list" })).toEqual({ command: "ls", description: "list" }) + expect(parse(Shell, { command: "ls", description: "list" })).toEqual({ command: "ls", description: "list" }) }) test("accepts optional timeout + workdir", () => { - const parsed = parse(Bash, { command: "ls", description: "list", timeout: 5000, workdir: "/tmp" }) + const parsed = parse(Shell, { command: "ls", description: "list", timeout: 5000, workdir: "/tmp" }) expect(parsed.timeout).toBe(5000) expect(parsed.workdir).toBe("/tmp") }) - test("rejects missing description (required by zod)", () => { - expect(accepts(Bash, { command: "ls" })).toBe(false) + test("rejects missing description", () => { + expect(accepts(Shell, { command: "ls" })).toBe(false) }) test("rejects missing command", () => { - expect(accepts(Bash, { description: "list" })).toBe(false) + expect(accepts(Shell, { description: "list" })).toBe(false) }) }) diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts new file mode 100644 index 000000000..43295e2d5 --- /dev/null +++ b/packages/opencode/test/tool/shell.test.ts @@ -0,0 +1,1288 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Layer, ManagedRuntime } from "effect" +import os from "os" +import path from "path" +import { Config } from "@/config/config" +import { Shell } from "../../src/shell/shell" +import { ShellTool } from "../../src/tool/shell" +import { Instance } from "../../src/project/instance" +import { Filesystem } from "@/util/filesystem" +import { tmpdir } from "../fixture/fixture" +import type { Permission } from "../../src/permission" +import { Agent } from "../../src/agent/agent" +import { Truncate } from "@/tool/truncate" +import { SessionID, MessageID } from "../../src/session/schema" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Plugin } from "../../src/plugin" + +const runtime = ManagedRuntime.make( + Layer.mergeAll( + CrossSpawnSpawner.defaultLayer, + AppFileSystem.defaultLayer, + Plugin.defaultLayer, + Truncate.defaultLayer, + Config.defaultLayer, + Agent.defaultLayer, + ), +) + +function initBash() { + return runtime.runPromise(ShellTool.pipe(Effect.flatMap((info) => info.init()))) +} + +const initShell = initBash + +const ctx = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make(""), + callID: "", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, +} + +Shell.acceptable.reset() +const quote = (text: string) => `"${text}"` +const squote = (text: string) => `'${text}'` +const projectRoot = path.join(__dirname, "../..") +const bin = quote(process.execPath.replaceAll("\\", "/")) +const bash = (() => { + const shell = Shell.acceptable() + if (Shell.name(shell) === "bash") return shell + return Shell.gitbash() +})() +const shells = (() => { + if (process.platform !== "win32") { + const shell = Shell.acceptable() + return [{ label: Shell.name(shell), shell }] + } + + const list = [bash, Bun.which("pwsh"), Bun.which("powershell"), process.env.COMSPEC || Bun.which("cmd.exe")] + .filter((shell): shell is string => Boolean(shell)) + .map((shell) => ({ label: Shell.name(shell), shell })) + + return list.filter( + (item, i) => list.findIndex((other) => other.shell.toLowerCase() === item.shell.toLowerCase()) === i, + ) +})() +const PS = new Set(["pwsh", "powershell"]) +const ps = shells.filter((item) => PS.has(item.label)) +const cmdShell = shells.find((item) => item.label === "cmd") + +const sh = () => Shell.name(Shell.acceptable()) +const evalarg = (text: string) => (sh() === "cmd" ? quote(text) : squote(text)) + +const fill = (mode: "lines" | "bytes", n: number) => { + const code = + mode === "lines" + ? "console.log(Array.from({length:Number(Bun.argv[1])},(_,i)=>i+1).join(String.fromCharCode(10)))" + : "process.stdout.write(String.fromCharCode(97).repeat(Number(Bun.argv[1])))" + const text = `${bin} -e ${evalarg(code)} ${n}` + if (PS.has(sh())) return `& ${text}` + return text +} +const glob = (p: string) => + process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/") + +const forms = (dir: string) => { + if (process.platform !== "win32") return [dir] + const full = Filesystem.normalizePath(dir) + const slash = full.replaceAll("\\", "/") + const root = slash.replace(/^[A-Za-z]:/, "") + return Array.from(new Set([full, slash, root, root.toLowerCase()])) +} + +const withShell = (item: { label: string; shell: string }, fn: () => Promise) => async () => { + const prev = process.env.SHELL + process.env.SHELL = item.shell + Shell.acceptable.reset() + Shell.preferred.reset() + try { + await fn() + } finally { + if (prev === undefined) delete process.env.SHELL + else process.env.SHELL = prev + Shell.acceptable.reset() + Shell.preferred.reset() + } +} + +const each = (name: string, fn: (item: { label: string; shell: string }) => Promise) => { + for (const item of shells) { + test( + `${name} [${item.label}]`, + withShell(item, () => fn(item)), + ) + } +} + +const capture = (requests: Array>, stop?: Error) => ({ + ...ctx, + ask: (req: Omit) => + Effect.sync(() => { + requests.push(req) + if (stop) throw stop + }), +}) + +const mustTruncate = (result: { + metadata: { truncated?: boolean; exit?: number | null } & Record + output: string +}) => { + if (result.metadata.truncated) return + throw new Error( + [`shell: ${process.env.SHELL || ""}`, `exit: ${String(result.metadata.exit)}`, "output:", result.output].join("\n"), + ) +} + +describe("tool.shell", () => { + each("basic", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await initShell() + const result = await Effect.runPromise( + bash.execute( + { + command: "echo test", + description: "Echo test message", + }, + ctx, + ), + ) + expect(result.metadata.exit).toBe(0) + expect(result.metadata.output).toContain("test") + }, + }) + }) + + test("falls back from terminal-only configured shell", async () => { + await using tmp = await tmpdir({ + config: { shell: "fish" }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await initBash() + const fallback = Shell.name(Shell.acceptable("fish")) + expect(fallback).not.toBe("fish") + expect(bash.description).toContain(fallback) + + const result = await Effect.runPromise( + bash.execute( + { + command: "echo fallback", + description: "Echo fallback text", + }, + ctx, + ), + ) + expect(result.metadata.exit).toBe(0) + expect(result.output).toContain("fallback") + }, + }) + }) +}) + +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 initShell() + const requests: Array> = [] + await Effect.runPromise( + bash.execute( + { + command: "echo hello", + description: "Echo hello", + }, + capture(requests), + ), + ) + expect(requests.length).toBe(1) + expect(requests[0].permission).toBe("bash") + expect(requests[0].patterns).toContain("echo hello") + }, + }) + }) + + each("asks for bash permission with multiple commands", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await initShell() + const requests: Array> = [] + await Effect.runPromise( + bash.execute( + { + command: "echo foo && echo bar", + description: "Echo twice", + }, + capture(requests), + ), + ) + expect(requests.length).toBe(1) + expect(requests[0].permission).toBe("bash") + expect(requests[0].patterns).toContain("echo foo") + expect(requests[0].patterns).toContain("echo bar") + }, + }) + }) + + for (const item of ps) { + test( + `parses PowerShell conditionals for permission prompts [${item.label}]`, + withShell(item, async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await initShell() + const requests: Array> = [] + await Effect.runPromise( + bash.execute( + { + command: "Write-Host foo; if ($?) { Write-Host bar }", + description: "Check PowerShell conditional", + }, + capture(requests), + ), + ) + const bashReq = requests.find((r) => r.permission === "bash") + expect(bashReq).toBeDefined() + expect(bashReq!.patterns).toContain("Write-Host foo") + expect(bashReq!.patterns).toContain("Write-Host bar") + expect(bashReq!.always).toContain("Write-Host *") + }, + }) + }), + ) + } + + for (const item of ps) { + test( + `uses PowerShell cmdlet prefixes for always-allow prompts [${item.label}]`, + withShell(item, async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await initShell() + const err = new Error("stop after permission") + const requests: Array> = [] + await expect( + Effect.runPromise( + bash.execute( + { + command: "Remove-Item -Recurse tmp", + description: "Remove a temp directory", + }, + capture(requests, err), + ), + ), + ).rejects.toThrow(err.message) + const bashReq = requests.find((r) => r.permission === "bash") + expect(bashReq).toBeDefined() + expect(bashReq!.always).toContain("Remove-Item *") + expect(bashReq!.always).not.toContain("Remove-Item -Recurse *") + }, + }) + }), + ) + } + + each("asks for external_directory permission for wildcard external paths", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await initShell() + const err = new Error("stop after permission") + const requests: Array> = [] + const file = process.platform === "win32" ? `${process.env.WINDIR!.replaceAll("\\", "/")}/*` : "/etc/*" + const want = process.platform === "win32" ? glob(path.join(process.env.WINDIR!, "*")) : "/etc/*" + await expect( + Effect.runPromise( + bash.execute( + { + command: `cat ${file}`, + description: "Read wildcard path", + }, + capture(requests, err), + ), + ), + ).rejects.toThrow(err.message) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain(want) + }, + }) + }) + + if (process.platform === "win32") { + if (bash) { + test( + "asks for nested bash command permissions [bash]", + withShell({ label: "bash", shell: bash }, async () => { + await using outerTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "outside.txt"), "x") + }, + }) + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await initShell() + const file = path.join(outerTmp.path, "outside.txt").replaceAll("\\", "/") + const requests: Array> = [] + await Effect.runPromise( + bash.execute( + { + command: `echo $(cat "${file}")`, + description: "Read nested bash file", + }, + capture(requests), + ), + ) + const extDirReq = requests.find((r) => r.permission === "external_directory") + const bashReq = requests.find((r) => r.permission === "bash") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain(glob(path.join(outerTmp.path, "*"))) + expect(bashReq).toBeDefined() + expect(bashReq!.patterns).toContain(`cat "${file}"`) + }, + }) + }), + ) + } + } + + if (process.platform === "win32") { + for (const item of ps) { + test( + `asks for external_directory permission for PowerShell paths after switches [${item.label}]`, + withShell(item, async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await initShell() + const err = new Error("stop after permission") + const requests: Array> = [] + await expect( + Effect.runPromise( + bash.execute( + { + command: `Copy-Item -PassThru "${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini" ./out`, + description: "Copy Windows ini", + }, + capture(requests, err), + ), + ), + ).rejects.toThrow(err.message) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain(glob(path.join(process.env.WINDIR!, "*"))) + }, + }) + }), + ) + } + + for (const item of ps) { + test( + `asks for nested PowerShell command permissions [${item.label}]`, + withShell(item, async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await initShell() + const requests: Array> = [] + const file = `${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini` + await Effect.runPromise( + bash.execute( + { + command: `Write-Output $(Get-Content ${file})`, + description: "Read nested PowerShell file", + }, + capture(requests), + ), + ) + const extDirReq = requests.find((r) => r.permission === "external_directory") + const bashReq = requests.find((r) => r.permission === "bash") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain(glob(path.join(process.env.WINDIR!, "*"))) + expect(bashReq).toBeDefined() + expect(bashReq!.patterns).toContain(`Get-Content ${file}`) + }, + }) + }), + ) + } + + for (const item of ps) { + test( + `asks for external_directory permission for drive-relative PowerShell paths [${item.label}]`, + withShell(item, async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await initShell() + const err = new Error("stop after permission") + const requests: Array> = [] + await expect( + Effect.runPromise( + bash.execute( + { + command: 'Get-Content "C:../outside.txt"', + description: "Read drive-relative file", + }, + capture(requests, err), + ), + ), + ).rejects.toThrow(err.message) + expect(requests[0]?.permission).toBe("external_directory") + if (requests[0]?.permission !== "external_directory") return + expect(requests[0].patterns).toContain(glob(path.join(path.dirname(tmp.path), "*"))) + }, + }) + }), + ) + } + + for (const item of ps) { + test( + `asks for external_directory permission for $HOME PowerShell paths [${item.label}]`, + withShell(item, async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await initShell() + const err = new Error("stop after permission") + const requests: Array> = [] + await expect( + Effect.runPromise( + bash.execute( + { + command: 'Get-Content "$HOME/.ssh/config"', + description: "Read home config", + }, + capture(requests, err), + ), + ), + ).rejects.toThrow(err.message) + expect(requests[0]?.permission).toBe("external_directory") + if (requests[0]?.permission !== "external_directory") return + expect(requests[0].patterns).toContain(glob(path.join(os.homedir(), ".ssh", "*"))) + }, + }) + }), + ) + } + + for (const item of ps) { + test( + `asks for external_directory permission for $PWD PowerShell paths [${item.label}]`, + withShell(item, async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await initBash() + const err = new Error("stop after permission") + const requests: Array> = [] + await expect( + Effect.runPromise( + bash.execute( + { + command: 'Get-Content "$PWD/../outside.txt"', + description: "Read pwd-relative file", + }, + capture(requests, err), + ), + ), + ).rejects.toThrow(err.message) + expect(requests[0]?.permission).toBe("external_directory") + if (requests[0]?.permission !== "external_directory") return + expect(requests[0].patterns).toContain(glob(path.join(path.dirname(tmp.path), "*"))) + }, + }) + }), + ) + } + + for (const item of ps) { + test( + `asks for external_directory permission for $PSHOME PowerShell paths [${item.label}]`, + withShell(item, async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await initBash() + const err = new Error("stop after permission") + const requests: Array> = [] + await expect( + Effect.runPromise( + bash.execute( + { + command: 'Get-Content "$PSHOME/outside.txt"', + description: "Read pshome file", + }, + capture(requests, err), + ), + ), + ).rejects.toThrow(err.message) + expect(requests[0]?.permission).toBe("external_directory") + if (requests[0]?.permission !== "external_directory") return + expect(requests[0].patterns).toContain(glob(path.join(path.dirname(item.shell), "*"))) + }, + }) + }), + ) + } + + for (const item of ps) { + test( + `asks for external_directory permission for missing PowerShell env paths [${item.label}]`, + withShell(item, async () => { + const key = "OPENCODE_TEST_MISSING" + const prev = process.env[key] + delete process.env[key] + try { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await initShell() + const err = new Error("stop after permission") + const requests: Array> = [] + const root = path.parse(process.env.WINDIR!).root.replace(/[\\/]+$/, "") + await expect( + Effect.runPromise( + bash.execute( + { + command: `Get-Content -Path "${root}$env:${key}\\Windows\\win.ini"`, + description: "Read Windows ini with missing env", + }, + capture(requests, err), + ), + ), + ).rejects.toThrow(err.message) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain(glob(path.join(process.env.WINDIR!, "*"))) + }, + }) + } finally { + if (prev === undefined) delete process.env[key] + else process.env[key] = prev + } + }), + ) + } + + for (const item of ps) { + test( + `asks for external_directory permission for PowerShell env paths [${item.label}]`, + withShell(item, async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await initBash() + const requests: Array> = [] + await Effect.runPromise( + bash.execute( + { + command: "Get-Content $env:WINDIR/win.ini", + description: "Read Windows ini from env", + }, + capture(requests), + ), + ) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain( + Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")), + ) + }, + }) + }), + ) + } + + for (const item of ps) { + test( + `asks for external_directory permission for PowerShell FileSystem paths [${item.label}]`, + withShell(item, async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await initBash() + const err = new Error("stop after permission") + const requests: Array> = [] + await expect( + Effect.runPromise( + bash.execute( + { + command: `Get-Content -Path FileSystem::${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`, + description: "Read Windows ini from FileSystem provider", + }, + capture(requests, err), + ), + ), + ).rejects.toThrow(err.message) + expect(requests[0]?.permission).toBe("external_directory") + if (requests[0]?.permission !== "external_directory") return + expect(requests[0].patterns).toContain( + Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")), + ) + }, + }) + }), + ) + } + + for (const item of ps) { + test( + `asks for external_directory permission for braced PowerShell env paths [${item.label}]`, + withShell(item, async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await initBash() + const err = new Error("stop after permission") + const requests: Array> = [] + await expect( + Effect.runPromise( + bash.execute( + { + command: "Get-Content ${env:WINDIR}/win.ini", + description: "Read Windows ini from braced env", + }, + capture(requests, err), + ), + ), + ).rejects.toThrow(err.message) + expect(requests[0]?.permission).toBe("external_directory") + if (requests[0]?.permission !== "external_directory") return + expect(requests[0].patterns).toContain( + Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")), + ) + }, + }) + }), + ) + } + + for (const item of ps) { + test( + `treats Set-Location like cd for permissions [${item.label}]`, + withShell(item, async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await initBash() + const requests: Array> = [] + await Effect.runPromise( + bash.execute( + { + command: "Set-Location C:/Windows", + description: "Change location", + }, + capture(requests), + ), + ) + const extDirReq = requests.find((r) => r.permission === "external_directory") + const bashReq = requests.find((r) => r.permission === "bash") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain( + Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")), + ) + expect(bashReq).toBeUndefined() + }, + }) + }), + ) + } + + for (const item of ps) { + test( + `does not add nested PowerShell expressions to permission prompts [${item.label}]`, + withShell(item, async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await initShell() + const requests: Array> = [] + await Effect.runPromise( + bash.execute( + { + command: "Write-Output ('a' * 3)", + description: "Write repeated text", + }, + capture(requests), + ), + ) + const bashReq = requests.find((r) => r.permission === "bash") + expect(bashReq).toBeDefined() + expect(bashReq!.patterns).not.toContain("a * 3") + expect(bashReq!.always).not.toContain("a *") + }, + }) + }), + ) + } + } + + if (process.platform === "win32" && cmdShell) { + test( + "asks for external_directory permission for cmd file commands [cmd]", + withShell(cmdShell, async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await initShell() + const requests: Array> = [] + await Effect.runPromise( + bash.execute( + { + command: `TYPE "${path.join(process.env.WINDIR!, "win.ini")}"`, + description: "Read Windows ini with cmd", + }, + capture(requests), + ), + ) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain( + Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")), + ) + }, + }) + }), + ) + } + + each("asks for external_directory permission when cd to parent", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await initBash() + const err = new Error("stop after permission") + const requests: Array> = [] + await expect( + Effect.runPromise( + bash.execute( + { + command: "cd ../", + description: "Change to parent directory", + }, + capture(requests, err), + ), + ), + ).rejects.toThrow(err.message) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + }, + }) + }) + + each("asks for external_directory permission when workdir is outside project", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await initBash() + const err = new Error("stop after permission") + const requests: Array> = [] + await expect( + Effect.runPromise( + bash.execute( + { + command: "echo ok", + workdir: os.tmpdir(), + description: "Echo from temp dir", + }, + capture(requests, err), + ), + ), + ).rejects.toThrow(err.message) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain(glob(path.join(os.tmpdir(), "*"))) + }, + }) + }) + + if (process.platform === "win32") { + test("normalizes external_directory workdir variants on Windows", async () => { + const err = new Error("stop after permission") + await using outerTmp = await tmpdir() + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await initBash() + const want = Filesystem.normalizePathPattern(path.join(outerTmp.path, "*")) + + for (const dir of forms(outerTmp.path)) { + const requests: Array> = [] + await expect( + Effect.runPromise( + bash.execute( + { + command: "echo ok", + workdir: dir, + description: "Echo from external dir", + }, + capture(requests, err), + ), + ), + ).rejects.toThrow(err.message) + + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect({ dir, patterns: extDirReq?.patterns, always: extDirReq?.always }).toEqual({ + dir, + patterns: [want], + always: [want], + }) + } + }, + }) + }) + + if (bash) { + test( + "uses Git Bash /tmp semantics for external workdir", + withShell({ label: "bash", shell: bash }, async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await initBash() + const err = new Error("stop after permission") + const requests: Array> = [] + const want = glob(path.join(os.tmpdir(), "*")) + await expect( + Effect.runPromise( + bash.execute( + { + command: "echo ok", + workdir: "/tmp", + description: "Echo from Git Bash tmp", + }, + capture(requests, err), + ), + ), + ).rejects.toThrow(err.message) + expect(requests[0]).toMatchObject({ + permission: "external_directory", + patterns: [want], + always: [want], + }) + }, + }) + }), + ) + + test( + "uses Git Bash /tmp semantics for external file paths", + withShell({ label: "bash", shell: bash }, async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await initBash() + const err = new Error("stop after permission") + const requests: Array> = [] + const want = glob(path.join(os.tmpdir(), "*")) + await expect( + Effect.runPromise( + bash.execute( + { + command: "cat /tmp/opencode-does-not-exist", + description: "Read Git Bash tmp file", + }, + capture(requests, err), + ), + ), + ).rejects.toThrow(err.message) + expect(requests[0]).toMatchObject({ + permission: "external_directory", + patterns: [want], + always: [want], + }) + }, + }) + }), + ) + } + } + + each("asks for external_directory permission when file arg is outside project", async () => { + await using outerTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "outside.txt"), "x") + }, + }) + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await initBash() + const err = new Error("stop after permission") + const requests: Array> = [] + const filepath = path.join(outerTmp.path, "outside.txt") + await expect( + Effect.runPromise( + bash.execute( + { + command: `cat ${filepath}`, + description: "Read external file", + }, + capture(requests, err), + ), + ), + ).rejects.toThrow(err.message) + const extDirReq = requests.find((r) => r.permission === "external_directory") + const expected = glob(path.join(outerTmp.path, "*")) + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain(expected) + expect(extDirReq!.always).toContain(expected) + }, + }) + }) + + each("does not ask for external_directory permission when rm inside project", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "tmpfile"), "x") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await initBash() + const requests: Array> = [] + await Effect.runPromise( + bash.execute( + { + command: `rm -rf ${path.join(tmp.path, "nested")}`, + description: "Remove nested dir", + }, + capture(requests), + ), + ) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeUndefined() + }, + }) + }) + + each("includes always patterns for auto-approval", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await initBash() + const requests: Array> = [] + await Effect.runPromise( + bash.execute( + { + command: "git log --oneline -5", + description: "Git log", + }, + capture(requests), + ), + ) + expect(requests.length).toBe(1) + expect(requests[0].always.length).toBeGreaterThan(0) + expect(requests[0].always.some((item) => item.endsWith("*"))).toBe(true) + }, + }) + }) + + each("does not ask for bash permission when command is cd only", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await initShell() + const requests: Array> = [] + await Effect.runPromise( + bash.execute( + { + command: "cd .", + description: "Stay in current directory", + }, + capture(requests), + ), + ) + const bashReq = requests.find((r) => r.permission === "bash") + expect(bashReq).toBeUndefined() + }, + }) + }) + + each("matches redirects in permission pattern", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await initShell() + const err = new Error("stop after permission") + const requests: Array> = [] + await expect( + Effect.runPromise( + bash.execute( + { command: "echo test > output.txt", description: "Redirect test output" }, + capture(requests, err), + ), + ), + ).rejects.toThrow(err.message) + const bashReq = requests.find((r) => r.permission === "bash") + expect(bashReq).toBeDefined() + expect(bashReq!.patterns).toContain("echo test > output.txt") + }, + }) + }) + + each("always pattern has space before wildcard to not include different commands", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await initBash() + const requests: Array> = [] + await Effect.runPromise(bash.execute({ command: "ls -la", description: "List" }, capture(requests))) + const bashReq = requests.find((r) => r.permission === "bash") + expect(bashReq).toBeDefined() + expect(bashReq!.always[0]).toBe("ls *") + }, + }) + }) +}) + +describe("tool.shell abort", () => { + test("preserves output when aborted", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await initShell() + const controller = new AbortController() + const collected: string[] = [] + const res = await Effect.runPromise( + bash.execute( + { + command: `echo before && sleep 30`, + description: "Long running command", + }, + { + ...ctx, + abort: controller.signal, + metadata: (input) => + Effect.sync(() => { + const output = (input.metadata as { output?: string })?.output + if (output && output.includes("before") && !controller.signal.aborted) { + collected.push(output) + controller.abort() + } + }), + }, + ), + ) + expect(res.output).toContain("before") + expect(res.output).toContain("User aborted the command") + expect(collected.length).toBeGreaterThan(0) + }, + }) + }, 15_000) + + test("terminates command on timeout", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await initShell() + const result = await Effect.runPromise( + bash.execute( + { + command: `echo started && sleep 60`, + description: "Timeout test", + timeout: 500, + }, + ctx, + ), + ) + expect(result.output).toContain("started") + expect(result.output).toContain("shell tool terminated command after exceeding timeout") + expect(result.output).toContain("retry with a larger timeout value in milliseconds") + }, + }) + }, 15_000) + + test.skipIf(process.platform === "win32")("captures stderr in output", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await initShell() + const result = await Effect.runPromise( + bash.execute( + { + command: `echo stdout_msg && echo stderr_msg >&2`, + description: "Stderr test", + }, + ctx, + ), + ) + expect(result.output).toContain("stdout_msg") + expect(result.output).toContain("stderr_msg") + expect(result.metadata.exit).toBe(0) + }, + }) + }) + + test("returns non-zero exit code", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await initShell() + const result = await Effect.runPromise( + bash.execute( + { + command: `exit 42`, + description: "Non-zero exit", + }, + ctx, + ), + ) + expect(result.metadata.exit).toBe(42) + }, + }) + }) + + test("streams metadata updates progressively", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await initBash() + const updates: string[] = [] + const result = await Effect.runPromise( + bash.execute( + { + command: `echo first && sleep 0.1 && echo second`, + description: "Streaming test", + }, + { + ...ctx, + metadata: (input) => + Effect.sync(() => { + const output = (input.metadata as { output?: string })?.output + if (output) updates.push(output) + }), + }, + ), + ) + expect(result.output).toContain("first") + expect(result.output).toContain("second") + expect(updates.length).toBeGreaterThan(1) + }, + }) + }) +}) + +describe("tool.shell truncation", () => { + test("truncates output exceeding line limit", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await initShell() + const lineCount = Truncate.MAX_LINES + 500 + const result = await Effect.runPromise( + bash.execute( + { + command: fill("lines", lineCount), + description: "Generate lines exceeding limit", + }, + ctx, + ), + ) + mustTruncate(result) + expect(result.output).toMatch(/\.\.\.output truncated\.\.\./) + expect(result.output).toMatch(/Full output saved to:\s+\S+/) + }, + }) + }) + + test("truncates output exceeding byte limit", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await initShell() + const byteCount = Truncate.MAX_BYTES + 10000 + const result = await Effect.runPromise( + bash.execute( + { + command: fill("bytes", byteCount), + description: "Generate bytes exceeding limit", + }, + ctx, + ), + ) + mustTruncate(result) + expect(result.output).toMatch(/\.\.\.output truncated\.\.\./) + expect(result.output).toMatch(/Full output saved to:\s+\S+/) + }, + }) + }) + + test("does not truncate small output", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await initShell() + const result = await Effect.runPromise( + bash.execute( + { + command: "echo hello", + description: "Echo hello", + }, + ctx, + ), + ) + expect((result.metadata as { truncated?: boolean }).truncated).toBe(false) + expect(result.output).toContain("hello") + }, + }) + }) + + test("full output is saved to file when truncated", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await initShell() + const lineCount = Truncate.MAX_LINES + 100 + const result = await Effect.runPromise( + bash.execute( + { + command: fill("lines", lineCount), + description: "Generate lines for file check", + }, + ctx, + ), + ) + mustTruncate(result) + + const filepath = (result.metadata as { outputPath?: string }).outputPath + expect(filepath).toBeTruthy() + + const saved = await Filesystem.readText(filepath!) + const lines = saved.trim().split(/\r?\n/) + expect(lines.length).toBe(lineCount) + expect(lines[0]).toBe("1") + expect(lines[lineCount - 1]).toBe(String(lineCount)) + }, + }) + }) +}) -- cgit v1.2.3