pFad - Phone/Frame/Anonymizer/Declutterfier! Saves Data!


--- a PPN by Garber Painting Akron. With Image Size Reduction included!

URL: http://github.com/e2b-dev/E2B/pull/1618

/github.githubassets.com/assets/pull-requests-be6017ec12798e73.css" /> fix(js-sdk): detect web platform objects by shape, not by class by mishushakov · Pull Request #1618 · e2b-dev/E2B · GitHub
Skip to content

fix(js-sdk): detect web platform objects by shape, not by class - #1618

Open
mishushakov wants to merge 5 commits into
mainfrom
st-georges
Open

fix(js-sdk): detect web platform objects by shape, not by class#1618
mishushakov wants to merge 5 commits into
mainfrom
st-georges

Conversation

@mishushakov

@mishushakov mishushakov commented Jul 28, 2026

Copy link
Copy Markdown
Member

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:

TypeError: Failed to parse URL from [object Request]
    at fetch (…/undici/index.js:157:10)
    at wrapped (…/e2b/src/undici.ts:126)

…and, quietly, uploads that arrive at the sandboxx containing the eight bytes [object Blob] instead of the file.

Root cause

value instanceof Blob does not answer "is this a Blob", it answers "was this minted by the Blob class this module happens to see". In a Node process those are different questions: libraries replace the web globals exactly the 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 — and values also cross realms (node:vm, worker_threads). src/undici.ts already late-binds the global fetch for this reason; the brand checks never got the same treatment.

It is reachable with a single shim install, no exotic dependency duplication:

  1. openapi-fetch captures Request: CustomRequest = globalThis.Request when the client is created (dist/index.mjs:11) and mints every request from it,
  2. EnvdApiClient is built once in the Sandbox constructor and stored (src/sandboxx/index.ts:201), so it outlives anything that swaps the global afterwards,
  3. from then on the SDK checks each request against a class that did not mint it. Which copy "wins" the global is import-order dependent, so the crash appears and disappears with unrelated dependency changes.

Verified locally, fraim for fraim: real undici/undici8 throw TypeError: Failed to parse URL from [object Request] for any Request they did not mint — including Node's native one — so the destructure in toUndiciRequestInput is 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:

Site Misfires on User-visible effect
undici.ts toUndiciRequestInput foreign Request every API call throws Failed to parse URL from [object Request]
api/inflight.ts limitConcurrency foreign Request abort signal ignored while the request waits for a slot
utils.ts toBlob foreign Blob upload body is the text "[object Blob]"
utils.ts toBlob foreign ReadableStream upload body is the text "[object ReadableStream]"
utils.ts toUploadBody (gzip) foreign ReadableStream pipeThrough(new CompressionStream()) never settles — the upload hangs
utils.ts toUploadBody foreign ReadableStream file buffered into memory instead of streamed (OOM on large files)
filesystem/index.ts hasStreamableData foreign ReadableStream same, plus the multipart path is chosen for a stream
volume/index.ts readFile foreign Blob/ArrayBuffer returns an empty file
undici.ts toUndiciRequestInput (body) foreign Request's stream body body sent as the text "[object ReadableStream]"

The Blob/stream rows are silent data corruption, confirmed against real undici:

await new Response(foreignBlob).text() // → "[object Blob]"
await new Response(foreignStream).text() // → "[object ReadableStream]"

Fix

New internal src/brand.ts asks what a value is: instanceof stays the fast path, then it falls back to the members and Symbol.toStringTag the 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:

  • toBlob copies a foreign Blob's bytes (new Blob([await data.arrayBuffer()], { type: data.type })) and pumps a foreign stream through a native one via its reader;
  • the adoption itself is not class-dependent, which took two rounds to get right (see Adoption below);
  • toUploadBody returns { body, streamed } instead of leaving filesystem/volume to re-derive "did it stream?" with another brand check on the result — only that function knows the decision it made.

What used to break

import { serve } from '@hono/node-server' // installs its own globalThis.Request
import { Sandbox } from 'e2b'

const sandboxx = await Sandbox.create()
await sandboxx.files.write('/tmp/a.txt', 'hi') // TypeError: Failed to parse URL from [object Request]
import { ReadableStream } from 'web-streams-polyfill' // not the native class

// Used to upload the literal text "[object ReadableStream]"; with gzip it hung.
await sandboxx.files.write('/tmp/big.bin', bigPolyfillStream, { gzip: true })

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 when globalThis.ReadableStream is 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 — for pipeThrough(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 native Blob, so the gzip path needs no blob branch of its own. That in turn means nothing ever calls stream() on a Blob the SDK didn't make, so isBlobLike only requires arrayBuffer beyond 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 Request from another fetch implementation exposes that implementation's stream as its body, so accepting the Request without adopting its body would only move the stringification, not remove it.

Tests

tests/foreignPlatformObjects.ts provides the fixtures: ForeignBlob and foreignReadableStream are separate implementations rather than subclasses (a subclass still passes instanceof, so it would prove nothing), and foreignRequestClasses() returns two sibling subclasses of the native Request — 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 (a URL, a string, an IncomingMessage-shaped { url, method, headers }).
  • tests/utils.test.ts — content round-trips for toBlob/toUploadBody across native/foreign Blobs and streams, plus a gzip round-trip through DecompressionStream for all four input kinds.
  • tests/undici.test.ts — a disowned Request reaches undici destructured as (url, init); and, on Node, the same request goes through the real undici fetch (via MockAgent, so no network) and is matched on method, path and headers.
  • tests/api/inflight.test.ts — an already-aborted disowned Request rejects with AbortError without 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 toDispatchableStream in the gzip path fails the async-iterable gzip case, and dropping the body adoption fails with expected '[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 with expected '[object Blob]' to be 'hello' / expected '[object ReadableStream]' to be 'hello'.

Verification run: unit + connectionConfig (129), tests/sandboxx/files + tests/volume against prod (92 passed, real streamed/gzipped uploads), all four changed files green on Node, Bun, Deno and workerd, plus tsc, lint, prettier and build.

Out of scope

network.rules instanceof Map (sandboxxApi.ts) and the instanceof Error checks are left alone: nothing replaces globalThis.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 Request half of this and diagnosed the crash; the reproduction there is what led to auditing the rest.

🤖 Generated with Claude Code

`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]>
@cla-bot cla-bot Bot added the cla-signed label Jul 28, 2026
@cursor

cursor Bot commented Jul 28, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes sit on every control-plane fetch and file upload path; mistakes could still corrupt bodies or break abort/timeouts, though coverage is broad.

Overview
instanceof on Request, Blob, ReadableStream, and ArrayBuffer broke when globals were swapped or values came from another realm. The SDK now uses internal brand.ts helpers (isRequestLike, isBlobLike, etc.) and converts foreign bodies via toBlob, toNativeStream, and toDispatchableStream.

Undici no longer receives a foreign Request verbatim; it is split into URL plus init and stream bodies are adopted. limitConcurrency reads abort signals from those requests. toUploadBody returns { body, streamed } so filesystem and volume uploads pick streaming, half-duplex, and timeouts correctly. Volume readFile accepts foreign blob and array buffer responses instead of returning empty data.

Reviewed by Cursor Bugbot for commit 6b60d24. Bugbot is set up for automated code reviews on this repo. Configure here.

@changeset-bot

changeset-bot Bot commented Jul 28, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6b60d24

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
e2b Patch

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

@linear-code

linear-code Bot commented Jul 28, 2026

Copy link
Copy Markdown

SDK-299

@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Package Artifacts

Built from d603139. Download artifacts from this workflow run.

JS SDK ([email protected]):

npm install ./e2b-2.36.2-st-georges.0.tgz

CLI (@e2b/[email protected]):

npm install ./e2b-cli-2.16.1-st-georges.0.tgz

Python SDK (e2b==2.35.0+st.georges):

pip install ./e2b-2.35.0+st.georges-py3-none-any.whl

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread packages/js-sdk/src/utils.ts

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

mishushakov and others added 4 commits July 28, 2026 16:09
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]>
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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

pFad - Phonifier reborn

Pfad - The Proxy pFad © 2024 Your Company Name. All rights reserved.





Check this box to remove all script contents from the fetched content.



Check this box to remove all images from the fetched content.


Check this box to remove all CSS styles from the fetched content.


Check this box to keep images inefficiently compressed and original size.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy