fix(plugins): adopt and constrain verified distribution bundles

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip committed 2026-09-19 08:57:49 -07:00
1 parent 8509dc3f07
commit 9ec88d5fb0
6 files changed
+110 -15

No files matched your search

+6
View File
@@ -51,6 +51,12 @@ is recorded as a plugin error without taking down the application.
The catalog alone does not auto-enable plugins on self-hosted instances.
Operators can explicitly install catalog entries through the normal plugin
CLI. The package's manifest ID and version must match the catalog.
The manifest's worker and optional UI entrypoints must match the verified
`package.json` declarations and stay inside the bundle.
At boot, selected distribution entries adopt the current image's package path
even when a previous npm or local install has the same version. Reconciliation
keeps the registry ID, configuration, stored state and operator-disabled status.
Keep each key's directory stable across releases. The activation guard also
covers persisted installs: a plugin removed from the image catalog, or no
@@ -215,6 +215,7 @@ type LooseRow = {
version?: string;
manifestJson?: Record<string, unknown>;
lastError?: string | null;
packagePath?: string | null;
};
// Build a minimal manifest for a persisted row or a shipped bundle. The reconcile
@@ -402,6 +403,41 @@ describe("ensureBundledPlugins", () => {
expect(update).not.toHaveBeenCalled();
});
it.each([null, "/old/plugins/widget"])("adopts a selected distribution path from %s even at the same version", async (packagePath) => {
const localPath = path.join(CATALOG_ROOT, "distribution/widget");
const distribution = { key: "widget", pluginKey: "acme.widget", version: "0.1.0", directory: "widget", digest: `sha256:${"a".repeat(64)}`, localPath, entrypoints: { worker: "dist/worker.js" } };
for (const status of ["ready", "disabled", "error"]) {
const { deps, loadManifest, update, installPlugin, updateStatus } = makeDeps({
rows: { "acme.widget": { id: "row-widget", pluginKey: "acme.widget", status, version: "0.1.0", packagePath } },
// Distribution packages may declare a manifest outside dist/manifest.js.
bundleManifestExists: () => false,
});
const manifest = makeManifest("acme.widget", "0.1.0");
loadManifest.mockResolvedValue(manifest);
await ensureBundledPlugins([{ ...distribution, distribution }], deps, { reinstallUninstalled: true });
// Same row: retain config/state and operator-disabled status; only error
// gets the existing one-shot boot retry. No reinstall/capability reset.
expect(update).toHaveBeenCalledExactlyOnceWith("row-widget", { packagePath: localPath, version: "0.1.0", manifest });
expect(installPlugin).not.toHaveBeenCalled();
expect(updateStatus).toHaveBeenCalledTimes(status === "error" ? 1 : 0);
expect(loadManifest).toHaveBeenCalledExactlyOnceWith(localPath);
expect(deps.logger.error).not.toHaveBeenCalled();
}
});
it("does not rebind a distribution install whose new manifest fails validation", async () => {
const localPath = path.join(CATALOG_ROOT, "distribution/widget");
const distribution = { key: "widget", pluginKey: "acme.widget", version: "0.1.0", directory: "widget", digest: `sha256:${"a".repeat(64)}`, localPath, entrypoints: { worker: "dist/worker.js" } };
const { deps, loadManifest, update, updateStatus } = makeDeps({
rows: { "acme.widget": { id: "row-widget", pluginKey: "acme.widget", status: "error", packagePath: "/old/widget" } },
});
loadManifest.mockRejectedValue(new Error("Distribution manifest entrypoints do not match the verified package"));
await ensureBundledPlugins([{ ...distribution, distribution }], deps, { reinstallUninstalled: true });
expect(update).not.toHaveBeenCalled();
expect(updateStatus).not.toHaveBeenCalled();
expect(deps.logger.error).toHaveBeenCalled();
});
it("swallows a reconcile error and continues boot", async () => {
const { deps, update } = makeDeps({
rows: {
@@ -76,4 +76,29 @@ describe("image-owned plugin catalogs", () => {
expect(() => guard({ pluginKey: "acme.widget", packageRoot: localPath, manifest: manifest as PaperclipPluginManifestV1 })).toThrow(/identity\/version/);
}
});
it("binds runtime worker and UI entrypoints to the digest-verified package declarations", () => {
const { root, localPath } = fixture();
const entries = readDistributionPluginCatalog(root, BUNDLED_PLUGIN_CATALOG);
const guard = distributionPluginActivationGuard(root, entries, ["acme.widget"]);
const manifest = { id: "acme.widget", version: "1.0.0", entrypoints: { worker: "dist/worker.js", ui: "./dist/ui" } } as PaperclipPluginManifestV1;
expect(() => guard({ packageRoot: localPath, manifest })).not.toThrow();
for (const name of ["worker", "ui"] as const) {
for (const value of ["/outside/worker.js", "../outside", "dist/../worker.js", "C:/outside", "dist\\worker.js", "dist/other", "", undefined]) {
const invalid = { ...manifest, entrypoints: { ...manifest.entrypoints, [name]: value } } as PaperclipPluginManifestV1;
expect(() => guard({ packageRoot: localPath, manifest: invalid })).toThrow(/entrypoint/);
}
}
});
it("accepts worker-only bundles but rejects a UI path absent from verified metadata", () => {
const { root, localPath, entry, save } = fixture();
writeFileSync(path.join(localPath, "package.json"), JSON.stringify({ version: "1.0.0", paperclipPlugin: { manifest: "./dist/manifest.js", worker: "./dist/worker.js" } }));
save([{ ...entry, digest: distributionBundleDigest(localPath) }]);
const guard = distributionPluginActivationGuard(root, readDistributionPluginCatalog(root, BUNDLED_PLUGIN_CATALOG), null);
const manifest = { id: "acme.widget", version: "1.0.0", entrypoints: { worker: "./dist/worker.js" } } as PaperclipPluginManifestV1;
expect(() => guard({ packageRoot: localPath, manifest })).not.toThrow();
manifest.entrypoints.ui = "./dist/ui";
expect(() => guard({ packageRoot: localPath, manifest })).toThrow(/verified package/);
});
});
+19 -10
View File
@@ -208,6 +208,7 @@ interface RegistryPluginRow {
status: string;
version: string;
manifestJson: PaperclipPluginManifestV1;
packagePath?: string | null;
lastError?: string | null;
}
@@ -216,7 +217,7 @@ export interface BundledPluginProvisionerDeps {
getByKey(pluginKey: string): Promise<RegistryPluginRow | null>;
update(
id: string,
data: { version?: string; manifest?: PaperclipPluginManifestV1 },
data: { version?: string; manifest?: PaperclipPluginManifestV1; packagePath?: string },
): Promise<unknown>;
updateStatus(id: string, input: { status: "ready"; lastError: string | null }): Promise<unknown>;
};
@@ -246,7 +247,9 @@ function defaultBundleManifestExists(localPath: string): boolean {
* 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
* persisted version, update the stored manifest and version. Distribution
* entries also adopt the image's package path, including same-version installs.
* This propagates
* a manifest change (for example a new driver capability) to an existing
* install that the auto-install path skips.
*
@@ -259,21 +262,25 @@ async function reconcileBundledPluginManifest(
install: ResolvedBundledPlugin,
deps: BundledPluginProvisionerDeps,
bundleManifestExists: (localPath: string) => boolean,
verifiedManifest?: PaperclipPluginManifestV1,
): Promise<void> {
try {
if (!bundleManifestExists(install.localPath)) return;
const bundleManifest = await deps.loader.loadManifest(install.localPath);
if (!verifiedManifest && !bundleManifestExists(install.localPath)) return;
const bundleManifest = verifiedManifest ?? await deps.loader.loadManifest(install.localPath);
if (!bundleManifest) return;
if (bundleManifest.version === existing.version) return;
const rebindPackage = install.distribution && existing.packagePath !== install.localPath && existing.status !== "uninstalled";
if (bundleManifest.version === existing.version && !rebindPackage) return;
await deps.registry.update(existing.id, {
version: bundleManifest.version,
manifest: bundleManifest,
...(rebindPackage ? { packagePath: install.localPath } : {}),
});
deps.logger.info(
{
pluginKey: install.pluginKey,
fromVersion: existing.version,
toVersion: bundleManifest.version,
...(rebindPackage ? { packagePath: install.localPath } : {}),
},
"reconciled bundled plugin manifest to the shipped bundle version",
);
@@ -367,22 +374,24 @@ export async function ensureBundledPlugins(
const bundleManifestExists = deps.bundleManifestExists ?? defaultBundleManifestExists;
for (const install of installs) {
try {
let verifiedManifest: PaperclipPluginManifestV1 | undefined;
if (install.distribution) {
const manifest = await deps.loader.loadManifest(install.localPath);
if (manifest?.id !== install.pluginKey || manifest.version !== install.distribution.version) {
throw new Error("Distribution manifest does not match its catalog identity/version");
}
verifiedManifest = manifest;
}
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
// manifest when the shipped bundle declares a different 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);
// plugin. Distribution entries also replace a legacy/npm package path
// before loadAll resolves the worker. Configuration and status stay put.
await reconcileBundledPluginManifest(existing, install, deps, bundleManifestExists, verifiedManifest);
if (existing.status === "error") {
await reenableErroredBundledPlugin(existing, install, deps);
continue;
@@ -395,7 +404,7 @@ export async function ensureBundledPlugins(
}
// 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)) {
if (!verifiedManifest && !bundleManifestExists(install.localPath)) {
deps.logger.info(
{ pluginKey: install.pluginKey, pluginPath: install.localPath },
"bundled plugin bundle not present; skipping auto-install",
@@ -18,8 +18,18 @@ export const distributionPluginCatalogSchema = z.object({
export type DistributionPlugin = z.infer<typeof distributionPluginCatalogSchema>["plugins"][number] & {
localPath: string;
/** Normalized paths from the digest-verified package metadata. */
entrypoints: { worker: string; ui?: string };
};
function bundleEntrypoint(declared: unknown): string {
const relative = typeof declared === "string" ? declared.replace(/^\.\//, "").replace(/\/$/, "") : "";
if (!relative || path.posix.isAbsolute(relative) || path.win32.isAbsolute(relative) || relative.includes("\\") || relative.split("/").some((part) => !part || part === "." || part === "..")) {
throw new Error("Distribution entrypoint must stay inside its bundle");
}
return relative;
}
export function distributionPluginsRoot(catalogRoot: string): string {
// Match canonical paths persisted by local-path installs (for example,
// macOS /tmp -> /private/tmp), without permitting a symlinked catalog itself.
@@ -54,6 +64,13 @@ export function distributionPluginActivationGuard(
if (input.manifest && (input.manifest.id !== entry.pluginKey || input.manifest.version !== entry.version)) {
throw new Error("Distribution manifest does not match its catalog identity/version");
}
if (input.manifest) {
const worker = bundleEntrypoint(input.manifest.entrypoints.worker);
const ui = input.manifest.entrypoints.ui === undefined ? undefined : bundleEntrypoint(input.manifest.entrypoints.ui);
if (worker !== entry.entrypoints.worker || ui !== entry.entrypoints.ui) {
throw new Error("Distribution manifest entrypoints do not match the verified package");
}
}
};
}
@@ -117,13 +134,13 @@ export function readDistributionPluginCatalog(
for (const name of ["manifest", "worker", "ui"] as const) {
const declared = pkg.paperclipPlugin[name];
if (name === "ui" && declared === undefined) continue;
const relative = typeof declared === "string" ? declared.replace(/^\.\//, "").replace(/\/$/, "") : "";
if (!relative || relative.startsWith("/") || relative.includes("\\") || relative.split("/").some((part: string) => !part || part === "." || part === "..")) {
throw new Error("Distribution entrypoint must stay inside its bundle");
}
const relative = bundleEntrypoint(declared);
const target = fs.statSync(path.join(localPath, relative));
if (name === "ui" ? !target.isDirectory() : !target.isFile()) throw new Error("Distribution entrypoint is not prebuilt");
}
return { ...entry, localPath };
return { ...entry, localPath, entrypoints: {
worker: bundleEntrypoint(pkg.paperclipPlugin.worker),
...(pkg.paperclipPlugin.ui === undefined ? {} : { ui: bundleEntrypoint(pkg.paperclipPlugin.ui) }),
} };
});
}
+2
View File
@@ -199,6 +199,7 @@ export function pluginRegistryService(db: Db) {
id: string,
data: {
packageName?: string;
packagePath?: string;
version?: string;
manifest?: PaperclipPluginManifestV1;
},
@@ -210,6 +211,7 @@ export function pluginRegistryService(db: Db) {
updatedAt: new Date(),
};
if (data.packageName !== undefined) setClause.packageName = data.packageName;
if (data.packagePath !== undefined) setClause.packagePath = data.packagePath;
if (data.version !== undefined) setClause.version = data.version;
if (data.manifest !== undefined) {
setClause.manifestJson = data.manifest;