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.
This commit is contained in:
686f6c61
2026-08-25 21:34:08 +02:00
committed by Teknium
parent a837c7aaa2
commit 961635c19c
2 changed files with 32 additions and 27 deletions
+18 -27
View File
@@ -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)
@@ -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')