Skip to content
Draft
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
2 changes: 1 addition & 1 deletion docs/content/docs/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ Each agent on Rivet is one [Rivet Actor](/actors/docs). You define the agent onc
Give agents model access with your own provider keys.
</Card>
<Card title="Sandboxes" href="/agents/docs/sandboxes">
Run an agent's file and shell tools in E2B, Daytona, Freestyle, or your own provider.
Run an agent's file and shell tools in E2B, Daytona, Freestyle, boxd, or your own provider.
</Card>
<Card title="Custom Tools" href="/agents/docs/custom-tools">
Give an agent tools that run in your backend, with your APIs and secrets.
Expand Down
3 changes: 2 additions & 1 deletion docs/content/docs/quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,10 @@ npm add @rivet-dev/sandbox-adapter
<CodeSnippet file="examples/docs/pi/server.ts" title="e2b.ts" />
<CodeSnippet file="examples/docs/pi/daytona.ts" title="daytona.ts" />
<CodeSnippet file="examples/docs/sandboxes/freestyle.ts" title="freestyle.ts" />
<CodeSnippet file="examples/docs/sandboxes/boxd.ts" title="boxd.ts" />
</CodeGroup>

[agentOS](/agentos/docs) needs no provider account. For <a href="https://e2b.dev" target="_blank" rel="noopener noreferrer">E2B</a>, install `e2b` and set `E2B_API_KEY`. For <a href="https://daytona.io" target="_blank" rel="noopener noreferrer">Daytona</a>, install `@daytonaio/sdk` and set `DAYTONA_API_KEY`. For <a href="https://freestyle.sh" target="_blank" rel="noopener noreferrer">Freestyle</a>, install `freestyle` and set `FREESTYLE_API_KEY`. See [Sandboxes](/agents/docs/sandboxes).
[agentOS](/agentos/docs) needs no provider account. For <a href="https://e2b.dev" target="_blank" rel="noopener noreferrer">E2B</a>, install `e2b` and set `E2B_API_KEY`. For <a href="https://daytona.io" target="_blank" rel="noopener noreferrer">Daytona</a>, install `@daytonaio/sdk` and set `DAYTONA_API_KEY`. For <a href="https://freestyle.sh" target="_blank" rel="noopener noreferrer">Freestyle</a>, install `freestyle` and set `FREESTYLE_API_KEY`. For <a href="https://boxd.sh/?ref=rivet" target="_blank" rel="noopener noreferrer">boxd</a>, install `@boxd-sh/sdk` and set `BOXD_API_KEY`. See [Sandboxes](/agents/docs/sandboxes).

</Step>

Expand Down
16 changes: 9 additions & 7 deletions docs/content/docs/sandbox-adapter.mdx
Original file line number Diff line number Diff line change
@@ -1,25 +1,26 @@
---
title: "Sandbox Adapter"
description: "Use one API to create and drive sandboxes on agentOS, E2B, Daytona, Freestyle, or Docker from any Rivet Actor, without the agent harness."
description: "Use one API to create and drive sandboxes on agentOS, E2B, Daytona, Freestyle, boxd, or Docker from any Rivet Actor, without the agent harness."
---

