Skip to content

[pull] main from vercel:main - #492

Merged
pull[bot] merged 1 commit into
erickirt:mainfrom
vercel:main
Aug 1, 2026
Merged

[pull] main from vercel:main#492
pull[bot] merged 1 commit into
erickirt:mainfrom
vercel:main

Conversation

@pull

@pull pull Bot commented Aug 1, 2026

Copy link
Copy Markdown

See Commits and Changes for more details.


Created by pull[bot] (v2.0.0-alpha.4)

Can you help keep this open source service alive? 💖 Please sponsor : )

* feat(core): side-effect-free serialization of workflow VM values

Serialization runs on the host but inspects values constructed inside the
node:vm sandbox, so ordinary dynamic operations dispatch into the sandbox
realm and execute workflow code: `value.toISOString()`, `Array.from(map)`,
`Object.prototype.toString` (via Symbol.toStringTag), `.source`/`.flags`,
`.href`, view `.buffer`/`.byteOffset`/`.byteLength`, and error
`.message`/`.stack`/`.cause` reads.

That is a determinism hazard. A payload is serialized exactly once and is
never re-serialized on replay, so any workflow-visible side effect it
triggers exists only on the live path — a patched `Date.prototype.toISOString`
that consumes a seeded `Math.random()` draw, for example, shifts every
subsequent draw and diverges from replay.

This makes serialization side-effect free where the data allows it, and
observable where it does not:

- Classification uses engine brand checks (node:util types, internal-slot
  probes) instead of `instanceof global.X` and Object.prototype.toString, so
  it is immune to Symbol.hasInstance, reassigned sandbox globals, and
  Symbol.toStringTag spoofs. An unbranded value claiming a brand-decided tag
  is now classified as a plain object instead of being routed into an
  extractor that requires the real internal slot (unhardened devalue crashes
  on that input).
- Extraction goes through intrinsics captured at module load — host boot,
  before any workflow bundle runs — invoked with explicit receivers.
  Internal slots are realm-agnostic, so host intrinsics read VM-realm
  objects without touching the sandbox's patchable prototypes.
- Property access reads through descriptors, so plain data never invokes
  anything.

Where workflow code must run because the data lives behind it — getters,
proxies, custom [WORKFLOW_SERIALIZE] methods, toString() on
toStringTag-branded objects like Temporal polyfills — the execution is
preserved for compatibility and recorded in a new `CodecOptions.guestCodeStats`
sink, surfaced as workflow.serialization.guest_code_{executions,details} span
attributes. Consumers that retain a VM across steps can treat a non-empty
report as "serialization may have perturbed VM state".

Engine-provided accessors are deliberately not reported: V8 defines `stack`
as an own accessor on every Error instance, so reporting it would flag every
serialized error. Nativeness is decided with the captured host
Function.prototype.toString; the bound-function caveat is documented in
hardened.ts.

Requires devalue 5.9.0 for the pluggable `operations` option.

* chore: shorten changeset

* fix(core): close review gaps in hardened serialization

Five correctness fixes, all with repros:

- Callable proxies were treated as engine accessors. V8 returns
  `function () { [native code] }` from Function.prototype.toString for a
  proxy around a function rather than throwing, so a proxy-wrapped getter
  was cached as engine-provided and invoked unreported. Gate on
  types.isProxy first.

