feat: add plugin loader with manifest validation and crash restart

Validates an unpacked plugin, spawns a worker_threads worker and manages
activate/deactivate/crash-restart with backoff, disabling after three
tries.

The crash counter clears only after a plugin stays up past a stability
window, not on activation. Clearing it on activation let a plugin that
activates cleanly and then dies restart forever, because every attempt
looked like the first.

loader.ts documents what the worker boundary does and does not buy you.
Node's permission model is process-wide, not per-worker, so it cannot be
enabled for plugin workers alone and is not claimed here.
This commit is contained in:
LukeGus committed 2026-09-21 07:19:01 -05:00
1 parent 9f4a2d3951
commit 0a771c9600
6 files changed
+1341

No files matched your search

+115
View File
@@ -0,0 +1,115 @@
import { describe, expect, it } from "vitest";
import {
parseManifest,
validateManifest,
SUPPORTED_PLUGIN_API_VERSION,
} from "../../plugins/manifest.js";
function validManifest(overrides: Record<string, unknown> = {}) {
return {
id: "sample-plugin",
name: "Sample Plugin",
version: "1.0.0",
description: "A sample plugin.",
author: { name: "Jane Doe" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: SUPPORTED_PLUGIN_API_VERSION },
capabilities: {
backend: true,
frontend: false,
electron: false,
platforms: ["linux"],
},
permissions: ["hosts.read", "storage.own"],
sidecars: [],
...overrides,
};
}
describe("plugin manifest validation", () => {
it("accepts a valid manifest", () => {
expect(validateManifest(validManifest())).toEqual([]);
});
it("reports every missing required field", () => {
const errors = validateManifest({ id: "x" });
expect(errors).toContain('Missing required field: "description"');
expect(errors).toContain('Missing required field: "engine"');
expect(errors).toContain('Missing required field: "sidecars"');
});
it("rejects a bad id pattern", () => {
const errors = validateManifest(validManifest({ id: "Bad_Id" }));
expect(errors.some((e) => e.startsWith('Field "id" must match'))).toBe(
true,
);
});
it("rejects a non-semver version", () => {
const errors = validateManifest(validManifest({ version: "1.0" }));
expect(errors).toContain(
'Field "version" must be valid semver, got: "1.0"',
);
});
it("rejects an unknown permission", () => {
const errors = validateManifest(
validManifest({ permissions: ["credentials.read"] }),
);
expect(
errors.some((e) => e.includes('Unknown permission: "credentials.read"')),
).toBe(true);
});
it("rejects an unknown category", () => {
const errors = validateManifest(validManifest({ category: "Games" }));
expect(
errors.some((e) => e.startsWith('Field "category" must be one of')),
).toBe(true);
});
it("rejects a non-object manifest", () => {
expect(validateManifest(null)).toEqual(["Manifest must be a JSON object"]);
expect(validateManifest([])).toEqual(["Manifest must be a JSON object"]);
});
it("validates contributes sub-shapes", () => {
const errors = validateManifest(
validManifest({
contributes: {
tabs: [{ id: "t", titleKey: "k", icon: "Box", openFrom: ["nope"] }],
apiPrefix: "Bad Prefix",
},
}),
);
expect(
errors.some((e) => e.includes('openFrom has unknown value: "nope"')),
).toBe(true);
expect(
errors.some((e) => e.includes('"contributes.apiPrefix" must match')),
).toBe(true);
});
describe("parseManifest", () => {
it("narrows a valid manifest", () => {
const { manifest, errors } = parseManifest(validManifest());
expect(errors).toEqual([]);
expect(manifest?.id).toBe("sample-plugin");
});
it("refuses a manifest targeting a different SDK major", () => {
const { manifest, errors } = parseManifest(
validManifest({ engine: { termix: ">=2.9.0", api: "99" } }),
);
expect(manifest).toBeUndefined();
expect(errors[0]).toContain('targets SDK api version "99"');
});
it("does not narrow when validation failed", () => {
const { manifest, errors } = parseManifest({ id: "x" });
expect(manifest).toBeUndefined();
expect(errors.length).toBeGreaterThan(0);
});
});
});