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.
This commit is contained in:
686f6c61
2026-08-25 11:43:57 +02:00
committed by Teknium
parent 4a0b2778c7
commit a837c7aaa2
2 changed files with 149 additions and 6 deletions
+60 -6
View File
@@ -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
@@ -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')
})