From a55d0c3e3a2de364a9f8cb8eed3d6d0329aeb329 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 5 Oct 2026 20:11:19 -0700 Subject: [PATCH 1/2] fix(executor): unwrap boxed primitives by internal slot when checking JSON serializability --- .../execution/snapshot-serializer.test.ts | 9 +++++++ .../executor/execution/snapshot-serializer.ts | 8 +++--- apps/sim/lib/core/utils/boxed-primitives.ts | 26 +++++++++++++++++++ .../lib/logs/execution/json-byte-size.test.ts | 8 ++++++ apps/sim/lib/logs/execution/json-byte-size.ts | 7 ++--- 5 files changed, 48 insertions(+), 10 deletions(-) create mode 100644 apps/sim/lib/core/utils/boxed-primitives.ts diff --git a/apps/sim/executor/execution/snapshot-serializer.test.ts b/apps/sim/executor/execution/snapshot-serializer.test.ts index b2482ed1eff..2d9997894e3 100644 --- a/apps/sim/executor/execution/snapshot-serializer.test.ts +++ b/apps/sim/executor/execution/snapshot-serializer.test.ts @@ -352,6 +352,7 @@ describe('buildCompletedExecutionState', () => { 'a boxed number carrying a BigInt property', { value: Object.assign(Object(1), { big: BigInt(1) }) }, ], + ['an object that only inherits from BigInt', { value: Object.create(BigInt.prototype) }], ])('serializes like the JSON-cloned pause state for %s', (_name, output) => { const context = contextWithOutput(output) expect(JSON.stringify(buildCompletedExecutionState(context))).toBe( @@ -359,9 +360,17 @@ describe('buildCompletedExecutionState', () => { ) }) + const numberLookalike = Object.create(Number.prototype) + numberLookalike.self = numberLookalike + it.each([ ['a cycle', cyclic], ['a BigInt', { big: BigInt(1) }], + ['a cycle in an object that only inherits from Number', { value: numberLookalike }], + [ + 'a BigInt wrapper whose prototype was swapped', + { value: Object.setPrototypeOf(Object(BigInt(1)), {}) }, + ], ])('throws like the pause snapshot for %s', (_name, output) => { const context = contextWithOutput(output) expect(() => jsonClonedState(context)).toThrow(TypeError) diff --git a/apps/sim/executor/execution/snapshot-serializer.ts b/apps/sim/executor/execution/snapshot-serializer.ts index ec58b61adf5..f49c25c561e 100644 --- a/apps/sim/executor/execution/snapshot-serializer.ts +++ b/apps/sim/executor/execution/snapshot-serializer.ts @@ -1,3 +1,4 @@ +import { unboxJsonPrimitive } from '@/lib/core/utils/boxed-primitives' import { LARGE_VALUE_THRESHOLD_BYTES } from '@/lib/execution/payloads/large-value-ref' import type { DAG } from '@/executor/dag/builder' import type { EdgeManager } from '@/executor/execution/edge-manager' @@ -353,14 +354,11 @@ function assertJsonSerializable(value: unknown): void { const toJSON = (current as { toJSON?: unknown }).toJSON if (typeof toJSON === 'function') current = toJSON.call(current, key) } - if (typeof current === 'bigint' || current instanceof BigInt) { + if (typeof current === 'object' && current !== null) current = unboxJsonPrimitive(current) + if (typeof current === 'bigint') { throw new TypeError('Do not know how to serialize a BigInt') } if (typeof current !== 'object' || current === null) return - // Serialized as their primitive value; their own properties are never read. - if (current instanceof Number || current instanceof String || current instanceof Boolean) { - return - } if (ancestors.has(current)) { throw new TypeError('Converting circular structure to JSON') } diff --git a/apps/sim/lib/core/utils/boxed-primitives.ts b/apps/sim/lib/core/utils/boxed-primitives.ts new file mode 100644 index 00000000000..31f7df95267 --- /dev/null +++ b/apps/sim/lib/core/utils/boxed-primitives.ts @@ -0,0 +1,26 @@ +import { types } from 'node:util' + +/** + * Unwraps a primitive wrapper the way `JSON.stringify` does: by its internal + * slot, not its prototype, so an object that merely inherits from + * `Number.prototype` stays an object and a wrapper with a swapped prototype is + * still unwrapped. Number and String wrappers convert through `Number()` and + * `String()` (running any user `valueOf`/`toString`, like ToNumber/ToString); + * Boolean and BigInt wrappers read their stored value. Anything else is + * returned unchanged. + * + * Arrays and objects whose prototype is `Object.prototype` skip the slot check: + * they are nearly every node a walk visits, and only a wrapper whose prototype + * was explicitly reset to `Object.prototype` could be missed. + */ +export function unboxJsonPrimitive(value: unknown): unknown { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return value + if (Object.getPrototypeOf(value) === Object.prototype || !types.isBoxedPrimitive(value)) { + return value + } + if (types.isNumberObject(value)) return Number(value) + if (types.isStringObject(value)) return String(value) + if (types.isBooleanObject(value)) return Boolean.prototype.valueOf.call(value) + if (types.isBigIntObject(value)) return BigInt.prototype.valueOf.call(value) + return value +} diff --git a/apps/sim/lib/logs/execution/json-byte-size.test.ts b/apps/sim/lib/logs/execution/json-byte-size.test.ts index b696328ae94..89d84a10c56 100644 --- a/apps/sim/lib/logs/execution/json-byte-size.test.ts +++ b/apps/sim/lib/logs/execution/json-byte-size.test.ts @@ -40,6 +40,14 @@ describe('getJsonByteSize', () => { }, ], ['a function with toJSON', { fn: Object.assign(() => 1, { toJSON: () => 'serialized' }) }], + [ + 'objects that only inherit from wrapper prototypes', + { + n: Object.assign(Object.create(Number.prototype), { a: 1 }), + s: Object.assign(Object.create(String.prototype), { b: 'x' }), + }, + ], + ['a Number wrapper whose prototype was swapped', { n: Object.setPrototypeOf(Object(42), {}) }], ['escapes, multi-byte text, and lone surrogates', { 'k"\\': 'a\n\u0001é漢😀\ud800' }], ])('matches JSON.stringify for %s', (_name, payload) => { expect(getJsonByteSize(payload, LIMIT)).toBe(jsonBytes(payload)) diff --git a/apps/sim/lib/logs/execution/json-byte-size.ts b/apps/sim/lib/logs/execution/json-byte-size.ts index f7d3f866fc0..b602db1820f 100644 --- a/apps/sim/lib/logs/execution/json-byte-size.ts +++ b/apps/sim/lib/logs/execution/json-byte-size.ts @@ -1,5 +1,6 @@ import { getErrorMessage } from '@sim/utils/errors' import { quotedStringBytes } from '@/lib/core/utils/bounded-json' +import { unboxJsonPrimitive } from '@/lib/core/utils/boxed-primitives' /** * Byte length of `JSON.stringify(value)`, measured without building the string. @@ -35,11 +36,7 @@ export function getJsonByteSize(value: unknown, maxBytes: number): number | unde ? (raw as { toJSON?: unknown }).toJSON : undefined const value = typeof toJSON === 'function' ? toJSON.call(raw, key) : raw - if (value instanceof Number) return Number(value) - if (value instanceof String) return String(value) - if (value instanceof Boolean) return Boolean.prototype.valueOf.call(value) - if (value instanceof BigInt) return BigInt.prototype.valueOf.call(value) - return value + return typeof value === 'object' && value !== null ? unboxJsonPrimitive(value) : value } const isOmitted = (item: unknown): boolean => From 9d8378aebbcae3c976ab4b9bda5178656aebab66 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 5 Oct 2026 20:18:01 -0700 Subject: [PATCH 2/2] fix(executor): check the boxed slot first and convert wrappers with ToNumber/ToString --- .../execution/snapshot-serializer.test.ts | 21 ++++++++++++++++++ apps/sim/lib/core/utils/boxed-primitives.ts | 22 ++++++++----------- .../lib/logs/execution/json-byte-size.test.ts | 17 ++++++++++++++ 3 files changed, 47 insertions(+), 13 deletions(-) diff --git a/apps/sim/executor/execution/snapshot-serializer.test.ts b/apps/sim/executor/execution/snapshot-serializer.test.ts index 2d9997894e3..3fb1451d70b 100644 --- a/apps/sim/executor/execution/snapshot-serializer.test.ts +++ b/apps/sim/executor/execution/snapshot-serializer.test.ts @@ -353,6 +353,19 @@ describe('buildCompletedExecutionState', () => { { value: Object.assign(Object(1), { big: BigInt(1) }) }, ], ['an object that only inherits from BigInt', { value: Object.create(BigInt.prototype) }], + [ + 'a proxy whose getPrototypeOf trap throws', + { + value: new Proxy( + { a: 1 }, + { + getPrototypeOf: () => { + throw new Error('JSON.stringify never asks for the prototype') + }, + } + ), + }, + ], ])('serializes like the JSON-cloned pause state for %s', (_name, output) => { const context = contextWithOutput(output) expect(JSON.stringify(buildCompletedExecutionState(context))).toBe( @@ -371,6 +384,14 @@ describe('buildCompletedExecutionState', () => { 'a BigInt wrapper whose prototype was swapped', { value: Object.setPrototypeOf(Object(BigInt(1)), {}) }, ], + [ + 'a BigInt wrapper whose prototype was reset to Object.prototype', + { value: Object.setPrototypeOf(Object(BigInt(1)), Object.prototype) }, + ], + [ + 'a Number wrapper that converts to a BigInt', + { value: Object.assign(Object(1), { [Symbol.toPrimitive]: () => BigInt(1) }) }, + ], ])('throws like the pause snapshot for %s', (_name, output) => { const context = contextWithOutput(output) expect(() => jsonClonedState(context)).toThrow(TypeError) diff --git a/apps/sim/lib/core/utils/boxed-primitives.ts b/apps/sim/lib/core/utils/boxed-primitives.ts index 31f7df95267..f7088e59f9f 100644 --- a/apps/sim/lib/core/utils/boxed-primitives.ts +++ b/apps/sim/lib/core/utils/boxed-primitives.ts @@ -3,23 +3,19 @@ import { types } from 'node:util' /** * Unwraps a primitive wrapper the way `JSON.stringify` does: by its internal * slot, not its prototype, so an object that merely inherits from - * `Number.prototype` stays an object and a wrapper with a swapped prototype is - * still unwrapped. Number and String wrappers convert through `Number()` and - * `String()` (running any user `valueOf`/`toString`, like ToNumber/ToString); - * Boolean and BigInt wrappers read their stored value. Anything else is - * returned unchanged. - * - * Arrays and objects whose prototype is `Object.prototype` skip the slot check: - * they are nearly every node a walk visits, and only a wrapper whose prototype - * was explicitly reset to `Object.prototype` could be missed. + * `Number.prototype` stays an object and a wrapper with a replaced prototype is + * still unwrapped. Number and String wrappers convert through ToNumber and + * ToString (unary `+` and a template literal, which run any user + * `valueOf`/`toString` and throw where JSON would, e.g. on a BigInt or Symbol + * result); Boolean and BigInt wrappers read their stored value. The slot check + * runs no Proxy traps. Anything else is returned unchanged. */ export function unboxJsonPrimitive(value: unknown): unknown { - if (typeof value !== 'object' || value === null || Array.isArray(value)) return value - if (Object.getPrototypeOf(value) === Object.prototype || !types.isBoxedPrimitive(value)) { + if (typeof value !== 'object' || value === null || !types.isBoxedPrimitive(value)) { return value } - if (types.isNumberObject(value)) return Number(value) - if (types.isStringObject(value)) return String(value) + if (types.isNumberObject(value)) return +value + if (types.isStringObject(value)) return `${value}` if (types.isBooleanObject(value)) return Boolean.prototype.valueOf.call(value) if (types.isBigIntObject(value)) return BigInt.prototype.valueOf.call(value) return value diff --git a/apps/sim/lib/logs/execution/json-byte-size.test.ts b/apps/sim/lib/logs/execution/json-byte-size.test.ts index 89d84a10c56..c9f00da1749 100644 --- a/apps/sim/lib/logs/execution/json-byte-size.test.ts +++ b/apps/sim/lib/logs/execution/json-byte-size.test.ts @@ -48,6 +48,23 @@ describe('getJsonByteSize', () => { }, ], ['a Number wrapper whose prototype was swapped', { n: Object.setPrototypeOf(Object(42), {}) }], + [ + 'a Number wrapper whose prototype was reset to Object.prototype', + { n: Object.setPrototypeOf(Object(7), Object.prototype) }, + ], + [ + 'a proxy whose getPrototypeOf trap throws', + { + p: new Proxy( + { a: 1 }, + { + getPrototypeOf: () => { + throw new Error('JSON.stringify never asks for the prototype') + }, + } + ), + }, + ], ['escapes, multi-byte text, and lone surrogates', { 'k"\\': 'a\n\u0001é漢😀\ud800' }], ])('matches JSON.stringify for %s', (_name, payload) => { expect(getJsonByteSize(payload, LIMIT)).toBe(jsonBytes(payload))