Run CI jobs in Boxes
Run each CI job or scripted task in a fresh Cubicle Box created with a service account API key, with a GitHub Actions workflow that runs tests in a new Box per run, cleanup of leftover Boxes, the cubicle CLI with CB_CLI_API_KEY in shell scripts, grants on existing Boxes and what a CI job cannot do in a Box.
Run each CI job in a Box that the job creates when it starts and deletes when it ends. A new Box's disk is a fresh copy of its base image, or of a template snapshot you choose, so nothing an earlier run installed or wrote is on it. The job's code runs in its own virtual machine with its own kernel, and Boxes cannot reach each other over the network. There is no runner to install, register or update in the Box: the job drives it through the API with a service account's API key. Deleting the Box removes its disk and snapshots and releases the CPU, memory and disk it reserved.
This page builds a GitHub Actions job that runs tests in a fresh Box, makes sure every Box it creates is deleted, then does the same from a shell script with the CLI and shows how to use Boxes that already exist. A service account is a non-interactive member of one organization, such as acme/ci, whose API keys let jobs create Boxes, run commands and delete them without a person signing in. Sign in and API keys covers keys and scopes, and Organizations and access covers roles and grants.
Before you begin
- Create a service account
ciinacmeand awritekey namedgithub-actions, as in Create a service account and a key. This needs the owner or admin role and a person's login: API keys cannot manage service accounts, keys or grants. - The organization must allow Box creation. Otherwise creating a Box fails with
403 box_create_disabled. - Store the key as a secret in your CI system. The workflow below reads a GitHub Actions repository secret named
CUBICLE_API_KEY.
The SDK reads CB_SDK_API_URL and CB_SDK_API_KEY, and the CLI reads CB_CLI_API_URL and CB_CLI_API_KEY. The API URL is https://api.cubicle.sh. To check a key, run CB_CLI_API_URL=https://api.cubicle.sh CB_CLI_API_KEY=cbk_… cubicle whoami, which prints acme/ci (acme:member).
Run tests in a Box from GitHub Actions
This job creates a Box for each workflow run, runs a test script in it, reports the script's exit code as the job's result and deletes the Box. The script travels in the exec request, which is limited to 64 KiB. Move files into and out of a Box covers larger inputs.
-
Add the script to run in the Box:
ci/test.sh set -eu uname -a python3 -c 'print("tests passed")' -
Add the driver. Node 24 runs this TypeScript file directly:
ci/run-in-box.mts import { readFile } from "node:fs/promises" import { Cubicle, type Box } from "@cubicle/sdk" const cubicle = new Cubicle() // reads CB_SDK_API_URL and CB_SDK_API_KEY const name = process.env.BOX_NAME ?? "ci-local" const script = await readFile("ci/test.sh", "utf8") let box: Box | undefined try { // wait: false returns the handle at once, so cleanup has it even if the Box fails to start. box = await cubicle.boxes.create({ name }, { wait: false }) await box.waitUntilReady() const result = await box.exec(["sh", "-c", script], { timeoutMs: 600_000 }) process.stdout.write(result.stdout) process.stderr.write(result.stderr) if (result.truncated) console.error("output was truncated at 64 KiB") process.exitCode = result.timedOut ? 1 : result.exitCode } finally { await box?.delete() // waits until the Box is removed } -
Add the workflow:
.github/workflows/box-tests.yml name: Box tests on: push jobs: test: runs-on: ubuntu-latest timeout-minutes: 30 env: BOX_NAME: ci-${{ github.run_id }}-${{ github.run_attempt }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 24 - run: npm install @cubicle/sdk - name: Run tests in a Box run: node ci/run-in-box.mts env: CB_SDK_API_URL: https://api.cubicle.sh CB_SDK_API_KEY: ${{ secrets.CUBICLE_API_KEY }} # A cancelled job never reaches the script's finally block. - name: Delete the Box after a cancellation if: cancelled() run: | curl -fsSL https://api.cubicle.sh/downloads/install | sh -s -- --yes --no-tunnel ~/.local/bin/cubicle boxes delete "acme/$BOX_NAME" env: CB_CLI_API_URL: https://api.cubicle.sh CB_CLI_API_KEY: ${{ secrets.CUBICLE_API_KEY }} -
Push a commit. The Run tests in a Box step's log ends with:
Linux box-ci-… GNU/Linux tests passed
- The Box name is unique per run and attempt, because Box names must be unique among an organization's live Boxes.
- The SDK sends an idempotency key with every create and exec, and reuses it when it retries, so its own retries never create a second Box or run the script twice. To make a retry from a new process safe, see Idempotency keys.
- Keep the job's
timeout-minutesabove the sum of its waits, so the job is not killed before its cleanup runs. Waiting lists each call's default limit. - Errors and retries explains each error the driver can throw and which ones the SDK already retried.
- All keys of a service account, and every process that uses them, share one rate-limit budget. When several jobs run at once, see Rate limits and Poll pacing and the rate-limit budget.
Clean up Boxes
A Box keeps its reserved CPU, memory and disk until it is deleted, even while it is stopped or hibernated. Delete every Box a job creates:
- Delete in a
finallyblock, as the driver above does. When the wait insidecreatefails, the Box still exists. The error usually carries its record, but creating with{ wait: false }and then callingwaitUntilReady()gives you the handle first. - Delete by the handle or the Box's ID rather than by name, so cleanup never removes another Box that has the same name.
deletewaits until the Node has removed the Box and released its resources. Pass{ wait: false }to return once the deletion is accepted.- Deleting a Box also deletes its snapshots.
- A job that is killed skips its
finallyblock. Add a cancellation step, as in the workflow above, or sweep for leftovers on a schedule:
const cutoff = Date.now() - 6 * 60 * 60_000 // 6 hours ago
for await (const info of cubicle.boxes.list()) {
if (
info.name.startsWith("ci-") &&
info.deletion === null &&
Date.parse(info.createdAt) < cutoff
) {
await cubicle.boxes.delete(info.id, { wait: false })
}
}Use the CLI in scripts
Set CB_CLI_API_URL and CB_CLI_API_KEY to use a key instead of a stored login:
#!/bin/sh
set -eu
export CB_CLI_API_URL=https://api.cubicle.sh
export CB_CLI_API_KEY="$CUBICLE_API_KEY"
cubicle boxes create --org acme job-1 --no-start
trap 'cubicle boxes delete acme/job-1' EXIT
cubicle boxes start acme/job-1 --wait
cubicle exec acme/job-1 --timeout 300000 -- sh -c 'uname -a && python3 --version'- The script creates the Box stopped and sets the
EXITtrap before it starts the Box, so a failed start still deletes it. If the create itself fails, for example because the name is taken, the trap is not set and no other Box is touched. cubicle execexits with the command's exit code, soset -estops the script when the command fails. The trap deletes the Box either way.- Name Boxes as
ORG/NAMEor by ID. The CLI does not accept a bare name. - Add
--jsonfor machine-readable output.cubicle boxes getalways prints JSON. cubicle boxes createsends a new idempotency key on every run. Running it again after a lost response fails with409 conflict"a box named job-1 already exists".- The CLI does not retry a
429 rate_limitedresponse: it exits 1. Run only a few CLI waits at once per service account. - Commands that need a person's login, such as
grants,ssh-configandssh, do not work with a key. API-key mode lists which commands do.
Grant access to existing Boxes
A Box the service account creates is granted to it automatically. For any other Box, an owner or admin adds a grant with the service account's ID, the first column of cubicle service-accounts list --org acme, as in Grant access to a Box. It needs the grant even with the admin role: without one, exec fails with 403 forbidden "an explicit box grant is required". Right after a new grant, an exec can fail with an error ending in unknown guest account: retry it after a few seconds.
What a CI job cannot do
- Workflow steps do not run in the Box. The runner runs the steps and actions; only the commands you send with exec run in the Box, and the runner's checkout is not copied into it. Send small inputs in the command, as the driver does with
ci/test.sh, and larger ones as in Move files into and out of a Box. - No
sudo. Commands sent with an API key cannot rely onsudo, so a job may not be able to install system packages. Create the job's Boxes from a template Box that has them instead; every Box created from a snapshot goes on the snapshot's Node. - At most 10 minutes per command, with output at the end. Output arrives when the command ends, with no live log, and is capped at 1 MiB. Split a longer suite as in Run work longer than 10 minutes.
- About an hour per start. A Box hibernates 60 minutes after each start, whatever is running, and a command still running then ends
unknown. - No connections into the Box from the runner. API keys cannot use SSH or the private network, and Boxes have no public IP address or port. To test a service the job starts in the Box, run its client in the Box with exec, against
localhost. - No other Boxes or private networks. A Box reaches the public IPv4 internet only: not other Boxes, private address ranges or IPv6. Run every part of a multi-service test in one Box.
When not to use a Box covers the rest, such as GPUs and other CPU architectures.
Troubleshooting
403 box_create_disabled"this organization cannot create boxes; ask an operator to enable it": the organization does not allow Box creation.409 conflict"a box named ci-… already exists": a Box with that name is still live. Use a unique name per run, or the same idempotency key for retries.503 capacity"no healthy node with the default base image has capacity for this box": no Node can place the Box now. Retry later, or ask for a smaller size.- SDK
missing_api_urlormissing_api_key: the job does not setCB_SDK_API_URLandCB_SDK_API_KEY. Set both, as the workflow above does, or passapiUrlandapiKeytonew Cubicle(). - Key, scope, grant and rate-limit errors, such as
401 unauthenticated"API key is invalid, expired or revoked",403 insufficient_scope,403 api_key_not_allowed,403 forbidden"an explicit box grant is required" or "box grant or organization admin role required",429 rate_limited,403 blockedandCB_CLI_API_KEY requires CB_CLI_API_URL: Troubleshooting has the fix for each.
Related
- Sign in and API keys: service accounts, scopes and key rotation.
- Organizations and access: roles, grants and keys.
- Manage organization members: grant a service account access to a Box.
- Run commands: everything about exec, including passing secrets.
- Build a template Box: install software once and create each job's Box from it.
- Give an AI agent a Box: exec as an agent's tool.
- Waiting and Errors and retries: the SDK in depth.
- Rate limits: every policy and how blocking works.
- Account and access commands: the
service-accounts,api-keysandgrantscommands.
Give an AI agent a Box
Give an LLM agent a Cubicle Box as its sandbox with @cubicle/sdk, with a framework-neutral run_command tool covering sh -c, timeoutMs, maxOutputBytes, truncated and timedOut results, exit codes, wake, idempotency keys, one Box per task, cleanup in finally, rate limits and what API keys cannot do.
Remote development environments
Develop inside Cubicle Boxes from a macOS or Linux laptop, with one virtual machine per developer reached over SSH and the private network, VS Code or Cursor Remote-SSH through the alias cubicle ssh-config writes, dev servers at private names such as job-1.acme.cubicle.internal, a template Box snapshot as the team baseline, automatic hibernation 60 minutes after each start, and what Cubicle cannot do for remote development.

