mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 13:20:54 +01:00
<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 <noreply@anthropic.com>
815 lines
29 KiB
TypeScript
815 lines
29 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { and, eq, sql } from "drizzle-orm";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
companies,
|
|
createDb,
|
|
issueRelations,
|
|
issues,
|
|
pluginDatabaseNamespaces,
|
|
pluginMigrations,
|
|
plugins,
|
|
} from "@paperclipai/db";
|
|
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./helpers/embedded-postgres.js";
|
|
import {
|
|
derivePluginDatabaseNamespace,
|
|
pluginDatabaseService,
|
|
validatePluginMigrationStatement,
|
|
validatePluginRuntimeExecute,
|
|
validatePluginRuntimeQuery,
|
|
} from "../services/plugin-database.js";
|
|
import { buildPluginWorkerEnv, pluginLoader } from "../services/plugin-loader.js";
|
|
|
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
const multiMigrationPluginKey = "paperclip.dbfixture";
|
|
const llmWikiPluginKey = "paperclipai.plugin-llm-wiki";
|
|
|
|
if (!embeddedPostgresSupport.supported) {
|
|
console.warn(
|
|
`Skipping embedded Postgres plugin database tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
|
);
|
|
}
|
|
|
|
describe("plugin database SQL validation", () => {
|
|
it("allows namespace migrations with whitelisted public foreign keys", () => {
|
|
expect(() =>
|
|
validatePluginMigrationStatement(
|
|
"CREATE TABLE plugin_test.rows (id uuid PRIMARY KEY, issue_id uuid REFERENCES public.issues(id))",
|
|
"plugin_test",
|
|
["issues"],
|
|
)
|
|
).not.toThrow();
|
|
});
|
|
|
|
it("allows qualified index creation and namespace-scoped migration backfills", () => {
|
|
expect(() =>
|
|
validatePluginMigrationStatement(
|
|
"CREATE INDEX IF NOT EXISTS rows_issue_idx ON plugin_test.rows (issue_id)",
|
|
"plugin_test",
|
|
)
|
|
).not.toThrow();
|
|
expect(() =>
|
|
validatePluginMigrationStatement(
|
|
`
|
|
WITH source_rows AS (
|
|
SELECT id FROM plugin_test.rows
|
|
)
|
|
INSERT INTO plugin_test.row_copies (id)
|
|
SELECT id FROM source_rows
|
|
ON CONFLICT (id) DO NOTHING
|
|
`,
|
|
"plugin_test",
|
|
)
|
|
).not.toThrow();
|
|
expect(() =>
|
|
validatePluginMigrationStatement(
|
|
`
|
|
UPDATE plugin_test.rows r
|
|
SET copied_from_id = s.id
|
|
FROM plugin_test.source_rows s
|
|
WHERE s.id = r.id
|
|
`,
|
|
"plugin_test",
|
|
)
|
|
).not.toThrow();
|
|
});
|
|
|
|
it("keeps migration backfill writes scoped to the plugin namespace", () => {
|
|
expect(() =>
|
|
validatePluginMigrationStatement(
|
|
"CREATE TABLE rows (id uuid PRIMARY KEY, issue_id uuid REFERENCES public.issues(id))",
|
|
"plugin_test",
|
|
["issues"],
|
|
)
|
|
).toThrow(/fully qualified/i);
|
|
expect(() =>
|
|
validatePluginMigrationStatement(
|
|
"WITH source_rows AS (SELECT id FROM plugin_test.rows) INSERT INTO public.issues (id) SELECT id FROM source_rows",
|
|
"plugin_test",
|
|
["issues"],
|
|
)
|
|
).toThrow(/public/i);
|
|
expect(() =>
|
|
validatePluginMigrationStatement(
|
|
"UPDATE public.issues SET title = 'bad'",
|
|
"plugin_test",
|
|
["issues"],
|
|
)
|
|
).toThrow(/public/i);
|
|
});
|
|
|
|
it("rejects migrations that create public objects", () => {
|
|
expect(() =>
|
|
validatePluginMigrationStatement(
|
|
"CREATE TABLE public.rows (id uuid PRIMARY KEY)",
|
|
"plugin_test",
|
|
["issues"],
|
|
)
|
|
).toThrow(/public/i);
|
|
});
|
|
|
|
it("allows whitelisted runtime reads but rejects public writes", () => {
|
|
expect(() =>
|
|
validatePluginRuntimeQuery(
|
|
"SELECT r.id FROM plugin_test.rows r JOIN public.issues i ON i.id = r.issue_id",
|
|
"plugin_test",
|
|
["issues"],
|
|
)
|
|
).not.toThrow();
|
|
expect(() =>
|
|
validatePluginRuntimeExecute("UPDATE public.issues SET title = $1", "plugin_test")
|
|
).toThrow(/namespace/i);
|
|
});
|
|
|
|
it("targets anonymous DO blocks without rejecting do-prefixed aliases", () => {
|
|
expect(() =>
|
|
validatePluginRuntimeQuery(
|
|
"SELECT EXTRACT(DOW FROM created_at) AS do_flag FROM plugin_test.rows",
|
|
"plugin_test",
|
|
)
|
|
).not.toThrow();
|
|
expect(() =>
|
|
validatePluginMigrationStatement("DO $$ BEGIN END $$;", "plugin_test")
|
|
).toThrow(/disallowed/i);
|
|
});
|
|
});
|
|
|
|
describe("buildPluginWorkerEnv", () => {
|
|
const instanceInfo = {
|
|
deploymentMode: "authenticated",
|
|
deploymentExposure: "public",
|
|
};
|
|
|
|
it("passes only model provider keys through to environment driver plugins", () => {
|
|
const env = buildPluginWorkerEnv({
|
|
manifest: { capabilities: ["environment.drivers.register"] },
|
|
instanceInfo,
|
|
processEnv: {
|
|
ANTHROPIC_API_KEY: "anthropic-token",
|
|
OPENAI_API_KEY: "openai-token",
|
|
GEMINI_API_KEY: " ",
|
|
AWS_SECRET_ACCESS_KEY: "aws-secret",
|
|
},
|
|
});
|
|
|
|
expect(env).toEqual({
|
|
PAPERCLIP_DEPLOYMENT_MODE: "authenticated",
|
|
PAPERCLIP_DEPLOYMENT_EXPOSURE: "public",
|
|
ANTHROPIC_API_KEY: "anthropic-token",
|
|
OPENAI_API_KEY: "openai-token",
|
|
});
|
|
});
|
|
|
|
it("passes in-cluster Kubernetes service-discovery vars to environment driver plugins", () => {
|
|
const env = buildPluginWorkerEnv({
|
|
manifest: { capabilities: ["environment.drivers.register"] },
|
|
instanceInfo,
|
|
processEnv: {
|
|
KUBERNETES_SERVICE_HOST: "10.0.0.1",
|
|
KUBERNETES_SERVICE_PORT: "443",
|
|
KUBERNETES_SERVICE_PORT_HTTPS: " ",
|
|
AWS_SECRET_ACCESS_KEY: "aws-secret",
|
|
},
|
|
});
|
|
|
|
expect(env).toEqual({
|
|
PAPERCLIP_DEPLOYMENT_MODE: "authenticated",
|
|
PAPERCLIP_DEPLOYMENT_EXPOSURE: "public",
|
|
KUBERNETES_SERVICE_HOST: "10.0.0.1",
|
|
KUBERNETES_SERVICE_PORT: "443",
|
|
});
|
|
});
|
|
|
|
it("does not pass provider keys to non-environment plugins", () => {
|
|
const env = buildPluginWorkerEnv({
|
|
manifest: { capabilities: ["ui.slots.register"] },
|
|
instanceInfo,
|
|
processEnv: {
|
|
OPENAI_API_KEY: "openai-token",
|
|
},
|
|
});
|
|
|
|
expect(env).toEqual({
|
|
PAPERCLIP_DEPLOYMENT_MODE: "authenticated",
|
|
PAPERCLIP_DEPLOYMENT_EXPOSURE: "public",
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{ packagePath: null, packageName: "@paperclipai/plugin-createos", driverKey: "createos", allowed: true },
|
|
{ packagePath: "/app/packages/plugins/sandbox-providers/createos", packageName: "@paperclipai/plugin-createos", driverKey: "createos", allowed: true },
|
|
{ packagePath: "/home/operator/plugins/fake-createos", packageName: "@paperclipai/plugin-createos", driverKey: "createos", allowed: false },
|
|
{ packagePath: null, packageName: "@acme/plugin-createos", driverKey: "createos", allowed: false },
|
|
{ packagePath: null, packageName: "@paperclipai/plugin-createos", driverKey: "daytona", allowed: false },
|
|
])("confines the CreateOS 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: { CREATEOS_API_KEY: "createos-token", DAYTONA_API_KEY: "daytona-token" },
|
|
});
|
|
expect(env).toEqual({
|
|
PAPERCLIP_DEPLOYMENT_MODE: "authenticated",
|
|
PAPERCLIP_DEPLOYMENT_EXPOSURE: "public",
|
|
...(allowed ? { CREATEOS_API_KEY: "createos-token" } : {}),
|
|
});
|
|
});
|
|
|
|
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: {
|
|
capabilities: ["environment.drivers.register"],
|
|
environmentDrivers: [{ driverKey: "daytona" }],
|
|
},
|
|
packageName: "@paperclipai/plugin-daytona",
|
|
packagePath: null,
|
|
instanceInfo,
|
|
processEnv: {
|
|
DAYTONA_API_KEY: "daytona-token",
|
|
NOVITA_API_KEY: "novita-token",
|
|
E2B_API_KEY: " ",
|
|
},
|
|
});
|
|
|
|
expect(env).toEqual({
|
|
PAPERCLIP_DEPLOYMENT_MODE: "authenticated",
|
|
PAPERCLIP_DEPLOYMENT_EXPOSURE: "public",
|
|
DAYTONA_API_KEY: "daytona-token",
|
|
});
|
|
});
|
|
|
|
it("passes the credential to a first-party plugin installed from the bundled catalog", () => {
|
|
const env = buildPluginWorkerEnv({
|
|
manifest: {
|
|
capabilities: ["environment.drivers.register"],
|
|
environmentDrivers: [{ driverKey: "daytona" }],
|
|
},
|
|
packageName: "@paperclipai/plugin-daytona",
|
|
packagePath: "/app/packages/plugins/sandbox-providers/daytona",
|
|
trustedLocalPluginRoots: ["/app/packages/plugins"],
|
|
instanceInfo,
|
|
processEnv: {
|
|
DAYTONA_API_KEY: "daytona-token",
|
|
},
|
|
});
|
|
|
|
expect(env).toEqual({
|
|
PAPERCLIP_DEPLOYMENT_MODE: "authenticated",
|
|
PAPERCLIP_DEPLOYMENT_EXPOSURE: "public",
|
|
DAYTONA_API_KEY: "daytona-token",
|
|
});
|
|
});
|
|
|
|
it("does not pass the credential to a local plugin that self-declares the first-party name", () => {
|
|
const env = buildPluginWorkerEnv({
|
|
manifest: {
|
|
capabilities: ["environment.drivers.register"],
|
|
environmentDrivers: [{ driverKey: "daytona" }],
|
|
},
|
|
packageName: "@paperclipai/plugin-daytona",
|
|
packagePath: "/home/operator/.paperclip/plugins/fake-daytona",
|
|
trustedLocalPluginRoots: ["/app/packages/plugins"],
|
|
instanceInfo,
|
|
processEnv: {
|
|
DAYTONA_API_KEY: "daytona-token",
|
|
},
|
|
});
|
|
|
|
expect(env).toEqual({
|
|
PAPERCLIP_DEPLOYMENT_MODE: "authenticated",
|
|
PAPERCLIP_DEPLOYMENT_EXPOSURE: "public",
|
|
});
|
|
});
|
|
|
|
it("does not pass a credential to a third-party plugin that claims a first-party driver key", () => {
|
|
const env = buildPluginWorkerEnv({
|
|
manifest: {
|
|
capabilities: ["environment.drivers.register"],
|
|
environmentDrivers: [{ driverKey: "daytona" }],
|
|
},
|
|
packageName: "@acme/plugin-fake-daytona",
|
|
instanceInfo,
|
|
processEnv: {
|
|
DAYTONA_API_KEY: "daytona-token",
|
|
},
|
|
});
|
|
|
|
expect(env).toEqual({
|
|
PAPERCLIP_DEPLOYMENT_MODE: "authenticated",
|
|
PAPERCLIP_DEPLOYMENT_EXPOSURE: "public",
|
|
});
|
|
});
|
|
|
|
it("does not pass a credential when the first-party package omits its expected driver key", () => {
|
|
const env = buildPluginWorkerEnv({
|
|
manifest: {
|
|
capabilities: ["environment.drivers.register"],
|
|
environmentDrivers: [{ driverKey: "kubernetes" }],
|
|
},
|
|
packageName: "@paperclipai/plugin-daytona",
|
|
instanceInfo,
|
|
processEnv: {
|
|
DAYTONA_API_KEY: "daytona-token",
|
|
},
|
|
});
|
|
|
|
expect(env).toEqual({
|
|
PAPERCLIP_DEPLOYMENT_MODE: "authenticated",
|
|
PAPERCLIP_DEPLOYMENT_EXPOSURE: "public",
|
|
});
|
|
});
|
|
});
|
|
|
|
describeEmbeddedPostgres("plugin database namespaces", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
let packageRoots: string[] = [];
|
|
|
|
beforeAll(async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-db-");
|
|
db = createDb(tempDb.connectionString);
|
|
}, 20_000);
|
|
|
|
afterEach(async () => {
|
|
for (const pluginKey of ["paperclip.dbtest", "paperclip.escape", "paperclip.refresh", multiMigrationPluginKey, llmWikiPluginKey]) {
|
|
const namespace = derivePluginDatabaseNamespace(pluginKey);
|
|
await db.execute(sql.raw(`DROP SCHEMA IF EXISTS "${namespace}" CASCADE`));
|
|
}
|
|
await db.execute(sql.raw(`DROP SCHEMA IF EXISTS "${derivePluginDatabaseNamespace(llmWikiPluginKey, "llm_wiki")}" CASCADE`));
|
|
await db.delete(pluginMigrations);
|
|
await db.delete(pluginDatabaseNamespaces);
|
|
await db.delete(plugins);
|
|
await db.delete(issueRelations);
|
|
await db.delete(issues);
|
|
await db.delete(companies);
|
|
await Promise.all(packageRoots.map((root) => rm(root, { recursive: true, force: true })));
|
|
packageRoots = [];
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
async function createPluginPackage(manifest: PaperclipPluginManifestV1, migrationSql: string) {
|
|
const packageRoot = await mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-package-"));
|
|
packageRoots.push(packageRoot);
|
|
const migrationsDir = path.join(packageRoot, manifest.database!.migrationsDir);
|
|
await mkdir(migrationsDir, { recursive: true });
|
|
await writeFile(path.join(migrationsDir, "001_init.sql"), migrationSql, "utf8");
|
|
return packageRoot;
|
|
}
|
|
|
|
function llmWikiManifest(): PaperclipPluginManifestV1 {
|
|
return {
|
|
id: llmWikiPluginKey,
|
|
apiVersion: 1,
|
|
version: "0.1.0",
|
|
displayName: "LLM Wiki",
|
|
description: "Local-file LLM Wiki plugin.",
|
|
author: "Paperclip",
|
|
categories: ["automation", "ui"],
|
|
capabilities: [
|
|
"database.namespace.migrate",
|
|
"database.namespace.read",
|
|
"database.namespace.write",
|
|
],
|
|
entrypoints: { worker: "./dist/worker.js" },
|
|
database: {
|
|
namespaceSlug: "llm_wiki",
|
|
migrationsDir: "migrations",
|
|
coreReadTables: ["companies", "issues", "projects", "agents"],
|
|
},
|
|
};
|
|
}
|
|
|
|
async function createInstallablePluginPackage(
|
|
pluginManifest: PaperclipPluginManifestV1,
|
|
migrationSql: string,
|
|
) {
|
|
const packageRoot = await createPluginPackage(pluginManifest, migrationSql);
|
|
await writeFile(
|
|
path.join(packageRoot, "package.json"),
|
|
JSON.stringify({
|
|
name: pluginManifest.id,
|
|
version: pluginManifest.version,
|
|
type: "module",
|
|
paperclipPlugin: { manifest: "./manifest.js" },
|
|
}),
|
|
"utf8",
|
|
);
|
|
await writeFile(
|
|
path.join(packageRoot, "manifest.js"),
|
|
`export default ${JSON.stringify(pluginManifest, null, 2)};\n`,
|
|
"utf8",
|
|
);
|
|
await mkdir(path.join(packageRoot, "dist"), { recursive: true });
|
|
await writeFile(path.join(packageRoot, "dist", "worker.js"), "export {};\n", "utf8");
|
|
return packageRoot;
|
|
}
|
|
|
|
async function installPluginRecord(manifest: PaperclipPluginManifestV1) {
|
|
const pluginId = randomUUID();
|
|
await db.insert(plugins).values({
|
|
id: pluginId,
|
|
pluginKey: manifest.id,
|
|
packageName: manifest.id,
|
|
version: manifest.version,
|
|
apiVersion: manifest.apiVersion,
|
|
categories: manifest.categories,
|
|
manifestJson: manifest,
|
|
status: "installed",
|
|
installOrder: 1,
|
|
});
|
|
return pluginId;
|
|
}
|
|
|
|
function manifest(pluginKey = "paperclip.dbtest"): PaperclipPluginManifestV1 {
|
|
return {
|
|
id: pluginKey,
|
|
apiVersion: 1,
|
|
version: "1.0.0",
|
|
displayName: "DB Test",
|
|
description: "Exercises restricted plugin database access.",
|
|
author: "Paperclip",
|
|
categories: ["automation"],
|
|
capabilities: [
|
|
"database.namespace.migrate",
|
|
"database.namespace.read",
|
|
"database.namespace.write",
|
|
],
|
|
entrypoints: { worker: "./dist/worker.js" },
|
|
database: {
|
|
migrationsDir: "migrations",
|
|
coreReadTables: ["issues"],
|
|
},
|
|
};
|
|
}
|
|
|
|
it("applies multi-file plugin migrations through the production validator", async () => {
|
|
const pluginManifest = manifest(multiMigrationPluginKey);
|
|
const namespace = derivePluginDatabaseNamespace(pluginManifest.id);
|
|
const packageRoot = await createPluginPackage(
|
|
pluginManifest,
|
|
`CREATE TABLE ${namespace}.source_rows (id uuid PRIMARY KEY, label text NOT NULL);`,
|
|
);
|
|
await writeFile(
|
|
path.join(packageRoot, pluginManifest.database!.migrationsDir, "002_derived.sql"),
|
|
`CREATE TABLE ${namespace}.derived_rows (
|
|
id uuid PRIMARY KEY,
|
|
source_id uuid NOT NULL REFERENCES ${namespace}.source_rows(id)
|
|
);`,
|
|
"utf8",
|
|
);
|
|
const pluginId = await installPluginRecord(pluginManifest);
|
|
await pluginDatabaseService(db).applyMigrations(pluginId, pluginManifest, packageRoot);
|
|
|
|
const migrations = await db
|
|
.select()
|
|
.from(pluginMigrations)
|
|
.where(and(eq(pluginMigrations.pluginId, pluginId), eq(pluginMigrations.status, "applied")));
|
|
expect(migrations).toHaveLength(2);
|
|
});
|
|
|
|
it("applies the bundled LLM Wiki migrations through the production validator", async () => {
|
|
const pluginManifest = llmWikiManifest();
|
|
const repoRoot = path.basename(process.cwd()) === "server" ? path.resolve(process.cwd(), "..") : process.cwd();
|
|
const packageRoot = path.join(repoRoot, "packages", "plugins", "plugin-llm-wiki");
|
|
const namespace = derivePluginDatabaseNamespace(pluginManifest.id, pluginManifest.database?.namespaceSlug);
|
|
const pluginId = await installPluginRecord(pluginManifest);
|
|
|
|
await pluginDatabaseService(db).applyMigrations(pluginId, pluginManifest, packageRoot);
|
|
|
|
const migrations = await db
|
|
.select()
|
|
.from(pluginMigrations)
|
|
.where(and(eq(pluginMigrations.pluginId, pluginId), eq(pluginMigrations.status, "applied")));
|
|
expect(migrations.map((migration) => migration.migrationKey)).toEqual([
|
|
"001_llm_wiki.sql",
|
|
"002_paperclip_distillation.sql",
|
|
"003_spaces.sql",
|
|
]);
|
|
|
|
const constraintRows = Array.from(
|
|
await db.execute(
|
|
sql<{ table_name: string; conname: string; columns: string[] }>`
|
|
SELECT t.relname AS table_name, c.conname, array_agg(a.attname ORDER BY constraint_columns.ordinality)::text[] AS columns
|
|
FROM pg_constraint c
|
|
JOIN pg_class t ON t.oid = c.conrelid
|
|
JOIN unnest(c.conkey) WITH ORDINALITY AS constraint_columns(attnum, ordinality) ON true
|
|
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = constraint_columns.attnum
|
|
WHERE c.connamespace = ${namespace}::regnamespace AND c.contype = 'u'
|
|
GROUP BY t.relname, c.conname
|
|
ORDER BY t.relname, c.conname
|
|
`,
|
|
) as Iterable<{ table_name: string; conname: string; columns: string[] }>,
|
|
);
|
|
const constraints = constraintRows.map((row) => row.conname);
|
|
const uniqueColumnSets = new Set(
|
|
constraintRows.map((row) => `${row.table_name}:${row.columns.join(",")}`),
|
|
);
|
|
expect(constraints).toEqual(
|
|
expect.arrayContaining([
|
|
"wiki_pages_company_wiki_space_path_key",
|
|
"distillation_cursors_company_wiki_space_scope_key",
|
|
"distillation_work_items_company_wiki_space_idempotency_key",
|
|
"page_bindings_company_wiki_space_page_path_key",
|
|
]),
|
|
);
|
|
expect(constraints).not.toContain("wiki_pages_company_id_wiki_id_path_key");
|
|
expect(constraints).not.toContain("paperclip_distillation_cursor_company_id_wiki_id_source_sco_key");
|
|
expect(constraints).not.toContain("paperclip_distillation_work_i_company_id_wiki_id_idempotenc_key");
|
|
expect(constraints).not.toContain("paperclip_page_bindings_company_id_wiki_id_page_path_key");
|
|
expect(uniqueColumnSets).not.toContain("wiki_pages:company_id,wiki_id,path");
|
|
expect(uniqueColumnSets).not.toContain("paperclip_distillation_cursors:company_id,wiki_id,source_scope,scope_key,source_kind");
|
|
expect(uniqueColumnSets).not.toContain("paperclip_distillation_work_items:company_id,wiki_id,idempotency_key");
|
|
expect(uniqueColumnSets).not.toContain("paperclip_page_bindings:company_id,wiki_id,page_path");
|
|
});
|
|
|
|
it("applies migrations once and allows whitelisted core joins at runtime", async () => {
|
|
const pluginManifest = manifest();
|
|
const namespace = derivePluginDatabaseNamespace(pluginManifest.id);
|
|
const packageRoot = await createPluginPackage(
|
|
pluginManifest,
|
|
`
|
|
CREATE TABLE ${namespace}.mission_rows (
|
|
id uuid PRIMARY KEY,
|
|
issue_id uuid NOT NULL REFERENCES public.issues(id),
|
|
label text NOT NULL
|
|
);
|
|
`,
|
|
);
|
|
const pluginId = await installPluginRecord(pluginManifest);
|
|
const companyId = randomUUID();
|
|
const issueId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: "TST",
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Joined issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
identifier: "TST-1",
|
|
});
|
|
|
|
const pluginDb = pluginDatabaseService(db);
|
|
await pluginDb.applyMigrations(pluginId, pluginManifest, packageRoot);
|
|
await pluginDb.applyMigrations(pluginId, pluginManifest, packageRoot);
|
|
|
|
await pluginDb.execute(
|
|
pluginId,
|
|
`INSERT INTO ${namespace}.mission_rows (id, issue_id, label) VALUES ($1, $2, $3)`,
|
|
[randomUUID(), issueId, "alpha"],
|
|
);
|
|
const rows = await pluginDb.query<{ label: string; title: string }>(
|
|
pluginId,
|
|
`SELECT m.label, i.title FROM ${namespace}.mission_rows m JOIN public.issues i ON i.id = m.issue_id`,
|
|
);
|
|
expect(rows).toEqual([{ label: "alpha", title: "Joined issue" }]);
|
|
|
|
const migrations = await db
|
|
.select()
|
|
.from(pluginMigrations)
|
|
.where(and(eq(pluginMigrations.pluginId, pluginId), eq(pluginMigrations.status, "applied")));
|
|
expect(migrations).toHaveLength(1);
|
|
});
|
|
|
|
it("rejects runtime writes to public core tables", async () => {
|
|
const pluginManifest = manifest();
|
|
const namespace = derivePluginDatabaseNamespace(pluginManifest.id);
|
|
const packageRoot = await createPluginPackage(
|
|
pluginManifest,
|
|
`CREATE TABLE ${namespace}.notes (id uuid PRIMARY KEY, body text NOT NULL);`,
|
|
);
|
|
const pluginId = await installPluginRecord(pluginManifest);
|
|
const pluginDb = pluginDatabaseService(db);
|
|
await pluginDb.applyMigrations(pluginId, pluginManifest, packageRoot);
|
|
|
|
await expect(
|
|
pluginDb.execute(pluginId, "UPDATE public.issues SET title = $1", ["bad"]),
|
|
).rejects.toThrow(/plugin namespace/i);
|
|
});
|
|
|
|
it("records a failed migration when SQL escapes the plugin namespace", async () => {
|
|
const pluginManifest = manifest("paperclip.escape");
|
|
const packageRoot = await createPluginPackage(
|
|
pluginManifest,
|
|
"CREATE TABLE public.plugin_escape (id uuid PRIMARY KEY);",
|
|
);
|
|
const pluginId = await installPluginRecord(pluginManifest);
|
|
|
|
await expect(
|
|
pluginDatabaseService(db).applyMigrations(pluginId, pluginManifest, packageRoot),
|
|
).rejects.toThrow(/public\.plugin_escape|public/i);
|
|
|
|
const [migration] = await db
|
|
.select()
|
|
.from(pluginMigrations)
|
|
.where(eq(pluginMigrations.pluginId, pluginId));
|
|
expect(migration?.status).toBe("failed");
|
|
});
|
|
|
|
it("rolls back plugin install when migration validation fails", async () => {
|
|
const pluginManifest = manifest("paperclip.escape");
|
|
const namespace = derivePluginDatabaseNamespace(pluginManifest.id);
|
|
const packageRoot = await createInstallablePluginPackage(
|
|
pluginManifest,
|
|
"CREATE TABLE public.plugin_escape (id uuid PRIMARY KEY);",
|
|
);
|
|
const loader = pluginLoader(db, {
|
|
enableLocalFilesystem: false,
|
|
enableNpmDiscovery: false,
|
|
});
|
|
|
|
await expect(loader.installPlugin({ localPath: packageRoot }))
|
|
.rejects.toThrow(/public\.plugin_escape|public/i);
|
|
|
|
const installedPlugins = await db
|
|
.select()
|
|
.from(plugins)
|
|
.where(eq(plugins.pluginKey, pluginManifest.id));
|
|
const namespaces = await db
|
|
.select()
|
|
.from(pluginDatabaseNamespaces)
|
|
.where(eq(pluginDatabaseNamespaces.pluginKey, pluginManifest.id));
|
|
const migrations = await db
|
|
.select()
|
|
.from(pluginMigrations)
|
|
.where(eq(pluginMigrations.pluginKey, pluginManifest.id));
|
|
const schemaRows = Array.from(
|
|
await db.execute(
|
|
sql<{ schema_name: string }>`SELECT schema_name FROM information_schema.schemata WHERE schema_name = ${namespace}`,
|
|
) as Iterable<{ schema_name: string }>,
|
|
);
|
|
|
|
expect(installedPlugins).toHaveLength(0);
|
|
expect(namespaces).toHaveLength(0);
|
|
expect(migrations).toHaveLength(0);
|
|
expect(schemaRows).toHaveLength(0);
|
|
});
|
|
|
|
it("refreshes persisted manifests from disk before activation", async () => {
|
|
const staleManifest = manifest("paperclip.refresh");
|
|
const refreshedManifest: PaperclipPluginManifestV1 = {
|
|
...staleManifest,
|
|
capabilities: [...staleManifest.capabilities, "agent.tools.register"],
|
|
database: {
|
|
...staleManifest.database!,
|
|
coreReadTables: ["companies"],
|
|
},
|
|
tools: [
|
|
{
|
|
name: "db-smoke",
|
|
displayName: "DB Smoke",
|
|
description: "Exercises plugin tool registration worker lookup.",
|
|
parametersSchema: { type: "object", properties: {} },
|
|
},
|
|
],
|
|
};
|
|
const namespace = derivePluginDatabaseNamespace(refreshedManifest.id);
|
|
const packageRoot = await createInstallablePluginPackage(
|
|
refreshedManifest,
|
|
`
|
|
CREATE TABLE ${namespace}.company_refs (
|
|
id uuid PRIMARY KEY,
|
|
company_id uuid NOT NULL REFERENCES public.companies(id)
|
|
);
|
|
`,
|
|
);
|
|
const pluginId = await installPluginRecord(staleManifest);
|
|
await db
|
|
.update(plugins)
|
|
.set({
|
|
packagePath: packageRoot,
|
|
status: "ready",
|
|
})
|
|
.where(eq(plugins.id, pluginId));
|
|
|
|
const workerManager = {
|
|
startWorker: vi.fn().mockResolvedValue(undefined),
|
|
stopAll: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
const toolDispatcher = {
|
|
registerPluginTools: vi.fn(),
|
|
};
|
|
const loader = pluginLoader(db, {
|
|
enableLocalFilesystem: false,
|
|
enableNpmDiscovery: false,
|
|
}, {
|
|
workerManager,
|
|
eventBus: {
|
|
forPlugin: vi.fn(() => ({})),
|
|
subscriptionCount: vi.fn(() => 0),
|
|
},
|
|
jobScheduler: {
|
|
registerPlugin: vi.fn().mockResolvedValue(undefined),
|
|
stop: vi.fn(),
|
|
},
|
|
jobStore: {
|
|
syncJobDeclarations: vi.fn().mockResolvedValue(undefined),
|
|
},
|
|
toolDispatcher,
|
|
lifecycleManager: {
|
|
markError: vi.fn().mockResolvedValue(undefined),
|
|
},
|
|
buildHostHandlers: vi.fn(() => ({})),
|
|
instanceInfo: {
|
|
instanceId: "test-instance",
|
|
hostVersion: "1.0.0",
|
|
deploymentMode: "authenticated",
|
|
deploymentExposure: "public",
|
|
},
|
|
} as never);
|
|
|
|
const result = await loader.loadSingle(pluginId);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(workerManager.startWorker).toHaveBeenCalledWith(
|
|
pluginId,
|
|
expect.objectContaining({
|
|
databaseNamespace: namespace,
|
|
env: {
|
|
PAPERCLIP_DEPLOYMENT_MODE: "authenticated",
|
|
PAPERCLIP_DEPLOYMENT_EXPOSURE: "public",
|
|
},
|
|
manifest: expect.objectContaining({
|
|
database: expect.objectContaining({ coreReadTables: ["companies"] }),
|
|
}),
|
|
}),
|
|
);
|
|
expect(toolDispatcher.registerPluginTools).toHaveBeenCalledWith(
|
|
refreshedManifest.id,
|
|
expect.objectContaining({
|
|
tools: refreshedManifest.tools,
|
|
}),
|
|
pluginId,
|
|
);
|
|
const [plugin] = await db
|
|
.select()
|
|
.from(plugins)
|
|
.where(eq(plugins.id, pluginId));
|
|
expect(plugin?.manifestJson.database?.coreReadTables).toEqual(["companies"]);
|
|
});
|
|
|
|
it("rejects checksum changes for already applied migrations", async () => {
|
|
const pluginManifest = manifest();
|
|
const namespace = derivePluginDatabaseNamespace(pluginManifest.id);
|
|
const packageRoot = await createPluginPackage(
|
|
pluginManifest,
|
|
`CREATE TABLE ${namespace}.checksum_rows (id uuid PRIMARY KEY);`,
|
|
);
|
|
const pluginId = await installPluginRecord(pluginManifest);
|
|
const pluginDb = pluginDatabaseService(db);
|
|
await pluginDb.applyMigrations(pluginId, pluginManifest, packageRoot);
|
|
|
|
await writeFile(
|
|
path.join(packageRoot, "migrations", "001_init.sql"),
|
|
`CREATE TABLE ${namespace}.checksum_rows (id uuid PRIMARY KEY, note text);`,
|
|
"utf8",
|
|
);
|
|
|
|
await expect(pluginDb.applyMigrations(pluginId, pluginManifest, packageRoot))
|
|
.rejects.toThrow(/checksum mismatch/i);
|
|
});
|
|
});
|