diff --git a/.changeset/bash-command-timeout.md b/.changeset/bash-command-timeout.md new file mode 100644 index 0000000000..663edc3137 --- /dev/null +++ b/.changeset/bash-command-timeout.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Run built-in `bash` commands in the foreground for 30 seconds 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, wait for up to five minutes by default, or kill; results also report wall time. diff --git a/docs/concepts/built-in-tools.md b/docs/concepts/built-in-tools.md index 3c33601639..38b024c396 100644 --- a/docs/concepts/built-in-tools.md +++ b/docs/concepts/built-in-tools.md @@ -13,7 +13,7 @@ The default shell and file tools (`bash`, `read_file`, and `write_file`) run in | Tool | Does | Where it runs | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | -| `bash` | Run a shell command. | Sandbox | +| `bash` | Run a shell command. After `yieldTimeMs` milliseconds (default 30000), a command still running continues in the background and returns a process id. | Sandbox | | `read_file` | Read a text file with line-numbered output (enables read-before-write). | Sandbox FS | | `write_file` | Write a complete file; enforces read-before-write and stale-read detection. | Sandbox FS | | `web_fetch` | Fetch a URL. | App runtime | @@ -33,6 +33,7 @@ Notes: - **`connection_search`** surfaces a connection's tools by their qualified name (e.g. `linear__list_issues`), which the model can then call directly. The model sees it only when the agent has connections. - **`web_search`** has no local executor; the provider runs it. AI Gateway models use Exa by default. To use Parallel instead, export `webSearch({ provider: "parallel" })` from `agent/tools/web_search.ts`. Direct provider models continue to use their native search implementation. To supply your own implementation, override it with `defineTool()`. - **`web_fetch`** follows up to ten redirects, rechecking every destination for SSRF safety. Non-success HTTP responses return a plain-text failure result with the response body when available instead of failing the tool call. +- **`bash`** accepts a returned process id through the same tool: use `poll` to read current output, `wait` to wait for another bounded foreground interval (five minutes by default), or `kill` to stop it. Every result reports `wallTimeSeconds`, and one eve runtime tracks at most 64 commands per sandbox (completed command state is reclaimed first). Vercel commands can be reattached after the app runtime relocates; other backends report an unavailable process id when their runtime-local handle is lost. An unavailable process id is reported rather than replayed. Review these default tools before production use. Disable, wrap, restrict, or require approval for any tool that can access the filesystem, network, shell, or sensitive data. diff --git a/e2e/fixtures/agent-prompt-cache/agent/tools/bash.ts b/e2e/fixtures/agent-prompt-cache/agent/tools/bash.ts new file mode 100644 index 0000000000..04bd054473 --- /dev/null +++ b/e2e/fixtures/agent-prompt-cache/agent/tools/bash.ts @@ -0,0 +1,3 @@ +import { disableTool } from "eve/tools"; + +export default disableTool(); diff --git a/packages/eve/src/execution/sandbox/bash.test.ts b/packages/eve/src/execution/sandbox/bash.test.ts index 3febee27b0..81d3f227e3 100644 --- a/packages/eve/src/execution/sandbox/bash.test.ts +++ b/packages/eve/src/execution/sandbox/bash.test.ts @@ -1,59 +1,269 @@ 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 { SandboxProcess, SandboxSession } from "#shared/sandbox-session.js"; +import { MAX_OUTPUT_LINES } from "#execution/sandbox/truncate-output.js"; -import { executeBashOnSandbox } from "./bash.js"; +import { + DEFAULT_BASH_RUN_YIELD_TIME_MS, + DEFAULT_BASH_WAIT_YIELD_TIME_MS, + executeBashOnSandbox, + formatBashOutput, + getBackgroundBashProcess, + MAX_BACKGROUND_BASH_PROCESSES, + startBackgroundBashProcess, + waitForBackgroundBashProcess, +} from "./bash.js"; -describe("executeBashOnSandbox", () => { - const previousDevFlag = process.env[EVE_DEV_ENV_FLAG]; +const previousDevFlag = process.env[EVE_DEV_ENV_FLAG]; +let sandboxId = 0; + +afterEach(() => { + if (previousDevFlag === undefined) { + delete process.env[EVE_DEV_ENV_FLAG]; + } else { + process.env[EVE_DEV_ENV_FLAG] = previousDevFlag; + } + vi.restoreAllMocks(); +}); - afterEach(() => { - if (previousDevFlag === undefined) { - delete process.env[EVE_DEV_ENV_FLAG]; - } else { - process.env[EVE_DEV_ENV_FLAG] = previousDevFlag; - } - vi.restoreAllMocks(); +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; }); + return { promise, reject, resolve }; +} - 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({ - exitCode: 0, - stderr: "", - stdout: "weather-codes.md\n", - }); +function outputStream(value: string, error?: unknown): ReadableStream { + return new ReadableStream({ + start(controller) { + if (error !== undefined) { + controller.error(error); + return; + } + if (value !== "") controller.enqueue(new TextEncoder().encode(value)); + controller.close(); + }, + }); +} + +function sandboxProcess(input?: { + readonly exitCode?: number; + readonly outputError?: unknown; + readonly running?: boolean; + readonly stderr?: string; + readonly stdout?: string; +}): SandboxProcess { + const completion = deferred<{ exitCode: number }>(); + if (input?.running !== true) completion.resolve({ exitCode: input?.exitCode ?? 0 }); + return { + stderr: outputStream(input?.stderr ?? ""), + stdout: outputStream(input?.stdout ?? "", input?.outputError), + wait: vi.fn(() => completion.promise), + kill: vi.fn(async () => completion.resolve({ exitCode: 143 })), + }; +} + +function sandbox(createProcess: () => SandboxProcess = () => sandboxProcess()): SandboxSession { + return { + id: `sandbox-${sandboxId++}`, + 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 () => createProcess()), + writeBinaryFile: vi.fn(async () => {}), + writeFile: vi.fn(async () => {}), + writeTextFile: vi.fn(async () => {}), + }; +} - const result = await executeBashOnSandbox(sandbox, { command: "ls -la /workspace" }); +describe("executeBashOnSandbox", () => { + it("returns completed output", async () => { + const session = sandbox(() => sandboxProcess({ stdout: "done\n" })); - expect(result).toEqual({ + await expect(executeBashOnSandbox(session, { command: "build" })).resolves.toEqual({ exitCode: 0, + status: "completed", stderr: "", - stdout: "weather-codes.md\n", + stdout: "done\n", truncated: false, + wallTimeSeconds: expect.any(Number), + }); + }); + + it("attaches output readers before waiting for completion", async () => { + const stdout = outputStream("captured"); + const commandProcess = sandboxProcess(); + Object.defineProperty(commandProcess, "stdout", { value: stdout }); + vi.mocked(commandProcess.wait).mockImplementation(async () => { + expect(stdout.locked).toBe(true); + return { exitCode: 0 }; }); - expect(log).toHaveBeenCalledWith("eve: starting sandbox command: ls -la /workspace"); - expect(log).toHaveBeenCalledWith("eve: sandbox command finished (exit 0): ls -la /workspace"); + const session = sandbox(() => commandProcess); + + await expect(executeBashOnSandbox(session, { command: "build" })).resolves.toMatchObject({ + stdout: "captured", + }); + }); + + it("yields a running command", async () => { + const session = sandbox(() => sandboxProcess({ running: true, stdout: "partial" })); + + await expect( + executeBashOnSandbox(session, { command: "build", yieldTimeMs: 0 }), + ).resolves.toMatchObject({ status: "running" }); + }); + + it("does not kill after an observation failure", async () => { + const process = sandboxProcess({ outputError: new Error("read failed") }); + const session = sandbox(() => process); + + await expect(executeBashOnSandbox(session, { command: "build" })).rejects.toThrow( + "read failed", + ); + expect(process.kill).not.toHaveBeenCalled(); + }); + + it("kills when cancelled", async () => { + const process = sandboxProcess({ running: true }); + const session = sandbox(() => process); + const cancelled = new DOMException("cancelled", "AbortError"); + + await expect( + executeBashOnSandbox( + session, + { command: "build" }, + { abortSignal: AbortSignal.abort(cancelled) }, + ), + ).rejects.toBe(cancelled); + expect(process.kill).toHaveBeenCalledOnce(); + }); + + it("logs command progress in development", async () => { + process.env[EVE_DEV_ENV_FLAG] = "1"; + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const session = sandbox(); + + 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 a short run yield and a longer follow-up wait", () => { + expect(DEFAULT_BASH_RUN_YIELD_TIME_MS).toBe(30_000); + expect(DEFAULT_BASH_WAIT_YIELD_TIME_MS).toBe(300_000); }); }); -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 () => {}, - }; -} +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("spawns the command through the sandbox process API", async () => { + const session = sandbox(); + const process = await startBackgroundBashProcess(session, "exit 7"); + + expect(process.commandId).toMatch(/^[0-9a-f-]{36}$/); + expect(session.spawn).toHaveBeenCalledWith({ command: "exit 7" }); + expect(session.run).not.toHaveBeenCalled(); + }); + + it("reuses a command when the durable tool call is retried", async () => { + const session = sandbox(() => sandboxProcess({ running: true })); + + const [first, retried] = await Promise.all([ + startBackgroundBashProcess(session, "sleep 10", "call-1"), + startBackgroundBashProcess(session, "sleep 10", "call-1"), + ]); + + expect(retried.commandId).toBe(first.commandId); + expect(session.spawn).toHaveBeenCalledOnce(); + }); + + it("rejects when the process cap is reached", async () => { + const session = sandbox(() => sandboxProcess({ running: true })); + await Promise.all( + Array.from({ length: MAX_BACKGROUND_BASH_PROCESSES }, () => + startBackgroundBashProcess(session, "sleep 10"), + ), + ); + + await expect(startBackgroundBashProcess(session, "sleep 10")).rejects.toThrow( + `This sandbox already tracks ${MAX_BACKGROUND_BASH_PROCESSES} running commands.`, + ); + }); + + it("reads completed process state", async () => { + const session = sandbox(() => sandboxProcess({ exitCode: 7, stderr: "err", stdout: "out" })); + const started = await startBackgroundBashProcess(session, "build"); + await vi.waitFor(async () => { + await expect(started.inspectStatus()).resolves.toEqual({ exitCode: 7 }); + }); + + await expect( + (await getBackgroundBashProcess(session, started.commandId)).inspect(), + ).resolves.toEqual({ + exitCode: 7, + stderr: "err", + stdout: "out", + truncated: false, + }); + }); + + it("removes a killed process from the registry", async () => { + const handle = sandboxProcess({ running: true }); + const session = sandbox(() => handle); + const process = await startBackgroundBashProcess(session, "sleep 10"); + + await process.terminate(); + + expect(handle.kill).toHaveBeenCalledOnce(); + await expect(getBackgroundBashProcess(session, process.commandId)).rejects.toThrow( + "unavailable", + ); + }); + + it("rejects unavailable process state", async () => { + const session = sandbox(); + + await expect( + getBackgroundBashProcess(session, "11111111-1111-4111-8111-111111111111"), + ).rejects.toThrow("unavailable"); + }); + + it("polls status without reading output", async () => { + const inspect = vi.fn(); + const inspectStatus = vi.fn(async () => ({})); + + await expect( + waitForBackgroundBashProcess({ + process: { + commandId: "process", + inspect, + inspectStatus, + terminate: vi.fn(), + }, + yieldTimeMs: 0, + }), + ).resolves.toBeNull(); + expect(inspectStatus).toHaveBeenCalledOnce(); + expect(inspect).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/eve/src/execution/sandbox/bash.ts b/packages/eve/src/execution/sandbox/bash.ts index 4ea926c3ce..ae84eee2d4 100644 --- a/packages/eve/src/execution/sandbox/bash.ts +++ b/packages/eve/src/execution/sandbox/bash.ts @@ -1,125 +1,202 @@ +import { randomUUID } from "node:crypto"; + import type { SandboxSession } from "#shared/sandbox-session.js"; +import { + getManagedSandboxCommands, + MAX_MANAGED_SANDBOX_COMMANDS, + type ManagedSandboxCommand, +} from "#execution/sandbox/managed-command.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 POLL_INTERVAL_MS = 250; -// --------------------------------------------------------------------------- -// Input shape -// --------------------------------------------------------------------------- +export const DEFAULT_BASH_RUN_YIELD_TIME_MS = 30_000; +export const DEFAULT_BASH_WAIT_YIELD_TIME_MS = 300_000; +export const MAX_BACKGROUND_BASH_PROCESSES = MAX_MANAGED_SANDBOX_COMMANDS; -/** - * Typed input accepted by {@link executeBashOnSandbox}. - */ export interface BashInput { readonly command: string; + readonly yieldTimeMs?: number; } -// --------------------------------------------------------------------------- -// Result shape -// --------------------------------------------------------------------------- +export interface BashExecuteOptions { + readonly abortSignal?: AbortSignal; + readonly idempotencyKey?: string; +} -/** - * Structured result returned from {@link executeBashOnSandbox}. - */ -export interface BashResult { - readonly exitCode: number; +export type BashResult = BashOutput & + ( + | { readonly exitCode: number; readonly status: "completed" } + | { readonly processId: string; readonly status: "running" } + ); + +export interface BashOutput { readonly stderr: string; readonly stdout: string; - /** True when stdout or stderr was shortened to fit within output limits. */ readonly truncated: boolean; + /** Elapsed wall time this call spent before returning, in seconds. */ + readonly wallTimeSeconds: number; } -// --------------------------------------------------------------------------- -// Executor -// --------------------------------------------------------------------------- - /** - * Executes one shell command inside the agent's sandbox via `SandboxKey` - * on the active runtime context. + * Executes one shell command inside the agent's sandbox. * - * Both stdout and stderr are tail-truncated to keep the end of the output - * (where errors and final results typically appear) within the shared - * {@link MAX_OUTPUT_LINES} / {@link MAX_OUTPUT_BYTES} limits. + * The command waits in the foreground for a bounded interval, then continues + * in the background when still running. Both stdout and stderr are + * tail-truncated because errors and final results typically appear at the end. * - * Used by the framework `bash` tool and authored wrappers around its - * exported definition. Centralizing the executor here keeps the error - * messages and result shape identical across all bash-style tools. + * Used by the framework `bash` tool and authored wrappers around its exported + * definition so all bash-style tools share one result shape and lifecycle. */ export async function executeBashOnSandbox( sandbox: SandboxSession, args: BashInput, + options?: BashExecuteOptions, ): Promise { - const raw = await runWithDevelopmentSandboxProgress(sandbox, args.command); - - const stdoutResult = truncateTail(raw.stdout); - const stderrResult = truncateTail(raw.stderr); - const truncated = stdoutResult.truncated || stderrResult.truncated; + const startedAt = Date.now(); + const commandLabel = formatCommand(args.command); + logDevelopmentSandboxCommand(`eve: starting sandbox command: ${commandLabel}`); + const progressTimer = startDevelopmentProgressTimer(commandLabel, startedAt); - let stdout = stdoutResult.output; - if (stdoutResult.truncated) { - stdout = - `[stdout truncated: showing last ${stdoutResult.outputLines} of ${stdoutResult.totalLines} lines]\n` + - stdout; + try { + const process = await startBackgroundBashProcess( + sandbox, + args.command, + options?.idempotencyKey ?? randomUUID(), + ); + try { + await waitForBackgroundBashProcess({ + abortSignal: options?.abortSignal, + process, + yieldTimeMs: args.yieldTimeMs ?? DEFAULT_BASH_RUN_YIELD_TIME_MS, + }); + } catch (error) { + if (!options?.abortSignal?.aborted) throw error; + try { + await process.terminate(); + } 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.inspect(); + const output = formatBashOutput( + observed.stdout, + observed.stderr, + startedAt, + observed.truncated, + ); + const result: BashResult = + observed.exitCode === undefined + ? { ...output, processId: process.commandId, status: "running" } + : { ...output, exitCode: observed.exitCode, status: "completed" }; + logDevelopmentSandboxCommand( + result.status === "completed" + ? `eve: sandbox command finished (exit ${result.exitCode}): ${commandLabel}` + : `eve: sandbox command yielded: ${commandLabel}`, + ); + return result; + } catch (error) { + logDevelopmentSandboxCommand(`eve: sandbox command failed: ${commandLabel}`); + throw error; + } finally { + if (progressTimer !== undefined) clearInterval(progressTimer); } +} + +export async function startBackgroundBashProcess( + sandbox: SandboxSession, + command: string, + idempotencyKey: string = randomUUID(), +): Promise { + return await getManagedSandboxCommands(sandbox).start({ command, idempotencyKey }); +} - let stderr = stderrResult.output; - if (stderrResult.truncated) { - stderr = - `[stderr truncated: showing last ${stderrResult.outputLines} of ${stderrResult.totalLines} lines]\n` + - stderr; +export async function getBackgroundBashProcess( + sandbox: SandboxSession, + processId: string, +): Promise { + return await getManagedSandboxCommands(sandbox).get(processId); +} + +export async function waitForBackgroundBashProcess(input: { + readonly abortSignal?: AbortSignal; + readonly process: ManagedSandboxCommand; + readonly yieldTimeMs: number; +}): Promise<{ readonly exitCode?: number } | null> { + const deadline = Date.now() + input.yieldTimeMs; + while (true) { + input.abortSignal?.throwIfAborted(); + const state = await input.process.inspectStatus(); + 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 abortableDelay(ms: number, abortSignal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const onAbort = () => { + clearTimeout(timer); + reject(abortSignal?.reason); + }; + const timer = setTimeout(() => { + abortSignal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + abortSignal?.addEventListener("abort", onAbort, { once: true }); + }); +} +export function formatBashOutput( + stdoutValue: string, + stderrValue: string, + startedAt: number, + alreadyTruncated = false, +): BashOutput { + const stdoutResult = truncateTail(stdoutValue); + const stderrResult = truncateTail(stderrValue); return { - exitCode: raw.exitCode, - stderr, - stdout, - truncated, + stderr: stderrResult.output, + stdout: stdoutResult.output, + truncated: alreadyTruncated || stdoutResult.truncated || stderrResult.truncated, + wallTimeSeconds: wallTimeSeconds(startedAt), }; } -async function runWithDevelopmentSandboxProgress( - sandbox: SandboxSession, - command: string, -): Promise>> { - logDevelopmentSandboxCommand(`eve: starting sandbox command: ${formatCommand(command)}`); - if (!isEveDevEnvironment()) { - return await sandbox.run({ command }); - } +export function wallTimeSeconds(startedAt: number): number { + return Math.round(Date.now() - startedAt) / 1_000; +} - const startedAt = Date.now(); +function startDevelopmentProgressTimer( + command: string, + startedAt: number, +): NodeJS.Timeout | undefined { + if (!isEveDevEnvironment()) return undefined; const timer = setInterval(() => { - const elapsedSeconds = Math.round((Date.now() - startedAt) / 1000); + const elapsedSeconds = Math.round((Date.now() - startedAt) / 1_000); logDevelopmentSandboxCommand( - `eve: waiting for sandbox command (${elapsedSeconds}s elapsed): ${formatCommand(command)}`, + `eve: waiting for sandbox command (${elapsedSeconds}s elapsed): ${command}`, ); }, 5_000); timer.unref?.(); - - try { - const result = await sandbox.run({ command }); - logDevelopmentSandboxCommand( - `eve: sandbox command finished (exit ${result.exitCode}): ${formatCommand(command)}`, - ); - return result; - } catch (error) { - logDevelopmentSandboxCommand(`eve: sandbox command failed: ${formatCommand(command)}`); - throw error; - } finally { - clearInterval(timer); - } + return timer; } function logDevelopmentSandboxCommand(message: string): void { - if (isEveDevEnvironment()) { - console.log(message); - } + if (isEveDevEnvironment()) console.log(message); } function formatCommand(command: string): string { const singleLine = command.replaceAll(/\s+/g, " ").trim(); - if (singleLine.length <= MAX_LOG_COMMAND_LENGTH) { - return singleLine; - } + if (singleLine.length <= MAX_LOG_COMMAND_LENGTH) return singleLine; return `${singleLine.slice(0, MAX_LOG_COMMAND_LENGTH - 1)}…`; } diff --git a/packages/eve/src/execution/sandbox/bindings/vercel-managed-command.ts b/packages/eve/src/execution/sandbox/bindings/vercel-managed-command.ts new file mode 100644 index 0000000000..c151c816d1 --- /dev/null +++ b/packages/eve/src/execution/sandbox/bindings/vercel-managed-command.ts @@ -0,0 +1,44 @@ +import { adaptMultiplexedCommandToSandboxProcess } from "#execution/sandbox/multiplexed-command.js"; +import type { + ManagedSandboxCommandBackend, + ManagedSandboxCommandBackendProcess, +} from "#execution/sandbox/managed-command.js"; +import { isVercelSandboxMissingError } from "#execution/sandbox/bindings/vercel-errors.js"; +import type { VercelSandbox } from "#execution/sandbox/bindings/vercel-sdk-types.js"; +import { WORKSPACE_ROOT } from "#runtime/workspace/types.js"; + +export function createVercelManagedCommandBackend( + sandbox: VercelSandbox, +): ManagedSandboxCommandBackend { + return { + async start(command) { + const started = await sandbox.runCommand({ + args: ["-lc", command], + cmd: "bash", + cwd: WORKSPACE_ROOT, + detached: true, + }); + return adaptVercelManagedCommand(started); + }, + async reconnect(commandId) { + try { + return adaptVercelManagedCommand(await sandbox.getCommand(commandId)); + } catch (error) { + if (isVercelSandboxMissingError(error)) return null; + throw error; + } + }, + }; +} + +function adaptVercelManagedCommand( + command: Awaited>, +): ManagedSandboxCommandBackendProcess { + return { + commandId: command.cmdId, + process: adaptMultiplexedCommandToSandboxProcess({ + command, + getOutput: (log) => log.stream, + }), + }; +} diff --git a/packages/eve/src/execution/sandbox/bindings/vercel.test.ts b/packages/eve/src/execution/sandbox/bindings/vercel.test.ts index cab2e0fe45..19abbc0a75 100644 --- a/packages/eve/src/execution/sandbox/bindings/vercel.test.ts +++ b/packages/eve/src/execution/sandbox/bindings/vercel.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { SandboxTemplateNotProvisionedError } from "#public/definitions/sandbox-backend.js"; import { vercel } from "#public/sandbox/backends/vercel.js"; import { createVercelSandbox } from "#execution/sandbox/bindings/vercel.js"; +import { getManagedSandboxCommands } from "#execution/sandbox/managed-command.js"; // The credential fallback consults the developer's Vercel CLI auth and the // repo's `.vercel` project link; on a linked, logged-in machine it would @@ -31,8 +32,10 @@ function createMockCommandResult() { */ function createMockDetachedCommand( logs: ReadonlyArray<{ readonly data: string; readonly stream: "stderr" | "stdout" }> = [], + cmdId = "cmd-1", ) { return { + cmdId, kill: vi.fn().mockResolvedValue(undefined), logs() { return (async function* () { @@ -58,6 +61,7 @@ function createMockSandbox(input: { rm: vi.fn().mockResolvedValue(undefined), unlink: vi.fn().mockResolvedValue(undefined), }, + getCommand: vi.fn(async (cmdId: string) => createMockDetachedCommand([], cmdId)), name: input.name, readFile: vi.fn(async (file: { path: string }): Promise => { const content = files.get(file.path); @@ -1075,6 +1079,27 @@ describe("createVercelSandbox", () => { expect(state.metadata).toEqual({ sandboxName: "persisted-sandbox-name" }); }); + it("starts and reconnects managed commands through the Vercel command API", async () => { + const { handle, sessionSandbox } = await createTestVercelSession(); + vi.mocked(sessionSandbox.runCommand).mockResolvedValue( + createMockDetachedCommand([], "cmd-started") as never, + ); + + const commands = getManagedSandboxCommands(handle.session); + const started = await commands.start({ command: "sleep 10", idempotencyKey: "call-1" }); + const reconnected = await commands.get("cmd-existing"); + + expect(started.commandId).toBe("cmd-started"); + expect(reconnected.commandId).toBe("cmd-existing"); + expect(sessionSandbox.runCommand).toHaveBeenCalledWith({ + args: ["-lc", "sleep 10"], + cmd: "bash", + cwd: "/workspace", + detached: true, + }); + expect(sessionSandbox.getCommand).toHaveBeenCalledWith("cmd-existing"); + }); + it("stops the session sandbox on shutdown so no VM outlives the server", async () => { const { handle, sessionSandbox } = await createTestVercelSession(); diff --git a/packages/eve/src/execution/sandbox/bindings/vercel.ts b/packages/eve/src/execution/sandbox/bindings/vercel.ts index 2ce7c5e653..7ff163e3be 100644 --- a/packages/eve/src/execution/sandbox/bindings/vercel.ts +++ b/packages/eve/src/execution/sandbox/bindings/vercel.ts @@ -33,6 +33,8 @@ import { WORKSPACE_ROOT } from "#runtime/workspace/types.js"; import { createLoggingSandboxSession } from "#execution/sandbox/logging-session.js"; import { adaptMultiplexedCommandToSandboxProcess } from "#execution/sandbox/multiplexed-command.js"; import { buildSandboxSession } from "#execution/sandbox/session.js"; +import { registerManagedSandboxCommandBackend } from "#execution/sandbox/managed-command.js"; +import { createVercelManagedCommandBackend } from "#execution/sandbox/bindings/vercel-managed-command.js"; import { streamToBuffer } from "#execution/sandbox/stream-utils.js"; import { createVercelEveImageSandbox, @@ -454,18 +456,12 @@ function createHandle( sessionKey: string, ): SandboxBackendHandle { return { - session: buildSandboxSession( - createVercelInternalSandboxSession(sandbox, sessionKey), - createVercelNetworkPolicySetter(sandbox), - ), + session: createVercelSandboxSession(sandbox, sessionKey), useSessionFn: async (options?: VercelSandboxSessionUseOptions) => { if (options !== undefined) { await sandbox.update(options); } - return buildSandboxSession( - createVercelInternalSandboxSession(sandbox, sessionKey), - createVercelNetworkPolicySetter(sandbox), - ); + return createVercelSandboxSession(sandbox, sessionKey); }, async captureState() { return { @@ -502,6 +498,15 @@ function createVercelNetworkPolicySetter( }; } +function createVercelSandboxSession(sandbox: VercelSandbox, sessionKey: string): SandboxSession { + const session = buildSandboxSession( + createVercelInternalSandboxSession(sandbox, sessionKey), + createVercelNetworkPolicySetter(sandbox), + ); + registerManagedSandboxCommandBackend(session, createVercelManagedCommandBackend(sandbox)); + return session; +} + function createVercelInternalSandboxSession( sandbox: VercelSandbox, id: string, diff --git a/packages/eve/src/execution/sandbox/ensure.ts b/packages/eve/src/execution/sandbox/ensure.ts index 51e6a5e1ce..99d696e5e7 100644 --- a/packages/eve/src/execution/sandbox/ensure.ts +++ b/packages/eve/src/execution/sandbox/ensure.ts @@ -12,6 +12,7 @@ import { type RuntimeCompiledArtifactsSource, } from "#runtime/compiled-artifacts-source.js"; import { trackActiveSandboxHandle } from "#execution/sandbox/active-handles.js"; +import { clearManagedSandboxCommands } from "#execution/sandbox/managed-command.js"; import { waitForDevelopmentSandboxPrewarm } from "#execution/sandbox/development-prewarm.js"; import { prewarmAppSandboxes } from "#execution/sandbox/prewarm.js"; import { waitForSandboxTemplatePrewarmLock } from "#execution/sandbox/template-prewarm-lock.js"; @@ -141,7 +142,15 @@ export async function ensureSandboxAccess(input: EnsureSandboxAccessInput): Prom ); trackActiveSandboxHandle({ backendName: backend.name, - handle, + handle: { + async shutdown() { + try { + await handle.shutdown(); + } finally { + clearManagedSandboxCommands(handle.session.id); + } + }, + }, sessionKey: keys.sessionKey, }); @@ -188,6 +197,7 @@ export async function ensureSandboxAccess(input: EnsureSandboxAccessInput): Prom throw new Error("The sandbox is not available in the current authored runtime context."); } await handle.stop(); + clearManagedSandboxCommands(handle.session.id); }, }; } diff --git a/packages/eve/src/execution/sandbox/managed-command.ts b/packages/eve/src/execution/sandbox/managed-command.ts new file mode 100644 index 0000000000..2c24bec552 --- /dev/null +++ b/packages/eve/src/execution/sandbox/managed-command.ts @@ -0,0 +1,266 @@ +import { randomUUID } from "node:crypto"; + +import type { SandboxProcess, SandboxSession } from "#shared/sandbox-session.js"; +import { + LINE_TRUNCATION_SUFFIX, + MAX_LINE_LENGTH, + truncateTail, +} from "#execution/sandbox/truncate-output.js"; + +export const MAX_MANAGED_SANDBOX_COMMANDS = 64; + +export interface ManagedSandboxCommandObservation { + readonly exitCode?: number; + readonly stderr: string; + readonly stdout: string; + readonly truncated: boolean; +} + +export interface ManagedSandboxCommand { + readonly commandId: string; + inspect(): Promise; + inspectStatus(): Promise<{ readonly exitCode?: number }>; + terminate(): Promise; +} + +export interface ManagedSandboxCommandBackend { + start(command: string): Promise; + reconnect(commandId: string): Promise; +} + +export interface ManagedSandboxCommandBackendProcess { + readonly commandId: string; + readonly process: SandboxProcess; +} + +const backends = new Map(); +const registries = new Map(); + +export function registerManagedSandboxCommandBackend( + sandbox: SandboxSession, + backend: ManagedSandboxCommandBackend, +): void { + backends.set(sandbox.id, backend); +} + +export function clearManagedSandboxCommands(sandboxId: string): void { + registries.delete(sandboxId); + backends.delete(sandboxId); +} + +export function getManagedSandboxCommands(sandbox: SandboxSession): ManagedCommandRegistry { + const backend = backends.get(sandbox.id) ?? createSpawnBackend(sandbox); + const existing = registries.get(sandbox.id); + if (existing !== undefined) { + existing.backend = backend; + return existing; + } + const registry = new ManagedCommandRegistry(backend); + registries.set(sandbox.id, registry); + return registry; +} + +function createSpawnBackend(sandbox: SandboxSession): ManagedSandboxCommandBackend { + return { + async start(command) { + return { commandId: randomUUID(), process: await sandbox.spawn({ command }) }; + }, + async reconnect() { + return null; + }, + }; +} + +export class ManagedCommandRegistry { + readonly #commands = new Map(); + readonly #starts = new Map>(); + #pendingStarts = 0; + backend: ManagedSandboxCommandBackend; + + constructor(backend: ManagedSandboxCommandBackend) { + this.backend = backend; + } + + async start(input: { + readonly command: string; + readonly idempotencyKey: string; + }): Promise { + const existing = this.#starts.get(input.idempotencyKey); + if (existing !== undefined) return await existing; + + if (this.#commands.size + this.#pendingStarts >= MAX_MANAGED_SANDBOX_COMMANDS) { + this.#pruneCompleted(); + } + if (this.#commands.size + this.#pendingStarts >= MAX_MANAGED_SANDBOX_COMMANDS) { + throw new Error( + `This sandbox already tracks ${MAX_MANAGED_SANDBOX_COMMANDS} running commands. Terminate or wait for existing commands before starting another.`, + ); + } + + this.#pendingStarts += 1; + const start = this.backend + .start(input.command) + .then((process) => this.#track(process, input.idempotencyKey)); + this.#starts.set(input.idempotencyKey, start); + try { + return await start; + } catch (error) { + if (this.#starts.get(input.idempotencyKey) === start) { + this.#starts.delete(input.idempotencyKey); + } + throw error; + } finally { + this.#pendingStarts -= 1; + } + } + + async get(commandId: string): Promise { + validateCommandId(commandId); + const existing = this.#commands.get(commandId); + if (existing !== undefined) return existing; + + const reconnected = await this.backend.reconnect(commandId); + if (reconnected === null) { + throw new Error( + `Sandbox command "${commandId}" is unavailable. Its completion state is unknown; do not rerun it unless you first verify that retrying is safe.`, + ); + } + return this.#track(reconnected); + } + + #track(process: ManagedSandboxCommandBackendProcess, idempotencyKey?: string): SpawnedCommand { + const command = new SpawnedCommand(process, idempotencyKey, () => { + if (this.#commands.get(process.commandId) === command) { + this.#commands.delete(process.commandId); + } + if (idempotencyKey !== undefined) this.#starts.delete(idempotencyKey); + }); + this.#commands.set(process.commandId, command); + return command; + } + + #pruneCompleted(): void { + for (const command of this.#commands.values()) { + if (!command.completed) continue; + this.#commands.delete(command.commandId); + if (command.idempotencyKey !== undefined) this.#starts.delete(command.idempotencyKey); + } + } +} + +class SpawnedCommand implements ManagedSandboxCommand { + readonly commandId: string; + readonly idempotencyKey?: string; + readonly #handle: SandboxProcess; + readonly #remove: () => void; + readonly #stderr = new TailOutputBuffer(); + readonly #stdout = new TailOutputBuffer(); + #exitCode: number | undefined; + #failure: unknown; + + constructor( + process: ManagedSandboxCommandBackendProcess, + idempotencyKey: string | undefined, + remove: () => void, + ) { + this.commandId = process.commandId; + this.idempotencyKey = idempotencyKey; + this.#handle = process.process; + this.#remove = remove; + const stdout = captureOutput(this.#handle.stdout, this.#stdout); + const stderr = captureOutput(this.#handle.stderr, this.#stderr); + void Promise.all([this.#handle.wait(), stdout, stderr]).then( + ([result]) => { + this.#exitCode = result.exitCode; + }, + (error: unknown) => { + this.#failure = error; + }, + ); + } + + get completed(): boolean { + return this.#exitCode !== undefined || this.#failure !== undefined; + } + + async inspect(): Promise { + this.#throwIfFailed(); + return { + exitCode: this.#exitCode, + stderr: this.#stderr.output, + stdout: this.#stdout.output, + truncated: this.#stderr.truncated || this.#stdout.truncated, + }; + } + + async inspectStatus(): Promise<{ readonly exitCode?: number }> { + this.#throwIfFailed(); + return { exitCode: this.#exitCode }; + } + + async terminate(): Promise { + try { + await this.#handle.kill(); + } catch (error) { + await Promise.resolve(); + if (!this.completed) throw error; + } + this.#remove(); + } + + #throwIfFailed(): void { + if (this.#failure !== undefined) throw this.#failure; + } +} + +function validateCommandId(commandId: string): void { + if (commandId.length === 0 || commandId.length > 256 || /\s/.test(commandId)) { + throw new Error("Invalid sandbox command id."); + } +} + +class TailOutputBuffer { + #discardingLine = false; + #lineLength = 0; + output = ""; + truncated = false; + + append(value: string): void { + const segments = value.split("\n"); + for (const [index, segment] of segments.entries()) { + if (!this.#discardingLine) { + const available = MAX_LINE_LENGTH - this.#lineLength; + const kept = segment.slice(0, Math.max(0, available)); + this.output += kept; + this.#lineLength += kept.length; + if (kept.length < segment.length) { + this.output += LINE_TRUNCATION_SUFFIX; + this.#discardingLine = true; + this.truncated = true; + } + } + if (index < segments.length - 1) { + this.output += "\n"; + this.#discardingLine = false; + this.#lineLength = 0; + } + } + const bounded = truncateTail(this.output); + this.output = bounded.output; + this.truncated ||= bounded.truncated; + } +} + +async function captureOutput( + stream: ReadableStream, + output: TailOutputBuffer, +): Promise { + const decoder = new TextDecoder(); + const reader = stream.getReader(); + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + output.append(decoder.decode(chunk.value, { stream: true })); + } + output.append(decoder.decode()); +} 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..fd4c00370a --- /dev/null +++ b/packages/eve/src/tools/provided/bash-execute.test.ts @@ -0,0 +1,126 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { RuntimeSandboxSession } from "#shared/sandbox-session.js"; +import { + executeBashOnSandbox, + getBackgroundBashProcess, + waitForBackgroundBashProcess, +} from "#execution/sandbox/bash.js"; + +import { executeBashTool } from "./bash.js"; + +vi.mock("#execution/sandbox/bash.js", async (importOriginal) => ({ + ...(await importOriginal()), + executeBashOnSandbox: vi.fn(), + getBackgroundBashProcess: vi.fn(), + waitForBackgroundBashProcess: vi.fn(), +})); + +const context = { + abortSignal: new AbortController().signal, + callId: "call-1", + getSandbox: vi.fn(async () => ({}) as RuntimeSandboxSession), + session: { id: "session-1" }, +}; + +function process(state: { + exitCode?: number; + stderr: string; + stdout: string; + truncated?: boolean; +}) { + const observation = { truncated: false, ...state }; + return { + commandId: "11111111-1111-4111-8111-111111111111", + inspect: vi.fn(async () => observation), + inspectStatus: vi.fn(async () => ({ exitCode: state.exitCode })), + terminate: vi.fn(async () => {}), + }; +} + +describe("executeBashTool process actions", () => { + afterEach(() => vi.resetAllMocks()); + + it("scopes start idempotency to the durable session", async () => { + vi.mocked(executeBashOnSandbox).mockResolvedValue({ + exitCode: 0, + status: "completed", + stderr: "", + stdout: "", + truncated: false, + wallTimeSeconds: 0, + }); + + await executeBashTool({ action: "run", command: "true" }, context); + + expect(executeBashOnSandbox).toHaveBeenCalledWith( + expect.anything(), + { action: "run", command: "true" }, + { + abortSignal: context.abortSignal, + idempotencyKey: "session-1:call-1", + }, + ); + }); + + it("polls a running process through bash", async () => { + const running = process({ stderr: "", stdout: "partial", truncated: true }); + vi.mocked(getBackgroundBashProcess).mockResolvedValue(running); + + await expect( + executeBashTool({ action: "poll", processId: running.commandId }, context), + ).resolves.toEqual({ + processId: running.commandId, + status: "running", + stderr: "", + stdout: "partial", + truncated: true, + wallTimeSeconds: expect.any(Number), + }); + }); + + it("waits for a process through bash", async () => { + const running = process({ stderr: "", stdout: "partial" }); + vi.mocked(getBackgroundBashProcess).mockResolvedValue(running); + vi.mocked(waitForBackgroundBashProcess).mockResolvedValue({ exitCode: 0 }); + running.inspect.mockResolvedValue({ + exitCode: 0, + stderr: "", + stdout: "done", + truncated: false, + }); + + await expect( + executeBashTool({ action: "wait", processId: running.commandId }, 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: 300_000, + }); + }); + + it("kills a running process but preserves a completed result", async () => { + const running = process({ stderr: "", stdout: "partial" }); + vi.mocked(getBackgroundBashProcess).mockResolvedValueOnce(running); + + await expect( + executeBashTool({ action: "kill", processId: running.commandId }, context), + ).resolves.toMatchObject({ status: "killed" }); + expect(running.terminate).toHaveBeenCalledOnce(); + + const completed = process({ exitCode: 7, stderr: "failed", stdout: "" }); + vi.mocked(getBackgroundBashProcess).mockResolvedValueOnce(completed); + await expect( + executeBashTool({ action: "kill", processId: completed.commandId }, context), + ).resolves.toMatchObject({ exitCode: 7, status: "completed" }); + expect(completed.terminate).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/eve/src/tools/provided/bash.test.ts b/packages/eve/src/tools/provided/bash.test.ts new file mode 100644 index 0000000000..e94f5d4101 --- /dev/null +++ b/packages/eve/src/tools/provided/bash.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; + +import { z } from "#compiled/zod/index.js"; + +import { BASH_INPUT_SCHEMA, BASH_OUTPUT_SCHEMA } from "./bash.js"; + +describe("bash schemas", () => { + it("emits a provider-compatible object schema", () => { + const schema = z.toJSONSchema(BASH_INPUT_SCHEMA, { io: "input" }); + expect(schema).toMatchObject({ + required: ["action", "command", "processId", "yieldTimeMs"], + type: "object", + }); + expect(schema).not.toHaveProperty("anyOf"); + }); + + it("accepts an optional nonnegative foreground yield time in milliseconds", () => { + expect(BASH_INPUT_SCHEMA.safeParse({ command: "pnpm test", yieldTimeMs: 0 }).success).toBe( + true, + ); + expect(BASH_INPUT_SCHEMA.safeParse({ command: "pnpm test" }).success).toBe(true); + expect(BASH_INPUT_SCHEMA.safeParse({ command: "pnpm test", yieldTimeMs: -1 }).success).toBe( + false, + ); + }); + + it("accepts process follow-up operations through the same interface", () => { + expect(BASH_INPUT_SCHEMA.safeParse({ action: "poll", processId: "process-123" }).success).toBe( + true, + ); + expect( + BASH_INPUT_SCHEMA.safeParse({ action: "wait", processId: "process-123", yieldTimeMs: 30_000 }) + .success, + ).toBe(true); + expect(BASH_INPUT_SCHEMA.safeParse({ action: "kill", processId: "process-123" }).success).toBe( + true, + ); + expect(BASH_INPUT_SCHEMA.safeParse({ action: "poll" }).success).toBe(false); + expect(BASH_INPUT_SCHEMA.safeParse({ command: "pwd", action: "poll" }).success).toBe(false); + expect( + BASH_INPUT_SCHEMA.safeParse({ + action: "run", + command: "pwd", + processId: "", + yieldTimeMs: null, + }).success, + ).toBe(true); + }); + + it("distinguishes completed commands from running process receipts", () => { + expect( + BASH_OUTPUT_SCHEMA.safeParse({ + exitCode: 0, + status: "completed", + stderr: "", + stdout: "done", + truncated: false, + wallTimeSeconds: 1.5, + }).success, + ).toBe(true); + expect( + BASH_OUTPUT_SCHEMA.safeParse({ + processId: "process-123", + status: "running", + stderr: "", + stdout: "partial", + truncated: false, + wallTimeSeconds: 300, + }).success, + ).toBe(true); + expect( + BASH_OUTPUT_SCHEMA.safeParse({ + exitCode: 0, + status: "completed", + stderr: "", + stdout: "done", + truncated: false, + }).success, + ).toBe(false); + }); +}); diff --git a/packages/eve/src/tools/provided/bash.ts b/packages/eve/src/tools/provided/bash.ts index f82251646d..e61f775243 100644 --- a/packages/eve/src/tools/provided/bash.ts +++ b/packages/eve/src/tools/provided/bash.ts @@ -1,41 +1,157 @@ import { z } from "#compiled/zod/index.js"; -import { executeBashOnSandbox, type BashInput } from "#execution/sandbox/bash.js"; +import type { SessionContext } from "#context/session-context.js"; +import { + DEFAULT_BASH_RUN_YIELD_TIME_MS, + DEFAULT_BASH_WAIT_YIELD_TIME_MS, + executeBashOnSandbox, + formatBashOutput, + getBackgroundBashProcess, + waitForBackgroundBashProcess, + type BashInput, +} from "#execution/sandbox/bash.js"; import { defineTool, type ToolDefinition } from "#tools/definition.js"; -/** - * Input schema for the provided `bash` tool. - */ -export const BASH_INPUT_SCHEMA = z.strictObject({ - command: z.string().describe("The shell command to execute."), -}); +const YIELD_TIME_SCHEMA = z + .number() + .nonnegative() + .describe( + `Maximum time in milliseconds to wait before returning. Run defaults to ${DEFAULT_BASH_RUN_YIELD_TIME_MS} ms; wait defaults to ${DEFAULT_BASH_WAIT_YIELD_TIME_MS} ms.`, + ) + .nullable() + .optional(); + +type BashProcessToolInput = { + readonly action: "poll" | "wait" | "kill"; + readonly processId: string; + readonly yieldTimeMs?: number; +}; + +export type BashToolInput = + | { readonly action?: "run"; readonly command: string; readonly yieldTimeMs?: number } + | BashProcessToolInput; -/** - * Output schema for the provided `bash` tool. - */ -export const BASH_OUTPUT_SCHEMA = z.strictObject({ - exitCode: z.number(), +export const BASH_INPUT_SCHEMA = z + .strictObject({ + action: z + .enum(["run", "poll", "wait", "kill"]) + .describe("Run a new command, read process output, wait longer, or terminate a process.") + .default("run"), + command: z + .string() + .describe("Required with action run: the shell command to execute.") + .nullable() + .optional(), + processId: z + .string() + .describe("Required with action poll, wait, or kill: the id returned by an earlier call.") + .nullable() + .optional(), + yieldTimeMs: YIELD_TIME_SCHEMA, + }) + .superRefine((input, context) => { + const hasCommand = typeof input.command === "string" && input.command !== ""; + const hasProcessId = typeof input.processId === "string" && input.processId !== ""; + const invalid = + input.action === "run" ? !hasCommand || hasProcessId : hasCommand || !hasProcessId; + if (invalid) { + context.addIssue({ + code: "custom", + message: "Action run requires command; other actions require processId.", + }); + } + }) + .describe("Choose an action, then provide its command or processId.") + .meta({ + required: ["action", "command", "processId", "yieldTimeMs"], + }) as z.ZodType; + +const BASH_OUTPUT_FIELDS = { stderr: z.string(), stdout: z.string(), truncated: z.boolean(), -}); + wallTimeSeconds: z + .number() + .describe("Elapsed wall time this call spent before returning, in seconds."), +}; + +export const BASH_OUTPUT_SCHEMA = z.discriminatedUnion("status", [ + z.strictObject({ + ...BASH_OUTPUT_FIELDS, + exitCode: z.number(), + status: z.literal("completed"), + }), + z.strictObject({ ...BASH_OUTPUT_FIELDS, status: z.literal("killed") }), + z.strictObject({ + ...BASH_OUTPUT_FIELDS, + processId: z.string(), + status: z.literal("running"), + }), +]); -export type BashToolInput = z.infer; export type BashToolOutput = z.infer; -/** - * Framework-owned executors stay statically imported so hosted server bundles - * can trace and rewrite them into deployable output chunks. - * - * These modules are only used by the Nitro-hosted runtime path. Their deeper - * sandbox dependencies remain lazily loaded inside the execution layer, so the - * top-level import here does not force those backends to initialize eagerly. - */ -export const bash: ToolDefinition = defineTool({ - description: "Execute a shell command in the shared workspace environment.", - async execute(input, ctx) { - return await executeBashOnSandbox(await ctx.getSandbox(), input as BashInput); +export async function executeBashTool( + input: BashToolInput, + context: Pick & { + readonly abortSignal: AbortSignal; + readonly session: Pick; + readonly callId: string; }, +): Promise { + const sandbox = await context.getSandbox(); + if (input.action === undefined || input.action === "run") { + return await executeBashOnSandbox(sandbox, input as BashInput, { + abortSignal: context.abortSignal, + idempotencyKey: `${context.session.id}:${context.callId}`, + }); + } + + const processInput = input as BashProcessToolInput; + const startedAt = Date.now(); + const process = await getBackgroundBashProcess(sandbox, processInput.processId); + if (processInput.action === "kill") { + const before = await process.inspect(); + if (before.exitCode !== undefined) { + const output = formatBashOutput(before.stdout, before.stderr, startedAt, before.truncated); + return { ...output, exitCode: before.exitCode, status: "completed" }; + } + await process.terminate(); + return { + ...formatBashOutput(before.stdout, before.stderr, startedAt, before.truncated), + status: "killed", + }; + } + if (processInput.action === "wait") { + await waitForBackgroundBashProcess({ + abortSignal: context.abortSignal, + process, + yieldTimeMs: processInput.yieldTimeMs ?? DEFAULT_BASH_WAIT_YIELD_TIME_MS, + }); + } + const state = await process.inspect(); + if (state.exitCode === undefined) { + return { + ...formatBashOutput(state.stdout, state.stderr, startedAt, state.truncated), + processId: process.commandId, + status: "running", + }; + } + return { + ...formatBashOutput(state.stdout, state.stderr, startedAt, state.truncated), + exitCode: state.exitCode, + status: "completed", + }; +} + +export const bash: ToolDefinition = defineTool({ + description: [ + "Run shell commands and manage commands that continue in the background.", + `Use action run with command for a new command; it waits up to ${DEFAULT_BASH_RUN_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; wait blocks up to ${DEFAULT_BASH_WAIT_YIELD_TIME_MS} ms by default.`, + "When run yields, wait if the user asked for completion; otherwise report that the command is still running.", + ].join(" "), + execute: executeBashTool, inputSchema: BASH_INPUT_SCHEMA, outputSchema: BASH_OUTPUT_SCHEMA, }); diff --git a/packages/eve/test/scenarios/app-runtime-dependencies.scenario.test.ts b/packages/eve/test/scenarios/app-runtime-dependencies.scenario.test.ts index 53dd38c92f..288bcba14a 100644 --- a/packages/eve/test/scenarios/app-runtime-dependencies.scenario.test.ts +++ b/packages/eve/test/scenarios/app-runtime-dependencies.scenario.test.ts @@ -802,7 +802,7 @@ describe("app runtime dependency tracing", () => { expect(serverModuleSource).not.toContain('import("esbuild")'); expect(serverModuleSource).not.toContain('import("rolldown")'); expect(serverModuleSource).toContain( - "Execute a shell command in the shared workspace environment.", + "Run shell commands and manage commands that continue in the background.", ); expect(serverModuleSource).toContain("The dynamic skill"); expect(serverModuleSource).toContain("URL must start with https://");