Skip to content
Closed
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 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.
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 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 |
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, 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.

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();
238 changes: 196 additions & 42 deletions packages/eve/src/execution/sandbox/bash.test.ts
Original file line number Diff line number Diff line change
@@ -1,59 +1,213 @@
import { afterEach, describe, expect, it, vi } from "vitest";

import { EVE_DEV_ENV_FLAG } from "#internal/application/optional-package-install.js";
import type { SandboxCommandResult, SandboxSession } from "#shared/sandbox-session.js";
import type { SandboxSession } from "#shared/sandbox-session.js";
import { MAX_OUTPUT_LINES } from "#execution/sandbox/truncate-output.js";

import { executeBashOnSandbox } from "./bash.js";
import {
DEFAULT_BASH_YIELD_TIME_MS,
executeBashOnSandbox,
formatBashOutput,
getBackgroundBashProcess,
MAX_BACKGROUND_BASH_PROCESSES,
startBackgroundBashProcess,
waitForBackgroundBashProcess,
} from "./bash.js";

const previousDevFlag = process.env[EVE_DEV_ENV_FLAG];

afterEach(() => {
if (previousDevFlag === undefined) {
delete process.env[EVE_DEV_ENV_FLAG];
} else {
process.env[EVE_DEV_ENV_FLAG] = previousDevFlag;
}
vi.restoreAllMocks();
});

function sandbox(files: Record<string, string | null> = {}): SandboxSession {
return {
id: "sandbox",
readBinaryFile: vi.fn(async () => null),
readFile: vi.fn(async () => null),
readTextFile: vi.fn(async ({ path }) => files[path] ?? null),
removePath: vi.fn(async () => {}),
resolvePath: (path) => path,
run: vi.fn(async () => ({ exitCode: 0, stderr: "", stdout: "" })),
setNetworkPolicy: vi.fn(async () => {}),
spawn: vi.fn(async () => {
throw new Error("not used");
}),
writeBinaryFile: vi.fn(async () => {}),
writeFile: vi.fn(async () => {}),
writeTextFile: vi.fn(async () => {}),
};
}

function processFiles(values: { exitCode?: number; stderr?: string; stdout?: string }) {
return new Proxy<Record<string, string | null>>(
{},
{
get: (_target, path) => {
if (typeof path !== "string") return null;
if (path.endsWith("/pid")) return "123";
if (path.endsWith("/exit-code")) return values.exitCode?.toString() ?? null;
if (path.endsWith("/stderr")) return values.stderr ?? "";
if (path.endsWith("/stdout")) return values.stdout ?? "";
return null;
},
},
);
}

