/** * Runtime plugin loader — plugins as CODE, not registry edits, loaded after * build time. The pipeline every non-bundled plugin takes: * * source (plain ESM js) -> [integrity check] -> bare-specifier rewrite * (`@hermes/plugin-sdk` / `react*` -> live shim blobs, see sdk/runtime.ts) * -> blob `import()` -> validate default HermesPlugin -> register(ctx) * * Loading the same plugin id again disposes the previous registrations first * (agent rewrites a plugin file -> clean reload). Failures toast + log; a * broken plugin can never take the app down. * * Sources today: the in-repo runtime example (`?raw`, proves the pipeline) * and the two on-disk doors — `/desktop-plugins//plugin.js` * and the unified agent-plugin half `/plugins//desktop/ * plugin.js` — the doors the agent writes through. * * SECURITY — this is NOT a capability boundary. A loaded plugin is evaluated * as ESM in the renderer realm with FULL app authority: the React singleton, * the whole SDK (`host.request` gateway RPC, `ctx.rest`, storage, `navigate`). * The isolation here is *error* isolation only (ContribBoundary, isolated * listeners) — a plugin can't crash the app, but it can do anything the app * can. That's acceptable for local sources (disk files can already run code), * and `integrity` only proves the bytes match a hash — it does NOT sandbox. * A remote source (https + allowlist) must NOT reuse this pipeline as-is: * it needs a real boundary (iframe/worker + CSP + capability gating) before * it can land. The `{ integrity }` option is the transport seam, not the * trust seam. */ import { installPluginSdk, sdkImportMap } from '@/sdk/runtime' import { notifyError } from '@/store/notifications' import { createPluginContext, type HermesPlugin } from './plugin' import { $pluginRecords, dropPlugin, pluginActive, type PluginKind, publishPlugin } from './plugins-store' interface LoadOptions { /** Root-level default-enable CAP: `false` ships the plugin opt-in (inventory * row, off until the user toggles) even if the plugin says otherwise. The * unified agent-plugin root sets this so `~/.hermes/plugins` keeps its * installed-but-inert posture (GHSA-mcfc-hp25-cjv7) on the desktop side too. */ defaultEnabled?: boolean /** Absolute plugin.js path (disk plugins) — recorded for reveal/inventory. */ file?: string /** `sha256-` — verified against the source before evaluation. */ integrity?: string /** Inventory bucket; the disk door is the default runtime source. */ kind?: PluginKind /** Agent package whose desktop half this is (unified packages). */ packageName?: string packageOrigin?: PackageMarker['origin'] } /** Live runtime plugins: id -> disposers (unload/reload support). */ const loaded = new Map void)[]>() // Matches the specifier of a static `from '…'`, a side-effect `import '…'`, or // a dynamic `import('…')` — anchored to import/export syntax so a bare string // literal or comment (e.g. `notify('react')`) is never touched. const importSpecifierRe = () => /(from\s*|import\s*\(\s*|import\s+)(['"])([^'"]+)\2/g /** Rewrite ONLY mapped import specifiers (@hermes/plugin-sdk, react*) to their * live shim blob URLs — never occurrences inside strings/comments. */ function rewriteSpecifiers(source: string): string { const map = sdkImportMap() return source.replace(importSpecifierRe(), (whole, pre, quote, spec) => map[spec] ? `${pre}${quote}${map[spec]}${quote}` : whole ) } /** Bare import specifiers the loader can't resolve (not relative/URL, not in * the SDK map). Surfaced up-front so they don't fail as a cryptic native * "Failed to resolve module specifier" from the blob import. */ function unsupportedImports(source: string): string[] { const map = sdkImportMap() const bare = new Set() for (const m of source.matchAll(importSpecifierRe())) { const spec = m[3] // Skip relative/absolute (./ ../ /) and any URL scheme (blob: http(s):). if (spec && !/^[./]/.test(spec) && !/^[a-z][a-z0-9+.-]*:/i.test(spec) && !map[spec]) { bare.add(spec) } } return [...bare] } async function verifyIntegrity(source: string, integrity: string): Promise { const [algo, expected] = integrity.split('-', 2) if (algo !== 'sha256' || !expected) { return false } const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(source)) // Standard SRI base64 (`sha256-`) — a base64url-encoded hash won't match. const actual = btoa(String.fromCharCode(...new Uint8Array(digest))) return actual === expected } export function unloadRuntimePlugin(id: string): void { loaded.get(id)?.forEach(dispose => dispose()) loaded.delete(id) } /** Evaluate + register one runtime plugin. Returns its id, or null on failure. */ export async function loadRuntimePlugin( source: string, origin: string, options: LoadOptions = {} ): Promise { installPluginSdk() try { if (options.integrity && !(await verifyIntegrity(source, options.integrity))) { throw new Error(`integrity check failed for ${origin}`) } const unsupported = unsupportedImports(source) if (unsupported.length > 0) { throw new Error( `unsupported import${unsupported.length > 1 ? 's' : ''}: ${unsupported.join(', ')} — ` + `runtime plugins may only import @hermes/plugin-sdk and react` ) } const url = URL.createObjectURL(new Blob([rewriteSpecifiers(source)], { type: 'text/javascript' })) let mod: { default?: HermesPlugin } try { mod = await import(/* @vite-ignore */ url) } finally { URL.revokeObjectURL(url) } const plugin = mod.default if (!plugin?.id || typeof plugin.register !== 'function') { throw new Error(`${origin} has no valid default HermesPlugin export`) } // A disk/runtime copy of a plugin that now ships BUNDLED (e.g. a // standalone install of hermes-bots predating its adoption in-tree) must // not register a second time: contributions would double up and the two // copies would fight over storage. The bundled copy wins; the disk copy // is skipped — but VISIBLY: a silent skip left the stale folder // undiscoverable while (on shells without the bundled twin) the same // folder actively breaks the feature it shadows. The inventory row // carries the file path so Capabilities → Plugins can reveal it for deletion. if ($pluginRecords.get()[plugin.id]?.kind === 'bundled') { console.info(`[plugins] ${origin} skipped — "${plugin.id}" already ships bundled with the app`) publishPlugin({ id: `${plugin.id}:disk-shadowed`, name: `${plugin.name ?? plugin.id} (stale disk copy)`, description: `Shadowed by the bundled "${plugin.id}" plugin — this folder is no longer used and can be deleted.`, kind: options.kind ?? 'disk', file: options.file, status: 'disabled' }) return null } const record = { id: plugin.id, name: plugin.name ?? plugin.id, description: plugin.description, kind: options.kind ?? 'disk', file: options.file, packageName: options.packageName, packageOrigin: options.packageOrigin } const activate = () => { // Reload = dispose the previous incarnation, then register fresh. unloadRuntimePlugin(plugin.id) const disposers: (() => void)[] = [] plugin.register(createPluginContext(plugin.id, dispose => disposers.push(dispose))) loaded.set(plugin.id, disposers) publishPlugin({ ...record, status: 'loaded' }) } publishPlugin({ ...record, status: 'disabled' }, { activate, deactivate: () => unloadRuntimePlugin(plugin.id) }) // A disabled plugin still inventories (settings shows it, toggle // reactivates via the handle above) — it just never registers. A root-level // `defaultEnabled: false` caps the plugin's own default: the user's explicit // enable still wins, a plugin can't self-enable past its root's posture. if (pluginActive(plugin.id, (plugin.defaultEnabled ?? true) && (options.defaultEnabled ?? true))) { activate() } return plugin.id } catch (error) { console.error(`[plugins] runtime load failed (${origin})`, error) notifyError(error, `Plugin "${origin}" failed to load`) publishPlugin({ id: origin, name: origin, kind: options.kind ?? 'disk', file: options.file, packageName: options.packageName, packageOrigin: options.packageOrigin, status: 'error', error: error instanceof Error ? error.message : String(error) }) return null } } // --------------------------------------------------------------------------- // The on-disk plugin door — ONE app-level root, `/desktop-plugins/`: // - `/plugin.js` — a standalone desktop plugin (agent- or user-written); // - `/plugin.js` + `.hermes-package.json` — the desktop HALF of a // unified agent+desktop package, COPIED here by Electron from the package's // `plugins//desktop/` folder (electron/desktop-plugins-root.ts). // The agent half stays in its profile; the desktop half lives with the app, // so it neither appears nor disappears when the active profile changes. // SELF-MAINTAINING — no reload ceremony: // - each plugin.js is fs-watched (the preview watcher IPC, debounced in // main): saving the file hot-reloads the plugin in place; // - each root is fs-watched too (watchDirectory IPC), so new folders load + // removed ones unload on the change tick; older Electron shells without // watchDirectory fall back to the slow visible-tab poll. // Panes land via placement adoption and STAY where the user drags them — // the tree treats not-yet-loaded pane ids as hidden, so boot and reload are // collapse -> appear, never a placeholder flash. // --------------------------------------------------------------------------- const DISK_POLL_MS = 5_000 interface DiskRoot { dir: string /** Path segments below each scanned folder to the entry file. */ entrySegments: readonly string[] } /** The app-level root, resolved fresh each pass (Electron-local, never the * backend's hermes_home — #66899). Resolving it also runs Electron's * reconcile, so unified packages' desktop halves are current before we scan. */ async function diskRoots(): Promise { const root = await window.hermesDesktop?.desktopPluginsRoot?.() return root ? [{ dir: root, entrySegments: ['plugin.js'] }] : [] } /** Marker Electron writes beside a materialized unified-package half. Its * presence means: opt-in posture (the Python half is installed-but-inert * until allowlisted — GHSA-mcfc-hp25-cjv7 — so the desktop half matches), and * the record carries the package name so the Plugins page pairs it with the * agent row. */ const PACKAGE_MARKER = '.hermes-package.json' interface PackageMarker { origin?: { catalogName?: string; repo?: string; sha?: string } package: string } async function readPackageMarker(desktop: Window['hermesDesktop'], folder: string): Promise { try { const { entries } = await desktop.readDir(folder) const marker = entries.find(entry => entry.name === PACKAGE_MARKER && !entry.isDirectory) if (!marker) { return null } const parsed = JSON.parse((await desktop.readFileText(marker.path)).text) as { catalogName?: string package?: string repo?: string sha?: string } if (!parsed.package) { return null } return { origin: parsed.repo ? { catalogName: parsed.catalogName, repo: parsed.repo, sha: parsed.sha } : undefined, package: parsed.package } } catch { return null } } interface DiskPlugin { /** Root posture, forwarded on every (re)load of this entry. */ defaultEnabled?: boolean file: string /** Agent package this folder is the desktop half of (unified packages). */ packageName?: string packageOrigin?: PackageMarker['origin'] /** Loaded plugin id (null while broken — kept so a fixing save reloads). */ id: null | string /** Origin label (folder name) — the toast/inventory name for load errors. */ origin: string watchId: null | string } /** Live disk plugins keyed by ENTRY FILE path — unique across both roots * (folder names alone can collide between them). */ const disk = new Map() let watching = false let scanning = false /** Drop a folder-named error record — unless that name is the live plugin id * of ANOTHER disk entry (two roots can carry same-named folders; a broken one * must not clobber its healthy namesake's inventory row). */ function dropOriginRecord(origin: string, except: DiskPlugin): void { for (const other of disk.values()) { if (other !== except && other.id === origin) { return } } dropPlugin(origin) } /** A plugin source that could not be read in FULL. Evaluating a truncated * file is never acceptable — half a module can still parse. */ class PluginSourceOversizeError extends Error {} /** Read a plugin entry file in full. Prefers the dedicated readPluginSource * IPC (16 MiB cap, no truncation). Older shells predate it and only offer * the preview read, which silently truncates at 512 KiB — there the read * fails loudly instead of handing a partial file to the evaluator. */ async function readPluginSourceText(file: string): Promise { const desktop = window.hermesDesktop! if (desktop.readPluginSource) { return (await desktop.readPluginSource(file)).text } const result = await desktop.readFileText(file) if (result.truncated) { throw new PluginSourceOversizeError( "plugin.js exceeds this shell's 512 KiB read limit — update Hermes Desktop to load larger plugins" ) } return result.text } /** Returns false when the entry file could not be read (vanished mid-read) so * the caller can reconcile/unload the registration instead of retaining a * live ghost for a missing entry. */ async function loadDiskPlugin(entry: DiskPlugin): Promise { const prevId = entry.id try { const text = await readPluginSourceText(entry.file) const id = await loadRuntimePlugin(text, entry.origin, { defaultEnabled: entry.defaultEnabled, file: entry.file, packageName: entry.packageName, packageOrigin: entry.packageOrigin }) // A hot-edit that changes `plugin.id`: loadRuntimePlugin only disposes the // NEW id, so unload the previous incarnation here or its contributions + // inventory row orphan. if (id && prevId && prevId !== id) { unloadRuntimePlugin(prevId) dropPlugin(prevId) } entry.id = id ?? entry.id // A fixing save under a different plugin id — drop the folder-named // error record so the inventory shows one row, not a ghost. if (id && id !== entry.origin) { dropOriginRecord(entry.origin, entry) } return true } catch (error) { // An oversize source is a REAL failure the user must see (the silent // shape was the bug: a truncated file evaluated as a syntax error, or // worse, as half a plugin). It is a completed read of an existing file, // so report it and keep the registration (true) — everything else is a // file vanishing mid-read, where false lets the caller reconcile/unload. if (error instanceof PluginSourceOversizeError) { console.error(`[plugins] ${entry.origin}: ${error.message}`) notifyError(error, `Plugin "${entry.origin}" failed to load`) publishPlugin({ id: entry.origin, name: entry.origin, kind: 'disk', file: entry.file, status: 'error', error: error.message }) return true } return false } } async function resolveDiskPluginEntry( desktop: Window['hermesDesktop'], folderPath: string, segments: readonly string[] ): Promise { let currentDir = folderPath for (let index = 0; index < segments.length; index += 1) { const { entries } = await desktop.readDir(currentDir) const entry = entries.find(candidate => candidate.name === segments[index]) if (!entry) { return null } const last = index === segments.length - 1 if (last) { return entry.isDirectory ? null : entry.path } if (!entry.isDirectory) { return null } currentDir = entry.path } return null } async function scanDiskPlugins(): Promise { const desktop = window.hermesDesktop // Re-entrancy guard: the 5s poll must not overlap a slow in-flight scan // (reads/loads can exceed the interval). if (!desktop || scanning) { return } scanning = true try { const roots = await diskRoots() if (roots.length === 0) { return } const seen = new Set() for (const root of roots) { let entries try { ;({ entries } = await desktop.readDir(root.dir)) } catch { continue // Root missing (no plugins yet) — the poll/watch reconciles. } for (const dir of entries.filter(e => e.isDirectory)) { let file: string | null try { file = await resolveDiskPluginEntry(desktop, dir.path, root.entrySegments) } catch { continue // Folder changed during the metadata walk; the next tick reconciles. } if (!file) { continue // Ordinary agent package with no Desktop half — not an error. } seen.add(file) if (disk.has(file)) { continue } const marker = await readPackageMarker(desktop, dir.path) const record: DiskPlugin = { // A unified package's desktop half ships opt-in, like its agent half. defaultEnabled: marker ? false : undefined, file, id: null, origin: dir.name, packageName: marker?.package, packageOrigin: marker?.origin, watchId: null } disk.set(file, record) if (!(await loadDiskPlugin(record))) { disk.delete(file) continue } try { record.watchId = (await desktop.watchPreviewFile(file)).id } catch { // Unwatchable — the poll still reconciles new folders; edits need a // manual "Reload desktop plugins". } } } // Folder deleted -> plugin gone, cleanly (inventory row included). for (const [file, record] of disk) { if (seen.has(file)) { continue } if (record.id) { unloadRuntimePlugin(record.id) dropPlugin(record.id) } dropOriginRecord(record.origin, record) if (record.watchId) { void desktop.stopPreviewFileWatch(record.watchId) } disk.delete(file) } } catch { // No plugin roots (or no gateway yet) — nothing to reconcile. } finally { scanning = false } } /** Manual rescan (the ⌘K "Reload desktop plugins" fallback). */ export const discoverRuntimePlugins = scanDiskPlugins /** Start the self-maintaining disk door: initial scan, per-file hot reload, * fs-watched folder reconciliation (poll fallback on older shells). Idempotent. */ export function watchRuntimePlugins(): void { const desktop = window.hermesDesktop if (watching || !desktop) { return } watching = true const dirWatchIds = new Set() const watchedDirs = new Set() desktop.onPreviewFileChanged(({ id }) => { // Directory tick: a plugin folder appeared or vanished — reconcile. if (dirWatchIds.has(id)) { void scanDiskPlugins() return } for (const record of disk.values()) { if (record.watchId === id) { void loadDiskPlugin(record).then(readable => { if (!readable) { void scanDiskPlugins() } }) return } } }) // True only when EVERY root is fs-watched — a partially watched set keeps // the poll alive so unwatched roots still reconcile new/removed folders. const startDirWatches = async (): Promise => { if (!desktop.watchDirectory) { return false } const roots = await diskRoots() if (roots.length === 0) { return false } let all = true for (const root of roots) { if (watchedDirs.has(root.dir)) { continue } try { dirWatchIds.add((await desktop.watchDirectory(root.dir)).id) watchedDirs.add(root.dir) } catch { // Dir missing or unwatchable — the poll covers it and retries here. all = false } } return all } void scanDiskPlugins() void startDirWatches().then(watched => { if (watched) { return } const timer = window.setInterval(() => { if (document.visibilityState !== 'visible') { return } void scanDiskPlugins() // A root may have appeared since — upgrade to the watches and retire // this poll once every root is covered. void startDirWatches().then(upgraded => { if (upgraded) { window.clearInterval(timer) } }) }, DISK_POLL_MS) }) }