feat(sandbox-providers): add boxd sandbox provider (#17284)

<p align="center">
<img
src="https://raw.githubusercontent.com/MichielMAnalytics/paperclip/08f1cdda04443ed10fc43e7097ae0b2d14b5cc19/boxd-x-paperclip.png"
alt="boxd × Paperclip" width="700">
</p>

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agent work runs in sandboxes that provider plugins supply
> - An operator selects a provider for each environment, and a reused
lease keeps one sandbox for an issue across heartbeats
> - The time to resume a reused sandbox delays every heartbeat on that
issue (see #14684)
> - boxd runs each sandbox as an isolated microVM that suspends and
resumes in milliseconds, with disk, memory and processes kept
> - This pull request adds a boxd sandbox provider plugin
> - The benefit is that operators get one more provider, with fast
resume for reused leases

## Linked Issues or Issue Description

Refs #14684. That pull request keys the remote session identity on the
provider lease ID. This provider keeps the provider lease ID equal to
the boxd machine ID, so the ID stays the same across suspend and resume.

No issue exists for boxd. The request is described below with the fields
of `adapter_request.yml`.

**Agent or provider**

boxd (https://boxd.sh). boxd runs each sandbox as a KVM microVM. The
plugin uses the public TypeScript SDK, `@boxd-sh/sdk` 0.2.20 or later.

**Why this adapter is useful**

boxd suspends a machine in about 17 ms and resumes it in about 0.2 s,
with the disk, the memory and the running processes kept. This fits
`reuseLease`: the next heartbeat on an issue continues on the same
machine. Each machine is created with `isolated: true`. An isolated
machine has no metadata endpoint, no in-VM `boxd` CLI and no boxd
integrations, so sandbox code cannot reach the provider management
interface.

**How the agent is invoked**

Build and install the plugin by local path, as the package README
describes. Open Instance Settings, then Environments. Select the `boxd`
provider and supply a boxd API key. Agents that use the environment run
their adapter commands in a boxd machine.

**Are you willing to implement it?**

Yes. This pull request is the implementation.

## What Changed

- Adds the `boxd` sandbox provider under
`packages/plugins/sandbox-providers/boxd`. Its structure follows the E2B
and exe.dev plugins. The plugin has no code comments, to follow
`CONTRIBUTING.md`.
- Implements the validate, probe, acquire, resume, release, stop,
destroy, inspect, realize-workspace and execute hooks. The manifest
declares `sandboxCapabilities: { reusableLeases: true }`.
- With `reuseLease`, release suspends the machine and resume wakes it. A
suspended, hibernated or stopped machine is woken by the matching boxd
call. A release that cancels active work stops the machine and keeps its
disk.
- Release and destroy return a `destroyed` receipt only after boxd no
longer lists the machine. Stop returns a `stopped` receipt only after
boxd reports the `stopped` state.
- When the host sets `requestedExpiresAt`, the plugin sets a boxd delete
timer at or before that time and returns `expiresAt` on the lease. The
timer is set after the create call returns, so a failure while setting
it cannot lose the machine ID.
- Commands run through `sh` with quoted arguments and per-command
environment. Stdin is staged with boxd's file API in a temporary file
that only the machine user can read, and removed afterwards; the
workspace directory is created the same way. A machine whose boxd agent
predates that API, such as one restored from an older snapshot, falls
back to the older upload and `mkdir` calls. A command timeout ends the
remote process group and returns `timedOut: true`. A command on a
stopped machine starts the machine and runs one more time.
- A failed acquisition deletes its machine. If boxd cannot confirm the
deletion, the plugin throws `PluginEnvironmentCreationCleanupError` with
the machine ID, so the host records it and retries the teardown; the
destroy hook checks the account and the machine labels before it deletes
a failed creation. A probe whose machine cannot be deleted reports a
failure, and probe machines carry a 15-minute boxd delete timer.
- The plugin keeps one SDK client for each API key. This prevents a
session token exchange for each command.
- Machines carry the labels `paperclip-provider`,
`paperclip-environment` and `paperclip-run`, so an operator can find
them with `boxd machine list --label paperclip-provider=boxd`.
- Registers the optional bundled catalog key `boxd` and the trusted
`BOXD_API_KEY` host fallback, with tests that mirror the CreateOS cases.
- Adds the package to the release manifest with `publishFromCi: false`
until a maintainer bootstraps the first npm publish.
- Adds unit tests with a mocked SDK (40 tests) and opt-in live tests
(`BOXD_LIVE_TEST=1`, 9 tests) that run every hook against boxd.
- Adds a section to `doc/plugins/PLUGIN_AUTHORING_GUIDE.md` and adds
boxd to the README provider list.

## Verification

Provider package (`packages/plugins/sandbox-providers/boxd`):

- `pnpm install --ignore-workspace --no-lockfile && pnpm typecheck`:
passes.
- `pnpm test`: 40 passed, 9 live tests skipped.
- `BOXD_LIVE_TEST=1 BOXD_API_KEY=... pnpm test`: 49 passed against
production boxd on 2026-10-28 with `@boxd-sh/sdk` 0.2.20. The live tests
cover:
- validation, and a probe at 2 vCPU that deletes its machine (a bad key
returns a failed probe);
  - the labels, the isolation and the org of an acquired machine;
- stdin, env, argument quoting, the exit code and stderr, and the
removal of the staged stdin file;
- an empty stdin, and a staged stdin file that only the machine user can
read (mode 600);
- a 5 MiB round trip through the base64-over-exec path (the SHA-256
matches);
- a command timeout, which returns `timedOut` and ends the remote
processes;
- suspend and resume (a file and a running process stay), stop and
resume (the file stays), and inspect in each state;
  - a command on a stopped machine, which starts the machine and runs;
- resume and stop of a hibernated machine, stop of a suspended machine,
and release with `cancelActiveWork` and with `stop_and_retain`;
  - release without reuse, which deletes the machine;
- a `requestedExpiresAt` 45 s away, which boxd enforces by deleting the
machine;
- a run restored from a snapshot, and a fresh machine at 2 vCPU / 8 GiB;
- resume after destroy, which returns an expired lease, and a final
check that no test machines remain.
- Measured times: probe 1.5 s, acquire 1.4 s, release with suspend 17
ms, resume from suspend 0.13 s, resume from hibernate 0.18 s, stop 14
ms, resume from stop 3.2 s, acquire from snapshot 1.6 s, confirmed
destroy 30 ms.
- On a machine started before boxd's file API (the test host), the
plugin's hooks fell back to the older calls: the workspace was created,
stdin was staged (mode 644, the upload path), an empty stdin worked, and
no staged file was left.

Host:

- `npx vitest run src/__tests__/bundled-plugins.test.ts
src/__tests__/plugin-database.test.ts` in `server`: 73 passed, 6 of them
new (re-run on 2026-10-28).
- `npx vitest run src/__tests__/cli-invocation-safety.test.ts` in
`server`: 40 passed. The README install commands now use the direct-exec
CLI form.
- `npx tsc --noEmit` in `server`: passes.
- `node --test scripts/release-package-map.test.mjs
scripts/check-release-package-bootstrap.test.mjs`: 18 passed.

Full stack, on a Linux host with `pnpm dev:once` in `local_trusted`
mode:

- I installed the plugin with `POST /api/plugins/install` and a local
path, then created boxd environments with a secret reference for the API
key.
- Ephemeral environment: the environment probe passed. A `claude_local`
agent (`claude-sonnet-5-5`, legacy runner) ran a task in a boxd machine.
It reported Ubuntu 24.04.5 and the workspace
`/home/boxd/paperclip-workspace`, and it marked the task done. The lease
was released and the machine was deleted.
- Reused-lease environment: the first run wrote a file and started a
background loop. After release, the machine was in the `suspended`
state. A comment with `reopen: true` started a second run. That run used
the same machine, so the provider lease ID was the same on both lease
rows. The file was there and the background loop was still running.
- Validation: a config with `vcpu: 3`, and a config with both `vcpu` and
`snapshot`, return 422 with the plugin error. The unsaved-config probe
(`/environments/probe-config`) passed in 2.0 s and deleted its machine.
A saved probe with `vcpu: 4` created a 4 vCPU / 16 GiB isolated machine.
- Cancel: cancelling the first run on a reused lease marked the lease
`expired`, and the host destroyed the machine through the plugin about
12 s later.
- Delete: deleting an environment that holds a reused lease returns 409.
With `?destroyReusableSandboxLeases=true` it returns 200, and the host
destroys the suspended machine through the plugin.
- I re-ran this flow on 2026-10-26 and again on 2026-10-28 with
`@boxd-sh/sdk` 0.2.20, from a script, with each step checked. On
2026-10-28 the ephemeral run posted its proof and both of its machines
were deleted. The reused-lease follow-up ran on the same machine (both
lease rows have the same provider lease ID), and found the file and a
background loop that had kept running. The validation errors returned
422, the unsaved-config probe passed in 1.8 s, and deleting the
environments left no machines. Every check passed except the host gap
below.
- After the runs, `boxd machine list` showed no machines with the test
prefix.

Not run: `pnpm -r typecheck`, `pnpm test:run` and `pnpm build` at the
root. The test host has no Rust toolchain for the runner binary. On that
host, `scripts/check-forbidden-tokens.mjs` adds the local username
(`boxd`) as a token. It then flags every `boxd` substring, and it also
fails on `master` there (`InboxDismissal`). A CI runner uses a different
username.

## Risks

- Host gap, not in this plugin: when a run that **resumed** a reused
lease is cancelled, the host marks the run `cancelled` but does not stop
the agent in the sandbox. The adapter keeps polling the file bridge, and
the host calls no release, stop or destroy hook. The lease stays
`active` until the environment is deleted. A plugin-side trace showed
only bridge-polling `execute` calls after the cancel. boxd is the only
provider that declares `reusableLeases`, so this path may not have been
exercised before. A cancel on the first run of a lease works as
expected.

- Low risk to existing behavior. The plugin is opt-in and is not
installed by default. The host changes add one catalog entry, one
credential passthrough entry and their tests.
- boxd machine creation has no idempotency key. If a create response is
lost, a machine can stay behind. The labels above help an operator find
it.
- A suspended machine keeps its memory image until boxd hibernates it
after a period of idle time. An operator who keeps many reused leases
pays for that storage.
- boxd stops a non-PTY command that has no output for 30 minutes. Agent
CLIs write output during a run, so this does not affect normal runs.
- boxd has one region (EU), amd64 machines only, and no GPUs. Machine
sizes are 1/4 GB, 2/8 GB, 4/16 GB and 8/32 GB.
- The new `package.json` needs code-owner approval.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`) through Claude Code, with tool use
and shell execution on a boxd machine. The session does not expose the
context window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass (focused checks; the limits
are listed under Verification)
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01HNWtBYxLByQmmNKjuaou1P

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
MichielMAnalyticsandClaude Opus 5.5 authored and GitHub committed 2026-10-28 12:34:21 -07:00
1 parent f4f02880ca
commit 644937c931
18 files changed
+1914 -1

No files matched your search

+1 -1
View File
@@ -504,7 +504,7 @@ See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide.
- ✅ Better Budgeting
- ✅ Agent Reviews and Approvals
- ✅ Multiple Human Users
- ✅ Cloud / Sandbox agents (e2b, Cloudflare, Daytona, Modal, Novita, self-hosted Kubernetes)
- ✅ Cloud / Sandbox agents (e2b, Cloudflare, Daytona, Modal, Novita, boxd, self-hosted Kubernetes)
- ✅ Artifacts & Work Products
- ✅ Deep Planning (planning mode, revisioned plans, plan approvals)
- ✅ Enforced Outcomes (watchdogs, recovery actions, review gates)
+12
View File
@@ -248,6 +248,18 @@ not advertise interactive login or template capture. Install
the built package by local path; its optional managed-image catalog key is
`createos`. The package README describes configuration and the opt-in live smoke.
### boxd sandbox provider
The in-repo [`@paperclipai/plugin-boxd`](../../packages/plugins/sandbox-providers/boxd/README.md)
package implements the environment lifecycle hooks on isolated boxd microVMs
through the `@boxd-sh/sdk` package. It supports reusable leases: a released
machine is suspended and the next run resumes it with its disk, memory and
processes. It also implements the stop and inspect hooks, and it bounds a lease
with a provider-side delete timer when the host requests an expiry. It does not
advertise interactive login, template capture or native file sync. Install the
built package by local path; its optional managed-image catalog key is `boxd`.
The package README describes configuration and the opt-in live smoke.
### Worker APIs
Worker:
@@ -0,0 +1,3 @@
dist
node_modules
.paperclip-sdk
@@ -0,0 +1,141 @@
# `@paperclipai/plugin-boxd`
boxd sandbox provider for Paperclip. [boxd](https://boxd.sh) runs each sandbox
as an isolated KVM microVM. This package lives alongside E2B, Daytona and the
other sandbox providers, outside the root pnpm workspace. It uses the
[`@boxd-sh/sdk`](https://www.npmjs.com/package/@boxd-sh/sdk) package. The
package is not published yet; use a local-path install. Its release manifest
entry has `publishFromCi: false` until a maintainer bootstraps the first npm
publish.
## Build and install locally
Requires Node 24.11+, pnpm, and an installed Paperclip checkout.
```sh
cd packages/plugins/sandbox-providers/boxd
pnpm install --ignore-workspace --no-lockfile
pnpm typecheck
pnpm test
pnpm build
```
From the Paperclip checkout, with your instance running:
```sh
node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts plugin install /absolute/path/to/paperclip/packages/plugins/sandbox-providers/boxd
node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts plugin inspect paperclip.boxd-sandbox-provider
```
## Configure an environment
Configure boxd under **Instance Settings -> Environments**, not from the
plugin page. Select the `boxd` provider.
- `apiKey`: a boxd API key. Create one with
`boxd auth keys create paperclip --org <org>`. Each key belongs to one org,
and machines are created in that org. Paperclip saves a pasted key as a
company secret. `BOXD_API_KEY` on the Paperclip host is an optional fallback
when the environment has no key.
- `reuseLease`: default `false`. With `false`, the plugin deletes the machine
when the run ends. With `true`, the plugin suspends the machine when the run
ends, and the next run on the same issue resumes it. Disk, memory and running
processes stay. The provider lease ID is the machine ID, so it stays the same
across resumes.
- `snapshot`: optional snapshot name or ID. Each run restores this snapshot,
for example a machine with your tools and repository already in place.
- `vcpu`: optional machine size for fresh machines: 1 vCPU/4 GB, 2/8 GB,
4/16 GB or 8/32 GB. A blank value uses the org default. A snapshot keeps its
own size.
- `org`: optional org name or ID. A blank value uses the API key's org.
- `namePrefix`: machine name prefix, `paperclip` by default.
- `timeoutMs`: default command timeout, 3600000 ms by default.
The default boxd image is Ubuntu 24.04 with Node 24, `tar`, `git`, Docker,
Claude Code, Codex and OpenCode on the PATH. It meets
[the sandbox runtime requirements](../SANDBOX-REQUIREMENTS.md) without a
setup step. Use a snapshot to add other agent CLIs.
The probe creates a machine, prepares the workspace and deletes the machine.
It uses real boxd resources. The probe machine also has a 15-minute boxd delete
timer, and a probe whose machine could not be deleted reports a failure.
## Implemented behavior
- Every machine is created with `isolated: true`. An isolated machine has no
metadata endpoint, no in-VM `boxd` CLI and no boxd integrations, and other
machines in the org cannot reach it over the private network. The API key
stays in the plugin worker and never enters the machine.
- Machines carry the labels `paperclip-provider=boxd`,
`paperclip-environment=<id>` and `paperclip-run=<id>`. Use
`boxd machine list --label paperclip-provider=boxd` to find them.
- Commands run through `sh` in the workspace, with quoted arguments,
per-command environment, staged stdin, and separate stdout and stderr. A
command timeout ends the remote process group and returns `timedOut: true`.
- Stdin is staged with boxd's file API in a file only the machine user can
read, and removed after the command. The workspace directory is created the
same way. A machine whose boxd agent predates the file API, such as one
restored from an older snapshot, falls back to the older upload and `mkdir`
calls.
- A failed acquisition deletes its machine. If boxd cannot confirm the
deletion, the plugin throws `PluginEnvironmentCreationCleanupError` with the
machine ID, so the host records it and retries the teardown. The destroy hook
checks that the machine's labels match before it deletes it.
- A command on a stopped machine starts the machine and runs the command one
more time. A suspended or hibernated machine wakes on the command.
- Release deletes the machine and returns a receipt only after boxd no longer
lists it. With `reuseLease`, release suspends the machine. A release that
cancels active work stops the machine and keeps its disk.
- `onEnvironmentStopLease` stops the machine and keeps its disk.
`onEnvironmentInspectLease` reads the machine state and does not change it.
- When the host requests a lease expiry, the plugin sets a boxd delete timer
at or before that time and returns the expiry on the lease.
- The plugin keeps one SDK client for each API key, so commands do not
exchange the key for a new session token each time.
## Capability boundaries
Interactive login PTYs, template capture, native file sync, persistent
sessions, incremental output, duplex channels and runner ingress are not
advertised. Workspace transfer uses the host's base64-over-exec fallback.
boxd has one region (EU), amd64 machines only, and no GPUs.
## Opt-in live tests
Default tests use a mocked SDK and do not contact boxd. To run against boxd,
export `BOXD_API_KEY` (and optionally `BOXD_ORG`), then run:
```sh
BOXD_LIVE_TEST=1 pnpm test
```
The live tests run every hook against boxd. They check:
- config validation, and a probe with a sized machine that deletes the machine
afterwards (a bad API key returns a failed probe);
- the labels, the isolation and the org of an acquired machine;
- stdin, env, argument quoting, exit codes and stderr, and the removal of the
staged stdin file;
- an empty stdin, and a staged stdin file that only the machine user can read;
- a 5 MiB round trip through the base64-over-exec path;
- that a command timeout returns `timedOut` and ends the remote processes;
- suspend and resume with a file and a running process kept, stop and resume
with the file kept, and inspect in each state;
- a command on a stopped machine, which starts the machine and runs;
- resume and stop of a hibernated machine, stop of a suspended machine, and a
release with `cancelActiveWork` or `stop_and_retain`;
- a release without reuse, which deletes the machine;
- a `requestedExpiresAt` that boxd enforces by deleting the machine;
- a run restored from a snapshot, and a fresh machine at the configured size;
- that resume after destroy reports an expired lease, and that no test
machines are left.
Each test deletes its machines in `finally`. The tests print timings and no
credentials. A full run takes about two minutes.
## Optional managed-image inclusion
The bundled catalog key is `boxd`. Include the `boxd` directory in the Docker
`CLOUD_BUNDLED_PLUGINS` build argument, then include `boxd` in the managed
configuration's `plugins.autoInstall` list. The catalog entry does not
auto-install the plugin or change the default image.
@@ -0,0 +1,63 @@
{
"name": "@paperclipai/plugin-boxd",
"version": "0.1.0",
"description": "boxd sandbox provider plugin for Paperclip environments",
"license": "MIT",
"homepage": "https://github.com/paperclipai/paperclip",
"bugs": {
"url": "https://github.com/paperclipai/paperclip/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/paperclipai/paperclip",
"directory": "packages/plugins/sandbox-providers/boxd"
},
"type": "module",
"exports": {
".": "./src/index.ts"
},
"publishConfig": {
"access": "public",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"files": [
"dist"
],
"paperclipPlugin": {
"manifest": "./dist/manifest.js",
"worker": "./dist/worker.js"
},
"keywords": [
"paperclip",
"plugin",
"sandbox",
"boxd"
],
"scripts": {
"prebuild": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps",
"build": "rm -rf dist && tsc",
"clean": "rm -rf dist",
"typecheck": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit",
"test": "vitest run --config vitest.config.ts",
"prepack": "rm -f package.dev.json && cp package.json package.dev.json && node ../../../../scripts/generate-plugin-package-json.mjs",
"postpack": "if [ -f package.dev.json ]; then mv package.dev.json package.json; fi"
},
"dependencies": {
"@boxd-sh/sdk": "^0.2.20"
},
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "^5.7.3",
"vitest": "^4.1.8"
},
"engines": {
"node": ">=24.11.0"
}
}
@@ -0,0 +1,2 @@
export { default as manifest } from "./manifest.js";
export { default as plugin } from "./plugin.js";
@@ -0,0 +1,83 @@
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
const PLUGIN_ID = "paperclip.boxd-sandbox-provider";
const PLUGIN_VERSION = "0.1.0";
const manifest: PaperclipPluginManifestV1 = {
id: PLUGIN_ID,
apiVersion: 1,
version: PLUGIN_VERSION,
displayName: "boxd Sandbox Provider",
description:
"Sandbox provider plugin that runs Paperclip environments on boxd microVMs, with suspend and resume for reused leases.",
author: "boxd",
categories: ["automation"],
capabilities: ["environment.drivers.register"],
entrypoints: {
worker: "./dist/worker.js",
},
environmentDrivers: [
{
driverKey: "boxd",
kind: "sandbox_provider",
displayName: "boxd Machine",
description:
"Creates an isolated boxd machine for each run. With reuse on, the machine is suspended after a run and resumed with its disk, memory and processes for the next run.",
sandboxCapabilities: { reusableLeases: true },
configSchema: {
type: "object",
properties: {
apiKey: {
type: "string",
format: "secret-ref",
description:
"Paste a boxd API key (`bxd_...`) or pick a saved Paperclip secret. Create one with `boxd auth keys create paperclip --org <org>`. The key decides which org the machines belong to.",
},
reuseLease: {
type: "boolean",
description:
"Suspend the machine after a run and resume it for the next run on the same issue, instead of deleting it. Disk, memory and running processes are kept.",
default: false,
},
snapshot: {
type: "string",
description:
"Optional snapshot name or ID to restore for each run, for example a machine with your tools preinstalled. Leave blank to start from the default boxd image (Ubuntu 24.04 with Node 24, git, Docker, Claude Code, Codex and OpenCode).",
"x-paperclip-advanced": true,
"x-paperclip-group": "Machine",
},
vcpu: {
type: "number",
enum: [1, 2, 4, 8],
description:
"Machine size for fresh machines: 1 vCPU/4 GB, 2/8 GB, 4/16 GB or 8/32 GB. Leave blank for the org default. A snapshot keeps its own size.",
"x-paperclip-advanced": true,
"x-paperclip-group": "Machine",
},
org: {
type: "string",
description: "Optional org name or ID. Leave blank to use the API key's org.",
"x-paperclip-advanced": true,
"x-paperclip-group": "Machine",
},
namePrefix: {
type: "string",
description: "Prefix for machine names. Machines are reachable at https://<name>.boxd.sh.",
default: "paperclip",
"x-paperclip-advanced": true,
"x-paperclip-group": "Machine",
},
timeoutMs: {
type: "number",
description: "Default command timeout in milliseconds. Paperclip also uses it as the default run timeout for this environment.",
default: 3600000,
"x-paperclip-advanced": true,
"x-paperclip-group": "Runtime",
},
},
},
},
],
};
export default manifest;
@@ -0,0 +1,323 @@
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { Boxd, NotFoundError } from "@boxd-sh/sdk";
import { afterAll, describe, expect, it } from "vitest";
import plugin from "./plugin.js";
const live = process.env.BOXD_LIVE_TEST === "1";
const hooks = plugin.definition;
const timings: Record<string, number> = {};
const params = {
driverKey: "boxd",
companyId: "boxd-plugin-smoke",
environmentId: "boxd-plugin-smoke",
config: {
...(process.env.BOXD_ORG ? { org: process.env.BOXD_ORG } : {}),
namePrefix: "paperclip-e2e",
reuseLease: true,
timeoutMs: 120_000,
},
};
const ephemeral = { ...params, config: { ...params.config, reuseLease: false } };
let sdkClient: Boxd | null = null;
function sdk(): Boxd {
sdkClient ??= new Boxd({ apiKey: process.env.BOXD_API_KEY });
return sdkClient;
}
async function timed<T>(label: string, work: () => Promise<T>): Promise<T> {
const startedAt = performance.now();
try {
return await work();
} finally {
timings[label] = Math.round(performance.now() - startedAt);
}
}
async function acquire(input: typeof params = params, extra: Record<string, unknown> = {}) {
return await hooks.onEnvironmentAcquireLease!({ ...input, runId: `smoke-${randomUUID().slice(0, 8)}`, ...extra });
}
async function cleanup(providerLeaseId: string | null | undefined) {
if (!providerLeaseId) return;
await hooks.onEnvironmentDestroyLease!({ ...params, providerLeaseId }).catch(() => undefined);
}
async function machineOrNull(id: string) {
try {
return await sdk().machines.get(id);
} catch (error) {
if (error instanceof NotFoundError) return null;
throw error;
}
}
async function waitFor(description: string, timeoutMs: number, check: () => Promise<boolean>) {
const deadline = Date.now() + timeoutMs;
while (!(await check())) {
if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${description}.`);
await new Promise((resolve) => setTimeout(resolve, 1_000));
}
}
describe.skipIf(!live)("boxd live", () => {
afterAll(async () => {
await hooks.onShutdown!();
await sdkClient?.close();
console.log(`boxd live timings (ms): ${JSON.stringify(timings)}`);
});
it("validates config before the API is called", async () => {
if (!process.env.BOXD_API_KEY) throw new Error("Set BOXD_API_KEY for the live tests.");
expect(await hooks.onEnvironmentValidateConfig!({ ...params, config: { vcpu: 3 } })).toMatchObject({ ok: false });
expect(await hooks.onEnvironmentValidateConfig!({ ...params, config: { vcpu: 2, snapshot: "x" } })).toMatchObject({ ok: false });
expect(await hooks.onEnvironmentValidateConfig!(params)).toMatchObject({ ok: true });
});
it("probes with a sized throwaway machine and deletes it", async () => {
const before = new Set((await sdk().machines.list()).map((machine) => machine.id));
const probe = await timed("probe", () => hooks.onEnvironmentProbe!({ ...params, config: { ...params.config, vcpu: 2 } }));
expect(probe).toMatchObject({ ok: true, metadata: { remoteCwd: "/home/boxd/paperclip-workspace" } });
const name = String(probe.metadata?.machineName);
expect(name).toMatch(/^paperclip-e2e-probe-/);
const leftover = (await sdk().machines.list()).filter((machine) => !before.has(machine.id) && machine.name === name);
expect(leftover).toEqual([]);
const failed = await hooks.onEnvironmentProbe!({ ...params, config: { ...params.config, apiKey: "bxd_invalid" } });
expect(failed).toMatchObject({ ok: false, metadata: { provider: "boxd" } });
expect(String(failed.metadata?.error)).not.toBe("");
}, 120_000);
it("runs create/exec/stdin/timeout/suspend/resume/stop/destroy", async () => {
const lease = await timed("acquire", () => acquire());
const providerLeaseId = lease.providerLeaseId!;
try {
const machine = await sdk().machines.get(providerLeaseId);
expect(machine.labels).toMatchObject({
"paperclip-provider": "boxd",
"paperclip-environment": "boxd-plugin-smoke",
});
expect(machine.labels["paperclip-run"]).toMatch(/^smoke-/);
expect(machine.networking.isolated).toBe(true);
if (process.env.BOXD_ORG) expect([machine.org?.id, machine.org?.name]).toContain(process.env.BOXD_ORG);
const workspace = await hooks.onEnvironmentRealizeWorkspace!({ ...params, lease, workspace: {} });
expect(workspace.cwd).toBe("/home/boxd/paperclip-workspace");
const tools = await hooks.onEnvironmentExecute!({
...params, lease, command: "sh", args: ["-c", "command -v node && command -v tar && command -v claude"],
});
expect(tools).toMatchObject({ exitCode: 0, timedOut: false });
const streams = await timed("execute", () => hooks.onEnvironmentExecute!({
...params, lease, command: "sh", args: ["-c", "cat > note.txt; printf '%s' \"$SMOKE\" >&2; exit 7"],
stdin: "persisted\n", env: { SMOKE: "stderr works" }, cwd: workspace.cwd,
}));
expect(streams).toEqual({ exitCode: 7, timedOut: false, stdout: "", stderr: "stderr works" });
const staged = await hooks.onEnvironmentExecute!({
...params, lease, command: "sh", args: ["-c", "ls /tmp | grep -c '^paperclip-stdin-' || true"],
});
expect(staged.stdout.trim()).toBe("0");
const emptyStdin = await hooks.onEnvironmentExecute!({ ...params, lease, command: "wc", args: ["-c"], stdin: "" });
expect(emptyStdin).toMatchObject({ exitCode: 0, stdout: "0\n" });
const stdinMode = await hooks.onEnvironmentExecute!({
...params, lease, command: "sh", args: ["-c", "stat -c %a \"$(readlink /proc/self/fd/0)\""], stdin: "private",
});
expect(stdinMode.stdout.trim()).toBe("600");
const quoted = await hooks.onEnvironmentExecute!({
...params, lease, command: "printf", args: ["%s|", "it's", "$HOME", "a b"],
});
expect(quoted.stdout).toBe("it's|$HOME|a b|");
const payload = randomBytes(5 * 1024 * 1024);
const digest = createHash("sha256").update(payload).digest("hex");
const upload = await timed("upload5MiB", () => hooks.onEnvironmentExecute!({
...params, lease, command: "sh", args: ["-c", "base64 -d > big.bin && sha256sum big.bin | cut -d' ' -f1"],
stdin: payload.toString("base64"), cwd: workspace.cwd,
}));
expect(upload.stdout.trim()).toBe(digest);
const download = await timed("download5MiB", () => hooks.onEnvironmentExecute!({
...params, lease, command: "base64", args: ["-w0", "big.bin"], cwd: workspace.cwd,
}));
expect(createHash("sha256").update(Buffer.from(download.stdout, "base64")).digest("hex")).toBe(digest);
const timeout = await timed("timeout", () => hooks.onEnvironmentExecute!({
...params, lease, command: "sh", args: ["-c", "sleep 47 & sleep 48; wait"], timeoutMs: 2_000,
}));
expect(timeout).toMatchObject({ exitCode: null, timedOut: true });
await waitFor("the timed out command to end", 15_000, async () => {
const left = await hooks.onEnvironmentExecute!({
...params, lease, command: "sh", args: ["-c", "pgrep -fc 'sleep 4[78]' || true"],
});
return left.stdout.trim() === "0";
});
await hooks.onEnvironmentExecute!({
...params, lease, command: "sh", args: ["-c", "setsid -f sh -c 'while true; do date +%s >> ticks; sleep 1; done' >/dev/null 2>&1 < /dev/null"], cwd: workspace.cwd,
});
await new Promise((resolve) => setTimeout(resolve, 2_000));
const released = await timed("releaseSuspend", () => hooks.onEnvironmentReleaseLease!({ ...params, providerLeaseId, leaseMetadata: lease.metadata }));
expect(released).toBeUndefined();
expect((await sdk().machines.get(providerLeaseId)).status).toBe("suspended");
expect(await hooks.onEnvironmentInspectLease!({ ...params, providerLeaseId })).toEqual({ providerLeaseId, state: "stopped" });
const resumed = await timed("resumeSuspended", () => hooks.onEnvironmentResumeLease!({ ...params, providerLeaseId, leaseMetadata: lease.metadata }));
expect(resumed.providerLeaseId).toBe(providerLeaseId);
expect(resumed.metadata).toMatchObject({ resumedLease: true, remoteCwd: workspace.cwd });
expect(await hooks.onEnvironmentInspectLease!({ ...params, providerLeaseId })).toEqual({ providerLeaseId, state: "active" });
const kept = await hooks.onEnvironmentExecute!({
...params, lease: resumed, command: "sh", args: ["-c", "cat note.txt; a=$(wc -l < ticks); sleep 2; b=$(wc -l < ticks); [ \"$b\" -gt \"$a\" ] && echo process-kept"], cwd: workspace.cwd,
});
expect(kept.stdout).toBe("persisted\nprocess-kept\n");
const stopped = await timed("stop", () => hooks.onEnvironmentStopLease!({ ...params, providerLeaseId, leaseMetadata: lease.metadata }));
expect(stopped).toEqual({ providerLeaseId, state: "stopped" });
const restarted = await timed("resumeStopped", () => hooks.onEnvironmentResumeLease!({ ...params, providerLeaseId, leaseMetadata: lease.metadata }));
const afterStop = await hooks.onEnvironmentExecute!({ ...params, lease: restarted, command: "cat", args: ["note.txt"], cwd: workspace.cwd });
expect(afterStop).toMatchObject({ exitCode: 0, stdout: "persisted\n" });
} finally {
const receipt = await timed("destroy", () => hooks.onEnvironmentDestroyLease!({ ...params, providerLeaseId, leaseMetadata: lease.metadata }));
expect(receipt).toEqual({ providerLeaseId, state: "destroyed" });
expect(await hooks.onEnvironmentInspectLease!({ ...params, providerLeaseId })).toEqual({ providerLeaseId, state: "destroyed" });
expect(await hooks.onEnvironmentResumeLease!({ ...params, providerLeaseId, leaseMetadata: lease.metadata }))
.toEqual({ providerLeaseId: null, metadata: { expired: true } });
}
}, 300_000);
it("starts a stopped machine when a command arrives, and deletes it on release without reuse", async () => {
const lease = await acquire(ephemeral, { requestedCwd: "/home/boxd/custom-cwd" });
const providerLeaseId = lease.providerLeaseId!;
try {
expect(lease.metadata).toMatchObject({ remoteCwd: "/home/boxd/custom-cwd", reuseLease: false });
await hooks.onEnvironmentExecute!({ ...ephemeral, lease, command: "sh", args: ["-c", "echo kept > note.txt"] });
await sdk().machines.stop(providerLeaseId);
await waitFor("the machine to stop", 60_000, async () => (await sdk().machines.get(providerLeaseId)).status === "stopped");
const result = await timed("executeOnStopped", () => hooks.onEnvironmentExecute!({ ...ephemeral, lease, command: "cat", args: ["note.txt"] }));
expect(result).toMatchObject({ exitCode: 0, stdout: "kept\n" });
await sdk().machines.stop(providerLeaseId);
await waitFor("the machine to stop", 60_000, async () => (await sdk().machines.get(providerLeaseId)).status === "stopped");
const realized = await hooks.onEnvironmentRealizeWorkspace!({ ...ephemeral, lease, workspace: {} });
expect(realized.cwd).toBe("/home/boxd/custom-cwd");
const receipt = await timed("releaseDestroy", () => hooks.onEnvironmentReleaseLease!({ ...ephemeral, providerLeaseId, leaseMetadata: lease.metadata }));
expect(receipt).toEqual({ providerLeaseId, state: "destroyed" });
expect(await machineOrNull(providerLeaseId)).toBeNull();
} finally {
await cleanup(providerLeaseId);
}
}, 300_000);
it("wakes and stops hibernated and suspended machines, and stops on cancel or stop_and_retain", async () => {
const lease = await acquire();
const providerLeaseId = lease.providerLeaseId!;
const leaseMetadata = lease.metadata;
const readNote = async (current: typeof lease) =>
await hooks.onEnvironmentExecute!({ ...params, lease: current, command: "cat", args: ["note.txt"] });
const hibernate = async () => {
await sdk().machines.hibernate(providerLeaseId);
await waitFor("the machine to hibernate", 120_000, async () => (await sdk().machines.get(providerLeaseId)).status === "hibernated");
};
try {
await hooks.onEnvironmentExecute!({ ...params, lease, command: "sh", args: ["-c", "echo kept > note.txt"] });
await hibernate();
expect(await hooks.onEnvironmentInspectLease!({ ...params, providerLeaseId })).toEqual({ providerLeaseId, state: "stopped" });
const woken = await timed("resumeHibernated", () => hooks.onEnvironmentResumeLease!({ ...params, providerLeaseId, leaseMetadata }));
expect(await readNote(woken)).toMatchObject({ exitCode: 0, stdout: "kept\n" });
await hibernate();
expect(await timed("stopHibernated", () => hooks.onEnvironmentStopLease!({ ...params, providerLeaseId, leaseMetadata })))
.toEqual({ providerLeaseId, state: "stopped" });
expect((await sdk().machines.get(providerLeaseId)).status).toBe("stopped");
let current = await hooks.onEnvironmentResumeLease!({ ...params, providerLeaseId, leaseMetadata });
await hooks.onEnvironmentReleaseLease!({ ...params, providerLeaseId, leaseMetadata });
expect((await sdk().machines.get(providerLeaseId)).status).toBe("suspended");
expect(await timed("stopSuspended", () => hooks.onEnvironmentStopLease!({ ...params, providerLeaseId, leaseMetadata })))
.toEqual({ providerLeaseId, state: "stopped" });
current = await hooks.onEnvironmentResumeLease!({ ...params, providerLeaseId, leaseMetadata });
expect(await hooks.onEnvironmentReleaseLease!({ ...params, providerLeaseId, leaseMetadata, cancelActiveWork: true }))
.toEqual({ providerLeaseId, state: "stopped" });
current = await hooks.onEnvironmentResumeLease!({ ...params, providerLeaseId, leaseMetadata });
expect(await hooks.onEnvironmentReleaseLease!({ ...ephemeral, providerLeaseId, leaseMetadata, resourceDisposition: "stop_and_retain" }))
.toEqual({ providerLeaseId, state: "stopped" });
current = await hooks.onEnvironmentResumeLease!({ ...params, providerLeaseId, leaseMetadata });
expect(await readNote(current)).toMatchObject({ exitCode: 0, stdout: "kept\n" });
} finally {
await cleanup(providerLeaseId);
}
}, 600_000);
it("bounds the machine lifetime at a requested expiry", async () => {
const requestedExpiresAt = new Date(Date.now() + 45_000).toISOString();
const lease = await acquire(ephemeral, { requestedExpiresAt });
const providerLeaseId = lease.providerLeaseId!;
try {
expect(Date.parse(lease.expiresAt!)).toBeLessThanOrEqual(Date.parse(requestedExpiresAt));
const machine = await sdk().machines.get(providerLeaseId);
expect(machine.deleteAt).not.toBeNull();
expect(machine.deleteAt!.getTime()).toBeLessThanOrEqual(Date.parse(requestedExpiresAt));
await timed("expiry", () => waitFor("boxd to delete the expired machine", 180_000, async () => {
const current = await machineOrNull(providerLeaseId);
return current === null || current.status === "destroyed";
}));
expect(await hooks.onEnvironmentInspectLease!({ ...ephemeral, providerLeaseId })).toEqual({ providerLeaseId, state: "destroyed" });
} finally {
await cleanup(providerLeaseId);
}
}, 300_000);
it("restores each run from a snapshot", async () => {
const source = await acquire(ephemeral);
const snapshotName = `paperclip-e2e-${randomUUID().slice(0, 8)}`;
let restoredId: string | null = null;
try {
await hooks.onEnvironmentExecute!({ ...ephemeral, lease: source, command: "sh", args: ["-c", "echo from-snapshot > /home/boxd/golden.txt"] });
await timed("snapshotSave", () => sdk().snapshots.create(source.providerLeaseId!, snapshotName));
await waitFor("the snapshot to be ready", 300_000, async () => {
const snapshot = await sdk().snapshots.get(snapshotName).catch(() => null);
return snapshot?.status === "ready";
});
const snapshotParams = { ...ephemeral, config: { ...ephemeral.config, snapshot: snapshotName } };
const restored = await timed("acquireFromSnapshot", () => acquire(snapshotParams));
restoredId = restored.providerLeaseId!;
expect(restored.metadata).toMatchObject({ snapshot: snapshotName });
expect((await sdk().machines.get(restoredId)).networking.isolated).toBe(true);
const golden = await hooks.onEnvironmentExecute!({ ...snapshotParams, lease: restored, command: "cat", args: ["/home/boxd/golden.txt"] });
expect(golden).toMatchObject({ exitCode: 0, stdout: "from-snapshot\n" });
} finally {
await cleanup(restoredId);
await cleanup(source.providerLeaseId);
await sdk().snapshots.delete(snapshotName).catch(() => undefined);
}
}, 600_000);
it("creates fresh machines at the configured size", async () => {
const sized = { ...ephemeral, config: { ...ephemeral.config, vcpu: 2 } };
const lease = await acquire(sized);
try {
const machine = await sdk().machines.get(lease.providerLeaseId!);
expect(machine.resources.vcpu).toBe(2);
expect(machine.resources.memoryBytes).toBe(8 * 1024 ** 3);
const nproc = await hooks.onEnvironmentExecute!({ ...sized, lease, command: "nproc" });
expect(nproc.stdout.trim()).toBe("2");
} finally {
await cleanup(lease.providerLeaseId);
}
}, 180_000);
it("leaves no machines behind", async () => {
const leftover = (await sdk().machines.list())
.filter((machine) => machine.name.startsWith("paperclip-e2e-") && machine.status !== "destroyed");
expect(leftover.map((machine) => machine.name)).toEqual([]);
});
});
@@ -0,0 +1,486 @@
import { createHash } from "node:crypto";
import { PluginEnvironmentCreationCleanupError } from "@paperclipai/plugin-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
const sdk = vi.hoisted(() => {
class BoxdError extends Error {
grpcCode?: number;
constructor(message: string, grpcCode?: number) {
super(message);
this.grpcCode = grpcCode;
}
}
class NotFoundError extends BoxdError {}
class ConflictError extends BoxdError {}
class AgentOutdatedError extends ConflictError {}
class FilesystemError extends BoxdError {}
const machines = {
create: vi.fn(),
get: vi.fn(),
delete: vi.fn(),
exec: vi.fn(),
waitUntilReady: vi.fn(),
pause: vi.fn(),
resume: vi.fn(),
wake: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
setDeleteAfter: vi.fn(),
files: {
upload: vi.fn(),
delete: vi.fn(),
},
fs: {
write: vi.fn(),
remove: vi.fn(),
mkdir: vi.fn(),
},
};
const constructed: Array<{ apiKey?: string }> = [];
const close = vi.fn().mockResolvedValue(undefined);
class Boxd {
machines = machines;
close = close;
constructor(options: { apiKey?: string }) {
constructed.push(options);
}
}
return { Boxd, BoxdError, NotFoundError, ConflictError, AgentOutdatedError, FilesystemError, machines, constructed, close };
});
vi.mock("@boxd-sh/sdk", () => ({
Boxd: sdk.Boxd,
BoxdError: sdk.BoxdError,
NotFoundError: sdk.NotFoundError,
ConflictError: sdk.ConflictError,
AgentOutdatedError: sdk.AgentOutdatedError,
FilesystemError: sdk.FilesystemError,
}));
import plugin from "./plugin.js";
const { machines } = sdk;
const hooks = plugin.definition;
const base = { driverKey: "boxd", companyId: "company-1", environmentId: "env-12345678abc" };
function machine(overrides: Partial<{ id: string; name: string; status: string }> = {}) {
return { id: "vm-1", name: "paperclip-env12345-run12345-abcd", status: "running", ...overrides };
}
function execOk(stdout = "", stderr = "", exitCode = 0) {
return { stdout, stderr, exitCode, success: exitCode === 0 };
}
function lease(metadata: Record<string, unknown> = {}) {
return { providerLeaseId: "vm-1", metadata: { remoteCwd: "/home/boxd/paperclip-workspace", ...metadata } };
}
describe("boxd sandbox provider plugin", () => {
beforeEach(async () => {
vi.clearAllMocks();
await hooks.onShutdown?.();
sdk.constructed.length = 0;
delete process.env.BOXD_API_KEY;
machines.create.mockResolvedValue(machine());
machines.waitUntilReady.mockResolvedValue(machine());
machines.get.mockRejectedValue(new sdk.NotFoundError("not found", 5));
machines.delete.mockResolvedValue(undefined);
machines.exec.mockImplementation(async (_id: string, params: { command: string }) =>
params.command.startsWith("printf") ? execOk("/home/boxd\n") : execOk(),
);
machines.setDeleteAfter.mockImplementation(async (_id: string, seconds: number) => new Date(Date.now() + seconds * 1000));
machines.files.upload.mockResolvedValue(12);
machines.files.delete.mockResolvedValue(undefined);
machines.fs.write.mockResolvedValue({ size: 8 });
machines.fs.remove.mockResolvedValue(undefined);
machines.fs.mkdir.mockResolvedValue(undefined);
});
it("declares environment lifecycle handlers", async () => {
expect(await hooks.onHealth?.()).toEqual({ status: "ok", message: "boxd sandbox provider plugin healthy" });
for (const hook of [
"onEnvironmentAcquireLease",
"onEnvironmentResumeLease",
"onEnvironmentReleaseLease",
"onEnvironmentStopLease",
"onEnvironmentDestroyLease",
"onEnvironmentInspectLease",
"onEnvironmentRealizeWorkspace",
"onEnvironmentExecute",
] as const) {
expect(hooks[hook]).toBeTypeOf("function");
}
});
it("normalizes config", async () => {
const result = await hooks.onEnvironmentValidateConfig?.({
driverKey: "boxd",
config: { apiKey: " bxd_test ", org: " acme ", vcpu: "4", namePrefix: "My Paperclip!", timeoutMs: "450000.9", reuseLease: true },
});
expect(result).toEqual({
ok: true,
normalizedConfig: {
apiKey: "bxd_test",
org: "acme",
snapshot: null,
vcpu: 4,
namePrefix: "my-paperclip",
timeoutMs: 450000,
reuseLease: true,
},
});
});
it("rejects sizes boxd does not offer, a size with a snapshot, and a missing key", async () => {
const result = await hooks.onEnvironmentValidateConfig?.({
driverKey: "boxd",
config: { vcpu: 3, snapshot: "golden", timeoutMs: 0 },
});
expect(result?.ok).toBe(false);
expect(result?.errors).toEqual([
"timeoutMs must be between 1 and 86400000.",
"vcpu must be 1, 2, 4 or 8. boxd sizes are 1 vCPU/4 GB, 2/8 GB, 4/16 GB and 8/32 GB.",
"vcpu applies to fresh machines only. A machine restored from a snapshot keeps the snapshot's size; clear vcpu or snapshot.",
"boxd environments require an API key in config or BOXD_API_KEY.",
]);
});
it("accepts the BOXD_API_KEY host fallback", async () => {
process.env.BOXD_API_KEY = "bxd_env";
const result = await hooks.onEnvironmentValidateConfig?.({ driverKey: "boxd", config: {} });
expect(result?.ok).toBe(true);
});
it("acquires an isolated machine, waits until it is ready, and prepares the workspace", async () => {
const result = await hooks.onEnvironmentAcquireLease?.({
...base,
runId: "run-12345678-xyz",
config: { apiKey: "bxd_test", org: "acme", vcpu: 2 },
});
expect(machines.create).toHaveBeenCalledWith({
name: expect.stringMatching(/^paperclip-env12345-run12345-[0-9a-f]{4}$/),
org: "acme",
isolated: true,
labels: { "paperclip-provider": "boxd", "paperclip-environment": "env-12345678abc", "paperclip-run": "run-12345678-xyz" },
config: { autoSuspendTimeout: 0, vcpu: 2, memory: "8G" },
});
expect(machines.waitUntilReady).toHaveBeenCalledWith("vm-1", { timeout: 120000 });
expect(machines.fs.mkdir).toHaveBeenCalledWith("vm-1", "/home/boxd/paperclip-workspace");
expect(machines.setDeleteAfter).not.toHaveBeenCalled();
expect(result).toEqual({
providerLeaseId: "vm-1",
metadata: {
provider: "boxd",
machineId: "vm-1",
machineName: "paperclip-env12345-run12345-abcd",
sandboxName: "paperclip-env12345-run12345-abcd",
url: "https://paperclip-env12345-run12345-abcd.boxd.sh",
org: "acme",
snapshot: null,
shellCommand: "bash",
remoteCwd: "/home/boxd/paperclip-workspace",
timeoutMs: 3600000,
reuseLease: false,
resumedLease: false,
},
});
});
it("restores from a snapshot without a size", async () => {
await hooks.onEnvironmentAcquireLease?.({ ...base, runId: "run-1", config: { apiKey: "bxd_test", snapshot: "golden" } });
expect(machines.create).toHaveBeenCalledWith(expect.objectContaining({
fromSnapshot: "golden",
isolated: true,
config: { autoSuspendTimeout: 0 },
}));
});
it("bounds the machine lifetime at or before a requested expiry", async () => {
const requestedExpiresAt = new Date(Date.now() + 600_000).toISOString();
const result = await hooks.onEnvironmentAcquireLease?.({ ...base, runId: "run-1", config: { apiKey: "bxd_test" }, requestedExpiresAt });
expect(machines.create.mock.calls[0][0]).not.toHaveProperty("deleteAfter");
const [id, deleteAfter] = machines.setDeleteAfter.mock.calls[0];
expect(id).toBe("vm-1");
expect(deleteAfter).toBeGreaterThan(580);
expect(deleteAfter).toBeLessThanOrEqual(595);
expect(Date.parse(result!.expiresAt!)).toBeLessThanOrEqual(Date.parse(requestedExpiresAt));
});
it("deletes the machine when its lifetime cannot be bounded", async () => {
machines.setDeleteAfter.mockRejectedValue(new sdk.BoxdError("unavailable", 14));
const requestedExpiresAt = new Date(Date.now() + 600_000).toISOString();
await expect(hooks.onEnvironmentAcquireLease?.({ ...base, runId: "run-1", config: { apiKey: "bxd_test" }, requestedExpiresAt }))
.rejects.toThrow("unavailable");
expect(machines.delete).toHaveBeenCalledWith("vm-1");
});
it("refuses an expiry it cannot honor before creating anything", async () => {
await expect(hooks.onEnvironmentAcquireLease?.({
...base,
runId: "run-1",
config: { apiKey: "bxd_test" },
requestedExpiresAt: new Date(Date.now() + 2_000).toISOString(),
})).rejects.toThrow(/less than six seconds/);
expect(machines.create).not.toHaveBeenCalled();
});
it("deletes the machine when workspace setup fails", async () => {
machines.fs.mkdir.mockRejectedValue(new sdk.FilesystemError("/home/boxd/paperclip-workspace: Read-only file system"));
await expect(hooks.onEnvironmentAcquireLease?.({ ...base, runId: "run-1", config: { apiKey: "bxd_test" } }))
.rejects.toThrow("Failed to create /home/boxd/paperclip-workspace on boxd machine vm-1: /home/boxd/paperclip-workspace: Read-only file system");
expect(machines.delete).toHaveBeenCalledWith("vm-1");
});
it("hands the host the machine when it cannot be deleted after a failed acquisition", async () => {
machines.waitUntilReady.mockRejectedValue(new sdk.BoxdError("not ready", 4));
machines.delete.mockRejectedValue(new sdk.BoxdError("unavailable", 14));
const error = await hooks.onEnvironmentAcquireLease?.({
...base,
runId: "run-12345678-xyz",
config: { apiKey: "bxd_test" },
}).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(PluginEnvironmentCreationCleanupError);
const cleanupError = error as PluginEnvironmentCreationCleanupError;
expect(cleanupError.errors.map((cause) => (cause as Error).message)).toEqual(["not ready", "unavailable"]);
expect(cleanupError.cleanup).toEqual({
providerLeaseId: "vm-1",
companyId: "company-1",
environmentId: "env-12345678abc",
runId: "run-12345678-xyz",
attemptId: expect.stringMatching(/^[0-9a-f-]{36}$/),
accountFingerprint: createHash("sha256").update("bxd_test").digest("hex"),
labels: { "paperclip-provider": "boxd", "paperclip-environment": "env-12345678abc", "paperclip-run": "run-12345678-xyz" },
});
});
it("destroys a failed creation the host hands back only when it matches the machine", async () => {
const failedCreateCleanup = {
providerLeaseId: "vm-1",
companyId: "company-1",
environmentId: "env-12345678abc",
runId: "run-1",
attemptId: "attempt-1",
accountFingerprint: createHash("sha256").update("bxd_test").digest("hex"),
labels: { "paperclip-provider": "boxd", "paperclip-run": "run-1" },
};
const owned = machine({ labels: { "paperclip-provider": "boxd", "paperclip-run": "run-1" } } as never);
machines.get.mockResolvedValueOnce(owned).mockRejectedValue(new sdk.NotFoundError("not found", 5));
expect(await hooks.onEnvironmentDestroyLease?.({
...base, config: { apiKey: "bxd_test" }, providerLeaseId: "vm-1", leaseMetadata: { failedCreateCleanup },
})).toEqual({ providerLeaseId: "vm-1", state: "destroyed" });
expect(machines.delete).toHaveBeenCalledWith("vm-1");
machines.delete.mockClear();
machines.get.mockResolvedValue(machine({ labels: { "paperclip-provider": "boxd", "paperclip-run": "run-2" } } as never));
await expect(hooks.onEnvironmentDestroyLease?.({
...base, config: { apiKey: "bxd_test" }, providerLeaseId: "vm-1", leaseMetadata: { failedCreateCleanup },
})).rejects.toThrow("does not match");
await expect(hooks.onEnvironmentDestroyLease?.({
...base, config: { apiKey: "bxd_other" }, providerLeaseId: "vm-1", leaseMetadata: { failedCreateCleanup },
})).rejects.toThrow("does not match");
expect(machines.delete).not.toHaveBeenCalled();
});
it("reuses one client per API key", async () => {
const config = { apiKey: "bxd_test" };
await hooks.onEnvironmentAcquireLease?.({ ...base, runId: "run-1", config });
await hooks.onEnvironmentExecute?.({ ...base, config, lease: lease(), command: "true" });
await hooks.onEnvironmentDestroyLease?.({ ...base, config, providerLeaseId: "vm-1" });
expect(sdk.constructed).toEqual([{ apiKey: "bxd_test" }]);
});
it("executes through a login shell with quoted env, cwd and timeout", async () => {
machines.exec.mockResolvedValue(execOk("out\n", "err\n", 42));
const result = await hooks.onEnvironmentExecute?.({
...base,
config: { apiKey: "bxd_test", timeoutMs: 1000 },
lease: lease(),
command: "sh",
args: ["-c", "echo $GREETING"],
env: { GREETING: "it's here" },
timeoutMs: 5000,
});
const [id, params] = machines.exec.mock.calls[0];
expect(id).toBe("vm-1");
expect(params.cwd).toBe("/home/boxd/paperclip-workspace");
expect(params.timeout).toBe(5000);
expect(params.command).toContain(". \"$HOME/.profile\"");
expect(params.command).toMatch(/exec env GREETING='it'"'"'s here' 'sh' '-c' 'echo \$GREETING'$/);
expect(result).toEqual({ exitCode: 42, timedOut: false, stdout: "out\n", stderr: "err\n" });
});
it("falls back to the configured timeout", async () => {
await hooks.onEnvironmentExecute?.({ ...base, config: { apiKey: "bxd_test", timeoutMs: 1234 }, lease: lease(), command: "true", timeoutMs: 0 });
expect(machines.exec.mock.calls[0][1].timeout).toBe(1234);
});
it("stages stdin in a private temporary file and removes it afterwards", async () => {
await hooks.onEnvironmentExecute?.({ ...base, config: { apiKey: "bxd_test" }, lease: lease(), command: "cat", stdin: "payload\n" });
const [, stdinPath, contents, options] = machines.fs.write.mock.calls[0];
expect(stdinPath).toMatch(/^\/tmp\/paperclip-stdin-/);
expect(contents).toBe("payload\n");
expect(options).toEqual({ mode: 0o600 });
expect(machines.exec.mock.calls[0][1].command).toMatch(new RegExp(`exec 'cat' < '${stdinPath}'$`));
expect(machines.fs.remove).toHaveBeenCalledWith("vm-1", stdinPath);
expect(machines.files.upload).not.toHaveBeenCalled();
});
it("stages an empty stdin", async () => {
await hooks.onEnvironmentExecute?.({ ...base, config: { apiKey: "bxd_test" }, lease: lease(), command: "cat", stdin: "" });
expect(machines.fs.write.mock.calls[0][2]).toBe("");
});
it("falls back to the older file calls on a machine whose boxd agent predates the filesystem API", async () => {
const outdated = () => new sdk.AgentOutdatedError("vm-1 was started before the filesystem API existed; reboot it to update it", 9);
machines.fs.write.mockRejectedValue(outdated());
machines.fs.remove.mockRejectedValue(outdated());
machines.fs.mkdir.mockRejectedValue(outdated());
await hooks.onEnvironmentExecute?.({ ...base, config: { apiKey: "bxd_test" }, lease: lease(), command: "cat", stdin: "payload\n" });
const [, stdinPath, contents] = machines.files.upload.mock.calls[0];
expect(contents).toBe("payload\n");
expect(machines.files.delete).toHaveBeenCalledWith("vm-1", stdinPath);
await hooks.onEnvironmentRealizeWorkspace?.({ ...base, config: { apiKey: "bxd_test" }, lease: lease(), workspace: {} });
expect(machines.exec).toHaveBeenLastCalledWith("vm-1", { command: "mkdir -p '/home/boxd/paperclip-workspace'" });
});
it("reports an exec timeout as timedOut", async () => {
machines.exec.mockRejectedValue(new sdk.BoxdError("exec timed out after 5000ms"));
const result = await hooks.onEnvironmentExecute?.({ ...base, config: { apiKey: "bxd_test" }, lease: lease(), command: "sleep", args: ["60"], timeoutMs: 5000 });
expect(result).toEqual({ exitCode: null, timedOut: true, stdout: "", stderr: "exec timed out after 5000ms\n" });
});
it("starts a stopped machine and retries the command once", async () => {
machines.exec
.mockRejectedValueOnce(new sdk.ConflictError("VM paperclip-x is stopped, so it won't be resumed automatically.", 9))
.mockResolvedValueOnce(execOk("ok\n"));
const result = await hooks.onEnvironmentExecute?.({ ...base, config: { apiKey: "bxd_test" }, lease: lease(), command: "echo", args: ["ok"] });
expect(machines.start).toHaveBeenCalledWith("vm-1");
expect(machines.waitUntilReady).toHaveBeenCalledWith("vm-1", { timeout: 120000 });
expect(result?.stdout).toBe("ok\n");
});
it("rejects an invalid env key", async () => {
await expect(hooks.onEnvironmentExecute?.({ ...base, config: { apiKey: "bxd_test" }, lease: lease(), command: "true", env: { "BAD-KEY": "x" } }))
.rejects.toThrow("Invalid sandbox environment variable key: BAD-KEY");
});
it.each([
["suspended", "resume"],
["hibernated", "wake"],
["stopped", "start"],
] as const)("resumes a %s machine with %s and keeps the provider lease id", async (status, method) => {
machines.get.mockResolvedValue(machine({ status }));
const result = await hooks.onEnvironmentResumeLease?.({
...base,
config: { apiKey: "bxd_test", reuseLease: true },
providerLeaseId: "vm-1",
leaseMetadata: { remoteCwd: "/home/boxd/project" },
});
expect(machines[method]).toHaveBeenCalledWith("vm-1");
expect(machines.waitUntilReady).toHaveBeenCalledWith("vm-1", { timeout: 120000 });
expect(result?.providerLeaseId).toBe("vm-1");
expect(result?.metadata).toMatchObject({ resumedLease: true, remoteCwd: "/home/boxd/project", reuseLease: true });
});
it("reports a missing machine as an expired lease", async () => {
const result = await hooks.onEnvironmentResumeLease?.({ ...base, config: { apiKey: "bxd_test" }, providerLeaseId: "vm-gone" });
expect(result).toEqual({ providerLeaseId: null, metadata: { expired: true } });
});
it("suspends a reused machine on release instead of deleting it", async () => {
machines.get.mockResolvedValue(machine());
const result = await hooks.onEnvironmentReleaseLease?.({ ...base, config: { apiKey: "bxd_test", reuseLease: true }, providerLeaseId: "vm-1" });
expect(machines.pause).toHaveBeenCalledWith("vm-1");
expect(machines.delete).not.toHaveBeenCalled();
expect(result).toBeUndefined();
});
it("deletes on release and returns a receipt only after the machine is gone", async () => {
machines.get.mockResolvedValueOnce(machine()).mockRejectedValue(new sdk.NotFoundError("not found", 5));
const result = await hooks.onEnvironmentReleaseLease?.({ ...base, config: { apiKey: "bxd_test" }, providerLeaseId: "vm-1" });
expect(machines.delete).toHaveBeenCalledWith("vm-1");
expect(machines.get).toHaveBeenCalledTimes(2);
expect(result).toEqual({ providerLeaseId: "vm-1", state: "destroyed" });
});
it("stops and retains files when the host asks for it", async () => {
machines.get.mockResolvedValueOnce(machine()).mockResolvedValue(machine({ status: "stopped" }));
const result = await hooks.onEnvironmentReleaseLease?.({
...base,
config: { apiKey: "bxd_test" },
providerLeaseId: "vm-1",
resourceDisposition: "stop_and_retain",
});
expect(machines.stop).toHaveBeenCalledWith("vm-1");
expect(machines.delete).not.toHaveBeenCalled();
expect(result).toEqual({ providerLeaseId: "vm-1", state: "stopped" });
});
it("stops a reused machine when active work is cancelled", async () => {
machines.get.mockResolvedValueOnce(machine()).mockResolvedValue(machine({ status: "stopped" }));
const result = await hooks.onEnvironmentReleaseLease?.({
...base,
config: { apiKey: "bxd_test", reuseLease: true },
providerLeaseId: "vm-1",
cancelActiveWork: true,
});
expect(machines.pause).not.toHaveBeenCalled();
expect(result).toEqual({ providerLeaseId: "vm-1", state: "stopped" });
});
it("treats an already deleted machine as destroyed", async () => {
machines.delete.mockRejectedValue(new sdk.NotFoundError("not found", 5));
const result = await hooks.onEnvironmentDestroyLease?.({ ...base, config: { apiKey: "bxd_test" }, providerLeaseId: "vm-1" });
expect(result).toEqual({ providerLeaseId: "vm-1", state: "destroyed" });
});
it.each([
["running", "active"],
["starting", "active"],
["suspended", "stopped"],
["hibernated", "stopped"],
["stopped", "stopped"],
["failed", "unknown"],
])("inspects a %s machine as %s", async (status, state) => {
machines.get.mockResolvedValue(machine({ status }));
expect(await hooks.onEnvironmentInspectLease?.({ ...base, config: { apiKey: "bxd_test" }, providerLeaseId: "vm-1" }))
.toEqual({ providerLeaseId: "vm-1", state });
});
it("inspects a missing machine as destroyed and surfaces other errors", async () => {
expect(await hooks.onEnvironmentInspectLease?.({ ...base, config: { apiKey: "bxd_test" }, providerLeaseId: "vm-1" }))
.toEqual({ providerLeaseId: "vm-1", state: "destroyed" });
machines.get.mockRejectedValue(new sdk.BoxdError("unavailable", 14));
await expect(hooks.onEnvironmentInspectLease?.({ ...base, config: { apiKey: "bxd_test" }, providerLeaseId: "vm-1" }))
.rejects.toThrow("unavailable");
});
it("probes with a throwaway machine that boxd deletes on a timer, and deletes it", async () => {
const result = await hooks.onEnvironmentProbe?.({ ...base, config: { apiKey: "bxd_test" } });
expect(result?.ok).toBe(true);
expect(machines.create.mock.calls[0][0].name).toMatch(/^paperclip-probe-/);
expect(machines.setDeleteAfter).toHaveBeenCalledWith("vm-1", 900);
expect(machines.delete).toHaveBeenCalledWith("vm-1");
});
it("fails a probe whose machine could not be deleted", async () => {
machines.delete.mockRejectedValue(new sdk.BoxdError("unavailable", 14));
const result = await hooks.onEnvironmentProbe?.({ ...base, config: { apiKey: "bxd_test" } });
expect(result).toMatchObject({
ok: false,
summary: "boxd environment probe could not delete its machine paperclip-env12345-run12345-abcd.",
metadata: { machineId: "vm-1", cleanupError: "unavailable", remoteCwd: "/home/boxd/paperclip-workspace" },
});
});
it("reports a probe failure without throwing", async () => {
machines.create.mockRejectedValue(new sdk.BoxdError("quota exceeded", 8));
const result = await hooks.onEnvironmentProbe?.({ ...base, config: { apiKey: "bxd_test" } });
expect(result).toMatchObject({ ok: false, summary: "boxd environment probe failed.", metadata: { error: "quota exceeded" } });
});
});
@@ -0,0 +1,729 @@
import path from "node:path";
import { createHash, randomUUID } from "node:crypto";
import { AgentOutdatedError, Boxd, BoxdError, ConflictError, FilesystemError, NotFoundError } from "@boxd-sh/sdk";
import type { Machine } from "@boxd-sh/sdk";
import {
definePlugin,
PluginEnvironmentCreationCleanupError,
readEnvironmentCreationCleanupError,
} from "@paperclipai/plugin-sdk";
import type {
PluginEnvironmentCreationCleanup,
PluginEnvironmentAcquireLeaseParams,
PluginEnvironmentDestroyLeaseParams,
PluginEnvironmentExecuteParams,
PluginEnvironmentExecuteResult,
PluginEnvironmentInspectLeaseParams,
PluginEnvironmentLease,
PluginEnvironmentLeaseObservation,
PluginEnvironmentProbeParams,
PluginEnvironmentProbeResult,
PluginEnvironmentRealizeWorkspaceParams,
PluginEnvironmentRealizeWorkspaceResult,
PluginEnvironmentReleaseLeaseParams,
PluginEnvironmentResumeLeaseParams,
PluginEnvironmentTerminationReceipt,
PluginEnvironmentValidateConfigParams,
PluginEnvironmentValidationResult,
} from "@paperclipai/plugin-sdk";
interface BoxdDriverConfig {
apiKey: string | null;
org: string | null;
snapshot: string | null;
vcpu: number | null;
namePrefix: string;
timeoutMs: number;
reuseLease: boolean;
}
const DEFAULT_TIMEOUT_MS = 3_600_000;
const READY_TIMEOUT_MS = 120_000;
const STATE_CONFIRM_TIMEOUT_MS = 60_000;
const STATE_POLL_INTERVAL_MS = 250;
const EXPIRY_MARGIN_SECONDS = 5;
const PROBE_DELETE_AFTER_SECONDS = 900;
const STDIN_MODE = 0o600;
const CREATION_CLEANUP_SCHEMA = "paperclip/environment-creation-cleanup/v1";
const CLEANUP_IDENTIFIER = /^[a-zA-Z0-9_-]{1,200}$/;
const WORKSPACE_DIR = "paperclip-workspace";
const FALLBACK_HOME = "/home/boxd";
const MEMORY_BY_VCPU: Record<number, string> = { 1: "4G", 2: "8G", 4: "16G", 8: "32G" };
const ACTIVE_STATES = new Set(["pending", "starting", "running", "migrating"]);
const STOPPED_STATES = new Set(["suspended", "hibernated", "stopped"]);
const clients = new Map<string, Boxd>();
function parseOptionalString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function parseOptionalInteger(value: unknown): number | null {
if (value == null || value === "") return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.trunc(parsed) : null;
}
function normalizeNamePrefix(value: string | null): string {
const normalized = (value ?? "paperclip")
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/^-+|-+$/g, "")
.replace(/-{2,}/g, "-");
return normalized.length > 0 ? normalized.slice(0, 24).replace(/-+$/, "") : "paperclip";
}
function parseDriverConfig(raw: Record<string, unknown>): BoxdDriverConfig {
const timeoutMs = Number(raw.timeoutMs ?? DEFAULT_TIMEOUT_MS);
return {
apiKey: parseOptionalString(raw.apiKey),
org: parseOptionalString(raw.org),
snapshot: parseOptionalString(raw.snapshot),
vcpu: parseOptionalInteger(raw.vcpu),
namePrefix: normalizeNamePrefix(parseOptionalString(raw.namePrefix)),
timeoutMs: Number.isFinite(timeoutMs) ? Math.trunc(timeoutMs) : DEFAULT_TIMEOUT_MS,
reuseLease: raw.reuseLease === true,
};
}
function resolveApiKey(config: BoxdDriverConfig): string {
if (config.apiKey) return config.apiKey;
const envApiKey = process.env.BOXD_API_KEY?.trim() ?? "";
if (!envApiKey) {
throw new Error("boxd environments require an API key in config or BOXD_API_KEY.");
}
return envApiKey;
}
function accountFingerprint(config: BoxdDriverConfig): string {
return createHash("sha256").update(resolveApiKey(config)).digest("hex");
}
function clientFor(config: BoxdDriverConfig): Boxd {
const cacheKey = accountFingerprint(config);
let client = clients.get(cacheKey);
if (!client) {
client = new Boxd({ apiKey: resolveApiKey(config) });
clients.set(cacheKey, client);
}
return client;
}
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
function isValidShellEnvKey(value: string): boolean {
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
}
function formatErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function namePart(value: string): string {
return value.replace(/[^a-z0-9]+/gi, "").slice(0, 8).toLowerCase();
}
function buildMachineName(config: BoxdDriverConfig, environmentId: string, runId: string): string {
const envPart = namePart(environmentId) || "env";
const runPart = namePart(runId) || randomUUID().slice(0, 8);
return `${config.namePrefix}-${envPart}-${runPart}-${randomUUID().slice(0, 4)}`.slice(0, 63);
}
function machineLabels(environmentId: string, runId: string): Record<string, string> {
const labels: Record<string, string> = { "paperclip-provider": "boxd" };
const environment = environmentId.replace(/[^A-Za-z0-9._-]+/g, "").slice(0, 63);
const run = runId.replace(/[^A-Za-z0-9._-]+/g, "").slice(0, 63);
if (environment) labels["paperclip-environment"] = environment;
if (run) labels["paperclip-run"] = run;
return labels;
}
function expirySeconds(requestedExpiresAt: string, now: number): number {
const deadline = Date.parse(requestedExpiresAt);
if (!Number.isFinite(deadline)) {
throw new Error(`requestedExpiresAt is not a valid timestamp: ${requestedExpiresAt}`);
}
const seconds = Math.floor((deadline - now) / 1000) - EXPIRY_MARGIN_SECONDS;
if (seconds < 1) {
throw new Error("boxd cannot bound a lease whose requested expiry is less than six seconds away.");
}
return seconds;
}
async function createMachine(
client: Boxd,
config: BoxdDriverConfig,
input: { name: string; labels: Record<string, string> },
): Promise<Machine> {
const common = {
name: input.name,
...(config.org ? { org: config.org } : {}),
isolated: true,
labels: input.labels,
};
return config.snapshot
? await client.machines.create({
...common,
fromSnapshot: config.snapshot,
config: { autoSuspendTimeout: 0 },
})
: await client.machines.create({
...common,
config: {
autoSuspendTimeout: 0,
...(config.vcpu ? { vcpu: config.vcpu, memory: MEMORY_BY_VCPU[config.vcpu] } : {}),
},
});
}
async function boundLifetime(client: Boxd, machineId: string, requestedExpiresAt: string): Promise<string> {
const deleteAt = await client.machines.setDeleteAfter(machineId, expirySeconds(requestedExpiresAt, Date.now()));
const requested = Date.parse(requestedExpiresAt);
return new Date(Math.min(deleteAt?.getTime() ?? requested, requested)).toISOString();
}
function creationCleanup(
config: BoxdDriverConfig,
machineId: string,
owner: { companyId: string; environmentId: string; runId: string },
labels: Record<string, string>,
): PluginEnvironmentCreationCleanup {
return {
providerLeaseId: machineId,
companyId: owner.companyId,
environmentId: owner.environmentId,
runId: owner.runId,
attemptId: randomUUID(),
accountFingerprint: accountFingerprint(config),
labels: Object.fromEntries(Object.entries(labels).filter(([, value]) => CLEANUP_IDENTIFIER.test(value))),
};
}
async function machineOrNull(client: Boxd, machineId: string): Promise<Machine | null> {
try {
return await client.machines.get(machineId);
} catch (error) {
if (error instanceof NotFoundError) return null;
throw error;
}
}
async function checkFailedCreation(
client: Boxd,
config: BoxdDriverConfig,
params: PluginEnvironmentDestroyLeaseParams,
): Promise<void> {
const cleanup = readEnvironmentCreationCleanupError({
data: { schema: CREATION_CLEANUP_SCHEMA, cleanup: params.leaseMetadata?.failedCreateCleanup },
});
const matches = cleanup !== null
&& cleanup.companyId === params.companyId
&& cleanup.environmentId === params.environmentId
&& cleanup.providerLeaseId === params.providerLeaseId
&& cleanup.accountFingerprint === accountFingerprint(config);
const machine = matches ? await machineOrNull(client, cleanup.providerLeaseId) : null;
if (!matches || (machine && Object.entries(cleanup.labels).some(([key, value]) => machine.labels[key] !== value))) {
throw new Error("The failed-creation cleanup does not match a boxd machine that this environment created.");
}
}
async function confirmState(
client: Boxd,
machineId: string,
accept: (machine: Machine | null) => boolean,
description: string,
): Promise<Machine | null> {
const deadline = Date.now() + STATE_CONFIRM_TIMEOUT_MS;
for (;;) {
let machine: Machine | null;
try {
machine = await client.machines.get(machineId);
} catch (error) {
if (!(error instanceof NotFoundError)) throw error;
machine = null;
}
if (accept(machine)) return machine;
if (Date.now() >= deadline) {
throw new Error(`boxd did not confirm that machine ${machineId} is ${description} (last status: ${machine?.status ?? "missing"}).`);
}
await sleep(STATE_POLL_INTERVAL_MS);
}
}
async function withAgentFallback(work: () => Promise<void>, fallback: () => Promise<void>): Promise<void> {
try {
await work();
} catch (error) {
if (!(error instanceof AgentOutdatedError)) throw error;
await fallback();
}
}
async function destroyMachine(client: Boxd, machineId: string): Promise<PluginEnvironmentTerminationReceipt> {
try {
await client.machines.delete(machineId);
} catch (error) {
if (!(error instanceof NotFoundError)) throw error;
}
await confirmState(client, machineId, (machine) => machine === null || machine.status === "destroyed", "destroyed");
return { providerLeaseId: machineId, state: "destroyed" };
}
async function stopMachine(client: Boxd, machineId: string): Promise<PluginEnvironmentTerminationReceipt> {
let machine: Machine;
try {
machine = await client.machines.get(machineId);
} catch (error) {
if (error instanceof NotFoundError) return { providerLeaseId: machineId, state: "destroyed" };
throw error;
}
if (machine.status === "suspended") await client.machines.resume(machineId);
if (machine.status === "hibernated") await client.machines.wake(machineId);
if (machine.status !== "stopped") await client.machines.stop(machineId);
const confirmed = await confirmState(
client,
machineId,
(current) => current === null || current.status === "stopped",
"stopped",
);
return { providerLeaseId: machineId, state: confirmed ? "stopped" : "destroyed" };
}
async function pauseOrDestroy(client: Boxd, machineId: string): Promise<PluginEnvironmentTerminationReceipt | void> {
try {
const machine = await client.machines.get(machineId);
if (machine.status === "running") await client.machines.pause(machineId);
} catch (error) {
if (error instanceof NotFoundError) return;
console.warn(`Failed to suspend boxd machine ${machineId} for reuse: ${formatErrorMessage(error)}. Deleting it instead.`);
return await destroyMachine(client, machineId);
}
}
async function wakeMachine(client: Boxd, machine: Machine): Promise<void> {
if (machine.status === "suspended") await client.machines.resume(machine.id);
if (machine.status === "hibernated") await client.machines.wake(machine.id);
if (machine.status === "stopped") await client.machines.start(machine.id);
await client.machines.waitUntilReady(machine.id, { timeout: READY_TIMEOUT_MS });
}
function isStoppedConflict(error: unknown): boolean {
return error instanceof ConflictError && /\bstopped\b/i.test(error.message);
}
async function withStartedMachine<T>(client: Boxd, machineId: string, work: () => Promise<T>): Promise<T> {
try {
return await work();
} catch (error) {
if (!isStoppedConflict(error)) throw error;
await client.machines.start(machineId);
await client.machines.waitUntilReady(machineId, { timeout: READY_TIMEOUT_MS });
return await work();
}
}
function isExecTimeout(error: unknown): boolean {
return error instanceof BoxdError && (error.grpcCode === 4 || /timed out/i.test(error.message));
}
async function detectHome(client: Boxd, machineId: string): Promise<string> {
const result = await client.machines.exec(machineId, {
command: 'printf "%s\\n" "${HOME:-$(pwd)}"',
});
const home = result.exitCode === 0 ? result.stdout.trim().split(/\r?\n/)[0]?.trim() : "";
return home && home.startsWith("/") ? home : FALLBACK_HOME;
}
async function makeWorkspaceWithShell(client: Boxd, machineId: string, remoteCwd: string): Promise<void> {
const result = await client.machines.exec(machineId, { command: `mkdir -p ${shellQuote(remoteCwd)}` });
if (result.exitCode !== 0) {
throw new Error(`Failed to create ${remoteCwd} on boxd machine ${machineId}: ${result.stderr.trim() || result.stdout.trim()}`);
}
}
async function ensureWorkspace(client: Boxd, machineId: string, remoteCwd: string): Promise<void> {
try {
await withStartedMachine(client, machineId, () => withAgentFallback(
() => client.machines.fs.mkdir(machineId, remoteCwd),
() => makeWorkspaceWithShell(client, machineId, remoteCwd),
));
} catch (error) {
if (!(error instanceof FilesystemError)) throw error;
throw new Error(`Failed to create ${remoteCwd} on boxd machine ${machineId}: ${error.message}`);
}
}
function leaseMetadata(input: {
config: BoxdDriverConfig;
machine: Pick<Machine, "id" | "name">;
remoteCwd: string;
resumedLease: boolean;
}): Record<string, unknown> {
return {
provider: "boxd",
machineId: input.machine.id,
machineName: input.machine.name,
sandboxName: input.machine.name,
url: `https://${input.machine.name}.boxd.sh`,
org: input.config.org,
snapshot: input.config.snapshot,
shellCommand: "bash",
remoteCwd: input.remoteCwd,
timeoutMs: input.config.timeoutMs,
reuseLease: input.config.reuseLease,
resumedLease: input.resumedLease,
};
}
function buildLoginShellScript(input: {
command: string;
args: string[];
env?: Record<string, string>;
}): string {
const env = input.env ?? {};
for (const key of Object.keys(env)) {
if (!isValidShellEnvKey(key)) {
throw new Error(`Invalid sandbox environment variable key: ${key}`);
}
}
const envArgs = Object.entries(env)
.filter((entry): entry is [string, string] => typeof entry[1] === "string")
.map(([key, value]) => `${key}=${shellQuote(value)}`);
const commandParts = [shellQuote(input.command), ...input.args.map(shellQuote)].join(" ");
const execLine = envArgs.length > 0
? `exec env ${envArgs.join(" ")} ${commandParts}`
: `exec ${commandParts}`;
return [
'if [ -f /etc/profile ]; then . /etc/profile >/dev/null 2>&1 || true; fi',
'if [ -f "$HOME/.profile" ]; then . "$HOME/.profile" >/dev/null 2>&1 || true; fi',
'if [ -f "$HOME/.bash_profile" ]; then . "$HOME/.bash_profile" >/dev/null 2>&1 || true; elif [ -f "$HOME/.bashrc" ]; then . "$HOME/.bashrc" >/dev/null 2>&1 || true; fi',
'if [ -f "$HOME/.zprofile" ]; then . "$HOME/.zprofile" >/dev/null 2>&1 || true; fi',
execLine,
].join(" && ");
}
async function acquireMachine(
config: BoxdDriverConfig,
input: {
companyId: string;
environmentId: string;
runId: string;
requestedCwd?: string;
requestedExpiresAt?: string | null;
},
): Promise<PluginEnvironmentLease> {
const client = clientFor(config);
if (input.requestedExpiresAt) expirySeconds(input.requestedExpiresAt, Date.now());
const labels = machineLabels(input.environmentId, input.runId);
const machine = await createMachine(client, config, {
name: buildMachineName(config, input.environmentId, input.runId),
labels,
});
try {
const expiresAt = input.requestedExpiresAt
? await boundLifetime(client, machine.id, input.requestedExpiresAt)
: undefined;
await client.machines.waitUntilReady(machine.id, { timeout: READY_TIMEOUT_MS });
const remoteCwd = input.requestedCwd?.trim() || path.posix.join(await detectHome(client, machine.id), WORKSPACE_DIR);
await ensureWorkspace(client, machine.id, remoteCwd);
return {
providerLeaseId: machine.id,
metadata: leaseMetadata({ config, machine, remoteCwd, resumedLease: false }),
...(expiresAt ? { expiresAt } : {}),
};
} catch (error) {
try {
await destroyMachine(client, machine.id);
} catch (cleanupError) {
throw new PluginEnvironmentCreationCleanupError(
[error, cleanupError],
`boxd machine ${machine.id} could not be deleted after its acquisition failed.`,
creationCleanup(config, machine.id, input, labels),
);
}
throw error;
}
}
const plugin = definePlugin({
async setup(ctx) {
ctx.logger.info("boxd sandbox provider plugin ready");
},
async onHealth() {
return { status: "ok", message: "boxd sandbox provider plugin healthy" };
},
async onShutdown() {
const open = [...clients.values()];
clients.clear();
await Promise.all(open.map((client) => client.close().catch(() => undefined)));
},
async onEnvironmentValidateConfig(
params: PluginEnvironmentValidateConfigParams,
): Promise<PluginEnvironmentValidationResult> {
const config = parseDriverConfig(params.config);
const errors: string[] = [];
if (config.timeoutMs < 1 || config.timeoutMs > 86_400_000) {
errors.push("timeoutMs must be between 1 and 86400000.");
}
if (config.vcpu != null && !(config.vcpu in MEMORY_BY_VCPU)) {
errors.push("vcpu must be 1, 2, 4 or 8. boxd sizes are 1 vCPU/4 GB, 2/8 GB, 4/16 GB and 8/32 GB.");
}
if (config.vcpu != null && config.snapshot) {
errors.push("vcpu applies to fresh machines only. A machine restored from a snapshot keeps the snapshot's size; clear vcpu or snapshot.");
}
if (!config.apiKey && !(process.env.BOXD_API_KEY?.trim())) {
errors.push("boxd environments require an API key in config or BOXD_API_KEY.");
}
if (errors.length > 0) {
return { ok: false, errors };
}
return {
ok: true,
normalizedConfig: { ...config },
};
},
async onEnvironmentProbe(
params: PluginEnvironmentProbeParams,
): Promise<PluginEnvironmentProbeResult> {
const config = parseDriverConfig(params.config);
const configMetadata = {
provider: "boxd",
org: config.org,
snapshot: config.snapshot,
timeoutMs: config.timeoutMs,
reuseLease: config.reuseLease,
};
let machine: Machine | null = null;
let result: PluginEnvironmentProbeResult;
try {
const client = clientFor(config);
machine = await createMachine(client, config, {
name: `${config.namePrefix}-probe-${randomUUID().slice(0, 8)}`,
labels: machineLabels(params.environmentId, "probe"),
});
await client.machines.setDeleteAfter(machine.id, PROBE_DELETE_AFTER_SECONDS);
await client.machines.waitUntilReady(machine.id, { timeout: READY_TIMEOUT_MS });
const remoteCwd = path.posix.join(await detectHome(client, machine.id), WORKSPACE_DIR);
await ensureWorkspace(client, machine.id, remoteCwd);
result = {
ok: true,
summary: `Connected to boxd machine ${machine.name}.`,
metadata: { ...configMetadata, machineName: machine.name, remoteCwd },
};
} catch (error) {
result = {
ok: false,
summary: "boxd environment probe failed.",
metadata: { ...configMetadata, error: formatErrorMessage(error) },
};
}
if (!machine) return result;
try {
await destroyMachine(clientFor(config), machine.id);
return result;
} catch (error) {
return {
ok: false,
summary: `boxd environment probe could not delete its machine ${machine.name}.`,
metadata: {
...result.metadata,
machineId: machine.id,
machineName: machine.name,
cleanupError: formatErrorMessage(error),
},
};
}
},
async onEnvironmentAcquireLease(
params: PluginEnvironmentAcquireLeaseParams,
): Promise<PluginEnvironmentLease> {
return await acquireMachine(parseDriverConfig(params.config), {
companyId: params.companyId,
environmentId: params.environmentId,
runId: params.runId,
requestedCwd: params.requestedCwd,
requestedExpiresAt: params.requestedExpiresAt,
});
},
async onEnvironmentResumeLease(
params: PluginEnvironmentResumeLeaseParams,
): Promise<PluginEnvironmentLease> {
const config = parseDriverConfig(params.config);
const client = clientFor(config);
let machine: Machine;
try {
machine = await client.machines.get(params.providerLeaseId);
} catch (error) {
if (error instanceof NotFoundError) return { providerLeaseId: null, metadata: { expired: true } };
throw error;
}
if (machine.status === "destroyed" || machine.status === "failed") {
return { providerLeaseId: null, metadata: { expired: true } };
}
await wakeMachine(client, machine);
const remoteCwd = parseOptionalString(params.leaseMetadata?.remoteCwd)
?? path.posix.join(await detectHome(client, machine.id), WORKSPACE_DIR);
await ensureWorkspace(client, machine.id, remoteCwd);
return {
providerLeaseId: machine.id,
metadata: leaseMetadata({ config, machine, remoteCwd, resumedLease: true }),
};
},
async onEnvironmentReleaseLease(
params: PluginEnvironmentReleaseLeaseParams,
): Promise<PluginEnvironmentTerminationReceipt | void> {
if (!params.providerLeaseId) return;
const config = parseDriverConfig(params.config);
const client = clientFor(config);
if (params.resourceDisposition === "stop_and_retain") {
return await stopMachine(client, params.providerLeaseId);
}
if (config.reuseLease) {
if (params.cancelActiveWork) return await stopMachine(client, params.providerLeaseId);
return await pauseOrDestroy(client, params.providerLeaseId);
}
return await destroyMachine(client, params.providerLeaseId);
},
async onEnvironmentStopLease(
params: PluginEnvironmentReleaseLeaseParams,
): Promise<PluginEnvironmentTerminationReceipt> {
if (!params.providerLeaseId) {
throw new Error("No provider lease ID available to stop.");
}
const config = parseDriverConfig(params.config);
return await stopMachine(clientFor(config), params.providerLeaseId);
},
async onEnvironmentDestroyLease(
params: PluginEnvironmentDestroyLeaseParams,
): Promise<PluginEnvironmentTerminationReceipt | void> {
if (!params.providerLeaseId) return;
const config = parseDriverConfig(params.config);
const client = clientFor(config);
if (params.leaseMetadata?.failedCreateCleanup !== undefined) await checkFailedCreation(client, config, params);
return await destroyMachine(client, params.providerLeaseId);
},
async onEnvironmentInspectLease(
params: PluginEnvironmentInspectLeaseParams,
): Promise<PluginEnvironmentLeaseObservation> {
const config = parseDriverConfig(params.config);
try {
const machine = await clientFor(config).machines.get(params.providerLeaseId);
if (ACTIVE_STATES.has(machine.status)) return { providerLeaseId: params.providerLeaseId, state: "active" };
if (STOPPED_STATES.has(machine.status)) return { providerLeaseId: params.providerLeaseId, state: "stopped" };
if (machine.status === "destroyed") return { providerLeaseId: params.providerLeaseId, state: "destroyed" };
return { providerLeaseId: params.providerLeaseId, state: "unknown" };
} catch (error) {
if (error instanceof NotFoundError) return { providerLeaseId: params.providerLeaseId, state: "destroyed" };
throw error;
}
},
async onEnvironmentRealizeWorkspace(
params: PluginEnvironmentRealizeWorkspaceParams,
): Promise<PluginEnvironmentRealizeWorkspaceResult> {
const config = parseDriverConfig(params.config);
const remoteCwd =
parseOptionalString(params.lease.metadata?.remoteCwd)
?? params.workspace.remotePath
?? path.posix.join(FALLBACK_HOME, WORKSPACE_DIR);
if (params.lease.providerLeaseId) {
await ensureWorkspace(clientFor(config), params.lease.providerLeaseId, remoteCwd);
}
return {
cwd: remoteCwd,
metadata: {
provider: "boxd",
remoteCwd,
},
};
},
async onEnvironmentExecute(
params: PluginEnvironmentExecuteParams,
): Promise<PluginEnvironmentExecuteResult> {
const machineId = params.lease.providerLeaseId;
if (!machineId) {
return {
exitCode: 1,
timedOut: false,
stdout: "",
stderr: "No provider lease ID available for execution.",
};
}
const config = parseDriverConfig(params.config);
const client = clientFor(config);
const script = buildLoginShellScript({
command: params.command,
args: params.args ?? [],
env: params.env,
});
const timeoutMs = params.timeoutMs != null && params.timeoutMs > 0 ? params.timeoutMs : config.timeoutMs;
const cwd = params.cwd ?? parseOptionalString(params.lease.metadata?.remoteCwd) ?? undefined;
const stdinPath = params.stdin != null ? `/tmp/paperclip-stdin-${randomUUID()}` : null;
const command = stdinPath ? `${script} < ${shellQuote(stdinPath)}` : script;
try {
const result = await withStartedMachine(client, machineId, async () => {
if (stdinPath) {
const stdin = params.stdin ?? "";
await withAgentFallback(
async () => {
await client.machines.fs.write(machineId, stdinPath, stdin, { mode: STDIN_MODE });
},
async () => {
await client.machines.files.upload(machineId, stdinPath, stdin);
},
);
}
return await client.machines.exec(machineId, { command, ...(cwd ? { cwd } : {}), timeout: timeoutMs });
});
return {
exitCode: result.exitCode,
timedOut: false,
stdout: result.stdout,
stderr: result.stderr,
};
} catch (error) {
if (isExecTimeout(error)) {
return {
exitCode: null,
timedOut: true,
stdout: "",
stderr: `${formatErrorMessage(error)}\n`,
};
}
throw error;
} finally {
if (stdinPath) {
await withAgentFallback(
() => client.machines.fs.remove(machineId, stdinPath),
() => client.machines.files.delete(machineId, stdinPath),
).catch(() => undefined);
}
}
},
});
export default plugin;
@@ -0,0 +1,5 @@
import { runWorker } from "@paperclipai/plugin-sdk";
import plugin from "./plugin.js";
export default plugin;
runWorker(plugin, import.meta.url);
@@ -0,0 +1,11 @@
{
"extends": "../../../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2023"],
"types": ["node"]
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
environment: "node",
},
});
+5
View File
@@ -124,6 +124,11 @@
"name": "@paperclipai/create-paperclip-plugin",
"publishFromCi": true
},
{
"dir": "packages/plugins/sandbox-providers/boxd",
"name": "@paperclipai/plugin-boxd",
"publishFromCi": false
},
{
"dir": "packages/plugins/sandbox-providers/cloudflare",
"name": "@paperclipai/plugin-cloudflare-sandbox",
@@ -43,6 +43,18 @@ describe("resolveBundledPluginInstalls", () => {
}]);
expect(SELF_HOSTED_AUTO_INSTALL_KEYS).not.toContain("createos");
});
it("resolves the optional boxd sandbox provider without making it a self-hosted default", () => {
expect(resolveBundledPluginInstalls(["boxd"], {
catalogRoot: CATALOG_ROOT,
env: {},
enforceCatalogRoot: true,
})).toEqual([{
key: "boxd",
pluginKey: "paperclip.boxd-sandbox-provider",
localPath: path.join(CATALOG_ROOT, "sandbox-providers/boxd"),
}]);
expect(SELF_HOSTED_AUTO_INSTALL_KEYS).not.toContain("boxd");
});
it("resolves known keys to paths inside the catalog root", () => {
const resolved = resolveBundledPluginInstalls(["kubernetes", "daytona"], {
catalogRoot: CATALOG_ROOT,
@@ -227,6 +227,30 @@ describe("buildPluginWorkerEnv", () => {
});
});
it.each([
{ packagePath: null, packageName: "@paperclipai/plugin-boxd", driverKey: "boxd", allowed: true },
{ packagePath: "/app/packages/plugins/sandbox-providers/boxd", packageName: "@paperclipai/plugin-boxd", driverKey: "boxd", allowed: true },
{ packagePath: "/home/operator/plugins/fake-boxd", packageName: "@paperclipai/plugin-boxd", driverKey: "boxd", allowed: false },
{ packagePath: null, packageName: "@acme/plugin-boxd", driverKey: "boxd", allowed: false },
{ packagePath: null, packageName: "@paperclipai/plugin-boxd", driverKey: "daytona", allowed: false },
])("confines the boxd fallback credential to its trusted worker: $packageName / $packagePath / $driverKey", ({ allowed, driverKey, ...installation }) => {
const env = buildPluginWorkerEnv({
...installation,
manifest: {
capabilities: ["environment.drivers.register"],
environmentDrivers: [{ driverKey }],
},
trustedLocalPluginRoots: ["/app/packages/plugins"],
instanceInfo,
processEnv: { BOXD_API_KEY: "boxd-token", DAYTONA_API_KEY: "daytona-token" },
});
expect(env).toEqual({
PAPERCLIP_DEPLOYMENT_MODE: "authenticated",
PAPERCLIP_DEPLOYMENT_EXPOSURE: "public",
...(allowed ? { BOXD_API_KEY: "boxd-token" } : {}),
});
});
it("passes a first-party sandbox provider's documented credential env var to its own worker", () => {
const env = buildPluginWorkerEnv({
manifest: {
+5
View File
@@ -63,6 +63,11 @@ export interface BundledPluginCatalogEntry {
* regardless of what the managed config document says.
*/
export const BUNDLED_PLUGIN_CATALOG: readonly BundledPluginCatalogEntry[] = [
{
key: "boxd",
pluginKey: "paperclip.boxd-sandbox-provider",
relativePath: "sandbox-providers/boxd",
},
{
key: "createos",
pluginKey: "paperclip.createos-sandbox-provider",
+1
View File
@@ -142,6 +142,7 @@ const SANDBOX_PROVIDER_CREDENTIAL_ENV_PASSTHROUGH: Record<
string,
{ driverKey: string; envVars: readonly string[] }
> = {
"@paperclipai/plugin-boxd": { driverKey: "boxd", envVars: ["BOXD_API_KEY"] },
"@paperclipai/plugin-createos": { driverKey: "createos", envVars: ["CREATEOS_API_KEY"] },
"@paperclipai/plugin-daytona": { driverKey: "daytona", envVars: ["DAYTONA_API_KEY"] },
"@paperclipai/plugin-e2b": { driverKey: "e2b", envVars: ["E2B_API_KEY"] },