<Diagram
id="sandbox-adapter"
label="Any Rivet Actor calls create or connect on a SandboxProvider from the sandbox adapter, then calls exec and the file operations on the Sandbox it gets back. The adapter talks to the chosen provider: agentOS, E2B, Daytona, Freestyle, or Docker. The Actor stores the sandbox id so it can connect again after it sleeps. There is one sandbox per Actor."
label="Any Rivet Actor calls create or connect on a SandboxProvider from the sandbox adapter, then calls exec and the file operations on the Sandbox it gets back. The adapter talks to the chosen provider: agentOS, E2B, Daytona, Freestyle, boxd, or Docker. The Actor stores the sandbox id so it can connect again after it sleeps. There is one sandbox per Actor."
stackLabel="One sandbox per Actor"
nodes={[
{ id: "actor", x: 20, y: 175, w: 200, product: "actors", stack: true, title: "Any Rivet Actor", subtitle: "stores the sandbox id" },
{ id: "adapter", x: 390, y: 175, w: 180, title: "`sandbox-adapter`", subtitle: "`SandboxProvider`, `Sandbox`" },
{ id: "providers", kind: "boundary", x: 700, y: 15, w: 210, h: 380, title: "Providers" },
{ id: "actor", x: 20, y: 210, w: 200, product: "actors", stack: true, title: "Any Rivet Actor", subtitle: "stores the sandbox id" },
{ id: "adapter", x: 390, y: 210, w: 180, title: "`sandbox-adapter`", subtitle: "`SandboxProvider`, `Sandbox`" },
{ id: "providers", kind: "boundary", x: 700, y: 15, w: 210, h: 450, title: "Providers" },
{ id: "agentos", x: 720, y: 50, h: 50, product: "agentos", title: "agentOS" },
{ id: "e2b", x: 720, y: 120, h: 50, title: "E2B" },
{ id: "daytona", x: 720, y: 190, h: 50, title: "Daytona" },
{ id: "freestyle", x: 720, y: 260, h: 50, title: "Freestyle" },
{ id: "docker", x: 720, y: 330, h: 50, title: "Docker" },
{ id: "boxd", x: 720, y: 330, h: 50, title: "boxd" },
{ id: "docker", x: 720, y: 400, h: 50, title: "Docker" },
]}
edges={[
{ from: "actor", to: "adapter", fromOffset: -12, toOffset: -12, label: "`create`, `connect`", step: 1 },
{ from: "actor", to: "adapter", fromOffset: 12, toOffset: 12, label: "`exec`, files", labelAt: [311, 235], step: 2 },
{ from: "actor", to: "adapter", fromOffset: 12, toOffset: 12, label: "`exec`, files", labelAt: [311, 270], step: 2 },
{ from: "adapter", to: "providers", label: "provider API" },
]}
/>
Expand Down Expand Up @@ -50,6 +51,7 @@ Every provider returns the same `SandboxProvider`, so you change one import and
| E2B | `e2bProvider()` | `@rivet-dev/sandbox-adapter/e2b` | `e2b` | `E2B_API_KEY` |
| Daytona | `daytonaProvider()` | `@rivet-dev/sandbox-adapter/daytona` | `@daytonaio/sdk` | `DAYTONA_API_KEY` |
| Freestyle | `freestyleProvider({ create })` | `@rivet-dev/sandbox-adapter/freestyle` | `freestyle` | `FREESTYLE_API_KEY` |
| boxd | `boxdProvider()` | `@rivet-dev/sandbox-adapter/boxd` | `@boxd-sh/sdk` | `BOXD_API_KEY` |
| Docker | `dockerProvider()` | `@rivet-dev/sandbox-adapter/docker` | Nothing | None |

## Manage the lifecycle
Expand Down
21 changes: 20 additions & 1 deletion docs/content/docs/sandboxes.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: "Sandboxes"
description: "Run an agent without a sandbox, or give it a shell in agentOS, E2B, Daytona, Freestyle, Docker, or a sandbox provider you write."
description: "Run an agent without a sandbox, or give it a shell in agentOS, E2B, Daytona, Freestyle, boxd, Docker, or a sandbox provider you write."
---

An agent doesn't need a sandbox. Without one, its file tools work on files in the Actor's own database. Add a sandbox only when the agent needs a shell.
Expand Down Expand Up @@ -83,6 +83,7 @@ agentOS needs no provider account. To use E2B or Daytona instead, see
| <a href="https://e2b.dev" target="_blank" rel="noopener noreferrer">E2B</a> | `e2b` | Paused | Deleted |
| <a href="https://daytona.io" target="_blank" rel="noopener noreferrer">Daytona</a> | `@daytonaio/sdk` | Stopped | Deleted |
| <a href="https://freestyle.sh" target="_blank" rel="noopener noreferrer">Freestyle</a> | `freestyle` | Paused | Deleted |
| <a href="https://boxd.sh/?ref=rivet" target="_blank" rel="noopener noreferrer">boxd</a> | `@boxd-sh/sdk` | Hibernated | Deleted |
| <a href="https://docs.docker.com" target="_blank" rel="noopener noreferrer">Docker</a> | None. Uses the `docker` CLI. | Stopped | Removed |

Provider credentials stay on your worker and never enter the sandbox.
Expand Down Expand Up @@ -149,6 +150,24 @@ npm add @rivet-dev/sandbox-adapter freestyle

A Freestyle VM has no network access until a `firewall` rule allows it. Commands run as `root` through Python 3, so the image must include it. Every `freestyle/ubuntu*` snapshot does.

### boxd

```sh
npm add @rivet-dev/sandbox-adapter @boxd-sh/sdk
```

<CodeSnippet file="examples/docs/sandboxes/boxd.ts" title="server.ts" />

| Option | Description |
| --- | --- |
| `create` | Options for boxd's `machines.create`, or a function that receives the Actor context and returns them. Defaults to a machine from the default Ubuntu image in your organization's default size. |
| `client` | A boxd client. Defaults to `new Boxd()`, which reads `BOXD_API_KEY`. |
| `cwd` | Working directory inside the machine. Created when the Actor connects. Defaults to `/home/boxd`. |

