# Sandboxes



`ccp apply` also manages **Sandbox templates** — versioned, organization-scoped
recipes for ephemeral VMs. Applying a template reconciles its stable identity
and admits one immutable build for its exact recipe; `ccp sandbox create`
launches a fresh, short-lived Sandbox from a template's current ready build.

## Apply a template [#apply-a-template]

```bash
ccp apply -f sandbox.yaml --org-id "$CCP_ORG_ID"
```

Reapplying an unchanged manifest reuses the same template and its ready or
in-progress build — it never creates a duplicate. Pass `--dry-run` to run the
same reconciliation without writing a template or build.

A `SandboxTemplate` manifest looks like:

```yaml
apiVersion: sandboxes.clusterbase.ai/v1
kind: SandboxTemplate
metadata:
  name: python-tools
spec:
  base: debian
  resources:
    vcpu: 2
    memory_mb: 1024
  packages:
    apt: [jq, python3, python3-venv]
  ports:
    - name: cdp
      port: 9222
      protocol: websocket
  steps:
    - run: |
        python3 -m venv /opt/python
        /opt/python/bin/pip install cowsay==6.1
        mkdir -p /workspace
        touch /workspace/ready
```

`metadata.name` is the stable organization-scoped template identity.
`spec.resources` is required and must be one supported `vcpu` / `memory_mb`
pair — `1/256`, `1/512`, `2/1024`, `4/2048`, or `4/4096` (vCPU/MiB); any other
pair is rejected. `spec.base` is exactly one of `debian` or `alpine`; Infra
resolves it to a committed immutable minimal system build. Debian supports
`apt` and Alpine supports `apk` — incompatible package managers are rejected
before build admission. Language runtimes aren't preinstalled on either base:
add the runtime as an OS package and install language dependencies in the
`run` step. The retired `ubuntu` base and the full `development` image are
not accepted as manifest bases. Exactly one `run` step follows the package
lists.

`spec.ports` declares private, immutable services the running Sandbox exposes.
Each entry has a unique DNS-label `name`, a unique guest `port`, and a
`protocol` of `http` or `websocket`; at most eight may be declared. Your
template's boot setup must start the service and bind it to the guest
interface — declaring a port doesn't start anything. A running Sandbox
publishes each declared service behind an authenticated HTTPS or WSS route;
raw VM addresses and undeclared ports stay private. Use `ccp sandbox connect`
(below) to mint access to a declared service.

Changing the base, resources, packages, ports, or the run step admits a
new immutable build; existing builds are never mutated. Build execution,
publication, scheduling, and every Sandbox launch use the exact resource
pair snapshotted by that build.

Keep manifests commit-safe. Unknown fields are rejected, including plaintext
`secrets` or `env` blocks and mutable `templateId`, `templateBuildId`, `vmId`,
or `sandboxId` fields — build and runtime identities are server-derived.

## Launch a Sandbox [#launch-a-sandbox]

Ephemeral creation is a separate lifecycle operation from applying a template.
Creating a Sandbox launches the applied template's current exact ready build:

```bash
ccp sandbox create --template python-tools --ttl 15m --org-id "$CCP_ORG_ID"
```

`--ttl` defaults to `15m` and accepts whole-second human durations from `1m`
through `24h`. Every invocation creates a distinct Sandbox while reusing the
same immutable build; applying a template's desired state never launches a
Sandbox on its own.

`create` fails with `template_not_found` when the active organization-scoped
name doesn't exist, and `template_not_ready` while its current recipe has no
ready build yet — wait for the applied build to publish, then retry.

## Persistent Sandboxes [#persistent-sandboxes]

`ccp sandbox create --ttl` always sends a bounded lifetime between `1m` and
`24h` — there's no CLI flag to skip expiry. Callers hitting the Sandbox
creation API directly can pass `ttl_seconds: 0` instead of a bounded value to
launch a Sandbox with no age-based expiry; every subsequent response reports
`expires_at: null` for it.

A Sandbox that already started with a bounded lifetime can be adopted as
persistent later:

```bash
curl -X POST https://api.clusterbase.dev/api/v1/sandboxes/sbx_.../retain \
  -H "Authorization: Bearer $(ccp auth print-access-token)"
```

Retaining clears the Sandbox's age deadline immediately and is idempotent —
repeating it is a no-op. It fails rather than reviving the Sandbox once the
Sandbox is deleting or already deleted. Persistent Sandboxes keep the same
idle pause/resume behavior as any other desktop Sandbox (below), and
short-lived grants like `ccp sandbox connect` access and desktop-control
leases keep their own bounded expiry regardless of the Sandbox's own
lifetime.

