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
+144 -134
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(() =>
ctx.ask({
permission: "external_directory", permission: "external_directory",
patterns: globs, patterns: globs,
always: globs, always: globs,
metadata: {}, metadata: {},
}) }),
)
} }
if (scan.patterns.size === 0) return if (scan.patterns.size === 0) return
await ctx.ask({ yield* Effect.promise(() =>
ctx.ask({
permission: "bash", permission: "bash",
patterns: Array.from(scan.patterns), patterns: Array.from(scan.patterns),
always: Array.from(scan.always), always: Array.from(scan.always),
metadata: {}, 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,7 +269,111 @@ function cmd(shell: string, name: string, command: string, cwd: string, env: Nod
}) })
} }
async function run(
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.defineEffect(
"bash",
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner
const fs = yield* AppFileSystem.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) {
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: { input: {
shell: string shell: string
name: string name: string
@@ -341,7 +384,7 @@ async function run(
description: string description: string
}, },
ctx: Tool.Context, ctx: Tool.Context,
) { ) {
let output = "" let output = ""
let expired = false let expired = false
let aborted = false let aborted = false
@@ -353,7 +396,7 @@ async function run(
}, },
}) })
const exit = await CrossSpawnSpawner.runPromiseExit((spawner) => const code: number | null = yield* Effect.scoped(
Effect.gen(function* () { Effect.gen(function* () {
const handle = yield* spawner.spawn(cmd(input.shell, input.name, input.command, input.cwd, input.env)) const handle = yield* spawner.spawn(cmd(input.shell, input.name, input.command, input.cwd, input.env))
@@ -396,15 +439,8 @@ async function run(
} }
return exit.kind === "exit" ? exit.code : null return exit.kind === "exit" ? exit.code : null
}).pipe(Effect.scoped, Effect.orDie), }),
) ).pipe(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[] = [] const meta: string[] = []
if (expired) meta.push(`bash tool terminated command after exceeding timeout ${input.timeout} ms`) if (expired) meta.push(`bash tool terminated command after exceeding timeout ${input.timeout} ms`)
@@ -422,37 +458,9 @@ async function run(
}, },
output, output,
} }
} })
const parser = lazy(async () => { return async () => {
const { Parser } = await import("web-tree-sitter")
const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, {
with: { type: "wasm" },
})
const treePath = resolveWasm(treeWasm)
await Parser.init({
locateFile() {
return treePath
},
})
const { default: bashWasm } = await import("tree-sitter-bash/tree-sitter-bash.wasm" as string, {
with: { type: "wasm" },
})
const { default: psWasm } = await import("tree-sitter-powershell/tree-sitter-powershell.wasm" as string, {
with: { type: "wasm" },
})
const bashPath = resolveWasm(bashWasm)
const psPath = resolveWasm(psWasm)
const [bashLanguage, psLanguage] = await Promise.all([Language.load(bashPath), Language.load(psPath)])
const bash = new Parser()
bash.setLanguage(bashLanguage)
const ps = new Parser()
ps.setLanguage(psLanguage)
return { bash, ps }
})
// TODO: we may wanna rename this tool so it works better on other shells
export const BashTool = Tool.define("bash", async () => {
const shell = Shell.acceptable() const shell = Shell.acceptable()
const name = Shell.name(shell) const name = Shell.name(shell)
const chain = const chain =
@@ -469,30 +477,32 @@ export const BashTool = Tool.define("bash", async () => {
.replaceAll("${maxLines}", String(Truncate.MAX_LINES)) .replaceAll("${maxLines}", String(Truncate.MAX_LINES))
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)), .replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
parameters: Parameters, parameters: Parameters,
async execute(params, ctx) { execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
const cwd = params.workdir ? await resolvePath(params.workdir, Instance.directory, shell) : Instance.directory Effect.gen(function* () {
const cwd = params.workdir
? yield* resolvePath(params.workdir, Instance.directory, shell)
: Instance.directory
if (params.timeout !== undefined && params.timeout < 0) { if (params.timeout !== undefined && params.timeout < 0) {
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
} }
const timeout = params.timeout ?? DEFAULT_TIMEOUT const timeout = params.timeout ?? DEFAULT_TIMEOUT
const ps = PS.has(name) const ps = PS.has(name)
const root = await parse(params.command, ps) const root = yield* parse(params.command, ps)
const scan = await collect(root, cwd, ps, shell) const scan = yield* collect(root, cwd, ps, shell)
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd) if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
await ask(ctx, scan) yield* ask(ctx, scan)
return run( return yield* run({
{
shell, shell,
name, name,
command: params.command, command: params.command,
cwd, cwd,
env: await shellEnv(ctx, cwd), env: yield* shellEnv(ctx, cwd),
timeout, timeout,
description: params.description, description: params.description,
}, }, ctx)
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(
{ {