From cd328c1e51df6e2c6e7e7a936fd6bb135a0dae26 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 30 Oct 2026 11:29:37 -0500 Subject: [PATCH] fix(cloud): honor managed plugin floors and provider origins Align shipped provider package versions with their manifests and preserve the registry origin of self-hosted npm workers. Co-Authored-By: Paperclip --- doc/plugins/PLUGIN_SPEC.md | 5 ++- .../sandbox-providers/daytona/package.json | 2 +- .../sandbox-providers/kubernetes/package.json | 2 +- .../__tests__/bundled-plugin-package.test.ts | 34 +++++++++++++++---- .../bundled-plugin-registration.test.ts | 26 +++++++++++++- server/src/__tests__/bundled-plugins.test.ts | 16 +++++++++ server/src/app.ts | 3 +- server/src/services/bundled-plugins.ts | 8 +++++ server/src/services/plugin-loader.ts | 2 +- 9 files changed, 85 insertions(+), 13 deletions(-) diff --git a/doc/plugins/PLUGIN_SPEC.md b/doc/plugins/PLUGIN_SPEC.md index 614d2ba50a1..7d7b4d1fb33 100644 --- a/doc/plugins/PLUGIN_SPEC.md +++ b/doc/plugins/PLUGIN_SPEC.md @@ -37,7 +37,10 @@ In practice, that means the current implementation is a good fit for local devel ### Managed image packages Managed Cloud provisions only plugins selected from the current release image -catalog. The database registration's plugin key, package name, version and +catalog. A Cloud tenant token or managed configuration keeps this image-only +policy active. Without an explicit selected package list, Cloud selects no +packages and cannot fall back to local installs. An explicit operator-managed +list also keeps this policy active outside Cloud. The database registration's plugin key, package name, version and approved manifest identify that package. Its legacy `packagePath` remains for local/self-hosted compatibility; Cloud never uses that path to locate code. Workers, plugin migrations and UI files resolve through the same current-image diff --git a/packages/plugins/sandbox-providers/daytona/package.json b/packages/plugins/sandbox-providers/daytona/package.json index ddc5acd934f..84007584468 100644 --- a/packages/plugins/sandbox-providers/daytona/package.json +++ b/packages/plugins/sandbox-providers/daytona/package.json @@ -1,6 +1,6 @@ { "name": "@paperclipai/plugin-daytona", - "version": "0.1.1", + "version": "0.1.9", "description": "Daytona sandbox provider plugin for Paperclip environments", "license": "MIT", "homepage": "https://github.com/paperclipai/paperclip", diff --git a/packages/plugins/sandbox-providers/kubernetes/package.json b/packages/plugins/sandbox-providers/kubernetes/package.json index d1238aa7073..057f6685b8b 100644 --- a/packages/plugins/sandbox-providers/kubernetes/package.json +++ b/packages/plugins/sandbox-providers/kubernetes/package.json @@ -1,6 +1,6 @@ { "name": "@paperclipai/plugin-kubernetes", - "version": "0.1.0", + "version": "0.1.0-alpha.1", "description": "Kubernetes sandbox provider plugin for Paperclip environments", "license": "MIT", "homepage": "https://github.com/paperclipai/paperclip", diff --git a/server/src/__tests__/bundled-plugin-package.test.ts b/server/src/__tests__/bundled-plugin-package.test.ts index d90192353a4..367006a2c65 100644 --- a/server/src/__tests__/bundled-plugin-package.test.ts +++ b/server/src/__tests__/bundled-plugin-package.test.ts @@ -1,4 +1,4 @@ -import { rmSync, writeFileSync, symlinkSync, mkdirSync } from "node:fs"; +import { rmSync, writeFileSync, symlinkSync, mkdirSync, readFileSync } from "node:fs"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { bundledImageFixture } from "./helpers/bundled-image-fixture.js"; @@ -6,6 +6,17 @@ import { createBundledPluginPackageResolver } from "../services/bundled-plugin-p import { BUNDLED_PLUGIN_CATALOG, resolveBundledPluginInstalls } from "../services/bundled-plugins.js"; import { readDistributionPluginCatalog } from "../services/distribution-plugin-catalog.js"; +import boxd from "../../../packages/plugins/sandbox-providers/boxd/src/manifest.js"; +import createos from "../../../packages/plugins/sandbox-providers/createos/src/manifest.js"; +import cloudflare from "../../../packages/plugins/sandbox-providers/cloudflare/src/manifest.js"; +import daytona from "../../../packages/plugins/sandbox-providers/daytona/src/manifest.js"; +import e2b from "../../../packages/plugins/sandbox-providers/e2b/src/manifest.js"; +import exe_dev from "../../../packages/plugins/sandbox-providers/exe-dev/src/manifest.js"; +import kubernetes from "../../../packages/plugins/sandbox-providers/kubernetes/src/manifest.js"; +import modal from "../../../packages/plugins/sandbox-providers/modal/src/manifest.js"; +import novita from "../../../packages/plugins/sandbox-providers/novita/src/manifest.js"; + +const manifests = { boxd, createos, cloudflare, daytona, e2b, "exe-dev": exe_dev, kubernetes, modal, novita }; const roots: string[] = []; function fixture() { const result = bundledImageFixture(); roots.push(result.root); return result; } afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); @@ -56,11 +67,20 @@ describe("bundled image package identity", () => { expect(() => resolver.resolve(registration)).toThrow(/escapes/); }); - it("applies the same identity and containment checks to managed built-in packages", () => { - const { root, packageRoot, manifest, registration } = fixture(); - const installs = resolveBundledPluginInstalls(["kubernetes"], { catalogRoot: root, enforceCatalogRoot: true, distributionPlugins: [], env: {} }); - installs[0] = { ...installs[0]!, localPath: packageRoot, pluginKey: manifest.id }; - expect(createBundledPluginPackageResolver(installs).resolve(registration)).toBe(packageRoot); - expect(() => createBundledPluginPackageResolver(installs).resolve({ ...registration, version: "2.0.0" })).toThrow(/identity\/version/); + it.each(BUNDLED_PLUGIN_CATALOG)("accepts the real shipped $key package and manifest contract", (entry) => { + const manifest = manifests[entry.key as keyof typeof manifests]; + const pkg = JSON.parse(readFileSync(new URL(`../../../packages/plugins/${entry.relativePath}/package.json`, import.meta.url), "utf8")); + const { root } = fixture(); + const packageRoot = path.join(root, entry.relativePath); + mkdirSync(path.join(packageRoot, "dist"), { recursive: true }); + writeFileSync(path.join(packageRoot, "package.json"), JSON.stringify(pkg)); + writeFileSync(path.join(packageRoot, "dist", "manifest.js"), `export default ${JSON.stringify(manifest)};`); + writeFileSync(path.join(packageRoot, "dist", "worker.js"), "export default {};"); + const installs = resolveBundledPluginInstalls([entry.key], { catalogRoot: root, enforceCatalogRoot: true, distributionPlugins: [], env: {} }); + const resolver = createBundledPluginPackageResolver(installs); + const registration = { pluginKey: entry.pluginKey, packageName: pkg.name, version: manifest.version, manifestJson: manifest }; + expect(pkg.version).toBe(manifest.version); + expect(resolver.resolve(registration)).toBe(packageRoot); + expect(resolver.inspect(registration, manifest)).toMatchObject({ compatible: true }); }); }); diff --git a/server/src/__tests__/bundled-plugin-registration.test.ts b/server/src/__tests__/bundled-plugin-registration.test.ts index 99147bc8619..e7f5c2b7de8 100644 --- a/server/src/__tests__/bundled-plugin-registration.test.ts +++ b/server/src/__tests__/bundled-plugin-registration.test.ts @@ -1,7 +1,7 @@ import express from "express"; import request from "supertest"; import { execFileSync } from "node:child_process"; -import { rmSync, mkdirSync, writeFileSync } from "node:fs"; +import { rmSync, mkdirSync, writeFileSync, cpSync, readFileSync } from "node:fs"; import path from "node:path"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { applyPendingMigrations, closeRegisteredClients, companies, createDb, plugins } from "@paperclipai/db"; @@ -14,6 +14,7 @@ import { pluginUiStaticRoutes } from "../routes/plugin-ui-static.js"; import { errorHandler } from "../middleware/error-handler.js"; import { derivePluginDatabaseNamespace } from "../services/plugin-database.js"; import { ensureBundledPlugins } from "../services/bundled-plugins.js"; +import daytonaManifest from "../../../packages/plugins/sandbox-providers/daytona/src/manifest.js"; import { bundledImageFixture } from "./helpers/bundled-image-fixture.js"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; @@ -193,6 +194,29 @@ describePostgres("bundled image registration in PostgreSQL", () => { await db.execute((await import("drizzle-orm")).sql.raw(`DROP SCHEMA "${derivePluginDatabaseNamespace(image.manifest.id)}" CASCADE`)); }); + it("retains registry-install provider credentials for an ordinary npm package", async () => { + const npmManifest = { ...daytonaManifest, id: "paperclip.image-npm-fixture" }; + const image = bundledImageFixture(npmManifest); + roots.push(image.root); + const localPluginDir = path.join(image.root, "npm-home"); + const packageRoot = path.join(localPluginDir, "node_modules", "@paperclipai", "plugin-daytona"); + mkdirSync(path.dirname(packageRoot), { recursive: true }); + cpSync(image.packageRoot, packageRoot, { recursive: true }); + const metadata = JSON.parse(readFileSync(path.join(packageRoot, "package.json"), "utf8")); + metadata.name = "@paperclipai/plugin-daytona"; + writeFileSync(path.join(packageRoot, "package.json"), JSON.stringify(metadata)); + const registry = pluginRegistryService(db); + const row = (await registry.install({ packageName: metadata.name }, npmManifest))!; + await registry.updateStatus(row.id, { status: "ready" }); + vi.stubEnv("DAYTONA_API_KEY", "synthetic-provider-key"); + try { + const current = runtime(); + const loader = pluginLoader(db, { localPluginDir, enableNpmDiscovery: false, enableLocalFilesystem: false }, current.services as unknown as PluginRuntimeServices); + expect((await loader.loadSingle(row.id)).success).toBe(true); + expect(current.services.workerManager.startWorker).toHaveBeenCalledWith(row.id, expect.objectContaining({ env: expect.objectContaining({ DAYTONA_API_KEY: "synthetic-provider-key" }) })); + } finally { vi.unstubAllEnvs(); } + }); + it("keeps self-hosted local-path loading available without a managed resolver", async () => { const image = fixture(); const loader = pluginLoader(db, { localPluginDir: path.join(image.root, "selfhost"), enableNpmDiscovery: false, enableLocalFilesystem: false }); diff --git a/server/src/__tests__/bundled-plugins.test.ts b/server/src/__tests__/bundled-plugins.test.ts index 5dfd5e8f90e..d66b0d849da 100644 --- a/server/src/__tests__/bundled-plugins.test.ts +++ b/server/src/__tests__/bundled-plugins.test.ts @@ -9,6 +9,7 @@ import { ensureBundledPlugins, resolveBundledCatalogRoot, resolveBundledPluginInstalls, + resolveManagedBundledPluginKeys, type BundledPluginProvisionerDeps, type ResolvedBundledPlugin, } from "../services/bundled-plugins.js"; @@ -30,6 +31,21 @@ afterAll(() => { // Catalog-root resolution (fail-to-start allowlist) // --------------------------------------------------------------------------- +describe("managed bundled plugin selection", () => { + it.each([ + { env: {}, selected: undefined, expected: null }, + { env: { PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN: "tenant-fixture" }, selected: undefined, expected: [] }, + { env: { PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN: " " }, selected: undefined, expected: null }, + { env: { PAPERCLIP_MANAGED_CONFIG: "" }, selected: undefined, expected: [] }, + { env: { PAPERCLIP_MANAGED_CONFIG: "{}" }, selected: null, expected: [] }, + { env: { PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN: "tenant-fixture" }, selected: ["daytona"], expected: ["daytona"] }, + { env: {}, selected: ["modal"], expected: ["modal"] }, + { env: {}, selected: [], expected: [] }, + ])("uses the canonical Cloud floor and preserves explicit selection: $env / $selected", ({ env, selected, expected }) => { + expect(resolveManagedBundledPluginKeys(selected, env)).toEqual(expected); + }); +}); + describe("resolveBundledPluginInstalls", () => { it("resolves the optional CreateOS sandbox provider without making it a self-hosted default", () => { expect(resolveBundledPluginInstalls(["createos"], { diff --git a/server/src/app.ts b/server/src/app.ts index d1d3ebbdfbc..130bfed7b60 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -183,6 +183,7 @@ import { ensureBundledPlugins, resolveBundledCatalogRoot, resolveBundledPluginInstalls, + resolveManagedBundledPluginKeys, } from "./services/bundled-plugins.js"; import { createBundledPluginPackageResolver } from "./services/bundled-plugin-package.js"; import { readDistributionPluginCatalog, distributionPluginActivationGuard } from "./services/distribution-plugin-catalog.js"; @@ -624,7 +625,7 @@ export async function createApp( res.set("Cache-Control", "no-store"); res.set("Referrer-Policy", "no-referrer"); res.redirect(303, await chatChannels.githubWizard.identityCallback(String(req.query.state ?? ""), String(req.query.code ?? ""), req.actor)); }); - const managedAutoInstallKeys = opts.managedPluginAutoInstall ?? null; + const managedAutoInstallKeys = resolveManagedBundledPluginKeys(opts.managedPluginAutoInstall); const bundledCatalogRoot = opts.bundledPluginCatalogRoot ?? resolveBundledCatalogRoot(process.env); const distributionPlugins = readDistributionPluginCatalog(bundledCatalogRoot, BUNDLED_PLUGIN_CATALOG); diff --git a/server/src/services/bundled-plugins.ts b/server/src/services/bundled-plugins.ts index a53bc124ae9..cb59fa5cbae 100644 --- a/server/src/services/bundled-plugins.ts +++ b/server/src/services/bundled-plugins.ts @@ -1,6 +1,7 @@ import path from "node:path"; import fs from "node:fs"; import { isDeepStrictEqual } from "node:util"; +import { isCloudManagedInstance } from "./cloud-instance.js"; import type { PaperclipPluginManifestV1 } from "@paperclipai/shared"; import { assertDistributionManifestCapabilities, readDistributionPluginCatalog, type DistributionPlugin } from "./distribution-plugin-catalog.js"; @@ -118,6 +119,13 @@ export const BUNDLED_PLUGIN_CATALOG: readonly BundledPluginCatalogEntry[] = [ */ export const SELF_HOSTED_AUTO_INSTALL_KEYS: readonly string[] = ["kubernetes"]; +export function resolveManagedBundledPluginKeys( + keys: readonly string[] | null | undefined, + env: Record = process.env, +): readonly string[] | null { + return keys ?? (isCloudManagedInstance(env) ? [] : null); +} + export function resolveBundledCatalogRoot( env: Record, ): string { diff --git a/server/src/services/plugin-loader.ts b/server/src/services/plugin-loader.ts index 9fc558f9f9b..ab5fb092786 100644 --- a/server/src/services/plugin-loader.ts +++ b/server/src/services/plugin-loader.ts @@ -2338,7 +2338,7 @@ export function pluginLoader( env: buildPluginWorkerEnv({ manifest, packageName: activePlugin.packageName, - packagePath: packageRoot, + packagePath: resolvePackageRoot ? packageRoot : activePlugin.packagePath, instanceInfo, }), // Authorize the worker to act on each configured company from its