Each sandbox is a full Linux VM with a persistent disk. Commands run with `/bin/sh` as the `boxd` user, who has passwordless `sudo`. While the Actor sleeps the machine is hibernated: its memory is saved to disk, so files and background processes come back on the next connect. A timeout or an abort ends the command and its child processes. On a timeout, `stdout` and `stderr` hold the output so far.

The machine can reach the internet by default. To limit what it can reach, pass a machine spec with an `egress` allowlist as `create.spec`. See the <a href="https://docs.boxd.sh/?ref=rivet" target="_blank" rel="noopener noreferrer">boxd docs</a>.

### Docker

For local development. The provider runs the `docker` CLI on your worker, so it uses the current Docker context.
Expand Down
1 change: 1 addition & 0 deletions examples/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"check-types": "tsc --noEmit"
},
"dependencies": {
"@boxd-sh/sdk": "^0.3.0",
"@daytonaio/sdk": "^0.216.0",
"@earendil-works/chord": "1.5.0",
"@earendil-works/pi-ai": "1.5.0",
Expand Down
22 changes: 22 additions & 0 deletions examples/docs/sandboxes/boxd.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { createRegistry } from "@earendil-works/pi-durable";
import { CodingTools } from "@earendil-works/pi-durable/tools";
import { pi } from "@rivet-dev/pi";
import { boxdProvider } from "@rivet-dev/sandbox-adapter/boxd";
import { setup } from "rivetkit";

// read, write, edit, and bash, running in the sandbox
const extensions = createRegistry();
extensions.install(CodingTools);

const agent = pi({
model: "anthropic/claude-opus-5-5",
registry: extensions,
sandbox: boxdProvider({
// Labels each machine with its Actor, so you can find it with `boxd machine list --label`.
create: (c) => ({ labels: { "rivet-actor-id": c.actorId } }),
}),
});

export const registry = setup({ use: { agent } });

registry.start();
10 changes: 10 additions & 0 deletions packages/sandbox-adapter/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"e2b",
"daytona",
"freestyle",
"boxd",
"docker",
"agent",
"ai",
Expand Down Expand Up @@ -54,6 +55,10 @@
"types": "./dist/freestyle.d.ts",
"default": "./dist/freestyle.js"
},
"./boxd": {
"types": "./dist/boxd.d.ts",
"default": "./dist/boxd.js"
},
"./pi": {
"types": "./dist/pi/index.d.ts",
"default": "./dist/pi/index.js"
Expand All @@ -67,13 +72,17 @@
"test": "vitest run"
},
"peerDependencies": {
"@boxd-sh/sdk": "^0.3.0",
"@daytonaio/sdk": "^0.216.0",
"@earendil-works/chord": "~1.5.0",
"@earendil-works/pi-durable": "~1.5.0",
"e2b": "^2.51.0",
"freestyle": "^0.2.16"
},
"peerDependenciesMeta": {
"@boxd-sh/sdk": {
"optional": true
},
"@daytonaio/sdk": {
"optional": true
},
Expand All @@ -91,6 +100,7 @@
}
},
"devDependencies": {
"@boxd-sh/sdk": "^0.3.0",
"@daytonaio/sdk": "^0.216.0",
"@earendil-works/chord": "1.5.0",
"@earendil-works/pi-durable": "1.5.0",
Expand Down
208 changes: 208 additions & 0 deletions packages/sandbox-adapter/src/boxd.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import { posix } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import {
Boxd,
ConflictError,
type MachineCreateParams,
NotFoundError,
} from "@boxd-sh/sdk";
import type {
Sandbox,
SandboxActorContext,
SandboxExecOptions,
SandboxExecResult,
SandboxProvider,
} from "./index.js";

export interface BoxdProviderOptions {
/** Defaults to `new Boxd()`, which reads `BOXD_API_KEY`. */
client?: Boxd;
/**
* Options for `machines.create`, or a function that returns them for the
* owning Actor. Defaults to a machine from the default image in the
* organization's default size.
*/
create?:
| MachineCreateParams
| ((
c: SandboxActorContext,
) => MachineCreateParams | Promise<MachineCreateParams>);
/** Working directory inside the machine, created when the Actor connects. Defaults to `/home/boxd`. */
cwd?: string;
}

/** Runs an agent's file and shell tools in a boxd machine. The machine is hibernated while the actor sleeps. */
export function boxdProvider(
options: BoxdProviderOptions = {},
): SandboxProvider {
const cwd = posix.normalize(options.cwd ?? "/home/boxd");
if (!posix.isAbsolute(cwd))
throw new Error(`boxd cwd must be an absolute path, received ${cwd}`);
let client = options.client;
const boxd = () => {
client ??= new Boxd();
return client;
};
return {
name: "boxd",
cwd,
create: async (c) => {
const create =
typeof options.create === "function"
? await options.create(c)
: options.create;
return (await boxd().machines.create(create)).id;
},
connect: async (_c, id) => {
const machines = boxd().machines;
try {
const { status } = await machines.get(id);
if (status === "destroyed") return undefined;
if (status === "suspended") await machines.resume(id);
else if (status === "hibernated") await machines.wake(id);
else if (status === "stopped") await machines.start(id);
await machines.waitUntilReady(id);
} catch (error) {
if (error instanceof NotFoundError) return undefined;
throw error;
}
await machines.fs.mkdir(id, cwd);
return boxdSandbox(boxd(), id, cwd);
},
suspend: async (_c, id) => {
const machines = boxd().machines;
try {
await ignoreMissingMachine(machines.hibernate(id));
} catch (error) {
// A stopped machine already uses no compute, so it stays stopped.
if (
error instanceof ConflictError &&
(await machines.get(id)).status === "stopped"
)
return;
throw error;
}
},
destroy: async (_c, id) => {
await ignoreMissingMachine(boxd().machines.delete(id));
},
};
}

function boxdSandbox(boxd: Boxd, id: string, cwd: string): Sandbox {
const fs = boxd.machines.fs;
return {
cwd,
exec: (command, options) => exec(boxd, id, command, options),
readFile: (path) => fs.read(id, path),
writeFile: async (path, content) => {
await fs.write(id, path, content);
},
mkdir: (path) => fs.mkdir(id, path),
stat: async (path) => ({ isDirectory: (await fs.stat(id, path)).isDir }),
readdir: async (path) =>
(await fs.list(id, path)).map((entry) => entry.name),
exists: (path) => fs.exists(id, path),
};
}

async function ignoreMissingMachine(
operation: Promise<unknown>,
): Promise<void> {
try {
await operation;
} catch (error) {
if (!(error instanceof NotFoundError)) throw error;
}
}

/** How long a closed command may take to report its end before `exec` returns without it. */
const CLOSE_GRACE_MS = 2_000;

/**
* Streams one command through boxd's exec call. Closing the call makes boxd
* kill the command's process group, so a timeout or an abort stops the
* command and its children.
*/
async function exec(
boxd: Boxd,
id: string,
command: string,
options: SandboxExecOptions,
): Promise<SandboxExecResult> {
if (options.signal?.aborted) throw new Error("aborted");
const stream = boxd.machines.streamExec(id, {
command: commandLine(command, options),
closeStdin: true,
});
const stdout: Uint8Array[] = [];
const stderr: Uint8Array[] = [];
let closed: "timeout" | "aborted" | undefined;
let onClosed = () => {};
const whenClosed = new Promise<void>((resolve) => {
onClosed = resolve;
});
const close = (reason: "timeout" | "aborted") => {
closed ??= reason;
stream.close();
onClosed();
};
const collect = async (
source: AsyncIterable<Uint8Array>,
into: Uint8Array[],
) => {
for await (const chunk of source) {
into.push(chunk);
options.onData?.(chunk);
}
};
const abort = () => close("aborted");
options.signal?.addEventListener("abort", abort, { once: true });
const timer = options.timeoutMs
? setTimeout(() => close("timeout"), options.timeoutMs)
: undefined;
try {
const finished = Promise.all([
stream.wait(),
collect(stream.stdout, stdout),
collect(stream.stderr, stderr),
]).then(
([exitCode]) => exitCode,
(error: unknown) => {
// Closing the call rejects the stream. That is the expected end of a timeout or abort.
if (closed) return null;
throw error;
},
);
// After a timeout or an abort, wait at most CLOSE_GRACE_MS for the call to end.
const exitCode = await Promise.race([
finished,
whenClosed.then(() => delay(CLOSE_GRACE_MS)).then(() => null),
]);
if (closed === "aborted") throw new Error("aborted");
return {
exitCode: closed === "timeout" ? null : exitCode,
timedOut: closed === "timeout",
stdout: Buffer.concat(stdout).toString(),
stderr: Buffer.concat(stderr).toString(),
};
} finally {
clearTimeout(timer);
options.signal?.removeEventListener("abort", abort);
}
}

/** Runs `command` in its own `/bin/sh -c` in `options.cwd`, with `options.env` set for all of it. */
function commandLine(command: string, options: SandboxExecOptions): string {
const env = Object.entries(options.env ?? {}).map(([key, value]) => {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
throw new Error(`invalid environment variable name: ${key}`);
return shellQuote(`${key}=${value}`);
});
const run = ["exec", ...(env.length ? ["env", ...env] : []), "/bin/sh", "-c"];
return `cd ${shellQuote(options.cwd)} && ${run.join(" ")} ${shellQuote(command)}`;
}

function shellQuote(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`;
}
Loading