Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bash-command-timeout.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion docs/concepts/built-in-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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.

Expand Down
3 changes: 3 additions & 0 deletions e2e/fixtures/agent-prompt-cache/agent/tools/bash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { disableTool } from "eve/tools";

export default disableTool();
294 changes: 252 additions & 42 deletions packages/eve/src/execution/sandbox/bash.test.ts
Original file line number Diff line number Diff line change
@@ -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<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((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<Uint8Array> {
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();
});
});
Loading
Loading