diff --git a/.changeset/bash-command-timeout.md b/.changeset/bash-command-timeout.md new file mode 100644 index 0000000000..6641cee19d --- /dev/null +++ b/.changeset/bash-command-timeout.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Run built-in `bash` commands in the foreground for five minutes by default, with an optional `yieldTimeMs` override. Commands still running then continue in the background and return a process id that the model can pass back to `bash` to poll, await, or kill; results also report wall time. diff --git a/docs/concepts/built-in-tools.md b/docs/concepts/built-in-tools.md index 3c33601639..2020908971 100644 --- a/docs/concepts/built-in-tools.md +++ b/docs/concepts/built-in-tools.md @@ -13,7 +13,7 @@ The default shell and file tools (`bash`, `read_file`, and `write_file`) run in | Tool | Does | Where it runs | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | -| `bash` | Run a shell command. | Sandbox | +| `bash` | Run a shell command. After `yieldTimeMs` milliseconds (default 300000), a command still running continues in the background and returns a process id. | Sandbox | | `read_file` | Read a text file with line-numbered output (enables read-before-write). | Sandbox FS | | `write_file` | Write a complete file; enforces read-before-write and stale-read detection. | Sandbox FS | | `web_fetch` | Fetch a URL. | App runtime | @@ -33,6 +33,7 @@ Notes: - **`connection_search`** surfaces a connection's tools by their qualified name (e.g. `linear__list_issues`), which the model can then call directly. The model sees it only when the agent has connections. - **`web_search`** has no local executor; the provider runs it. AI Gateway models use Exa by default. To use Parallel instead, export `webSearch({ provider: "parallel" })` from `agent/tools/web_search.ts`. Direct provider models continue to use their native search implementation. To supply your own implementation, override it with `defineTool()`. - **`web_fetch`** follows up to ten redirects, rechecking every destination for SSRF safety. Non-success HTTP responses return a plain-text failure result with the response body when available instead of failing the tool call. +- **`bash`** accepts a returned process id through the same tool: use `poll` to read current output, `wait` to wait for another bounded foreground interval, or `kill` to stop it. Every result reports `wallTimeSeconds`, and one sandbox tracks at most 64 background commands (completed process state is reclaimed first). A process id remains available while its underlying sandbox process is live; sandbox shutdown or provider expiration can make it unavailable. Process termination requires a backend with real OS processes; `just-bash` reports that it cannot kill the process. Review these default tools before production use. Disable, wrap, restrict, or require approval for any tool that can access the filesystem, network, shell, or sensitive data. diff --git a/e2e/fixtures/agent-prompt-cache/agent/tools/bash.ts b/e2e/fixtures/agent-prompt-cache/agent/tools/bash.ts new file mode 100644 index 0000000000..04bd054473 --- /dev/null +++ b/e2e/fixtures/agent-prompt-cache/agent/tools/bash.ts @@ -0,0 +1,3 @@ +import { disableTool } from "eve/tools"; + +export default disableTool(); diff --git a/packages/eve/src/execution/sandbox/bash.test.ts b/packages/eve/src/execution/sandbox/bash.test.ts index 3febee27b0..2e39869d00 100644 --- a/packages/eve/src/execution/sandbox/bash.test.ts +++ b/packages/eve/src/execution/sandbox/bash.test.ts @@ -1,59 +1,213 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { EVE_DEV_ENV_FLAG } from "#internal/application/optional-package-install.js"; -import type { SandboxCommandResult, SandboxSession } from "#shared/sandbox-session.js"; +import type { SandboxSession } from "#shared/sandbox-session.js"; +import { MAX_OUTPUT_LINES } from "#execution/sandbox/truncate-output.js"; -import { executeBashOnSandbox } from "./bash.js"; +import { + DEFAULT_BASH_YIELD_TIME_MS, + executeBashOnSandbox, + formatBashOutput, + getBackgroundBashProcess, + MAX_BACKGROUND_BASH_PROCESSES, + startBackgroundBashProcess, + waitForBackgroundBashProcess, +} from "./bash.js"; + +const previousDevFlag = process.env[EVE_DEV_ENV_FLAG]; + +afterEach(() => { + if (previousDevFlag === undefined) { + delete process.env[EVE_DEV_ENV_FLAG]; + } else { + process.env[EVE_DEV_ENV_FLAG] = previousDevFlag; + } + vi.restoreAllMocks(); +}); + +function sandbox(files: Record = {}): SandboxSession { + return { + id: "sandbox", + readBinaryFile: vi.fn(async () => null), + readFile: vi.fn(async () => null), + readTextFile: vi.fn(async ({ path }) => files[path] ?? null), + removePath: vi.fn(async () => {}), + resolvePath: (path) => path, + run: vi.fn(async () => ({ exitCode: 0, stderr: "", stdout: "" })), + setNetworkPolicy: vi.fn(async () => {}), + spawn: vi.fn(async () => { + throw new Error("not used"); + }), + writeBinaryFile: vi.fn(async () => {}), + writeFile: vi.fn(async () => {}), + writeTextFile: vi.fn(async () => {}), + }; +} + +function processFiles(values: { exitCode?: number; stderr?: string; stdout?: string }) { + return new Proxy>( + {}, + { + get: (_target, path) => { + if (typeof path !== "string") return null; + if (path.endsWith("/pid")) return "123"; + if (path.endsWith("/exit-code")) return values.exitCode?.toString() ?? null; + if (path.endsWith("/stderr")) return values.stderr ?? ""; + if (path.endsWith("/stdout")) return values.stdout ?? ""; + return null; + }, + }, + ); +} describe("executeBashOnSandbox", () => { - const previousDevFlag = process.env[EVE_DEV_ENV_FLAG]; + it("returns completed output", async () => { + const session = sandbox(processFiles({ exitCode: 0, stdout: "done\n" })); + + await expect(executeBashOnSandbox(session, { command: "build" })).resolves.toEqual({ + exitCode: 0, + status: "completed", + stderr: "", + stdout: "done\n", + truncated: false, + wallTimeSeconds: expect.any(Number), + }); + }); + + it("yields a running command", async () => { + const session = sandbox(processFiles({ stderr: "partial err", stdout: "partial out" })); + + await expect( + executeBashOnSandbox(session, { command: "build", yieldTimeMs: 0 }), + ).resolves.toMatchObject({ + status: "running", + stderr: "partial err", + stdout: "partial out", + }); + }); + + it("does not kill after an observation failure", async () => { + const session = sandbox(); + vi.mocked(session.readTextFile).mockRejectedValue(new Error("read failed")); + + await expect(executeBashOnSandbox(session, { command: "build" })).rejects.toThrow( + "read failed", + ); + expect(session.run).toHaveBeenCalledTimes(2); + }); - afterEach(() => { - if (previousDevFlag === undefined) { - delete process.env[EVE_DEV_ENV_FLAG]; - } else { - process.env[EVE_DEV_ENV_FLAG] = previousDevFlag; - } - vi.restoreAllMocks(); + it("kills when cancelled", async () => { + const session = sandbox(processFiles({})); + const cancelled = new DOMException("cancelled", "AbortError"); + + await expect( + executeBashOnSandbox( + session, + { command: "build" }, + { abortSignal: AbortSignal.abort(cancelled) }, + ), + ).rejects.toBe(cancelled); + expect(session.removePath).toHaveBeenCalledWith({ + force: true, + path: expect.stringContaining("/.eve/processes/"), + recursive: true, + }); }); - it("logs sandbox command progress in dev without adding to stderr", async () => { + it("logs command progress in development", async () => { process.env[EVE_DEV_ENV_FLAG] = "1"; const log = vi.spyOn(console, "log").mockImplementation(() => {}); - const sandbox = createTestSandboxSession({ - exitCode: 0, - stderr: "", - stdout: "weather-codes.md\n", + const session = sandbox(processFiles({ exitCode: 0 })); + + await executeBashOnSandbox(session, { command: "pwd" }); + + expect(log).toHaveBeenCalledWith("eve: starting sandbox command: pwd"); + expect(log).toHaveBeenCalledWith("eve: sandbox command finished (exit 0): pwd"); + }); + + it("uses the default foreground wait", () => { + expect(DEFAULT_BASH_YIELD_TIME_MS).toBe(300_000); + }); +}); + +describe("formatBashOutput", () => { + it("preserves the end of long command output", () => { + const lines = Array.from({ length: MAX_OUTPUT_LINES + 1 }, (_, index) => `line ${index}`); + + const result = formatBashOutput(lines.join("\n"), "", Date.now()); + + expect(result.truncated).toBe(true); + expect(result.stdout).not.toContain("line 0\n"); + expect(result.stdout).toContain(`line ${MAX_OUTPUT_LINES}`); + }); +}); + +describe("background bash processes", () => { + it("launches an isolated command behind the process cap", async () => { + const session = sandbox(); + const process = await startBackgroundBashProcess(session, "exit 7"); + const capacityCommand = vi.mocked(session.run).mock.calls[0]?.[0].command; + const launchCommand = vi.mocked(session.run).mock.calls[1]?.[0].command; + + expect(process.processId).toMatch(/^[0-9a-f-]{36}$/); + expect(capacityCommand).toContain(`-lt ${MAX_BACKGROUND_BASH_PROCESSES}`); + expect(launchCommand).toContain("set -m 2>/dev/null || true"); + expect(launchCommand).toContain(" ( eval 'exit 7' )\n code=$?"); + }); + + it("rejects when the process cap is reached", async () => { + const session = sandbox(); + vi.mocked(session.run).mockResolvedValue({ + exitCode: 75, + stderr: "EVE_BASH_PROCESS_LIMIT\n", + stdout: "", }); - const result = await executeBashOnSandbox(sandbox, { command: "ls -la /workspace" }); + await expect(startBackgroundBashProcess(session, "pnpm test")).rejects.toThrow( + `This sandbox already tracks ${MAX_BACKGROUND_BASH_PROCESSES} running background commands.`, + ); + }); + + it("reads completed process state", async () => { + const session = sandbox(processFiles({ exitCode: 7, stderr: "err", stdout: "out" })); - expect(result).toEqual({ - exitCode: 0, - stderr: "", - stdout: "weather-codes.md\n", - truncated: false, + await expect( + getBackgroundBashProcess(session, "11111111-1111-4111-8111-111111111111").read(), + ).resolves.toEqual({ exitCode: 7, stderr: "err", stdout: "out" }); + }); + + it("removes process state even when the process already exited", async () => { + const session = sandbox(processFiles({})); + vi.mocked(session.run).mockResolvedValue({ exitCode: 1, stderr: "", stdout: "" }); + + await getBackgroundBashProcess(session, "11111111-1111-4111-8111-111111111111").kill(); + + expect(session.removePath).toHaveBeenCalledWith({ + force: true, + path: "/workspace/.eve/processes/11111111-1111-4111-8111-111111111111", + recursive: true, }); - expect(log).toHaveBeenCalledWith("eve: starting sandbox command: ls -la /workspace"); - expect(log).toHaveBeenCalledWith("eve: sandbox command finished (exit 0): ls -la /workspace"); }); -}); -function createTestSandboxSession(result: SandboxCommandResult): SandboxSession { - return { - id: "test-sandbox", - readBinaryFile: async () => null, - readFile: async () => null, - readTextFile: async () => null, - removePath: async () => {}, - resolvePath: (path) => path, - run: vi.fn().mockResolvedValue(result), - setNetworkPolicy: async () => {}, - spawn: async () => { - throw new Error("spawn is not implemented in this test sandbox"); - }, - writeBinaryFile: async () => {}, - writeFile: async () => {}, - writeTextFile: async () => {}, - }; -} + it("rejects missing process state", async () => { + const session = sandbox(); + + await expect( + getBackgroundBashProcess(session, "11111111-1111-4111-8111-111111111111").read(), + ).rejects.toThrow("does not exist"); + }); + + it("polls status without reading output", async () => { + const read = vi.fn(); + const readStatus = vi.fn(async () => ({})); + + await expect( + waitForBackgroundBashProcess({ + process: { kill: vi.fn(), processId: "process", read, readStatus }, + yieldTimeMs: 0, + }), + ).resolves.toBeNull(); + expect(readStatus).toHaveBeenCalledOnce(); + expect(read).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/eve/src/execution/sandbox/bash.ts b/packages/eve/src/execution/sandbox/bash.ts index 4ea926c3ce..1f73cc8b4f 100644 --- a/packages/eve/src/execution/sandbox/bash.ts +++ b/packages/eve/src/execution/sandbox/bash.ts @@ -1,125 +1,344 @@ +import { randomUUID } from "node:crypto"; + import type { SandboxSession } from "#shared/sandbox-session.js"; +import { shellQuote } from "#execution/sandbox/shell-quote.js"; import { truncateTail } from "#execution/sandbox/truncate-output.js"; import { isEveDevEnvironment } from "#internal/application/optional-package-install.js"; const MAX_LOG_COMMAND_LENGTH = 240; +const PROCESS_ROOT = "/workspace/.eve/processes"; +const POLL_INTERVAL_MS = 250; +const PROCESS_LIMIT_MARKER = "EVE_BASH_PROCESS_LIMIT"; -// --------------------------------------------------------------------------- -// Input shape -// --------------------------------------------------------------------------- +export const DEFAULT_BASH_YIELD_TIME_MS = 300_000; +export const MAX_BACKGROUND_BASH_PROCESSES = 64; -/** - * Typed input accepted by {@link executeBashOnSandbox}. - */ export interface BashInput { readonly command: string; + readonly yieldTimeMs?: number; } -// --------------------------------------------------------------------------- -// Result shape -// --------------------------------------------------------------------------- +export interface BashExecuteOptions { + readonly abortSignal?: AbortSignal; +} -/** - * Structured result returned from {@link executeBashOnSandbox}. - */ -export interface BashResult { +export type BashResult = BashCompletedResult | BashRunningResult; + +export interface BashCompletedResult extends BashOutput { readonly exitCode: number; + readonly status: "completed"; +} + +export interface BashRunningResult extends BashOutput { + readonly processId: string; + readonly status: "running"; +} + +export interface BashOutput { readonly stderr: string; readonly stdout: string; - /** True when stdout or stderr was shortened to fit within output limits. */ readonly truncated: boolean; + /** Elapsed wall time this call spent before returning, in seconds. */ + readonly wallTimeSeconds: number; } -// --------------------------------------------------------------------------- -// Executor -// --------------------------------------------------------------------------- +export interface BackgroundBashProcess { + readonly processId: string; + read(): Promise; + readStatus(): Promise; + kill(): Promise; +} + +export interface BackgroundBashProcessStatus { + readonly exitCode?: number; +} + +export interface BackgroundBashProcessState extends BackgroundBashProcessStatus { + readonly stderr: string; + readonly stdout: string; +} /** - * Executes one shell command inside the agent's sandbox via `SandboxKey` - * on the active runtime context. + * Executes one shell command inside the agent's sandbox. * - * Both stdout and stderr are tail-truncated to keep the end of the output - * (where errors and final results typically appear) within the shared - * {@link MAX_OUTPUT_LINES} / {@link MAX_OUTPUT_BYTES} limits. + * The command waits in the foreground for a bounded interval, then continues + * in the background when still running. Both stdout and stderr remain + * tail-truncated because errors and final results typically appear at the end. * - * Used by the framework `bash` tool and authored wrappers around its - * exported definition. Centralizing the executor here keeps the error - * messages and result shape identical across all bash-style tools. + * Used by the framework `bash` tool and authored wrappers around its exported + * definition so all bash-style tools share one result shape and lifecycle. */ export async function executeBashOnSandbox( sandbox: SandboxSession, args: BashInput, + options?: BashExecuteOptions, ): Promise { - const raw = await runWithDevelopmentSandboxProgress(sandbox, args.command); + const startedAt = Date.now(); + const commandLabel = formatCommand(args.command); + logDevelopmentSandboxCommand(`eve: starting sandbox command: ${commandLabel}`); + const progressTimer = startDevelopmentProgressTimer(commandLabel, startedAt); + + try { + const process = await startBackgroundBashProcess(sandbox, args.command); + try { + await waitForBackgroundBashProcess({ + abortSignal: options?.abortSignal, + process, + yieldTimeMs: args.yieldTimeMs ?? DEFAULT_BASH_YIELD_TIME_MS, + }); + } catch (error) { + if (!options?.abortSignal?.aborted) { + throw error; + } + try { + await process.kill(); + } catch (killError) { + throw new AggregateError( + [error, killError], + "The bash command was cancelled but could not be killed.", + { cause: error }, + ); + } + throw error; + } + + const observed = await process.read(); + const output = formatBashOutput(observed.stdout, observed.stderr, startedAt); + const result: BashResult = + observed.exitCode === undefined + ? { ...output, processId: process.processId, status: "running" } + : { ...output, exitCode: observed.exitCode, status: "completed" }; + logDevelopmentSandboxCommand( + result.status === "completed" + ? `eve: sandbox command finished (exit ${result.exitCode}): ${commandLabel}` + : `eve: sandbox command yielded: ${commandLabel}`, + ); + return result; + } catch (error) { + logDevelopmentSandboxCommand(`eve: sandbox command failed: ${commandLabel}`); + throw error; + } finally { + if (progressTimer !== undefined) clearInterval(progressTimer); + } +} + +export async function startBackgroundBashProcess( + sandbox: SandboxSession, + command: string, +): Promise { + const processId = randomUUID(); + const directory = `${PROCESS_ROOT}/${processId}`; + await reserveBackgroundProcessDirectory(sandbox, directory); + + const result = await sandbox.run({ + command: buildBackgroundLaunchCommand(command, directory), + }); + if (result.exitCode !== 0) { + await sandbox.removePath({ force: true, path: directory, recursive: true }); + throw new Error(`Failed to start background command: ${result.stderr || result.stdout}`); + } + + return backgroundBashProcess(sandbox, processId); +} + +function buildBackgroundLaunchCommand(command: string, directory: string): string { + const exitCode = shellQuote(`${directory}/exit-code`); + const exitCodeTemp = shellQuote(`${directory}/exit-code.tmp`); + const stderr = shellQuote(`${directory}/stderr`); + const stdout = shellQuote(`${directory}/stdout`); + const pid = shellQuote(`${directory}/pid`); + return [ + `set -m 2>/dev/null || true`, + `(`, + ` ( eval ${shellQuote(command)} )`, + ` code=$?`, + ` printf '%s' "$code" > ${exitCodeTemp}`, + ` mv ${exitCodeTemp} ${exitCode}`, + `) > ${stdout} 2> ${stderr} &`, + `printf '%s' "$!" > ${pid}`, + ].join("\n"); +} - const stdoutResult = truncateTail(raw.stdout); - const stderrResult = truncateTail(raw.stderr); - const truncated = stdoutResult.truncated || stderrResult.truncated; +async function reserveBackgroundProcessDirectory( + sandbox: SandboxSession, + directory: string, +): Promise { + const quotedRoot = shellQuote(PROCESS_ROOT); + const result = await sandbox.run({ + command: [ + `mkdir -p ${quotedRoot}`, + `count=0`, + `for directory in ${quotedRoot}/*/; do`, + ` [ -d "$directory" ] || continue`, + ` if [ -f "$directory/exit-code" ]; then`, + ` rm -rf "$directory"`, + ` else`, + ` count=$((count + 1))`, + ` fi`, + `done`, + `[ "$count" -lt ${MAX_BACKGROUND_BASH_PROCESSES} ] || { echo ${PROCESS_LIMIT_MARKER} >&2; exit 75; }`, + `mkdir ${shellQuote(directory)}`, + ].join("\n"), + }); + if (result.exitCode === 0) return; + if (result.stderr.includes(PROCESS_LIMIT_MARKER)) { + throw new Error( + `This sandbox already tracks ${MAX_BACKGROUND_BASH_PROCESSES} running background commands. Kill or wait for existing processes before starting another.`, + ); + } + throw new Error(`Failed to inspect background commands: ${result.stderr || result.stdout}`); +} - let stdout = stdoutResult.output; - if (stdoutResult.truncated) { - stdout = - `[stdout truncated: showing last ${stdoutResult.outputLines} of ${stdoutResult.totalLines} lines]\n` + - stdout; +export function getBackgroundBashProcess( + sandbox: SandboxSession, + processId: string, +): BackgroundBashProcess { + if (!/^[0-9a-f-]{36}$/.test(processId)) { + throw new Error("Invalid bash process id."); } + return backgroundBashProcess(sandbox, processId); +} - let stderr = stderrResult.output; - if (stderrResult.truncated) { - stderr = - `[stderr truncated: showing last ${stderrResult.outputLines} of ${stderrResult.totalLines} lines]\n` + - stderr; +export async function waitForBackgroundBashProcess(input: { + readonly abortSignal?: AbortSignal; + readonly process: BackgroundBashProcess; + readonly yieldTimeMs: number; +}): Promise { + const deadline = Date.now() + input.yieldTimeMs; + while (true) { + input.abortSignal?.throwIfAborted(); + const state = await input.process.readStatus(); + if (state.exitCode !== undefined) return state; + const remaining = deadline - Date.now(); + if (remaining <= 0) return null; + await abortableDelay(Math.min(POLL_INTERVAL_MS, remaining), input.abortSignal); } +} +function backgroundBashProcess(sandbox: SandboxSession, processId: string): BackgroundBashProcess { + const directory = `${PROCESS_ROOT}/${processId}`; return { - exitCode: raw.exitCode, - stderr, - stdout, - truncated, + processId, + async read() { + const [status, stdout, stderr] = await Promise.all([ + readBackgroundBashProcessStatus(sandbox, processId, directory), + sandbox.readTextFile({ path: `${directory}/stdout` }), + sandbox.readTextFile({ path: `${directory}/stderr` }), + ]); + return { ...status, stderr: stderr ?? "", stdout: stdout ?? "" }; + }, + async readStatus() { + return await readBackgroundBashProcessStatus(sandbox, processId, directory); + }, + async kill() { + const pidValue = await sandbox.readTextFile({ path: `${directory}/pid` }); + const pid = pidValue?.trim(); + if (!pid || !/^[1-9]\d*$/.test(pid)) { + throw new Error(`Bash process "${processId}" could not be killed by this sandbox backend.`); + } + + if (await isProcessAlive(sandbox, pid)) { + await signalProcess(sandbox, pid); + await abortableDelay(100); + if (await isProcessAlive(sandbox, pid)) { + await signalProcess(sandbox, pid, "-KILL"); + } + } + await sandbox.removePath({ force: true, path: directory, recursive: true }); + }, }; } -async function runWithDevelopmentSandboxProgress( +async function isProcessAlive(sandbox: SandboxSession, pid: string): Promise { + const result = await sandbox.run({ + command: `kill -0 -- -${pid} 2>/dev/null || kill -0 ${pid} 2>/dev/null`, + }); + return result.exitCode === 0; +} + +async function signalProcess( sandbox: SandboxSession, - command: string, -): Promise>> { - logDevelopmentSandboxCommand(`eve: starting sandbox command: ${formatCommand(command)}`); - if (!isEveDevEnvironment()) { - return await sandbox.run({ command }); + pid: string, + signal?: "-KILL", +): Promise { + const option = signal ? `${signal} ` : ""; + const result = await sandbox.run({ + command: `kill ${option}-- -${pid} 2>/dev/null || kill ${option}${pid} 2>/dev/null`, + }); + if (result.exitCode !== 0 && (await isProcessAlive(sandbox, pid))) { + throw new Error(`Bash process ${pid} could not be signalled by this sandbox backend.`); } +} - const startedAt = Date.now(); +async function readBackgroundBashProcessStatus( + sandbox: SandboxSession, + processId: string, + directory: string, +): Promise { + const [pid, exitCode] = await Promise.all([ + sandbox.readTextFile({ path: `${directory}/pid` }), + sandbox.readTextFile({ path: `${directory}/exit-code` }), + ]); + if (pid === null) { + throw new Error(`Bash process "${processId}" does not exist.`); + } + return exitCode === null ? {} : { exitCode: Number.parseInt(exitCode, 10) }; +} + +function abortableDelay(ms: number, abortSignal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const onAbort = () => { + clearTimeout(timer); + reject(abortSignal?.reason); + }; + const timer = setTimeout(() => { + abortSignal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + abortSignal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +export function formatBashOutput( + stdoutValue: string, + stderrValue: string, + startedAt: number, +): BashOutput { + const stdoutResult = truncateTail(stdoutValue); + const stderrResult = truncateTail(stderrValue); + return { + stderr: stderrResult.output, + stdout: stdoutResult.output, + truncated: stdoutResult.truncated || stderrResult.truncated, + wallTimeSeconds: wallTimeSeconds(startedAt), + }; +} + +export function wallTimeSeconds(startedAt: number): number { + return Math.round(Date.now() - startedAt) / 1_000; +} + +function startDevelopmentProgressTimer( + command: string, + startedAt: number, +): NodeJS.Timeout | undefined { + if (!isEveDevEnvironment()) return undefined; const timer = setInterval(() => { - const elapsedSeconds = Math.round((Date.now() - startedAt) / 1000); + const elapsedSeconds = Math.round((Date.now() - startedAt) / 1_000); logDevelopmentSandboxCommand( - `eve: waiting for sandbox command (${elapsedSeconds}s elapsed): ${formatCommand(command)}`, + `eve: waiting for sandbox command (${elapsedSeconds}s elapsed): ${command}`, ); }, 5_000); timer.unref?.(); - - try { - const result = await sandbox.run({ command }); - logDevelopmentSandboxCommand( - `eve: sandbox command finished (exit ${result.exitCode}): ${formatCommand(command)}`, - ); - return result; - } catch (error) { - logDevelopmentSandboxCommand(`eve: sandbox command failed: ${formatCommand(command)}`); - throw error; - } finally { - clearInterval(timer); - } + return timer; } function logDevelopmentSandboxCommand(message: string): void { - if (isEveDevEnvironment()) { - console.log(message); - } + if (isEveDevEnvironment()) console.log(message); } function formatCommand(command: string): string { const singleLine = command.replaceAll(/\s+/g, " ").trim(); - if (singleLine.length <= MAX_LOG_COMMAND_LENGTH) { - return singleLine; - } + if (singleLine.length <= MAX_LOG_COMMAND_LENGTH) return singleLine; return `${singleLine.slice(0, MAX_LOG_COMMAND_LENGTH - 1)}…`; } diff --git a/packages/eve/src/execution/sandbox/session.test.ts b/packages/eve/src/execution/sandbox/session.test.ts index 37808dfcc5..c400ad90f7 100644 --- a/packages/eve/src/execution/sandbox/session.test.ts +++ b/packages/eve/src/execution/sandbox/session.test.ts @@ -121,6 +121,27 @@ describe("buildSandboxSession", () => { expect(result).toEqual({ exitCode: 0, stderr: "", stdout: "hello" }); }); + it("kills a spawned process when run is aborted", async () => { + const controller = new AbortController(); + const kill = vi.fn(async () => {}); + const process: SandboxProcess = { + kill, + stderr: new ReadableStream(), + stdout: new ReadableStream(), + wait: async () => await new Promise(() => {}), + }; + const session = buildSandboxSession( + createTestPrimitives({ spawn: vi.fn(async () => process) }), + ); + const run = session.run({ abortSignal: controller.signal, command: "sleep forever" }); + const rejection = expect(run).rejects.toMatchObject({ name: "TimeoutError" }); + + controller.abort(new DOMException("The command timed out.", "TimeoutError")); + + await rejection; + expect(kill).toHaveBeenCalledOnce(); + }); + it("does not pollute stderr with framework command progress logs", async () => { const error = vi.spyOn(console, "error").mockImplementation(() => {}); const spawn = vi.fn(async () => syntheticProcess({ exitCode: 0, stdout: "hello" })); diff --git a/packages/eve/src/execution/sandbox/session.ts b/packages/eve/src/execution/sandbox/session.ts index 048f132955..9c8dde09f4 100644 --- a/packages/eve/src/execution/sandbox/session.ts +++ b/packages/eve/src/execution/sandbox/session.ts @@ -36,12 +36,36 @@ export function buildSandboxSession( ): SandboxSession { async function run(options: SandboxRunOptions) { const process = await primitives.spawn(options); - const [stdout, stderr, { exitCode }] = await Promise.all([ + const completed = Promise.all([ collectStreamToString(process.stdout), collectStreamToString(process.stderr), process.wait(), ]); - return { exitCode, stderr, stdout }; + const abortSignal = options.abortSignal; + if (abortSignal === undefined) { + const [stdout, stderr, { exitCode }] = await completed; + return { exitCode, stderr, stdout }; + } + + let onAbort!: () => void; + const aborted = new Promise((_resolve, reject) => { + onAbort = () => { + void Promise.resolve(process.kill()).catch(() => {}); + reject(abortSignal.reason); + }; + if (abortSignal.aborted) { + onAbort(); + } else { + abortSignal.addEventListener("abort", onAbort, { once: true }); + } + }); + + try { + const [stdout, stderr, { exitCode }] = await Promise.race([completed, aborted]); + return { exitCode, stderr, stdout }; + } finally { + abortSignal.removeEventListener("abort", onAbort); + } } return { id: primitives.id, diff --git a/packages/eve/src/tools/provided/bash-execute.test.ts b/packages/eve/src/tools/provided/bash-execute.test.ts new file mode 100644 index 0000000000..f90ca29481 --- /dev/null +++ b/packages/eve/src/tools/provided/bash-execute.test.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { RuntimeSandboxSession } from "#shared/sandbox-session.js"; +import { getBackgroundBashProcess, waitForBackgroundBashProcess } from "#execution/sandbox/bash.js"; + +import { executeBashTool } from "./bash.js"; + +vi.mock("#execution/sandbox/bash.js", async (importOriginal) => ({ + ...(await importOriginal()), + getBackgroundBashProcess: vi.fn(), + waitForBackgroundBashProcess: vi.fn(), +})); + +const context = { + abortSignal: new AbortController().signal, + getSandbox: vi.fn(async () => ({}) as RuntimeSandboxSession), +}; + +function process(state: { exitCode?: number; stderr: string; stdout: string }) { + return { + kill: vi.fn(async () => {}), + processId: "11111111-1111-4111-8111-111111111111", + read: vi.fn(async () => state), + readStatus: vi.fn(async () => ({ exitCode: state.exitCode })), + }; +} + +describe("executeBashTool process actions", () => { + afterEach(() => vi.resetAllMocks()); + + it("polls a running process through bash", async () => { + const running = process({ stderr: "", stdout: "partial" }); + vi.mocked(getBackgroundBashProcess).mockReturnValue(running); + + await expect( + executeBashTool({ action: "poll", processId: running.processId }, context), + ).resolves.toEqual({ + processId: running.processId, + status: "running", + stderr: "", + stdout: "partial", + truncated: false, + wallTimeSeconds: expect.any(Number), + }); + }); + + it("waits for a process through bash", async () => { + const running = process({ stderr: "", stdout: "partial" }); + vi.mocked(getBackgroundBashProcess).mockReturnValue(running); + vi.mocked(waitForBackgroundBashProcess).mockResolvedValue({ exitCode: 0 }); + running.read.mockResolvedValue({ exitCode: 0, stderr: "", stdout: "done" }); + + await expect( + executeBashTool( + { action: "wait", processId: running.processId, yieldTimeMs: 10_000 }, + context, + ), + ).resolves.toEqual({ + exitCode: 0, + status: "completed", + stderr: "", + stdout: "done", + truncated: false, + wallTimeSeconds: expect.any(Number), + }); + expect(waitForBackgroundBashProcess).toHaveBeenCalledWith({ + abortSignal: context.abortSignal, + process: running, + yieldTimeMs: 10_000, + }); + }); + + it("kills a running process but preserves a completed result", async () => { + const running = process({ stderr: "", stdout: "partial" }); + vi.mocked(getBackgroundBashProcess).mockReturnValueOnce(running); + + await expect( + executeBashTool({ action: "kill", processId: running.processId }, context), + ).resolves.toMatchObject({ status: "killed" }); + expect(running.kill).toHaveBeenCalledOnce(); + + const completed = process({ exitCode: 7, stderr: "failed", stdout: "" }); + vi.mocked(getBackgroundBashProcess).mockReturnValueOnce(completed); + await expect( + executeBashTool({ action: "kill", processId: completed.processId }, context), + ).resolves.toMatchObject({ exitCode: 7, status: "completed" }); + expect(completed.kill).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/eve/src/tools/provided/bash.test.ts b/packages/eve/src/tools/provided/bash.test.ts new file mode 100644 index 0000000000..e94f5d4101 --- /dev/null +++ b/packages/eve/src/tools/provided/bash.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; + +import { z } from "#compiled/zod/index.js"; + +import { BASH_INPUT_SCHEMA, BASH_OUTPUT_SCHEMA } from "./bash.js"; + +describe("bash schemas", () => { + it("emits a provider-compatible object schema", () => { + const schema = z.toJSONSchema(BASH_INPUT_SCHEMA, { io: "input" }); + expect(schema).toMatchObject({ + required: ["action", "command", "processId", "yieldTimeMs"], + type: "object", + }); + expect(schema).not.toHaveProperty("anyOf"); + }); + + it("accepts an optional nonnegative foreground yield time in milliseconds", () => { + expect(BASH_INPUT_SCHEMA.safeParse({ command: "pnpm test", yieldTimeMs: 0 }).success).toBe( + true, + ); + expect(BASH_INPUT_SCHEMA.safeParse({ command: "pnpm test" }).success).toBe(true); + expect(BASH_INPUT_SCHEMA.safeParse({ command: "pnpm test", yieldTimeMs: -1 }).success).toBe( + false, + ); + }); + + it("accepts process follow-up operations through the same interface", () => { + expect(BASH_INPUT_SCHEMA.safeParse({ action: "poll", processId: "process-123" }).success).toBe( + true, + ); + expect( + BASH_INPUT_SCHEMA.safeParse({ action: "wait", processId: "process-123", yieldTimeMs: 30_000 }) + .success, + ).toBe(true); + expect(BASH_INPUT_SCHEMA.safeParse({ action: "kill", processId: "process-123" }).success).toBe( + true, + ); + expect(BASH_INPUT_SCHEMA.safeParse({ action: "poll" }).success).toBe(false); + expect(BASH_INPUT_SCHEMA.safeParse({ command: "pwd", action: "poll" }).success).toBe(false); + expect( + BASH_INPUT_SCHEMA.safeParse({ + action: "run", + command: "pwd", + processId: "", + yieldTimeMs: null, + }).success, + ).toBe(true); + }); + + it("distinguishes completed commands from running process receipts", () => { + expect( + BASH_OUTPUT_SCHEMA.safeParse({ + exitCode: 0, + status: "completed", + stderr: "", + stdout: "done", + truncated: false, + wallTimeSeconds: 1.5, + }).success, + ).toBe(true); + expect( + BASH_OUTPUT_SCHEMA.safeParse({ + processId: "process-123", + status: "running", + stderr: "", + stdout: "partial", + truncated: false, + wallTimeSeconds: 300, + }).success, + ).toBe(true); + expect( + BASH_OUTPUT_SCHEMA.safeParse({ + exitCode: 0, + status: "completed", + stderr: "", + stdout: "done", + truncated: false, + }).success, + ).toBe(false); + }); +}); diff --git a/packages/eve/src/tools/provided/bash.ts b/packages/eve/src/tools/provided/bash.ts index f82251646d..6c0733e821 100644 --- a/packages/eve/src/tools/provided/bash.ts +++ b/packages/eve/src/tools/provided/bash.ts @@ -1,41 +1,147 @@ import { z } from "#compiled/zod/index.js"; -import { executeBashOnSandbox, type BashInput } from "#execution/sandbox/bash.js"; +import type { SessionContext } from "#context/session-context.js"; +import { + DEFAULT_BASH_YIELD_TIME_MS, + executeBashOnSandbox, + formatBashOutput, + getBackgroundBashProcess, + waitForBackgroundBashProcess, + type BashInput, +} from "#execution/sandbox/bash.js"; import { defineTool, type ToolDefinition } from "#tools/definition.js"; -/** - * Input schema for the provided `bash` tool. - */ -export const BASH_INPUT_SCHEMA = z.strictObject({ - command: z.string().describe("The shell command to execute."), -}); +const YIELD_TIME_SCHEMA = z + .number() + .nonnegative() + .describe( + `Maximum time in milliseconds to wait before returning a process id for a still-running command. Defaults to ${DEFAULT_BASH_YIELD_TIME_MS} ms.`, + ) + .nullable() + .optional(); + +type BashProcessToolInput = { + readonly action: "poll" | "wait" | "kill"; + readonly processId: string; + readonly yieldTimeMs?: number; +}; + +export type BashToolInput = + | { readonly action?: "run"; readonly command: string; readonly yieldTimeMs?: number } + | BashProcessToolInput; + +export const BASH_INPUT_SCHEMA = z + .strictObject({ + action: z + .enum(["run", "poll", "wait", "kill"]) + .describe("Run a new command, read process output, wait longer, or terminate a process.") + .default("run"), + command: z + .string() + .describe("Required with action run: the shell command to execute.") + .nullable() + .optional(), + processId: z + .string() + .describe("Required with action poll, wait, or kill: the id returned by an earlier call.") + .nullable() + .optional(), + yieldTimeMs: YIELD_TIME_SCHEMA, + }) + .superRefine((input, context) => { + const hasCommand = typeof input.command === "string" && input.command !== ""; + const hasProcessId = typeof input.processId === "string" && input.processId !== ""; + const invalid = + input.action === "run" ? !hasCommand || hasProcessId : hasCommand || !hasProcessId; + if (invalid) { + context.addIssue({ + code: "custom", + message: "Action run requires command; other actions require processId.", + }); + } + }) + .describe("Choose an action, then provide its command or processId.") + .meta({ + required: ["action", "command", "processId", "yieldTimeMs"], + }) as z.ZodType; -/** - * Output schema for the provided `bash` tool. - */ -export const BASH_OUTPUT_SCHEMA = z.strictObject({ - exitCode: z.number(), +const BASH_OUTPUT_FIELDS = { stderr: z.string(), stdout: z.string(), truncated: z.boolean(), -}); + wallTimeSeconds: z + .number() + .describe("Elapsed wall time this call spent before returning, in seconds."), +}; + +export const BASH_OUTPUT_SCHEMA = z.discriminatedUnion("status", [ + z.strictObject({ + ...BASH_OUTPUT_FIELDS, + exitCode: z.number(), + status: z.literal("completed"), + }), + z.strictObject({ ...BASH_OUTPUT_FIELDS, status: z.literal("killed") }), + z.strictObject({ + ...BASH_OUTPUT_FIELDS, + processId: z.string(), + status: z.literal("running"), + }), +]); -export type BashToolInput = z.infer; export type BashToolOutput = z.infer; -/** - * Framework-owned executors stay statically imported so hosted server bundles - * can trace and rewrite them into deployable output chunks. - * - * These modules are only used by the Nitro-hosted runtime path. Their deeper - * sandbox dependencies remain lazily loaded inside the execution layer, so the - * top-level import here does not force those backends to initialize eagerly. - */ +export async function executeBashTool( + input: BashToolInput, + context: Pick & { readonly abortSignal: AbortSignal }, +): Promise { + const sandbox = await context.getSandbox(); + if (input.action === undefined || input.action === "run") { + return await executeBashOnSandbox(sandbox, input as BashInput, { + abortSignal: context.abortSignal, + }); + } + + const processInput = input as BashProcessToolInput; + const startedAt = Date.now(); + const process = getBackgroundBashProcess(sandbox, processInput.processId); + if (processInput.action === "kill") { + const before = await process.read(); + if (before.exitCode !== undefined) { + const output = formatBashOutput(before.stdout, before.stderr, startedAt); + return { ...output, exitCode: before.exitCode, status: "completed" }; + } + await process.kill(); + return { ...formatBashOutput(before.stdout, before.stderr, startedAt), status: "killed" }; + } + if (processInput.action === "wait") { + await waitForBackgroundBashProcess({ + abortSignal: context.abortSignal, + process, + yieldTimeMs: processInput.yieldTimeMs ?? DEFAULT_BASH_YIELD_TIME_MS, + }); + } + const state = await process.read(); + if (state.exitCode === undefined) { + return { + ...formatBashOutput(state.stdout, state.stderr, startedAt), + processId: process.processId, + status: "running", + }; + } + return { + ...formatBashOutput(state.stdout, state.stderr, startedAt), + exitCode: state.exitCode, + status: "completed", + }; +} + export const bash: ToolDefinition = defineTool({ - description: "Execute a shell command in the shared workspace environment.", - async execute(input, ctx) { - return await executeBashOnSandbox(await ctx.getSandbox(), input as BashInput); - }, + description: [ + "Run shell commands and manage commands that continue in the background.", + `Use action run with command for a new command; it waits up to ${DEFAULT_BASH_YIELD_TIME_MS} ms by default, then returns a process id if still running.`, + "Pass that process id back with action poll, wait, or kill.", + ].join(" "), + execute: executeBashTool, inputSchema: BASH_INPUT_SCHEMA, outputSchema: BASH_OUTPUT_SCHEMA, }); diff --git a/packages/eve/test/scenarios/app-runtime-dependencies.scenario.test.ts b/packages/eve/test/scenarios/app-runtime-dependencies.scenario.test.ts index 53dd38c92f..288bcba14a 100644 --- a/packages/eve/test/scenarios/app-runtime-dependencies.scenario.test.ts +++ b/packages/eve/test/scenarios/app-runtime-dependencies.scenario.test.ts @@ -802,7 +802,7 @@ describe("app runtime dependency tracing", () => { expect(serverModuleSource).not.toContain('import("esbuild")'); expect(serverModuleSource).not.toContain('import("rolldown")'); expect(serverModuleSource).toContain( - "Execute a shell command in the shared workspace environment.", + "Run shell commands and manage commands that continue in the background.", ); expect(serverModuleSource).toContain("The dynamic skill"); expect(serverModuleSource).toContain("URL must start with https://");