mirror of
https://github.com/vercel/workflow.git
synced 2026-09-14 19:59:43 +08:00
b732e91fac
* 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
8bc462fb5 (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.
58 lines
1.4 KiB
YAML
58 lines
1.4 KiB
YAML
packages:
|
|
- workbench/*
|
|
- "!workbench/nitro"
|
|
- packages/*
|
|
- docs
|
|
- tarballs
|
|
|
|
catalog:
|
|
"@biomejs/biome": ^2.4.4
|
|
"@swc/core": 1.15.3
|
|
"@types/json-schema": ^7.0.15
|
|
"@types/node": 22.19.0
|
|
"@vercel/functions": ^3.4.3
|
|
"@vercel/oidc": 3.2.0
|
|
"@vercel/queue": 0.4.0
|
|
"@vitest/coverage-v8": ^4.1.10
|
|
ai: 6.0.116
|
|
enhanced-resolve: 5.19.0
|
|
esbuild: ^0.28.1
|
|
nitro: ^3.0.260610-beta
|
|
semver: 7.7.4
|
|
typescript: ^6.0.3
|
|
ulid: ~3.0.1
|
|
undici: 7.28.0
|
|
vitest: ^4.1.10
|
|
zod: ~4.3.6
|
|
|
|
onlyBuiltDependencies:
|
|
- esbuild
|
|
|
|
overrides:
|
|
# Single @opentelemetry/api instance workspace-wide: with two resolved
|
|
# copies (1.9.0 + 1.9.1), the copy that registers the tracer provider and
|
|
# the copy a package imports can differ, and the API's global-registration
|
|
# version check then hands the importer a noop tracer — world-vercel's
|
|
# spans were silently dropped this way while core's flowed (#2900).
|
|
'@opentelemetry/api': 1.9.1
|
|
rfc6902: 5.1.2
|
|
devalue: 5.9.0
|
|
'@sveltejs/acorn-typescript': 1.0.10
|
|
# postcss < 8.5.18 is vulnerable to CVE-2026-45623 and GHSA-r28c-9q8g-f849
|
|
# (arbitrary file read / .map disclosure via attacker-controlled
|
|
# sourceMappingURL). Forces transitive pins (e.g. next.js's postcss@8.4.31)
|
|
# onto a patched version.
|
|
'postcss@<8.5.18': 8.5.22
|
|
|
|
savePrefix: ""
|
|
|
|
minimumReleaseAge: 2880
|
|
|
|
minimumReleaseAgeExclude:
|
|
- '@turbo/*'
|
|
- 'turbo'
|
|
- '@vercel/*'
|
|
- '@workflow/*'
|
|
- 'esbuild'
|
|
- '@esbuild/*'
|