From a837c7aaa236d69087434cb65ef271b6a6bcf4d6 Mon Sep 17 00:00:00 2001 From: 686f6c61 Date: Tue, 25 Aug 2026 11:43:57 +0200 Subject: [PATCH] fix(desktop): coerce bot RPC rejections for React 19 error formatting JSON-RPC/IPC can reject with a plain object whose name is a number. React 19 then crashes on (error.name || '').trim, which takes down the Routines pane instead of showing the cron.manage failure. requestForBot now wraps those values in an Error with a string name, including cross-realm Error-like objects from the plugin test vm. --- .../desktop/src/plugins/hermes-bots/plugin.js | 66 ++++++++++++-- .../tests/routines-rpc-error.test.mjs | 89 +++++++++++++++++++ 2 files changed, 149 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/plugins/hermes-bots/tests/routines-rpc-error.test.mjs diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index 3299bccd42..4628d59bd7 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -5301,17 +5301,71 @@ function botBackendProfileScope(route, fallbackProfile = 'default') { /** Gateway RPC on the bot's OWN source. Source-scoped rows always use the * explicit descriptor, including a registered local source. */ async function requestForBot(bot, method, params = {}) { - const route = botConnectionRoute(bot) + try { + const route = botConnectionRoute(bot) - if (route) { - if (typeof host.requestProfile !== 'function') { - throw new Error(`Cannot route ${method} for ${route.connectionId}::${route.profile}`) + if (route) { + if (typeof host.requestProfile !== 'function') { + throw new Error(`Cannot route ${method} for ${route.connectionId}::${route.profile}`) + } + + return await host.requestProfile(route, method, scopedBotParams(route, method, params)) } - return host.requestProfile(route, method, scopedBotParams(route, method, params)) + return await host.request(method, params) + } catch (error) { + // React 19 formats query errors with `(error.name || '').trim()`. IPC / + // JSON-RPC rejections are often plain objects whose `name` is a number, + // which crashes the Routines pane and hides the original failure (#94471). + throw asRpcError(error, `Gateway request ${method} failed`) + } +} + +/** Coerce an IPC/JSON-RPC rejection into an Error with a string `name`. + * + * React Query stores whatever the queryFn throws. React 19 then formats it + * with `(e.name || '').trim()`, which throws TypeError when `name` is a + * number (JSON-RPC codes) or another non-string — the Routines pane crash + * in #94471. Real Error instances are returned as-is when already safe. + */ +function asRpcError(value, fallback) { + // Duck-type across realms (plugin tests run the source in `vm`, and IPC + // can deliver Error-like objects whose prototype is not this realm's + // Error). React 19 only needs a string `name`; a stack marks a real + // exception vs a JSON-RPC payload like `{ name: 32000, message }`. + const isObject = value != null && typeof value === 'object' + const name = isObject ? value.name : undefined + const message = isObject ? value.message : undefined + const hasStringName = typeof name === 'string' + const hasStringMessage = typeof message === 'string' + const hasStack = isObject && typeof value.stack === 'string' + + if (isObject && hasStringName && (hasStack || hasStringMessage)) { + return value } - return host.request(method, params) + if (hasStack) { + try { + value.name = 'Error' + if (typeof value.name === 'string') { + return value + } + } catch { + void 0 + } + const copy = new Error(hasStringMessage && message ? String(message) : fallback) + copy.cause = value + return copy + } + + if (isObject) { + const text = hasStringMessage && String(message).trim() ? String(message) : fallback + const error = new Error(text) + error.cause = value + return error + } + + return new Error(value == null || value === '' ? fallback : String(value)) } /** Stable per-member identity inside a group room. Local members keep their diff --git a/apps/desktop/src/plugins/hermes-bots/tests/routines-rpc-error.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/routines-rpc-error.test.mjs new file mode 100644 index 0000000000..80a667befd --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/tests/routines-rpc-error.test.mjs @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import vm from 'node:vm' + +// loadRoutines -> requestForBot. A rejected JSON-RPC value that is not an +// Error (or an Error whose `name` is not a string) used to crash React 19's +// formatter: `(e.name || '').trim` is not a function. The Routines pane then +// died instead of showing "Could not load cronjobs" (#94471). + +const pluginSource = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8') + +function load(request, requestProfile) { + const values = new Map() + const atom = initial => { + const slot = { get: () => values.get(slot), set: value => values.set(slot, value) } + values.set(slot, initial) + return slot + } + const host = { request, state: { profile: { listen: () => undefined } } } + if (requestProfile) { + host.requestProfile = requestProfile + } + const context = { + atom, + PALETTE_AREA: 'palette', + COMPOSER_AREAS: { middleware: 'middleware' }, + document: { getElementById: () => null, createElement: () => ({}), head: { appendChild: () => undefined } }, + host + } + const source = pluginSource + .replace(/^import\s+\*\s+as\s+sdk\s+from '@hermes\/plugin-sdk'\r?\n/m, '') + .replace(/^import\s+\{[\s\S]*?\}\s+from '@hermes\/plugin-sdk'\r?\n/m, '') + .replace(/^const \{ McpTab, ToolsetConfigPanel \} = sdk\r?\n/m, '') + .replace(/^import .* from 'react'\r?\n/m, '') + .replace(/^import .* from 'react\/jsx-runtime'\r?\n/m, '') + .replace('export default {', 'globalThis.plugin = {') + .concat('\nglobalThis.__routines = { asRpcError, loadRoutines, requestForBot };\n') + vm.runInNewContext(source, context, { filename: 'plugin.js' }) + return context +} + +function react19Format(error) { + return `${(error.name || '').trim()}: ${error.message}` +} + +test('regression: a non-Error cron.manage rejection becomes a string-named Error', async () => { + const runtime = load(async () => { + const failure = { name: 32000, message: 'cron.manage failed', code: -32603 } + throw failure + }) + + await assert.rejects( + () => runtime.__routines.loadRoutines('research'), + error => { + assert.equal(typeof error.name, 'string') + assert.doesNotThrow(() => react19Format(error)) + assert.match(String(error.message), /cron.manage failed/) + assert.equal(error.cause.name, 32000) + return true + } + ) +}) + +test('regression: Error with a numeric name is coerced before React 19 format', () => { + const weird = new Error('down') + Object.defineProperty(weird, 'name', { value: 13, configurable: true }) + const coerced = load(async () => {}).__routines.asRpcError(weird, 'fallback') + assert.equal(typeof coerced.name, 'string') + assert.doesNotThrow(() => react19Format(coerced)) + assert.match(String(coerced.message), /down/) +}) + +test('regression: a frozen non-string Error name is copied, not mutated in place', () => { + const weird = new Error('down') + Object.defineProperty(weird, 'name', { value: 13, configurable: false, writable: false }) + const coerced = load(async () => {}).__routines.asRpcError(weird, 'fallback') + assert.notEqual(coerced, weird) + assert.equal(typeof coerced.name, 'string') + assert.doesNotThrow(() => react19Format(coerced)) + assert.match(coerced.message, /down/) +}) + +test('regression: a real Error passes through unchanged', () => { + const original = new Error('gateway rejected the pause') + const coerced = load(async () => {}).__routines.asRpcError(original, 'fallback') + assert.equal(coerced, original) + assert.equal(coerced.name, 'Error') +})