@zhushanwen/pi-network-proxy 0.2.0 → 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/src/index.ts CHANGED
@@ -1,14 +1,12 @@
1
1
  /**
2
- * @zhushanwen/pi-network-proxy 入口:配置驱动的 undici 全局分流分发器安装与事件接线。
2
+ * @zhushanwen/pi-network-proxy 入口:配置驱动的 globalThis.fetch 包装与事件接线。
3
3
  *
4
- * 行为序列(设计 .tmp/tech-design/llm-network-proxy.md §3.1):
5
- * - 加载时:读配置校验,enabled 且校验过 → 捕获原分发器(pi 启动已自装 EnvHttpProxyAgent,
6
- * setGlobalDispatcher 是接管而非向空闲槽安装——pi dist/core/http-dispatcher.js:73 构造、
7
- * :85 装入全局槽,首次安装 dist/main.js:468 早于 extension 加载)→ 构造分流器安装;
8
- * 校验失败 / 构造失败 →
9
- * extension error(文案含恢复动作:配置文件路径),不安装不阻断 pi(全部直连)。
4
+ * 行为序列(fetch-wrapper.ts 头注有完整设计依据):
5
+ * - 加载时:读配置校验,enabled 且校验过 → 包装 globalThis.fetch(命中规则走代理,
6
+ * 未命中委托原 fetch);校验失败 / 构造失败 → extension error(文案含恢复动作:
7
+ * 配置文件路径),不包装不阻断 pi(全部直连)。
10
8
  * - 每 turn 开始:重读配置(llm-shared 读时刷新热重载),指纹变化才重建;enabled true→false
11
- * 恢复原分发器(installer.ts 状态机)。
9
+ * 恢复原 fetch(fetch-wrapper.ts 状态机)。
12
10
  * - 每 turn 结束:计数增量 flush 追加写入 stats JSONL(stats.ts);进程退出兜底 flush。
13
11
  *
14
12
  * 覆盖面含 subagent 的 pi 进程(subagent 的 LLM 请求同样分流计数,设计前提 P4)——不设
@@ -16,7 +14,7 @@
16
14
  */
17
15
 
18
16
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
- import { getGlobalDispatcher, setGlobalDispatcher } from "undici";
17
+ import { fetch as undiciFetch } from "undici";
20
18
 
21
19
  import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
22
20
  import { getLogger, setPiHandle } from "@zhushanwen/pi-extension-logger";
@@ -26,42 +24,64 @@ import { loadNetworkProxyConfig } from "./config.js";
26
24
  import { buildProxyDispatcher } from "./dispatcher.js";
27
25
  import {
28
26
  applyNetworkProxyConfig,
29
- detachResidualDispatcher,
30
- type InstalledState,
31
- } from "./installer.js";
27
+ detachResidualFetchWrapper,
28
+ type FetchWrapperDeps,
29
+ type FetchWrapperState,
30
+ type ProxyFetch,
31
+ } from "./fetch-wrapper.js";
32
32
  import { ensureExitFlush, flushStats } from "./stats.js";
33
33
 
34
34
  const logger = getLogger("network-proxy");
35
35
 
36
- /** 进程级安装状态(模块级——全局分发器槽跨 session 存续,session 替换重跑 factory 不丢引用)。 */
37
- let installed: InstalledState | null = null;
36
+ /** 进程级包装状态(模块级——globalThis.fetch 是进程级资源,session 替换重跑 factory 不丢引用)。 */
37
+ let installed: FetchWrapperState | null = null;
38
38
 
39
39
  /** 恢复动作文案:所有 error 形态共用的收尾指引(指向配置文件)。 */
40
40
  function recoveryHint(): string {
41
41
  return `配置文件: ${getConfigPath("network-proxy")},修正该文件后自下一轮对话起生效,或删除该文件恢复默认禁用态。`;
42
42
  }
43
43
 
44
- /** 读配置并推进安装状态机(加载时与每 turn 开始共用同一入口);推进前先摘除 reload 残留。 */
44
+ /**
45
+ * 代理 fetch 适配:pi-ai 的 global Request/Response 与 undici.fetch 的 web 标准类型同构,
46
+ * 运行时直接透传;dispatcher 由 fetch-wrapper 注入到 init。
47
+ */
48
+ const proxyFetch: ProxyFetch = (input, init) =>
49
+ undiciFetch(
50
+ input as Parameters<typeof undiciFetch>[0],
51
+ init as Parameters<typeof undiciFetch>[1],
52
+ );
53
+
54
+ const deps: FetchWrapperDeps = {
55
+ getFetch: () => globalThis.fetch,
56
+ setFetch: (fetch) => {
57
+ globalThis.fetch = fetch;
58
+ },
59
+ buildProxy: buildProxyDispatcher,
60
+ proxyFetch,
61
+ onError: (message) => logger.error(`${message} ${recoveryHint()}`),
62
+ };
63
+
64
+ /** 读配置并推进包装状态机(加载时与每 turn 开始共用同一入口);推进前先摘除 reload 残留。 */
45
65
  function refreshFromConfig(): void {
46
66
  const config = loadNetworkProxyConfig((warning) => {
47
- // 非法配置 / 坏 JSON:loadConfig 已回落 defaults(enabled=false → 不安装,全部直连),
67
+ // 非法配置 / 坏 JSON:loadConfig 已回落 defaults(enabled=false → 不包装,全部直连),
48
68
  // 这里把具体错误记录到 extension 日志通道(appendEntry 持久化 + <agentDir>/logs/
49
69
  // network-proxy-<day>.log 文件日志;验收场景 10 以文件日志为归因口径)。
50
70
  logger.error(`${warning} ${recoveryHint()}`);
51
71
  });
52
72
  try {
53
- detachResidualDispatcher(installed, { getGlobalDispatcher, setGlobalDispatcher });
54
- installed = applyNetworkProxyConfig(config, installed, {
55
- getGlobalDispatcher,
56
- setGlobalDispatcher,
57
- buildProxy: buildProxyDispatcher,
58
- onError: (message) => logger.error(`${message} ${recoveryHint()}`),
59
- });
73
+ if (typeof globalThis.fetch !== "function") {
74
+ logger.error(`globalThis.fetch 不可用,网络代理不生效(全部直连)。${recoveryHint()}`);
75
+ installed = null;
76
+ return;
77
+ }
78
+ detachResidualFetchWrapper(installed, deps);
79
+ installed = applyNetworkProxyConfig(config, installed, deps);
60
80
  } catch (error) {
61
81
  // 辅助功能降级契约(本扩展为 P2 辅助功能,故障不得拖垮 pi 主流程):状态机推进的
62
82
  // 意外异常不外抛——外抛会升为 pi 用户可见 extension error 且每 turn 重复出现;
63
- // 此处保持未安装态(全部直连),错误归因到本扩展日志。
64
- logger.error(`network-proxy installer failed: ${toErrorMessage(error)} ${recoveryHint()}`);
83
+ // 此处保持未包装态(全部直连),错误归因到本扩展日志。
84
+ logger.error(`network-proxy state update failed: ${toErrorMessage(error)} ${recoveryHint()}`);
65
85
  installed = null;
66
86
  }
67
87
  }
package/src/stats.ts CHANGED
@@ -66,8 +66,8 @@ let exitFlushInstalled = false;
66
66
  * jiti 全量 reload 产生的新模块环境会再注册一个 listener,但旧环境的计数 Map 已停更,旧
67
67
  * listener flush 空增量为 no-op,无重复行(新环境再注册 = extensions/loader.js:472-476 重新
68
68
  * import 重置模块级 once flag 后重跑工厂;旧计数停更 = 旧 runtime 的 turn 订阅被 invalidate
69
- * 退订(extensions/loader.js:147-149)+ 旧分流器摘除后旧 recordDispatch 不再触发;pi reload
70
- * 链总锚点见 installer.ts detachResidualDispatcher 注释)。
69
+ * 退订(extensions/loader.js:147-149)+ 旧 fetch 包装摘除后旧 recordDispatch 不再触发;pi reload
70
+ * 链总锚点见 fetch-wrapper.ts detachResidualFetchWrapper 注释)。
71
71
  */
