feat: stabilize Pi Web plugin API

This commit is contained in:
Federico Jaramillo Martinez
2026-05-17 22:46:44 +02:00
parent c77c47c1d5
commit 30995797ea
24 changed files with 764 additions and 496 deletions
+44 -14
View File
@@ -18,8 +18,8 @@ describe("PiWebPluginService", () => {
it("discovers local plugins and serves assets", async () => {
const pluginDir = join(tempDir, "plugins", "info");
await writePlugin(pluginDir, {
packageJson: { piWeb: { id: "info", plugin: "pi-web-plugin.js" } },
files: { "pi-web-plugin.js": "export default { id: 'info' };" },
packageJson: { piWeb: { plugins: [{ id: "info", module: "pi-web-plugin.js" }] } },
files: { "pi-web-plugin.js": "export default { apiVersion: 1, name: 'Info', activate: () => ({ contributions: {} }) };" },
});
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
@@ -38,8 +38,8 @@ describe("PiWebPluginService", () => {
it("discovers Pi package plugins through an injected package provider", async () => {
const packageDir = join(tempDir, "pkg");
await writePlugin(packageDir, {
packageJson: { pi: { piWeb: { plugins: [{ id: "review", module: "dist/review.js" }] } } },
files: { "dist/review.js": "export default { id: 'review' };" },
packageJson: { piWeb: { plugins: [{ id: "review", module: "dist/review.js" }] } },
files: { "dist/review.js": "export default { apiVersion: 1, name: 'Review', activate: () => ({ contributions: {} }) };" },
});
const packageProvider: PiPackageProvider = {
listPackages: () => [{ source: "npm:@acme/review", scope: "user", installedPath: packageDir }],
@@ -57,8 +57,8 @@ describe("PiWebPluginService", () => {
it("discovers local plugins through symlinks for development", async () => {
const pluginDir = join(tempDir, "dev-plugin");
await writePlugin(pluginDir, {
packageJson: { piWeb: { id: "dev", plugin: "pi-web-plugin.js" } },
files: { "pi-web-plugin.js": "export default { id: 'dev' };" },
packageJson: { piWeb: { plugins: [{ id: "dev", module: "pi-web-plugin.js" }] } },
files: { "pi-web-plugin.js": "export default { apiVersion: 1, name: 'Dev', activate: () => ({ contributions: {} }) };" },
});
await mkdir(join(tempDir, "plugins"), { recursive: true });
await symlink(pluginDir, join(tempDir, "plugins", "dev"), "dir");
@@ -71,27 +71,58 @@ describe("PiWebPluginService", () => {
await expect(service.readAsset("dev", "pi-web-plugin.js")).resolves.toBeDefined();
});
it("keeps duplicate plugin ids addressable", async () => {
it("skips duplicate plugin ids", async () => {
await writePlugin(join(tempDir, "plugins", "one"), {
packageJson: { piWeb: { id: "duplicate", plugin: "pi-web-plugin.js" } },
packageJson: { piWeb: { plugins: [{ id: "duplicate", module: "pi-web-plugin.js" }] } },
files: { "pi-web-plugin.js": "export default {};" },
});
await writePlugin(join(tempDir, "plugins", "two"), {
packageJson: { piWeb: { id: "duplicate", plugin: "pi-web-plugin.js" } },
packageJson: { piWeb: { plugins: [{ id: "duplicate", module: "pi-web-plugin.js" }] } },
files: { "pi-web-plugin.js": "export default {};" },
});
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
const manifest = await service.manifest();
expect(manifest.plugins.map((plugin) => plugin.id)).toEqual(["duplicate", "duplicate.2"]);
await expect(service.readAsset("duplicate.2", "pi-web-plugin.js")).resolves.toBeDefined();
expect(manifest.plugins.map((plugin) => plugin.id)).toEqual(["duplicate"]);
});
it("rejects unsafe plugin entries and asset traversal", async () => {
it("skips legacy metadata shortcuts and unsafe module paths", async () => {
const legacyRoot = join(tempDir, "legacy-root");
await writePlugin(join(legacyRoot, "legacy"), {
packageJson: { piWeb: { id: "legacy", plugin: "pi-web-plugin.js" } },
files: { "pi-web-plugin.js": "export default {};" },
});
const unsafeRoot = join(tempDir, "unsafe-root");
await writePlugin(join(unsafeRoot, "unsafe"), {
packageJson: { piWeb: { plugins: [{ id: "unsafe", module: "../escape.js" }] } },
files: { "pi-web-plugin.js": "export default {};" },
});
await expect(new PiWebPluginService({ roots: [{ path: legacyRoot, source: "test", scope: "local" }], packageProvider: false }).manifest()).resolves.toEqual({ plugins: [] });
await expect(new PiWebPluginService({ roots: [{ path: unsafeRoot, source: "test", scope: "local" }], packageProvider: false }).manifest()).resolves.toEqual({ plugins: [] });
});
it("continues discovering valid plugins when another local plugin is invalid", async () => {
await writePlugin(join(tempDir, "plugins", "valid"), {
packageJson: { piWeb: { plugins: [{ id: "valid", module: "pi-web-plugin.js" }] } },
files: { "pi-web-plugin.js": "export default {};" },
});
await writePlugin(join(tempDir, "plugins", "legacy"), {
packageJson: { piWeb: { id: "legacy", plugin: "pi-web-plugin.js" } },
files: { "pi-web-plugin.js": "export default {};" },
});
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
const manifest = await service.manifest();
expect(manifest.plugins.map((plugin) => plugin.id)).toEqual(["valid"]);
});
it("rejects unsafe asset traversal", async () => {
const pluginDir = join(tempDir, "plugins", "safe");
await writePlugin(pluginDir, {
packageJson: { piWeb: { id: "safe", plugins: ["../escape.js", "pi-web-plugin.js"] } },
packageJson: { piWeb: { plugins: [{ id: "safe", module: "pi-web-plugin.js" }] } },
files: { "pi-web-plugin.js": "export default {};" },
});
await writeFile(join(tempDir, "plugins", "escape.js"), "nope");
@@ -100,7 +131,6 @@ describe("PiWebPluginService", () => {
const manifest = await service.manifest();
expect(manifest.plugins).toHaveLength(1);
expect(manifest.plugins[0]?.module).toContain("pi-web-plugin.js");
await expect(service.readAsset("safe", "../escape.js")).resolves.toBeUndefined();
});
});
+45 -54
View File
@@ -1,12 +1,11 @@
import { existsSync } from "node:fs";
import { readdir, readFile, realpath, stat } from "node:fs/promises";
import { basename, dirname, join, relative, resolve, sep } from "node:path";
import { dirname, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
import { piWebDataDir } from "../config.js";
const pluginIdPattern = /^[a-z][a-z0-9.-]*$/u;
const defaultEntryFile = "pi-web-plugin.js";
export interface PiWebPluginManifest {
plugins: { id: string; module: string; source: string; scope: PiWebPluginScope }[];
@@ -48,13 +47,12 @@ interface LocalPluginRoot {
}
interface PiWebPackageConfig {
id?: string;
plugins: PiWebPluginEntry[];
}
interface PiWebPluginEntry {
id?: string;
path: string;
id: string;
module: string;
}
type ArraylessPluginRecord = Omit<PluginRecord, "source" | "scope">;
@@ -140,7 +138,11 @@ export class PiWebPluginService {
for (const configuredPackage of packageProvider.listPackages()) {
const root = configuredPackage.installedPath ?? packageProvider.getInstalledPath(configuredPackage.source, configuredPackage.scope);
if (root === undefined) continue;
plugins.push(...await discoverPackageRoot(root, configuredPackage));
try {
plugins.push(...await discoverPackageRoot(root, configuredPackage));
} catch (error) {
warnInvalidPlugin(configuredPackage.source, error);
}
}
return plugins;
}
@@ -163,93 +165,82 @@ async function discoverLocalRoot(root: LocalPluginRoot): Promise<PluginRecord[]>
const pluginRoot = join(root.path, entry.name);
const pluginStat = entry.isDirectory() ? undefined : entry.isSymbolicLink() ? await stat(pluginRoot).catch(() => undefined) : undefined;
if (!entry.isDirectory() && pluginStat?.isDirectory() !== true) continue;
plugins.push(...await discoverLocalPlugin(pluginRoot, entry.name, root));
try {
plugins.push(...await discoverLocalPlugin(pluginRoot, root));
} catch (error) {
warnInvalidPlugin(pluginRoot, error);
}
}
return plugins;
}
async function discoverLocalPlugin(root: string, fallbackId: string, localRoot: LocalPluginRoot): Promise<PluginRecord[]> {
const config = await readPiWebPackageConfig(root) ?? { plugins: [{ path: defaultEntryFile }] };
const plugins = await discoverPluginEntries(root, config, fallbackId);
async function discoverLocalPlugin(root: string, localRoot: LocalPluginRoot): Promise<PluginRecord[]> {
const config = await readPiWebPackageConfig(root);
if (config === undefined) return [];
const plugins = await discoverPluginEntries(root, config);
return plugins.map((plugin) => ({ ...plugin, source: localRoot.source, scope: localRoot.scope }));
}
async function discoverPackageRoot(root: string, configuredPackage: ConfiguredPiPackage): Promise<PluginRecord[]> {
const config = await readPiWebPackageConfig(root);
if (config === undefined) return [];
const fallbackId = sanitizePluginId(config.id ?? configuredPackage.source);
const plugins = await discoverPluginEntries(root, config, fallbackId);
const plugins = await discoverPluginEntries(root, config);
return plugins.map((plugin) => ({ ...plugin, source: configuredPackage.source, scope: configuredPackage.scope }));
}
async function discoverPluginEntries(root: string, config: PiWebPackageConfig, fallbackId: string): Promise<ArraylessPluginRecord[]> {
async function discoverPluginEntries(root: string, config: PiWebPackageConfig): Promise<ArraylessPluginRecord[]> {
const plugins: ArraylessPluginRecord[] = [];
for (const [index, entry] of config.plugins.entries()) {
if (!isSafeRelativePath(entry.path)) continue;
const entryPath = join(root, entry.path);
for (const entry of config.plugins) {
if (!isSafeRelativePath(entry.module)) throw new Error(`Unsafe Pi Web plugin module path for ${entry.id}: ${entry.module}`);
const entryPath = join(root, entry.module);
const entryStat = await stat(entryPath).catch(() => undefined);
if (entryStat?.isFile() !== true) continue;
const id = pluginEntryId(config, entry, fallbackId, index);
plugins.push({ id, root, entryFile: entry.path, version: String(Math.floor(entryStat.mtimeMs)) });
if (entryStat?.isFile() !== true) throw new Error(`Pi Web plugin module not found for ${entry.id}: ${entry.module}`);
plugins.push({ id: entry.id, root, entryFile: entry.module, version: String(Math.floor(entryStat.mtimeMs)) });
}
return plugins;
}
function pluginEntryId(config: PiWebPackageConfig, entry: PiWebPluginEntry, fallbackId: string, index: number): string {
if (entry.id !== undefined) return sanitizePluginId(entry.id);
if (config.id !== undefined && config.plugins.length === 1) return sanitizePluginId(config.id);
if (config.id !== undefined) return sanitizePluginId(`${config.id}.${basename(entry.path, ".js")}`);
if (config.plugins.length === 1) return sanitizePluginId(fallbackId);
return sanitizePluginId(`${fallbackId}.${String(index + 1)}`);
}
async function readPiWebPackageConfig(root: string): Promise<PiWebPackageConfig | undefined> {
const packagePath = join(root, "package.json");
const content = await readFile(packagePath, "utf8").catch(() => undefined);
if (content === undefined) return undefined;
const parsed: unknown = JSON.parse(content);
if (!isRecord(parsed)) return undefined;
const pi = parsed["pi"];
const piWeb = isRecord(parsed["piWeb"]) ? parsed["piWeb"] : isRecord(pi) && isRecord(pi["piWeb"]) ? pi["piWeb"] : undefined;
const piWeb = parsed["piWeb"];
if (!isRecord(piWeb)) return undefined;
const plugins = parsePluginEntries(piWeb);
const plugins = parsePluginEntries(piWeb, packagePath);
if (plugins.length === 0) return undefined;
return {
...(typeof piWeb["id"] === "string" ? { id: piWeb["id"] } : {}),
plugins,
};
return { plugins };
}
function parsePluginEntries(piWeb: Record<string, unknown>): PiWebPluginEntry[] {
const plugin = piWeb["plugin"];
if (typeof plugin === "string") return [{ path: plugin }];
function parsePluginEntries(piWeb: Record<string, unknown>, packagePath: string): PiWebPluginEntry[] {
if (piWeb["plugin"] !== undefined) throw new Error(`Unsupported Pi Web plugin metadata in ${packagePath}: use piWeb.plugins with { id, module } entries`);
const plugins = piWeb["plugins"];
if (!Array.isArray(plugins)) return [];
return plugins.flatMap((entry): PiWebPluginEntry[] => {
if (typeof entry === "string" && entry !== "") return [{ path: entry }];
if (!isRecord(entry) || typeof entry["module"] !== "string" || entry["module"] === "") return [];
return [{ path: entry["module"], ...(typeof entry["id"] === "string" ? { id: entry["id"] } : {}) }];
if (plugins === undefined) return [];
if (!Array.isArray(plugins)) throw new Error(`Pi Web plugins must be an array in ${packagePath}`);
return plugins.map((entry, index): PiWebPluginEntry => {
if (!isRecord(entry)) throw new Error(`Pi Web plugin entry ${String(index + 1)} must be an object in ${packagePath}`);
const id = entry["id"];
const module = entry["module"];
if (typeof id !== "string" || !pluginIdPattern.test(id)) throw new Error(`Invalid Pi Web plugin id in ${packagePath}: ${String(id)}`);
if (typeof module !== "string" || module === "") throw new Error(`Invalid Pi Web plugin module for ${id} in ${packagePath}`);
return { id, module };
});
}
function addUnique(records: Map<string, PluginRecord>, plugin: PluginRecord): void {
if (!records.has(plugin.id)) {
records.set(plugin.id, plugin);
if (records.has(plugin.id)) {
warnInvalidPlugin(plugin.source, `Duplicate Pi Web plugin id: ${plugin.id}`);
return;
}
for (let index = 2; ; index += 1) {
const id = sanitizePluginId(`${plugin.id}.${String(index)}`);
if (!records.has(id)) {
records.set(id, { ...plugin, id });
return;
}
}
records.set(plugin.id, plugin);
}
function sanitizePluginId(value: string): string {
const normalized = value.toLowerCase().replace(/^npm:/u, "").replace(/^git:/u, "").replace(/[^a-z0-9.-]+/gu, ".").replace(/^[^a-z]+/u, "").replace(/[.-]+$/u, "");
return pluginIdPattern.test(normalized) ? normalized : "plugin";
function warnInvalidPlugin(source: string, error: unknown): void {
const message = error instanceof Error ? error.message : String(error);
console.warn(`Skipping Pi Web plugin from ${source}: ${message}`);
}
function isSafeRelativePath(path: string): boolean {