diff --git a/apps/sim/executor/execution/snapshot-serializer.test.ts b/apps/sim/executor/execution/snapshot-serializer.test.ts index b2482ed1eff..3fb1451d70b 100644 --- a/apps/sim/executor/execution/snapshot-serializer.test.ts +++ b/apps/sim/executor/execution/snapshot-serializer.test.ts @@ -352,6 +352,20 @@ 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) }], + [ + '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( @@ -359,9 +373,25 @@ 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)), {}) }, + ], + [ + '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/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..f7088e59f9f --- /dev/null +++ b/apps/sim/lib/core/utils/boxed-primitives.ts @@ -0,0 +1,22 @@ +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 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 || !types.isBoxedPrimitive(value)) { + return 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 b696328ae94..c9f00da1749 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,31 @@ 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), {}) }], + [ + '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)) 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 =>