Archived
feat: stabilize Pi Web plugin API
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user