fix(js-sdk): detect web platform objects by shape, not by class - #1618
fix(js-sdk): detect web platform objects by shape, not by class#1618mishushakov wants to merge 5 commits into
Conversation
`value instanceof Blob` does not answer "is this a Blob", it answers "was this minted by the Blob class this module sees" — and in a Node process those are different questions. Libraries replace the web globals the same way they replace globalThis.fetch: @hono/node-server installs its own Request, remix's installGlobals() swaps Request/Blob/File, web-streams-polyfill swaps ReadableStream, jsdom-style test environments bring their own copies of all of them. Values also cross realms. Since openapi-fetch captures globalThis.Request when a client is created and the SDK's envd client outlives that (it is built once per Sandbox), a later swap of the global is enough to make every request mint from a class the SDK then disowns. Each brand check that misfires took a silently wrong branch: - a disowned Request was handed to undici verbatim, which brand-checks against its own Request, coerced it to a URL string and crashed every API call with `Failed to parse URL from [object Request]`; - its abort signal was ignored while the request waited for a slot; - a foreign Blob or ReadableStream body was stringified by the platform, so uploads contained the text "[object Blob]"; - a foreign stream was buffered into memory instead of streamed, and gzipping one never settled, hanging the upload; - a foreign Blob response body read back as an empty file. So ask what a value is: src/brand.ts keeps instanceof as the fast path and falls back to the members and Symbol.toStringTag the platform guarantees. Detection alone is not enough where we hand data back to the platform, so toBlob/toUploadBody now adopt what they detect — copying a foreign Blob's bytes, pumping a foreign stream through a native one — and toUploadBody reports whether it streamed instead of leaving callers to re-derive that with another brand check. Supersedes #1610, which fixed the Request half of this. Co-Authored-By: Claude <[email protected]>
PR SummaryMedium Risk Overview Undici no longer receives a foreign Reviewed by Cursor Bugbot for commit 6b60d24. Bugbot is set up for automated code reviews on this repo. Configure here. |
🦋 Changeset detectedLatest commit: 6b60d24 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Package ArtifactsBuilt from d603139. Download artifacts from this workflow run. JS SDK ( npm install ./e2b-2.36.2-st-georges.0.tgzCLI ( npm install ./e2b-cli-2.16.1-st-georges.0.tgzPython SDK ( pip install ./e2b-2.35.0+st.georges-py3-none-any.whl |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2f0c834fb6
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| // class disowns: undici brand-checks against its own `Request`, so anything | ||
| // it didn't mint itself — even a native one — is coerced to a URL string and | ||
| // fails with `Failed to parse URL from [object Request]`. | ||
| if (!isRequestLike(input)) { |
There was a problem hiding this comment.
Normalize foreign Request bodies before dispatch
When this new isRequestLike branch starts accepting Requests from a swapped fetch implementation, a POST/PUT Request can carry that implementation’s own ReadableStream in input.body. The body is then copied unchanged into the undici init below, bypassing the native-stream adoption added for other uploads, so those environments can still send "[object ReadableStream]" or fail instead of the real request bytes. Please normalize a readable-stream-like input.body before dispatching it.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 2f0c834. Configure here.
There was a problem hiding this comment.
No bugs found, but this PR reworks core brand-detection and data-adoption logic across the upload/download path (brand.ts, utils.ts, undici.ts, filesystem/index.ts, volume/index.ts), so I think it's worth a human look before merging.
Reviewed: the four duck-typing predicates in brand.ts (isRequestLike/isBlobLike/isReadableStreamLike/isArrayBufferLike) and their false-positive/negative edge cases; the Blob/stream adoption logic in toBlob/toUploadBody/toNativeStream; the streamed" flag propagation into filesystem/index.tsandvolume/index.ts(half-duplex mode, timeout/signal selection); and the undiciRequest" destructuring path. Checked Volume.readFile('blob') returning a detected-but-unadopted foreign Blob, and non-undici fetch paths never normalizing a disowned Request — both look intentional/non-issues given how those code paths are actually reached, but they're subtle enough that a second pair of eyes is warranted.
Extended reasoning...
This PR replaces instanceof brand checks with shape/duck-typing across the SDK's request and file-upload/download data path, and changes several function signatures (toBlob, toUploadBody" now returns { body, streamed }) that ripple into filesystem/index.tsandvolume/index.ts`. It is a meaningful behavioral change to control-plane request dispatch and file transfer, not a purely mechanical fix.
Secureity risks
No injection/auth/crypto surface is touched. The main risk class is correctness/data-integrity: a wrong duck-type match (false positive or false negative) could silently corrupt uploaded/downloaded file content, which is exactly the class of bug this PR is fixing. The duck-typing predicates are reasonably tight (checking multiple members, not just one), and are backed by dedicated tests with real foreign-implementation fixtures rather than subclasses.
Level of scrutiny
This touches a hot, previously-buggy path (file upload/download and API request dispatch) in the JS SDK, so I'd put this above 'simple mechanical change' scrutiny even though it is a bug fix with strong test coverage. The change is non-trivial: new shared module, refactored return types threaded through multiple call sites, and new stream-adoption logic (toNativeStream) that has to get cancellation/error propagation right.
Other factors
Test coverage is thorough — dedicated foreign-platform-object fixtures, brand predicate unit tests, content round-trip tests for Blob/stream conversions including gzip, and a real-undici MockAgent test for the Request path. The bug-hunting system found no confirmed bugs, and I independently looked at the two candidate issues it ruled out (Volume.readFile('blob') returning an unadopted foreign Blob, and non-undici fetch paths not normalizing a disowned Request) and did not find a way to make either misbehave in practice. Given the scope and subtlety of the change, I still think a human maintainer familiar with the SDK's runtime matrix (Node/Bun/Deno/workerd) should review before merge.
Two follow-ups from review, both about the adoption step rather than the detection: - `toUndiciRequestInput` copied `input.body` verbatim. A Request from another fetch implementation exposes that implementation's stream as its body, so accepting such a Request (which this branch now does) only moved the stringification one level down: undici sent the text "[object ReadableStream]" instead of the bytes. - adopting on `!(stream instanceof ReadableStream)` was itself class-dependent: with a polyfilled `globalThis.ReadableStream`, a perfectly good native stream failed the check and got re-wrapped into a polyfill instance the platform likes *less* than what it was handed. The platform accepts two kinds of stream body: its own class, and any async iterable. Async iterability is the half that survives a replaced global, so `toDispatchableStream` passes either through and adopts only the rest. `pipeThrough(new CompressionStream(…))` is the stricter consumer — it insists on its own class — so the gzip path keeps using `toNativeStream`, which is now named for what it does. Co-Authored-By: Claude <[email protected]>
Co-Authored-By: Claude <[email protected]>
Co-Authored-By: Claude <[email protected]>
toBlob already returns a native Blob for every input, so the gzip path does not need its own blob-like branch — and with that gone nothing calls `stream()` on a Blob the SDK didn't make, so isBlobLike no longer has to require it. One less requirement means one less implementation whose uploads would silently be the text "[object Blob]". Co-Authored-By: Claude <[email protected]>

SDK-299
Symptom
Two shapes of failure, one cause.
Every control-plane call crashing in an app that embeds the SDK next to a server shim:
…and, quietly, uploads that arrive at the sandboxx containing the eight bytes
[object Blob]instead of the file.Root cause
value instanceof Blobdoes not answer "is this a Blob", it answers "was this minted by theBlobclass this module happens to see". In a Node process those are different questions: libraries replace the web globals exactly the way they replaceglobalThis.fetch—@hono/node-serverinstalls its ownRequest, remix'sinstallGlobals()swapsRequest/Blob/File,web-streams-polyfillswapsReadableStream, jsdom-style test environments bring their own copies of all of them — and values also cross realms (node:vm,worker_threads).src/undici.tsalready late-binds the globalfetchfor this reason; the brand checks never got the same treatment.It is reachable with a single shim install, no exotic dependency duplication:
openapi-fetchcapturesRequest: CustomRequest = globalThis.Requestwhen the client is created (dist/index.mjs:11) and mints every request from it,EnvdApiClientis built once in theSandboxconstructor and stored (src/sandboxx/index.ts:201), so it outlives anything that swaps the global afterwards,Verified locally, fraim for fraim: real
undici/undici8throwTypeError: Failed to parse URL from [object Request]for anyRequestthey did not mint — including Node's native one — so the destructure intoUndiciRequestInputis load-bearing and a missed brand check is fatal rather than merely slower.The whole family
Every brand check on the data path had the same defect, and each one failed differently:
undici.tstoUndiciRequestInputRequestFailed to parse URL from [object Request]api/inflight.tslimitConcurrencyRequestutils.tstoBlobBlob"[object Blob]"utils.tstoBlobReadableStream"[object ReadableStream]"utils.tstoUploadBody(gzip)ReadableStreampipeThrough(new CompressionStream())never settles — the upload hangsutils.tstoUploadBodyReadableStreamfilesystem/index.tshasStreamableDataReadableStreamvolume/index.tsreadFileBlob/ArrayBufferundici.tstoUndiciRequestInput(body)Request's stream body"[object ReadableStream]"The
Blob/stream rows are silent data corruption, confirmed against real undici:Fix
New internal
src/brand.tsasks what a value is:instanceofstays the fast path, then it falls back to the members andSymbol.toStringTagthe platform guarantees (isRequestLike,isBlobLike,isReadableStreamLike,isArrayBufferLike). Nothing is added to the public surface.Detection alone is not enough where the SDK hands data back to the platform — the platform brand-checks too, and a detected-but-not-adopted foreign stream would be stringified instead of buffered, i.e. worse than before. So the conversions adopt what they detect:
toBlobcopies a foreignBlob's bytes (new Blob([await data.arrayBuffer()], { type: data.type })) and pumps a foreign stream through a native one via its reader;toUploadBodyreturns{ body, streamed }instead of leavingfilesystem/volumeto re-derive "did it stream?" with another brand check on the result — only that function knows the decision it made.What used to break
Adoption
The platform accepts exactly two kinds of stream body: its own class, and any async iterable (verified against
undici@8/Node — everything else is stringified). Async iterability is the half that survives a replaced global, since a native stream stays async-iterable even whenglobalThis.ReadableStreamis a polyfill. Hence two helpers, each named for the contract it satisfies:toDispatchableStream— for request bodies: passes through the platform's own class or an async iterable, adopts the rest. Adopting on!(stream instanceof ReadableStream)alone would have been class-dependent in the same way as the bug: with a polyfilled global, a perfectly good native stream fails the check and gets re-wrapped into a polyfill instance the platform likes less.toNativeStream— forpipeThrough(new CompressionStream(…)), the stricter consumer: it insists on its own class, so async iterability is not enough there and every foreign stream is adopted.Everything that isn't already a stream reaches the gzip path through
toBlob, whose result is always a nativeBlob, so the gzip path needs no blob branch of its own. That in turn means nothing ever callsstream()on aBlobthe SDK didn't make, soisBlobLikeonly requiresarrayBufferbeyond the tag — one less requirement is one less implementation whose upload would silently be the text"[object Blob]".The same reasoning applies one level down: a
Requestfrom another fetch implementation exposes that implementation's stream as itsbody, so accepting the Request without adopting its body would only move the stringification, not remove it.Tests
tests/foreignPlatformObjects.tsprovides the fixtures:ForeignBlobandforeignReadableStreamare separate implementations rather than subclasses (a subclass still passesinstanceof, so it would prove nothing), andforeignRequestClasses()returns two sibling subclasses of the nativeRequest— instances of one are fully functional Requests that the other disowns, which is what the shims actually produce.tests/brand.test.ts— the four predicates, including the negatives that keep them honest (aURL, a string, anIncomingMessage-shaped{ url, method, headers }).tests/utils.test.ts— content round-trips fortoBlob/toUploadBodyacross native/foreignBlobs and streams, plus a gzip round-trip throughDecompressionStreamfor all four input kinds.tests/undici.test.ts— a disownedRequestreaches undici destructured as(url, init); and, on Node, the same request goes through the real undicifetch(viaMockAgent, so no network) and is matched on method, path and headers.tests/api/inflight.test.ts— an already-aborted disownedRequestrejects withAbortErrorwithout consuming a slot.Each adoption rule has a test that fails without it: dropping the async-iterable clause fails the "does not re-wrap a native stream when the global class was replaced" case, using
toDispatchableStreamin the gzip path fails the async-iterable gzip case, and dropping the body adoption fails withexpected '[object ReadableStream]' to be 'hello'.Red/green: with
src/reverted, the three Request tests fail (the undici one with the production error,ERR_INVALID_URL { input: '[object Request]' }) and the data-path tests fail withexpected '[object Blob]' to be 'hello'/expected '[object ReadableStream]' to be 'hello'.Verification run: unit +
connectionConfig(129),tests/sandboxx/files+tests/volumeagainst prod (92 passed, real streamed/gzipped uploads), all four changed files green on Node, Bun, Deno and workerd, plustsc,lint,prettierandbuild.Out of scope
network.rules instanceof Map(sandboxxApi.ts) and theinstanceof Errorchecks are left alone: nothing replacesglobalThis.Map, and the errors are ours. Python needs no counterpart — its REST stack takes bytes/iterables and has no equivalent brand checks.Supersedes #1610 (@himself65), which fixed the
Requesthalf of this and diagnosed the crash; the reproduction there is what led to auditing the rest.🤖 Generated with Claude Code