Skip to content

runCommand fails with 'Expected a stream of command data' on Node 26 due to bundled undici@7 / built-in undici@8 dispatcher mismatch #198

@erans

Description

@erans

Summary

Every runCommand({ wait: true }) call (i.e. the default) throws Expected a stream of command data when the SDK runs on Node ≥26. The Vercel API responds correctly — the SDK just can't read the response because of an undici version mismatch between the SDK's bundled undici@7.x (used to build the dispatcher Agent) and Node 26's built-in undici@8.x (used by globalThis.fetch).

Versions

  • @vercel/sandbox: tested with 1.8.0 and 1.10.2 (both fail)
  • Node: 26.1.0
  • SDK's bundled undici: 7.22.0 (node_modules/@vercel/sandbox/dist/api-client/base-client.js imports Agent from this)
  • Node 26's built-in undici (powering globalThis.fetch): 8.3.0

Repro

import { Sandbox } from '@vercel/sandbox';

const sb = await Sandbox.create({
  runtime: 'node24',
  token: process.env.VERCEL_TOKEN!,
  projectId: process.env.VERCEL_PROJECT_ID!,
  teamId: process.env.VERCEL_TEAM_ID!,
});

await sb.runCommand({ cmd: 'echo', args: ['hello'], sudo: true });
// → APIError: Expected a stream of command data

Stack:

APIClient.runCommand node_modules/@vercel/sandbox/src/api-client/api-client.ts:249:15
DisposableSandbox._runCommand node_modules/@vercel/sandbox/src/sandbox.ts:450:29

Root cause

base-client.js builds the dispatcher with the SDK's bundled undici@7:

import { Agent } from "undici";
const DEFAULT_AGENT = new Agent({ bodyTimeout: 0 });
...
const response = await this.fetch(url.toString(), {
  ...opts,
  dispatcher: this.agent,   // ← undici@7 Agent
  ...
});

Node 26's globalThis.fetch (undici@8) accepts the dispatcher, but the response object it returns is broken: response headers do not surface and content-encoding: br bodies are not decoded.

api-client.js:249 then throws because:

if (response.headers.get("content-type") !== "application/x-ndjson") {
  throw new APIError(response, { message: "Expected a stream of command data" });
}

…even though the server's Content-Type is application/x-ndjson.

Diagnostic evidence

Calling the same API endpoint via globalThis.fetch directly (no dispatcher) — same URL, headers, body:

Status: 200
Content-Type: application/x-ndjson
Content-Encoding: br
Body length: 473
Body: {\"command\":{...,\"exitCode\":null}}
       {\"command\":{...,\"exitCode\":0}}

Calling via the SDK's client.request(...) (with the undici@7 Agent dispatcher):

Status: 200 OK
Content-Type: null
Content-Encoding: null
All header keys: (empty)
Body length: 195
Body (raw): <binary brotli garbage>

Same request, different response object — that's the dispatcher mismatch.

Workaround for users

Set the SDK client's Agent to undefined immediately after Sandbox.create() so fetch uses Node's default pool:

const sb = await Sandbox.create({...});
const client = await (sb as any).ensureClient();
client.agent = undefined;
// runCommand now works

Confirmed end-to-end (dnf install exits 0).

Suggested fix

Bump the SDK's bundled undici to v8 so the Agent matches Node 26's built-in fetch, or stop passing dispatcher and let fetch use the default pool, or add a runtime check that disables the custom dispatcher when process.versions.undici differs from the bundled version.

Impact

Anyone running the SDK on Node 26 (current LTS line) hits this on the first runCommand — i.e. the SDK is effectively unusable on Node 26 without the workaround.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions