Skip to content
Merged
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
10 changes: 6 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
# AgentBox — context for Claude Code

`agentbox` is an npm CLI that spins up isolated sandboxes ("boxes") for coding agents (Claude Code, Codex, others) to work in, so they can't touch the host. Three backends share one provider abstraction: **Docker** (the default — one local container per box, isolated by per-box git branch in an in-container worktree against the bind-mounted host `.git/`), **Daytona Cloud** (`--provider daytona` — a managed remote sandbox seeded from a host git bundle + per-agent credential volumes, reached via SSH-token attach and an in-sandbox bridge relay), and **Hetzner Cloud** (`--provider hetzner` — a bare VPS per box, pure OpenSSH ControlMaster comms, locked-down Hetzner Cloud Firewall, baked from a one-time `agentbox prepare --provider hetzner` snapshot).
`agentbox` is an npm CLI that spins up isolated sandboxes ("boxes") for coding agents (Claude Code, Codex, others) to work in, so they can't touch the host. Four backends share one provider abstraction: **Docker** (the default — one local container per box, isolated by per-box git branch in an in-container worktree against the bind-mounted host `.git/`), **Daytona Cloud** (`--provider daytona` — a managed remote sandbox seeded from a host git bundle + per-agent credential volumes, reached via SSH-token attach and an in-sandbox bridge relay), **Hetzner Cloud** (`--provider hetzner` — a bare VPS per box, pure OpenSSH ControlMaster comms, locked-down Hetzner Cloud Firewall, baked from a one-time `agentbox prepare --provider hetzner` snapshot), and **Vercel Sandbox** (`--provider vercel` — a Firecracker microVM per box, persistent snapshots, public HTTPS preview URLs; no nested containers, no SSH, baked from a one-time `agentbox prepare --provider vercel` snapshot).

## Architecture overview