- Host builtins implemented in JavaScript were reported as workflow code.
  Node's DOMException.prototype.message/name are ordinary functions, so
  the nativeness test failed and every serialized DOMException reported
  two getter executions. They belong to the *host* realm, though, and
  workflow code cannot author a host-realm function — so provenance is
  now decided by nativeness OR host-realm `Function.prototype`, which are
  disjoint and together cover both cases (V8 installs `stack` per realm,
  so a VM error's getter is native but VM-realm).

- The extraReducers at the two VM call sites were still unhardened, and
  they run on every value the earlier reducers do not claim — which is
  exactly where the report has to be complete. `instanceof
  global.ReadableStream/WritableStream/Request/Response` consulted
  Symbol.hasInstance on the sandbox class (14 invocations for an ordinary
  payload once the classes are patched), and AbortController's guard did
  a bare `value.signal` read, so a non-enumerable `signal` getter ran
  with an empty report. All five now walk the prototype chain and read
  through descriptors.

- `__closureVarsFn` was invoked unreported on a purity argument that
  nothing checked: the property is reachable from workflow code, which
  can replace the compiler-generated function. step.ts now registers the
  generated function as trusted when it builds the proxy, so provenance
  is verified rather than assumed, and an unrecognized function is
  reported.

- The URL/URLSearchParams test patched prototypes of *host* classes
  injected into the sandbox, mutating them for the rest of the worker
  process. Restored in a finally.

Also, per review:

- `dehydrateStepArguments` / `dehydrateWorkflowReturnValue` take an
  optional GuestCodeStats out-param, so a retained-VM gate can consume
  the report instead of it being spent on span attributes. The
  report-completeness tests use it to exercise the real dehydrate path.

- Every intrinsic capture is now optional. The table is built at module
  scope, so a missing member was an import-time crash of @workflow/core
  rather than a degraded path; only SharedArrayBuffer was guarded, while
  URLSearchParams.prototype.size (Node 19.8+) and the WHATWG classes were
  assumed. Absent captures now make the corresponding reducer decline to
  match.

- Documented that recording is not prevention (a recorded getter calling
  Math.random() still advances the run's seeded PRNG), and that a
  `{ kind: 'proxy' }` report implies a silent shape change (a proxied Map
  serializes as a plain object).

- Parity coverage extended to DataView, boxed primitives, null-prototype
  objects, setter-only properties, DOMException, AggregateError, an
  accessor-valued Symbol.toStringTag, both RetryableError retryAfter
  paths, and a WORKFLOW_SERIALIZE class instance.

* fix(core): keep identifying proxied host classes

Every Next.js e2e job failed on the two webhook tests: the hook POST
returned 404 because `resumeWebhook` could not serialize its step return
value ("Cannot stringify arbitrary non-POJOs"), so no hook was ever
registered.

The value was a `NextRequest`, which Next.js hands over as a **Proxy**.
`isInstanceOfPrototype` rejected proxies outright, so the Request reducer
answered "not a Request" and devalue fell through to the POJO check. The
reasoning behind rejecting them — that proxied built-ins were never
serializable, because internal-slot reads throw on a proxy receiver — is
true for `Map`/`Date`/`URL`, whose reducers read internal slots, but not
for `Request`/`Response`/streams, whose reducers read ordinary
properties. Next's proxy forwards those with the target as receiver, so
they serialized fine before this PR.

Identification now walks through proxies, matching `instanceof`, and
records the traps rather than suppressing the answer. The three reducers
that do read internal slots (URL, URLSearchParams, Headers) fall back to
the dynamic read when the value is a proxy, so their behavior is exactly
what it was before — including throwing for a bare proxy over a built-in,
which threw before too.

Verified against the real thing: the full nextjs-turbopack e2e suite
(135 tests) passes locally, having reproduced the failure first and
confirmed a reverted `serialization.ts` fixed it.

The regression test uses a receiver-correcting proxy, which is what makes
NextRequest work in practice; a comment records that a bare
`new Proxy(request, {})` throws on undici's private slots with or without
this change.

* fix(core): state what the closure-fn mark proves, and correct stale docs

- `isInstanceOfPrototype`'s JSDoc still described the behavior removed in
  8bc462f (proxies rejected without firing traps), which is the opposite
  of what it now does.

- The `__closureVarsFn` provenance check proves the function was passed to
  `useStep`, not that this package generated it: `useStep` is published on
  the sandbox global, so workflow code can call it with a function of its
  own and have it marked. Renamed `registerTrustedFunction` /
  `isTrustedFunction` to `markUseStepClosureFn` / `isUseStepClosureFn` so
  the name states the boundary, and documented the laundering caveat
  alongside the existing ones. Marking still earns its keep — reporting
  every step that captures a variable would bury the signal — and closing
  the gap properly needs a compiler-emitted marker, which is a compiler
  change.

- Added the missing coverage for both sides of that check: an unmarked
  `__closureVarsFn` is invoked and reported, a marked one is invoked and
  not.

- `guestCodeStats` was documented as something a retained-VM gate consumes,
  but no runtime caller passes a sink; the executions reach telemetry from
  every dehydrate path regardless. Reworded both docs to say that, so the
  out-param is not mistaken for wiring that already exists.
@pull pull Bot locked and limited conversation to collaborators Aug 1, 2026
@pull pull Bot added the ⤵️ pull label Aug 1, 2026
@pull
pull Bot merged commit b732e91 into erickirt:main Aug 1, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant