feat(desktop): ship unified desktop halves opt-in; guard cross-root inventory rows

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.
This commit is contained in:
Brooklyn Nicholson 2026-08-13 02:16:17 -05:00 committed by brooklyn!
parent 01773dd733
commit 7c48dfba79
2 changed files with 106 additions and 7 deletions

View File

@ -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)', () => {

View File

@ -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-<base64>` — 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<DiskRoot[]> {
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<string, DiskPlugin>()
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<void> {
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<void> {
// 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<void> {
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<void> {
dropPlugin(record.id)
}
dropPlugin(record.origin)
dropOriginRecord(record.origin, record)
if (record.watchId) {
void desktop.stopPreviewFileWatch(record.watchId)