| export * as BashTool from "./bash" |
|
|
| import path from "path" |
| import { ToolFailure } from "@opencode-ai/llm" |
| import { Duration, Effect, Layer, Schema } from "effect" |
| import { ChildProcess } from "effect/unstable/process" |
| import { Config } from "../config" |
| import { makeLocationNode } from "../effect/app-node" |
| import { FSUtil } from "../fs-util" |
| import { LocationMutation } from "../location-mutation" |
| import { AppProcess } from "../process" |
| import { PermissionV2 } from "../permission" |
| import { PositiveInt } from "../schema" |
| import { ToolRegistry } from "./registry" |
| import { Tool } from "./tool" |
| import { Tools } from "./tools" |
|
|
| export const name = "bash" |
| export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000 |
| export const MAX_TIMEOUT_MS = 10 * 60 * 1_000 |
| export const MAX_CAPTURE_BYTES = 1024 * 1024 |
|
|
| export const Input = Schema.Struct({ |
| command: Schema.String.annotate({ description: "Shell command string to execute" }), |
| workdir: Schema.String.pipe(Schema.optional).annotate({ |
| description: "Working directory. Defaults to the active Location; relative paths resolve from that Location.", |
| }), |
| timeout: PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_TIMEOUT_MS)) |
| .pipe(Schema.optional) |
| .annotate({ |
| description: `Timeout in milliseconds. Defaults to ${DEFAULT_TIMEOUT_MS} and may not exceed ${MAX_TIMEOUT_MS}.`, |
| }), |
| }) |
|
|
| const StructuredOutput = Schema.Struct({ |
| exit: Schema.Number.pipe(Schema.optional), |
| truncated: Schema.Boolean, |
| timeout: Schema.Boolean.pipe(Schema.optional), |
| }) |
|
|
| const Output = Schema.Struct({ |
| ...StructuredOutput.fields, |
| output: Schema.String, |
| warnings: Schema.Array(Schema.String).pipe(Schema.optional), |
| }) |
|
|
| type Output = typeof Output.Type |
|
|
| const defaultShell = () => (process.platform === "win32" ? (process.env.COMSPEC ?? "cmd.exe") : "/bin/sh") |
|
|
| const modelOutput = (output: Output) => { |
| const warnings = output.warnings?.length |
| ? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}` |
| : "" |
| if (output.timeout) return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command timed out before completion.` |
| return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command exited with code ${output.exit}.` |
| } |
|
|
| const isTimeout = (error: AppProcess.AppProcessError) => |
| error.cause instanceof Error && error.cause.message === "Timed out" |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const shellTokens = (command: string) => command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [] |
| const unquote = (value: string) => value.replace(/^(['"])(.*)\1$/, "$2") |
| const externalCommandDirectories = Effect.fn("BashTool.externalCommandDirectories")(function* ( |
| fs: FSUtil.Interface, |
| command: string, |
| cwd: string, |
| ) { |
| const directories = new Set<string>() |
| for (const token of shellTokens(command)) { |
| const value = unquote(token).replace(/[;,|&]+$/, "") |
| if (!path.isAbsolute(value)) continue |
| const resolved = yield* fs.resolve(value) |
| if (FSUtil.contains(cwd, resolved)) continue |
| directories.add(yield* fs.resolve(path.dirname(resolved))) |
| } |
| return [...directories] |
| }) |
|
|
| const layer = Layer.effectDiscard( |
| Effect.gen(function* () { |
| const tools = yield* Tools.Service |
| const mutation = yield* LocationMutation.Service |
| const fs = yield* FSUtil.Service |
| const appProcess = yield* AppProcess.Service |
| const config = yield* Config.Service |
| const permission = yield* PermissionV2.Service |
|
|
| yield* tools |
| .register({ |
| [name]: Tool.make({ |
| description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows.`, |
| input: Input, |
| output: Output, |
| structured: StructuredOutput, |
| toStructuredOutput: ({ output }) => ({ |
| truncated: output.truncated, |
| ...(output.exit === undefined ? {} : { exit: output.exit }), |
| ...(output.timeout === undefined ? {} : { timeout: output.timeout }), |
| }), |
| toModelOutput: ({ output }) => [ |
| { type: "text", text: output.output }, |
| { type: "text", text: modelOutput(output) }, |
| ], |
| execute: (input, context) => |
| Effect.gen(function* () { |
| const source = { |
| type: "tool" as const, |
| messageID: context.assistantMessageID, |
| callID: context.toolCallID, |
| } |
| const target = yield* mutation.resolve({ path: input.workdir ?? ".", kind: "directory" }) |
| const external = target.externalDirectory |
| if (external) |
| yield* permission.assert({ |
| ...LocationMutation.externalDirectoryPermission(external), |
| sessionID: context.sessionID, |
| agent: context.agent, |
| source, |
| }) |
| const warnings = (yield* externalCommandDirectories(fs, input.command, target.canonical)).map( |
| (directory) => |
| `Command argument references external directory ${path.join(directory, "*").replaceAll("\\", "/")}. Bash runs with host-user filesystem, process, and network authority; this scan is advisory only.`, |
| ) |
| yield* permission.assert({ |
| action: name, |
| resources: [input.command], |
| save: [input.command], |
| sessionID: context.sessionID, |
| agent: context.agent, |
| source, |
| }) |
|
|
| if ((yield* fs.stat(target.canonical)).type !== "Directory") |
| return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`)) |
|
|
| const entries = yield* config.entries() |
| const shell = |
| Object.assign({}, ...entries.flatMap((entry) => (entry.type === "document" ? [entry.info] : []))) |
| .shell ?? defaultShell() |
| const command = ChildProcess.make(input.command, [], { |
| cwd: target.canonical, |
| shell, |
| stdin: "ignore", |
| detached: process.platform !== "win32", |
| forceKillAfter: Duration.seconds(3), |
| }) |
| const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS |
| const result = yield* appProcess |
| .run(command, { |
| combineOutput: true, |
| timeout: Duration.millis(timeout), |
| maxOutputBytes: MAX_CAPTURE_BYTES, |
| }) |
| .pipe( |
| Effect.catchTag("AppProcessError", (error) => |
| isTimeout(error) ? Effect.succeed(undefined) : Effect.fail(error), |
| ), |
| ) |
| if (!result) { |
| return { |
| output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`, |
| truncated: false, |
| timeout: true, |
| ...(warnings.length ? { warnings } : {}), |
| } |
| } |
|
|
| const output = result.output?.toString("utf8") || "(no output)" |
| const notice = result.outputTruncated |
| ? "[output capture truncated at the in-memory safety limit]" |
| : undefined |
| return { |
| exit: result.exitCode, |
| output: notice ? `${output}\n\n${notice}` : output, |
| truncated: result.outputTruncated === true, |
| ...(warnings.length ? { warnings } : {}), |
| } |
| }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))), |
| }), |
| }) |
| .pipe(Effect.orDie) |
| }), |
| ) |
|
|
| export const node = makeLocationNode({ |
| name: "tool/bash", |
| layer, |
| deps: [ToolRegistry.node, LocationMutation.node, FSUtil.node, AppProcess.node, Config.node, PermissionV2.node], |
| }) |
|
|