From f0c823bdae3d8ade8c4f2e541b55ce88d29423af Mon Sep 17 00:00:00 2001 From: Marco D'Alia Date: Thu, 30 Jul 2026 19:10:45 +0000 Subject: [PATCH 1/2] docs(hub): document the /api/v1 end state + complete the OpenAPI (thin CLI Step 14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final step of the one-path plan: make the docs and the OpenAPI spec describe the consolidated end state accurately — including the deliberate exceptions, not an overstated "everything goes through /api/v1". OpenAPI (api/v1/lib/openapi.ts): document the 8 routes that shipped without an entry (boxes/:id/{agent,checkpoint,logs,rename}, checkpoints GET|DELETE, prune, jobs GET, jobs/:id/login-code), add Checkpoints/Fleet tags + their schemas, enrich Job (error/provider/name/agent/createdAt/login) and Box (the Step-3 adoption fields: sandboxId/originUrl/publicHost/image/webPort/previewUrls/ lastAgent/topology/shellCount). New guard test openapi-coverage.test.ts diffs the App-Router route files against the document both ways — the "verification checklist" the header always claimed but never had. Docs: api.mdx (9 endpoints + one-path framing), deployed-hub.mdx (new "What still needs your laptop" section — direct IO plane, local adoption, secrets.env on both machines, the launcher-foreground-create exception, no-TTL parked approvals; fixed the stale ls merge/on-hub/orphan description), configuration.mdx (git.pushMode), architecture.md (End state: one path through /api/v1 + the four exceptions), create-and-checkpoints.md + cloud-providers.md + hub-testing.md (routing notes, custody/adoption §4b). CLAUDE.md: /api/events accepts Bearer (same gate as /api/v1) — the cookie is an additional same-origin credential. Tray (../agentbox-tray) is a host-side sibling repo unreachable from this box; left as a documented follow-up per the step brief (needs the enriched Box payload decoded in HubAPIBoxSource). Verified e2e: built the standalone hub, restarted it, served GET /api/v1/openapi.json lists all 37 routes with a perfect bijection to the route files (0 missing, 0 stale); /api/v1/docs renders; web docs build green; hub tests (122) + typecheck green. Claude-Session: https://claude.ai/code/session_01P5tWZxdr38EUaF9tXFUFXB --- CLAUDE.md | 12 +- .../hub/app/(dashboard)/api/v1/lib/openapi.ts | 585 ++++++++++++++++++ apps/hub/test/openapi-coverage.test.ts | 63 ++ apps/web/content/docs/api.mdx | 31 +- apps/web/content/docs/configuration.mdx | 265 ++++---- apps/web/content/docs/deployed-hub.mdx | 47 +- docs/architecture.md | 11 + docs/cloud-providers.md | 42 ++ docs/create-and-checkpoints.md | 21 + docs/hub-api-single-path-plan.md | 42 +- docs/hub-testing.md | 10 + 11 files changed, 979 insertions(+), 150 deletions(-) create mode 100644 apps/hub/test/openapi-coverage.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index d8c57f8a..eca832c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,10 +83,14 @@ It has **no build-time coupling** to this repo — it's a Swift Package Manager (which carries the raw host-side fields — `state`, `projectRoot`, endpoint URLs, session titles — and the synthetic `creating`/`error` boxes for in-flight/failed creates) plus the lifecycle (`start`/`pause`/`resume`/`stop`/`destroy`), git, rename, and services routes. Approvals use - `/api/v1/approvals` (+ `…/{id}/answer`), live events the SSE `/api/events` stream. **Auth split to - remember when changing the hub:** `/api/v1/*` uses `Authorization: Bearer `, but - `/api/events` reads the **`agentbox_hub_token` cookie** (Bearer there 401s). Token is - `~/.agentbox/hub/token`. SSE events are refetch signals only (empty `data: {}`). + `/api/v1/approvals` (+ `…/{id}/answer`), live events the SSE `/api/events` stream. **Auth to + remember when changing the hub:** both `/api/v1/*` and `/api/events` go through the same gate + (`apps/hub/proxy.ts`) and accept `Authorization: Bearer ` — a headless client (the tray + against a remote control box) subscribes to events with the same Bearer key it uses for `/api/v1`. + The token cookie (`agentbox_hub_token`, or the better-auth session cookie on a password profile) is + an *additional* accepted credential for the hub's own same-origin browser fetches, not a + replacement. Token is `~/.agentbox/hub/token`. SSE events are refetch signals only (empty + `data: {}`). - **Inherently-local actions** via the installed CLI, shelled through a login shell (`/bin/zsh -lc 'agentbox …'`, because a GUI app has no inherited PATH): `open --in`/`open --targets` (terminal attach in iTerm2/cmux/Herdr), and `hub status`/`hub start` to bootstrap the hub itself. diff --git a/apps/hub/app/(dashboard)/api/v1/lib/openapi.ts b/apps/hub/app/(dashboard)/api/v1/lib/openapi.ts index 31659363..01466963 100644 --- a/apps/hub/app/(dashboard)/api/v1/lib/openapi.ts +++ b/apps/hub/app/(dashboard)/api/v1/lib/openapi.ts @@ -36,6 +36,14 @@ export function buildOpenApi(): Record { { name: 'Hosts', description: 'Remote-docker host aliases (name -> SSH connection).' }, { name: 'Approvals', description: 'Pending host-action approvals.' }, { name: 'Jobs', description: 'Async create/bake job status and log streams.' }, + { + name: 'Checkpoints', + description: 'Durable per-project checkpoints (docker image / cloud snapshot).', + }, + { + name: 'Fleet', + description: 'Fleet-wide maintenance (prune orphan boxes and resources).', + }, { name: 'Custody', description: 'What the control box holds in custody (metadata only — never values).', @@ -285,6 +293,178 @@ export function buildOpenApi(): Record { }, }, }, + '/boxes/{id}/rename': { + post: { + tags: ['Boxes'], + summary: "Set or clear a box's display label", + description: + 'Cosmetic only — the container, branch and URLs are untouched. Pass an empty string to clear the label. Backs `agentbox status --set-name/--clear-name`.', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + displayName: { + type: 'string', + description: 'New label (max 60 chars); empty string clears it.', + }, + }, + required: ['displayName'], + }, + }, + }, + }, + responses: { + '200': { + description: 'Renamed', + content: { + 'application/json': { + schema: { type: 'object', properties: { ok: { const: true } }, required: ['ok'] }, + }, + }, + }, + '400': errorResponse, + '401': errorResponse, + '404': errorResponse, + '409': errorResponse, + '503': errorResponse, + }, + }, + }, + '/boxes/{id}/agent': { + get: { + tags: ['Boxes'], + summary: "Get the box's in-box coding-agent status snapshot", + description: + "The agent's live activity (working / idle / waiting / question / end-plan / …), plan/question payload and session title, from the persisted status store. Backs `agentbox agent state/wait-for/get-plan-question`.", + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + '200': { + description: 'Agent state', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/AgentState' } }, + }, + }, + '401': errorResponse, + '404': errorResponse, + '503': errorResponse, + }, + }, + }, + '/boxes/{id}/logs': { + get: { + tags: ['Boxes'], + summary: "Read (or follow) a box's service log", + description: + 'One of two shapes on one route. `follow=0` (default) returns a JSON `{ output }` snapshot — a bounded `--tail` dump. `follow=1` returns an SSE stream (`open` / `log`* / `end`) the hub pipes live from the in-box `agentbox-ctl logs --follow`. Pass `service=` for a declared service, or `daemon=1` for the ctl-daemon log.', + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, + { + name: 'service', + in: 'query', + required: false, + schema: { type: 'string' }, + description: 'A declared service name (required unless daemon=1).', + }, + { + name: 'daemon', + in: 'query', + required: false, + schema: { type: 'string', enum: ['1'] }, + description: 'Tail the ctl-daemon log instead of a service.', + }, + { + name: 'follow', + in: 'query', + required: false, + schema: { type: 'string', enum: ['1'] }, + description: 'Stream the log as SSE instead of returning a snapshot.', + }, + { + name: 'tail', + in: 'query', + required: false, + schema: { type: 'integer' }, + description: 'Lines of history (default 200).', + }, + ], + responses: { + '200': { + description: 'Log snapshot (JSON) or SSE stream (text/event-stream when follow=1)', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { output: { type: 'string' } }, + required: ['output'], + }, + }, + 'text/event-stream': { schema: { type: 'string' } }, + }, + }, + '400': errorResponse, + '401': errorResponse, + '404': errorResponse, + '409': errorResponse, + '503': errorResponse, + }, + }, + }, + '/boxes/{id}/checkpoint': { + post: { + tags: ['Checkpoints'], + summary: 'Capture the box state as a project checkpoint', + description: + 'Commits the box (docker commit / cloud snapshot) into the project checkpoint store on the hub machine, via provider.checkpoint.*. A durable project asset — it survives the box. Backs `agentbox checkpoint create`.', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + requestBody: { + required: false, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Checkpoint name (auto-generated if omitted).', + }, + merged: { + type: 'boolean', + description: 'docker: flatten to a single squashed layer (FROM scratch).', + }, + setDefault: { + type: 'boolean', + description: "Also pin this as the project's default checkpoint.", + }, + replace: { + type: 'boolean', + description: 'Overwrite an existing checkpoint of the same name.', + }, + }, + }, + }, + }, + }, + responses: { + '200': { + description: 'Checkpoint captured', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/CheckpointCreateResult' }, + }, + }, + }, + '400': errorResponse, + '401': errorResponse, + '404': errorResponse, + '409': errorResponse, + '503': errorResponse, + }, + }, + }, '/boxes/{id}/open': { post: { tags: ['Box services'], @@ -745,6 +925,129 @@ export function buildOpenApi(): Record { }, }, }, + '/checkpoints': { + get: { + tags: ['Checkpoints'], + summary: "List a project's (or every project's) checkpoints", + description: + 'The project checkpoint store lives on the hub machine, keyed by the absolute project root. Pass `?project=` for one project, or `?global=1` for every project. Backs `agentbox checkpoint ls` / `ls -g`.', + parameters: [ + { + name: 'project', + in: 'query', + required: false, + schema: { type: 'string' }, + description: 'Absolute project root (required unless global=1).', + }, + { + name: 'global', + in: 'query', + required: false, + schema: { type: 'string', enum: ['1'] }, + description: 'List checkpoints for every project.', + }, + ], + responses: { + '200': { + description: 'Checkpoints', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/CheckpointListing' } }, + }, + }, + '400': errorResponse, + '401': errorResponse, + '503': errorResponse, + }, + }, + delete: { + tags: ['Checkpoints'], + summary: 'Delete a checkpoint', + description: + 'Removes one checkpoint from every store that had it and sweeps any dangling default-checkpoint config pointer. Backs `agentbox checkpoint rm`.', + parameters: [ + { + name: 'project', + in: 'query', + required: true, + schema: { type: 'string' }, + description: 'Absolute project root.', + }, + { + name: 'ref', + in: 'query', + required: true, + schema: { type: 'string' }, + description: 'The checkpoint name.', + }, + { + name: 'provider', + in: 'query', + required: false, + schema: { type: 'string' }, + description: 'Scope the delete to one provider store.', + }, + ], + responses: { + '200': { + description: 'Deleted', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/CheckpointRemoveResult' }, + }, + }, + }, + '400': errorResponse, + '401': errorResponse, + '404': errorResponse, + '503': errorResponse, + }, + }, + }, + '/prune': { + post: { + tags: ['Fleet'], + summary: 'Prune orphan boxes and resources', + description: + 'Without a provider (or provider `docker`) it reaps orphan docker records, containers, volumes, snapshot/box dirs — and, with `all`, orphan project configs. With a cloud provider it enumerates untracked sandboxes and (when not a `dryRun`) deletes them AND reaps their control-box registrations. Durable project checkpoints are always left intact. Backs `agentbox prune`.', + requestBody: { + required: false, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + provider: { + type: 'string', + description: + 'Cloud provider to prune; omit (or `docker`) for the local docker sweep.', + }, + all: { + type: 'boolean', + description: 'docker: also remove orphan per-project config dirs.', + }, + dryRun: { + type: 'boolean', + description: 'Report what would be removed without removing anything.', + }, + }, + }, + }, + }, + }, + responses: { + '200': { + description: 'Prune result', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/PruneResult' } }, + }, + }, + '400': errorResponse, + '401': errorResponse, + '409': errorResponse, + '503': errorResponse, + }, + }, + }, '/approvals': { get: { tags: ['Approvals'], @@ -827,6 +1130,32 @@ export function buildOpenApi(): Record { }, }, }, + '/jobs': { + get: { + tags: ['Jobs'], + summary: 'List background jobs', + description: + "The unified job listing — the local file queue's create jobs merged with, on a control box, the control-plane create queue. Backs `agentbox queue list` and `agentbox hub jobs`.", + responses: { + '200': { + description: 'Jobs', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + jobs: { type: 'array', items: { $ref: '#/components/schemas/JobListItem' } }, + }, + required: ['jobs'], + }, + }, + }, + }, + '401': errorResponse, + '503': errorResponse, + }, + }, + }, '/jobs/{id}': { get: { tags: ['Jobs'], @@ -857,6 +1186,41 @@ export function buildOpenApi(): Record { }, }, }, + '/jobs/{id}/login-code': { + post: { + tags: ['Jobs'], + summary: 'Deliver an OAuth login code to a create job', + description: + 'Feeds a pasted Claude OAuth approval code to a create job that is awaiting a re-login. The create worker consumes it and completes the in-box login. The one interactive create affordance that survives.', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { code: { type: 'string', description: 'The OAuth approval code.' } }, + required: ['code'], + }, + }, + }, + }, + responses: { + '200': { + description: 'Accepted', + content: { + 'application/json': { + schema: { type: 'object', properties: { ok: { const: true } }, required: ['ok'] }, + }, + }, + }, + '400': errorResponse, + '401': errorResponse, + '404': errorResponse, + '503': errorResponse, + }, + }, + }, '/custody': { get: { tags: ['Custody'], @@ -1223,6 +1587,47 @@ export function buildOpenApi(): Record { 'working | idle | waiting | end-plan | question | compacting | error | unknown', }, codexActivity: { type: 'string' }, + shellCount: { + type: 'number', + description: 'Live shell-session count (docker only); absent → the CLI renders "-".', + }, + sandboxId: { + type: 'string', + description: + 'Provider-native sandbox id (cloud boxes). Part of the non-secret adoption block a thin client rebuilds a drivable local record from — tokens are never serialized, a fresh adoption re-mints them.', + }, + originUrl: { + type: ['string', 'null'], + description: + "Box repo's origin remote URL. Lets project-scoped `ls` match a box to the cwd repo by identity when its projectRoot is a remote hub's path. Populated for any registered box, docker included.", + }, + publicHost: { + type: 'string', + description: + 'Public IP/host of the box VM (direct-SSH providers: hetzner/digitalocean).', + }, + image: { + type: 'string', + description: 'Base image / snapshot ref the sandbox booted from.', + }, + webPort: { + type: 'number', + description: 'In-box WebProxy port (cloud boxes bind a non-privileged port).', + }, + previewUrls: { + type: 'object', + additionalProperties: { type: 'string' }, + description: 'Token-authed preview URLs keyed by in-box port.', + }, + lastAgent: { + type: 'string', + enum: ['claude', 'codex', 'opencode'], + description: 'The agent the box was created for.', + }, + topology: { + type: 'string', + description: "Sync federation shape ('cloud' | 'control-plane'); absent for docker.", + }, }, required: ['id', 'projectId', 'status', 'agent'], }, @@ -1330,9 +1735,189 @@ export function buildOpenApi(): Record { id: { type: 'string' }, status: { type: 'string', enum: ['queued', 'running', 'done', 'failed', 'cancelled'] }, boxId: { type: 'string' }, + error: { + type: 'string', + description: + "A failed job's reason — so a create reports the failure, not a silent 'done'.", + }, + provider: { type: 'string' }, + name: { type: 'string' }, + agent: { type: 'string' }, + createdAt: { type: 'string' }, + login: { + type: 'object', + description: + 'Present when the create is awaiting a Claude re-login (see POST /jobs/{id}/login-code).', + properties: { + required: { type: 'boolean' }, + phase: { type: 'string' }, + url: { type: 'string' }, + error: { type: 'string' }, + lastError: { type: 'string' }, + }, + }, + }, + required: ['id', 'status'], + }, + JobListItem: { + type: 'object', + description: 'One row of GET /jobs — the Job shape without the streamable log path.', + properties: { + id: { type: 'string' }, + status: { type: 'string', enum: ['queued', 'running', 'done', 'failed', 'cancelled'] }, + boxId: { type: 'string' }, + error: { type: 'string' }, + provider: { type: 'string' }, + name: { type: 'string' }, + agent: { type: 'string' }, + createdAt: { type: 'string' }, }, required: ['id', 'status'], }, + AgentState: { + type: 'object', + description: + "The box's in-box coding-agent status snapshot. `claude` is the raw ctl status payload (activity, plan/question, session title); null when no snapshot exists yet.", + properties: { + claude: { description: 'Raw BoxStatusClaude payload (opaque here).' }, + }, + required: ['claude'], + }, + CheckpointCreateResult: { + type: 'object', + properties: { + ok: { const: true }, + name: { type: 'string' }, + kind: { + type: 'string', + description: + "docker manifest type ('layered' | 'merged') or 'snapshot' for a cloud backend.", + }, + ref: { type: 'string', description: 'The image tag / snapshot id created.' }, + provider: { type: 'string' }, + dir: { type: 'string', description: 'Snapshot dir (cloud backends).' }, + setDefaultKey: { + type: 'string', + description: 'The config key written when setDefault was requested.', + }, + }, + required: ['ok', 'name', 'kind', 'ref', 'provider'], + }, + CheckpointItem: { + type: 'object', + properties: { + name: { type: 'string' }, + provider: { type: 'string', description: "'docker' or the cloud backend name." }, + kind: { type: 'string' }, + sourceBoxName: { type: 'string' }, + createdAt: { type: 'string' }, + isDefault: { + type: 'boolean', + description: "Resolved server-side against the project's effective config.", + }, + }, + required: ['name', 'provider', 'kind', 'isDefault'], + }, + CheckpointListing: { + type: 'object', + properties: { + projects: { + type: 'array', + items: { + type: 'object', + properties: { + segment: { type: 'string', description: 'The path-hash store segment.' }, + projectRoot: { + type: 'string', + description: 'Absent for an orphan segment whose project config was GC-ed.', + }, + label: { type: 'string' }, + items: { type: 'array', items: { $ref: '#/components/schemas/CheckpointItem' } }, + }, + required: ['segment', 'label', 'items'], + }, + }, + }, + required: ['projects'], + }, + CheckpointRemoveResult: { + type: 'object', + properties: { + ok: { const: true }, + removed: { + type: 'array', + items: { type: 'string' }, + description: 'Providers the checkpoint was deleted from.', + }, + clearedKeys: { + type: 'array', + items: { type: 'string' }, + description: 'Default-checkpoint config pointers cleared in the project layer.', + }, + warnedKeys: { + type: 'array', + items: { type: 'string' }, + description: "Dangling pointers in a layer we can't auto-edit (warned, not cleared).", + }, + }, + required: ['ok', 'removed', 'clearedKeys', 'warnedKeys'], + }, + PruneResult: { + description: + 'The prune outcome — discriminated by `kind`: `general` (docker sweep), `cloud` (untracked cloud sandboxes).', + oneOf: [ + { + type: 'object', + properties: { + kind: { const: 'general' }, + result: { + type: 'object', + properties: { + removedRecords: { type: 'array', items: { type: 'string' } }, + removedContainers: { type: 'array', items: { type: 'string' } }, + removedVolumes: { type: 'array', items: { type: 'string' } }, + removedSnapshotDirs: { type: 'array', items: { type: 'string' } }, + removedBoxDirs: { type: 'array', items: { type: 'string' } }, + removedCheckpointImages: { type: 'array', items: { type: 'string' } }, + dryRun: { type: 'boolean' }, + }, + required: ['dryRun'], + }, + projectConfigs: { type: 'array', items: { type: 'string' } }, + }, + required: ['kind', 'result', 'projectConfigs'], + }, + { + type: 'object', + properties: { + kind: { const: 'cloud' }, + provider: { type: 'string' }, + dryRun: { type: 'boolean' }, + orphans: { + type: 'array', + items: { + type: 'object', + properties: { + sandboxId: { type: 'string' }, + name: { type: 'string' }, + state: { type: 'string' }, + createdAt: { type: 'string' }, + }, + required: ['sandboxId'], + }, + }, + deleted: { type: 'number' }, + failed: { type: 'number' }, + reaped: { + type: 'number', + description: + 'Control-box registrations reaped for the deleted sandboxes (0 on a dry run).', + }, + }, + required: ['kind', 'provider', 'dryRun', 'orphans', 'deleted', 'failed', 'reaped'], + }, + ], + }, CreateBox: { type: 'object', properties: { diff --git a/apps/hub/test/openapi-coverage.test.ts b/apps/hub/test/openapi-coverage.test.ts new file mode 100644 index 00000000..084dc38f --- /dev/null +++ b/apps/hub/test/openapi-coverage.test.ts @@ -0,0 +1,63 @@ +import { readdirSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { buildOpenApi } from '../app/(dashboard)/api/v1/lib/openapi'; + +// The OpenAPI document is hand-authored (no zod/codegen convention in this repo), +// so it drifts silently when a route lands without a matching entry. This test is +// the "verification checklist asserts every route appears here" the openapi.ts +// header promises: it diffs the actual App-Router route files against the paths +// the document declares. Fails loudly on a new route that forgot its docs. + +const HERE = dirname(fileURLToPath(import.meta.url)); +const V1_DIR = join(HERE, '..', 'app', '(dashboard)', 'api', 'v1'); + +// These two routes SERVE the API description itself — the spec JSON and the +// Scalar docs page — rather than being documented API endpoints. +const SELF_DESCRIBING = new Set(['/openapi.json', '/docs']); + +// dir segment -> OpenAPI path segment. `[id]`/`[action]` -> `{id}`/`{action}`; +// a catch-all `[...path]` -> `{path}`. +function segToPath(seg: string): string { + const catchAll = seg.match(/^\[\.\.\.(.+)\]$/); + if (catchAll) return `{${catchAll[1]}}`; + const dynamic = seg.match(/^\[(.+)\]$/); + if (dynamic) return `{${dynamic[1]}}`; + return seg; +} + +// Walk the route tree, collecting the API path for every `route.ts`. +function collectRoutePaths(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + out.push(...collectRoutePaths(join(dir, entry.name))); + } else if (entry.name === 'route.ts') { + const rel = relative(V1_DIR, dir); + const apiPath = rel === '' ? '/' : '/' + rel.split('/').map(segToPath).join('/'); + out.push(apiPath); + } + } + return out; +} + +describe('OpenAPI route coverage', () => { + it('documents every /api/v1 route (except the self-describing spec/docs pages)', () => { + const spec = buildOpenApi(); + const documented = new Set(Object.keys(spec.paths as Record)); + const routes = collectRoutePaths(V1_DIR).filter((p) => !SELF_DESCRIBING.has(p)); + + const missing = routes.filter((p) => !documented.has(p)).sort(); + expect(missing, `undocumented routes in openapi.ts:\n${missing.join('\n')}`).toEqual([]); + }); + + it('has no documented path that no longer has a route file', () => { + const spec = buildOpenApi(); + const documented = Object.keys(spec.paths as Record); + const routes = new Set(collectRoutePaths(V1_DIR)); + + const stale = documented.filter((p) => !routes.has(p)).sort(); + expect(stale, `documented paths with no route file:\n${stale.join('\n')}`).toEqual([]); + }); +}); diff --git a/apps/web/content/docs/api.mdx b/apps/web/content/docs/api.mdx index 534978ad..d394b7b9 100644 --- a/apps/web/content/docs/api.mdx +++ b/apps/web/content/docs/api.mdx @@ -9,6 +9,8 @@ programmatic callers — IDE integrations, scripts, macOS apps, etc. — and is way to drive AgentBox when the hub runs on a **separate host** (a control plane), where there's no local CLI to shell out to. +This is not a second surface bolted onto the side. The AgentBox **CLI itself** drives every box and fleet operation through this same `/api/v1` — create, lifecycle, listing, git, approvals, services, checkpoints, prune and custody all go through the hub, against a local hub and a remote control box alike. "Enable a remote hub" is a base-URL swap, not a different code path. (The one operation still handled outside the API is the **direct IO plane** — `shell`, `attach`, `cp`, `download`, `code`, `open`, `url`, `screen` — which talks to the box from your machine; see [what still needs your laptop](/docs/deployed-hub#driving-a-box-from-your-laptop).) + On your own machine you can also just call the CLI with `--json` (`agentbox list --json`, `agentbox status --json`). The API is what you reach for across a network, or when you want one stable HTTP contract instead of spawning a process per call. ## Base URL and versioning @@ -85,6 +87,25 @@ the interactive reference at `GET /docs`. | `POST /boxes/{id}/stop` | Stop a box. | | `POST /boxes/{id}/destroy` | Destroy a box (container + volumes). On a synthetic `job:` id it instead **dismisses** a failed create (clears the queue entry); a create still in progress returns `409`. The other lifecycle actions all `409` on a `job:` id. | | `POST /boxes/{id}/screen` | Open-VNC prep: points the in-box browser at the box's web app so the VNC desktop shows the app instead of a blank X screen (the `agentbox screen` step). Call it right before opening the box's `vncUrl`; browser-launch failures are logged server-side, not returned. | +| `POST /boxes/{id}/rename` | Set (or clear) a box's cosmetic display label. Body: `{ displayName }` (max 60 chars; an empty string clears it). The container/branch/URLs are untouched. Backs `agentbox status --set-name`/`--clear-name`. | +| `GET /boxes/{id}/agent` | The box's in-box coding-agent status snapshot (activity, plan/question, session title) from the persisted status store: `{ claude }`. Backs `agentbox agent state`/`wait-for`/`get-plan-question`. | +| `GET /boxes/{id}/logs` | A box service log. `?service=` (or `?daemon=1` for the ctl-daemon log). Default returns a JSON `{ output }` tail (`?tail=`, default 200); `?follow=1` streams it as SSE (`open` / `log`\* / `end`) — the hub pipes the in-box `agentbox-ctl logs --follow`. Backs `agentbox logs`. | + +### Checkpoints + +Checkpoints are **durable per-project assets** — a docker image or cloud snapshot the box's create warms from. They outlive the box and live in a store on the hub's machine, keyed by the absolute project root (so on a genuinely remote control box a thin laptop can't list them by its own path — a limitation of path-hash-keyed stores). `agentbox prune` never touches them. + +| Method + path | Description | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `POST /boxes/{id}/checkpoint` | Capture the box state as a project checkpoint (`docker commit` / cloud snapshot) via `provider.checkpoint.*`. Optional body `{ name?, merged?, setDefault?, replace? }`. Returns `{ ok, name, kind, ref, provider, dir?, setDefaultKey? }`. Backs `agentbox checkpoint create`. | +| `GET /checkpoints` | List a project's checkpoints (`?project=`) or every project's (`?global=1`): `{ projects: [{ segment, projectRoot?, label, items }] }`. Each item carries `isDefault` resolved server-side. Backs `agentbox checkpoint ls`/`ls -g`. | +| `DELETE /checkpoints` | Delete a checkpoint (`?project=&ref=`, optional `&provider=

`) from every store that had it, sweeping any dangling default pointer: `{ ok, removed, clearedKeys, warnedKeys }`. Backs `agentbox checkpoint rm`. | + +### Fleet + +| Method + path | Description | +| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `POST /prune` | Prune orphan boxes and resources. Body `{ provider?, all?, dryRun? }`. Without a provider (or `docker`) it reaps orphan docker records/containers/volumes/box dirs — and, with `all`, orphan project configs. With a cloud provider it enumerates untracked sandboxes and (when not a `dryRun`) deletes them **and** reaps their control-box registrations server-side. Durable checkpoints are always left intact. Backs `agentbox prune`. | ### Box git @@ -129,10 +150,12 @@ the interactive reference at `GET /docs`. ### Jobs -| Method + path | Description | -| --------------------- | ------------------------------------------------------- | -| `GET /jobs/{id}` | Create-job status (`queued`/`running`/`done`/`failed`). | -| `GET /jobs/{id}/logs` | Stream the build log (SSE). | +| Method + path | Description | +| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET /jobs` | The unified job listing — the local file queue's create jobs merged with, on a control box, the control-plane create queue: `{ jobs: [{ id, status, boxId?, error?, provider?, name?, agent?, createdAt? }] }`. Backs `agentbox queue list` and `agentbox hub jobs`. | +| `GET /jobs/{id}` | Create-job status (`queued`/`running`/`done`/`failed`), plus `error`/`provider`/`name`/`agent`/`login` when set. | +| `GET /jobs/{id}/logs` | Stream the build log (SSE). | +| `POST /jobs/{id}/login-code` | Deliver a pasted Claude OAuth approval code (`{ code }`) to a create job awaiting a re-login — the one interactive create affordance that survives. The worker consumes it and completes the in-box login. | ### Custody diff --git a/apps/web/content/docs/configuration.mdx b/apps/web/content/docs/configuration.mdx index 1edbf305..01022195 100644 --- a/apps/web/content/docs/configuration.mdx +++ b/apps/web/content/docs/configuration.mdx @@ -8,7 +8,8 @@ AgentBox reads layered defaults at the start of every command. The same key shap You rarely need to touch config — every key has a built-in default and most surfaces also expose a one-off CLI flag. Config is for making a preference stick. -Don't memorize keys. `agentbox config list` prints every key with its current value and where it came from, and `agentbox config list --include-advanced` adds the advanced ones. + Don't memorize keys. `agentbox config list` prints every key with its current value and where it + came from, and `agentbox config list --include-advanced` adds the advanced ones. ## Config layers @@ -19,20 +20,24 @@ AgentBox merges four layers on top of the built-in defaults. Highest layer that CLI flag > workspace defaults: > per-project > global > built-in default ``` -| Layer | Where it lives | Scope | -| --- | --- | --- | -| CLI flag | the command you type | this invocation only, never persists | -| workspace | the `defaults:` block in `agentbox.yaml` | committed, shared with the team | +| Layer | Where it lives | Scope | +| ----------- | ----------------------------------------- | ------------------------------------ | +| CLI flag | the command you type | this invocation only, never persists | +| workspace | the `defaults:` block in `agentbox.yaml` | committed, shared with the team | | per-project | `~/.agentbox/projects//config.yaml` | per machine, per user, not committed | -| global | `~/.agentbox/config.yaml` | every project on this machine | -| built-in | `BUILT_IN_DEFAULTS` in the CLI | the fallback for any unset key | +| global | `~/.agentbox/config.yaml` | every project on this machine | +| built-in | `BUILT_IN_DEFAULTS` in the CLI | the fallback for any unset key | The per-project `` is the first 16 hex chars of the SHA-1 of the project's absolute path. The workspace layer is found by walking up from the current directory to the nearest `agentbox.yaml`. `config set`/`unset`/`edit` only write the `--global` or `--project` files (project is the default). The committed `defaults:` block is hand-edited — see [agentbox.yaml](/docs/agentbox-yaml) for its full schema. `engine.kind` is the one key applied at CLI startup; every other key flows through the per-command effective-config load. {/* DIAGRAM /diagrams/configuration.png — nano-banana-pro, home-diagram style ref. Recipe + prompt in apps/web/images.md → Phase D. */} -

+ +
## The config command @@ -87,17 +92,19 @@ box.provider: -`box.snapshot` was renamed to `box.hostSnapshot`. The old name now errors with a migration hint — update any config or `agentbox.yaml` still using it. AgentBox is pre-1.0, so there are no compatibility aliases. + `box.snapshot` was renamed to `box.hostSnapshot`. The old name now errors with a migration hint — + update any config or `agentbox.yaml` still using it. AgentBox is pre-1.0, so there are no + compatibility aliases. -## box.* — the box keys +## box.\* — the box keys The largest group. A pattern runs through it: a generic key plus per-provider overrides of the form `` (e.g. `box.sizeHetzner`). The provider-specific form wins over the generic one for that provider. This applies to `box.size*`, `box.image*`, and `box.defaultCheckpoint*`. ### Provider -| Key | Type | Default | Meaning | -| --- | --- | --- | --- | +| Key | Type | Default | Meaning | +| -------------- | ------------------------------------------------------------------------------- | -------- | -------------------------------- | | `box.provider` | enum `docker`/`daytona`/`hetzner`/`vercel`/`e2b`/`digitalocean`/`remote-docker` | `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: @@ -113,37 +120,37 @@ See [local Docker](/docs/local-docker), [Remote Docker](/docs/remote-docker), [H ### Resource limits (Docker) -| Key | Type | Default | Meaning | -| --- | --- | --- | --- | -| `box.memory` | int (MiB) | `0` (unlimited) | hard memory ceiling; use `--memory` on create for byte/k/m/g strings | -| `box.cpus` | int | `0` (unlimited) | whole-core cap; use `--cpus` for fractional like `1.5` | -| `box.pidsLimit` | int | `0` (unlimited) | max PID count | -| `box.disk` | string (advanced) | empty | best-effort writable-layer size; no-op on overlay2 / macOS engines | +| Key | Type | Default | Meaning | +| --------------- | ----------------- | --------------- | -------------------------------------------------------------------- | +| `box.memory` | int (MiB) | `0` (unlimited) | hard memory ceiling; use `--memory` on create for byte/k/m/g strings | +| `box.cpus` | int | `0` (unlimited) | whole-core cap; use `--cpus` for fractional like `1.5` | +| `box.pidsLimit` | int | `0` (unlimited) | max PID count | +| `box.disk` | string (advanced) | empty | best-effort writable-layer size; no-op on overlay2 / macOS engines | ### VM size (cloud) -| 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.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 | -| `box.daytonaTimeoutMs` | int | `1500000` (25 min) | how long a box may sit idle before it's paused; `0` disables. The host enforces this (and holds the box open while the agent is working): Daytona's own timer is an *inactivity* window that the relay's polling keeps resetting, so it only fires as a backstop when the relay isn't running. See [Daytona → idle boxes](/docs/daytona#idle-boxes-pause-themselves). Daytona-only | -| `box.daytonaVmBaseImage` | string (advanced) | empty | registry image the `linux-vm` base is baked from. Empty uses the box image AgentBox publishes. Set it when there's no published image for your build context — a locally modified `Dockerfile.box` — or to bake from a private mirror. Must be amd64 with an explicit tag. Daytona-only | -| `box.hetznerLocation` | string | `nbg1` | Hetzner datacenter new boxes are created in (`nbg1`, `fsn1`, `hel1`, `ash`); override per box with `--location`. Hetzner-only | -| `box.digitaloceanRegion` | string | `nyc3` | DigitalOcean region new boxes are created in (`nyc3`, `sfo3`, `ams3`, `fra1`, …); override per box with `--location`. DigitalOcean-only | -| `box.remoteDockerHost` | string | empty | default SSH destination whose Docker engine runs new boxes — an `~/.ssh/config` alias or `[user@]host[:port]`. Override per box with `agentbox docker: …` or `--remote-host`. See [Remote Docker](/docs/remote-docker). remote-docker-only | -| `box.digitaloceanProject` | string | empty | the [DigitalOcean Project](/docs/digitalocean#projects) new boxes are placed in — a project name or its UUID. Empty leaves them in the account's default project. Pick it at `agentbox digitalocean login`, or set it per repo in `agentbox.yaml`. DigitalOcean-only | -| `box.inbound` | string | `locked` | inbound-access policy for the VPS per-box firewall. `locked` = SSH from your host egress IP only; `open` = SSH from anywhere (`0.0.0.0/0`, key-only — reach a box from a phone with the laptop off); a CIDR list (e.g. `203.0.113.5/32`) = host egress plus those. Override per box with `--inbound` or after create with `agentbox inbound `. Hetzner/DigitalOcean-only. See [remote access](/docs/access-your-box#use-a-box-with-your-laptop-offline) | +| 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.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 | +| `box.daytonaTimeoutMs` | int | `1500000` (25 min) | how long a box may sit idle before it's paused; `0` disables. The host enforces this (and holds the box open while the agent is working): Daytona's own timer is an _inactivity_ window that the relay's polling keeps resetting, so it only fires as a backstop when the relay isn't running. See [Daytona → idle boxes](/docs/daytona#idle-boxes-pause-themselves). Daytona-only | +| `box.daytonaVmBaseImage` | string (advanced) | empty | registry image the `linux-vm` base is baked from. Empty uses the box image AgentBox publishes. Set it when there's no published image for your build context — a locally modified `Dockerfile.box` — or to bake from a private mirror. Must be amd64 with an explicit tag. Daytona-only | +| `box.hetznerLocation` | string | `nbg1` | Hetzner datacenter new boxes are created in (`nbg1`, `fsn1`, `hel1`, `ash`); override per box with `--location`. Hetzner-only | +| `box.digitaloceanRegion` | string | `nyc3` | DigitalOcean region new boxes are created in (`nyc3`, `sfo3`, `ams3`, `fra1`, …); override per box with `--location`. DigitalOcean-only | +| `box.remoteDockerHost` | string | empty | default SSH destination whose Docker engine runs new boxes — an `~/.ssh/config` alias or `[user@]host[:port]`. Override per box with `agentbox docker: …` or `--remote-host`. See [Remote Docker](/docs/remote-docker). remote-docker-only | +| `box.digitaloceanProject` | string | empty | the [DigitalOcean Project](/docs/digitalocean#projects) new boxes are placed in — a project name or its UUID. Empty leaves them in the account's default project. Pick it at `agentbox digitalocean login`, or set it per repo in `agentbox.yaml`. DigitalOcean-only | +| `box.inbound` | string | `locked` | inbound-access policy for the VPS per-box firewall. `locked` = SSH from your host egress IP only; `open` = SSH from anywhere (`0.0.0.0/0`, key-only — reach a box from a phone with the laptop off); a CIDR list (e.g. `203.0.113.5/32`) = host egress plus those. Override per box with `--inbound` or after create with `agentbox inbound `. Hetzner/DigitalOcean-only. See [remote access](/docs/access-your-box#use-a-box-with-your-laptop-offline) | ### Box image -| 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.imageRegistry` | string (advanced, docker only) | `ghcr.io/madarco/agentbox/box` | registry to pull the prebuilt base from before building locally; empty = always build | +| 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.imageRegistry` | string (advanced, docker only) | `ghcr.io/madarco/agentbox/box` | registry to pull the prebuilt base from before building locally; empty = always build | Setting the generic `box.image` to a provider-native snapshot id breaks creates on other providers. Prefer the per-provider `box.image` keys (which is what `prepare` writes). If a stale `box.image` blocks creates, clear it with `agentbox config unset box.image --project`. @@ -151,102 +158,105 @@ Setting the generic `box.image` to a provider-native snapshot id breaks creates ### Default checkpoint per project -| 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 ` | +| 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 ` | See [checkpoints and pausing](/docs/checkpoints-and-pausing). ### Create-time toggles -| Key | Type | Default | Meaning | -| --- | --- | --- | --- | -| `box.hostSnapshot` | bool | prompt | use a frozen APFS clone of the host workspace as overlay lower (renamed from `box.snapshot`) | -| `box.withEnv` | bool | `false` | copy host env/config files (`.env*`, `secrets.toml`, `agentbox.yaml`, …) into `/workspace`, bypassing gitignore | -| `box.withPlaywright` | bool | `false` | install `@playwright/cli@latest` in the box at create | -| `box.claudeInstall` | enum `native`/`npm` | `native` | how `agentbox prepare` installs Claude Code into the base image/snapshot. `npm` (`@anthropic-ai/claude-code`) is a fallback for cloud egress IPs the native installer's CDN 403s; bake-time only, so re-run `prepare` after changing it. Override per-run with `prepare --claude-install ` | -| `box.vnc` | bool | `true` | run the per-box Xvnc + noVNC stack | -| `box.autoApproveSafeHostActions` | bool | `true` | auto-approve the **safe** subset of host actions without a prompt: opening a PR, PR/review comments, re-running CI, pushing to the box's scratch or host-sanctioned branch, checkpoints, integration writes, and file copy/download that stays inside the box project folder (non-secret). Uncontained/secret transfers, non-sanctioned-branch pushes, and PR merge/checkout still prompt. Set `false` to prompt for every host action. Each bypass is logged as a relay event. See [sync & git](/docs/sync-and-git) | -| `box.autoApproveHostActions` | bool | `false` | auto-approve **all** host-action confirms (the superset: git push, cp, gh PR writes incl. merge/checkout, checkpoint) for this box without a prompt; for unattended orchestration of trusted boxes. Each bypass is logged as a relay event. See [orchestration](/docs/background-and-parallel) | -| `box.resyncOnStart` | bool | `true` | on starting a session, merge the host's current branch + overlay changes (box wins on conflict, warns the agent) | -| `box.bundleDepth` | int | adaptive | cap git-bundle history shipped to cloud sandboxes; `0` = full history; ignored for docker | -| `box.dockerCacheShared` | bool | `false` | share the in-box docker image cache across boxes; only one box can run at a time when set | -| `box.credentialSync` | bool | `true` | automatically sync refreshed agent credentials from boxes to the host backup and out to all other running boxes (Claude's OAuth refresh rotates the refresh token, killing every other copy). `--no-credential-sync` at create disables the in-box watcher for that box. See [run an agent](/docs/run-an-agent) | +| Key | Type | Default | Meaning | +| -------------------------------- | ------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `box.hostSnapshot` | bool | prompt | use a frozen APFS clone of the host workspace as overlay lower (renamed from `box.snapshot`) | +| `box.withEnv` | bool | `false` | copy host env/config files (`.env*`, `secrets.toml`, `agentbox.yaml`, …) into `/workspace`, bypassing gitignore | +| `box.withPlaywright` | bool | `false` | install `@playwright/cli@latest` in the box at create | +| `box.claudeInstall` | enum `native`/`npm` | `native` | how `agentbox prepare` installs Claude Code into the base image/snapshot. `npm` (`@anthropic-ai/claude-code`) is a fallback for cloud egress IPs the native installer's CDN 403s; bake-time only, so re-run `prepare` after changing it. Override per-run with `prepare --claude-install ` | +| `box.vnc` | bool | `true` | run the per-box Xvnc + noVNC stack | +| `box.autoApproveSafeHostActions` | bool | `true` | auto-approve the **safe** subset of host actions without a prompt: opening a PR, PR/review comments, re-running CI, pushing to the box's scratch or host-sanctioned branch, checkpoints, integration writes, and file copy/download that stays inside the box project folder (non-secret). Uncontained/secret transfers, non-sanctioned-branch pushes, and PR merge/checkout still prompt. Set `false` to prompt for every host action. Each bypass is logged as a relay event. See [sync & git](/docs/sync-and-git) | +| `box.autoApproveHostActions` | bool | `false` | auto-approve **all** host-action confirms (the superset: git push, cp, gh PR writes incl. merge/checkout, checkpoint) for this box without a prompt; for unattended orchestration of trusted boxes. Each bypass is logged as a relay event. See [orchestration](/docs/background-and-parallel) | +| `box.resyncOnStart` | bool | `true` | on starting a session, merge the host's current branch + overlay changes (box wins on conflict, warns the agent) | +| `box.bundleDepth` | int | adaptive | cap git-bundle history shipped to cloud sandboxes; `0` = full history; ignored for docker | +| `box.dockerCacheShared` | bool | `false` | share the in-box docker image cache across boxes; only one box can run at a time when set | +| `box.credentialSync` | bool | `true` | automatically sync refreshed agent credentials from boxes to the host backup and out to all other running boxes (Claude's OAuth refresh rotates the refresh token, killing every other copy). `--no-credential-sync` at create disables the in-box watcher for that box. See [run an agent](/docs/run-an-agent) | See [teleport a project](/docs/teleport-a-project), [environment](/docs/environment), [browser and screen](/docs/browser-and-screen), [sync and git](/docs/sync-and-git), and [Docker in Docker](/docs/docker-in-docker). ### Config-volume isolation -| Key | Type | Default | Meaning | -| --- | --- | --- | --- | +| Key | Type | Default | Meaning | +| ---------------------------------------------------------------------------------- | ---- | ------- | ------------------------------------------------------------------ | | `box.isolateClaudeConfig` / `box.isolateCodexConfig` / `box.isolateOpencodeConfig` | bool | `false` | give the box its own agent config volume instead of the shared one | ### Vercel only -| 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) | +| 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) | See [Vercel](/docs/vercel). -## checkpoint.* +## checkpoint.\* -| Key | Type | Default | Meaning | -| --- | --- | --- | --- | -| `checkpoint.maxLayers` | int (advanced) | `3` | max stacked checkpoint layers before a new checkpoint is materialized flattened instead of layered | +| Key | Type | Default | Meaning | +| ---------------------- | -------------- | ------- | -------------------------------------------------------------------------------------------------- | +| `checkpoint.maxLayers` | int (advanced) | `3` | max stacked checkpoint layers before a new checkpoint is materialized flattened instead of layered | See [checkpoints and pausing](/docs/checkpoints-and-pausing). ## Agent sessions — claude / codex / opencode -| Key | Type | Default | Meaning | -| --- | --- | --- | --- | -| `claude.sessionName` | string | `claude` | tmux session name for the claude agent | -| `codex.sessionName` | string | `codex` | tmux session name for the codex agent | -| `opencode.sessionName` | string | `opencode` | tmux session name for the opencode agent | -| `claude.dangerouslySkipPermissions` | bool | `true` | launch claude with `--dangerously-skip-permissions` (auto-accept tool use) | -| `codex.dangerouslySkipPermissions` | bool | `true` | launch codex with `--dangerously-bypass-approvals-and-sandbox` | +| Key | Type | Default | Meaning | +| ----------------------------------- | ------ | ---------- | -------------------------------------------------------------------------- | +| `claude.sessionName` | string | `claude` | tmux session name for the claude agent | +| `codex.sessionName` | string | `codex` | tmux session name for the codex agent | +| `opencode.sessionName` | string | `opencode` | tmux session name for the opencode agent | +| `claude.dangerouslySkipPermissions` | bool | `true` | launch claude with `--dangerously-skip-permissions` (auto-accept tool use) | +| `codex.dangerouslySkipPermissions` | bool | `true` | launch codex with `--dangerously-bypass-approvals-and-sandbox` | -The permission-skip defaults are on precisely because each box is a throwaway sandbox the agent can't escape — that's the whole point of AgentBox. Turn them off per-box with `--no-dangerously-skip-permissions` if you want approval prompts. See [core concepts](/docs/core-concepts) and [run an agent](/docs/run-an-agent). + The permission-skip defaults are on precisely because each box is a throwaway sandbox the agent + can't escape — that's the whole point of AgentBox. Turn them off per-box with + `--no-dangerously-skip-permissions` if you want approval prompts. See [core + concepts](/docs/core-concepts) and [run an agent](/docs/run-an-agent). ## attach -| Key | Type | Default | Meaning | -| --- | --- | --- | --- | -| `attach.openIn` | enum `split`/`window`/`tab`/`same` | `split` | where `agentbox claude\|codex\|opencode` opens the attached session under tmux, cmux, Herdr, or iTerm2 | -| `attach.cmuxStatus` | bool | `true` | when attached inside cmux, reflect the box agent's activity on its cmux workspace (colour + description) | -| `attach.herdrStatus` | bool | `true` | when attached inside Herdr, report the box agent's activity to its Herdr pane (so it looks like a normal agent) and highlight AgentBox's own approval prompts | +| Key | Type | Default | Meaning | +| -------------------- | ---------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `attach.openIn` | enum `split`/`window`/`tab`/`same` | `split` | where `agentbox claude\|codex\|opencode` opens the attached session under tmux, cmux, Herdr, or iTerm2 | +| `attach.cmuxStatus` | bool | `true` | when attached inside cmux, reflect the box agent's activity on its cmux workspace (colour + description) | +| `attach.herdrStatus` | bool | `true` | when attached inside Herdr, report the box agent's activity to its Herdr pane (so it looks like a normal agent) and highlight AgentBox's own approval prompts | `split` uses a tmux split-window / cmux new-split / Herdr `pane.split` / iTerm2 vertical split (same workspace). `tab` opens a new tmux window / a new cmux surface (a tab in the current pane, same workspace) / a new Herdr tab / a new iTerm2 tab. `window` opens a new tmux window / a separate cmux workspace / a new Herdr workspace / a new iTerm2 window. Outside tmux/cmux/Herdr/iTerm2 every value behaves like `same`. **Under Herdr the default is a new tab** (rather than a split) — set `attach.openIn` (or pass `--attach-in`) explicitly to override. See [access your box](/docs/access-your-box). -`attach.cmuxStatus` surfaces a box's agent state in the [cmux](https://cmux.com) sidebar. While you're attached inside a cmux surface, AgentBox reflects the agent's live activity on the box's cmux workspace via its colour and description — blue/"working" while the agent runs, amber/"needs input" when it asks a question or is waiting, and the tint clears when idle. The workspace's original colour and description are restored when you detach. When you open several boxes from one project as tabs in the same workspace (`--attach-in tab`), AgentBox also **flags the individual tab** whose agent needs input via a cmux notification (tab badge + reorder + a desktop notification), so you can tell which box is waiting; the flag clears when you focus that tab. (cmux only renders its status *pills* for workspaces running an agent it recognizes; a box runs the agent inside the container, so AgentBox drives the always-visible workspace colour/description and the per-tab highlight instead.) No effect outside cmux; set to `false` to disable. +`attach.cmuxStatus` surfaces a box's agent state in the [cmux](https://cmux.com) sidebar. While you're attached inside a cmux surface, AgentBox reflects the agent's live activity on the box's cmux workspace via its colour and description — blue/"working" while the agent runs, amber/"needs input" when it asks a question or is waiting, and the tint clears when idle. The workspace's original colour and description are restored when you detach. When you open several boxes from one project as tabs in the same workspace (`--attach-in tab`), AgentBox also **flags the individual tab** whose agent needs input via a cmux notification (tab badge + reorder + a desktop notification), so you can tell which box is waiting; the flag clears when you focus that tab. (cmux only renders its status _pills_ for workspaces running an agent it recognizes; a box runs the agent inside the container, so AgentBox drives the always-visible workspace colour/description and the per-tab highlight instead.) No effect outside cmux; set to `false` to disable. -`attach.herdrStatus` makes a box look like a *normal* agent inside [Herdr](https://herdr.dev). While you're attached inside a Herdr pane, AgentBox reports the box agent's live activity to that pane (working / blocked / idle), so Herdr applies its native agent treatment — **including its own needs-input handling**. The one thing Herdr can't see is AgentBox's own host-relay **approval prompts** (git push, PR, checkpoint, …), so those get an explicit Herdr notification. No effect outside Herdr; set to `false` to disable. See [Herdr integration](/docs/integrations-herdr). +`attach.herdrStatus` makes a box look like a _normal_ agent inside [Herdr](https://herdr.dev). While you're attached inside a Herdr pane, AgentBox reports the box agent's live activity to that pane (working / blocked / idle), so Herdr applies its native agent treatment — **including its own needs-input handling**. The one thing Herdr can't see is AgentBox's own host-relay **approval prompts** (git push, PR, checkpoint, …), so those get an explicit Herdr notification. No effect outside Herdr; set to `false` to disable. See [Herdr integration](/docs/integrations-herdr). ## code / shell -| Key | Type | Default | Meaning | -| --- | --- | --- | --- | -| `code.ide` | enum `vscode`/`cursor`/`auto` | `auto` | which IDE `agentbox code` launches; `auto` prefers `code`, falls back to `cursor` | -| `code.wait` | bool | `true` | block on `agentbox-ctl wait-ready` before opening the IDE | -| `code.timeoutMs` | int | `120000` | wait-ready timeout | -| `code.autoTerminals` | bool | `true` | generate `/workspace/.vscode/tasks.json` so the IDE auto-opens log panels | -| `shell.user` | string | `vscode` | default in-container user for `agentbox shell` | -| `shell.login` | bool | `true` | pass `-l` to bash (load login profile) | -| `shell.tmux` | bool | `true` | run inside a detachable tmux session (Ctrl+a d to detach) | +| Key | Type | Default | Meaning | +| -------------------- | ----------------------------- | -------- | --------------------------------------------------------------------------------- | +| `code.ide` | enum `vscode`/`cursor`/`auto` | `auto` | which IDE `agentbox code` launches; `auto` prefers `code`, falls back to `cursor` | +| `code.wait` | bool | `true` | block on `agentbox-ctl wait-ready` before opening the IDE | +| `code.timeoutMs` | int | `120000` | wait-ready timeout | +| `code.autoTerminals` | bool | `true` | generate `/workspace/.vscode/tasks.json` so the IDE auto-opens log panels | +| `shell.user` | string | `vscode` | default in-container user for `agentbox shell` | +| `shell.login` | bool | `true` | pass `-l` to bash (load login profile) | +| `shell.tmux` | bool | `true` | run inside a detachable tmux session (Ctrl+a d to detach) | See [access your box](/docs/access-your-box). ## ssh -| Key | Type | Default | Meaning | -| --- | --- | --- | --- | -| `ssh.autoConfig` | bool | `true` | auto-maintain `~/.agentbox/ssh/config` (Include'd from `~/.ssh/config`) for SSH-capable cloud boxes | +| Key | Type | Default | Meaning | +| ---------------- | ---- | ------- | --------------------------------------------------------------------------------------------------- | +| `ssh.autoConfig` | bool | `true` | auto-maintain `~/.agentbox/ssh/config` (Include'd from `~/.ssh/config`) for SSH-capable cloud boxes | For providers that reach a box over plain SSH with a persistent per-box key (**Hetzner**, and DigitalOcean once wired), AgentBox writes one `Host ` @@ -269,8 +279,8 @@ alias. ## browser -| Key | Type | Default | Meaning | -| --- | --- | --- | --- | +| Key | Type | Default | Meaning | +| ----------------- | ---------------------------------------- | --------------- | ------------------------------------------------------------------------------------- | | `browser.default` | enum `agent-browser`/`playwright`/`both` | `agent-browser` | default browser stack in the box; `playwright` or `both` implies `box.withPlaywright` | See [browser and screen](/docs/browser-and-screen). @@ -279,9 +289,9 @@ See [browser and screen](/docs/browser-and-screen). Per-service toggles for relay-gated service integrations. Each integration is **disabled by default** — even when the host CLI is installed and authed, the box can't call out until you flip it on. The box never holds the service's token; reads pass through, writes prompt on the host. See [Notion](/docs/integrations-notion) and [Linear](/docs/integrations-linear). -| Key | Type | Default | Meaning | -| --- | --- | --- | --- | -| `integrations.notion.enabled` | bool | `false` | proxy `ntn` calls from the box through the host relay; reads pass, writes prompt | +| Key | Type | Default | Meaning | +| ----------------------------- | ---- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `integrations.notion.enabled` | bool | `false` | proxy `ntn` calls from the box through the host relay; reads pass, writes prompt | | `integrations.linear.enabled` | bool | `false` | proxy `linear` calls (`@schpet/linear-cli`) from the box through the host relay; reads pass, writes prompt; `auth token` is hard-rejected | ```bash @@ -295,16 +305,16 @@ agentbox config set --project integrations.linear.enabled true `queue.*` schedules background `-i` jobs; `autopause.*` pauses idle boxes. -| Key | Type | Default | Meaning | -| --- | --- | --- | --- | -| `queue.enabled` | bool | `true` | run `agentbox claude\|codex\|opencode -i ` jobs through the host-wide FIFO queue | -| `queue.maxConcurrent` | int | `5` | max simultaneously-running boxes before `-i` jobs queue; override with `--max-running ` | -| `queue.maxWorking` | int | `0` (off) | max agents actively working at once before `-i` jobs queue; override with `--max-working ` | -| `queue.idleGraceSeconds` | int | `15` | debounce before an agent frees its working slot; only used when `maxWorking > 0` | -| `queue.openIn` | enum `none`/`split`/`window`/`tab` | `none` | when a background `-i` job's box becomes ready, open an attached terminal onto it (a tmux/cmux/Herdr split, window, or tab); `none` opens nothing | -| `autopause.enabled` | bool | `true` | let the relay periodically pause idle boxes when more than `maxRunningBoxes` run | -| `autopause.maxRunningBoxes` | int | `5` | target ceiling of running boxes before idle ones get paused | -| `autopause.idleMinutes` | int | `5` | minutes a box must be continuously idle before it's eligible for auto-pause | +| Key | Type | Default | Meaning | +| --------------------------- | ---------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `queue.enabled` | bool | `true` | run `agentbox claude\|codex\|opencode -i ` jobs through the host-wide FIFO queue | +| `queue.maxConcurrent` | int | `5` | max simultaneously-running boxes before `-i` jobs queue; override with `--max-running ` | +| `queue.maxWorking` | int | `0` (off) | max agents actively working at once before `-i` jobs queue; override with `--max-working ` | +| `queue.idleGraceSeconds` | int | `15` | debounce before an agent frees its working slot; only used when `maxWorking > 0` | +| `queue.openIn` | enum `none`/`split`/`window`/`tab` | `none` | when a background `-i` job's box becomes ready, open an attached terminal onto it (a tmux/cmux/Herdr split, window, or tab); `none` opens nothing | +| `autopause.enabled` | bool | `true` | let the relay periodically pause idle boxes when more than `maxRunningBoxes` run | +| `autopause.maxRunningBoxes` | int | `5` | target ceiling of running boxes before idle ones get paused | +| `autopause.idleMinutes` | int | `5` | minutes a box must be continuously idle before it's eligible for auto-pause | ```bash agentbox config set queue.maxConcurrent 3 --global @@ -315,7 +325,11 @@ agentbox config set queue.openIn split --global By default a background `-i` run just queues and prints its job line. Set `queue.openIn` to `split`, `window`, or `tab` and the host relay opens an attached terminal onto the box the moment its worker finishes creating it — no need to find the box and `attach` by hand. It only fires when the submitting shell is inside tmux, cmux, Herdr, or iTerm2. Under cmux, `split` splits the pane you submitted from (falling back to the parent workspace, then a new workspace), `tab` adds a tab in the parent workspace, and `window` opens a separate workspace; under Herdr, `split` splits the pane you submitted from, `tab` adds a tab in the parent workspace, and `window` opens a separate workspace; iTerm2 opens relative to the frontmost window. Unlike `attach.openIn` there is no `same` mode (the box is created asynchronously, so it is always a fresh terminal). -The box is opened by the relay's queue **worker**, a detached host process — not a cmux-initiated one. cmux's default `socketControlMode: cmuxOnly` only trusts processes cmux itself started, so it blocks the worker and nothing opens. To use `queue.openIn` under cmux, set `socketControlMode` to `automation` (or `password`) in `~/.config/cmux/cmux.json` and run `cmux reload-config`. tmux and iTerm2 need no such change. + The box is opened by the relay's queue **worker**, a detached host process — not a cmux-initiated + one. cmux's default `socketControlMode: cmuxOnly` only trusts processes cmux itself started, so it + blocks the worker and nothing opens. To use `queue.openIn` under cmux, set `socketControlMode` to + `automation` (or `password`) in `~/.config/cmux/cmux.json` and run `cmux reload-config`. tmux and + iTerm2 need no such change. Auto-pause only ever targets a box whose live agent has settled to **idle** for `idleMinutes`. A box where **any** agent (Claude, Codex, or OpenCode) is working, compacting, or waiting on you is never auto-paused. If a box does get paused while you still need it, `agentbox drive …`, `agentbox shell`, and `agentbox unpause` all resume it on demand (drive auto-unpauses before it reaches the session). @@ -326,23 +340,24 @@ See [background and parallel](/docs/background-and-parallel) and [checkpoints an The remaining infrastructure knobs — mostly advanced. -| Key | Type | Default | Meaning | -| --- | --- | --- | --- | -| `cloud.useCurrentBranch` | bool | `false` | on daytona/hetzner, start boxes on the host's current branch instead of forking `agentbox/`; overridden by `--use-branch` / `--from-branch` | -| `cloud.viaHub` | bool | `true` | when a control box is configured (`relay.controlPlaneUrl`), build **cloud** boxes on it by default (so they keep running with the laptop off) instead of on this machine; overridden per-command by `--via-hub` / `--local`. Docker and remote-docker always build locally. Cloud **base bakes** (`agentbox prepare`) follow the same routing, and this machine's hub UI mirrors the control box's provider state. See [deployed hub](/docs/deployed-hub) | -| `engine.kind` | enum `orbstack`/`docker-desktop`/`other`/`auto` | `auto` | override docker-engine auto-detection; the one key applied at CLI startup | -| `portless.enabled` | bool | prompt | map each box web app to `https://.localhost` via the Portless proxy (Docker Desktop only) | -| `portless.stateDir` | string (advanced) | empty | host Portless state dir to share into boxes | -| `relay.port` | int (advanced) | `8787` | host relay TCP port | -| `relay.controlPlaneUrl` | string | empty | URL of a deployed control plane; cloud boxes point at it for git-token leasing, permission state, and the registry/events so they keep working with the laptop off. Set via `agentbox hub set-url` | -| `hub.gitAuth` | enum `gh`/`app` | `gh` | which git credential a deployed [control box](/docs/deployed-hub) uses: `gh` (the hub holds a GitHub token taken from your own `gh` login and does the git work itself, so boxes never receive a credential and nothing needs installing on GitHub) or `app` (the hub holds a GitHub App key and leases a 1-hour, single-repo token to each box — tighter, but the repo owner must install the App). Deploy intent: it selects what `agentbox hub setup` / `hub deploy` provisions and which push mode a cloud box gets; it can't reconfigure a hub that's already running | -| `hub.mode` | enum `auto`/`thin`/`local` | `auto` | whether the local docker engine is offered on this machine. `auto` gates `docker`/`remote-docker` off once a [control box](/docs/deployed-hub) is configured (`relay.controlPlaneUrl`) — a docker box built on your laptop can't run with the laptop off, so `create --provider docker` is refused, docker rows drop out of the provider pickers / `doctor` / `prepare`, and docker boxes show as inactive in `ls`. `thin` forces that even with no control box; `local` keeps docker on regardless (the escape hatch every "docker is hidden here" message names) | -| `relay.custodyMaxBodyBytes` | int (advanced) | `33554432` (32 MiB) | per-request body cap for custody uploads. Custody carries a project's untracked-files seed tar, which the relay's 1 MiB body cap is too small for; this applies only to custody, so every other route keeps the smaller cap. On your machine it governs how large a seed blob the client uploads; a control box enforces its own cap via `AGENTBOX_CUSTODY_MAX_BODY_BYTES`, so raise **both** to admit a bigger seed (a blob the control box refuses is dropped, and the rest of the seed still pushes) | -| `vnc.containerPort` | int (advanced) | `6080` | container-side noVNC port | -| `maintenance.pruneProjectConfigs` | bool | `true` | periodically delete `~/.agentbox/projects//` dirs whose source folder is gone | -| `maintenance.pruneProjectConfigsEvery` | int | `50` | run the orphan sweep every N successful `agentbox create` | -| `update.check` | bool | `true` | daily background check for a newer published CLI (npm) and menu-bar app (release checksum), plus the "newer version available" nudge; at most one network probe per 24h — `false` disables both | -| `update.channel` | enum | `auto` | release channel `self-update` and `install app` follow: `auto` follows the installed build, `nightly` opts into [pre-release builds](/docs/nightly), `stable` opts back out. Nightly installs the newest build of *either* channel, so a stable release supersedes the nightlies before it | +| Key | Type | Default | Meaning | +| -------------------------------------- | ----------------------------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cloud.useCurrentBranch` | bool | `false` | on daytona/hetzner, start boxes on the host's current branch instead of forking `agentbox/`; overridden by `--use-branch` / `--from-branch` | +| `cloud.viaHub` | bool | `true` | when a control box is configured (`relay.controlPlaneUrl`), build **cloud** boxes on it by default (so they keep running with the laptop off) instead of on this machine; overridden per-command by `--via-hub` / `--local`. Docker and remote-docker always build locally. Cloud **base bakes** (`agentbox prepare`) follow the same routing, and this machine's hub UI mirrors the control box's provider state. See [deployed hub](/docs/deployed-hub) | +| `engine.kind` | enum `orbstack`/`docker-desktop`/`other`/`auto` | `auto` | override docker-engine auto-detection; the one key applied at CLI startup | +| `portless.enabled` | bool | prompt | map each box web app to `https://.localhost` via the Portless proxy (Docker Desktop only) | +| `portless.stateDir` | string (advanced) | empty | host Portless state dir to share into boxes | +| `relay.port` | int (advanced) | `8787` | host relay TCP port | +| `relay.controlPlaneUrl` | string | empty | URL of a deployed control plane; cloud boxes point at it for git-token leasing, permission state, and the registry/events so they keep working with the laptop off. Set via `agentbox hub set-url` | +| `hub.gitAuth` | enum `gh`/`app` | `gh` | which git credential a deployed [control box](/docs/deployed-hub) uses: `gh` (the hub holds a GitHub token taken from your own `gh` login and does the git work itself, so boxes never receive a credential and nothing needs installing on GitHub) or `app` (the hub holds a GitHub App key and leases a 1-hour, single-repo token to each box — tighter, but the repo owner must install the App). Deploy intent: it selects what `agentbox hub setup` / `hub deploy` provisions and which push mode a cloud box gets; it can't reconfigure a hub that's already running | +| `git.pushMode` | enum `auto`/`relay`/`lease`/`direct` | `auto` | how a **cloud** box's `git push` reaches GitHub (docker always uses `relay`): `relay` (the host relay pushes with your host credentials — they never enter the box), `lease` (the relay/plane leases a short-lived GitHub-App token and the box pushes directly, so it works with the laptop off), `direct` (the box holds a **copy** of your git credentials and pushes on its own — set via `--with-credentials`), or `auto` (lease when `relay.controlPlaneUrl` is set, else relay). `direct` is **refused when a control box is configured** — use leasing (the `auto` default) instead, which does the same job without copying the credential into the box and its snapshots | +| `hub.mode` | enum `auto`/`thin`/`local` | `auto` | whether the local docker engine is offered on this machine. `auto` gates `docker`/`remote-docker` off once a [control box](/docs/deployed-hub) is configured (`relay.controlPlaneUrl`) — a docker box built on your laptop can't run with the laptop off, so `create --provider docker` is refused, docker rows drop out of the provider pickers / `doctor` / `prepare`, and docker boxes show as inactive in `ls`. `thin` forces that even with no control box; `local` keeps docker on regardless (the escape hatch every "docker is hidden here" message names) | +| `relay.custodyMaxBodyBytes` | int (advanced) | `33554432` (32 MiB) | per-request body cap for custody uploads. Custody carries a project's untracked-files seed tar, which the relay's 1 MiB body cap is too small for; this applies only to custody, so every other route keeps the smaller cap. On your machine it governs how large a seed blob the client uploads; a control box enforces its own cap via `AGENTBOX_CUSTODY_MAX_BODY_BYTES`, so raise **both** to admit a bigger seed (a blob the control box refuses is dropped, and the rest of the seed still pushes) | +| `vnc.containerPort` | int (advanced) | `6080` | container-side noVNC port | +| `maintenance.pruneProjectConfigs` | bool | `true` | periodically delete `~/.agentbox/projects//` dirs whose source folder is gone | +| `maintenance.pruneProjectConfigsEvery` | int | `50` | run the orphan sweep every N successful `agentbox create` | +| `update.check` | bool | `true` | daily background check for a newer published CLI (npm) and menu-bar app (release checksum), plus the "newer version available" nudge; at most one network probe per 24h — `false` disables both | +| `update.channel` | enum | `auto` | release channel `self-update` and `install app` follow: `auto` follows the installed build, `nightly` opts into [pre-release builds](/docs/nightly), `stable` opts back out. Nightly installs the newest build of _either_ channel, so a stable release supersedes the nightlies before it | See [web apps and tunnels](/docs/web-apps-and-tunnels), [local Docker](/docs/local-docker), [sync and git](/docs/sync-and-git), and [browser and screen](/docs/browser-and-screen). diff --git a/apps/web/content/docs/deployed-hub.mdx b/apps/web/content/docs/deployed-hub.mdx index 28d1da02..d73303a4 100644 --- a/apps/web/content/docs/deployed-hub.mdx +++ b/apps/web/content/docs/deployed-hub.mdx @@ -18,12 +18,13 @@ It's the same `@agentbox/relay` core you already run locally — the relay daemo **docker** box bind-mounts your host `.git`, so it's simply offline when the laptop is — it stays on your laptop relay and is never sent to the control box. - Because a laptop-built docker box defeats the point of a control box, once one is configured - `docker` and `remote-docker` are **gated off** by default (`hub.mode=auto`): `create --provider +Because a laptop-built docker box defeats the point of a control box, once one is configured +`docker` and `remote-docker` are **gated off** by default (`hub.mode=auto`): `create --provider docker` is refused, docker drops out of the provider pickers / `doctor` / `prepare`, and any docker - boxes you already have show as **inactive** in `agentbox ls` (still there, so you can `destroy` - them by name). Set [`hub.mode=local`](/docs/configuration) to keep using docker on this machine - anyway. +boxes you already have show as **inactive** in `agentbox ls` (still there, so you can `destroy` +them by name). Set [`hub.mode=local`](/docs/configuration) to keep using docker on this machine +anyway. + ## Two ways to get one @@ -394,27 +395,25 @@ The box leases a 1-hour, single-repo token from the control box and pushes to Gi With a control box configured, your laptop becomes a **thin client**: the control box is the source of truth for cloud boxes, and your local state is a cache of the ones you actually drive. Boxes created either way are operable from either side. -`agentbox ls` asks the control box and merges the answer with your local boxes, so a box created from the web UI shows up alongside your own: +`agentbox ls` reads the control box's `GET /api/v1/boxes` — a single listing, no client-side merge — so a box created from the web UI shows up alongside your own with its real state: ```bash agentbox ls -g ``` ``` -N NAME STATE AGENT PROVIDER URL -1 fix-login running claude docker … -- from-web-ui on hub - e2b … +N NAME STATE AGENT SHELLS PROVIDER URL +1 fix-login running claude 1 docker … +- from-web-ui running - - e2b … ``` -A row marked `on hub` exists on the control box but hasn't been **adopted** here yet — nothing about it is local. Adoption happens automatically the first time you use it by name: +Every row comes from the hub, whether or not you've driven that box before. The first time you use a control-box box **by name**, the CLI **adopts** it — writes a local box record and downloads its per-box SSH key from custody — so the direct IO commands (`attach`, `cp`, `download`, `url`, `screen`) can reach it from your machine: ```bash -agentbox attach from-web-ui # adopts it, then attaches +agentbox attach from-web-ui # adopts it on first use, then attaches ``` -`agentbox dashboard` lists these rows too; selecting one adopts it, then behaves like any other box. - -Adoption writes the local box record from the control box's registration and downloads the box's per-box SSH key from custody, so `attach`, `cp`, `download`, `url`, and `screen` then work exactly as for a box you created here. If the box's repo is also cloned on your laptop, adoption links it to that clone, so it appears in the project-scoped `agentbox ls` too, and its `git push` targets your local repo. +`agentbox dashboard` lists these rows too; selecting one adopts it, then behaves like any other box. Adoption is what keeps the **direct IO plane** working from your laptop even though every _box/fleet_ operation goes through the hub (see [what still needs your laptop](#what-still-needs-your-laptop)). If the box's repo is also cloned on your laptop, adoption links it to that clone, so it appears in the project-scoped `agentbox ls` too, and its `git push` targets your local repo. `agentbox git push --host-only` lands the box's branch in the **host repo of the machine @@ -439,9 +438,25 @@ agentbox hub adopt [`url`](/docs/web-apps-and-tunnels) is a public HTTPS domain that works from anywhere regardless. -If a cloud box is in your local state but the control box has never heard of it, `ls` marks it `orphan` — usually it was destroyed from the web UI, and the leftover record is shown rather than silently dropped. +When the control box is unreachable, `ls` renders its last known boxes from a local cache and says so. + +## What still needs your laptop + +Every **box and fleet operation** goes through the hub's `/api/v1`: `create`, lifecycle (start/stop/pause/unpause/destroy), listing, git, approvals, services, rename, checkpoints, prune, and custody. Enabling a control box is a base-URL swap — the same client code, the same routes, against a local hub or the remote one. -When the control box is unreachable, `ls` renders its last known hub boxes from a local cache and says so; your own local boxes are unaffected. +What deliberately stays on your machine, and why: + +- **The direct IO plane.** `shell`, `attach`, `cp`, `download`, `code`, `open`, `url`, and `screen` talk to the box **from your laptop**, not through the hub. Moving them behind the hub (a uniform tunnel with hub-side SSH termination) is future work, explicitly out of scope for now. So these commands need your laptop up and reachable to the box, and `cp`/`download` between host and box are meaningless with the laptop off (see the callout above). +- **Local adoption.** Because the IO plane is direct, the CLI still materializes a local box record (and pulls per-box SSH keys) so those commands can resolve a box. Adoption is re-sourced from `/api/v1` — it is a cache of the hub's truth, not a second source. +- **`secrets.env` on both machines.** Your laptop needs the provider credentials to do direct SDK IO for **e2b / vercel / daytona** (`cp`, `attach`, `url`). So a provider login writes `~/.agentbox/secrets.env` **and** pushes the credential to the control box. This dual copy is intentional and temporary — it goes away when the IO plane moves behind the hub. +- **The agent launchers' local foreground create.** `agentbox claude`/`codex`/`opencode` with **no** control box (or with a project checked out here) still build the box **inline** on your machine, then attach. `agentbox create` and the launchers' `--via-hub`/control-box path already go through `POST /api/v1/boxes`; only this one create-then-attach path stays inline, because converting it means moving the create + attach boundary behind the hub — the same out-of-scope IO plane. Everything else the launchers do (queued `-i` runs, cloud creates) is on `/api/v1`. + + + When a box's host action parks for approval on a control box, it waits **indefinitely** — there is + no TTL. A `git push` (or any parked action) that nobody ever answers blocks that box silently + until someone answers it from the web UI, the tray, or `agentbox hub approvals answer`. Per-box + auto-approval (`box.autoApproveHostActions`) is the opt-in for a box you want to run unattended. + ## Adding a project so the control box can build it diff --git a/docs/architecture.md b/docs/architecture.md index 921361e5..f67756c1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -256,6 +256,17 @@ The same `@agentbox/relay` core also ships as a **hosted control plane** — a N - **Custody** (control-box copies of what boxes need: agent creds, per-project secrets, per-box SSH keys). A `CustodyStore` seam (`packages/relay/src/custody/`) with a filesystem backend (`FsCustodyStore`, `~/.agentbox/hub/custody/`, `0700`/`0600`) is exposed over `PUT/GET/DELETE /admin/custody//` plus `GET /admin/custody[?prefix=]` (the manifest). The routes are a shared dispatcher (`custody/routes.ts`) mounted in **both** the hosted-plane handler and the relay daemon; unlike the other `/admin/*` routes they are **admin-bearer-gated, not loopback-gated** (a control box behind Caddy makes every request look loopback), and fail closed (503 unconfigured / 401 wrong token). Scopes: `agents//…` (registry-driven from `AGENT_SYNC_SPECS`), `projects//…`, `boxes//ssh/…`. Uploads are hash-skipped (sha256, never timestamps); values are never logged. CLI: `agentbox hub {credentials push|pull, secrets push, custody pull|list|rm}`, plus an opportunistic hash-skipped push from `credentials propagate`. No box-token access — the gate stays at the host/hub boundary. (Plan: [`hub-testing.md`](./hub-testing.md) phase 2.) - **The PC operates through the control box** (phase 4). When `relay.controlPlaneUrl` is set, the laptop reads the box registry / statuses / approvals from the control box's admin API (docker boxes stay strictly local on the loopback relay). `agentbox relay status` shows + probes the control box; the local hub topbar links to it (`HubState.controlPlane`). CLI: `hub boxes list|rm` (`rm` = `DELETE /remote/boxes/:id` → forget + deleteStatus + custody `boxes//` cleanup), `hub approvals list|answer` (over `/admin/prompts` + `/admin/prompts/answer`, now admin-bearer-reachable non-loopback), and `agentbox hub pull ` (download a hub-created box's SSH key to `~/.agentbox/boxes//ssh/` so attach/cp/port-forward work). A host-side `git.push`/`git.fetch` against a worker-created box (its seed clone is gone) is rejected with a clear error rather than a cryptic `git -C` failure. (Plan: [`hub-testing.md`](./hub-testing.md) phase 4.) +#### End state: one path through `/api/v1` (2026-07) + +The bullets above describe the phased build-out, in which the PC drove the control box over the internal `/admin/*` + `/remote/boxes` wire while a local hub was a *second client* of the same `~/.agentbox` state — two implementations of the same operations that had already drifted. The **thin-CLI consolidation** ([`hub-api-single-path-plan.md`](./hub-api-single-path-plan.md)) collapsed that: the CLI now drives **every box and fleet operation through the hub's public `/api/v1`**, against a local hub and a remote control box alike (same client, same routes — "enable a remote hub" is a base-URL swap). `apps/hub/lib/hub-backend.ts` is the one implementation; the CLI keeps no inline provider code for a converted command. Converted: `create`, lifecycle (start/stop/pause/unpause/destroy), listing + box resolution, git (push/pull/checkout/branch/host-only), approvals, services/rename/url/screen, checkpoints, prune, agent state, logs, providers (login/prepare), and custody. The internal `/admin/*` + `/remote/*` surface remains, but only for **box→hub** and hub-internal traffic — a guard test (`apps/cli/test/no-internal-wire-client.test.ts`) fails if `apps/cli` reintroduces a client call to it. + +Four deliberate exceptions the reader should know are **not** on `/api/v1`: + +- **The IO plane stays direct.** `shell`, `attach`, `cp`, `download*`, `code`, `open`, `url`, `screen`, `drive` talk to the box from the laptop. So **local adoption stays** (the CLI materializes a local `BoxRecord`, re-sourced from `/api/v1`) and **`secrets.env` stays on both machines** (direct SDK IO for e2b/vercel/daytona needs the provider credential on the PC). Moving this behind a hub-side tunnel is future work. +- **The three agent launchers' local foreground create.** `claude`/`codex`/`opencode` still build the box **inline** via `createBox` on their foreground create-then-attach path (so `control-plane/route-create.ts` survives); `agentbox create` and the launchers' `--via-hub`/control-box path already go through `POST /api/v1/boxes`. Finishing it means deciding the create+attach IO boundary — coupled to the out-of-scope IO plane above. +- **One custody `/admin` fallback is allowlisted.** `CustodyClient` speaks `/api/v1/custody` when it holds the hub API key; a machine that ran `hub setup` but has no API key (a via-hub-create host) falls back to `/admin/custody` so it can still pull per-box SSH keys — without it, `attach`/`cp` break on hetzner/DO after a via-hub create. The guard test allowlists exactly that one path. +- **The localhost hub binds `0.0.0.0`** (docker boxes reach the embedded relay at `host.docker.internal:8787`). The custody byte-read is therefore **loopback peer-gated** (`custody-auth.ts` + `apps/hub/lib/peer.ts`): the gate depends on `server.ts` owning the socket and stripping any client-supplied copy of the trust header. And a **parked approval has no TTL** — on a control box a prompt nobody answers blocks that box indefinitely. + ## What works today ### What works today diff --git a/docs/cloud-providers.md b/docs/cloud-providers.md index b2024ed9..2a15cc84 100644 --- a/docs/cloud-providers.md +++ b/docs/cloud-providers.md @@ -1055,6 +1055,35 @@ General settings and the project id in the project's General settings. `.env`/`.env.local` are never harvested. First-time use of `--provider e2b` triggers the login prompt automatically. +## 4b. Custody, adoption and the thin-client laptop + +With a control box configured, cloud-box **management** runs on the control box +(create, lifecycle, checkpoints, prune, git leasing, approvals, status) while the +**direct IO plane** stays on the laptop. That split is bridged by two things: + +- **Custody** — the control box holds, metadata-addressed, what a box needs from + either side: agent credentials, per-project seed material (`.env` + untracked), + provider bake records, and **per-box SSH private keys** (hetzner/DigitalOcean). + It is served under `/api/v1/custody` with a strict **two-tier** contract: + `list` / `PUT` / `DELETE` authorize with the hub API key and their responses are + **metadata only** (path/size/sha256/mode/mtime — never a value); the byte-read + `GET /api/v1/custody/` is the **one** value-returning route and is + *elevated* — on a control box (password profile) it additionally requires the + admin token, so the widely-distributed API key alone can never read a secret. On + a plain localhost hub the hub token suffices but the byte-read is + **loopback-peer-gated** (the localhost hub binds `0.0.0.0` for docker boxes, so a + non-loopback byte-read is refused even with a valid token — custody bytes must not + cross the network). The gate is fail-closed and depends on the custom server + owning the socket and stripping any client-supplied copy of the trust header. +- **Adoption** — because the IO plane is direct, the laptop materializes a local + `BoxRecord` for a cloud box it wants to drive, re-sourced from `GET /api/v1/boxes` + (the payload carries every non-secret reconstruction field; tokens are re-minted + host-side) and pulling the box's per-box SSH key from custody. A thin client with + the API key but no admin token adopts the record and works over the SDK + (e2b/vercel/daytona) or flags `sshKeysMissing` for an SSH provider rather than + failing opaquely later. This is why **`secrets.env` stays on the laptop too**: the + provider credential is needed for that direct SDK IO. + ## 5. Known caveats - **Destroy lag in the Daytona dashboard**: `sb.delete()` returns immediately @@ -1081,6 +1110,19 @@ Every command below honors `box.provider` automatically. Pass `--provider ` on `create` / `claude` / `codex` / `opencode` to override per invocation. +> **Routing (2026-07).** For every **box/fleet** command in the table below — +> `create`, lifecycle, `checkpoint`, `prune`, `list`, git, services, approvals — +> the CLI no longer calls the provider inline; it goes through the hub's +> `POST/GET /api/v1/...` and the **hub's backend** invokes the `provider.*` call +> named in the "cloud path" column, against a local hub or a remote control box +> alike (the thin-CLI consolidation — +> [`hub-api-single-path-plan.md`](../docs/hub-api-single-path-plan.md)). The +> **direct IO** commands (`shell`, `cp`/`download`, `url`, `screen`, `code`, +> `open`, `attach`) still call the provider from the laptop — that is why a cloud +> box the control box created is **adopted** locally (a `BoxRecord` re-sourced from +> `GET /api/v1/boxes`, plus a per-box SSH-key pull from custody) before those work. +> See §4b. + | Command | Cloud path | | --- | --- | | `create` | `provider.create` (workspace seed + ctl + dockerd + VNC + agent volumes). | diff --git a/docs/create-and-checkpoints.md b/docs/create-and-checkpoints.md index 377c7d2d..371276f2 100644 --- a/docs/create-and-checkpoints.md +++ b/docs/create-and-checkpoints.md @@ -8,6 +8,19 @@ capture/restore mechanics, with code pointers. Source of truth: ## `agentbox create` — files and git +> **Where this runs (2026-07).** `agentbox create` no longer drives the provider +> inline — it goes through the hub's `POST /api/v1/boxes` and streams +> `GET /api/v1/jobs/:id/logs`, against a local hub or a remote control box alike +> (the thin-CLI consolidation — [`hub-api-single-path-plan.md`](./hub-api-single-path-plan.md)). +> The hub's backend keeps the fork that already existed: a resolvable **local** +> workspace goes to the file queue (`_run-queued-job`), a **no-workspace** project +> goes to the control-plane clone queue (`create-worker.ts`), both returning +> `202 { jobId }`. The file/git mechanics below are exactly what that worker +> executes for a docker box — they moved *behind* the API, they did not change. +> (The one create path still inline is the agent launchers' `claude`/`codex`/ +> `opencode` **local foreground** create — see the exceptions in +> [`architecture.md`](./architecture.md) → "End state: one path through `/api/v1`".) + ### Git: in-container worktree against a bind-mounted `.git` 1. **Detect repos** — `detectGitRepos(workspace)` (`git-worktree.ts`) scans for @@ -88,6 +101,14 @@ Purpose: let a new box start warm (deps installed, project built) instead of cold, without baking anything into the base image. Code: `checkpoint.ts` (capture + resolve) and the restore path in `create.ts`. +> **Where this runs (2026-07).** Like `create`, the CLI's checkpoint commands go +> through the hub: `agentbox checkpoint create` → `POST /api/v1/boxes/:id/checkpoint`, +> `checkpoint ls`/`rm` → `GET|DELETE /api/v1/checkpoints`, and `agentbox prune` → +> `POST /api/v1/prune` (which always leaves checkpoint images intact). Box-scoped +> ops route to the box's **owning** hub; the project checkpoint store is keyed by the +> absolute project root, so listing/removing resolves on the machine that holds it. +> The `docker commit` mechanics below are what the hub backend runs behind those routes. + ### Storage model - **One Docker image *tag* per checkpoint**: diff --git a/docs/hub-api-single-path-plan.md b/docs/hub-api-single-path-plan.md index 58fc573a..7498d8b0 100644 --- a/docs/hub-api-single-path-plan.md +++ b/docs/hub-api-single-path-plan.md @@ -1403,7 +1403,7 @@ fires from `git.pushMode=direct` set via **config** (no flag); `--via-hub --- -## Step 14 — Docs and tray +## Step 14 — Docs and tray ✅ done (tray = documented follow-up) - Update `apps/web/content/docs/{api,deployed-hub,configuration,cli}.mdx` and `docs/{architecture,cloud-providers,hub-testing,create-and-checkpoints}.md` as each step lands @@ -1415,6 +1415,46 @@ fires from `git.pushMode=direct` set via **config** (no flag); `--via-hub - Fix the stale claim in `CLAUDE.md` that `/api/events` is cookie-only — Bearer works (`apps/hub/proxy.ts:20,76`). +**Landed (docs + OpenAPI).** OpenAPI (`api/v1/lib/openapi.ts`) now documents **every** route — the +8 that shipped without an entry (`GET /boxes/:id/agent`, `POST /boxes/:id/checkpoint`, +`GET /boxes/:id/logs`, `POST /boxes/:id/rename`, `GET|DELETE /checkpoints`, `POST /prune`, +`GET /jobs`, `POST /jobs/:id/login-code`), new `Checkpoints`/`Fleet` tags + their response schemas +(`CheckpointCreateResult`/`CheckpointListing`/`CheckpointRemoveResult`/`PruneResult`/`JobListItem`/ +`AgentState`), the enriched `Job` (error/provider/name/agent/createdAt/login), and the Step-3 `Box` +adoption fields (`sandboxId`/`originUrl`/`publicHost`/`image`/`webPort`/`previewUrls`/`lastAgent`/ +`topology`/`shellCount`). `rename`/`open`/`open-targets`/`hosts` were already present. A **new guard +test** `apps/hub/test/openapi-coverage.test.ts` diffs the App-Router route files against the document +in both directions (fails on an undocumented route AND a documented path with no route file) — the +"verification checklist" the openapi.ts header always claimed but never had. **Verified end-to-end**: +built the standalone hub, restarted it, and the served `GET /api/v1/openapi.json` lists all **37** +routes with a **perfect bijection** to the route files (0 missing, 0 stale), `GET /api/v1/docs` +renders, and `pnpm --filter @agentbox/web build` is green. + +Public docs: `api.mdx` (all 9 endpoints + Checkpoints/Fleet groups + the accurate "one path, IO plane +excepted" framing), `deployed-hub.mdx` (a new **"What still needs your laptop"** section — the direct +IO plane, local adoption, `secrets.env` on both machines, the launcher-foreground-create exception, +the no-TTL parked-approval callout — plus corrected the stale `ls` merge/`on hub`/`orphan` description +to the single-`/api/v1/boxes` listing), `configuration.mdx` (added `git.pushMode`), `cli.mdx` (already +accurate — no overstatement). Internal docs: `architecture.md` (a new **"End state: one path through +`/api/v1`"** subsection with the four deliberate exceptions), `create-and-checkpoints.md` (create + +checkpoint now route through `/api/v1`, the mechanics moved *behind* the API), `cloud-providers.md` +(§4b custody/adoption/thin-client + a routing note on §6), `hub-testing.md` (why "both modes" is the +point + the coverage test). `CLAUDE.md` fixed: `/api/events` accepts Bearer (same gate as `/api/v1`); +the cookie is an *additional* same-origin credential, not a replacement (confirmed in `proxy.ts` — +`gateApi` handles both prefixes). + +**Tray — NOT done, deliberate documented follow-up.** `../agentbox-tray` is a **host-side sibling +repo** and is not reachable from inside this AgentBox (`/workspace`'s parent has no `agentbox-tray`, +and the box has no path to it or to its `main`). Per the step brief ("if you cannot reach it, say so +plainly and leave the tray work as a documented follow-up rather than pretending it is done"), the +tray change is a **follow-up**. What it needs (actionable): the tray's Swift `Box` model +(`HubAPIBoxSource`) should decode the enriched Step-3 fields now present on `GET /api/v1/boxes` — +`sandboxId`, `originUrl`, `publicHost`, `image`, `webPort`, `previewUrls`, `lastAgent`, `topology`, +`shellCount` (all optional; docker/synthetic rows omit them) — to reconstruct/adopt and label cloud +boxes without a second wire. The hub API contract it already speaks is unchanged otherwise; the +`Box` schema in the served `openapi.json` is the authoritative field list. Commit + push straight to +`main` there (no PR flow), from a checkout that can reach the sibling repo. + --- ## What this leaves on the laptop diff --git a/docs/hub-testing.md b/docs/hub-testing.md index 0d013ed6..1e0bc0b7 100644 --- a/docs/hub-testing.md +++ b/docs/hub-testing.md @@ -19,6 +19,16 @@ you changed.** A useful rule: **1 and 2 test the hub; 3 tests the deploy; 4 tests the user's first run.** +> **Why "both modes" is the whole point.** Since the thin-CLI consolidation +> ([`hub-api-single-path-plan.md`](./hub-api-single-path-plan.md)) the CLI drives +> every box/fleet operation through the hub's `/api/v1` — same client, same routes — +> so a change must be exercised against a **local hub** (no control box) *and* a +> **remote-shaped** hub (`hub expose`, environment 1). The cheapest remote-shaped +> check is almost always `hub expose` on your own machine: it runs the real password +> profile + `AGENTBOX_HUB_API_KEY` path in seconds. Every `/api/v1` route is +> asserted present in the OpenAPI doc by `apps/hub/test/openapi-coverage.test.ts`, so +> a new route can't ship undocumented. + --- ## 1. Your host — `hub expose` From 3c1786e1a15ff889ff5505be47fdd406c73a03fd Mon Sep 17 00:00:00 2001 From: Marco D'Alia Date: Thu, 30 Jul 2026 19:21:00 +0000 Subject: [PATCH 2/2] docs(api): point the "what needs my laptop" link at its exact section The api.mdx cross-link targeted #driving-a-box-from-your-laptop; the dedicated #what-still-needs-your-laptop section (added in this PR) is the exact answer. Claude-Session: https://claude.ai/code/session_01P5tWZxdr38EUaF9tXFUFXB --- apps/web/content/docs/api.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/content/docs/api.mdx b/apps/web/content/docs/api.mdx index d394b7b9..0ac50bb1 100644 --- a/apps/web/content/docs/api.mdx +++ b/apps/web/content/docs/api.mdx @@ -9,7 +9,7 @@ programmatic callers — IDE integrations, scripts, macOS apps, etc. — and is way to drive AgentBox when the hub runs on a **separate host** (a control plane), where there's no local CLI to shell out to. -This is not a second surface bolted onto the side. The AgentBox **CLI itself** drives every box and fleet operation through this same `/api/v1` — create, lifecycle, listing, git, approvals, services, checkpoints, prune and custody all go through the hub, against a local hub and a remote control box alike. "Enable a remote hub" is a base-URL swap, not a different code path. (The one operation still handled outside the API is the **direct IO plane** — `shell`, `attach`, `cp`, `download`, `code`, `open`, `url`, `screen` — which talks to the box from your machine; see [what still needs your laptop](/docs/deployed-hub#driving-a-box-from-your-laptop).) +This is not a second surface bolted onto the side. The AgentBox **CLI itself** drives every box and fleet operation through this same `/api/v1` — create, lifecycle, listing, git, approvals, services, checkpoints, prune and custody all go through the hub, against a local hub and a remote control box alike. "Enable a remote hub" is a base-URL swap, not a different code path. (The one operation still handled outside the API is the **direct IO plane** — `shell`, `attach`, `cp`, `download`, `code`, `open`, `url`, `screen` — which talks to the box from your machine; see [what still needs your laptop](/docs/deployed-hub#what-still-needs-your-laptop).) On your own machine you can also just call the CLI with `--json` (`agentbox list --json`, `agentbox status --json`). The API is what you reach for across a network, or when you want one stable HTTP contract instead of spawning a process per call.