diff --git a/CLAUDE.md b/CLAUDE.md index f9efdd8d..4987b838 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. Seven 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), **Vercel Sandbox** (`--provider vercel` — a Firecracker microVM per box, persistent snapshots, public HTTPS preview URLs; nested containers (in-box docker) now supported and baked in, no SSH, baked from a one-time `agentbox prepare --provider vercel` snapshot), **E2B Sandbox** (`--provider e2b` — a Firecracker microVM per box, SDK-only comms, public HTTPS preview URLs, persistent pause/resume; uniquely among the cloud providers, E2B builds its base template **directly from a Dockerfile** via `Template.build()` — `agentbox prepare --provider e2b` runs the build), and **Remote Docker** (`--provider remote-docker`, spelled `agentbox docker: …` — one container per box on a machine the *user* already owns, reached over an OpenSSH ControlMaster and driving **that machine's** docker engine. Cloud-shaped despite being docker: a bind mount can't cross a network, so the workspace is synced (git clone + carried-over stash/untracked) exactly as for the clouds, while the image (Dockerfile), checkpoints (`docker commit`) and DinD stay docker-shaped. No credential — it connects as you, over your own `~/.ssh/config`). +`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. Eight 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), **Vercel Sandbox** (`--provider vercel` — a Firecracker microVM per box, persistent snapshots, public HTTPS preview URLs; nested containers (in-box docker) now supported and baked in, no SSH, baked from a one-time `agentbox prepare --provider vercel` snapshot), **E2B Sandbox** (`--provider e2b` — a Firecracker microVM per box, SDK-only comms, public HTTPS preview URLs, persistent pause/resume; uniquely among the cloud providers, E2B builds its base template **directly from a Dockerfile** via `Template.build()` — `agentbox prepare --provider e2b` runs the build), **Remote Docker** (`--provider remote-docker`, spelled `agentbox docker: …` — one container per box on a machine the *user* already owns, reached over an OpenSSH ControlMaster and driving **that machine's** docker engine. Cloud-shaped despite being docker: a bind mount can't cross a network, so the workspace is synced (git clone + carried-over stash/untracked) exactly as for the clouds, while the image (Dockerfile), checkpoints (`docker commit`) and DinD stay docker-shaped. No credential — it connects as you, over your own `~/.ssh/config`), and **Tenki Sandbox** (`--provider tenki` — a Firecracker microVM per box driven over the `@tenkicloud/sandbox` TypeScript SDK: a ConnectRPC control plane + a per-session data plane for `exec`/file transfer and `session.ssh()` for attach; boots from a registry image published into the Tenki workspace by `agentbox prepare --provider tenki`; free pause/resume + id-addressed snapshots). ## Architecture overview @@ -10,7 +10,8 @@ - **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. - **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. **In-box docker (DinD)** is baked into the base snapshot and `dockerd` is auto-started on create/resume (`launchDockerd:true`, via the shared `agentbox-dockerd-start`) — Vercel Sandbox now supports nested containers. **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). - **e2b** (cloud): one E2B Sandbox (Firecracker microVM, Debian 12) per box. Workspace seeded the same way (git bundle + stash + untracked tar). **Key differentiator from Vercel/Hetzner: E2B builds its base image directly from a Dockerfile** via the SDK's `Template.build()` — `agentbox prepare --provider e2b` drives the build and pins the resulting template id to `box.imageE2b`. `Sandbox.pause`/`Sandbox.connect` (auto-resume) gives free pause/resume; `Sandbox.createSnapshot` is the reusable, id-addressed checkpoint primitive (same shape as Vercel). Comms via the SDK: `exec` runs as `vscode`; `previewUrl(port)` returns the public `{port}-{sandboxId}.e2b.app` URL (HTTPS, no token; constructed locally so it doesn't wake a paused sandbox). **In-box docker (DinD)** is baked into the base template and `dockerd` auto-starts on create/resume (`launchDockerd:true`) — E2B microVMs support nested containers (full root + cap_sys_admin, verified 2026-06-23), contrary to the original "same as Vercel" assumption. **No SSH** — attach is a custom `attach-helper.cjs` SDK-streaming PTY bridge over `pty.create`. 1-hour platform session cap on the Hobby tier (the attach helper caps at 55 minutes for headroom). See [`docs/e2b_backlog.md`](./docs/e2b_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, e2b). + - **tenki** (cloud): one Tenki sandbox (Firecracker microVM) per box. Workspace seeded the same way (git clone + stash + untracked tar). Boots from a **registry image** published into the Tenki workspace by `agentbox prepare --provider tenki` (built from the GHCR `agentbox/box` parent via Tenki's template build), or a checkpoint snapshot id. Comms via the `@tenkicloud/sandbox` TS SDK (ConnectRPC control plane + per-session data plane): `exec` = `session.run(['bash','-c',cmd])`, file transfer = `writeFileStream`/`readFileStream`, preview URLs = `session.exposePort(port)` (public HTTPS, WebProxy on 8080). Free pause/resume (`session.pause`/`resume`); `session.extend` renews the deadline (host keepalive); `createSnapshotAndWait` is the id-addressed checkpoint primitive (same shape as vercel/e2b). **No SSH for exec/files**; interactive attach bridges the host PTY to `session.ssh()` (custom `attach-helper.cjs`). `CloudHandle.sandboxId` is the Tenki session id. In-box docker is **off** (`launchDockerd:false`) pending DinD verification. Newly added — see [`docs/tenki-backlog.md`](./docs/tenki-backlog.md) for the live-verify checklist. +- **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, e2b, tenki). - **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; Vercel `sb.snapshot()` (id-addressed; stores the snapshot id in the cloud-checkpoint manifest) for vercel; E2B `Sandbox.createSnapshot` (id-addressed template, same shape as Vercel) for e2b. All flow through `provider.checkpoint.create`. `box.defaultCheckpoint` is the cross-provider fallback; `box.defaultCheckpointDocker` / `box.defaultCheckpointDaytona` / `box.defaultCheckpointHetzner` / `box.defaultCheckpointVercel` / `box.defaultCheckpointE2b` 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), [`docs/vercel-backlog.md`](./docs/vercel-backlog.md), and [`docs/e2b_backlog.md`](./docs/e2b_backlog.md). **Read them before making non-trivial changes to the lifecycle code.** @@ -127,6 +128,7 @@ Each topic has a dedicated file under [`docs/`](./docs). Read the relevant one b - [`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). - [`docs/e2b_backlog.md`](./docs/e2b_backlog.md) — E2B provider build-out status: how the shape maps onto `CloudBackend`, why E2B is the only cloud that builds the base **from a Dockerfile** (`Template.build()`), task-by-task progress, and shipped/deferred items. +- [`docs/tenki-backlog.md`](./docs/tenki-backlog.md) — Tenki provider build-out status: how the `@tenkicloud/sandbox` ConnectRPC SDK maps onto `CloudBackend`, what's implemented + unit-tested, and the **live-verify checklist** (the e2e path is unverified against a live Tenki workspace — no token was available at build time). - [`docs/linux-host-backlog.md`](./docs/linux-host-backlog.md) — Linux (Ubuntu) **host** support: what's done (`agentbox doctor` is Linux-aware), how to test on a persistent Hetzner Ubuntu VM (`scripts/linux-dev-vm.sh` — `up`/`deploy`/`ssh`/`doctor`/`down`), and the remaining macOS-only host assumptions (browser `open`→`xdg-open`, iTerm2/AppleScript terminal spawning, OrbStack-only fast paths). - [`docs/control-plane-roadmap.md`](./docs/control-plane-roadmap.md) — the **Control Hub** architecture + roadmap (the forward direction; "Control Hub" / `agentbox hub` is the new name for the control-plane, renamed in milestone M1). Covers the three shifts (in-box create/bake + poll to unify the local + serverless paths; hub-anywhere/PC-first with a SQLite local hub; custody + 3-way sync), the four deployment topologies (local host, mac-mini, server/container, serverless Vercel→Cloudflare), the two capability profiles (serverless "control plane" vs full-host "control-box"), the source-of-truth constraint, the CLI rename + `hub install/update/uninstall`, and milestones M0–M8. Pairs with [`docs/control-plane-backlog.md`](./docs/control-plane-backlog.md) (what shipped). - [`docs/control-plane-guide.md`](./docs/control-plane-guide.md) — the **feature guide** for the hosted control plane (a.k.a. the legacy "control-box"): the one-relay-core/three-topologies model, how it works (Store seam, GitHub-App token leasing, block-vs-poll approvals, dual-mode server + bridge + CloudBoxPoller, the create-job worker, the in-box clone/relay-env/lease-and-push), and how to use it (`agentbox control-plane setup|set-url|status|add|worker` with examples). Read this before the roadmap/backlog for the high-level "what + how to use". diff --git a/README.md b/README.md index c587f8ee..c5df4ee2 100644 --- a/README.md +++ b/README.md @@ -81,12 +81,12 @@ Uses `portless` to give box web apps the same URL from inside the box and on the ## Cloud Providers -| | local docker | remote docker | hetzner | daytona | vercel | e2b | -| ------------------- | ------------------------- | ---------------------- | ---------------------- | ------------------ | ------------------ | ------------------ | -| Support | ✅ | ✅ | ✅ | ⚠️ Partial | ✅ | ✅ | -| Base image | Dockerfile | Dockerfile (on the remote) | Setup script (Ubuntu) | Dockerfile | Setup script | Dockerfile (`Template.build`) | -| Live snapshots | ✅ | ✅ (`docker commit`) | ✅ | 🧪 Experimental | ✅ | ✅ | -| Private preview URLs| ✅ (portless or OrbStack) | ✅ (portless over SSH) | ✅ (portless) | ✅ (native) | ✅ (native) | ✅ (native) | +| | local docker | remote docker | hetzner | daytona | vercel | e2b | tenki | +| ------------------- | ------------------------- | ---------------------- | ---------------------- | ------------------ | ------------------ | ------------------ | ------------------ | +| Support | ✅ | ✅ | ✅ | ⚠️ Partial | ✅ | ✅ | 🧪 Experimental | +| Base image | Dockerfile | Dockerfile (on the remote) | Setup script (Ubuntu) | Dockerfile | Setup script | Dockerfile (`Template.build`) | Registry image | +| Live snapshots | ✅ | ✅ (`docker commit`) | ✅ | 🧪 Experimental | ✅ | ✅ | ✅ | +| Private preview URLs| ✅ (portless or OrbStack) | ✅ (portless over SSH) | ✅ (portless) | ✅ (native) | ✅ (native) | ✅ (native) | ✅ (native) | **Cloud setup** (optional — skip for local Docker) @@ -95,9 +95,10 @@ Uses `portless` to give box web apps the same URL from inside the box and on the - `agentbox hetzner login` — interactive Hetzner Cloud token setup, saved to `~/.agentbox/secrets.env` - `agentbox daytona login` — interactive Daytona API key setup, saved to `~/.agentbox/secrets.env` - `agentbox e2b login` — interactive E2B API key setup, saved to `~/.agentbox/secrets.env` +- `agentbox tenki login` — interactive Tenki auth token setup, saved to `~/.agentbox/secrets.env` - `agentbox digitalocean login` — interactive DigitalOcean Personal Access Token setup, saved to `~/.agentbox/secrets.env` - `agentbox remote-docker doctor ` — run boxes on a machine you already own, over SSH. No login and no token: it connects as you, using your own `~/.ssh/config`. Then `agentbox docker: claude`. -- `agentbox prepare [--provider daytona|hetzner|vercel|e2b|digitalocean|docker:]` — build the image and initial snapshot (e2b builds from a Dockerfile via `Template.build()`) +- `agentbox prepare [--provider daytona|hetzner|vercel|e2b|digitalocean|tenki|docker:]` — build the image and initial snapshot (e2b builds from a Dockerfile via `Template.build()`; tenki publishes a registry image) - `agentbox hetzner claude`, `agentbox hetzner codex`, `agentbox hetzner create`, etc. ## How to use @@ -156,7 +157,7 @@ Full documentation lives at **[agent-box.sh/docs](https://agent-box.sh/docs)**: - [Quickstart](https://agent-box.sh/docs) and [Core concepts](https://agent-box.sh/docs/core-concepts) - [Teleport a project](https://agent-box.sh/docs/teleport-a-project), [Run an agent](https://agent-box.sh/docs/run-an-agent), [Access your box](https://agent-box.sh/docs/access-your-box) - [Configuration](https://agent-box.sh/docs/configuration), [Services & tasks](https://agent-box.sh/docs/services-and-tasks), [Sync & git](https://agent-box.sh/docs/sync-and-git) -- Cloud providers: [Hetzner](https://agent-box.sh/docs/hetzner), [Daytona](https://agent-box.sh/docs/daytona), [Vercel](https://agent-box.sh/docs/vercel), [E2B](https://agent-box.sh/docs/e2b), [DigitalOcean](https://agent-box.sh/docs/digitalocean) +- Cloud providers: [Hetzner](https://agent-box.sh/docs/hetzner), [Daytona](https://agent-box.sh/docs/daytona), [Vercel](https://agent-box.sh/docs/vercel), [E2B](https://agent-box.sh/docs/e2b), [DigitalOcean](https://agent-box.sh/docs/digitalocean), [Tenki](https://agent-box.sh/docs/tenki) - Full [CLI reference](https://agent-box.sh/docs/cli) ## Development diff --git a/apps/cli/package.json b/apps/cli/package.json index 66473825..560b951e 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -88,6 +88,7 @@ "@agentbox/sandbox-docker": "workspace:*", "@agentbox/sandbox-e2b": "workspace:*", "@agentbox/sandbox-hetzner": "workspace:*", + "@agentbox/sandbox-tenki": "workspace:*", "@agentbox/sandbox-vercel": "workspace:*", "@types/node": "^22.10.1", "tsup": "^8.3.5", diff --git a/apps/cli/scripts/stage-runtime.mjs b/apps/cli/scripts/stage-runtime.mjs index 127187f8..7b27c3ad 100644 --- a/apps/cli/scripts/stage-runtime.mjs +++ b/apps/cli/scripts/stage-runtime.mjs @@ -251,6 +251,18 @@ for (const [srcRel, destRel, exec] of e2bFiles) { copy(srcRel, join(e2bCtx, destRel), exec); } +// Tenki provider — the only on-disk asset is the PTY attach helper bundle the +// provider's `buildAttach` spawns at runtime (`node attach-helper.cjs`). Boxes +// boot from a prepared Tenki registry image, so unlike docker/e2b there is no +// build context or shim tree to stage. The tenki package is bundled into +// dist/index.js (its dist/ isn't shipped), so stage the helper explicitly to +// runtime/tenki/attach-helper.cjs where resolveAttachHelperPath() looks. +const tenkiCtx = join(runtime, 'tenki'); +const tenkiFiles = [['packages/sandbox-tenki/dist/attach-helper.cjs', 'attach-helper.cjs', false]]; +for (const [srcRel, destRel, exec] of tenkiFiles) { + copy(srcRel, join(tenkiCtx, destRel), exec); +} + // README — npm reads the published package's README only from the package // root (apps/cli/), and there's no package.json field to point elsewhere. // Mirror the repo-root README here so npmjs.com has a landing page, rewriting @@ -284,6 +296,6 @@ if (missing > 0) { ); } else { console.log( - '[stage-runtime] staged runtime/ (relay bin + docker build context + hetzner install assets + digitalocean install assets + daytona overlay + vercel assets + e2b assets)', + '[stage-runtime] staged runtime/ (relay bin + docker build context + hetzner install assets + digitalocean install assets + daytona overlay + vercel assets + e2b assets + tenki attach helper)', ); } diff --git a/apps/cli/src/commands/dashboard.ts b/apps/cli/src/commands/dashboard.ts index 49356c8f..c37d0719 100644 --- a/apps/cli/src/commands/dashboard.ts +++ b/apps/cli/src/commands/dashboard.ts @@ -65,7 +65,7 @@ interface DashboardOptions { * lifetime semantics. Add future no-SSH providers here. */ function providerSupportsKeepAlive(provider: BoxRecord['provider']): boolean { - return provider === 'vercel' || provider === 'e2b'; + return provider === 'vercel' || provider === 'e2b' || provider === 'tenki'; } /** diff --git a/apps/cli/src/commands/doctor.ts b/apps/cli/src/commands/doctor.ts index 24a80b9f..3f4afc4e 100644 --- a/apps/cli/src/commands/doctor.ts +++ b/apps/cli/src/commands/doctor.ts @@ -31,7 +31,7 @@ export const doctorCommand = new Command('doctor') ) .option( '-p, --provider ', - 'limit checks to one provider (docker | daytona | hetzner | vercel | e2b)', + 'limit checks to one provider (docker | daytona | hetzner | vercel | e2b | tenki)', ) .action(async (opts: DoctorOptions) => { let groups: CheckGroup[]; @@ -39,7 +39,7 @@ export const doctorCommand = new Command('doctor') const name = opts.provider.trim(); if (!isKnownProvider(name)) { process.stderr.write( - 'error: --provider must be one of: docker, daytona, hetzner, vercel, e2b\n', + 'error: --provider must be one of: docker, daytona, hetzner, vercel, e2b, tenki\n', ); process.exit(1); } diff --git a/apps/cli/src/commands/prepare.ts b/apps/cli/src/commands/prepare.ts index de2c3166..4349aff2 100644 --- a/apps/cli/src/commands/prepare.ts +++ b/apps/cli/src/commands/prepare.ts @@ -214,6 +214,60 @@ function renderE2b(status: E2bStatusResult, pinnedImage?: string): string[] { return out; } +interface TenkiStatusUnknown { + configured: false; + reason?: string; +} +interface TenkiStatusOk { + configured: true; + image?: string; + imageName?: string; + createdAt?: string; + cliVersion?: string; +} +type TenkiStatusResult = TenkiStatusUnknown | TenkiStatusOk; + +async function tenkiStatus(): Promise { + try { + const mod = await import('@agentbox/sandbox-tenki'); + const cred = mod.readTenkiCredStatus(); + if (cred.auth === 'none') { + return { configured: false, reason: 'not configured — run `agentbox tenki login`' }; + } + const prepared = mod.readPreparedState(); + if (!prepared.base) return { configured: true }; + return { + configured: true, + image: prepared.base.image, + imageName: prepared.base.imageName, + createdAt: prepared.base.createdAt, + cliVersion: prepared.base.cliVersion, + }; + } catch (err) { + return { + configured: false, + reason: err instanceof Error ? err.message.split('\n')[0] : String(err), + }; + } +} + +function renderTenki(status: TenkiStatusResult, pinnedImage?: string): string[] { + const out: string[] = ['tenki:']; + if (!status.configured) { + out.push(` ${status.reason ?? '(not configured)'}`); + return out; + } + if (!status.image) { + out.push(' no agentbox base image — run `agentbox prepare --provider tenki`'); + return out; + } + const pinned = pinnedImage && pinnedImage === status.image ? ' (pinned in project)' : ''; + out.push( + ` image ${pad(status.imageName ?? status.image, 40)} ${pad(status.cliVersion ?? '—', 10)} ${humanAge(status.createdAt)}${pinned}`, + ); + return out; +} + function renderDaytona(status: DaytonaStatusResult, pinnedImage?: string): string[] { const out: string[] = ['daytona:']; if (!status.configured) { @@ -263,6 +317,7 @@ async function showStatus(opts: { onlyProvider?: string }): Promise { const wantDocker = !opts.onlyProvider || opts.onlyProvider === 'docker'; const wantDaytona = !opts.onlyProvider || opts.onlyProvider === 'daytona'; const wantE2b = !opts.onlyProvider || opts.onlyProvider === 'e2b'; + const wantTenki = !opts.onlyProvider || opts.onlyProvider === 'tenki'; if (wantDocker) { const status = await dockerStatus(); @@ -283,6 +338,16 @@ async function showStatus(opts: { onlyProvider?: string }): Promise { : undefined; lines.push(...renderE2b(status, e2bPinned)); } + if (wantTenki) { + if (lines.length > 0) lines.push(''); + const status = await tenkiStatus(); + // Use the per-provider pin (box.imageTenki) so the marker tracks the right key. + const tenkiPinned = + typeof cfg?.effective.box.imageTenki === 'string' && cfg.effective.box.imageTenki.length > 0 + ? cfg.effective.box.imageTenki + : undefined; + lines.push(...renderTenki(status, tenkiPinned)); + } if (pinned) { lines.push(''); lines.push(`project pin: box.image = ${pinned}`); diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 818d6aba..a009e4e6 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -54,6 +54,7 @@ import { dockerCommand } from './commands/docker.js'; import { hetznerCommand } from '@agentbox/sandbox-hetzner/cli'; import { vercelCommand } from '@agentbox/sandbox-vercel/cli'; import { e2bCommand } from '@agentbox/sandbox-e2b/cli'; +import { tenkiCommand } from '@agentbox/sandbox-tenki/cli'; import { digitaloceanCommand } from '@agentbox/sandbox-digitalocean/cli'; import { remoteDockerCommand } from '@agentbox/sandbox-remote-docker/cli'; import { destroyCommand } from './commands/destroy.js'; @@ -185,6 +186,7 @@ program.addCommand(daytonaCommand); program.addCommand(hetznerCommand); program.addCommand(vercelCommand); program.addCommand(e2bCommand); +program.addCommand(tenkiCommand); program.addCommand(digitaloceanCommand); program.addCommand(remoteDockerCommand); program.addCommand(dockerCommand); diff --git a/apps/cli/src/lib/cloud-sizing.ts b/apps/cli/src/lib/cloud-sizing.ts index 5e60d564..277f30c2 100644 --- a/apps/cli/src/lib/cloud-sizing.ts +++ b/apps/cli/src/lib/cloud-sizing.ts @@ -91,6 +91,12 @@ export function cloudSizingProviderOptions( if (providerName === 'e2b') { out.timeoutMs = cfg.box.e2bTimeoutMs; } + if (providerName === 'tenki') { + // Session lifetime the box is created with (maps to Tenki's maxDurationMs) + // and records as `cloud.sessionTimeoutMs` so the host keepalive loop can + // push the deadline forward (via `session.extend`) while the agent works. + out.timeoutMs = cfg.box.tenkiTimeoutMs; + } if (providerName === 'remote-docker') { // Which machine runs the container. Unlike every other provider's options // this one is mandatory — there is no sensible default engine — so resolve diff --git a/apps/cli/src/provider/loaders.ts b/apps/cli/src/provider/loaders.ts index bcfee6f6..f2761bc0 100644 --- a/apps/cli/src/provider/loaders.ts +++ b/apps/cli/src/provider/loaders.ts @@ -33,6 +33,7 @@ const IMPORTERS: Record Promise<{ providerModule: ProviderMo e2b: () => import('@agentbox/sandbox-e2b'), digitalocean: () => import('@agentbox/sandbox-digitalocean'), 'remote-docker': () => import('@agentbox/sandbox-remote-docker'), + tenki: () => import('@agentbox/sandbox-tenki'), }; /** diff --git a/apps/hub/app/(dashboard)/settings/components/provider-actions.tsx b/apps/hub/app/(dashboard)/settings/components/provider-actions.tsx index 04eba535..4e396778 100644 --- a/apps/hub/app/(dashboard)/settings/components/provider-actions.tsx +++ b/apps/hub/app/(dashboard)/settings/components/provider-actions.tsx @@ -50,6 +50,7 @@ const CRED_FIELDS: Record = { optional: true, }, ], + tenki: [{ key: 'token', label: 'Auth token', placeholder: 'tk_…' }], }; export function ProvidersSection() { diff --git a/apps/hub/lib/hub-backend.ts b/apps/hub/lib/hub-backend.ts index d880d284..83516cfe 100644 --- a/apps/hub/lib/hub-backend.ts +++ b/apps/hub/lib/hub-backend.ts @@ -105,6 +105,7 @@ const IMPORTERS: Record Promise<{ providerModule: ProviderMo e2b: () => import('@agentbox/sandbox-e2b'), digitalocean: () => import('@agentbox/sandbox-digitalocean'), 'remote-docker': () => import('@agentbox/sandbox-remote-docker'), + tenki: () => import('@agentbox/sandbox-tenki'), }; // Per-provider serialization of prepare-enqueue: `prepareProvider` reads the @@ -398,6 +399,7 @@ const PROVIDER_CRED_KEYS: Record = { // remote-docker authenticates as you, over your own ~/.ssh/config — there is // no credential to store, so there is none to check. 'remote-docker': [], + tenki: ['TENKI_AUTH_TOKEN'], }; /** Set of KEY names present in `~/.agentbox/secrets.env` (values ignored). */ diff --git a/apps/hub/package.json b/apps/hub/package.json index a4697978..83cbf31e 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -2,7 +2,7 @@ "name": "@agentbox/hub", "version": "0.0.0", "private": true, - "description": "AgentBox hub — the Web UI + relay core as a Next.js app (localhost, hetzner, or Vercel)", + "description": "AgentBox hub \u2014 the Web UI + relay core as a Next.js app (localhost, hetzner, or Vercel)", "type": "module", "scripts": { "dev": "next dev", @@ -27,10 +27,11 @@ "@agentbox/sandbox-core": "workspace:*", "@agentbox/sandbox-daytona": "workspace:*", "@agentbox/sandbox-digitalocean": "workspace:*", - "@agentbox/sandbox-remote-docker": "workspace:*", "@agentbox/sandbox-docker": "workspace:*", "@agentbox/sandbox-e2b": "workspace:*", "@agentbox/sandbox-hetzner": "workspace:*", + "@agentbox/sandbox-remote-docker": "workspace:*", + "@agentbox/sandbox-tenki": "workspace:*", "@agentbox/sandbox-vercel": "workspace:*", "@radix-ui/react-tooltip": "^1.2.12", "better-auth": "^1.6.0", diff --git a/apps/web/content/docs/cli.mdx b/apps/web/content/docs/cli.mdx index bcf1ab19..cc1041bd 100644 --- a/apps/web/content/docs/cli.mdx +++ b/apps/web/content/docs/cli.mdx @@ -383,7 +383,7 @@ agentbox remote-docker remove # forget an alias (+ default + bake `docker` is the default and is pure sugar (no login). Each cloud provider has a `login` subcommand (run by default); hetzner adds `firewall`, daytona adds `resync`. `remote-docker` has **no login at all** — it connects as you, over your own `~/.ssh/config` — and offers `check` / `use` / `hosts` instead. E2B is the only cloud whose `prepare` builds the base image **directly from a Dockerfile** via the SDK's `Template.build()` — the others bake a one-time snapshot. -See [Local Docker](/docs/local-docker), [Remote Docker](/docs/remote-docker), [Hetzner](/docs/hetzner), [DigitalOcean](/docs/digitalocean), [Daytona](/docs/daytona), [Vercel](/docs/vercel), and [E2B](/docs/e2b). +See [Local Docker](/docs/local-docker), [Remote Docker](/docs/remote-docker), [Hetzner](/docs/hetzner), [DigitalOcean](/docs/digitalocean), [Daytona](/docs/daytona), [Vercel](/docs/vercel), [E2B](/docs/e2b), and [Tenki](/docs/tenki). ## Maintenance diff --git a/apps/web/content/docs/configuration.mdx b/apps/web/content/docs/configuration.mdx index 016d8a60..03b3aaf8 100644 --- a/apps/web/content/docs/configuration.mdx +++ b/apps/web/content/docs/configuration.mdx @@ -98,7 +98,7 @@ The largest group. A pattern runs through it: a generic key plus per-provider ov | Key | Type | Default | Meaning | | --- | --- | --- | --- | -| `box.provider` | enum `docker`/`daytona`/`hetzner`/`vercel`/`e2b`/`digitalocean`/`remote-docker` | `docker` | backend new boxes are created on | +| `box.provider` | enum `docker`/`daytona`/`hetzner`/`vercel`/`e2b`/`digitalocean`/`remote-docker`/`tenki` | `docker` | backend new boxes are created on | This is what plain `agentbox claude` (or `create`, `codex`, `opencode`) uses when you don't pass `--provider`. `agentbox install` offers to set it for you at the end of the wizard, so the provider you just logged in to and prepared becomes the one new boxes go to. Set it yourself at any time: @@ -109,7 +109,7 @@ agentbox config set box.provider hetzner # this project only A per-run `--provider docker` (or the `agentbox docker claude` prefix) still overrides it. -See [local Docker](/docs/local-docker), [Remote Docker](/docs/remote-docker), [Hetzner](/docs/hetzner), [Daytona](/docs/daytona), [Vercel](/docs/vercel), and [core concepts](/docs/core-concepts). +See [local Docker](/docs/local-docker), [Remote Docker](/docs/remote-docker), [Hetzner](/docs/hetzner), [Daytona](/docs/daytona), [Vercel](/docs/vercel), [E2B](/docs/e2b), [Tenki](/docs/tenki), and [core concepts](/docs/core-concepts). ### Resource limits (Docker) @@ -125,7 +125,7 @@ See [local Docker](/docs/local-docker), [Remote Docker](/docs/remote-docker), [H | Key | Type | Default | Meaning | | --- | --- | --- | --- | | `box.size` | string | empty | generic cloud size, provider-interpreted: hetzner = server type (`cx33`), digitalocean = Droplet size slug (`s-4vcpu-8gb`), daytona = `cpu-memory-disk` GB (`4-8-20`), vercel = vCPU count (`1`/`2`/`4`/`8`), e2b = `cpu-memory` GB (`4-8`, baked at `prepare` time); docker ignores it | -| `box.sizeDaytona` / `box.sizeHetzner` / `box.sizeDigitalocean` / `box.sizeVercel` / `box.sizeE2b` / `box.sizeRemoteDocker` | string (advanced) | empty | per-provider override of `box.size` | +| `box.sizeDaytona` / `box.sizeHetzner` / `box.sizeDigitalocean` / `box.sizeVercel` / `box.sizeE2b` / `box.sizeRemoteDocker` / `box.sizeTenki` | string (advanced) | empty | per-provider override of `box.size` | | `box.sizeDocker` | string (advanced) | empty | reserved — docker uses `box.memory` / `box.cpus` / `box.disk` | | `box.daytonaClass` | string | `linux-vm` | Daytona [sandbox class](/docs/daytona#sandbox-class): `linux-vm` gives a true pause (CPU + memory frozen, running processes survive) and a ~1 min base bake, but runs only in `us-east-1`. `container` keeps the old behavior and any region. Changing it needs `agentbox prepare --provider daytona --force`. Daytona-only | | `box.daytonaRegion` | string | empty | Daytona region (`us`, `eu`, `us-east-1`). Empty derives it from the class — `linux-vm` ⇒ `us-east-1` (the only region with VM runners), `container` ⇒ the account default. Daytona-only | @@ -142,7 +142,7 @@ See [local Docker](/docs/local-docker), [Remote Docker](/docs/remote-docker), [H | Key | Type | Default | Meaning | | --- | --- | --- | --- | | `box.image` | string (advanced) | `agentbox/box:dev` | generic image ref; the default is a sentinel cloud backends read as "boot from the prepared base snapshot" | -| `box.imageDocker` / `box.imageDaytona` / `box.imageHetzner` / `box.imageDigitalocean` / `box.imageVercel` / `box.imageRemoteDocker` | string (advanced) | empty | per-provider override; the cloud ones are written by `agentbox prepare --provider `. Leave `box.imageRemoteDocker` empty — that provider derives a fingerprint-tagged ref and ensures it on the remote engine itself | +| `box.imageDocker` / `box.imageDaytona` / `box.imageHetzner` / `box.imageDigitalocean` / `box.imageVercel` / `box.imageRemoteDocker` / `box.imageTenki` | string (advanced) | empty | per-provider override; the cloud ones are written by `agentbox prepare --provider `. Leave `box.imageRemoteDocker` empty — that provider derives a fingerprint-tagged ref and ensures it on the remote engine itself | | `box.imageRegistry` | string (advanced, docker only) | `ghcr.io/madarco/agentbox/box` | registry to pull the prebuilt base from before building locally; empty = always build | @@ -154,7 +154,7 @@ Setting the generic `box.image` to a provider-native snapshot id breaks creates | Key | Type | Default | Meaning | | --- | --- | --- | --- | | `box.defaultCheckpoint` | string | empty | checkpoint new boxes start from when `--snapshot` isn't given; set via `agentbox checkpoint set-default` | -| `box.defaultCheckpointDocker` / `…Daytona` / `…Hetzner` / `…Digitalocean` / `…Vercel` / `…E2b` / `…RemoteDocker` | string (advanced) | empty | per-provider overrides; set via `checkpoint set-default --provider ` | +| `box.defaultCheckpointDocker` / `…Daytona` / `…Hetzner` / `…Digitalocean` / `…Vercel` / `…E2b` / `…RemoteDocker` / `…Tenki` | string (advanced) | empty | per-provider overrides; set via `checkpoint set-default --provider ` | See [checkpoints and pausing](/docs/checkpoints-and-pausing). @@ -182,15 +182,16 @@ See [teleport a project](/docs/teleport-a-project), [environment](/docs/environm | --- | --- | --- | --- | | `box.isolateClaudeConfig` / `box.isolateCodexConfig` / `box.isolateOpencodeConfig` | bool | `false` | give the box its own agent config volume instead of the shared one | -### Vercel only +### Provider-specific tuning (Vercel / E2B / Tenki) | Key | Type | Default | Meaning | | --- | --- | --- | --- | | `box.vercelTimeoutMs` | int | `2700000` (45 min) | max session length before auto-snapshot; persistent mode auto-resumes | | `box.vercelNetworkPolicy` | string | empty (`allow-all`) | egress lock: `allow-all`, `deny-all`, or a comma-separated domain allowlist (`github.com,*.npmjs.org`) | | `box.e2bTimeoutMs` | int | `2700000` (45 min) | session timeout a new `--provider e2b` box is created with before E2B auto-pauses it on inactivity; the host keepalive holds it open while the agent works (Hobby caps total session at ~1 h) | +| `box.tenkiTimeoutMs` | int | `2700000` (45 min) | session lifetime a new `--provider tenki` box is created with (Tenki `maxDurationMs`); the host keepalive pushes it forward via `session.extend` while the agent works | -See [Vercel](/docs/vercel). +See [Vercel](/docs/vercel), [E2B](/docs/e2b), and [Tenki](/docs/tenki). ## checkpoint.* diff --git a/apps/web/content/docs/daytona.mdx b/apps/web/content/docs/daytona.mdx index f274925c..0aaea68a 100644 --- a/apps/web/content/docs/daytona.mdx +++ b/apps/web/content/docs/daytona.mdx @@ -7,7 +7,7 @@ Run your agents in a managed Daytona cloud sandbox (default **2 vCPU / 4 GB RAM By default a Daytona box is a **Linux VM**, which means `agentbox pause` genuinely freezes it — CPU *and memory* — so a long build or a running agent picks up exactly where it left off on `unpause`. No other AgentBox provider preserves running processes across a pause. -Switch per box with `--provider daytona`, or pin it project-wide with `box.provider: daytona` in [`agentbox.yaml`](/docs/agentbox-yaml). Comparing options? See [local-docker](/docs/local-docker), [hetzner](/docs/hetzner), [vercel](/docs/vercel), and [e2b](/docs/e2b). +Switch per box with `--provider daytona`, or pin it project-wide with `box.provider: daytona` in [`agentbox.yaml`](/docs/agentbox-yaml). Comparing options? See [local-docker](/docs/local-docker), [hetzner](/docs/hetzner), [vercel](/docs/vercel), [e2b](/docs/e2b), and [tenki](/docs/tenki). ## Sandbox class diff --git a/apps/web/content/docs/e2b.mdx b/apps/web/content/docs/e2b.mdx index 2c05c777..c4411fbc 100644 --- a/apps/web/content/docs/e2b.mdx +++ b/apps/web/content/docs/e2b.mdx @@ -5,7 +5,7 @@ description: Run your agents in a fast E2B cloud sandbox with public preview URL Run your agents in a fast E2B cloud sandbox with a public HTTPS preview URL and free pause/resume — no local Docker needed. Each box is a remote E2B sandbox you drive with the same `agentbox` commands as a local box. It supports [Docker-in-Docker](/docs/docker-in-docker) (the engine is baked into the base) but the Hobby tier caps sessions at one hour — and it's the only provider that builds its base straight from a Dockerfile. -Switch per box with `--provider e2b`, or pin it project-wide with `box.provider: e2b` in [`agentbox.yaml`](/docs/agentbox-yaml). Comparing options? See [local-docker](/docs/local-docker), [hetzner](/docs/hetzner), [daytona](/docs/daytona), and [vercel](/docs/vercel). +Switch per box with `--provider e2b`, or pin it project-wide with `box.provider: e2b` in [`agentbox.yaml`](/docs/agentbox-yaml). Comparing options? See [local-docker](/docs/local-docker), [hetzner](/docs/hetzner), [daytona](/docs/daytona), [vercel](/docs/vercel), and [tenki](/docs/tenki). E2B support is available in the **nightly** build of AgentBox. diff --git a/apps/web/content/docs/hetzner.mdx b/apps/web/content/docs/hetzner.mdx index 9eb59717..1c5b1866 100644 --- a/apps/web/content/docs/hetzner.mdx +++ b/apps/web/content/docs/hetzner.mdx @@ -5,7 +5,7 @@ description: Run your agents on a real, inexpensive Hetzner VPS with full root a Run your agents on a real, inexpensive cloud VM you fully control — no local Docker needed. Each box is its own Hetzner Cloud VPS (1:1) you drive with the same `agentbox` commands as a local box, reached over pure OpenSSH (no third-party agent in the box — you own root). Heads up: a Hetzner box is a real VPS that bills ~€4/mo even when stopped — pause is poweroff/poweron, not free — and its firewall locks to your current egress IP. -Switch per box with `--provider hetzner`, or pin it project-wide with `box.provider: hetzner` in [`agentbox.yaml`](/docs/agentbox-yaml). Pick Hetzner for bare-VPS control (full kernel, your own region), a Cloud Firewall locked to your egress IP, and full [Docker-in-Docker](/docs/docker-in-docker). Cost is roughly €4/mo per *running* box. Comparing options? See [local-docker](/docs/local-docker), [daytona](/docs/daytona), [vercel](/docs/vercel), and [e2b](/docs/e2b). +Switch per box with `--provider hetzner`, or pin it project-wide with `box.provider: hetzner` in [`agentbox.yaml`](/docs/agentbox-yaml). Pick Hetzner for bare-VPS control (full kernel, your own region), a Cloud Firewall locked to your egress IP, and full [Docker-in-Docker](/docs/docker-in-docker). Cost is roughly €4/mo per *running* box. Comparing options? See [local-docker](/docs/local-docker), [daytona](/docs/daytona), [vercel](/docs/vercel), [e2b](/docs/e2b), and [tenki](/docs/tenki). ## Set up diff --git a/apps/web/content/docs/index.mdx b/apps/web/content/docs/index.mdx index 89a145c7..0328c9c5 100644 --- a/apps/web/content/docs/index.mdx +++ b/apps/web/content/docs/index.mdx @@ -39,7 +39,7 @@ npm -g i @madarco/agentbox && agentbox install ``` During `agentbox install` you'll be prompted to log in to any cloud providers you -want to use (Vercel, Hetzner, DigitalOcean, Daytona, E2B) — this is **not** needed for the +want to use (Vercel, Hetzner, DigitalOcean, Daytona, E2B, Tenki) — this is **not** needed for the default local Docker provider. You'll then be asked to build the base image for each selected provider (e.g. the local Ubuntu Docker image, the Vercel base sandbox, the E2B template, etc.) so the first box starts fast, and finally whether diff --git a/apps/web/content/docs/meta.json b/apps/web/content/docs/meta.json index d2321365..42f84995 100644 --- a/apps/web/content/docs/meta.json +++ b/apps/web/content/docs/meta.json @@ -32,6 +32,7 @@ "daytona", "vercel", "e2b", + "tenki", "---Community Providers---", "islo", "---Reference---", diff --git a/apps/web/content/docs/tenki.mdx b/apps/web/content/docs/tenki.mdx new file mode 100644 index 00000000..a2191ce7 --- /dev/null +++ b/apps/web/content/docs/tenki.mdx @@ -0,0 +1,131 @@ +--- +title: Tenki +description: Run your agents in a Tenki cloud sandbox (Firecracker microVM) with public preview URLs and free pause/resume +--- + +Run your agents in a [Tenki](https://tenki.cloud) cloud sandbox — a disposable Linux microVM built for AI agents — with a public HTTPS preview URL and free pause/resume, no local Docker needed. Each box is a remote Tenki session you drive with the same `agentbox` commands as a local box, seeded from your host repo. Comms run over Tenki's TypeScript SDK ([`@tenkicloud/sandbox`](https://www.npmjs.com/package/@tenkicloud/sandbox)): a ConnectRPC control plane plus a per-session data plane for `run` / file transfer, and an SSH-backed shell channel for interactive attach. + +Switch per box with `--provider tenki`, or pin it project-wide with `box.provider: tenki` in [`agentbox.yaml`](/docs/agentbox-yaml). Comparing options? See [local-docker](/docs/local-docker), [hetzner](/docs/hetzner), [daytona](/docs/daytona), [vercel](/docs/vercel), and [e2b](/docs/e2b). + + +The Tenki provider is newly added. The implementation follows the same `CloudBackend` contract as the other clouds and is fully typechecked and unit-tested, but the end-to-end path (provision → seed → exec → checkpoint → attach) should be verified against a live Tenki workspace before relying on it. See the live-verify checklist in [`docs/tenki-backlog.md`](https://github.com/madarco/agentbox/blob/main/docs/tenki-backlog.md) in the repo. + + +## Set up + +The easiest path is the interactive wizard — it signs you in and prepares the base image in one flow: + +```bash +agentbox install # then select tenki +``` + +Paste a Tenki workspace auth token (`tk_…`, created in your [Tenki dashboard](https://tenki.cloud)) when prompted. Credentials persist to `~/.agentbox/secrets.env`; project `.env` files are never harvested. `install` also prepares the base image (a one-time `agentbox prepare --provider tenki` under the hood) so every new box boots ready in seconds. + +For CI or scripted setup, run the explicit equivalents: + +```bash +agentbox tenki login # credentials only +agentbox prepare --provider tenki # publish the base image +``` + +The SDK reads `TENKI_AUTH_TOKEN`; optional `TENKI_BASE_URL` / `TENKI_GATEWAY_ADDRESS` overrides point the client at a non-default / self-hosted control plane. See [environment](/docs/environment) for where `~/.agentbox/secrets.env` lives. + +## Use it + +```bash +agentbox tenki claude +``` + +`agentbox tenki create|claude|codex|opencode` is sugar for the same command with `--provider tenki`. + +## Prepare the base image + +Tenki boots a session from a **registry image ref** (`workspace/name:tag`) or a snapshot id — it doesn't pull arbitrary external OCI refs at create time. So `agentbox prepare --provider tenki` gets the AgentBox box image (published to GHCR as `ghcr.io/madarco/agentbox/box`) into your Tenki workspace registry, then records the resolved ref under the per-provider key `box.imageTenki` and to `~/.agentbox/tenki-prepared.json`. Every `create --provider tenki` boots from it. + +```bash +agentbox prepare --provider tenki +``` + +Two paths: + +- **Auto-build (default)** — builds a Tenki template from the GHCR parent image (`createTemplate({ parentImage }) → buildTemplate → waitForTemplateBuild`), publishes a registry image from the build snapshot, and pins the resolved ref. +- **Explicit ref** — if you've already pushed the AgentBox image to your Tenki workspace registry, pass it (`--image ` or `AGENTBOX_TENKI_BASE_IMAGE=`) and `prepare` just validates + pins it. + +A repeat `prepare` reuses the recorded ref unless you pass `--force`. `agentbox tenki login` nudges you toward `prepare` on first login, so the login-only path doesn't silently trip the "no base image" error on `create`. + +## Create a box + +Once you're logged in and prepared, the flow is standard. The workspace is seeded from a host git clone plus your stash and untracked files (see [teleport-a-project](/docs/teleport-a-project)), and `/workspace` is checked out on branch `agentbox/`. + +```bash +agentbox create --provider tenki +``` + +```bash +# sugar: argv-prefix rewrite equivalent to --provider tenki +agentbox tenki claude +``` + +From there, [run an agent](/docs/run-an-agent) and [access your box](/docs/access-your-box) as usual. + +## Pause / resume + +Tenki sessions pause via `session.pause()` and resume via `session.resume()` — free pause/resume, the same model as e2b/vercel. The host keepalive loop pushes the session's death-time forward (via the additive `session.extend`) while the agent is actively working, so a long session isn't reaped at the create-time `maxDurationMs`. `destroy` terminates the session (`session.close()`). + +```bash +agentbox pause +agentbox start +``` + +## Checkpoints + +Cloud checkpoints map to Tenki snapshots — `createSnapshotAndWait(sessionId, { name })` returns an id-addressed reusable snapshot (same shape as vercel/e2b). `agentbox checkpoint create` captures it; `agentbox create --checkpoint ` boots from it with `/workspace` intact and skips workspace seeding. Pair `checkpoint create` with `box.defaultCheckpointTenki` so repeat creates boot from your project-ready snapshot. + +```bash +agentbox checkpoint create --name setup +agentbox create --provider tenki --checkpoint setup +``` + +AgentBox self-heals a stale checkpoint: `create` probes the snapshot's `READY` state, prunes the dangling local manifest, and falls back to the base image. + +## Maintenance + +`agentbox prune --provider tenki` lists every Tenki session the configured token can see, filters to the ones carrying the agentbox marker (`metadata.agentbox`), cross-references this CLI's local `state.json`, and offers to delete sessions you no longer track (typically left behind by an interrupted `create`). + +```bash +agentbox prune --provider tenki --dry-run +agentbox prune --provider tenki -y +``` + +`agentbox doctor --provider tenki` checks the credential token + the prepared base image. + +## Specs + +| Spec | | +| --- | --- | +| Base image | AgentBox box image published to the Tenki workspace registry | +| Build method | Tenki template build from the GHCR parent image (`agentbox prepare --provider tenki`); or an explicitly pre-published ref | +| Comms | TypeScript SDK — ConnectRPC control plane + per-session data plane (`run`, `readFile`/`writeFile`) | +| Docker-in-Docker | Not enabled yet (`launchDockerd: false`) pending verification of nested-container support in a Tenki microVM | +| SSH | None for exec/files; interactive attach bridges the host PTY to `session.ssh()` | +| Live snapshots | Free pause/resume (`session.pause`/`resume`) + id-addressed snapshots (`createSnapshotAndWait`) | +| Preview URL | Public HTTPS, returned by `session.exposePort(port)`; WebProxy runs on 8080 | + +## Caveats + +- **No live stats** — `agentbox top` / `dashboard` render `—` for cloud boxes, and `list` reports them optimistically from the last known state. +- **Privileged ports** — the WebProxy runs on 8080 (not 80), matching the other microVM providers; `agentbox url` resolves the 8080 preview. +- **Push through the relay** — use `agentbox-ctl git push` from a box; the relay holds the credentials. See [sync-and-git](/docs/sync-and-git). +- **Tenki-only config keys are ignored by other providers** — `box.imageTenki` / `box.defaultCheckpointTenki` / `box.sizeTenki` / `box.tenkiTimeoutMs` only apply to tenki. `box.tenkiTimeoutMs` (default 2700000, 45 min) tunes the session lifetime new boxes are created with; the host keepalive extends it while the agent works. See [configuration](/docs/configuration). +- **In-box docker is off by default** — DinD inside a Tenki microVM is unverified; a `docker`-based `agentbox.yaml` service won't start until it's confirmed and enabled. +- **Attach window size** — the SDK's SSH channel exposes no resize hook, so the attached tmux session renders at a default size (no live SIGWINCH propagation yet). + +## Related + +- [core-concepts](/docs/core-concepts) — what a box, branch, and worktree are. +- [teleport-a-project](/docs/teleport-a-project) — how `/workspace` is seeded. +- [run-an-agent](/docs/run-an-agent) · [access-your-box](/docs/access-your-box) — work in a cloud box. +- [web-apps-and-tunnels](/docs/web-apps-and-tunnels) — preview URLs, the 8080 WebProxy, `expose:` ports. +- [checkpoints-and-pausing](/docs/checkpoints-and-pausing) — pause/resume and checkpoints. +- [sync-and-git](/docs/sync-and-git) — relay-backed push/pull. +- [cli](/docs/cli) · [agentbox-yaml](/docs/agentbox-yaml) · [configuration](/docs/configuration) · [environment](/docs/environment) — reference. diff --git a/apps/web/content/docs/vercel.mdx b/apps/web/content/docs/vercel.mdx index d6f4e756..0a12e849 100644 --- a/apps/web/content/docs/vercel.mdx +++ b/apps/web/content/docs/vercel.mdx @@ -5,7 +5,7 @@ description: Run your agents in a fast Vercel cloud sandbox with public preview Run your agents in a fast Vercel cloud sandbox with a public HTTPS preview URL and persistent pause/resume — no local Docker needed. Each box is a remote Vercel sandbox you drive with the same `agentbox` commands as a local box. It supports [Docker-in-Docker](/docs/docker-in-docker) (baked into the snapshot and auto-started), and capturing a checkpoint stops the VM first (pause auto-snapshots on stop). -Switch per box with `--provider vercel`, or pin it project-wide with `box.provider: vercel` in [`agentbox.yaml`](/docs/agentbox-yaml). Comparing options? See [local-docker](/docs/local-docker), [hetzner](/docs/hetzner), [daytona](/docs/daytona), and [e2b](/docs/e2b). +Switch per box with `--provider vercel`, or pin it project-wide with `box.provider: vercel` in [`agentbox.yaml`](/docs/agentbox-yaml). Comparing options? See [local-docker](/docs/local-docker), [hetzner](/docs/hetzner), [daytona](/docs/daytona), [e2b](/docs/e2b), and [tenki](/docs/tenki). ## Set up diff --git a/docs/tenki-backlog.md b/docs/tenki-backlog.md new file mode 100644 index 00000000..24d547fe --- /dev/null +++ b/docs/tenki-backlog.md @@ -0,0 +1,91 @@ +# Tenki provider — build-out status + +The `tenki` provider (`--provider tenki`) runs each box in a [Tenki](https://tenki.cloud) +sandbox — a Firecracker microVM — driven over the official TypeScript SDK +[`@tenkicloud/sandbox`](https://www.npmjs.com/package/@tenkicloud/sandbox). + +It was added by mirroring the existing cloud providers (closest analog: **e2b**, +which is also a Dockerfile-derived microVM with SDK-only comms + pause/resume). +The full package builds, typechecks, lints, and unit-tests clean in the +workspace. **The end-to-end path has not yet been verified against a live Tenki +workspace** — no `TENKI_AUTH_TOKEN` was available when it was written — so the +items under "Live-verify" below are the gating work before relying on it. + +## How the SDK maps onto `CloudBackend` + +`@tenkicloud/sandbox` exposes a high-level `TenkiSandbox` (control plane) + a +`Session` handle (control + per-session data plane). The mapping: + +| `CloudBackend` | Tenki SDK | +| --- | --- | +| `provision` | `client.createAndWait({ name, image \| snapshotId, env, cpuCores, memoryMb, diskSizeGb, allowInbound, allowOutbound, maxDurationMs, metadata, tags })` → store `session.id` as `sandboxId` | +| `get` / `state` | `client.get(id)` → map `session.state` (`RUNNING`/`PAUSED`/…) to `CloudState` | +| `list` | `client.list()` filtered to `metadata.agentbox === 'true'` | +| `start` / `resume` | `session.resume()` | +| `stop` / `pause` | `session.pause()` (pause is the cold-store; no separate stop in the high-level SDK) | +| `destroy` | `session.close()` (terminate) | +| `exec` | `session.run(['bash','-c', cmd], { cwd, env, privileged })` → `{ exitCode, stdout, stderr }` | +| `uploadFile` / `downloadFile` | `session.writeFileStream` / `readFileStream` (streamed, so a ~20 MB workspace tarball never sits fully in memory) | +| `listFiles` | `session.list(dir)` | +| `previewUrl` / `signedPreviewUrl` | `session.exposePort(port, { slug, ttlMs })` → public HTTPS `previewUrl`; reused via `listExposedPorts` | +| `refreshPreviewUrl` | `unexposePort` + `exposePort` | +| `renewTimeout` | `session.extend(target - current)` (additive, like vercel) | +| `snapshotExists` | `client.getSnapshot(id).state === 'READY'` | +| checkpoint (in `index.ts`) | `client.createSnapshotAndWait(sessionId, { name })` → id-addressed snapshot; `deleteSnapshot(id)` | +| attach (`buildTenkiAttach`) | host PTY ↔ `session.ssh()` bridge (`attach-helper.cjs`) | + +Auth: `TENKI_AUTH_TOKEN` (workspace token, `tk_…`), with optional +`TENKI_BASE_URL` / `TENKI_GATEWAY_ADDRESS` control-plane overrides. Per-provider +config keys: `box.imageTenki`, `box.sizeTenki`, `box.defaultCheckpointTenki`. + +## Done + +- `packages/sandbox-tenki` package: `backend.ts`, `index.ts` (provider composition + + checkpoint + attach overrides), `sdk.ts`, `retry.ts` (`withTenkiRetry` with + ConnectRPC-code + socket-code classification), `credentials.ts`, `env-loader.ts`, + `prepared-state.ts`, `prepare.ts`, `cli.ts`, `build-attach.ts`, `attach-helper.ts`. +- Unit tests: state mapping, preview-slug derivation, name sanitisation, retry + classification, env parsing (`pnpm --filter @agentbox/sandbox-tenki test`). +- CLI wiring: provider registry, cloud-backend map, `tenki` subcommand + `--provider + tenki` sugar, install wizard, doctor checks, prepare status, prune, checkpoint, + fork, dashboard keep-alive. +- Config keys + `ProviderKind` / `PreparedProviderKind` unions. +- Docs: [`tenki.mdx`](../apps/web/content/docs/tenki.mdx) + sidebar + cross-links. + +## Live-verify checklist (gating) + +Run with a real `TENKI_AUTH_TOKEN` set, against a workspace with credits: + +1. **`agentbox prepare --provider tenki`** — confirm the template build from the + GHCR `agentbox/box` parent image works, OR that a pre-published + `box.imageTenki` ref validates. This is the least-certain path: it assumes + `createTemplate({ parentImage })` accepts an external OCI ref. If Tenki can't + pull external images, switch prepare to the "boot a base session → run the + agentbox install → snapshot → publishRegistryImage" flow (daytona's shape). +2. **`agentbox create --provider tenki`** — workspace seed (clone + stash + + untracked tar) extracts into `/workspace`; the box user the SDK's `run` / + file ops execute as matches the image's user (the backend assumes no chown is + needed because `writeFile` and `run` share the data-plane uid — verify). +3. **`agentbox tenki claude`** — interactive attach over `session.ssh()`: raw-mode + stdio bridge, tmux session, detach (`Ctrl+a d`). The SSH channel exposes no + resize hook, so window-size propagation is absent — confirm tmux is usable. +4. **Preview URLs** — `agentbox url` resolves `session.exposePort(8080)`; the host + `CloudBoxPoller` reaches the in-box relay over the exposed bridge port (8788). +5. **Checkpoints** — `agentbox checkpoint create` → `createSnapshotAndWait`; + `create --checkpoint` boots from the snapshot id. +6. **Pause/resume + keepalive** — `agentbox pause`/`start`; confirm `session.extend` + pushes the deadline while an agent is active. +7. **Comms through the agent proxy** — the SDK uses ConnectRPC + a `ws` data + plane. A policy-enforcing egress proxy that blocks websocket upgrades / HTTP/2 + would break the data plane; verify on the user's host network (not the CI proxy). + +## Deferred + +- **In-box Docker (DinD)** — `launchDockerd: false` for now. Tenki microVMs may or + may not grant the namespaces a container runtime needs (e2b does; the original + vercel assumption was that it didn't). Verify, then flip to `true` and bake the + engine into the prepared image if supported. +- **Attach window resize** — needs an SDK resize primitive on the SSH channel. +- **Volumes / agent-credential volume** — `ensureVolume` is not implemented, so + agent credentials are seeded per-box (the e2b/vercel/hetzner model) rather than + via a shared volume. Fine, but a Tenki volume could cache them. diff --git a/packages/config/schema/user-config.schema.json b/packages/config/schema/user-config.schema.json index eb45cf3a..f7ce99a4 100644 --- a/packages/config/schema/user-config.schema.json +++ b/packages/config/schema/user-config.schema.json @@ -21,6 +21,7 @@ "defaultCheckpointHetzner": { "type": "string", "minLength": 1 }, "defaultCheckpointVercel": { "type": "string", "minLength": 1 }, "defaultCheckpointE2b": { "type": "string", "minLength": 1 }, + "defaultCheckpointTenki": { "type": "string", "minLength": 1 }, "defaultCheckpointDigitalocean": { "type": "string", "minLength": 1 }, "defaultCheckpointRemoteDocker": { "type": "string", "minLength": 1 }, "size": { "type": "string", "minLength": 1 }, @@ -29,6 +30,7 @@ "sizeHetzner": { "type": "string", "minLength": 1 }, "sizeVercel": { "type": "string", "minLength": 1 }, "sizeE2b": { "type": "string", "minLength": 1 }, + "sizeTenki": { "type": "string", "minLength": 1 }, "sizeDigitalocean": { "type": "string", "minLength": 1 }, "sizeRemoteDocker": { "type": "string", "minLength": 1 }, "withPlaywright": { "type": "boolean" }, @@ -47,6 +49,7 @@ "imageHetzner": { "type": "string", "minLength": 1 }, "imageVercel": { "type": "string", "minLength": 1 }, "imageE2b": { "type": "string", "minLength": 1 }, + "imageTenki": { "type": "string", "minLength": 1 }, "imageDigitalocean": { "type": "string", "minLength": 1 }, "imageRemoteDocker": { "type": "string", "minLength": 1 }, "dockerCacheShared": { "type": "boolean" }, @@ -66,6 +69,7 @@ "inbound": { "type": "string", "minLength": 1 }, "vercelTimeoutMs": { "type": "integer", "minimum": 1 }, "vercelNetworkPolicy": { "type": "string" }, + "tenkiTimeoutMs": { "type": "integer", "minimum": 1 }, "cpMaxBytes": { "type": "integer", "minimum": 1 } } }, diff --git a/packages/config/src/providers.ts b/packages/config/src/providers.ts index 05b74037..9f5cf54d 100644 --- a/packages/config/src/providers.ts +++ b/packages/config/src/providers.ts @@ -120,6 +120,18 @@ export const PROVIDERS = [ imageDesc: 'Per-provider override of `box.image` for remote-docker (a docker image ref on the REMOTE engine). Normally left empty: the provider derives a fingerprint-tagged ref (`agentbox/box:`) and ensures it on the remote itself.', }, + { + name: 'tenki', + kind: 'cloud', + label: 'Tenki (cloud microVM)', + loginHint: 'paste an auth token from the Tenki dashboard', + rebuildMinutes: '5-10', + blurb: 'Tenki sandboxes', + sizeDesc: + 'Per-provider override of `box.size` for tenki. A `cpu-memory` or `cpu-memory-disk` GB spec (e.g. `4-8` or `4-8-20`), applied at create (Tenki `createAndWait` cpuCores / memoryMb / diskSizeGb).', + imageDesc: + 'Per-provider override of `box.image` for tenki (a Tenki workspace registry ref, e.g. `/agentbox-box:latest`). Written by `agentbox prepare --provider tenki`.', + }, ] as const satisfies readonly ProviderMeta[]; /** Sandbox backend new boxes are created on. Derived from the `PROVIDERS` table. */ diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index f93c2df6..22212235 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -72,6 +72,7 @@ export interface UserConfig { defaultCheckpointHetzner?: string; defaultCheckpointVercel?: string; defaultCheckpointE2b?: string; + defaultCheckpointTenki?: string; defaultCheckpointDigitalocean?: string; defaultCheckpointRemoteDocker?: string; /** @@ -88,6 +89,7 @@ export interface UserConfig { sizeHetzner?: string; sizeVercel?: string; sizeE2b?: string; + sizeTenki?: string; sizeDigitalocean?: string; sizeRemoteDocker?: string; withPlaywright?: boolean; @@ -116,6 +118,7 @@ export interface UserConfig { imageHetzner?: string; imageVercel?: string; imageE2b?: string; + imageTenki?: string; imageDigitalocean?: string; imageRemoteDocker?: string; imageRegistry?: string; @@ -136,6 +139,7 @@ export interface UserConfig { vercelTimeoutMs?: number; vercelNetworkPolicy?: string; e2bTimeoutMs?: number; + tenkiTimeoutMs?: number; cpMaxBytes?: number; credentialSync?: boolean; inbound?: string; @@ -257,6 +261,7 @@ export interface EffectiveConfig { defaultCheckpointHetzner: string; defaultCheckpointVercel: string; defaultCheckpointE2b: string; + defaultCheckpointTenki: string; defaultCheckpointDigitalocean: string; defaultCheckpointRemoteDocker: string; size: string; @@ -265,6 +270,7 @@ export interface EffectiveConfig { sizeHetzner: string; sizeVercel: string; sizeE2b: string; + sizeTenki: string; sizeDigitalocean: string; sizeRemoteDocker: string; withPlaywright: boolean; @@ -283,6 +289,7 @@ export interface EffectiveConfig { imageHetzner: string; imageVercel: string; imageE2b: string; + imageTenki: string; imageDigitalocean: string; imageRemoteDocker: string; imageRegistry: string; @@ -303,6 +310,7 @@ export interface EffectiveConfig { vercelTimeoutMs: number; vercelNetworkPolicy: string; e2bTimeoutMs: number; + tenkiTimeoutMs: number; cpMaxBytes: number; credentialSync: boolean; inbound: string; @@ -435,6 +443,7 @@ export const BUILT_IN_DEFAULTS: EffectiveConfig = { defaultCheckpointHetzner: '', defaultCheckpointVercel: '', defaultCheckpointE2b: '', + defaultCheckpointTenki: '', defaultCheckpointDigitalocean: '', defaultCheckpointRemoteDocker: '', size: '', @@ -443,6 +452,7 @@ export const BUILT_IN_DEFAULTS: EffectiveConfig = { sizeHetzner: '', sizeVercel: '', sizeE2b: '', + sizeTenki: '', sizeDigitalocean: '', sizeRemoteDocker: '', withPlaywright: false, @@ -461,6 +471,7 @@ export const BUILT_IN_DEFAULTS: EffectiveConfig = { imageHetzner: '', imageVercel: '', imageE2b: '', + imageTenki: '', imageDigitalocean: '', // Empty = the provider derives the fingerprint-tagged ref itself and ensures // it on the remote engine; set only to pin a hand-built image there. @@ -492,6 +503,7 @@ export const BUILT_IN_DEFAULTS: EffectiveConfig = { vercelTimeoutMs: 2_700_000, vercelNetworkPolicy: '', e2bTimeoutMs: 2_700_000, + tenkiTimeoutMs: 2_700_000, cpMaxBytes: 100 * 1024 * 1024, credentialSync: true, inbound: 'locked', @@ -834,6 +846,12 @@ export const KEY_REGISTRY: readonly KeyDescriptor[] = [ description: 'Session timeout (ms) a new --provider e2b box is created with, before E2B auto-pauses it on inactivity. The host keepalive loop pushes this forward while the agent is working. Default 2700000 (45 min); the Hobby tier caps total session at ~1 h regardless. E2B-only.', }, + { + key: 'box.tenkiTimeoutMs', + type: 'int', + description: + 'Session lifetime (ms) a new --provider tenki box is created with (maps to Tenki maxDurationMs). The host keepalive loop pushes this forward via session.extend while the agent is working. Default 2700000 (45 min). Tenki-only.', + }, { key: 'box.cpMaxBytes', type: 'int', @@ -1083,6 +1101,17 @@ export const KEY_REGISTRY: readonly KeyDescriptor[] = [ }, ]; +// Guard against duplicate keys — e.g. a manual per-provider entry that also +// gets emitted by the perProvider*Keys() generators above. A dupe would make +// `agentbox config list` print the key twice. +{ + const seen = new Set(); + for (const d of KEY_REGISTRY) { + if (seen.has(d.key)) throw new Error(`KEY_REGISTRY has a duplicate key: ${d.key}`); + seen.add(d.key); + } +} + const REGISTRY_BY_KEY = new Map(KEY_REGISTRY.map((d) => [d.key, d])); export function lookupKey(key: string): KeyDescriptor | undefined { diff --git a/packages/sandbox-tenki/package.json b/packages/sandbox-tenki/package.json new file mode 100644 index 00000000..11d29577 --- /dev/null +++ b/packages/sandbox-tenki/package.json @@ -0,0 +1,46 @@ +{ + "name": "@agentbox/sandbox-tenki", + "version": "0.0.0", + "private": true, + "description": "Tenki Sandbox provider — CloudBackend over the @tenkicloud/sandbox SDK (Firecracker microVMs + pause/resume 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": { + "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", + "@tenkicloud/sandbox": "^0.4.0", + "commander": "^12.1.0" + }, + "devDependencies": { + "@types/node": "^22.10.1", + "tsup": "^8.3.5", + "typescript": "^5.7.2", + "vitest": "^2.1.8" + } +} diff --git a/packages/sandbox-tenki/src/attach-helper.ts b/packages/sandbox-tenki/src/attach-helper.ts new file mode 100644 index 00000000..ad9c3395 --- /dev/null +++ b/packages/sandbox-tenki/src/attach-helper.ts @@ -0,0 +1,191 @@ +/** + * In-process attach bridge for `agentbox shell|claude|codex|opencode -p tenki`. + * The vercel provider drives an external CLI for the PTY hop and E2B opens an + * SDK PTY; Tenki exposes `session.ssh()` — a PTY-backed shell channel over the + * data plane — so we bridge the host PTY to it. + * + * Wire shape: + * + * stdin (host PTY) ─► process.stdin ─► conn.write ─► in-box shell (ssh PTY) + * │ + * stdout (host PTY) ◄── process.stdout ◄── conn.read ◄───────┘ + * + * Argv: `node attach-helper.cjs --session-id [--detached]`. + * Env: + * TENKI_AUTH_TOKEN Tenki credentials (threaded in by build-attach). + * AGENTBOX_TENKI_INNER_CMD Inner bash command (renderInnerCommand output: + * tmux ensure + attach). Passed via env so quoting + * stays sane and it doesn't leak through `ps`. + * TENKI_BASE_URL, + * TENKI_GATEWAY_ADDRESS Optional control-plane overrides. + * AGENTBOX_HOST_TERM Host TERM, forwarded into the session. + * + * `--detached`: pre-start mode. The inner command only creates + configures the + * tmux session (renderInnerCommand with `detached:true`, NO trailing `exec tmux + * attach`). Opening the interactive channel here would idle at a prompt forever + * (nothing to `exec` into), so the host's `runDetached` await would never + * resolve and `agentbox ` would hang after "box ready". In detached mode + * we run the inner command once via the non-interactive `run` and exit. + * + * NOTE: `SSHConnection` exposes only read/write/close — no resize — so the + * in-box PTY can't track host SIGWINCH. tmux renders at its default size; live + * window-size propagation is a follow-up if/when the SDK exposes a resize op. + */ + +import { TenkiSandbox } from '@tenkicloud/sandbox'; +import { ensureTenkiEnvLoaded } from './env-loader.js'; + +interface ParsedArgs { + sessionId: string; + detached: boolean; +} + +function parseArgs(argv: string[]): ParsedArgs { + let sessionId: string | undefined; + let detached = false; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--session-id') { + sessionId = argv[i + 1]; + i++; + } else if (a === '--detached') { + detached = true; + } + } + if (!sessionId) { + process.stderr.write('attach-helper: --session-id is required\n'); + process.exit(2); + } + return { sessionId, detached }; +} + +function buildClient(): TenkiSandbox { + const authToken = process.env.TENKI_AUTH_TOKEN; + if (!authToken) { + process.stderr.write('attach-helper: TENKI_AUTH_TOKEN env is required\n'); + process.exit(2); + } + const opts: ConstructorParameters[0] = { authToken }; + if (process.env.TENKI_BASE_URL) opts.baseUrl = process.env.TENKI_BASE_URL; + if (process.env.TENKI_GATEWAY_ADDRESS) opts.gatewayAddress = process.env.TENKI_GATEWAY_ADDRESS; + return new TenkiSandbox(opts); +} + +async function main(): Promise { + const { sessionId, detached } = parseArgs(process.argv.slice(2)); + ensureTenkiEnvLoaded(); + + const inner = process.env.AGENTBOX_TENKI_INNER_CMD; + if (!inner) { + process.stderr.write('attach-helper: AGENTBOX_TENKI_INNER_CMD env is required\n'); + process.exit(2); + } + + const client = buildClient(); + let session; + try { + session = await client.get(sessionId); + } catch (err) { + process.stderr.write( + `attach-helper: could not resolve session ${sessionId}: ${err instanceof Error ? err.message : String(err)}\n`, + ); + process.exit(1); + } + + // Defensive: an attach right after resume should already be RUNNING, but + // wake a paused box rather than failing the channel open. + if (session.state !== 'RUNNING') { + try { + await session.resume(); + await session.waitReady(60_000); + } catch { + // fall through — the op below will surface a clear transport error + } + } + + // Detached pre-start: run the session-create command once and exit (no + // interactive channel). See the file header for why opening ssh() here would + // hang the caller. + if (detached) { + try { + const r = await session.run(['bash', '-c', inner], { cwd: '/workspace' }); + process.exit(r.exitCode ?? 0); + } catch (err) { + process.stderr.write( + `attach-helper: detached pre-start failed: ${err instanceof Error ? err.message : String(err)}\n`, + ); + process.exit(1); + } + } + + // Interactive: open the SSH-backed shell channel and bridge it to the host PTY. + let conn; + try { + conn = await session.ssh(); + } catch (err) { + process.stderr.write( + `attach-helper: could not open shell channel: ${err instanceof Error ? err.message : String(err)}\n`, + ); + process.exit(1); + } + + if (process.stdin.isTTY && typeof process.stdin.setRawMode === 'function') { + process.stdin.setRawMode(true); + } + process.stdin.resume(); + + const cleanup = (): void => { + if (process.stdin.isTTY && typeof process.stdin.setRawMode === 'function') { + try { + process.stdin.setRawMode(false); + } catch { + // ignore + } + } + process.stdin.pause(); + }; + + // Hand control to the inner command (tmux ensure + `exec tmux attach`); the + // trailing newline triggers execution. + await conn.write(new TextEncoder().encode(inner + '\n')); + + // Host stdin -> in-box shell. + process.stdin.on('data', (chunk: Buffer) => { + conn.write(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength)).catch(() => { + // channel gone (box died / user detached); the read loop below exits. + }); + }); + // Forward Ctrl+C to the shell so tmux receives it rather than killing us. + process.on('SIGINT', () => { + conn.write(new Uint8Array([3])).catch(() => undefined); + }); + + let exitCode = 0; + try { + for (;;) { + const data = await conn.read(); + if (data === null) break; // channel closed (tmux detach / box stop) + process.stdout.write(data); + } + } catch (err) { + process.stderr.write( + `attach-helper: shell channel read failed: ${err instanceof Error ? err.message : String(err)}\n`, + ); + exitCode = 1; + } finally { + try { + conn.close(); + } catch { + // ignore + } + cleanup(); + } + process.exit(exitCode); +} + +main().catch((err) => { + process.stderr.write( + `attach-helper: unhandled error: ${err instanceof Error ? err.message : String(err)}\n`, + ); + process.exit(1); +}); diff --git a/packages/sandbox-tenki/src/backend.ts b/packages/sandbox-tenki/src/backend.ts new file mode 100644 index 00000000..6adba44b Binary files /dev/null and b/packages/sandbox-tenki/src/backend.ts differ diff --git a/packages/sandbox-tenki/src/build-attach.ts b/packages/sandbox-tenki/src/build-attach.ts new file mode 100644 index 00000000..fc3a1217 --- /dev/null +++ b/packages/sandbox-tenki/src/build-attach.ts @@ -0,0 +1,89 @@ +/** + * `buildTenkiAttach` — the Tenki provider's override of `Provider.buildAttach`. + * + * Tenki has no host-side SSH binary to shell out to, but the SDK exposes + * `session.ssh()` — a PTY-backed shell channel over the data plane. We ship a + * small helper (`attach-helper.cjs`) that the host's node-pty spawns; it opens + * `session.ssh()` and bridges the host PTY's stdin/stdout to that channel. + * + * Argv shape: + * node --session-id + * + * The inner bash command (`renderInnerCommand` output: tmux ensure + attach) + * is passed via the environment as `AGENTBOX_TENKI_INNER_CMD` so quoting stays + * sane and it doesn't leak through `ps`. The auth token + control-plane + * overrides are passed the same way. + */ + +import { existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + type AttachKind, + type AttachSpec, + type BoxRecord, + type BuildAttachOptions, +} from '@agentbox/core'; +import { hostTermForCloud, renderInnerCommand } from '@agentbox/sandbox-cloud'; +import { resolveAuthToken } from './sdk.js'; + +const SELF = dirname(fileURLToPath(import.meta.url)); + +/** + * Resolve the absolute path to `attach-helper.cjs`. In the published CLI it + * lives at `runtime/tenki/attach-helper.cjs` (next to the staged provider + * runtime tree); in dev it lives next to the package's `dist/`. + */ +export function resolveAttachHelperPath(): string { + const candidates = [ + resolve(SELF, 'attach-helper.cjs'), + resolve(SELF, '..', 'dist', 'attach-helper.cjs'), + resolve(SELF, '..', 'runtime', 'tenki', 'attach-helper.cjs'), + resolve(SELF, '..', '..', 'runtime', 'tenki', 'attach-helper.cjs'), + ]; + for (const p of candidates) { + if (existsSync(p)) return p; + } + return candidates[0]!; +} + +export async function buildTenkiAttach( + box: BoxRecord, + kind: AttachKind, + opts?: BuildAttachOptions, +): Promise { + const sandboxId = box.cloud?.sandboxId; + if (!sandboxId) { + throw new Error(`tenki box ${box.name} has no sandboxId — record is malformed`); + } + + const helper = resolveAttachHelperPath(); + if (!existsSync(helper)) { + throw new Error( + `tenki attach helper not found at ${helper} — rebuild the CLI (\`pnpm -w build\`) ` + + 'so packages/sandbox-tenki/dist/attach-helper.cjs is generated.', + ); + } + + const authToken = resolveAuthToken(); + const inner = renderInnerCommand(kind, opts); + const hostTerm = hostTermForCloud(); + + const argv = [process.execPath, helper, '--session-id', sandboxId]; + // Detached pre-start: the inner command only CREATES the tmux session (no + // `exec tmux attach`). The helper runs it once over a plain exec and exits, + // rather than opening an interactive channel that would idle forever and + // hang the host's `runDetached` await. See attach-helper.ts header. + if (opts?.detached) argv.push('--detached'); + + const env: Record = { + TENKI_AUTH_TOKEN: authToken, + AGENTBOX_TENKI_INNER_CMD: inner, + AGENTBOX_HOST_TERM: hostTerm, + }; + // Forward control-plane overrides so the helper talks to the same backend. + if (process.env.TENKI_BASE_URL) env.TENKI_BASE_URL = process.env.TENKI_BASE_URL; + if (process.env.TENKI_GATEWAY_ADDRESS) env.TENKI_GATEWAY_ADDRESS = process.env.TENKI_GATEWAY_ADDRESS; + + return { argv, env }; +} diff --git a/packages/sandbox-tenki/src/cli.ts b/packages/sandbox-tenki/src/cli.ts new file mode 100644 index 00000000..73ecbab2 --- /dev/null +++ b/packages/sandbox-tenki/src/cli.ts @@ -0,0 +1,84 @@ +/** + * `agentbox tenki` CLI surface — registered as a top-level subcommand by + * `apps/cli/src/index.ts` (same pattern as `e2bCommand` / `vercelCommand`). + * + * Subcommands: + * - `login` — interactive credential setup (paste an auth token). + * - `login --status` — show what is currently configured (masked). + * + * Also provides the `agentbox tenki create|claude|codex|opencode` sugar via the + * argv-prefix rewriter in apps/cli. + */ + +import { log } from '@clack/prompts'; +import { Command } from 'commander'; +import { + ensureTenkiCredentials, + maskKey, + readTenkiCredStatus, + secretsPath, +} from './credentials.js'; +import { readPreparedState } from './prepared-state.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 = readTenkiCredStatus(); + if (s.auth === 'none') { + process.stdout.write( + 'tenki: not configured\n' + ' run `agentbox tenki login` to set up credentials\n', + ); + return; + } + const lines = ['tenki: configured', ' auth: auth token']; + if (s.token) lines.push(` token: ${maskKey(s.token)}`); + lines.push(` source: ${s.source}`); + 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) Tenki 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( + 'tenki login needs an interactive terminal — set TENKI_AUTH_TOKEN in the environment ' + + 'or in ~/.agentbox/secrets.env for non-interactive use.\n', + ); + process.exitCode = 1; + return; + } + await ensureTenkiCredentials({ force: true }); + // Credentials alone don't get a user a working box — they also need a + // prepared base image. Nudge toward `prepare` so the login → first-create + // path doesn't hit the (otherwise-clean) "no base image found" error. + if (readPreparedState().base === undefined) { + log.info( + 'Base image not prepared yet — run `agentbox prepare --provider tenki` (or `agentbox install`) to publish it.', + ); + } + } catch (err) { + reportError(err); + } + }); + +export const tenkiCommand = new Command('tenki') + .description( + 'Tenki sandbox provider — credentials, plus sugar for `--provider tenki` ' + + '(e.g. `agentbox tenki create|claude|codex|opencode`)', + ) + .addCommand(loginSub, { isDefault: true }); diff --git a/packages/sandbox-tenki/src/credentials.ts b/packages/sandbox-tenki/src/credentials.ts new file mode 100644 index 00000000..24baf67e --- /dev/null +++ b/packages/sandbox-tenki/src/credentials.ts @@ -0,0 +1,145 @@ +/** + * Interactive Tenki credential setup. Single mode — paste a workspace auth + * token (`tk_…`) from the Tenki dashboard. Persists to + * `~/.agentbox/secrets.env` (the canonical store, matching daytona / hetzner / + * vercel / e2b). + * + * Non-interactive callers (no TTY): silent no-op, so scripted/CI runs surface + * the SDK's own "not configured" error instead of hanging on a prompt. + */ + +import { homedir } from 'node:os'; +import { resolve } from 'node:path'; +import { hostOpenCommand, writeManagedSecrets, type CredSetResult } from '@agentbox/sandbox-core'; +import { + cancel, + confirm, + intro, + isCancel, + log, + note, + outro, + password, +} from '@clack/prompts'; +import { ensureTenkiEnvLoaded, reloadTenkiEnv } from './env-loader.js'; +import { hasUsableCredentials } from './sdk.js'; + +// Ctrl+C at a prompt resolves with the cancel symbol; turn that into a real +// quit so the command never silently continues as if the user answered "No". +function exitOnCancel(v: T | symbol): T { + if (isCancel(v)) { + cancel('Cancelled.'); + process.exit(130); + } + return v as T; +} + +const DASHBOARD_URL = 'https://tenki.cloud'; +const DOCS_URL = 'https://tenki.cloud/docs'; + +/** + * 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 = ['TENKI_AUTH_TOKEN'] as const; + +export interface EnsureTenkiCredentialsOptions { + /** Re-prompt even when valid credentials are already present (`agentbox tenki login`). */ + force?: boolean; +} + +export async function ensureTenkiCredentials( + opts: EnsureTenkiCredentialsOptions = {}, +): Promise { + ensureTenkiEnvLoaded(); + + if (!opts.force && hasUsableCredentials()) return; + if (!process.stdin.isTTY) return; + + intro('Tenki setup'); + note( + `AgentBox needs a Tenki workspace auth token to provision sandboxes.\n` + + `Create one in your Tenki dashboard (${DASHBOARD_URL}; see ${DOCS_URL}), then paste it below.\n` + + `The token is stored in \`~/.agentbox/secrets.env\` (mode 0600) — no .env.local harvesting.`, + 'Credentials required', + ); + + const openIt = exitOnCancel( + await confirm({ + message: `Open ${DASHBOARD_URL} to create a token?`, + initialValue: true, + }), + ); + if (openIt) openDashboard(); + + const token = exitOnCancel( + await password({ + message: 'Paste your Tenki auth token (tk_…)', + validate: (v) => (v && v.trim().length > 0 ? undefined : 'Cannot be empty'), + }), + ); + + persistCredentials({ authToken: token.trim() }); + reloadTenkiEnv(); + log.success(`Tenki credentials saved to ${secretsPath()}`); + outro('Setup complete.'); +} + +function persistCredentials(creds: { authToken: string }): void { + // Delegate to the shared atomic writer (temp-file + rename, mode 0600) so + // Tenki tracks the same secrets-write path as e2b / daytona / hetzner / vercel. + writeManagedSecrets(MANAGED_KEYS, { TENKI_AUTH_TOKEN: creds.authToken }); +} + +/** + * Non-interactive credential write — the headless path a hub/API driver uses, + * bypassing the TTY-gated `ensureTenkiCredentials` prompts. Extracts the token + * field, persists it, and returns a normalized result. Mirrors + * `setE2bCredentials`; the hub Settings form (`CRED_FIELDS['tenki']`) posts + * `{ token }`. + */ +export function setTenkiCredentials(fields: Record): CredSetResult { + const token = (fields.token ?? '').trim(); + if (!token) { + return { ok: false, error: 'token is required', status: { configured: false } }; + } + persistCredentials({ authToken: token }); + const cred = readTenkiCredStatus(); + return { ok: true, status: { configured: cred.auth !== 'none', label: cred.auth } }; +} + +function openDashboard(): void { + import('node:child_process') + .then(({ spawnSync }) => { + const r = spawnSync(hostOpenCommand(), [DASHBOARD_URL], { stdio: 'ignore' }); + if (r.status !== 0) { + log.warn(`Could not auto-open the browser — visit ${DASHBOARD_URL} manually.`); + } + }) + .catch(() => { + log.warn(`Could not auto-open the browser — visit ${DASHBOARD_URL} manually.`); + }); +} + +export function secretsPath(): string { + return resolve(homedir(), '.agentbox', 'secrets.env'); +} + +export interface TenkiCredStatus { + auth: 'token' | 'none'; + token?: string; + source: 'env' | 'secrets.env' | 'none'; +} + +export function readTenkiCredStatus(): TenkiCredStatus { + const shellHad = process.env.TENKI_AUTH_TOKEN !== undefined; + ensureTenkiEnvLoaded(); + const token = process.env.TENKI_AUTH_TOKEN; + if (!token) return { auth: 'none', source: 'none' }; + return { auth: 'token', token, 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-tenki/src/env-loader.ts b/packages/sandbox-tenki/src/env-loader.ts new file mode 100644 index 00000000..a7a9f69e --- /dev/null +++ b/packages/sandbox-tenki/src/env-loader.ts @@ -0,0 +1,89 @@ +/** + * Tenki env auto-loader. The `@tenkicloud/sandbox` SDK reads `TENKI_AUTH_TOKEN` + * from `process.env` (and we honor optional `TENKI_BASE_URL` / + * `TENKI_GATEWAY_ADDRESS` overrides for non-default / self-hosted control + * planes). We seed those from `~/.agentbox/secrets.env` (written by `agentbox + * tenki login`) so the SDK Just Works after a one-time login — same pattern as + * the daytona / hetzner / vercel / e2b env-loaders. + * + * Lookup order (first wins; process.env is never overwritten): + * 1. `process.env` (already set in the shell). + * 2. `~/.agentbox/secrets.env` — written by `agentbox tenki login`. + * + * Project-level `.env` / `.env.local` are intentionally NOT consulted: those + * files belong to the app code being developed. Put host credentials in + * `~/.agentbox/secrets.env` (or the shell env). + * + * Only TENKI-prefixed keys are imported; the rest of the file is left alone. + * Idempotent and side-effect-free after the first call. + */ + +import { existsSync, readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { resolve } from 'node:path'; + +const TENKI_KEYS = ['TENKI_AUTH_TOKEN', 'TENKI_BASE_URL', 'TENKI_GATEWAY_ADDRESS'] as const; + +let loaded = false; + +export function ensureTenkiEnvLoaded(): void { + if (loaded) return; + loaded = true; + importTenkiFromFile(resolve(homedir(), '.agentbox', 'secrets.env'), TENKI_KEYS); +} + +/** + * Force a re-read of `~/.agentbox/secrets.env`. Used by the interactive + * `agentbox tenki login` flow after it persists the token, so the same process + * can pick it up without a restart. + */ +export function reloadTenkiEnv(): void { + loaded = false; + ensureTenkiEnvLoaded(); +} + +function importTenkiFromFile(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 / vercel / e2b + * loaders). + */ +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-tenki/src/index.ts b/packages/sandbox-tenki/src/index.ts new file mode 100644 index 00000000..0a851d1b --- /dev/null +++ b/packages/sandbox-tenki/src/index.ts @@ -0,0 +1,183 @@ +/** + * The Tenki sandbox provider. A thin `CloudBackend` over the + * `@tenkicloud/sandbox` SDK, composed via `@agentbox/sandbox-cloud`'s + * `createCloudProvider` for everything provider-agnostic (workspace seeding, + * ctl launch, state, relay polling). + * + * Three capabilities are overridden on top of the cloud scaffold: + * - `prepare` — publish the AgentBox base image into the Tenki workspace + * registry (`agentbox prepare --provider tenki`). + * - `buildAttach` — host-PTY ↔ `session.ssh()` bridge (no host SSH binary). + * - `checkpoint` — store the Tenki snapshot id in the manifest so restore + * boots from it (`createSnapshotAndWait` returns an id-addressed reusable + * snapshot, same shape as vercel/e2b). + * + * `launchDockerd: false` — Tenki runs Firecracker microVMs; nested-container + * (DinD) support inside a Tenki VM is not yet verified, so we take the + * conservative default (matching vercel) and don't auto-start dockerd, which + * would otherwise log a spurious failure on every create/resume. Flip to true + * once DinD is confirmed and baked into the base image. + */ + +import type { BoxRecord, Provider, ProviderCheckpoint } from '@agentbox/core'; +import { + createCloudProvider, + currentCloudBaseFingerprint, + listCloudCheckpoints, + removeCloudCheckpointDir, + resolveCloudCheckpoint, + writeCloudCheckpointManifest, +} from '@agentbox/sandbox-cloud'; +import { readCliStamp, type ProviderModule } from '@agentbox/sandbox-core'; +import { tenkiBackend } from './backend.js'; +import { getTenkiClient } from './sdk.js'; +import { withTenkiRetry } from './retry.js'; +import { prepareTenkiProvider } from './prepare.js'; +import { buildTenkiAttach } from './build-attach.js'; +import { currentTenkiBaseFingerprintLive } from './prepared-state.js'; +import { ensureTenkiCredentials, setTenkiCredentials } from './credentials.js'; +import { doctorChecks, readCredStatusSummary } from './provider-module.js'; + +const BACKEND_NAME = 'tenki'; + +const cloudProvider = createCloudProvider(tenkiBackend, { + defaultResources: { cpu: 2, memory: 4, disk: 8 }, + launchDockerd: false, +}); + +/** + * Capture a reusable, id-addressed Tenki snapshot from a running session. + * `createSnapshotAndWait` blocks until the snapshot reaches READY and returns + * its opaque id, usable later with `createAndWait({ snapshotId })`. + */ +async function createTenkiSnapshot(sessionId: string, name: string): Promise { + return withTenkiRetry( + { method: 'createSnapshot', retryOnAmbiguous: false, attemptTimeoutMs: 900_000, backoffMs: [] }, + async () => { + const snap = await getTenkiClient().createSnapshotAndWait(sessionId, { name }); + return snap.id; + }, + ); +} + +/** Delete a snapshot by id. Idempotent — a missing snapshot is success. */ +async function deleteTenkiSnapshot(snapshotId: string): Promise { + await withTenkiRetry({ method: 'deleteSnapshot', retryOnAmbiguous: true }, async () => { + try { + await getTenkiClient().deleteSnapshot(snapshotId); + } catch (err) { + const name = err instanceof Error ? err.name : ''; + const msg = err instanceof Error ? err.message : String(err); + if (name === 'SnapshotNotFoundError' || /not.?found|404/i.test(msg)) return; // idempotent + throw err; + } + }); +} + +/** + * Build a safe, unique Tenki snapshot label. Snapshots are id-addressed so the + * name is only a human label, but we stamp a per-create timestamp so the UI + * shows distinct entries rather than reusing one name. + */ +function snapshotLabel(boxName: string, checkpointName: string): string { + const sanitize = (s: string): string => + s.toLowerCase().replace(/[^a-z0-9.-]+/g, '-').replace(/^-+|-+$/g, ''); + const ts = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').replace('Z', ''); + return `agentbox-${sanitize(boxName)}-${sanitize(checkpointName)}-${sanitize(ts)}`; +} + +/** + * Tenki-specific checkpoint capability. We capture the SDK-returned opaque + * snapshot id and store THAT in the manifest's `snapshotName` field — the cloud + * create flow passes `manifest.snapshotName` straight to `provision({ snapshot })`, + * and the Tenki backend boots from it as `createAndWait({ snapshotId })`. (Same + * id-addressed shape as vercel/e2b.) + */ +const tenkiCheckpoint: 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(`tenki box ${box.name} has no sandboxId — record is malformed`); + } + const label = snapshotLabel(box.name, name); + const snapshotId = await createTenkiSnapshot(box.cloud.sandboxId, label); + const info = await writeCloudCheckpointManifest(box.projectRoot, BACKEND_NAME, name, { + snapshotName: snapshotId, + sourceBoxId: box.id, + sourceBoxName: box.name, + baseProvider: BACKEND_NAME, + baseFingerprint: currentCloudBaseFingerprint(BACKEND_NAME), + cliVersion: readCliStamp().cliVersion, + }); + 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; + // Delete the remote snapshot FIRST and only drop the local manifest once it + // is gone. A missing snapshot is already swallowed as success by + // deleteTenkiSnapshot (idempotent), and withTenkiRetry has exhausted its + // transient retries by the time this throws — so a throw is a real, + // persistent failure. Preserve the manifest and surface it rather than + // orphaning a billable, quota-limited snapshot the user can no longer + // reference to retry the delete. + await deleteTenkiSnapshot(entry.manifest.snapshotName); + await removeCloudCheckpointDir(projectRoot, BACKEND_NAME, ref); + }, +}; + +export const tenkiProvider: Provider = { + ...cloudProvider, + prepare: prepareTenkiProvider, + buildAttach: buildTenkiAttach, + checkpoint: tenkiCheckpoint, + baseFingerprint: () => currentTenkiBaseFingerprintLive(), +}; + +/** Uniform surface the CLI provider loader resolves this package through. */ +export const providerModule: ProviderModule = { + provider: tenkiProvider, + backend: tenkiBackend, + ensureCredentials: ensureTenkiCredentials, + readCredStatus: readCredStatusSummary, + setCredentials: (fields) => Promise.resolve(setTenkiCredentials(fields)), + currentBaseFingerprintLive: () => currentTenkiBaseFingerprintLive(), + doctorChecks, +}; + +export { tenkiBackend }; +export { ensureTenkiEnvLoaded, reloadTenkiEnv } from './env-loader.js'; +export { + ensureTenkiCredentials, + setTenkiCredentials, + readTenkiCredStatus, + secretsPath, + maskKey, + type EnsureTenkiCredentialsOptions, + type TenkiCredStatus, +} from './credentials.js'; +export { + prepareTenki, + prepareTenkiProvider, + type PrepareTenkiOptions, + type PrepareTenkiResult, +} from './prepare.js'; +export { + currentTenkiBaseFingerprintLive, + ensureTenkiBaseImage, + preparedStatePath, + readPreparedState, + writePreparedState, + updatePreparedState, + type PreparedTenkiState, + type PreparedTenkiBase, +} from './prepared-state.js'; +export { buildTenkiAttach } from './build-attach.js'; diff --git a/packages/sandbox-tenki/src/prepare.ts b/packages/sandbox-tenki/src/prepare.ts new file mode 100644 index 00000000..098da0ff --- /dev/null +++ b/packages/sandbox-tenki/src/prepare.ts @@ -0,0 +1,181 @@ +/** + * `agentbox prepare --provider tenki` — make the AgentBox base image available + * in the user's Tenki workspace registry so per-box `create` boots ready in + * seconds. + * + * Tenki boots a session from a registry image ref (`workspace/name:tag`) or a + * snapshot id — it does not pull arbitrary external OCI refs at create time. + * So `prepare` gets the AgentBox box image (published to GHCR as + * `ghcr.io/madarco/agentbox/box`) INTO Tenki's registry, then records the + * resolved ref in `~/.agentbox/tenki-prepared.json`. + * + * Two paths: + * 1. Explicit ref (`--image` / `AGENTBOX_TENKI_BASE_IMAGE`): the image is + * already published to your Tenki workspace registry — we just resolve + + * pin it. The reliable path when you've pushed the image yourself. + * 2. Auto-build (default): build a Tenki template FROM the GHCR parent image + * (`createTemplate({ parentImage }) → buildTemplate → waitForTemplateBuild`), + * then `publishRegistryImage` from the build snapshot and pin the resolved + * ref. Requires Tenki to be able to pull the parent image. + * + * vCPU / RAM are template-level here so per-box `create` doesn't fight them. + */ + +import type { Provider } from '@agentbox/core'; +import { readCliStamp } from '@agentbox/sandbox-core'; +import { ensureTenkiCredentials } from './credentials.js'; +import { getTenkiClient, resolveAuthToken } from './sdk.js'; +import { preparedStatePath, readPreparedState, writePreparedState } from './prepared-state.js'; + +/** GHCR ref for the AgentBox box image — the parent the Tenki base is built from. */ +const DEFAULT_PARENT_IMAGE = 'ghcr.io/madarco/agentbox/box:dev'; +/** Artifact name the base is published under in the Tenki workspace registry. */ +const ARTIFACT_NAME = 'agentbox-box'; +const DEFAULT_TAG = 'latest'; +const DEFAULT_CPU = 2; +const DEFAULT_MEMORY_MB = 4096; +const DEFAULT_DISK_GB = 8; + +export interface PrepareTenkiOptions { + name?: string; + hostWorkspace?: string; + /** Force re-publish even when an up-to-date base ref is recorded. */ + force?: boolean; + /** A pre-published Tenki registry ref to validate + pin (skips the build). */ + image?: string; + /** External parent image the auto-build path builds the base from. */ + parentImage?: string; + /** Tenki workspace to publish into (defaults to the first from `whoAmI`). */ + workspaceId?: string; + /** vCPUs baked into the template (default 2). */ + cpuCount?: number; + /** Memory in MiB baked into the template (default 4096). */ + memoryMB?: number; + onLog?: (line: string) => void; +} + +export interface PrepareTenkiResult { + /** The resolved Tenki registry ref recorded as the base image. */ + snapshotName?: string; +} + +export async function prepareTenki(opts: PrepareTenkiOptions = {}): Promise { + await ensureTenkiCredentials(); + resolveAuthToken(); // fail loud before any RPC if creds are missing + const client = getTenkiClient(); + const log = opts.onLog ?? (() => {}); + const progress = (s: string): void => log(`prepare-tenki: ${s}`); + + const explicit = opts.image ?? process.env.AGENTBOX_TENKI_BASE_IMAGE; + + // Skip-fast: an existing recorded base that still resolves, unless --force. + const existing = readPreparedState(); + if (!opts.force && existing.base) { + const target = explicit; + if (!target || target === existing.base.image) { + if (await refResolves(client, existing.base.image)) { + progress( + `base image ${existing.base.image} already prepared; skipping (pass --force to rebuild)`, + ); + return { snapshotName: existing.base.image }; + } + progress(`recorded base ${existing.base.image} no longer resolves; re-preparing`); + } + } + + let resolvedRef: string; + if (explicit) { + progress(`validating provided base image ref ${explicit}`); + const r = await client.resolveRegistryRef(explicit); + resolvedRef = r.resolvedRef; + } else { + const identity = await client.whoAmI(); + const workspaceId = + opts.workspaceId ?? + process.env.AGENTBOX_TENKI_WORKSPACE_ID ?? + identity.workspaces[0]?.id; + if (!workspaceId) { + throw new Error( + 'tenki prepare: no workspace found for these credentials — pass --workspace or set AGENTBOX_TENKI_WORKSPACE_ID', + ); + } + const parentImage = + opts.parentImage ?? process.env.AGENTBOX_TENKI_PARENT_IMAGE ?? DEFAULT_PARENT_IMAGE; + + progress(`creating template '${ARTIFACT_NAME}' from parent image ${parentImage}`); + const template = await client.createTemplate({ + workspaceId, + name: ARTIFACT_NAME, + parentImage, + setupScript: '', + resources: { + cpuCores: opts.cpuCount ?? DEFAULT_CPU, + memoryMb: opts.memoryMB ?? DEFAULT_MEMORY_MB, + diskSizeGb: DEFAULT_DISK_GB, + }, + }); + + progress(`building template ${template.id}`); + const build = await client.buildTemplate(template.id); + const done = await client.waitForTemplateBuild(build.id); + if (done.state !== 'READY' || !done.snapshotId) { + throw new Error( + `tenki prepare: template build ${build.id} ended in state ${done.state}` + + (done.error ? `: ${done.error}` : ''), + ); + } + + progress(`publishing registry image from build snapshot ${done.snapshotId}`); + const published = await client.publishRegistryImage({ + fromSnapshotId: done.snapshotId, + workspaceId, + name: ARTIFACT_NAME, + tag: DEFAULT_TAG, + visibility: 'private', + }); + resolvedRef = + published.digestRef || + published.tag?.ref || + `${identity.workspaces[0]?.name ?? workspaceId}/${ARTIFACT_NAME}:${DEFAULT_TAG}`; + } + + const cliStamp = readCliStamp(); + writePreparedState({ + schema: 1, + base: { + image: resolvedRef, + imageName: ARTIFACT_NAME, + cliVersion: cliStamp.cliVersion, + cliCommit: cliStamp.cliCommit, + createdAt: new Date().toISOString(), + }, + }); + progress(`wrote ${preparedStatePath()}`); + progress(`prepare complete — base image ${resolvedRef}`); + return { snapshotName: resolvedRef }; +} + +/** True when a registry ref still resolves (used by the skip-fast path). */ +async function refResolves( + client: ReturnType, + ref: string, +): Promise { + try { + await client.resolveRegistryRef(ref); + return true; + } catch { + return false; + } +} + +/** Provider-level binding used by the CLI's `prepare` command. */ +export const prepareTenkiProvider: NonNullable = (req) => + prepareTenki({ + name: req.name, + hostWorkspace: req.hostWorkspace ?? process.cwd(), + force: req.force, + // Box size is applied at create (see `box.sizeTenki` → the backend's + // create-time resources), so `--size` is intentionally not baked into the + // template here. A pre-published ref is pinned via `AGENTBOX_TENKI_BASE_IMAGE`. + onLog: req.onLog, + }); diff --git a/packages/sandbox-tenki/src/prepared-state.ts b/packages/sandbox-tenki/src/prepared-state.ts new file mode 100644 index 00000000..55974402 --- /dev/null +++ b/packages/sandbox-tenki/src/prepared-state.ts @@ -0,0 +1,99 @@ +/** + * Persisted record of what `agentbox prepare --provider tenki` has resolved. + * Lives at `~/.agentbox/tenki-prepared.json` so the auto-prepare gate + * (`ensureTenkiBaseImage()`) and `backend.provision` can resolve the base + * image every box boots from. + * + * Unlike E2B (which bakes a template from a build DSL), Tenki boots a session + * from a registry image ref (`workspace/name:tag`) or a snapshot id. The + * "base" recorded here is the Tenki registry ref carrying the AgentBox runtime + * (agentbox-ctl, the agents, tmux) — published into the workspace registry by + * `prepare`. Per-box `create` then boots from it in seconds. + * + * Schema versioned so future shape changes can migrate; only `schema: 1` is + * accepted today. + */ + +import { + readPreparedStateRaw, + writePreparedStateRaw, + preparedStatePathFor, +} from '@agentbox/sandbox-core'; +import { UserFacingError } from '@agentbox/core'; + +const SCHEMA = 1 as const; + +export interface PreparedTenkiBase { + /** Tenki registry image ref the AgentBox runtime is published under (e.g. `ws-slug/agentbox-box:latest`). createAndWait({ image }) boots from this. */ + image: string; + /** Human-friendly artifact name passed to publishRegistryImage (informational). */ + imageName?: string; + /** CLI version that produced this base (informational). */ + cliVersion?: string; + /** Git short SHA of the CLI build (informational). */ + cliCommit?: string; + /** ISO timestamp of prepare completion. */ + createdAt: string; +} + +export interface PreparedTenkiState { + schema: typeof SCHEMA; + /** The shared base image ref. Absent until first `agentbox prepare`. */ + base?: PreparedTenkiBase; +} + +export function preparedStatePath(): string { + return preparedStatePathFor('tenki'); +} + +export function readPreparedState(): PreparedTenkiState { + const raw = readPreparedStateRaw('tenki'); + 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: PreparedTenkiState): void { + writePreparedStateRaw('tenki', state); +} + +/** Update one field of the state without forcing callers to read/merge/write. */ +export function updatePreparedState(mutate: (s: PreparedTenkiState) => void): void { + const s = readPreparedState(); + mutate(s); + writePreparedState(s); +} + +/** + * CURRENT build-context fingerprint for the tenki base. Tenki boots from a + * registry image the user controls (published by `prepare`), not an + * asset-baked template, so there is no reproducible host-side fingerprint to + * diff against — `prepare` instead skip-checks the resolved ref directly + * against `tenki-prepared.json.base.image`. Returning `undefined` makes the + * cross-provider freshness nudge degrade to "can't tell, don't nag" (the same + * contract the other providers use when assets can't be resolved). + */ +export async function currentTenkiBaseFingerprintLive(): Promise { + return undefined; +} + +/** + * First-use gate. If no base image is recorded, throw an actionable error + * pointing at `agentbox prepare --provider tenki`. Called by + * `backend.provision` (so `create` / `claude` trip it but `prepare` itself + * does not — same shape as the hetzner / vercel / e2b gates). + */ +export function ensureTenkiBaseImage(): void { + const state = readPreparedState(); + if (state.base !== undefined) return; + throw new UserFacingError( + 'no Tenki base image found.\n' + + 'Run `agentbox prepare --provider tenki` first — it publishes a registry image ' + + 'with the agentbox runtime (agentbox-ctl, claude/codex/opencode, tmux) into your ' + + 'Tenki workspace so per-box `create` boots ready in seconds.', + ); +} diff --git a/packages/sandbox-tenki/src/provider-module.ts b/packages/sandbox-tenki/src/provider-module.ts new file mode 100644 index 00000000..b58a9a5a --- /dev/null +++ b/packages/sandbox-tenki/src/provider-module.ts @@ -0,0 +1,46 @@ +/** + * Doctor probes + normalized credential status for the tenki provider, + * assembled into `providerModule` in `index.ts`. Moved out of apps/cli so the + * CLI dispatches to it generically (see `@agentbox/sandbox-core`'s `ProviderModule`). + */ + +import { errSummary, type CheckResult, type CredStatusSummary } from '@agentbox/sandbox-core'; +import { readTenkiCredStatus } from './credentials.js'; +import { readPreparedState } from './prepared-state.js'; + +export function readCredStatusSummary(): CredStatusSummary { + const cred = readTenkiCredStatus(); + return { configured: cred.auth !== 'none', label: cred.auth }; +} + +export async function doctorChecks(): Promise { + try { + const cred = readTenkiCredStatus(); + const credRes: CheckResult = + cred.auth === 'none' + ? { + label: 'credentials', + status: 'warn', + detail: 'not configured', + hint: '`agentbox tenki login`', + } + : { label: 'credentials', status: 'ok', detail: `${cred.auth} (${cred.source})` }; + + const prepared = readPreparedState(); + const baseRes: CheckResult = prepared.base?.image + ? { + label: 'base image', + status: 'ok', + detail: `${prepared.base.imageName ?? prepared.base.image} (${prepared.base.cliVersion ?? '—'})`, + } + : { + label: 'base image', + status: 'warn', + detail: 'not prepared', + hint: '`agentbox prepare --provider tenki`', + }; + return [credRes, baseRes]; + } catch (err) { + return [{ label: 'credentials', status: 'warn', detail: errSummary(err) }]; + } +} diff --git a/packages/sandbox-tenki/src/retry.ts b/packages/sandbox-tenki/src/retry.ts new file mode 100644 index 00000000..38c52381 --- /dev/null +++ b/packages/sandbox-tenki/src/retry.ts @@ -0,0 +1,193 @@ +/** + * Bounded retry wrapper for Tenki SDK calls — mirrors `withE2bRetry` / + * `withVercelRetry` in shape and intent. The Tenki API rate-limits and can + * report transient capacity / transport errors during incidents; without + * bounded retries those propagate as wedges in the calling lifecycle code. + * + * Non-idempotent ops (`provision`/`createAndWait`) pass `retryOnAmbiguous: + * false` so a timeout after the request reached the origin doesn't create a + * duplicate billable session. + * + * Classification is by the SDK's typed error names (`RateLimitedError`, + * `CapacityUnavailableError`, …) plus ConnectRPC status codes (the SDK is + * ConnectRPC-based) and raw Node socket error codes. + */ + +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, transient transport). 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(`tenki ${method}: per-attempt timeout after ${String(ms)}ms`); + this.name = 'AttemptTimeoutError'; + } +} + +export function isAttemptTimeout(err: unknown): err is AttemptTimeoutError { + return err instanceof AttemptTimeoutError; +} + +/** ConnectRPC status code string (e.g. "unavailable") dug out of the error. */ +function connectCodeOf(err: unknown): string | undefined { + if (!err || typeof err !== 'object') return undefined; + const code = (err as { code?: unknown }).code; + return typeof code === 'string' ? code : undefined; +} + +/** Numeric 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'] as const) { + const v = (err as Record)[key]; + if (typeof v === 'number') return v; + } + return undefined; +} + +/** ConnectRPC codes that are safe-to-retry transient server conditions. */ +const RETRIABLE_CONNECT_CODES = new Set([ + 'unavailable', + 'resource_exhausted', + 'deadline_exceeded', + 'aborted', +]); + +export function isRetriable(err: unknown, allowAmbiguous: boolean): boolean { + if (err instanceof AttemptTimeoutError) return allowAmbiguous; + + // Match the SDK's typed errors by name to avoid an import cycle + // (sdk.ts → retry → sdk). RateLimited / CapacityUnavailable are explicitly + // transient; auth / not-found / quota / invalid-state are terminal. + const name = err instanceof Error ? err.name : undefined; + if (name === 'RateLimitedError' || name === 'CapacityUnavailableError') return true; + if ( + name === 'UnauthorizedError' || + name === 'PermissionDeniedError' || + name === 'MissingAuthTokenError' || + name === 'SessionNotFoundError' || + name === 'SnapshotNotFoundError' || + name === 'QuotaExceededError' || + name === 'InvalidStateError' || + name === 'FileNotFoundError' + ) { + return false; + } + + // Only short-circuit on a KNOWN retriable Connect code. A non-retriable + // Connect code (e.g. `permission_denied`) and raw Node socket codes (e.g. + // `ECONNRESET`) both fall through to the status / socket checks below — so a + // socket error isn't misread as a terminal Connect code and dropped. + const connectCode = connectCodeOf(err); + if (connectCode !== undefined && RETRIABLE_CONNECT_CODES.has(connectCode)) { + // `aborted` / `deadline_exceeded` are ambiguous (the server may have + // applied the call); `unavailable` / `resource_exhausted` are pre-apply. + return connectCode === 'unavailable' || connectCode === 'resource_exhausted' + ? true + : allowAmbiguous; + } + + const status = statusCodeOf(err); + if (status !== undefined) { + if (status === 429) return true; + if (status >= 500 && status <= 599) return allowAmbiguous; + return false; + } + + // Raw fetch / undici / ws 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 === 'EPIPE' || + code === 'UND_ERR_SOCKET' || + code === 'UND_ERR_CONNECT_TIMEOUT' + ) { + return allowAmbiguous; + } + } + } + return false; +} + +export async function withTenkiRetry(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( + `tenki ${opts.method}: attempt ${String(attempt)} failed (${errorSummary(err)}); retrying in ${String(delay)}ms`, + ); + await sleep(delay); + } + } + throw new Error(`withTenkiRetry: exhausted attempts for ${opts.method}`); +} + +function defaultRetryLog(line: string): void { + process.stderr.write(`\n[tenki-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 code = connectCodeOf(err) ?? statusCodeOf(err); + return code !== undefined + ? `${err.name}(${String(code)}): ${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-tenki/src/sdk.ts b/packages/sandbox-tenki/src/sdk.ts new file mode 100644 index 00000000..6ad763a0 --- /dev/null +++ b/packages/sandbox-tenki/src/sdk.ts @@ -0,0 +1,85 @@ +/** + * Thin wrapper around the `@tenkicloud/sandbox` SDK. Resolves the auth token + * once and re-exports the SDK surface the rest of the package uses from a + * single place (so tests can mock `./sdk.js` instead of the package). + * + * Tenki authenticates with a single workspace auth token (`tk_…`). The SDK + * reads `TENKI_AUTH_TOKEN` from `process.env`, but we still expose + * `resolveAuthToken()` so callers can fail loud with an actionable error + * before the SDK throws a generic "missing auth token" deep inside an op, and + * `getTenkiClient()` so the optional `TENKI_BASE_URL` / `TENKI_GATEWAY_ADDRESS` + * overrides (self-hosted / staging control planes) are wired consistently. + */ + +import { TenkiSandbox } from '@tenkicloud/sandbox'; +import type { ClientOptions } from '@tenkicloud/sandbox'; +import { ensureTenkiEnvLoaded } from './env-loader.js'; + +export { TenkiSandbox }; +export type { + Session, + ClientOptions, + CreateOptions, + ExposedPort, + FileInfo, + Identity, + RegistryPublishResult, + ResolvedRegistryRef, + Snapshot, + SnapshotState, + SessionState, +} from '@tenkicloud/sandbox'; +export { + SandboxError, + MissingAuthTokenError, + SessionNotFoundError, + SessionTerminatedError, + SessionExpiredError, + SnapshotNotFoundError, + SnapshotFailedError, + UnauthorizedError, + PermissionDeniedError, + QuotaExceededError, + CapacityUnavailableError, + RateLimitedError, + InvalidStateError, + FileNotFoundError, +} from '@tenkicloud/sandbox'; + +/** + * Return the configured Tenki auth token. Throws an actionable error when + * nothing is configured. Idempotent — env-loader caches itself after the + * first call. + */ +export function resolveAuthToken(): string { + ensureTenkiEnvLoaded(); + const t = process.env.TENKI_AUTH_TOKEN; + if (!t) { + throw new Error( + 'Tenki credentials not configured.\n' + + 'Run `agentbox tenki login` to paste your auth token (from https://tenki.cloud), ' + + 'or set TENKI_AUTH_TOKEN in the environment / ~/.agentbox/secrets.env.', + ); + } + return t; +} + +/** True when an auth token is configured. Used by the credential gate. */ +export function hasUsableCredentials(): boolean { + ensureTenkiEnvLoaded(); + return Boolean(process.env.TENKI_AUTH_TOKEN); +} + +/** + * Construct a `TenkiSandbox` client wired to the configured credentials and + * any control-plane overrides. Each call returns a fresh client — the CLI is a + * short-lived process per command, so we don't bother caching one. + */ +export function getTenkiClient(): TenkiSandbox { + const opts: ClientOptions = { authToken: resolveAuthToken() }; + const baseUrl = process.env.TENKI_BASE_URL; + if (baseUrl) opts.baseUrl = baseUrl; + const gatewayAddress = process.env.TENKI_GATEWAY_ADDRESS; + if (gatewayAddress) opts.gatewayAddress = gatewayAddress; + return new TenkiSandbox(opts); +} diff --git a/packages/sandbox-tenki/test/backend-mapping.test.ts b/packages/sandbox-tenki/test/backend-mapping.test.ts new file mode 100644 index 00000000..caaeed33 --- /dev/null +++ b/packages/sandbox-tenki/test/backend-mapping.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { isUnderWorkdir, mapState, parseTenkiSize, previewSlug, safeName } from '../src/backend.js'; + +describe('mapState', () => { + it('maps running-ish states to running', () => { + for (const s of ['RUNNING', 'CREATING', 'RESUMING'] as const) { + expect(mapState(s)).toBe('running'); + } + }); + + it('maps pausing/paused/user-shutdown to paused', () => { + for (const s of ['PAUSED', 'PAUSING', 'USER_SHUTDOWN'] as const) { + expect(mapState(s)).toBe('paused'); + } + }); + + it('maps terminal / unknown / undefined to missing', () => { + for (const s of ['TERMINATING', 'TERMINATED', 'UNSPECIFIED'] as const) { + expect(mapState(s)).toBe('missing'); + } + expect(mapState(undefined)).toBe('missing'); + }); +}); + +describe('previewSlug', () => { + it('is stable + DNS-safe per (session, port)', () => { + const a = previewSlug('sess_AbC123XyZ789', 8080); + expect(a).toBe(previewSlug('sess_AbC123XyZ789', 8080)); + expect(a).toMatch(/^ab-[a-z0-9]+-8080$/); + }); + + it('differs by port', () => { + expect(previewSlug('sess_x', 80)).not.toBe(previewSlug('sess_x', 6080)); + }); + + it('falls back to a non-empty slug for an id with no alnum chars', () => { + expect(previewSlug('___', 80)).toBe('ab-box-80'); + }); +}); + +describe('safeName', () => { + it('keeps hyphens and alphanumerics (box names are not mangled)', () => { + expect(safeName('my-box-123')).toBe('my-box-123'); + }); + + it('strips control characters (newlines/tabs)', () => { + expect(safeName('a\nb\tc')).toBe('abc'); + }); + + it('caps length at 200', () => { + expect(safeName('x'.repeat(500)).length).toBe(200); + }); +}); + +describe('isUnderWorkdir', () => { + it('treats relative paths as under the workdir (RPC resolves them there)', () => { + expect(isUnderWorkdir('seed.tar.gz', '/home/tenki')).toBe(true); + expect(isUnderWorkdir('sub/dir/file', '/home/tenki')).toBe(true); + }); + + it('accepts the workdir itself and paths beneath it', () => { + expect(isUnderWorkdir('/home/tenki', '/home/tenki')).toBe(true); + expect(isUnderWorkdir('/home/tenki/', '/home/tenki')).toBe(true); + expect(isUnderWorkdir('/home/tenki/a/b.txt', '/home/tenki')).toBe(true); + expect(isUnderWorkdir('/workspace/a', '/workspace/')).toBe(true); + }); + + it('rejects absolute paths outside the workdir (the exec-bridge case)', () => { + expect(isUnderWorkdir('/tmp/agentbox-workspace.tar.gz', '/home/tenki')).toBe(false); + expect(isUnderWorkdir('/workspace/x', '/home/tenki')).toBe(false); + }); + + it('is not fooled by a shared name prefix', () => { + expect(isUnderWorkdir('/home/tenki-evil/x', '/home/tenki')).toBe(false); + }); + + it('normalizes traversal before comparing', () => { + expect(isUnderWorkdir('/home/tenki/../etc/passwd', '/home/tenki')).toBe(false); + }); +}); + +describe('parseTenkiSize', () => { + it('parses cpu-memory (GB)', () => { + expect(parseTenkiSize('4-8')).toEqual({ cpu: 4, memoryGb: 8, diskGb: undefined }); + }); + + it('parses cpu-memory-disk (GB)', () => { + expect(parseTenkiSize('4-8-20')).toEqual({ cpu: 4, memoryGb: 8, diskGb: 20 }); + }); + + it('rejects malformed / non-positive / wrong-arity specs', () => { + for (const s of ['', '4', '4-8-20-1', '0-8', '4-0', '-1-8', 'a-b', '4.5-8']) { + expect(parseTenkiSize(s)).toBeUndefined(); + } + expect(parseTenkiSize(undefined)).toBeUndefined(); + }); +}); diff --git a/packages/sandbox-tenki/test/env-loader.test.ts b/packages/sandbox-tenki/test/env-loader.test.ts new file mode 100644 index 00000000..4660d927 --- /dev/null +++ b/packages/sandbox-tenki/test/env-loader.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { parseEnvFile } from '../src/env-loader.js'; + +describe('parseEnvFile', () => { + it('handles bare KEY=value', () => { + expect(parseEnvFile('TENKI_AUTH_TOKEN=tk_abc')).toEqual({ TENKI_AUTH_TOKEN: 'tk_abc' }); + }); + + it('handles double-quoted, single-quoted, and `export`-prefixed forms', () => { + const body = ['TENKI_AUTH_TOKEN="quoted"', "TENKI_BASE_URL='single'", 'export FOO=bar'].join( + '\n', + ); + expect(parseEnvFile(body)).toEqual({ + TENKI_AUTH_TOKEN: 'quoted', + TENKI_BASE_URL: 'single', + FOO: 'bar', + }); + }); + + it('skips blank lines and comments', () => { + const body = ['', '# header', 'TENKI_AUTH_TOKEN=tk_abc', '#trailing', ''].join('\n'); + expect(parseEnvFile(body)).toEqual({ TENKI_AUTH_TOKEN: 'tk_abc' }); + }); + + it('ignores malformed lines (no = sign)', () => { + expect(parseEnvFile('no_equals_here\nTENKI_AUTH_TOKEN=tk_abc')).toEqual({ + TENKI_AUTH_TOKEN: 'tk_abc', + }); + }); + + it('preserves = signs inside values', () => { + expect(parseEnvFile('TENKI_AUTH_TOKEN=ab=cd=ef')).toEqual({ TENKI_AUTH_TOKEN: 'ab=cd=ef' }); + }); +}); diff --git a/packages/sandbox-tenki/test/retry.test.ts b/packages/sandbox-tenki/test/retry.test.ts new file mode 100644 index 00000000..5b314ca0 --- /dev/null +++ b/packages/sandbox-tenki/test/retry.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it, vi } from 'vitest'; +import { isRetriable, withTenkiRetry } from '../src/retry.js'; + +/** Build an Error whose `.name` matches one of the SDK's typed error classes. */ +function named(name: string): Error { + const e = new Error(name); + e.name = name; + return e; +} + +describe('isRetriable', () => { + it('always retries RateLimitedError / CapacityUnavailableError', () => { + for (const n of ['RateLimitedError', 'CapacityUnavailableError']) { + expect(isRetriable(named(n), true)).toBe(true); + expect(isRetriable(named(n), false)).toBe(true); + } + }); + + it('never retries terminal typed errors', () => { + for (const n of [ + 'UnauthorizedError', + 'PermissionDeniedError', + 'MissingAuthTokenError', + 'SessionNotFoundError', + 'SnapshotNotFoundError', + 'QuotaExceededError', + 'InvalidStateError', + 'FileNotFoundError', + ]) { + expect(isRetriable(named(n), true)).toBe(false); + } + }); + + it('retries Connect unavailable / resource_exhausted regardless of ambiguity', () => { + expect(isRetriable({ code: 'unavailable' }, false)).toBe(true); + expect(isRetriable({ code: 'resource_exhausted' }, false)).toBe(true); + }); + + it('retries Connect aborted / deadline_exceeded only when ambiguous allowed', () => { + expect(isRetriable({ code: 'deadline_exceeded' }, true)).toBe(true); + expect(isRetriable({ code: 'deadline_exceeded' }, false)).toBe(false); + expect(isRetriable({ code: 'aborted' }, true)).toBe(true); + expect(isRetriable({ code: 'aborted' }, false)).toBe(false); + }); + + it('does not retry non-retriable Connect codes', () => { + expect(isRetriable({ code: 'permission_denied' }, true)).toBe(false); + expect(isRetriable({ code: 'invalid_argument' }, true)).toBe(false); + expect(isRetriable({ code: 'not_found' }, true)).toBe(false); + }); + + it('handles HTTP status codes (429 always, 5xx ambiguous, 4xx never)', () => { + expect(isRetriable({ statusCode: 429 }, false)).toBe(true); + expect(isRetriable({ status: 503 }, true)).toBe(true); + expect(isRetriable({ status: 503 }, false)).toBe(false); + expect(isRetriable({ statusCode: 404 }, true)).toBe(false); + }); + + it('retries raw socket errors only when ambiguous allowed (not misread as Connect codes)', () => { + expect(isRetriable({ code: 'ECONNRESET' }, true)).toBe(true); + expect(isRetriable({ code: 'ECONNRESET' }, false)).toBe(false); + expect(isRetriable({ code: 'ETIMEDOUT' }, true)).toBe(true); + expect(isRetriable({ cause: { code: 'UND_ERR_SOCKET' } }, true)).toBe(true); + }); + + it('does not retry random thrown values', () => { + expect(isRetriable('boom', true)).toBe(false); + expect(isRetriable(undefined, true)).toBe(false); + expect(isRetriable(new Error('plain'), true)).toBe(false); + }); +}); + +describe('withTenkiRetry', () => { + it('retries a transient Connect unavailable then returns the final value', async () => { + let calls = 0; + const onRetry = vi.fn(); + const result = await withTenkiRetry( + { method: 'test', retryOnAmbiguous: true, backoffMs: [1, 1, 1], onRetry }, + async () => { + calls += 1; + if (calls < 3) throw Object.assign(new Error('blip'), { code: 'unavailable' }); + return 'ok'; + }, + ); + expect(result).toBe('ok'); + expect(calls).toBe(3); + expect(onRetry).toHaveBeenCalledTimes(2); + }); + + it('does not retry a terminal typed error — surfaces the original', async () => { + let calls = 0; + const original = named('UnauthorizedError'); + await expect( + withTenkiRetry( + { method: 'test', retryOnAmbiguous: true, backoffMs: [1, 1], onRetry: () => {} }, + async () => { + calls += 1; + throw original; + }, + ), + ).rejects.toBe(original); + expect(calls).toBe(1); + }); + + it('enforces the per-attempt timeout, retries it, surfaces it on exhaustion', async () => { + let calls = 0; + await expect( + withTenkiRetry( + { method: 'test', retryOnAmbiguous: true, backoffMs: [1, 1], attemptTimeoutMs: 30, onRetry: () => {} }, + async () => { + calls += 1; + await new Promise(() => {}); + }, + ), + ).rejects.toThrow(/per-attempt timeout after 30ms/); + expect(calls).toBe(3); + }); + + it('passes through the first-attempt result when no error', async () => { + const onRetry = vi.fn(); + expect( + await withTenkiRetry( + { method: 'test', retryOnAmbiguous: true, backoffMs: [1], onRetry }, + async () => 42, + ), + ).toBe(42); + expect(onRetry).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/sandbox-tenki/tsconfig.json b/packages/sandbox-tenki/tsconfig.json new file mode 100644 index 00000000..f24546c2 --- /dev/null +++ b/packages/sandbox-tenki/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src/**/*", "test/**/*"] +} diff --git a/packages/sandbox-tenki/tsup.config.ts b/packages/sandbox-tenki/tsup.config.ts new file mode 100644 index 00000000..df392c0f --- /dev/null +++ b/packages/sandbox-tenki/tsup.config.ts @@ -0,0 +1,36 @@ +import { defineConfig } from 'tsup'; + +// Three entries: +// - `src/index.ts` (ESM) — provider surface consumed by apps/cli. +// - `src/cli.ts` (ESM) — `agentbox tenki login` subcommand. +// - `src/attach-helper.ts` (CJS bundle) — standalone Node process spawned by +// `buildTenkiAttach` to bridge the host PTY -> an in-box SSH channel +// (`session.ssh()`). CJS because it's invoked via `node ` (no +// package-level type:module hint reaches a standalone .js); bundling lets +// us ship one file. +export default defineConfig([ + { + entry: ['src/index.ts', 'src/cli.ts'], + format: ['esm'], + target: 'node20', + clean: true, + dts: true, + sourcemap: true, + // commander + @clack/prompts are external (apps/cli bundles them at the + // root). The `@tenkicloud/sandbox` SDK is external too — it pulls in + // ConnectRPC + protobuf + `ws`, which we let resolve via the host's normal + // module graph rather than inlining into our bundle. + external: ['commander', '@clack/prompts', '@tenkicloud/sandbox'], + }, + { + entry: { 'attach-helper': 'src/attach-helper.ts' }, + format: ['cjs'], + target: 'node20', + // Don't clean — the ESM build above already cleaned dist/. + clean: false, + // No d.ts for the standalone helper. + dts: false, + sourcemap: true, + external: ['commander', '@clack/prompts', '@tenkicloud/sandbox'], + }, +]); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 61b1cc84..0050f367 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -136,6 +136,9 @@ importers: '@agentbox/sandbox-remote-docker': specifier: workspace:* version: link:../../packages/sandbox-remote-docker + '@agentbox/sandbox-tenki': + specifier: workspace:* + version: link:../../packages/sandbox-tenki '@agentbox/sandbox-vercel': specifier: workspace:* version: link:../../packages/sandbox-vercel @@ -190,6 +193,9 @@ importers: '@agentbox/sandbox-remote-docker': specifier: workspace:* version: link:../../packages/sandbox-remote-docker + '@agentbox/sandbox-tenki': + specifier: workspace:* + version: link:../../packages/sandbox-tenki '@agentbox/sandbox-vercel': specifier: workspace:* version: link:../../packages/sandbox-vercel @@ -748,6 +754,43 @@ importers: specifier: ^2.1.8 version: 2.1.9(@types/node@22.19.19)(lightningcss@1.32.0) + packages/sandbox-tenki: + 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 + '@tenkicloud/sandbox': + specifier: ^0.4.0 + version: 0.4.0 + commander: + specifier: ^12.1.0 + version: 12.1.0 + devDependencies: + '@types/node': + specifier: ^22.10.1 + version: 22.19.19 + tsup: + specifier: ^8.3.5 + version: 8.5.1(jiti@2.7.0)(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)(lightningcss@1.32.0) + packages/sandbox-vercel: dependencies: '@agentbox/config': @@ -1001,12 +1044,22 @@ packages: '@bufbuild/protobuf@2.12.0': resolution: {integrity: sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA==} + '@bufbuild/protobuf@2.12.1': + resolution: {integrity: sha512-BvAMfS6LrgZiryOAZ4pBYucu4wG/Ei/9o9DZ9akbREnMLbPJiom2i8b9C8IsKErQoiKqVhrerzt3kOT/RrzLHg==} + '@clack/core@0.4.1': resolution: {integrity: sha512-Pxhij4UXg8KSr7rPek6Zowm+5M22rbd2g1nfojHJkxp5YkFqiZ2+YLEM/XGVIzvGOcM0nqjIFxrpDwWRZYWYjA==} '@clack/prompts@0.9.1': resolution: {integrity: sha512-JIpyaboYZeWYlyP0H+OoPPxd6nqueG/CmN6ixBiNFsIDHREevjIf0n0Ohh5gr5C8pEDknzgvz+pIJ8dMhzWIeg==} + '@connectrpc/connect-node@2.1.2': + resolution: {integrity: sha512-+i/aAOpsI8sIx1mbYp6d99zvxaUSF6t/jP9Ux9maAmjsZPgmIQ3JuIeYi0zJIP9zlCnBlJjkpPosshCgdRuThQ==} + engines: {node: '>=20'} + peerDependencies: + '@bufbuild/protobuf': ^2.7.0 + '@connectrpc/connect': 2.1.2 + '@connectrpc/connect-web@2.0.0-rc.3': resolution: {integrity: sha512-w88P8Lsn5CCsA7MFRl2e6oLY4J/5toiNtJns/YJrlyQaWOy3RO8pDgkz+iIkG98RPMhj2thuBvsd3Cn4DKKCkw==} peerDependencies: @@ -1018,6 +1071,11 @@ packages: peerDependencies: '@bufbuild/protobuf': ^2.2.0 + '@connectrpc/connect@2.1.2': + resolution: {integrity: sha512-MXkBijtcX09R10Eb6sFeIetc6w6746eio6xtfuyVOH7oQAacT1X0GzMIQFux6Qy8cq3W/T5qX5Bei8YbFtmRGA==} + peerDependencies: + '@bufbuild/protobuf': ^2.7.0 + '@daytona/api-client@0.196.0': resolution: {integrity: sha512-c77YAwlDQw2lQOOLMYpJFQkPrYwJIK1MX3fCjv5OSpDR8FXpH1GGqItvpgOsPG6Qu+KgZFBbKYTjK63EKZYS2g==} @@ -2911,6 +2969,10 @@ packages: '@tailwindcss/postcss@4.3.0': resolution: {integrity: sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w==} + '@tenkicloud/sandbox@0.4.0': + resolution: {integrity: sha512-p9GiQutfqEhEEAQlEOph/wuP2Vk0kDof4GjhIvnV96TbvzoqPFp1pD6iEjUcJMri6sJsEXBGf0Grsyl+X52GiA==} + engines: {node: '>=18'} + '@turbo/darwin-64@2.9.12': resolution: {integrity: sha512-eu3eFRmE9NjgZ0wPdRJ44l+LGSeIky+tz5ZQd8zQkw/Yqi+BM7wq+8nbabeoiVUcICi/IZweMOKl/MCmkrd1+g==} cpu: [x64] @@ -5715,6 +5777,8 @@ snapshots: '@bufbuild/protobuf@2.12.0': {} + '@bufbuild/protobuf@2.12.1': {} + '@clack/core@0.4.1': dependencies: picocolors: 1.1.1 @@ -5726,6 +5790,11 @@ snapshots: picocolors: 1.1.1 sisteransi: 1.0.5 + '@connectrpc/connect-node@2.1.2(@bufbuild/protobuf@2.12.1)(@connectrpc/connect@2.1.2(@bufbuild/protobuf@2.12.1))': + dependencies: + '@bufbuild/protobuf': 2.12.1 + '@connectrpc/connect': 2.1.2(@bufbuild/protobuf@2.12.1) + '@connectrpc/connect-web@2.0.0-rc.3(@bufbuild/protobuf@2.12.0)(@connectrpc/connect@2.0.0-rc.3(@bufbuild/protobuf@2.12.0))': dependencies: '@bufbuild/protobuf': 2.12.0 @@ -5735,6 +5804,10 @@ snapshots: dependencies: '@bufbuild/protobuf': 2.12.0 + '@connectrpc/connect@2.1.2(@bufbuild/protobuf@2.12.1)': + dependencies: + '@bufbuild/protobuf': 2.12.1 + '@daytona/api-client@0.196.0': dependencies: axios: 1.16.1 @@ -7375,6 +7448,16 @@ snapshots: postcss: 8.5.14 tailwindcss: 4.3.0 + '@tenkicloud/sandbox@0.4.0': + dependencies: + '@bufbuild/protobuf': 2.12.1 + '@connectrpc/connect': 2.1.2(@bufbuild/protobuf@2.12.1) + '@connectrpc/connect-node': 2.1.2(@bufbuild/protobuf@2.12.1)(@connectrpc/connect@2.1.2(@bufbuild/protobuf@2.12.1)) + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@turbo/darwin-64@2.9.12': optional: true