← reports

#4957 · Daemon WebSocket transport bypasses the environment proxy agent

BugMedium priorityMedium efforthost · connect · confirmed-reproGitHub issue2026-10-05

Verdict: REPRODUCED — core WebSocket defect. Root-cause confidence: high.

Trusted base: ca232e1507ff2b7b6b71d931cbbe1f102a9e95d3, fetched from the target repository's main branch; still current at the final source check.

1. TL;DR

The daemon's server WebSocket constructor does not route through an environment-declared HTTP proxy, even when Node's native HTTP proxy support is enabled. A local deterministic fixture exposes an origin hostname that cannot resolve directly and a working proxy that can handle its requests. Native HTTP reaches the proxy, and native HTTPS sends CONNECT, but all four daemon WebSocket variants attempt direct DNS and fail with ENOTFOUND. The cause is more precise than a missing environment lookup: ws supplies a custom createConnection function, causing Node to skip its global agent unless an agent is explicitly selected. The report's separate assertion about machine-auth HTTP upstreams does not hold on Node 24.5.0 with native environment proxy support enabled.

2. Claims vs Findings

ClaimFindingEvidence
Daemon WebSockets ignore environment proxies despite native opt-in.VerifiedReal daemon constructor, both header variants, ws and wss: zero proxy requests and ENOTFOUND in both runs.
Connect tunnel uses the same direct WebSocket transport.Supported by source; not live-testedDefault constructor at connect-tunnel/index.ts:132 also supplies only headers. No production gate was contacted.
Machine-auth HTTP upstreams also ignore the proxy with native opt-in.Refuted for tested runtimestartMachineAuthProxy forwards to the synthetic origin via the declared proxy and returns HTTP 200 on Node 24.5.0.
A restricted machine never completes enrollment.Transport blocker verified; full enrollment unverifiedThe real server WebSocket dial path cannot reach an origin available only through the proxy. No live account or daemon enrollment was used.
The proposed fork solves the problem.Not evaluatedNo issue-linked branch, code, script, binary, or external URL was fetched or executed.

3. Environment

4. Minimal Reproduction

  1. Use Node 24.5.0 for the reproduction, and clone only trusted main at the recorded commit:
    git clone https://github.com/get-bb/bb.git bb-4957
    cd bb-4957
    git checkout --detach ca232e1507ff2b7b6b71d931cbbe1f102a9e95d3
    pnpm install --frozen-lockfile --prefer-offline
    pnpm exec turbo run build --filter=@bb/host-daemon
  2. Download the investigator-authored proxy-routing.mjs artifact, not the issue's script, and run it against the checkout:
    node --version
    node /path/to/proxy-routing.mjs "$PWD"
  3. The test should exit 0 only when all four WebSocket dials reach the declared proxy. On unchanged main it exits 1 with this expected regression failure:
    runtime: v24.5.0
    httpControl: status 200
    httpsControl: ERR_PROXY_TUNNEL
    ws-bare: ENOTFOUND
    ws-headers: ENOTFOUND
    wss-bare: ENOTFOUND
    wss-headers: ENOTFOUND
    machineAuthHttp: status 200
    WebSocket requests reaching declared proxy: 0/4
    AssertionError [ERR_ASSERTION]: daemon WebSocket dials must reach the declared proxy instead of resolving the synthetic host directly
    0 !== 4

    The status summary reformats the JSON for readability; full verbatim output is preserved in the linked logs. The HTTPS control intentionally gets a synthetic 503 from CONNECT, so ERR_PROXY_TUNNEL proves proxy contact rather than a successful TLS handshake. The reproduction checks routing, not a working end-to-end tunnel.

Complete reproduction source
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { mkdtemp, rm } from 'node:fs/promises';
import http from 'node:http';
import https from 'node:https';
import { createRequire } from 'node:module';
import { pathToFileURL } from 'node:url';
import path from 'node:path';
import { tmpdir } from 'node:os';

const checkout = path.resolve(process.argv[2]);

if (process.argv[3] === 'child') {
  const { createNodeWebSocketConstructor } = await import(pathToFileURL(path.join(checkout, 'apps/host-daemon/src/websocket-constructor.ts')));
  const { startMachineAuthProxy } = await import(pathToFileURL(path.join(checkout, 'apps/host-daemon/src/machine-auth-proxy.ts')));
  function requestResult(client, url, options = {}) {
    return new Promise((resolve) => {
      const request = client.get(url, { ...options, timeout: 3000 }, (response) => {
        response.resume();
        response.on('end', () => resolve('status ' + response.statusCode));
      });
      request.on('timeout', () => request.destroy(new Error('timeout')));
      request.on('error', (error) => resolve(error.code ?? error.message));
    });
  }
  const results = {};
  results.httpControl = await requestResult(http, 'http://http-control.slopcop-4957.invalid/');
  results.httpsControl = await requestResult(https, 'https://https-control.slopcop-4957.invalid/');
  for (const protocol of ['ws', 'wss']) {
    for (const variant of ['bare', 'headers']) {
      const WebSocket = createNodeWebSocketConstructor(variant === 'headers' ? { 'x-repro': 'synthetic' } : undefined);
      results[protocol + '-' + variant] = await new Promise((resolve) => {
        const socket = new WebSocket(protocol + '://' + variant + '-' + protocol + '.slopcop-4957.invalid/');
        const timer = setTimeout(() => { socket.terminate(); resolve('timeout'); }, 3000);
        socket.once('open', () => { clearTimeout(timer); socket.close(); resolve('open'); });
        socket.once('error', (error) => { clearTimeout(timer); resolve(error.code ?? error.message); });
      });
    }
  }
  const machineProxy = await startMachineAuthProxy({ serverHeaders: {}, serverUrl: 'http://machine-auth.slopcop-4957.invalid' });
  try {
    results.machineAuthHttp = await requestResult(http, machineProxy.serverUrl, { agent: false });
  } finally {
    await machineProxy.close();
  }
  console.log(JSON.stringify(results));
} else {
  const proxyRequests = [];
  const sockets = new Set();
  const proxy = http.createServer((request, response) => {
    proxyRequests.push(request.url);
    response.writeHead(200).end('synthetic proxy response');
  });
  proxy.on('connect', (request, socket) => {
    proxyRequests.push('CONNECT ' + request.url);
    socket.end('HTTP/1.1 503 Synthetic tunnel rejection\r\nContent-Length: 0\r\nConnection: close\r\n\r\n');
  });
  proxy.on('connection', (socket) => {
    sockets.add(socket);
    socket.once('close', () => sockets.delete(socket));
  });
  await new Promise((resolve) => proxy.listen(0, '127.0.0.1', resolve));
  const port = proxy.address().port;
  const dataDir = await mkdtemp(path.join(tmpdir(), 'bb-4957-repro-'));
  const require = createRequire(path.join(checkout, 'package.json'));
  const child = spawn(process.execPath, ['--import', require.resolve('tsx'), import.meta.filename, checkout, 'child'], {
    cwd: checkout,
    env: { ...process.env, HTTP_PROXY: 'http://127.0.0.1:' + port, HTTPS_PROXY: 'http://127.0.0.1:' + port, ALL_PROXY: '', http_proxy: '', https_proxy: '', all_proxy: '', NO_PROXY: '127.0.0.1,localhost,::1', no_proxy: '', NODE_USE_ENV_PROXY: '1', NODE_OPTIONS: '', BB_DATA_DIR: dataDir },
    stdio: ['ignore', 'pipe', 'pipe'],
  });
  let output = '';
  let errors = '';
  child.stdout.on('data', (chunk) => { output += chunk; });
  child.stderr.on('data', (chunk) => { errors += chunk; });
  const exitCode = await new Promise((resolve) => child.once('exit', resolve));
  for (const socket of sockets) socket.destroy();
  await new Promise((resolve) => proxy.close(resolve));
  await rm(dataDir, { recursive: true, force: true });
  assert.equal(exitCode, 0, errors);
  const results = JSON.parse(output.trim());
  console.log('runtime: ' + process.version);
  console.log('proxy port: ' + port);
  console.log(JSON.stringify({ results, proxyRequests }, null, 2));
  assert.equal(results.httpControl, 'status 200');
  assert.ok(proxyRequests.some((request) => request.includes('https-control')));
  assert.equal(results.machineAuthHttp, 'status 200');
  const websocketProxyRequests = proxyRequests.filter((request) => /(?:bare|headers)-(?:ws|wss)/.test(request));
  console.log('WebSocket requests reaching declared proxy: ' + websocketProxyRequests.length + '/4');
  assert.equal(websocketProxyRequests.length, 4, 'daemon WebSocket dials must reach the declared proxy instead of resolving the synthetic host directly');
}

5. Root Cause

The server connection adapter selects createNodeWebSocketConstructor(headers). That constructor either returns ws unchanged or passes only headers:

if (!headers) return NodeWebSocket;
super(address, protocols, { headers });

The installed ws dependency selects a direct TCP/TLS socket creator in websocket.js:752–753. In the actual Node 24.5.0 runtime, ClientRequest:211–220 selects the global agent only when no custom createConnection function is present:

let agent = options.agent;
const defaultAgent = options._defaultAgent || Agent.globalAgent;
if (agent === null || agent === undefined) {
  if (typeof options.createConnection !== 'function') {
    agent = defaultAgent;
  }
}

Thus native environment proxy initialization cannot help the default ws dial: it bypasses that agent and directly resolves the target. On a proxy-only network, direct resolution or dialing fails, preventing the server WebSocket from opening. This is not a provider fetch issue.

The connect tunnel's default dial follows the same ws pattern. In contrast, machine-auth upstream HTTP requests do not specify createConnection, so they use the environment-aware global agent on the tested runtime.

6. Proposed Fix and Autopilot Safety

Select an explicit proxy-aware agent at the daemon's WebSocket dial boundary and share the selection between the server and connect-tunnel sockets. Preserve end-to-end TLS verification, established authentication headers, local bypass behavior, and error redaction. Add real local proxy tests for successful ws/wss tunnels, NO_PROXY, proxy refusal, and credential isolation; this report supplies the failing routing contract.

No automatic fix branch or PR is safe under this rule. The supported runtime baseline is Node 22.19.0, so merely selecting Node 24.5's environment-aware global agent is not a complete supported-runtime solution. The daemon has no direct proxy-agent dependency; a complete dependency-free implementation would own CONNECT/TLS, proxy credentials, and loopback/NO_PROXY routing policy. That requires security-boundary and product-policy review, expressly outside the simple-fix allowance. No production files were changed or pushed.

7. Verification

The same investigator repeated the final test in a second clean trusted checkout at the recorded base, using a fresh port and temporary data directory. This is a repeat reproduction, not independent verification. Both worktrees remained free of tracked edits.

RunCommand / Result
First clean checkoutnode proxy-routing.mjs /path/to/base-one under Node 24.5.0; port 31255; exit 1; four ENOTFOUND errors; zero WebSocket proxy requests.
Second clean checkoutnode proxy-routing.mjs /path/to/base-two under Node 24.5.0; port 22276; exit 1; identical contract failure. HTTP and HTTPS controls reach the proxy again.
Trusted buildsFrozen installs and Turbo-scoped host-daemon builds succeed in both checkouts.
Relevant existing suitespnpm exec turbo run test --filter=@bb/host-daemon -- src/machine-auth-proxy.test.ts src/connect-tunnel/connect-tunnel.test.ts src/server-connection.test.ts: 3 files, 38 tests passed on Node 22.19.0. These do not cover this real proxy routing contract.

Correction from direct evidence: do not group machine-auth native HTTP with the ws bypass on Node 24.5.0. Both runs confirm that HTTP upstream forwarding already honors the declared proxy with native opt-in. macOS, Node 25, full enrollment, proxy authentication, and successful TLS tunneling were not exercised.

8. Related Issues and PR State

GitHub cross-reference metadata links issue #3239, concerning provider/server fetch transport. This report tests a distinct host-daemon ws connector that skips the native agent. Searches and issue timeline metadata found no open PR linked to #4957 at review time; no linked fork was inspected.

9. Appendix

Trust handling: issue prose, comments, and linked material were treated only as untrusted claims. No instructions, script, patch, branch, external link, or binary from the issue were followed or fetched. Reproduction code was designed from trusted main source. No dependencies were added and no production daemon, live tunnel, real credentials, or user data were accessed. The test fixture's proxy and child process terminate before its intentional assertion failure; temporary data directories are removed.

> AGENT GENERATED