From 644aa53ff01886a242628ba34200d81ce1bf7a52 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:10:28 -0700 Subject: [PATCH 01/13] feat(core): widen retained-VM step inputs via per-boundary serialization pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retained boundaries now accept plain data (objects, arrays, primitives) and the standard built-ins (Map, Set, Date, typed arrays, ArrayBuffer) as step inputs, not just primitives. Rather than freezing value-type prototypes — which breaks legitimate polyfills like Temporal's Date.prototype.toTemporalInstant or core-js Set.prototype.union — the sandbox captures serialization pins at context creation: the exact members serialization executes for each supported type (iterators, Date getters, typed-array/ArrayBuffer getters, each cited to its serde/devalue source line), prototype and constructor back-ref identities, and own-accessor snapshots. Each suspension boundary re-verifies, per type, with descriptor-only reads: - new data-valued members (polyfill methods, statics) are inert and stay allowed - replaced executed members, new/changed accessors, swapped prototypes or constructor back-refs decline retention for values of that type only — a patched Date.prototype.toISOString demotes Date arguments while Maps, strings, and everything else keep retaining - constructor chains (instanceof dispatch, Instance-reducer statics) gate the boundary globally, since reducer predicates probe every non-primitive value The instrumentation coverage test wraps every configurable member on the relevant prototypes and asserts serialization executes nothing outside the pinned set, keeping the pin list coupled to serde. Part 3 of the retained-VM stack (#2990). --- .changeset/retained-input-walker.md | 6 + .../docs/v5/configuration/runtime-tuning.mdx | 2 +- packages/core/src/retained-vm-loop.test.ts | 85 ++ .../src/runtime/retained-step-input.test.ts | 580 +++++++++++++ .../core/src/runtime/retained-step-input.ts | 762 ++++++++++++++++++ .../core/src/runtime/suspension-handler.ts | 33 +- packages/core/src/workflow.ts | 6 +- 7 files changed, 1451 insertions(+), 23 deletions(-) create mode 100644 .changeset/retained-input-walker.md create mode 100644 packages/core/src/runtime/retained-step-input.test.ts create mode 100644 packages/core/src/runtime/retained-step-input.ts diff --git a/.changeset/retained-input-walker.md b/.changeset/retained-input-walker.md new file mode 100644 index 0000000000..faceca3f9a --- /dev/null +++ b/.changeset/retained-input-walker.md @@ -0,0 +1,6 @@ +--- +'@workflow/core': patch +'workflow': patch +--- + +Retained-VM boundaries now accept plain data and standard built-ins (`Map`, `Set`, `Date`, typed arrays, `ArrayBuffer`) as step inputs, verifying per boundary that serializing them executes no workflow code. Polyfills that add methods to built-in prototypes keep working. diff --git a/docs/content/docs/v5/configuration/runtime-tuning.mdx b/docs/content/docs/v5/configuration/runtime-tuning.mdx index 9007fb7c2a..269563d0b8 100644 --- a/docs/content/docs/v5/configuration/runtime-tuning.mdx +++ b/docs/content/docs/v5/configuration/runtime-tuning.mdx @@ -81,7 +81,7 @@ For example, a workflow can run a 10-minute inline step even with `WORKFLOW_REPL - Default: enabled - Keeps the suspended workflow VM alive across inline steps within one invocation, so each iteration of the inline loop appends only the newly written events instead of replaying the whole event log in a fresh VM. - Suspensions involving hooks, waits, or attributes — and any replay divergence — always fall back to a full replay. -- Step inputs of primitive values remain retainable; anything whose serialization could execute workflow code falls back to replay for that boundary. (Support for plain objects, arrays, and standard built-ins lands in a follow-up.) +- Step inputs made of plain data (objects, arrays, primitives) and standard built-ins (`Map`, `Set`, `Date`, typed arrays, `ArrayBuffer`) remain retainable. Adding methods to built-in prototypes (e.g. a Temporal polyfill's `Date.prototype.toTemporalInstant`) does not affect retention. Anything whose serialization could execute workflow code — accessors, proxies, functions, custom class serializers, `RegExp`, or a value whose built-in serialization members were replaced — falls back to replay for that boundary. - Set `0` or `false` to replay from scratch in a fresh VM on every iteration. ### `WORKFLOW_INLINE_OWNERSHIP` diff --git a/packages/core/src/retained-vm-loop.test.ts b/packages/core/src/retained-vm-loop.test.ts index d24b59bcc2..69fffcb758 100644 --- a/packages/core/src/retained-vm-loop.test.ts +++ b/packages/core/src/retained-vm-loop.test.ts @@ -100,6 +100,55 @@ const mixedBatchWorkflow = `const s1 = globalThis[Symbol.for("WORKFLOW_USE_STEP" } globalThis.__private_workflows = new Map([["workflow", workflow]]);`; +// Map/Date/typed-array arguments serialize through pinned prototype members +// (see runtime/retained-step-input.ts), so these boundaries stay retainable. +const builtinArgsWorkflow = `const s1 = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("r_s1"); + const s2 = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("r_s2"); + async function workflow() { + const a = await s1({ index: new Map([["k", 1]]), when: new Date(1234) }); + const b = await s2(new Uint8Array([1, 2, 3])); + return a + b; + } + globalThis.__private_workflows = new Map([["workflow", workflow]]);`; + +// The Temporal / core-js pattern: polyfills add new data-valued methods to +// built-in prototypes and constructor statics. Serialization never reads +// them, so retention is unaffected. +const polyfillArgsWorkflow = `const s1 = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("r_s1"); + const s2 = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("r_s2"); + Date.prototype.toTemporalInstant = function () { return "instant"; }; + Set.prototype.union = function (other) { return new Set([...this, ...other]); }; + Object.groupBy = function () { return {}; }; + async function workflow() { + const a = await s1(new Date(1234)); + const b = await s2(new Set([1, 2])); + return a + b; + } + globalThis.__private_workflows = new Map([["workflow", workflow]]);`; + +// Replacing a member serialization executes (Date reducer calls getDate / +// toISOString — serialization/reducers/common.ts:184-188) declines retention +// for boundaries passing that type; converting to a string first retains. +const patchedDateArgWorkflow = `const s1 = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("r_s1"); + const s2 = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("r_s2"); + Date.prototype.toISOString = function () { return "patched"; }; + async function workflow() { + const a = await s1(new Date(1234)); + const b = await s2(); + return a + b; + } + globalThis.__private_workflows = new Map([["workflow", workflow]]);`; + +const patchedDateStringArgWorkflow = `const s1 = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("r_s1"); + const s2 = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("r_s2"); + Date.prototype.toISOString = function () { return "patched"; }; + async function workflow() { + const a = await s1(new Date(1234).toISOString()); + const b = await s2(); + return a + b; + } + globalThis.__private_workflows = new Map([["workflow", workflow]]);`; + // `crypto.subtle.digest` computes synchronously via node:crypto, so a // digest-using VM stays quiescent at suspension and remains retainable. const digestWorkflow = `const s1 = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("r_s1"); @@ -319,6 +368,42 @@ describe('retained VM through the inline replay loop', () => { expect(vmBuilds).toBeGreaterThan(1); }); + it('retains boundaries whose args are supported built-ins', async () => { + const { vmBuilds, output } = await drive( + 'wrun_retained_builtins', + builtinArgsWorkflow + ); + expect(output).toBeInstanceOf(Uint8Array); + expect(vmBuilds).toBe(1); + }); + + it('retains boundaries when prototypes carry polyfilled data methods', async () => { + const { vmBuilds, output } = await drive( + 'wrun_retained_polyfill', + polyfillArgsWorkflow + ); + expect(output).toBeInstanceOf(Uint8Array); + expect(vmBuilds).toBe(1); + }); + + it('demotes a Date arg when an executed serialization member is replaced', async () => { + const { vmBuilds, output } = await drive( + 'wrun_retained_patched_date', + patchedDateArgWorkflow + ); + expect(output).toBeInstanceOf(Uint8Array); + expect(vmBuilds).toBeGreaterThan(1); + }); + + it('retains when the patched type is converted to a string first', async () => { + const { vmBuilds, output } = await drive( + 'wrun_retained_patched_date_string', + patchedDateStringArgWorkflow + ); + expect(output).toBeInstanceOf(Uint8Array); + expect(vmBuilds).toBe(1); + }); + it('retains a VM that used the synchronous crypto.subtle.digest', async () => { const { vmBuilds, output } = await drive( 'wrun_retained_digest', diff --git a/packages/core/src/runtime/retained-step-input.test.ts b/packages/core/src/runtime/retained-step-input.test.ts new file mode 100644 index 0000000000..5636af4d11 --- /dev/null +++ b/packages/core/src/runtime/retained-step-input.test.ts @@ -0,0 +1,580 @@ +import vm from 'node:vm'; +import { describe, expect, it } from 'vitest'; +import { dehydrateStepArguments } from '../serialization.js'; +import { createContext, freezeSerializationIntrinsics } from '../vm/index.js'; +import { + isRetainedSerializationPassive, + registerSerializationPins, +} from './retained-step-input.js'; + +const seed = 'retained-step-input'; +const fixedTimestamp = 1_700_000_000_000; + +function makeContext({ freeze = true, register = freeze } = {}) { + const { context, globalThis: workflowGlobal } = createContext({ + seed, + fixedTimestamp, + }); + // Mimic the globals workflow.ts installs before any serialization happens + // (the stream/request reducers dispatch on them unguarded). + for (const name of [ + 'ReadableStream', + 'WritableStream', + 'TransformStream', + 'Request', + 'Response', + 'AbortController', + 'AbortSignal', + ]) { + if ((workflowGlobal as any)[name] === undefined) { + (workflowGlobal as any)[name] = (globalThis as any)[name]; + } + } + if (freeze) freezeSerializationIntrinsics(workflowGlobal); + if (register) registerSerializationPins(workflowGlobal); + return { context, workflowGlobal }; +} + +describe('isRetainedSerializationPassive', () => { + it('accepts plain cross-realm data', () => { + const { context, workflowGlobal } = makeContext(); + const value = vm.runInContext( + `({ + nested: [{ ok: true }, "text", 42n], + sparse: [1, , 3], + flag: false, + })`, + context + ); + + expect(isRetainedSerializationPassive(value, workflowGlobal)).toBe(true); + }); + + it('accepts the supported built-ins on a registered realm', () => { + const { context, workflowGlobal } = makeContext(); + for (const expression of [ + 'new Map([["k", { ok: true }]])', + 'new Set([1, "two"])', + 'new Date(1234)', + 'new Uint8Array([1, 2, 3])', + 'new Float32Array([1.5])', + 'new ArrayBuffer(8)', + '({ when: new Date(0), bytes: new Uint8Array(2), index: new Map() })', + ]) { + const value = vm.runInContext(expression, context); + expect(isRetainedSerializationPassive(value, workflowGlobal)).toBe(true); + } + }); + + it('accepts host-realm plain data but not host-realm built-ins', () => { + const { workflowGlobal } = makeContext(); + // Hydrated step results are host-realm plain objects/arrays. + expect( + isRetainedSerializationPassive({ nested: [{ ok: true }] }, workflowGlobal) + ).toBe(true); + // Host built-in prototypes cannot be frozen (process-shared) and are + // reachable from workflow code, so host-realm instances decline. + expect( + isRetainedSerializationPassive(new Map([['k', 1]]), workflowGlobal) + ).toBe(false); + expect(isRetainedSerializationPassive(new Date(0), workflowGlobal)).toBe( + false + ); + }); + + it('declines types whose serialization surface is not pinned', () => { + const { context, workflowGlobal } = makeContext(); + for (const expression of [ + '/workflow/gi', + 'new DataView(new ArrayBuffer(8))', + 'new SharedArrayBuffer(8)', + 'new Uint8Array(new SharedArrayBuffer(4))', + 'new Error("boom")', + 'new (class Sub extends Map {})()', + 'Object.assign(new Map(), { expando: 1 })', + 'Object.create(null)', + ]) { + const value = vm.runInContext(expression, context); + expect(isRetainedSerializationPassive(value, workflowGlobal)).toBe(false); + } + }); + + it('declines accessors without invoking them', () => { + const { context, workflowGlobal } = makeContext(); + const value = vm.runInContext( + `(() => { + globalThis.__retainedTestCalls = 0; + return { get value() { globalThis.__retainedTestCalls++; return 1; } }; + })()`, + context + ); + + expect(isRetainedSerializationPassive(value, workflowGlobal)).toBe(false); + expect(vm.runInContext('globalThis.__retainedTestCalls', context)).toBe(0); + }); + + it('declines proxies without invoking their traps', () => { + const { context, workflowGlobal } = makeContext(); + const value = vm.runInContext( + `(() => { + globalThis.__retainedTestCalls = 0; + return new Proxy({ value: 1 }, { + ownKeys(target) { + globalThis.__retainedTestCalls++; + return Reflect.ownKeys(target); + } + }); + })()`, + context + ); + + expect(isRetainedSerializationPassive(value, workflowGlobal)).toBe(false); + expect(vm.runInContext('globalThis.__retainedTestCalls', context)).toBe(0); + }); + + it('declines custom class serializers without invoking them', () => { + const { context, workflowGlobal } = makeContext(); + const value = vm.runInContext( + `(() => { + globalThis.__retainedTestCalls = 0; + class Value { + static classId = "test/Value"; + static [Symbol.for("workflow-serialize")](instance) { + globalThis.__retainedTestCalls++; + return { value: instance.value }; + } + constructor(value) { this.value = value; } + } + return new Value(1); + })()`, + context + ); + + expect(isRetainedSerializationPassive(value, workflowGlobal)).toBe(false); + expect(vm.runInContext('globalThis.__retainedTestCalls', context)).toBe(0); + }); + + it('declines hidden own keys (symbols, non-enumerables, constructor)', () => { + const { context, workflowGlobal } = makeContext(); + for (const expression of [ + `(() => { + const tagged = { plain: true }; + Object.defineProperty(tagged, Symbol.for("WORKFLOW_ABORT_STREAM_NAME"), { + value: "abort-stream", enumerable: false, + }); + return tagged; + })()`, + `(() => { + const hidden = { plain: true }; + Object.defineProperty(hidden, "signal", { + get() { return { aborted: false }; }, enumerable: false, + }); + return hidden; + })()`, + `(() => { + const arr = [1]; + Object.defineProperty(arr, "constructor", { + value: class Fake { static classId = "fake"; }, enumerable: false, + }); + return arr; + })()`, + `(() => { + const arr = [1, 2]; + Object.defineProperty(arr, "0", { value: 7, enumerable: false }); + return arr; + })()`, + ]) { + const value = vm.runInContext(expression, context); + expect(isRetainedSerializationPassive(value, workflowGlobal)).toBe(false); + } + }); + + it('allows polyfilled data members on built-in prototypes and statics', () => { + // The Temporal / core-js pattern: new methods on value-type prototypes + // and new constructor statics. Serialization never reads them, so + // retention is unaffected. + const { context, workflowGlobal } = makeContext(); + vm.runInContext( + ` + Date.prototype.toTemporalInstant = function () { return "instant"; }; + Set.prototype.union = function (other) { return new Set([...this, ...other]); }; + Map.groupBy = function () { return new Map(); }; + Object.groupBy = function () { return {}; }; + `, + context + ); + for (const expression of [ + 'new Date(1234)', + 'new Set([1, 2])', + 'new Map([["k", 1]])', + '({ ok: true })', + ]) { + const value = vm.runInContext(expression, context); + expect( + isRetainedSerializationPassive(value, workflowGlobal), + expression + ).toBe(true); + } + }); + + it('declines only the types whose executed members were replaced', () => { + const { context, workflowGlobal } = makeContext(); + vm.runInContext( + 'Date.prototype.toISOString = function () { return "x"; };', + context + ); + // A Date argument would execute the replaced method while serializing. + const date = vm.runInContext('new Date(1234)', context); + expect(isRetainedSerializationPassive(date, workflowGlobal)).toBe(false); + // Every other type keeps its verified surface — the user's workaround of + // passing `date.toISOString()` (a string) instead still retains. + for (const expression of ['new Map([["k", 1]])', '"2024-01-01"']) { + const value = vm.runInContext(expression, context); + expect( + isRetainedSerializationPassive(value, workflowGlobal), + expression + ).toBe(true); + } + }); + + it('declines per replaced surface: iterators, getters, constructor back-refs', () => { + for (const [patch, expression] of [ + ['Map.prototype[Symbol.iterator] = function () {};', 'new Map()'], + [ + `const proto = Object.getPrototypeOf(new Set()[Symbol.iterator]()); + proto.next = function () { return { done: true }; };`, + 'new Set([1])', + ], + [ + `Object.defineProperty(Object.getPrototypeOf(Uint8Array.prototype), + "buffer", { get() { return new ArrayBuffer(0); } });`, + 'new Uint8Array([1])', + ], + [ + 'Object.defineProperty(Map.prototype, "constructor", { value: class Fake {} });', + 'new Map()', + ], + ['Object.setPrototypeOf(Date.prototype, { evil: true });', 'new Date(0)'], + ] as const) { + const { context, workflowGlobal } = makeContext(); + vm.runInContext(patch, context); + const value = vm.runInContext(expression, context); + expect(isRetainedSerializationPassive(value, workflowGlobal), patch).toBe( + false + ); + } + }); + + it('declines new accessors on pinned prototypes without invoking them', () => { + const { context, workflowGlobal } = makeContext(); + const map = vm.runInContext( + `(() => { + globalThis.__retainedTestCalls = 0; + Object.defineProperty(Map.prototype, "then", { + get() { globalThis.__retainedTestCalls++; return undefined; }, + configurable: true, + }); + return new Map([["k", 1]]); + })()`, + context + ); + expect(isRetainedSerializationPassive(map, workflowGlobal)).toBe(false); + expect(vm.runInContext('globalThis.__retainedTestCalls', context)).toBe(0); + }); + + it('declines shadowed typed-array members on subclass prototypes', () => { + const { context, workflowGlobal } = makeContext(); + vm.runInContext( + 'Object.defineProperty(Uint8Array.prototype, "buffer", { value: new ArrayBuffer(0) });', + context + ); + const u8 = vm.runInContext('new Uint8Array([1, 2])', context); + expect(isRetainedSerializationPassive(u8, workflowGlobal)).toBe(false); + // Sibling typed arrays read through the unshadowed %TypedArray% getters. + const f32 = vm.runInContext('new Float32Array([1.5])', context); + expect(isRetainedSerializationPassive(f32, workflowGlobal)).toBe(true); + }); + + it('declines everything while a realm dispatch constructor is hooked', () => { + for (const patch of [ + 'Object.defineProperty(Set, Symbol.hasInstance, { value: () => false });', + 'Object.setPrototypeOf(Date, { [Symbol.hasInstance]: () => false });', + 'Object[Symbol.for("workflow-serialize")] = function () { return {}; };', + 'Map.classId = "hijack";', + ]) { + const { context, workflowGlobal } = makeContext(); + vm.runInContext(patch, context); + // Reducer dispatch runs `instanceof` / constructor probes for every + // non-primitive value, so even a plain object declines. + const plain = vm.runInContext('({ ok: true })', context); + expect(isRetainedSerializationPassive(plain, workflowGlobal), patch).toBe( + false + ); + } + }); + + it('declines everything when the realm has no registered pins', () => { + const { context, workflowGlobal } = makeContext({ + freeze: true, + register: false, + }); + for (const expression of ['new Map()', '({ ok: true })', '[1, 2]']) { + const value = vm.runInContext(expression, context); + expect( + isRetainedSerializationPassive(value, workflowGlobal), + expression + ).toBe(false); + } + }); + + it('refuses to register pins for an unfrozen realm', () => { + const { workflowGlobal } = makeContext({ freeze: false, register: false }); + expect(() => registerSerializationPins(workflowGlobal)).toThrow( + /freezeSerializationIntrinsics/ + ); + }); + + it('declines retention while a host dispatch constructor is hooked', () => { + const { context, workflowGlobal } = makeContext(); + const plain = vm.runInContext('({ ok: true })', context); + expect(isRetainedSerializationPassive(plain, workflowGlobal)).toBe(true); + + Object.defineProperty(Headers, Symbol.hasInstance, { + value: () => false, + configurable: true, + }); + try { + expect(isRetainedSerializationPassive(plain, workflowGlobal)).toBe(false); + } finally { + delete (Headers as any)[Symbol.hasInstance]; + } + + expect(isRetainedSerializationPassive(plain, workflowGlobal)).toBe(true); + }); +}); + +describe('serialization touches only the pinned surface', () => { + // THE coupling test: retention is only sound if every prototype member + // `dehydrateStepArguments` executes for the supported built-ins is one + // `registerSerializationPins` pins and re-verifies per boundary. Wrap + // every configurable member on the relevant prototypes (in an unfrozen, + // unregistered realm) with a recorder and assert the serializer hits + // nothing beyond this measured set. If serde starts touching something + // new, this fails loudly: extend the pins (and this list) together. + const PINNED_SURFACE = new Set([ + 'Map.prototype.Symbol(Symbol.iterator)', + '%MapIteratorPrototype%.next', + 'Set.prototype.Symbol(Symbol.iterator)', + '%SetIteratorPrototype%.next', + 'Date.prototype.getDate', + 'Date.prototype.toISOString', + '%TypedArray%.prototype.buffer', + '%TypedArray%.prototype.byteOffset', + '%TypedArray%.prototype.byteLength', + 'ArrayBuffer.prototype.byteLength', + ]); + + it('for Map, Set, Date, typed arrays, and ArrayBuffer', async () => { + // Unfrozen realm: the recorders themselves need to redefine members. + const { context, workflowGlobal } = makeContext({ freeze: false }); + const g = workflowGlobal as any; + const touched = new Set(); + + const wrapPrototype = (prototype: object, label: string) => { + for (const key of Reflect.ownKeys(prototype)) { + if (key === 'constructor') continue; + const descriptor = Object.getOwnPropertyDescriptor(prototype, key); + if (!descriptor || !descriptor.configurable) continue; + const name = `${label}.${String(key)}`; + if (typeof descriptor.value === 'function') { + const original = descriptor.value; + Object.defineProperty(prototype, key, { + ...descriptor, + value: function (this: unknown, ...args: unknown[]) { + touched.add(name); + return original.apply(this, args); + }, + }); + } else if (descriptor.get) { + const originalGet = descriptor.get; + Object.defineProperty(prototype, key, { + ...descriptor, + get() { + touched.add(name); + return originalGet.call(this); + }, + set: descriptor.set, + }); + } + } + }; + + const values = vm.runInContext( + `({ + map: new Map([["k", 1]]), + set: new Set([1, 2]), + date: new Date(1234), + f32: new Float32Array([1.5, 2.5]), + u8: new Uint8Array([1, 2, 3]), + ab: new ArrayBuffer(8), + })`, + context + ); + + wrapPrototype(g.Map.prototype, 'Map.prototype'); + wrapPrototype( + Object.getPrototypeOf(new g.Map()[Symbol.iterator]()), + '%MapIteratorPrototype%' + ); + wrapPrototype(g.Set.prototype, 'Set.prototype'); + wrapPrototype( + Object.getPrototypeOf(new g.Set()[Symbol.iterator]()), + '%SetIteratorPrototype%' + ); + const datePrototype = Object.getOwnPropertyDescriptor(g.Date, 'prototype')! + .value as object; + wrapPrototype(datePrototype, 'Date.prototype'); + wrapPrototype( + Object.getPrototypeOf(g.Uint8Array.prototype), + '%TypedArray%.prototype' + ); + wrapPrototype(g.Uint8Array.prototype, 'Uint8Array.prototype'); + wrapPrototype(g.Float32Array.prototype, 'Float32Array.prototype'); + wrapPrototype(g.ArrayBuffer.prototype, 'ArrayBuffer.prototype'); + + for (const value of Object.values(values as Record)) { + touched.clear(); + await dehydrateStepArguments( + { args: [value], closureVars: undefined, thisVal: undefined }, + 'wrun_pin_coverage', + undefined, + g, + false, + false + ); + for (const name of touched) { + expect( + PINNED_SURFACE, + `member outside the pinned surface executed: ${name}` + ).toContain(name); + } + } + }); +}); + +describe('checker execution surface', () => { + it('never invokes live host collection methods', () => { + const { context, workflowGlobal } = makeContext(); + const map = vm.runInContext('new Map([["k", 1]])', context); + + let invoked = 0; + const original = Map.prototype.forEach; + // Simulate workflow code having replaced the reachable host method. + Map.prototype.forEach = function ( + this: Map, + ...args: [any] + ) { + invoked++; + return original.apply(this, args); + }; + try { + // The checker uses the module-captured primordial: same verdict, + // replaced method never runs. + expect(isRetainedSerializationPassive(map, workflowGlobal)).toBe(true); + expect(invoked).toBe(0); + } finally { + Map.prototype.forEach = original; + } + }); + + it('declines typed arrays re-prototyped onto a frozen hostile prototype', () => { + const { context, workflowGlobal } = makeContext(); + const value = vm.runInContext( + `(() => { + globalThis.__retainedTestCalls = 0; + const realGetter = Object.getOwnPropertyDescriptor( + Object.getPrototypeOf(Uint8Array.prototype), "buffer").get; + const hostile = Object.create( + Object.getPrototypeOf(Uint8Array.prototype), + { buffer: { get() { globalThis.__retainedTestCalls++; return realGetter.call(this); } } } + ); + Object.freeze(hostile); + const ta = new Uint8Array([1, 2]); + Object.setPrototypeOf(ta, hostile); + return ta; + })()`, + context + ); + + expect(isRetainedSerializationPassive(value, workflowGlobal)).toBe(false); + expect(vm.runInContext('globalThis.__retainedTestCalls', context)).toBe(0); + }); +}); + +describe('bigint serialization', () => { + it('declines retention while host BigInt.prototype.toString is replaced', () => { + const { context, workflowGlobal } = makeContext(); + const value = vm.runInContext('({ big: 42n })', context); + expect(isRetainedSerializationPassive(value, workflowGlobal)).toBe(true); + + const original = BigInt.prototype.toString; + // biome-ignore lint/suspicious/noGlobalAssign: simulating workflow-realm tampering + BigInt.prototype.toString = function (this: bigint, ...args: [number?]) { + return original.apply(this, args); + }; + try { + expect(isRetainedSerializationPassive(value, workflowGlobal)).toBe(false); + } finally { + BigInt.prototype.toString = original; + } + + expect(isRetainedSerializationPassive(value, workflowGlobal)).toBe(true); + }); +}); + +describe('walker primordials', () => { + it('uses captured primordials, not live host statics', () => { + const { context, workflowGlobal } = makeContext(); + const map = vm.runInContext('new Map([["k", { ok: true }]])', context); + + // If the walker consulted the live host statics, these would throw. + const originalDescriptor = Object.getOwnPropertyDescriptor; + const originalOwnKeys = Reflect.ownKeys; + (Object as any).getOwnPropertyDescriptor = () => { + throw new Error('live getOwnPropertyDescriptor used'); + }; + (Reflect as any).ownKeys = () => { + throw new Error('live ownKeys used'); + }; + try { + expect(isRetainedSerializationPassive(map, workflowGlobal)).toBe(true); + } finally { + (Object as any).getOwnPropertyDescriptor = originalDescriptor; + (Reflect as any).ownKeys = originalOwnKeys; + } + }); + + it('declines when host Function.prototype carries serializer statics', () => { + const { context, workflowGlobal } = makeContext(); + const plain = vm.runInContext('({ ok: true })', context); + expect(isRetainedSerializationPassive(plain, workflowGlobal)).toBe(true); + + Object.defineProperty( + Function.prototype, + Symbol.for('workflow-serialize'), + { + get() { + return undefined; + }, + configurable: true, + } + ); + try { + expect(isRetainedSerializationPassive(plain, workflowGlobal)).toBe(false); + } finally { + delete (Function.prototype as any)[Symbol.for('workflow-serialize')]; + } + + expect(isRetainedSerializationPassive(plain, workflowGlobal)).toBe(true); + }); +}); diff --git a/packages/core/src/runtime/retained-step-input.ts b/packages/core/src/runtime/retained-step-input.ts new file mode 100644 index 0000000000..e8dac34d48 --- /dev/null +++ b/packages/core/src/runtime/retained-step-input.ts @@ -0,0 +1,762 @@ +import { types } from 'node:util'; +import { WORKFLOW_SERIALIZE } from '@workflow/serde'; + +// What this module proves, per suspension boundary: serializing the queued +// step inputs through the ordinary pipeline executes no workflow code. +// +// Retained sessions keep running after suspension, so a serialization side +// effect (a getter, a patched prototype member, a custom serializer) would +// mutate the live VM in a way a cold replay never repeats — replay reads the +// recorded `step_created` event instead of re-serializing. The bytes cannot +// differ by mode (serialization happens exactly once, on the one shared +// path); passivity is the only property retention needs. +// +// The sandbox freezes only the universal lookup backstops +// (Object/Array/Function.prototype — see vm/index.ts). Value-type prototypes +// stay patchable so polyfills (Temporal's `Date.prototype.toTemporalInstant`, +// core-js `Set.prototype.union`) keep working. In exchange, every surface +// serialization can *execute* is pinned at context creation +// (registerSerializationPins) and re-verified per boundary: any drift +// declines retention for that boundary and the session falls back to +// ordinary replay, which serializes the exact same bytes. + +// Host constructors that serialization dispatches on (`value instanceof +// global.X`), plus Object/Array, whose statics and prototypes the class +// reducer and devalue's tag lookup read for host-prototype values (hydrated +// step results are host-realm plain objects). Host intrinsics are shared +// with the whole process and cannot be frozen — and workflow code can reach +// them (exposed host classes, `structuredClone` results) and plant +// workflow-realm hooks. Verified instead: any dirt declines retention, so a +// planted hook can never execute while a retained VM's inputs serialize. +const HOST_DISPATCH_CONSTRUCTORS = [ + 'Object', + 'Array', + 'Function', + 'Map', + 'Set', + 'Date', + 'RegExp', + 'ArrayBuffer', + 'SharedArrayBuffer', + 'DataView', + 'Int8Array', + 'Uint8Array', + 'Uint8ClampedArray', + 'Int16Array', + 'Uint16Array', + 'Int32Array', + 'Uint32Array', + 'Float16Array', + 'Float32Array', + 'Float64Array', + 'BigInt64Array', + 'BigUint64Array', + 'Headers', + 'URL', + 'URLSearchParams', + 'DOMException', + 'AbortController', + 'AbortSignal', + 'Request', + 'Response', + 'ReadableStream', + 'WritableStream', + 'TransformStream', +] as const; + +// Host primordials captured at module load — before any workflow code can +// exist in the process — so the checker itself never invokes a live host +// member workflow code could have replaced (host constructors are reachable +// via e.g. `structuredClone(new Map()).constructor` or exposed classes). +const hostGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const hostGetPrototypeOf = Object.getPrototypeOf; +const hostIsFrozen = Object.isFrozen; +const hostOwnKeys = Reflect.ownKeys; +const hostIsArray = Array.isArray; +const hostIsInteger = Number.isInteger; +const hostNumber = Number; +const hostString = String; +const hostBigIntToString = BigInt.prototype.toString; +const hostMapForEach = Map.prototype.forEach; +const hostSetForEach = Set.prototype.forEach; +const hostObjectPrototype = Object.prototype; +const hostArrayPrototype = Array.prototype; +const hostFunctionPrototype = Function.prototype; +// biome-ignore lint/style/noNonNullAssertion: the %TypedArray% buffer getter always exists +const hostTypedArrayBuffer = Object.getOwnPropertyDescriptor( + Object.getPrototypeOf(Uint8Array.prototype), + 'buffer' +)!.get!; + +const TYPED_ARRAY_CONSTRUCTORS = [ + 'Int8Array', + 'Uint8Array', + 'Uint8ClampedArray', + 'Int16Array', + 'Uint16Array', + 'Int32Array', + 'Uint32Array', + 'Float16Array', + 'Float32Array', + 'Float64Array', + 'BigInt64Array', + 'BigUint64Array', +] as const; + +function hasOwn(target: object, key: string | symbol): boolean { + return hostGetOwnPropertyDescriptor(target, key) !== undefined; +} + +function isHostDispatchPristine(): boolean { + const hostGlobal = globalThis as unknown as Record; + for (const name of HOST_DISPATCH_CONSTRUCTORS) { + const constructor = hostGlobal[name]; + if (constructor === undefined) continue; + if (hasOwn(constructor as object, Symbol.hasInstance)) return false; + } + // The class reducer reads `cls[WORKFLOW_SERIALIZE]` / `cls.classId` as + // inherited Gets (serialization/reducers/class.ts:28-41), so the + // constructors' whole prototype chains — host Function.prototype and + // Object.prototype — must be clean too. + for (const target of [Object, Array, Function.prototype, Object.prototype]) { + if (hasOwn(target, WORKFLOW_SERIALIZE) || hasOwn(target, 'classId')) { + return false; + } + } + for (const [prototype, constructor] of [ + [Object.prototype, Object], + [Array.prototype, Array], + ] as const) { + if ( + hasOwn(prototype, Symbol.toStringTag) || + ownDataProperty(prototype, 'constructor') !== constructor + ) { + return false; + } + } + // Constructor prototype chains end at host Object.prototype, where an + // added @@hasInstance would be found by dispatch lookup. (Host + // Function.prototype's @@hasInstance is spec non-configurable.) + if (hasOwn(Object.prototype, Symbol.hasInstance)) return false; + // The BigInt reducer calls `value.toString()` on bigint primitives + // (serialization/reducers/common.ts:179) from host code, which resolves on + // the HOST BigInt.prototype (primitives are realm-less; method lookup uses + // the running code's realm). + if (ownDataProperty(BigInt.prototype, 'toString') !== hostBigIntToString) { + return false; + } + return true; +} + +// Own data-property read that never performs a property Get — workflow code +// can redefine members with accessors, and validation must not execute +// workflow-owned code. +function ownDataProperty(target: object, key: string | symbol): unknown { + const descriptor = hostGetOwnPropertyDescriptor(target, key); + return descriptor && 'value' in descriptor ? descriptor.value : undefined; +} + +// --------------------------------------------------------------------------- +// Realm serialization pins +// +// Captured once per workflow realm at context creation — after +// freezeSerializationIntrinsics, before any workflow code evaluates — and +// re-verified per suspension boundary with descriptor-only reads. The pin +// list is the measured set of members serialization executes for the +// supported built-ins (see the instrumentation test in +// retained-step-input.test.ts, which wraps every member and asserts nothing +// outside this set runs): +// +// - `Map.prototype[Symbol.iterator]` + `%MapIteratorPrototype%.next` — +// the Map reducer does `Array.from(value)` +// (serialization/reducers/common.ts:319; iteration protocol per +// https://tc39.es/ecma262/#sec-getiterator) +// - `Set.prototype[Symbol.iterator]` + `%SetIteratorPrototype%.next` — +// Set reducer, common.ts:329 +// - `Date.prototype.getDate` / `.toISOString` — Date reducer, +// common.ts:184-188 +// - `%TypedArray%.prototype` `buffer`/`byteOffset`/`byteLength` getters — +// typed-array reducer calls +// `arrayBufferToBase64(value.buffer, value.byteOffset, value.byteLength)`, +// common.ts:23-36 +// - `ArrayBuffer.prototype.byteLength` getter — ArrayBuffer reducer, +// common.ts:177-178 +// +// Two more lookup families execute during dispatch and are verified on the +// constructors below: `value instanceof global.X` (devalue runs every custom +// reducer predicate on every non-primitive value — +// https://github.com/sveltejs/devalue/blob/v5.8.1/src/stringify.js#L111-L116) +// and the Instance reducer's `value.constructor` / +// `cls[WORKFLOW_SERIALIZE]` / `cls.classId` reads +// (serialization/reducers/class.ts:26-43). Plain objects and arrays need no +// pins of their own: devalue walks them purely via own-property reads +// (arrays: stringify.js#L186-L192; objects: `Object.keys` at +// stringify.js#L329-L357), and their lookup backstops are frozen. +// --------------------------------------------------------------------------- + +interface AccessorPin { + get: unknown; + set: unknown; +} + +interface PrototypePin { + proto: object; + // Required [[Prototype]] identity, so a missed lookup on a passive value + // terminates on the frozen backstops. null: lookups never traverse past + // this object (iterator prototypes — only `next` is read, and it is own). + parent: object | null; + // Required own `constructor` data value: the Instance reducer Gets + // `value.constructor` (class.ts:28) on every object, and a swapped value + // would route the follow-up WORKFLOW_SERIALIZE/classId Gets to an + // arbitrary workflow object. undefined: never consulted for this proto. + ctor: unknown; + // Own keys that must remain absent — they would shadow an executed member + // defined further up the chain (e.g. `buffer` on Uint8Array.prototype + // shadowing the pinned %TypedArray%.prototype getter). + banned: readonly (string | symbol)[]; + // Own data-valued members serialization calls: identity must hold. + executedData: ReadonlyMap; + // Own accessors serialization reads: getter identity must hold. + executedGetters: ReadonlyMap; + // All own accessors at registration. Any other own accessor declines: a + // Get reaching this prototype would execute it. New own DATA members stay + // allowed — reading a data property runs no code, and serialization only + // ever *executes* the pinned members — which is what keeps polyfills + // (Temporal's `Date.prototype.toTemporalInstant`, core-js + // `Set.prototype.union`) compatible with retention. + accessors: ReadonlyMap; +} + +// Pins are grouped per value type: a violated pin declines only values whose +// lookup chains include that prototype (a replaced `Date.prototype.toISOString` +// declines Date arguments; Maps, strings, and everything else keep retaining). +// The constructor checks stay realm-global — reducer dispatch runs +// `instanceof` probes for every non-primitive value regardless of its type. +interface RealmPins { + dispatchConstructors: readonly object[]; + functionPrototype: object; + objectPrototype: object; + arrayPrototype: object; + mapPrototype: object; + mapPins: readonly PrototypePin[]; + setPrototype: object; + setPins: readonly PrototypePin[]; + datePrototype: object; + datePins: readonly PrototypePin[]; + arrayBufferPrototype: object; + arrayBufferPins: readonly PrototypePin[]; + // Concrete typed-array prototype → its pins (the concrete pin plus the + // shared %TypedArray%.prototype pin). + typedArrayPins: ReadonlyMap; +} + +const pinsByRealm = new WeakMap(); + +function snapshotAccessors(proto: object): Map { + const accessors = new Map(); + for (const key of hostOwnKeys(proto)) { + // biome-ignore lint/style/noNonNullAssertion: key is an own key + const descriptor = hostGetOwnPropertyDescriptor(proto, key)!; + if (!('value' in descriptor)) { + accessors.set(key, { get: descriptor.get, set: descriptor.set }); + } + } + return accessors; +} + +/** + * Capture the serialization pins for a freshly created workflow realm. Must + * run after `freezeSerializationIntrinsics` and before any workflow code + * evaluates — registration takes the realm's current members as the trusted + * originals. + */ +export function registerSerializationPins( + workflowGlobal: Record +): void { + const objectPrototype = prototypeOf(workflowGlobal, 'Object'); + const arrayPrototype = prototypeOf(workflowGlobal, 'Array'); + const functionPrototype = prototypeOf(workflowGlobal, 'Function'); + if ( + !hostIsFrozen(objectPrototype) || + !hostIsFrozen(arrayPrototype) || + !hostIsFrozen(functionPrototype) + ) { + throw new Error( + 'registerSerializationPins requires freezeSerializationIntrinsics to have run' + ); + } + + const pinPrototype = ( + proto: object, + pin: Omit, 'proto' | 'accessors'> & { + parent: object | null; + } + ): PrototypePin => ({ + proto, + ctor: undefined, + banned: [], + executedData: new Map(), + executedGetters: new Map(), + ...pin, + accessors: snapshotAccessors(proto), + }); + + const pinIterableCollection = ( + name: 'Map' | 'Set' + ): { proto: object; pins: PrototypePin[] } => { + const constructor = ownDataProperty(workflowGlobal, name) as new () => {}; + const proto = prototypeOf(workflowGlobal, name); + const iterator = ownDataProperty(proto, Symbol.iterator) as ( + this: unknown + ) => object; + // A scratch instance yields the realm's %MapIteratorPrototype% / + // %SetIteratorPrototype% — no workflow code exists yet, so calling the + // realm constructor and iterator here is safe. + const iteratorProto = hostGetPrototypeOf(iterator.call(new constructor())); + return { + proto, + pins: [ + pinPrototype(proto, { + parent: objectPrototype, + ctor: ownDataProperty(proto, 'constructor'), + executedData: new Map([[Symbol.iterator, iterator]]), + }), + pinPrototype(iteratorProto, { + parent: null, + executedData: new Map([ + ['next', ownDataProperty(iteratorProto, 'next')], + ]), + }), + ], + }; + }; + + const map = pinIterableCollection('Map'); + const set = pinIterableCollection('Set'); + + // The realm's `Date` binding is the sandbox's deterministic wrapper + // (vm/index.ts); its `.prototype` is the realm's original Date.prototype, + // whose `constructor` back-ref is the original constructor — capture the + // back-ref itself, since that is what the Instance reducer's + // `value.constructor` Get resolves to. + const datePrototype = prototypeOf(workflowGlobal, 'Date'); + const datePins = [ + pinPrototype(datePrototype, { + parent: objectPrototype, + ctor: ownDataProperty(datePrototype, 'constructor'), + executedData: new Map([ + ['getDate', ownDataProperty(datePrototype, 'getDate')], + ['toISOString', ownDataProperty(datePrototype, 'toISOString')], + ]), + }), + ]; + + const typedArrayPrototype = hostGetPrototypeOf( + prototypeOf(workflowGlobal, 'Uint8Array') + ) as object; + const typedArrayGetter = (key: string) => + // biome-ignore lint/style/noNonNullAssertion: spec accessors on %TypedArray%.prototype + hostGetOwnPropertyDescriptor(typedArrayPrototype, key)!.get; + const typedArraySharedPin = pinPrototype(typedArrayPrototype, { + parent: objectPrototype, + ctor: ownDataProperty(typedArrayPrototype, 'constructor'), + executedGetters: new Map([ + ['buffer', typedArrayGetter('buffer')], + ['byteOffset', typedArrayGetter('byteOffset')], + ['byteLength', typedArrayGetter('byteLength')], + ]), + }); + const typedArrayPins = new Map(); + for (const name of TYPED_ARRAY_CONSTRUCTORS) { + const constructor = ownDataProperty(workflowGlobal, name); + if (constructor === undefined) continue; // Float16Array on older Node + const proto = prototypeOf(workflowGlobal, name); + typedArrayPins.set(proto, [ + pinPrototype(proto, { + parent: typedArrayPrototype, + ctor: ownDataProperty(proto, 'constructor'), + banned: ['buffer', 'byteOffset', 'byteLength'], + }), + typedArraySharedPin, + ]); + } + + const arrayBufferPrototype = prototypeOf(workflowGlobal, 'ArrayBuffer'); + const arrayBufferPins = [ + pinPrototype(arrayBufferPrototype, { + parent: objectPrototype, + ctor: ownDataProperty(arrayBufferPrototype, 'constructor'), + executedGetters: new Map([ + [ + 'byteLength', + // biome-ignore lint/style/noNonNullAssertion: spec accessor on ArrayBuffer.prototype + hostGetOwnPropertyDescriptor(arrayBufferPrototype, 'byteLength')!.get, + ], + ]), + }), + ]; + + const dispatchConstructors: object[] = []; + for (const name of HOST_DISPATCH_CONSTRUCTORS) { + const constructor = ownDataProperty(workflowGlobal, name); + if (typeof constructor === 'function') { + dispatchConstructors.push(constructor); + } + } + + pinsByRealm.set(workflowGlobal, { + dispatchConstructors, + functionPrototype, + objectPrototype, + arrayPrototype, + mapPrototype: map.proto, + mapPins: map.pins, + setPrototype: set.proto, + setPins: set.pins, + datePrototype, + datePins, + arrayBufferPrototype, + arrayBufferPins, + typedArrayPins, + }); +} + +function prototypeOf( + realmGlobal: Record, + constructorName: string +): object { + const constructor = ownDataProperty(realmGlobal, constructorName); + const proto = ownDataProperty(constructor as object, 'prototype'); + // Function.prototype is itself a function; every other pinned prototype is + // an ordinary object. + if ( + (typeof proto !== 'object' && typeof proto !== 'function') || + proto === null + ) { + throw new Error(`realm has no ${constructorName}.prototype`); + } + return proto; +} + +function verifyPrototypePin(pin: PrototypePin): boolean { + if (pin.parent !== null && hostGetPrototypeOf(pin.proto) !== pin.parent) { + return false; + } + if ( + pin.ctor !== undefined && + ownDataProperty(pin.proto, 'constructor') !== pin.ctor + ) { + return false; + } + for (const key of pin.banned) { + if (hasOwn(pin.proto, key)) return false; + } + for (const [key, value] of pin.executedData) { + const descriptor = hostGetOwnPropertyDescriptor(pin.proto, key); + if (!descriptor || !('value' in descriptor) || descriptor.value !== value) { + return false; + } + } + for (const [key, get] of pin.executedGetters) { + const descriptor = hostGetOwnPropertyDescriptor(pin.proto, key); + if (!descriptor || 'value' in descriptor || descriptor.get !== get) { + return false; + } + } + // New own data members (polyfill methods) are inert; any own accessor that + // was not present at registration — or whose get/set changed — declines. + for (const key of hostOwnKeys(pin.proto)) { + // biome-ignore lint/style/noNonNullAssertion: key is an own key + const descriptor = hostGetOwnPropertyDescriptor(pin.proto, key)!; + if ('value' in descriptor) continue; + const pinned = pin.accessors.get(key); + if ( + !pinned || + pinned.get !== descriptor.get || + pinned.set !== descriptor.set + ) { + return false; + } + } + return true; +} + +// The lookups serialization performs on a constructor traverse its whole +// [[Prototype]] chain: +// - `value instanceof global.X` first Gets C[@@hasInstance] +// (https://tc39.es/ecma262/#sec-instanceofoperator), and devalue runs every +// custom reducer predicate on every non-primitive value +// (devalue v5.8.1 src/stringify.js#L111-L116) +// - the Instance reducer Gets `cls[WORKFLOW_SERIALIZE]` / `cls.classId` on +// `value.constructor` (serialization/reducers/class.ts:26-43): a +// data-valued serializer would be *called*, an accessor executes on the +// read itself. Either declines. (Statics under any other name — e.g. a +// polyfilled `Object.groupBy` — are never read and stay allowed.) +// Walk the chain: every link must be a real function carrying none of those +// keys, ending at a real Function.prototype — the realm's (frozen), or the +// host's for host-implemented classes the SDK installs (Request, streams; +// their spec @@hasInstance is non-configurable there, and +// isHostDispatchPristine covers the serializer-static keys). Chains are +// legitimately more than one link deep: the sandbox's deterministic `Date` +// wrapper chains to the realm's original Date (vm/index.ts), AbortSignal +// chains to EventTarget. +function verifyConstructorChain( + constructor: unknown, + pins: RealmPins +): boolean { + let link: unknown = constructor; + for (let depth = 0; depth < 8; depth++) { + if (link === pins.functionPrototype || link === hostFunctionPrototype) { + return true; + } + if (typeof link !== 'function' || types.isProxy(link)) return false; + if ( + hasOwn(link, Symbol.hasInstance) || + hasOwn(link, WORKFLOW_SERIALIZE) || + hasOwn(link, 'classId') + ) { + return false; + } + link = hostGetPrototypeOf(link); + } + return false; +} + +// Per-boundary walk state: pin verdicts are computed lazily (only for the +// types actually present in the inputs) and memoized for the boundary. +interface BoundaryContext { + pins: RealmPins; + seen: WeakSet; + verified: Map; +} + +function verifiedPins( + pins: readonly PrototypePin[], + ctx: BoundaryContext +): boolean { + for (const pin of pins) { + let ok = ctx.verified.get(pin); + if (ok === undefined) { + ok = + verifyPrototypePin(pin) && + // The `constructor` back-ref is itself the target of inherited Gets + // (the Instance reducer), so its chain must be clean too. + (pin.ctor === undefined || verifyConstructorChain(pin.ctor, ctx.pins)); + ctx.verified.set(pin, ok); + } + if (!ok) return false; + } + return true; +} + +function isArrayIndex(key: string): boolean { + const index = hostNumber(key); + return ( + hostIsInteger(index) && + index >= 0 && + index < 2 ** 32 - 1 && + hostString(index) === key + ); +} + +function isPassiveArrayProperty( + array: unknown[], + key: string | symbol, + ctx: BoundaryContext +): boolean { + if (key === 'length') return true; + const descriptor = hostGetOwnPropertyDescriptor(array, key); + if (!descriptor) return false; + // Only own enumerable data indices are passive. Anything hidden — symbol + // tags, non-enumerable properties, accessors — can be observed by + // serialization dispatch (reducer probes, thenable checks, the class + // reducer) and can execute workflow code when read. devalue reads array + // elements as own indexed properties (v5.8.1 src/stringify.js#L186-L192). + return ( + typeof key === 'string' && + isArrayIndex(key) && + descriptor.enumerable === true && + 'value' in descriptor && + isPassive(descriptor.value, ctx) + ); +} + +function isPassiveArray(value: unknown[], ctx: BoundaryContext): boolean { + const prototype = hostGetPrototypeOf(value); + return ( + (prototype === ctx.pins.arrayPrototype || + prototype === hostArrayPrototype) && + hostOwnKeys(value).every((key) => isPassiveArrayProperty(value, key, ctx)) + ); +} + +// Instances of the supported built-ins must carry no own properties at all: +// serialization never reads own properties on them, but an own accessor or +// symbol could still be observed through other dispatch lookups, and clean +// instances are the overwhelmingly common case anyway. +function hasNoOwnProperties(value: object): boolean { + return hostOwnKeys(value).length === 0; +} + +function isPassiveMap( + value: Map, + ctx: BoundaryContext +): boolean { + if (hostGetPrototypeOf(value) !== ctx.pins.mapPrototype) return false; + if (!verifiedPins(ctx.pins.mapPins, ctx)) return false; + if (!hasNoOwnProperties(value)) return false; + let passive = true; + // The captured host forEach iterates via internal slots — no realm + // members (and no live, replaceable host members) execute. + hostMapForEach.call(value, (entryValue: unknown, key: unknown) => { + passive &&= isPassive(key, ctx) && isPassive(entryValue, ctx); + }); + return passive; +} + +function isPassiveSet(value: Set, ctx: BoundaryContext): boolean { + if (hostGetPrototypeOf(value) !== ctx.pins.setPrototype) return false; + if (!verifiedPins(ctx.pins.setPins, ctx)) return false; + if (!hasNoOwnProperties(value)) return false; + let passive = true; + hostSetForEach.call(value, (entryValue: unknown) => { + passive &&= isPassive(entryValue, ctx); + }); + return passive; +} + +function isPassiveDate(value: object, ctx: BoundaryContext): boolean { + return ( + hostGetPrototypeOf(value) === ctx.pins.datePrototype && + verifiedPins(ctx.pins.datePins, ctx) && + hasNoOwnProperties(value) + ); +} + +function isPassiveTypedArray(value: object, ctx: BoundaryContext): boolean { + // Identity against the finite set of the realm's pinned typed-array + // prototypes — NOT "anything chaining to %TypedArray%": a workflow can + // manufacture a hostile prototype with a delegating `buffer` getter and + // setPrototypeOf a real typed array onto it. + const pins = ctx.pins.typedArrayPins.get(hostGetPrototypeOf(value)); + if (pins === undefined || !verifiedPins(pins, ctx)) return false; + // Own keys on a typed array are exactly its canonical indices. + if ( + !hostOwnKeys(value).every( + (key) => typeof key === 'string' && isArrayIndex(key) + ) + ) { + return false; + } + // Read the backing buffer via the captured host getter (internal slots + // work cross-realm); reject SharedArrayBuffer backing. + return !types.isSharedArrayBuffer(hostTypedArrayBuffer.call(value)); +} + +function isPassiveArrayBuffer(value: object, ctx: BoundaryContext): boolean { + return ( + hostGetPrototypeOf(value) === ctx.pins.arrayBufferPrototype && + verifiedPins(ctx.pins.arrayBufferPins, ctx) && + hasNoOwnProperties(value) + ); +} + +function isPassiveObjectProperty( + object: object, + key: string | symbol, + ctx: BoundaryContext +): boolean { + const descriptor = hostGetOwnPropertyDescriptor(object, key); + if (!descriptor) return false; + // Only own enumerable string-keyed data properties are passive. Anything + // hidden — symbol tags, non-enumerable properties, accessors — can be + // observed by serialization dispatch (reducer probes like `.signal`, + // thenable checks, the class reducer) and can execute workflow code. + // devalue walks objects purely via `Object.keys` + own reads + // (v5.8.1 src/stringify.js#L329-L357). + return ( + typeof key === 'string' && + key !== '__proto__' && + descriptor.enumerable === true && + 'value' in descriptor && + isPassive(descriptor.value, ctx) + ); +} + +function isPassivePlainObject(value: object, ctx: BoundaryContext): boolean { + const prototype = hostGetPrototypeOf(value); + if ( + prototype !== ctx.pins.objectPrototype && + prototype !== hostObjectPrototype + ) { + return false; + } + return hostOwnKeys(value).every((key) => + isPassiveObjectProperty(value, key, ctx) + ); +} + +/** + * Whether serializing `value` through the ordinary pipeline provably executes + * no workflow code and draws no workflow-realm randomness. + * + * Passive values: primitives; plain objects and arrays (own enumerable + * string-keyed data properties only — devalue traverses these purely via own + * reads); and workflow-realm Map/Set/Date/typed arrays/ArrayBuffer whose + * serialization surfaces still match the pins captured at context creation. + * Everything else — proxies, accessors, functions, custom classes, RegExp, + * hidden keys — declines, serializes exactly the same way, and the session + * falls back to ordinary replay for that boundary. + */ +function isPassive(value: unknown, ctx: BoundaryContext): boolean { + if ( + value === null || + value === undefined || + typeof value === 'boolean' || + typeof value === 'bigint' || + typeof value === 'number' || + typeof value === 'string' + ) { + return true; + } + if (typeof value !== 'object' || types.isProxy(value)) return false; + if (ctx.seen.has(value)) return true; + ctx.seen.add(value); + + if (hostIsArray(value)) return isPassiveArray(value, ctx); + if (types.isMap(value)) return isPassiveMap(value, ctx); + if (types.isSet(value)) return isPassiveSet(value, ctx); + if (types.isDate(value)) return isPassiveDate(value, ctx); + if (types.isTypedArray(value)) return isPassiveTypedArray(value, ctx); + if (types.isArrayBuffer(value)) return isPassiveArrayBuffer(value, ctx); + if ( + types.isSharedArrayBuffer(value) || + types.isRegExp(value) || + types.isDataView(value) || + types.isBoxedPrimitive(value) || + types.isNativeError(value) || + types.isPromise(value) || + types.isArgumentsObject(value) + ) { + return false; + } + return isPassivePlainObject(value, ctx); +} + +export function isRetainedSerializationPassive( + value: unknown, + workflowGlobal: Record +): boolean { + const pins = pinsByRealm.get(workflowGlobal); + // No pins: the realm never went through context creation's registration, + // so nothing about its lookup surfaces is known. + if (pins === undefined) return false; + if (!isHostDispatchPristine()) return false; + // Reducer dispatch runs `instanceof` probes against every dispatch + // constructor for every non-primitive value, so the constructor chains + // gate the whole boundary regardless of which types the inputs use. + for (const constructor of pins.dispatchConstructors) { + if (!verifyConstructorChain(constructor, pins)) return false; + } + return isPassive(value, { pins, seen: new WeakSet(), verified: new Map() }); +} diff --git a/packages/core/src/runtime/suspension-handler.ts b/packages/core/src/runtime/suspension-handler.ts index c23ab2e878..01944bf55c 100644 --- a/packages/core/src/runtime/suspension-handler.ts +++ b/packages/core/src/runtime/suspension-handler.ts @@ -1,4 +1,5 @@ import type { Span } from '@opentelemetry/api'; +import { isRetainedSerializationPassive } from './retained-step-input.js'; import { EntityConflictError, FatalError, @@ -32,19 +33,6 @@ import { getAbortStreamIdFromToken } from '../util.js'; import { getMaxInlineSteps } from './constants.js'; import { type MutableEventLog, withPreconditionRetry } from './helpers.js'; -// Serializing a primitive executes no code of any kind. BigInt is excluded: -// its encoding calls a prototype method. Widened to plain data and standard -// built-ins by the retained-input walker in a follow-up. -function isPrimitiveStepArgument(value: unknown): boolean { - return ( - value === null || - value === undefined || - typeof value === 'boolean' || - typeof value === 'number' || - typeof value === 'string' - ); -} - export interface SuspensionHandlerParams { suspension: WorkflowSuspension; world: World; @@ -514,19 +502,22 @@ export async function handleSuspension({ // durable bytes cannot depend on retention. What retention needs to know is // whether that serialization will execute workflow code (getters, hooks, // patched prototype members) — side effects a cold replay would not repeat. - // For now only primitive arguments are provably passive (serializing them - // executes no code at all); a follow-up widens this to plain data and the - // standard built-ins. If any input in the batch is not provably passive, - // the caller demotes the session so the side effects land in a VM that is - // about to be discarded, exactly like the pre-retention runtime. + // If any input in the batch is not provably passive, the caller demotes + // the session so the side effects land in a VM that is about to be + // discarded, exactly like the pre-retention runtime. let retainedStepInputsSafe = true; if (prepareForRetention) { for (const queueItem of stepItems) { if (!stepsNeedingCreation.has(queueItem.correlationId)) continue; if ( - queueItem.thisVal !== undefined || - queueItem.closureVars !== undefined || - !queueItem.args.every(isPrimitiveStepArgument) + !isRetainedSerializationPassive( + { + args: queueItem.args, + closureVars: queueItem.closureVars, + thisVal: queueItem.thisVal, + }, + suspension.globalThis + ) ) { retainedStepInputsSafe = false; break; diff --git a/packages/core/src/workflow.ts b/packages/core/src/workflow.ts index 57f75435fe..f82ca7bdf5 100644 --- a/packages/core/src/workflow.ts +++ b/packages/core/src/workflow.ts @@ -24,6 +24,7 @@ import { ReplayPayloadCache } from './replay-payload-cache.js'; import { getPortLazy } from './runtime/get-port-lazy.js'; import { runIdCreatedAt } from './runtime/run-id-time.js'; import { handleSuspension } from './runtime/suspension-handler.js'; +import { registerSerializationPins } from './runtime/retained-step-input.js'; import { getWorld } from './runtime/world.js'; import { dehydrateWorkflowReturnValue, @@ -1026,8 +1027,11 @@ async function createWorkflowSession({ // is rare (it requires the lookup `?.get(...)` expression to throw) and // does not affect the workflow function or replay determinism. // All SDK globals are installed; pin the serialization-consulted - // intrinsics before any workflow code can run. + // intrinsics before any workflow code can run, then capture the + // serialization pins the retained-input gate re-verifies per boundary + // (see runtime/retained-step-input.ts). freezeSerializationIntrinsics(vmGlobalThis); + registerSerializationPins(vmGlobalThis); runCachedWorkflowScript(workflowCode, filename, context); const workflowFn = runCachedWorkflowScript( From 0c4376cd8eb88f433ac9ce1657dfa74c9975eeff Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:19:23 -0700 Subject: [PATCH 02/13] Patch devalue 5.8.1 with the pluggable stringify operations option Applies the runtime changes from sveltejs/devalue#172 (head 2e97724) via pnpm patch, adapted to 5.8.1's merged TypedArray/DataView case so encoded output stays byte-identical. Enables injecting hardened, side-effect-free introspection operations into stringify. --- patches/devalue@5.8.1.patch | 622 ++++++++++++++++++++++++++++++++++++ pnpm-lock.yaml | 29 +- pnpm-workspace.yaml | 8 + 3 files changed, 647 insertions(+), 12 deletions(-) create mode 100644 patches/devalue@5.8.1.patch diff --git a/patches/devalue@5.8.1.patch b/patches/devalue@5.8.1.patch new file mode 100644 index 0000000000..d71ca5e509 --- /dev/null +++ b/patches/devalue@5.8.1.patch @@ -0,0 +1,622 @@ +diff --git a/index.js b/index.js +index 86e3a2dcae942f79b02da1e9c5fe2f6390b8b487..427a24b076f00237a6ff9b2d0fdbd3fd3292c6d7 100644 +--- a/index.js ++++ b/index.js +@@ -1,4 +1,5 @@ + export { uneval } from './src/uneval.js'; + export { parse, unflatten } from './src/parse.js'; + export { stringify, stringifyAsync } from './src/stringify.js'; ++export { default_operations as defaultOperations } from './src/operations.js'; + export { DevalueError } from './src/utils.js'; +diff --git a/src/operations.js b/src/operations.js +new file mode 100644 +index 0000000000000000000000000000000000000000..03ffe62973c7a529f814e2ffe6433329cc4b8d8a +--- /dev/null ++++ b/src/operations.js +@@ -0,0 +1,82 @@ ++import { ++ enumerable_symbols, ++ get_type, ++ is_plain_object, ++ valid_array_indices ++} from './utils.js'; ++ ++/** @type {{ kind: 'not-plain' }} */ ++const NOT_PLAIN = Object.freeze({ kind: 'not-plain' }); ++ ++/** @type {{ kind: 'symbol-keys' }} */ ++const SYMBOL_KEYS = Object.freeze({ kind: 'symbol-keys' }); ++ ++/** ++ * The default implementations of every introspection/extraction operation ++ * `stringify` performs on the value being serialized. Each one uses native ++ * JavaScript semantics (property access, iteration, prototype methods, etc). ++ * ++ * Pass overrides via the `operations` option of `stringify`/`stringifyAsync` ++ * to customize how values are inspected — e.g. to serialize values without ++ * triggering getters, proxy traps, or patched prototype methods, or to ++ * serialize values that live in a different JavaScript runtime (a `node:vm` ++ * context, a WASM-hosted engine, a remote process) through handle objects. ++ * ++ * The object is frozen — it is shared by every `stringify` call that does ++ * not override a given operation. ++ * ++ * @type {import('./types.js').StringifyOperations} ++ */ ++export const default_operations = Object.freeze({ ++ identify: (value) => value, ++ ++ typeOf: (value) => (value === null ? 'null' : typeof value), ++ ++ primitive: (value) => value, ++ ++ tag: (value) => get_type(value), ++ ++ isThenable: (value) => typeof value.then === 'function', ++ ++ resolveThenable: (value) => Promise.resolve(value), ++ ++ unbox: (value) => value.valueOf(), ++ ++ dateISO: (value) => (isNaN(value.getDate()) ? '' : value.toISOString()), ++ ++ toStringValue: (value) => value.toString(), ++ ++ regExp: (value) => ({ source: value.source, flags: value.flags }), ++ ++ setValues: (value) => value, ++ ++ mapEntries: (value) => value, ++ ++ viewInfo: (value) => ({ ++ buffer: value.buffer, ++ byteOffset: value.byteOffset, ++ byteLength: value.byteLength, ++ length: value.length, ++ bufferByteLength: value.buffer.byteLength ++ }), ++ ++ arrayBuffer: (value) => value, ++ ++ arrayLength: (value) => value.length, ++ ++ hasOwnIndex: (value, index) => Object.hasOwn(value, index), ++ ++ arrayIndices: (value) => valid_array_indices(value), ++ ++ objectShape: (value) => { ++ if (!is_plain_object(value)) return NOT_PLAIN; ++ if (enumerable_symbols(value).length > 0) return SYMBOL_KEYS; ++ ++ return { ++ kind: Object.getPrototypeOf(value) === null ? 'null-proto' : 'plain', ++ keys: Object.keys(value) ++ }; ++ }, ++ ++ get: (value, key) => value[key] ++}); +diff --git a/src/stringify.js b/src/stringify.js +index 35dadffb9b8140180957cfe3d8a950acbf5d4a5c..2051d0c2fe2a58492a5fa88d76a239775afe19a4 100644 +--- a/src/stringify.js ++++ b/src/stringify.js +@@ -1,13 +1,4 @@ +-import { +- DevalueError, +- enumerable_symbols, +- get_type, +- is_plain_object, +- is_primitive, +- stringify_key, +- stringify_string, +- valid_array_indices +-} from './utils.js'; ++import { DevalueError, stringify_key, stringify_string } from './utils.js'; + import { + HOLE, + NAN, +@@ -18,14 +9,16 @@ import { + UNDEFINED + } from './constants.js'; + import { encode64 } from './base64.js'; ++import { default_operations } from './operations.js'; + + /** + * Turn a value into a JSON string that can be parsed with `devalue.parse` + * @param {any} value + * @param {Record any>} [reducers] ++ * @param {import('./types.js').StringifyOptions} [options] + */ +-export function stringify(value, reducers) { +- const stringified = run(false, value, reducers); ++export function stringify(value, reducers, options) { ++ const stringified = run(false, value, reducers, options); + return typeof stringified === 'string' ? stringified : `[${stringified.join(',')}]`; + } + +@@ -33,9 +26,10 @@ export function stringify(value, reducers) { + * Turn a value into a JSON string that can be parsed with `devalue.parse` + * @param {any} value + * @param {Record any>} [reducers] ++ * @param {import('./types.js').StringifyOptions} [options] + */ +-export async function stringifyAsync(value, reducers) { +- const stringified = run(true, value, reducers); ++export async function stringifyAsync(value, reducers, options) { ++ const stringified = run(true, value, reducers, options); + + if (typeof stringified === 'string') { + return stringified; +@@ -71,8 +65,23 @@ export async function stringifyAsync(value, reducers) { + * @param {boolean} async + * @param {any} value + * @param {Record any>} [reducers] ++ * @param {import('./types.js').StringifyOptions} [options] + */ +-function run(async, value, reducers) { ++function run(async, value, reducers, options) { ++ /** @type {import('./types.js').StringifyOperations} */ ++ let ops = default_operations; ++ ++ if (options?.operations) { ++ ops = { ...default_operations }; ++ ++ // treat explicitly-`undefined` members like omitted members, so that ++ // programmatically-built overrides can't clobber a default with undefined ++ for (const key of /** @type {(keyof typeof ops)[]} */ (Object.keys(options.operations))) { ++ const fn = options.operations[key]; ++ if (fn !== undefined) ops[key] = /** @type {any} */ (fn); ++ } ++ } ++ + /** @type {any[]} */ + const stringified = []; + +@@ -97,16 +106,27 @@ function run(async, value, reducers) { + * @param {number} [index] + */ + function flatten(thing, index) { +- if (thing === undefined) return UNDEFINED; +- if (Number.isNaN(thing)) return NAN; +- if (thing === Infinity) return POSITIVE_INFINITY; +- if (thing === -Infinity) return NEGATIVE_INFINITY; +- if (thing === 0 && 1 / thing < 0) return NEGATIVE_ZERO; ++ const type = ops.typeOf(thing); ++ ++ if (type === 'undefined') return UNDEFINED; ++ ++ /** @type {number | undefined} */ ++ let number; + +- if (indexes.has(thing)) return /** @type {number} */ (indexes.get(thing)); ++ if (type === 'number') { ++ number = /** @type {number} */ (ops.primitive(thing)); ++ if (Number.isNaN(number)) return NAN; ++ if (number === Infinity) return POSITIVE_INFINITY; ++ if (number === -Infinity) return NEGATIVE_INFINITY; ++ if (number === 0 && 1 / number < 0) return NEGATIVE_ZERO; ++ } ++ ++ const id = ops.identify(thing); ++ ++ if (indexes.has(id)) return /** @type {number} */ (indexes.get(id)); + + index ??= p++; +- indexes.set(thing, index); ++ indexes.set(id, index); + + for (const { key, fn } of custom) { + const value = fn(thing); +@@ -116,18 +136,19 @@ function run(async, value, reducers) { + } + } + +- if (typeof thing === 'function') { ++ if (type === 'function') { + throw new DevalueError(`Cannot stringify a function`, keys, thing, value); +- } else if (typeof thing === 'symbol') { ++ } else if (type === 'symbol') { + throw new DevalueError(`Cannot stringify a Symbol primitive`, keys, thing, value); + } + + /** @type {string | Promise} */ + let str = ''; + +- if (is_primitive(thing)) { +- str = stringify_primitive(thing); +- } else if (typeof thing.then === 'function') { ++ if (type !== 'object') { ++ // 'null' | 'boolean' | 'number' | 'bigint' | 'string' ++ str = stringify_primitive(type === 'number' ? number : ops.primitive(thing)); ++ } else if (ops.isThenable(thing)) { + if (!async) { + throw new DevalueError( + `Cannot stringify a Promise or thenable — use stringifyAsync instead`, +@@ -137,36 +158,35 @@ function run(async, value, reducers) { + ); + } + +- str = Promise.resolve(thing).then((value) => { ++ str = Promise.resolve(ops.resolveThenable(thing)).then((value) => { + const i = flatten(value, index); + if (i < 0) stringified[index] = i; + }); + } else { +- const type = get_type(thing); ++ const tag = ops.tag(thing); + +- switch (type) { ++ switch (tag) { + case 'Number': + case 'String': + case 'Boolean': + case 'BigInt': +- str = `["Object",${flatten(thing.valueOf())}]`; ++ str = `["Object",${flatten(ops.unbox(thing))}]`; + break; + + case 'Date': +- const valid = !isNaN(thing.getDate()); +- str = `["Date","${valid ? thing.toISOString() : ''}"]`; ++ str = `["Date","${ops.dateISO(thing)}"]`; + break; + + case 'URL': +- str = `["URL",${stringify_string(thing.toString())}]`; ++ str = `["URL",${stringify_string(ops.toStringValue(thing))}]`; + break; + + case 'URLSearchParams': +- str = `["URLSearchParams",${stringify_string(thing.toString())}]`; ++ str = `["URLSearchParams",${stringify_string(ops.toStringValue(thing))}]`; + break; + + case 'RegExp': +- const { source, flags } = thing; ++ const { source, flags } = ops.regExp(thing); + str = flags + ? `["RegExp",${stringify_string(source)},"${flags}"]` + : `["RegExp",${stringify_string(source)}]`; +@@ -182,14 +202,16 @@ function run(async, value, reducers) { + // is what protects against the DoS of e.g. `arr[1000000] = 1`. + let mostly_dense = false; + ++ const length = ops.arrayLength(thing); ++ + str = '['; + +- for (let i = 0; i < thing.length; i += 1) { ++ for (let i = 0; i < length; i += 1) { + if (i > 0) str += ','; + +- if (Object.hasOwn(thing, i)) { ++ if (ops.hasOwnIndex(thing, i)) { + keys.push(`[${i}]`); +- str += flatten(thing[i]); ++ str += flatten(ops.get(thing, i)); + keys.pop(); + } else if (mostly_dense) { + // Use dense encoding. The heuristic guarantees the +@@ -228,19 +250,19 @@ function run(async, value, reducers) { + // + // Sparse encoding is cheaper when: + // (4 + d) + P * (d + 1) < (L - P) * 3 +- const populated_keys = valid_array_indices(/** @type {any[]} */ (thing)); ++ const populated_keys = ops.arrayIndices(thing); + const population = populated_keys.length; +- const d = String(thing.length).length; ++ const d = String(length).length; + +- const hole_cost = (thing.length - population) * 3; ++ const hole_cost = (length - population) * 3; + const sparse_cost = 4 + d + population * (d + 1); + + if (hole_cost > sparse_cost) { +- str = '[' + SPARSE + ',' + thing.length; ++ str = '[' + SPARSE + ',' + length; + for (let j = 0; j < populated_keys.length; j++) { + const key = populated_keys[j]; + keys.push(`[${key}]`); +- str += ',' + key + ',' + flatten(thing[key]); ++ str += ',' + key + ',' + flatten(ops.get(thing, key)); + keys.pop(); + } + break; +@@ -259,7 +281,7 @@ function run(async, value, reducers) { + case 'Set': + str = '["Set"'; + +- for (const value of thing) { ++ for (const value of ops.setValues(thing)) { + str += `,${flatten(value)}`; + } + +@@ -269,8 +291,13 @@ function run(async, value, reducers) { + case 'Map': + str = '["Map"'; + +- for (const [key, value] of thing) { +- keys.push(`.get(${is_primitive(key) ? stringify_primitive(key) : '...'})`); ++ for (const [key, value] of ops.mapEntries(thing)) { ++ const key_type = ops.typeOf(key); ++ const key_is_primitive = ++ key_type !== 'object' && key_type !== 'function' && key_type !== 'symbol'; ++ keys.push( ++ `.get(${key_is_primitive ? stringify_primitive(ops.primitive(key)) : '...'})` ++ ); + str += `,${flatten(key)},${flatten(value)}`; + keys.pop(); + } +@@ -291,14 +318,13 @@ function run(async, value, reducers) { + case 'BigInt64Array': + case 'BigUint64Array': + case 'DataView': { +- /** @type {import("./types.js").TypedArray} */ +- const typedArray = thing; +- str = '["' + type + '",' + flatten(typedArray.buffer); ++ const info = ops.viewInfo(thing); ++ str = '["' + tag + '",' + flatten(info.buffer); + + // handle subarrays +- if (typedArray.byteLength !== typedArray.buffer.byteLength) { ++ if (info.byteLength !== info.bufferByteLength) { + // to be used with `new TypedArray(buffer, byteOffset, length)` +- str += `,${typedArray.byteOffset},${typedArray.length}`; ++ str += `,${info.byteOffset},${info.length}`; + } + + str += ']'; +@@ -306,9 +332,7 @@ function run(async, value, reducers) { + } + + case 'ArrayBuffer': { +- /** @type {ArrayBuffer} */ +- const arraybuffer = thing; +- const base64 = encode64(arraybuffer); ++ const base64 = encode64(ops.arrayBuffer(thing)); + + str = `["ArrayBuffer","${base64}"]`; + break; +@@ -322,21 +346,23 @@ function run(async, value, reducers) { + case 'Temporal.PlainMonthDay': + case 'Temporal.PlainYearMonth': + case 'Temporal.ZonedDateTime': +- str = `["${type}",${stringify_string(thing.toString())}]`; ++ str = `["${tag}",${stringify_string(ops.toStringValue(thing))}]`; + break; + +- default: +- if (!is_plain_object(thing)) { ++ default: { ++ const shape = ops.objectShape(thing); ++ ++ if (shape.kind === 'not-plain') { + throw new DevalueError(`Cannot stringify arbitrary non-POJOs`, keys, thing, value); + } + +- if (enumerable_symbols(thing).length > 0) { ++ if (shape.kind === 'symbol-keys') { + throw new DevalueError(`Cannot stringify POJOs with symbolic keys`, keys, thing, value); + } + +- if (Object.getPrototypeOf(thing) === null) { ++ if (shape.kind === 'null-proto') { + str = '["null"'; +- for (const key of Object.keys(thing)) { ++ for (const key of shape.keys) { + if (key === '__proto__') { + throw new DevalueError( + `Cannot stringify objects with __proto__ keys`, +@@ -347,14 +373,14 @@ function run(async, value, reducers) { + } + + keys.push(stringify_key(key)); +- str += `,${stringify_string(key)},${flatten(thing[key])}`; ++ str += `,${stringify_string(key)},${flatten(ops.get(thing, key))}`; + keys.pop(); + } + str += ']'; + } else { + str = '{'; + let started = false; +- for (const key of Object.keys(thing)) { ++ for (const key of shape.keys) { + if (key === '__proto__') { + throw new DevalueError( + `Cannot stringify objects with __proto__ keys`, +@@ -367,11 +393,12 @@ function run(async, value, reducers) { + if (started) str += ','; + started = true; + keys.push(stringify_key(key)); +- str += `${stringify_string(key)}:${flatten(thing[key])}`; ++ str += `${stringify_string(key)}:${flatten(ops.get(thing, key))}`; + keys.pop(); + } + str += '}'; + } ++ } + } + } + +diff --git a/src/types.d.ts b/src/types.d.ts +index 5f1df8487a35ac493ca0d3221231b773417ca031..f7219ae27ab705fcbb83b8bc5eed8efe5729cc5d 100644 +--- a/src/types.d.ts ++++ b/src/types.d.ts +@@ -11,3 +11,177 @@ export type TypedArray = + | Float64Array + | BigInt64Array + | BigUint64Array; ++ ++/** ++ * The introspection/extraction operations `stringify` performs on the value ++ * being serialized. Every dynamic operation — property reads, prototype ++ * method calls, iteration, type classification — goes through this ++ * interface, so overriding members lets you control exactly how values are ++ * inspected. ++ * ++ * Use cases: ++ * - **Side-effect-free serialization**: replace operations that can execute ++ * user code (getters, proxy traps, patched prototypes, `Symbol.toStringTag` ++ * accessors) with implementations based on captured intrinsics, internal ++ * slots, or property descriptors. ++ * - **Foreign-runtime serialization**: serialize values that live in another ++ * JavaScript runtime (a `node:vm` context, a WASM-hosted engine, a remote ++ * process) by implementing the operations over handle objects. The ++ * `stringify` algorithm never touches the value directly, so "value" can ++ * be any opaque token as long as the operations agree on what it means. ++ * ++ * All members are optional when passed to `stringify` — omitted members fall ++ * back to the defaults (native behavior, exported as `defaultOperations`). ++ */ ++export interface StringifyOperations { ++ /** ++ * Returns the key used for deduplication and cycle detection (compared ++ * with `Map` key semantics). Two values that represent the same logical ++ * object must return the same key. Default: the value itself. ++ * ++ * Override this when serializing through handles, where two distinct ++ * handle objects may refer to the same underlying value. ++ */ ++ identify(value: any): unknown; ++ ++ /** ++ * Classifies a value. Same contract as the `typeof` operator, except ++ * `null` must be reported as `'null'` (not `'object'`). ++ */ ++ typeOf(value: any): ++ | 'undefined' ++ | 'null' ++ | 'boolean' ++ | 'number' ++ | 'bigint' ++ | 'string' ++ | 'symbol' ++ | 'function' ++ | 'object'; ++ ++ /** ++ * Extracts the host-JavaScript primitive from a value whose `typeOf` is ++ * `'null'`, `'boolean'`, `'number'`, `'bigint'` or `'string'`. ++ * Default: the value itself (it already is the primitive). ++ */ ++ primitive(value: any): undefined | null | boolean | number | bigint | string; ++ ++ /** ++ * Returns the brand of an object value — the strings produced by ++ * `Object.prototype.toString` without the wrapping (`'Date'`, `'Array'`, ++ * `'Map'`, `'Object'`, `'Temporal.Instant'`, …). This decides which ++ * serialization strategy is used, so hardened implementations should use ++ * engine-level brand checks rather than (spoofable, getter-invoking) ++ * `Symbol.toStringTag` lookups. ++ */ ++ tag(value: any): string; ++ ++ /** Returns true if the object value should be treated as a thenable. */ ++ isThenable(value: any): boolean; ++ ++ /** ++ * Resolves a thenable to its settled value (which is then serialized). ++ * Only called from `stringifyAsync` for values where `isThenable` ++ * returned true. ++ */ ++ resolveThenable(value: any): Promise; ++ ++ /** ++ * Extracts the inner value of a boxed primitive (`Number`, `String`, ++ * `Boolean`, `BigInt` objects). Equivalent to `value.valueOf()`. The ++ * result is serialized recursively, so it may be a foreign value/handle. ++ */ ++ unbox(value: any): any; ++ ++ /** ++ * Returns the ISO string for a `Date` value, or `''` for an invalid ++ * date. Equivalent to `value.toISOString()`. ++ */ ++ dateISO(value: any): string; ++ ++ /** ++ * Returns the string form of a `URL`, `URLSearchParams` or `Temporal.*` ++ * value. Equivalent to `value.toString()`. ++ */ ++ toStringValue(value: any): string; ++ ++ /** Returns the source and flags of a `RegExp` value. */ ++ regExp(value: any): { source: string; flags: string }; ++ ++ /** ++ * Returns an iterable over the elements of a `Set` value. The iterable ++ * is consumed on the host; elements may be foreign values/handles. ++ */ ++ setValues(value: any): Iterable; ++ ++ /** ++ * Returns an iterable over the `[key, value]` entries of a `Map` value. ++ * The iterable is consumed on the host; keys/values may be foreign ++ * values/handles. ++ */ ++ mapEntries(value: any): Iterable<[any, any]>; ++ ++ /** ++ * Returns the view metadata of a typed array or `DataView` value. ++ * `length` is only meaningful for typed arrays. `buffer` is serialized ++ * recursively, so it may be a foreign value/handle. ++ */ ++ viewInfo(value: any): { ++ buffer: any; ++ byteOffset: number; ++ byteLength: number; ++ length?: number; ++ bufferByteLength: number; ++ }; ++ ++ /** ++ * Returns a host `ArrayBuffer` with the bytes of an `ArrayBuffer` value. ++ * Default: the value itself. Foreign-runtime implementations should copy ++ * the bytes into a host buffer. ++ */ ++ arrayBuffer(value: any): ArrayBuffer; ++ ++ /** Returns the length of an `Array` value. */ ++ arrayLength(value: any): number; ++ ++ /** Returns true if an `Array` value has an own element at `index`. */ ++ hasOwnIndex(value: any, index: number): boolean; ++ ++ /** ++ * Returns the populated indices of a (sparse) `Array` value as strings, ++ * in ascending order. Equivalent to `Object.keys(value)` filtered to ++ * valid array indices. ++ */ ++ arrayIndices(value: any): string[]; ++ ++ /** ++ * Classifies a plain-object candidate: ++ * - `{ kind: 'plain' | 'null-proto', keys }` — a serializable POJO and ++ * its own enumerable string keys ++ * - `{ kind: 'not-plain' }` — a non-POJO (stringify throws) ++ * - `{ kind: 'symbol-keys' }` — a POJO with enumerable symbol keys ++ * (stringify throws) ++ */ ++ objectShape( ++ value: any ++ ): ++ | { kind: 'plain' | 'null-proto'; keys: string[] } ++ | { kind: 'not-plain' } ++ | { kind: 'symbol-keys' }; ++ ++ /** ++ * Reads a property from an `Array` or plain-object value. Equivalent to ++ * `value[key]`. Hardened implementations can read through property ++ * descriptors to control what happens for accessor properties. ++ */ ++ get(value: any, key: string | number): any; ++} ++ ++/** Options for `stringify` and `stringifyAsync`. */ ++export interface StringifyOptions { ++ /** ++ * Overrides for the introspection/extraction operations used while ++ * serializing. Omitted members fall back to `defaultOperations`. ++ */ ++ operations?: Partial; ++} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8195b4f798..33246e5a36 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -64,6 +64,11 @@ overrides: devalue: 5.8.1 '@sveltejs/acorn-typescript': 1.0.10 +patchedDependencies: + devalue@5.8.1: + hash: e1ec892cb378119c2d3be4a7c15e3cf1447437d3a12a4baeda42c4e25a81c325 + path: patches/devalue@5.8.1.patch + importers: .: @@ -553,7 +558,7 @@ importers: version: 4.4.3(supports-color@8.1.1) devalue: specifier: 5.8.1 - version: 5.8.1 + version: 5.8.1(patch_hash=e1ec892cb378119c2d3be4a7c15e3cf1447437d3a12a4baeda42c4e25a81c325) ms: specifier: 2.1.3 version: 2.1.3 @@ -19490,7 +19495,7 @@ snapshots: consola: 3.4.2 defu: 6.1.7 destr: 2.0.5 - devalue: 5.8.1 + devalue: 5.8.1(patch_hash=e1ec892cb378119c2d3be4a7c15e3cf1447437d3a12a4baeda42c4e25a81c325) errx: 0.1.0 escape-string-regexp: 5.0.0 exsolve: 1.0.8 @@ -19560,7 +19565,7 @@ snapshots: consola: 3.4.2 defu: 6.1.7 destr: 2.0.5 - devalue: 5.8.1 + devalue: 5.8.1(patch_hash=e1ec892cb378119c2d3be4a7c15e3cf1447437d3a12a4baeda42c4e25a81c325) errx: 0.1.0 escape-string-regexp: 5.0.0 exsolve: 1.0.8 @@ -23753,7 +23758,7 @@ snapshots: '@types/cookie': 0.6.0 acorn: 8.16.0 cookie: 0.6.0 - devalue: 5.8.1 + devalue: 5.8.1(patch_hash=e1ec892cb378119c2d3be4a7c15e3cf1447437d3a12a4baeda42c4e25a81c325) esm-env: 1.2.2 kleur: 4.1.5 magic-string: 0.30.21 @@ -23775,7 +23780,7 @@ snapshots: '@types/cookie': 0.6.0 acorn: 8.16.0 cookie: 0.6.0 - devalue: 5.8.1 + devalue: 5.8.1(patch_hash=e1ec892cb378119c2d3be4a7c15e3cf1447437d3a12a4baeda42c4e25a81c325) esm-env: 1.2.2 kleur: 4.1.5 magic-string: 0.30.21 @@ -23797,7 +23802,7 @@ snapshots: '@types/cookie': 0.6.0 acorn: 8.16.0 cookie: 0.6.0 - devalue: 5.8.1 + devalue: 5.8.1(patch_hash=e1ec892cb378119c2d3be4a7c15e3cf1447437d3a12a4baeda42c4e25a81c325) esm-env: 1.2.2 kleur: 4.1.5 magic-string: 0.30.21 @@ -25820,7 +25825,7 @@ snapshots: clsx: 2.1.1 common-ancestor-path: 2.0.0 cookie: 1.1.1 - devalue: 5.8.1 + devalue: 5.8.1(patch_hash=e1ec892cb378119c2d3be4a7c15e3cf1447437d3a12a4baeda42c4e25a81c325) diff: 8.0.3 dset: 3.1.4 es-module-lexer: 2.0.0 @@ -25912,7 +25917,7 @@ snapshots: clsx: 2.1.1 common-ancestor-path: 2.0.0 cookie: 1.1.1 - devalue: 5.8.1 + devalue: 5.8.1(patch_hash=e1ec892cb378119c2d3be4a7c15e3cf1447437d3a12a4baeda42c4e25a81c325) diff: 8.0.3 dset: 3.1.4 es-module-lexer: 2.0.0 @@ -27126,7 +27131,7 @@ snapshots: detect-node-es@1.1.0: {} - devalue@5.8.1: {} + devalue@5.8.1(patch_hash=e1ec892cb378119c2d3be4a7c15e3cf1447437d3a12a4baeda42c4e25a81c325): {} devlop@1.1.0: dependencies: @@ -30739,7 +30744,7 @@ snapshots: consola: 3.4.2 cookie-es: 3.1.1 defu: 6.1.7 - devalue: 5.8.1 + devalue: 5.8.1(patch_hash=e1ec892cb378119c2d3be4a7c15e3cf1447437d3a12a4baeda42c4e25a81c325) errx: 0.1.0 escape-string-regexp: 5.0.0 exsolve: 1.0.8 @@ -30868,7 +30873,7 @@ snapshots: consola: 3.4.2 cookie-es: 3.1.1 defu: 6.1.7 - devalue: 5.8.1 + devalue: 5.8.1(patch_hash=e1ec892cb378119c2d3be4a7c15e3cf1447437d3a12a4baeda42c4e25a81c325) errx: 0.1.0 escape-string-regexp: 5.0.0 exsolve: 1.0.8 @@ -33417,7 +33422,7 @@ snapshots: aria-query: 5.3.1 axobject-query: 4.1.0 clsx: 2.1.1 - devalue: 5.8.1 + devalue: 5.8.1(patch_hash=e1ec892cb378119c2d3be4a7c15e3cf1447437d3a12a4baeda42c4e25a81c325) esm-env: 1.2.2 esrap: 2.2.13(@typescript-eslint/types@8.46.4) is-reference: 3.0.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index baa4158d4b..a49a54ba19 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -39,6 +39,14 @@ overrides: devalue: 5.8.1 '@sveltejs/acorn-typescript': 1.0.10 +# devalue 5.8.1 + the pending `operations` option from +# https://github.com/sveltejs/devalue/pull/172 (head 2e97724, adapted to the +# 5.8.1 release: its merged TypedArray/DataView case is preserved so encoded +# output stays byte-identical). Drop the patch and bump the catalog version +# once #172 ships in a release. +patchedDependencies: + devalue@5.8.1: patches/devalue@5.8.1.patch + savePrefix: "" minimumReleaseAge: 2880 From 16d5914121d10644f3365076bdee657e279393fc Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:22:07 -0700 Subject: [PATCH 03/13] Record wire-format byte-stability corpus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins the exact devalue strings the workflow→step boundary produces for a representative corpus, recorded from the serialization code before the hardened stringify operations. The follow-up hardening commit must keep every byte identical. --- .../serialization/byte-corpus.snapshot.json | 60 +++++ .../core/src/serialization/byte-corpus.ts | 233 ++++++++++++++++++ .../src/serialization/byte-stability.test.ts | 84 +++++++ 3 files changed, 377 insertions(+) create mode 100644 packages/core/src/serialization/byte-corpus.snapshot.json create mode 100644 packages/core/src/serialization/byte-corpus.ts create mode 100644 packages/core/src/serialization/byte-stability.test.ts diff --git a/packages/core/src/serialization/byte-corpus.snapshot.json b/packages/core/src/serialization/byte-corpus.snapshot.json new file mode 100644 index 0000000000..e0ddc38fc2 --- /dev/null +++ b/packages/core/src/serialization/byte-corpus.snapshot.json @@ -0,0 +1,60 @@ +{ + "primitives": "[[1,2,3,4,5,6],null,true,false,\"text\",42,-1.5]", + "special numbers": "[[-3,-4,-5,-6]]", + "undefined": "-1", + "bigint": "[{\"big\":1,\"negative\":3},[\"BigInt\",2],\"42\",[\"BigInt\",4],\"-7\"]", + "string escapes": "[[1,2,3,4],\"\\u003Cscript>\",\"line\\nbreak\",\"tab\\there\",\"u2028\\u2028u2029\\u2029\"]", + "nested plain object": "[{\"a\":1,\"nested\":2},1,{\"b\":3,\"c\":6},[4,5],2,3,{\"deep\":7},\"yes\"]", + "null prototype object": "[[\"null\",\"x\",1],1]", + "sparse array": "[[1,-2,2],1,3]", + "very sparse array": "[[-7,100,0,1,99,2],1,2]", + "repeated references": "[{\"first\":1,\"second\":1},{\"shared\":2},true]", + "cyclic object": "[{\"name\":1,\"self\":0},\"cycle\"]", + "date": "[[\"Date\",1],\"2023-11-14T22:13:20.000Z\"]", + "invalid date": "[[\"Date\",1],\".\"]", + "map": "[[\"Map\",1],[2,6],[3,4],\"k\",{\"v\":5},1,[7,8],2,\"two\"]", + "set": "[[\"Set\",1],[2,3,4],1,\"two\",{\"three\":5},3]", + "nested map in set": "[[\"Set\",1],[2],[\"Map\",3],[4],[5,6],\"inner\",1]", + "regexp": "[[\"RegExp\",1],{\"source\":2,\"flags\":3},\"ab+c\",\"gi\"]", + "regexp no flags": "[[\"RegExp\",1],{\"source\":2,\"flags\":3},\"plain\",\"\"]", + "url": "[[\"URL\",1],\"https://example.com/path?q=1#frag\"]", + "url search params": "[[\"URLSearchParams\",1],\"a=1&b=2\"]", + "empty url search params": "[[\"URLSearchParams\",1],\".\"]", + "headers": "[[\"Headers\",1],[2],[3,4],\"content-type\",\"text/plain\"]", + "array buffer": "[[\"ArrayBuffer\",1],\"AQIDBAUGBwg=\"]", + "empty array buffer": "[[\"ArrayBuffer\",1],\".\"]", + "uint8 array": "[[\"Uint8Array\",1],\"AQID\"]", + "uint8 subarray": "[[\"Uint8Array\",1],\"AwQFBg==\"]", + "int16 array": "[[\"Int16Array\",1],\"//8CAP3/\"]", + "float32 array": "[[\"Float32Array\",1],\"AADAPwAAIMA=\"]", + "float64 array": "[[\"Float64Array\",1],\"GC1EVPshCUA=\"]", + "bigint64 array": "[[\"BigInt64Array\",1],\"AQAAAAAAAAD+/////////w==\"]", + "uint8 clamped array": "[[\"Uint8ClampedArray\",1],\"AP8=\"]", + "data view": "[[\"DataView\",1],[\"ArrayBuffer\",2],\"CQgH\"]", + "data view subview": "[[\"DataView\",1,1,undefined],[\"ArrayBuffer\",2],\"AQIDBAUGBwg=\"]", + "node buffer": "[[\"Uint8Array\",1],\"aGk=\"]", + "boxed number": "[[\"Object\",1],42]", + "boxed string": "[[\"Object\",1],\"boxed\"]", + "boxed boolean": "[[\"Object\",1],false]", + "boxed bigint": "[[\"Object\",1],[\"BigInt\",2],\"123\"]", + "plain error": "[[\"Error\",1],{\"name\":2,\"message\":3,\"stack\":4},\"Error\",\"boom\",\"Error: boom\\n at \\u003Ccorpus>\"]", + "error subclasses": "[[1,5,9],[\"TypeError\",2],{\"message\":3,\"stack\":4},\"t\",\"TypeError: t\\n at \\u003Ccorpus>\",[\"RangeError\",6],{\"message\":7,\"stack\":8},\"r\",\"RangeError: r\\n at \\u003Ccorpus>\",[\"SyntaxError\",10],{\"message\":11,\"stack\":12},\"s\",\"SyntaxError: s\\n at \\u003Ccorpus>\"]", + "error with cause chain": "[[\"Error\",1],{\"name\":2,\"message\":3,\"stack\":4,\"cause\":5},\"Error\",\"outer\",\"Error: outer\\n at \\u003Ccorpus>\",[\"TypeError\",6],{\"message\":7,\"stack\":8},\"inner\",\"TypeError: inner\\n at \\u003Ccorpus>\"]", + "error with undefined cause": "[[\"Error\",1],{\"name\":2,\"message\":3,\"stack\":4,\"cause\":-1},\"Error\",\"has undefined cause\",\"Error: has undefined cause\\n at \\u003Ccorpus>\"]", + "custom named error": "[[\"Error\",1],{\"name\":2,\"message\":3,\"stack\":4},\"MyCustomError\",\"custom\",\"MyCustomError: custom\\n at \\u003Ccorpus>\"]", + "fatal error": "[[\"FatalError\",1],{\"message\":2,\"stack\":3},\"fatal\",\"FatalError: fatal\\n at \\u003Ccorpus>\"]", + "retryable error": "[[\"RetryableError\",1],{\"message\":2,\"stack\":3,\"retryAfter\":4},\"try again\",\"RetryableError: try again\\n at \\u003Ccorpus>\",1700000060000]", + "aggregate error": "[[\"AggregateError\",1],{\"message\":2,\"stack\":3,\"errors\":4},\"many\",\"AggregateError: many\\n at \\u003Ccorpus>\",[5],[\"Error\",6],{\"name\":7,\"message\":8,\"stack\":9},\"Error\",\"a\",\"Error: a\\n at \\u003Ccorpus>\"]", + "workflow function reference": "[[\"WorkflowFunction\",1],{\"workflowId\":2},\"workflow//corpus\"]", + "class reference": "[[\"Class\",1],{\"classId\":2},\"corpus/Class\"]", + "custom serialized instance": "[[\"Instance\",1],{\"classId\":2,\"data\":3},\"corpus/Thing\",{\"label\":4},\"hi\"]", + "object with getter": "[{\"plain\":1,\"computed\":2},1,\"from getter\"]", + "proxy over plain object": "[{\"wrapped\":1,\"n\":2},true,2]", + "step argument envelope": "[{\"args\":1,\"closureVars\":-1,\"thisVal\":-1},[2,6],{\"userId\":3,\"when\":4},\"u_1\",[\"Date\",5],\"2023-11-14T22:13:20.000Z\",3]", + "throws: bare function": "Failed to serialize step arguments\n╰▶ hint: Ensure you're passing workflow serializable types. Check the serialization docs to see what's serializable: https://workflow-sdk.dev/docs/foundations/serialization", + "throws: symbol": "Failed to serialize step arguments\n╰▶ hint: Ensure you're passing workflow serializable types. Check the serialization docs to see what's serializable: https://workflow-sdk.dev/docs/foundations/serialization", + "throws: pojo with symbol keys": "Failed to serialize step arguments\n╰▶ hint: Ensure you're passing workflow serializable types. Check the serialization docs to see what's serializable: https://workflow-sdk.dev/docs/foundations/serialization", + "throws: class instance without serializer": "Failed to serialize step arguments\n╰▶ hint: Ensure you're passing workflow serializable types. Check the serialization docs to see what's serializable: https://workflow-sdk.dev/docs/foundations/serialization", + "throws: promise (sync stringify)": "Failed to serialize step arguments\n╰▶ hint: Ensure you're passing workflow serializable types. Check the serialization docs to see what's serializable: https://workflow-sdk.dev/docs/foundations/serialization", + "throws: proto key": "Failed to serialize step arguments\n╰▶ hint: Ensure you're passing workflow serializable types. Check the serialization docs to see what's serializable: https://workflow-sdk.dev/docs/foundations/serialization" +} diff --git a/packages/core/src/serialization/byte-corpus.ts b/packages/core/src/serialization/byte-corpus.ts new file mode 100644 index 0000000000..76e5156196 --- /dev/null +++ b/packages/core/src/serialization/byte-corpus.ts @@ -0,0 +1,233 @@ +/** + * Shared corpus for the serialization byte-stability test. + * + * Each entry builds a value whose serialized devalue string is pinned in + * `byte-corpus.snapshot.json`. The snapshot was recorded from the + * serialization code as it existed BEFORE the hardened stringify operations + * were introduced (see operations.ts), so the accompanying test proves the + * hardening did not change a single byte of the wire format. + * + * Regenerating the snapshot is an explicit act: it means "I intend to change + * the wire format". See byte-stability.test.ts for how. + */ + +import { WORKFLOW_SERIALIZE } from '@workflow/serde'; +import { FatalError, RetryableError } from '@workflow/errors'; + +/** An error with a deterministic stack, safe to pin in a snapshot. */ +function fixedError(error: T, stack: string): T { + error.stack = stack; + return error; +} + +export class CorpusSerializableThing { + static classId = 'corpus/Thing'; + static [WORKFLOW_SERIALIZE](instance: CorpusSerializableThing) { + return { label: instance.label }; + } + constructor(public label: string) {} +} + +export function buildByteCorpus(): Array<{ name: string; value: unknown }> { + const shared = { shared: true }; + const cyclic: Record = { name: 'cycle' }; + cyclic.self = cyclic; + + const buffer = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]).buffer; + + const causeChain = fixedError( + new Error('outer', { + cause: fixedError( + new TypeError('inner'), + 'TypeError: inner\n at ' + ), + }), + 'Error: outer\n at ' + ); + + const retryable = fixedError( + new RetryableError('try again', { + retryAfter: new Date(1_700_000_060_000), + }), + 'RetryableError: try again\n at ' + ); + + const aggregate = fixedError( + new AggregateError( + [fixedError(new Error('a'), 'Error: a\n at ')], + 'many' + ), + 'AggregateError: many\n at ' + ); + + return [ + // ---- primitives & special numbers ---- + { name: 'primitives', value: [null, true, false, 'text', 42, -1.5] }, + { name: 'special numbers', value: [Number.NaN, Infinity, -Infinity, -0] }, + { name: 'undefined', value: undefined }, + { name: 'bigint', value: { big: 42n, negative: -7n } }, + { + name: 'string escapes', + value: ['