@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.
@@ -0,0 +1,86 @@
1
+ /**
2
+ * 匹配语义真值表(任务书验收②:suffix 点边界 / noProxy 条目语义 / 归一化)。
3
+ *
4
+ * 本包 match.ts 是 packages/shared/src/network-proxy.ts 匹配函数的复制体(bundle 自包含
5
+ * 不 import shared 运行时代码)——真值表即两侧行为一致性的镜像锁定,漂移时以 shared 为准。
6
+ */
7
+ import { describe, expect, it } from "vitest";
8
+
9
+ import { matchesHostPattern, normalizeHostname } from "../match.ts";
10
+
11
+ describe("normalizeHostname(归一化)", () => {
12
+ it("转小写", () => {
13
+ expect(normalizeHostname("API.Anthropic.COM")).toBe("api.anthropic.com");
14
+ });
15
+
16
+ it("去末尾点(FQDN 根点与裸主机名同义)", () => {
17
+ expect(normalizeHostname("api.anthropic.com.")).toBe("api.anthropic.com");
18
+ expect(normalizeHostname("api.anthropic.com..")).toBe("api.anthropic.com");
19
+ });
20
+
21
+ it("组合形态:大写 + 末尾点", () => {
22
+ expect(normalizeHostname("API.Anthropic.COM.")).toBe("api.anthropic.com");
23
+ });
24
+
25
+ it("无点主机名原样(小写化)", () => {
26
+ expect(normalizeHostname("localhost")).toBe("localhost");
27
+ expect(normalizeHostname("LocalHost")).toBe("localhost");
28
+ });
29
+
30
+ it("不 trim 首尾空白(带空白条目由校验层显式拒绝,匹配层保持严格)", () => {
31
+ expect(normalizeHostname(" api.anthropic.com")).toBe(" api.anthropic.com");
32
+ expect(normalizeHostname("api.anthropic.com ")).toBe("api.anthropic.com ");
33
+ });
34
+ });
35
+
36
+ describe("matchesHostPattern 真值表(suffix 点边界)", () => {
37
+ const pattern = "api.anthropic.com";
38
+
39
+ it("等于命中", () => {
40
+ expect(matchesHostPattern("api.anthropic.com", pattern)).toBe(true);
41
+ });
42
+
43
+ it("子域命中(以 .pattern 结尾)", () => {
44
+ expect(matchesHostPattern("eu.api.anthropic.com", pattern)).toBe(true);
45
+ expect(matchesHostPattern("a.b.api.anthropic.com", pattern)).toBe(true);
46
+ });
47
+
48
+ it("前缀拼接不命中(点边界必须对齐)", () => {
49
+ expect(matchesHostPattern("xapi.anthropic.com", pattern)).toBe(false);
50
+ expect(matchesHostPattern("papi.anthropic.com", pattern)).toBe(false);
51
+ });
52
+
53
+ it("恶意后缀不命中(pattern 出现在中间也不命中)", () => {
54
+ expect(matchesHostPattern("api-anthropic.com.evil.com", pattern)).toBe(false);
55
+ expect(matchesHostPattern("api.anthropic.com.evil.com", pattern)).toBe(false); // evil.com 的子域,非 pattern 子域
56
+ });
57
+
58
+ it("完全不同的主机名不命中", () => {
59
+ expect(matchesHostPattern("openai.com", pattern)).toBe(false);
60
+ expect(matchesHostPattern("anthropic.com", pattern)).toBe(false); // pattern 本身不是其子域
61
+ });
62
+
63
+ it("归一化参与匹配:大小写与末尾点在 host/pattern 任一侧生效", () => {
64
+ expect(matchesHostPattern("API.Anthropic.COM.", "api.anthropic.com")).toBe(true);
65
+ expect(matchesHostPattern("api.anthropic.com", "API.ANTHROPIC.COM.")).toBe(true);
66
+ expect(matchesHostPattern("EU.API.Anthropic.Com", pattern)).toBe(true);
67
+ });
68
+
69
+ it("空主机名 / 空 pattern 不命中任何条目", () => {
70
+ expect(matchesHostPattern("", pattern)).toBe(false);
71
+ expect(matchesHostPattern("api.anthropic.com", "")).toBe(false);
72
+ expect(matchesHostPattern("", "")).toBe(false);
73
+ });
74
+
75
+ it("首尾空白不参与匹配(host/pattern 任一侧带空白恒不命中——空白条目由校验层显式拒绝,匹配层不静默 trim 容忍)", () => {
76
+ expect(matchesHostPattern("api.anthropic.com", " api.anthropic.com")).toBe(false);
77
+ expect(matchesHostPattern("api.anthropic.com", "api.anthropic.com ")).toBe(false);
78
+ expect(matchesHostPattern(" api.anthropic.com", pattern)).toBe(false);
79
+ });
80
+
81
+ it("noProxy 条目语义:localhost 同时覆盖 localhost 与 *.localhost", () => {
82
+ expect(matchesHostPattern("localhost", "localhost")).toBe(true);
83
+ expect(matchesHostPattern("foo.localhost", "localhost")).toBe(true);
84
+ expect(matchesHostPattern("127.0.0.1", "127.0.0.1")).toBe(true);
85
+ });
86
+ });
@@ -0,0 +1,38 @@
1
+ /**
2
+ * startupConfig 声明守护测试:断言 package.json `taiji.startupConfig` 声明的 content
3
+ * 与代码 DEFAULT_NETWORK_PROXY_CONFIG 常量深相等,防止两侧任一改动未同步(漂移)。
4
+ * 机制:runtime 启动序列统一 ensure(extension-startup-config.ts),声明即首建内容。
5
+ * 范式:smart-context 同名测试。
6
+ */
7
+ import { readFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ import { describe, expect, it } from "vitest";
12
+
13
+ import { DEFAULT_NETWORK_PROXY_CONFIG } from "../config.ts";
14
+
15
+ /** package.json `taiji.startupConfig` 声明条目的最小形状(守护断言用)。 */
16
+ interface StartupConfigEntry { // oe-exempt:20261019:framework:声明守护测试的最小形状锚(测试常驻资产,非临时期限类目可承载)
17
+
18
+ path: string;
19
+ content: unknown;
20
+ }
21
+
22
+ const pkg = JSON.parse(
23
+ readFileSync(join(fileURLToPath(import.meta.url), "../../../package.json"), "utf-8"),
24
+ ) as { "taiji"?: { startupConfig?: StartupConfigEntry[] } };
25
+
26
+ describe("startupConfig 声明守护", () => {
27
+ it("声明 content 与代码 DEFAULT_NETWORK_PROXY_CONFIG 深相等", () => {
28
+ const entry = pkg["taiji"]?.startupConfig?.find(
29
+ (e: StartupConfigEntry) => e.path === "config/network-proxy-ext-config.json",
30
+ );
31
+ expect(entry).toBeDefined();
32
+ expect(entry?.content).toEqual(DEFAULT_NETWORK_PROXY_CONFIG);
33
+ });
34
+
35
+ it("默认声明为禁用态(P2 降级契约:默认无行为变化)", () => {
36
+ expect(DEFAULT_NETWORK_PROXY_CONFIG.enabled).toBe(false);
37
+ });
38
+ });
@@ -0,0 +1,171 @@
1
+ /**
2
+ * stats 增量行格式 + 单行 write 原子性(任务书验收②)。
3
+ * 单行原子性 = 每次 appendFileSync 调用的 payload 恰好是一行完整 JSON + \n(O_APPEND 单次
4
+ * write 是多 pi 进程并发追加不交错的实现前提,防改为分片 write 时静默交错)。
5
+ * fs-guard 白名单:写入目标 = PI_CODING_AGENT_DIR 注入的 mkdtemp tmp 目录。
6
+ */
7
+ import * as fs from "node:fs";
8
+ import { mkdtempSync, readFileSync } 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
+ const { appendCalls } = vi.hoisted(() => ({
14
+ appendCalls: [] as Array<{ path: string; data: string }>,
15
+ }));
16
+
17
+ // node:fs 的 ESM namespace 不可配置,vi.spyOn 对具名导出失效(vitest 限制,llm-shared 同款)——
18
+ // 用 vi.mock 包装 appendFileSync(默认透传原始实现并记录调用),其余 fs 操作原样透传。
19
+ // 注意:本文件级 mock 覆盖 fs-guard 全局 mock(fs-guard 头注已登记的既有形态)——本文件
20
+ // 写入目标全部为 mkdtemp tmp,自守白名单纪律。
21
+ vi.mock("node:fs", async (importOriginal) => {
22
+ const actual = await importOriginal() as typeof import("node:fs");
23
+ return {
24
+ ...actual,
25
+ appendFileSync: vi.fn((path: string, data: string) => {
26
+ appendCalls.push({ path, data });
27
+ return actual.appendFileSync(path, data);
28
+ }),
29
+ };
30
+ });
31
+
32
+ const { loggerMock } = vi.hoisted(() => ({
33
+ loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
34
+ }));
35
+ vi.mock("@zhushanwen/pi-extension-logger", () => ({
36
+ getLogger: () => loggerMock,
37
+ createLogger: () => loggerMock,
38
+ setPiHandle: vi.fn(),
39
+ }));
40
+
41
+ import {
42
+ flushStats,
43
+ recordDispatch,
44
+ resetCounters,
45
+ snapshotCounters,
46
+ statsFilePath,
47
+ } from "../stats.ts";
48
+
49
+ let agentDir: string;
50
+
51
+ beforeEach(() => {
52
+ agentDir = mkdtempSync(join(tmpdir(), "network-proxy-stats-"));
53
+ vi.stubEnv("PI_CODING_AGENT_DIR", agentDir);
54
+ resetCounters();
55
+ appendCalls.length = 0;
56
+ loggerMock.warn.mockClear();
57
+ });
58
+
59
+ afterEach(() => {
60
+ vi.unstubAllEnvs();
61
+ });
62
+
63
+ describe("stats 增量行格式", () => {
64
+ it("flush 后每行完整 JSON:字段 {ts, host, proxied, direct},ts 为 flush 时刻 epoch ms(runtime 读侧 number 契约)", () => {
65
+ const before = Date.now();
66
+ recordDispatch("api.anthropic.com", true);
67
+ recordDispatch("open.bigmodel.cn", false);
68
+ flushStats();
69
+
70
+ const lines = readFileSync(statsFilePath(), "utf-8").split("\n").filter((l) => l !== "");
71
+ expect(lines).toHaveLength(2);
72
+ const rows = lines.map((l) => JSON.parse(l) as Record<string, unknown>);
73
+ expect(rows).toContainEqual({
74
+ ts: expect.any(Number),
75
+ host: "api.anthropic.com",
76
+ proxied: 1,
77
+ direct: 0,
78
+ });
79
+ expect(rows).toContainEqual({
80
+ ts: expect.any(Number),
81
+ host: "open.bigmodel.cn",
82
+ proxied: 0,
83
+ direct: 1,
84
+ });
85
+ for (const row of rows) {
86
+ // epoch ms 合理区间(≥ before 且 ≤ now + 1s 时钟偏移容差)——与 runtime
87
+ // parseStatsLine typeof ts === 'number' 契约互证
88
+ expect(row.ts as number).toBeGreaterThanOrEqual(before);
89
+ expect(row.ts as number).toBeLessThanOrEqual(Date.now() + 1000);
90
+ }
91
+ });
92
+
93
+ it("flush 清空已写增量,下次 flush 只写新增量(追加式增量行语义)", () => {
94
+ recordDispatch("a.test", true);
95
+ flushStats();
96
+ expect(readFileSync(statsFilePath(), "utf-8").trim().split("\n")).toHaveLength(1);
97
+
98
+ recordDispatch("b.test", false);
99
+ flushStats();
100
+ const lines = readFileSync(statsFilePath(), "utf-8").trim().split("\n");
101
+ expect(lines).toHaveLength(2);
102
+ expect(JSON.parse(lines[1]).host).toBe("b.test");
103
+ });
104
+
105
+ it("同主机名聚合:多次同向计数累加为一行", () => {
106
+ recordDispatch("api.anthropic.com", true);
107
+ recordDispatch("api.anthropic.com", true);
108
+ recordDispatch("api.anthropic.com", false);
109
+ flushStats();
110
+ const rows = readFileSync(statsFilePath(), "utf-8")
111
+ .trim()
112
+ .split("\n")
113
+ .map((l) => JSON.parse(l));
114
+ expect(rows).toEqual([
115
+ { ts: expect.any(Number), host: "api.anthropic.com", proxied: 2, direct: 1 },
116
+ ]);
117
+ });
118
+
119
+ it("空增量 flush 零 IO(不建目录不写文件)", () => {
120
+ flushStats();
121
+ expect(appendCalls).toHaveLength(0);
122
+ });
123
+
124
+ it("snapshotCounters / resetCounters(内存视图)", () => {
125
+ recordDispatch("a.test", true);
126
+ expect(snapshotCounters().get("a.test")).toEqual({ proxied: 1, direct: 0 });
127
+ resetCounters();
128
+ expect(snapshotCounters().size).toBe(0);
129
+ });
130
+ });
131
+
132
+ describe("单行 write 原子性", () => {
133
+ it("每行恰好一次 appendFileSync 调用,payload 为单行完整 JSON + \\n(禁止分片 write)", () => {
134
+ recordDispatch("api.anthropic.com", true);
135
+ recordDispatch("open.bigmodel.cn", false);
136
+ flushStats();
137
+
138
+ expect(appendCalls).toHaveLength(2);
139
+ for (const call of appendCalls) {
140
+ expect(call.path).toBe(statsFilePath());
141
+ expect(call.data.endsWith("\n")).toBe(true);
142
+ expect(call.data.split("\n")).toHaveLength(2); // 单行内容 + 尾换行
143
+ const row = JSON.parse(call.data);
144
+ expect(Object.keys(row).sort()).toEqual(["direct", "host", "proxied", "ts"]);
145
+ }
146
+ });
147
+ });
148
+
149
+ describe("写失败不阻断(log + 增量保留重试)", () => {
150
+ it("appendFileSync 抛错 → warn 留痕 + 增量保留;恢复后重试成功且不丢行", () => {
151
+ recordDispatch("a.test", true);
152
+ recordDispatch("b.test", false);
153
+
154
+ vi.mocked(fs.appendFileSync).mockImplementationOnce(() => {
155
+ throw Object.assign(new Error("ENOSPC"), { code: "ENOSPC" });
156
+ });
157
+ expect(() => flushStats()).not.toThrow();
158
+ expect(loggerMock.warn).toHaveBeenCalledTimes(1);
159
+ // 失败行的增量保留在内存
160
+ expect(snapshotCounters().size).toBe(2);
161
+
162
+ // 恢复:重试把保留的增量完整落盘
163
+ flushStats();
164
+ const hosts = readFileSync(statsFilePath(), "utf-8")
165
+ .trim()
166
+ .split("\n")
167
+ .map((l) => JSON.parse(l).host);
168
+ expect(hosts).toEqual(["a.test", "b.test"]);
169
+ expect(snapshotCounters().size).toBe(0);
170
+ });
171
+ });
package/src/config.ts ADDED
@@ -0,0 +1,176 @@
1
+ /**
2
+ * network-proxy 配置读取与指纹。
3
+ *
4
+ * 类型与校验语义 SSOT = packages/shared/src/network-proxy.ts(u-foundation 契约)——
5
+ * extension bundle 自包含不 import shared 运行时代码(extensions 对 shared 的既有惯例:
6
+ * 结构类型本地声明 + 行为镜像锁定测试),本文件是复制体;漂移时以 shared 为准,
7
+ * __tests__/config.test.ts 锁两侧行为一致。
8
+ *
9
+ * 配置文件 = <agentDir>/config/network-proxy-ext-config.json(llm-shared getConfigPath
10
+ * 推导;runtime 写侧与 settings 页经 RPC 打通,设计 .tmp/tech-design/llm-network-proxy.md
11
+ * §3.1 配置 schema)。热重载 = loadConfig 读时刷新(mtime+size),调用方每次直接调
12
+ * loadNetworkProxyConfig,禁止上层闭包缓存(llm-shared 头注热重载契约)。
13
+ */
14
+
15
+ import { loadConfig } from "@zhushanwen/pi-llm-shared";
16
+
17
+ import { matchesHostPattern } from "./match.js";
18
+
19
+ /** 代理协议词表:http / https 走 undici ProxyAgent,socks5 走 Socks5ProxyAgent。 */
20
+ export type NetworkProxyProtocol = "http" | "https" | "socks5";
21
+
22
+ /** 代理服务器连接参数。username/password 成对(非空时 percent-encode 拼入代理 URL)。 */
23
+ export interface NetworkProxyServer { // oe-exempt:20261019:framework:ext-config 契约的 bundle 自包含复制体声明(SSOT=shared,镜像锁定测试锚定)
24
+
25
+ protocol: NetworkProxyProtocol;
26
+ host: string;
27
+ port: number;
28
+ username: string;
29
+ password: string;
30
+ }
31
+
32
+ /** 网络代理配置(ext-config 文件单一权威 schema;noProxy 优先级高于 rules)。 */
33
+ export interface NetworkProxyConfig { // oe-exempt:20261019:framework:ext-config 契约的 bundle 自包含复制体声明(SSOT=shared,镜像锁定测试锚定)
34
+
35
+ enabled: boolean;
36
+ proxy: NetworkProxyServer;
37
+ /** 直连排除清单(主机名条目),命中即不经代理 */
38
+ noProxy: string[];
39
+ /** 代理规则(主机名条目),命中即经代理 */
40
+ rules: string[];
41
+ }
42
+
43
+ /** 默认配置:禁用态 + 常见本地代理地址占位 + 保护本地回环(与 shared DEFAULT 深相等,镜像锁定测试锁两侧)。 */
44
+ export const DEFAULT_NETWORK_PROXY_CONFIG: NetworkProxyConfig = {
45
+ enabled: false,
46
+ proxy: { protocol: "http", host: "127.0.0.1", port: 7890, username: "", password: "" },
47
+ noProxy: ["localhost", "127.0.0.1"],
48
+ rules: [],
49
+ };
50
+
51
+ /** llm-shared loadConfig 的包名(决定文件名 network-proxy-ext-config.json)。 */
52
+ const CONFIG_PKG = "network-proxy";
53
+
54
+ const PROTOCOLS: readonly string[] = ["http", "https", "socks5"];
55
+
56
+ /** 端口合法区间(语义镜像 shared validateNetworkProxyConfig 字面量)。 */
57
+ const PORT_MIN = 1;
58
+ const PORT_MAX = 65535;
59
+
60
+ /** 校验结果信封:ok 或面向用户的错误清单(每条含修复动作,逐项收集不短路)。 */
61
+ export type NetworkProxyValidationResult = { ok: true } | { ok: false; errors: string[] };
62
+
63
+ /** noProxy/rules 条目校验:数组 + 每条为非空且不含空白字符的主机名字符串;重复条目不报错(匹配天然幂等)。 */
64
+ function collectHostEntryErrors(entries: unknown, label: string): string[] {
65
+ if (!Array.isArray(entries)) {
66
+ return [`${label} 必须是主机名列表(当前不是列表),请恢复为每行一个主机名的形式`];
67
+ }
68
+ const errors: string[] = [];
69
+ for (let i = 0; i < entries.length; i++) {
70
+ const entry = entries[i];
71
+ if (typeof entry !== "string" || entry.trim() === "") {
72
+ errors.push(`${label} 第 ${i + 1} 条为空,请填写主机名或删除该条`);
73
+ } else if (/\s/.test(entry)) {
74
+ // 归一化不 trim,含空白字符的条目(首尾或内部)对真实主机名恒不匹配(静默不生效)——校验层显式拒绝并给出修复动作
75
+ errors.push(`${label} 第 ${i + 1} 条含空白字符,请删除空白后保存`);
76
+ }
77
+ }
78
+ return errors;
79
+ }
80
+
81
+ /** proxy 对象字段校验(validateNetworkProxyConfig 的子步骤,语义镜像 shared collectProxyServerErrors)。 */
82
+ function collectProxyServerErrors(proxy: unknown): string[] {
83
+ if (proxy === null || typeof proxy !== "object" || Array.isArray(proxy)) {
84
+ return ["代理服务器配置必须是对象(协议 + 地址 + 端口),请重新填写代理服务器信息"];
85
+ }
86
+ const errors: string[] = [];
87
+ const { protocol, host, port, username, password } = proxy as Record<string, unknown>;
88
+ if (typeof protocol !== "string" || !PROTOCOLS.includes(protocol)) {
89
+ errors.push(`代理协议必须是 http / https / socks5 之一: ${String(protocol)},请重新选择协议`);
90
+ }
91
+ if (typeof host !== "string" || host.trim() === "") {
92
+ errors.push("代理服务器地址不能为空,请填写代理地址(如 127.0.0.1)");
93
+ }
94
+ if (typeof port !== "number" || !Number.isInteger(port) || port < PORT_MIN || port > PORT_MAX) {
95
+ errors.push(`代理端口必须是 ${PORT_MIN}-${PORT_MAX} 的整数: ${String(port)},请填写正确的端口`);
96
+ }
97
+ if (typeof username !== "string" || typeof password !== "string") {
98
+ errors.push("认证用户名与密码必须是文本,请检查认证输入框内容");
99
+ } else if ((username !== "") !== (password !== "")) {
100
+ errors.push("认证用户名与密码必须同时填写或同时留空(代理认证要求两者成对),请补全另一项或清空两项");
101
+ }
102
+ return errors;
103
+ }
104
+
105
+ /**
106
+ * 网络代理配置校验(语义复制自 shared validateNetworkProxyConfig):
107
+ * 协议白名单、端口 1-65535 整数、凭据成对、noProxy/rules 条目非空主机名且不含空白字符
108
+ * (归一化不 trim,带空白恒不匹配故显式拒绝)。
109
+ * 错误逐项收集(errors 复数,不短路),文案面向用户且含修复动作。
110
+ * 对象性守卫:null/数组解构会抛 TypeError 或静默得 undefined,读取路径须以错误信封逃逸。
111
+ */
112
+ export function validateNetworkProxyConfig(config: unknown): NetworkProxyValidationResult {
113
+ if (config === null || typeof config !== "object" || Array.isArray(config)) {
114
+ return { ok: false, errors: [`网络代理配置必须是对象: ${String(config)}`] };
115
+ }
116
+ const errors: string[] = [];
117
+ const { enabled, proxy, noProxy, rules } = config as Record<string, unknown>;
118
+ if (typeof enabled !== "boolean") {
119
+ errors.push(
120
+ `网络代理开关必须是开或关: ${JSON.stringify(enabled) ?? String(enabled)},请重新切换一次开关后保存`,
121
+ );
122
+ }
123
+ errors.push(...collectProxyServerErrors(proxy));
124
+ errors.push(...collectHostEntryErrors(noProxy, "直连排除清单(noProxy)"));
125
+ errors.push(...collectHostEntryErrors(rules, "代理规则(rules)"));
126
+ return errors.length > 0 ? { ok: false, errors } : { ok: true };
127
+ }
128
+
129
+ /**
130
+ * loadConfig 的 normalize:校验通过的 raw 即合法配置(校验 = 运行时 guard,断言有兜底)。
131
+ * 非法配置 throw(含逐项错误清单)→ loadConfig 回落 defaults 并把消息交给 onWarning——
132
+ * 非法配置不静默修正(设计验收场景 10:坏配置不装分流器且 error 可归因),回落 defaults
133
+ * 即「全部直连」的安全态。
134
+ */
135
+ export function normalizeNetworkProxyConfig(raw: unknown): NetworkProxyConfig {
136
+ const result = validateNetworkProxyConfig(raw);
137
+ if (!result.ok) {
138
+ throw new Error(result.errors.join(";"));
139
+ }
140
+ return raw as NetworkProxyConfig;
141
+ }
142
+
143
+ /** 条目去重 + 归一化视图(分流匹配用:匹配函数自带归一化,这里只去重保序)。 */
144
+ function dedupeEntries(entries: string[]): string[] {
145
+ return [...new Set(entries)];
146
+ }
147
+
148
+ /** 读取配置(llm-shared mtime+size 读时刷新热重载)。 */
149
+ export function loadNetworkProxyConfig(onWarning?: (msg: string) => void): NetworkProxyConfig {
150
+ return loadConfig(CONFIG_PKG, DEFAULT_NETWORK_PROXY_CONFIG, normalizeNetworkProxyConfig, onWarning);
151
+ }
152
+
153
+ /** 保存配置入口暂无(写入侧 = taiji runtime RMW);pi CLI 用户直接手编文件即可。 */
154
+
155
+ /** noProxy 命中判定(去重视图 + suffix 匹配)。 */
156
+ export function hitsNoProxy(host: string, noProxy: string[]): boolean {
157
+ return dedupeEntries(noProxy).some((pattern) => matchesHostPattern(host, pattern));
158
+ }
159
+
160
+ /** 规则命中判定(去重视图 + suffix 匹配)。 */
161
+ export function hitsRules(host: string, rules: string[]): boolean {
162
+ return dedupeEntries(rules).some((pattern) => matchesHostPattern(host, pattern));
163
+ }
164
+
165
+ /**
166
+ * 配置指纹:enabled+proxy+noProxy+rules 序列化比对(行为序列 2——指纹变化才重建分发器)。
167
+ * normalize 后结构固定,JSON.stringify 字段序稳定(字面量构造序)。
168
+ */
169
+ export function networkProxyFingerprint(config: NetworkProxyConfig): string {
170
+ return JSON.stringify({
171
+ enabled: config.enabled,
172
+ proxy: config.proxy,
173
+ noProxy: dedupeEntries(config.noProxy),
174
+ rules: dedupeEntries(config.rules),
175
+ });
176
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * 按主机名规则分流的 undici 全局分发器。
3
+ *
4
+ * 形态依据(设计 .tmp/tech-design/llm-network-proxy.md §3.1 行为序列 3 + §3.3 决策 1/7/8):
5
+ * - 拦截点 = 公开 `dispatch(opts, handler)`:参照 pi 自装 EnvHttpProxyAgent(pi 内部继承
6
+ * undici 非公开 DispatcherBase override [kDispatch] symbol 方法);undici 7.28 未公开
7
+ * 导出 DispatcherBase(运行时 require 返回 undefined、类型层无声明,✅ 本会话读码核实
8
+ * undici types/index.d.ts 导出清单与 lib/dispatcher/dispatcher-base.js),照抄内部形态
9
+ * 需 any/深路径 import,故继承公开导出的 `Dispatcher` override 公开 dispatch(close/
10
+ * destroy 不 override,见类尾注释)。
11
+ * - 两协议构造契约(✅ undici 7.28.0 源码读码核实,单测冒烟防回归):
12
+ * http/https 走 ProxyAgent,调优经 `proxyTls: { autoSelectFamilyAttemptTimeout: 2000 }`
13
+ * (构造内部 buildConnector({...opts.proxyTls}) 透传到「到代理」连接);禁传 connect 选项
14
+ * ——ProxyAgent 不消费该字段,传入被静默忽略、调优失效且不报错。
15
+ * socks5 走 Socks5ProxyAgent(实验性 API),调优经 `connect: buildConnector({...})` 的
16
+ * connector 函数形态——该构造选项只收函数,对象形态首个请求建隧道时 TypeError;
17
+ * `proxyTls` 是「代理连接走 TLS」开关(truthy 即按 TLS 连代理),不可当调优参数袋。
18
+ * - 高延迟路由语义:到代理服务器的连接不回落 autoSelectFamilyAttemptTimeout 默认 250ms,
19
+ * 对齐 pi 原生调优同值 2000(pi dist/core/http-dispatcher.js:5
20
+ * DEFAULT_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS = 2_000;其 :4 注释即 250ms 默认会
21
+ * 终止高延迟路由有效连接尝试的动机)。
22
+ * - 计数时点 = 请求发起时(不等待完成——统计观测「按规则尝试的出站方式」,成败由 pi 原生
23
+ * 错误链路呈现)。
24
+ */
25
+
26
+ import { EventEmitter } from "node:events";
27
+
28
+ import {
29
+ buildConnector,
30
+ Dispatcher,
31
+ ProxyAgent,
32
+ Socks5ProxyAgent,
33
+ } from "undici";
34
+ import type { Dispatcher as IDispatcher } from "undici";
35
+
36
+ import { hitsNoProxy, hitsRules, type NetworkProxyConfig, type NetworkProxyServer } from "./config.js";
37
+ import { normalizeHostname } from "./match.js";
38
+
39
+ /** 到代理服务器连接的 autoSelectFamilyAttemptTimeout(对齐 pi 原生调优同值)。 */
40
+ export const PROXY_CONNECT_ATTEMPT_TIMEOUT_MS = 2000;
41
+
42
+ /**
43
+ * 调优选项(BuildOptions 类型走 TcpNetConnectOpts 分支,port 必填 → 0 占位):
44
+ * 构造 connector 时的 options.port 是默认值,实际建连时被目标端口后置覆盖
45
+ * (undici connect.js 的 `...options` 前置展开 + connector 参数 port 后置,✅ 读码核实),
46
+ * 0 不会落到真实连接参数。
47
+ */
48
+ const PROXY_TLS_TUNING: buildConnector.BuildOptions = {
49
+ port: 0,
50
+ autoSelectFamilyAttemptTimeout: PROXY_CONNECT_ATTEMPT_TIMEOUT_MS,
51
+ };
52
+
53
+ /** 代理出站计数回调:host = 归一化目标主机名;proxied = true 经代理 / false 直连。 */
54
+ export type DispatchCounter = (host: string, proxied: boolean) => void;
55
+
56
+ /**
57
+ * 跨模块环境身份标记:jiti 全量 reload 产生的新模块环境与旧环境的类对象互异(instanceof
58
+ * 失效;根因 = reload 后 extensions/loader.js:472-476 新建 jiti 实例(moduleCache: false)
59
+ * 重新 import 本扩展,两环境各持一份类构造函数——pi reload 链总锚点见 installer.ts
60
+ * detachResidualDispatcher 注释),而 Symbol.for 全局注册表是进程级注册表、跨模块环境
61
+ * 共享——以它识别全局槽上本扩展旧环境残留的
62
+ * 分流器实例(残留不摘除则禁用失效 / 重装嵌套双计,见 installer.ts detachResidualDispatcher)。
63
+ */
64
+ const RULE_DISPATCH_MARK = Symbol.for("@zhushanwen/pi-network-proxy:rule-dispatch");
65
+
66
+ /** 类型守卫:目标分发器是否本扩展的 RuleDispatchDispatcher(跨模块环境可辨,见 RULE_DISPATCH_MARK)。 */
67
+ export function isRuleDispatchDispatcher(
68
+ dispatcher: IDispatcher,
69
+ ): dispatcher is RuleDispatchDispatcher {
70
+ return RULE_DISPATCH_MARK in dispatcher;
71
+ }
72
+
73
+ /**
74
+ * 防崩溃 error listener(对齐 pi withUndiciErrorListener 形态):undici 内部 Client 在
75
+ * 中途终止 fetch body 时可能 emit "error"——EventEmitter 的 unhandled "error" 特例会炸掉
76
+ * pi 进程,listener 只吞事件不掩盖请求本身的错误(body 流照常经 reader.read() reject)。
77
+ */
78
+ function withUndiciErrorListener<T extends IDispatcher>(dispatcher: T): T {
79
+ if (dispatcher instanceof EventEmitter) {
80
+ EventEmitter.prototype.on.call(dispatcher, "error", () => {});
81
+ }
82
+ return dispatcher;
83
+ }
84
+
85
+ /** 代理服务器 URL:凭据非空时 percent-encode 拼入(undici 构造内部 decodeURIComponent 还原)。 */
86
+ export function buildProxyUrl(proxy: NetworkProxyServer): string {
87
+ const auth =
88
+ proxy.username !== ""
89
+ ? `${encodeURIComponent(proxy.username)}:${encodeURIComponent(proxy.password)}@`
90
+ : "";
91
+ return `${proxy.protocol}://${auth}${proxy.host}:${proxy.port}`;
92
+ }
93
+
94
+ /** 按协议构造代理分发器(协议词表校验在配置层已完成;未知协议不可达)。 */
95
+ export function buildProxyDispatcher(config: NetworkProxyConfig): IDispatcher {
96
+ const uri = buildProxyUrl(config.proxy);
97
+ if (config.proxy.protocol === "socks5") {
98
+ return withUndiciErrorListener(
99
+ new Socks5ProxyAgent(uri, {
100
+ connect: buildConnector(PROXY_TLS_TUNING),
101
+ }),
102
+ );
103
+ }
104
+ return withUndiciErrorListener(
105
+ new ProxyAgent({
106
+ uri,
107
+ proxyTls: PROXY_TLS_TUNING,
108
+ }),
109
+ );
110
+ }
111
+
112
+ /** 从 dispatch opts.origin 取归一化主机名;origin 缺失/非法(无已发生证据的异常路径)返回空串。 */
113
+ function hostnameOf(origin: string | URL | undefined): string {
114
+ if (origin === undefined) return "";
115
+ try {
116
+ const url = origin instanceof URL ? origin : new URL(origin);
117
+ return normalizeHostname(url.hostname);
118
+ } catch {
119
+ return "";
120
+ }
121
+ }
122
+
123
+ export interface RuleDispatchDeps { // oe-exempt:20261019:framework:分流器依赖注入接缝(单测以 fake 分发器驱动,不 mock undici 全局槽)
124
+
125
+ /** 安装前捕获的原分发器:noProxy 命中与规则未命中流量委托——保留 pi 原生 EnvHttpProxyAgent 的 env 代理路由/连接调优/防崩溃语义(构造性等价:未命中 = 未安装本扩展时 pi 的确切行为)。 */
126
+ fallback: IDispatcher;
127
+ /** 规则命中流量的代理分发器(buildProxyDispatcher 产物)。 */
128
+ proxy: IDispatcher;
129
+ noProxy: string[];
130
+ rules: string[];
131
+ /** 分流计数回调(发起时点,本类不落盘)。 */
132
+ onCount: DispatchCounter;
133
+ }
134
+
135
+ /**
136
+ * 规则分流分发器:dispatch 内 noProxy(排除,优先)→ rules(命中走代理)→ 委托 fallback。
137
+ * 分流覆盖一切经全局分发器的请求(含 WebSocket 升级请求——Node 全局 WebSocket 建连默认取
138
+ * 全局分发器,设计 §3.1 行为序列 3)。
139
+ */
140
+ export class RuleDispatchDispatcher extends Dispatcher {
141
+ readonly [RULE_DISPATCH_MARK] = true;
142
+ readonly fallback: IDispatcher;
143
+ readonly proxy: IDispatcher;
144
+ readonly noProxy: string[];
145
+ readonly rules: string[];
146
+ readonly onCount: DispatchCounter;
147
+
148
+ constructor(deps: RuleDispatchDeps) {
149
+ super();
150
+ this.fallback = deps.fallback;
151
+ this.proxy = deps.proxy;
152
+ this.noProxy = deps.noProxy;
153
+ this.rules = deps.rules;
154
+ this.onCount = deps.onCount;
155
+ }
156
+
157
+ dispatch(opts: IDispatcher.DispatchOptions, handler: IDispatcher.DispatchHandler): boolean {
158
+ const host = hostnameOf(opts.origin);
159
+ const useProxy = !hitsNoProxy(host, this.noProxy) && hitsRules(host, this.rules);
160
+ this.onCount(host, useProxy);
161
+ return useProxy ? this.proxy.dispatch(opts, handler) : this.fallback.dispatch(opts, handler);
162
+ }
163
+
164
+ // 不 override close/destroy:①全链路无全局分发器 close/destroy 调用方(✅ 本会话读码
165
+ // 核实——undici fetch 链路、pi http-dispatcher.js(只构造与 setGlobalDispatcher)、本包
166
+ // 代码均不调用);②undici 父类 close/destroy 是 void 与 Promise<void> 返回互斥的重载集,
167
+ // 单一实现签名无法通过 TS override 兼容检查(实测 TS2322);父类默认抛 not implemented
168
+ // = 若未来出现调用方则 fail-fast 暴露,不为无已发生证据的调用方保留透传实现。
169
+ }