From 9c50aa7bb4849bd5f6ccfac838ec885cb701a984 Mon Sep 17 00:00:00 2001 From: Colton Padden Date: Tue, 25 Aug 2026 17:01:56 -0400 Subject: [PATCH 01/17] fix(eve): bound bash command execution Signed-off-by: Colton Padden --- .changeset/bash-command-timeout.md | 5 ++ docs/concepts/built-in-tools.md | 2 +- .../eve/src/execution/sandbox/bash.test.ts | 74 ++++++++++++++++++- packages/eve/src/execution/sandbox/bash.ts | 23 +++++- .../eve/src/execution/sandbox/session.test.ts | 21 ++++++ packages/eve/src/execution/sandbox/session.ts | 28 ++++++- packages/eve/src/tools/provided/bash.test.ts | 15 ++++ packages/eve/src/tools/provided/bash.ts | 23 +++++- 8 files changed, 181 insertions(+), 10 deletions(-) create mode 100644 .changeset/bash-command-timeout.md create mode 100644 packages/eve/src/tools/provided/bash.test.ts diff --git a/.changeset/bash-command-timeout.md b/.changeset/bash-command-timeout.md new file mode 100644 index 0000000000..68b821fae3 --- /dev/null +++ b/.changeset/bash-command-timeout.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Bound built-in `bash` commands to five minutes by default and allow the model to request up to ten minutes with the optional `timeout` input. Bash deadlines now compose with turn cancellation so stalled commands are terminated before they can hold a run indefinitely. diff --git a/docs/concepts/built-in-tools.md b/docs/concepts/built-in-tools.md index 3c33601639..0d17d8bf9c 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, with an optional timeout in seconds. Commands time out after 300 seconds by default; requested timeouts are capped at 600 seconds. | 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 | diff --git a/packages/eve/src/execution/sandbox/bash.test.ts b/packages/eve/src/execution/sandbox/bash.test.ts index 3febee27b0..328c0e23fc 100644 --- a/packages/eve/src/execution/sandbox/bash.test.ts +++ b/packages/eve/src/execution/sandbox/bash.test.ts @@ -3,7 +3,11 @@ 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 { executeBashOnSandbox } from "./bash.js"; +import { + DEFAULT_BASH_TIMEOUT_SECONDS, + executeBashOnSandbox, + MAX_BASH_TIMEOUT_SECONDS, +} from "./bash.js"; describe("executeBashOnSandbox", () => { const previousDevFlag = process.env[EVE_DEV_ENV_FLAG]; @@ -37,8 +41,76 @@ describe("executeBashOnSandbox", () => { expect(log).toHaveBeenCalledWith("eve: starting sandbox command: ls -la /workspace"); expect(log).toHaveBeenCalledWith("eve: sandbox command finished (exit 0): ls -la /workspace"); }); + + it.each([ + { + expectedTimeoutMs: DEFAULT_BASH_TIMEOUT_SECONDS * 1_000, + input: { command: "sleep forever" }, + scenario: "uses the default timeout", + }, + { + expectedTimeoutMs: 10_000, + input: { command: "sleep forever", timeout: 10 }, + scenario: "honors a shorter requested timeout", + }, + { + expectedTimeoutMs: MAX_BASH_TIMEOUT_SECONDS * 1_000, + input: { command: "sleep forever", timeout: MAX_BASH_TIMEOUT_SECONDS * 2 }, + scenario: "caps a requested timeout at the maximum", + }, + ])("$scenario", async ({ expectedTimeoutMs, input }) => { + const { abort, timeout } = mockTimeoutSignal(); + const execution = executeBashOnSandbox(createAbortingTestSandboxSession(), input); + const rejection = expect(execution).rejects.toMatchObject({ name: "TimeoutError" }); + + expect(timeout).toHaveBeenCalledWith(expectedTimeoutMs); + abort(); + + await rejection; + }); + + it("composes the command timeout with turn cancellation", async () => { + const controller = new AbortController(); + const sandbox = createAbortingTestSandboxSession(); + const execution = executeBashOnSandbox( + sandbox, + { command: "sleep forever" }, + { abortSignal: controller.signal }, + ); + const rejection = expect(execution).rejects.toMatchObject({ name: "AbortError" }); + + controller.abort(new DOMException("The turn was cancelled.", "AbortError")); + + await rejection; + }); }); +function mockTimeoutSignal() { + const controller = new AbortController(); + const timeout = vi.spyOn(AbortSignal, "timeout").mockReturnValue(controller.signal); + return { + abort: () => controller.abort(new DOMException("The command timed out.", "TimeoutError")), + timeout, + }; +} + +function createAbortingTestSandboxSession(): SandboxSession { + const sandbox = createTestSandboxSession({ exitCode: 0, stderr: "", stdout: "" }); + return { + ...sandbox, + run: vi.fn( + async ({ abortSignal }) => + await new Promise((_resolve, reject) => { + if (abortSignal?.aborted === true) { + reject(abortSignal.reason); + return; + } + abortSignal?.addEventListener("abort", () => reject(abortSignal.reason), { once: true }); + }), + ), + }; +} + function createTestSandboxSession(result: SandboxCommandResult): SandboxSession { return { id: "test-sandbox", diff --git a/packages/eve/src/execution/sandbox/bash.ts b/packages/eve/src/execution/sandbox/bash.ts index 4ea926c3ce..c683ab1f03 100644 --- a/packages/eve/src/execution/sandbox/bash.ts +++ b/packages/eve/src/execution/sandbox/bash.ts @@ -13,8 +13,16 @@ const MAX_LOG_COMMAND_LENGTH = 240; */ export interface BashInput { readonly command: string; + readonly timeout?: number; } +export interface BashExecuteOptions { + readonly abortSignal?: AbortSignal; +} + +export const DEFAULT_BASH_TIMEOUT_SECONDS = 300; +export const MAX_BASH_TIMEOUT_SECONDS = 600; + // --------------------------------------------------------------------------- // Result shape // --------------------------------------------------------------------------- @@ -49,8 +57,16 @@ export interface BashResult { export async function executeBashOnSandbox( sandbox: SandboxSession, args: BashInput, + options?: BashExecuteOptions, ): Promise { - const raw = await runWithDevelopmentSandboxProgress(sandbox, args.command); + const timeoutMs = + Math.min(args.timeout ?? DEFAULT_BASH_TIMEOUT_SECONDS, MAX_BASH_TIMEOUT_SECONDS) * 1_000; + const timeoutSignal = AbortSignal.timeout(timeoutMs); + const abortSignal = + options?.abortSignal === undefined + ? timeoutSignal + : AbortSignal.any([options.abortSignal, timeoutSignal]); + const raw = await runWithDevelopmentSandboxProgress(sandbox, args.command, abortSignal); const stdoutResult = truncateTail(raw.stdout); const stderrResult = truncateTail(raw.stderr); @@ -81,10 +97,11 @@ export async function executeBashOnSandbox( async function runWithDevelopmentSandboxProgress( sandbox: SandboxSession, command: string, + abortSignal: AbortSignal, ): Promise>> { logDevelopmentSandboxCommand(`eve: starting sandbox command: ${formatCommand(command)}`); if (!isEveDevEnvironment()) { - return await sandbox.run({ command }); + return await sandbox.run({ abortSignal, command }); } const startedAt = Date.now(); @@ -97,7 +114,7 @@ async function runWithDevelopmentSandboxProgress( timer.unref?.(); try { - const result = await sandbox.run({ command }); + const result = await sandbox.run({ abortSignal, command }); logDevelopmentSandboxCommand( `eve: sandbox command finished (exit ${result.exitCode}): ${formatCommand(command)}`, ); 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.test.ts b/packages/eve/src/tools/provided/bash.test.ts new file mode 100644 index 0000000000..0e7ea42663 --- /dev/null +++ b/packages/eve/src/tools/provided/bash.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; + +import { BASH_INPUT_SCHEMA } from "./bash.js"; + +describe("BASH_INPUT_SCHEMA", () => { + it("accepts an optional positive timeout in seconds", () => { + expect(BASH_INPUT_SCHEMA.safeParse({ command: "pnpm test", timeout: 30 }).success).toBe(true); + expect(BASH_INPUT_SCHEMA.safeParse({ command: "pnpm test" }).success).toBe(true); + }); + + it("rejects non-positive timeouts", () => { + expect(BASH_INPUT_SCHEMA.safeParse({ command: "pnpm test", timeout: 0 }).success).toBe(false); + expect(BASH_INPUT_SCHEMA.safeParse({ command: "pnpm test", timeout: -1 }).success).toBe(false); + }); +}); diff --git a/packages/eve/src/tools/provided/bash.ts b/packages/eve/src/tools/provided/bash.ts index f82251646d..702a45577b 100644 --- a/packages/eve/src/tools/provided/bash.ts +++ b/packages/eve/src/tools/provided/bash.ts @@ -1,6 +1,11 @@ import { z } from "#compiled/zod/index.js"; -import { executeBashOnSandbox, type BashInput } from "#execution/sandbox/bash.js"; +import { + DEFAULT_BASH_TIMEOUT_SECONDS, + executeBashOnSandbox, + MAX_BASH_TIMEOUT_SECONDS, + type BashInput, +} from "#execution/sandbox/bash.js"; import { defineTool, type ToolDefinition } from "#tools/definition.js"; /** @@ -8,6 +13,13 @@ import { defineTool, type ToolDefinition } from "#tools/definition.js"; */ export const BASH_INPUT_SCHEMA = z.strictObject({ command: z.string().describe("The shell command to execute."), + timeout: z + .number() + .positive() + .describe( + `Optional timeout in seconds. Defaults to ${DEFAULT_BASH_TIMEOUT_SECONDS}, max ${MAX_BASH_TIMEOUT_SECONDS}.`, + ) + .optional(), }); /** @@ -32,9 +44,14 @@ export type BashToolOutput = z.infer; * top-level import here does not force those backends to initialize eagerly. */ export const bash: ToolDefinition = defineTool({ - description: "Execute a shell command in the shared workspace environment.", + description: [ + "Execute a shell command in the shared workspace environment.", + `Commands time out after ${DEFAULT_BASH_TIMEOUT_SECONDS} seconds by default and may request up to ${MAX_BASH_TIMEOUT_SECONDS} seconds.`, + ].join(" "), async execute(input, ctx) { - return await executeBashOnSandbox(await ctx.getSandbox(), input as BashInput); + return await executeBashOnSandbox(await ctx.getSandbox(), input as BashInput, { + abortSignal: ctx.abortSignal, + }); }, inputSchema: BASH_INPUT_SCHEMA, outputSchema: BASH_OUTPUT_SCHEMA, From 493014a7310f08e8cfa745fad91a72c0c68f1785 Mon Sep 17 00:00:00 2001 From: Colton Padden Date: Tue, 25 Aug 2026 17:48:11 -0400 Subject: [PATCH 02/17] fix(eve): allow extended bash timeouts Signed-off-by: Colton Padden --- .changeset/bash-command-timeout.md | 2 +- docs/concepts/built-in-tools.md | 2 +- packages/eve/src/execution/sandbox/bash.test.ts | 12 ++++-------- packages/eve/src/execution/sandbox/bash.ts | 4 +--- packages/eve/src/tools/provided/bash.ts | 7 ++----- 5 files changed, 9 insertions(+), 18 deletions(-) diff --git a/.changeset/bash-command-timeout.md b/.changeset/bash-command-timeout.md index 68b821fae3..868ade4e32 100644 --- a/.changeset/bash-command-timeout.md +++ b/.changeset/bash-command-timeout.md @@ -2,4 +2,4 @@ "eve": patch --- -Bound built-in `bash` commands to five minutes by default and allow the model to request up to ten minutes with the optional `timeout` input. Bash deadlines now compose with turn cancellation so stalled commands are terminated before they can hold a run indefinitely. +Bound built-in `bash` commands to five minutes by default and allow the model to request a different positive duration with the optional `timeout` input. Bash deadlines now compose with turn cancellation so stalled commands are terminated before they can hold a run indefinitely. diff --git a/docs/concepts/built-in-tools.md b/docs/concepts/built-in-tools.md index 0d17d8bf9c..e2a9702821 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, with an optional timeout in seconds. Commands time out after 300 seconds by default; requested timeouts are capped at 600 seconds. | Sandbox | +| `bash` | Run a shell command, with an optional timeout in seconds. Commands time out after 300 seconds by default unless the call requests a different timeout. | 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 | diff --git a/packages/eve/src/execution/sandbox/bash.test.ts b/packages/eve/src/execution/sandbox/bash.test.ts index 328c0e23fc..40bfe581a1 100644 --- a/packages/eve/src/execution/sandbox/bash.test.ts +++ b/packages/eve/src/execution/sandbox/bash.test.ts @@ -3,11 +3,7 @@ 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 { - DEFAULT_BASH_TIMEOUT_SECONDS, - executeBashOnSandbox, - MAX_BASH_TIMEOUT_SECONDS, -} from "./bash.js"; +import { DEFAULT_BASH_TIMEOUT_SECONDS, executeBashOnSandbox } from "./bash.js"; describe("executeBashOnSandbox", () => { const previousDevFlag = process.env[EVE_DEV_ENV_FLAG]; @@ -54,9 +50,9 @@ describe("executeBashOnSandbox", () => { scenario: "honors a shorter requested timeout", }, { - expectedTimeoutMs: MAX_BASH_TIMEOUT_SECONDS * 1_000, - input: { command: "sleep forever", timeout: MAX_BASH_TIMEOUT_SECONDS * 2 }, - scenario: "caps a requested timeout at the maximum", + expectedTimeoutMs: 1_200_000, + input: { command: "sleep forever", timeout: 1_200 }, + scenario: "honors a requested timeout above ten minutes", }, ])("$scenario", async ({ expectedTimeoutMs, input }) => { const { abort, timeout } = mockTimeoutSignal(); diff --git a/packages/eve/src/execution/sandbox/bash.ts b/packages/eve/src/execution/sandbox/bash.ts index c683ab1f03..2d37b7d870 100644 --- a/packages/eve/src/execution/sandbox/bash.ts +++ b/packages/eve/src/execution/sandbox/bash.ts @@ -21,7 +21,6 @@ export interface BashExecuteOptions { } export const DEFAULT_BASH_TIMEOUT_SECONDS = 300; -export const MAX_BASH_TIMEOUT_SECONDS = 600; // --------------------------------------------------------------------------- // Result shape @@ -59,8 +58,7 @@ export async function executeBashOnSandbox( args: BashInput, options?: BashExecuteOptions, ): Promise { - const timeoutMs = - Math.min(args.timeout ?? DEFAULT_BASH_TIMEOUT_SECONDS, MAX_BASH_TIMEOUT_SECONDS) * 1_000; + const timeoutMs = (args.timeout ?? DEFAULT_BASH_TIMEOUT_SECONDS) * 1_000; const timeoutSignal = AbortSignal.timeout(timeoutMs); const abortSignal = options?.abortSignal === undefined diff --git a/packages/eve/src/tools/provided/bash.ts b/packages/eve/src/tools/provided/bash.ts index 702a45577b..b535b805c2 100644 --- a/packages/eve/src/tools/provided/bash.ts +++ b/packages/eve/src/tools/provided/bash.ts @@ -3,7 +3,6 @@ import { z } from "#compiled/zod/index.js"; import { DEFAULT_BASH_TIMEOUT_SECONDS, executeBashOnSandbox, - MAX_BASH_TIMEOUT_SECONDS, type BashInput, } from "#execution/sandbox/bash.js"; import { defineTool, type ToolDefinition } from "#tools/definition.js"; @@ -16,9 +15,7 @@ export const BASH_INPUT_SCHEMA = z.strictObject({ timeout: z .number() .positive() - .describe( - `Optional timeout in seconds. Defaults to ${DEFAULT_BASH_TIMEOUT_SECONDS}, max ${MAX_BASH_TIMEOUT_SECONDS}.`, - ) + .describe(`Optional timeout in seconds. Defaults to ${DEFAULT_BASH_TIMEOUT_SECONDS}.`) .optional(), }); @@ -46,7 +43,7 @@ export type BashToolOutput = z.infer; export const bash: ToolDefinition = defineTool({ description: [ "Execute a shell command in the shared workspace environment.", - `Commands time out after ${DEFAULT_BASH_TIMEOUT_SECONDS} seconds by default and may request up to ${MAX_BASH_TIMEOUT_SECONDS} seconds.`, + `Commands time out after ${DEFAULT_BASH_TIMEOUT_SECONDS} seconds unless a different timeout is requested.`, ].join(" "), async execute(input, ctx) { return await executeBashOnSandbox(await ctx.getSandbox(), input as BashInput, { From 83db690525372c4d331719822b23eaf53083e14f Mon Sep 17 00:00:00 2001 From: Colton Padden Date: Tue, 25 Aug 2026 18:25:54 -0400 Subject: [PATCH 03/17] refactor(eve): yield long-running bash commands Signed-off-by: Colton Padden --- .changeset/bash-command-timeout.md | 2 +- docs/concepts/built-in-tools.md | 2 +- .../execution/sandbox/bash-background.test.ts | 73 ++++++++ .../src/execution/sandbox/bash-background.ts | 108 +++++++++++ .../eve/src/execution/sandbox/bash.test.ts | 171 +++++++----------- packages/eve/src/execution/sandbox/bash.ts | 149 ++++++--------- .../src/tools/provided/bash-execute.test.ts | 84 +++++++++ packages/eve/src/tools/provided/bash.test.ts | 46 ++++- packages/eve/src/tools/provided/bash.ts | 123 +++++++++---- 9 files changed, 511 insertions(+), 247 deletions(-) create mode 100644 packages/eve/src/execution/sandbox/bash-background.test.ts create mode 100644 packages/eve/src/execution/sandbox/bash-background.ts create mode 100644 packages/eve/src/tools/provided/bash-execute.test.ts diff --git a/.changeset/bash-command-timeout.md b/.changeset/bash-command-timeout.md index 868ade4e32..983da8806a 100644 --- a/.changeset/bash-command-timeout.md +++ b/.changeset/bash-command-timeout.md @@ -2,4 +2,4 @@ "eve": patch --- -Bound built-in `bash` commands to five minutes by default and allow the model to request a different positive duration with the optional `timeout` input. Bash deadlines now compose with turn cancellation so stalled commands are terminated before they can hold a run indefinitely. +Run built-in `bash` commands in the foreground for five minutes by default, with an optional `yieldAfter` 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. diff --git a/docs/concepts/built-in-tools.md b/docs/concepts/built-in-tools.md index e2a9702821..fc7774231e 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, with an optional timeout in seconds. Commands time out after 300 seconds by default unless the call requests a different timeout. | Sandbox | +| `bash` | Run a shell command. After `yieldAfter` seconds (default 300), 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 | diff --git a/packages/eve/src/execution/sandbox/bash-background.test.ts b/packages/eve/src/execution/sandbox/bash-background.test.ts new file mode 100644 index 0000000000..07eb1779ce --- /dev/null +++ b/packages/eve/src/execution/sandbox/bash-background.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { SandboxSession } from "#shared/sandbox-session.js"; + +import { + getBackgroundBashProcess, + startBackgroundBashProcess, + waitForBackgroundBashProcess, +} from "./bash-background.js"; + +function sandbox(): SandboxSession { + return { + id: "sandbox", + readBinaryFile: vi.fn(async () => null), + readFile: vi.fn(async () => null), + readTextFile: vi.fn(async () => 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 () => {}), + }; +} + +describe("background bash processes", () => { + it("launches a detached command with durable status and output files", async () => { + const session = sandbox(); + const process = await startBackgroundBashProcess(session, "pnpm test"); + + expect(process.processId).toMatch(/^[0-9a-f-]{36}$/); + expect(session.run).toHaveBeenCalledWith({ + command: expect.stringContaining("( eval 'pnpm test'; code=$?"), + }); + }); + + it("reads a completed process from its durable process id", async () => { + const session = sandbox(); + vi.mocked(session.readTextFile) + .mockResolvedValueOnce("123") + .mockResolvedValueOnce("7") + .mockResolvedValueOnce("out") + .mockResolvedValueOnce("err"); + + await expect( + getBackgroundBashProcess(session, "11111111-1111-4111-8111-111111111111").read(), + ).resolves.toEqual({ exitCode: 7, stderr: "err", stdout: "out" }); + }); + + it("rejects a process id without durable process state", async () => { + const session = sandbox(); + + await expect( + getBackgroundBashProcess(session, "11111111-1111-4111-8111-111111111111").read(), + ).rejects.toThrow('Bash process "11111111-1111-4111-8111-111111111111" does not exist.'); + }); + + it("yields without killing a process that is still running", async () => { + const read = vi.fn(async () => ({ stderr: "", stdout: "partial" })); + + await expect( + waitForBackgroundBashProcess({ + process: { kill: vi.fn(async () => {}), processId: "process", read }, + yieldAfterMs: 0, + }), + ).resolves.toBeNull(); + expect(read).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/eve/src/execution/sandbox/bash-background.ts b/packages/eve/src/execution/sandbox/bash-background.ts new file mode 100644 index 0000000000..dc7da90284 --- /dev/null +++ b/packages/eve/src/execution/sandbox/bash-background.ts @@ -0,0 +1,108 @@ +import { randomUUID } from "node:crypto"; + +import type { SandboxSession } from "#shared/sandbox-session.js"; +import { shellQuote } from "#execution/sandbox/shell-quote.js"; + +const PROCESS_ROOT = "/workspace/.eve/processes"; +const POLL_INTERVAL_MS = 250; + +export interface BackgroundBashProcess { + readonly processId: string; + read(): Promise; + kill(): Promise; +} + +export interface BackgroundBashProcessState { + readonly exitCode?: number; + readonly stderr: string; + readonly stdout: string; +} + +export async function startBackgroundBashProcess( + sandbox: SandboxSession, + command: string, +): Promise { + const processId = randomUUID(); + const directory = `${PROCESS_ROOT}/${processId}`; + const launch = [ + `mkdir -p ${shellQuote(directory)}`, + `( eval ${shellQuote(command)}; code=$?; printf '%s' "$code" > ${shellQuote(`${directory}/exit-code`)} ) > ${shellQuote(`${directory}/stdout`)} 2> ${shellQuote(`${directory}/stderr`)} &`, + `printf '%s' "$!" > ${shellQuote(`${directory}/pid`)}`, + ].join("\n"); + const result = await sandbox.run({ command: launch }); + if (result.exitCode !== 0) { + throw new Error(`Failed to start background command: ${result.stderr || result.stdout}`); + } + + return backgroundBashProcess(sandbox, processId); +} + +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); +} + +function backgroundBashProcess(sandbox: SandboxSession, processId: string): BackgroundBashProcess { + const directory = `${PROCESS_ROOT}/${processId}`; + return { + processId, + async read() { + const [pid, exitCode, stdout, stderr] = await Promise.all([ + sandbox.readTextFile({ path: `${directory}/pid` }), + sandbox.readTextFile({ path: `${directory}/exit-code` }), + sandbox.readTextFile({ path: `${directory}/stdout` }), + sandbox.readTextFile({ path: `${directory}/stderr` }), + ]); + if (pid === null) { + throw new Error(`Bash process "${processId}" does not exist.`); + } + const state: { exitCode?: number; stderr: string; stdout: string } = { + stderr: stderr ?? "", + stdout: stdout ?? "", + }; + if (exitCode !== null) { + state.exitCode = Number.parseInt(exitCode, 10); + } + return state; + }, + async kill() { + const result = await sandbox.run({ + command: `pid=$(cat ${shellQuote(`${directory}/pid`)}) && [ "$pid" -gt 0 ] && { kill -- -"$pid" 2>/dev/null || kill "$pid" 2>/dev/null; }`, + }); + if (result.exitCode !== 0) { + throw new Error(`Bash process "${processId}" could not be killed by this sandbox backend.`); + } + }, + }; +} + +export async function waitForBackgroundBashProcess(input: { + readonly abortSignal?: AbortSignal; + readonly process: BackgroundBashProcess; + readonly yieldAfterMs: number; +}): Promise { + const deadline = Date.now() + input.yieldAfterMs; + while (true) { + input.abortSignal?.throwIfAborted(); + const state = await input.process.read(); + if (state.exitCode !== undefined) return state; + const remaining = deadline - Date.now(); + if (remaining <= 0) return null; + await new Promise((resolve, reject) => { + const timer = setTimeout(resolve, Math.min(POLL_INTERVAL_MS, remaining)); + input.abortSignal?.addEventListener( + "abort", + () => { + clearTimeout(timer); + reject(input.abortSignal?.reason); + }, + { once: true }, + ); + }); + } +} diff --git a/packages/eve/src/execution/sandbox/bash.test.ts b/packages/eve/src/execution/sandbox/bash.test.ts index 40bfe581a1..db76c914b1 100644 --- a/packages/eve/src/execution/sandbox/bash.test.ts +++ b/packages/eve/src/execution/sandbox/bash.test.ts @@ -1,127 +1,84 @@ 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 { + startBackgroundBashProcess, + waitForBackgroundBashProcess, +} from "#execution/sandbox/bash-background.js"; -import { DEFAULT_BASH_TIMEOUT_SECONDS, executeBashOnSandbox } from "./bash.js"; +import { DEFAULT_BASH_YIELD_AFTER_SECONDS, executeBashOnSandbox } from "./bash.js"; -describe("executeBashOnSandbox", () => { - const previousDevFlag = process.env[EVE_DEV_ENV_FLAG]; +vi.mock("#execution/sandbox/bash-background.js", () => ({ + startBackgroundBashProcess: vi.fn(), + waitForBackgroundBashProcess: vi.fn(), +})); - afterEach(() => { - if (previousDevFlag === undefined) { - delete process.env[EVE_DEV_ENV_FLAG]; - } else { - process.env[EVE_DEV_ENV_FLAG] = previousDevFlag; - } - vi.restoreAllMocks(); - }); +const sandbox = {} as SandboxSession; + +function process() { + return { + kill: vi.fn(async () => {}), + processId: "process-123", + read: vi.fn(async () => ({ stderr: "partial err", stdout: "partial out" })), + }; +} + +describe("executeBashOnSandbox", () => { + afterEach(() => vi.resetAllMocks()); - it("logs sandbox command progress in dev without adding to stderr", async () => { - process.env[EVE_DEV_ENV_FLAG] = "1"; - const log = vi.spyOn(console, "log").mockImplementation(() => {}); - const sandbox = createTestSandboxSession({ + it("returns completed output when the command finishes during the foreground wait", async () => { + const running = process(); + vi.mocked(startBackgroundBashProcess).mockResolvedValue(running); + vi.mocked(waitForBackgroundBashProcess).mockResolvedValue({ exitCode: 0, stderr: "", - stdout: "weather-codes.md\n", + stdout: "done\n", }); - const result = await executeBashOnSandbox(sandbox, { command: "ls -la /workspace" }); - - expect(result).toEqual({ + await expect(executeBashOnSandbox(sandbox, { command: "build" })).resolves.toEqual({ exitCode: 0, + status: "completed", stderr: "", - stdout: "weather-codes.md\n", + stdout: "done\n", truncated: false, }); - expect(log).toHaveBeenCalledWith("eve: starting sandbox command: ls -la /workspace"); - expect(log).toHaveBeenCalledWith("eve: sandbox command finished (exit 0): ls -la /workspace"); + expect(waitForBackgroundBashProcess).toHaveBeenCalledWith({ + abortSignal: undefined, + process: running, + yieldAfterMs: DEFAULT_BASH_YIELD_AFTER_SECONDS * 1_000, + }); }); - it.each([ - { - expectedTimeoutMs: DEFAULT_BASH_TIMEOUT_SECONDS * 1_000, - input: { command: "sleep forever" }, - scenario: "uses the default timeout", - }, - { - expectedTimeoutMs: 10_000, - input: { command: "sleep forever", timeout: 10 }, - scenario: "honors a shorter requested timeout", - }, - { - expectedTimeoutMs: 1_200_000, - input: { command: "sleep forever", timeout: 1_200 }, - scenario: "honors a requested timeout above ten minutes", - }, - ])("$scenario", async ({ expectedTimeoutMs, input }) => { - const { abort, timeout } = mockTimeoutSignal(); - const execution = executeBashOnSandbox(createAbortingTestSandboxSession(), input); - const rejection = expect(execution).rejects.toMatchObject({ name: "TimeoutError" }); - - expect(timeout).toHaveBeenCalledWith(expectedTimeoutMs); - abort(); - - await rejection; + it("returns a process receipt instead of killing a command after yieldAfter", async () => { + const running = process(); + vi.mocked(startBackgroundBashProcess).mockResolvedValue(running); + vi.mocked(waitForBackgroundBashProcess).mockResolvedValue(null); + + await expect( + executeBashOnSandbox(sandbox, { command: "build", yieldAfter: 10 }), + ).resolves.toEqual({ + processId: "process-123", + status: "running", + stderr: "partial err", + stdout: "partial out", + truncated: false, + }); + expect(running.kill).not.toHaveBeenCalled(); }); - it("composes the command timeout with turn cancellation", async () => { - const controller = new AbortController(); - const sandbox = createAbortingTestSandboxSession(); - const execution = executeBashOnSandbox( - sandbox, - { command: "sleep forever" }, - { abortSignal: controller.signal }, - ); - const rejection = expect(execution).rejects.toMatchObject({ name: "AbortError" }); - - controller.abort(new DOMException("The turn was cancelled.", "AbortError")); - - await rejection; + it("kills a background command when the turn is cancelled", async () => { + const running = process(); + const cancelled = new DOMException("cancelled", "AbortError"); + vi.mocked(startBackgroundBashProcess).mockResolvedValue(running); + vi.mocked(waitForBackgroundBashProcess).mockRejectedValue(cancelled); + + await expect( + executeBashOnSandbox( + sandbox, + { command: "build" }, + { abortSignal: AbortSignal.abort(cancelled) }, + ), + ).rejects.toBe(cancelled); + expect(running.kill).toHaveBeenCalledOnce(); }); }); - -function mockTimeoutSignal() { - const controller = new AbortController(); - const timeout = vi.spyOn(AbortSignal, "timeout").mockReturnValue(controller.signal); - return { - abort: () => controller.abort(new DOMException("The command timed out.", "TimeoutError")), - timeout, - }; -} - -function createAbortingTestSandboxSession(): SandboxSession { - const sandbox = createTestSandboxSession({ exitCode: 0, stderr: "", stdout: "" }); - return { - ...sandbox, - run: vi.fn( - async ({ abortSignal }) => - await new Promise((_resolve, reject) => { - if (abortSignal?.aborted === true) { - reject(abortSignal.reason); - return; - } - abortSignal?.addEventListener("abort", () => reject(abortSignal.reason), { once: true }); - }), - ), - }; -} - -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 () => {}, - }; -} diff --git a/packages/eve/src/execution/sandbox/bash.ts b/packages/eve/src/execution/sandbox/bash.ts index 2d37b7d870..6d663436b6 100644 --- a/packages/eve/src/execution/sandbox/bash.ts +++ b/packages/eve/src/execution/sandbox/bash.ts @@ -1,140 +1,93 @@ import type { SandboxSession } from "#shared/sandbox-session.js"; +import { + startBackgroundBashProcess, + waitForBackgroundBashProcess, +} from "#execution/sandbox/bash-background.js"; import { truncateTail } from "#execution/sandbox/truncate-output.js"; -import { isEveDevEnvironment } from "#internal/application/optional-package-install.js"; -const MAX_LOG_COMMAND_LENGTH = 240; - -// --------------------------------------------------------------------------- -// Input shape -// --------------------------------------------------------------------------- - -/** - * Typed input accepted by {@link executeBashOnSandbox}. - */ export interface BashInput { readonly command: string; - readonly timeout?: number; + readonly yieldAfter?: number; } export interface BashExecuteOptions { readonly abortSignal?: AbortSignal; } -export const DEFAULT_BASH_TIMEOUT_SECONDS = 300; +export const DEFAULT_BASH_YIELD_AFTER_SECONDS = 300; -// --------------------------------------------------------------------------- -// Result shape -// --------------------------------------------------------------------------- +export type BashResult = BashCompletedResult | BashRunningResult; -/** - * Structured result returned from {@link executeBashOnSandbox}. - */ -export interface BashResult { +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; } -// --------------------------------------------------------------------------- -// Executor -// --------------------------------------------------------------------------- - -/** - * Executes one shell command inside the agent's sandbox via `SandboxKey` - * on the active runtime context. - * - * 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. - * - * 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. - */ +/** Starts one shell command and yields it to the background after the foreground wait. */ export async function executeBashOnSandbox( sandbox: SandboxSession, args: BashInput, options?: BashExecuteOptions, ): Promise { - const timeoutMs = (args.timeout ?? DEFAULT_BASH_TIMEOUT_SECONDS) * 1_000; - const timeoutSignal = AbortSignal.timeout(timeoutMs); - const abortSignal = - options?.abortSignal === undefined - ? timeoutSignal - : AbortSignal.any([options.abortSignal, timeoutSignal]); - const raw = await runWithDevelopmentSandboxProgress(sandbox, args.command, abortSignal); + const process = await startBackgroundBashProcess(sandbox, args.command); + let state; + try { + state = await waitForBackgroundBashProcess({ + abortSignal: options?.abortSignal, + process, + yieldAfterMs: (args.yieldAfter ?? DEFAULT_BASH_YIELD_AFTER_SECONDS) * 1_000, + }); + } catch (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 stdoutResult = truncateTail(raw.stdout); - const stderrResult = truncateTail(raw.stderr); - const truncated = stdoutResult.truncated || stderrResult.truncated; + const observed = state ?? (await process.read()); + const output = formatBashOutput(observed.stdout, observed.stderr); + return state === null + ? { ...output, processId: process.processId, status: "running" } + : { ...output, exitCode: state.exitCode!, status: "completed" }; +} +export function formatBashOutput(stdoutValue: string, stderrValue: string): BashOutput { + const stdoutResult = truncateTail(stdoutValue); + const stderrResult = truncateTail(stderrValue); let stdout = stdoutResult.output; + let stderr = stderrResult.output; if (stdoutResult.truncated) { stdout = `[stdout truncated: showing last ${stdoutResult.outputLines} of ${stdoutResult.totalLines} lines]\n` + stdout; } - - let stderr = stderrResult.output; if (stderrResult.truncated) { stderr = `[stderr truncated: showing last ${stderrResult.outputLines} of ${stderrResult.totalLines} lines]\n` + stderr; } - return { - exitCode: raw.exitCode, stderr, stdout, - truncated, + truncated: stdoutResult.truncated || stderrResult.truncated, }; } - -async function runWithDevelopmentSandboxProgress( - sandbox: SandboxSession, - command: string, - abortSignal: AbortSignal, -): Promise>> { - logDevelopmentSandboxCommand(`eve: starting sandbox command: ${formatCommand(command)}`); - if (!isEveDevEnvironment()) { - return await sandbox.run({ abortSignal, command }); - } - - const startedAt = Date.now(); - const timer = setInterval(() => { - const elapsedSeconds = Math.round((Date.now() - startedAt) / 1000); - logDevelopmentSandboxCommand( - `eve: waiting for sandbox command (${elapsedSeconds}s elapsed): ${formatCommand(command)}`, - ); - }, 5_000); - timer.unref?.(); - - try { - const result = await sandbox.run({ abortSignal, 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); - } -} - -function logDevelopmentSandboxCommand(message: string): void { - 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; - } - return `${singleLine.slice(0, MAX_LOG_COMMAND_LENGTH - 1)}…`; -} 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..1e15998287 --- /dev/null +++ b/packages/eve/src/tools/provided/bash-execute.test.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { RuntimeSandboxSession } from "#shared/sandbox-session.js"; +import { + getBackgroundBashProcess, + waitForBackgroundBashProcess, +} from "#execution/sandbox/bash-background.js"; + +import { executeBashTool } from "./bash.js"; + +vi.mock("#execution/sandbox/bash-background.js", () => ({ + getBackgroundBashProcess: vi.fn(), + startBackgroundBashProcess: 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), + }; +} + +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, + }); + }); + + 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, + stderr: "", + stdout: "done", + }); + + await expect( + executeBashTool({ action: "wait", processId: running.processId, yieldAfter: 10 }, context), + ).resolves.toEqual({ + exitCode: 0, + status: "completed", + stderr: "", + stdout: "done", + truncated: false, + }); + }); + + 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 index 0e7ea42663..0564c91a7e 100644 --- a/packages/eve/src/tools/provided/bash.test.ts +++ b/packages/eve/src/tools/provided/bash.test.ts @@ -1,15 +1,47 @@ import { describe, expect, it } from "vitest"; -import { BASH_INPUT_SCHEMA } from "./bash.js"; +import { BASH_INPUT_SCHEMA, BASH_OUTPUT_SCHEMA } from "./bash.js"; -describe("BASH_INPUT_SCHEMA", () => { - it("accepts an optional positive timeout in seconds", () => { - expect(BASH_INPUT_SCHEMA.safeParse({ command: "pnpm test", timeout: 30 }).success).toBe(true); +describe("bash schemas", () => { + it("accepts an optional nonnegative foreground yield duration", () => { + expect(BASH_INPUT_SCHEMA.safeParse({ command: "pnpm test", yieldAfter: 0 }).success).toBe(true); expect(BASH_INPUT_SCHEMA.safeParse({ command: "pnpm test" }).success).toBe(true); + expect(BASH_INPUT_SCHEMA.safeParse({ command: "pnpm test", yieldAfter: -1 }).success).toBe( + false, + ); }); - it("rejects non-positive timeouts", () => { - expect(BASH_INPUT_SCHEMA.safeParse({ command: "pnpm test", timeout: 0 }).success).toBe(false); - expect(BASH_INPUT_SCHEMA.safeParse({ command: "pnpm test", timeout: -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", yieldAfter: 30 }) + .success, + ).toBe(true); + expect(BASH_INPUT_SCHEMA.safeParse({ action: "kill", processId: "process-123" }).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, + }).success, + ).toBe(true); + expect( + BASH_OUTPUT_SCHEMA.safeParse({ + processId: "process-123", + status: "running", + stderr: "", + stdout: "partial", + truncated: false, + }).success, + ).toBe(true); }); }); diff --git a/packages/eve/src/tools/provided/bash.ts b/packages/eve/src/tools/provided/bash.ts index b535b805c2..5e56236d53 100644 --- a/packages/eve/src/tools/provided/bash.ts +++ b/packages/eve/src/tools/provided/bash.ts @@ -1,55 +1,112 @@ import { z } from "#compiled/zod/index.js"; +import type { SessionContext } from "#context/session-context.js"; import { - DEFAULT_BASH_TIMEOUT_SECONDS, + DEFAULT_BASH_YIELD_AFTER_SECONDS, executeBashOnSandbox, + formatBashOutput, type BashInput, } from "#execution/sandbox/bash.js"; +import { + getBackgroundBashProcess, + waitForBackgroundBashProcess, +} from "#execution/sandbox/bash-background.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."), - timeout: z - .number() - .positive() - .describe(`Optional timeout in seconds. Defaults to ${DEFAULT_BASH_TIMEOUT_SECONDS}.`) - .optional(), -}); +const YIELD_AFTER_SCHEMA = z + .number() + .nonnegative() + .describe(`Optional foreground wait in seconds. Defaults to ${DEFAULT_BASH_YIELD_AFTER_SECONDS}.`) + .optional(); + +export const BASH_INPUT_SCHEMA = z.union([ + z.strictObject({ + command: z.string().describe("The shell command to execute."), + yieldAfter: YIELD_AFTER_SCHEMA.describe( + `Optional foreground wait in seconds. Defaults to ${DEFAULT_BASH_YIELD_AFTER_SECONDS}. If the command is still running, bash returns a process id instead of stopping it.`, + ), + }), + z.strictObject({ + action: z.enum(["poll", "wait", "kill"]), + processId: z.string().describe("The process id returned by an earlier bash call."), + yieldAfter: YIELD_AFTER_SCHEMA, + }), +]); -/** - * 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(), -}); +}; + +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 ("command" in input) { + return await executeBashOnSandbox(sandbox, input as BashInput, { + abortSignal: context.abortSignal, + }); + } + + const process = getBackgroundBashProcess(sandbox, input.processId); + if (input.action === "kill") { + const before = await process.read(); + if (before.exitCode !== undefined) { + const output = formatBashOutput(before.stdout, before.stderr); + return { ...output, exitCode: before.exitCode, status: "completed" }; + } + await process.kill(); + return { ...formatBashOutput(before.stdout, before.stderr), status: "killed" }; + } + const state = + input.action === "poll" + ? await process.read() + : await waitForBackgroundBashProcess({ + abortSignal: context.abortSignal, + process, + yieldAfterMs: (input.yieldAfter ?? DEFAULT_BASH_YIELD_AFTER_SECONDS) * 1_000, + }); + if (state === null || state.exitCode === undefined) { + const latest = state ?? (await process.read()); + return { + ...formatBashOutput(latest.stdout, latest.stderr), + processId: process.processId, + status: "running", + }; + } + return { + ...formatBashOutput(state.stdout, state.stderr), + exitCode: state.exitCode, + status: "completed", + }; +} + export const bash: ToolDefinition = defineTool({ description: [ - "Execute a shell command in the shared workspace environment.", - `Commands time out after ${DEFAULT_BASH_TIMEOUT_SECONDS} seconds unless a different timeout is requested.`, + "Run shell commands and manage commands that continue in the background.", + `A new command waits up to ${DEFAULT_BASH_YIELD_AFTER_SECONDS} seconds by default, then returns a process id if still running.`, + "Pass that process id back with action poll, wait, or kill.", ].join(" "), - async execute(input, ctx) { - return await executeBashOnSandbox(await ctx.getSandbox(), input as BashInput, { - abortSignal: ctx.abortSignal, - }); - }, + execute: executeBashTool, inputSchema: BASH_INPUT_SCHEMA, outputSchema: BASH_OUTPUT_SCHEMA, }); From 9134147a7a88674d2aa3ae3a9fd65c0807e47d67 Mon Sep 17 00:00:00 2001 From: Colton Padden Date: Tue, 25 Aug 2026 18:26:28 -0400 Subject: [PATCH 04/17] docs(eve): explain yielded bash process actions Signed-off-by: Colton Padden --- docs/concepts/built-in-tools.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/concepts/built-in-tools.md b/docs/concepts/built-in-tools.md index fc7774231e..114736b749 100644 --- a/docs/concepts/built-in-tools.md +++ b/docs/concepts/built-in-tools.md @@ -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. 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. From 4be62be8037432a059da3bad63949105a115f453 Mon Sep 17 00:00:00 2001 From: Colton Padden Date: Tue, 25 Aug 2026 22:01:48 -0400 Subject: [PATCH 05/17] refactor(eve): report bash wall time and cap background processes Signed-off-by: Colton Padden --- .changeset/bash-command-timeout.md | 2 +- docs/concepts/built-in-tools.md | 4 +- .../execution/sandbox/bash-background.test.ts | 22 +++++-- .../src/execution/sandbox/bash-background.ts | 22 ++++++- .../eve/src/execution/sandbox/bash.test.ts | 15 +++-- packages/eve/src/execution/sandbox/bash.ts | 48 ++++++++------- .../execution/sandbox/truncate-output.test.ts | 24 ++++++++ .../src/execution/sandbox/truncate-output.ts | 59 +++++++++++++++++++ .../src/tools/provided/bash-execute.test.ts | 12 +++- packages/eve/src/tools/provided/bash.test.ts | 21 +++++-- packages/eve/src/tools/provided/bash.ts | 30 ++++++---- 11 files changed, 203 insertions(+), 56 deletions(-) diff --git a/.changeset/bash-command-timeout.md b/.changeset/bash-command-timeout.md index 983da8806a..958f69dc6c 100644 --- a/.changeset/bash-command-timeout.md +++ b/.changeset/bash-command-timeout.md @@ -2,4 +2,4 @@ "eve": patch --- -Run built-in `bash` commands in the foreground for five minutes by default, with an optional `yieldAfter` 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. +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 report wall time and long output preserves its head and tail. diff --git a/docs/concepts/built-in-tools.md b/docs/concepts/built-in-tools.md index 114736b749..b02c541191 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. After `yieldAfter` seconds (default 300), a command still running continues in the background and returns a process id. | 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,7 +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. Process termination requires a backend with real OS processes; `just-bash` reports that it cannot kill the process. +- **`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`, long output keeps its beginning and end with the middle omitted, and one sandbox tracks at most 64 background commands (completed process state is reclaimed first). 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/packages/eve/src/execution/sandbox/bash-background.test.ts b/packages/eve/src/execution/sandbox/bash-background.test.ts index 07eb1779ce..b952a8991d 100644 --- a/packages/eve/src/execution/sandbox/bash-background.test.ts +++ b/packages/eve/src/execution/sandbox/bash-background.test.ts @@ -4,6 +4,7 @@ import type { SandboxSession } from "#shared/sandbox-session.js"; import { getBackgroundBashProcess, + MAX_BACKGROUND_BASH_PROCESSES, startBackgroundBashProcess, waitForBackgroundBashProcess, } from "./bash-background.js"; @@ -28,14 +29,27 @@ function sandbox(): SandboxSession { } describe("background bash processes", () => { - it("launches a detached command with durable status and output files", async () => { + it("launches a detached command with durable status files behind the process cap", async () => { const session = sandbox(); const process = await startBackgroundBashProcess(session, "pnpm test"); expect(process.processId).toMatch(/^[0-9a-f-]{36}$/); - expect(session.run).toHaveBeenCalledWith({ - command: expect.stringContaining("( eval 'pnpm test'; code=$?"), + const command = vi.mocked(session.run).mock.calls[0]?.[0].command; + expect(command).toContain("( eval 'pnpm test'; code=$?"); + expect(command).toContain(`-ge ${MAX_BACKGROUND_BASH_PROCESSES}`); + }); + + it("rejects a new command when the sandbox is at the process cap", async () => { + const session = sandbox(); + vi.mocked(session.run).mockResolvedValue({ + exitCode: 75, + stderr: "EVE_BASH_PROCESS_LIMIT\n", + stdout: "", }); + + await expect(startBackgroundBashProcess(session, "pnpm test")).rejects.toThrow( + `This sandbox already tracks ${MAX_BACKGROUND_BASH_PROCESSES} running background commands.`, + ); }); it("reads a completed process from its durable process id", async () => { @@ -65,7 +79,7 @@ describe("background bash processes", () => { await expect( waitForBackgroundBashProcess({ process: { kill: vi.fn(async () => {}), processId: "process", read }, - yieldAfterMs: 0, + yieldTimeMs: 0, }), ).resolves.toBeNull(); expect(read).toHaveBeenCalledOnce(); diff --git a/packages/eve/src/execution/sandbox/bash-background.ts b/packages/eve/src/execution/sandbox/bash-background.ts index dc7da90284..edebf95ca3 100644 --- a/packages/eve/src/execution/sandbox/bash-background.ts +++ b/packages/eve/src/execution/sandbox/bash-background.ts @@ -5,6 +5,14 @@ import { shellQuote } from "#execution/sandbox/shell-quote.js"; const PROCESS_ROOT = "/workspace/.eve/processes"; const POLL_INTERVAL_MS = 250; +const PROCESS_LIMIT_MARKER = "EVE_BASH_PROCESS_LIMIT"; + +/** + * Maximum number of tracked background bash processes per sandbox. + * Launching past the cap first prunes completed process state; a + * sandbox still at the cap after pruning rejects the new command. + */ +export const MAX_BACKGROUND_BASH_PROCESSES = 64; export interface BackgroundBashProcess { readonly processId: string; @@ -24,13 +32,23 @@ export async function startBackgroundBashProcess( ): Promise { const processId = randomUUID(); const directory = `${PROCESS_ROOT}/${processId}`; + const quotedRoot = shellQuote(PROCESS_ROOT); const launch = [ + `mkdir -p ${quotedRoot}`, + // Reclaim completed process state before enforcing the process cap. + `if [ "$(ls ${quotedRoot} | wc -l)" -ge ${MAX_BACKGROUND_BASH_PROCESSES} ]; then for d in ${quotedRoot}/*/; do [ -f "$d/exit-code" ] && rm -rf "$d"; done; fi`, + `if [ "$(ls ${quotedRoot} | wc -l)" -ge ${MAX_BACKGROUND_BASH_PROCESSES} ]; then echo ${PROCESS_LIMIT_MARKER} >&2; exit 75; fi`, `mkdir -p ${shellQuote(directory)}`, `( eval ${shellQuote(command)}; code=$?; printf '%s' "$code" > ${shellQuote(`${directory}/exit-code`)} ) > ${shellQuote(`${directory}/stdout`)} 2> ${shellQuote(`${directory}/stderr`)} &`, `printf '%s' "$!" > ${shellQuote(`${directory}/pid`)}`, ].join("\n"); const result = await sandbox.run({ command: launch }); if (result.exitCode !== 0) { + 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 start background command: ${result.stderr || result.stdout}`); } @@ -84,9 +102,9 @@ function backgroundBashProcess(sandbox: SandboxSession, processId: string): Back export async function waitForBackgroundBashProcess(input: { readonly abortSignal?: AbortSignal; readonly process: BackgroundBashProcess; - readonly yieldAfterMs: number; + readonly yieldTimeMs: number; }): Promise { - const deadline = Date.now() + input.yieldAfterMs; + const deadline = Date.now() + input.yieldTimeMs; while (true) { input.abortSignal?.throwIfAborted(); const state = await input.process.read(); diff --git a/packages/eve/src/execution/sandbox/bash.test.ts b/packages/eve/src/execution/sandbox/bash.test.ts index db76c914b1..465699c7b4 100644 --- a/packages/eve/src/execution/sandbox/bash.test.ts +++ b/packages/eve/src/execution/sandbox/bash.test.ts @@ -6,7 +6,7 @@ import { waitForBackgroundBashProcess, } from "#execution/sandbox/bash-background.js"; -import { DEFAULT_BASH_YIELD_AFTER_SECONDS, executeBashOnSandbox } from "./bash.js"; +import { DEFAULT_BASH_YIELD_TIME_MS, executeBashOnSandbox } from "./bash.js"; vi.mock("#execution/sandbox/bash-background.js", () => ({ startBackgroundBashProcess: vi.fn(), @@ -41,27 +41,34 @@ describe("executeBashOnSandbox", () => { stderr: "", stdout: "done\n", truncated: false, + wallTimeSeconds: expect.any(Number), }); expect(waitForBackgroundBashProcess).toHaveBeenCalledWith({ abortSignal: undefined, process: running, - yieldAfterMs: DEFAULT_BASH_YIELD_AFTER_SECONDS * 1_000, + yieldTimeMs: DEFAULT_BASH_YIELD_TIME_MS, }); }); - it("returns a process receipt instead of killing a command after yieldAfter", async () => { + it("returns a process receipt instead of killing a command after yieldTimeMs", async () => { const running = process(); vi.mocked(startBackgroundBashProcess).mockResolvedValue(running); vi.mocked(waitForBackgroundBashProcess).mockResolvedValue(null); await expect( - executeBashOnSandbox(sandbox, { command: "build", yieldAfter: 10 }), + executeBashOnSandbox(sandbox, { command: "build", yieldTimeMs: 10_000 }), ).resolves.toEqual({ processId: "process-123", status: "running", stderr: "partial err", stdout: "partial out", truncated: false, + wallTimeSeconds: expect.any(Number), + }); + expect(waitForBackgroundBashProcess).toHaveBeenCalledWith({ + abortSignal: undefined, + process: running, + yieldTimeMs: 10_000, }); expect(running.kill).not.toHaveBeenCalled(); }); diff --git a/packages/eve/src/execution/sandbox/bash.ts b/packages/eve/src/execution/sandbox/bash.ts index 6d663436b6..93d617fe80 100644 --- a/packages/eve/src/execution/sandbox/bash.ts +++ b/packages/eve/src/execution/sandbox/bash.ts @@ -3,18 +3,18 @@ import { startBackgroundBashProcess, waitForBackgroundBashProcess, } from "#execution/sandbox/bash-background.js"; -import { truncateTail } from "#execution/sandbox/truncate-output.js"; +import { truncateHeadTail } from "#execution/sandbox/truncate-output.js"; export interface BashInput { readonly command: string; - readonly yieldAfter?: number; + readonly yieldTimeMs?: number; } export interface BashExecuteOptions { readonly abortSignal?: AbortSignal; } -export const DEFAULT_BASH_YIELD_AFTER_SECONDS = 300; +export const DEFAULT_BASH_YIELD_TIME_MS = 300_000; export type BashResult = BashCompletedResult | BashRunningResult; @@ -32,6 +32,8 @@ export interface BashOutput { readonly stderr: string; readonly stdout: string; readonly truncated: boolean; + /** Elapsed wall time this call spent before returning, in seconds. */ + readonly wallTimeSeconds: number; } /** Starts one shell command and yields it to the background after the foreground wait. */ @@ -40,13 +42,14 @@ export async function executeBashOnSandbox( args: BashInput, options?: BashExecuteOptions, ): Promise { + const startedAt = Date.now(); const process = await startBackgroundBashProcess(sandbox, args.command); let state; try { state = await waitForBackgroundBashProcess({ abortSignal: options?.abortSignal, process, - yieldAfterMs: (args.yieldAfter ?? DEFAULT_BASH_YIELD_AFTER_SECONDS) * 1_000, + yieldTimeMs: args.yieldTimeMs ?? DEFAULT_BASH_YIELD_TIME_MS, }); } catch (error) { try { @@ -55,39 +58,34 @@ export async function executeBashOnSandbox( throw new AggregateError( [error, killError], "The bash command was cancelled but could not be killed.", - { - cause: error, - }, + { cause: error }, ); } throw error; } const observed = state ?? (await process.read()); - const output = formatBashOutput(observed.stdout, observed.stderr); + const output = formatBashOutput(observed.stdout, observed.stderr, startedAt); return state === null ? { ...output, processId: process.processId, status: "running" } : { ...output, exitCode: state.exitCode!, status: "completed" }; } -export function formatBashOutput(stdoutValue: string, stderrValue: string): BashOutput { - const stdoutResult = truncateTail(stdoutValue); - const stderrResult = truncateTail(stderrValue); - let stdout = stdoutResult.output; - let stderr = stderrResult.output; - if (stdoutResult.truncated) { - stdout = - `[stdout truncated: showing last ${stdoutResult.outputLines} of ${stdoutResult.totalLines} lines]\n` + - stdout; - } - if (stderrResult.truncated) { - stderr = - `[stderr truncated: showing last ${stderrResult.outputLines} of ${stderrResult.totalLines} lines]\n` + - stderr; - } +export function formatBashOutput( + stdoutValue: string, + stderrValue: string, + startedAt: number, +): BashOutput { + const stdoutResult = truncateHeadTail(stdoutValue); + const stderrResult = truncateHeadTail(stderrValue); return { - stderr, - stdout, + 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; +} diff --git a/packages/eve/src/execution/sandbox/truncate-output.test.ts b/packages/eve/src/execution/sandbox/truncate-output.test.ts index 21beabacd8..15abb9755f 100644 --- a/packages/eve/src/execution/sandbox/truncate-output.test.ts +++ b/packages/eve/src/execution/sandbox/truncate-output.test.ts @@ -5,6 +5,7 @@ import { MAX_OUTPUT_BYTES, MAX_OUTPUT_LINES, truncateHead, + truncateHeadTail, truncateTail, } from "#execution/sandbox/truncate-output.js"; @@ -89,3 +90,26 @@ describe("truncateHead", () => { expect(lines[0]).toContain("[truncated]"); }); }); + +describe("truncateHeadTail", () => { + it("returns small text unchanged", () => { + const result = truncateHeadTail("start\nend"); + expect(result).toEqual({ + output: "start\nend", + outputLines: 2, + totalLines: 2, + truncated: false, + }); + }); + + it("keeps both ends of long output and marks the omitted middle", () => { + const lines = Array.from({ length: MAX_OUTPUT_LINES * 2 }, (_, i) => `line ${i}`); + const result = truncateHeadTail(lines.join("\n")); + + expect(result.truncated).toBe(true); + expect(result.output).toContain("line 0"); + expect(result.output).toContain(`line ${MAX_OUTPUT_LINES * 2 - 1}`); + expect(result.output).toMatch(/\[\.\.\. \d+ lines omitted \.\.\.\]/); + expect(Buffer.byteLength(result.output, "utf8")).toBeLessThanOrEqual(MAX_OUTPUT_BYTES + 100); + }); +}); diff --git a/packages/eve/src/execution/sandbox/truncate-output.ts b/packages/eve/src/execution/sandbox/truncate-output.ts index c6c590808e..2b43e2a4d9 100644 --- a/packages/eve/src/execution/sandbox/truncate-output.ts +++ b/packages/eve/src/execution/sandbox/truncate-output.ts @@ -72,6 +72,65 @@ export function truncateTail(text: string): TruncationResult { return truncateByDirection(text, "tail"); } +/** + * Keeps the **first and last** lines of `text` within the shared + * budgets, dropping the middle. Long command output is informative at + * both ends — startup banners and configuration at the head, errors + * and summaries at the tail — so each end receives half of the line + * and byte budgets. An omission marker line replaces the dropped + * middle. + */ +export function truncateHeadTail(text: string): TruncationResult { + const full = truncateHead(text); + if (!full.truncated) { + return full; + } + + const rawLines = text.split("\n"); + const totalLines = countLogicalLines(rawLines); + const head = collectLines(rawLines, "head", MAX_OUTPUT_LINES / 2, MAX_OUTPUT_BYTES / 2); + const tail = collectLines(rawLines, "tail", MAX_OUTPUT_LINES / 2, MAX_OUTPUT_BYTES / 2); + const omitted = totalLines - head.length - tail.length; + if (omitted <= 0) { + return full; + } + + return { + output: [...head, `[... ${omitted} lines omitted ...]`, ...tail].join("\n"), + outputLines: head.length + tail.length, + totalLines, + truncated: true, + }; +} + +function collectLines( + rawLines: readonly string[], + direction: "head" | "tail", + maxLines: number, + maxBytes: number, +): string[] { + const fromStart = direction === "head"; + const kept: string[] = []; + let bytes = 0; + + const start = fromStart ? 0 : rawLines.length - 1; + const step = fromStart ? 1 : -1; + for (let i = start; i >= 0 && i < rawLines.length && kept.length < maxLines; i += step) { + const line = capLineLength(rawLines[i] ?? ""); + const lineBytes = Buffer.byteLength(line, "utf8") + 1; + if (bytes + lineBytes > maxBytes && kept.length > 0) { + break; + } + kept.push(line); + bytes += lineBytes; + } + + if (!fromStart) { + kept.reverse(); + } + return kept; +} + /** * Shared truncation loop used by {@link truncateHead} and * {@link truncateTail}. The only difference between the two is the diff --git a/packages/eve/src/tools/provided/bash-execute.test.ts b/packages/eve/src/tools/provided/bash-execute.test.ts index 1e15998287..4c2612b86f 100644 --- a/packages/eve/src/tools/provided/bash-execute.test.ts +++ b/packages/eve/src/tools/provided/bash-execute.test.ts @@ -42,6 +42,7 @@ describe("executeBashTool process actions", () => { stderr: "", stdout: "partial", truncated: false, + wallTimeSeconds: expect.any(Number), }); }); @@ -55,13 +56,22 @@ describe("executeBashTool process actions", () => { }); await expect( - executeBashTool({ action: "wait", processId: running.processId, yieldAfter: 10 }, context), + 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, }); }); diff --git a/packages/eve/src/tools/provided/bash.test.ts b/packages/eve/src/tools/provided/bash.test.ts index 0564c91a7e..c014503bb8 100644 --- a/packages/eve/src/tools/provided/bash.test.ts +++ b/packages/eve/src/tools/provided/bash.test.ts @@ -3,10 +3,12 @@ import { describe, expect, it } from "vitest"; import { BASH_INPUT_SCHEMA, BASH_OUTPUT_SCHEMA } from "./bash.js"; describe("bash schemas", () => { - it("accepts an optional nonnegative foreground yield duration", () => { - expect(BASH_INPUT_SCHEMA.safeParse({ command: "pnpm test", yieldAfter: 0 }).success).toBe(true); + 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", yieldAfter: -1 }).success).toBe( + expect(BASH_INPUT_SCHEMA.safeParse({ command: "pnpm test", yieldTimeMs: -1 }).success).toBe( false, ); }); @@ -16,7 +18,7 @@ describe("bash schemas", () => { true, ); expect( - BASH_INPUT_SCHEMA.safeParse({ action: "wait", processId: "process-123", yieldAfter: 30 }) + 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( @@ -32,6 +34,7 @@ describe("bash schemas", () => { stderr: "", stdout: "done", truncated: false, + wallTimeSeconds: 1.5, }).success, ).toBe(true); expect( @@ -41,7 +44,17 @@ describe("bash schemas", () => { 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 5e56236d53..66e66cd9cf 100644 --- a/packages/eve/src/tools/provided/bash.ts +++ b/packages/eve/src/tools/provided/bash.ts @@ -2,7 +2,7 @@ import { z } from "#compiled/zod/index.js"; import type { SessionContext } from "#context/session-context.js"; import { - DEFAULT_BASH_YIELD_AFTER_SECONDS, + DEFAULT_BASH_YIELD_TIME_MS, executeBashOnSandbox, formatBashOutput, type BashInput, @@ -13,23 +13,23 @@ import { } from "#execution/sandbox/bash-background.js"; import { defineTool, type ToolDefinition } from "#tools/definition.js"; -const YIELD_AFTER_SCHEMA = z +const YIELD_TIME_SCHEMA = z .number() .nonnegative() - .describe(`Optional foreground wait in seconds. Defaults to ${DEFAULT_BASH_YIELD_AFTER_SECONDS}.`) + .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.`, + ) .optional(); export const BASH_INPUT_SCHEMA = z.union([ z.strictObject({ command: z.string().describe("The shell command to execute."), - yieldAfter: YIELD_AFTER_SCHEMA.describe( - `Optional foreground wait in seconds. Defaults to ${DEFAULT_BASH_YIELD_AFTER_SECONDS}. If the command is still running, bash returns a process id instead of stopping it.`, - ), + yieldTimeMs: YIELD_TIME_SCHEMA, }), z.strictObject({ action: z.enum(["poll", "wait", "kill"]), processId: z.string().describe("The process id returned by an earlier bash call."), - yieldAfter: YIELD_AFTER_SCHEMA, + yieldTimeMs: YIELD_TIME_SCHEMA, }), ]); @@ -37,6 +37,9 @@ 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", [ @@ -67,15 +70,16 @@ export async function executeBashTool( }); } + const startedAt = Date.now(); const process = getBackgroundBashProcess(sandbox, input.processId); if (input.action === "kill") { const before = await process.read(); if (before.exitCode !== undefined) { - const output = formatBashOutput(before.stdout, before.stderr); + const output = formatBashOutput(before.stdout, before.stderr, startedAt); return { ...output, exitCode: before.exitCode, status: "completed" }; } await process.kill(); - return { ...formatBashOutput(before.stdout, before.stderr), status: "killed" }; + return { ...formatBashOutput(before.stdout, before.stderr, startedAt), status: "killed" }; } const state = input.action === "poll" @@ -83,18 +87,18 @@ export async function executeBashTool( : await waitForBackgroundBashProcess({ abortSignal: context.abortSignal, process, - yieldAfterMs: (input.yieldAfter ?? DEFAULT_BASH_YIELD_AFTER_SECONDS) * 1_000, + yieldTimeMs: input.yieldTimeMs ?? DEFAULT_BASH_YIELD_TIME_MS, }); if (state === null || state.exitCode === undefined) { const latest = state ?? (await process.read()); return { - ...formatBashOutput(latest.stdout, latest.stderr), + ...formatBashOutput(latest.stdout, latest.stderr, startedAt), processId: process.processId, status: "running", }; } return { - ...formatBashOutput(state.stdout, state.stderr), + ...formatBashOutput(state.stdout, state.stderr, startedAt), exitCode: state.exitCode, status: "completed", }; @@ -103,7 +107,7 @@ export async function executeBashTool( export const bash: ToolDefinition = defineTool({ description: [ "Run shell commands and manage commands that continue in the background.", - `A new command waits up to ${DEFAULT_BASH_YIELD_AFTER_SECONDS} seconds by default, then returns a process id if still running.`, + `A new command 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, From 9d1de60a0f1af25a07e6a6d72e4186f9121d3e7e Mon Sep 17 00:00:00 2001 From: Colton Padden Date: Tue, 25 Aug 2026 23:12:07 -0400 Subject: [PATCH 06/17] fix(eve): harden yielded bash process handling Signed-off-by: Colton Padden --- docs/concepts/built-in-tools.md | 2 +- .../execution/sandbox/bash-background.test.ts | 87 --------- .../src/execution/sandbox/bash-background.ts | 126 ------------- .../eve/src/execution/sandbox/bash.test.ts | 178 +++++++++++++----- packages/eve/src/execution/sandbox/bash.ts | 155 +++++++++++++-- .../execution/sandbox/truncate-output.test.ts | 3 +- .../src/execution/sandbox/truncate-output.ts | 15 +- .../src/tools/provided/bash-execute.test.ts | 17 +- packages/eve/src/tools/provided/bash.ts | 27 ++- 9 files changed, 303 insertions(+), 307 deletions(-) delete mode 100644 packages/eve/src/execution/sandbox/bash-background.test.ts delete mode 100644 packages/eve/src/execution/sandbox/bash-background.ts diff --git a/docs/concepts/built-in-tools.md b/docs/concepts/built-in-tools.md index b02c541191..d870477bc3 100644 --- a/docs/concepts/built-in-tools.md +++ b/docs/concepts/built-in-tools.md @@ -33,7 +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`, long output keeps its beginning and end with the middle omitted, and one sandbox tracks at most 64 background commands (completed process state is reclaimed first). Process termination requires a backend with real OS processes; `just-bash` reports that it cannot kill the process. +- **`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`, long output keeps its beginning and end with the middle omitted, 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/packages/eve/src/execution/sandbox/bash-background.test.ts b/packages/eve/src/execution/sandbox/bash-background.test.ts deleted file mode 100644 index b952a8991d..0000000000 --- a/packages/eve/src/execution/sandbox/bash-background.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import type { SandboxSession } from "#shared/sandbox-session.js"; - -import { - getBackgroundBashProcess, - MAX_BACKGROUND_BASH_PROCESSES, - startBackgroundBashProcess, - waitForBackgroundBashProcess, -} from "./bash-background.js"; - -function sandbox(): SandboxSession { - return { - id: "sandbox", - readBinaryFile: vi.fn(async () => null), - readFile: vi.fn(async () => null), - readTextFile: vi.fn(async () => 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 () => {}), - }; -} - -describe("background bash processes", () => { - it("launches a detached command with durable status files behind the process cap", async () => { - const session = sandbox(); - const process = await startBackgroundBashProcess(session, "pnpm test"); - - expect(process.processId).toMatch(/^[0-9a-f-]{36}$/); - const command = vi.mocked(session.run).mock.calls[0]?.[0].command; - expect(command).toContain("( eval 'pnpm test'; code=$?"); - expect(command).toContain(`-ge ${MAX_BACKGROUND_BASH_PROCESSES}`); - }); - - it("rejects a new command when the sandbox is at the process cap", async () => { - const session = sandbox(); - vi.mocked(session.run).mockResolvedValue({ - exitCode: 75, - stderr: "EVE_BASH_PROCESS_LIMIT\n", - stdout: "", - }); - - await expect(startBackgroundBashProcess(session, "pnpm test")).rejects.toThrow( - `This sandbox already tracks ${MAX_BACKGROUND_BASH_PROCESSES} running background commands.`, - ); - }); - - it("reads a completed process from its durable process id", async () => { - const session = sandbox(); - vi.mocked(session.readTextFile) - .mockResolvedValueOnce("123") - .mockResolvedValueOnce("7") - .mockResolvedValueOnce("out") - .mockResolvedValueOnce("err"); - - await expect( - getBackgroundBashProcess(session, "11111111-1111-4111-8111-111111111111").read(), - ).resolves.toEqual({ exitCode: 7, stderr: "err", stdout: "out" }); - }); - - it("rejects a process id without durable process state", async () => { - const session = sandbox(); - - await expect( - getBackgroundBashProcess(session, "11111111-1111-4111-8111-111111111111").read(), - ).rejects.toThrow('Bash process "11111111-1111-4111-8111-111111111111" does not exist.'); - }); - - it("yields without killing a process that is still running", async () => { - const read = vi.fn(async () => ({ stderr: "", stdout: "partial" })); - - await expect( - waitForBackgroundBashProcess({ - process: { kill: vi.fn(async () => {}), processId: "process", read }, - yieldTimeMs: 0, - }), - ).resolves.toBeNull(); - expect(read).toHaveBeenCalledOnce(); - }); -}); diff --git a/packages/eve/src/execution/sandbox/bash-background.ts b/packages/eve/src/execution/sandbox/bash-background.ts deleted file mode 100644 index edebf95ca3..0000000000 --- a/packages/eve/src/execution/sandbox/bash-background.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { randomUUID } from "node:crypto"; - -import type { SandboxSession } from "#shared/sandbox-session.js"; -import { shellQuote } from "#execution/sandbox/shell-quote.js"; - -const PROCESS_ROOT = "/workspace/.eve/processes"; -const POLL_INTERVAL_MS = 250; -const PROCESS_LIMIT_MARKER = "EVE_BASH_PROCESS_LIMIT"; - -/** - * Maximum number of tracked background bash processes per sandbox. - * Launching past the cap first prunes completed process state; a - * sandbox still at the cap after pruning rejects the new command. - */ -export const MAX_BACKGROUND_BASH_PROCESSES = 64; - -export interface BackgroundBashProcess { - readonly processId: string; - read(): Promise; - kill(): Promise; -} - -export interface BackgroundBashProcessState { - readonly exitCode?: number; - readonly stderr: string; - readonly stdout: string; -} - -export async function startBackgroundBashProcess( - sandbox: SandboxSession, - command: string, -): Promise { - const processId = randomUUID(); - const directory = `${PROCESS_ROOT}/${processId}`; - const quotedRoot = shellQuote(PROCESS_ROOT); - const launch = [ - `mkdir -p ${quotedRoot}`, - // Reclaim completed process state before enforcing the process cap. - `if [ "$(ls ${quotedRoot} | wc -l)" -ge ${MAX_BACKGROUND_BASH_PROCESSES} ]; then for d in ${quotedRoot}/*/; do [ -f "$d/exit-code" ] && rm -rf "$d"; done; fi`, - `if [ "$(ls ${quotedRoot} | wc -l)" -ge ${MAX_BACKGROUND_BASH_PROCESSES} ]; then echo ${PROCESS_LIMIT_MARKER} >&2; exit 75; fi`, - `mkdir -p ${shellQuote(directory)}`, - `( eval ${shellQuote(command)}; code=$?; printf '%s' "$code" > ${shellQuote(`${directory}/exit-code`)} ) > ${shellQuote(`${directory}/stdout`)} 2> ${shellQuote(`${directory}/stderr`)} &`, - `printf '%s' "$!" > ${shellQuote(`${directory}/pid`)}`, - ].join("\n"); - const result = await sandbox.run({ command: launch }); - if (result.exitCode !== 0) { - 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 start background command: ${result.stderr || result.stdout}`); - } - - return backgroundBashProcess(sandbox, processId); -} - -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); -} - -function backgroundBashProcess(sandbox: SandboxSession, processId: string): BackgroundBashProcess { - const directory = `${PROCESS_ROOT}/${processId}`; - return { - processId, - async read() { - const [pid, exitCode, stdout, stderr] = await Promise.all([ - sandbox.readTextFile({ path: `${directory}/pid` }), - sandbox.readTextFile({ path: `${directory}/exit-code` }), - sandbox.readTextFile({ path: `${directory}/stdout` }), - sandbox.readTextFile({ path: `${directory}/stderr` }), - ]); - if (pid === null) { - throw new Error(`Bash process "${processId}" does not exist.`); - } - const state: { exitCode?: number; stderr: string; stdout: string } = { - stderr: stderr ?? "", - stdout: stdout ?? "", - }; - if (exitCode !== null) { - state.exitCode = Number.parseInt(exitCode, 10); - } - return state; - }, - async kill() { - const result = await sandbox.run({ - command: `pid=$(cat ${shellQuote(`${directory}/pid`)}) && [ "$pid" -gt 0 ] && { kill -- -"$pid" 2>/dev/null || kill "$pid" 2>/dev/null; }`, - }); - if (result.exitCode !== 0) { - throw new Error(`Bash process "${processId}" could not be killed by this sandbox backend.`); - } - }, - }; -} - -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.read(); - if (state.exitCode !== undefined) return state; - const remaining = deadline - Date.now(); - if (remaining <= 0) return null; - await new Promise((resolve, reject) => { - const timer = setTimeout(resolve, Math.min(POLL_INTERVAL_MS, remaining)); - input.abortSignal?.addEventListener( - "abort", - () => { - clearTimeout(timer); - reject(input.abortSignal?.reason); - }, - { once: true }, - ); - }); - } -} diff --git a/packages/eve/src/execution/sandbox/bash.test.ts b/packages/eve/src/execution/sandbox/bash.test.ts index 465699c7b4..2582c0d9ed 100644 --- a/packages/eve/src/execution/sandbox/bash.test.ts +++ b/packages/eve/src/execution/sandbox/bash.test.ts @@ -1,41 +1,56 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { SandboxSession } from "#shared/sandbox-session.js"; + import { + DEFAULT_BASH_YIELD_TIME_MS, + executeBashOnSandbox, + getBackgroundBashProcess, + MAX_BACKGROUND_BASH_PROCESSES, startBackgroundBashProcess, waitForBackgroundBashProcess, -} from "#execution/sandbox/bash-background.js"; - -import { DEFAULT_BASH_YIELD_TIME_MS, executeBashOnSandbox } from "./bash.js"; - -vi.mock("#execution/sandbox/bash-background.js", () => ({ - startBackgroundBashProcess: vi.fn(), - waitForBackgroundBashProcess: vi.fn(), -})); +} from "./bash.js"; -const sandbox = {} as SandboxSession; - -function process() { +function sandbox(files: Record = {}): SandboxSession { return { - kill: vi.fn(async () => {}), - processId: "process-123", - read: vi.fn(async () => ({ stderr: "partial err", stdout: "partial out" })), + 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 () => {}), }; } -describe("executeBashOnSandbox", () => { - afterEach(() => vi.resetAllMocks()); +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; + }, + }, + ); +} - it("returns completed output when the command finishes during the foreground wait", async () => { - const running = process(); - vi.mocked(startBackgroundBashProcess).mockResolvedValue(running); - vi.mocked(waitForBackgroundBashProcess).mockResolvedValue({ - exitCode: 0, - stderr: "", - stdout: "done\n", - }); +describe("executeBashOnSandbox", () => { + it("returns completed output", async () => { + const session = sandbox(processFiles({ exitCode: 0, stdout: "done\n" })); - await expect(executeBashOnSandbox(sandbox, { command: "build" })).resolves.toEqual({ + await expect(executeBashOnSandbox(session, { command: "build" })).resolves.toEqual({ exitCode: 0, status: "completed", stderr: "", @@ -43,49 +58,108 @@ describe("executeBashOnSandbox", () => { truncated: false, wallTimeSeconds: expect.any(Number), }); - expect(waitForBackgroundBashProcess).toHaveBeenCalledWith({ - abortSignal: undefined, - process: running, - yieldTimeMs: DEFAULT_BASH_YIELD_TIME_MS, - }); }); - it("returns a process receipt instead of killing a command after yieldTimeMs", async () => { - const running = process(); - vi.mocked(startBackgroundBashProcess).mockResolvedValue(running); - vi.mocked(waitForBackgroundBashProcess).mockResolvedValue(null); + it("yields a running command", async () => { + const session = sandbox(processFiles({ stderr: "partial err", stdout: "partial out" })); await expect( - executeBashOnSandbox(sandbox, { command: "build", yieldTimeMs: 10_000 }), - ).resolves.toEqual({ - processId: "process-123", + executeBashOnSandbox(session, { command: "build", yieldTimeMs: 0 }), + ).resolves.toMatchObject({ status: "running", stderr: "partial err", stdout: "partial out", - truncated: false, - wallTimeSeconds: expect.any(Number), }); - expect(waitForBackgroundBashProcess).toHaveBeenCalledWith({ - abortSignal: undefined, - process: running, - yieldTimeMs: 10_000, - }); - expect(running.kill).not.toHaveBeenCalled(); }); - it("kills a background command when the turn is cancelled", async () => { - const running = process(); + 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).toHaveBeenCalledOnce(); + }); + + it("kills when cancelled", async () => { + const session = sandbox(); const cancelled = new DOMException("cancelled", "AbortError"); - vi.mocked(startBackgroundBashProcess).mockResolvedValue(running); - vi.mocked(waitForBackgroundBashProcess).mockRejectedValue(cancelled); await expect( executeBashOnSandbox( - sandbox, + session, { command: "build" }, { abortSignal: AbortSignal.abort(cancelled) }, ), ).rejects.toBe(cancelled); - expect(running.kill).toHaveBeenCalledOnce(); + expect(session.run).toHaveBeenCalledTimes(2); + }); + + it("uses the default foreground wait", () => { + expect(DEFAULT_BASH_YIELD_TIME_MS).toBe(300_000); + }); +}); + +describe("background bash processes", () => { + it("launches a process group behind the process cap", async () => { + const session = sandbox(); + const process = await startBackgroundBashProcess(session, "pnpm test"); + const command = vi.mocked(session.run).mock.calls[0]?.[0].command; + + expect(process.processId).toMatch(/^[0-9a-f-]{36}$/); + expect(command).toContain("set -m 2>/dev/null || true"); + expect(command).toContain(`-ge ${MAX_BACKGROUND_BASH_PROCESSES}`); + }); + + 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: "", + }); + + 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" })); + + await expect( + getBackgroundBashProcess(session, "11111111-1111-4111-8111-111111111111").read(), + ).resolves.toEqual({ exitCode: 7, stderr: "err", stdout: "out" }); + }); + + it("removes process state after killing", async () => { + const session = sandbox(); + + await getBackgroundBashProcess(session, "11111111-1111-4111-8111-111111111111").kill(); + + expect(vi.mocked(session.run).mock.calls[0]?.[0].command).toContain("&& rm -rf"); + }); + + 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 93d617fe80..c8041dd2e3 100644 --- a/packages/eve/src/execution/sandbox/bash.ts +++ b/packages/eve/src/execution/sandbox/bash.ts @@ -1,10 +1,16 @@ +import { randomUUID } from "node:crypto"; + import type { SandboxSession } from "#shared/sandbox-session.js"; -import { - startBackgroundBashProcess, - waitForBackgroundBashProcess, -} from "#execution/sandbox/bash-background.js"; +import { shellQuote } from "#execution/sandbox/shell-quote.js"; import { truncateHeadTail } from "#execution/sandbox/truncate-output.js"; +const PROCESS_ROOT = "/workspace/.eve/processes"; +const POLL_INTERVAL_MS = 250; +const PROCESS_LIMIT_MARKER = "EVE_BASH_PROCESS_LIMIT"; + +export const DEFAULT_BASH_YIELD_TIME_MS = 300_000; +export const MAX_BACKGROUND_BASH_PROCESSES = 64; + export interface BashInput { readonly command: string; readonly yieldTimeMs?: number; @@ -14,8 +20,6 @@ export interface BashExecuteOptions { readonly abortSignal?: AbortSignal; } -export const DEFAULT_BASH_YIELD_TIME_MS = 300_000; - export type BashResult = BashCompletedResult | BashRunningResult; export interface BashCompletedResult extends BashOutput { @@ -36,6 +40,22 @@ export interface BashOutput { readonly wallTimeSeconds: number; } +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; +} + /** Starts one shell command and yields it to the background after the foreground wait. */ export async function executeBashOnSandbox( sandbox: SandboxSession, @@ -44,14 +64,16 @@ export async function executeBashOnSandbox( ): Promise { const startedAt = Date.now(); const process = await startBackgroundBashProcess(sandbox, args.command); - let state; try { - state = await waitForBackgroundBashProcess({ + 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) { @@ -64,11 +86,122 @@ export async function executeBashOnSandbox( throw error; } - const observed = state ?? (await process.read()); + const observed = await process.read(); const output = formatBashOutput(observed.stdout, observed.stderr, startedAt); - return state === null + return observed.exitCode === undefined ? { ...output, processId: process.processId, status: "running" } - : { ...output, exitCode: state.exitCode!, status: "completed" }; + : { ...output, exitCode: observed.exitCode, status: "completed" }; +} + +export async function startBackgroundBashProcess( + sandbox: SandboxSession, + command: string, +): Promise { + const processId = randomUUID(); + const directory = `${PROCESS_ROOT}/${processId}`; + const quotedRoot = shellQuote(PROCESS_ROOT); + const launch = [ + `mkdir -p ${quotedRoot}`, + `if [ "$(ls ${quotedRoot} | wc -l)" -ge ${MAX_BACKGROUND_BASH_PROCESSES} ]; then for d in ${quotedRoot}/*/; do [ -f "$d/exit-code" ] && rm -rf "$d"; done; fi`, + `if [ "$(ls ${quotedRoot} | wc -l)" -ge ${MAX_BACKGROUND_BASH_PROCESSES} ]; then echo ${PROCESS_LIMIT_MARKER} >&2; exit 75; fi`, + `mkdir -p ${shellQuote(directory)}`, + `set -m 2>/dev/null || true`, + `( eval ${shellQuote(command)}; code=$?; printf '%s' "$code" > ${shellQuote(`${directory}/exit-code.tmp`)} && mv ${shellQuote(`${directory}/exit-code.tmp`)} ${shellQuote(`${directory}/exit-code`)} ) > ${shellQuote(`${directory}/stdout`)} 2> ${shellQuote(`${directory}/stderr`)} &`, + `printf '%s' "$!" > ${shellQuote(`${directory}/pid`)}`, + ].join("\n"); + const result = await sandbox.run({ command: launch }); + if (result.exitCode !== 0) { + 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 start background command: ${result.stderr || result.stdout}`); + } + + return backgroundBashProcess(sandbox, processId); +} + +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); +} + +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 { + 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 quotedDirectory = shellQuote(directory); + const result = await sandbox.run({ + command: `pid=$(cat ${shellQuote(`${directory}/pid`)}) && [ "$pid" -gt 0 ] && { kill -- -"$pid" 2>/dev/null || kill "$pid" 2>/dev/null; } && { sleep 0.1; kill -KILL -- -"$pid" 2>/dev/null || kill -KILL "$pid" 2>/dev/null || true; } && rm -rf ${quotedDirectory}`, + }); + if (result.exitCode !== 0) { + throw new Error(`Bash process "${processId}" could not be killed by this sandbox backend.`); + } + }, + }; +} + +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( diff --git a/packages/eve/src/execution/sandbox/truncate-output.test.ts b/packages/eve/src/execution/sandbox/truncate-output.test.ts index 15abb9755f..7eca29f8d0 100644 --- a/packages/eve/src/execution/sandbox/truncate-output.test.ts +++ b/packages/eve/src/execution/sandbox/truncate-output.test.ts @@ -110,6 +110,7 @@ describe("truncateHeadTail", () => { expect(result.output).toContain("line 0"); expect(result.output).toContain(`line ${MAX_OUTPUT_LINES * 2 - 1}`); expect(result.output).toMatch(/\[\.\.\. \d+ lines omitted \.\.\.\]/); - expect(Buffer.byteLength(result.output, "utf8")).toBeLessThanOrEqual(MAX_OUTPUT_BYTES + 100); + expect(result.output.split("\n")).toHaveLength(MAX_OUTPUT_LINES); + expect(Buffer.byteLength(result.output, "utf8")).toBeLessThanOrEqual(MAX_OUTPUT_BYTES); }); }); diff --git a/packages/eve/src/execution/sandbox/truncate-output.ts b/packages/eve/src/execution/sandbox/truncate-output.ts index 2b43e2a4d9..0f615c897f 100644 --- a/packages/eve/src/execution/sandbox/truncate-output.ts +++ b/packages/eve/src/execution/sandbox/truncate-output.ts @@ -87,9 +87,18 @@ export function truncateHeadTail(text: string): TruncationResult { } const rawLines = text.split("\n"); - const totalLines = countLogicalLines(rawLines); - const head = collectLines(rawLines, "head", MAX_OUTPUT_LINES / 2, MAX_OUTPUT_BYTES / 2); - const tail = collectLines(rawLines, "tail", MAX_OUTPUT_LINES / 2, MAX_OUTPUT_BYTES / 2); + if (rawLines.at(-1) === "") rawLines.pop(); + const totalLines = rawLines.length; + const markerBudget = Buffer.byteLength(`[... ${totalLines} lines omitted ...]`, "utf8"); + const lineBudget = MAX_OUTPUT_LINES - 1; + const byteBudget = MAX_OUTPUT_BYTES - markerBudget; + const head = collectLines(rawLines, "head", Math.ceil(lineBudget / 2), Math.ceil(byteBudget / 2)); + const tail = collectLines( + rawLines, + "tail", + Math.floor(lineBudget / 2), + Math.floor(byteBudget / 2), + ); const omitted = totalLines - head.length - tail.length; if (omitted <= 0) { return full; diff --git a/packages/eve/src/tools/provided/bash-execute.test.ts b/packages/eve/src/tools/provided/bash-execute.test.ts index 4c2612b86f..f90ca29481 100644 --- a/packages/eve/src/tools/provided/bash-execute.test.ts +++ b/packages/eve/src/tools/provided/bash-execute.test.ts @@ -1,16 +1,13 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { RuntimeSandboxSession } from "#shared/sandbox-session.js"; -import { - getBackgroundBashProcess, - waitForBackgroundBashProcess, -} from "#execution/sandbox/bash-background.js"; +import { getBackgroundBashProcess, waitForBackgroundBashProcess } from "#execution/sandbox/bash.js"; import { executeBashTool } from "./bash.js"; -vi.mock("#execution/sandbox/bash-background.js", () => ({ +vi.mock("#execution/sandbox/bash.js", async (importOriginal) => ({ + ...(await importOriginal()), getBackgroundBashProcess: vi.fn(), - startBackgroundBashProcess: vi.fn(), waitForBackgroundBashProcess: vi.fn(), })); @@ -24,6 +21,7 @@ function process(state: { exitCode?: number; stderr: string; stdout: string }) { kill: vi.fn(async () => {}), processId: "11111111-1111-4111-8111-111111111111", read: vi.fn(async () => state), + readStatus: vi.fn(async () => ({ exitCode: state.exitCode })), }; } @@ -49,11 +47,8 @@ describe("executeBashTool process actions", () => { 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, - stderr: "", - stdout: "done", - }); + vi.mocked(waitForBackgroundBashProcess).mockResolvedValue({ exitCode: 0 }); + running.read.mockResolvedValue({ exitCode: 0, stderr: "", stdout: "done" }); await expect( executeBashTool( diff --git a/packages/eve/src/tools/provided/bash.ts b/packages/eve/src/tools/provided/bash.ts index 66e66cd9cf..2c878a47b8 100644 --- a/packages/eve/src/tools/provided/bash.ts +++ b/packages/eve/src/tools/provided/bash.ts @@ -5,12 +5,10 @@ import { DEFAULT_BASH_YIELD_TIME_MS, executeBashOnSandbox, formatBashOutput, - type BashInput, -} from "#execution/sandbox/bash.js"; -import { getBackgroundBashProcess, waitForBackgroundBashProcess, -} from "#execution/sandbox/bash-background.js"; + type BashInput, +} from "#execution/sandbox/bash.js"; import { defineTool, type ToolDefinition } from "#tools/definition.js"; const YIELD_TIME_SCHEMA = z @@ -81,18 +79,17 @@ export async function executeBashTool( await process.kill(); return { ...formatBashOutput(before.stdout, before.stderr, startedAt), status: "killed" }; } - const state = - input.action === "poll" - ? await process.read() - : await waitForBackgroundBashProcess({ - abortSignal: context.abortSignal, - process, - yieldTimeMs: input.yieldTimeMs ?? DEFAULT_BASH_YIELD_TIME_MS, - }); - if (state === null || state.exitCode === undefined) { - const latest = state ?? (await process.read()); + if (input.action === "wait") { + await waitForBackgroundBashProcess({ + abortSignal: context.abortSignal, + process, + yieldTimeMs: input.yieldTimeMs ?? DEFAULT_BASH_YIELD_TIME_MS, + }); + } + const state = await process.read(); + if (state.exitCode === undefined) { return { - ...formatBashOutput(latest.stdout, latest.stderr, startedAt), + ...formatBashOutput(state.stdout, state.stderr, startedAt), processId: process.processId, status: "running", }; From 76d03894dcae4d91aa6b3c31cd4f3664386b113c Mon Sep 17 00:00:00 2001 From: Colton Padden Date: Tue, 25 Aug 2026 23:26:14 -0400 Subject: [PATCH 07/17] fix(eve): emit object schema for bash tool Signed-off-by: Colton Padden --- packages/eve/src/tools/provided/bash.test.ts | 8 ++++ packages/eve/src/tools/provided/bash.ts | 37 +++++++++++++------ .../app-runtime-dependencies.scenario.test.ts | 2 +- 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/packages/eve/src/tools/provided/bash.test.ts b/packages/eve/src/tools/provided/bash.test.ts index c014503bb8..d271e2f868 100644 --- a/packages/eve/src/tools/provided/bash.test.ts +++ b/packages/eve/src/tools/provided/bash.test.ts @@ -1,8 +1,14 @@ 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", () => { + expect(z.toJSONSchema(BASH_INPUT_SCHEMA, { io: "input" })).toMatchObject({ type: "object" }); + }); + it("accepts an optional nonnegative foreground yield time in milliseconds", () => { expect(BASH_INPUT_SCHEMA.safeParse({ command: "pnpm test", yieldTimeMs: 0 }).success).toBe( true, @@ -24,6 +30,8 @@ describe("bash schemas", () => { 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); }); it("distinguishes completed commands from running process receipts", () => { diff --git a/packages/eve/src/tools/provided/bash.ts b/packages/eve/src/tools/provided/bash.ts index 2c878a47b8..25dcbc0e2a 100644 --- a/packages/eve/src/tools/provided/bash.ts +++ b/packages/eve/src/tools/provided/bash.ts @@ -19,17 +19,33 @@ const YIELD_TIME_SCHEMA = z ) .optional(); -export const BASH_INPUT_SCHEMA = z.union([ - z.strictObject({ - command: z.string().describe("The shell command to execute."), - yieldTimeMs: YIELD_TIME_SCHEMA, - }), - z.strictObject({ - action: z.enum(["poll", "wait", "kill"]), - processId: z.string().describe("The process id returned by an earlier bash call."), +export type BashToolInput = + | { readonly command: string; readonly yieldTimeMs?: number } + | { + readonly action: "poll" | "wait" | "kill"; + readonly processId: string; + readonly yieldTimeMs?: number; + }; + +export const BASH_INPUT_SCHEMA = z + .strictObject({ + action: z.enum(["poll", "wait", "kill"]).optional(), + command: z.string().describe("The shell command to execute.").optional(), + processId: z.string().describe("The process id returned by an earlier bash call.").optional(), yieldTimeMs: YIELD_TIME_SCHEMA, - }), -]); + }) + .superRefine((input, context) => { + const invalid = + input.command === undefined + ? input.action === undefined || input.processId === undefined + : input.action !== undefined || input.processId !== undefined; + if (invalid) { + context.addIssue({ + code: "custom", + message: "Provide either command or both action and processId.", + }); + } + }) as z.ZodType; const BASH_OUTPUT_FIELDS = { stderr: z.string(), @@ -54,7 +70,6 @@ export const BASH_OUTPUT_SCHEMA = z.discriminatedUnion("status", [ }), ]); -export type BashToolInput = z.infer; export type BashToolOutput = z.infer; export async function executeBashTool( 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://"); From d475d871667adfb701f8d25dffa7955f0f95e5cb Mon Sep 17 00:00:00 2001 From: Colton Padden Date: Tue, 25 Aug 2026 23:37:08 -0400 Subject: [PATCH 08/17] fix(eve): preserve bash input alternatives Signed-off-by: Colton Padden --- packages/eve/src/tools/provided/bash.test.ts | 5 ++- packages/eve/src/tools/provided/bash.ts | 39 +++++++------------- 2 files changed, 17 insertions(+), 27 deletions(-) diff --git a/packages/eve/src/tools/provided/bash.test.ts b/packages/eve/src/tools/provided/bash.test.ts index d271e2f868..60fd2848dd 100644 --- a/packages/eve/src/tools/provided/bash.test.ts +++ b/packages/eve/src/tools/provided/bash.test.ts @@ -6,7 +6,10 @@ import { BASH_INPUT_SCHEMA, BASH_OUTPUT_SCHEMA } from "./bash.js"; describe("bash schemas", () => { it("emits a provider-compatible object schema", () => { - expect(z.toJSONSchema(BASH_INPUT_SCHEMA, { io: "input" })).toMatchObject({ type: "object" }); + expect(z.toJSONSchema(BASH_INPUT_SCHEMA, { io: "input" })).toMatchObject({ + anyOf: expect.any(Array), + type: "object", + }); }); it("accepts an optional nonnegative foreground yield time in milliseconds", () => { diff --git a/packages/eve/src/tools/provided/bash.ts b/packages/eve/src/tools/provided/bash.ts index 25dcbc0e2a..7c5211742f 100644 --- a/packages/eve/src/tools/provided/bash.ts +++ b/packages/eve/src/tools/provided/bash.ts @@ -19,33 +19,19 @@ const YIELD_TIME_SCHEMA = z ) .optional(); -export type BashToolInput = - | { readonly command: string; readonly yieldTimeMs?: number } - | { - readonly action: "poll" | "wait" | "kill"; - readonly processId: string; - readonly yieldTimeMs?: number; - }; - export const BASH_INPUT_SCHEMA = z - .strictObject({ - action: z.enum(["poll", "wait", "kill"]).optional(), - command: z.string().describe("The shell command to execute.").optional(), - processId: z.string().describe("The process id returned by an earlier bash call.").optional(), - yieldTimeMs: YIELD_TIME_SCHEMA, - }) - .superRefine((input, context) => { - const invalid = - input.command === undefined - ? input.action === undefined || input.processId === undefined - : input.action !== undefined || input.processId !== undefined; - if (invalid) { - context.addIssue({ - code: "custom", - message: "Provide either command or both action and processId.", - }); - } - }) as z.ZodType; + .union([ + z.strictObject({ + command: z.string().describe("The shell command to execute."), + yieldTimeMs: YIELD_TIME_SCHEMA, + }), + z.strictObject({ + action: z.enum(["poll", "wait", "kill"]), + processId: z.string().describe("The process id returned by an earlier bash call."), + yieldTimeMs: YIELD_TIME_SCHEMA, + }), + ]) + .meta({ type: "object" }); const BASH_OUTPUT_FIELDS = { stderr: z.string(), @@ -70,6 +56,7 @@ export const BASH_OUTPUT_SCHEMA = z.discriminatedUnion("status", [ }), ]); +export type BashToolInput = z.infer; export type BashToolOutput = z.infer; export async function executeBashTool( From 932c76134e629a69fe67af0991252525a5351369 Mon Sep 17 00:00:00 2001 From: Colton Padden Date: Tue, 25 Aug 2026 23:40:53 -0400 Subject: [PATCH 09/17] fix(eve): describe bash input alternatives Signed-off-by: Colton Padden --- packages/eve/src/tools/provided/bash.test.ts | 7 ++- packages/eve/src/tools/provided/bash.ts | 45 ++++++++++++++------ 2 files changed, 35 insertions(+), 17 deletions(-) diff --git a/packages/eve/src/tools/provided/bash.test.ts b/packages/eve/src/tools/provided/bash.test.ts index 60fd2848dd..566172544c 100644 --- a/packages/eve/src/tools/provided/bash.test.ts +++ b/packages/eve/src/tools/provided/bash.test.ts @@ -6,10 +6,9 @@ import { BASH_INPUT_SCHEMA, BASH_OUTPUT_SCHEMA } from "./bash.js"; describe("bash schemas", () => { it("emits a provider-compatible object schema", () => { - expect(z.toJSONSchema(BASH_INPUT_SCHEMA, { io: "input" })).toMatchObject({ - anyOf: expect.any(Array), - type: "object", - }); + const schema = z.toJSONSchema(BASH_INPUT_SCHEMA, { io: "input" }); + expect(schema).toMatchObject({ type: "object" }); + expect(schema).not.toHaveProperty("anyOf"); }); it("accepts an optional nonnegative foreground yield time in milliseconds", () => { diff --git a/packages/eve/src/tools/provided/bash.ts b/packages/eve/src/tools/provided/bash.ts index 7c5211742f..d211ab090b 100644 --- a/packages/eve/src/tools/provided/bash.ts +++ b/packages/eve/src/tools/provided/bash.ts @@ -19,19 +19,39 @@ const YIELD_TIME_SCHEMA = z ) .optional(); +export type BashToolInput = + | { readonly command: string; readonly yieldTimeMs?: number } + | { + readonly action: "poll" | "wait" | "kill"; + readonly processId: string; + readonly yieldTimeMs?: number; + }; + export const BASH_INPUT_SCHEMA = z - .union([ - z.strictObject({ - command: z.string().describe("The shell command to execute."), - yieldTimeMs: YIELD_TIME_SCHEMA, - }), - z.strictObject({ - action: z.enum(["poll", "wait", "kill"]), - processId: z.string().describe("The process id returned by an earlier bash call."), - yieldTimeMs: YIELD_TIME_SCHEMA, - }), - ]) - .meta({ type: "object" }); + .strictObject({ + action: z + .enum(["poll", "wait", "kill"]) + .describe("Follow up on a process id: read output, wait longer, or terminate it.") + .optional(), + command: z.string().describe("A new shell command to execute.").optional(), + processId: z.string().describe("Required with action; returned by an earlier call.").optional(), + yieldTimeMs: YIELD_TIME_SCHEMA, + }) + .superRefine((input, context) => { + const invalid = + input.command === undefined + ? input.action === undefined || input.processId === undefined + : input.action !== undefined || input.processId !== undefined; + if (invalid) { + context.addIssue({ + code: "custom", + message: "Provide either command or both action and processId.", + }); + } + }) + .describe( + "Provide command for a new process, or action and processId for a follow-up.", + ) as z.ZodType; const BASH_OUTPUT_FIELDS = { stderr: z.string(), @@ -56,7 +76,6 @@ export const BASH_OUTPUT_SCHEMA = z.discriminatedUnion("status", [ }), ]); -export type BashToolInput = z.infer; export type BashToolOutput = z.infer; export async function executeBashTool( From d3ef0d70a8ebce274673bb2b7d231c11cbea37e4 Mon Sep 17 00:00:00 2001 From: Colton Padden Date: Tue, 25 Aug 2026 23:51:38 -0400 Subject: [PATCH 10/17] fix(eve): require bash action in model schema Signed-off-by: Colton Padden --- packages/eve/src/tools/provided/bash.test.ts | 2 +- packages/eve/src/tools/provided/bash.ts | 31 ++++++++++++-------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/packages/eve/src/tools/provided/bash.test.ts b/packages/eve/src/tools/provided/bash.test.ts index 566172544c..32811828d4 100644 --- a/packages/eve/src/tools/provided/bash.test.ts +++ b/packages/eve/src/tools/provided/bash.test.ts @@ -7,7 +7,7 @@ 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({ type: "object" }); + expect(schema).toMatchObject({ required: ["action"], type: "object" }); expect(schema).not.toHaveProperty("anyOf"); }); diff --git a/packages/eve/src/tools/provided/bash.ts b/packages/eve/src/tools/provided/bash.ts index d211ab090b..b3e02f32d2 100644 --- a/packages/eve/src/tools/provided/bash.ts +++ b/packages/eve/src/tools/provided/bash.ts @@ -20,7 +20,7 @@ const YIELD_TIME_SCHEMA = z .optional(); export type BashToolInput = - | { readonly command: string; readonly yieldTimeMs?: number } + | { readonly action?: "run"; readonly command: string; readonly yieldTimeMs?: number } | { readonly action: "poll" | "wait" | "kill"; readonly processId: string; @@ -30,28 +30,33 @@ export type BashToolInput = export const BASH_INPUT_SCHEMA = z .strictObject({ action: z - .enum(["poll", "wait", "kill"]) - .describe("Follow up on a process id: read output, wait longer, or terminate it.") + .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.") + .optional(), + processId: z + .string() + .describe("Required with action poll, wait, or kill: the id returned by an earlier call.") .optional(), - command: z.string().describe("A new shell command to execute.").optional(), - processId: z.string().describe("Required with action; returned by an earlier call.").optional(), yieldTimeMs: YIELD_TIME_SCHEMA, }) .superRefine((input, context) => { const invalid = - input.command === undefined - ? input.action === undefined || input.processId === undefined - : input.action !== undefined || input.processId !== undefined; + input.action === "run" + ? input.command === undefined || input.processId !== undefined + : input.command !== undefined || input.processId === undefined; if (invalid) { context.addIssue({ code: "custom", - message: "Provide either command or both action and processId.", + message: "Action run requires command; other actions require processId.", }); } }) - .describe( - "Provide command for a new process, or action and processId for a follow-up.", - ) as z.ZodType; + .describe("Choose an action, then provide its command or processId.") + .meta({ required: ["action"] }) as z.ZodType; const BASH_OUTPUT_FIELDS = { stderr: z.string(), @@ -125,7 +130,7 @@ export async function executeBashTool( export const bash: ToolDefinition = defineTool({ description: [ "Run shell commands and manage commands that continue in the background.", - `A new command waits up to ${DEFAULT_BASH_YIELD_TIME_MS} ms by default, then returns a process id if still running.`, + `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, From a374fd07abd8ff187ee5fff5c5ec12bae6710a2e Mon Sep 17 00:00:00 2001 From: Colton Padden Date: Wed, 26 Aug 2026 01:10:57 -0400 Subject: [PATCH 11/17] fix(eve): accept empty unused bash fields Signed-off-by: Colton Padden --- packages/eve/src/tools/provided/bash.test.ts | 3 ++ packages/eve/src/tools/provided/bash.ts | 29 +++++++++++--------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/packages/eve/src/tools/provided/bash.test.ts b/packages/eve/src/tools/provided/bash.test.ts index 32811828d4..6bc1aca843 100644 --- a/packages/eve/src/tools/provided/bash.test.ts +++ b/packages/eve/src/tools/provided/bash.test.ts @@ -34,6 +34,9 @@ describe("bash schemas", () => { ); 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: "" }).success, + ).toBe(true); }); it("distinguishes completed commands from running process receipts", () => { diff --git a/packages/eve/src/tools/provided/bash.ts b/packages/eve/src/tools/provided/bash.ts index b3e02f32d2..010e4640c1 100644 --- a/packages/eve/src/tools/provided/bash.ts +++ b/packages/eve/src/tools/provided/bash.ts @@ -19,13 +19,15 @@ const YIELD_TIME_SCHEMA = z ) .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 } - | { - readonly action: "poll" | "wait" | "kill"; - readonly processId: string; - readonly yieldTimeMs?: number; - }; + | BashProcessToolInput; export const BASH_INPUT_SCHEMA = z .strictObject({ @@ -44,10 +46,10 @@ export const BASH_INPUT_SCHEMA = z yieldTimeMs: YIELD_TIME_SCHEMA, }) .superRefine((input, context) => { + const hasCommand = input.command !== undefined && input.command !== ""; + const hasProcessId = input.processId !== undefined && input.processId !== ""; const invalid = - input.action === "run" - ? input.command === undefined || input.processId !== undefined - : input.command !== undefined || input.processId === undefined; + input.action === "run" ? !hasCommand || hasProcessId : hasCommand || !hasProcessId; if (invalid) { context.addIssue({ code: "custom", @@ -88,15 +90,16 @@ export async function executeBashTool( context: Pick & { readonly abortSignal: AbortSignal }, ): Promise { const sandbox = await context.getSandbox(); - if ("command" in input) { + 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, input.processId); - if (input.action === "kill") { + 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); @@ -105,11 +108,11 @@ export async function executeBashTool( await process.kill(); return { ...formatBashOutput(before.stdout, before.stderr, startedAt), status: "killed" }; } - if (input.action === "wait") { + if (processInput.action === "wait") { await waitForBackgroundBashProcess({ abortSignal: context.abortSignal, process, - yieldTimeMs: input.yieldTimeMs ?? DEFAULT_BASH_YIELD_TIME_MS, + yieldTimeMs: processInput.yieldTimeMs ?? DEFAULT_BASH_YIELD_TIME_MS, }); } const state = await process.read(); From aa2bccaea98818b1d3a8a07f2ca3bfe9410d7b60 Mon Sep 17 00:00:00 2001 From: Colton Padden Date: Wed, 26 Aug 2026 01:31:10 -0400 Subject: [PATCH 12/17] fix(eve): emit strict bash model schema Signed-off-by: Colton Padden --- packages/eve/src/tools/provided/bash.test.ts | 12 ++++++++++-- packages/eve/src/tools/provided/bash.ts | 11 ++++++++--- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/eve/src/tools/provided/bash.test.ts b/packages/eve/src/tools/provided/bash.test.ts index 6bc1aca843..e94f5d4101 100644 --- a/packages/eve/src/tools/provided/bash.test.ts +++ b/packages/eve/src/tools/provided/bash.test.ts @@ -7,7 +7,10 @@ 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"], type: "object" }); + expect(schema).toMatchObject({ + required: ["action", "command", "processId", "yieldTimeMs"], + type: "object", + }); expect(schema).not.toHaveProperty("anyOf"); }); @@ -35,7 +38,12 @@ describe("bash schemas", () => { 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: "" }).success, + BASH_INPUT_SCHEMA.safeParse({ + action: "run", + command: "pwd", + processId: "", + yieldTimeMs: null, + }).success, ).toBe(true); }); diff --git a/packages/eve/src/tools/provided/bash.ts b/packages/eve/src/tools/provided/bash.ts index 010e4640c1..6c0733e821 100644 --- a/packages/eve/src/tools/provided/bash.ts +++ b/packages/eve/src/tools/provided/bash.ts @@ -17,6 +17,7 @@ const YIELD_TIME_SCHEMA = z .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 = { @@ -38,16 +39,18 @@ export const BASH_INPUT_SCHEMA = z 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 = input.command !== undefined && input.command !== ""; - const hasProcessId = input.processId !== undefined && input.processId !== ""; + 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) { @@ -58,7 +61,9 @@ export const BASH_INPUT_SCHEMA = z } }) .describe("Choose an action, then provide its command or processId.") - .meta({ required: ["action"] }) as z.ZodType; + .meta({ + required: ["action", "command", "processId", "yieldTimeMs"], + }) as z.ZodType; const BASH_OUTPUT_FIELDS = { stderr: z.string(), From 991c1f3695c08979f0c0dee6291d2017bbbaa7c5 Mon Sep 17 00:00:00 2001 From: Colton Padden Date: Wed, 26 Aug 2026 01:35:20 -0400 Subject: [PATCH 13/17] test(e2e): isolate prompt cache fixture tools Signed-off-by: Colton Padden --- e2e/fixtures/agent-prompt-cache/agent/tools/bash.ts | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 e2e/fixtures/agent-prompt-cache/agent/tools/bash.ts 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(); From 424610c8d2e7ac02f72607ea97ae446533ece66f Mon Sep 17 00:00:00 2001 From: Colton Padden Date: Wed, 26 Aug 2026 10:50:48 -0400 Subject: [PATCH 14/17] fix(eve): finalize yielded bash processes Signed-off-by: Colton Padden --- packages/eve/src/execution/sandbox/bash.test.ts | 11 +++++++---- packages/eve/src/execution/sandbox/bash.ts | 12 ++++++++++-- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/eve/src/execution/sandbox/bash.test.ts b/packages/eve/src/execution/sandbox/bash.test.ts index 2582c0d9ed..59a731ca96 100644 --- a/packages/eve/src/execution/sandbox/bash.test.ts +++ b/packages/eve/src/execution/sandbox/bash.test.ts @@ -102,14 +102,15 @@ describe("executeBashOnSandbox", () => { }); describe("background bash processes", () => { - it("launches a process group behind the process cap", async () => { + it("launches an isolated command behind the process cap", async () => { const session = sandbox(); - const process = await startBackgroundBashProcess(session, "pnpm test"); + const process = await startBackgroundBashProcess(session, "exit 7"); const command = vi.mocked(session.run).mock.calls[0]?.[0].command; expect(process.processId).toMatch(/^[0-9a-f-]{36}$/); expect(command).toContain("set -m 2>/dev/null || true"); expect(command).toContain(`-ge ${MAX_BACKGROUND_BASH_PROCESSES}`); + expect(command).toContain("( ( eval 'exit 7' ); code=$?"); }); it("rejects when the process cap is reached", async () => { @@ -133,12 +134,14 @@ describe("background bash processes", () => { ).resolves.toEqual({ exitCode: 7, stderr: "err", stdout: "out" }); }); - it("removes process state after killing", async () => { + it("removes process state even when the process already exited", async () => { const session = sandbox(); await getBackgroundBashProcess(session, "11111111-1111-4111-8111-111111111111").kill(); - expect(vi.mocked(session.run).mock.calls[0]?.[0].command).toContain("&& rm -rf"); + const command = vi.mocked(session.run).mock.calls[0]?.[0].command; + expect(command).toContain('if kill -0 -- -"$pid"'); + expect(command).toContain("\nrm -rf"); }); it("rejects missing process state", async () => { diff --git a/packages/eve/src/execution/sandbox/bash.ts b/packages/eve/src/execution/sandbox/bash.ts index c8041dd2e3..861c428f53 100644 --- a/packages/eve/src/execution/sandbox/bash.ts +++ b/packages/eve/src/execution/sandbox/bash.ts @@ -106,7 +106,7 @@ export async function startBackgroundBashProcess( `if [ "$(ls ${quotedRoot} | wc -l)" -ge ${MAX_BACKGROUND_BASH_PROCESSES} ]; then echo ${PROCESS_LIMIT_MARKER} >&2; exit 75; fi`, `mkdir -p ${shellQuote(directory)}`, `set -m 2>/dev/null || true`, - `( eval ${shellQuote(command)}; code=$?; printf '%s' "$code" > ${shellQuote(`${directory}/exit-code.tmp`)} && mv ${shellQuote(`${directory}/exit-code.tmp`)} ${shellQuote(`${directory}/exit-code`)} ) > ${shellQuote(`${directory}/stdout`)} 2> ${shellQuote(`${directory}/stderr`)} &`, + `( ( eval ${shellQuote(command)} ); code=$?; printf '%s' "$code" > ${shellQuote(`${directory}/exit-code.tmp`)} && mv ${shellQuote(`${directory}/exit-code.tmp`)} ${shellQuote(`${directory}/exit-code`)} ) > ${shellQuote(`${directory}/stdout`)} 2> ${shellQuote(`${directory}/stderr`)} &`, `printf '%s' "$!" > ${shellQuote(`${directory}/pid`)}`, ].join("\n"); const result = await sandbox.run({ command: launch }); @@ -166,7 +166,15 @@ function backgroundBashProcess(sandbox: SandboxSession, processId: string): Back async kill() { const quotedDirectory = shellQuote(directory); const result = await sandbox.run({ - command: `pid=$(cat ${shellQuote(`${directory}/pid`)}) && [ "$pid" -gt 0 ] && { kill -- -"$pid" 2>/dev/null || kill "$pid" 2>/dev/null; } && { sleep 0.1; kill -KILL -- -"$pid" 2>/dev/null || kill -KILL "$pid" 2>/dev/null || true; } && rm -rf ${quotedDirectory}`, + command: [ + `pid=$(cat ${shellQuote(`${directory}/pid`)}) && [ "$pid" -gt 0 ] || exit 1`, + `if kill -0 -- -"$pid" 2>/dev/null || kill -0 "$pid" 2>/dev/null; then`, + ` kill -- -"$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true`, + ` sleep 0.1`, + ` kill -KILL -- -"$pid" 2>/dev/null || kill -KILL "$pid" 2>/dev/null || true`, + `fi`, + `rm -rf ${quotedDirectory}`, + ].join("\n"), }); if (result.exitCode !== 0) { throw new Error(`Bash process "${processId}" could not be killed by this sandbox backend.`); From 4683b7f05f5ba9cc7e2ecaab598a775f8b891ce2 Mon Sep 17 00:00:00 2001 From: Colton Padden Date: Wed, 26 Aug 2026 10:55:54 -0400 Subject: [PATCH 15/17] refactor(eve): manage bash termination in framework Signed-off-by: Colton Padden --- .../eve/src/execution/sandbox/bash.test.ts | 19 +++++--- packages/eve/src/execution/sandbox/bash.ts | 46 +++++++++++++------ 2 files changed, 46 insertions(+), 19 deletions(-) diff --git a/packages/eve/src/execution/sandbox/bash.test.ts b/packages/eve/src/execution/sandbox/bash.test.ts index 59a731ca96..0669969a2b 100644 --- a/packages/eve/src/execution/sandbox/bash.test.ts +++ b/packages/eve/src/execution/sandbox/bash.test.ts @@ -83,7 +83,7 @@ describe("executeBashOnSandbox", () => { }); it("kills when cancelled", async () => { - const session = sandbox(); + const session = sandbox(processFiles({})); const cancelled = new DOMException("cancelled", "AbortError"); await expect( @@ -93,7 +93,11 @@ describe("executeBashOnSandbox", () => { { abortSignal: AbortSignal.abort(cancelled) }, ), ).rejects.toBe(cancelled); - expect(session.run).toHaveBeenCalledTimes(2); + expect(session.removePath).toHaveBeenCalledWith({ + force: true, + path: expect.stringContaining("/.eve/processes/"), + recursive: true, + }); }); it("uses the default foreground wait", () => { @@ -135,13 +139,16 @@ describe("background bash processes", () => { }); it("removes process state even when the process already exited", async () => { - const session = sandbox(); + const session = sandbox(processFiles({})); + vi.mocked(session.run).mockResolvedValue({ exitCode: 1, stderr: "", stdout: "" }); await getBackgroundBashProcess(session, "11111111-1111-4111-8111-111111111111").kill(); - const command = vi.mocked(session.run).mock.calls[0]?.[0].command; - expect(command).toContain('if kill -0 -- -"$pid"'); - expect(command).toContain("\nrm -rf"); + expect(session.removePath).toHaveBeenCalledWith({ + force: true, + path: "/workspace/.eve/processes/11111111-1111-4111-8111-111111111111", + recursive: true, + }); }); it("rejects missing process state", async () => { diff --git a/packages/eve/src/execution/sandbox/bash.ts b/packages/eve/src/execution/sandbox/bash.ts index 861c428f53..0b730edfd2 100644 --- a/packages/eve/src/execution/sandbox/bash.ts +++ b/packages/eve/src/execution/sandbox/bash.ts @@ -164,25 +164,45 @@ function backgroundBashProcess(sandbox: SandboxSession, processId: string): Back return await readBackgroundBashProcessStatus(sandbox, processId, directory); }, async kill() { - const quotedDirectory = shellQuote(directory); - const result = await sandbox.run({ - command: [ - `pid=$(cat ${shellQuote(`${directory}/pid`)}) && [ "$pid" -gt 0 ] || exit 1`, - `if kill -0 -- -"$pid" 2>/dev/null || kill -0 "$pid" 2>/dev/null; then`, - ` kill -- -"$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true`, - ` sleep 0.1`, - ` kill -KILL -- -"$pid" 2>/dev/null || kill -KILL "$pid" 2>/dev/null || true`, - `fi`, - `rm -rf ${quotedDirectory}`, - ].join("\n"), - }); - if (result.exitCode !== 0) { + 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 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, + 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.`); + } +} + async function readBackgroundBashProcessStatus( sandbox: SandboxSession, processId: string, From 8fd77b71337bbb42eba5b5468908d64b6ef1cc7d Mon Sep 17 00:00:00 2001 From: Colton Padden Date: Wed, 26 Aug 2026 11:00:32 -0400 Subject: [PATCH 16/17] refactor(eve): separate bash process launch steps Signed-off-by: Colton Padden --- .../eve/src/execution/sandbox/bash.test.ts | 11 +-- packages/eve/src/execution/sandbox/bash.ts | 70 ++++++++++++++----- 2 files changed, 60 insertions(+), 21 deletions(-) diff --git a/packages/eve/src/execution/sandbox/bash.test.ts b/packages/eve/src/execution/sandbox/bash.test.ts index 0669969a2b..f7d1d399ed 100644 --- a/packages/eve/src/execution/sandbox/bash.test.ts +++ b/packages/eve/src/execution/sandbox/bash.test.ts @@ -79,7 +79,7 @@ describe("executeBashOnSandbox", () => { await expect(executeBashOnSandbox(session, { command: "build" })).rejects.toThrow( "read failed", ); - expect(session.run).toHaveBeenCalledOnce(); + expect(session.run).toHaveBeenCalledTimes(2); }); it("kills when cancelled", async () => { @@ -109,12 +109,13 @@ 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 command = vi.mocked(session.run).mock.calls[0]?.[0].command; + 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(command).toContain("set -m 2>/dev/null || true"); - expect(command).toContain(`-ge ${MAX_BACKGROUND_BASH_PROCESSES}`); - expect(command).toContain("( ( eval 'exit 7' ); code=$?"); + 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 () => { diff --git a/packages/eve/src/execution/sandbox/bash.ts b/packages/eve/src/execution/sandbox/bash.ts index 0b730edfd2..420221d960 100644 --- a/packages/eve/src/execution/sandbox/bash.ts +++ b/packages/eve/src/execution/sandbox/bash.ts @@ -99,29 +99,67 @@ export async function startBackgroundBashProcess( ): Promise { const processId = randomUUID(); const directory = `${PROCESS_ROOT}/${processId}`; - const quotedRoot = shellQuote(PROCESS_ROOT); - const launch = [ - `mkdir -p ${quotedRoot}`, - `if [ "$(ls ${quotedRoot} | wc -l)" -ge ${MAX_BACKGROUND_BASH_PROCESSES} ]; then for d in ${quotedRoot}/*/; do [ -f "$d/exit-code" ] && rm -rf "$d"; done; fi`, - `if [ "$(ls ${quotedRoot} | wc -l)" -ge ${MAX_BACKGROUND_BASH_PROCESSES} ]; then echo ${PROCESS_LIMIT_MARKER} >&2; exit 75; fi`, - `mkdir -p ${shellQuote(directory)}`, - `set -m 2>/dev/null || true`, - `( ( eval ${shellQuote(command)} ); code=$?; printf '%s' "$code" > ${shellQuote(`${directory}/exit-code.tmp`)} && mv ${shellQuote(`${directory}/exit-code.tmp`)} ${shellQuote(`${directory}/exit-code`)} ) > ${shellQuote(`${directory}/stdout`)} 2> ${shellQuote(`${directory}/stderr`)} &`, - `printf '%s' "$!" > ${shellQuote(`${directory}/pid`)}`, - ].join("\n"); - const result = await sandbox.run({ command: launch }); + await reserveBackgroundProcessDirectory(sandbox, directory); + + const result = await sandbox.run({ + command: buildBackgroundLaunchCommand(command, directory), + }); if (result.exitCode !== 0) { - 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.`, - ); - } + 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"); +} + +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}`); +} + export function getBackgroundBashProcess( sandbox: SandboxSession, processId: string, From 69f829058fa0f29bc4c2066a1b9745d8a9b6bb6d Mon Sep 17 00:00:00 2001 From: Colton Padden Date: Wed, 26 Aug 2026 11:08:25 -0400 Subject: [PATCH 17/17] fix(eve): preserve bash output behavior Signed-off-by: Colton Padden --- .changeset/bash-command-timeout.md | 2 +- docs/concepts/built-in-tools.md | 2 +- .../eve/src/execution/sandbox/bash.test.ts | 39 ++++++- packages/eve/src/execution/sandbox/bash.ts | 108 +++++++++++++----- .../execution/sandbox/truncate-output.test.ts | 25 ---- .../src/execution/sandbox/truncate-output.ts | 68 ----------- 6 files changed, 121 insertions(+), 123 deletions(-) diff --git a/.changeset/bash-command-timeout.md b/.changeset/bash-command-timeout.md index 958f69dc6c..6641cee19d 100644 --- a/.changeset/bash-command-timeout.md +++ b/.changeset/bash-command-timeout.md @@ -2,4 +2,4 @@ "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 report wall time and long output preserves its head and tail. +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 d870477bc3..2020908971 100644 --- a/docs/concepts/built-in-tools.md +++ b/docs/concepts/built-in-tools.md @@ -33,7 +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`, long output keeps its beginning and end with the middle omitted, 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. +- **`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/packages/eve/src/execution/sandbox/bash.test.ts b/packages/eve/src/execution/sandbox/bash.test.ts index f7d1d399ed..2e39869d00 100644 --- a/packages/eve/src/execution/sandbox/bash.test.ts +++ b/packages/eve/src/execution/sandbox/bash.test.ts @@ -1,16 +1,30 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EVE_DEV_ENV_FLAG } from "#internal/application/optional-package-install.js"; import type { SandboxSession } from "#shared/sandbox-session.js"; +import { MAX_OUTPUT_LINES } from "#execution/sandbox/truncate-output.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", @@ -100,11 +114,34 @@ describe("executeBashOnSandbox", () => { }); }); + it("logs command progress in development", async () => { + process.env[EVE_DEV_ENV_FLAG] = "1"; + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + 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(); diff --git a/packages/eve/src/execution/sandbox/bash.ts b/packages/eve/src/execution/sandbox/bash.ts index 420221d960..1f73cc8b4f 100644 --- a/packages/eve/src/execution/sandbox/bash.ts +++ b/packages/eve/src/execution/sandbox/bash.ts @@ -2,8 +2,10 @@ import { randomUUID } from "node:crypto"; import type { SandboxSession } from "#shared/sandbox-session.js"; import { shellQuote } from "#execution/sandbox/shell-quote.js"; -import { truncateHeadTail } from "#execution/sandbox/truncate-output.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"; @@ -56,41 +58,68 @@ export interface BackgroundBashProcessState extends BackgroundBashProcessStatus readonly stdout: string; } -/** Starts one shell command and yields it to the background after the foreground wait. */ +/** + * Executes one shell command inside the agent's sandbox. + * + * 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 so all bash-style tools share one result shape and lifecycle. + */ export async function executeBashOnSandbox( sandbox: SandboxSession, args: BashInput, options?: BashExecuteOptions, ): Promise { const startedAt = Date.now(); - const process = await startBackgroundBashProcess(sandbox, args.command); + const commandLabel = formatCommand(args.command); + logDevelopmentSandboxCommand(`eve: starting sandbox command: ${commandLabel}`); + const progressTimer = startDevelopmentProgressTimer(commandLabel, startedAt); + try { - await waitForBackgroundBashProcess({ - abortSignal: options?.abortSignal, - process, - yieldTimeMs: args.yieldTimeMs ?? DEFAULT_BASH_YIELD_TIME_MS, - }); - } catch (error) { - if (!options?.abortSignal?.aborted) { - throw error; - } + const process = await startBackgroundBashProcess(sandbox, args.command); try { - await process.kill(); - } catch (killError) { - throw new AggregateError( - [error, killError], - "The bash command was cancelled but could not be killed.", - { cause: error }, - ); + 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); } - - const observed = await process.read(); - const output = formatBashOutput(observed.stdout, observed.stderr, startedAt); - return observed.exitCode === undefined - ? { ...output, processId: process.processId, status: "running" } - : { ...output, exitCode: observed.exitCode, status: "completed" }; } export async function startBackgroundBashProcess( @@ -275,8 +304,8 @@ export function formatBashOutput( stderrValue: string, startedAt: number, ): BashOutput { - const stdoutResult = truncateHeadTail(stdoutValue); - const stderrResult = truncateHeadTail(stderrValue); + const stdoutResult = truncateTail(stdoutValue); + const stderrResult = truncateTail(stderrValue); return { stderr: stderrResult.output, stdout: stdoutResult.output, @@ -288,3 +317,28 @@ export function formatBashOutput( 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) / 1_000); + logDevelopmentSandboxCommand( + `eve: waiting for sandbox command (${elapsedSeconds}s elapsed): ${command}`, + ); + }, 5_000); + timer.unref?.(); + return timer; +} + +function logDevelopmentSandboxCommand(message: string): void { + 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; + return `${singleLine.slice(0, MAX_LOG_COMMAND_LENGTH - 1)}…`; +} diff --git a/packages/eve/src/execution/sandbox/truncate-output.test.ts b/packages/eve/src/execution/sandbox/truncate-output.test.ts index 7eca29f8d0..21beabacd8 100644 --- a/packages/eve/src/execution/sandbox/truncate-output.test.ts +++ b/packages/eve/src/execution/sandbox/truncate-output.test.ts @@ -5,7 +5,6 @@ import { MAX_OUTPUT_BYTES, MAX_OUTPUT_LINES, truncateHead, - truncateHeadTail, truncateTail, } from "#execution/sandbox/truncate-output.js"; @@ -90,27 +89,3 @@ describe("truncateHead", () => { expect(lines[0]).toContain("[truncated]"); }); }); - -describe("truncateHeadTail", () => { - it("returns small text unchanged", () => { - const result = truncateHeadTail("start\nend"); - expect(result).toEqual({ - output: "start\nend", - outputLines: 2, - totalLines: 2, - truncated: false, - }); - }); - - it("keeps both ends of long output and marks the omitted middle", () => { - const lines = Array.from({ length: MAX_OUTPUT_LINES * 2 }, (_, i) => `line ${i}`); - const result = truncateHeadTail(lines.join("\n")); - - expect(result.truncated).toBe(true); - expect(result.output).toContain("line 0"); - expect(result.output).toContain(`line ${MAX_OUTPUT_LINES * 2 - 1}`); - expect(result.output).toMatch(/\[\.\.\. \d+ lines omitted \.\.\.\]/); - expect(result.output.split("\n")).toHaveLength(MAX_OUTPUT_LINES); - expect(Buffer.byteLength(result.output, "utf8")).toBeLessThanOrEqual(MAX_OUTPUT_BYTES); - }); -}); diff --git a/packages/eve/src/execution/sandbox/truncate-output.ts b/packages/eve/src/execution/sandbox/truncate-output.ts index 0f615c897f..c6c590808e 100644 --- a/packages/eve/src/execution/sandbox/truncate-output.ts +++ b/packages/eve/src/execution/sandbox/truncate-output.ts @@ -72,74 +72,6 @@ export function truncateTail(text: string): TruncationResult { return truncateByDirection(text, "tail"); } -/** - * Keeps the **first and last** lines of `text` within the shared - * budgets, dropping the middle. Long command output is informative at - * both ends — startup banners and configuration at the head, errors - * and summaries at the tail — so each end receives half of the line - * and byte budgets. An omission marker line replaces the dropped - * middle. - */ -export function truncateHeadTail(text: string): TruncationResult { - const full = truncateHead(text); - if (!full.truncated) { - return full; - } - - const rawLines = text.split("\n"); - if (rawLines.at(-1) === "") rawLines.pop(); - const totalLines = rawLines.length; - const markerBudget = Buffer.byteLength(`[... ${totalLines} lines omitted ...]`, "utf8"); - const lineBudget = MAX_OUTPUT_LINES - 1; - const byteBudget = MAX_OUTPUT_BYTES - markerBudget; - const head = collectLines(rawLines, "head", Math.ceil(lineBudget / 2), Math.ceil(byteBudget / 2)); - const tail = collectLines( - rawLines, - "tail", - Math.floor(lineBudget / 2), - Math.floor(byteBudget / 2), - ); - const omitted = totalLines - head.length - tail.length; - if (omitted <= 0) { - return full; - } - - return { - output: [...head, `[... ${omitted} lines omitted ...]`, ...tail].join("\n"), - outputLines: head.length + tail.length, - totalLines, - truncated: true, - }; -} - -function collectLines( - rawLines: readonly string[], - direction: "head" | "tail", - maxLines: number, - maxBytes: number, -): string[] { - const fromStart = direction === "head"; - const kept: string[] = []; - let bytes = 0; - - const start = fromStart ? 0 : rawLines.length - 1; - const step = fromStart ? 1 : -1; - for (let i = start; i >= 0 && i < rawLines.length && kept.length < maxLines; i += step) { - const line = capLineLength(rawLines[i] ?? ""); - const lineBytes = Buffer.byteLength(line, "utf8") + 1; - if (bytes + lineBytes > maxBytes && kept.length > 0) { - break; - } - kept.push(line); - bytes += lineBytes; - } - - if (!fromStart) { - kept.reverse(); - } - return kept; -} - /** * Shared truncation loop used by {@link truncateHead} and * {@link truncateTail}. The only difference between the two is the