describe("executeBashOnSandbox", () => {
const previousDevFlag = process.env[EVE_DEV_ENV_FLAG];
it("returns completed output", async () => {
const session = sandbox(processFiles({ exitCode: 0, stdout: "done\n" }));

await expect(executeBashOnSandbox(session, { command: "build" })).resolves.toEqual({
exitCode: 0,
status: "completed",
stderr: "",
stdout: "done\n",
truncated: false,
wallTimeSeconds: expect.any(Number),
});
});

it("yields a running command", async () => {
const session = sandbox(processFiles({ stderr: "partial err", stdout: "partial out" }));

await expect(
executeBashOnSandbox(session, { command: "build", yieldTimeMs: 0 }),
).resolves.toMatchObject({
status: "running",
stderr: "partial err",
stdout: "partial out",
});
});

it("does not kill after an observation failure", async () => {
const session = sandbox();
vi.mocked(session.readTextFile).mockRejectedValue(new Error("read failed"));

await expect(executeBashOnSandbox(session, { command: "build" })).rejects.toThrow(
"read failed",
);
expect(session.run).toHaveBeenCalledTimes(2);
});

afterEach(() => {
if (previousDevFlag === undefined) {
delete process.env[EVE_DEV_ENV_FLAG];
} else {
process.env[EVE_DEV_ENV_FLAG] = previousDevFlag;
}
vi.restoreAllMocks();
it("kills when cancelled", async () => {
const session = sandbox(processFiles({}));
const cancelled = new DOMException("cancelled", "AbortError");

await expect(
executeBashOnSandbox(
session,
{ command: "build" },
{ abortSignal: AbortSignal.abort(cancelled) },
),
).rejects.toBe(cancelled);
expect(session.removePath).toHaveBeenCalledWith({
force: true,
path: expect.stringContaining("/.eve/processes/"),
recursive: true,
});
});

it("logs sandbox command progress in dev without adding to stderr", async () => {
it("logs command progress in development", async () => {
process.env[EVE_DEV_ENV_FLAG] = "1";
const log = vi.spyOn(console, "log").mockImplementation(() => {});
const sandbox = createTestSandboxSession({
exitCode: 0,
stderr: "",
stdout: "weather-codes.md\n",
const session = sandbox(processFiles({ exitCode: 0 }));

await executeBashOnSandbox(session, { command: "pwd" });

expect(log).toHaveBeenCalledWith("eve: starting sandbox command: pwd");
expect(log).toHaveBeenCalledWith("eve: sandbox command finished (exit 0): pwd");
});

it("uses the default foreground wait", () => {
expect(DEFAULT_BASH_YIELD_TIME_MS).toBe(300_000);
});
});

describe("formatBashOutput", () => {
it("preserves the end of long command output", () => {
const lines = Array.from({ length: MAX_OUTPUT_LINES + 1 }, (_, index) => `line ${index}`);

const result = formatBashOutput(lines.join("\n"), "", Date.now());

expect(result.truncated).toBe(true);
expect(result.stdout).not.toContain("line 0\n");
expect(result.stdout).toContain(`line ${MAX_OUTPUT_LINES}`);
});
});

describe("background bash processes", () => {
it("launches an isolated command behind the process cap", async () => {
const session = sandbox();
const process = await startBackgroundBashProcess(session, "exit 7");
const capacityCommand = vi.mocked(session.run).mock.calls[0]?.[0].command;
const launchCommand = vi.mocked(session.run).mock.calls[1]?.[0].command;

expect(process.processId).toMatch(/^[0-9a-f-]{36}$/);
expect(capacityCommand).toContain(`-lt ${MAX_BACKGROUND_BASH_PROCESSES}`);
expect(launchCommand).toContain("set -m 2>/dev/null || true");
expect(launchCommand).toContain(" ( eval 'exit 7' )\n code=$?");
});

it("rejects when the process cap is reached", async () => {
const session = sandbox();
vi.mocked(session.run).mockResolvedValue({
exitCode: 75,
stderr: "EVE_BASH_PROCESS_LIMIT\n",
stdout: "",
});

const result = await executeBashOnSandbox(sandbox, { command: "ls -la /workspace" });
await expect(startBackgroundBashProcess(session, "pnpm test")).rejects.toThrow(
`This sandbox already tracks ${MAX_BACKGROUND_BASH_PROCESSES} running background commands.`,
);
});

it("reads completed process state", async () => {
const session = sandbox(processFiles({ exitCode: 7, stderr: "err", stdout: "out" }));

expect(result).toEqual({
exitCode: 0,
stderr: "",
stdout: "weather-codes.md\n",
truncated: false,
await expect(
getBackgroundBashProcess(session, "11111111-1111-4111-8111-111111111111").read(),
).resolves.toEqual({ exitCode: 7, stderr: "err", stdout: "out" });
});

it("removes process state even when the process already exited", async () => {
const session = sandbox(processFiles({}));
vi.mocked(session.run).mockResolvedValue({ exitCode: 1, stderr: "", stdout: "" });

await getBackgroundBashProcess(session, "11111111-1111-4111-8111-111111111111").kill();

expect(session.removePath).toHaveBeenCalledWith({
force: true,
path: "/workspace/.eve/processes/11111111-1111-4111-8111-111111111111",
recursive: true,
});
expect(log).toHaveBeenCalledWith("eve: starting sandbox command: ls -la /workspace");
expect(log).toHaveBeenCalledWith("eve: sandbox command finished (exit 0): ls -la /workspace");
});
});

function createTestSandboxSession(result: SandboxCommandResult): SandboxSession {
return {
id: "test-sandbox",
readBinaryFile: async () => null,
readFile: async () => null,
readTextFile: async () => null,
removePath: async () => {},
resolvePath: (path) => path,
run: vi.fn().mockResolvedValue(result),
setNetworkPolicy: async () => {},
spawn: async () => {
throw new Error("spawn is not implemented in this test sandbox");
},
writeBinaryFile: async () => {},
writeFile: async () => {},
writeTextFile: async () => {},
};
}
it("rejects missing process state", async () => {
const session = sandbox();

await expect(
getBackgroundBashProcess(session, "11111111-1111-4111-8111-111111111111").read(),
).rejects.toThrow("does not exist");
});

it("polls status without reading output", async () => {
const read = vi.fn();
const readStatus = vi.fn(async () => ({}));

await expect(
waitForBackgroundBashProcess({
process: { kill: vi.fn(), processId: "process", read, readStatus },
yieldTimeMs: 0,
}),
).resolves.toBeNull();
expect(readStatus).toHaveBeenCalledOnce();
expect(read).not.toHaveBeenCalled();
});
});
Loading
Loading