Files
PaperClipAI/server/src/services/bundled-plugins.ts
T
a8d32e5e61 feat(sandbox-providers): add CreateOS sandbox provider (#13434)
## 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
> - Operators can choose a provider to run agent work
> - CreateOS adds another provider with workspace-preserving pause and
resume
> - This pull request adds a CreateOS provider plugin
> - The benefit is that operators can preserve a workspace between runs
without keeping its compute active

## Linked Issues or Issue Description

Refs #13203 and the earlier closed #13096.

This continues the CreateOS contribution from @bhautikchudasama and
@ashwaq06. The branch preserves the original implementation commit.
Thank you to both contributors.

When squash-merging, preserve the original author's credit in the squash
commit body:

```text
Co-Authored-By: bhautikchudasama <BhautikChudasama@users.noreply.github.com>
```

The original fork rejects maintainer pushes. This branch includes the
merge-conflict resolution and review fixes. The request is described
below using `adapter_request.yml`.

**Agent or provider**

CreateOS sandbox API (https://api.sb.createos.sh).

**Why this adapter is useful**

CreateOS can pause a sandbox and resume it by ID. The workspace survives
the pause. This adds a reusable-lease option to the existing sandbox
provider system.

**How the agent is invoked**

Build and install the local plugin as described in its README. Open
Instance Settings, then Environments. Select the `createos` driver.
Supply an API key and shape. The driver then supplies sandbox leases for
agent runs.

**Are you willing to implement it?**

Yes. This pull request is the implementation.

## What Changed

- Adds the `createos` sandbox provider under
`packages/plugins/sandbox-providers/createos`.
- Calls the CreateOS HTTP API directly. The package adds no vendor SDK.
- Implements the environment lifecycle hooks, incremental process
output, and binary workspace sync.
- Registers the optional bundled provider and its trusted host
credential fallback. The fallback is limited to the official API origin;
custom endpoints require an explicit key.
- Lists the package in the release manifest with `publishFromCi: false`
until its first npm publish is bootstrapped.
- Waits through delayed pause/resume state updates without duplicate
action requests.
- Cancels queued API requests promptly while preserving request spacing.
- Uses direct CLI invocation in the setup guide so paths and IDs are
passed without an extra shell expansion.
- Includes current master and retains its existing Git-subfolder
containment fix.

## Demo

Fresh setup and a run against a CreateOS sandbox.


https://github.com/user-attachments/assets/e71b9e06-c006-4fb9-b847-52dfd68f6110


https://github.com/user-attachments/assets/43b5ac75-66bd-4f76-8563-67e4c7759084

## Verification

All 25 jobs in [CI run
34884260542](https://github.com/paperclipai/paperclip/actions/runs/34884260542)
passed at commit `f8d0997677024b784fdadf9d44a84c01cb4e813c`, including
typecheck, build, native runner verification, server and workspace
tests, browser tests, and the canary release dry run. Greptile reviewed
the same commit at 5/5 with no unresolved review threads.

GitHub reports no merge conflicts. The remaining merge gate is
code-owner approval for the new `package.json`, as required by
`.github/CODEOWNERS` and the `master` ruleset. Reviewers have been
requested automatically.

Local checks passed:

- Provider: `pnpm typecheck`, `pnpm test` (52 passed, one live smoke
skipped), and `pnpm build`.
- Host: focused credential and bundled-plugin tests (17 passed), plus
CLI invocation safety (39 passed).
- Release: package manifest check and release policy tests (18 passed).

The full local `pnpm test:run` attempt caught the README command issue;
its focused rerun now passes. The full local run stopped after its
general-server group: 7,804 tests passed, with unrelated embedded
PostgreSQL startup failures and 10 failures in unchanged
runtime-skill-cache tests (`EACCES` on directory rename on macOS). It
did not reach the later test groups. Local `pnpm -r typecheck` and `pnpm
build` reach the runner package and stop because this machine has no
Rust/Cargo installation. The corresponding CI checks passed on
provisioned runners, as linked above.

The live CreateOS smoke requires explicit provider credentials and was
not run during this review. It is available with `CREATEOS_LIVE_TEST=1
pnpm test` in the provider directory. The author supplied the demo links
above.

## Risks

The provider is opt-in and is not installed by default. It is available
through a local-path install or explicit image inclusion. npm
publication remains disabled until a maintainer bootstraps the package
and enables publishing.

Sandbox creation has no idempotency key. An ambiguous create response
can leave a resource that requires provider-account inspection. Process
tracking is in memory; durable lease recovery belongs to the host. The
provider does not advertise guaranteed expiry, interactive login,
snapshots, duplex channels, or ingress. Live native-runner qualification
remains outside this PR's tested claims.

## Model Used

Original provider implementation: human-authored by @bhautikchudasama,
as reported in #13203. The original description reports Claude Opus 5
assistance.

Review and follow-up fixes: OpenAI GPT-6 via Codex, with code review,
editing, and tool execution. The precise runtime model variant and
context-window size are not exposed in this session.

## 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; full-suite
environment limits documented above)
- [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

---------

Co-authored-by: bhautikchudasama <bhautikrchudasama@gmail.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-15 17:09:43 -07:00

427 lines
16 KiB
TypeScript

import path from "node:path";
import fs from "node:fs";
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
/**
* Bundled plugin auto-provisioning.
*
* Managed-cloud instances receive a `plugins.autoInstall` key list through
* `PAPERCLIP_MANAGED_CONFIG` (parsed fail-closed at startup — see
* `managed-config.ts`). Each key maps to a plugin bundled into
* the release image under the bundled catalog root. Nobody "installs" on a
* managed instance: the control plane provisions, tenants use.
*
* Two distinct failure postures, deliberately split:
*
* 1. **Resolution (this file, `resolveBundledPluginInstalls`) fails to
* start.** An unknown key or a path that escapes the bundled catalog
* root is a configuration/security violation — a positive allowlist,
* not a lookup. Throwing here happens
* synchronously inside `createApp`, before the server listens, so a bad
* document refuses to start rather than silently widening what code can
* be loaded into the host.
*
* 2. **Installation (`ensureBundledPlugins`) is fail-safe.**
* Missing bundle on disk, install error, load error: caught, logged,
* and swallowed per entry so the server ALWAYS finishes booting. A
* degraded boot (one provider unavailable) is strictly preferable to a
* crash loop across a fleet.
*
* Removal of a key from `autoInstall` stops future installs but never
* auto-uninstalls: there is intentionally no uninstall
* path anywhere in this module.
*/
/** Default location of the bundled plugin catalog inside the release image. */
export const DEFAULT_BUNDLED_CATALOG_ROOT = "/app/packages/plugins";
/**
* Env var that relocates the bundled catalog root (dev images, tests).
*/
export const BUNDLED_CATALOG_ROOT_ENV_VAR = "PAPERCLIP_BUNDLED_PLUGIN_ROOT";
export interface BundledPluginCatalogEntry {
/** Key the managed config's `plugins.autoInstall` list uses. */
key: string;
/** Manifest id / registry `pluginKey` the bundle installs as. */
pluginKey: string;
/** Bundle location relative to the bundled catalog root. */
relativePath: string;
/**
* Legacy absolute-path override honored for compatibility (the kubernetes
* bundle predates the catalog). Overrides are still subject to catalog
* containment when enforcement is on.
*/
pathOverrideEnvVar?: string;
}
/**
* The positive allowlist of plugins the control plane may auto-provision.
* Keys outside this table can never be installed through this path,
* regardless of what the managed config document says.
*/
export const BUNDLED_PLUGIN_CATALOG: readonly BundledPluginCatalogEntry[] = [
{
key: "createos",
pluginKey: "paperclip.createos-sandbox-provider",
relativePath: "sandbox-providers/createos",
},
{
key: "cloudflare",
pluginKey: "paperclip.cloudflare-sandbox-provider",
relativePath: "sandbox-providers/cloudflare",
},
{
key: "daytona",
pluginKey: "paperclip.daytona-sandbox-provider",
relativePath: "sandbox-providers/daytona",
},
{
key: "e2b",
pluginKey: "paperclip.e2b-sandbox-provider",
relativePath: "sandbox-providers/e2b",
},
{
key: "exe-dev",
pluginKey: "paperclip.exe-dev-sandbox-provider",
relativePath: "sandbox-providers/exe-dev",
},
{
key: "kubernetes",
pluginKey: "paperclip.kubernetes-sandbox-provider",
relativePath: "sandbox-providers/kubernetes",
pathOverrideEnvVar: "PAPERCLIP_KUBERNETES_PLUGIN_PATH",
},
{
key: "modal",
pluginKey: "paperclip.modal-sandbox-provider",
relativePath: "sandbox-providers/modal",
},
{
key: "novita",
pluginKey: "paperclip.novita-sandbox-provider",
relativePath: "sandbox-providers/novita",
},
];
/**
* Keys ensured on a self-hosted instance (no managed config present).
* Exactly the pre-refactor behavior: the kubernetes sandbox provider is
* auto-installed when its bundle is present, nothing else.
*/
export const SELF_HOSTED_AUTO_INSTALL_KEYS: readonly string[] = ["kubernetes"];
export function resolveBundledCatalogRoot(
env: Record<string, string | undefined>,
): string {
const override = env[BUNDLED_CATALOG_ROOT_ENV_VAR]?.trim();
return override ? override : DEFAULT_BUNDLED_CATALOG_ROOT;
}
export interface ResolvedBundledPlugin {
key: string;
pluginKey: string;
/** Absolute path handed to `loader.installPlugin({ localPath })`. */
localPath: string;
}
/**
* Canonicalize a path for containment comparison. Symlinks are resolved
* when the path exists so a link inside the catalog cannot point install
* resolution at a directory outside it; nonexistent paths fall back to a
* lexical resolve (`..` segments still collapse).
*/
function canonicalize(p: string): string {
const resolved = path.resolve(p);
try {
return fs.realpathSync(resolved);
} catch {
return resolved;
}
}
function isInsideRoot(candidate: string, root: string): boolean {
const rel = path.relative(root, candidate);
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
}
/**
* Resolve auto-install keys to concrete bundle paths.
*
* Throws — and the instance must refuse to start — when a key is not in
* the bundled catalog, or when `enforceCatalogRoot` is set and the
* resolved path escapes the catalog root. Callers pass
* `enforceCatalogRoot: true` for managed (control-plane-driven) key lists
* and `false` for the self-hosted built-in list, where the legacy
* kubernetes path override may point anywhere (unchanged behavior).
*/
export function resolveBundledPluginInstalls(
keys: readonly string[],
opts: {
catalogRoot: string;
env: Record<string, string | undefined>;
enforceCatalogRoot: boolean;
},
): ResolvedBundledPlugin[] {
const resolved: ResolvedBundledPlugin[] = [];
const seen = new Set<string>();
const canonicalRoot = canonicalize(opts.catalogRoot);
for (const key of keys) {
if (seen.has(key)) continue;
seen.add(key);
const entry = BUNDLED_PLUGIN_CATALOG.find((candidate) => candidate.key === key);
if (!entry) {
const known = BUNDLED_PLUGIN_CATALOG.map((candidate) => candidate.key).join(", ");
throw new Error(
`bundled plugin auto-install key "${key}" is not in the bundled catalog (known keys: ${known}); refusing to start`,
);
}
const override = entry.pathOverrideEnvVar
? opts.env[entry.pathOverrideEnvVar]?.trim()
: undefined;
const localPath = override
? path.resolve(override)
: path.resolve(opts.catalogRoot, entry.relativePath);
if (opts.enforceCatalogRoot && !isInsideRoot(canonicalize(localPath), canonicalRoot)) {
throw new Error(
`bundled plugin "${key}" resolves to "${localPath}", outside the bundled catalog root "${opts.catalogRoot}"; refusing to start`,
);
}
resolved.push({ key: entry.key, pluginKey: entry.pluginKey, localPath });
}
return resolved;
}
interface RegistryPluginRow {
id: string;
pluginKey: string;
status: string;
version: string;
manifestJson: PaperclipPluginManifestV1;
lastError?: string | null;
}
export interface BundledPluginProvisionerDeps {
registry: {
getByKey(pluginKey: string): Promise<RegistryPluginRow | null>;
update(
id: string,
data: { version?: string; manifest?: PaperclipPluginManifestV1 },
): Promise<unknown>;
updateStatus(id: string, input: { status: "ready"; lastError: string | null }): Promise<unknown>;
};
loader: {
installPlugin(options: { localPath: string }): Promise<{
manifest: { id: string } | null;
}>;
loadManifest(packagePath: string): Promise<PaperclipPluginManifestV1 | null>;
};
lifecycle: {
load(pluginId: string): Promise<unknown>;
};
logger: {
info(obj: unknown, msg?: string): void;
warn(obj: unknown, msg?: string): void;
error(obj: unknown, msg?: string): void;
};
/** Overridable for tests; defaults to checking `dist/manifest.js`. */
bundleManifestExists?: (localPath: string) => boolean;
}
function defaultBundleManifestExists(localPath: string): boolean {
return fs.existsSync(path.join(localPath, "dist", "manifest.js"));
}
/**
* Reconcile a present bundled plugin's persisted manifest with the shipped
* bundle. The bundle is part of the release image, so its manifest is the
* source of truth. When the bundle declares a version that differs from the
* persisted version, update the stored manifest and version. This propagates
* a manifest change (for example a new driver capability) to an existing
* install that the auto-install path skips.
*
* The step is fail-safe. A missing bundle, a manifest read error, or a
* database error is caught, logged, and swallowed, so boot always completes.
* The step updates only the stored manifest row; it never restarts the worker.
*/
async function reconcileBundledPluginManifest(
existing: RegistryPluginRow,
install: ResolvedBundledPlugin,
deps: BundledPluginProvisionerDeps,
bundleManifestExists: (localPath: string) => boolean,
): Promise<void> {
try {
if (!bundleManifestExists(install.localPath)) return;
const bundleManifest = await deps.loader.loadManifest(install.localPath);
if (!bundleManifest) return;
if (bundleManifest.version === existing.version) return;
await deps.registry.update(existing.id, {
version: bundleManifest.version,
manifest: bundleManifest,
});
deps.logger.info(
{
pluginKey: install.pluginKey,
fromVersion: existing.version,
toVersion: bundleManifest.version,
},
"reconciled bundled plugin manifest to the shipped bundle version",
);
} catch (err) {
deps.logger.error(
{ err, pluginKey: install.pluginKey },
"Failed to reconcile bundled plugin manifest; continuing boot with the stored manifest",
);
}
}
/**
* Re-enable a bundled plugin that a previous boot left in `error`.
*
* `error` is not an operator choice: the loader records it when activation
* fails (for example the worker's `initialize` RPC timed out once) and it
* also switches off the worker's auto-restart. Every automatic path
* afterwards (`loadAll()`, the lazy worker recovery, the run lease) only
* considers `ready` plugins, so a bundled plugin in `error` stays unusable
* across restarts until an operator enables it by hand, and every run that
* needs its provider fails with "that plugin is currently error". The bundle
* ships with the release image and is expected to work, so one fresh attempt
* per boot is the right default: the row goes back to `ready` (with its
* `lastError` cleared), and the startup `loadAll()` activates it. If
* activation fails again the loader marks `error` again and nothing retries
* until the next boot, so this cannot loop within one process.
*
* This is a plain registry status reset, not `lifecycle.enable()`: the
* lifecycle call would emit `plugin.enabled` before `loadAll()` has started
* the worker, and a consumer of that event (the dev watcher, activity
* listeners) would act on a plugin that may still fail to activate.
* Activation, and its own events, stay with `loadAll()`.
*
* Fail-safe like the rest of the provisioner: a failed status reset is
* logged and boot continues with the plugin unavailable.
*/
async function reenableErroredBundledPlugin(
existing: RegistryPluginRow,
install: ResolvedBundledPlugin,
deps: BundledPluginProvisionerDeps,
): Promise<void> {
deps.logger.warn(
{
pluginId: existing.id,
pluginKey: install.pluginKey,
lastError: existing.lastError ?? null,
},
"bundled plugin is in error status from a previous activation; re-enabling it for this boot",
);
try {
await deps.registry.updateStatus(existing.id, { status: "ready", lastError: null });
deps.logger.info(
{ pluginId: existing.id, pluginKey: install.pluginKey },
"bundled plugin reset to ready; the startup loader will activate it",
);
} catch (err) {
deps.logger.error(
{ err, pluginId: existing.id, pluginKey: install.pluginKey },
"Failed to re-enable errored bundled plugin; continuing boot (degraded: plugin unavailable)",
);
}
}
/**
* Ensure each resolved bundled plugin is installed and loaded.
*
* Same mechanism the kubernetes bundle has always used: in-process
* `loader.installPlugin({ localPath })` at boot — no HTTP, no user, no
* role. Fully fail-safe per entry: any disk/install/load
* failure is caught, logged, and swallowed so boot always completes.
*
* Skip semantics:
* - A plugin present in any non-uninstalled state is skipped, so an
* operator-disabled plugin is not silently re-enabled on reboot. Before the
* skip, the persisted manifest is reconciled to the shipped bundle version
* (see `reconcileBundledPluginManifest`).
* - The one exception is `error`, which the loader sets when an activation
* fails and which no automatic path ever clears. A bundled plugin in
* `error` is moved back to `ready` once per boot so `loadAll()` gets a
* fresh attempt (see `reenableErroredBundledPlugin`).
* - A soft-uninstalled plugin is reinstalled only when
* `reinstallUninstalled` is set (managed mode, where the control plane
* owns provisioning). Self-hosted keeps the pre-refactor behavior of
* leaving an operator's uninstall alone.
*/
export async function ensureBundledPlugins(
installs: readonly ResolvedBundledPlugin[],
deps: BundledPluginProvisionerDeps,
opts: { reinstallUninstalled: boolean },
): Promise<void> {
const bundleManifestExists = deps.bundleManifestExists ?? defaultBundleManifestExists;
for (const install of installs) {
try {
const existing = await deps.registry.getByKey(install.pluginKey);
if (existing && (existing.status !== "uninstalled" || !opts.reinstallUninstalled)) {
// The bundle ships with the release image, so its manifest is the
// source of truth for a present plugin. Reconcile the persisted
// manifest when the shipped bundle declares a newer version. Without
// this step a manifest capability added to a bundle never reaches an
// existing install, because the auto-install below skips a present
// plugin. The reconcile updates only the stored manifest row; the
// running worker already runs the shipped code.
await reconcileBundledPluginManifest(existing, install, deps, bundleManifestExists);
if (existing.status === "error") {
await reenableErroredBundledPlugin(existing, install, deps);
continue;
}
deps.logger.info(
{ pluginKey: install.pluginKey, status: existing.status },
"bundled plugin already present; skipping auto-install",
);
continue;
}
// Skip silently when the bundle is absent (e.g. local dev or an image
// built without the plugin). Not an error condition.
if (!bundleManifestExists(install.localPath)) {
deps.logger.info(
{ pluginKey: install.pluginKey, pluginPath: install.localPath },
"bundled plugin bundle not present; skipping auto-install",
);
continue;
}
deps.logger.info(
{ pluginKey: install.pluginKey, pluginPath: install.localPath },
"auto-installing bundled plugin",
);
const discovered = await deps.loader.installPlugin({ localPath: install.localPath });
if (!discovered.manifest) {
deps.logger.error(
{ pluginKey: install.pluginKey },
"bundled plugin installed but manifest is missing",
);
continue;
}
// Transition installed -> ready. Whether this also starts the worker
// depends on the injected lifecycle manager: one built with a
// runtime-capable loader activates here; the boot-time manager in
// app.ts is not, so at startup this only records `ready` and the
// worker is started exactly once by the subsequent loader.loadAll().
const installed = await deps.registry.getByKey(discovered.manifest.id);
if (installed) {
await deps.lifecycle.load(installed.id);
deps.logger.info(
{ pluginId: installed.id, pluginKey: installed.pluginKey },
"bundled plugin auto-installed and loaded",
);
} else {
deps.logger.error(
{ pluginKey: install.pluginKey },
"bundled plugin installed but not found in registry",
);
}
} catch (err) {
deps.logger.error(
{ err, pluginKey: install.pluginKey },
"Failed to auto-install bundled plugin; continuing boot (degraded: plugin unavailable)",
);
}
}
}