Skip to content

Commit a90a35d

Browse files
Merge pull request #51 from modelstudioai/fix/proxy-env-support-v2
fix: honor HTTP_PROXY / HTTPS_PROXY / NO_PROXY env vars (#35)
2 parents 7c1be39 + d36bc5a commit a90a35d

15 files changed

Lines changed: 259 additions & 7 deletions

.claude/scheduled_tasks.lock

Lines changed: 0 additions & 1 deletion
This file was deleted.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ tools/generated
3333
.claude/worktrees/
3434
.claude/settings.json
3535
.claude/settings.local.json
36+
.claude/scheduled_tasks.lock
3637
.cursor/
3738
.qwen/
3839
.playwright-mcp/

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
66

77
[中文版](CHANGELOG.zh.md) · [README](README.md) · [Contributing](CONTRIBUTING.md)
88

9+
## [1.3.1] - 2026-06-12
10+
11+
### Fixed
12+
13+
- `bl` now honors `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` environment variables (#35). Node's built-in `fetch` (undici) ignores proxy env vars by default, causing `ECONNRESET` for users behind a VPN or corporate proxy. A global proxy dispatcher is now installed at startup when these variables are set, and the `ECONNRESET` error hint points to `export HTTPS_PROXY=http://127.0.0.1:<port>`.
14+
915
## [1.3.0] - 2026-06-10
1016

1117
### Added

CHANGELOG.zh.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@
66

77
[English](CHANGELOG.md) · [README](README.zh.md) · [参与贡献](CONTRIBUTING.zh.md)
88

9+
## [1.3.1] - 2026-06-12
10+
11+
### 修复
12+
13+
- `bl` 现在会读取 `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` 环境变量(#35)。Node 内置的 `fetch`(undici)默认忽略代理环境变量,导致 VPN 或公司代理下出现 `ECONNRESET`。现已在启动时根据这些变量安装全局代理 dispatcher,并在 `ECONNRESET` 报错提示中给出 `export HTTPS_PROXY=http://127.0.0.1:<port>` 的指引。
14+
915
## [1.3.0] - 2026-06-11
1016

1117
### 新增

packages/cli/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "bailian-cli",
3-
"version": "1.3.0",
3+
"version": "1.3.1",
44
"description": "CLI for Aliyun Model Studio (DashScope) AI Platform.",
55
"keywords": [
66
"agent",
@@ -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
@@ -8,6 +8,7 @@ import {
88
flushTelemetry,
99
} from "bailian-cli-core";
1010
import { ensureApiKey } from "./utils/ensure-key.ts";
11+
import { setupProxyFromEnv } from "./proxy.ts";
1112
import { handleError } from "./error-handler.ts";
1213
import { checkForUpdate, getPendingUpdateNotification } from "./utils/update-checker.ts";
1314
import { maybeShowStatusBar } from "./output/status-bar.ts";
@@ -19,6 +20,13 @@ import {
1920
setExecutingCommandPath,
2021
} from "./utils/command-help.ts";
2122

23+
// 必须在任何 fetch 发起前安装(含 update-checker / telemetry)
24+
try {
25+
setupProxyFromEnv();
26+
} catch (err) {
27+
handleError(err);
28+
}
29+
2230
registerCommandHelpPrinter((commandPath, out) => {
2331
registry.printHelp(commandPath, out);
2432
});

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: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { execFile } from "child_process";
2+
import { createServer, type Server } from "http";
3+
import { mkdtempSync, rmSync, writeFileSync } from "fs";
4+
import type { AddressInfo } from "net";
5+
import { tmpdir } from "os";
6+
import { join } from "path";
7+
import { promisify } from "util";
8+
import { afterAll, beforeAll, describe, expect, test } from "vite-plus/test";
9+
import { cliPackageRoot } from "./helpers.ts";
10+
11+
const execFileAsync = promisify(execFile);
12+
13+
/**
14+
* 代理支持 E2E(issue #35):只验证 `setupProxyFromEnv()` 是否把代理 dispatcher
15+
* 正确装到全局 fetch 上——设了 HTTPS_PROXY 后裸 `fetch()` 走代理,未设置时直连,
16+
* NO_PROXY 命中时跳过,非法代理值给出明确报错。
17+
*
18+
* 不经过任何 CLI 命令(不解析凭证、不打 gateway),因此 CI 上无需 api key /
19+
* access token,与既有 e2e 设计一致。全程离线:目标域名用 `.invalid`(保留顶级域,
20+
* 必然无法解析),代理收到 CONNECT 后规范返回 502,不产生真实外网请求。
21+
*/
22+
23+
const FAKE_HOST = "bl-proxy-e2e.invalid";
24+
const FAKE_URL = `https://${FAKE_HOST}/probe`;
25+
26+
/**
27+
* 最小探针脚本:调用真实的 `setupProxyFromEnv()`,再对目标发一个普通 fetch。
28+
* 代理行为由进程环境变量决定,正是被测对象;fetch 成败不重要,我们只看代理是否收到 CONNECT。
29+
*/
30+
const PROBE_SCRIPT = `
31+
import { setupProxyFromEnv } from ${JSON.stringify(join(cliPackageRoot, "src", "proxy.ts"))};
32+
setupProxyFromEnv();
33+
try {
34+
await fetch(${JSON.stringify(FAKE_URL)}, { signal: AbortSignal.timeout(5000) });
35+
} catch {
36+
// 目标不可达/隧道被拒都正常——本测试只关心代理是否收到 CONNECT
37+
}
38+
`;
39+
40+
let proxy: Server;
41+
let proxyUrl: string;
42+
let scriptDir: string;
43+
let scriptPath: string;
44+
const connectTargets: string[] = [];
45+
46+
beforeAll(async () => {
47+
proxy = createServer();
48+
// 记录收到的 CONNECT 目标(host:port),并以 502 拒绝隧道
49+
proxy.on("connect", (req, clientSocket) => {
50+
connectTargets.push(req.url ?? "");
51+
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
52+
});
53+
await new Promise<void>((resolve) => proxy.listen(0, "127.0.0.1", resolve));
54+
proxyUrl = `http://127.0.0.1:${(proxy.address() as AddressInfo).port}`;
55+
56+
scriptDir = mkdtempSync(join(tmpdir(), "bl-proxy-e2e-"));
57+
scriptPath = join(scriptDir, "probe.ts");
58+
writeFileSync(scriptPath, PROBE_SCRIPT);
59+
});
60+
61+
afterAll(async () => {
62+
await new Promise<void>((resolve) => proxy.close(() => resolve()));
63+
rmSync(scriptDir, { recursive: true, force: true });
64+
});
65+
66+
/** 清空所有代理相关环境变量,确保每个用例只受自身设置影响 */
67+
const PROXY_ENV_CLEARED = {
68+
HTTPS_PROXY: "",
69+
https_proxy: "",
70+
HTTP_PROXY: "",
71+
http_proxy: "",
72+
NO_PROXY: "",
73+
no_proxy: "",
74+
};
75+
76+
/** 以给定代理环境变量运行探针脚本,返回 { exitCode, stderr } */
77+
async function runProbe(
78+
envOverrides: NodeJS.ProcessEnv,
79+
): Promise<{ exitCode: number; stderr: string }> {
80+
try {
81+
await execFileAsync("node", [scriptPath], {
82+
cwd: cliPackageRoot,
83+
encoding: "utf8",
84+
env: { ...process.env, NODE_NO_WARNINGS: "1", ...PROXY_ENV_CLEARED, ...envOverrides },
85+
});
86+
return { exitCode: 0, stderr: "" };
87+
} catch (err: unknown) {
88+
const e = err as { stderr?: string; code?: number };
89+
return { exitCode: typeof e.code === "number" ? e.code : 1, stderr: e.stderr ?? "" };
90+
}
91+
}
92+
93+
describe("e2e: proxy", () => {
94+
test("设置 HTTPS_PROXY 后 fetch 经过代理(CONNECT 到目标主机)", async () => {
95+
connectTargets.length = 0;
96+
await runProbe({ HTTPS_PROXY: proxyUrl });
97+
expect(connectTargets).toContain(`${FAKE_HOST}:443`);
98+
});
99+
100+
test("空字符串小写变量不屏蔽大写 HTTPS_PROXY(undici ?? 取值回归)", async () => {
101+
connectTargets.length = 0;
102+
await runProbe({ https_proxy: "", HTTPS_PROXY: proxyUrl });
103+
expect(connectTargets).toContain(`${FAKE_HOST}:443`);
104+
});
105+
106+
test("NO_PROXY 命中目标主机时不走代理", async () => {
107+
connectTargets.length = 0;
108+
await runProbe({ HTTPS_PROXY: proxyUrl, NO_PROXY: FAKE_HOST });
109+
expect(connectTargets.filter((t) => t.startsWith(FAKE_HOST))).toEqual([]);
110+
});
111+
112+
test("未设置代理变量时保持直连(代理收不到任何流量)", async () => {
113+
connectTargets.length = 0;
114+
await runProbe({});
115+
expect(connectTargets).toEqual([]);
116+
});
117+
118+
test("代理 URL 非法时给出明确报错而非堆栈", async () => {
119+
const { exitCode, stderr } = await runProbe({ HTTPS_PROXY: "::::not-a-url" });
120+
expect(exitCode).not.toBe(0);
121+
expect(stderr).toMatch(/Invalid proxy configuration/);
122+
expect(stderr).toMatch(/HTTPS_PROXY/);
123+
});
124+
});

packages/cli/tests/e2e/video-ref-r2v.e2e.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
6969
"--model",
7070
"qwen-image-2.0",
7171
"--prompt",
72-
"一只简笔画小猫,白底",
72+
"一片绿色的树叶,白底",
7373
"--out-dir",
7474
outDir,
7575
"--out-prefix",

0 commit comments

Comments
 (0)