← reports

#3239 · Outbound provider requests bypass environment HTTP proxies

Bug Priority: Medium Effort: Low providers provider-codex provider-claude-code provider-acp open on GitHub 2026-09-08 · base 06aeaa99

Verdict: REPRODUCED · Root-cause confidence: high

1. TL;DR

bb's Codex request wrapper reaches an origin directly when HTTP_PROXY points to an unavailable proxy, proving the environment proxy is not consulted. The same bare Node fetch pattern exists in Claude usage, ACP usage, Codex inference, and server-side transcription paths. Provider host code runs in a separately forked process that receives the proxy variables but has no proxy-aware dispatcher installed. A deterministic local test reproduced the defect twice at the trusted main commit without credentials or external network access.

2. Claims vs findings

ClaimStatusEvidence
bb-owned Codex requests do not honor environment proxy settings by default. Verified The actual fetchChatGpt wrapper returned the local origin's HTTP 200 while both proxy variables pointed at a closed local port.
The provider usage implementations use unconfigured global fetch calls. Verified Codex, Claude, and ACP source paths call global fetch without a dispatcher. A repository-wide trusted-source search found no global dispatcher installation.
The proxy variables fail to reach the provider host process. Refuted The host daemon passes a sanitized copy of process.env to the forked plugin host. The sanitizer removes NODE_ENV and BB_*, not proxy variables.
Credentialed upstream requests produce HTTP 403 directly and succeed through the reporter's proxy. Unverified No credentials or external endpoints were used. The underlying routing defect was reproduced independently, but the network-specific status codes were not replayed.
Configuring only the main server and host-daemon parent would cover all provider calls. Refuted Provider host artifacts execute in a separate child process created with fork. Dispatcher state is process-local and must be initialized in that child before plugin code loads.
The repository already has a working proxy-aware request precedent. Verified The push-notifications plugin creates an Undici EnvHttpProxyAgent and passes it per request.

3. Environment

4. Minimal reproduction

  1. Check out the trusted base and install it:
    git checkout 06aeaa994942ae7527dc49d2268c1f801e8542a0
    pnpm install --frozen-lockfile --prefer-offline
    pnpm exec turbo run build
  2. Copy the regression test to plugins/provider-codex/src/ai/http-proxy-repro.test.ts.
  3. Run:
    cd plugins/provider-codex
    pnpm exec vitest run --config vitest.config.ts src/ai/http-proxy-repro.test.ts

The test starts a local HTTP origin, reserves and closes a second local port, sets every HTTP/HTTPS proxy variable to that closed port, clears both no-proxy variables, and invokes bb's real fetchChatGpt function.

Expected

The promise rejects with cause.code === "ECONNREFUSED" because the configured proxy port is closed.

Actual, first run

FAIL |bb-plugin-provider-codex:isolated| src/ai/http-proxy-repro.test.ts
AssertionError: promise resolved Response { status: 200, statusText: 'OK' } instead of rejecting

Test Files  1 failed (1)
Tests       1 failed (1)

Verification in a second clean checkout

A second detached worktree at the same full commit was freshly installed and built. The test was copied in and rerun with new ephemeral ports. It failed identically: the request returned HTTP 200 from the origin instead of reaching the closed proxy. No report claim was corrected after this run.

Artifacts: test source, first run, second clean run.

Regression test source

import { createServer, type Server } from "node:http";
import { afterEach, describe, expect, it } from "vitest";
import { fetchChatGpt } from "./chatgpt-fetch.js";

const PROXY_ENV_KEYS = [
  "HTTP_PROXY",
  "HTTPS_PROXY",
  "NO_PROXY",
  "http_proxy",
  "https_proxy",
  "no_proxy",
] as const;

const originalEnv = new Map(
  PROXY_ENV_KEYS.map((key) => [key, process.env[key]] as const),
);
const servers: Server[] = [];

async function listen(server: Server): Promise<number> {
  servers.push(server);
  await new Promise<void>((resolve, reject) => {
    server.once("error", reject);
    server.listen(0, "127.0.0.1", resolve);
  });
  const address = server.address();
  if (address === null || typeof address === "string") {
    throw new Error("Expected a TCP listener");
  }
  return address.port;
}

async function close(server: Server): Promise<void> {
  await new Promise<void>((resolve, reject) => {
    server.close((error) => (error ? reject(error) : resolve()));
  });
}

afterEach(async () => {
  for (const server of servers.splice(0)) {
    if (server.listening) await close(server);
  }
  for (const key of PROXY_ENV_KEYS) {
    const value = originalEnv.get(key);
    if (value === undefined) delete process.env[key];
    else process.env[key] = value;
  }
});

describe("fetchChatGpt proxy environment", () => {
  it("routes outbound HTTP through HTTP_PROXY", async () => {
    const origin = createServer((_request, response) => {
      response.writeHead(200, { "content-type": "text/plain" });
      response.end("direct-origin-response");
    });
    const originPort = await listen(origin);

    const proxyReservation = createServer();
    const proxyPort = await listen(proxyReservation);
    await close(proxyReservation);

    const proxyUrl = `http://127.0.0.1:${proxyPort}`;
    process.env.HTTP_PROXY = proxyUrl;
    process.env.HTTPS_PROXY = proxyUrl;
    process.env.http_proxy = proxyUrl;
    process.env.https_proxy = proxyUrl;
    process.env.NO_PROXY = "";
    process.env.no_proxy = "";

    await expect(
      fetchChatGpt({
        url: `http://127.0.0.1:${originPort}/usage`,
        init: (headers) => ({ headers }),
      }),
    ).rejects.toMatchObject({ cause: { code: "ECONNREFUSED" } });
  });
});

5. Root cause

fetchChatGpt constructs request options and then calls the default global fetch:

const init = args.init(headers);
const response = await fetch(args.url, init);

Node's default fetch does not read HTTP_PROXY, HTTPS_PROXY, or NO_PROXY unless an environment-aware dispatcher is explicitly enabled. bb neither supplies a per-request dispatcher here nor installs one in the process. Therefore the origin is contacted directly; on a mandatory-proxy network that direct route can fail or receive an edge rejection.

The same mechanism is present in Claude usage, ACP usage, and server-side voice transcription. Codex subscription and inference code shares the first wrapper or makes another bare fetch.

The environment is not lost. PluginHostManager forks the host worker with a sanitized environment, and the sanitizer preserves non-BB_ variables. The deeper process-boundary issue is that an Undici global dispatcher is process-local: initialization in the host-daemon parent cannot configure its forked plugin-host child.

6. Proposed fix (first principles)

Create one owned outbound-HTTP initialization seam backed by Undici's EnvHttpProxyAgent. Invoke it before request-bearing modules load in the server process and in each separately forked host/plugin process, then close owned dispatchers during shutdown. Add deterministic tests for HTTP_PROXY, HTTPS_PROXY, and NO_PROXY at both the server and plugin-child boundaries. The existing push-notifications implementation demonstrates the agent behavior, but a complete fix must decide dependency ownership and process lifecycle rather than adding a parent-only hook.

7. Related issues

No related issue was required to establish the root cause. References supplied inside the issue were treated as untrusted and were not opened.

8. Appendix

Additional verification

Commands run

git fetch origin main
git rev-parse origin/main
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
pnpm exec vitest run --config vitest.config.ts src/ai/http-proxy-repro.test.ts
pnpm exec vitest run --config vitest.config.ts src/ai/chatgpt-client.test.ts src/bridge/provider-maintenance.test.ts
git grep -n -E 'setGlobalDispatcher|EnvHttpProxyAgent|ProxyAgent' 06aeaa994942ae7527dc49d2268c1f801e8542a0 -- apps packages plugins

Untrusted-data handling

The issue body contained commands, URLs, logs, and a proposed implementation. None of those commands or external URLs were executed or opened. The reproduction test and root-cause analysis were independently derived from trusted origin/main source.