## Connect to a declared service [#connect-to-a-declared-service]

Mint short-lived, scoped access to one of the template's declared `ports`
services:

```bash
ccp sandbox connect sbx_... cdp --ttl 5m
ccp sandbox connect sbx_... cdp --ttl 5m --json
```

`--ttl` defaults to `15m` and accepts whole-second human durations from `1s`
through `1h`, never beyond the Sandbox's own remaining TTL. The response keeps
the endpoint and opaque bearer token separate — attach
`Authorization: Bearer <token>` to the HTTPS request or the WebSocket Upgrade;
the token is never embedded in the URL. `--json` emits the stable automation
contract with `endpoint`, `token`, and `expires_at` fields.

Connecting validates the organization, the Sandbox's remaining lifetime, and
the exact service declaration atomically when minting access — a service name
not declared in the template's `spec.ports`, or a Sandbox past its TTL, is
rejected rather than granted a stale or partial token.

## Public preview on the `development` template [#public-preview-on-the-development-template]

A Sandbox created from the built-in `development` system template via the
console or the Infra API always carries one fixed, system-owned HTTP service
named `preview` on port `8000`. Unlike ports you
declare yourself, `preview` is published without authentication — anyone with
the URL can reach it, so treat it as a shareable preview link, not a private
endpoint. Any port your own template declares stays private and
authenticated; a template recipe cannot mark its own ports public. The
`development` image now runs on Debian rather than Ubuntu; custom
`SandboxTemplate` manifests can no longer select this full image as a
`spec.base` — see [Apply a template](#apply-a-template).

While the Sandbox is running, `ccp sandbox create` and every subsequent get
or list call return the published preview as an opaque URL under
`services` in the response, alongside the service's name and protocol. The
entry disappears once the Sandbox stops or expires.

Until the dev server behind `preview` starts accepting connections, the URL
serves a branded HTML waiting page (still with a `502` status) that retries
every two seconds instead of a plain-text `Bad Gateway`. It automatically
transitions to the app once the server responds. A failed WebSocket upgrade
still returns the plain-text `502`.

## Watch Sandboxes in the console [#watch-sandboxes-in-the-console]

The console lists every Sandbox in the active organization under
**Build → Sandboxes** at [console.clusterbase.ai](https://console.clusterbase.ai),
with its exact template build, lifecycle status, creation time, and remaining
TTL. Open a Sandbox to see the same metadata, or delete it from the row menu
or the detail page. The console only reports what Infra reports: it never
owns expiry, and creation stays with `ccp sandbox create`.

## Open a graphical desktop [#open-a-graphical-desktop]

The graphical Ubuntu desktop is a native system template with a desktop
environment and a noVNC service. It uses the stable `desktop` system
identity, so it needs no organization-owned manifest, `ccp apply`, or custom
build — create a Sandbox from it directly:

```bash
ccp sandbox create --template desktop --ttl 1h --org-id "$CCP_ORG_ID"
```

Open its read-only desktop without ever putting a bearer token in a browser
URL:

```bash
ccp sandbox desktop sbx_... --ttl 20m
```

`--ttl` defaults to `15m` and accepts whole-second durations from `1s`
through `1h`. The command opens a token-free `127.0.0.1` URL and stays in
the foreground: it injects the scoped `desktop` service bearer into the
upstream HTTPS and WebSocket requests, rejects cross-origin browser access,
and closes on Ctrl-C or access expiry. The guest VNC server enforces
view-only access on this transport — browser settings cannot enable pointer,
keyboard, or clipboard input — and a separate, reserved control transport is
not published by ordinary route access. Raw VNC is never published — it
stays loopback-only inside the guest. For non-browser automation that can
set its own `Authorization` header, use `ccp sandbox connect` instead.

First-party browser and native desktop clients may also connect directly to
the desktop view (or control) Upgrade without a local `ccp sandbox desktop`
tunnel, since browser WebSocket APIs can't set `Authorization` themselves.
Pass the capability as an exact, ordered WebSocket subprotocol offer instead:

```text
binary, bearer.sbxt_<base64url>
```

The server selects only `binary` and never echoes the bearer back. Browser
requests must come from an HTTPS Origin the target environment allows;
native clients may omit Origin. Keep the bearer in memory only — never in a
URL or persistent browser storage — and request a fresh capability once it
expires. If a client can set request headers, `Authorization: Bearer <token>` still works and takes priority over the subprotocol form when both
are present.

A desktop Sandbox auto-pauses after 15 minutes with no connected viewer,
control lease, or model `ComputerAct`/`ComputerObserve` activity, freeing its
scheduled memory while preserving disk and in-memory VM state. The next
`ccp sandbox desktop` connection, `ccp sandbox connect`, or `ComputerObserve`
call resumes the same VM transparently before serving the request. This idle
pause is independent of the Sandbox's own lifetime: a bounded Sandbox keeps
tearing down on schedule whether or not it's currently paused, while a
[persistent](#persistent-sandboxes) Sandbox has no such deadline to enforce.

A fresh desktop Sandbox uses a prewarmed Blink microVM browser: the snapshot
already has Blink running on a blank, minimized window, so the Browser
launcher restores that existing window (or opens a new one in the resident
process) instead of a cold start. If the process has exited, the launcher
restarts the supervised browser service; new windows open maximized. A
normal close leaves the supervised process resident. The desktop's Blink is
machine-managed to force-install its bundled uBlock Origin, with managed
filters covering advertising, privacy, cookie, and newsletter rulesets
enabled by default. Ads, cookie-consent banners, and intrusive overlays are
suppressed automatically.

## Take input control of a desktop [#take-input-control-of-a-desktop]

The reserved `desktop-control` transport is the one that carries input. Its
guest services are stopped by default, and neither `ccp sandbox desktop` nor
`ccp sandbox connect` opens it. An organization member or an authenticated
trusted service acquires it directly against the infra API:

```bash
curl -X POST https://api.clusterbase.dev/api/v1/sandboxes/sbx_.../desktop-control \
  -H "Authorization: Bearer $(ccp auth print-access-token)" \
  -H "Content-Type: application/json" \
  -d '{"lease_id":"sbxcl_...","token":"sbxt_...","ttl_seconds":300}'
# {"lease_id":"sbxcl_...","fence":1,"endpoint":"wss://...","token":"sbxt_...","expires_at":"..."}
```

The caller generates a fresh `sbxcl_<uuid>` lease ID and a fresh 256-bit
`sbxt_<base64url>` bearer; `ttl_seconds` accepts 10 through 900, and the
lease is additionally capped at the Sandbox's own expiry. A successful
response carries `Cache-Control: no-store` and returns the lease ID, its
monotonically increasing `fence`, the WSS `endpoint`, the bearer, and
`expires_at`. Infra persists only the bearer digest — retain the returned
token, since it can't be recovered later. Retrying an identical acquisition
while a matching lease is still active recovers the same capability instead
of failing.

`PATCH` the same path with `lease_id`, `fence`, and a new `ttl_seconds` to
renew the lease; `DELETE` it with `lease_id` and `fence` to release it. A
stale lease ID or fence can't renew or release the lease that replaced it.
While a control lease is active, model-driven `ComputerAct` input is
rejected for that Sandbox. Release, expiry, or Sandbox deletion revokes the
lease, closes any open control WebSockets, stops the guest control
services, clears the desktop's X11 clipboard and primary selection (plus
legacy cut buffers), and invalidates the model's current frame — automation
must take a fresh observation before it can act again. If clearing the
clipboard fails, model input stays rejected so a hand-back can be retried
safely rather than risking human clipboard contents leaking into resumed
model control. `ccp sandbox desktop` stays view-only throughout and never
grants or reflects this lease.

## Browse workspace files over HTTP [#browse-workspace-files-over-http]

A running Sandbox exposes its guest workspace read-only over the normal Infra
bearer token — no declared port or `ccp sandbox connect` required:

```bash
curl https://api.clusterbase.dev/api/v1/sandboxes/sbx_.../files?path=/home/user \
  -H "Authorization: Bearer $(ccp auth print-access-token)"

curl "https://api.clusterbase.dev/api/v1/sandboxes/sbx_.../files/content?path=/home/user/notes.txt" \
  -H "Authorization: Bearer $(ccp auth print-access-token)"
```

`GET /files` lists one directory, defaulting to the workspace root
(`/home/user` for managed Build); pass `?path=` to expand any other
directory. The response contains `path`, `entries` (each with `name`,
`path`, `kind`, and `size`), and `truncated`. Hidden and ignored files are
included, and directories sort first.

`GET /files/content` reads bounded UTF-8 text, returning `path`, `content`,
`size`, and `truncated`; the preview is limited to 1 MiB. URL-encode paths,
including spaces. Binary files return `415`, and files over the guest input
limit return `413`.

Both routes reject paths or symlinks that resolve outside the workspace,
return `403` if the caller has lost organization access, `404` for a
missing path, and `409` if the Sandbox isn't running. Neither route writes,
renames, or deletes files.
