plugins 2.0 update
This commit is contained in:
@@ -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, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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