From 7c48dfba79fb798edcb04d18641f9898a7079e9e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 13 Aug 2026 02:16:17 -0500 Subject: [PATCH] feat(desktop): ship unified desktop halves opt-in; guard cross-root inventory rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unified agent-plugin root now loads its desktop halves disabled by default — inventoried in Settings → Plugins, off until the user toggles — so ~/.hermes/plugins keeps its installed-but-inert posture (GHSA-mcfc-hp25-cjv7) on the desktop side too. The root-level cap only lowers a plugin's own defaultEnabled; an explicit user enable still wins. Also guards the folder-named error-record drop: with two roots, a broken plugin folder can share its name with a healthy plugin's id from the other root, and the unconditional drop clobbered the healthy inventory row. --- .../src/contrib/runtime-loader.test.ts | 60 +++++++++++++++++++ apps/desktop/src/contrib/runtime-loader.ts | 53 +++++++++++++--- 2 files changed, 106 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/contrib/runtime-loader.test.ts b/apps/desktop/src/contrib/runtime-loader.test.ts index 7c89a52640b83..17a0661c24e1f 100644 --- a/apps/desktop/src/contrib/runtime-loader.test.ts +++ b/apps/desktop/src/contrib/runtime-loader.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { HermesReadDirResult } from '@/global' import type * as HermesModule from '@/hermes' +import { $pluginRecords, setPluginEnabled } from './plugins-store' import { discoverRuntimePlugins, watchRuntimePlugins } from './runtime-loader' // getStatus would supply the connected backend's hermes_home — a REMOTE path in @@ -99,6 +100,65 @@ describe('scanDiskPlugins (#66899)', () => { expect(readDir).toHaveBeenCalledWith('/local/.hermes/desktop-plugins') expect(readDir).toHaveBeenCalledTimes(1) }) + + it('loads a unified desktop half OPT-IN: inventoried but not activated by default', async () => { + desktopPluginsRoot.mockResolvedValue('/local/.hermes/desktop-plugins') + agentPluginsRoot.mockResolvedValue('/local/.hermes/plugins') + readDir.mockImplementation(async dir => + dir === '/local/.hermes/plugins' + ? { entries: [{ isDirectory: true, name: 'uni', path: '/local/.hermes/plugins/uni' }] } + : { entries: [] } + ) + + const register = vi.fn() + + ;(globalThis as unknown as { __uniRegister: unknown }).__uniRegister = register + readFileText.mockResolvedValue({ + text: 'export default { id: "uni", register: globalThis.__uniRegister }' + }) + watchPreviewFile.mockResolvedValue({ id: 'w-uni' }) + + // The loader evaluates plugins via blob-URL import(), which vite's module + // runner can't resolve in tests — reroute to a data: URL, which node's + // native ESM loader handles. + const createObjectURL = vi + .spyOn(URL, 'createObjectURL') + .mockImplementation( + blob => + `data:text/javascript;base64,${Buffer.from((blob as unknown as { parts: string[] }).parts.join('')).toString('base64')}` + ) + + const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined) + const RealBlob = globalThis.Blob + vi.stubGlobal( + 'Blob', + class { + parts: string[] + constructor(parts: string[]) { + this.parts = parts + } + } + ) + + try { + await discoverRuntimePlugins() + + // Inventoried for Settings → Plugins, but the root's opt-in posture wins: + // ~/.hermes/plugins stays installed-but-inert until the user toggles it. + expect($pluginRecords.get().uni).toMatchObject({ kind: 'disk', status: 'disabled' }) + expect(register).not.toHaveBeenCalled() + + // The user's explicit enable still activates it. + await setPluginEnabled('uni', true) + expect(register).toHaveBeenCalledTimes(1) + expect($pluginRecords.get().uni.status).toBe('loaded') + } finally { + createObjectURL.mockRestore() + revokeObjectURL.mockRestore() + vi.stubGlobal('Blob', RealBlob) + delete (globalThis as unknown as { __uniRegister?: unknown }).__uniRegister + } + }) }) describe('watchRuntimePlugins dir watch (#66899)', () => { diff --git a/apps/desktop/src/contrib/runtime-loader.ts b/apps/desktop/src/contrib/runtime-loader.ts index 4680148a2eca6..c97589d263e36 100644 --- a/apps/desktop/src/contrib/runtime-loader.ts +++ b/apps/desktop/src/contrib/runtime-loader.ts @@ -35,6 +35,11 @@ import { createPluginContext, type HermesPlugin } from './plugin' import { 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. */ @@ -157,8 +162,10 @@ export async function loadRuntimePlugin( 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. - if (pluginActive(plugin.id, plugin.defaultEnabled ?? true)) { + // 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() } @@ -201,6 +208,8 @@ export async function loadRuntimePlugin( const DISK_POLL_MS = 5_000 interface DiskRoot { + /** Root-level enable posture, forwarded to the loader (see LoadOptions). */ + defaultEnabled?: boolean dir: string /** Resolve a scanned folder to its candidate plugin entry file. */ entry: (folderPath: string) => string @@ -226,13 +235,19 @@ async function diskRoots(): Promise { const unified = await desktop.agentPluginsRoot?.() if (unified) { - roots.push({ dir: unified, entry: folder => `${folder}/desktop/plugin.js` }) + // Opt-in by default: `~/.hermes/plugins` is installed-but-inert until the + // user allowlists the Python half (plugins.enabled), so the desktop half + // matches that posture — inventoried in Settings → Plugins, off until + // toggled. The standalone desktop-plugins door keeps its default-on trust. + roots.push({ defaultEnabled: false, dir: unified, entry: folder => `${folder}/desktop/plugin.js` }) } return roots } interface DiskPlugin { + /** Root posture, forwarded on every (re)load of this entry. */ + defaultEnabled?: boolean file: string /** Loaded plugin id (null while broken — kept so a fixing save reloads). */ id: null | string @@ -247,13 +262,30 @@ 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) +} + async function loadDiskPlugin(entry: DiskPlugin): Promise { const desktop = window.hermesDesktop! const prevId = entry.id try { const { text } = await desktop.readFileText(entry.file) - const id = await loadRuntimePlugin(text, entry.origin, { file: entry.file }) + + const id = await loadRuntimePlugin(text, entry.origin, { + defaultEnabled: entry.defaultEnabled, + file: entry.file + }) // A hot-edit that changes `plugin.id`: loadRuntimePlugin only disposes the // NEW id, so unload the previous incarnation here or its contributions + @@ -268,7 +300,7 @@ async function loadDiskPlugin(entry: DiskPlugin): Promise { // 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) { - dropPlugin(entry.origin) + dropOriginRecord(entry.origin, entry) } } catch { // File vanished mid-read — the next scan reconciles. @@ -318,7 +350,14 @@ async function scanDiskPlugins(): Promise { continue // No entry file (yet) — not a plugin folder for this root. } - const record: DiskPlugin = { file, id: null, origin: dir.name, watchId: null } + const record: DiskPlugin = { + defaultEnabled: root.defaultEnabled, + file, + id: null, + origin: dir.name, + watchId: null + } + disk.set(file, record) await loadDiskPlugin(record) @@ -342,7 +381,7 @@ async function scanDiskPlugins(): Promise { dropPlugin(record.id) } - dropPlugin(record.origin) + dropOriginRecord(record.origin, record) if (record.watchId) { void desktop.stopPreviewFileWatch(record.watchId)