diff --git a/CLAUDE.md b/CLAUDE.md index d2851f4..37ad662 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # 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 @@ -8,10 +8,11 @@ - **docker**: container `agentbox-`; `/workspace` is the in-container git worktree on branch `agentbox/`; 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//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 @@ -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). diff --git a/apps/cli/package.json b/apps/cli/package.json index 84421f8..2ab7cd4 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -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", @@ -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", diff --git a/apps/cli/src/commands/create.ts b/apps/cli/src/commands/create.ts index 7eeb03e..879a596 100644 --- a/apps/cli/src/commands/create.ts +++ b/apps/cli/src/commands/create.ts @@ -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 diff --git a/apps/cli/src/commands/prepare.ts b/apps/cli/src/commands/prepare.ts index fef6ce8..93b654b 100644 --- a/apps/cli/src/commands/prepare.ts +++ b/apps/cli/src/commands/prepare.ts @@ -201,7 +201,7 @@ export const prepareCommand = new Command('prepare') ) .option( '-p, --provider ', - 'provider to prepare (docker | daytona | hetzner). Omit for status-only.', + 'provider to prepare (docker | daytona | hetzner | vercel). Omit for status-only.', ) .option( '-n, --name ', diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index e200c46..edf2688 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -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'; @@ -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); diff --git a/apps/cli/src/provider/argv-prefix.ts b/apps/cli/src/provider/argv-prefix.ts index 3b73912..350dd4a 100644 --- a/apps/cli/src/provider/argv-prefix.ts +++ b/apps/cli/src/provider/argv-prefix.ts @@ -2,7 +2,7 @@ * Provider-prefix argv sugar: * * agentbox [...rest] - * where provider ∈ {docker, daytona, hetzner} + * where provider ∈ {docker, daytona, hetzner, vercel} * and subcmd ∈ SUGARED_COMMANDS * * ↓ rewritten before commander parses diff --git a/apps/cli/src/provider/registry.ts b/apps/cli/src/provider/registry.ts index 3b1aee9..09fc1c1 100644 --- a/apps/cli/src/provider/registry.ts +++ b/apps/cli/src/provider/registry.ts @@ -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); @@ -45,6 +45,15 @@ export async function getProvider(name: ProviderName): Promise { 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)}`); } diff --git a/apps/cli/test/argv-prefix.test.ts b/apps/cli/test/argv-prefix.test.ts index 6ee675a..2ed4e0c 100644 --- a/apps/cli/test/argv-prefix.test.ts +++ b/apps/cli/test/argv-prefix.test.ts @@ -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 ` 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')); diff --git a/apps/cli/test/provider-registry.test.ts b/apps/cli/test/provider-registry.test.ts index 5a7dee9..541bfea 100644 --- a/apps/cli/test/provider-registry.test.ts +++ b/apps/cli/test/provider-registry.test.ts @@ -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 () => { diff --git a/apps/cli/tsup.config.ts b/apps/cli/tsup.config.ts index aa25b9b..9929d8a 100644 --- a/apps/cli/tsup.config.ts +++ b/apps/cli/tsup.config.ts @@ -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: { diff --git a/docs/cloud-providers.md b/docs/cloud-providers.md index 909c93f..96c9f56 100644 --- a/docs/cloud-providers.md +++ b/docs/cloud-providers.md @@ -1,8 +1,8 @@ # 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 | @@ -10,11 +10,12 @@ AgentBox runs on three backends today, behind a single `Provider` interface | `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: ` 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: ` +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 @@ -392,6 +393,44 @@ hetzner-specific code (verified live in Phase-7 smoke). - `agentbox hetzner firewall show ` — 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 @@ -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 diff --git a/docs/vercel-backlog.md b/docs/vercel-backlog.md new file mode 100644 index 0000000..a4677ff --- /dev/null +++ b/docs/vercel-backlog.md @@ -0,0 +1,188 @@ +# Vercel provider — build-out status + +Status of the `@agentbox/sandbox-vercel` backend (Vercel Sandbox — Firecracker +microVMs + snapshots). Same `CloudBackend` shape as Daytona/Hetzner, composed by +`@agentbox/sandbox-cloud`'s `createCloudProvider`. Maintained live during +implementation (per the project convention), not as end-of-PR cleanup. + +## Why Vercel is shaped differently + +- **No custom image.** Vercel Sandbox is Amazon Linux 2023 only; there's no + Dockerfile build. The base environment is a **Vercel snapshot** baked once by + `agentbox prepare --provider vercel` (boot fresh node24 → run `provision.sh` + → `sandbox.snapshot()`), exactly the hetzner-style one-time prerequisite. +- **No nested containers** (validated 2026-05-18, memory + `project-vercel-sandbox-no-containers`): seccomp blocks `clone`/`unshare`, no + `CAP_SYS_ADMIN`. The provider sets `launchDockerd: false`; in-box `docker` is + unavailable by design. +- **No SSH.** `sandbox.domain(port)` is an HTTPS(+WebSocket) proxy only. There's + no `attachArgv`; attach goes through a custom SDK-streaming helper. +- **Persistent by default.** Stopping a sandbox auto-snapshots; the next + `Sandbox.get({ resume: true })` resumes from it. That maps cleanly to + pause/resume — `pause == stop`, `resume == start`. +- **Hard limits:** region `iad1` only, 32 GB fixed ephemeral disk, 2048 MB RAM + per vCPU (coupled), **≤4 exposed ports** (we use 80 / 6080 / 8788, one free), + 45 min (Hobby) / 5 hr (Pro+) max session. + +## Phase status + +- [x] **Phase 0 — package scaffold.** `packages/sandbox-vercel` (tsup/tsconfig/ + vitest), `@vercel/sandbox` dep, registry + argv-prefix + CLI registration, + config `ProviderKind`/`defaultCheckpointVercel`, relay `resolveCloudBackend`. +- [x] **Phase 1 — credentials + SDK loader.** OIDC (`VERCEL_OIDC_TOKEN`) and + access-token trio (`VERCEL_TOKEN`/`VERCEL_TEAM_ID`/`VERCEL_PROJECT_ID`); + `agentbox vercel login` + `--status`; env auto-load from + `~/.agentbox/secrets.env` and `.env.local`. +- [x] **Phase 2 — `CloudBackend`.** provision/get/list/start/stop/pause/resume/ + destroy/state/exec/uploadFile/downloadFile/listFiles/previewUrl/ + signedPreviewUrl + snapshot helpers, all mapped to `@vercel/sandbox` 2.x. +- [x] **Phase 3 — prepare + provision.sh.** Base-snapshot bake with context + fingerprinting + skip-fast; AL2023 installer (dnf, vscode user, ctl/vnc/shims, + Claude native installer, codex/opencode). +- [x] **Phase 4 — attach.** `buildVercelAttach` + `attach-helper.js` tmux bridge + (send-keys / capture-pane pump over the SDK). +- [x] **Phase 5 — checkpoints.** Provider-level `checkpoint` override storing the + Vercel snapshot **id** in the cloud-checkpoint manifest (Vercel snapshots are + id-addressed, not name-addressed). +- [x] **Phase 6 — unit tests.** env-loader, credentials, prepared-state, + backend (mocked SDK), build-attach. `pnpm build && lint && typecheck && test` + all green. + +## What's still missing + +The code builds/lints/typechecks and the unit suite (pure, mocked SDK) is green. +Two live e2e passes ran **2026-05-28**, both with a `VERCEL_TOKEN` access-token +trio (`VERCEL_TOKEN`/`VERCEL_TEAM_ID`/`VERCEL_PROJECT_ID`) — every dev OIDC token +kept arriving already-expired, so the access-token trio is the practical path for +anything long-running like `prepare`. The two passes (in-box, then re-validated +from the host repo via `scripts/vercel-live-e2e.sh`) confirmed prepare → create → +boot → pause/resume → checkpoint round-trip → destroy, and surfaced three real +bugs, all now fixed (see "Bugs found live" below). Only the relay round-trip (#4) +is still unconfirmed (it's interactive — needs a pushable origin). The list below +is the actionable backlog, roughly in priority order. + +### P0 — first live smoke pass + +Confirmed live 2026-05-28: + +1. [x] **`prepare` / `provision.sh` completes on AL2023.** Bakes a base snapshot + (~1.3 GB) in a few minutes; the snapshot comes back usable. claude / codex / + opencode are all present in a booted box. +2. [x] **User mapping.** A booted box runs as `vscode` (uid 1001) with `/workspace` + checked out on `agentbox/`; `docker` is correctly unavailable + (`launchDockerd:false`). vscode passwordless sudo now works (see bug #3). +3. [x] **Workspace seed.** The shallow-clone seed (`$SUDO rm/mkdir/chown` + + tar-extract as vscode) lands `/workspace` on the box branch — gated on the + sudoers fix (#3). Agent-credential / carry / env-file ownership beyond this was + not separately audited but the box boots with the agent CLIs present. +4. [ ] **Relay round-trip.** Confirm the host `CloudBoxPoller` reaches the in-box + relay over `sandbox.domain(8788)` and that `agentbox-ctl git push|pull` + + `gh pr` work from inside a vercel box. (Still open — interactive; see runbook.) +5. [x] **Lifecycle semantics.** `stop` auto-snapshots (live status `running → + stopping → stopped` in ~18 s); `start` resumes (`get({resume:true})`) with the + same `/workspace` (marker survived); `destroy` preserves the base; the public + `*.vercel.run` preview URL is stable across a stop/start (did not rotate). +6. [x] **Checkpoint round-trip.** `agentbox checkpoint create` snapshots, the + manifest stores the Vercel snapshot **id**, and `create --snapshot ` boots + from it with the captured `/workspace` intact. + +#### Bugs found live 2026-05-28 (fixed) + +- **vscode had no working passwordless sudo → workspace seed failed.** Vercel's + AL2023 base ships `/etc/sudoers` with **no `@includedir /etc/sudoers.d`** (and + non-0440 perms), so provision.sh's `/etc/sudoers.d/90-agentbox-vscode` drop-in + was silently ignored and `sudo -n` as vscode failed with "a password is + required" — breaking the workspace-seed `$SUDO rm/mkdir/chown` (and it would + break ctl-launch / carry too). provision.sh now appends the includedir, + normalises `/etc/sudoers` to 0440, and `visudo -cf`-validates the result. +- **`destroy` nuked the shared base snapshot.** A box created from a snapshot has + `currentSnapshotId === sourceSnapshotId` until it pauses/snapshots itself, so a + naive "delete `currentSnapshotId` on destroy" deleted the shared base and broke + every later `create` with a 410. `destroy` now purges only a box's *own* + auto-snapshot (`snapId !== source && snapId !== base`). Covered by a unit test in + `packages/sandbox-vercel/test/backend.test.ts`. +- **`prepare` skip-fast treated a deleted snapshot as present.** `Snapshot.get` + resolves deleted/failed tombstones (`status: 'deleted'|'failed'`, `sizeBytes: 0`) + instead of throwing, so "get didn't throw" wrongly meant "exists." The skip check + now requires `status === 'created'` (`prepare.ts`). + +The platform-side root causes (the AL2023 sudoers gap, the `Snapshot.get` +tombstone behavior, the `currentSnapshotId === sourceSnapshotId` aliasing, the +`list`/`get` inconsistency, and the headless-OIDC refresh failure) are written up +for the Vercel team in [`docs/vercel-sandbox-findings.md`](./vercel-sandbox-findings.md). + +#### Running the remaining P0 checks + +`scripts/vercel-live-e2e.sh` automates items #5 (pause/resume + `/workspace` +survival) and #6 (checkpoint round-trip), plus a regression for the destroy/base +guard. It must run from a context that holds a `VERCEL_TOKEN` trio — e.g. the host +repo checkout (with `pnpm build` run) or a box with the repo built, and the trio +in env. Pass `AGENTBOX_BIN="node /apps/cli/dist/index.js"` since the +published CLI can't do `--provider vercel` yet (backlog #9). It avoids the laggy +attach bridge: the `/workspace` marker travels over `agentbox cp` (the +relay-backed provider transfer), the snapshot id is read from the checkpoint +manifest, and **box state is read from the live Vercel SDK** +(`packages/sandbox-vercel/test/live-state.mjs`) — *not* `agentbox list`, which +reports cloud boxes as optimistically `running` with no live probe +(`sandbox-docker/src/lifecycle.ts`, "tracked for Phase 6"). + +``` +VERCEL_TOKEN=… VERCEL_TEAM_ID=… VERCEL_PROJECT_ID=… \ + AGENTBOX_BIN="node $PWD/apps/cli/dist/index.js" bash scripts/vercel-live-e2e.sh +``` + +Item #4 (relay round-trip) is inherently interactive and needs a pushable origin +the host relay can reach, so it's opt-in (`E2E_RELAY=1`) and otherwise printed as +a manual runbook: `agentbox shell ` → commit in `/workspace` → +`agentbox-ctl git push` → confirm on the host that `git ls-remote origin +agentbox/` shows the commit, then try `agentbox-ctl git pull` and a `gh pr`. + +### P1 — known functional gaps + +7. **VNC on AL2023 — confirmed broken.** The e2e showed the VNC daemon launch + failing at create/start (`agentbox-vnc-start failed: websockify did not bind + 6080 within 5s`); the box continues (it's best-effort) but `agentbox screen` + won't work. `tigervnc-server` + `websockify` (pip) + noVNC (git clone) / + `agentbox-vnc-start` need fixing for AL2023 (the script was written for + Debian/Ubuntu). +8. **Attach is laggy.** The `send-keys`/`capture-pane` pump is real but + higher-latency than a PTY and repaints the whole pane (cursor position not + preserved). **Upgrade:** a ttyd / WebSocket terminal over `sandbox.domain(port)` + (WebSocket works through the domain proxy — noVNC relies on it) — needs a ttyd + binary in the snapshot + a ws client in `attach-helper.ts`, and the 4th port. +9. **Published-CLI asset staging.** `buildVercelAttach` resolves `attach-helper.js` + next to its own dist (monorepo only); `runtime-assets.ts` resolves `provision.sh` + + ctl/shims from monorepo paths. The standalone `@madarco/agentbox` bundle needs + all of these staged into its runtime tree via `apps/cli/scripts/stage-runtime.mjs` + + a `runtime/vercel/` resolver branch. Until then, `--provider vercel` only works + from a monorepo checkout, not the published CLI. +10. **Builder cleanup after `prepare`.** We deliberately do NOT `delete()` the + builder sandbox after `snapshot()` (in case delete cascades to the snapshot). + Confirm a snapshot survives its source's deletion; if so, delete the builder + so it isn't left for Vercel's reaper. +11. **OIDC 12h expiry friction.** Dev OIDC tokens last ~12h, so a long `prepare` + can outlive the token. `resolveCredentials` detects expiry with a clear + message, but there's no auto-refresh. Document the access-token trio as the + recommended path for long operations (it doesn't expire on the 12h cycle). +12. **No per-provider resource/region/timeout config.** `vcpus` defaults to 2, + timeout to 45 min, region is fixed `iad1` (Vercel constraint). The + "per-provider VM size config" TODO (already tracked in the repo TODO.md) + should cover vercel `box.vercel.vcpus` / `timeoutMs`. + +### P2 — deferred (parity niceties, not blocking) + +13. **`agentbox checkpoint list` aggregate view** shows only docker + daytona + (hetzner is also absent). Add vercel (and hetzner) to the merged list in + `apps/cli/src/commands/checkpoint.ts`. +14. **Per-project snapshot tier** — the daytona/hetzner `projects[]` + optimization that skips workspace/credential re-seeding on repeat creates for + the same project. `prepared-state.ts` is single-tier (base only) today. +15. **`agentbox prune --provider vercel`** — the backend `list()` works; the + prune command branch isn't wired. +16. **`Sandbox.fork()`** as a faster "branch from a running box" primitive than + snapshot + create (Vercel-native, no host round-trip). +17. **4th port / per-service `expose`.** Only 3 of the 4 allowed ports are used + (80/6080/8788); per-service `expose` URLs from `agentbox.yaml` beyond the + WebProxy aren't surfaced (the scaffold tries, but we're near the port cap). +18. **`networkPolicy` / `extendTimeout`** are unused — could expose egress + locking (a safety win, cf. the hetzner firewall) and longer single sessions. diff --git a/docs/vercel-sandbox-findings.md b/docs/vercel-sandbox-findings.md new file mode 100644 index 0000000..e67f97c --- /dev/null +++ b/docs/vercel-sandbox-findings.md @@ -0,0 +1,132 @@ +# Vercel Sandbox — findings & gotchas (to share with Vercel) + +Context: we built a provider for [AgentBox](https://github.com/madarco/agentbox) +on top of **`@vercel/sandbox@2.0.1`**, baking a base environment once via +`sandbox.snapshot()` and booting per-task sandboxes from it (Amazon Linux 2023, +`node24` runtime image, Firecracker microVM, `iad1`). Auth via a personal access +token trio (`VERCEL_TOKEN` + team id + project id). Everything below was observed +live on 2026-05-28; nothing here is a guess. + +We're sharing this as constructive feedback — the platform worked well overall +(snapshot bake + boot in well under a minute, persistent stop/resume, public +preview URLs all behaved). These are the rough edges that cost us real debugging +time. + +## Bugs / surprising behavior + +### 1. AL2023 base `/etc/sudoers` has no `@includedir /etc/sudoers.d` and wrong perms + +**Highest-impact issue for us.** On the `node24` Amazon Linux 2023 base image: + +- `/etc/sudoers` does **not** contain an `@includedir /etc/sudoers.d` (nor the + legacy `#includedir`) directive, so **any drop-in file under `/etc/sudoers.d/` + is silently ignored.** +- `visudo -cf /etc/sudoers` reports `/etc/sudoers: bad permissions, should be + mode 0440` — i.e. the base file's permissions are not what sudo expects. + +Net effect: a standard, idiomatic setup — create a user, drop +`/etc/sudoers.d/90-myuser` with `myuser ALL=(ALL) NOPASSWD: ALL`, `chmod 0440` — +does **nothing**. `sudo -n` as that user fails with `sudo: a password is +required`, which is baffling because the drop-in is present, correct, and +0440-owned-by-root. + +Repro (from a sandbox booted on the base): +``` +$ cat /etc/sudoers.d/90-foo # vscode ALL=(ALL) NOPASSWD: ALL (0440 root:root) +$ grep includedir /etc/sudoers # (no match) +$ sudo -u foo sudo -n true # sudo: a password is required +$ visudo -cf /etc/sudoers # /etc/sudoers: bad permissions, should be mode 0440 +``` + +Our workaround: append `@includedir /etc/sudoers.d` to `/etc/sudoers`, `chmod +0440 /etc/sudoers`, then `visudo -cf` to validate, during the snapshot bake. + +Suggestion: ship the AL2023 base with a standard sudoers that includes +`/etc/sudoers.d` and is mode 0440 (matching Debian/Ubuntu and stock AL2023 +cloud images), so drop-ins work as expected. + +### 2. `Snapshot.get()` resolves deleted/failed snapshots instead of failing + +`Snapshot.get({ snapshotId })` returns a value for snapshots that have been +**deleted or failed** — a tombstone with `status: 'deleted' | 'failed'` and +`sizeBytes: 0` — rather than throwing or 404-ing. So "`get` didn't throw" does +**not** mean "usable": a subsequent `Sandbox.create({ source: { type: +'snapshot', snapshotId }})` from a `deleted` snapshot fails with a 410. + +This made our "does the base snapshot already exist?" fast-path wrong — it +treated a deleted base as present and skipped the rebuild. We now gate on +`status === 'created'`. + +Suggestion: either throw/404 from `get` for non-existent snapshots, or document +the `status` field prominently as the gate for usability. + +### 3. A fresh sandbox's `currentSnapshotId` equals its `sourceSnapshotId` + +A sandbox created from a snapshot reports `currentSnapshotId === +sourceSnapshotId === `, and only diverges once it +stops/auto-snapshots itself. + +This is an easy footgun: a naive cleanup that deletes `currentSnapshotId` on +teardown will delete the **shared source snapshot** that every other sandbox was +created from — breaking all later `create`s from it with 410s. We had to guard +teardown with `currentSnapshotId !== sourceSnapshotId`. + +Suggestion: leave `currentSnapshotId` unset/null until the sandbox creates its +own snapshot, or document this aliasing clearly. + +### 4. `Sandbox.list()` vs `Sandbox.get()` inconsistency for stopped/expired sandboxes + +`Sandbox.list()` keeps returning recently-stopped/expired sandboxes (with +`status: 'stopped'`), but `Sandbox.get({ sandboxId })` on the same name then +returns **404**. Also, the items returned by `list()` are summaries without a +`.delete()` method, so cleanup requires `get()` → `.delete()` — which 404s for +exactly these lingering entries. The result is "ghost" sandboxes that show up in +`list()` but can't be acted on; they eventually age out. + +Suggestion: make `list()` and `get()` agree on lifecycle state, and/or expose +delete on the list summaries (or accept a delete-by-id that tolerates +already-reaped sandboxes). + +### 5. OIDC dev tokens can't be used headlessly without the Vercel CLI + +`VERCEL_OIDC_TOKEN` from `vercel env pull` lasts ~12h and the SDK's env-OIDC path +(`@vercel/oidc`) tries to **refresh** it via the local Vercel CLI's +`.vercel/project.json` + cached auth. In a headless sandbox/CI box that linkage +doesn't exist, so it fails with `Could not get credentials from OIDC context` — +even though the token itself is still a valid bearer. + +Our workaround: decode the OIDC JWT's `owner_id`/`project_id` claims and pass +`{ token, teamId, projectId }` explicitly (the SDK's direct-credentials path), +which works. But the practical path for any long-running operation (our base +bake takes a few minutes and can outlive a token) ended up being the +non-expiring access-token trio. + +Suggestion: support a non-CLI OIDC code path (use the token directly as a bearer +without attempting a CLI-backed refresh), and/or offer longer-lived tokens for +CI/headless use. + +## Platform constraints we worked around (not bugs — for your awareness) + +- **No nested containers.** seccomp blocks `clone`/`unshare` of new namespaces + and there's no `CAP_SYS_ADMIN`, so no Docker/Podman/buildah inside a sandbox + (rootless or rootful). We disable our in-box Docker on this provider. This is a + reasonable isolation boundary; just worth stating explicitly in the docs. +- **`tigervnc-server` + `websockify` on AL2023.** Our VNC stack (Xvnc + + websockify + noVNC), written for Debian/Ubuntu, doesn't come up on AL2023 + (websockify never binds its port). Likely our packaging, but flagging in case + the AL2023 package set differs in a way you'd want to document. +- **Resource/region limits.** `iad1` only, ≤4 exposed ports, RAM coupled to vCPU + count, max session length (45 min Hobby / 5 hr Pro+). All fine for us once + known; a one-page "sandbox limits" reference would help. + +## What worked well + +- `sandbox.snapshot()` bake → `Sandbox.create({ source: snapshot })` boot is fast + (~30–60s boot from a ~1.3 GB base) and reliable. +- Persistent sandboxes: `stop` auto-snapshots and `get({ resume: true })` resumes + with the filesystem intact — clean mapping to pause/resume. Live status + transitions (`running → stopping → stopped`) are observable and quick (~18s). +- Public `sandbox.domain(port)` preview URLs (HTTPS + WebSocket) were stable + across a stop/start cycle (did not rotate). +- `writeFiles` / `runCommand({ sudo: true })` / `readdir` / `downloadFile` were + straightforward to build a file-transfer + exec layer on. diff --git a/packages/config/schema/user-config.schema.json b/packages/config/schema/user-config.schema.json index f3b8325..7034495 100644 --- a/packages/config/schema/user-config.schema.json +++ b/packages/config/schema/user-config.schema.json @@ -14,6 +14,8 @@ "defaultCheckpoint": { "type": "string", "minLength": 1 }, "defaultCheckpointDocker": { "type": "string", "minLength": 1 }, "defaultCheckpointDaytona": { "type": "string", "minLength": 1 }, + "defaultCheckpointHetzner": { "type": "string", "minLength": 1 }, + "defaultCheckpointVercel": { "type": "string", "minLength": 1 }, "withPlaywright": { "type": "boolean" }, "withEnv": { "type": "boolean" }, "vnc": { "type": "boolean" }, diff --git a/packages/config/src/checkpoint.ts b/packages/config/src/checkpoint.ts index bf3f65e..752a404 100644 --- a/packages/config/src/checkpoint.ts +++ b/packages/config/src/checkpoint.ts @@ -26,7 +26,9 @@ export function resolveDefaultCheckpoint( ? cfg.box.defaultCheckpointDaytona : provider === 'hetzner' ? cfg.box.defaultCheckpointHetzner - : cfg.box.defaultCheckpointDocker; + : provider === 'vercel' + ? cfg.box.defaultCheckpointVercel + : cfg.box.defaultCheckpointDocker; if (perProvider && perProvider.length > 0) return perProvider; return cfg.box.defaultCheckpoint; } @@ -42,9 +44,11 @@ export function defaultCheckpointConfigKey( | 'box.defaultCheckpoint' | 'box.defaultCheckpointDocker' | 'box.defaultCheckpointDaytona' - | 'box.defaultCheckpointHetzner' { + | 'box.defaultCheckpointHetzner' + | 'box.defaultCheckpointVercel' { if (provider === 'docker') return 'box.defaultCheckpointDocker'; if (provider === 'daytona') return 'box.defaultCheckpointDaytona'; if (provider === 'hetzner') return 'box.defaultCheckpointHetzner'; + if (provider === 'vercel') return 'box.defaultCheckpointVercel'; return 'box.defaultCheckpoint'; } diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 0aa559d..9810e55 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -12,7 +12,7 @@ export type IdeFlavor = 'vscode' | 'cursor' | 'auto'; export type EngineKind = 'orbstack' | 'docker-desktop' | 'other' | 'auto'; export type BrowserKind = 'agent-browser' | 'playwright' | 'both'; /** Sandbox backend new boxes are created on. */ -export type ProviderKind = 'docker' | 'daytona' | 'hetzner'; +export type ProviderKind = 'docker' | 'daytona' | 'hetzner' | 'vercel'; /** Where `agentbox claude|codex|opencode` opens the attached session when the host * shell is running inside tmux or iTerm2. `same` keeps today's inline behavior. */ export type AttachOpenIn = 'split' | 'window' | 'tab' | 'same'; @@ -34,6 +34,7 @@ export interface UserConfig { defaultCheckpointDocker?: string; defaultCheckpointDaytona?: string; defaultCheckpointHetzner?: string; + defaultCheckpointVercel?: string; withPlaywright?: boolean; withEnv?: boolean; vnc?: boolean; @@ -121,6 +122,7 @@ export interface EffectiveConfig { defaultCheckpointDocker: string; defaultCheckpointDaytona: string; defaultCheckpointHetzner: string; + defaultCheckpointVercel: string; withPlaywright: boolean; withEnv: boolean; vnc: boolean; @@ -227,6 +229,7 @@ export const BUILT_IN_DEFAULTS: EffectiveConfig = { defaultCheckpointDocker: '', defaultCheckpointDaytona: '', defaultCheckpointHetzner: '', + defaultCheckpointVercel: '', withPlaywright: false, withEnv: false, vnc: true, @@ -320,9 +323,9 @@ export const KEY_REGISTRY: readonly KeyDescriptor[] = [ { key: 'box.provider', type: 'enum', - enumValues: ['docker', 'daytona', 'hetzner'] as const, + enumValues: ['docker', 'daytona', 'hetzner', 'vercel'] as const, description: - 'Sandbox backend new boxes are created on: local Docker containers, Daytona Cloud sandboxes, or Hetzner Cloud VPSes.', + 'Sandbox backend new boxes are created on: local Docker containers, Daytona Cloud sandboxes, Hetzner Cloud VPSes, or Vercel Sandboxes.', }, { key: 'box.hostSnapshot', @@ -357,6 +360,13 @@ export const KEY_REGISTRY: readonly KeyDescriptor[] = [ 'Per-provider override of `box.defaultCheckpoint` for hetzner. Wins over the global when set; set via `agentbox checkpoint set-default --provider hetzner`.', advanced: true, }, + { + key: 'box.defaultCheckpointVercel', + type: 'string', + description: + 'Per-provider override of `box.defaultCheckpoint` for vercel. Wins over the global when set; set via `agentbox checkpoint set-default --provider vercel`.', + advanced: true, + }, { key: 'checkpoint.maxLayers', type: 'int', diff --git a/packages/relay/src/host-actions.ts b/packages/relay/src/host-actions.ts index 4b8542a..424b9ab 100644 --- a/packages/relay/src/host-actions.ts +++ b/packages/relay/src/host-actions.ts @@ -118,6 +118,21 @@ export async function resolveCloudBackend(name: string): Promise { throw err; } } + if (name === 'vercel') { + const pkg = '@agentbox/sandbox-' + 'vercel'; + try { + const mod = (await import(pkg)) as { vercelBackend: CloudBackend }; + return mod.vercelBackend; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (/cannot find module|MODULE_NOT_FOUND/i.test(msg)) { + throw new Error( + `relay: cannot load '${pkg}' at runtime — install it alongside @agentbox/relay (the @madarco/agentbox CLI normally provides this dependency). Original: ${msg}`, + ); + } + throw err; + } + } throw new Error(`no host executor for cloud backend '${name}'`); } diff --git a/packages/sandbox-cloud/src/cloud-provider.ts b/packages/sandbox-cloud/src/cloud-provider.ts index cf9c2dc..9e76693 100644 --- a/packages/sandbox-cloud/src/cloud-provider.ts +++ b/packages/sandbox-cloud/src/cloud-provider.ts @@ -105,6 +105,13 @@ export interface CreateCloudProviderOptions { * Per-create cloud resource ceiling. Default: 2 cpu / 4 GiB / 8 GiB disk. */ defaultResources?: { cpu?: number; memory?: number; disk?: number }; + /** + * Whether to launch the in-box `dockerd` daemon on create/start. Default + * true (Daytona/Hetzner support nested containers). Vercel Sandbox blocks the + * namespace syscalls a container runtime needs, so its provider sets this + * false — otherwise every create/start logs a spurious dockerd failure. + */ + launchDockerd?: boolean; } const FALLBACK_IMAGE = 'agentbox/box:dev'; @@ -429,13 +436,16 @@ export function createCloudProvider( // /usr/local/bin/agentbox-dockerd-start; Daytona sandboxes ship with // CAP_SYS_ADMIN so it starts cleanly. Best-effort — a slow or failed // start shouldn't fail create; `agentbox start` re-launches it on - // resume because dockerd dies with the sandbox. - log('launching in-box dockerd'); - try { - const dockerd = await launchCloudDockerdDaemon({ backend, handle, timeoutMs: 60_000 }); - if (!dockerd.up) log(`dockerd did not become ready (continuing): ${dockerd.reason ?? 'unknown'}`); - } catch (err) { - log(`dockerd daemon launch failed (continuing): ${err instanceof Error ? err.message : String(err)}`); + // resume because dockerd dies with the sandbox. Skipped for backends + // that can't run nested containers (vercel), which set launchDockerd:false. + if (opts.launchDockerd !== false) { + log('launching in-box dockerd'); + try { + const dockerd = await launchCloudDockerdDaemon({ backend, handle, timeoutMs: 60_000 }); + if (!dockerd.up) log(`dockerd did not become ready (continuing): ${dockerd.reason ?? 'unknown'}`); + } catch (err) { + log(`dockerd daemon launch failed (continuing): ${err instanceof Error ? err.message : String(err)}`); + } } // Mint the per-box VNC password and start the in-sandbox VNC stack @@ -751,14 +761,17 @@ export function createCloudProvider( bridgeToken: box.cloud?.bridgeToken, }); // Re-launch in-box dockerd — also dies with the sandbox. Best-effort, - // mirrors the docker provider's lifecycle.ts:276 relaunch. - try { - const dockerd = await launchCloudDockerdDaemon({ backend, handle: h, timeoutMs: 60_000 }); - if (!dockerd.up) { - // swallowed; surface only on follow-up `docker info` + // mirrors the docker provider's lifecycle.ts:276 relaunch. Skipped for + // backends that can't run nested containers (vercel). + if (opts.launchDockerd !== false) { + try { + const dockerd = await launchCloudDockerdDaemon({ backend, handle: h, timeoutMs: 60_000 }); + if (!dockerd.up) { + // swallowed; surface only on follow-up `docker info` + } + } catch { + // best-effort } - } catch { - // best-effort } // Re-launch the VNC stack — Xvnc + websockify die with the sandbox. // Best-effort: a failure here shouldn't block start; `agentbox screen` diff --git a/packages/sandbox-core/src/prepared-state.ts b/packages/sandbox-core/src/prepared-state.ts index 9344057..f60f43e 100644 --- a/packages/sandbox-core/src/prepared-state.ts +++ b/packages/sandbox-core/src/prepared-state.ts @@ -23,7 +23,7 @@ import { readFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { dirname, resolve as pathResolve } from 'node:path'; -export type PreparedProviderKind = 'docker' | 'daytona' | 'hetzner'; +export type PreparedProviderKind = 'docker' | 'daytona' | 'hetzner' | 'vercel'; /** * The cross-provider record. `TImage` is the provider's opaque image diff --git a/packages/sandbox-vercel/package.json b/packages/sandbox-vercel/package.json new file mode 100644 index 0000000..c2adc66 --- /dev/null +++ b/packages/sandbox-vercel/package.json @@ -0,0 +1,48 @@ +{ + "name": "@agentbox/sandbox-vercel", + "version": "0.0.0", + "private": true, + "description": "Vercel Sandbox provider — CloudBackend over @vercel/sandbox (Firecracker microVMs + snapshots)", + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./cli": { + "types": "./dist/cli.d.ts", + "import": "./dist/cli.js" + } + }, + "files": [ + "dist", + "scripts" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "lint": "eslint src test", + "test": "vitest run", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist .turbo" + }, + "dependencies": { + "@agentbox/config": "workspace:*", + "@agentbox/core": "workspace:*", + "@agentbox/sandbox-cloud": "workspace:*", + "@agentbox/sandbox-core": "workspace:*", + "@clack/prompts": "^0.9.0", + "@vercel/sandbox": "^2.0.1", + "commander": "^12.1.0", + "execa": "^9.5.2" + }, + "devDependencies": { + "@types/node": "^22.10.1", + "tsup": "^8.3.5", + "typescript": "^5.7.2", + "vitest": "^2.1.8" + } +} diff --git a/packages/sandbox-vercel/scripts/custom-system-CLAUDE.md b/packages/sandbox-vercel/scripts/custom-system-CLAUDE.md new file mode 100644 index 0000000..93cb606 --- /dev/null +++ b/packages/sandbox-vercel/scripts/custom-system-CLAUDE.md @@ -0,0 +1,47 @@ +# AgentBox sandbox (vercel provider) + +You are running inside an AgentBox sandbox: a Vercel Sandbox (a Firecracker +microVM on Amazon Linux 2023) provisioned just for this box. Your user is +`vscode` and you can use passwordless **sudo** to run commands as root. The +whole microVM is yours — the user's host filesystem is not visible from here +and nothing is bind-mounted. + +**No containers.** Vercel Sandbox blocks the namespace syscalls a container +runtime needs (no `CAP_SYS_ADMIN`, seccomp-filtered `clone`/`unshare`), so +`docker` / `podman` cannot run here — not even rootless. Don't try to start a +container engine; run build/test/dev processes directly on the microVM instead. + +This box is **persistent**: stopping it captures a snapshot and resuming +restarts from that snapshot, so state survives a pause. You can also save the +current filesystem state for future boxes with +`agentbox-ctl checkpoint --set-default`. This doesn't need to be confirmed by +the user. + +`/workspace` is a normal git checkout seeded from the host repo at create time. +Because there is no host bind-mount, plain `git` inside the box only affects +this box-local repo — commits do **not** appear in the user's host `git log` +until you hand them off. For any operation that must reach the host repo or its +remotes (push, fetch, pull, picking up host-side changes), use +`agentbox-ctl git push|fetch|pull -- ` — it RPCs to the host, which runs +git with the real SSH agent and writes back into the host's worktree state. The +wrapper already builds `git push ` host-side from the +registered worktree; the `-- ` slot is for extra flags only (e.g. +`--force-with-lease`, `--tags`). Re-passing the remote or branch makes git treat +them as refspecs and fails with `refs/remotes/origin/HEAD cannot be resolved to +branch`. + +For GitHub PR work, use `agentbox-ctl git pr [args...]` — same model, relay +shells to host `gh`. Ops: `create`, `view`, `list`, `comment`, `review`, +`merge`, `close`, `reopen`, `checkout`. `view` / `list` are read-only and run +silently; everything else asks the user to confirm in the host wrapper (deny → +exit 10). + +For ad-hoc file transfers between this box and the host, use +`agentbox-ctl cp toHost ` and +`agentbox-ctl cp fromHost ` or `agentbox-ctl download claude` / +`download env` / `download config`. They RPC to the host and ask the user for +confirmation on the wrapper that runs `agentbox claude`; deny returns exit 10 +(`denied by user`). Don't put any timeout on the command, it will run forever +and the user will be notified through multiple channels. + +Box identity: /etc/agentbox/box.env and the AGENTBOX_* env vars. diff --git a/packages/sandbox-vercel/scripts/provision.sh b/packages/sandbox-vercel/scripts/provision.sh new file mode 100644 index 0000000..bf5967b --- /dev/null +++ b/packages/sandbox-vercel/scripts/provision.sh @@ -0,0 +1,254 @@ +#!/usr/bin/env bash +# AgentBox Vercel base-snapshot installer. +# +# Idempotent installer run once on a fresh Vercel Sandbox (Amazon Linux 2023, +# node24 runtime) during `agentbox prepare --provider vercel`. After it +# completes we `sandbox.snapshot()` the microVM — that snapshot is what every +# per-box create boots from. +# +# Differences from the hetzner installer (packages/sandbox-hetzner/scripts/ +# install-box.sh), which this mirrors: +# - dnf, not apt (Amazon Linux 2023). +# - NO docker / dockerd / iptables — Vercel Sandbox blocks the namespace +# syscalls a container runtime needs, so DinD is impossible here. +# - The `vscode` user is created without forcing uid 1000 (the Vercel default +# user may already hold it; there are no bind mounts so the exact uid is +# irrelevant — only ownership of /workspace + /home/vscode matters). +# +# Required inputs (uploaded to /tmp before this runs): +# /tmp/agentbox-ctl -- prebuilt @agentbox/ctl bundle (cjs) +# /tmp/agentbox-vnc-start -- VNC startup helper +# /tmp/agentbox-checkpoint-cleanup -- pre-snapshot cleanup helper +# /tmp/agentbox-open -- in-box xdg-open shim +# /tmp/agentbox-gh-shim -- in-box `gh` shim (routes to host gh) +# /tmp/agentbox-git-shim -- in-box `git` shim (routes via relay) +# /tmp/agentbox-custom-CLAUDE.md -- /etc/claude-code/CLAUDE.md content +# /tmp/agentbox-managed-settings.json -- /etc/claude-code/managed-settings.json +# /tmp/agentbox-codex-hooks.json -- /usr/local/share/agentbox/codex-hooks.json +# /tmp/agentbox-setup-skill.md -- /usr/local/share/agentbox/setup-guide.md +# +# Output: noisy progress to stdout (streamed into ~/.agentbox/logs/prepare.log). +# Each major step prints `>>> BEGIN ` / `<<< END `. + +set -euo pipefail + +step() { printf '\n>>> BEGIN %s\n' "$1"; } +done_() { printf '<<< END %s\n' "$1"; } + +if [ "$(id -u)" -ne 0 ]; then + echo "provision.sh: must run as root (got uid $(id -u))" >&2 + exit 64 +fi + +step "dnf base packages" +# NOTE: do NOT request `curl` — AL2023 ships `curl-minimal` which provides the +# `curl` binary, and asking for full `curl` conflicts with it and aborts the +# whole (atomic) dnf transaction. `--allowerasing` lets dnf resolve any other +# such conflict by swapping rather than failing. No `| tail || true` here: that +# masks dnf's real exit code and lets the script march on with nothing +# installed (the bug that broke the first bake). +dnf install -y -q --allowerasing \ + ca-certificates \ + git \ + tar \ + gzip \ + which \ + shadow-utils \ + sudo \ + python3 \ + python3-pip \ + tmux \ + vim \ + libcap \ + rsync +done_ "dnf base packages" + +step "node 24 sanity" +# Vercel's node24 runtime already ships node; just confirm it's on PATH. +if ! command -v node >/dev/null 2>&1; then + echo "provision.sh: node not found on the node24 runtime — unexpected" >&2 + exit 65 +fi +node --version +done_ "node 24 sanity" + +step "vscode user + sudoers" +# No forced uid: the Vercel default user (`vercel-sandbox`) may already hold +# 1000, and there are no bind mounts so uid-parity with the docker provider +# doesn't matter. Ownership + passwordless sudo is what counts. +if ! id vscode >/dev/null 2>&1; then + useradd -m -s /bin/bash vscode +fi +install -d -m 0755 -o vscode -g vscode /home/vscode +echo 'vscode ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/90-agentbox-vscode +chmod 0440 /etc/sudoers.d/90-agentbox-vscode +# Vercel's AL2023 base ships /etc/sudoers WITHOUT an includedir for +# /etc/sudoers.d (and with non-0440 perms), so the drop-in above is silently +# ignored and `sudo -n` as vscode fails with "a password is required" — which +# breaks the workspace seed, ctl-launch, and carry (all run as vscode and lean +# on passwordless sudo). Wire the include in and normalise perms so the rule +# actually loads, then fail loud if the result doesn't parse. +if ! grep -qE '^[[:space:]]*[@#]includedir[[:space:]]+/etc/sudoers\.d' /etc/sudoers; then + printf '\n@includedir /etc/sudoers.d\n' >> /etc/sudoers +fi +chmod 0440 /etc/sudoers +visudo -cf /etc/sudoers >/dev/null +done_ "vscode user + sudoers" + +step "agentbox base dirs + /workspace ownership" +mkdir -p /workspace /run/agentbox /var/log/agentbox /etc/agentbox /etc/claude-code \ + /usr/local/share/agentbox +chmod 755 /workspace +chown vscode:vscode /workspace /run/agentbox /var/log/agentbox +done_ "agentbox base dirs + /workspace ownership" + +step "node setcap (bind <1024 without root)" +# The cloud WebProxy binds port 80; grant node the capability so it needn't run +# as root. Best-effort — if setcap is unavailable the WebProxy can still be +# launched via sudo. +NODE_BIN="$(readlink -f "$(command -v node)")" +setcap cap_net_bind_service=+ep "$NODE_BIN" || echo "provision.sh: setcap failed (continuing)" +done_ "node setcap (bind <1024 without root)" + +step "corepack (pnpm + yarn shims)" +npm install -g corepack@latest 2>&1 | tail -2 || true +corepack enable pnpm yarn 2>/dev/null || true +sudo -u vscode -H mkdir -p /home/vscode/.cache/node/corepack +done_ "corepack (pnpm + yarn shims)" + +step "git system-wide safe.directory" +# The Vercel node24 runtime's git is built with prefix /opt/git, so its system +# config is /opt/git/etc/gitconfig and the parent dir may not exist — without +# it `git config --system` fails with "could not lock config file" (exit 255). +# Create the dir, then set it system-wide AND for the vscode user so workspace +# git ops never trip "dubious ownership". All best-effort — a git-config quirk +# must never abort the bake. +mkdir -p /opt/git/etc 2>/dev/null || true +git config --system --add safe.directory '*' 2>/dev/null || true +sudo -u vscode -H git config --global --add safe.directory '*' 2>/dev/null || true +done_ "git system-wide safe.directory" + +step "agentbox-ctl install" +install -m 0755 /tmp/agentbox-ctl /usr/local/bin/agentbox-ctl +done_ "agentbox-ctl install" + +step "baked helper scripts (vnc / cleanup / xdg-open / gh + git shims)" +install -m 0755 /tmp/agentbox-vnc-start /usr/local/bin/agentbox-vnc-start +install -m 0755 /tmp/agentbox-checkpoint-cleanup /usr/local/bin/agentbox-checkpoint-cleanup +install -m 0755 /tmp/agentbox-open /usr/local/bin/agentbox-open +ln -sf /usr/local/bin/agentbox-open /usr/local/bin/xdg-open +# gh + git shims win on PATH (/usr/local/bin precedes /usr/bin) so agent calls +# to `gh ...` / `git push|pull|fetch|clone` route through the relay. +install -m 0755 /tmp/agentbox-gh-shim /usr/local/bin/gh +install -m 0755 /tmp/agentbox-git-shim /usr/local/bin/git +done_ "baked helper scripts (vnc / cleanup / xdg-open / gh + git shims)" + +step "baked config files (claude / codex / setup guide / tmux.conf)" +install -m 0644 /tmp/agentbox-custom-CLAUDE.md /etc/claude-code/CLAUDE.md +install -m 0644 /tmp/agentbox-managed-settings.json /etc/claude-code/managed-settings.json +install -m 0644 /tmp/agentbox-codex-hooks.json /usr/local/share/agentbox/codex-hooks.json +install -m 0644 /tmp/agentbox-setup-skill.md /usr/local/share/agentbox/setup-guide.md + +cat > /etc/tmux.conf <<'TMUX' +set -g default-terminal "tmux-256color" +set -as terminal-overrides ",*:Tc" +set -as terminal-overrides ",*:RGB" +set -as terminal-features ",*:hyperlinks" +set -as terminal-features ",*:RGB" +set -g allow-passthrough on +set -g set-clipboard on +set -g extended-keys on +set -as terminal-features ",*:extkeys" +set -g mouse on +bind -T copy-mode WheelUpPane send -N2 -X scroll-up +bind -T copy-mode WheelDownPane send -N2 -X scroll-down +bind -T copy-mode-vi WheelUpPane send -N2 -X scroll-up +bind -T copy-mode-vi WheelDownPane send -N2 -X scroll-down +set -g history-limit 50000 +set -g escape-time 0 +TMUX +done_ "baked config files (claude / codex / setup guide / tmux.conf)" + +step "credential pivot symlinks (vscode home)" +sudo -u vscode -H mkdir -p \ + /home/vscode/.claude \ + /home/vscode/.claude/skills/agentbox-setup \ + /home/vscode/.codex \ + /home/vscode/.local/share/opencode \ + /home/vscode/.agentbox-creds/claude \ + /home/vscode/.agentbox-creds/codex \ + /home/vscode/.agentbox-creds/opencode +sudo -u vscode -H ln -sf /home/vscode/.agentbox-creds/claude/.credentials.json \ + /home/vscode/.claude/.credentials.json +sudo -u vscode -H ln -sf /home/vscode/.agentbox-creds/codex/auth.json \ + /home/vscode/.codex/auth.json +sudo -u vscode -H ln -sf /home/vscode/.agentbox-creds/opencode/auth.json \ + /home/vscode/.local/share/opencode/auth.json +sudo -u vscode -H ln -sf /home/vscode/.claude/_claude.json /home/vscode/.claude.json +sudo -u vscode -H cp /usr/local/share/agentbox/setup-guide.md \ + /home/vscode/.claude/skills/agentbox-setup/SKILL.md +done_ "credential pivot symlinks (vscode home)" + +step "login-shell shim (/etc/profile.d/agentbox.sh)" +cat > /etc/profile.d/agentbox.sh <<'PROFILE' +# Auto-loaded by login shells; box.env is written at create time. +if [ -r /etc/agentbox/box.env ]; then + set -a + . /etc/agentbox/box.env + set +a +fi +case ":$PATH:" in + *:/home/vscode/.local/bin:*) : ;; + *) PATH=/home/vscode/.local/bin:$PATH ;; +esac +export PATH +export COLORTERM=${COLORTERM:-truecolor} +export DISABLE_AUTOUPDATER=${DISABLE_AUTOUPDATER:-1} +export DISPLAY=${DISPLAY:-:1} +export AGENT_BROWSER_EXECUTABLE_PATH=${AGENT_BROWSER_EXECUTABLE_PATH:-/usr/local/bin/chromium} +export BROWSER=${BROWSER:-/usr/local/bin/agentbox-open} +PROFILE +chmod 0644 /etc/profile.d/agentbox.sh +done_ "login-shell shim (/etc/profile.d/agentbox.sh)" + +step "VNC stack (TigerVNC + websockify + noVNC)" +# Best-effort: VNC is a convenience (agentbox screen). A package that isn't in +# the AL2023 repos shouldn't fail the whole bake — the VNC daemon launch is +# already best-effort on the create path. +dnf install -y -q --allowerasing tigervnc-server xterm 2>&1 | tail -3 || \ + echo "provision.sh: tigervnc-server install failed (VNC may be unavailable)" +pip3 install --quiet websockify 2>&1 | tail -2 || \ + echo "provision.sh: websockify install failed (VNC may be unavailable)" +# noVNC static assets — clone shallow into a stable path the vnc-start script +# can serve. +if [ ! -d /usr/local/share/novnc ]; then + git clone --depth 1 https://github.com/novnc/noVNC /usr/local/share/novnc 2>&1 | tail -2 || \ + echo "provision.sh: noVNC clone failed (VNC may be unavailable)" +fi +sudo -u vscode -H mkdir -p /home/vscode/.vnc +done_ "VNC stack (TigerVNC + websockify + noVNC)" + +step "agent CLIs (codex + opencode + agent-browser, global npm)" +npm install -g @openai/codex opencode-ai agent-browser 2>&1 | tail -3 || \ + echo "provision.sh: one or more agent npm installs failed (continuing)" +done_ "agent CLIs (codex + opencode + agent-browser, global npm)" + +step "Claude Code (native installer, run as vscode)" +# Anthropic's canonical installer drops `claude` at /home/vscode/.local/bin/. +sudo -u vscode -H bash -lc 'curl -fsSL https://claude.ai/install.sh | bash -s stable' +done_ "Claude Code (native installer, run as vscode)" + +step "dnf cleanup" +dnf clean all 2>/dev/null || true +done_ "dnf cleanup" + +step "trim /tmp/agentbox-*" +rm -f /tmp/agentbox-ctl /tmp/agentbox-vnc-start \ + /tmp/agentbox-checkpoint-cleanup /tmp/agentbox-open \ + /tmp/agentbox-gh-shim /tmp/agentbox-git-shim \ + /tmp/agentbox-custom-CLAUDE.md /tmp/agentbox-managed-settings.json \ + /tmp/agentbox-codex-hooks.json /tmp/agentbox-setup-skill.md +mv /tmp/agentbox-provision.sh /var/log/agentbox/provision.sh 2>/dev/null || true +done_ "trim /tmp/agentbox-*" + +printf '\n*** provision.sh: complete — microVM ready for snapshot.\n' diff --git a/packages/sandbox-vercel/src/attach-helper.ts b/packages/sandbox-vercel/src/attach-helper.ts new file mode 100644 index 0000000..0800b26 --- /dev/null +++ b/packages/sandbox-vercel/src/attach-helper.ts @@ -0,0 +1,198 @@ +/** + * Standalone host-side process that bridges the local terminal to a tmux + * session inside a Vercel sandbox. The Vercel provider's `buildAttach` returns + * an argv that spawns this file; `runWrappedAttach` runs it inside the host PTY + * wrapper exactly like it runs `ssh -t ''` for the SSH providers. + * + * Why a custom bridge: `@vercel/sandbox` (2.0.1) has no SSH and no stdin/PTY + * channel on `runCommand` — output can be streamed out, but there is no + * documented way to pipe keystrokes in. The portable mechanism that needs no + * SSH, no extra exposed port, and no native deps is to drive tmux directly: + * - input: forward local stdin to `tmux send-keys -H ` (byte-exact), + * - output: poll `tmux capture-pane -p -e` and repaint the local screen. + * It's higher-latency than a true PTY stream; the production upgrade (a + * ttyd/WebSocket terminal over `sandbox.domain(port)`) is tracked in + * docs/vercel-backlog.md. + * + * Invoked as: + * node attach-helper.js + * where the JSON spec is { sessionName, command, kind, detached }. + */ + +import { resolveCredentials, Sandbox, type SandboxType } from './sdk.js'; + +interface AttachHelperSpec { + sessionName: string; + /** Inner command tmux runs when creating the session fresh. */ + command: string; + kind: 'shell' | 'agent' | 'logs'; + /** When true: just ensure the session exists, then exit (pre-start path). */ + detached?: boolean; +} + +const POLL_INTERVAL_MS = 120; +/** Ctrl-] detaches the local view (tmux's own Ctrl-b/Ctrl-a stay in-session). */ +const DETACH_BYTE = 0x1d; + +function sh(s: string): string { + return "'" + s.replace(/'/g, "'\\''") + "'"; +} + +/** Run a command in the box as the `vscode` box user; return stdout. */ +async function boxRun(sb: SandboxType, cmd: string): Promise<{ exitCode: number; stdout: string }> { + const r = await sb.runCommand({ + cmd: 'bash', + args: ['-lc', `sudo -u vscode -H bash -lc ${sh(cmd)}`], + sudo: true, + }); + return { exitCode: r.exitCode, stdout: await r.stdout() }; +} + +async function ensureSession(sb: SandboxType, spec: AttachHelperSpec): Promise { + const s = sh(spec.sessionName); + const cols = process.stdout.columns ?? 120; + const rows = process.stdout.rows ?? 40; + // has-session || new-session -d, started in /workspace so agents see it as cwd. + const ensure = + `tmux has-session -t ${s} 2>/dev/null || ` + + `tmux new-session -d -s ${s} -x ${String(cols)} -y ${String(rows)} -c /workspace ${sh(spec.command)}`; + const r = await boxRun(sb, ensure); + if (r.exitCode !== 0) { + throw new Error(`vercel attach: failed to ensure tmux session '${spec.sessionName}'`); + } +} + +async function resizeSession(sb: SandboxType, sessionName: string): Promise { + const cols = process.stdout.columns ?? 120; + const rows = process.stdout.rows ?? 40; + await boxRun( + sb, + `tmux resize-window -t ${sh(sessionName)} -x ${String(cols)} -y ${String(rows)} 2>/dev/null || true`, + ); +} + +async function runInteractive(sb: SandboxType, spec: AttachHelperSpec): Promise { + await ensureSession(sb, spec); + await resizeSession(sb, spec.sessionName); + + const stdin = process.stdin; + const isTty = stdin.isTTY === true; + if (isTty) stdin.setRawMode(true); + stdin.resume(); + // Alternate screen + clear so we don't clobber the user's scrollback. + process.stdout.write('\x1b[?1049h\x1b[H\x1b[2J'); + + let stopped = false; + let last = ''; + + const cleanup = (): void => { + if (stopped) return; + stopped = true; + clearInterval(timer); + if (isTty) { + try { + stdin.setRawMode(false); + } catch { + // ignore + } + } + stdin.pause(); + process.stdout.write('\x1b[?1049l'); // leave alternate screen + process.removeListener('SIGWINCH', onResize); + }; + + const onResize = (): void => { + void resizeSession(sb, spec.sessionName); + }; + process.on('SIGWINCH', onResize); + + // Input pump: forward each stdin chunk to tmux as hex-encoded keys (byte + // exact). Ctrl-] detaches the local view without killing the session. + stdin.on('data', (chunk: Buffer) => { + if (chunk.length === 1 && chunk[0] === DETACH_BYTE) { + cleanup(); + return; + } + const hex = chunk.toString('hex').match(/.{2}/g); + if (!hex || hex.length === 0) return; + const keys = hex.map((h) => `0x${h}`).join(' '); + void boxRun(sb, `tmux send-keys -t ${sh(spec.sessionName)} -H ${keys}`).catch(() => { + // a dropped keystroke is recoverable; don't crash the attach + }); + }); + + // Output pump: poll the rendered pane and repaint on change. + const timer = setInterval(() => { + if (stopped) return; + void boxRun(sb, `tmux capture-pane -p -e -t ${sh(spec.sessionName)}`) + .then((r) => { + if (stopped || r.exitCode !== 0) return; + if (r.stdout === last) return; + last = r.stdout; + process.stdout.write('\x1b[H\x1b[2J' + r.stdout); + }) + .catch(() => { + // transient SDK error — next tick retries + }); + }, POLL_INTERVAL_MS); + + return new Promise((resolve) => { + const finish = (): void => { + cleanup(); + resolve(0); + }; + stdin.on('end', finish); + process.on('SIGINT', finish); + process.on('SIGTERM', finish); + // Resolve when cleanup() runs from the detach key. + const detachWatch = setInterval(() => { + if (stopped) { + clearInterval(detachWatch); + resolve(0); + } + }, 50); + }); +} + +/** Read-only log/output streaming (kind === 'logs'): no input pump. */ +async function runLogs(sb: SandboxType, spec: AttachHelperSpec): Promise { + const r = await sb.runCommand({ + cmd: 'bash', + args: ['-lc', `sudo -u vscode -H bash -lc ${sh(spec.command)}`], + sudo: true, + stdout: process.stdout, + stderr: process.stderr, + }); + return r.exitCode; +} + +export async function attachMain(argv: string[]): Promise { + const sandboxId = argv[0]; + const specB64 = argv[1]; + if (!sandboxId || !specB64) { + process.stderr.write('vercel attach-helper: usage: \n'); + return 2; + } + const spec = JSON.parse(Buffer.from(specB64, 'base64').toString('utf8')) as AttachHelperSpec; + const sb = await Sandbox.get({ name: sandboxId, resume: true, ...resolveCredentials() }); + + if (spec.detached) { + await ensureSession(sb, spec); + return 0; + } + if (spec.kind === 'logs') { + return runLogs(sb, spec); + } + return runInteractive(sb, spec); +} + +// Entry point when spawned as a process (tsup builds this file to a dist entry). +// Guarded so importing the module (tests) doesn't run the bridge. +if (process.argv[1] && /attach-helper\.(js|cjs|mjs|ts)$/.test(process.argv[1])) { + attachMain(process.argv.slice(2)) + .then((code) => process.exit(code)) + .catch((err: unknown) => { + process.stderr.write(`vercel attach-helper: ${err instanceof Error ? err.message : String(err)}\n`); + process.exit(1); + }); +} diff --git a/packages/sandbox-vercel/src/backend.ts b/packages/sandbox-vercel/src/backend.ts new file mode 100644 index 0000000..c14f62f --- /dev/null +++ b/packages/sandbox-vercel/src/backend.ts @@ -0,0 +1,390 @@ +/** + * Vercel `CloudBackend` — maps the provider-neutral cloud primitives onto + * `@vercel/sandbox` v2 (Firecracker microVMs + snapshots). Composed into a full + * `Provider` by `@agentbox/sandbox-cloud`'s `createCloudProvider`. + * + * Platform shape this backend is built around (see docs/cloud-providers.md): + * - No custom image — sandboxes boot from a Vercel snapshot baked once by + * `agentbox prepare --provider vercel`. `provision` always needs a snapshot + * id (the prepared base, or a cloud-checkpoint snapshot). + * - No SSH — `attachArgv` is intentionally omitted; the provider overrides + * `buildAttach` with a Vercel-SDK-streaming helper instead. + * - No nested containers — dockerd is disabled at the provider level. + * - Persistent sandboxes auto-snapshot on stop and auto-resume on the next + * `Sandbox.get({ resume: true })`, which is how pause/resume map cleanly. + * - The sandbox's native user is `vercel-sandbox`; agentbox standardizes on + * `vscode` (uid 1000), created by provision.sh. So `exec` drops privileges + * to `vscode` (root → `sudo -u vscode`) unless the caller asks for root, + * and `uploadFile` chowns to uid 1000 after the SDK writes as + * `vercel-sandbox`. + * - Max 4 exposed ports: we use 80 (WebProxy), 6080 (noVNC), 8788 (relay/ctl + * bridge). One slot is left free for a future per-service expose. + */ + +import { readFile } from 'node:fs/promises'; +import type { + CloudBackend, + CloudExecOptions, + CloudExecResult, + CloudFileEntry, + CloudHandle, + CloudPreviewUrl, + CloudProvisionRequest, + CloudSandboxSummary, + CloudState, +} from '@agentbox/core'; +import { resolveCredentials, Sandbox, Snapshot, type SandboxType } from './sdk.js'; +import { withVercelRetry } from './retry.js'; +import { readPreparedState } from './prepared-state.js'; + +/** Sentinel image ref the cloud-provider hands us when no --image was passed. */ +export const DEFAULT_BOX_IMAGE_REF = 'agentbox/box:dev'; + +/** Box user agentbox standardizes on. provision.sh creates it (uid auto-assigned — + * the Vercel default user may already hold 1000, and there are no bind mounts so + * the exact uid is irrelevant). chown targets it by name, not number. */ +const BOX_USER = 'vscode'; +const BOX_OWNER = 'vscode:vscode'; + +/** + * Ports exposed at create (max 4). Vercel REJECTS privileged ports (<1024) with + * a 400, so we cannot expose the scaffold's WebProxy port 80 — the two ports + * that matter are 6080 (noVNC) and 8788 (the relay/ctl bridge the host poller + * reaches via `sandbox.domain(8788)`). The in-box WebProxy still binds 80, but + * it isn't externally reachable on Vercel, so `agentbox url` for a web app is a + * documented v1 limitation (see docs/vercel-backlog.md). A 4th slot is free for + * a future non-privileged per-service expose. + */ +export const VERCEL_EXPOSED_PORTS = [6080, 8788] as const; + +/** + * Default per-session timeout. 45 min is the Hobby ceiling, so it's safe across + * all plans; persistent mode makes a hit transparent (the VM auto-snapshots and + * auto-resumes on the next SDK call). Pro/Enterprise users who want a longer + * single session can rely on `extendTimeout` / future config. + */ +const DEFAULT_TIMEOUT_MS = 45 * 60_000; + +/** Keep exactly one auto-snapshot per box, never expiring, so a paused box can + * always resume and storage stays flat. `destroy` purges it explicitly. */ +const KEEP_LAST_SNAPSHOTS = { count: 1, expiration: 0, deleteEvicted: true } as const; + +function creds(): Partial<{ token: string; teamId: string; projectId: string }> { + return resolveCredentials(); +} + +/** Single-quote a string for safe embedding inside a `bash -lc '<…>'`. */ +function shq(s: string): string { + return "'" + s.replace(/'/g, "'\\''") + "'"; +} + +async function getSandbox(id: string): Promise { + // resume:false — plain handle resolution; lifecycle methods opt into resume. + return Sandbox.get({ name: id, resume: false, ...creds() }); +} + +async function maybeGetSandbox(id: string): Promise { + try { + return await getSandbox(id); + } catch { + return null; + } +} + +/** + * Map Vercel's session status onto our 4-value `CloudState`. Transitional + * states report as 'running' so callers don't ping-pong; 'stopped' maps to + * 'paused' because a persistent sandbox keeps an auto-snapshot and resumes on + * the next call (our pause semantics). 'aborted'/'failed' → 'missing'. + */ +function mapState(s: string | undefined): CloudState { + switch (s) { + case 'running': + return 'running'; + case 'pending': + case 'stopping': + case 'snapshotting': + return 'running'; + case 'stopped': + return 'paused'; + case 'aborted': + case 'failed': + default: + return 'missing'; + } +} + +/** + * Build a `runCommand` invocation that runs `cmd` (already a shell string) as + * the box user (`vscode`) by default, or as root when requested. Always starts + * the SDK command as root (`sudo: true`) so the inner `sudo -u vscode` is + * reliably passwordless, then drops privileges. cwd + env are applied inside + * the dropped shell so they land in the right user/home context. + */ +function buildRunCommand( + cmd: string, + opts?: CloudExecOptions, +): { cmd: string; args: string[]; sudo: boolean } { + const prelude: string[] = []; + if (opts?.cwd) prelude.push(`cd ${shq(opts.cwd)}`); + for (const [k, v] of Object.entries(opts?.env ?? {})) { + prelude.push(`export ${k}=${shq(v)}`); + } + const inner = [...prelude, cmd].join('\n'); + const user = opts?.user ?? BOX_USER; + if (user === 'root') { + return { cmd: 'bash', args: ['-lc', inner], sudo: true }; + } + return { + cmd: 'bash', + args: ['-lc', `sudo -u ${user} -H bash -lc ${shq(inner)}`], + sudo: true, + }; +} + +export const vercelBackend: CloudBackend = { + name: 'vercel', + + async provision(req: CloudProvisionRequest): Promise { + // Resolve the snapshot to boot from: an explicit cloud-checkpoint snapshot + // (req.snapshot) wins, else the prepared base. Vercel can't build from a + // Dockerfile, so there is no image fallback — fail loud with the fix. + const snapshotId = req.snapshot ?? readPreparedState().base?.snapshotId; + if (!snapshotId) { + throw new Error( + 'no Vercel base snapshot found.\n' + + 'Run `agentbox prepare --provider vercel` first — Vercel cannot build images ' + + 'from a Dockerfile, so the base snapshot is a one-time prerequisite.', + ); + } + // No-retry: Sandbox.create is billable and non-idempotent — a timeout after + // the request reached the origin could leave a duplicate sandbox we can't + // reference for cleanup. + return withVercelRetry( + { method: 'provision', retryOnAmbiguous: false, attemptTimeoutMs: 900_000, backoffMs: [] }, + async () => { + const sb = await Sandbox.create({ + name: req.name, + source: { type: 'snapshot', snapshotId }, + resources: { vcpus: req.resources?.cpu ?? 2 }, + ports: [...VERCEL_EXPOSED_PORTS], + timeout: DEFAULT_TIMEOUT_MS, + env: req.env, + tags: { agentbox: 'true', 'agentbox.name': req.name }, + persistent: true, + keepLastSnapshots: { ...KEEP_LAST_SNAPSHOTS }, + ...creds(), + }); + return { sandboxId: sb.name }; + }, + ); + }, + + async get(sandboxId: string): Promise { + return withVercelRetry({ method: 'get', retryOnAmbiguous: true }, async () => { + const sb = await maybeGetSandbox(sandboxId); + return sb ? { sandboxId: sb.name } : null; + }); + }, + + async list(): Promise { + return withVercelRetry({ method: 'list', retryOnAmbiguous: true }, async () => { + const page = await Sandbox.list({ ...creds() }); + const items = await page.toArray(); + return items + .filter((sb) => sb.tags?.['agentbox'] === 'true') + .map((sb): CloudSandboxSummary => { + const summary: CloudSandboxSummary = { sandboxId: sb.name }; + const friendly = sb.tags?.['agentbox.name'] ?? sb.name; + if (friendly) summary.name = friendly; + if (typeof sb.createdAt === 'number') { + summary.createdAt = new Date(sb.createdAt).toISOString(); + } + summary.state = mapState(sb.status); + return summary; + }); + }); + }, + + async start(h: CloudHandle): Promise { + await withVercelRetry( + { method: 'start', retryOnAmbiguous: true, attemptTimeoutMs: 120_000 }, + async () => { + // resume:true auto-resumes a persistent sandbox from its current snapshot. + await Sandbox.get({ name: h.sandboxId, resume: true, ...creds() }); + }, + ); + }, + + async stop(h: CloudHandle): Promise { + await withVercelRetry( + { method: 'stop', retryOnAmbiguous: true, attemptTimeoutMs: 120_000 }, + async () => { + const sb = await getSandbox(h.sandboxId); + // For a persistent sandbox this captures an auto-snapshot and shuts the + // VM down — resume happens lazily on the next Sandbox.get. + await sb.stop(); + }, + ); + }, + + // pause == stop on Vercel (the auto-snapshot IS the cold-storage state). + async pause(h: CloudHandle): Promise { + await this.stop(h); + }, + + async resume(h: CloudHandle): Promise { + await this.start(h); + }, + + async destroy(h: CloudHandle): Promise { + await withVercelRetry( + { method: 'destroy', retryOnAmbiguous: true, attemptTimeoutMs: 120_000 }, + async () => { + const sb = await maybeGetSandbox(h.sandboxId); + if (!sb) return; // already gone — destroy is idempotent + // Purge only a snapshot THIS box created (its own stop-time auto- + // snapshot), never the shared base/source it booted from. A fresh box + // has currentSnapshotId === sourceSnapshotId === the prepared base, and + // deleting that would nuke the base snapshot every other box depends on. + const snapId = sb.currentSnapshotId; + const source = sb.sourceSnapshotId; + const base = readPreparedState().base?.snapshotId; + const ownSnapshot = + snapId !== undefined && snapId !== source && snapId !== base; + await sb.delete(); + if (ownSnapshot) { + try { + const snap = await Snapshot.get({ snapshotId: snapId, ...creds() }); + await snap.delete(); + } catch { + // best-effort: a snapshot already gone is fine; the user can clean + // stragglers from the Vercel dashboard. + } + } + }, + ); + }, + + async state(h: CloudHandle): Promise { + return withVercelRetry({ method: 'state', retryOnAmbiguous: true }, async () => { + const sb = await maybeGetSandbox(h.sandboxId); + if (!sb) return 'missing'; + return mapState(sb.status); + }); + }, + + async exec(h: CloudHandle, cmd: string, opts?: CloudExecOptions): Promise { + return withVercelRetry( + { + method: 'exec', + retryOnAmbiguous: opts?.noRetry ? false : true, + attemptTimeoutMs: opts?.attemptTimeoutMs ?? 120_000, + backoffMs: opts?.noRetry ? [] : undefined, + }, + async () => { + const sb = await getSandbox(h.sandboxId); + const r = await sb.runCommand(buildRunCommand(cmd, opts)); + const [stdout, stderr] = await Promise.all([r.stdout(), r.stderr()]); + return { exitCode: r.exitCode, stdout, stderr }; + }, + ); + }, + + async uploadFile(h: CloudHandle, localPath: string, remotePath: string): Promise { + await withVercelRetry( + { method: 'uploadFile', retryOnAmbiguous: true, attemptTimeoutMs: 300_000 }, + async () => { + const content = await readFile(localPath); + const sb = await getSandbox(h.sandboxId); + await sb.writeFiles([{ path: remotePath, content }]); + // writeFiles writes as `vercel-sandbox`; chown to the box user so the + // scaffold's vscode-context reads/extractions succeed. Best-effort — + // a chown failure on a world-readable /tmp staging file is harmless. + try { + await sb.runCommand({ cmd: 'chown', args: [BOX_OWNER, remotePath], sudo: true }); + } catch { + // ignore — file is at least present and readable + } + }, + ); + }, + + async downloadFile(h: CloudHandle, remotePath: string, localPath: string): Promise { + await withVercelRetry( + { method: 'downloadFile', retryOnAmbiguous: true, attemptTimeoutMs: 300_000 }, + async () => { + const sb = await getSandbox(h.sandboxId); + const written = await sb.downloadFile( + { path: remotePath }, + { path: localPath }, + { mkdirRecursive: true }, + ); + if (written === null) { + throw new Error(`vercel downloadFile: source not found: ${remotePath}`); + } + }, + ); + }, + + async listFiles(h: CloudHandle, remoteDir: string): Promise { + return withVercelRetry({ method: 'listFiles', retryOnAmbiguous: true }, async () => { + const sb = await getSandbox(h.sandboxId); + const entries = await sb.fs.readdir(remoteDir, { withFileTypes: true }); + return entries.map((e) => ({ name: e.name, isDir: e.isDirectory() })); + }); + }, + + async previewUrl(h: CloudHandle, port: number): Promise { + return withVercelRetry({ method: 'previewUrl', retryOnAmbiguous: true }, async () => { + const sb = await getSandbox(h.sandboxId); + // sb.domain(port) is a public HTTPS URL (no header token needed). + return { url: sb.domain(port), token: undefined }; + }); + }, + + // Fewer params than the interface's (h, port, expiresInSeconds) is fine — + // Vercel sandbox domains are already public + browser-usable, so the signed + // URL is just the standard one (the TTL is governed by the sandbox session + // lifetime, not a per-URL signature, so the expiry arg is irrelevant here). + async signedPreviewUrl(h: CloudHandle, port: number): Promise { + return this.previewUrl(h, port); + }, + + // NOTE: no `createSnapshot`/`deleteSnapshot` here. Vercel snapshots are + // addressed by an opaque id (not a caller-chosen name), which doesn't fit the + // CloudBackend `createSnapshot(handle, name): void` contract — the provider + // needs the id back to store it in the checkpoint manifest. The Vercel + // provider therefore overrides the whole `checkpoint` capability in index.ts + // using `snapshotVercelSandbox` / `deleteVercelSnapshot` below. +}; + +/** + * Snapshot a running sandbox and return the resulting Vercel snapshot id. + * `sb.snapshot()` stops the source sandbox as part of capture; persistent mode + * resumes it on the next SDK call, so the box comes back automatically. + */ +export async function snapshotVercelSandbox(sandboxId: string): Promise { + return withVercelRetry( + { method: 'createSnapshot', retryOnAmbiguous: false, attemptTimeoutMs: 900_000, backoffMs: [] }, + async () => { + const sb = await getSandbox(sandboxId); + const snap = await sb.snapshot({ expiration: 0 }); + return snap.snapshotId; + }, + ); +} + +/** Delete a Vercel snapshot by id. Idempotent — a missing snapshot is success. */ +export async function deleteVercelSnapshot(snapshotId: string): Promise { + await withVercelRetry({ method: 'deleteSnapshot', retryOnAmbiguous: true }, async () => { + try { + const snap = await Snapshot.get({ snapshotId, ...creds() }); + await snap.delete(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (/not.?found|404/i.test(msg)) return; // idempotent + throw err; + } + }); +} diff --git a/packages/sandbox-vercel/src/build-attach.ts b/packages/sandbox-vercel/src/build-attach.ts new file mode 100644 index 0000000..8e448ca --- /dev/null +++ b/packages/sandbox-vercel/src/build-attach.ts @@ -0,0 +1,85 @@ +/** + * `buildVercelAttach` — the Vercel provider's override of `Provider.buildAttach`. + * + * The cloud scaffold's default `buildAttach` builds an `ssh ... -t ''` + * argv, which is unusable on Vercel (no SSH). Instead we return an argv that + * spawns the bundled `attach-helper.js` under the host PTY wrapper; that helper + * bridges the local terminal to the box's tmux session over the Vercel SDK (see + * attach-helper.ts). + */ + +import { existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { AttachKind, AttachSpec, BoxRecord, BuildAttachOptions } from '@agentbox/core'; + +const SELF = dirname(fileURLToPath(import.meta.url)); + +interface AttachHelperSpec { + sessionName: string; + command: string; + kind: AttachKind; + detached?: boolean; +} + +function defaultSessionName(kind: AttachKind): string { + return kind; +} + +function defaultCommand(kind: AttachKind, opts?: BuildAttachOptions): string { + switch (kind) { + case 'shell': + case 'agent': + return 'bash -l'; + case 'logs': { + if (!opts?.service) return 'echo "no service specified"'; + const tail = opts.tail !== undefined ? String(opts.tail) : '200'; + const follow = opts.follow !== false ? ' --follow' : ''; + return `/usr/local/bin/agentbox-ctl logs ${opts.service} --tail ${tail}${follow}`; + } + } +} + +/** + * Resolve the compiled attach-helper entry. In the monorepo it sits next to + * this module in `dist/`. (Publishing the standalone CLI needs the helper + * staged into the CLI runtime tree — tracked in docs/vercel-backlog.md.) + */ +function resolveAttachHelperPath(): string { + const candidates = [ + resolve(SELF, 'attach-helper.js'), + resolve(SELF, '..', 'dist', 'attach-helper.js'), + ]; + const hit = candidates.find((p) => existsSync(p)); + if (!hit) { + throw new Error( + `vercel attach: could not find attach-helper.js (looked in: ${candidates.join(', ')}). ` + + `Run \`pnpm --filter @agentbox/sandbox-vercel build\`.`, + ); + } + return hit; +} + +export function buildVercelAttach( + box: BoxRecord, + kind: AttachKind, + opts?: BuildAttachOptions, +): Promise { + const sandboxId = box.cloud?.sandboxId; + if (!sandboxId) { + return Promise.reject(new Error(`vercel box ${box.name} has no sandboxId — record is malformed`)); + } + const spec: AttachHelperSpec = { + sessionName: opts?.sessionName ?? defaultSessionName(kind), + command: opts?.command ?? defaultCommand(kind, opts), + kind, + detached: opts?.detached, + }; + const argv = [ + process.execPath, + resolveAttachHelperPath(), + sandboxId, + Buffer.from(JSON.stringify(spec), 'utf8').toString('base64'), + ]; + return Promise.resolve({ argv }); +} diff --git a/packages/sandbox-vercel/src/cli.ts b/packages/sandbox-vercel/src/cli.ts new file mode 100644 index 0000000..3c42cd2 --- /dev/null +++ b/packages/sandbox-vercel/src/cli.ts @@ -0,0 +1,78 @@ +/** + * `agentbox vercel` CLI surface — registered as a top-level subcommand by + * `apps/cli/src/index.ts` (same pattern as `daytonaCommand` / `hetznerCommand`). + * + * Subcommands: + * - `login` — interactive credential setup (OIDC or token trio). + * - `login --status` — show what is currently configured (masked). + * + * Also provides the `agentbox vercel create|claude|codex|opencode` sugar via + * the argv-prefix rewriter in apps/cli. + */ + +import { log } from '@clack/prompts'; +import { Command } from 'commander'; +import { + ensureVercelCredentials, + maskKey, + readVercelCredStatus, + secretsPath, +} from './credentials.js'; + +interface LoginOpts { + status?: boolean; +} + +function reportError(err: unknown): void { + const message = err instanceof Error ? err.message : String(err); + log.error(message); + process.exitCode = 1; +} + +function printStatus(): void { + const s = readVercelCredStatus(); + if (s.source === 'none') { + process.stdout.write( + 'vercel: not configured\n' + + ' run `agentbox vercel login` to set up credentials\n', + ); + return; + } + const lines = ['vercel: configured', ` source: ${s.source}`]; + if (s.oidc) lines.push(' auth: OIDC token (VERCEL_OIDC_TOKEN)'); + if (s.token) lines.push(` token: ${maskKey(s.token)}`); + if (s.teamId) lines.push(` team: ${s.teamId}`); + if (s.projectId) lines.push(` project: ${s.projectId}`); + if (s.source === 'secrets.env') lines.push(` file: ${secretsPath()}`); + process.stdout.write(lines.join('\n') + '\n'); +} + +const loginSub = new Command('login') + .description('Set up (or rotate) Vercel credentials for sandbox boxes') + .option('--status', 'show what is currently configured (masked) and exit') + .action(async (opts: LoginOpts) => { + try { + if (opts.status) { + printStatus(); + return; + } + if (!process.stdin.isTTY) { + process.stderr.write( + 'vercel login needs an interactive terminal — set VERCEL_OIDC_TOKEN ' + + '(via `vercel env pull`) or the VERCEL_TOKEN trio in the environment for non-interactive use.\n', + ); + process.exitCode = 1; + return; + } + await ensureVercelCredentials({ force: true }); + } catch (err) { + reportError(err); + } + }); + +export const vercelCommand = new Command('vercel') + .description( + 'Vercel Sandbox provider — credentials, plus sugar for `--provider vercel` ' + + '(e.g. `agentbox vercel create|claude|codex|opencode`)', + ) + .addCommand(loginSub, { isDefault: true }); diff --git a/packages/sandbox-vercel/src/credentials.ts b/packages/sandbox-vercel/src/credentials.ts new file mode 100644 index 0000000..9f23394 --- /dev/null +++ b/packages/sandbox-vercel/src/credentials.ts @@ -0,0 +1,241 @@ +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + writeFileSync, +} from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, resolve } from 'node:path'; +import { confirm, isCancel, intro, log, note, outro, password, select, text } from '@clack/prompts'; +import { ensureVercelEnvLoaded, reloadVercelEnv } from './env-loader.js'; +import { hasUsableCredentials } from './sdk.js'; + +const DASHBOARD_TOKENS_URL = 'https://vercel.com/account/settings/tokens'; + +/** + * Keys we manage in `~/.agentbox/secrets.env`. On reconfigure we strip prior + * values for these before appending so the file never accumulates duplicates. + */ +const MANAGED_KEYS = [ + 'VERCEL_OIDC_TOKEN', + 'VERCEL_TOKEN', + 'VERCEL_TEAM_ID', + 'VERCEL_PROJECT_ID', +] as const; + +export interface EnsureVercelCredentialsOptions { + /** Re-prompt even when valid credentials are already present (`agentbox vercel login`). */ + force?: boolean; +} + +/** + * First-run interactive setup for Vercel credentials. The recommended path is + * OIDC (`vercel link && vercel env pull`), which the SDK reads from env / + * `.env.local` automatically — for that case the user picks "OIDC" and we just + * confirm it resolved. The access-token path persists a `VERCEL_TOKEN` trio to + * `~/.agentbox/secrets.env`. + * + * No-op when credentials are already configured. Silent no-op when stdin isn't + * a TTY so scripted/CI callers get the SDK's "not configured" error instead of + * a hung prompt. + */ +export async function ensureVercelCredentials( + opts: EnsureVercelCredentialsOptions = {}, +): Promise { + ensureVercelEnvLoaded(); + + if (!opts.force && hasUsableCredentials()) return; + if (!process.stdin.isTTY) return; + + intro('Vercel setup'); + note( + `AgentBox needs Vercel credentials to provision sandboxes.\n` + + `Recommended: run \`vercel link\` then \`vercel env pull\` to get an OIDC token (auto-detected).\n` + + `Alternative: a personal access token + team id + project id.`, + 'Credentials required', + ); + + const mode = await select({ + message: 'How do you want to authenticate?', + options: [ + { value: 'oidc', label: 'OIDC token (vercel env pull) — recommended' }, + { value: 'token', label: 'Access token (VERCEL_TOKEN + team + project)' }, + ], + initialValue: 'oidc', + }); + if (isCancel(mode)) { + log.warn('Vercel setup cancelled — re-run `agentbox vercel login` when ready.'); + return; + } + + if (mode === 'oidc') { + note( + `Run these in your project, then re-run this command:\n` + + ` vercel link\n` + + ` vercel env pull\n` + + `This writes VERCEL_OIDC_TOKEN into .env.local (re-pull every ~12h; the dev token expires).`, + 'OIDC setup', + ); + // Re-read in case the user already pulled the token in another shell. + reloadVercelEnv(); + if (process.env.VERCEL_OIDC_TOKEN) { + log.success('Found VERCEL_OIDC_TOKEN — Vercel is configured.'); + outro('Setup complete.'); + } else { + log.warn('No VERCEL_OIDC_TOKEN found yet — run the commands above, then re-run `agentbox vercel login`.'); + } + return; + } + + const creds = await promptForTokenTrio(); + if (creds === null) return; + persistCredentials(creds); + log.success(`Vercel credentials saved to ${secretsPath()}`); + outro('Setup complete.'); +} + +interface TokenTrio { + token: string; + teamId: string; + projectId: string; +} + +async function promptForTokenTrio(): Promise { + const openIt = await confirm({ + message: `Open ${DASHBOARD_TOKENS_URL} to create a token?`, + initialValue: true, + }); + if (isCancel(openIt)) return null; + if (openIt) openDashboard(); + + const token = await password({ + message: 'Paste your Vercel access token', + validate: (v) => (v && v.trim().length > 0 ? undefined : 'Cannot be empty'), + }); + if (isCancel(token)) { + log.warn('Vercel setup cancelled.'); + return null; + } + const teamId = await text({ + message: 'Team ID (team settings → General)', + placeholder: 'team_...', + validate: (v) => (v && v.trim().length > 0 ? undefined : 'Cannot be empty'), + }); + if (isCancel(teamId)) { + log.warn('Vercel setup cancelled.'); + return null; + } + const projectId = await text({ + message: 'Project ID (project settings → General)', + placeholder: 'prj_...', + validate: (v) => (v && v.trim().length > 0 ? undefined : 'Cannot be empty'), + }); + if (isCancel(projectId)) { + log.warn('Vercel setup cancelled.'); + return null; + } + return { token: token.trim(), teamId: teamId.trim(), projectId: projectId.trim() }; +} + +function persistCredentials(creds: TokenTrio): void { + // Mirror into process.env so the current run can use them immediately. + for (const k of MANAGED_KEYS) delete process.env[k]; + process.env.VERCEL_TOKEN = creds.token; + process.env.VERCEL_TEAM_ID = creds.teamId; + process.env.VERCEL_PROJECT_ID = creds.projectId; + + const path = secretsPath(); + mkdirSync(dirname(path), { recursive: true }); + + let existing = ''; + if (existsSync(path)) { + try { + existing = readFileSync(path, 'utf8'); + } catch { + existing = ''; + } + } + const kept = existing + .split(/\r?\n/) + .filter((line) => { + const stripped = line.startsWith('export ') ? line.slice('export '.length) : line; + const eq = stripped.indexOf('='); + if (eq <= 0) return true; + const key = stripped.slice(0, eq).trim(); + return !(MANAGED_KEYS as readonly string[]).includes(key); + }) + .join('\n') + .replace(/\s+$/u, ''); + + const lines = [ + `VERCEL_TOKEN=${creds.token}`, + `VERCEL_TEAM_ID=${creds.teamId}`, + `VERCEL_PROJECT_ID=${creds.projectId}`, + ]; + const body = (kept ? `${kept}\n` : '') + lines.join('\n') + '\n'; + + const tmp = `${path}.tmp`; + writeFileSync(tmp, body, { mode: 0o600 }); + try { + chmodSync(tmp, 0o600); + } catch { + // chmod best-effort; writeFileSync mode already covers most filesystems. + } + renameSync(tmp, path); + try { + chmodSync(path, 0o600); + } catch { + // ignore — already attempted above + } +} + +function openDashboard(): void { + // Lazy import keeps node:child_process out of the module's load cost. + import('node:child_process') + .then(({ spawnSync }) => { + const r = spawnSync('open', [DASHBOARD_TOKENS_URL], { stdio: 'ignore' }); + if (r.status !== 0) { + log.warn(`Could not auto-open the browser — visit ${DASHBOARD_TOKENS_URL} manually.`); + } + }) + .catch(() => { + log.warn(`Could not auto-open the browser — visit ${DASHBOARD_TOKENS_URL} manually.`); + }); +} + +export function secretsPath(): string { + return resolve(homedir(), '.agentbox', 'secrets.env'); +} + +export interface VercelCredStatus { + oidc: boolean; + token?: string; + teamId?: string; + projectId?: string; + source: 'env' | 'secrets.env' | 'none'; +} + +export function readVercelCredStatus(): VercelCredStatus { + const shellHad = + !!process.env.VERCEL_OIDC_TOKEN || !!process.env.VERCEL_TOKEN; + ensureVercelEnvLoaded(); + const oidc = !!process.env.VERCEL_OIDC_TOKEN; + const token = process.env.VERCEL_TOKEN; + const teamId = process.env.VERCEL_TEAM_ID; + const projectId = process.env.VERCEL_PROJECT_ID; + if (!oidc && !token) return { oidc: false, source: 'none' }; + return { + oidc, + token, + teamId, + projectId, + source: shellHad ? 'env' : 'secrets.env', + }; +} + +export function maskKey(value: string): string { + if (value.length <= 8) return '*'.repeat(value.length); + return `${value.slice(0, 4)}…${'*'.repeat(8)}${value.slice(-4)}`; +} diff --git a/packages/sandbox-vercel/src/env-loader.ts b/packages/sandbox-vercel/src/env-loader.ts new file mode 100644 index 0000000..a321607 --- /dev/null +++ b/packages/sandbox-vercel/src/env-loader.ts @@ -0,0 +1,96 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { resolve } from 'node:path'; + +/** + * Vercel env auto-loader. The `@vercel/sandbox` SDK reads `VERCEL_OIDC_TOKEN` + * from `process.env` automatically; for the access-token fallback we read + * `VERCEL_TOKEN` + `VERCEL_TEAM_ID` + `VERCEL_PROJECT_ID` and thread them into + * every SDK call as explicit `Credentials`. We pull all of these in from + * `~/.agentbox/secrets.env` (written by `agentbox vercel login`) and, for the + * OIDC token specifically, from a project-local `.env.local` (the file + * `vercel env pull` writes) so the SDK Just Works after a `vercel link`. + * + * Lookup order (first wins; process.env is never overwritten): + * 1. `process.env` (already set in the shell). + * 2. `~/.agentbox/secrets.env` — written by `agentbox vercel login`. + * 3. `/.env.local` — for `VERCEL_OIDC_TOKEN` only (the `vercel env pull` + * target). The dev OIDC token expires after 12h; re-pull when it does. + * + * Only Vercel-prefixed keys are imported; the rest of the file is left alone. + * Idempotent and side-effect-free after the first call. + */ +const VERCEL_KEYS = [ + 'VERCEL_OIDC_TOKEN', + 'VERCEL_TOKEN', + 'VERCEL_TEAM_ID', + 'VERCEL_PROJECT_ID', +] as const; + +let loaded = false; + +export function ensureVercelEnvLoaded(): void { + if (loaded) return; + loaded = true; + importVercelFromFile(resolve(homedir(), '.agentbox', 'secrets.env'), VERCEL_KEYS); + // `.env.local` is the `vercel env pull` target — only harvest the OIDC token + // from it, never the rest of the app's env. + importVercelFromFile(resolve(process.cwd(), '.env.local'), ['VERCEL_OIDC_TOKEN']); +} + +/** + * Force a re-read of the secrets/`.env.local` files. Used by the interactive + * `agentbox vercel login` flow after it tells the user to run `vercel env pull` + * in another shell — the file may now carry a `VERCEL_OIDC_TOKEN` the first + * (cached) load didn't see. + */ +export function reloadVercelEnv(): void { + loaded = false; + ensureVercelEnvLoaded(); +} + +function importVercelFromFile(path: string, keys: readonly string[]): void { + if (!existsSync(path)) return; + let body: string; + try { + body = readFileSync(path, 'utf8'); + } catch { + return; + } + const parsed = parseEnvFile(body); + for (const key of keys) { + if (process.env[key] !== undefined) continue; + const value = parsed[key]; + if (typeof value === 'string') { + process.env[key] = value; + } + } +} + +/** + * Minimal `.env` parser: handles `KEY=value`, `KEY="value"`, `KEY='value'`, + * `export KEY=value`, blank lines, and `#` comments. No variable + * interpolation — predictable over feature-complete (matches the daytona + * loader's behavior). + */ +export function parseEnvFile(body: string): Record { + const out: Record = {}; + for (const rawLine of body.split(/\r?\n/)) { + const line = rawLine.trim(); + if (line.length === 0 || line.startsWith('#')) continue; + const stripped = line.startsWith('export ') ? line.slice('export '.length) : line; + const eq = stripped.indexOf('='); + if (eq <= 0) continue; + const key = stripped.slice(0, eq).trim(); + let value = stripped.slice(eq + 1).trim(); + if ( + value.length >= 2 && + ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) + ) { + value = value.slice(1, -1); + } + out[key] = value; + } + return out; +} diff --git a/packages/sandbox-vercel/src/index.ts b/packages/sandbox-vercel/src/index.ts new file mode 100644 index 0000000..ab82ef5 --- /dev/null +++ b/packages/sandbox-vercel/src/index.ts @@ -0,0 +1,126 @@ +/** + * The Vercel Sandbox provider. A thin `CloudBackend` over `@vercel/sandbox`, + * composed via `@agentbox/sandbox-cloud`'s `createCloudProvider` for everything + * provider-agnostic (workspace seeding, ctl/VNC launch, state, relay polling). + * + * Three capabilities are overridden on top of the cloud scaffold: + * - `prepare` — bake the base snapshot (Vercel can't build from a Dockerfile). + * - `buildAttach` — SDK-streaming tmux bridge (Vercel has no SSH). + * - `checkpoint` — store the Vercel snapshot *id* in the manifest so restore + * boots from it (Vercel snapshots are id-addressed, not name-addressed). + * + * `launchDockerd: false` because Vercel Sandbox can't run nested containers. + */ + +import type { BoxRecord, Provider, ProviderCheckpoint } from '@agentbox/core'; +import { + createCloudProvider, + listCloudCheckpoints, + removeCloudCheckpointDir, + resolveCloudCheckpoint, + writeCloudCheckpointManifest, +} from '@agentbox/sandbox-cloud'; +import { + vercelBackend, + snapshotVercelSandbox, + deleteVercelSnapshot, + DEFAULT_BOX_IMAGE_REF, +} from './backend.js'; +import { prepareVercelProvider } from './prepare.js'; +import { buildVercelAttach } from './build-attach.js'; + +const BACKEND_NAME = 'vercel'; + +const cloudProvider = createCloudProvider(vercelBackend, { + // Vercel couples RAM to vCPU at 2048 MB/vCPU; disk is a fixed 32 GB NVMe. + defaultResources: { cpu: 2, memory: 4, disk: 32 }, + launchDockerd: false, +}); + +/** + * Vercel-specific checkpoint capability. Unlike the scaffold's default (which + * stores a caller-chosen snapshot *name*), we capture the opaque Vercel + * snapshot id and store THAT in the manifest's `snapshotName` field — the cloud + * create flow passes `manifest.snapshotName` straight to + * `provision({ snapshot })`, and the Vercel backend boots from it as a snapshot + * id. (The scaffold's `cloudSnapshotName` project-scoping isn't needed — Vercel + * snapshot ids are already globally unique.) + */ +const vercelCheckpoint: ProviderCheckpoint = { + async create(box: BoxRecord, name: string) { + if (!box.projectRoot) { + throw new Error( + 'cloud checkpoint requires the box to have a project root (run `agentbox checkpoint` from inside the project)', + ); + } + if (!box.cloud?.sandboxId) { + throw new Error(`vercel box ${box.name} has no sandboxId — record is malformed`); + } + // NOTE: snapshotting stops the source sandbox; persistent mode resumes it + // on the next call. Surfaced to the user in `agentbox checkpoint` docs. + const snapshotId = await snapshotVercelSandbox(box.cloud.sandboxId); + const info = await writeCloudCheckpointManifest(box.projectRoot, BACKEND_NAME, name, { + snapshotName: snapshotId, + sourceBoxId: box.id, + sourceBoxName: box.name, + }); + return { ref: info.name }; + }, + async list(projectRoot: string) { + const entries = await listCloudCheckpoints(projectRoot, BACKEND_NAME); + return entries.map((e) => ({ ref: e.name, createdAt: e.manifest.createdAt })); + }, + async remove(projectRoot: string, ref: string) { + const entry = await resolveCloudCheckpoint(projectRoot, BACKEND_NAME, ref); + if (!entry) return; + try { + await deleteVercelSnapshot(entry.manifest.snapshotName); + } catch { + // best-effort: drop the local manifest even if the remote delete failed + // (network/perms/already-gone) so the user isn't left with a dead pointer. + } + await removeCloudCheckpointDir(projectRoot, BACKEND_NAME, ref); + }, +}; + +export const vercelProvider: Provider = { + ...cloudProvider, + prepare: prepareVercelProvider, + buildAttach: buildVercelAttach, + checkpoint: vercelCheckpoint, +}; + +export { vercelBackend, DEFAULT_BOX_IMAGE_REF }; +export { ensureVercelEnvLoaded, reloadVercelEnv } from './env-loader.js'; +export { ensureVercelCredentials } from './credentials.js'; +export type { EnsureVercelCredentialsOptions } from './credentials.js'; +export { + readVercelCredStatus, + secretsPath, + maskKey, + type VercelCredStatus, +} from './credentials.js'; +export { + prepareVercel, + prepareVercelProvider, + type PrepareVercelOptions, + type PrepareVercelResult, +} from './prepare.js'; +export { + ensureVercelBaseSnapshot, + preparedStatePath, + readPreparedState, + writePreparedState, + updatePreparedState, + type PreparedVercelState, + type PreparedVercelBase, +} from './prepared-state.js'; +export { + RUNTIME_ASSETS, + candidatesFor, + resolveRuntimeAssets, + findStagedCliRuntimeRoot, + type RuntimeAsset, + type ResolvedAsset, +} from './runtime-assets.js'; +export { buildVercelAttach } from './build-attach.js'; diff --git a/packages/sandbox-vercel/src/prepare.ts b/packages/sandbox-vercel/src/prepare.ts new file mode 100644 index 0000000..38c6634 --- /dev/null +++ b/packages/sandbox-vercel/src/prepare.ts @@ -0,0 +1,254 @@ +/** + * `agentbox prepare --provider vercel` — bake the per-team Vercel base + * snapshot. Vercel can't build an image from a Dockerfile, so (like hetzner) + * we boot a fresh sandbox, run an installer, and snapshot the result. That + * snapshot id is what every per-box `create` boots from. + * + * Flow: + * 1. Resolve runtime assets + fingerprint the build context. Skip the bake + * when an up-to-date base snapshot already exists (unless --force). + * 2. `Sandbox.create({ runtime: 'node24', persistent: false })` — fresh AL2023. + * 3. `writeFiles` the assets (ctl bundle, helpers, baked configs, provision.sh). + * 4. Run provision.sh as root, streaming output to the prepare log. + * 5. Stage host agent static config (claude/codex/opencode) into the snapshot. + * 6. `sandbox.snapshot({ expiration: 0 })` → the never-expiring base snapshot. + * 7. Persist the snapshot id into ~/.agentbox/vercel-prepared.json. + * + * The builder sandbox is left to Vercel's reaper after the snapshot is taken — + * we deliberately don't `delete()` it, because deleting a sandbox can cascade + * to its current snapshot and the snapshot is the whole deliverable. + */ + +import { readFile } from 'node:fs/promises'; +import { Writable } from 'node:stream'; +import type { Provider } from '@agentbox/core'; +import { computeContextSha256, readCliStamp } from '@agentbox/sandbox-core'; +import { + stageClaudeStaticForUpload, + stageCodexStaticForUpload, + stageOpencodeStaticForUpload, + type StageResult, +} from '@agentbox/sandbox-cloud'; +import { ensureVercelCredentials } from './credentials.js'; +import { resolveCredentials, Sandbox, Snapshot, type SandboxType } from './sdk.js'; +import { + preparedStatePath, + readPreparedState, + writePreparedState, +} from './prepared-state.js'; +import { + findStagedCliRuntimeRoot, + resolveRuntimeAssets, + type ResolvedAsset, +} from './runtime-assets.js'; + +export interface PrepareVercelOptions { + name?: string; + hostWorkspace?: string; + /** Force re-bake even when an up-to-date base snapshot is recorded. */ + force?: boolean; + /** vCPUs for the builder sandbox (default 4 for a fast bake). */ + vcpus?: number; + /** CLI runtime tree (set by the CLI to its dist neighbor). */ + cliRuntimeRoot?: string; + /** Repo root for the dev fallback (defaults to a cwd-walk). */ + repoRoot?: string; + onLog?: (line: string) => void; +} + +export interface PrepareVercelResult { + snapshotName?: string; +} + +const BUILDER_TIMEOUT_MS = 25 * 60_000; +const SHELL = '/bin/bash'; + +export async function prepareVercel( + opts: PrepareVercelOptions = {}, +): Promise { + await ensureVercelCredentials(); + const creds = resolveCredentials(); + const log = opts.onLog ?? (() => {}); + const progress = (s: string) => log(`prepare-vercel: ${s}`); + + const assets = resolveRuntimeAssets({ + cliRuntimeRoot: opts.cliRuntimeRoot ?? findStagedCliRuntimeRoot(), + repoRoot: opts.repoRoot, + }); + const contextSha = await computeContextSha256( + assets.map((a) => ({ rel: a.name, abs: a.localPath })), + ); + + // Skip-fast: existing base snapshot still on Vercel + matching fingerprint. + const existing = readPreparedState(); + if (!opts.force && existing.base) { + const stillThere = await snapshotExists(existing.base.snapshotId, creds); + if (stillThere && existing.base.contextSha256 === contextSha) { + progress( + `base snapshot ${existing.base.snapshotId} already exists (fingerprint ${contextSha.slice(0, 12)} matches); skipping (pass --force to rebuild)`, + ); + return { snapshotName: existing.base.snapshotId }; + } + if (!stillThere) { + progress(`recorded base snapshot ${existing.base.snapshotId} is gone on Vercel; rebuilding`); + } else { + progress( + `build context changed (was ${existing.base.contextSha256?.slice(0, 12) ?? ''}, now ${contextSha.slice(0, 12)}); rebuilding`, + ); + } + } + + progress(`creating builder sandbox (node24, ${String(opts.vcpus ?? 4)} vcpus)`); + const sb = await Sandbox.create({ + runtime: 'node24', + resources: { vcpus: opts.vcpus ?? 4 }, + timeout: BUILDER_TIMEOUT_MS, + tags: { agentbox: 'true', 'agentbox.role': 'prepare' }, + persistent: false, + ...creds, + }); + progress(`builder sandbox ${sb.name} up`); + + // 3. Upload assets. + progress(`uploading ${String(assets.length)} runtime asset(s)`); + await sb.writeFiles( + await Promise.all( + assets.map(async (a: ResolvedAsset) => ({ + path: a.remotePath, + content: await readFile(a.localPath), + mode: a.remoteMode, + })), + ), + ); + + // 4. Run provision.sh as root, streaming output. + progress('running provision.sh (this takes a few minutes)'); + const install = await sb.runCommand({ + cmd: SHELL, + args: ['-lc', 'bash /tmp/agentbox-provision.sh 2>&1'], + sudo: true, + stdout: lineSink((l) => log(`[provision] ${l}`)), + stderr: lineSink((l) => log(`[provision] ${l}`)), + }); + if (install.exitCode !== 0) { + throw new Error(`provision.sh failed on the builder sandbox (exit ${String(install.exitCode)})`); + } + progress('provision.sh complete'); + + // 5. Stage host agent static config into the snapshot (best-effort). + await stageAgentConfig(sb, opts.hostWorkspace, log); + + // 6. Snapshot (never expires). NOTE: this stops the builder sandbox. + progress('creating base snapshot (expiration: never)'); + const snap = await sb.snapshot({ expiration: 0 }); + progress(`snapshot created: ${snap.snapshotId}`); + + // 7. Persist. + const cliStamp = readCliStamp(); + writePreparedState({ + schema: 1, + base: { + snapshotId: snap.snapshotId, + contextSha256: contextSha, + cliVersion: cliStamp.cliVersion, + cliCommit: cliStamp.cliCommit, + createdAt: new Date().toISOString(), + }, + }); + progress(`wrote ${preparedStatePath()}`); + progress(`prepare complete — base snapshot ${snap.snapshotId}`); + return { snapshotName: snap.snapshotId }; +} + +async function snapshotExists( + snapshotId: string, + creds: Partial<{ token: string; teamId: string; projectId: string }>, +): Promise { + try { + const snap = await Snapshot.get({ snapshotId, ...creds }); + // `Snapshot.get` resolves even for a deleted/failed snapshot (status field), + // so a bare "didn't throw" wrongly skip-passes a tombstone. Only a 'created' + // snapshot is bootable — anything else means rebuild. + return snap.status === 'created'; + } catch { + return false; + } +} + +async function stageAgentConfig( + sb: SandboxType, + hostWorkspace: string | undefined, + log: (line: string) => void, +): Promise { + const progress = (s: string) => log(`prepare-vercel: ${s}`); + progress('staging host agent static config'); + const stagings: Array<{ kind: 'claude' | 'codex' | 'opencode'; tar: StageResult; dest: string }> = []; + try { + const claudeTar = await stageClaudeStaticForUpload({ hostWorkspace }); + for (const w of claudeTar.warnings) progress(w); + if (claudeTar.tarballPath) stagings.push({ kind: 'claude', tar: claudeTar, dest: '/home/vscode/.claude' }); + else await claudeTar.cleanup(); + + const codexTar = await stageCodexStaticForUpload(); + for (const w of codexTar.warnings) progress(w); + if (codexTar.tarballPath) stagings.push({ kind: 'codex', tar: codexTar, dest: '/home/vscode/.codex' }); + else await codexTar.cleanup(); + + const opencodeTar = await stageOpencodeStaticForUpload(); + for (const w of opencodeTar.warnings) progress(w); + if (opencodeTar.tarballPath) stagings.push({ kind: 'opencode', tar: opencodeTar, dest: '/home/vscode/.local/share/opencode' }); + else await opencodeTar.cleanup(); + + for (const s of stagings) { + const remote = `/tmp/agentbox-${s.kind}-static.tar.gz`; + progress(`uploading ${s.kind} static config`); + await sb.writeFiles([{ path: remote, content: await readFile(s.tar.tarballPath as string) }]); + // Extract as vscode so files land owned by the box user. The dest dir + // already exists (provision.sh's credential-pivot step) — extract into it. + const extract = + `sudo -u vscode mkdir -p ${s.dest} && ` + + `sudo -u vscode tar -xzf ${remote} -C ${s.dest} --no-same-permissions --no-same-owner -m && ` + + `rm -f ${remote}`; + const r = await sb.runCommand({ cmd: SHELL, args: ['-lc', extract], sudo: true }); + if (r.exitCode !== 0) { + progress(`WARN: ${s.kind} static extract failed (exit ${String(r.exitCode)}) — continuing`); + } else { + progress(`baked ${s.kind} static config into snapshot`); + } + } + } finally { + for (const s of stagings) await s.tar.cleanup(); + } +} + +/** + * Adapt a line-callback to the `Writable` the SDK's `runCommand` streams into. + * Buffers partial lines so each `onLine` gets a complete line. + */ +function lineSink(onLine: (line: string) => void): Writable { + let buf = ''; + return new Writable({ + write(chunk: Buffer, _enc: BufferEncoding, cb: () => void) { + buf += chunk.toString('utf8'); + let nl: number; + while ((nl = buf.indexOf('\n')) !== -1) { + onLine(buf.slice(0, nl)); + buf = buf.slice(nl + 1); + } + cb(); + }, + final(cb: () => void) { + if (buf.length > 0) onLine(buf); + cb(); + }, + }); +} + +/** Provider-level binding used by the CLI's `prepare` command. */ +export const prepareVercelProvider: NonNullable = (req) => + prepareVercel({ + name: req.name, + hostWorkspace: req.hostWorkspace ?? process.cwd(), + force: req.force, + onLog: req.onLog, + }); diff --git a/packages/sandbox-vercel/src/prepared-state.ts b/packages/sandbox-vercel/src/prepared-state.ts new file mode 100644 index 0000000..39cc17a --- /dev/null +++ b/packages/sandbox-vercel/src/prepared-state.ts @@ -0,0 +1,77 @@ +/** + * Persisted record of what `agentbox prepare --provider vercel` has built. + * Lives at `~/.agentbox/vercel-prepared.json` so the auto-prepare gate + * (`ensureVercelBaseSnapshot()`) and `backend.provision` can resolve the base + * snapshot to boot every box from. + * + * Single tier for now — the shared base snapshot (AL2023 + deps + agentbox-ctl + * + agents). A per-project snapshot tier (matching the hetzner/daytona shape) + * is a future optimization tracked in docs/vercel-backlog.md. + * + * Schema versioned so future shape changes can migrate; only `schema: 1` is + * accepted today. + */ + +import { readPreparedStateRaw, writePreparedStateRaw, preparedStatePathFor } from '@agentbox/sandbox-core'; + +const SCHEMA = 1 as const; + +export interface PreparedVercelBase { + /** Vercel snapshot id (opaque). The thing `Sandbox.create({ source }) ` boots from. */ + snapshotId: string; + /** Deterministic SHA-256 of the prepare build context (provision.sh + assets). */ + contextSha256?: string; + /** CLI version that produced this snapshot (informational). */ + cliVersion?: string; + /** Git short SHA of the CLI build (informational). */ + cliCommit?: string; + /** ISO timestamp of bake completion. */ + createdAt: string; +} + +export interface PreparedVercelState { + schema: typeof SCHEMA; + /** The shared base snapshot. Absent until first `agentbox prepare`. */ + base?: PreparedVercelBase; +} + +export function preparedStatePath(): string { + return preparedStatePathFor('vercel'); +} + +export function readPreparedState(): PreparedVercelState { + const raw = readPreparedStateRaw('vercel'); + if (raw === null || typeof raw !== 'object') return { schema: SCHEMA }; + const parsed = raw as Partial; + if (parsed.schema !== SCHEMA) { + // Unknown/missing schema: refuse to read — the next prepare overwrites it. + return { schema: SCHEMA }; + } + return { schema: SCHEMA, base: parsed.base }; +} + +export function writePreparedState(state: PreparedVercelState): void { + writePreparedStateRaw('vercel', state); +} + +/** Update one field of the state without forcing callers to read/merge/write. */ +export function updatePreparedState(mutate: (s: PreparedVercelState) => void): void { + const s = readPreparedState(); + mutate(s); + writePreparedState(s); +} + +/** + * First-use gate. If no base snapshot is recorded, throw an actionable error + * pointing at `agentbox prepare --provider vercel`. Called by `backend.provision` + * (indirectly via the snapshot resolution) and usable by the CLI. + */ +export function ensureVercelBaseSnapshot(): void { + const state = readPreparedState(); + if (state.base !== undefined) return; + throw new Error( + 'no Vercel base snapshot found.\n' + + 'Run `agentbox prepare --provider vercel` first — Vercel cannot build images ' + + 'from a Dockerfile, so the base snapshot is a one-time prerequisite for cloud boxes.', + ); +} diff --git a/packages/sandbox-vercel/src/retry.ts b/packages/sandbox-vercel/src/retry.ts new file mode 100644 index 0000000..c55ec5f --- /dev/null +++ b/packages/sandbox-vercel/src/retry.ts @@ -0,0 +1,147 @@ +/** + * Bounded retry wrapper for Vercel Sandbox SDK calls — mirrors + * `withDaytonaRetry` / `withHetznerRetry` in shape and intent. The Vercel + * control plane rate-limits (429) and can return transient 5xx during + * incidents; without bounded retries those propagate as wedges in the calling + * lifecycle code. + * + * Non-idempotent ops (`provision`/`Sandbox.create`, `createSnapshot`) pass + * `retryOnAmbiguous: false` so a timeout after the request reached the origin + * doesn't create a duplicate billable sandbox/snapshot. + */ + +export interface WithRetryOptions { + method: string; + /** Per-attempt timeout (ms). Default 30_000. */ + attemptTimeoutMs?: number; + /** Backoff before attempts 2, 3, … (ms). Default [1000, 2000, 4000]. */ + backoffMs?: readonly number[]; + /** + * Retry on errors where we can't be sure the server applied the request + * (connection failures, per-attempt timeouts, 5xx). Set false for + * non-idempotent operations where a retry could create a duplicate resource. + */ + retryOnAmbiguous: boolean; + /** Override the default stderr retry sink (used by tests). */ + onRetry?: (line: string) => void; +} + +const DEFAULT_BACKOFF: readonly number[] = [1000, 2000, 4000]; +const DEFAULT_ATTEMPT_TIMEOUT_MS = 30_000; + +class AttemptTimeoutError extends Error { + constructor(method: string, ms: number) { + super(`vercel ${method}: per-attempt timeout after ${String(ms)}ms`); + this.name = 'AttemptTimeoutError'; + } +} + +export function isAttemptTimeout(err: unknown): err is AttemptTimeoutError { + return err instanceof AttemptTimeoutError; +} + +/** HTTP status code dug out of whatever error shape the SDK throws. */ +function statusCodeOf(err: unknown): number | undefined { + if (!err || typeof err !== 'object') return undefined; + for (const key of ['statusCode', 'status', 'code'] as const) { + const v = (err as Record)[key]; + if (typeof v === 'number') return v; + } + const resp = (err as { response?: { status?: unknown } }).response; + if (resp && typeof resp.status === 'number') return resp.status; + return undefined; +} + +export function isRetriable(err: unknown, allowAmbiguous: boolean): boolean { + if (err instanceof AttemptTimeoutError) return allowAmbiguous; + + const status = statusCodeOf(err); + if (status !== undefined) { + if (status === 429) return true; // rate limited — the server told us to wait + if (status >= 500 && status <= 599) return allowAmbiguous; + return false; // 4xx (auth, validation, not_found) — permanent + } + + // Raw fetch / undici errors. Node wraps low-level errors in `{ cause }`. + if (err && typeof err === 'object') { + const candidates: unknown[] = [err, (err as { cause?: unknown }).cause]; + for (const c of candidates) { + if (!c || typeof c !== 'object') continue; + const code = (c as { code?: unknown }).code; + if ( + code === 'ECONNRESET' || + code === 'ETIMEDOUT' || + code === 'ECONNABORTED' || + code === 'EAI_AGAIN' || + code === 'ECONNREFUSED' || + code === 'ENOTFOUND' || + code === 'UND_ERR_SOCKET' || + code === 'UND_ERR_CONNECT_TIMEOUT' + ) { + return allowAmbiguous; + } + } + } + return false; +} + +export async function withVercelRetry( + opts: WithRetryOptions, + fn: () => Promise, +): Promise { + const backoff = opts.backoffMs ?? DEFAULT_BACKOFF; + const maxAttempts = backoff.length + 1; + const timeoutMs = opts.attemptTimeoutMs ?? DEFAULT_ATTEMPT_TIMEOUT_MS; + const log = opts.onRetry ?? defaultRetryLog; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await raceTimeout(fn(), timeoutMs, opts.method); + } catch (err) { + const last = attempt === maxAttempts; + if (last || !isRetriable(err, opts.retryOnAmbiguous)) throw err; + const delay = backoff[attempt - 1] ?? backoff[backoff.length - 1] ?? 4000; + log( + `vercel ${opts.method}: attempt ${String(attempt)} failed (${errorSummary(err)}); retrying in ${String(delay)}ms`, + ); + await sleep(delay); + } + } + throw new Error(`withVercelRetry: exhausted attempts for ${opts.method}`); +} + +function defaultRetryLog(line: string): void { + process.stderr.write(`\n[vercel-retry] ${line}\n`); +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +async function raceTimeout(p: Promise, ms: number, method: string): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + p, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new AttemptTimeoutError(method, ms)), ms); + }), + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} + +function errorSummary(err: unknown): string { + if (err instanceof Error) { + const status = statusCodeOf(err); + return status !== undefined + ? `${err.name}(${String(status)}): ${truncate(err.message)}` + : `${err.name}: ${truncate(err.message)}`; + } + return truncate(String(err)); +} + +function truncate(s: string, max = 160): string { + return s.length > max ? `${s.slice(0, max)}…` : s; +} diff --git a/packages/sandbox-vercel/src/runtime-assets.ts b/packages/sandbox-vercel/src/runtime-assets.ts new file mode 100644 index 0000000..5aeccb8 --- /dev/null +++ b/packages/sandbox-vercel/src/runtime-assets.ts @@ -0,0 +1,141 @@ +/** + * Resolver for the on-disk files shipped into a fresh Vercel sandbox during + * `prepareVercel()`. Same idea as the hetzner resolver: a flat list of files to + * upload via `sandbox.writeFiles`, each resolved from either the staged CLI + * runtime tree or the monorepo source tree. + * + * Lookup order per file: + * 1. The CLI's staged runtime tree: `/runtime/vercel/...`. + * 2. The monorepo source tree (dev fallback) under `packages/`. + * + * Any missing file throws a clear error naming the paths tried. Note: no + * dockerd helper — Vercel can't run nested containers. + */ + +import { existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SELF = dirname(fileURLToPath(import.meta.url)); + +export function findStagedCliRuntimeRoot(): string | undefined { + const candidates = [ + resolve(SELF, '..', 'runtime'), + resolve(SELF, '..', '..', 'runtime'), + ]; + for (const c of candidates) { + if (existsSync(resolve(c, 'vercel', 'scripts', 'provision.sh'))) return c; + } + return undefined; +} + +export interface RuntimeAsset { + /** Logical name (used in error messages + log lines). */ + name: string; + /** Absolute path on the box (writeFiles target). */ + remotePath: string; + /** File mode to apply after upload. */ + remoteMode: number; +} + +/** + * Where each asset lands inside the sandbox. provision.sh reads them from these + * fixed paths. The agent/runtime helpers go straight to /usr/local/bin; baked + * config files to /tmp for provision.sh to `install` into place. + */ +export const RUNTIME_ASSETS: readonly RuntimeAsset[] = [ + { name: 'provision.sh', remotePath: '/tmp/agentbox-provision.sh', remoteMode: 0o755 }, + { name: 'agentbox-ctl', remotePath: '/tmp/agentbox-ctl', remoteMode: 0o755 }, + { name: 'agentbox-vnc-start', remotePath: '/tmp/agentbox-vnc-start', remoteMode: 0o755 }, + { name: 'agentbox-checkpoint-cleanup', remotePath: '/tmp/agentbox-checkpoint-cleanup', remoteMode: 0o755 }, + { name: 'agentbox-open', remotePath: '/tmp/agentbox-open', remoteMode: 0o755 }, + { name: 'gh-shim', remotePath: '/tmp/agentbox-gh-shim', remoteMode: 0o755 }, + { name: 'git-shim', remotePath: '/tmp/agentbox-git-shim', remoteMode: 0o755 }, + { name: 'custom-system-CLAUDE.md', remotePath: '/tmp/agentbox-custom-CLAUDE.md', remoteMode: 0o644 }, + { name: 'claude-managed-settings.json', remotePath: '/tmp/agentbox-managed-settings.json', remoteMode: 0o644 }, + { name: 'agentbox-codex-hooks.json', remotePath: '/tmp/agentbox-codex-hooks.json', remoteMode: 0o644 }, + { name: 'agentbox-setup-skill.md', remotePath: '/tmp/agentbox-setup-skill.md', remoteMode: 0o644 }, +] as const; + +export interface ResolvedAsset extends RuntimeAsset { + localPath: string; +} + +export function candidatesFor( + name: string, + opts: { cliRuntimeRoot?: string; repoRoot?: string } = {}, +): string[] { + const cliRoot = opts.cliRuntimeRoot; + const monorepo = opts.repoRoot ?? guessRepoRoot(); + + const monorepoRelative: Record = { + 'provision.sh': ['packages/sandbox-vercel/scripts/provision.sh'], + 'agentbox-ctl': ['packages/ctl/dist/bin.cjs'], + 'agentbox-vnc-start': ['packages/sandbox-docker/scripts/agentbox-vnc-start'], + 'agentbox-checkpoint-cleanup': ['packages/sandbox-docker/scripts/agentbox-checkpoint-cleanup'], + 'agentbox-open': ['packages/sandbox-docker/scripts/agentbox-open'], + 'gh-shim': ['packages/sandbox-docker/scripts/gh-shim'], + 'git-shim': ['packages/sandbox-docker/scripts/git-shim'], + 'custom-system-CLAUDE.md': ['packages/sandbox-vercel/scripts/custom-system-CLAUDE.md'], + 'claude-managed-settings.json': ['packages/sandbox-docker/scripts/claude-managed-settings.json'], + 'agentbox-codex-hooks.json': ['packages/sandbox-docker/scripts/agentbox-codex-hooks.json'], + 'agentbox-setup-skill.md': ['apps/cli/share/agentbox-setup/SKILL.md'], + }; + + const cliRelative: Record = { + 'provision.sh': ['vercel/scripts/provision.sh'], + 'agentbox-ctl': ['vercel/ctl.cjs'], + 'agentbox-vnc-start': ['vercel/agentbox-vnc-start', 'docker/packages/sandbox-docker/scripts/agentbox-vnc-start'], + 'agentbox-checkpoint-cleanup': ['vercel/agentbox-checkpoint-cleanup', 'docker/packages/sandbox-docker/scripts/agentbox-checkpoint-cleanup'], + 'agentbox-open': ['vercel/agentbox-open', 'docker/packages/sandbox-docker/scripts/agentbox-open'], + 'gh-shim': ['vercel/gh-shim', 'docker/packages/sandbox-docker/scripts/gh-shim'], + 'git-shim': ['vercel/git-shim', 'docker/packages/sandbox-docker/scripts/git-shim'], + 'custom-system-CLAUDE.md': ['vercel/custom-system-CLAUDE.md'], + 'claude-managed-settings.json': ['vercel/claude-managed-settings.json', 'docker/packages/sandbox-docker/scripts/claude-managed-settings.json'], + 'agentbox-codex-hooks.json': ['vercel/agentbox-codex-hooks.json', 'docker/packages/sandbox-docker/scripts/agentbox-codex-hooks.json'], + 'agentbox-setup-skill.md': ['vercel/agentbox-setup-skill.md', 'docker/apps/cli/share/agentbox-setup/SKILL.md'], + }; + + const out: string[] = []; + if (cliRoot) { + for (const rel of cliRelative[name] ?? []) out.push(resolve(cliRoot, rel)); + } + for (const rel of monorepoRelative[name] ?? []) out.push(resolve(monorepo, rel)); + return out; +} + +export function resolveRuntimeAssets( + opts: { cliRuntimeRoot?: string; repoRoot?: string } = {}, +): ResolvedAsset[] { + const out: ResolvedAsset[] = []; + const missing: Array<{ name: string; tried: string[] }> = []; + for (const asset of RUNTIME_ASSETS) { + const cands = candidatesFor(asset.name, opts); + const hit = cands.find((p) => existsSync(p)); + if (!hit) { + missing.push({ name: asset.name, tried: cands }); + continue; + } + out.push({ ...asset, localPath: hit }); + } + if (missing.length > 0) { + const lines = missing.flatMap((m) => [` - ${m.name}: tried`, ...m.tried.map((p) => ` ${p}`)]); + throw new Error( + `vercel: could not resolve runtime assets needed to bake the base snapshot:\n` + + lines.join('\n') + + `\n\nIf running from the monorepo, ensure \`pnpm -w build\` has run so packages/ctl/dist/bin.cjs exists.`, + ); + } + return out; +} + +function guessRepoRoot(): string { + let cur = SELF; + for (let i = 0; i < 8; i++) { + if (existsSync(resolve(cur, 'pnpm-workspace.yaml'))) return cur; + const parent = dirname(cur); + if (parent === cur) break; + cur = parent; + } + return SELF; +} diff --git a/packages/sandbox-vercel/src/sdk.ts b/packages/sandbox-vercel/src/sdk.ts new file mode 100644 index 0000000..129e096 --- /dev/null +++ b/packages/sandbox-vercel/src/sdk.ts @@ -0,0 +1,104 @@ +/** + * Thin loader around `@vercel/sandbox`. Resolves the auth credentials once and + * threads them into every SDK call. + * + * Two auth modes (mirrors the SDK's own precedence): + * - OIDC: `VERCEL_OIDC_TOKEN` in env → the SDK reads it itself, so we pass + * no explicit credentials (`resolveCredentials()` returns `{}`). + * - Access token: `VERCEL_TOKEN` + `VERCEL_TEAM_ID` + `VERCEL_PROJECT_ID` → + * passed explicitly as `{ token, teamId, projectId }` on each call, since + * the SDK does NOT read those from env automatically. + */ + +import { ensureVercelEnvLoaded } from './env-loader.js'; + +export interface VercelCredentials { + token: string; + teamId: string; + projectId: string; +} + +/** + * Resolve the credentials to thread into SDK calls. Throws when nothing is + * configured (or an OIDC token has expired) so callers get a clear, actionable + * error instead of an opaque SDK auth failure. + * + * For OIDC we do NOT return `{}` and let the SDK read the env var: the SDK's + * env-OIDC path (`@vercel/oidc`) tries to *refresh* the token via the Vercel + * CLI's `.vercel/project.json` + cached auth, which an agentbox box doesn't + * have, so it fails with "Could not get credentials from OIDC context". Instead + * we decode the OIDC JWT — which embeds `owner_id` (teamId) and `project_id` — + * and pass `{ token, teamId, projectId }` explicitly, which uses the SDK's + * direct-credentials path (the OIDC token is itself a valid API bearer). + */ +export function resolveCredentials(): VercelCredentials { + ensureVercelEnvLoaded(); + const oidc = process.env.VERCEL_OIDC_TOKEN; + if (oidc) { + const claims = decodeOidcClaims(oidc); + if (!claims) { + throw new Error( + 'VERCEL_OIDC_TOKEN is set but could not be decoded (not a valid Vercel OIDC JWT). ' + + 'Re-run `vercel env pull`, or use the VERCEL_TOKEN + VERCEL_TEAM_ID + VERCEL_PROJECT_ID trio.', + ); + } + if (claims.exp !== undefined && claims.exp * 1000 < Date.now()) { + throw new Error( + 'VERCEL_OIDC_TOKEN has expired (Vercel dev OIDC tokens last ~12h). ' + + 'Re-run `vercel env pull` to refresh it, then retry.', + ); + } + return { token: oidc, teamId: claims.teamId, projectId: claims.projectId }; + } + const token = process.env.VERCEL_TOKEN; + const teamId = process.env.VERCEL_TEAM_ID; + const projectId = process.env.VERCEL_PROJECT_ID; + if (token && teamId && projectId) return { token, teamId, projectId }; + throw new Error( + 'Vercel credentials not configured.\n' + + 'Either run `vercel link && vercel env pull` to get a VERCEL_OIDC_TOKEN, ' + + 'or set VERCEL_TOKEN + VERCEL_TEAM_ID + VERCEL_PROJECT_ID ' + + '(see `agentbox vercel login`).', + ); +} + +interface OidcClaims { + teamId: string; + projectId: string; + exp?: number; +} + +/** Decode the `owner_id`/`project_id`/`exp` claims from a Vercel OIDC JWT. */ +function decodeOidcClaims(token: string): OidcClaims | null { + const parts = token.split('.'); + if (parts.length < 2 || !parts[1]) return null; + try { + const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')) as { + owner_id?: unknown; + project_id?: unknown; + exp?: unknown; + }; + if (typeof payload.owner_id !== 'string' || typeof payload.project_id !== 'string') return null; + return { + teamId: payload.owner_id, + projectId: payload.project_id, + exp: typeof payload.exp === 'number' ? payload.exp : undefined, + }; + } catch { + return null; + } +} + +/** True when either auth mode is configured. Used by the credential gate. */ +export function hasUsableCredentials(): boolean { + ensureVercelEnvLoaded(); + if (process.env.VERCEL_OIDC_TOKEN) return true; + return Boolean( + process.env.VERCEL_TOKEN && process.env.VERCEL_TEAM_ID && process.env.VERCEL_PROJECT_ID, + ); +} + +// Re-export the SDK surface we use so the rest of the package imports from one +// place (and tests can mock `./sdk.js` instead of the package). +export { Sandbox, Snapshot } from '@vercel/sandbox'; +export type { Sandbox as SandboxType } from '@vercel/sandbox'; diff --git a/packages/sandbox-vercel/test/backend.test.ts b/packages/sandbox-vercel/test/backend.test.ts new file mode 100644 index 0000000..0ba2694 --- /dev/null +++ b/packages/sandbox-vercel/test/backend.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +// Mock the SDK loader so the backend never touches the real @vercel/sandbox. +const mocks = vi.hoisted(() => { + return { + get: vi.fn(), + create: vi.fn(), + list: vi.fn(), + snapshotGet: vi.fn(), + }; +}); + +vi.mock('../src/sdk.js', () => ({ + resolveCredentials: () => ({}), + Sandbox: { + get: mocks.get, + create: mocks.create, + list: mocks.list, + }, + Snapshot: { + get: mocks.snapshotGet, + }, +})); + +import { vercelBackend } from '../src/backend.js'; + +function fakeSandbox(over: Record = {}): Record { + return { + name: 'box-1', + status: 'running', + currentSnapshotId: undefined, + runCommand: vi.fn(), + stop: vi.fn(async () => undefined), + delete: vi.fn(async () => undefined), + snapshot: vi.fn(async () => ({ snapshotId: 'snap_new' })), + ...over, + }; +} + +beforeEach(() => { + for (const m of Object.values(mocks)) m.mockReset(); +}); + +describe('vercelBackend.state', () => { + it('maps running → running', async () => { + mocks.get.mockResolvedValue(fakeSandbox({ status: 'running' })); + expect(await vercelBackend.state({ sandboxId: 'box-1' })).toBe('running'); + }); + + it('maps stopped → paused (persistent auto-snapshot is resumable)', async () => { + mocks.get.mockResolvedValue(fakeSandbox({ status: 'stopped' })); + expect(await vercelBackend.state({ sandboxId: 'box-1' })).toBe('paused'); + }); + + it('maps transitional snapshotting → running', async () => { + mocks.get.mockResolvedValue(fakeSandbox({ status: 'snapshotting' })); + expect(await vercelBackend.state({ sandboxId: 'box-1' })).toBe('running'); + }); + + it('maps failed → missing', async () => { + mocks.get.mockResolvedValue(fakeSandbox({ status: 'failed' })); + expect(await vercelBackend.state({ sandboxId: 'box-1' })).toBe('missing'); + }); + + it('returns missing when the sandbox is not found', async () => { + mocks.get.mockRejectedValue(new Error('not_found')); + expect(await vercelBackend.state({ sandboxId: 'gone' })).toBe('missing'); + }); +}); + +describe('vercelBackend.exec', () => { + type RunArg = { args: string[]; sudo: boolean }; + const firstRunArg = (fn: ReturnType): RunArg => + (fn.mock.calls[0] as unknown as [RunArg])[0]; + + it('runs as vscode by default and returns split streams', async () => { + const runCommand = vi.fn(() => + Promise.resolve({ exitCode: 0, stdout: async () => 'hello\n', stderr: async () => '' }), + ); + mocks.get.mockResolvedValue(fakeSandbox({ runCommand })); + + const r = await vercelBackend.exec({ sandboxId: 'box-1' }, 'echo hello'); + expect(r).toEqual({ exitCode: 0, stdout: 'hello\n', stderr: '' }); + // The command should be wrapped to drop privileges to vscode. + const arg = firstRunArg(runCommand); + expect(arg.sudo).toBe(true); + expect(arg.args.join(' ')).toMatch(/sudo -u vscode -H bash -lc/); + }); + + it('runs directly as root when user=root (no privilege drop)', async () => { + const runCommand = vi.fn(() => + Promise.resolve({ exitCode: 0, stdout: async () => '', stderr: async () => '' }), + ); + mocks.get.mockResolvedValue(fakeSandbox({ runCommand })); + + await vercelBackend.exec({ sandboxId: 'box-1' }, 'whoami', { user: 'root' }); + const arg = firstRunArg(runCommand); + expect(arg.sudo).toBe(true); + expect(arg.args.join(' ')).not.toMatch(/sudo -u vscode/); + }); +}); + +describe('vercelBackend.destroy', () => { + it('deletes the sandbox and purges its current snapshot', async () => { + const snapDelete = vi.fn(async () => undefined); + const sb = fakeSandbox({ currentSnapshotId: 'snap_live' }); + mocks.get.mockResolvedValue(sb); + mocks.snapshotGet.mockResolvedValue({ delete: snapDelete }); + + await vercelBackend.destroy({ sandboxId: 'box-1' }); + expect((sb.delete as ReturnType)).toHaveBeenCalled(); + expect(snapDelete).toHaveBeenCalled(); + }); + + it('does NOT delete the source snapshot a box still sits on (protects the base)', async () => { + const snapDelete = vi.fn(async () => undefined); + // currentSnapshotId === sourceSnapshotId → the box never made its own + // snapshot; deleting it would nuke the shared base/checkpoint. + const sb = fakeSandbox({ currentSnapshotId: 'snap_base', sourceSnapshotId: 'snap_base' }); + mocks.get.mockResolvedValue(sb); + mocks.snapshotGet.mockResolvedValue({ delete: snapDelete }); + + await vercelBackend.destroy({ sandboxId: 'box-1' }); + expect((sb.delete as ReturnType)).toHaveBeenCalled(); + expect(snapDelete).not.toHaveBeenCalled(); + }); + + it('is idempotent when the sandbox is already gone', async () => { + mocks.get.mockRejectedValue(new Error('not_found')); + await expect(vercelBackend.destroy({ sandboxId: 'gone' })).resolves.toBeUndefined(); + }); +}); diff --git a/packages/sandbox-vercel/test/build-attach.test.ts b/packages/sandbox-vercel/test/build-attach.test.ts new file mode 100644 index 0000000..cbbbac1 --- /dev/null +++ b/packages/sandbox-vercel/test/build-attach.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from 'vitest'; + +// Pretend the compiled attach-helper.js exists so resolveAttachHelperPath() +// doesn't depend on a prior build when running the unit test. +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, existsSync: () => true }; +}); + +import { buildVercelAttach } from '../src/build-attach.js'; +import type { BoxRecord } from '@agentbox/core'; + +function boxWith(sandboxId: string | undefined): BoxRecord { + return { + id: 'id-1', + name: 'vbox', + provider: 'vercel', + container: `cloud:${sandboxId ?? ''}`, + image: 'snap', + workspacePath: '/workspace', + createdAt: '2026-05-28T00:00:00Z', + cloud: sandboxId ? { backend: 'vercel', sandboxId } : undefined, + } as BoxRecord; +} + +function decodeSpec(argv: string[]): Record { + const b64 = argv[argv.length - 1]!; + return JSON.parse(Buffer.from(b64, 'base64').toString('utf8')) as Record; +} + +describe('buildVercelAttach', () => { + it('rejects a record with no sandboxId', async () => { + await expect(buildVercelAttach(boxWith(undefined), 'shell')).rejects.toThrow(/no sandboxId/); + }); + + it('encodes the agent session spec into the helper argv', async () => { + const spec = await buildVercelAttach(boxWith('box-1'), 'agent', { command: 'bash -lc exec\\ claude' }); + // argv = [node, helperPath, sandboxId, base64(spec)] + expect(spec.argv).toHaveLength(4); + expect(spec.argv[2]).toBe('box-1'); + const decoded = decodeSpec(spec.argv); + expect(decoded).toMatchObject({ + sessionName: 'agent', + command: 'bash -lc exec\\ claude', + kind: 'agent', + }); + }); + + it('defaults the session name to the kind and shell command to a login shell', async () => { + const spec = await buildVercelAttach(boxWith('box-1'), 'shell'); + const decoded = decodeSpec(spec.argv); + expect(decoded).toMatchObject({ sessionName: 'shell', command: 'bash -l', kind: 'shell' }); + }); + + it('carries the detached flag for the pre-start path', async () => { + const spec = await buildVercelAttach(boxWith('box-1'), 'agent', { command: 'x', detached: true }); + expect(decodeSpec(spec.argv).detached).toBe(true); + }); +}); diff --git a/packages/sandbox-vercel/test/credentials.test.ts b/packages/sandbox-vercel/test/credentials.test.ts new file mode 100644 index 0000000..17c11ca --- /dev/null +++ b/packages/sandbox-vercel/test/credentials.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { maskKey } from '../src/credentials.js'; +import { resolveCredentials, hasUsableCredentials } from '../src/sdk.js'; +import { reloadVercelEnv } from '../src/env-loader.js'; + +const VERCEL_KEYS = [ + 'VERCEL_OIDC_TOKEN', + 'VERCEL_TOKEN', + 'VERCEL_TEAM_ID', + 'VERCEL_PROJECT_ID', + // Point HOME at a nonexistent dir so the loader can't pick up a real + // ~/.agentbox/secrets.env on the dev machine running the test. +] as const; + +let saved: Record; + +beforeEach(() => { + saved = {}; + for (const k of VERCEL_KEYS) { + saved[k] = process.env[k]; + delete process.env[k]; + } + saved.HOME = process.env.HOME; + process.env.HOME = '/nonexistent-agentbox-test-home'; + reloadVercelEnv(); +}); + +afterEach(() => { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + reloadVercelEnv(); +}); + +describe('maskKey', () => { + it('fully masks short values', () => { + expect(maskKey('abcd')).toBe('****'); + }); + it('shows a prefix/suffix for long values', () => { + expect(maskKey('abcdefghijklmnop')).toMatch(/^abcd…\*{8}mnop$/); + }); +}); + +// Build a fake-but-well-formed Vercel OIDC JWT (header.payload.sig) with the +// claims resolveCredentials decodes. Only the payload segment is read. +function makeOidcToken(claims: { owner_id: string; project_id: string; exp?: number }): string { + const b64 = (o: unknown) => Buffer.from(JSON.stringify(o)).toString('base64url'); + return `${b64({ alg: 'RS256', typ: 'JWT' })}.${b64(claims)}.sig`; +} + +const FUTURE = Math.floor(Date.now() / 1000) + 3600; +const PAST = Math.floor(Date.now() / 1000) - 3600; + +describe('resolveCredentials / hasUsableCredentials', () => { + it('decodes teamId/projectId from the OIDC token and returns explicit creds', () => { + const tok = makeOidcToken({ owner_id: 'team_1', project_id: 'prj_1', exp: FUTURE }); + process.env.VERCEL_OIDC_TOKEN = tok; + expect(hasUsableCredentials()).toBe(true); + expect(resolveCredentials()).toEqual({ token: tok, teamId: 'team_1', projectId: 'prj_1' }); + }); + + it('returns the token trio when no OIDC but the trio is present', () => { + process.env.VERCEL_TOKEN = 't'; + process.env.VERCEL_TEAM_ID = 'team'; + process.env.VERCEL_PROJECT_ID = 'prj'; + expect(hasUsableCredentials()).toBe(true); + expect(resolveCredentials()).toEqual({ token: 't', teamId: 'team', projectId: 'prj' }); + }); + + it('prefers OIDC over a partial trio', () => { + const tok = makeOidcToken({ owner_id: 'team_x', project_id: 'prj_x', exp: FUTURE }); + process.env.VERCEL_OIDC_TOKEN = tok; + process.env.VERCEL_TOKEN = 't'; + expect(resolveCredentials()).toEqual({ token: tok, teamId: 'team_x', projectId: 'prj_x' }); + }); + + it('throws a clear error when the OIDC token has expired', () => { + process.env.VERCEL_OIDC_TOKEN = makeOidcToken({ owner_id: 'team_1', project_id: 'prj_1', exp: PAST }); + expect(() => resolveCredentials()).toThrow(/expired/i); + }); + + it('throws when the OIDC token cannot be decoded', () => { + process.env.VERCEL_OIDC_TOKEN = 'not-a-jwt'; + expect(() => resolveCredentials()).toThrow(/could not be decoded/i); + }); + + it('throws an actionable error when nothing is configured', () => { + expect(hasUsableCredentials()).toBe(false); + expect(() => resolveCredentials()).toThrow(/credentials not configured/i); + }); + + it('does not treat a partial trio as usable', () => { + process.env.VERCEL_TOKEN = 't'; + process.env.VERCEL_TEAM_ID = 'team'; + // missing VERCEL_PROJECT_ID + expect(hasUsableCredentials()).toBe(false); + }); +}); diff --git a/packages/sandbox-vercel/test/env-loader.test.ts b/packages/sandbox-vercel/test/env-loader.test.ts new file mode 100644 index 0000000..b4d8ae3 --- /dev/null +++ b/packages/sandbox-vercel/test/env-loader.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { parseEnvFile } from '../src/env-loader.js'; + +describe('parseEnvFile', () => { + it('parses bare KEY=value', () => { + expect(parseEnvFile('VERCEL_TOKEN=abc')).toEqual({ VERCEL_TOKEN: 'abc' }); + }); + + it('strips surrounding single and double quotes', () => { + expect(parseEnvFile('A="x y"\nB=\'z\'')).toEqual({ A: 'x y', B: 'z' }); + }); + + it('honors the export prefix', () => { + expect(parseEnvFile('export VERCEL_OIDC_TOKEN=tok')).toEqual({ VERCEL_OIDC_TOKEN: 'tok' }); + }); + + it('ignores comments and blank lines', () => { + expect(parseEnvFile('# comment\n\nK=v\n')).toEqual({ K: 'v' }); + }); + + it('ignores lines without a key', () => { + expect(parseEnvFile('=novalue\njust-a-word')).toEqual({}); + }); +}); diff --git a/packages/sandbox-vercel/test/live-state.mjs b/packages/sandbox-vercel/test/live-state.mjs new file mode 100644 index 0000000..3752a67 --- /dev/null +++ b/packages/sandbox-vercel/test/live-state.mjs @@ -0,0 +1,32 @@ +// Print the LIVE Vercel status of an agentbox box, matched by its +// `agentbox.name` tag (set in backend.provision). Used by the live e2e harness +// (scripts/vercel-live-e2e.sh). +// +// WHY this is needed: `agentbox list` reports cloud boxes as optimistically +// 'running' with no live SDK probe (sandbox-docker/src/lifecycle.ts — "tracked +// for Phase 6"), so it can never observe a stopped/paused cloud box. A +// stop/resume test must read the provider directly. +// +// Lives under test/ (not scripts/) so it isn't mistaken for a box runtime asset +// and so `@vercel/sandbox` resolves from the package's node_modules. vitest +// ignores it (discovery is *.test.ts). +// +// Usage: VERCEL_TOKEN=… VERCEL_TEAM_ID=… VERCEL_PROJECT_ID=… \ +// node packages/sandbox-vercel/test/live-state.mjs +// Prints one of: running | stopping | stopped | pending | snapshotting | absent +import { Sandbox } from '@vercel/sandbox'; + +const creds = { + token: process.env.VERCEL_TOKEN, + teamId: process.env.VERCEL_TEAM_ID, + projectId: process.env.VERCEL_PROJECT_ID, +}; +const want = process.argv[2]; +if (!want) { + process.stderr.write('usage: live-state.mjs \n'); + process.exit(2); +} +const page = await Sandbox.list({ ...creds }); +const items = await page.toArray(); +const hit = items.find((sb) => (sb.tags?.['agentbox.name'] ?? sb.name) === want); +process.stdout.write(hit ? String(hit.status) : 'absent'); diff --git a/packages/sandbox-vercel/test/prepared-state.test.ts b/packages/sandbox-vercel/test/prepared-state.test.ts new file mode 100644 index 0000000..a02ed81 --- /dev/null +++ b/packages/sandbox-vercel/test/prepared-state.test.ts @@ -0,0 +1,55 @@ +import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + readPreparedState, + writePreparedState, + ensureVercelBaseSnapshot, + preparedStatePath, +} from '../src/prepared-state.js'; + +let home: string; +let savedHome: string | undefined; + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'agentbox-vercel-prep-')); + mkdirSync(join(home, '.agentbox'), { recursive: true }); + savedHome = process.env.HOME; + process.env.HOME = home; +}); + +afterEach(() => { + if (savedHome === undefined) delete process.env.HOME; + else process.env.HOME = savedHome; +}); + +describe('vercel prepared-state', () => { + it('returns an empty schema-1 state when the file is absent', () => { + expect(readPreparedState()).toEqual({ schema: 1 }); + }); + + it('round-trips a base snapshot record', () => { + writePreparedState({ + schema: 1, + base: { snapshotId: 'snap_abc', contextSha256: 'deadbeef', createdAt: '2026-05-28T00:00:00Z' }, + }); + const s = readPreparedState(); + expect(s.base?.snapshotId).toBe('snap_abc'); + expect(s.base?.contextSha256).toBe('deadbeef'); + }); + + it('refuses an unknown schema (treated as rebuild-needed)', () => { + writeFileSync(preparedStatePath(), JSON.stringify({ schema: 99, base: { snapshotId: 'x' } })); + expect(readPreparedState()).toEqual({ schema: 1 }); + }); + + it('ensureVercelBaseSnapshot throws with the prepare hint when no base exists', () => { + expect(() => ensureVercelBaseSnapshot()).toThrow(/agentbox prepare --provider vercel/); + }); + + it('ensureVercelBaseSnapshot passes once a base is recorded', () => { + writePreparedState({ schema: 1, base: { snapshotId: 'snap_x', createdAt: '2026-05-28T00:00:00Z' } }); + expect(() => ensureVercelBaseSnapshot()).not.toThrow(); + }); +}); diff --git a/packages/sandbox-vercel/tsconfig.json b/packages/sandbox-vercel/tsconfig.json new file mode 100644 index 0000000..f24546c --- /dev/null +++ b/packages/sandbox-vercel/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src/**/*", "test/**/*"] +} diff --git a/packages/sandbox-vercel/tsup.config.ts b/packages/sandbox-vercel/tsup.config.ts new file mode 100644 index 0000000..615bfcb --- /dev/null +++ b/packages/sandbox-vercel/tsup.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + // Three entries: the provider surface (`.`), the CLI surface (`./cli`), and + // the standalone attach-helper (a host-side process the PTY wrapper spawns to + // pump stdio through the Vercel SDK — Vercel has no SSH, so attach can't be a + // plain `ssh` argv like daytona/hetzner). + entry: ['src/index.ts', 'src/cli.ts', 'src/attach-helper.ts'], + format: ['esm'], + target: 'node20', + clean: true, + dts: true, + sourcemap: true, + // commander + @clack/prompts are external (apps/cli bundles them at the root). + // @vercel/sandbox is bundled by tsup as usual for sibling deps. + external: ['commander', '@clack/prompts'], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b72bb9..42c1a14 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: '@daytonaio/sdk': specifier: ^0.179.0 version: 0.179.0(ws@8.21.0) + '@vercel/sandbox': + specifier: ^2.0.1 + version: 2.0.1 '@xterm/headless': specifier: ^5.5.0 version: 5.5.0 @@ -97,6 +100,9 @@ importers: '@agentbox/sandbox-hetzner': specifier: workspace:* version: link:../../packages/sandbox-hetzner + '@agentbox/sandbox-vercel': + specifier: workspace:* + version: link:../../packages/sandbox-vercel '@types/node': specifier: ^22.10.1 version: 22.19.19 @@ -370,6 +376,46 @@ importers: specifier: ^2.1.8 version: 2.1.9(@types/node@22.19.19) + packages/sandbox-vercel: + dependencies: + '@agentbox/config': + specifier: workspace:* + version: link:../config + '@agentbox/core': + specifier: workspace:* + version: link:../core + '@agentbox/sandbox-cloud': + specifier: workspace:* + version: link:../sandbox-cloud + '@agentbox/sandbox-core': + specifier: workspace:* + version: link:../sandbox-core + '@clack/prompts': + specifier: ^0.9.0 + version: 0.9.1 + '@vercel/sandbox': + specifier: ^2.0.1 + version: 2.0.1 + commander: + specifier: ^12.1.0 + version: 12.1.0 + execa: + specifier: ^9.5.2 + version: 9.6.1 + devDependencies: + '@types/node': + specifier: ^22.10.1 + version: 22.19.19 + tsup: + specifier: ^8.3.5 + version: 8.5.1(postcss@8.5.14)(tsx@4.22.3)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.19.19) + packages: '@aws-crypto/crc32@5.2.0': @@ -1621,6 +1667,13 @@ packages: resolution: {integrity: sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vercel/oidc@3.2.0': + resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} + engines: {node: '>= 20'} + + '@vercel/sandbox@2.0.1': + resolution: {integrity: sha512-Q1PZVcTORgEMWAK63ga5h3Yfb5EIGpZAn25nJBw0bwA6Gu8rp7em58/OnpjduXsT408hhY6QP63ACWPM3acYPg==} + '@vitest/expect@2.1.9': resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} @@ -1650,6 +1703,9 @@ packages: '@vitest/utils@2.1.9': resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + '@workflow/serde@4.1.0-beta.2': + resolution: {integrity: sha512-8kkeoQKLDaKXefjV5dbhBj2aErfKp1Mc4pb6tj8144cF+Em5SPbyMbyLCHp+BVrFfFVCBluCtMx+jjvaFVZGww==} + '@xterm/headless@5.5.0': resolution: {integrity: sha512-5xXB7kdQlFBP82ViMJTwwEc3gKCLGKR/eoxQm4zge7GPBl86tCdI0IdPJjoKd8mUSFXz5V7i/25sfsEkP4j46g==} @@ -1707,12 +1763,23 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + async-retry@1.3.3: + resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} axios@1.16.1: resolution: {integrity: sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==} + b4a@1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1720,6 +1787,14 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + bare-events@2.8.3: + resolution: {integrity: sha512-HdUm8EMQBLaJvGUdidNNbqpA1kYkwNcb+MYxkxCLAPJGQzlv9J0C24h8V65Z4c5GLd/JEALDvpFCQgpLJqc0zw==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -1998,6 +2073,9 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -2024,6 +2102,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -2272,6 +2353,9 @@ packages: peerDependencies: ws: '*' + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -2299,6 +2383,9 @@ packages: jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + jsonlines@0.1.1: + resolution: {integrity: sha512-ekDrAGso79Cvf+dtm+mL8OBI2bmAOt3gssYs833De/C9NmIpWDWyUO4zPgB5x2/OhY366dkhgfPMYfwZF7yOZA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -2437,6 +2524,10 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + os-paths@4.4.0: + resolution: {integrity: sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg==} + engines: {node: '>= 6.0'} + outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} @@ -2642,6 +2733,10 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -2724,6 +2819,9 @@ packages: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} + streamx@2.26.0: + resolution: {integrity: sha512-VvNG1K72Po/xwJzxZFnZ++Tbrv4lwSptsbkFuzXCJAYZvCK5nnxsvXU6ajqkv7chyiI1Y0YXq2Jh8Iy8Y7NF/A==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -2774,6 +2872,9 @@ packages: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} + tar-stream@3.1.7: + resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + tar@7.5.15: resolution: {integrity: sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==} engines: {node: '>=18'} @@ -2782,6 +2883,9 @@ packages: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -2884,6 +2988,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@7.26.0: + resolution: {integrity: sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==} + engines: {node: '>=20.18.1'} + unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} @@ -2992,6 +3100,14 @@ packages: utf-8-validate: optional: true + xdg-app-paths@5.1.0: + resolution: {integrity: sha512-RAQ3WkPf4KTU1A8RtFx3gWywzVKe00tfOPFfl2NDGqbIFENQO4kqAJp7mhQjNj/33W5x5hiWWUdyfPq/5SU3QA==} + engines: {node: '>=6'} + + xdg-portable@7.3.0: + resolution: {integrity: sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==} + engines: {node: '>= 6.0'} + xml-naming@0.1.0: resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} engines: {node: '>=16.0.0'} @@ -3025,6 +3141,9 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} + zod@3.24.4: + resolution: {integrity: sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==} + snapshots: '@aws-crypto/crc32@5.2.0': @@ -4384,6 +4503,25 @@ snapshots: '@typescript-eslint/types': 8.59.3 eslint-visitor-keys: 5.0.1 + '@vercel/oidc@3.2.0': {} + + '@vercel/sandbox@2.0.1': + dependencies: + '@vercel/oidc': 3.2.0 + '@workflow/serde': 4.1.0-beta.2 + async-retry: 1.3.3 + jose: 6.2.3 + jsonlines: 0.1.1 + ms: 2.1.3 + picocolors: 1.1.1 + tar-stream: 3.1.7 + undici: 7.26.0 + xdg-app-paths: 5.1.0 + zod: 3.24.4 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + '@vitest/expect@2.1.9': dependencies: '@vitest/spy': 2.1.9 @@ -4424,6 +4562,8 @@ snapshots: loupe: 3.2.1 tinyrainbow: 1.2.0 + '@workflow/serde@4.1.0-beta.2': {} + '@xterm/headless@5.5.0': {} acorn-import-attributes@1.9.5(acorn@8.16.0): @@ -4476,6 +4616,10 @@ snapshots: assertion-error@2.0.1: {} + async-retry@1.3.3: + dependencies: + retry: 0.13.1 + asynckit@0.4.0: {} axios@1.16.1: @@ -4488,10 +4632,14 @@ snapshots: - debug - supports-color + b4a@1.8.1: {} + balanced-match@1.0.2: {} balanced-match@4.0.4: {} + bare-events@2.8.3: {} + base64-js@1.5.1: {} better-path-resolve@1.0.0: @@ -4839,6 +4987,12 @@ snapshots: esutils@2.0.3: {} + events-universal@1.0.1: + dependencies: + bare-events: 2.8.3 + transitivePeerDependencies: + - bare-abort-controller + events@3.3.0: {} execa@9.6.1: @@ -4869,6 +5023,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-fifo@1.3.2: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -5105,6 +5261,8 @@ snapshots: dependencies: ws: 8.21.0 + jose@6.2.3: {} + joycon@3.1.1: {} js-yaml@3.14.2: @@ -5128,6 +5286,8 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonlines@0.1.1: {} + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -5258,6 +5418,8 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + os-paths@4.4.0: {} + outdent@0.5.0: {} p-filter@2.1.0: @@ -5450,6 +5612,8 @@ snapshots: resolve-from@5.0.0: {} + retry@0.13.1: {} + reusify@1.1.0: {} rollup@4.60.3: @@ -5541,6 +5705,15 @@ snapshots: streamsearch@1.1.0: {} + streamx@2.26.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -5602,6 +5775,15 @@ snapshots: readable-stream: 3.6.2 optional: true + tar-stream@3.1.7: + dependencies: + b4a: 1.8.1 + fast-fifo: 1.3.2 + streamx: 2.26.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + tar@7.5.15: dependencies: '@isaacs/fs-minipass': 4.0.1 @@ -5612,6 +5794,12 @@ snapshots: term-size@2.2.1: {} + text-decoder@1.2.7: + dependencies: + b4a: 1.8.1 + transitivePeerDependencies: + - react-native-b4a + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -5718,6 +5906,8 @@ snapshots: undici-types@6.21.0: {} + undici@7.26.0: {} + unicorn-magic@0.3.0: {} universalify@0.1.2: {} @@ -5812,6 +6002,14 @@ snapshots: ws@8.21.0: {} + xdg-app-paths@5.1.0: + dependencies: + xdg-portable: 7.3.0 + + xdg-portable@7.3.0: + dependencies: + os-paths: 4.4.0 + xml-naming@0.1.0: {} y18n@5.0.8: {} @@ -5835,3 +6033,5 @@ snapshots: yocto-queue@0.1.0: {} yoctocolors@2.1.2: {} + + zod@3.24.4: {} diff --git a/scripts/vercel-live-e2e.sh b/scripts/vercel-live-e2e.sh new file mode 100755 index 0000000..a79cad0 --- /dev/null +++ b/scripts/vercel-live-e2e.sh @@ -0,0 +1,260 @@ +#!/usr/bin/env bash +# +# vercel-live-e2e.sh — drive the remaining P0 live-smoke items for the Vercel +# provider (docs/vercel-backlog.md): pause/resume (#5), checkpoint round-trip +# (#6), and (opt-in) relay round-trip (#4). +# +# WHY this exists: the boot path was validated live 2026-05-28, but pause/resume, +# the checkpoint round-trip, and the relay round-trip were never exercised. This +# script automates them from a context that holds a VERCEL_TOKEN trio. It +# deliberately avoids the laggy attach bridge: box state is read from the LIVE +# Vercel SDK (test/live-state.mjs — `agentbox list` reports cloud boxes as +# optimistically 'running'), the /workspace marker travels over `agentbox cp` +# (relay-backed provider transfer), and the snapshot id is read straight from the +# checkpoint manifest. +# +# Usage: +# VERCEL_TOKEN=... VERCEL_TEAM_ID=... VERCEL_PROJECT_ID=... \ +# AGENTBOX_BIN="node $PWD/apps/cli/dist/index.js" bash scripts/vercel-live-e2e.sh +# +# Flags / env: +# --keep leave boxes + checkpoint behind for inspection (no cleanup) +# --prepare run `agentbox prepare --provider vercel` first if no base +# E2E_RELAY=1 also attempt the relay round-trip (#4); needs a pushable +# origin reachable by the host relay (see Phase D notes) +# AGENTBOX_BIN=... explicit CLI command (default: agentbox on PATH, else +# node /apps/cli/dist/index.js). The published CLI +# can't do --provider vercel yet, so use the monorepo dist. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SUF="$(date +%s)" +KEEP=0 +PREPARE=0 +for arg in "$@"; do + case "$arg" in + --keep) KEEP=1 ;; + --prepare) PREPARE=1 ;; + *) echo "unknown arg: $arg" >&2; exit 2 ;; + esac +done + +if [[ -n "${AGENTBOX_BIN:-}" ]]; then + # shellcheck disable=SC2206 + AB=($AGENTBOX_BIN) +elif command -v agentbox >/dev/null 2>&1; then + AB=(agentbox) +else + AB=(node "$REPO_ROOT/apps/cli/dist/index.js") +fi + +BOX="vfe-$SUF" +RESTORE="vfe-restore-$SUF" +CKPT="vfe-ckpt-$SUF" +MARKER_VAL="agentbox-e2e-$SUF" +TMP="$(mktemp -d)" +PASS=0 +FAIL=0 + +c_green() { printf '\033[32m%s\033[0m\n' "$*"; } +c_red() { printf '\033[31m%s\033[0m\n' "$*"; } +info() { printf '\n=== %s\n' "$*"; } +pass() { PASS=$((PASS + 1)); c_green "PASS: $*"; } +fail() { FAIL=$((FAIL + 1)); c_red "FAIL: $*"; } + +# LIVE Vercel status of a box, by name. We do NOT use `agentbox list` for state: +# it reports cloud boxes as optimistically 'running' with no SDK probe +# (sandbox-docker/src/lifecycle.ts — "tracked for Phase 6"), so it can never see +# a stopped box. The helper reads the provider directly. +LIVE_STATE=(node "$REPO_ROOT/packages/sandbox-vercel/test/live-state.mjs") +live_status() { "${LIVE_STATE[@]}" "$1" 2>/dev/null || echo "err"; } + +# Poll live status until it equals $2 (or timeout $3 seconds). Prints transitions. +poll_status() { + local box="$1" want="$2" timeout="$3" t=0 s + while [[ "$t" -lt "$timeout" ]]; do + s="$(live_status "$box")" + echo " [t=${t}s] live status of $box: $s" + [[ "$s" == "$want" ]] && return 0 + sleep 6 + t=$((t + 6)) + done + return 1 +} + +box_provider() { + "${AB[@]}" list -j -g 2>/dev/null | node -e ' + const fs = require("fs"); + let d; try { d = JSON.parse(fs.readFileSync(0, "utf8")); } catch { process.exit(0); } + const arr = Array.isArray(d) ? d : (d.boxes || d.items || []); + const b = arr.find((x) => x.name === process.argv[1]); + process.stdout.write(b ? String(b.provider ?? "docker") : ""); + ' "$1" +} + +cleanup() { + local code=$? + if [[ "$KEEP" == "1" ]]; then + info "--keep set; leaving $BOX / $RESTORE / checkpoint $CKPT in place" + else + info "cleanup" + "${AB[@]}" destroy "$BOX" -y >/dev/null 2>&1 || true + "${AB[@]}" destroy "$RESTORE" -y >/dev/null 2>&1 || true + "${AB[@]}" checkpoint rm "$CKPT" -y >/dev/null 2>&1 || true + fi + rm -rf "$TMP" + echo + printf 'summary: %d passed, %d failed\n' "$PASS" "$FAIL" + if [[ "$FAIL" -gt 0 && "$code" == "0" ]]; then exit 1; fi + exit "$code" +} +trap cleanup EXIT + +# --------------------------------------------------------------------------- +# Preconditions +# --------------------------------------------------------------------------- +info "preconditions" +if [[ -z "${VERCEL_TOKEN:-}" && -z "${VERCEL_OIDC_TOKEN:-}" ]]; then + fail "no Vercel credential in env (set the VERCEL_TOKEN trio — OIDC tends to be expired)" + exit 1 +fi +if [[ -n "${VERCEL_TOKEN:-}" ]]; then + [[ -n "${VERCEL_TEAM_ID:-}" && -n "${VERCEL_PROJECT_ID:-}" ]] \ + || { fail "VERCEL_TOKEN set but VERCEL_TEAM_ID / VERCEL_PROJECT_ID missing"; exit 1; } + pass "VERCEL_TOKEN trio present" +else + c_red "note: using VERCEL_OIDC_TOKEN — it likely expires mid-run; the token trio is the practical path" +fi + +if [[ ! -f "$HOME/.agentbox/vercel-prepared.json" ]]; then + if [[ "$PREPARE" == "1" ]]; then + info "no base snapshot; running prepare (slow)" + "${AB[@]}" prepare --provider vercel + else + fail "no base snapshot (~/.agentbox/vercel-prepared.json). Run with --prepare or 'agentbox prepare --provider vercel' first" + exit 1 + fi +fi +pass "base snapshot recorded" + +# --------------------------------------------------------------------------- +# Phase A — create +# --------------------------------------------------------------------------- +info "Phase A: create $BOX" +# --carry skip: this is a lifecycle smoke, not a carry test; skipping avoids the +# non-TTY carry-approval gate when the workspace's agentbox.yaml has a carry block. +"${AB[@]}" create --provider vercel -n "$BOX" -y --carry skip +[[ "$(box_provider "$BOX")" == "vercel" ]] && pass "box provider == vercel" || fail "box provider != vercel" +poll_status "$BOX" running 90 && pass "box running after create" || fail "box not running after create" + +# --------------------------------------------------------------------------- +# Phase B — pause / resume (#5) +# --------------------------------------------------------------------------- +info "Phase B: pause/resume + /workspace survival (#5)" +printf '%s' "$MARKER_VAL" > "$TMP/AGENTBOX_E2E_MARKER" +"${AB[@]}" cp "$TMP/AGENTBOX_E2E_MARKER" "$BOX:/workspace/" + +URL1="$("${AB[@]}" url "$BOX" --print 2>/dev/null || true)" +[[ -n "$URL1" ]] && echo "preview URL before stop: $URL1" || echo "preview URL before stop: (none / not exposed)" + +# stop auto-snapshots; the VM transitions running -> stopping -> stopped (~20s). +"${AB[@]}" stop "$BOX" +poll_status "$BOX" stopped 150 && pass "box stopped (auto-snapshot)" || fail "box did not reach 'stopped' in 150s" + +# start resumes lazily from the auto-snapshot. +"${AB[@]}" start "$BOX" +poll_status "$BOX" running 150 && pass "box running after start (resume)" || fail "box did not resume in 150s" + +rm -f "$TMP/marker.out" +"${AB[@]}" cp "$BOX:/workspace/AGENTBOX_E2E_MARKER" "$TMP/marker.out" +if [[ -f "$TMP/marker.out" && "$(cat "$TMP/marker.out")" == "$MARKER_VAL" ]]; then + pass "/workspace marker survived stop/start" +else + fail "/workspace marker lost or wrong after stop/start" +fi + +URL2="$("${AB[@]}" url "$BOX" --print 2>/dev/null || true)" +echo "preview URL after start: ${URL2:-(none)}" +if [[ -n "$URL1" && -n "$URL2" ]]; then + [[ "$URL1" == "$URL2" ]] && echo "info: preview URL stable across stop/start" \ + || echo "info: preview URL ROTATED across stop/start (expected per backlog #5 — note it)" +fi + +# --------------------------------------------------------------------------- +# Phase C — checkpoint round-trip (#6) +# --------------------------------------------------------------------------- +info "Phase C: checkpoint round-trip (#6)" +"${AB[@]}" checkpoint create "$BOX" --name "$CKPT" --replace + +MANIFEST="$(ls "$HOME"/.agentbox/cloud-checkpoints/vercel/*/"$CKPT"/manifest.json 2>/dev/null | head -1 || true)" +if [[ -n "$MANIFEST" ]]; then + SNAP_ID="$(node -e ' + const fs = require("fs"); + const m = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + process.stdout.write(String(m.snapshotName ?? "")); + ' "$MANIFEST")" + [[ -n "$SNAP_ID" ]] && pass "manifest stores vercel snapshot id ($SNAP_ID)" \ + || fail "manifest has empty snapshotName" +else + fail "no checkpoint manifest at ~/.agentbox/cloud-checkpoints/vercel/*/$CKPT/manifest.json" +fi + +"${AB[@]}" create --provider vercel --snapshot "$CKPT" -n "$RESTORE" -y --carry skip +poll_status "$RESTORE" running 90 && pass "restored box running from --snapshot $CKPT" \ + || fail "restored box not running" + +rm -f "$TMP/marker.restore" +"${AB[@]}" cp "$RESTORE:/workspace/AGENTBOX_E2E_MARKER" "$TMP/marker.restore" 2>/dev/null || true +if [[ -f "$TMP/marker.restore" && "$(cat "$TMP/marker.restore")" == "$MARKER_VAL" ]]; then + pass "checkpoint captured /workspace state (marker present in restored box)" +else + fail "marker missing from restored box — checkpoint did not capture /workspace" +fi + +# --------------------------------------------------------------------------- +# Phase D — relay round-trip (#4) — opt-in (needs a pushable origin) +# --------------------------------------------------------------------------- +info "Phase D: relay round-trip (#4)" +if [[ "${E2E_RELAY:-0}" == "1" ]]; then + echo "attempting in-box commit + 'agentbox-ctl git push' via the relay..." + echo "(this uses the one-shot attach path, which is laggy for vercel — read the output carefully)" + "${AB[@]}" shell "$BOX" -- bash -lc ' + set -e + cd /workspace + git config user.email e2e@agentbox.local + git config user.name agentbox-e2e + date > AGENTBOX_RELAY_PROBE + git add AGENTBOX_RELAY_PROBE + git commit -m "e2e relay probe" >/dev/null + agentbox-ctl git push + ' && pass "in-box 'agentbox-ctl git push' returned success" \ + || fail "in-box git push via relay failed (check the host relay is up + origin is reachable)" + echo "VERIFY MANUALLY on the host: 'git ls-remote origin agentbox/$BOX' should show the probe commit," + echo "and the host relay log should record the push host-action." +else + cat < AGENTBOX_RELAY_PROBE && git add -A && git commit -m probe + 4. agentbox-ctl git push # routes through the in-box relay client -> host relay + 5. On the host: git ls-remote origin agentbox/$BOX -> should show the probe commit + 6. Also try: agentbox-ctl git pull, and a 'gh pr' command from inside the box. +EOF +fi + +# --------------------------------------------------------------------------- +# Regression: destroy preserves the shared base (the 2026-05-28 bug #1) +# --------------------------------------------------------------------------- +info "Regression: destroy preserves the base snapshot" +"${AB[@]}" destroy "$RESTORE" -y >/dev/null 2>&1 || true +"${AB[@]}" destroy "$BOX" -y >/dev/null 2>&1 || true +BASECHECK="vfe-basecheck-$SUF" +if "${AB[@]}" create --provider vercel -n "$BASECHECK" -y --carry skip; then + poll_status "$BASECHECK" running 90 \ + && pass "base snapshot still bootable after destroys (guard holds)" \ + || fail "base-check box not running" + "${AB[@]}" destroy "$BASECHECK" -y >/dev/null 2>&1 || true +else + fail "could not create from base after destroys — base may have been deleted (410?)" +fi