- **Boxes** — one isolated sandbox per agent run. The shape differs by provider but the abstraction is one `Provider` interface (`packages/core/src/provider.ts`):
- **docker**: container `agentbox-<id|name>`; `/workspace` is the in-container git worktree on branch `agentbox/<box-name>`; host's `.git/` is bind-mounted RW so commits land on the host immediately. Boxes pause/unpause for cheap context switching and survive stop/start; `destroy` wipes the container + per-box volumes.
- **daytona** (cloud): Daytona sandbox with `/workspace` seeded from a host `git bundle create` (incl. stash + untracked carry-over for the user's local state). Lifecycle goes through the Daytona SDK; agent credentials (`~/.claude`, `~/.codex`, `~/.config/opencode`) live in shared per-org volumes seeded from the host. Host↔box comms go through a per-box bridge URL (CloudFront preview) that the host relay's `CloudBoxPoller` long-polls.
- **hetzner** (cloud): one Hetzner VPS per box (default `cx23` / `nbg1`). Workspace seeded the same way (git bundle + stash + untracked tar). Per-box ed25519 SSH key minted on the host into `~/.agentbox/boxes/<sandboxId>/ssh/` and injected via cloud-init. Per-box Hetzner Cloud Firewall auto-locked to the host's egress IP (multi-probe fail-loud). All comms (exec, scp, port forwards, attach) flow over one persistent `ssh -fNT -M` ControlMaster per box; `previewUrl(port)` mints `ssh -O forward` on demand. No agent credentials volume — credentials pushed via scp at create time (Hetzner has no shared-volume primitive). `agentbox prepare --provider hetzner` bakes a one-time base snapshot since Hetzner can't build images from a Dockerfile.
- **In-box supervisor** (`@agentbox/ctl`) — reads `/workspace/agentbox.yaml` and runs the declared tasks/services under a DAG scheduler. Ships as `agentbox-ctl` inside every box (docker, daytona, hetzner).
- **vercel** (cloud): one Vercel Sandbox (Firecracker microVM, Amazon Linux 2023) per box. Workspace seeded the same way (git bundle + stash + untracked tar). Boots from a Vercel snapshot baked once by `agentbox prepare --provider vercel` (no Dockerfile build). Persistent sandboxes auto-snapshot on stop and auto-resume on `Sandbox.get({ resume: true })` → pause/resume for free. Comms via the SDK: `exec` runs as `vscode` (root → `sudo -u vscode`); `previewUrl(port)` returns the public `sandbox.domain(port)` (HTTPS, no token), so the host relay's `CloudBoxPoller` reaches the in-box bridge directly. **No nested containers** (`launchDockerd:false`) and **no SSH** (attach is a custom `attach-helper.js` tmux bridge over the SDK). Max 4 exposed ports, region `iad1` only. See [`docs/vercel-backlog.md`](./docs/vercel-backlog.md).
- **In-box supervisor** (`@agentbox/ctl`) — reads `/workspace/agentbox.yaml` and runs the declared tasks/services under a DAG scheduler. Ships as `agentbox-ctl` inside every box (docker, daytona, hetzner, vercel).
- **Host relay** (`@agentbox/relay`) — a host node process boxes call for things they have no credentials for (`git push`, checkpoint capture, `cp`/`download`) and to push status events. Keeps SSH keys out of the box. The cloud path drives the same relay via `CloudBoxPoller` + `executeCloudAction`.
- **Checkpoints** — `docker commit` (+ periodic `FROM scratch` flatten) for docker; Daytona snapshots (`sb._experimental_createSnapshot`) for daytona; Hetzner `create_image` snapshots (no-pause default — matches `docker commit`) for hetzner. All three flow through `provider.checkpoint.create`. `box.defaultCheckpoint` is the cross-provider fallback; `box.defaultCheckpointDocker` / `box.defaultCheckpointDaytona` / `box.defaultCheckpointHetzner` override per provider.
- The full design — file-handling rationale, the checkpoint model, pause/resume strategy, what we explicitly rejected — lives in [`docs/architecture.md`](./docs/architecture.md) and [`docs/create-and-checkpoints.md`](./docs/create-and-checkpoints.md). Cloud-specific status lives in [`docs/daytona-backlog.md`](./docs/daytona-backlog.md) and [`docs/hertzner_backlog.md`](./docs/hertzner_backlog.md). **Read them before making non-trivial changes to the lifecycle code.**
- **Checkpoints** — `docker commit` (+ periodic `FROM scratch` flatten) for docker; Daytona snapshots (`sb._experimental_createSnapshot`) for daytona; Hetzner `create_image` snapshots (no-pause default — matches `docker commit`) for hetzner; Vercel `sb.snapshot()` (id-addressed; stores the snapshot id in the cloud-checkpoint manifest) for vercel. All flow through `provider.checkpoint.create`. `box.defaultCheckpoint` is the cross-provider fallback; `box.defaultCheckpointDocker` / `box.defaultCheckpointDaytona` / `box.defaultCheckpointHetzner` / `box.defaultCheckpointVercel` override per provider.
- The full design — file-handling rationale, the checkpoint model, pause/resume strategy, what we explicitly rejected — lives in [`docs/architecture.md`](./docs/architecture.md) and [`docs/create-and-checkpoints.md`](./docs/create-and-checkpoints.md). Cloud-specific status lives in [`docs/daytona-backlog.md`](./docs/daytona-backlog.md), [`docs/hertzner_backlog.md`](./docs/hertzner_backlog.md), and [`docs/vercel-backlog.md`](./docs/vercel-backlog.md). **Read them before making non-trivial changes to the lifecycle code.**

## Important notes

Expand Down Expand Up @@ -73,3 +74,4 @@ Each topic has a dedicated file under [`docs/`](./docs). Read the relevant one b
- [`docs/cloud-create-flow.md`](./docs/cloud-create-flow.md) — step-by-step walk of `agentbox create --provider daytona`: how `.git` and workspace files get into the box (git bundle + stash + untracked tar), cloud checkpoints, the **base snapshot vs project snapshot** tiers, and the docker-auto-builds-but-daytona-doesn't asymmetry.
- [`docs/daytona-backlog.md`](./docs/daytona-backlog.md) — what's done vs still missing on the Daytona path. Quick index of where each cloud feature actually lives.
- [`docs/hertzner_backlog.md`](./docs/hertzner_backlog.md) — Hetzner provider build-out status: phase-by-phase progress, the live e2e smoke results, deferred follow-ups (per-project snapshot tier, `--pause` checkpoint flag, `agentbox prune --provider hetzner`, the install-script post-Chromium trace mystery). Filename uses the user-requested spelling.
- [`docs/vercel-backlog.md`](./docs/vercel-backlog.md) — Vercel provider build-out status: why Vercel's shape differs (no Dockerfile, no containers, no SSH, persistent snapshots), phase-by-phase progress, and the live-verify checklist (user mapping, attach latency / ttyd upgrade, snapshot-vs-delete cascade, VNC on AL2023, published-CLI asset staging).
2 changes: 2 additions & 0 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"dependencies": {
"@clack/prompts": "^0.9.0",
"@daytonaio/sdk": "^0.179.0",
"@vercel/sandbox": "^2.0.1",
"@xterm/headless": "^5.5.0",
"commander": "^12.1.0",
"execa": "^9.5.2",
Expand All @@ -72,6 +73,7 @@
"@agentbox/sandbox-daytona": "workspace:*",
"@agentbox/sandbox-docker": "workspace:*",
"@agentbox/sandbox-hetzner": "workspace:*",
"@agentbox/sandbox-vercel": "workspace:*",
"@types/node": "^22.10.1",
"tsup": "^8.3.5",
"typescript": "^5.7.2",
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ export const createCommand = new Command('create')
const providerName = opts.provider ?? cfg.effective.box.provider ?? 'docker';
const checkpointRef = resolveCheckpointRef(
opts,
resolveDefaultCheckpoint(cfg.effective, providerName as 'docker' | 'daytona' | 'hetzner'),
resolveDefaultCheckpoint(cfg.effective, providerName as 'docker' | 'daytona' | 'hetzner' | 'vercel'),
);

// Cloud providers that use the Daytona public-URL path don't need
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/commands/prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ export const prepareCommand = new Command('prepare')
)
.option(
'-p, --provider <name>',
'provider to prepare (docker | daytona | hetzner). Omit for status-only.',
'provider to prepare (docker | daytona | hetzner | vercel). Omit for status-only.',
)
.option(
'-n, --name <name>',
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { dashboardCommand } from './commands/dashboard.js';
import { daytonaCommand } from '@agentbox/sandbox-daytona/cli';
import { dockerCommand } from './commands/docker.js';
import { hetznerCommand } from '@agentbox/sandbox-hetzner/cli';
import { vercelCommand } from '@agentbox/sandbox-vercel/cli';
import { destroyCommand } from './commands/destroy.js';
import { downloadCommand } from './commands/download.js';
import { driveCommand } from './commands/drive.js';
Expand Down Expand Up @@ -105,6 +106,7 @@ program.addCommand(relayCommand);
program.addCommand(runQueuedJobCommand, { hidden: true });
program.addCommand(daytonaCommand);
program.addCommand(hetznerCommand);
program.addCommand(vercelCommand);
program.addCommand(dockerCommand);
program.addCommand(updateCommand);
program.addCommand(installCommand);
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/provider/argv-prefix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Provider-prefix argv sugar:
*
* agentbox <provider> <subcmd> [...rest]
* where provider ∈ {docker, daytona, hetzner}
* where provider ∈ {docker, daytona, hetzner, vercel}
* and subcmd ∈ SUGARED_COMMANDS
*
* ↓ rewritten before commander parses
Expand Down
13 changes: 11 additions & 2 deletions apps/cli/src/provider/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
import type { EffectiveConfig } from '@agentbox/config';
import type { BoxRecord, Provider, ProviderName } from '@agentbox/core';

export type KnownProviderName = 'docker' | 'daytona' | 'hetzner';
export type KnownProviderName = 'docker' | 'daytona' | 'hetzner' | 'vercel';

const KNOWN: readonly KnownProviderName[] = ['docker', 'daytona', 'hetzner'];
const KNOWN: readonly KnownProviderName[] = ['docker', 'daytona', 'hetzner', 'vercel'];

export function isKnownProvider(name: string): name is KnownProviderName {
return (KNOWN as readonly string[]).includes(name);
Expand Down Expand Up @@ -45,6 +45,15 @@ export async function getProvider(name: ProviderName): Promise<Provider> {
await mod.ensureHetznerCredentials();
return mod.hetznerProvider;
}
case 'vercel': {
// Same lazy-import pattern. `ensureVercelCredentials` walks the user
// through `agentbox vercel login` (OIDC or token trio) on first use. The
// base-snapshot gate lives inside `backend.provision` (so `prepare` can
// build it without tripping the gate), matching the hetzner shape.
const mod = await import('@agentbox/sandbox-vercel');
await mod.ensureVercelCredentials();
return mod.vercelProvider;
}
default:
throw new Error(`unknown sandbox provider: ${String(name)}`);
}
Expand Down
13 changes: 11 additions & 2 deletions apps/cli/test/argv-prefix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,21 @@ describe('rewriteProviderPrefix', () => {
});

it('leaves unknown two-token combinations alone', () => {
// `agentbox vercel create` — vercel isn't a known provider.
expect(rewriteProviderPrefix(argv('vercel', 'create'))).toEqual(argv('vercel', 'create'));
// `agentbox fly create` — fly isn't a known provider.
expect(rewriteProviderPrefix(argv('fly', 'create'))).toEqual(argv('fly', 'create'));
// `agentbox daytona stop` — stop isn't sugared.
expect(rewriteProviderPrefix(argv('daytona', 'stop'))).toEqual(argv('daytona', 'stop'));
});

it('rewrites `vercel <sugared>` to `--provider vercel`', () => {
expect(rewriteProviderPrefix(argv('vercel', 'create'))).toEqual(
argv('create', '--provider', 'vercel'),
);
expect(rewriteProviderPrefix(argv('vercel', 'claude'))).toEqual(
argv('claude', '--provider', 'vercel'),
);
});

it('handles short argvs without crashing', () => {
expect(rewriteProviderPrefix([NODE, BIN])).toEqual([NODE, BIN]);
expect(rewriteProviderPrefix(argv('daytona'))).toEqual(argv('daytona'));
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/test/provider-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ describe('provider/registry', () => {
});

it('getProvider rejects unknown names', async () => {
await expect(getProvider('vercel' as 'docker')).rejects.toThrow(/unknown sandbox provider/);
await expect(getProvider('fly' as 'docker')).rejects.toThrow(/unknown sandbox provider/);
});

it('providerForBox defaults a missing provider tag to docker', async () => {
Expand Down
3 changes: 3 additions & 0 deletions apps/cli/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ export default defineConfig({
// `__require` shim ("Dynamic require of 'util' is not supported"). Keep
// it external; the published `agent-box` package lists it as a real dep.
'@daytonaio/sdk',
// @vercel/sandbox bundles undici, which uses dynamic `require('assert')`
// etc. — same ESM `__require` breakage. External + real dep, like daytona.
'@vercel/sandbox',
],
noExternal: [/^@agentbox\//],
banner: {
Expand Down
59 changes: 53 additions & 6 deletions docs/cloud-providers.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
# Cloud providers

> _Status: v1 ships with Daytona + Hetzner. The provider abstraction is generic — adding another cloud is ~150 lines (see §6)._
> _Status: v1 ships with Daytona + Hetzner + Vercel. The provider abstraction is generic — adding another cloud is ~150 lines (see §6)._

AgentBox runs on three backends today, behind a single `Provider` interface
AgentBox runs on four backends today, behind a single `Provider` interface
(`packages/core/src/provider.ts`):

| Provider | Where the box lives | When to use it |
| --- | --- | --- |
| `docker` (default) | Local Docker container | Fast, free, owns the host. Good default. |
| `daytona` | Daytona Cloud sandbox | When the workload outgrows the laptop, when teammates need to attach, when you want a snapshot-ready remote env. |
| `hetzner` | Hetzner Cloud VPS (1:1 per box) | When you want bare-VPS control (root, full kernel, your own region), pure OpenSSH (no third-party agent in the box), and a Cloud Firewall locked to your egress IP. ~€4/mo per running box. |
| `vercel` | Vercel Sandbox (Firecracker microVM) | When you want a fast snapshot-based remote env with public HTTPS preview URLs and persistent pause/resume. No nested containers (no in-box `docker`); region `iad1` only. See §3b. |

Switch backends per box: `agentbox create --provider daytona` (or `--provider hetzner`),
or pin project-wide via `box.provider: <name>` in `agentbox.yaml`. The rest of
the CLI surface (`shell`, `claude`, `url`, `cp`, `checkpoint`, …) routes
on `box.provider` and Just Works for all three.
Switch backends per box: `agentbox create --provider daytona` (or `--provider
hetzner` / `--provider vercel`), or pin project-wide via `box.provider: <name>`
in `agentbox.yaml`. The rest of the CLI surface (`shell`, `claude`, `url`, `cp`,
`checkpoint`, …) routes on `box.provider` and Just Works for all four.

## 1. The provider abstraction

Expand Down Expand Up @@ -392,6 +393,44 @@ hetzner-specific code (verified live in Phase-7 smoke).
- `agentbox hetzner firewall show <box>` — prints current rules + the
host's current egress IP, with a `WARN` line on drift.

## 3b. The Vercel shape

Vercel Sandbox is a Firecracker microVM on Amazon Linux 2023 with first-class
snapshots and `sandbox.domain(port)` public preview URLs. Full build-out status
and the live-verify checklist live in [`vercel-backlog.md`](./vercel-backlog.md);
the shape in brief:

- **Base via snapshot, not Dockerfile.** Vercel can't build an image, so
`agentbox prepare --provider vercel` boots a fresh `node24` sandbox, runs
`packages/sandbox-vercel/scripts/provision.sh` (dnf deps, `vscode` user,
agentbox-ctl + shims, Claude native installer, codex/opencode), then
`sandbox.snapshot({ expiration: 0 })`. The snapshot id is persisted to
`~/.agentbox/vercel-prepared.json` and every `create` boots from it.
- **No nested containers.** Seccomp blocks the namespace syscalls a container
runtime needs (validated), so the provider passes `launchDockerd: false`;
in-box `docker` is unavailable. Everything else (node, python, git, tmux,
VNC, Claude Code) runs as plain processes.
- **Persistent → pause/resume for free.** Sandboxes are created `persistent:
true` with `keepLastSnapshots: { count: 1, expiration: 0 }`. `pause`/`stop`
call `sb.stop()` (auto-snapshot + shut down); `resume`/`start` call
`Sandbox.get({ resume: true })`. `destroy` deletes the sandbox and purges its
current snapshot so storage doesn't linger.
- **Preview URLs are public HTTPS.** `previewUrl`/`signedPreviewUrl` both return
`sandbox.domain(port)` — reachable from the host browser AND from inside the
box, so (like Daytona) the Portless in-box mirror is skipped. Max 4 exposed
ports; we declare 80 (WebProxy), 6080 (noVNC), 8788 (relay/ctl bridge).
- **No SSH → custom attach.** `@vercel/sandbox` exposes no stdin/PTY channel, so
`buildAttach` is overridden to spawn `attach-helper.js`, which bridges the
local terminal to a box-side tmux session via `send-keys`/`capture-pane` over
the SDK. (A ttyd/WebSocket terminal is the planned latency upgrade — see the
backlog.)
- **Checkpoints store the snapshot id.** Vercel snapshots are id-addressed, so
the provider overrides `checkpoint` to write the Vercel snapshot id into the
cloud-checkpoint manifest's `snapshotName` field; restore boots from it.
Caveat: `sb.snapshot()` stops the source box (it auto-resumes on next call).
- **Hard platform limits:** region `iad1` only, 32 GB fixed disk, 2048 MB RAM
per vCPU, 45 min (Hobby) / 5 hr (Pro+) sessions.

## 4. Authentication

`agentbox daytona login` is the supported path. It prompts for
Expand All @@ -406,6 +445,14 @@ Security → API Tokens page), validates it via `GET /locations`, and
persists it to the same `~/.agentbox/secrets.env`. First-time use of
`--provider hetzner` triggers the login prompt automatically.

`agentbox vercel login` is the Vercel equivalent. Two auth modes: the
recommended **OIDC** path (`vercel link && vercel env pull` writes
`VERCEL_OIDC_TOKEN` into `.env.local`, which the SDK reads directly — the dev
token expires ~12h, re-pull when it does), or an **access token** trio
(`VERCEL_TOKEN` + `VERCEL_TEAM_ID` + `VERCEL_PROJECT_ID`) persisted to
`~/.agentbox/secrets.env`. First-time use of `--provider vercel` triggers the
prompt automatically.

## 5. Known caveats

- **Destroy lag in the Daytona dashboard**: `sb.delete()` returns immediately
Expand Down
Loading
Loading