Skip to content

Commit c95dcbb

Browse files
XXPermanentXXclaude
andcommitted
fix: honor HTTP_PROXY / HTTPS_PROXY / NO_PROXY env vars (#35)
Node's built-in fetch (undici) ignores proxy environment variables, so bl bypassed VPN / corporate proxies and failed with ECONNRESET. Install an EnvHttpProxyAgent as the global dispatcher at startup, but only when a proxy variable is actually set — behavior is unchanged otherwise. Values are trimmed and passed explicitly to work around undici reading env vars with `??`, where an empty lowercase variable (https_proxy="") masks a configured uppercase one. Invalid proxy URLs fail with a clear usage error instead of a stack trace, and the ECONNRESET hint now suggests exporting HTTPS_PROXY. Tests are fully offline: unit tests cover env parsing, and e2e tests drive a local CONNECT proxy against a .invalid host to verify traffic routes through the proxy, NO_PROXY is honored, and no dispatcher is installed when no proxy is configured. Fixes #35 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent abe29d1 commit c95dcbb

8 files changed

Lines changed: 216 additions & 2 deletions

File tree

packages/cli/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@
4646
"dependencies": {
4747
"bailian-cli-core": "workspace:*",
4848
"boxen": "catalog:",
49-
"chalk": "catalog:"
49+
"chalk": "catalog:",
50+
"undici": "catalog:"
5051
},
5152
"devDependencies": {
5253
"@clack/prompts": "^0.7.0",

packages/cli/src/error-handler.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,11 @@ function pickNetworkHint(code: string | undefined): string {
8080
case "ECONNREFUSED":
8181
return "Connection refused. Check the target host/port and proxy settings.";
8282
case "ECONNRESET":
83-
return "Connection reset by peer. Retry, or check proxy / firewall.";
83+
return (
84+
"Connection reset by peer. Retry, or check proxy / firewall.\n" +
85+
"If you are behind a VPN or corporate proxy, route bl through it:\n" +
86+
"export HTTPS_PROXY=http://127.0.0.1:<proxy-port>"
87+
);
8488
case "ETIMEDOUT":
8589
return "Connection timed out. Check your network or try a different region.";
8690
case "CERT_HAS_EXPIRED":

packages/cli/src/main.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
type Region,
1111
} from "bailian-cli-core";
1212
import { ensureApiKey } from "./utils/ensure-key.ts";
13+
import { setupProxyFromEnv } from "./proxy.ts";
1314
import { handleError } from "./error-handler.ts";
1415
import { checkForUpdate, getPendingUpdateNotification } from "./utils/update-checker.ts";
1516
import { maybeShowStatusBar } from "./output/status-bar.ts";
@@ -21,6 +22,13 @@ import {
2122
setExecutingCommandPath,
2223
} from "./utils/command-help.ts";
2324

25+
// 必须在任何 fetch 发起前安装(含 update-checker / telemetry)
26+
try {
27+
setupProxyFromEnv();
28+
} catch (err) {
29+
handleError(err);
30+
}
31+
2432
registerCommandHelpPrinter((commandPath, out) => {
2533
const a = process.argv.slice(2);
2634
const ri = a.indexOf("--region");

packages/cli/src/proxy.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { setGlobalDispatcher, EnvHttpProxyAgent } from "undici";
2+
import { BailianError, ExitCode } from "bailian-cli-core";
3+
4+
export interface ProxyEnv {
5+
httpProxy?: string;
6+
httpsProxy?: string;
7+
noProxy?: string;
8+
}
9+
10+
function pick(env: NodeJS.ProcessEnv, ...keys: string[]): string | undefined {
11+
for (const key of keys) {
12+
const value = env[key]?.trim();
13+
if (value) return value;
14+
}
15+
return undefined;
16+
}
17+
18+
/**
19+
* 读取代理环境变量(小写优先,与 curl 约定一致)。
20+
* 空白值视为未设置——undici 自身用 `??` 取值,空字符串的小写变量会屏蔽
21+
* 已设置的大写变量,这里统一清洗后显式传入,绕开该坑。
22+
*/
23+
export function readProxyEnv(env: NodeJS.ProcessEnv = process.env): ProxyEnv {
24+
return {
25+
httpProxy: pick(env, "http_proxy", "HTTP_PROXY"),
26+
httpsProxy: pick(env, "https_proxy", "HTTPS_PROXY"),
27+
noProxy: pick(env, "no_proxy", "NO_PROXY"),
28+
};
29+
}
30+
31+
// Node 内置 fetch(undici)默认不读取代理环境变量,VPN / 公司代理环境下会
32+
// 绕过代理直连而被拦截(见 issue #35)。仅当用户显式设置了 HTTP_PROXY /
33+
// HTTPS_PROXY 时才安装代理 dispatcher(同时支持 NO_PROXY),未设置时不触碰
34+
// 全局 dispatcher,行为与之前完全一致。
35+
export function setupProxyFromEnv(): void {
36+
const { httpProxy, httpsProxy, noProxy } = readProxyEnv();
37+
if (!httpProxy && !httpsProxy) return;
38+
try {
39+
setGlobalDispatcher(new EnvHttpProxyAgent({ httpProxy, httpsProxy, noProxy }));
40+
} catch (err) {
41+
throw new BailianError(
42+
`Invalid proxy configuration: ${err instanceof Error ? err.message : String(err)}`,
43+
ExitCode.USAGE,
44+
"Check HTTP_PROXY / HTTPS_PROXY values, e.g. export HTTPS_PROXY=http://127.0.0.1:7890",
45+
{ cause: err },
46+
);
47+
}
48+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { createServer, type Server } from "http";
2+
import type { AddressInfo } from "net";
3+
import { afterAll, beforeAll, describe, expect, test } from "vite-plus/test";
4+
import { runCli } from "./helpers.ts";
5+
6+
/**
7+
* 代理支持 E2E(issue #35):验证设置 HTTPS_PROXY 后 CLI 流量经过代理,
8+
* 未设置时保持直连。全程离线——目标域名用 `.invalid`(保留顶级域,必然
9+
* 无法解析),代理收到 CONNECT 后规范返回 502,不产生真实外网请求。
10+
*/
11+
12+
const FAKE_GATEWAY_HOST = "bl-proxy-e2e.invalid";
13+
14+
/** 记录收到的 CONNECT 目标(host:port),并以 502 拒绝隧道的最小代理 */
15+
let proxy: Server;
16+
let proxyUrl: string;
17+
const connectTargets: string[] = [];
18+
19+
beforeAll(async () => {
20+
proxy = createServer();
21+
proxy.on("connect", (req, clientSocket) => {
22+
connectTargets.push(req.url ?? "");
23+
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
24+
});
25+
await new Promise<void>((resolve) => proxy.listen(0, "127.0.0.1", resolve));
26+
proxyUrl = `http://127.0.0.1:${(proxy.address() as AddressInfo).port}`;
27+
});
28+
29+
afterAll(async () => {
30+
await new Promise<void>((resolve) => proxy.close(() => resolve()));
31+
});
32+
33+
const PROXY_ENV_CLEARED = {
34+
HTTPS_PROXY: "",
35+
https_proxy: "",
36+
HTTP_PROXY: "",
37+
http_proxy: "",
38+
NO_PROXY: "",
39+
no_proxy: "",
40+
};
41+
42+
describe("e2e: proxy", () => {
43+
test("设置 HTTPS_PROXY 后请求经过代理(CONNECT 到目标主机)", async () => {
44+
connectTargets.length = 0;
45+
const { exitCode } = await runCli(["app", "list"], {
46+
...PROXY_ENV_CLEARED,
47+
HTTPS_PROXY: proxyUrl,
48+
BAILIAN_CONSOLE_GATEWAY_URL: `https://${FAKE_GATEWAY_HOST}`,
49+
});
50+
// 代理对 CONNECT 返回 502,命令本身按网络错误退出
51+
expect(exitCode).not.toBe(0);
52+
expect(connectTargets).toContain(`${FAKE_GATEWAY_HOST}:443`);
53+
});
54+
55+
test("空字符串小写变量不屏蔽大写 HTTPS_PROXY(undici ?? 取值回归)", async () => {
56+
connectTargets.length = 0;
57+
await runCli(["app", "list"], {
58+
...PROXY_ENV_CLEARED,
59+
https_proxy: "",
60+
HTTPS_PROXY: proxyUrl,
61+
BAILIAN_CONSOLE_GATEWAY_URL: `https://${FAKE_GATEWAY_HOST}`,
62+
});
63+
expect(connectTargets).toContain(`${FAKE_GATEWAY_HOST}:443`);
64+
});
65+
66+
test("NO_PROXY 命中目标主机时不走代理", async () => {
67+
connectTargets.length = 0;
68+
await runCli(["app", "list"], {
69+
...PROXY_ENV_CLEARED,
70+
HTTPS_PROXY: proxyUrl,
71+
NO_PROXY: FAKE_GATEWAY_HOST,
72+
BAILIAN_CONSOLE_GATEWAY_URL: `https://${FAKE_GATEWAY_HOST}`,
73+
});
74+
expect(connectTargets.filter((t) => t.startsWith(FAKE_GATEWAY_HOST))).toEqual([]);
75+
});
76+
77+
test("未设置代理变量时保持直连(代理收不到任何流量)", async () => {
78+
connectTargets.length = 0;
79+
const { exitCode } = await runCli(["app", "list"], {
80+
...PROXY_ENV_CLEARED,
81+
BAILIAN_CONSOLE_GATEWAY_URL: `https://${FAKE_GATEWAY_HOST}`,
82+
});
83+
// .invalid 域名直连必然 DNS 失败
84+
expect(exitCode).not.toBe(0);
85+
expect(connectTargets).toEqual([]);
86+
});
87+
88+
test("代理 URL 非法时给出明确报错而非堆栈", async () => {
89+
const { exitCode, stderr } = await runCli(["app", "list"], {
90+
...PROXY_ENV_CLEARED,
91+
HTTPS_PROXY: "::::not-a-url",
92+
BAILIAN_CONSOLE_GATEWAY_URL: `https://${FAKE_GATEWAY_HOST}`,
93+
});
94+
expect(exitCode).not.toBe(0);
95+
expect(stderr).toMatch(/Invalid proxy configuration/);
96+
expect(stderr).toMatch(/HTTPS_PROXY/);
97+
});
98+
});

packages/cli/tests/proxy.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { expect, test } from "vite-plus/test";
2+
import { readProxyEnv } from "../src/proxy.ts";
3+
4+
test("readProxyEnv: 未设置任何代理变量时全部为 undefined", () => {
5+
expect(readProxyEnv({})).toEqual({
6+
httpProxy: undefined,
7+
httpsProxy: undefined,
8+
noProxy: undefined,
9+
});
10+
});
11+
12+
test("readProxyEnv: 空白值视为未设置", () => {
13+
expect(readProxyEnv({ HTTPS_PROXY: "", HTTP_PROXY: " ", NO_PROXY: "" })).toEqual({
14+
httpProxy: undefined,
15+
httpsProxy: undefined,
16+
noProxy: undefined,
17+
});
18+
});
19+
20+
test("readProxyEnv: 大小写变量均可识别,小写优先", () => {
21+
expect(readProxyEnv({ HTTPS_PROXY: "http://upper:1" }).httpsProxy).toBe("http://upper:1");
22+
expect(readProxyEnv({ https_proxy: "http://lower:1" }).httpsProxy).toBe("http://lower:1");
23+
expect(
24+
readProxyEnv({ https_proxy: "http://lower:1", HTTPS_PROXY: "http://upper:1" }).httpsProxy,
25+
).toBe("http://lower:1");
26+
});
27+
28+
test("readProxyEnv: 空字符串小写变量不屏蔽已设置的大写变量", () => {
29+
expect(readProxyEnv({ https_proxy: "", HTTPS_PROXY: "http://upper:1" }).httpsProxy).toBe(
30+
"http://upper:1",
31+
);
32+
expect(readProxyEnv({ http_proxy: "", HTTP_PROXY: "http://upper:2" }).httpProxy).toBe(
33+
"http://upper:2",
34+
);
35+
});
36+
37+
test("readProxyEnv: NO_PROXY 独立读取", () => {
38+
const r = readProxyEnv({ NO_PROXY: "*.aliyuncs.com" });
39+
expect(r.noProxy).toBe("*.aliyuncs.com");
40+
expect(r.httpProxy).toBeUndefined();
41+
expect(r.httpsProxy).toBeUndefined();
42+
});

pnpm-lock.yaml

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ catalog:
88
boxen: ^8.0.1
99
chalk: ^5.6.2
1010
typescript: ^5
11+
undici: ^8.4.1
1112
vite: npm:@voidzero-dev/vite-plus-core@latest
1213
vite-plus: latest
1314
vitest: npm:@voidzero-dev/vite-plus-test@latest

0 commit comments

Comments
 (0)