Declare and enforce plugin Paseo version requirements (#4430)

* feat(plugins): enforce declared Paseo version requirements

Treat manifests without a Paseo requirement as pre-0.8 plugins so the first breaking plugin release offers an explicit migration path. Check the daemon and app independently before plugin execution, and preserve installed Git revisions when updates are incompatible.

* fix(nix): refresh dependency hash for plugin requirements

* refactor(plugins): validate test manifest inputs and name compatibility input
This commit is contained in:
Mohamed Boudra
2026-09-07 17:20:19 +02:00
committed by GitHub
parent 1c51b52d07
commit a5d2b7d863
53 changed files with 928 additions and 81 deletions
+8 -2
View File
@@ -41,7 +41,7 @@ runtime-safe: run `paseo reload` after editing `config.json`. Enabling starts ev
enabled plugin; disabling tears them all down without restarting the daemon. Plugin source entries
remain lifecycle-owned and do not reload from manual config edits.
The directory contains an identity-only manifest, one optional entry per runtime, runtime-owned
The directory contains a manifest declaring identity and Paseo requirements, one optional entry per runtime, runtime-owned
directories, and local typechecking support. At least one entry is required.
```text
@@ -62,10 +62,15 @@ runtime modules, so consumers do not install these packages when adding the plug
```json
{
"id": "my-plugin"
"id": "my-plugin",
"requirements": { "paseo": ">=0.8.0" }
}
```
Declare the supported Paseo range and keep it current when adopting newer APIs. See the
[requirements contract](../public-docs/plugins/v0.8/reference.md#requirements), including legacy
manifests and prerelease matching.
The config key is the runtime plugin ID. The manifest ID is the default selected during install;
`--id` overrides it. Existing configuration is not renamed when the manifest changes, and the
runtime does not compare the two IDs. The same directory can be installed under several config
@@ -111,6 +116,7 @@ step:
```json
{
"id": "review",
"requirements": { "paseo": ">=0.8.0" },
"build": [
["npm", "ci"],
["npm", "run", "build"]
+1 -1
View File
@@ -1 +1 @@
sha256-Vw6bgh+gPdVxySdbawKawr9qZT8QEEo2+okdpuKceMA=
sha256-1MBzb+r/t92G9uR/jBuc3oNEfEloHeRg9uUgzSHS4UM=
+21
View File
@@ -13059,6 +13059,13 @@
"@types/node": "*"
}
},
"node_modules/@types/semver": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
@@ -38731,15 +38738,29 @@
"name": "@getpaseo/protocol",
"version": "0.7.2",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^20.9.0",
"@types/semver": "^7.8.0",
"typescript": "^5.2.2",
"vitest": "^4.1.6",
"zod-aot": "0.20.4"
}
},
"packages/protocol/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.7.2",
@@ -1,3 +1,4 @@
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -118,7 +119,10 @@ test("plugin modal adapts its presentation and preserves host contexts", async (
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-host-ui-e2e-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previousConfig = await client.getDaemonConfig();
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), PLUGIN_SOURCE);
try {
@@ -1,3 +1,4 @@
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -120,7 +121,10 @@ test("installs, reloads, recovers, disables, and removes a trusted local plugin"
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-e2e-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: "e2e-plugin" }));
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "e2e-plugin", requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v1"));
try {
@@ -1,4 +1,4 @@
import path from "node:path";
import { copyPluginExample } from "../support/helpers/plugin-fixture";
import { expect, test as base, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
@@ -95,11 +95,10 @@ const test = base.extend<{ modalExample: Page }>({
modalExample: async ({ page, context }, provide) => {
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const example = await copyPluginExample("modal-ui");
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(
path.resolve(__dirname, "../../../../plugin-examples/modal-ui"),
);
await client.installDirectoryPlugin(example.directory);
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await provide(page);
} finally {
@@ -108,6 +107,7 @@ const test = base.extend<{ modalExample: Page }>({
.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false })
.catch(() => undefined);
await client.close().catch(() => undefined);
await example.cleanup();
}
},
});
@@ -0,0 +1,18 @@
import {
test,
openRequirementHost,
expectAppMismatch,
correctRequirementAndReload,
rejectDaemonMismatch,
} from "../support/helpers/plugin-requirements";
test("shows which runtime is incompatible and recovers after the requirement is corrected", async ({
page,
requirementHost,
}) => {
await openRequirementHost(page, requirementHost);
await expectAppMismatch(page);
await correctRequirementAndReload(page, requirementHost.directory);
await rejectDaemonMismatch(page, requirementHost.directory);
await correctRequirementAndReload(page, requirementHost.directory);
});
@@ -1,3 +1,4 @@
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -53,7 +54,10 @@ test("applies a contributed theme and falls back when its plugin is gone", async
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-theme-e2e-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previousConfig = await client.getDaemonConfig();
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.ts"), PLUGIN_SOURCE);
try {
@@ -1,3 +1,4 @@
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -199,7 +200,10 @@ test.describe("plugin workspace panels and Command Center", () => {
repoPrefix: "plugin-panel-secondary-",
port: secondaryDaemon.port,
});
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
);
await writePluginSources(directory, {
workspaceId: primary.workspaceId,
agentId: "missing-agent",
@@ -0,0 +1,29 @@
import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { z } from "zod";
import appPackage from "../../../package.json";
export const pluginRequirements = { paseo: `>=${appPackage.version}` };
export async function copyPluginExample(name: string) {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-example-"));
try {
await cp(path.resolve(__dirname, "../../../../../plugin-examples", name), directory, {
recursive: true,
});
const manifestPath = path.join(directory, "paseo-plugin.json");
const manifest = z
.record(z.string(), z.unknown())
.parse(JSON.parse(await readFile(manifestPath, "utf8")));
// Exercise example UI against the checkout runtime before its release version is bumped.
await writeFile(
manifestPath,
JSON.stringify({ ...manifest, requirements: pluginRequirements }),
);
return { directory, cleanup: () => rm(directory, { recursive: true, force: true }) };
} catch (error) {
await rm(directory, { recursive: true, force: true });
throw error;
}
}
@@ -0,0 +1,114 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { spawnTsx, killProcessTree } from "./spawn-node";
import { expect, test as base, type Page } from "../fixtures";
import { connectDaemonClient } from "./daemon-client-loader";
import { addConnectedHostAndReload } from "./hosts";
import { gotoAppShell, openSettings } from "./app";
import { openHostSection, selectSettingsHost } from "./settings";
import { pluginRequirements } from "./plugin-fixture";
export const test = base.extend<{
requirementHost: { serverId: string; port: number; directory: string; client: DaemonClient };
}>({
requirementHost: async ({ e2eWorker }, provide) => {
void e2eWorker;
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-requirement-ui-"));
await writeRequirements(directory, "^99.0.0");
await writeFile(
path.join(directory, "index.client.ts"),
`export default function(client) {
return () => {};
}`,
);
const daemon = spawnTsx(
path.resolve(__dirname, "../../../../server/src/server/test-utils/versioned-daemon.ts"),
["99.0.0"],
{ stdio: ["ignore", "pipe", "pipe", "ipc"] },
);
const ready = Promise.withResolvers<{ port: number; serverId: string }>();
const timeout = setTimeout(
() => ready.reject(new Error("Versioned daemon startup timed out")),
30_000,
);
let output = "";
daemon.stdout?.on("data", (data) => {
output += data;
});
daemon.stderr?.on("data", (data) => {
output += data;
});
daemon.once("error", ready.reject);
daemon.once("exit", () => ready.reject(new Error(`Versioned daemon exited: ${output}`)));
daemon.once("message", (message) => {
if (
typeof message !== "object" ||
message === null ||
!("port" in message) ||
typeof message.port !== "number" ||
!("serverId" in message) ||
typeof message.serverId !== "string"
) {
ready.reject(new Error("Invalid versioned daemon startup message"));
return;
}
ready.resolve({ port: message.port, serverId: message.serverId });
});
try {
const host = await ready.promise;
clearTimeout(timeout);
const client = await connectDaemonClient<DaemonClient>({
port: host.port,
clientIdPrefix: "requirements",
});
try {
await client.installDirectoryPlugin(directory);
await provide({ ...host, directory, client });
} finally {
await client.close();
}
} finally {
clearTimeout(timeout);
await killProcessTree(daemon);
await rm(directory, { recursive: true, force: true });
}
},
});
async function writeRequirements(directory: string, paseo: string) {
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "requirements-example", requirements: { paseo } }),
);
}
export async function openRequirementHost(page: Page, host: { serverId: string; port: number }) {
await gotoAppShell(page);
await addConnectedHostAndReload(page, { ...host, label: "Plugin requirements" });
await openSettings(page);
await selectSettingsHost(page, host.serverId);
await openHostSection(page, host.serverId, "plugins");
}
export async function expectAppMismatch(page: Page) {
await expect(page.getByLabel("requirements-example failed", { exact: true })).toBeVisible();
await expect(
page.getByText(/Plugin "requirements-example" requires Paseo \^99.0.0. Your app is/),
).toBeVisible();
}
export async function correctRequirementAndReload(page: Page, directory: string) {
await writeRequirements(directory, pluginRequirements.paseo);
await page.getByRole("button", { name: "Reload", exact: true }).click();
await expect(page.getByLabel("requirements-example running", { exact: true })).toBeVisible();
await expect(page.getByText(/Your app is/)).toHaveCount(0);
}
export async function rejectDaemonMismatch(page: Page, directory: string) {
await writeRequirements(directory, ">=100.0.0");
await page.getByRole("button", { name: "Reload", exact: true }).click();
await expect(page.getByLabel("requirements-example failed", { exact: true })).toBeVisible();
await expect(page.getByText(/Your daemon is 99.0.0/).first()).toBeVisible();
}
@@ -1,11 +1,10 @@
import path from "node:path";
import { copyPluginExample } from "./plugin-fixture";
import { expect, test as base, type Page } from "../fixtures";
import { gotoAppShell, openSettings } from "./app";
import { goBackInSettings, openCompactSettings, openHostSection } from "./settings";
import { getServerId } from "./server-id";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
const directory = path.resolve(__dirname, "../../../../../plugin-examples/settings");
type SettingsClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
export const test = base.extend<{ settingsPlugin: SettingsClient }>({
@@ -14,15 +13,17 @@ export const test = base.extend<{ settingsPlugin: SettingsClient }>({
void e2eWorker;
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const example = await copyPluginExample("settings");
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await client.installDirectoryPlugin(example.directory);
await provide(client);
await page.screenshot({ path: testInfo.outputPath("settings.png") });
} finally {
await client.removePlugin("settings-example");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await client.close();
await example.cleanup();
}
},
{ auto: true },
@@ -0,0 +1,86 @@
import { afterEach, expect, it } from "vitest";
import { createPaseoApi } from "@getpaseo/client";
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { PluginRegistry } from "./registry";
const client = new DaemonClient({ url: "ws://unused.test", clientId: "plugin-requirements-test" });
const registries: PluginRegistry[] = [];
function registry(version: string) {
let starts = 0;
let cleanups = 0;
const result = new PluginRegistry({
version,
createRuntime() {
starts++;
return {
paseo: createPaseoApi(client),
rpc: async () => {
throw new Error("No RPC in this plugin");
},
openSettings() {
cleanups++;
},
openSurface() {},
openPanel() {},
addComposerPill: () => () => {},
};
},
});
registries.push(result);
return { result, starts: () => starts, cleanups: () => cleanups };
}
const clientBundle =
'(function() { return { default: function(client) { return () => client.openSettings("cleanup"); } }; })';
afterEach(() => {
for (const value of registries.splice(0)) value.removeHost("host");
});
it("checks the app version before creating a runtime or evaluating plugin code", () => {
const { result, starts } = registry("0.8.0");
result.installCatalog(
"host",
[
{
id: "example",
requirements: { paseo: ">=0.9.0" },
clientBundle: "throw new Error('executed')",
},
],
{ client },
);
expect(starts()).toBe(0);
expect(result.getSnapshot()).toEqual([]);
expect(result.getEvaluationError("host", "example")).toBe(
'Plugin "example" requires Paseo >=0.9.0. Your app is 0.8.0. Use a compatible plugin version or update the app.',
);
});
it("rejects catalogs without requirements from pre-0.8 daemons", () => {
const { result, starts } = registry("0.8.0");
result.installCatalog("host", [{ id: "example", clientBundle }], { client });
expect(starts()).toBe(0);
expect(result.getEvaluationError("host", "example")).toContain(
"https://paseo.sh/docs/plugins/v0.8/migration",
);
});
it("unloads on a requirement-only edit and recovers after correction", () => {
const { result, starts, cleanups } = registry("0.8.0");
const install = (paseo: string) =>
result.installCatalog("host", [{ id: "example", clientBundle, requirements: { paseo } }], {
client,
});
install("^0.8.0");
expect(result.getSnapshot().map(({ id }) => id)).toEqual(["example"]);
install("^0.8.0");
expect(starts()).toBe(1);
install(">=0.9.0");
expect(cleanups()).toBe(1);
expect(starts()).toBe(1);
expect(result.getSnapshot()).toEqual([]);
expect(result.getEvaluationError("host", "example")).toContain("requires Paseo >=0.9.0");
install(">=0.8.0");
expect(starts()).toBe(2);
expect(result.getSnapshot().map(({ id }) => id)).toEqual(["example"]);
expect(result.getEvaluationError("host", "example")).toBeUndefined();
});
+6 -1
View File
@@ -1,3 +1,4 @@
import appPackage from "../../package.json";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { pluginRegistry as registry } from "./registry";
@@ -25,7 +26,11 @@ const pluginRegistry = {
catalog: Parameters<typeof registry.installCatalog>[1],
options: { replacePluginId?: string } = {},
) {
return registry.installCatalog(serverId, catalog, { ...options, client: daemonClient });
return registry.installCatalog(
serverId,
catalog.map((entry) => ({ ...entry, requirements: { paseo: `>=${appPackage.version}` } })),
{ ...options, client: daemonClient },
);
},
};
+20 -8
View File
@@ -1,22 +1,28 @@
import { useMemo, useSyncExternalStore } from "react";
import { QueryClient } from "@tanstack/react-query";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements";
import { resolveAppVersion } from "@/utils/app-version";
import { createPluginClientRuntime } from "./client-runtime";
import { runPluginClientBundle } from "./evaluate";
import type { InstalledPlugin } from "./types";
interface CatalogPlugin {
id: string;
clientBundle: string;
}
type CatalogPlugin = Awaited<ReturnType<DaemonClient["getPluginCatalog"]>>[number];
class PluginRegistry {
export class PluginRegistry {
private readonly byHost = new Map<string, InstalledPlugin[]>();
private readonly listeners = new Set<() => void>();
private snapshot: InstalledPlugin[] = [];
private readonly disposed = new WeakSet<InstalledPlugin>();
private readonly evaluationErrors = new Map<string, string>();
constructor(
private readonly dependencies: {
version: string | null;
createRuntime: typeof createPluginClientRuntime;
},
) {}
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
@@ -45,7 +51,8 @@ class PluginRegistry {
(plugin) =>
plugin.id !== options.replacePluginId &&
plugin.id === entry.id &&
plugin.clientBundle === entry.clientBundle,
plugin.clientBundle === entry.clientBundle &&
plugin.requirements?.paseo === entry.requirements?.paseo,
);
return existing ? [existing] : [];
});
@@ -59,6 +66,7 @@ class PluginRegistry {
const key = `${serverId}/${entry.id}`;
try {
if (!entry.clientBundle) return [];
assertPluginCompatibility({ ...entry, version: this.dependencies.version, runtime: "app" });
const existing = preserved.find(
(plugin) => plugin.id === entry.id && plugin.clientBundle === entry.clientBundle,
);
@@ -70,6 +78,7 @@ class PluginRegistry {
id: entry.id,
serverId,
clientBundle: entry.clientBundle,
requirements: entry.requirements,
queryClient: new QueryClient(),
cleanup: () => undefined,
surfaces: [],
@@ -86,7 +95,7 @@ class PluginRegistry {
const evaluated = runPluginClientBundle(
entry.id,
entry.clientBundle,
createPluginClientRuntime(installation, options.client),
this.dependencies.createRuntime(installation, options.client),
() => this.publish(),
);
Object.assign(installation, evaluated);
@@ -149,7 +158,10 @@ class PluginRegistry {
}
}
export const pluginRegistry = new PluginRegistry();
export const pluginRegistry = new PluginRegistry({
version: resolveAppVersion(),
createRuntime: createPluginClientRuntime,
});
export function useInstalledPlugins(): InstalledPlugin[] {
return useSyncExternalStore(
@@ -1,6 +1,7 @@
/**
* @vitest-environment jsdom
*/
import appPackage from "../../../package.json";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
@@ -111,9 +112,13 @@ afterEach(() => {
describe("PluginTimelineItemView", () => {
it("validates and renders the matching plugin component", () => {
pluginRegistry.installCatalog("host-1", [{ id: "reports", clientBundle: bundle }], {
client: daemonClient,
});
pluginRegistry.installCatalog(
"host-1",
[{ id: "reports", requirements: { paseo: `>=${appPackage.version}` }, clientBundle: bundle }],
{
client: daemonClient,
},
);
const markup = renderToStaticMarkup(
<PluginTimelineItemView serverId="host-1" agentId="agent-1" item={timelineItem} />,
@@ -125,9 +130,19 @@ describe("PluginTimelineItemView", () => {
it("contains renderer crashes to one timeline item", async () => {
vi.spyOn(console, "warn").mockImplementation(() => undefined);
vi.spyOn(console, "error").mockImplementation(() => undefined);
pluginRegistry.installCatalog("host-1", [{ id: "reports", clientBundle: failingBundle }], {
client: daemonClient,
});
pluginRegistry.installCatalog(
"host-1",
[
{
id: "reports",
requirements: { paseo: `>=${appPackage.version}` },
clientBundle: failingBundle,
},
],
{
client: daemonClient,
},
);
const container = document.createElement("div");
containers.push(container);
document.body.appendChild(container);
@@ -152,9 +167,19 @@ describe("PluginTimelineItemView", () => {
it("recovers when a streaming item's data changes", async () => {
vi.spyOn(console, "warn").mockImplementation(() => undefined);
vi.spyOn(console, "error").mockImplementation(() => undefined);
pluginRegistry.installCatalog("host-1", [{ id: "reports", clientBundle: recoveringBundle }], {
client: daemonClient,
});
pluginRegistry.installCatalog(
"host-1",
[
{
id: "reports",
requirements: { paseo: `>=${appPackage.version}` },
clientBundle: recoveringBundle,
},
],
{
client: daemonClient,
},
);
const container = document.createElement("div");
containers.push(container);
document.body.appendChild(container);
+2
View File
@@ -1,4 +1,5 @@
import type { QueryClient } from "@tanstack/react-query";
import type { PluginRequirements } from "@getpaseo/protocol/messages";
import type {
PluginAttachmentSourceContribution,
PluginCommandCenterItemContribution,
@@ -36,6 +37,7 @@ export interface EvaluatedPlugin {
export interface InstalledPlugin extends EvaluatedPlugin {
serverId: string;
requirements?: PluginRequirements;
clientBundle: string;
queryClient: QueryClient;
}
@@ -1,3 +1,4 @@
import appPackage from "../../../package.json";
import { describe, expect, it, vi } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { pluginRegistry } from "../registry";
@@ -30,9 +31,13 @@ function install(locations: readonly ("workspace" | "explorer")[]) {
return function() {};
}};
})`;
pluginRegistry.installCatalog("host-1", [{ id: "review", clientBundle: bundle }], {
client: {} as DaemonClient,
});
pluginRegistry.installCatalog(
"host-1",
[{ id: "review", requirements: { paseo: `>=${appPackage.version}` }, clientBundle: bundle }],
{
client: {} as DaemonClient,
},
);
return pluginRegistry.getSnapshot()[0]!;
}
@@ -45,14 +45,15 @@ describe("plugin scaffold", () => {
expect(config.compilerOptions.lib).toEqual(["ES2023"]);
expect(config.compilerOptions.types).toEqual(["react"]);
await expect(typecheckPlugin(directory)).resolves.toBeUndefined();
expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual(
{
id: "hello-plugin",
},
);
const cliPackageJson = JSON.parse(
await readFile(new URL("../../../package.json", import.meta.url), "utf8"),
) as { version: string };
expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual(
{
id: "hello-plugin",
requirements: { paseo: `>=${cliPackageJson.version}` },
},
);
expect(JSON.parse(await readFile(path.join(directory, "package.json"), "utf8"))).toEqual({
name: "hello-plugin",
private: true,
+6 -2
View File
@@ -142,13 +142,14 @@ export async function scaffoldPluginDirectory(
throw new Error(`Plugin directory must be empty: ${directory}`);
}
const version = resolveCliVersion();
const packageJson = {
name: id,
private: true,
version: "0.0.0",
scripts: { typecheck: "tsc --noEmit" },
devDependencies: {
"@getpaseo/plugin": resolveCliVersion(),
"@getpaseo/plugin": version,
"@tanstack/react-query": "^5.90.11",
"@types/react": "~19.2.0",
react: "19.1.0",
@@ -158,7 +159,10 @@ export async function scaffoldPluginDirectory(
},
};
const files = new Map<string, string>([
["paseo-plugin.json", `${JSON.stringify({ id }, null, 2)}\n`],
[
"paseo-plugin.json",
`${JSON.stringify({ id, requirements: { paseo: `>=${version}` } }, null, 2)}\n`,
],
["package.json", `${JSON.stringify(packageJson, null, 2)}\n`],
["tsconfig.json", `${JSON.stringify(TSCONFIG, null, 2)}\n`],
["index.client.tsx", CLIENT_ENTRY],
+16
View File
@@ -0,0 +1,16 @@
import { expect, it } from "vitest";
import { renderError, toCommandError } from "./index.js";
it("preserves the message of an Error with an RPC code in JSON output", () => {
const error = Object.assign(new Error("Plugin requires Paseo >=0.8.0. Your daemon is 0.7.2."), {
code: "handler_error",
requestId: "request-1",
});
expect(JSON.parse(renderError(toCommandError(error), { format: "json" }))).toEqual({
error: {
code: "handler_error",
requestId: "request-1",
message: "Plugin requires Paseo >=0.8.0. Your daemon is 0.7.2.",
},
});
});
+2 -1
View File
@@ -50,7 +50,8 @@ export function render<T>(
/** Convert an unknown error to a CommandError */
export function toCommandError(error: unknown): CommandError {
if (isCommandError(error)) {
return error;
// Error.message and class getters are not enumerable; materialize the output contract.
return { ...error, code: error.code, message: error.message, details: error.details };
}
if (error instanceof Error) {
@@ -1,8 +1,10 @@
#!/usr/bin/env npx tsx
import { resolveCliVersion } from "../../src/version.js";
import { readPluginManifest } from "../../../server/src/server/plugins/manifest.js";
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
@@ -26,7 +28,16 @@ async function main(): Promise<void> {
const gitDirectory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-git-cli-e2e-"));
const context = await createE2ETestContext({ timeout: 45_000 });
try {
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: "cli-e2e" }));
const scaffold = path.join(context.workDir, "authored-plugin");
const init = await context.paseo(["plugin", "init", scaffold, "--json"]);
assert.equal(init.exitCode, 0, init.stderr);
const manifestPath = path.join(scaffold, "paseo-plugin.json");
const manifest = await readPluginManifest(scaffold);
assert.deepEqual(manifest.requirements, { paseo: `>=${resolveCliVersion()}` });
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "cli-e2e", requirements: { paseo: `>=${resolveCliVersion()}` } }),
);
await writeFile(path.join(directory, "index.server.ts"), pluginSource);
const install = await context.paseo(["plugin", "install", directory, "--json"]);
@@ -37,12 +48,30 @@ async function main(): Promise<void> {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.close();
await writeFile(
manifestPath,
JSON.stringify({ ...manifest, requirements: { paseo: ">=999.0.0" } }),
);
const incompatibleInstall = await context.paseo(["plugin", "install", scaffold, "--json"]);
assert.equal(incompatibleInstall.exitCode, 1);
assert.match(incompatibleInstall.stderr, /requires Paseo >=999.0.0/);
const afterRejection = await context.paseo(["plugin", "ls", "--json"]);
assert.equal(afterRejection.exitCode, 0, afterRejection.stderr);
assert.deepEqual(
JSON.parse(afterRejection.stdout).map((plugin: { id: string }) => plugin.id),
["cli-e2e"],
);
await writeFile(manifestPath, JSON.stringify(manifest));
const scaffoldInstall = await context.paseo(["plugin", "install", scaffold, "--json"]);
assert.equal(scaffoldInstall.exitCode, 0, scaffoldInstall.stderr);
assert.equal(JSON.parse(scaffoldInstall.stdout).status, "running");
await git(gitDirectory, ["init", "-b", "main"]);
await git(gitDirectory, ["config", "user.name", "Paseo Tests"]);
await git(gitDirectory, ["config", "user.email", "paseo@example.test"]);
await writeFile(
path.join(gitDirectory, "paseo-plugin.json"),
JSON.stringify({ id: "git-cli-e2e" }),
JSON.stringify({ id: "git-cli-e2e", requirements: { paseo: `>=${resolveCliVersion()}` } }),
);
await writeFile(path.join(gitDirectory, "index.server.ts"), pluginSource);
await git(gitDirectory, ["add", "-A"]);
@@ -65,12 +94,52 @@ async function main(): Promise<void> {
await git(gitDirectory, ["commit", "-m", "update"]);
const status = await context.paseo(["plugin", "status", "git-cli-e2e", "--json"]);
assert.equal(status.exitCode, 0, status.stderr);
assert.equal(JSON.parse(status.stdout)[0].updateAvailable, true);
assert.equal(JSON.parse(status.stdout)[0].status, "running");
assert.equal(JSON.parse(status.stdout)[0].commit, JSON.parse(gitInstall.stdout).commit);
const update = await context.paseo(["plugin", "update", "git-cli-e2e", "--json"]);
assert.equal(update.exitCode, 0, update.stderr);
assert.equal(JSON.parse(update.stdout)[0].updated, true);
const installedCommit = JSON.parse(update.stdout)[0].currentCommit;
const buildMarker = path.join(context.workDir, "incompatible-build-ran");
await writeFile(
path.join(gitDirectory, "paseo-plugin.json"),
JSON.stringify({
id: "git-cli-e2e",
requirements: { paseo: ">=999.0.0" },
build: [
[
process.execPath,
"-e",
'require("node:fs").writeFileSync(process.argv[1], "ran")',
buildMarker,
],
],
}),
);
await git(gitDirectory, ["add", "-A"]);
await git(gitDirectory, ["commit", "-m", "requires a future Paseo"]);
const incompatibleUpdate = await context.paseo(["plugin", "update", "git-cli-e2e", "--json"]);
assert.equal(incompatibleUpdate.exitCode, 1);
assert.match(incompatibleUpdate.stderr, /requires Paseo >=999.0.0/);
await assert.rejects(readFile(buildMarker), { code: "ENOENT" });
const retained = await context.paseo(["plugin", "ls", "git-cli-e2e", "--json"]);
assert.equal(retained.exitCode, 0, retained.stderr);
assert.equal(JSON.parse(retained.stdout)[0].commit, installedCommit);
assert.equal(JSON.parse(retained.stdout)[0].status, "running");
const incompatibleAdd = await context.paseo([
"plugin",
"add",
pathToFileURL(gitDirectory).href,
"--id",
"future-plugin",
"--json",
]);
assert.equal(incompatibleAdd.exitCode, 1);
assert.match(incompatibleAdd.stderr, /requires Paseo >=999.0.0/);
await assert.rejects(readFile(buildMarker), { code: "ENOENT" });
const reload = await context.paseo(["plugin", "reload", "cli-e2e", "--json"]);
assert.equal(reload.exitCode, 0, reload.stderr);
assert.equal(JSON.parse(reload.stdout).status, "running");
@@ -87,6 +156,8 @@ async function main(): Promise<void> {
assert.equal(remove.exitCode, 0, remove.stderr);
const removeGit = await context.paseo(["plugin", "remove", "git-cli-e2e", "--json"]);
assert.equal(removeGit.exitCode, 0, removeGit.stderr);
const removeScaffold = await context.paseo(["plugin", "remove", "authored-plugin", "--json"]);
assert.equal(removeScaffold.exitCode, 0, removeScaffold.stderr);
const list = await context.paseo(["plugin", "ls", "--json"]);
assert.equal(list.exitCode, 0, list.stderr);
assert.deepEqual(JSON.parse(list.stdout), []);
+1 -1
View File
@@ -4918,7 +4918,7 @@ export class DaemonClient {
});
}
async getPluginCatalog(): Promise<Array<{ id: string; clientBundle: string }>> {
async getPluginCatalog() {
const requestId = this.createRequestId();
const payload = await this.sendCorrelatedSessionRequest({
requestId,
+2
View File
@@ -31,10 +31,12 @@
"pretest": "npm run generate:validators"
},
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^20.9.0",
"@types/semver": "^7.8.0",
"typescript": "^5.2.2",
"vitest": "^4.1.6",
"zod-aot": "0.20.4"
@@ -7,6 +7,21 @@ import {
} from "./messages.js";
describe("plugin protocol compatibility", () => {
it.each([undefined, { paseo: ">=0.8.0" }])(
"parses plugin catalogs with requirements %j",
(requirements) => {
const message = {
type: "plugin.catalog.get.response",
payload: {
requestId: "catalog",
plugins: [
{ id: "example", clientBundle: "bundle", ...(requirements ? { requirements } : {}) },
],
},
};
expect(SessionOutboundMessageSchema.parse(message)).toEqual(message);
},
);
it("parses plugin timeline append messages and advertises the capability", () => {
expect(
SessionInboundMessageSchema.parse({
+4
View File
@@ -192,6 +192,9 @@ const MutableRelayConfigSchema = z
.passthrough();
export const PluginIdSchema = z.string().regex(/^[a-z][a-z0-9-]*$/);
// Semver validation belongs at the manifest/runtime boundary, not on the wire.
export const PluginRequirementsSchema = z.object({ paseo: z.string().optional() });
export type PluginRequirements = z.infer<typeof PluginRequirementsSchema>;
export const DirectoryPluginSourceSchema = z
.object({
@@ -6262,6 +6265,7 @@ export const PluginCatalogGetResponseSchema = z.object({
z.object({
id: PluginIdSchema,
clientBundle: z.string(),
requirements: PluginRequirementsSchema.optional(),
}),
),
}),
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { assertPluginCompatibility, validatePluginRequirements } from "./plugin-requirements.js";
describe("plugin requirements", () => {
it("rejects legacy manifests on the first breaking release with migration instructions", () => {
expect(() =>
assertPluginCompatibility({ id: "legacy", version: "0.8.0", runtime: "daemon" }),
).toThrow(/legacy.*<0\.8\.0.*0\.8\.0.*https:\/\/paseo.sh\/docs\/plugins\/v0.8\/migration/);
});
it.each([
[undefined, "0.7.2"],
[">=0.8.0", "0.8.0"],
[">=0.8.0", "1.0.0"],
["^0.8.0", "0.8.4"],
[">=0.8.0-beta.1", "0.8.0-beta.2"],
[">=0.8.0-beta.1", "0.8.0"],
["^0.8.0 || ^0.9.0", "0.9.2+build.42"],
])("accepts %s on %s", (paseo, version) => {
expect(() =>
assertPluginCompatibility({ id: "test", requirements: { paseo }, version, runtime: "app" }),
).not.toThrow();
});
it.each([
[undefined, "0.8.0-beta.1"],
[undefined, "0.9.0"],
[">=0.8.0", "0.7.2"],
["^0.8.0", "0.9.0"],
[">=0.8.0", "0.8.0-beta.1"],
])("rejects %s on %s", (paseo, version) => {
expect(() =>
assertPluginCompatibility({ id: "test", requirements: { paseo }, version, runtime: "app" }),
).toThrow(`Your app is ${version}`);
});
it.each(["", " ", "latest", ">=potato", "0.8.0 nonsense"])(
"rejects malformed range %s",
(paseo) => {
expect(() => validatePluginRequirements({ paseo })).toThrow("Invalid requirements.paseo");
},
);
it.each([null, "unknown"])("fails closed when the runtime version is %s", (version) => {
expect(() =>
assertPluginCompatibility({
id: "test",
requirements: { paseo: "*" },
version,
runtime: "app",
}),
).toThrow("Paseo app version is unknown");
});
});
@@ -0,0 +1,39 @@
import type { PluginRequirements } from "./messages.js";
import valid from "semver/functions/valid.js";
import validRange from "semver/ranges/valid.js";
import satisfies from "semver/functions/satisfies.js";
export function validatePluginRequirements(requirements: PluginRequirements | undefined): void {
const range = requirements?.paseo;
if (range !== undefined && (!range.trim() || validRange(range) === null)) {
throw new Error(
`Invalid requirements.paseo: ${JSON.stringify(range)}. Use an npm semver range such as ">=0.8.0".`,
);
}
}
interface PluginCompatibilityInput {
id: string;
requirements?: PluginRequirements;
version: string | null;
runtime: "daemon" | "app";
}
export function assertPluginCompatibility(input: PluginCompatibilityInput): void {
validatePluginRequirements(input.requirements);
// COMPAT(plugin-requirements): added in v0.8.0; remove after 2027-03-07 once pre-0.8 plugins and catalogs are unsupported.
const range = input.requirements?.paseo ?? "<0.8.0";
if (!input.version || !valid(input.version)) {
throw new Error(
`Cannot check plugin "${input.id}" requirements: Paseo ${input.runtime} version is unknown. Update the ${input.runtime}.`,
);
}
if (satisfies(input.version, range)) return;
const action =
input.requirements?.paseo === undefined
? "This plugin has no requirements.paseo and targets Paseo before 0.8. Ask its author to migrate it: https://paseo.sh/docs/plugins/v0.8/migration"
: `Use a compatible plugin version or update the ${input.runtime}.`;
throw new Error(
`Plugin "${input.id}" requires Paseo ${range}. Your ${input.runtime} is ${input.version}. ${action}`,
);
}
@@ -1,3 +1,4 @@
import { resolveDaemonVersion } from "./daemon-version.js";
import os from "node:os";
import http from "node:http";
import path from "node:path";
@@ -672,7 +673,10 @@ describe("paseo daemon bootstrap", () => {
await mkdir(pluginDirectory);
await writeFile(
path.join(pluginDirectory, "paseo-plugin.json"),
JSON.stringify({ id: "startup-rollback" }),
JSON.stringify({
id: "startup-rollback",
requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` },
}),
);
await writeFile(
path.join(pluginDirectory, "index.server.ts"),
@@ -1,3 +1,4 @@
import { resolveDaemonVersion } from "../daemon-version.js";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -114,7 +115,10 @@ test("plugin handlers operate terminals through their host-owned Paseo API", asy
await mkdir(pluginDirectory);
await writeFile(
path.join(pluginDirectory, "paseo-plugin.json"),
JSON.stringify({ id: "terminal-sdk" }),
JSON.stringify({
id: "terminal-sdk",
requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` },
}),
);
await writeFile(
path.join(pluginDirectory, "index.server.ts"),
+12 -3
View File
@@ -11,6 +11,7 @@ import {
type PluginSourceUpdateItem,
} from "@getpaseo/protocol/messages";
import { parsePluginSourceReference } from "@getpaseo/protocol/plugin-source-reference";
import { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements";
import { BUILTIN_PROVIDER_IDS } from "@getpaseo/protocol/provider-manifest";
import type { DaemonConfigStore } from "../daemon-config-store.js";
import { type ManagedPluginCandidate, ManagedPluginSources } from "./managed-source.js";
@@ -23,7 +24,7 @@ import { readPluginProviderIcon } from "./provider-icon.js";
const BUILTIN_PROVIDER_ID_SET: ReadonlySet<string> = new Set(BUILTIN_PROVIDER_IDS);
interface PluginRuntimePort {
catalog(): Array<{ id: string; clientBundle: string }>;
catalog: PluginRuntime["catalog"];
invoke(pluginId: string, method: string, input: unknown): Promise<unknown>;
getLogs(pluginId: string): PluginLogEntry[];
clearLogs(pluginId: string): void;
@@ -70,7 +71,7 @@ export class PluginService {
constructor(
logger: pino.Logger,
private readonly configStore: DaemonConfigStore,
daemonVersion: string,
private readonly daemonVersion: string,
private readonly dependencies: PluginServiceDependencies = {},
) {
this.logger = logger.child({ module: "plugin-service" });
@@ -166,7 +167,7 @@ export class PluginService {
return this.runtime.getLogs(pluginId);
}
catalog(): Array<{ id: string; clientBundle: string }> {
catalog(): ReturnType<PluginRuntime["catalog"]> {
return this.runtime.catalog();
}
@@ -174,6 +175,7 @@ export class PluginService {
return this.enqueue(async () => {
const directory = path.resolve(input.path);
const manifest = await readPluginManifest(directory);
assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" });
const pluginId = PluginIdSchema.parse(input.id ?? manifest.id);
if (this.configStore.get().plugins?.[pluginId]) {
throw new Error(
@@ -225,6 +227,7 @@ export class PluginService {
});
let pluginId: string;
try {
await this.checkRequirements(candidate.directory);
await runPluginBuild(candidate.directory, candidate.build, this.logger);
pluginId = PluginIdSchema.parse(input.id ?? candidate.defaultId);
if (this.configStore.get().plugins?.[pluginId]) {
@@ -508,6 +511,11 @@ export class PluginService {
this.errors.set(pluginId, error instanceof Error ? error.message : String(error));
}
private async checkRequirements(directory: string): Promise<void> {
const manifest = await readPluginManifest(directory);
assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" });
}
private async updateSource(pluginId: string): Promise<PluginSourceUpdateItem> {
const managedSources = this.requireManagedSources();
const source = this.requireSource(pluginId);
@@ -525,6 +533,7 @@ export class PluginService {
}
let candidate = prepared.candidate;
try {
await this.checkRequirements(candidate.directory);
await runPluginBuild(candidate.directory, candidate.build, this.logger);
candidate = await managedSources.place(pluginId, candidate);
await this.validateCandidate(candidate);
@@ -11,6 +11,27 @@ afterEach(async () => {
});
describe("plugin manifest", () => {
it("reads and validates requirements before any plugin code runs", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-"));
directories.push(directory);
const manifest = path.join(directory, "paseo-plugin.json");
await writeFile(manifest, JSON.stringify({ id: "example", requirements: { paseo: "^0.8.0" } }));
await expect(readPluginManifest(directory)).resolves.toEqual({
id: "example",
requirements: { paseo: "^0.8.0" },
});
for (const requirements of [
{ paseo: "latest" },
{ paseo: "" },
{ paseo: 8 },
{ node: ">=20" },
"0.8.0",
]) {
await writeFile(manifest, JSON.stringify({ id: "example", requirements }));
await expect(readPluginManifest(directory)).rejects.toThrow();
}
});
it("accepts only non-empty argv arrays for build commands", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-"));
directories.push(directory);
@@ -1,7 +1,8 @@
import { readFile, stat } from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import { PluginIdSchema } from "@getpaseo/protocol/messages";
import { PluginIdSchema, PluginRequirementsSchema } from "@getpaseo/protocol/messages";
import { validatePluginRequirements } from "@getpaseo/protocol/plugin-requirements";
const MANIFEST_FILENAME = "paseo-plugin.json";
const PluginBuildCommandSchema = z
@@ -10,6 +11,7 @@ const PluginBuildCommandSchema = z
const PluginManifestSchema = z
.object({
id: PluginIdSchema,
requirements: PluginRequirementsSchema.strict().optional(),
build: z.array(PluginBuildCommandSchema).min(1).optional(),
})
.strict();
@@ -20,5 +22,7 @@ export async function readPluginManifest(directory: string): Promise<PluginManif
const manifestPath = path.join(directory, MANIFEST_FILENAME);
const info = await stat(manifestPath).catch(() => null);
if (!info?.isFile()) throw new Error(`Plugin manifest is missing: ${manifestPath}`);
return PluginManifestSchema.parse(JSON.parse(await readFile(manifestPath, "utf8")));
const manifest = PluginManifestSchema.parse(JSON.parse(await readFile(manifestPath, "utf8")));
validatePluginRequirements(manifest.requirements);
return manifest;
}
@@ -1,3 +1,4 @@
import { resolveDaemonVersion } from "../daemon-version.js";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -18,7 +19,10 @@ test("plugin handlers create workspaces and agents through their Paseo API", asy
roots.push(pluginDirectory, workspaceDirectory);
await writeFile(
path.join(pluginDirectory, "paseo-plugin.json"),
JSON.stringify({ id: "paseo-api" }),
JSON.stringify({
id: "paseo-api",
requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` },
}),
);
await writeFile(
path.join(pluginDirectory, "index.server.ts"),
@@ -141,7 +145,10 @@ test("daemon config reload enables and disables configured plugins without resta
roots.push(pluginDirectory, paseoHomeRoot);
await writeFile(
path.join(pluginDirectory, "paseo-plugin.json"),
JSON.stringify({ id: "reloadable-plugin" }),
JSON.stringify({
id: "reloadable-plugin",
requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` },
}),
);
await writeFile(
path.join(pluginDirectory, "index.server.ts"),
@@ -0,0 +1,133 @@
import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import pino from "pino";
import { afterEach, expect, it } from "vitest";
import { DaemonConfigStore } from "../daemon-config-store.js";
import { runGitCommand } from "../../utils/run-git-command.js";
import { PluginService } from "./index.js";
import { ManagedPluginSources } from "./managed-source.js";
const roots: string[] = [];
const services: PluginService[] = [];
async function directory() {
const root = await mkdtemp(path.join(tmpdir(), "plugin-requirements-"));
roots.push(root);
return root;
}
async function writePlugin(root: string, paseo?: string, build?: string[][]) {
await writeFile(
path.join(root, "paseo-plugin.json"),
JSON.stringify({
id: "example",
requirements: paseo === undefined ? undefined : { paseo },
build,
}),
);
await writeFile(
path.join(root, "index.client.ts"),
"export default function contribute() { return () => {}; }",
);
}
async function host(version = "0.8.0", pluginPath?: string) {
const home = await directory();
const store = new DaemonConfigStore(home, {
mcp: { injectIntoAgents: true },
browserTools: { enabled: false },
providers: {},
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
enableTerminalAgentHooks: false,
appendSystemPrompt: "",
pluginsEnabled: true,
plugins: pluginPath
? { example: { source: "directory", path: pluginPath, enabled: true } }
: {},
});
const service = new PluginService(pino({ level: "silent" }), store, version, {
managedSources: new ManagedPluginSources(home),
});
services.push(service);
await service.start();
return { home, store, service };
}
afterEach(async () => {
await Promise.all(services.splice(0).map((service) => service.stopAllPlugins()));
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
it("rejects incompatible installs without persisting them, then accepts the corrected manifest", async () => {
const root = await directory();
await writePlugin(root, ">=0.9.0");
const { service, store } = await host();
await expect(service.installDirectory({ path: root })).rejects.toThrow("Your daemon is 0.8.0");
expect(store.get().plugins).toEqual({});
expect(service.catalog()).toEqual([]);
await writePlugin(root, "^0.8.0");
await expect(service.installDirectory({ path: root })).resolves.toMatchObject({
status: "running",
});
expect(service.catalog()).toEqual([
{ id: "example", requirements: { paseo: "^0.8.0" }, clientBundle: expect.any(String) },
]);
});
it("marks pre-0.8 plugins failed on startup and recovers after migration and reload", async () => {
const root = await directory();
await writePlugin(root);
const { service } = await host("0.8.0", root);
expect(service.listPlugins()).toEqual([
expect.objectContaining({
status: "failed",
error: expect.stringContaining("https://paseo.sh/docs/plugins/v0.8/migration"),
}),
]);
await writePlugin(root, ">=0.8.0");
await expect(service.reloadPlugin("example")).resolves.toMatchObject({ status: "running" });
await writePlugin(root, ">=0.9.0");
await expect(service.reloadPlugin("example")).rejects.toThrow("requires Paseo >=0.9.0");
expect(service.catalog()).toEqual([]);
});
it("still requires entry migration when an old plugin adds a compatible requirement", async () => {
const root = await directory();
await writePlugin(root, ">=0.8.0");
await rm(path.join(root, "index.client.ts"));
await writeFile(path.join(root, "index.ts"), "export default () => () => {};");
const { service } = await host();
await expect(service.installDirectory({ path: root })).rejects.toThrow(
"https://paseo.sh/docs/plugins/v0.8/migration",
);
});
it("rejects Git install and update before build commands, preserving the running revision", async () => {
const repository = await directory();
await runGitCommand(["init", "-b", "main"], { cwd: repository });
await runGitCommand(["config", "user.name", "Paseo Tests"], { cwd: repository });
await runGitCommand(["config", "user.email", "tests@example.test"], { cwd: repository });
const commit = async () => {
await runGitCommand(["add", "-A"], { cwd: repository });
await runGitCommand(["commit", "-m", "plugin"], { cwd: repository });
};
await writePlugin(repository, ">=0.8.0");
await commit();
const { service, home } = await host();
const source = pathToFileURL(repository).href;
const installed = await service.installSource({ source });
const marker = path.join(home, "build-executed");
await writePlugin(repository, ">=0.9.0", [
[process.execPath, "-e", 'require("node:fs").writeFileSync(process.argv[1], "ran")', marker],
]);
await commit();
await expect(service.updateSources("example")).rejects.toThrow("requires Paseo >=0.9.0");
expect(service.listPlugins()).toEqual([installed]);
expect(service.catalog()).toHaveLength(1);
expect(await readdir(path.join(home, "plugins", ".staging"))).toEqual([]);
await expect(readFile(marker)).rejects.toMatchObject({ code: "ENOENT" });
await expect(service.installSource({ source, id: "second" })).rejects.toThrow(
"requires Paseo >=0.9.0",
);
await expect(readFile(marker)).rejects.toMatchObject({ code: "ENOENT" });
expect(service.listPlugins()).toEqual([installed]);
});
@@ -88,8 +88,9 @@ function createReloadChild(
function createTestRuntime(
dependencies: NonNullable<ConstructorParameters<typeof PluginRuntime>[2]> = {},
logger = pino({ level: "silent" }),
version = "0.4.0",
): PluginRuntime {
return new PluginRuntime(logger, "0.4.0", {
return new PluginRuntime(logger, version, {
...dependencies,
sessionHost: dependencies.sessionHost ?? {
async attachPluginSocket(_pluginId, socket) {
@@ -107,7 +108,7 @@ function createTestRuntime(
status: "server_info",
serverId: "plugin-test",
hostname: "plugin-test",
version: "0.4.0",
version,
features: {},
},
},
@@ -211,7 +212,7 @@ describe("PluginRuntime", () => {
const directory = fileURLToPath(
new URL("../../../../../plugin-examples/provider-direct/", import.meta.url),
);
const runtime = createTestRuntime();
const runtime = createTestRuntime({}, undefined, "0.8.0");
await runtime.startPlugin(pluginId, directory);
const [metadata] = runtime.getProviderRegistrations(pluginId);
expect(metadata).toBeDefined();
@@ -1319,7 +1320,7 @@ export default function contribute(server: { registerProvider(provider: Provider
path.dirname(fileURLToPath(import.meta.url)),
"../../../../../plugin-examples/linear",
);
const runtime = createTestRuntime();
const runtime = createTestRuntime({}, undefined, "0.8.0");
await runtime.startPlugin("linear", directory);
+11 -4
View File
@@ -16,6 +16,8 @@ import {
import type { PluginLogEntry } from "@getpaseo/protocol/messages";
import { compilePlugin } from "./compiler.js";
import { readPluginManifest } from "./manifest.js";
import type { PluginRequirements } from "@getpaseo/protocol/messages";
import { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements";
import type {
PluginProcessMessage,
PluginProcessRequest,
@@ -56,6 +58,7 @@ interface PendingInvocation {
interface LoadedPlugin {
id: string;
requirements: PluginRequirements | undefined;
clientBundle: string;
methods: ReadonlySet<string>;
providers: readonly PluginProviderMetadata[];
@@ -312,7 +315,8 @@ export class PluginRuntime {
async validatePlugin(configuredPath: string): Promise<void> {
const directory = path.resolve(configuredPath);
await readPluginManifest(directory);
const manifest = await readPluginManifest(directory);
assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" });
const entryPaths = await resolveEntryPaths(directory);
await compilePlugin(entryPaths);
}
@@ -326,9 +330,9 @@ export class PluginRuntime {
return true;
}
catalog(): Array<{ id: string; clientBundle: string }> {
catalog(): Array<{ id: string; clientBundle: string; requirements?: PluginRequirements }> {
return [...this.plugins.values()]
.map(({ id, clientBundle }) => ({ id, clientBundle }))
.map(({ id, clientBundle, requirements }) => ({ id, clientBundle, requirements }))
.sort((left, right) => left.id.localeCompare(right.id));
}
@@ -473,7 +477,8 @@ export class PluginRuntime {
configuredPath: string,
): Promise<LoadedPlugin> {
const directory = path.resolve(configuredPath);
await readPluginManifest(directory);
const manifest = await readPluginManifest(directory);
assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" });
const entryPaths = await resolveEntryPaths(directory);
const bundles = await compilePlugin(entryPaths);
const serverBundle = bundles.serverBundle;
@@ -481,6 +486,7 @@ export class PluginRuntime {
return {
id: pluginId,
clientBundle: bundles.clientBundle ?? "",
requirements: manifest.requirements,
methods: new Set(),
providers: [],
child: null,
@@ -576,6 +582,7 @@ export class PluginRuntime {
loaded = {
id: pluginId,
clientBundle: bundles.clientBundle ?? "",
requirements: manifest.requirements,
methods: new Set(ready.methods),
providers: ready.providers ?? [],
child,
@@ -1,3 +1,4 @@
import { resolveDaemonVersion } from "../daemon-version.js";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -19,7 +20,10 @@ test("two clients share settings, observe changes, and preserve values through p
try {
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "settings-test" }),
JSON.stringify({
id: "settings-test",
requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` },
}),
);
await writeFile(
path.join(directory, "index.server.ts"),
@@ -0,0 +1,16 @@
// Browser compatibility tests run a real daemon in a separate Node runtime.
import { createTestPaseoDaemon } from "./paseo-daemon.js";
const daemon = await createTestPaseoDaemon({
daemonVersion: process.argv[2],
pluginsEnabled: true,
mcpEnabled: false,
corsAllowedOrigins: ["*"],
});
const response = await fetch(`http://127.0.0.1:${daemon.port}/api/status`);
const { serverId } = await response.json();
process.send?.({ port: daemon.port, serverId });
process.once("SIGTERM", async () => {
await daemon.close();
process.exit(0);
});
+4 -1
View File
@@ -1,3 +1,6 @@
{
"id": "catppuccin"
"id": "catppuccin",
"requirements": {
"paseo": ">=0.8.0"
}
}
@@ -1,3 +1,6 @@
{
"id": "inline-thinking"
"id": "inline-thinking",
"requirements": {
"paseo": ">=0.8.0"
}
}
+4 -1
View File
@@ -1,3 +1,6 @@
{
"id": "linear"
"id": "linear",
"requirements": {
"paseo": ">=0.8.0"
}
}
@@ -1,3 +1,6 @@
{
"id": "local-example"
"id": "local-example",
"requirements": {
"paseo": ">=0.8.0"
}
}
+4 -1
View File
@@ -1,3 +1,6 @@
{
"id": "modal-ui-example"
"id": "modal-ui-example",
"requirements": {
"paseo": ">=0.8.0"
}
}
@@ -2,5 +2,8 @@
"id": "provider-acp-transformer-example",
"name": "ACP provider transformer example",
"version": "1.0.0",
"description": "Wraps an ACP command and normalizes one vendor file-edit shape."
"description": "Wraps an ACP command and normalizes one vendor file-edit shape.",
"requirements": {
"paseo": ">=0.8.0"
}
}
@@ -1,3 +1,6 @@
{
"id": "provider-direct-example"
"id": "provider-direct-example",
"requirements": {
"paseo": ">=0.8.0"
}
}
+6 -1
View File
@@ -1 +1,6 @@
{ "id": "settings-example" }
{
"id": "settings-example",
"requirements": {
"paseo": ">=0.8.0"
}
}
@@ -1,3 +1,6 @@
{
"id": "pi-tasks-timeline"
"id": "pi-tasks-timeline",
"requirements": {
"paseo": ">=0.8.0"
}
}
+1 -1
View File
@@ -49,7 +49,7 @@ greeting through an RPC.
```text
workspace-plugin/
paseo-plugin.json # { "id": "workspace-plugin" }
paseo-plugin.json # plugin ID and supported Paseo versions
index.client.tsx # runs in the Paseo app
index.server.ts # runs in a daemon subprocess
client/greeting.tsx # the surface component
+21 -1
View File
@@ -102,6 +102,7 @@ its registration; that registration belongs in the client entry.
| Compiler or load error | Meaning and fix |
| -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `This plugin has no requirements.paseo` | Complete the migration and declare the range in step 7. |
| `This plugin was made for an older version of Paseo` | The directory still has only the old root entry. Create a runtime entry, move registrations, then delete the old file. |
| `Plugin entry points are missing: expected index.client.ts or index.client.tsx and/or index.server.ts or index.server.tsx` | No supported entry exists. Add at least one exact filename. |
| `server-only module cannot be imported into the plugin client bundle: <file>` | A client import reaches `server/`. Move the call behind an RPC and import its contract from `shared/`. |
@@ -221,7 +222,26 @@ Import path changes inside the moved files:
calls it directly and returns its cleanup. A plugin whose `addClientSide` callback also registered
pills or subscriptions keeps that code; only the wrapper goes away.
## 7. Verify the migration
## 7. Declare the Paseo requirement
After migrating the entries and imports, add the minimum runtime version to `paseo-plugin.json`:
```json
{
"id": "my-plugin",
"requirements": { "paseo": ">=0.8.0" }
}
```
Keep your existing ID and build commands. Missing `requirements.paseo` means `<0.8.0`, so Paseo 0.8
rejects the plugin even if its files have been moved. Adding the field alone does not migrate the
code. Update the local `@getpaseo/plugin` development dependency to the version you target and
reinstall dependencies before typechecking.
For a 0.8 beta, use its explicit version in both the SDK dependency and range, for example
`>=0.8.0-beta.1`. See [requirements](reference#requirements) for range and prerelease semantics.
## 8. Verify the migration
Run:
+36 -2
View File
@@ -42,12 +42,45 @@ my-plugin/
tsconfig.json
```
The required root manifest is `paseo-plugin.json`. It contains the default plugin ID:
The required root manifest is `paseo-plugin.json`. It contains the default plugin ID and supported Paseo versions:
```json
{ "id": "my-plugin" }
{ "id": "my-plugin", "requirements": { "paseo": ">=0.8.0" } }
```
### Requirements
`requirements` is an optional object. Its currently supported key, `paseo`, accepts an npm semver
range. An omitted `requirements.paseo` means `<0.8.0`: the plugin predates the first breaking
plugin release. Paseo 0.8 and later reject it with a link to the [migration guide](migration).
Empty strings, invalid ranges, and unknown manifest requirement keys are rejected.
| Range | Compatible releases |
| ---------------- | ------------------------------------------------------------------- |
| `>=0.8.0` | 0.8.0 and later stable releases, including future breaking releases |
| `^0.8.0` | 0.8.x stable releases only |
| `>=0.8.3 <0.9.0` | 0.8.3 through the last 0.8 patch |
| `>=0.8.0-beta.1` | Betas from 0.8.0-beta.1, then 0.8.0 and later stable releases |
Standard npm prerelease rules apply: `>=0.8.0` excludes `0.8.0-beta.1`. A prerelease must be
explicitly included for its major/minor/patch tuple; no version tags are stripped.
`paseo plugin init` writes `>=` followed by the current CLI version and pins the matching SDK
for typechecking. Raise the minimum when adopting a newer API. Add an upper bound when a later
release is incompatible; a minimum alone does not promise protection from future breaking changes.
The daemon checks its version before installing, running Git build commands, or loading a plugin,
and checks again on startup, enable, and reload. A rejected Git update keeps the installed revision.
Each connected app checks its own version before evaluating client code and shows incompatibility
in Settings → Plugins. A compatible daemon does not make an older app compatible. A plugin with no
client entry does not require the connected app to match.
For example: `Plugin "review" requires Paseo >=0.8.0. Your daemon is 0.7.2.` Use a compatible plugin
revision or update the named runtime. Releases before 0.8 do not understand this manifest field
and cannot show this new diagnostic.
### Runtime entries
| Entry | Runtime | Receives | Required |
| ------------------ | --------------------- | --------------------- | ----------------------------------------------------------------- |
| `index.client.tsx` | Paseo app, per client | `PluginClientContext` | When the plugin has any UI, callback, theme, or attachment source |
@@ -1276,6 +1309,7 @@ step:
```json
{
"id": "review",
"requirements": { "paseo": ">=0.8.0" },
"build": [
["npm", "ci"],
["npm", "run", "build"]
+9 -2
View File
@@ -62,12 +62,19 @@ my-plugin/
shared/greeting.ts
```
The manifest supplies the default install ID:
The manifest supplies the default install ID and supported Paseo versions:
```json
{ "id": "my-plugin" }
{ "id": "my-plugin", "requirements": { "paseo": ">=0.8.0" } }
```
Keep `requirements.paseo` correct whenever creating or editing a plugin. `init` uses `>=` followed
by the CLI version. Raise the minimum when adopting newer APIs; add an upper bound when a later
Paseo release is incompatible. Use npm semver ranges and explicitly include beta versions when
targeting betas. Missing requirements mean `<0.8.0`; complete the 0.8 entry migration before adding
`>=0.8.0`. Verify compatibility with both the daemon and the app running client contributions.
See [requirements](https://paseo.sh/docs/plugins/v0.8/reference#requirements).
Each runtime has its own optional entry. A plugin must have at least one. Both entries accept
`.ts` or `.tsx`; use `.tsx` when an entry imports components.