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:
@@ -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')
|
||||
})
|
||||
Reference in New Issue
Block a user