@zhushanwen/pi-network-proxy 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -2
- package/index.ts +1 -0
- package/package.json +66 -3
- package/skills/network-proxy-ext-config/SKILL.md +75 -0
- package/src/__tests__/config.test.ts +234 -0
- package/src/__tests__/dispatcher-construction.test.ts +110 -0
- package/src/__tests__/dispatcher.test.ts +156 -0
- package/src/__tests__/index.test.ts +157 -0
- package/src/__tests__/installer.test.ts +313 -0
- package/src/__tests__/match.test.ts +86 -0
- package/src/__tests__/startup-config-declaration.test.ts +38 -0
- package/src/__tests__/stats.test.ts +171 -0
- package/src/config.ts +176 -0
- package/src/dispatcher.ts +169 -0
- package/src/index.ts +86 -0
- package/src/installer.ts +165 -0
- package/src/match.ts +33 -0
- package/src/stats.ts +127 -0
- package/vitest.config.ts +7 -0
package/README.md
CHANGED
|
@@ -1,3 +1,34 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @zhushanwen/pi-network-proxy
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
LLM 网络代理分流:在 pi 进程内安装按主机名规则分流的 undici 全局分发器——命中规则的请求经代理出站(http/https/socks5),未命中的委托 pi 原生分发器(直连或 env 代理路由),并按主机名计数(经代理 / 直连)。
|
|
4
|
+
|
|
5
|
+
## 行为摘要
|
|
6
|
+
|
|
7
|
+
- **安装点**:extension 加载时读配置,`enabled` 且校验通过 → 捕获 pi 已装的 `EnvHttpProxyAgent`(接管而非向空闲槽安装)→ 构造 `RuleDispatchDispatcher` 并 `setGlobalDispatcher`;校验/构造失败 → extension error(含配置文件恢复指引),不安装不阻断 pi(全部直连)
|
|
8
|
+
- **热生效**:turn 粒度——每 turn 开始重读配置(mtime+size 读时刷新),指纹变化才重建;`enabled` true→false 恢复原分发器
|
|
9
|
+
- **匹配语义**:主机名归一化(小写、去末尾点)后 suffix 匹配(等于或以 `.条目` 结尾,点边界对齐);noProxy 优先于 rules;覆盖一切走全局分发器的出站(含 WebSocket 升级请求)
|
|
10
|
+
- **计数**:请求发起时 +1(内存按主机名聚合),turn 结束 / 进程退出把增量追加写入 `<agentDir>/network-proxy-stats.jsonl`(每行完整 JSON `{ts, host, proxied, direct}`,单次 write 单行——多进程并发追加不交错);写失败 log 不阻断
|
|
11
|
+
- **降级契约**:socks5 构造失败(undici 实验性 API)→ 记错误、不安装、全部直连,切回 http 协议恢复;同指纹不重试构造,配置修正(指纹变化)后自然重试
|
|
12
|
+
|
|
13
|
+
## 配置
|
|
14
|
+
|
|
15
|
+
`<agentDir>/config/network-proxy-ext-config.json`(schema、匹配语义、生效时机、故障恢复见 `skills/network-proxy-ext-config/SKILL.md`):
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{
|
|
19
|
+
"enabled": false,
|
|
20
|
+
"proxy": { "protocol": "http", "host": "127.0.0.1", "port": 7890, "username": "", "password": "" },
|
|
21
|
+
"noProxy": ["localhost", "127.0.0.1"],
|
|
22
|
+
"rules": ["api.anthropic.com"]
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
taiji 桌面端经设置页「网络代理」读写(runtime RMW 写侧);独立 pi 用户手编文件即可,保存后自下一轮对话起生效。
|
|
27
|
+
|
|
28
|
+
## 两协议构造契约(防回归锚)
|
|
29
|
+
|
|
30
|
+
- http/https:`ProxyAgent` 调优经 `proxyTls: { autoSelectFamilyAttemptTimeout: 2000 }`(构造内部展开透传到「到代理」连接);禁传 `connect` 选项——主类不消费该字段,传入被静默忽略
|
|
31
|
+
- socks5:`Socks5ProxyAgent` 调优经 `connect: buildConnector({ autoSelectFamilyAttemptTimeout: 2000 })` 的 **connector 函数形态**(对象形态首个请求建隧道时 TypeError);`proxyTls` 是「代理连接走 TLS」开关,不可当调优参数袋
|
|
32
|
+
- 两协议 agent 均挂防崩溃 error listener(对齐 pi `withUndiciErrorListener` 形态)
|
|
33
|
+
|
|
34
|
+
上述契约由 `src/__tests__/dispatcher-construction.test.ts` 构造形态冒烟测试锁定;undici 升级时该测试是第一道回归防线。
|
package/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./src/index.ts";
|
package/package.json
CHANGED
|
@@ -1,6 +1,69 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-network-proxy",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "LLM network proxy: rule-based per-hostname traffic splitting via an undici global dispatcher (proxied vs direct), with request counters.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.ts",
|
|
7
|
+
"taiji": {
|
|
8
|
+
"role": "universal",
|
|
9
|
+
"startupConfig": [
|
|
10
|
+
{
|
|
11
|
+
"path": "config/network-proxy-ext-config.json",
|
|
12
|
+
"content": {
|
|
13
|
+
"enabled": false,
|
|
14
|
+
"proxy": {
|
|
15
|
+
"protocol": "http",
|
|
16
|
+
"host": "127.0.0.1",
|
|
17
|
+
"port": 7890,
|
|
18
|
+
"username": "",
|
|
19
|
+
"password": ""
|
|
20
|
+
},
|
|
21
|
+
"noProxy": [
|
|
22
|
+
"localhost",
|
|
23
|
+
"127.0.0.1"
|
|
24
|
+
],
|
|
25
|
+
"rules": []
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
"pi": {
|
|
31
|
+
"extensions": [
|
|
32
|
+
"./index.ts"
|
|
33
|
+
],
|
|
34
|
+
"skills": [
|
|
35
|
+
"./skills"
|
|
36
|
+
]
|
|
37
|
+
},
|
|
38
|
+
"keywords": [
|
|
39
|
+
"pi-package"
|
|
40
|
+
],
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"undici": "^7.28.0",
|
|
43
|
+
"@zhushanwen/pi-ext-guards": "0.5.0",
|
|
44
|
+
"@zhushanwen/pi-extension-logger": "0.6.2",
|
|
45
|
+
"@zhushanwen/pi-file-lock": "0.4.1",
|
|
46
|
+
"@zhushanwen/pi-llm-shared": "0.11.3"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@vitest/coverage-v8": "^4.1.9",
|
|
50
|
+
"vitest": "^4.1.8"
|
|
51
|
+
},
|
|
52
|
+
"files": [
|
|
53
|
+
"index.ts",
|
|
54
|
+
"src/**/*.ts",
|
|
55
|
+
"skills/",
|
|
56
|
+
"vitest.config.ts"
|
|
57
|
+
],
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"@earendil-works/pi-coding-agent": "^1.0.0"
|
|
60
|
+
},
|
|
61
|
+
"peerDependenciesMeta": {
|
|
62
|
+
"@earendil-works/pi-coding-agent": {
|
|
63
|
+
"optional": true
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
"scripts": {
|
|
67
|
+
"test": "vitest run"
|
|
68
|
+
}
|
|
6
69
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: network-proxy-ext-config
|
|
3
|
+
description: "配置 @zhushanwen/pi-network-proxy(LLM 网络代理分流)时加载。含配置文件路径、NetworkProxyConfig schema(enabled/proxy/noProxy/rules)、协议三选一(http/https/socks5)、suffix 主机名匹配语义、noProxy 优先级、凭据成对约束、默认值、示例、生效时机(turn 粒度热重载)、统计文件、故障恢复。触发词:配置网络代理、network-proxy 配置、代理规则、走代理、socks5、noProxy、network-proxy config、代理不生效、代理报错、直连排除。"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# network-proxy 配置指南
|
|
7
|
+
|
|
8
|
+
> @zhushanwen/pi-network-proxy:在 pi 进程内安装按主机名规则分流的 undici 全局分发器——命中规则的 LLM 请求经代理出站,未命中的保持 pi 原生行为(直连或 env 代理),并按主机名计数(经代理 / 直连)。
|
|
9
|
+
|
|
10
|
+
## 配置文件位置
|
|
11
|
+
|
|
12
|
+
`<agentDir>/config/network-proxy-ext-config.json`
|
|
13
|
+
|
|
14
|
+
- `<agentDir>` = pi agent 目录(`PI_CODING_AGENT_DIR` 覆盖,默认 `~/.pi/agent`;taiji 隔离环境为 `~/.taiji/agent`)
|
|
15
|
+
- 走 llm-shared 泛型 config(config/ 子目录 + getAgentDir 派生 + mtime+size 缓存 + 原子写)
|
|
16
|
+
- taiji 桌面端由 runtime 启动序列首建该文件(`taiji.startupConfig` 声明);独立 pi 用户手建同样生效
|
|
17
|
+
- 文件缺失/坏 JSON/字段非法:回落默认(enabled=false,全部直连)并以 extension error 呈现,不阻断 pi
|
|
18
|
+
|
|
19
|
+
## Schema
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
interface NetworkProxyConfig {
|
|
23
|
+
enabled: boolean; // 总开关,默认 false(禁用 = 不安装分流器,全部按 pi 原生行为出站)
|
|
24
|
+
proxy: {
|
|
25
|
+
protocol: "http" | "https" | "socks5"; // http/https 走 undici ProxyAgent,socks5 走 Socks5ProxyAgent
|
|
26
|
+
host: string; // 代理服务器地址,如 127.0.0.1
|
|
27
|
+
port: number; // 1-65535 整数
|
|
28
|
+
username: string; // 认证用户名(可空,但必须与 password 成对)
|
|
29
|
+
password: string; // 认证密码(可空,但必须与 username 成对)
|
|
30
|
+
};
|
|
31
|
+
noProxy: string[]; // 直连排除清单(主机名条目),优先级高于 rules——本地网关地址必须加在这里
|
|
32
|
+
rules: string[]; // 代理规则(主机名条目),命中即经代理
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
校验规则(违反即回落禁用态并报错,错误文案含修复动作):协议白名单、端口 1-65535 整数、凭据成对(同空或同非空)、noProxy/rules 每条非空且不含空白字符(含空白的条目对真实主机名恒不匹配,校验层显式拒绝,错误文案提示删除空白)。
|
|
37
|
+
|
|
38
|
+
## 匹配语义
|
|
39
|
+
|
|
40
|
+
- 主机名与条目先归一化(转小写、去末尾点),再 suffix 匹配:**等于或以 `.条目` 结尾**
|
|
41
|
+
- 条目 `api.anthropic.com` 命中 `api.anthropic.com` 与 `eu.api.anthropic.com`
|
|
42
|
+
- `xapi.anthropic.com` 不命中(无点前缀拼接);`api-anthropic.com.evil.com` 不命中(恶意后缀)
|
|
43
|
+
- 匹配对象 = 请求目标 URL 的主机名(服务商 baseURL 的主机名;端口不参与匹配)
|
|
44
|
+
- noProxy 先于 rules 判定(先排除再匹配);`localhost` 条目同时覆盖 `localhost` 与 `*.localhost`
|
|
45
|
+
- 不支持 `*` 通配(无已发生需求);分流覆盖 pi 进程内一切走全局分发器的出站(含 WebSocket 升级请求)
|
|
46
|
+
|
|
47
|
+
## 生效时机(turn 粒度热重载)
|
|
48
|
+
|
|
49
|
+
- 每个 turn 开始重读配置,指纹(enabled+proxy+noProxy+rules)变化才重建分发器——**运行中会话自下一轮对话起生效,无需重启**;turn 进行中(工具循环连续请求)保存的配置对该 turn 内后续请求不生效
|
|
50
|
+
- `enabled` 从 true → false:立即恢复 pi 原生分发器(全部直连/env 代理路由)
|
|
51
|
+
- 未命中规则的流量委托 pi 原生分发器——独立 pi 用户设了 `HTTPS_PROXY` 环境变量时,未命中流量仍走 env 代理,语义不丢失
|
|
52
|
+
|
|
53
|
+
## 统计文件
|
|
54
|
+
|
|
55
|
+
`<agentDir>/network-proxy-stats.jsonl`(agentDir 根):每行一个 JSON 片段 `{ ts, host, proxied, direct }`,turn 结束 / 进程退出时把本进程增量追加落盘;计数时点 = 请求发起时。taiji 设置页「网络代理 → 请求统计」读侧聚合展示(含重置与截断),直接读文件按行求和亦可。
|
|
56
|
+
|
|
57
|
+
## 故障恢复
|
|
58
|
+
|
|
59
|
+
| 现象 | 处置 |
|
|
60
|
+
|---|---|
|
|
61
|
+
| 代理不可达(请求报 `fetch failed` / `ECONNREFUSED`) | 启动代理软件;或把 `enabled` 改为 `false` 一键恢复直连 |
|
|
62
|
+
| socks5 构造失败(undici 实验性 API 变化) | extension error 提示并保持全部直连;切回 `http` 协议即可恢复代理 |
|
|
63
|
+
| 配置校验失败(端口越界 / 凭据单边等) | 按报错文案修正配置文件,或删除该文件恢复默认禁用态 |
|
|
64
|
+
| 想让某服务商不再走代理 | 从 `rules` 删除对应主机名条目,或将其加入 `noProxy` |
|
|
65
|
+
|
|
66
|
+
## 配置示例
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{
|
|
70
|
+
"enabled": true,
|
|
71
|
+
"proxy": { "protocol": "http", "host": "127.0.0.1", "port": 7890, "username": "", "password": "" },
|
|
72
|
+
"noProxy": ["localhost", "127.0.0.1"],
|
|
73
|
+
"rules": ["api.anthropic.com"]
|
|
74
|
+
}
|
|
75
|
+
```
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 配置校验语义镜像 + 指纹 + 热重载读取。
|
|
3
|
+
*
|
|
4
|
+
* 校验/默认值语义 SSOT = packages/shared/src/network-proxy.ts(复制体镜像锁定——本文件
|
|
5
|
+
* 关键形态断言的字面文案与 shared validateNetworkProxyConfig 一致);DEFAULT 与
|
|
6
|
+
* startupConfig 声明的深相等由 startup-config-declaration.test.ts 另锁。
|
|
7
|
+
*/
|
|
8
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
12
|
+
|
|
13
|
+
import { clearConfigCache } from "@zhushanwen/pi-llm-shared";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
DEFAULT_NETWORK_PROXY_CONFIG,
|
|
17
|
+
loadNetworkProxyConfig,
|
|
18
|
+
networkProxyFingerprint,
|
|
19
|
+
normalizeNetworkProxyConfig,
|
|
20
|
+
validateNetworkProxyConfig,
|
|
21
|
+
type NetworkProxyConfig,
|
|
22
|
+
} from "../config.ts";
|
|
23
|
+
|
|
24
|
+
// mock 掉共享 logger(loadConfig 的 onWarning 由本测试显式断言,logger 通道静音防噪音)
|
|
25
|
+
const { loggerMock } = vi.hoisted(() => ({
|
|
26
|
+
loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
27
|
+
}));
|
|
28
|
+
vi.mock("@zhushanwen/pi-extension-logger", () => ({
|
|
29
|
+
getLogger: () => loggerMock,
|
|
30
|
+
createLogger: () => loggerMock,
|
|
31
|
+
setPiHandle: vi.fn(),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
let agentDir: string;
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
agentDir = mkdtempSync(join(tmpdir(), "network-proxy-cfg-"));
|
|
38
|
+
vi.stubEnv("PI_CODING_AGENT_DIR", agentDir);
|
|
39
|
+
clearConfigCache();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
vi.unstubAllEnvs();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function writeConfigFile(content: unknown): void {
|
|
47
|
+
mkdirSync(join(agentDir, "config"), { recursive: true });
|
|
48
|
+
writeFileSync(
|
|
49
|
+
join(agentDir, "config", "network-proxy-ext-config.json"),
|
|
50
|
+
JSON.stringify(content, null, 2),
|
|
51
|
+
"utf-8",
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function validConfig(overrides?: Partial<NetworkProxyConfig>): NetworkProxyConfig {
|
|
56
|
+
return {
|
|
57
|
+
enabled: true,
|
|
58
|
+
proxy: { protocol: "http", host: "127.0.0.1", port: 7890, username: "", password: "" },
|
|
59
|
+
noProxy: ["localhost"],
|
|
60
|
+
rules: ["api.anthropic.com"],
|
|
61
|
+
...overrides,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe("validateNetworkProxyConfig(语义镜像)", () => {
|
|
66
|
+
it("合法配置 ok", () => {
|
|
67
|
+
expect(validateNetworkProxyConfig(validConfig())).toEqual({ ok: true });
|
|
68
|
+
expect(validateNetworkProxyConfig(DEFAULT_NETWORK_PROXY_CONFIG)).toEqual({ ok: true });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("协议白名单:非法协议报错且文案与 shared 一致", () => {
|
|
72
|
+
const result = validateNetworkProxyConfig(validConfig({
|
|
73
|
+
proxy: { protocol: "socks4" as never, host: "127.0.0.1", port: 7890, username: "", password: "" },
|
|
74
|
+
}));
|
|
75
|
+
expect(result.ok).toBe(false);
|
|
76
|
+
if (!result.ok) {
|
|
77
|
+
expect(result.errors[0]).toContain("http / https / socks5");
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("端口越界(70000 / 0 / 非整数)逐项报错,不短路", () => {
|
|
82
|
+
for (const port of [70000, 0, 1.5]) {
|
|
83
|
+
const result = validateNetworkProxyConfig(validConfig({
|
|
84
|
+
proxy: { protocol: "http", host: "127.0.0.1", port, username: "", password: "" },
|
|
85
|
+
}));
|
|
86
|
+
expect(result.ok).toBe(false);
|
|
87
|
+
if (!result.ok) {
|
|
88
|
+
expect(result.errors.some((e) => e.includes("1-65535"))).toBe(true);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("凭据单边报错(成对约束)", () => {
|
|
94
|
+
const onlyUser = validateNetworkProxyConfig(validConfig({
|
|
95
|
+
proxy: { protocol: "http", host: "h", port: 1, username: "u", password: "" },
|
|
96
|
+
}));
|
|
97
|
+
const onlyPass = validateNetworkProxyConfig(validConfig({
|
|
98
|
+
proxy: { protocol: "http", host: "h", port: 1, username: "", password: "p" },
|
|
99
|
+
}));
|
|
100
|
+
expect(onlyUser.ok).toBe(false);
|
|
101
|
+
expect(onlyPass.ok).toBe(false);
|
|
102
|
+
// 成对非空合法
|
|
103
|
+
expect(validateNetworkProxyConfig(validConfig({
|
|
104
|
+
proxy: { protocol: "http", host: "h", port: 1, username: "u", password: "p" },
|
|
105
|
+
})).ok).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("enabled 非布尔报错", () => {
|
|
109
|
+
const result = validateNetworkProxyConfig(validConfig({ enabled: "yes" as never }));
|
|
110
|
+
expect(result.ok).toBe(false);
|
|
111
|
+
if (!result.ok) {
|
|
112
|
+
expect(result.errors[0]).toContain("开或关");
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("noProxy/rules 条目空串与非列表报错", () => {
|
|
117
|
+
const emptyEntry = validateNetworkProxyConfig(validConfig({ rules: [" "] }));
|
|
118
|
+
expect(emptyEntry.ok).toBe(false);
|
|
119
|
+
const notArray = validateNetworkProxyConfig(validConfig({ noProxy: "localhost" as never }));
|
|
120
|
+
expect(notArray.ok).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("noProxy/rules 条目空白字符拒绝(首尾与内部同等拒绝——归一化不 trim,带空白恒不匹配故校验显式拒绝,文案与 shared 一致)", () => {
|
|
124
|
+
const leading = validateNetworkProxyConfig(validConfig({ rules: [" api.anthropic.com"] }));
|
|
125
|
+
expect(leading.ok).toBe(false);
|
|
126
|
+
if (!leading.ok) {
|
|
127
|
+
expect(leading.errors[0]).toContain("第 1 条含空白字符");
|
|
128
|
+
}
|
|
129
|
+
const trailingTab = validateNetworkProxyConfig(validConfig({ noProxy: ["localhost\t"] }));
|
|
130
|
+
expect(trailingTab.ok).toBe(false);
|
|
131
|
+
if (!trailingTab.ok) {
|
|
132
|
+
expect(trailingTab.errors[0]).toContain("含空白字符,请删除空白后保存");
|
|
133
|
+
}
|
|
134
|
+
// 内部空白(如「api. anthropic.com」)同样拒绝:对真实主机名恒不匹配,静默不生效
|
|
135
|
+
const inner = validateNetworkProxyConfig(validConfig({ rules: ["api. anthropic.com"] }));
|
|
136
|
+
expect(inner.ok).toBe(false);
|
|
137
|
+
if (!inner.ok) {
|
|
138
|
+
expect(inner.errors[0]).toContain("含空白字符,请删除空白后保存");
|
|
139
|
+
}
|
|
140
|
+
// 纯空白条目仍走「为空」分支,不误报为「含空白」
|
|
141
|
+
const blankOnly = validateNetworkProxyConfig(validConfig({ rules: [" "] }));
|
|
142
|
+
expect(blankOnly.ok).toBe(false);
|
|
143
|
+
if (!blankOnly.ok) {
|
|
144
|
+
expect(blankOnly.errors[0]).toContain("第 1 条为空");
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("对象性守卫:null / 数组 / 标量返回错误信封而非抛异常", () => {
|
|
149
|
+
for (const bad of [null, [validConfig()], "config", 42]) {
|
|
150
|
+
const result = validateNetworkProxyConfig(bad);
|
|
151
|
+
expect(result.ok).toBe(false);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("normalizeNetworkProxyConfig(读取路径校验闸)", () => {
|
|
157
|
+
it("合法 raw 原样返回", () => {
|
|
158
|
+
const config = validConfig();
|
|
159
|
+
expect(normalizeNetworkProxyConfig(config)).toEqual(config);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("非法 raw throw 且消息含逐项错误(loadConfig 捕获后回落 defaults + onWarning)", () => {
|
|
163
|
+
expect(() =>
|
|
164
|
+
normalizeNetworkProxyConfig(validConfig({
|
|
165
|
+
proxy: { protocol: "http", host: "127.0.0.1", port: 70000, username: "", password: "" },
|
|
166
|
+
})),
|
|
167
|
+
).toThrow(/1-65535/);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("networkProxyFingerprint(指纹比对)", () => {
|
|
172
|
+
it("同配置同指纹", () => {
|
|
173
|
+
expect(networkProxyFingerprint(validConfig())).toBe(networkProxyFingerprint(validConfig()));
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("enabled 翻转 / proxy 变更 / noProxy 变更 / rules 变更各自产生不同指纹", () => {
|
|
177
|
+
const base = networkProxyFingerprint(validConfig());
|
|
178
|
+
expect(networkProxyFingerprint(validConfig({ enabled: false }))).not.toBe(base);
|
|
179
|
+
expect(networkProxyFingerprint(validConfig({
|
|
180
|
+
proxy: { protocol: "http", host: "10.0.0.1", port: 7890, username: "", password: "" },
|
|
181
|
+
}))).not.toBe(base);
|
|
182
|
+
expect(networkProxyFingerprint(validConfig({ noProxy: ["localhost", "internal.test"] }))).not.toBe(base);
|
|
183
|
+
expect(networkProxyFingerprint(validConfig({ rules: ["api.openai.com"] }))).not.toBe(base);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("重复条目不影响指纹(去重语义——匹配幂等,重建判据不受条目重复干扰)", () => {
|
|
187
|
+
expect(networkProxyFingerprint(validConfig({ rules: ["a.test", "a.test"] }))).toBe(
|
|
188
|
+
networkProxyFingerprint(validConfig({ rules: ["a.test"] })),
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("loadNetworkProxyConfig(llm-shared 读时刷新热重载)", () => {
|
|
194
|
+
it("文件缺失回落 defaults", () => {
|
|
195
|
+
expect(loadNetworkProxyConfig()).toEqual(DEFAULT_NETWORK_PROXY_CONFIG);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("合法文件返回配置;改文件后同进程重读即时生效(热重载)", () => {
|
|
199
|
+
writeConfigFile(validConfig());
|
|
200
|
+
expect(loadNetworkProxyConfig()?.enabled).toBe(true);
|
|
201
|
+
|
|
202
|
+
writeConfigFile(validConfig({ enabled: false }));
|
|
203
|
+
expect(loadNetworkProxyConfig()?.enabled).toBe(false);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("非法文件(字段非法)回落 defaults 并经 onWarning 报错(验收场景 10 的读取侧形态)", () => {
|
|
207
|
+
const warnings: string[] = [];
|
|
208
|
+
writeConfigFile(validConfig({
|
|
209
|
+
proxy: { protocol: "http", host: "127.0.0.1", port: 70000, username: "", password: "" },
|
|
210
|
+
}));
|
|
211
|
+
const config = loadNetworkProxyConfig((msg) => warnings.push(msg));
|
|
212
|
+
expect(config).toEqual(DEFAULT_NETWORK_PROXY_CONFIG);
|
|
213
|
+
expect(warnings).toHaveLength(1);
|
|
214
|
+
expect(warnings[0]).toContain("1-65535");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("手编文件带空白字符条目(内部空白形态)回落 defaults 并经 onWarning 报错(不再静默不生效)", () => {
|
|
218
|
+
const warnings: string[] = [];
|
|
219
|
+
writeConfigFile(validConfig({ noProxy: ["api. anthropic.com"] }));
|
|
220
|
+
const config = loadNetworkProxyConfig((msg) => warnings.push(msg));
|
|
221
|
+
expect(config).toEqual(DEFAULT_NETWORK_PROXY_CONFIG);
|
|
222
|
+
expect(warnings).toHaveLength(1);
|
|
223
|
+
expect(warnings[0]).toContain("含空白字符,请删除空白后保存");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("坏 JSON 回落 defaults 并经 onWarning 报错", () => {
|
|
227
|
+
const warnings: string[] = [];
|
|
228
|
+
mkdirSync(join(agentDir, "config"), { recursive: true });
|
|
229
|
+
writeFileSync(join(agentDir, "config", "network-proxy-ext-config.json"), "{not json", "utf-8");
|
|
230
|
+
const config = loadNetworkProxyConfig((msg) => warnings.push(msg));
|
|
231
|
+
expect(config).toEqual(DEFAULT_NETWORK_PROXY_CONFIG);
|
|
232
|
+
expect(warnings).toHaveLength(1);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 两协议构造形态冒烟(任务书验收②,防回归锚——防实现照抄错形态):
|
|
3
|
+
* - http/https:ProxyAgent 必须收到 proxyTls 调优(主类内部 buildConnector({...opts.proxyTls})
|
|
4
|
+
* 透传),且不得传 connect 选项(ProxyAgent 不消费该字段——传入被静默忽略、调优失效不报错)
|
|
5
|
+
* - socks5:Socks5ProxyAgent 必须收到函数形态 connect(buildConnector 产物)——构造选项只收
|
|
6
|
+
* connector 函数,对象形态首个请求建隧道时 TypeError;proxyTls 是 TLS 开关不可当调优袋
|
|
7
|
+
* 契约来源:设计 §3.1 行为序列 3(undici 7.28.0 源码读码 + 构造探针核实)。
|
|
8
|
+
*/
|
|
9
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
10
|
+
|
|
11
|
+
const { proxyAgentCalls, socks5AgentCalls, connectorResults, mockBuildConnector } = vi.hoisted(() => ({
|
|
12
|
+
proxyAgentCalls: [] as Array<Record<string, unknown>>,
|
|
13
|
+
socks5AgentCalls: [] as Array<{ url: string; options: Record<string, unknown> }>,
|
|
14
|
+
connectorResults: [] as Array<(...args: unknown[]) => unknown>,
|
|
15
|
+
mockBuildConnector: (options: Record<string, unknown>) => {
|
|
16
|
+
const connector = () => {};
|
|
17
|
+
connectorResults.push(options);
|
|
18
|
+
return connector;
|
|
19
|
+
},
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock("undici", async (importOriginal) => {
|
|
23
|
+
const actual = await importOriginal<typeof import("undici")>();
|
|
24
|
+
class MockProxyAgent extends actual.Dispatcher {
|
|
25
|
+
constructor(options: Record<string, unknown>) {
|
|
26
|
+
super();
|
|
27
|
+
proxyAgentCalls.push(options);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
class MockSocks5ProxyAgent extends actual.Dispatcher {
|
|
31
|
+
constructor(url: string, options: Record<string, unknown> = {}) {
|
|
32
|
+
super();
|
|
33
|
+
socks5AgentCalls.push({ url, options });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
...actual,
|
|
38
|
+
ProxyAgent: MockProxyAgent,
|
|
39
|
+
Socks5ProxyAgent: MockSocks5ProxyAgent,
|
|
40
|
+
buildConnector: mockBuildConnector,
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
import {
|
|
45
|
+
buildProxyDispatcher,
|
|
46
|
+
buildProxyUrl,
|
|
47
|
+
PROXY_CONNECT_ATTEMPT_TIMEOUT_MS,
|
|
48
|
+
} from "../dispatcher.ts";
|
|
49
|
+
import type { NetworkProxyConfig } from "../config.ts";
|
|
50
|
+
|
|
51
|
+
function config(protocol: "http" | "https" | "socks5"): NetworkProxyConfig {
|
|
52
|
+
return {
|
|
53
|
+
enabled: true,
|
|
54
|
+
proxy: { protocol, host: "127.0.0.1", port: 7890, username: "", password: "" },
|
|
55
|
+
noProxy: [],
|
|
56
|
+
rules: [],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
proxyAgentCalls.length = 0;
|
|
62
|
+
socks5AgentCalls.length = 0;
|
|
63
|
+
connectorResults.length = 0;
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("两协议构造形态冒烟(防回归锚)", () => {
|
|
67
|
+
it("http:ProxyAgent 收到 proxyTls 调优,不传 connect 选项(传入会被静默忽略)", () => {
|
|
68
|
+
buildProxyDispatcher(config("http"));
|
|
69
|
+
expect(proxyAgentCalls).toHaveLength(1);
|
|
70
|
+
const options = proxyAgentCalls[0];
|
|
71
|
+
expect(options.uri).toBe(buildProxyUrl(config("http").proxy));
|
|
72
|
+
// port: 0 = BuildOptions 类型占位(实际建连被目标端口覆盖,见 dispatcher.ts PROXY_TLS_TUNING 注)
|
|
73
|
+
expect(options.proxyTls).toEqual({
|
|
74
|
+
port: 0,
|
|
75
|
+
autoSelectFamilyAttemptTimeout: PROXY_CONNECT_ATTEMPT_TIMEOUT_MS,
|
|
76
|
+
});
|
|
77
|
+
expect("connect" in options).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("https 与 http 同走 ProxyAgent(proxyTls 形态一致)", () => {
|
|
81
|
+
buildProxyDispatcher(config("https"));
|
|
82
|
+
expect(proxyAgentCalls).toHaveLength(1);
|
|
83
|
+
expect(proxyAgentCalls[0].proxyTls).toEqual({
|
|
84
|
+
port: 0,
|
|
85
|
+
autoSelectFamilyAttemptTimeout: PROXY_CONNECT_ATTEMPT_TIMEOUT_MS,
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("socks5:Socks5ProxyAgent 收到函数形态 connect(buildConnector 产物)+ 调优值透传", () => {
|
|
90
|
+
buildProxyDispatcher(config("socks5"));
|
|
91
|
+
expect(socks5AgentCalls).toHaveLength(1);
|
|
92
|
+
const { url, options } = socks5AgentCalls[0];
|
|
93
|
+
expect(url).toBe(buildProxyUrl(config("socks5").proxy));
|
|
94
|
+
expect(typeof options.connect).toBe("function");
|
|
95
|
+
// connect 必须是本实现经 buildConnector 构造的产物(调优经函数形态生效的唯一通道)
|
|
96
|
+
expect(connectorResults).toEqual([
|
|
97
|
+
{ port: 0, autoSelectFamilyAttemptTimeout: PROXY_CONNECT_ATTEMPT_TIMEOUT_MS },
|
|
98
|
+
]);
|
|
99
|
+
// proxyTls 不得作为调优袋传给 socks5(它是 TLS 开关:truthy 即按 TLS 连代理,明文代理会被错误握手)
|
|
100
|
+
expect("proxyTls" in options).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("两协议 agent 均挂防崩溃 error listener(对齐 pi withUndiciErrorListener 形态)", () => {
|
|
104
|
+
const httpDispatcher = buildProxyDispatcher(config("http"));
|
|
105
|
+
// EventEmitter "error" 无监听时 emit 会抛——挂上 noop listener 后 listenerCount > 0
|
|
106
|
+
expect(httpDispatcher.listenerCount("error")).toBeGreaterThan(0);
|
|
107
|
+
const socks5Dispatcher = buildProxyDispatcher(config("socks5"));
|
|
108
|
+
expect(socks5Dispatcher.listenerCount("error")).toBeGreaterThan(0);
|
|
109
|
+
});
|
|
110
|
+
});
|