@zhushanwen/pi-network-proxy 0.0.0-stage → 0.2.1
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 +43 -2
- package/index.ts +1 -0
- package/package.json +66 -3
- package/skills/network-proxy-ext-config/SKILL.md +76 -0
- package/src/__tests__/config.test.ts +234 -0
- package/src/__tests__/dispatcher-construction.test.ts +110 -0
- package/src/__tests__/dispatcher.test.ts +37 -0
- package/src/__tests__/fetch-wrapper.test.ts +324 -0
- package/src/__tests__/index.test.ts +182 -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 +79 -0
- package/src/fetch-wrapper.ts +244 -0
- package/src/index.ts +106 -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,44 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @zhushanwen/pi-network-proxy
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
LLM 网络代理分流:包装 pi 进程的 `globalThis.fetch`,按主机名规则把命中请求交给代理 dispatcher(http/https/socks5),未命中的原样委托包装前的 fetch(直连或 pi 原生 env 代理路由),并按主机名计数(经代理 / 直连)。
|
|
4
|
+
|
|
5
|
+
## 行为摘要
|
|
6
|
+
|
|
7
|
+
- **拦截点**:`globalThis.fetch`。pi-ai 的 OpenAI 兼容请求在每次构造 client 时经 `Shims.getDefaultFetch()` 动态取当前全局 fetch,包装它不依赖 pi 与扩展共享 undici 模块实例
|
|
8
|
+
- **安装/卸载**:extension 加载时读配置,`enabled` 且校验通过 → 包装并构造代理 dispatcher;`enabled` false → 恢复原 fetch;校验/构造失败 → extension error(含配置文件恢复指引),不包装不阻断 pi(全部直连)
|
|
9
|
+
- **热生效**:turn 粒度——每 turn 开始重读配置(mtime+size 读时刷新),指纹变化才重建;pi 的 `configureHttpDispatcher` 带 shouldInstallGlobals 守卫(pi dist/core/http-dispatcher.js:88-99:检测到 `globalThis.fetch` 已被第三方覆盖即保留不重装),本扩展的包装在同进程内持续生效;槽位被其他第三方覆盖时下一 turn 自动以覆盖者为新 original 重新包装(防御路径)
|
|
10
|
+
- **匹配语义**:主机名归一化(小写、去末尾点)后 suffix 匹配(等于或以 `.条目` 结尾,点边界对齐);noProxy 优先于 rules
|
|
11
|
+
- **覆盖面**:经 `globalThis.fetch` 发出的请求(pi LLM 调用与 OAuth/目录请求均走 fetch);直接使用 `undici.request` / `Client` 的调用与 WebSocket 升级请求不经过本包装
|
|
12
|
+
- **计数**:请求发起时 +1(内存按主机名聚合),turn 结束 / 进程退出把增量追加写入 `<agentDir>/network-proxy-stats.jsonl`(每行完整 JSON `{ts, host, proxied, direct}`,单次 write 单行——多进程并发追加不交错);写失败 log 不阻断
|
|
13
|
+
- **降级契约**:socks5 构造失败(undici 实验性 API)→ 记错误、恢复原 fetch、全部直连,切回 http 协议恢复;同指纹不重试构造,配置修正(指纹变化)后自然重试
|
|
14
|
+
|
|
15
|
+
## 为什么包装 fetch 而不是 undici 全局 dispatcher
|
|
16
|
+
|
|
17
|
+
undici 的 `setGlobalDispatcher` 槽是模块级变量,只有「pi 与扩展加载同一份 undici 模块实例」时才生效:
|
|
18
|
+
|
|
19
|
+
- 独立 pi(`node_modules/.bin/pi`)与扩展源码都从 `node_modules/undici` 解析,实例相同,旧方案可用
|
|
20
|
+
- 打包版 pi 是单文件二进制,HTTP 栈用内置 undici(`builtin://thirdparty/undici`);扩展 bundle 由 esbuild 内联自己的 undici 副本。两套全局槽互不相通——扩展安装的分流器 pi 根本不会使用,LLM 请求静默直连、统计为空
|
|
21
|
+
- `globalThis.fetch` 是两套实现之间稳定的接缝(pi-ai / OpenAI SDK 每次构造 client 时动态读取),因此拦截点定在这里
|
|
22
|
+
|
|
23
|
+
## 配置
|
|
24
|
+
|
|
25
|
+
`<agentDir>/config/network-proxy-ext-config.json`(schema、匹配语义、生效时机、故障恢复见 `skills/network-proxy-ext-config/SKILL.md`):
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"enabled": false,
|
|
30
|
+
"proxy": { "protocol": "http", "host": "127.0.0.1", "port": 7890, "username": "", "password": "" },
|
|
31
|
+
"noProxy": ["localhost", "127.0.0.1"],
|
|
32
|
+
"rules": ["api.anthropic.com"]
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
taiji 桌面端经设置页「网络代理」读写(runtime RMW 写侧);独立 pi 用户手编文件即可,保存后自下一轮对话起生效。
|
|
37
|
+
|
|
38
|
+
## 两协议构造契约(防回归锚)
|
|
39
|
+
|
|
40
|
+
- http/https:`ProxyAgent` 调优经 `proxyTls: { autoSelectFamilyAttemptTimeout: 2000 }`(构造内部展开透传到「到代理」连接);禁传 `connect` 选项——主类不消费该字段,传入被静默忽略
|
|
41
|
+
- socks5:`Socks5ProxyAgent` 调优经 `connect: buildConnector({ autoSelectFamilyAttemptTimeout: 2000 })` 的 **connector 函数形态**(对象形态首个请求建隧道时 TypeError);`proxyTls` 是「代理连接走 TLS」开关,不可当调优参数袋
|
|
42
|
+
- 两协议 agent 均挂防崩溃 error listener(对齐 pi `withUndiciErrorListener` 形态)
|
|
43
|
+
|
|
44
|
+
上述契约由 `src/__tests__/dispatcher-construction.test.ts` 构造形态冒烟测试锁定;分流/计数/状态机行为由 `src/__tests__/fetch-wrapper.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.1",
|
|
4
|
+
"description": "LLM network proxy: rule-based per-hostname traffic splitting via a globalThis.fetch wrapper (http/https/socks5 proxy), 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,76 @@
|
|
|
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 进程的 `globalThis.fetch`,按主机名规则分流——命中规则的请求经代理出站,未命中的保持 pi 原生行为(直连或 env 代理),并按主机名计数(经代理 / 直连)。打包版与独立 pi 都生效(不依赖 pi 与扩展共享 undici 模块实例)。
|
|
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(禁用 = 不包装 fetch,全部按 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
|
+
- 不支持 `*` 通配(无已发生需求);分流覆盖经 `globalThis.fetch` 发出的请求(pi LLM 调用与 OAuth/目录请求);直接使用 `undici.request` / `Client` 的调用不经过本包装
|
|
46
|
+
|
|
47
|
+
## 生效时机(turn 粒度热重载)
|
|
48
|
+
|
|
49
|
+
- 每个 turn 开始重读配置,指纹(enabled+proxy+noProxy+rules)变化才重建包装——**运行中会话自下一轮对话起生效,无需重启**;turn 进行中(工具循环连续请求)保存的配置对该 turn 内后续请求不生效
|
|
50
|
+
- `enabled` 从 true → false:立即恢复包装前的 fetch(全部直连/env 代理路由)
|
|
51
|
+
- 未命中规则的流量委托包装前的 fetch——独立 pi 用户设了 `HTTPS_PROXY` 环境变量时,未命中流量仍走 env 代理,语义不丢失
|
|
52
|
+
- pi 的 `configureHttpDispatcher` 带 shouldInstallGlobals 守卫(pi dist/core/http-dispatcher.js:88-99):检测到 `globalThis.fetch` 已被第三方覆盖即保留不重装,本扩展的包装在同进程内持续生效;槽位被其他第三方整体覆盖时,下一 turn 开始自动以覆盖者为新 original 重新包装(防御路径)
|
|
53
|
+
|
|
54
|
+
## 统计文件
|
|
55
|
+
|
|
56
|
+
`<agentDir>/network-proxy-stats.jsonl`(agentDir 根):每行一个 JSON 片段 `{ ts, host, proxied, direct }`,turn 结束 / 进程退出时把本进程增量追加落盘;计数时点 = 请求发起时。taiji 设置页「网络代理 → 请求统计」读侧聚合展示(含重置与截断),直接读文件按行求和亦可。
|
|
57
|
+
|
|
58
|
+
## 故障恢复
|
|
59
|
+
|
|
60
|
+
| 现象 | 处置 |
|
|
61
|
+
|---|---|
|
|
62
|
+
| 代理不可达(请求报 `fetch failed` / `ECONNREFUSED`) | 启动代理软件;或把 `enabled` 改为 `false` 一键恢复直连 |
|
|
63
|
+
| socks5 构造失败(undici 实验性 API 变化) | extension error 提示并保持全部直连;切回 `http` 协议即可恢复代理 |
|
|
64
|
+
| 配置校验失败(端口越界 / 凭据单边等) | 按报错文案修正配置文件,或删除该文件恢复默认禁用态 |
|
|
65
|
+
| 想让某服务商不再走代理 | 从 `rules` 删除对应主机名条目,或将其加入 `noProxy` |
|
|
66
|
+
|
|
67
|
+
## 配置示例
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
{
|
|
71
|
+
"enabled": true,
|
|
72
|
+
"proxy": { "protocol": "http", "host": "127.0.0.1", "port": 7890, "username": "", "password": "" },
|
|
73
|
+
"noProxy": ["localhost", "127.0.0.1"],
|
|
74
|
+
"rules": ["api.anthropic.com"]
|
|
75
|
+
}
|
|
76
|
+
```
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 代理 URL 构造(凭据 percent-encode 拼入)。
|
|
3
|
+
* 分流行为与计数已随拦截点迁移到 fetch-wrapper.test.ts;两协议构造形态冒烟在
|
|
4
|
+
* dispatcher-construction.test.ts(vi.mock undici 隔离)。
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
|
|
8
|
+
import { buildProxyUrl } from "../dispatcher.ts";
|
|
9
|
+
import type { NetworkProxyServer } from "../config.ts";
|
|
10
|
+
|
|
11
|
+
describe("buildProxyUrl(凭据 percent-encode 拼入)", () => {
|
|
12
|
+
const base: NetworkProxyServer = {
|
|
13
|
+
protocol: "http",
|
|
14
|
+
host: "127.0.0.1",
|
|
15
|
+
port: 7890,
|
|
16
|
+
username: "",
|
|
17
|
+
password: "",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
it("无凭据形态", () => {
|
|
21
|
+
expect(buildProxyUrl(base)).toBe("http://127.0.0.1:7890");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("凭据 percent-encode 拼入(特殊字符被编码,undici 构造内部 decodeURIComponent 还原)", () => {
|
|
25
|
+
expect(buildProxyUrl({ ...base, username: "user@example.com", password: "p@ss:word" })).toBe(
|
|
26
|
+
"http://user%40example.com:p%40ss%3Aword@127.0.0.1:7890",
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("socks5 协议形态(Socks5ProxyAgent 校验 socks5: 协议头)", () => {
|
|
31
|
+
expect(buildProxyUrl({ ...base, protocol: "socks5", port: 1080 })).toBe("socks5://127.0.0.1:1080");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("https 协议形态", () => {
|
|
35
|
+
expect(buildProxyUrl({ ...base, protocol: "https", port: 443 })).toBe("https://127.0.0.1:443");
|
|
36
|
+
});
|
|
37
|
+
});
|