72
72
  export function ensureExitFlush(): void {
73
73
  if (exitFlushInstalled) return;
@@ -1,313 +0,0 @@
1
- /**
2
- * 安装状态机单测(fake 依赖注入,不 mock undici 全局槽):
3
- * 指纹比对重建 / 禁用恢复原分发器 / 「构造失败 → 不安装 → 全直连」mock 注入(任务书验收②)。
4
- * 转移表见 installer.ts 文件头。
5
- */
6
- import { describe, expect, it, vi } from "vitest";
7
- import { Dispatcher } from "undici";
8
-
9
- import type { NetworkProxyConfig } from "../config.ts";
10
- import { RuleDispatchDispatcher } from "../dispatcher.ts";
11
- import {
12
- applyNetworkProxyConfig,
13
- detachResidualDispatcher,
14
- type InstalledState,
15
- type InstallerDeps,
16
- } from "../installer.ts";
17
-
18
- class FakeDispatcher extends Dispatcher {
19
- readonly label: string;
20
- constructor(label: string) {
21
- super();
22
- this.label = label;
23
- }
24
- }
25
-
26
- function config(overrides?: Partial<NetworkProxyConfig>): NetworkProxyConfig {
27
- return {
28
- enabled: true,
29
- proxy: { protocol: "http", host: "127.0.0.1", port: 7890, username: "", password: "" },
30
- noProxy: ["localhost"],
31
- rules: ["api.anthropic.com"],
32
- ...overrides,
33
- };
34
- }
35
-
36
- /** proxy 字段深覆盖辅助(overrides 是浅合并——protocol/port 必须写进 proxy 对象才进指纹)。 */
37
- function withProxy(patch: Partial<NetworkProxyConfig["proxy"]>): Partial<NetworkProxyConfig> {
38
- return { proxy: { ...config().proxy, ...patch } };
39
- }
40
-
41
- /** fake 依赖装配:original 捕获、set 调用记录、可注入的构造器(默认成功)。
42
- * original 可注入共享(真实场景进程内只有一个原分发器——跨 deps 实例传状态时恢复的是
43
- * 首装 deps 捕获的同一实例)。 */
44
- function createDeps(
45
- buildProxy: (c: NetworkProxyConfig) => FakeDispatcher = () => new FakeDispatcher("proxy"),
46
- sharedOriginal = new FakeDispatcher("original"),
47
- ) {
48
- const original = sharedOriginal;
49
- // get/set 连通同一槽变量(与真实全局槽语义一致:set 改写 get 的返回)——
50
- // 槽位归属核查(installer 同指纹分支)依赖该语义判「槽上是谁」。
51
- let slot: FakeDispatcher = original;
52
- const setCalls: FakeDispatcher[] = [];
53
- const errors: string[] = [];
54
- const deps: InstallerDeps & { original: FakeDispatcher; setCalls: FakeDispatcher[]; errors: string[] } = {
55
- getGlobalDispatcher: () => slot,
56
- setGlobalDispatcher: (d) => {
57
- slot = d as FakeDispatcher;
58
- setCalls.push(d as FakeDispatcher);
59
- },
60
- buildProxy,
61
- onError: (message) => errors.push(message),
62
- original,
63
- setCalls,
64
- errors,
65
- };
66
- return deps;
67
- }
68
-
69
- describe("applyNetworkProxyConfig 状态机", () => {
70
- it("首装 enabled:捕获 original → 构造 → 安装,状态装载", () => {
71
- const deps = createDeps();
72
- const state = applyNetworkProxyConfig(config(), null, deps);
73
- expect(state).not.toBeNull();
74
- expect(state?.current).toBeInstanceOf(RuleDispatchDispatcher);
75
- expect(state?.originalDispatcher).toBe(deps.original);
76
- expect(deps.setCalls).toHaveLength(1);
77
- expect(deps.errors).toHaveLength(0);
78
- });
79
-
80
- it("指纹未变:不重建不重复安装(setGlobalDispatcher 零调用)", () => {
81
- const deps = createDeps();
82
- const state = applyNetworkProxyConfig(config(), null, deps);
83
- expect(deps.setCalls).toHaveLength(1);
84
- const again = applyNetworkProxyConfig(config(), state, deps);
85
- expect(again).toBe(state);
86
- expect(deps.setCalls).toHaveLength(1);
87
- });
88
-
89
- it("指纹变化:撤旧装新,original 引用跨重建保留", () => {
90
- const deps = createDeps();
91
- const first = applyNetworkProxyConfig(config(), null, deps);
92
- const changed = applyNetworkProxyConfig(
93
- config({ rules: ["api.openai.com"] }),
94
- first,
95
- deps,
96
- );
97
- expect(changed?.current).not.toBe(first?.current);
98
- expect(changed?.originalDispatcher).toBe(deps.original);
99
- expect(deps.setCalls).toHaveLength(2);
100
- });
101
-
102
- it("enabled true→false:恢复原分发器(槽上回到 original)+ 状态清空", () => {
103
- const deps = createDeps();
104
- const installed = applyNetworkProxyConfig(config(), null, deps);
105
- const after = applyNetworkProxyConfig(config({ enabled: false }), installed, deps);
106
- expect(after).toBeNull();
107
- expect(deps.setCalls).toHaveLength(2);
108
- expect(deps.setCalls[1]).toBe(deps.original);
109
- });
110
-
111
- it("首装即禁用(enabled=false + 无状态):零动作", () => {
112
- const deps = createDeps();
113
- expect(applyNetworkProxyConfig(config({ enabled: false }), null, deps)).toBeNull();
114
- expect(deps.setCalls).toHaveLength(0);
115
- expect(deps.errors).toHaveLength(0);
116
- });
117
-
118
- it("构造失败(mock 注入抛错):onError 上报 + 不安装(setGlobalDispatcher 零调用)——全直连", () => {
119
- const deps = createDeps(() => {
120
- throw new TypeError("this[kConnector] is not a function");
121
- });
122
- const state = applyNetworkProxyConfig(config(), null, deps);
123
- expect(state?.current).toBeNull();
124
- expect(deps.setCalls).toHaveLength(0); // 槽上仍是 pi 原生分发器 = 全部直连
125
- expect(deps.errors).toHaveLength(1);
126
- expect(deps.errors[0]).toContain("构造失败");
127
- expect(deps.errors[0]).toContain("直连");
128
- });
129
-
130
- it("锁定态同指纹不重试构造;指纹变化后重试(用户修正配置自然恢复)", () => {
131
- let shouldThrow = true;
132
- const deps = createDeps(() => {
133
- if (shouldThrow) throw new Error("boom");
134
- return new FakeDispatcher("proxy");
135
- });
136
- const locked = applyNetworkProxyConfig(config(), null, deps);
137
- expect(locked?.current).toBeNull();
138
-
139
- // 同指纹:不重试(fail-fast 首败即停)
140
- const stillLocked = applyNetworkProxyConfig(config(), locked, deps);
141
- expect(stillLocked?.current).toBeNull();
142
- expect(deps.errors).toHaveLength(1);
143
-
144
- // 用户修正(端口变化 → 指纹变化):重试成功并安装
145
- shouldThrow = false;
146
- const recovered = applyNetworkProxyConfig(config(withProxy({ port: 7891 })), stillLocked, deps);
147
- expect(recovered?.current).toBeInstanceOf(RuleDispatchDispatcher);
148
- expect(deps.setCalls).toHaveLength(1);
149
- });
150
-
151
- it("运行中重建遇构造失败:撤下旧分流器恢复原分发器(全部直连),记指纹锁定", () => {
152
- const sharedOriginal = new FakeDispatcher("original");
153
- const deps = createDeps(() => new FakeDispatcher("proxy"), sharedOriginal);
154
- const installed = applyNetworkProxyConfig(config(), null, deps);
155
- expect(deps.setCalls).toHaveLength(1);
156
-
157
- const failing = createDeps(() => {
158
- throw new Error("socks5 experimental API changed");
159
- }, sharedOriginal);
160
- const locked = applyNetworkProxyConfig(config(withProxy({ protocol: "socks5" })), installed, failing);
161
- expect(locked?.current).toBeNull();
162
- // 撤旧:set 收到 original(回 pi 原生行为 = 全部直连/env 代理路由)
163
- expect(failing.setCalls).toHaveLength(1);
164
- expect(failing.setCalls[0]).toBe(sharedOriginal);
165
- expect(failing.errors[0]).toContain("socks5");
166
- });
167
-
168
- it("锁定态再遇禁用:无槽操作、状态清空(构造失败后关开关 = 干净退出)", () => {
169
- const deps = createDeps(() => {
170
- throw new Error("boom");
171
- });
172
- const locked = applyNetworkProxyConfig(config(), null, deps);
173
- const after = applyNetworkProxyConfig(config({ enabled: false }), locked, deps);
174
- expect(after).toBeNull();
175
- expect(deps.setCalls).toHaveLength(0);
176
- });
177
-
178
- it("安装状态的 current 即 setGlobalDispatcher 收到的实例(槽与状态一致)", () => {
179
- const deps = createDeps();
180
- const state: InstalledState | null = applyNetworkProxyConfig(config(), null, deps);
181
- expect(deps.setCalls[0]).toBe(state?.current);
182
- expect(vi.isMockFunction(deps.onError)).toBe(false);
183
- });
184
-
185
- // ── 槽位归属核查(pi 启动序列在 extension 安装后重跑 configureHttpDispatcher 整槽覆盖,
186
- // dist main.js:707 实证——指纹未变也必须重装,否则会话整段生命周期不走代理)──
187
-
188
- /** 可变槽 deps:getGlobalDispatcher 返回当前槽变量(覆盖测试用,createDeps 槽固定)。 */
189
- function createMutableSlotDeps(initialSlot: FakeDispatcher) {
190
- let slot: FakeDispatcher = initialSlot;
191
- const setCalls: FakeDispatcher[] = [];
192
- const errors: string[] = [];
193
- const deps: InstallerDeps & { setCalls: FakeDispatcher[]; errors: string[]; setSlot: (d: FakeDispatcher) => void } = {
194
- getGlobalDispatcher: () => slot,
195
- setGlobalDispatcher: (d) => {
196
- slot = d as FakeDispatcher;
197
- setCalls.push(d as FakeDispatcher);
198
- },
199
- buildProxy: () => new FakeDispatcher("proxy"),
200
- onError: (message) => errors.push(message),
201
- setCalls,
202
- errors,
203
- setSlot: (d) => {
204
- slot = d;
205
- },
206
- };
207
- return deps;
208
- }
209
-
210
- it("同指纹但槽位被外部覆盖(pi 启动重装形态):撤旧装新,fallback = 覆盖者", () => {
211
- const piNative = new FakeDispatcher("pi-native");
212
- const deps = createMutableSlotDeps(piNative);
213
- const first = applyNetworkProxyConfig(config(), null, deps);
214
- expect(first?.current).not.toBeNull();
215
- // pi 启动序列整槽覆盖:槽上换成 pi 原生分发器,本扩展状态未感知
216
- const overwritten = new FakeDispatcher("pi-native-after-reconfigure");
217
- deps.setSlot(overwritten);
218
- // 同一配置(指纹未变)下一 turn 推进:必须重装
219
- const again = applyNetworkProxyConfig(config(), first, deps);
220
- expect(again?.current).not.toBeNull();
221
- expect(again?.current).not.toBe(first?.current);
222
- expect(again?.fingerprint).toBe(first?.fingerprint);
223
- // 新分流器 fallback = 槽上覆盖者(未命中委托 = 覆盖者的行为)
224
- expect((again?.current as RuleDispatchDispatcher).fallback).toBe(overwritten);
225
- // 槽上回到本扩展分发器
226
- expect(deps.getGlobalDispatcher()).toBe(again?.current);
227
- });
228
-
229
- it("同指纹且槽位仍归本扩展:零动作(覆盖核查不误伤既有无动作分支)", () => {
230
- const deps = createMutableSlotDeps(new FakeDispatcher("pi-native"));
231
- const first = applyNetworkProxyConfig(config(), null, deps);
232
- const setCallsAfterInstall = deps.setCalls.length;
233
- const again = applyNetworkProxyConfig(config(), first, deps);
234
- expect(again).toBe(first);
235
- expect(deps.setCalls).toHaveLength(setCallsAfterInstall);
236
- });
237
-
238
- it("禁用时槽位已被外部覆盖:不回写(槽保持覆盖者)、状态清空", () => {
239
- const deps = createMutableSlotDeps(new FakeDispatcher("pi-native"));
240
- const installed = applyNetworkProxyConfig(config(), null, deps);
241
- const overwritten = new FakeDispatcher("pi-native-after-reconfigure");
242
- deps.setSlot(overwritten);
243
- const setCallsAfterInstall = deps.setCalls.length;
244
- const after = applyNetworkProxyConfig(config({ enabled: false }), installed, deps);
245
- expect(after).toBeNull();
246
- expect(deps.setCalls).toHaveLength(setCallsAfterInstall);
247
- expect(deps.getGlobalDispatcher()).toBe(overwritten);
248
- });
249
- });
250
-
251
- describe("detachResidualDispatcher(reload 残留摘除)", () => {
252
- /** 模拟旧模块环境残留在全局槽上的分流器(跨环境识别走 Symbol.for 标记,不走 instanceof)。 */
253
- function residualDispatcher(original: FakeDispatcher): RuleDispatchDispatcher {
254
- return new RuleDispatchDispatcher({
255
- fallback: original,
256
- proxy: new FakeDispatcher("proxy"),
257
- noProxy: [],
258
- rules: [],
259
- onCount: () => {},
260
- });
261
- }
262
-
263
- it("installed=null 且槽上是残留分流器:恢复其 fallback(旧环境安装时捕获的原分发器)", () => {
264
- const sharedOriginal = new FakeDispatcher("original");
265
- const deps = createDeps(() => new FakeDispatcher("proxy"), sharedOriginal);
266
- const residual = residualDispatcher(sharedOriginal);
267
- detachResidualDispatcher(null, { ...deps, getGlobalDispatcher: () => residual });
268
- expect(deps.setCalls).toEqual([sharedOriginal]);
269
- });
270
-
271
- it("installed=null 且槽上是 pi 原生分发器(非本扩展):零动作", () => {
272
- const deps = createDeps();
273
- detachResidualDispatcher(null, deps);
274
- expect(deps.setCalls).toHaveLength(0);
275
- });
276
-
277
- it("installed 非 null(同环境 factory 重跑):早退零动作——槽上 current 不被误摘", () => {
278
- const deps = createDeps();
279
- const state = applyNetworkProxyConfig(config(), null, deps);
280
- expect(state).not.toBeNull();
281
- detachResidualDispatcher(state, deps);
282
- expect(deps.setCalls).toHaveLength(1); // 仅首装那一次,摘除未触发
283
- });
284
-
285
- it("reload 后推进状态机:enabled=false 恢复直连(摘除 + 零动作,无旧分流器残留)", () => {
286
- const sharedOriginal = new FakeDispatcher("original");
287
- const deps = createDeps(() => new FakeDispatcher("proxy"), sharedOriginal);
288
- const residual = residualDispatcher(sharedOriginal);
289
- // reload 形态:新环境 installed=null、槽上是旧环境残留
290
- detachResidualDispatcher(null, { ...deps, getGlobalDispatcher: () => residual });
291
- const after = applyNetworkProxyConfig(config({ enabled: false }), null, {
292
- ...deps,
293
- getGlobalDispatcher: () => sharedOriginal,
294
- });
295
- expect(after).toBeNull();
296
- expect(deps.setCalls).toEqual([sharedOriginal]); // 摘除那一次;禁用分支零动作
297
- });
298
-
299
- it("reload 后推进状态机:enabled=true 捕获的 original 是原分发器(无嵌套双计)", () => {
300
- const sharedOriginal = new FakeDispatcher("original");
301
- const deps = createDeps(() => new FakeDispatcher("proxy"), sharedOriginal);
302
- const residual = residualDispatcher(sharedOriginal);
303
- detachResidualDispatcher(null, { ...deps, getGlobalDispatcher: () => residual });
304
- const state = applyNetworkProxyConfig(config(), null, {
305
- ...deps,
306
- getGlobalDispatcher: () => sharedOriginal,
307
- });
308
- expect(state?.originalDispatcher).toBe(sharedOriginal);
309
- expect(state?.current).toBeInstanceOf(RuleDispatchDispatcher);
310
- // 新分流器的 fallback 是原分发器而非旧残留——同一请求只经一层计数
311
- expect((state?.current as RuleDispatchDispatcher).fallback).toBe(sharedOriginal);
312
- });
313
- });
package/src/installer.ts DELETED
@@ -1,165 +0,0 @@
1
- /**
2
- * 分流器安装状态机(设计 §3.1 行为序列 1/2 + §3.3 决策 5/8)。
3
- *
4
- * 进程级单一安装状态(全局分发器槽是进程级资源,状态必须模块级持有——session 替换重跑
5
- * factory 时不因闭包重建丢失 originalDispatcher 引用)。转移表:
6
- *
7
- * | 当前态 | 事件(新配置) | 动作 | 次态 |
8
- * |-----------------|--------------------|----------------------------------------|------|
9
- * | 任意 | enabled=false | 槽上仍是分流器则恢复 original(仅限槽位归属核查通过) | null |
10
- * | null | enabled+新指纹 | 捕获 original → 构造 → 安装 | 装载 |
11
- * | 装载 | enabled+同指纹+槽位仍归本扩展 | 无动作(不重建不重复安装) | 装载 |
12
- * | 装载 | enabled+同指纹+槽位被外部覆盖 | 撤旧装新(pi 启动重装覆盖形态,original=覆盖者) | 装载 |
13
- * | 锁定 | enabled+同指纹 | 无动作(首败即停不重试) | 锁定 |
14
- * | 任意 | enabled+新指纹 | 构造成功 → 撤旧装新 | 装载 |
15
- * | 任意 | enabled+构造失败 | error 上报 + 撤旧(全部直连),记指纹锁定 | 锁定 |
16
- *
17
- * 锁定态(current=null):同指纹不重试构造(fail-fast 首败即停,AGENTS.md 事实驱动原则⑧);
18
- * 用户修正配置 → 指纹变化 → 自然重试。依赖注入(get/setGlobalDispatcher、buildProxy、
19
- * onError)使状态机在单测中以 fake 依赖驱动,不 mock undici 全局槽。
20
- *
21
- * 状态机前置伴随入口 = detachResidualDispatcher:jiti 全量 reload 后 installed=null 但槽上
22
- * 仍挂着旧环境残留分流器,状态机自身无法区分「真无状态」与「reload 残留」,由它在推进前
23
- * 摘除(转移表各分支才从干净基线成立;pi reload 链实装锚点见该函数注释)。
24
- */
25
-
26
- import type { Dispatcher as IDispatcher } from "undici";
27
-
28
- import { networkProxyFingerprint, type NetworkProxyConfig } from "./config.js";
29
- import { isRuleDispatchDispatcher, RuleDispatchDispatcher } from "./dispatcher.js";
30
- import { recordDispatch } from "./stats.js";
31
-
32
- /** 当前安装状态;null = 未安装(槽上是 pi 原生分发器)。 */
33
- export interface InstalledState { // oe-exempt:20261019:framework:安装状态机载体形状(状态机唯一状态类型,单实现为常态)
34
-
35
- /** 安装前捕获的原分发器引用(首次捕获后跨重建保留——恢复与 fallback 都用它)。 */
36
- originalDispatcher: IDispatcher;
37
- /** 已安装(或已锁定失败)配置的指纹。 */
38
- fingerprint: string;
39
- /** 槽上的分流器;null = 构造失败锁定态(未安装)。 */
40
- current: IDispatcher | null;
41
- }
42
-
43
- export interface InstallerDeps { // oe-exempt:20261019:framework:安装状态机依赖注入接缝(单测注入 get/setGlobalDispatcher 与可抛 buildProxy)
44
-
45
- getGlobalDispatcher: () => IDispatcher;
46
- setGlobalDispatcher: (dispatcher: IDispatcher) => void;
47
- /** 代理分发器构造(可注入抛错——「构造失败 → 不安装 → 全直连」单测入口)。 */
48
- buildProxy: (config: NetworkProxyConfig) => IDispatcher;
49
- /** extension error 呈现(logger.error;文案含恢复动作)。 */
50
- onError: (message: string) => void;
51
- }
52
-
53
- /**
54
- * reload 残留摘除(设计 §3.1 行为序列 2 在 reload 窗口的兑现):jiti 全量 reload 后本扩展
55
- * 换了新模块环境,installed=null 但全局槽仍挂着旧环境安装的 RuleDispatchDispatcher——
56
- * 不摘除则 enabled=false 状态机判「无状态」零动作(旧分流器继续生效,禁用失效),或
57
- * enabled=true 时旧分流器被捕获为 original 嵌套(同一请求经新旧两层各计一次,统计双计)。
58
- * 摘除 = 恢复残留分流器安装时捕获的原分发器(fallback 引用),状态机从干净基线推进。
59
- * installed 非 null(同环境 factory 重跑)早退:槽上就是 state.current,摘掉会与
60
- * 「指纹未变无动作」分支冲突(分流器被摘而状态机不重装)。
61
- *
62
- * pi reload 链实装锚点(@earendil-works/pi-coding-agent 1.0.0 dist,逐文件读码核实):
63
- * agent-session.js:2899 reload() → :2903 oldRunner.invalidate()(extensions/loader.js:141-149
64
- * 退订旧 runtime 的全部 eventBus 订阅)→ :2908 resourceLoader.reload()(resource-loader.js:350,
65
- * :353 clearExtensionCache() 清空 jiti 模块缓存并递增缓存代际)→ loader.js:456-484 缓存代际
66
- * 失效后 :472-476 新建 jiti 实例(moduleCache: false)重新 import 本扩展、:505-509 重跑工厂
67
- * ——新环境模块级 installed 重置为 null,旧环境分流器滞留全局槽无人恢复,即本函数的前提。
68
- */
69
- export function detachResidualDispatcher(
70
- installed: InstalledState | null,
71
- deps: Pick<InstallerDeps, "getGlobalDispatcher" | "setGlobalDispatcher">,
72
- ): void {
73
- if (installed !== null) return;
74
- const current = deps.getGlobalDispatcher();
75
- if (!isRuleDispatchDispatcher(current)) return;
76
- deps.setGlobalDispatcher(current.fallback);
77
- }
78
-
79
- /**
80
- * 禁用态收口(转移表首行):恢复仅限槽位仍归本扩展时执行——槽位已被外部覆盖(pi 启动重装,
81
- * 见下)时槽上已是 pi 原生分发器,回写 state.originalDispatcher 会把它挤掉(无害但多余),
82
- * 语义上禁用 = 摘除自己的安装,不是改写别人的。
83
- */
84
- function restoreOriginalIfOwned(
85
- state: InstalledState | null,
86
- deps: Pick<InstallerDeps, "getGlobalDispatcher" | "setGlobalDispatcher">,
87
- ): void {
88
- if (state?.current && deps.getGlobalDispatcher() === state.current) {
89
- deps.setGlobalDispatcher(state.originalDispatcher);
90
- }
91
- }
92
-
93
- /**
94
- * 槽位归属核查(pi 启动时序缺陷的根因修复):pi 1.0.0 启动序列在 extension 安装之后
95
- * 仍会重跑 configureHttpDispatcher() 整槽覆盖(dist main.js:707 实证)——指纹未变但槽上
96
- * 已非本扩展分发器时按「撤旧装新」重装。original 统一取槽上现持有者:覆盖场景下
97
- * state.originalDispatcher 是上一代 pi 原生分发器,fallback 必须指向当前覆盖者才是
98
- * 「未命中委托 = 未安装本扩展时 pi 的确切行为」。
99
- */
100
- function resolveOriginal(state: InstalledState | null, slotNow: IDispatcher): IDispatcher {
101
- return state && state.current !== null && slotNow === state.current
102
- ? state.originalDispatcher
103
- : slotNow;
104
- }
105
-
106
- /** 构造失败收口(转移表末行):error 上报 + 撤旧(全部直连),返回记指纹的锁定态。 */
107
- function lockOnBuildFailure(
108
- config: NetworkProxyConfig,
109
- state: InstalledState | null,
110
- original: IDispatcher,
111
- fingerprint: string,
112
- deps: InstallerDeps,
113
- error: unknown,
114
- ): InstalledState {
115
- deps.onError(
116
- `网络代理分流器构造失败(协议 ${config.proxy.protocol}),已保持全部直连,不影响 pi 运行;` +
117
- `可核对代理配置后保存重试,或关闭网络代理开关。构造错误: ${error instanceof Error ? error.message : String(error)}`,
118
- );
119
- if (state?.current) {
120
- deps.setGlobalDispatcher(original);
121
- }
122
- return { originalDispatcher: original, fingerprint, current: null };
123
- }
124
-
125
- /**
126
- * 按新配置推进安装状态机(见模块头转移表)。config 契约 = loadNetworkProxyConfig 产物
127
- * (normalize 已校验合法——非法配置在读取层回落 defaults+error,构造性到达本函数恒合法)。
128
- */
129
- export function applyNetworkProxyConfig(
130
- config: NetworkProxyConfig,
131
- state: InstalledState | null,
132
- deps: InstallerDeps,
133
- ): InstalledState | null {
134
- if (!config.enabled) {
135
- restoreOriginalIfOwned(state, deps);
136
- return null;
137
- }
138
-
139
- const fingerprint = networkProxyFingerprint(config);
140
- if (state && state.fingerprint === fingerprint && state.current === null) {
141
- return state; // 锁定态:同指纹不重试构造(首败即停,决策 8)
142
- }
143
- const slotNow = deps.getGlobalDispatcher();
144
- if (state && state.fingerprint === fingerprint && slotNow === state.current) {
145
- return state; // 同指纹且槽位仍归本扩展:无动作(不重建不重复安装)
146
- }
147
- const original = resolveOriginal(state, slotNow);
148
-
149
- let proxy: IDispatcher;
150
- try {
151
- proxy = deps.buildProxy(config);
152
- } catch (error) {
153
- return lockOnBuildFailure(config, state, original, fingerprint, deps, error);
154
- }
155
-
156
- const dispatcher = new RuleDispatchDispatcher({
157
- fallback: original,
158
- proxy,
159
- noProxy: config.noProxy,
160
- rules: config.rules,
161
- onCount: recordDispatch,
162
- });
163
- deps.setGlobalDispatcher(dispatcher);
164
- return { originalDispatcher: original, fingerprint, current: dispatcher };
165
- }