#3239 · Outbound provider requests bypass environment HTTP proxies
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
| Claim | Status | Evidence |
|---|---|---|
| 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
- Trusted repository:
get-bb/bbat06aeaa994942ae7527dc49d2268c1f801e8542a0; fetchedorigin/mainresolved to the same commit. - Host: macOS Darwin 25.6.0, arm64; Node
v22.22.3; pnpm9.15.0. - Install and full Turbo build succeeded in both trusted checkouts.
- No bb development instance, provider account, credential, external endpoint, fixed port, or persistent data directory was used. Both test servers bound ephemeral loopback ports and closed after the run.
4. Minimal reproduction
- Check out the trusted base and install it:
git checkout 06aeaa994942ae7527dc49d2268c1f801e8542a0 pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build
- Copy the regression test to
plugins/provider-codex/src/ai/http-proxy-repro.test.ts. - 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
pnpm exec turbo run build: 20/20 build tasks passed in both checkouts.pnpm exec vitest run --config vitest.config.ts src/ai/chatgpt-client.test.ts src/bridge/provider-maintenance.test.ts: 2 files and 24 tests passed.git grepat the base found nosetGlobalDispatcher,EnvHttpProxyAgent, orProxyAgentusage outside the push-notifications implementation and tests.- No open pull request linked to issue 3239 was present in issue metadata or numeric PR search results.
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.