mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-08 21:03:51 +02:00
fix(plugins): adopt and constrain verified distribution bundles
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
8509dc3f07
commit
9ec88d5fb0
6 files changed
+110
-15
No files matched your search
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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) }),
|
||||
} };
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user