refactor(tool): convert bash to defineEffect with ChildProcessSpawner (#21895)

This commit is contained in:
Kit Langton
2026-04-10 13:26:31 -04:00
committed by GitHub
parent 180ded6a27
commit f7514d9eca
6 changed files with 291 additions and 260 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ import { WorkspaceContext } from "@/control-plane/workspace-context"
export const memoMap = Layer.makeMemoMapUnsafe() export const memoMap = Layer.makeMemoMapUnsafe()
function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> { export function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
try { try {
const ctx = Instance.current const ctx = Instance.current
const workspaceID = WorkspaceContext.workspaceID const workspaceID = WorkspaceContext.workspaceID
+230 -220
View File
@@ -8,8 +8,7 @@ import { Instance } from "../project/instance"
import { lazy } from "@/util/lazy" import { lazy } from "@/util/lazy"
import { Language, type Node } from "web-tree-sitter" import { Language, type Node } from "web-tree-sitter"
import { Filesystem } from "@/util/filesystem" import { AppFileSystem } from "@/filesystem"
import { Process } from "@/util/process"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { Shell } from "@/shell/shell" import { Shell } from "@/shell/shell"
@@ -17,9 +16,9 @@ import { Shell } from "@/shell/shell"
import { BashArity } from "@/permission/arity" import { BashArity } from "@/permission/arity"
import { Truncate } from "./truncate" import { Truncate } from "./truncate"
import { Plugin } from "@/plugin" import { Plugin } from "@/plugin"
import { Cause, Effect, Exit, Stream } from "effect" import { Effect, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { ChildProcess } from "effect/unstable/process"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
const MAX_METADATA_LENGTH = 30_000 const MAX_METADATA_LENGTH = 30_000
const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
@@ -183,33 +182,6 @@ function prefix(text: string) {
return text.slice(0, match.index) return text.slice(0, match.index)
} }
async function cygpath(shell: string, text: string) {
const out = await Process.text([shell, "-lc", 'cygpath -w -- "$1"', "_", text], { nothrow: true })
if (out.code !== 0) return
const file = out.text.trim()
if (!file) return
return Filesystem.normalizePath(file)
}
async function resolvePath(text: string, root: string, shell: string) {
if (process.platform === "win32") {
if (Shell.posix(shell) && text.startsWith("/") && Filesystem.windowsPath(text) === text) {
const file = await cygpath(shell, text)
if (file) return file
}
return Filesystem.normalizePath(path.resolve(root, Filesystem.windowsPath(text)))
}
return path.resolve(root, text)
}
async function argPath(arg: string, cwd: string, ps: boolean, shell: string) {
const text = ps ? expand(arg, cwd, shell) : home(unquote(arg))
const file = text && prefix(text)
if (!file || dynamic(file, ps)) return
const next = ps ? provider(file) : file
if (!next) return
return resolvePath(next, cwd, shell)
}
function pathArgs(list: Part[], ps: boolean) { function pathArgs(list: Part[], ps: boolean) {
if (!ps) { if (!ps) {
@@ -238,78 +210,45 @@ function pathArgs(list: Part[], ps: boolean) {
return out return out
} }
async function collect(root: Node, cwd: string, ps: boolean, shell: string): Promise<Scan> {
const scan: Scan = {
dirs: new Set<string>(),
patterns: new Set<string>(),
always: new Set<string>(),
}
for (const node of commands(root)) {
const command = parts(node)
const tokens = command.map((item) => item.text)
const cmd = ps ? tokens[0]?.toLowerCase() : tokens[0]
if (cmd && FILES.has(cmd)) {
for (const arg of pathArgs(command, ps)) {
const resolved = await argPath(arg, cwd, ps, shell)
log.info("resolved path", { arg, resolved })
if (!resolved || Instance.containsPath(resolved)) continue
const dir = (await Filesystem.isDir(resolved)) ? resolved : path.dirname(resolved)
scan.dirs.add(dir)
}
}
if (tokens.length && (!cmd || !CWD.has(cmd))) {
scan.patterns.add(source(node))
scan.always.add(BashArity.prefix(tokens).join(" ") + " *")
}
}
return scan
}
function preview(text: string) { function preview(text: string) {
if (text.length <= MAX_METADATA_LENGTH) return text if (text.length <= MAX_METADATA_LENGTH) return text
return text.slice(0, MAX_METADATA_LENGTH) + "\n\n..." return text.slice(0, MAX_METADATA_LENGTH) + "\n\n..."
} }
async function parse(command: string, ps: boolean) { const parse = Effect.fn("BashTool.parse")(function* (command: string, ps: boolean) {
const tree = await parser().then((p) => (ps ? p.ps : p.bash).parse(command)) const tree = yield* Effect.promise(() => parser().then((p) => (ps ? p.ps : p.bash).parse(command)))
if (!tree) throw new Error("Failed to parse command") if (!tree) throw new Error("Failed to parse command")
return tree.rootNode return tree.rootNode
} })
async function ask(ctx: Tool.Context, scan: Scan) { const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan) {
if (scan.dirs.size > 0) { if (scan.dirs.size > 0) {
const globs = Array.from(scan.dirs).map((dir) => { const globs = Array.from(scan.dirs).map((dir) => {
if (process.platform === "win32") return Filesystem.normalizePathPattern(path.join(dir, "*")) if (process.platform === "win32") return AppFileSystem.normalizePathPattern(path.join(dir, "*"))
return path.join(dir, "*") return path.join(dir, "*")
}) })
await ctx.ask({ yield* Effect.promise(() =>
permission: "external_directory", ctx.ask({
patterns: globs, permission: "external_directory",
always: globs, patterns: globs,
metadata: {}, always: globs,
}) metadata: {},
}),
)
} }
if (scan.patterns.size === 0) return if (scan.patterns.size === 0) return
await ctx.ask({ yield* Effect.promise(() =>
permission: "bash", ctx.ask({
patterns: Array.from(scan.patterns), permission: "bash",
always: Array.from(scan.always), patterns: Array.from(scan.patterns),
metadata: {}, always: Array.from(scan.always),
}) metadata: {},
} }),
)
})
async function shellEnv(ctx: Tool.Context, cwd: string) {
const extra = await Plugin.trigger("shell.env", { cwd, sessionID: ctx.sessionID, callID: ctx.callID }, { env: {} })
return {
...process.env,
...extra.env,
}
}
function cmd(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) { function cmd(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
if (process.platform === "win32" && PS.has(name)) { if (process.platform === "win32" && PS.has(name)) {
@@ -330,99 +269,6 @@ function cmd(shell: string, name: string, command: string, cwd: string, env: Nod
}) })
} }
async function run(
input: {
shell: string
name: string
command: string
cwd: string
env: NodeJS.ProcessEnv
timeout: number
description: string
},
ctx: Tool.Context,
) {
let output = ""
let expired = false
let aborted = false
ctx.metadata({
metadata: {
output: "",
description: input.description,
},
})
const exit = await CrossSpawnSpawner.runPromiseExit((spawner) =>
Effect.gen(function* () {
const handle = yield* spawner.spawn(cmd(input.shell, input.name, input.command, input.cwd, input.env))
yield* Effect.forkScoped(
Stream.runForEach(Stream.decodeText(handle.all), (chunk) =>
Effect.sync(() => {
output += chunk
ctx.metadata({
metadata: {
output: preview(output),
description: input.description,
},
})
}),
),
)
const abort = Effect.callback<void>((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.scoped, Effect.orDie),
)
let code: number | null = null
if (Exit.isSuccess(exit)) {
code = exit.value
} else if (!Cause.hasInterruptsOnly(exit.cause)) {
throw Cause.squash(exit.cause)
}
const meta: string[] = []
if (expired) meta.push(`bash tool terminated command after exceeding timeout ${input.timeout} ms`)
if (aborted) meta.push("User aborted the command")
if (meta.length > 0) {
output += "\n\n<bash_metadata>\n" + meta.join("\n") + "\n</bash_metadata>"
}
return {
title: input.description,
metadata: {
output: preview(output),
exit: code,
description: input.description,
},
output,
}
}
const parser = lazy(async () => { const parser = lazy(async () => {
const { Parser } = await import("web-tree-sitter") const { Parser } = await import("web-tree-sitter")
@@ -452,47 +298,211 @@ const parser = lazy(async () => {
}) })
// TODO: we may wanna rename this tool so it works better on other shells // TODO: we may wanna rename this tool so it works better on other shells
export const BashTool = Tool.define("bash", async () => { export const BashTool = Tool.defineEffect(
const shell = Shell.acceptable() "bash",
const name = Shell.name(shell) Effect.gen(function* () {
const chain = const spawner = yield* ChildProcessSpawner
name === "powershell" const fs = yield* AppFileSystem.Service
? "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." const plugin = yield* Plugin.Service
: "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead."
log.info("bash tool using shell", { shell })
return { const cygpath = Effect.fn("BashTool.cygpath")(function* (shell: string, text: string) {
description: DESCRIPTION.replaceAll("${directory}", Instance.directory) const lines = yield* spawner.lines(
.replaceAll("${os}", process.platform) ChildProcess.make(shell, ["-lc", 'cygpath -w -- "$1"', "_", text]),
.replaceAll("${shell}", name) ).pipe(Effect.catch(() => Effect.succeed([] as string[])))
.replaceAll("${chaining}", chain) const file = lines[0]?.trim()
.replaceAll("${maxLines}", String(Truncate.MAX_LINES)) if (!file) return
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)), return AppFileSystem.normalizePath(file)
parameters: Parameters, })
async execute(params, ctx) {
const cwd = params.workdir ? await resolvePath(params.workdir, Instance.directory, shell) : Instance.directory const resolvePath = Effect.fn("BashTool.resolvePath")(function* (text: string, root: string, shell: string) {
if (params.timeout !== undefined && params.timeout < 0) { if (process.platform === "win32") {
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) 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)))
} }
const timeout = params.timeout ?? DEFAULT_TIMEOUT return path.resolve(root, text)
const ps = PS.has(name) })
const root = await parse(params.command, ps)
const scan = await collect(root, cwd, ps, shell)
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
await ask(ctx, scan)
return run( 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))
shell, const file = text && prefix(text)
name, if (!file || dynamic(file, ps)) return
command: params.command, const next = ps ? provider(file) : file
cwd, if (!next) return
env: await shellEnv(ctx, cwd), return yield* resolvePath(next, cwd, shell)
timeout, })
description: params.description,
const collect = Effect.fn("BashTool.collect")(function* (root: Node, cwd: string, ps: boolean, shell: string) {
const scan: Scan = {
dirs: new Set<string>(),
patterns: new Set<string>(),
always: new Set<string>(),
}
for (const node of commands(root)) {
const command = parts(node)
const tokens = command.map((item) => item.text)
const cmd = ps ? tokens[0]?.toLowerCase() : tokens[0]
if (cmd && FILES.has(cmd)) {
for (const arg of pathArgs(command, ps)) {
const resolved = yield* argPath(arg, cwd, ps, shell)
log.info("resolved path", { arg, resolved })
if (!resolved || Instance.containsPath(resolved)) 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
name: string
command: string
cwd: string
env: NodeJS.ProcessEnv
timeout: number
description: string
},
ctx: Tool.Context,
) {
let output = ""
let expired = false
let aborted = false
ctx.metadata({
metadata: {
output: "",
description: input.description,
}, },
ctx, })
)
}, const code: number | null = yield* Effect.scoped(
} Effect.gen(function* () {
}) const handle = yield* spawner.spawn(cmd(input.shell, input.name, input.command, input.cwd, input.env))
yield* Effect.forkScoped(
Stream.runForEach(Stream.decodeText(handle.all), (chunk) =>
Effect.sync(() => {
output += chunk
ctx.metadata({
metadata: {
output: preview(output),
description: input.description,
},
})
}),
),
)
const abort = Effect.callback<void>((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 (aborted) meta.push("User aborted the command")
if (meta.length > 0) {
output += "\n\n<bash_metadata>\n" + meta.join("\n") + "\n</bash_metadata>"
}
return {
title: input.description,
metadata: {
output: preview(output),
exit: code,
description: input.description,
},
output,
}
})
return async () => {
const shell = Shell.acceptable()
const name = Shell.name(shell)
const chain =
name === "powershell"
? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success."
: "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead."
log.info("bash tool using shell", { shell })
return {
description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
.replaceAll("${os}", process.platform)
.replaceAll("${shell}", name)
.replaceAll("${chaining}", chain)
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
parameters: Parameters,
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () {
const cwd = params.workdir
? yield* resolvePath(params.workdir, Instance.directory, shell)
: Instance.directory
if (params.timeout !== undefined && params.timeout < 0) {
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
}
const timeout = params.timeout ?? DEFAULT_TIMEOUT
const ps = PS.has(name)
const root = yield* parse(params.command, ps)
const scan = yield* collect(root, cwd, ps, shell)
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
yield* ask(ctx, scan)
return yield* run({
shell,
name,
command: params.command,
cwd,
env: yield* shellEnv(ctx, cwd),
timeout,
description: params.description,
}, ctx)
}).pipe(Effect.orDie, Effect.runPromise),
}
}
}),
)
+6 -1
View File
@@ -31,6 +31,8 @@ import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import { Effect, Layer, ServiceMap } from "effect" import { Effect, Layer, ServiceMap } from "effect"
import { FetchHttpClient, HttpClient } from "effect/unstable/http" import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { Env } from "../env" import { Env } from "../env"
@@ -86,6 +88,7 @@ export namespace ToolRegistry {
| Instruction.Service | Instruction.Service
| AppFileSystem.Service | AppFileSystem.Service
| HttpClient.HttpClient | HttpClient.HttpClient
| ChildProcessSpawner
> = Layer.effect( > = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
@@ -102,6 +105,7 @@ export namespace ToolRegistry {
const plan = yield* PlanExitTool const plan = yield* PlanExitTool
const webfetch = yield* WebFetchTool const webfetch = yield* WebFetchTool
const websearch = yield* WebSearchTool const websearch = yield* WebSearchTool
const bash = yield* BashTool
const codesearch = yield* CodeSearchTool const codesearch = yield* CodeSearchTool
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
@@ -161,7 +165,7 @@ export namespace ToolRegistry {
const tool = yield* Effect.all({ const tool = yield* Effect.all({
invalid: Tool.init(InvalidTool), invalid: Tool.init(InvalidTool),
bash: Tool.init(BashTool), bash: Tool.init(bash),
read: Tool.init(read), read: Tool.init(read),
glob: Tool.init(GlobTool), glob: Tool.init(GlobTool),
grep: Tool.init(GrepTool), grep: Tool.init(GrepTool),
@@ -315,6 +319,7 @@ export namespace ToolRegistry {
Layer.provide(Instruction.defaultLayer), Layer.provide(Instruction.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer), Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FetchHttpClient.layer), Layer.provide(FetchHttpClient.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
), ),
) )
@@ -171,6 +171,7 @@ function makeHttp() {
const registry = ToolRegistry.layer.pipe( const registry = ToolRegistry.layer.pipe(
Layer.provide(Skill.defaultLayer), Layer.provide(Skill.defaultLayer),
Layer.provide(FetchHttpClient.layer), Layer.provide(FetchHttpClient.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provideMerge(todo), Layer.provideMerge(todo),
Layer.provideMerge(question), Layer.provideMerge(question),
Layer.provideMerge(deps), Layer.provideMerge(deps),
@@ -135,6 +135,7 @@ function makeHttp() {
const registry = ToolRegistry.layer.pipe( const registry = ToolRegistry.layer.pipe(
Layer.provide(Skill.defaultLayer), Layer.provide(Skill.defaultLayer),
Layer.provide(FetchHttpClient.layer), Layer.provide(FetchHttpClient.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provideMerge(todo), Layer.provideMerge(todo),
Layer.provideMerge(question), Layer.provideMerge(question),
Layer.provideMerge(deps), Layer.provideMerge(deps),
+52 -38
View File
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Effect, Layer, ManagedRuntime } from "effect"
import os from "os" import os from "os"
import path from "path" import path from "path"
import { Shell } from "../../src/shell/shell" import { Shell } from "../../src/shell/shell"
@@ -9,6 +10,19 @@ import { tmpdir } from "../fixture/fixture"
import type { Permission } from "../../src/permission" import type { Permission } from "../../src/permission"
import { Truncate } from "../../src/tool/truncate" import { Truncate } from "../../src/tool/truncate"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { AppFileSystem } from "../../src/filesystem"
import { Plugin } from "../../src/plugin"
const runtime = ManagedRuntime.make(
Layer.mergeAll(CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer, Plugin.defaultLayer),
)
function initBash() {
return runtime.runPromise(
BashTool.pipe(Effect.flatMap((info) => Effect.promise(() => info.init()))),
)
}
const ctx = { const ctx = {
sessionID: SessionID.make("ses_test"), sessionID: SessionID.make("ses_test"),
@@ -118,7 +132,7 @@ describe("tool.bash", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const result = await bash.execute( const result = await bash.execute(
{ {
command: "echo test", command: "echo test",
@@ -139,7 +153,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute( await bash.execute(
{ {
@@ -160,7 +174,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute( await bash.execute(
{ {
@@ -184,7 +198,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute( await bash.execute(
{ {
@@ -208,7 +222,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const file = process.platform === "win32" ? `${process.env.WINDIR!.replaceAll("\\", "/")}/*` : "/etc/*" const file = process.platform === "win32" ? `${process.env.WINDIR!.replaceAll("\\", "/")}/*` : "/etc/*"
@@ -242,7 +256,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const file = path.join(outerTmp.path, "outside.txt").replaceAll("\\", "/") const file = path.join(outerTmp.path, "outside.txt").replaceAll("\\", "/")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute( await bash.execute(
@@ -273,7 +287,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect( await expect(
@@ -301,7 +315,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const file = `${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini` const file = `${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`
await bash.execute( await bash.execute(
@@ -331,7 +345,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect( await expect(
@@ -359,7 +373,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect( await expect(
@@ -388,7 +402,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect( await expect(
@@ -416,7 +430,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect( await expect(
@@ -448,7 +462,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const root = path.parse(process.env.WINDIR!).root.replace(/[\\/]+$/, "") const root = path.parse(process.env.WINDIR!).root.replace(/[\\/]+$/, "")
@@ -481,7 +495,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute( await bash.execute(
{ {
@@ -508,7 +522,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect( await expect(
@@ -538,7 +552,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect( await expect(
@@ -568,7 +582,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute( await bash.execute(
{ {
@@ -597,7 +611,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute( await bash.execute(
{ {
@@ -622,7 +636,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect( await expect(
@@ -645,7 +659,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect( await expect(
@@ -673,7 +687,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const want = Filesystem.normalizePathPattern(path.join(outerTmp.path, "*")) const want = Filesystem.normalizePathPattern(path.join(outerTmp.path, "*"))
for (const dir of forms(outerTmp.path)) { for (const dir of forms(outerTmp.path)) {
@@ -707,7 +721,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const want = glob(path.join(os.tmpdir(), "*")) const want = glob(path.join(os.tmpdir(), "*"))
@@ -737,7 +751,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const want = glob(path.join(os.tmpdir(), "*")) const want = glob(path.join(os.tmpdir(), "*"))
@@ -772,7 +786,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const filepath = path.join(outerTmp.path, "outside.txt") const filepath = path.join(outerTmp.path, "outside.txt")
@@ -803,7 +817,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute( await bash.execute(
{ {
@@ -823,7 +837,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute( await bash.execute(
{ {
@@ -844,7 +858,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute( await bash.execute(
{ {
@@ -864,7 +878,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect( await expect(
@@ -885,7 +899,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute({ command: "ls -la", description: "List" }, capture(requests)) await bash.execute({ command: "ls -la", description: "List" }, capture(requests))
const bashReq = requests.find((r) => r.permission === "bash") const bashReq = requests.find((r) => r.permission === "bash")
@@ -901,7 +915,7 @@ describe("tool.bash abort", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const controller = new AbortController() const controller = new AbortController()
const collected: string[] = [] const collected: string[] = []
const result = bash.execute( const result = bash.execute(
@@ -933,7 +947,7 @@ describe("tool.bash abort", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const result = await bash.execute( const result = await bash.execute(
{ {
command: `echo started && sleep 60`, command: `echo started && sleep 60`,
@@ -952,7 +966,7 @@ describe("tool.bash abort", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const result = await bash.execute( const result = await bash.execute(
{ {
command: `echo stdout_msg && echo stderr_msg >&2`, command: `echo stdout_msg && echo stderr_msg >&2`,
@@ -971,7 +985,7 @@ describe("tool.bash abort", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const result = await bash.execute( const result = await bash.execute(
{ {
command: `exit 42`, command: `exit 42`,
@@ -988,7 +1002,7 @@ describe("tool.bash abort", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const updates: string[] = [] const updates: string[] = []
const result = await bash.execute( const result = await bash.execute(
{ {
@@ -1016,7 +1030,7 @@ describe("tool.bash truncation", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const lineCount = Truncate.MAX_LINES + 500 const lineCount = Truncate.MAX_LINES + 500
const result = await bash.execute( const result = await bash.execute(
{ {
@@ -1036,7 +1050,7 @@ describe("tool.bash truncation", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const byteCount = Truncate.MAX_BYTES + 10000 const byteCount = Truncate.MAX_BYTES + 10000
const result = await bash.execute( const result = await bash.execute(
{ {
@@ -1056,7 +1070,7 @@ describe("tool.bash truncation", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const result = await bash.execute( const result = await bash.execute(
{ {
command: "echo hello", command: "echo hello",
@@ -1074,7 +1088,7 @@ describe("tool.bash truncation", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const lineCount = Truncate.MAX_LINES + 100 const lineCount = Truncate.MAX_LINES + 100
const result = await bash.execute( const result = await bash.execute(
{ {