From 961635c19ca960793bd513b6024f33efb5aeeffb Mon Sep 17 00:00:00 2001 From: 686f6c61 Date: Tue, 25 Aug 2026 21:34:08 +0200 Subject: [PATCH] fix(desktop): copy unsafe RPC rejections instead of mutating name asRpcError now always wraps a non-string name in a fresh Error. In-place assignment was a silent no-op on sealed objects in sloppy mode. Catch only host.request / requestProfile so routing TypeErrors keep their stack. --- .../desktop/src/plugins/hermes-bots/plugin.js | 45 ++++++++----------- .../tests/routines-rpc-error.test.mjs | 14 ++++++ 2 files changed, 32 insertions(+), 27 deletions(-) diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index 4628d59bd7..2cf1239c30 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -5301,22 +5301,26 @@ 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 = {}) { - try { - const route = botConnectionRoute(bot) + const route = botConnectionRoute(bot) - 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)) + if (route) { + if (typeof host.requestProfile !== 'function') { + throw new Error(`Cannot route ${method} for ${route.connectionId}::${route.profile}`) } + try { + return await host.requestProfile(route, method, scopedBotParams(route, 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`) + } + } + + try { 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`) } } @@ -5331,8 +5335,9 @@ async function requestForBot(bot, method, params = {}) { 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 }`. + // Error). React 19 only needs a string `name`. Never mutate the rejection: + // frozen/sealed objects make `name = 'Error'` a silent no-op in sloppy + // mode, so a non-string name always becomes a fresh Error with cause. const isObject = value != null && typeof value === 'object' const name = isObject ? value.name : undefined const message = isObject ? value.message : undefined @@ -5344,20 +5349,6 @@ function asRpcError(value, fallback) { return value } - 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) 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 index 80a667befd..c334eff125 100644 --- 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 @@ -81,6 +81,20 @@ test('regression: a frozen non-string Error name is copied, not mutated in place assert.match(coerced.message, /down/) }) +test('regression: a sealed Error with a numeric name is copied, not mutated', () => { + const weird = new Error('sealed') + Object.defineProperty(weird, 'name', { value: 32000, configurable: true, writable: true }) + Object.seal(weird) + const coerced = load(async () => {}).__routines.asRpcError(weird, 'fallback') + assert.notEqual(coerced, weird) + assert.equal(typeof coerced.name, 'string') + assert.doesNotThrow(() => react19Format(coerced)) + assert.match(String(coerced.message), /sealed/) + // Assignment would have succeeded on a sealed writable property; we still + // copy so React 19 never sees a numeric name even if mutation is possible. + assert.equal(weird.name, 32000) +}) + test('regression: a real Error passes through unchanged', () => { const original = new Error('gateway rejected the pause') const coerced = load(async () => {}).__routines.asRpcError(original, 'fallback')