plugins 2.0 update
This commit is contained in:
@@ -73,6 +73,7 @@ import {
|
||||
savePluginStateToDisk,
|
||||
sendPluginBundle,
|
||||
dispatchAutoLoadPlugins,
|
||||
arePluginNeedsApproved,
|
||||
} from "./server/plugin-state-bundle";
|
||||
import { createPluginRuntime } from "./server/plugin-runtime/runtime";
|
||||
import {
|
||||
@@ -276,7 +277,7 @@ const MAX_HTTP_BODY_BYTES = parseMaxHttpBodyBytes();
|
||||
const pluginLoadedByClient = new Map<string, Set<string>>();
|
||||
const pendingPluginEvents = new Map<string, Array<{ event: string; payload: any }>>();
|
||||
const pluginLoadingByClient = new Map<string, Set<string>>();
|
||||
let pluginState = { enabled: {} as Record<string, boolean>, lastError: {} as Record<string, string>, autoLoad: {} as Record<string, boolean>, autoStartEvents: {} as Record<string, Array<{ event: string; payload: any }>> };
|
||||
let pluginState = { enabled: {} as Record<string, boolean>, lastError: {} as Record<string, string>, autoLoad: {} as Record<string, boolean>, autoStartEvents: {} as Record<string, Array<{ event: string; payload: any }>>, approvedNeeds: {} as Record<string, string> };
|
||||
|
||||
const savePluginState = () => savePluginStateToDisk(PLUGIN_ROOT, PLUGIN_STATE_PATH, pluginState);
|
||||
const loadPluginState = async () => {
|
||||
@@ -633,6 +634,12 @@ async function startServer() {
|
||||
notificationPluginHandlers.markPluginLoading,
|
||||
notificationPluginHandlers.enqueuePluginEvent,
|
||||
loadPluginBundle,
|
||||
async (pluginId: string) => {
|
||||
const manifests = await listPluginManifests();
|
||||
const manifest = manifests.find((p) => p.id === pluginId);
|
||||
if (!manifest) return false;
|
||||
return arePluginNeedsApproved(pluginState, pluginId, manifest.needs);
|
||||
},
|
||||
).catch((err) => {
|
||||
logger.warn(`[plugin-autoload] dispatch error for ${info.id}: ${(err as Error).message}`);
|
||||
});
|
||||
|
||||
@@ -334,10 +334,14 @@ export type ScriptResult = {
|
||||
export type PluginManifest = {
|
||||
id: string;
|
||||
name: string;
|
||||
apiVersion?: number;
|
||||
runtime?: "native" | "wasm" | string;
|
||||
version?: string;
|
||||
description?: string;
|
||||
binary?: string;
|
||||
binaries?: Record<string, string>;
|
||||
wasm?: string;
|
||||
needs?: PluginNeeds;
|
||||
entry?: string;
|
||||
assets?: {
|
||||
html?: string;
|
||||
@@ -351,6 +355,16 @@ export type PluginManifest = {
|
||||
hasServer?: boolean;
|
||||
};
|
||||
|
||||
export type PluginFileNeed = {
|
||||
bucket: "home" | "desktop" | "documents" | "downloads" | "temp" | "appData" | "pluginData" | "fullDisk" | string;
|
||||
access: Array<"read" | "write" | "list" | "delete" | "mkdir" | string>;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type PluginNeeds = {
|
||||
files?: PluginFileNeed[];
|
||||
};
|
||||
|
||||
export type PluginSignatureInfo = {
|
||||
signed: boolean;
|
||||
trusted: boolean;
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "fs/promises";
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { loadPluginBundle } from "./plugin-state-bundle";
|
||||
import {
|
||||
arePluginNeedsApproved,
|
||||
compilePluginTypeScript,
|
||||
computePluginNeedsHash,
|
||||
loadPluginBundle,
|
||||
type PluginState,
|
||||
} from "./plugin-state-bundle";
|
||||
|
||||
let tempRoots: string[] = [];
|
||||
|
||||
@@ -37,4 +43,66 @@ describe("loadPluginBundle", () => {
|
||||
expect(bundle.binaryPath).toBe(join(pluginDir, "sample-c-windows-amd64.dll"));
|
||||
expect(bundle.size).toBe("test-binary".length);
|
||||
});
|
||||
|
||||
test("loads wasm manifest independently of client platform", async () => {
|
||||
const root = await createTempRoot();
|
||||
const pluginDir = join(root, "wasm-demo");
|
||||
await mkdir(pluginDir, { recursive: true });
|
||||
await writeFile(join(pluginDir, "plugin.wasm"), Buffer.from([0x00, 0x61, 0x73, 0x6d]));
|
||||
await writeFile(
|
||||
join(pluginDir, "manifest.json"),
|
||||
JSON.stringify({
|
||||
id: "wasm-demo",
|
||||
name: "WASM Demo",
|
||||
apiVersion: 2,
|
||||
runtime: "wasm",
|
||||
wasm: "plugin.wasm",
|
||||
needs: { files: [{ bucket: "downloads", access: ["list", "read"], reason: "Import files" }] },
|
||||
}),
|
||||
);
|
||||
|
||||
const bundle = await loadPluginBundle(root, "wasm-demo", async () => {}, "Windows 11", "amd64");
|
||||
|
||||
expect(bundle.manifest.apiVersion).toBe(2);
|
||||
expect(bundle.manifest.runtime).toBe("wasm");
|
||||
expect(bundle.manifest.wasm).toBe("plugin.wasm");
|
||||
expect(bundle.binaryPath).toBe(join(root, "wasm-demo", "plugin.wasm"));
|
||||
expect(bundle.size).toBe(4);
|
||||
});
|
||||
|
||||
test("computes stable need hashes and invalidates changed approvals", () => {
|
||||
const state: PluginState = {
|
||||
enabled: {},
|
||||
lastError: {},
|
||||
autoLoad: {},
|
||||
autoStartEvents: {},
|
||||
approvedNeeds: {},
|
||||
};
|
||||
const needs = { files: [{ bucket: "downloads", access: ["read", "list"] }] };
|
||||
const sameNeedsDifferentOrder = { files: [{ bucket: "downloads", access: ["list", "read"] }] };
|
||||
const changedNeeds = { files: [{ bucket: "downloads", access: ["read", "write"] }] };
|
||||
const hash = computePluginNeedsHash(needs);
|
||||
|
||||
expect(hash).toBe(computePluginNeedsHash(sameNeedsDifferentOrder));
|
||||
expect(hash).not.toBe(computePluginNeedsHash(changedNeeds));
|
||||
expect(arePluginNeedsApproved(state, "demo", needs)).toBe(false);
|
||||
state.approvedNeeds.demo = hash;
|
||||
expect(arePluginNeedsApproved(state, "demo", sameNeedsDifferentOrder)).toBe(true);
|
||||
expect(arePluginNeedsApproved(state, "demo", changedNeeds)).toBe(false);
|
||||
});
|
||||
|
||||
test("compiles TypeScript entrypoints with local imports", async () => {
|
||||
const root = await createTempRoot();
|
||||
await mkdir(join(root, "src"), { recursive: true });
|
||||
await writeFile(join(root, "src", "shared.ts"), "export const message = 'hello-ts';\n");
|
||||
await writeFile(join(root, "src", "ui.ts"), "import { message } from './shared'; document.body.dataset.sample = message;\n");
|
||||
await writeFile(join(root, "src", "server.ts"), "import { message } from './shared'; export default { rpc: { ping() { return { message }; } } };\n");
|
||||
await compilePluginTypeScript(join(root, "src", "ui.ts"), join(root, "assets", "sample-ts.js"), "browser", "sample-ts");
|
||||
await compilePluginTypeScript(join(root, "src", "server.ts"), join(root, "server.js"), "bun", "sample-ts");
|
||||
|
||||
const uiJs = await readFile(join(root, "assets", "sample-ts.js"), "utf-8");
|
||||
const serverJs = await readFile(join(root, "server.js"), "utf-8");
|
||||
expect(uiJs).toContain("hello-ts");
|
||||
expect(serverJs).toContain("hello-ts");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import AdmZip from "adm-zip";
|
||||
import { createHash } from "crypto";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type { ClientInfo } from "../types";
|
||||
import { logger } from "../logger";
|
||||
import { encodeMessage, type PluginManifest, type PluginSignatureInfo } from "../protocol";
|
||||
import { verifyPluginSignature, getOrVerifySignature } from "./plugin-signature";
|
||||
import type { PluginFileNeed, PluginManifest, PluginNeeds, PluginSignatureInfo } from "../protocol";
|
||||
|
||||
export type PluginState = {
|
||||
enabled: Record<string, boolean>;
|
||||
lastError: Record<string, string>;
|
||||
autoLoad: Record<string, boolean>;
|
||||
autoStartEvents: Record<string, Array<{ event: string; payload: any }>>;
|
||||
approvedNeeds: Record<string, string>;
|
||||
};
|
||||
|
||||
const FILE_BUCKETS = new Set(["home", "desktop", "documents", "downloads", "temp", "appData", "pluginData", "fullDisk"]);
|
||||
const FILE_ACCESS = new Set(["read", "write", "list", "delete", "mkdir"]);
|
||||
|
||||
export async function loadPluginStateFromDisk(pluginStatePath: string): Promise<PluginState> {
|
||||
try {
|
||||
const raw = await fs.readFile(pluginStatePath, "utf-8");
|
||||
@@ -23,9 +25,10 @@ export async function loadPluginStateFromDisk(pluginStatePath: string): Promise<
|
||||
lastError: parsed.lastError || {},
|
||||
autoLoad: parsed.autoLoad || {},
|
||||
autoStartEvents: parsed.autoStartEvents || {},
|
||||
approvedNeeds: parsed.approvedNeeds || {},
|
||||
};
|
||||
} catch {
|
||||
return { enabled: {}, lastError: {}, autoLoad: {}, autoStartEvents: {} };
|
||||
return { enabled: {}, lastError: {}, autoLoad: {}, autoStartEvents: {}, approvedNeeds: {} };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +78,7 @@ export async function ensurePluginExtracted(
|
||||
const assetsDir = path.join(pluginDir, "assets");
|
||||
await fs.mkdir(assetsDir, { recursive: true });
|
||||
|
||||
const AdmZip = await loadAdmZip();
|
||||
const zip = new AdmZip(zipPath);
|
||||
const entries = zip.getEntries();
|
||||
|
||||
@@ -97,14 +101,19 @@ export async function ensurePluginExtracted(
|
||||
let jsEntry: Buffer | null = null;
|
||||
let serverEntry: Buffer | null = null;
|
||||
let configEntry: Buffer | null = null;
|
||||
let wasmEntry: { filename: string; data: Buffer } | null = null;
|
||||
const sourceEntries: Map<string, Buffer> = new Map();
|
||||
const nativeBinaries: Map<string, Buffer> = new Map();
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory) continue;
|
||||
const normalizedEntry = normalizeBundlePath(entry.entryName);
|
||||
const base = path.basename(entry.entryName);
|
||||
const lower = base.toLowerCase();
|
||||
if (lower.endsWith(".so") || lower.endsWith(".dll") || lower.endsWith(".dylib")) {
|
||||
nativeBinaries.set(base, entry.getData());
|
||||
} else if (lower.endsWith(".wasm")) {
|
||||
wasmEntry = { filename: base, data: entry.getData() };
|
||||
} else if (lower === "server.js") {
|
||||
serverEntry = entry.getData();
|
||||
} else if (lower.endsWith(".html")) {
|
||||
@@ -116,10 +125,28 @@ export async function ensurePluginExtracted(
|
||||
} else if (lower === "config.json") {
|
||||
configEntry = entry.getData();
|
||||
}
|
||||
if (normalizedEntry && isPluginSourcePath(normalizedEntry)) {
|
||||
sourceEntries.set(normalizedEntry, entry.getData());
|
||||
}
|
||||
}
|
||||
|
||||
if (!htmlEntry || !cssEntry || !jsEntry) {
|
||||
throw new Error(`Invalid plugin bundle: ${safeId} (missing .html, .css, or .js)`);
|
||||
let extraConfig: any = {};
|
||||
if (configEntry) {
|
||||
try {
|
||||
extraConfig = JSON.parse(configEntry.toString("utf-8"));
|
||||
} catch (err) {
|
||||
warnPlugin(`[plugin] invalid config.json in bundle ${safeId}, ignoring: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
const uiTsEntry = getConfiguredSourceEntry(extraConfig, ["uiEntry", "ui.entry", "build.ui", "build.uiEntry"], sourceEntries)
|
||||
|| (sourceEntries.has("src/ui.ts") ? "src/ui.ts" : "")
|
||||
|| (sourceEntries.has("src/index.ts") ? "src/index.ts" : "");
|
||||
const serverTsEntry = getConfiguredSourceEntry(extraConfig, ["serverEntry", "server.entry", "build.server", "build.serverEntry"], sourceEntries)
|
||||
|| (sourceEntries.has("src/server.ts") ? "src/server.ts" : "");
|
||||
|
||||
if (!htmlEntry || !cssEntry || (!jsEntry && !uiTsEntry)) {
|
||||
throw new Error(`Invalid plugin bundle: ${safeId} (missing .html, .css, and either .js or src/ui.ts)`);
|
||||
}
|
||||
|
||||
const binariesMap: Record<string, string> = {};
|
||||
@@ -130,13 +157,35 @@ export async function ensurePluginExtracted(
|
||||
binariesMap[platformKey] = filename;
|
||||
}
|
||||
}
|
||||
if (wasmEntry) {
|
||||
await fs.writeFile(path.join(pluginDir, wasmEntry.filename), wasmEntry.data);
|
||||
binariesMap["wasm32-wasi"] = wasmEntry.filename;
|
||||
}
|
||||
|
||||
if (sourceEntries.size > 0) {
|
||||
await fs.rm(path.join(pluginDir, "src"), { recursive: true, force: true });
|
||||
for (const [relPath, data] of sourceEntries) {
|
||||
const target = resolveInside(pluginDir, relPath);
|
||||
if (!target) {
|
||||
throw new Error(`Invalid plugin bundle: ${safeId} (unsafe source path)`);
|
||||
}
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
await fs.writeFile(target, data);
|
||||
}
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(assetsDir, `${safeId}.html`), htmlEntry);
|
||||
await fs.writeFile(path.join(assetsDir, `${safeId}.css`), cssEntry);
|
||||
await fs.writeFile(path.join(assetsDir, `${safeId}.js`), jsEntry);
|
||||
if (uiTsEntry) {
|
||||
await compilePluginTypeScript(path.join(pluginDir, uiTsEntry), path.join(assetsDir, `${safeId}.js`), "browser", safeId);
|
||||
} else if (jsEntry) {
|
||||
await fs.writeFile(path.join(assetsDir, `${safeId}.js`), jsEntry);
|
||||
}
|
||||
|
||||
const serverScriptPath = path.join(pluginDir, "server.js");
|
||||
if (serverEntry) {
|
||||
if (serverTsEntry) {
|
||||
await compilePluginTypeScript(path.join(pluginDir, serverTsEntry), serverScriptPath, "bun", safeId);
|
||||
} else if (serverEntry) {
|
||||
await fs.writeFile(serverScriptPath, serverEntry);
|
||||
} else {
|
||||
// A previous extraction may have left a stale server.js — drop it so the
|
||||
@@ -144,20 +193,17 @@ export async function ensurePluginExtracted(
|
||||
try { await fs.rm(serverScriptPath, { force: true }); } catch {}
|
||||
}
|
||||
|
||||
let extraConfig: any = {};
|
||||
if (configEntry) {
|
||||
try {
|
||||
extraConfig = JSON.parse(configEntry.toString("utf-8"));
|
||||
} catch (err) {
|
||||
logger.warn(`[plugin] invalid config.json in bundle ${safeId}, ignoring: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
const runtime = normalizePluginRuntime(extraConfig.runtime, wasmEntry !== null);
|
||||
const needs = normalizePluginNeeds(extraConfig.needs);
|
||||
const manifest: PluginManifest = {
|
||||
id: safeId,
|
||||
name: extraConfig.name || safeId,
|
||||
apiVersion: Number(extraConfig.apiVersion) === 2 || runtime === "wasm" ? 2 : 1,
|
||||
runtime,
|
||||
version: extraConfig.version || "1.0.0",
|
||||
description: extraConfig.description,
|
||||
...(runtime === "wasm" && { wasm: String(extraConfig.wasm || wasmEntry?.filename || binariesMap["wasm32-wasi"] || "") }),
|
||||
...(needs && { needs }),
|
||||
binaries: binariesMap,
|
||||
entry: `${safeId}.html`,
|
||||
assets: {
|
||||
@@ -166,16 +212,17 @@ export async function ensurePluginExtracted(
|
||||
js: `${safeId}.js`,
|
||||
},
|
||||
...(extraConfig.navbar && { navbar: extraConfig.navbar }),
|
||||
hasServer: serverEntry !== null,
|
||||
hasServer: serverEntry !== null || !!serverTsEntry,
|
||||
};
|
||||
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2));
|
||||
|
||||
try {
|
||||
const { verifyPluginSignature } = await import("./plugin-signature");
|
||||
const sigInfo = await verifyPluginSignature(zipPath);
|
||||
const sigInfoPath = path.join(pluginDir, "signature-info.json");
|
||||
await fs.writeFile(sigInfoPath, JSON.stringify(sigInfo, null, 2));
|
||||
} catch (err) {
|
||||
logger.warn(`[plugin] failed to verify signature for ${safeId}: ${(err as Error).message}`);
|
||||
warnPlugin(`[plugin] failed to verify signature for ${safeId}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,7 +238,7 @@ export async function syncPluginBundles(
|
||||
try {
|
||||
await ensureExtracted(pluginId);
|
||||
} catch (err) {
|
||||
logger.warn(`[plugin] failed to extract ${pluginId}: ${(err as Error).message}`);
|
||||
warnPlugin(`[plugin] failed to extract ${pluginId}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,6 +246,7 @@ export async function syncPluginBundles(
|
||||
|
||||
export type PluginManifestWithSignature = PluginManifest & {
|
||||
signature?: PluginSignatureInfo;
|
||||
needsHash?: string;
|
||||
};
|
||||
|
||||
export async function listPluginManifests(
|
||||
@@ -222,8 +270,9 @@ export async function listPluginManifests(
|
||||
if (pluginState.enabled[id] === undefined) {
|
||||
pluginState.enabled[id] = true;
|
||||
}
|
||||
const { getOrVerifySignature } = await import("./plugin-signature");
|
||||
const sigInfo = await getOrVerifySignature(pluginRoot, id);
|
||||
manifests.push({ ...manifest, id, name, signature: sigInfo });
|
||||
manifests.push({ ...manifest, id, name, signature: sigInfo, needsHash: computePluginNeedsHash(manifest.needs) });
|
||||
} catch {}
|
||||
}
|
||||
await saveState();
|
||||
@@ -254,6 +303,19 @@ export async function loadPluginBundle(
|
||||
manifest.id = manifest.id || pluginId;
|
||||
manifest.name = manifest.name || pluginId;
|
||||
|
||||
if (manifest.runtime === "wasm" || manifest.wasm || manifest.binaries?.["wasm32-wasi"]) {
|
||||
const wasmName = manifest.wasm || manifest.binaries?.["wasm32-wasi"];
|
||||
if (!wasmName) {
|
||||
throw new Error(`No WASM module configured for ${pluginId}`);
|
||||
}
|
||||
const binaryPath = path.join(dir, wasmName);
|
||||
const stat = await fs.stat(binaryPath);
|
||||
return { manifest: { ...manifest, runtime: "wasm", apiVersion: 2 }, binaryPath, size: stat.size };
|
||||
}
|
||||
|
||||
manifest.runtime = manifest.runtime || "native";
|
||||
manifest.apiVersion = manifest.apiVersion || 1;
|
||||
|
||||
const hasBinaries = manifest.binaries && Object.keys(manifest.binaries).length > 0;
|
||||
if (!hasBinaries) {
|
||||
return { manifest, binaryPath: null, size: 0 };
|
||||
@@ -366,6 +428,7 @@ export function sendPluginBundle(
|
||||
bundle: PluginBundle,
|
||||
): void {
|
||||
if (!bundle.binaryPath) return;
|
||||
const { encodeMessage } = require("../protocol");
|
||||
|
||||
const pullId = uuidv4();
|
||||
const expiresAt = Date.now() + PLUGIN_PULL_TTL_MS;
|
||||
@@ -405,6 +468,7 @@ export async function dispatchAutoLoadPlugins(
|
||||
markPluginLoading: (clientId: string, pluginId: string) => void,
|
||||
enqueuePluginEvent: (clientId: string, pluginId: string, event: string, payload: any) => void,
|
||||
loadBundle: (pluginId: string, clientOS?: string, clientArch?: string) => Promise<PluginBundle>,
|
||||
needsApproved: (pluginId: string) => Promise<boolean> = async () => true,
|
||||
): Promise<void> {
|
||||
const autoLoadIds = Object.entries(pluginState.autoLoad)
|
||||
.filter(([id, enabled]) => enabled && pluginState.enabled[id] !== false)
|
||||
@@ -416,6 +480,10 @@ export async function dispatchAutoLoadPlugins(
|
||||
}
|
||||
|
||||
try {
|
||||
if (!(await needsApproved(pluginId))) {
|
||||
warnPlugin(`[plugin-autoload] skipped ${pluginId} for ${client.id}: plugin needs approval`);
|
||||
continue;
|
||||
}
|
||||
const bundle = await loadBundle(pluginId, client.os, client.arch);
|
||||
markPluginLoading(client.id, pluginId);
|
||||
sendPluginBundle(client, bundle);
|
||||
@@ -427,14 +495,134 @@ export async function dispatchAutoLoadPlugins(
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`[plugin-autoload] dispatched ${pluginId} to ${client.id}`);
|
||||
infoPlugin(`[plugin-autoload] dispatched ${pluginId} to ${client.id}`);
|
||||
} catch (err) {
|
||||
logger.warn(`[plugin-autoload] failed to load ${pluginId} for ${client.id}: ${(err as Error).message}`);
|
||||
warnPlugin(`[plugin-autoload] failed to load ${pluginId} for ${client.id}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function detectPluginIdFromZip(zip: AdmZip): string | null {
|
||||
function warnPlugin(message: string): void {
|
||||
console.warn(message);
|
||||
}
|
||||
|
||||
function infoPlugin(message: string): void {
|
||||
console.log(message);
|
||||
}
|
||||
|
||||
function normalizeBundlePath(entryName: string): string | null {
|
||||
if (!entryName || entryName.includes("\0")) return null;
|
||||
const normalized = entryName.replace(/\\/g, "/").replace(/^\/+/, "");
|
||||
const parts = normalized.split("/").filter(Boolean);
|
||||
if (parts.some((part) => part === "." || part === "..")) return null;
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
function isPluginSourcePath(relPath: string): boolean {
|
||||
if (!relPath.startsWith("src/")) return false;
|
||||
const lower = relPath.toLowerCase();
|
||||
return (
|
||||
lower.endsWith(".ts") ||
|
||||
lower.endsWith(".tsx") ||
|
||||
lower.endsWith(".js") ||
|
||||
lower.endsWith(".jsx") ||
|
||||
lower.endsWith(".json") ||
|
||||
lower.endsWith(".d.ts")
|
||||
);
|
||||
}
|
||||
|
||||
function resolveInside(root: string, relPath: string): string | null {
|
||||
const resolvedRoot = path.resolve(root);
|
||||
const resolvedTarget = path.resolve(resolvedRoot, relPath);
|
||||
const prefix = resolvedRoot.endsWith(path.sep) ? resolvedRoot : `${resolvedRoot}${path.sep}`;
|
||||
if (resolvedTarget !== resolvedRoot && !resolvedTarget.startsWith(prefix)) return null;
|
||||
return resolvedTarget;
|
||||
}
|
||||
|
||||
function getConfiguredSourceEntry(config: any, candidates: string[], sourceEntries: Map<string, Buffer>): string {
|
||||
for (const candidate of candidates) {
|
||||
const value = getPathValue(config, candidate);
|
||||
if (typeof value !== "string") continue;
|
||||
const normalized = normalizeBundlePath(value);
|
||||
if (!normalized || !isPluginSourcePath(normalized)) continue;
|
||||
if (!sourceEntries.has(normalized)) {
|
||||
throw new Error(`Configured TypeScript entry not found: ${normalized}`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function getPathValue(obj: any, dotted: string): unknown {
|
||||
let cur = obj;
|
||||
for (const part of dotted.split(".")) {
|
||||
if (!cur || typeof cur !== "object") return undefined;
|
||||
cur = cur[part];
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
export async function compilePluginTypeScript(entryPath: string, outFile: string, target: "browser" | "bun", pluginId: string): Promise<void> {
|
||||
const result = await Bun.build({
|
||||
entrypoints: [entryPath],
|
||||
target,
|
||||
format: "esm",
|
||||
minify: false,
|
||||
sourcemap: "none",
|
||||
write: false,
|
||||
});
|
||||
if (!result.success) {
|
||||
const logs = result.logs.map((log) => log.message).join("; ");
|
||||
throw new Error(`TypeScript build failed for ${pluginId}: ${logs || "unknown error"}`);
|
||||
}
|
||||
const output = result.outputs.find((out) => out.path.endsWith(".js")) || result.outputs[0];
|
||||
if (!output) {
|
||||
throw new Error(`TypeScript build failed for ${pluginId}: no JavaScript output`);
|
||||
}
|
||||
await fs.mkdir(path.dirname(outFile), { recursive: true });
|
||||
await fs.writeFile(outFile, await output.text());
|
||||
}
|
||||
|
||||
export function computePluginNeedsHash(needs: PluginNeeds | undefined): string {
|
||||
const normalized = normalizePluginNeeds(needs);
|
||||
if (!normalized || !normalized.files || normalized.files.length === 0) return "";
|
||||
return createHash("sha256").update(JSON.stringify(normalized)).digest("hex");
|
||||
}
|
||||
|
||||
export function arePluginNeedsApproved(pluginState: PluginState, pluginId: string, needs: PluginNeeds | undefined): boolean {
|
||||
const hash = computePluginNeedsHash(needs);
|
||||
return !hash || pluginState.approvedNeeds[pluginId] === hash;
|
||||
}
|
||||
|
||||
export function normalizePluginNeeds(raw: any): PluginNeeds | undefined {
|
||||
if (!raw || typeof raw !== "object") return undefined;
|
||||
const filesRaw = Array.isArray(raw.files) ? raw.files : [];
|
||||
const files: PluginFileNeed[] = [];
|
||||
for (const item of filesRaw) {
|
||||
if (!item || typeof item !== "object") continue;
|
||||
const bucket = typeof item.bucket === "string" ? item.bucket.trim() : "";
|
||||
if (!FILE_BUCKETS.has(bucket)) continue;
|
||||
const accessRaw = Array.isArray(item.access) ? item.access : [];
|
||||
const access = Array.from(new Set(accessRaw.filter((a: any) => typeof a === "string" && FILE_ACCESS.has(a))));
|
||||
if (access.length === 0) continue;
|
||||
files.push({
|
||||
bucket,
|
||||
access: access.sort(),
|
||||
...(typeof item.reason === "string" && item.reason.trim() ? { reason: item.reason.trim().slice(0, 500) } : {}),
|
||||
});
|
||||
}
|
||||
files.sort((a, b) => `${a.bucket}:${a.access.join(",")}`.localeCompare(`${b.bucket}:${b.access.join(",")}`));
|
||||
return files.length > 0 ? { files } : undefined;
|
||||
}
|
||||
|
||||
function normalizePluginRuntime(runtime: any, hasWasm: boolean): "native" | "wasm" | "server" {
|
||||
const value = typeof runtime === "string" ? runtime.trim().toLowerCase() : "";
|
||||
if (value === "wasm" || hasWasm) return "wasm";
|
||||
if (value === "server") return "server";
|
||||
return "native";
|
||||
}
|
||||
|
||||
export function detectPluginIdFromZip(zip: any): string | null {
|
||||
const entries = zip.getEntries();
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory) continue;
|
||||
@@ -457,6 +645,14 @@ export function detectPluginIdFromZip(zip: AdmZip): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function loadAdmZip(): Promise<any> {
|
||||
try {
|
||||
return require("adm-zip");
|
||||
} catch {
|
||||
return require(path.join(process.cwd(), "node_modules", "adm-zip", "adm-zip.js"));
|
||||
}
|
||||
}
|
||||
|
||||
function derivePlatformKey(filename: string): string {
|
||||
const match = filename.match(/-(linux|darwin|windows|freebsd)-(amd64|arm64|arm|386)\.(so|dll|dylib)$/i);
|
||||
if (match) {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { encodeMessage, type PluginSignatureInfo } from "../../protocol";
|
||||
import { getConfig, updatePluginsConfig } from "../../config";
|
||||
import { getOrVerifySignature, BUILTIN_TRUSTED_KEYS } from "../plugin-signature";
|
||||
import type { PluginRuntime } from "../plugin-runtime/runtime";
|
||||
import { getPluginPull, deletePluginPull, detectPluginIdFromZip } from "../plugin-state-bundle";
|
||||
import { arePluginNeedsApproved, computePluginNeedsHash, getPluginPull, deletePluginPull, detectPluginIdFromZip } from "../plugin-state-bundle";
|
||||
import { isAuthorizedAgentRequest } from "../agent-auth";
|
||||
import { logger } from "../../logger";
|
||||
|
||||
@@ -21,6 +21,11 @@ type PluginManifest = {
|
||||
name: string;
|
||||
signature?: PluginSignatureInfo;
|
||||
hasServer?: boolean;
|
||||
apiVersion?: number;
|
||||
runtime?: string;
|
||||
wasm?: string;
|
||||
needs?: any;
|
||||
needsHash?: string;
|
||||
};
|
||||
|
||||
type PluginBundle = {
|
||||
@@ -34,6 +39,7 @@ type PluginState = {
|
||||
lastError: Record<string, string>;
|
||||
autoLoad: Record<string, boolean>;
|
||||
autoStartEvents: Record<string, Array<{ event: string; payload: any }>>;
|
||||
approvedNeeds: Record<string, string>;
|
||||
};
|
||||
|
||||
type PluginRouteDeps = {
|
||||
@@ -72,6 +78,34 @@ export async function handlePluginRoutes(
|
||||
return null;
|
||||
}
|
||||
|
||||
async function loadManifest(pluginId: string): Promise<PluginManifest> {
|
||||
await deps.ensurePluginExtracted(pluginId);
|
||||
const raw = await fs.readFile(path.join(deps.PLUGIN_ROOT, pluginId, "manifest.json"), "utf-8");
|
||||
const manifest = JSON.parse(raw) as PluginManifest;
|
||||
manifest.id = manifest.id || pluginId;
|
||||
manifest.name = manifest.name || pluginId;
|
||||
manifest.needsHash = computePluginNeedsHash(manifest.needs);
|
||||
return manifest;
|
||||
}
|
||||
|
||||
function needsApproved(manifest: PluginManifest): boolean {
|
||||
return arePluginNeedsApproved(deps.pluginState, manifest.id, manifest.needs);
|
||||
}
|
||||
|
||||
async function requireNeedsApproval(pluginId: string): Promise<Response | null> {
|
||||
const manifest = await loadManifest(pluginId);
|
||||
if (needsApproved(manifest)) return null;
|
||||
return Response.json(
|
||||
{
|
||||
ok: false,
|
||||
error: "needs_approval_required",
|
||||
needs: manifest.needs || {},
|
||||
needsHash: manifest.needsHash || "",
|
||||
},
|
||||
{ status: 428 },
|
||||
);
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname.startsWith("/api/plugins/pull/")) {
|
||||
const agentToken = getConfig().auth.agentToken;
|
||||
if (!isAuthorizedAgentRequest(req, url, agentToken)) {
|
||||
@@ -137,6 +171,11 @@ export async function handlePluginRoutes(
|
||||
autoLoad: deps.pluginState.autoLoad[p.id] === true,
|
||||
autoStartEvents: deps.pluginState.autoStartEvents[p.id] || [],
|
||||
signature: p.signature || { signed: false, trusted: false, valid: false },
|
||||
runtime: p.runtime || "native",
|
||||
apiVersion: p.apiVersion || 1,
|
||||
needs: p.needs || {},
|
||||
needsHash: p.needsHash || computePluginNeedsHash(p.needs),
|
||||
needsApproved: arePluginNeedsApproved(deps.pluginState, p.id, p.needs),
|
||||
hasServer: p.hasServer === true || deps.pluginRuntime.hasServerCode(p.id),
|
||||
serverRunning: deps.pluginRuntime.isRunning(p.id),
|
||||
}));
|
||||
@@ -229,6 +268,11 @@ export async function handlePluginRoutes(
|
||||
enabled: deps.pluginState.enabled[manifest.id] !== false,
|
||||
lastError: deps.pluginState.lastError[manifest.id] || "",
|
||||
signature: manifest.signature || { signed: false, trusted: false, valid: false },
|
||||
runtime: manifest.runtime || "native",
|
||||
apiVersion: manifest.apiVersion || 1,
|
||||
needs: manifest.needs || {},
|
||||
needsHash: manifest.needsHash || computePluginNeedsHash(manifest.needs),
|
||||
needsApproved: arePluginNeedsApproved(deps.pluginState, manifest.id, manifest.needs),
|
||||
}));
|
||||
return Response.json({ plugins });
|
||||
}
|
||||
@@ -312,8 +356,10 @@ export async function handlePluginRoutes(
|
||||
|
||||
const sigInfo = await getOrVerifySignature(deps.PLUGIN_ROOT, pluginId);
|
||||
|
||||
const uploadedManifest = await loadManifest(pluginId);
|
||||
|
||||
if (deps.pluginState.enabled[pluginId] === undefined) {
|
||||
deps.pluginState.enabled[pluginId] = sigInfo.trusted === true;
|
||||
deps.pluginState.enabled[pluginId] = sigInfo.trusted === true && needsApproved(uploadedManifest);
|
||||
await deps.savePluginState();
|
||||
}
|
||||
|
||||
@@ -364,6 +410,8 @@ export async function handlePluginRoutes(
|
||||
);
|
||||
}
|
||||
}
|
||||
const needsResponse = await requireNeedsApproval(pluginId);
|
||||
if (needsResponse) return needsResponse;
|
||||
}
|
||||
|
||||
deps.pluginState.enabled[pluginId] = enabled;
|
||||
@@ -410,6 +458,10 @@ export async function handlePluginRoutes(
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (autoLoad) {
|
||||
const needsResponse = await requireNeedsApproval(pluginId);
|
||||
if (needsResponse) return needsResponse;
|
||||
}
|
||||
|
||||
deps.pluginState.autoLoad[pluginId] = autoLoad;
|
||||
|
||||
@@ -444,6 +496,33 @@ export async function handlePluginRoutes(
|
||||
});
|
||||
}
|
||||
|
||||
const pluginNeedsApproveMatch = url.pathname.match(/^\/api\/plugins\/(.+)\/needs\/approve$/);
|
||||
if (req.method === "POST" && pluginNeedsApproveMatch) {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
try {
|
||||
requirePermission(user, "plugins:manage");
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error;
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
let pluginId = "";
|
||||
try {
|
||||
pluginId = deps.sanitizePluginId(pluginNeedsApproveMatch[1]);
|
||||
} catch {
|
||||
return new Response("Invalid plugin id", { status: 400 });
|
||||
}
|
||||
const manifest = await loadManifest(pluginId);
|
||||
const needsHash = manifest.needsHash || "";
|
||||
if (!needsHash) {
|
||||
delete deps.pluginState.approvedNeeds[pluginId];
|
||||
} else {
|
||||
deps.pluginState.approvedNeeds[pluginId] = needsHash;
|
||||
}
|
||||
await deps.savePluginState();
|
||||
return Response.json({ ok: true, id: pluginId, needsHash, needsApproved: true });
|
||||
}
|
||||
|
||||
function resolveDataPath(pluginId: string, relPath: string): string | null {
|
||||
const dataDir = path.join(deps.PLUGIN_ROOT, pluginId, "data");
|
||||
const target = path.resolve(dataDir, relPath);
|
||||
@@ -755,6 +834,7 @@ export async function handlePluginRoutes(
|
||||
delete deps.pluginState.lastError[pluginId];
|
||||
delete deps.pluginState.autoLoad[pluginId];
|
||||
delete deps.pluginState.autoStartEvents[pluginId];
|
||||
delete deps.pluginState.approvedNeeds[pluginId];
|
||||
await deps.savePluginState();
|
||||
|
||||
if (deps.pluginRuntime.isRunning(pluginId)) {
|
||||
@@ -821,6 +901,8 @@ export async function handlePluginRoutes(
|
||||
|
||||
try {
|
||||
const bundle = await deps.loadPluginBundle(pluginId, target.os, target.arch);
|
||||
const needsResponse = await requireNeedsApproval(pluginId);
|
||||
if (needsResponse) return needsResponse;
|
||||
deps.markPluginLoading(targetId, pluginId);
|
||||
deps.sendPluginBundle(target, bundle);
|
||||
metrics.recordCommand("plugin_load");
|
||||
@@ -902,6 +984,8 @@ export async function handlePluginRoutes(
|
||||
}
|
||||
|
||||
if (!deps.isPluginLoaded(targetId, pluginId)) {
|
||||
const needsResponse = await requireNeedsApproval(pluginId);
|
||||
if (needsResponse) return needsResponse;
|
||||
deps.enqueuePluginEvent(targetId, pluginId, event, payload);
|
||||
if (!deps.isPluginLoading(targetId, pluginId)) {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user