refactor(bash): use Effect ChildProcess for bash tool execution (#20496)
parent
26fb6b8788
commit
e4ff1ea778
|
|
@ -488,3 +488,14 @@ export const layer: Layer.Layer<ChildProcessSpawner, never, FileSystem.FileSyste
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
|
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
|
||||||
|
|
||||||
|
import { lazy } from "@/util/lazy"
|
||||||
|
|
||||||
|
const rt = lazy(() => {
|
||||||
|
// Dynamic import to avoid circular dep: cross-spawn-spawner → run-service → Instance → project → cross-spawn-spawner
|
||||||
|
const { makeRuntime } = require("@/effect/run-service") as typeof import("@/effect/run-service")
|
||||||
|
return makeRuntime(ChildProcessSpawner, defaultLayer)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const runPromiseExit: ReturnType<typeof rt>["runPromiseExit"] = (...args) => rt().runPromiseExit(...(args as [any]))
|
||||||
|
export const runPromise: ReturnType<typeof rt>["runPromise"] = (...args) => rt().runPromise(...(args as [any]))
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import { spawn } from "child_process"
|
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import DESCRIPTION from "./bash.txt"
|
import DESCRIPTION from "./bash.txt"
|
||||||
|
|
@ -18,6 +17,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 { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||||
|
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -293,27 +295,26 @@ async function shellEnv(ctx: Tool.Context, cwd: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function launch(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)) {
|
||||||
return spawn(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], {
|
return ChildProcess.make(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], {
|
||||||
cwd,
|
cwd,
|
||||||
env,
|
env,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdin: "ignore",
|
||||||
detached: false,
|
detached: false,
|
||||||
windowsHide: true,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return spawn(command, {
|
return ChildProcess.make(command, [], {
|
||||||
shell,
|
shell,
|
||||||
cwd,
|
cwd,
|
||||||
env,
|
env,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdin: "ignore",
|
||||||
detached: process.platform !== "win32",
|
detached: process.platform !== "win32",
|
||||||
windowsHide: process.platform === "win32",
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function run(
|
async function run(
|
||||||
input: {
|
input: {
|
||||||
shell: string
|
shell: string
|
||||||
|
|
@ -326,8 +327,9 @@ async function run(
|
||||||
},
|
},
|
||||||
ctx: Tool.Context,
|
ctx: Tool.Context,
|
||||||
) {
|
) {
|
||||||
const proc = launch(input.shell, input.name, input.command, input.cwd, input.env)
|
|
||||||
let output = ""
|
let output = ""
|
||||||
|
let expired = false
|
||||||
|
let aborted = false
|
||||||
|
|
||||||
ctx.metadata({
|
ctx.metadata({
|
||||||
metadata: {
|
metadata: {
|
||||||
|
|
@ -336,76 +338,78 @@ async function run(
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const append = (chunk: Buffer) => {
|
const exit = await CrossSpawnSpawner.runPromiseExit((spawner) =>
|
||||||
output += chunk.toString()
|
Effect.gen(function* () {
|
||||||
ctx.metadata({
|
const handle = yield* spawner.spawn(
|
||||||
metadata: {
|
cmd(input.shell, input.name, input.command, input.cwd, input.env),
|
||||||
output: preview(output),
|
)
|
||||||
description: input.description,
|
|
||||||
},
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
proc.stdout?.on("data", append)
|
const meta: string[] = []
|
||||||
proc.stderr?.on("data", append)
|
if (expired) meta.push(`bash tool terminated command after exceeding timeout ${input.timeout} ms`)
|
||||||
|
if (aborted) meta.push("User aborted the command")
|
||||||
let expired = false
|
if (meta.length > 0) {
|
||||||
let aborted = false
|
output += "\n\n<bash_metadata>\n" + meta.join("\n") + "\n</bash_metadata>"
|
||||||
let exited = false
|
|
||||||
|
|
||||||
const kill = () => Shell.killTree(proc, { exited: () => exited })
|
|
||||||
|
|
||||||
if (ctx.abort.aborted) {
|
|
||||||
aborted = true
|
|
||||||
await kill()
|
|
||||||
}
|
|
||||||
|
|
||||||
const abort = () => {
|
|
||||||
aborted = true
|
|
||||||
void kill()
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.abort.addEventListener("abort", abort, { once: true })
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
expired = true
|
|
||||||
void kill()
|
|
||||||
}, input.timeout + 100)
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
const cleanup = () => {
|
|
||||||
clearTimeout(timer)
|
|
||||||
ctx.abort.removeEventListener("abort", abort)
|
|
||||||
}
|
|
||||||
|
|
||||||
proc.once("exit", () => {
|
|
||||||
exited = true
|
|
||||||
})
|
|
||||||
|
|
||||||
proc.once("close", () => {
|
|
||||||
exited = true
|
|
||||||
cleanup()
|
|
||||||
resolve()
|
|
||||||
})
|
|
||||||
|
|
||||||
proc.once("error", (error) => {
|
|
||||||
exited = true
|
|
||||||
cleanup()
|
|
||||||
reject(error)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const metadata: string[] = []
|
|
||||||
if (expired) metadata.push(`bash tool terminated command after exceeding timeout ${input.timeout} ms`)
|
|
||||||
if (aborted) metadata.push("User aborted the command")
|
|
||||||
if (metadata.length > 0) {
|
|
||||||
output += "\n\n<bash_metadata>\n" + metadata.join("\n") + "\n</bash_metadata>"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: input.description,
|
title: input.description,
|
||||||
metadata: {
|
metadata: {
|
||||||
output: preview(output),
|
output: preview(output),
|
||||||
exit: proc.exitCode,
|
exit: code,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
},
|
},
|
||||||
output,
|
output,
|
||||||
|
|
|
||||||
|
|
@ -896,6 +896,121 @@ describe("tool.bash permissions", () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("tool.bash abort", () => {
|
||||||
|
test("preserves output when aborted", async () => {
|
||||||
|
await Instance.provide({
|
||||||
|
directory: projectRoot,
|
||||||
|
fn: async () => {
|
||||||
|
const bash = await BashTool.init()
|
||||||
|
const controller = new AbortController()
|
||||||
|
const collected: string[] = []
|
||||||
|
const result = bash.execute(
|
||||||
|
{
|
||||||
|
command: `echo before && sleep 30`,
|
||||||
|
description: "Long running command",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...ctx,
|
||||||
|
abort: controller.signal,
|
||||||
|
metadata: (input) => {
|
||||||
|
const output = (input.metadata as { output?: string })?.output
|
||||||
|
if (output && output.includes("before") && !controller.signal.aborted) {
|
||||||
|
collected.push(output)
|
||||||
|
controller.abort()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const res = await result
|
||||||
|
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 BashTool.init()
|
||||||
|
const result = await 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")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, 15_000)
|
||||||
|
|
||||||
|
test.skipIf(process.platform === "win32")("captures stderr in output", async () => {
|
||||||
|
await Instance.provide({
|
||||||
|
directory: projectRoot,
|
||||||
|
fn: async () => {
|
||||||
|
const bash = await BashTool.init()
|
||||||
|
const result = await 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 BashTool.init()
|
||||||
|
const result = await 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 BashTool.init()
|
||||||
|
const updates: string[] = []
|
||||||
|
const result = await bash.execute(
|
||||||
|
{
|
||||||
|
command: `echo first && sleep 0.1 && echo second`,
|
||||||
|
description: "Streaming test",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...ctx,
|
||||||
|
metadata: (input) => {
|
||||||
|
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", () => {
|
describe("tool.bash truncation", () => {
|
||||||
test("truncates output exceeding line limit", async () => {
|
test("truncates output exceeding line limit", async () => {
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue