plugins 2.0 update

This commit is contained in:
KingKDot
2026-06-22 10:52:27 -05:00
parent e1f5e4c800
commit 30dfdda3ed
55 changed files with 2534 additions and 128 deletions
+85 -3
View File
@@ -616,8 +616,13 @@ function showPluginConfirmModal(pluginId, clientId, sigInfo) {
body: JSON.stringify({ confirmed: true }),
});
if (!res.ok) {
const text = await res.text();
alert(`Plugin load failed: ${text}`);
const data = await res.json().catch(() => null);
if (data?.error === "needs_approval_required") {
modal.remove();
showPluginNeedsModal(pluginId, clientId, data.needs, data.needsHash);
return;
}
alert(`Plugin load failed: ${data?.error || res.statusText}`);
} else {
window.open(`/plugins/${pluginId}?clientId=${clientId}`, "_blank", "noopener");
}
@@ -630,6 +635,79 @@ function showPluginConfirmModal(pluginId, clientId, sigInfo) {
input.focus();
}
function escapePluginHtml(value) {
return String(value)
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function pluginNeedLines(needs) {
const files = Array.isArray(needs?.files) ? needs.files : [];
return files.map((need) => {
const access = Array.isArray(need.access) ? need.access.join(", ") : "";
const reason = need.reason ? ` - ${need.reason}` : "";
return `${need.bucket}: ${access}${reason}`;
});
}
function showPluginNeedsModal(pluginId, clientId, needs, needsHash) {
document.getElementById("plugin-needs-modal")?.remove();
const lines = pluginNeedLines(needs);
const modal = document.createElement("div");
modal.id = "plugin-needs-modal";
modal.className = "fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm";
modal.innerHTML = `
<div class="bg-slate-900 border border-slate-700 rounded-xl shadow-2xl max-w-lg w-full mx-4 p-6">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full flex items-center justify-center bg-amber-900/60 text-amber-300">
<i class="fa-solid fa-key text-lg"></i>
</div>
<div>
<h3 class="font-semibold text-lg">Approve Plugin Needs</h3>
<p class="text-sm text-slate-400">${escapePluginHtml(pluginId)}</p>
</div>
</div>
<p class="text-sm text-slate-300 mb-3">This plugin declares filesystem bridges that must be approved before loading.</p>
<div class="rounded-lg border border-slate-700 bg-slate-950/70 p-3 mb-3 space-y-2">
${lines.length ? lines.map((line) => `<div class="text-xs font-mono text-slate-300">${escapePluginHtml(line)}</div>`).join("") : '<div class="text-sm text-slate-400">No filesystem needs declared.</div>'}
</div>
${needsHash ? `<p class="text-xs text-slate-500 font-mono mb-4">Needs hash: ${escapePluginHtml(needsHash.slice(0, 16))}...</p>` : ""}
<div class="flex gap-3 justify-end">
<button id="plugin-needs-cancel" class="px-4 py-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-800">Cancel</button>
<button id="plugin-needs-approve" class="px-4 py-2 rounded-lg bg-amber-900/40 border border-amber-700/60 text-amber-100 hover:bg-amber-800/60">Approve and Load</button>
</div>
</div>
`;
document.body.appendChild(modal);
document.getElementById("plugin-needs-cancel")?.addEventListener("click", () => modal.remove());
modal.addEventListener("click", (e) => { if (e.target === modal) modal.remove(); });
document.getElementById("plugin-needs-approve")?.addEventListener("click", async () => {
const approveBtn = document.getElementById("plugin-needs-approve");
approveBtn.disabled = true;
approveBtn.textContent = "Approving...";
const approve = await fetch(`/api/plugins/${pluginId}/needs/approve`, { method: "POST" });
if (!approve.ok) {
alert("Needs approval failed");
modal.remove();
return;
}
const res = await fetch(`/api/clients/${clientId}/plugins/${pluginId}/load`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirmed: true }),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
alert(`Plugin load failed: ${data?.error || res.statusText}`);
} else {
window.open(`/plugins/${pluginId}?clientId=${clientId}`, "_blank", "noopener");
}
modal.remove();
});
}
function renderPluginMenu(plugins) {
const section = document.getElementById("plugin-section");
const container = document.getElementById("plugin-menu");
@@ -1708,7 +1786,11 @@ menu.addEventListener("click", async (e) => {
if (res.status === 428) {
const data = await res.json();
closeMenu(clearContext);
showPluginConfirmModal(pluginId, savedClientId, data.signature);
if (data.error === "needs_approval_required") {
showPluginNeedsModal(pluginId, savedClientId, data.needs, data.needsHash);
} else {
showPluginConfirmModal(pluginId, savedClientId, data.signature);
}
return;
}
+141 -4
View File
@@ -96,6 +96,16 @@ function getTrustBadge(sig) {
return { icon: "fa-shield-halved", color: "text-orange-400 border-orange-600 bg-orange-900/30", label: "Unsigned", tooltip: "This plugin is not signed" };
}
function describeNeeds(needs) {
const files = Array.isArray(needs?.files) ? needs.files : [];
if (!files.length) return [];
return files.map((need) => {
const access = Array.isArray(need.access) ? need.access.join(", ") : "";
const reason = need.reason ? ` - ${need.reason}` : "";
return `${need.bucket}: ${access}${reason}`;
});
}
function renderPlugins(plugins) {
pluginList.innerHTML = "";
if (!plugins.length) {
@@ -123,6 +133,23 @@ function renderPlugins(plugins) {
trustBadge.title = badge.tooltip;
titleRow.appendChild(trustBadge);
const runtimeBadge = document.createElement("span");
const isWasm = plugin.runtime === "wasm";
const isV2 = Number(plugin.apiVersion || 1) >= 2;
runtimeBadge.className = `inline-flex items-center gap-1 text-[10px] uppercase tracking-wider px-2 py-0.5 rounded-full border ${
isWasm
? "text-cyan-300 border-cyan-700 bg-cyan-950/40"
: isV2
? "text-emerald-300 border-emerald-700 bg-emerald-950/40"
: "text-slate-300 border-slate-700 bg-slate-900/60"
}`;
runtimeBadge.innerHTML = isWasm
? '<i class="fa-solid fa-cube"></i> WASM 2.0'
: isV2
? '<i class="fa-solid fa-code"></i> Plugin 2.0'
: '<i class="fa-solid fa-puzzle-piece"></i> Native Legacy';
titleRow.appendChild(runtimeBadge);
const subtitle = document.createElement("div");
subtitle.className = "text-sm text-slate-400";
subtitle.textContent = `${plugin.id}${plugin.version ? ` • v${plugin.version}` : ""}`;
@@ -137,6 +164,19 @@ function renderPlugins(plugins) {
meta.appendChild(titleRow);
meta.appendChild(subtitle);
const needLines = describeNeeds(plugin.needs);
if (needLines.length) {
const needsBox = document.createElement("div");
needsBox.className = "mt-2 text-xs text-slate-300 space-y-1";
needsBox.innerHTML = `
<div class="${plugin.needsApproved ? "text-emerald-300" : "text-amber-300"}">
<i class="fa-solid ${plugin.needsApproved ? "fa-lock-open" : "fa-lock"} mr-1"></i>
${plugin.needsApproved ? "Needs approved" : "Needs approval before load"}
</div>
${needLines.map((line) => `<div class="font-mono text-slate-400">${escapeHtml(line)}</div>`).join("")}
`;
meta.appendChild(needsBox);
}
const actions = document.createElement("div");
actions.className = "flex items-center gap-2";
@@ -159,6 +199,13 @@ function renderPlugins(plugins) {
});
if (!res.ok) {
const data = await res.json().catch(() => null);
if (data && data.error === "needs_approval_required") {
showNeedsApprovalModal(plugin, data.needs, data.needsHash, async () => {
await setPluginEnabled(plugin, wantEnabled, true);
await refresh();
});
return;
}
if (data && data.error === "confirmation_required") {
showPluginEnableConfirmModal(plugin, data.signature);
return;
@@ -202,14 +249,37 @@ function renderPlugins(plugins) {
}),
});
if (!res.ok) {
const text = await res.text();
setStatus(`Auto-load toggle failed: ${text}`, true);
const data = await res.json().catch(() => null);
if (data && data.error === "needs_approval_required") {
showNeedsApprovalModal(plugin, data.needs, data.needsHash, async () => {
await fetch(`/api/plugins/${plugin.id}/autoload`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
autoLoad: !plugin.autoLoad,
autoStartEvents: plugin.autoStartEvents || [],
}),
});
await refresh();
});
return;
}
setStatus(`Auto-load toggle failed: ${data?.error || res.statusText}`, true);
return;
}
await refresh();
});
}
if (needLines.length && !plugin.needsApproved) {
const approveBtn = document.createElement("button");
approveBtn.className =
"inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-amber-900/40 border border-amber-700/60 hover:bg-amber-800/60 text-amber-100";
approveBtn.innerHTML = '<i class="fa-solid fa-key"></i> Approve needs';
approveBtn.addEventListener("click", () => showNeedsApprovalModal(plugin, plugin.needs, plugin.needsHash, refresh));
actions.appendChild(approveBtn);
}
const removeBtn = document.createElement("button");
removeBtn.className =
"inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-red-900/40 border border-red-700/60 hover:bg-red-800/60 text-red-100";
@@ -241,6 +311,65 @@ function renderPlugins(plugins) {
}
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
async function setPluginEnabled(plugin, enabled, confirmed = false) {
return fetch(`/api/plugins/${plugin.id}/enable`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled, confirmed }),
});
}
function showNeedsApprovalModal(plugin, needs, needsHash, afterApprove) {
document.getElementById("plugin-needs-approval-modal")?.remove();
const needLines = describeNeeds(needs);
const overlay = document.createElement("div");
overlay.id = "plugin-needs-approval-modal";
overlay.className = "fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm";
overlay.innerHTML = `
<div class="bg-slate-900 border border-slate-700 rounded-2xl p-6 max-w-lg w-full mx-4 shadow-2xl">
<div class="flex items-center gap-3 mb-4">
<i class="fa-solid fa-key text-2xl text-amber-300"></i>
<h3 class="text-lg font-semibold text-slate-100">Approve Plugin Needs</h3>
</div>
<p class="text-sm text-slate-300 mb-3"><strong class="text-slate-100">${escapeHtml(plugin.name || plugin.id)}</strong> is asking for filesystem bridges before it can be sent to clients.</p>
<div class="rounded-lg border border-slate-700 bg-slate-950/70 p-3 mb-3 space-y-2">
${needLines.length ? needLines.map((line) => `<div class="text-xs font-mono text-slate-300">${escapeHtml(line)}</div>`).join("") : '<div class="text-sm text-slate-400">No filesystem needs declared.</div>'}
</div>
${needsHash ? `<p class="text-xs text-slate-500 font-mono mb-4">Needs hash: ${escapeHtml(needsHash.slice(0, 16))}...</p>` : ""}
<div class="flex justify-end gap-2">
<button id="plugin-needs-cancel" class="px-4 py-2 rounded-lg bg-slate-800 border border-slate-600 text-slate-300 hover:bg-slate-700">Cancel</button>
<button id="plugin-needs-ok" class="px-4 py-2 rounded-lg bg-amber-900/40 border border-amber-700/60 text-amber-100 hover:bg-amber-800/60">Approve</button>
</div>
</div>
`;
document.body.appendChild(overlay);
document.getElementById("plugin-needs-cancel")?.addEventListener("click", () => overlay.remove());
overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); });
document.getElementById("plugin-needs-ok")?.addEventListener("click", async () => {
const okBtn = document.getElementById("plugin-needs-ok");
okBtn.disabled = true;
okBtn.textContent = "Approving...";
const res = await fetch(`/api/plugins/${plugin.id}/needs/approve`, { method: "POST" });
if (!res.ok) {
const text = await res.text();
setStatus(`Needs approval failed: ${text}`, true);
overlay.remove();
return;
}
overlay.remove();
setStatus("Plugin needs approved.");
if (typeof afterApprove === "function") await afterApprove();
});
}
function showPluginEnableConfirmModal(plugin, sigInfo) {
document.getElementById("plugin-enable-confirm-modal")?.remove();
@@ -302,8 +431,16 @@ function showPluginEnableConfirmModal(plugin, sigInfo) {
body: JSON.stringify({ enabled: true, confirmed: true }),
});
if (!res.ok) {
const text = await res.text();
setStatus(`Enable failed: ${text}`, true);
const data = await res.json().catch(() => null);
if (data && data.error === "needs_approval_required") {
overlay.remove();
showNeedsApprovalModal(plugin, data.needs, data.needsHash, async () => {
await setPluginEnabled(plugin, true, true);
await refresh();
});
return;
}
setStatus(`Enable failed: ${data?.error || res.statusText}`, true);
}
} catch {
setStatus("Enable failed", true);
+8 -1
View File
@@ -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}`);
});
+14
View File
@@ -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");
});
});
+221 -25
View File
@@ -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 {