sintbot 1.3.0 → 1.4.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.
Files changed (81) hide show
  1. package/CHANGELOG.en.json +50 -0
  2. package/CHANGELOG.json +50 -0
  3. package/README.md +138 -20
  4. package/README.zh-CN.md +144 -20
  5. package/assets/design-styles/acid-pop.md +155 -0
  6. package/assets/design-styles/archive-index.md +161 -0
  7. package/assets/design-styles/art-deco.md +164 -0
  8. package/assets/design-styles/cinematic-noir.md +178 -0
  9. package/assets/design-styles/editorial.md +121 -0
  10. package/assets/design-styles/geometric-pop.md +122 -0
  11. package/assets/design-styles/hand-drawn.md +117 -0
  12. package/assets/design-styles/islamic-geometry.md +155 -0
  13. package/assets/design-styles/liquid-glass.md +183 -0
  14. package/assets/design-styles/neo-chinese.md +136 -0
  15. package/assets/design-styles/precision-mono.md +156 -0
  16. package/assets/design-styles/quiet-luxury.md +163 -0
  17. package/assets/design-styles/riso-print.md +116 -0
  18. package/assets/design-styles/soft-friendly.md +174 -0
  19. package/assets/design-styles/swiss-grid.md +156 -0
  20. package/assets/design-styles/tech-dark.md +182 -0
  21. package/bin/sintbot-v2.js +291 -263
  22. package/docs/AI-ASSISTED-INSTALL.md +8 -8
  23. package/docs/install.md +80 -48
  24. package/docs/zh-CN/AI-ASSISTED-INSTALL.md +8 -8
  25. package/docs/zh-CN/install.md +80 -48
  26. package/install.sh +34 -81
  27. package/package.json +60 -5
  28. package/src/agent/claudecode.js +32 -3
  29. package/src/agent/claudemodels.js +60 -0
  30. package/src/agent/claudestatus.js +74 -0
  31. package/src/agent/claudeusage.js +200 -0
  32. package/src/agent/codexapp.js +21 -4
  33. package/src/agent/codexcode.js +8 -3
  34. package/src/agent/codexstatus.js +181 -0
  35. package/src/agent/privatecore.js +1 -1
  36. package/src/agent/privatecore.wasm +0 -0
  37. package/src/c2configcontrol.js +214 -0
  38. package/src/c2console.js +149 -0
  39. package/src/c2incident.js +125 -0
  40. package/src/c2install.js +247 -37
  41. package/src/c2migration.js +100 -0
  42. package/src/c2multigroupcapacity.js +103 -0
  43. package/src/c2multigroupservice.js +418 -0
  44. package/src/cdp.js +254 -0
  45. package/src/config.js +13 -2
  46. package/src/core/c2durablejournal.js +26 -9
  47. package/src/core/c2durableruntime.js +104 -1
  48. package/src/core/c2inputrejection.js +45 -0
  49. package/src/core/c2localeffectexecutor.js +12 -1
  50. package/src/core/c2payloadhandles.js +11 -0
  51. package/src/core/c2persistrelation.js +20 -0
  52. package/src/core/c2persiststate.js +28 -2
  53. package/src/core/c2platformrecoverysender.js +1 -1
  54. package/src/core/c2sourcequarantine.js +56 -1
  55. package/src/core/c2terminalprune.js +2 -1
  56. package/src/core/engineslotsadapter.js +108 -0
  57. package/src/core/issues.js +185 -0
  58. package/src/core/ledger.js +30 -0
  59. package/src/core/proxy.js +424 -0
  60. package/src/core/store.js +12 -0
  61. package/src/core/v2engine.js +427 -31
  62. package/src/design.js +5161 -0
  63. package/src/gitrepo.js +150 -0
  64. package/src/leftover.js +174 -0
  65. package/src/platform/slack.js +1294 -0
  66. package/src/platform/wecom.js +961 -0
  67. package/src/platform/wecomcli.js +128 -0
  68. package/src/platform/wecomname.js +43 -0
  69. package/src/platform/wecompoll.js +241 -0
  70. package/src/preview.js +581 -0
  71. package/src/projectmemory.js +175 -0
  72. package/src/ui/auth.js +31 -0
  73. package/src/ui/browser.html +392 -0
  74. package/src/ui/browserroutes.js +234 -0
  75. package/src/ui/console.html +5719 -0
  76. package/src/ui/dashboard.js +168 -0
  77. package/src/ui/feishulogin.js +136 -0
  78. package/src/ui/serverbase.js +2371 -0
  79. package/src/ui/sessions.js +78 -0
  80. package/src/ui/takeover.js +108 -0
  81. package/src/usage.js +144 -0
@@ -0,0 +1,424 @@
1
+ // 出网代理(CONNECT 隧道)—— 管的是 **daemon 自己出网**这一层。
2
+ //
3
+ // 由来(2026-09-01 实测撞出来):
4
+ // **Node 的 `fetch`(undici)与 `ws` 都不读 `http_proxy` / `HTTPS_PROXY`**。同一台 Mac、
5
+ // 同一个 Clash 系统代理(127.0.0.1:7890):`curl` 通、Chrome 通,而 Node 直连被墙。
6
+ // 挂上隧道后 REST 1351ms / WS 1479ms,直连两条都是 12s 超时。
7
+ //
8
+ // ⚠ **这个洞最贵的地方是错误形态不说人话** ⇒ 极易误读成「平台挂了 / 账号有问题 / token 不对」:
9
+ // · `fetch` → `TypeError: fetch failed`,cause 是 `UND_ERR_CONNECT_TIMEOUT`
10
+ // · `ws` → **「连上了但一直没 Hello」**,连 error 都不报,就是干等
11
+ // 两者都不会说「你没走代理」。所以本模块凡是拒绝出网,都必须**自己把原因说出来**。
12
+ // ⚠ 更贵的一层:Clash 一类的 **TUN / 增强模式**接管全部流量时,Node 不配置也能出去 ——
13
+ // 于是开发机上「明明是好的」,别人那台不是。**「我这儿是好的」在这条上没有信息量。**
14
+ //
15
+ // ⚠ `src/config.js` 的 `applyProxyEnv` 只把代理**塞进 env 给 spawn 出去的 agent CLI 继承**
16
+ // (注释原文 "so every spawned `claude` child inherits it")—— **daemon 自己出网不经过它**。
17
+ // 飞书 SDK 自带 http 栈所以从没暴露过;是 Slack 适配器用裸 `fetch` + 裸 `new WebSocket()`
18
+ // 才把这个洞顶出来。本模块就是补这一层,读的仍是 `applyProxyEnv` 落下的那几个 env 变量
19
+ // ⇒ **配置只有 `cfg.proxy` 一个来源**,daemon 与子进程走同一份,不会各读各的。
20
+ //
21
+ // ⭐ 零依赖能解:HTTP `CONNECT` + `tls.connect` 包成 `https.Agent` 子类,REST 与 WS
22
+ // **共用同一个 agent**。⇒ `src/design.js`「从参考提炼」段头那条「要支持得引依赖 —— 这是零依赖守则
23
+ // 不换的东西」**已被推翻**(要引依赖的是 undici 的 `ProxyAgent`/`setGlobalDispatcher`,
24
+ // 那是另一条路)。⚠ REST 必须从 `fetch` 换成 `node:https` —— **`fetch` 没有挂 agent 的口子**。
25
+ //
26
+ // ⚠ **只支持 `http://` 的代理**(明文 HTTP CONNECT)。`socks5://` / `https://` 一律不认:
27
+ // 把 SOCKS 端口当 HTTP 代理发 CONNECT,在 Clash 的 mixed-port 上会**恰好碰对**(同一端口
28
+ // 两种协议都收),纯 SOCKS 端口上则是 `socket hang up` 或干挂 —— 又是那种不说人话的错。
29
+ // 有的机器 shell 里就配着 `all_proxy=socks5://…`,所以这条不是假想。
30
+
31
+ const http = require('node:http')
32
+ const https = require('node:https')
33
+ const net = require('node:net')
34
+ const tls = require('node:tls')
35
+
36
+ // 与 applyProxyEnv 落下的键一一对应(大小写两套都读:各家工具约定不统一)。
37
+ // ⚠ 顺序即优先级 —— 改顺序要同步改所有用到它的地方,不然会出现「这条路通、那条路不通」这种最难查的差异。
38
+ const PROXY_KEYS = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy']
39
+ const NO_PROXY_KEYS = ['NO_PROXY', 'no_proxy']
40
+
41
+ function parseProxyUrl(v) {
42
+ try { return new URL(String(v).trim()) } catch { return null }
43
+ }
44
+
45
+ /**
46
+ * 从 env 读**可用的**代理地址(只认 `http:`);没配、或配的都不是 http 代理,返回 ''。
47
+ * ⚠ 不可用的那种不是静默跳过 —— `proxyWarning()` 会把它说出来,给启动日志/doctor 用。
48
+ */
49
+ function proxyFromEnv(env = process.env) {
50
+ for (const k of PROXY_KEYS) {
51
+ const v = env && env[k]
52
+ if (!v || !String(v).trim()) continue
53
+ const u = parseProxyUrl(v)
54
+ if (u && u.protocol === 'http:') return String(v).trim()
55
+ }
56
+ return ''
57
+ }
58
+
59
+ /**
60
+ * env 里有代理变量、但本模块用不了时,给一句能照做的话;正常情况返回 ''。
61
+ * 典型:`all_proxy=socks5://127.0.0.1:7890` —— shell 里常有,而我们只会发 HTTP CONNECT。
62
+ */
63
+ function proxyWarning(env = process.env) {
64
+ if (proxyFromEnv(env)) return ''
65
+ for (const k of PROXY_KEYS) {
66
+ const v = env && env[k]
67
+ if (!v || !String(v).trim()) continue
68
+ const u = parseProxyUrl(v)
69
+ const scheme = u ? u.protocol.replace(':', '') : '(无法解析)'
70
+ return `环境变量 ${k}=${describeUrl(v)} 不是 http 代理(${scheme}),出网会直连。`
71
+ + `要走代理请在配置 proxy.http 里填 http:// 地址(Clash 的 mixed-port 同一端口也收 http)`
72
+ }
73
+ return ''
74
+ }
75
+
76
+ function noProxyFromEnv(env = process.env) {
77
+ for (const k of NO_PROXY_KEYS) {
78
+ const v = env && env[k]
79
+ if (v && String(v).trim()) return String(v).trim()
80
+ }
81
+ return ''
82
+ }
83
+
84
+ /**
85
+ * NO_PROXY 匹配。⚠ 必须实现:`applyProxyEnv` 会把 `deriveNoProxy` 生成的国内端点写进
86
+ * NO_PROXY 给子进程用,daemon 这层不认的话,**同一份配置在父子进程里含义不同** ——
87
+ * 那种不一致平时无声,出事时最难查。
88
+ *
89
+ * 口径(与 curl 对齐的那部分):`*` 全绕过 · `.example.com` / `*.example.com` 后缀 ·
90
+ * `example.com` 精确且蕴含子域 · 条目可带端口(忽略:只按主机名分流)· 逗号或空白分隔。
91
+ * ⚠ **IP 地址只做精确匹配**:`10.0.0.1` 不该被条目 `1` 或 `0.0.1` 按"后缀"命中 —— 域名的
92
+ * 后缀是层级,IP 的后缀什么都不是。IPv6 条目按字面比(带不带方括号都认),不做 CIDR。
93
+ */
94
+ function shouldBypass(hostname, noProxy) {
95
+ const host = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '')
96
+ if (!host) return false
97
+ const hostIsIp = net.isIP(host) !== 0
98
+ const list = String(noProxy || '').split(/[,\s]+/).map((s) => s.trim().toLowerCase()).filter(Boolean)
99
+ for (const raw of list) {
100
+ if (raw === '*') return true
101
+ const entry = stripEntryPort(raw).replace(/^\*/, '').replace(/\.$/, '')
102
+ if (!entry) continue
103
+ if (hostIsIp || net.isIP(entry.replace(/^\./, '')) !== 0) {
104
+ // 任一侧是 IP ⇒ 只认精确相等(去掉前导点后比)
105
+ if (host === entry.replace(/^\./, '')) return true
106
+ continue
107
+ }
108
+ if (entry.startsWith('.')) { if (host === entry.slice(1) || host.endsWith(entry)) return true }
109
+ else if (host === entry || host.endsWith('.' + entry)) return true
110
+ }
111
+ return false
112
+ }
113
+
114
+ // 条目里的端口:`host:8080` 剥掉,`[::1]:8080` 剥掉,而裸 IPv6 `::1` **不能**被当成"host + 端口 1"。
115
+ function stripEntryPort(raw) {
116
+ const m = /^\[([^\]]+)\](?::\d+)?$/.exec(raw)
117
+ if (m) return m[1]
118
+ if ((raw.match(/:/g) || []).length > 1) return raw // 多个冒号 = 裸 IPv6 字面量
119
+ return raw.replace(/:\d+$/, '')
120
+ }
121
+
122
+ // 透传给 tls.connect 的信任类选项(agent 层给的 ca / rejectUnauthorized 等不该被隧道吞掉;
123
+ // 其余如 host/port/path/agent/headers 是 http 层的,tls.connect 不认或会误解)。
124
+ const TLS_PASSTHROUGH = ['ca', 'cert', 'key', 'pfx', 'passphrase', 'rejectUnauthorized', 'checkServerIdentity',
125
+ 'ALPNProtocols', 'minVersion', 'maxVersion', 'ciphers', 'secureContext', 'secureProtocol', 'crl', 'ecdhCurve']
126
+
127
+ /**
128
+ * CONNECT 隧道 agent。REST(node:https)与 WS(ws 的 `{ agent }`)共用同一个实例 ——
129
+ * 共用不只是省事:两条链路走同一个代理、同一套 keepAlive,出问题时排查面才是一个。
130
+ *
131
+ * `createConnection` 用回调式签名 —— Node 18.17 / 20 / 22 的 `_http_agent.js` 都是
132
+ * `const s = this.createConnection(options, oncreate); if (s) oncreate(null, s)`,两种形态都认。
133
+ */
134
+ class TunnelAgent extends https.Agent {
135
+ // `httpImpl` 只为单测留的注入口 —— 隧道的成功路径要真 TLS,测不了;而**出错路径才是要害**
136
+ // (407 / 代理没起来 / 代理收了 TCP 却不回 CONNECT),那几条必须测得到,否则又回到
137
+ // "报一个指不到这里的错"。
138
+ constructor(proxyUrl, { httpImpl = http, connectTimeoutMs = 15_000, ...opts } = {}) {
139
+ super({ keepAlive: true, ...opts })
140
+ const u = parseProxyUrl(proxyUrl)
141
+ if (!u) throw new TypeError(`代理地址无法解析: ${describeUrl(proxyUrl)}`)
142
+ if (u.protocol !== 'http:') {
143
+ throw new TypeError(`只支持 http:// 的代理,拿到的是 ${u.protocol}//…(${describeUrl(proxyUrl)})—— socks5/https 代理这里发不了 CONNECT`)
144
+ }
145
+ this.proxy = u
146
+ this.proxyUrl = String(proxyUrl).trim()
147
+ this._http = httpImpl
148
+ this.connectTimeoutMs = connectTimeoutMs
149
+ // 认证头算一次:CONNECT 与明文绝对 URI 两条路都要带,各算一份必漂移
150
+ this.proxyAuth = u.username
151
+ ? 'Basic ' + Buffer.from(`${decodeURIComponent(u.username)}:${decodeURIComponent(u.password || '')}`).toString('base64')
152
+ : ''
153
+ }
154
+
155
+ createConnection(options, cb) {
156
+ const host = String(options.host || '')
157
+ const target = `${host.includes(':') ? `[${host}]` : host}:${options.port || 443}` // IPv6 目标要带括号
158
+ const headers = { Host: target }
159
+ if (this.proxyAuth) headers['Proxy-Authorization'] = this.proxyAuth
160
+ const req = this._http.request({
161
+ host: this.proxy.hostname,
162
+ port: this.proxy.port || 80,
163
+ method: 'CONNECT',
164
+ path: target,
165
+ headers,
166
+ agent: false,
167
+ })
168
+ req.once('connect', (res, socket) => {
169
+ if (res.statusCode !== 200) {
170
+ socket.destroy()
171
+ // ⚠ 407 = 代理要认证但 URL 里没带凭据。非 200 一律当隧道没建起来、**不硬往下走**:
172
+ // 往下走的话 TLS 握手会在代理返回的错误页上失败,报出来的是一个指不到这里的密码学错误。
173
+ return cb(new Error(`代理 CONNECT ${target} → ${res.statusCode} ${res.statusMessage || ''}`.trim()))
174
+ }
175
+ const tlsOpts = { socket, servername: options.servername || host }
176
+ for (const k of TLS_PASSTHROUGH) if (options[k] !== undefined) tlsOpts[k] = options[k]
177
+ const sock = tls.connect(tlsOpts, () => cb(null, sock))
178
+ sock.once('error', cb)
179
+ })
180
+ req.once('error', (e) => cb(new Error(`连不上代理 ${this.proxy.origin}: ${e.message}`)))
181
+ // ⚠ CONNECT 自己要有超时:代理端口通、但那头根本不是 HTTP 代理(配错端口指到别的服务)时,
182
+ // TCP 建得起来、CONNECT 永远没有回应。外层请求的 AbortSignal 触发 destroy 时,这个
183
+ // 挂着的 CONNECT 请求并不随之收掉 —— 每次超时就泄一条 TCP(reviewer 用黑洞代理复现过:
184
+ // 3 次超时 = 代理侧 3 条连接全留着)。15s 与探针的 20s 同量级。
185
+ if (this.connectTimeoutMs > 0) {
186
+ req.setTimeout(this.connectTimeoutMs, () => req.destroy(new Error(`CONNECT ${this.connectTimeoutMs / 1000}s 没有回应 —— 那个端口多半不是 HTTP 代理`)))
187
+ }
188
+ req.end()
189
+ }
190
+ }
191
+
192
+ // ── fetch 形状的响应(只实现调用方真用到的那几个成员)──────────────────────────────
193
+ // ⚠ 刻意不做成"完整的 Response 垫片":假装完整反而会让某天用到未实现成员的人拿到
194
+ // `undefined` 而不是报错。用到什么补什么,缺的当场就炸得出来。
195
+ function makeResponse(status, rawHeaders, buf, url, flags = null) {
196
+ const lower = new Map()
197
+ for (const [k, v] of Object.entries(rawHeaders || {})) {
198
+ lower.set(String(k).toLowerCase(), Array.isArray(v) ? v.join(', ') : String(v))
199
+ }
200
+ return {
201
+ url,
202
+ status,
203
+ ok: status >= 200 && status < 300,
204
+ headers: { get: (n) => (lower.has(String(n).toLowerCase()) ? lower.get(String(n).toLowerCase()) : null) },
205
+ async text() { return buf.toString('utf8') },
206
+ async json() { return JSON.parse(buf.toString('utf8')) },
207
+ async arrayBuffer() { return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) },
208
+ ...(flags || {}),
209
+ }
210
+ }
211
+
212
+ function bodyToBuffer(body) {
213
+ if (body === undefined || body === null) return null
214
+ if (Buffer.isBuffer(body)) return body
215
+ // URLSearchParams(出站一律 form 编码,见 docs/SLACK.md「静默失败」)与普通字符串都走这里
216
+ return Buffer.from(typeof body === 'string' ? body : String(body), 'utf8')
217
+ }
218
+
219
+ // `init.headers` 三种形态都认:普通对象 / `Headers` 实例 / `[k,v]` 数组。
220
+ // ⚠ `{...new Headers()}` 会得到 `{}` —— 头**静默全丢**,请求照发、只是没了 Authorization。
221
+ function normalizeHeaders(h) {
222
+ if (!h) return {}
223
+ if (typeof h.forEach === 'function' && !Array.isArray(h)) { const o = {}; h.forEach((v, k) => { o[k] = v }); return o }
224
+ if (Array.isArray(h)) return Object.fromEntries(h)
225
+ return { ...h }
226
+ }
227
+
228
+ const REDIRECT_CODES = new Set([301, 302, 303, 307, 308])
229
+ const REDIRECT_MAX = 20 // 与 fetch 对齐
230
+
231
+ /**
232
+ * 造一个 fetch 形状、但底层走 `node:https` + 隧道 agent 的函数。
233
+ *
234
+ * ⭐ **做成 fetch 形状是关键的架构选择**:适配器里每一处出网都已经有 `fetchImpl` 注入口
235
+ * (30 个零网络单测靠的就是它)⇒ 只换默认实现,调用点与测试一行都不用动。
236
+ *
237
+ * @param {string} proxyUrl 代理地址;传空则所有请求直连(等价于不挂隧道)
238
+ */
239
+ function createProxyFetch(proxyUrl, { noProxy = '', agent: injectedAgent = null, httpsImpl = https, httpImpl = http } = {}) {
240
+ const agent = proxyUrl ? (injectedAgent || new TunnelAgent(proxyUrl)) : null
241
+
242
+ return function proxiedFetch(url, init = {}) {
243
+ return request(String(url), init, 0)
244
+ }
245
+
246
+ function request(url, init, depth) {
247
+ return new Promise((resolve, reject) => {
248
+ // ⚠ 重定向环在 Slack 的下载链路上真实存在过(凭据无效时它用 200 + 登录页表达,但网关
249
+ // 抖动时也见过 302 打转)—— 不设限就是静默挂死。
250
+ if (depth > REDIRECT_MAX) return reject(new Error(`重定向超过 ${REDIRECT_MAX} 跳: ` + url))
251
+ const signal = init.signal
252
+ // ⚠ 已经取消的 signal:**在创建请求之前**就拒 —— 不是"创建了再 destroy"。
253
+ // 后者会先把 CONNECT + TLS 整条隧道建起来(reviewer 现场:能走到 onSocketNT 说明隧道
254
+ // 已完整建好),然后 Node 在 socket 分配时无条件 emit 'error'('socket hang up');
255
+ // 而那时 `req.on('error')` 还没挂 ⇒ **未捕获异常,daemon 整个崩**。
256
+ if (signal && signal.aborted) return reject(abortError(signal))
257
+ let u
258
+ try { u = new URL(url) } catch { return reject(new TypeError('无效的 URL: ' + url)) }
259
+
260
+ const isHttps = u.protocol === 'https:'
261
+ if (!isHttps && u.protocol !== 'http:') return reject(new TypeError('不支持的协议: ' + u.protocol))
262
+
263
+ const bypass = !agent || shouldBypass(u.hostname, noProxy)
264
+ const headers = normalizeHeaders(init.headers)
265
+ const method = String(init.method || 'GET').toUpperCase()
266
+ const buf = bodyToBuffer(init.body)
267
+ if (buf && headers['Content-Length'] === undefined && headers['content-length'] === undefined) {
268
+ headers['Content-Length'] = String(buf.length)
269
+ }
270
+
271
+ // http:(明文)走代理是**绝对形式 URI**,不是 CONNECT —— 两者搞混的表现是代理回 400。
272
+ // 这条链路上目前没有明文目标(Slack/Discord 都是 https),留着是为了不留一个静默直连的口子。
273
+ const viaAbsolute = !isHttps && !bypass
274
+ const lib = isHttps ? httpsImpl : httpImpl
275
+ const opts = viaAbsolute
276
+ ? { host: agent.proxy.hostname, port: agent.proxy.port || 80, method, path: u.toString(), headers: { ...headers, Host: u.host, ...(agent.proxyAuth ? { 'Proxy-Authorization': agent.proxyAuth } : {}) }, agent: false }
277
+ : { host: u.hostname, port: u.port || (isHttps ? 443 : 80), method, path: u.pathname + u.search, headers, agent: bypass ? undefined : agent }
278
+
279
+ const viaProxy = !bypass && !!agent
280
+ const wrap = (e) => (viaProxy ? new Error(`${e.message}(经代理 ${agent.proxy.origin})`) : e)
281
+
282
+ const req = lib.request(opts, (res) => {
283
+ const status = res.statusCode
284
+ const loc = res.headers && res.headers.location
285
+ const mode = init.redirect || 'follow' // fetch 的默认就是 follow
286
+ if (loc && REDIRECT_CODES.has(status) && mode !== 'manual') {
287
+ res.resume() // 必须排干,否则 keepAlive 的连接不会被释放
288
+ if (mode === 'error') return reject(new Error(`redirect:'error' 而对端回了 ${status} → ${loc}`))
289
+ let next
290
+ try { next = new URL(loc, u).toString() } catch { return reject(new Error('重定向地址无法解析: ' + loc)) }
291
+ const nextInit = { ...init, headers: { ...headers } }
292
+ // ⚠ **跨源必须摘掉 Authorization** —— 与 fetch 同口径。Slack 的文件下载正是
293
+ // `files.slack.com` → 签名 CDN 地址这种跳法,带着 `xoxb-` 跳过去就是把 bot token
294
+ // 送给第三方主机。签名已经在 URL 里,摘掉不影响下载。
295
+ if (new URL(next).origin !== u.origin) {
296
+ for (const k of Object.keys(nextInit.headers)) {
297
+ if (/^(authorization|cookie|proxy-authorization)$/i.test(k)) delete nextInit.headers[k]
298
+ }
299
+ }
300
+ // 303(除 GET/HEAD 外),以及 301/302 上的 POST:按 fetch 口径降级成 GET 且丢掉 body
301
+ const toGet = (status === 303 && method !== 'GET' && method !== 'HEAD') || ((status === 301 || status === 302) && method === 'POST')
302
+ if (toGet) {
303
+ nextInit.method = 'GET'
304
+ delete nextInit.body
305
+ for (const k of Object.keys(nextInit.headers)) {
306
+ // body 没了,描述 body 的头也得跟着走 —— 留着 Content-Length 对端会一直等一个不会来的 body
307
+ if (/^(content-length|content-type|content-encoding|content-language|content-location)$/i.test(k)) delete nextInit.headers[k]
308
+ }
309
+ }
310
+ return resolve(request(next, nextInit, depth + 1))
311
+ }
312
+ // init.maxBytes(非标准,fetch 会忽略它):响应体边收边数,到线就**截断**并断开 —— 这条路把整个响应
313
+ // 攒进内存,不封顶的话一个几百 MB 的响应就能把 daemon 撑爆(风格提炼会替人去拉任意公网页面)。
314
+ // 截断而不是报错:要的往往只是开头(HTML 的 <head> 里就是全部样式表链接)。响应带 capped / truncated 两个标记。
315
+ const chunks = []
316
+ let got = 0
317
+ const cap = Number(init.maxBytes) > 0 ? Number(init.maxBytes) : 0
318
+ const flags = cap ? { capped: true, truncated: false } : null
319
+ res.on('data', (c) => {
320
+ if (flags && flags.truncated) return
321
+ if (cap && got + c.length > cap) {
322
+ chunks.push(c.subarray(0, cap - got)); got = cap; flags.truncated = true
323
+ try { res.destroy() } catch {}
324
+ return resolve(makeResponse(status, res.headers, Buffer.concat(chunks), u.toString(), flags))
325
+ }
326
+ got += c.length
327
+ chunks.push(c)
328
+ })
329
+ res.on('end', () => resolve(makeResponse(status, res.headers, Buffer.concat(chunks), u.toString(), flags)))
330
+ res.on('error', (e) => reject(wrap(e))) // 半截响应 / 中途断线走这条,也得点名代理
331
+ })
332
+
333
+ // 调用方给的是 `AbortSignal.timeout(...)`(适配器那层已有超时口径)⇒ 这里只负责响应它,
334
+ // 不自己再叠一个:两层超时会让"到底谁掐的"变成一个查不清的问题。
335
+ // ⚠ 监听器要在请求结束时**摘掉**:`AbortSignal.timeout` 的信号一旦挂上监听器就被 Node 钉住
336
+ // (gcPersistentSignals)直到计时到点 —— 不摘,每次调用后 req→res→body 都被钉整整 timeoutMs;
337
+ // 长寿的 signal(复用一个 AbortController)上则只增不减。
338
+ const onAbort = () => { req.destroy(); reject(abortError(signal)) }
339
+ if (signal) {
340
+ signal.addEventListener('abort', onAbort, { once: true })
341
+ req.once('close', () => signal.removeEventListener('abort', onAbort))
342
+ }
343
+ // ⚠ 这条 message 是本模块存在的理由的一半:直连超时的原始报错完全指不到"没走代理"。
344
+ req.on('error', (e) => reject(wrap(e)))
345
+ if (buf) req.write(buf)
346
+ req.end()
347
+ })
348
+ }
349
+ }
350
+
351
+ function abortError(signal) {
352
+ const e = new Error((signal && signal.reason && signal.reason.message) || 'aborted')
353
+ e.name = (signal && signal.reason && signal.reason.name) || 'AbortError'
354
+ return e
355
+ }
356
+
357
+ // ── 模块级单例(进程内共用一个 agent / 一个 fetch)────────────────────────────────
358
+ // ⚠ 惰性 + 可重置:`applyProxyEnv` 在 `loadConfig` 之后才写 env,而本模块可能在那之前
359
+ // 就被 require 进来 ⇒ **不能在 require 时就定形**。
360
+ // ⚠ 但**别指望热重载会换代理**:`applyProxyEnv` 只在 env 里没有时才写(不覆盖 shell 已有值),
361
+ // 改了配置里的 proxy,env 不变、这里的 key 也就不变。要换代理得重启 daemon。`resetProxy()`
362
+ // 是给测试和显式调用留的,不是热重载钩子。
363
+ let cached = null
364
+ let cachedKey = null
365
+
366
+ /**
367
+ * 取一个代理感知的 fetch;**没配代理时返回 null**(调用方回落到全局 fetch)。
368
+ * ⭐ 返回 null 而不是"透明直连的包装" —— 没配代理的绝大多数机器上,行为与改动前逐字节相同,
369
+ * 这条改动的风险面因此只覆盖"真的配了代理"的那部分。
370
+ */
371
+ function getProxyFetch(env = process.env) {
372
+ const p = proxyFromEnv(env)
373
+ if (!p) return null
374
+ ensure(env, p)
375
+ return cached.fetch
376
+ }
377
+
378
+ /** 取共用的隧道 agent(给 `ws` 的 `{ agent }` 用);没配代理返回 null。 */
379
+ function getProxyAgent(env = process.env) {
380
+ const p = proxyFromEnv(env)
381
+ if (!p) return null
382
+ ensure(env, p)
383
+ return cached.agent
384
+ }
385
+
386
+ function ensure(env, p) {
387
+ const np = noProxyFromEnv(env)
388
+ const key = p + '|' + np
389
+ if (cached && cachedKey === key) return
390
+ // 换了就先关旧 agent 的 keepAlive 连接再换新的,否则每换一次泄一批 socket。
391
+ // (在途 REST 会被打成 aborted,createSlackApi 的重试吸收得住;已升级的 WS 早被 Node 从
392
+ // agent 上摘走 —— 不受影响,但也就意味着它会走旧隧道直到自然重连。)
393
+ resetProxy()
394
+ const agent = new TunnelAgent(p)
395
+ cached = { agent, fetch: createProxyFetch(p, { noProxy: np, agent }) }
396
+ cachedKey = key
397
+ }
398
+
399
+ /** 丢弃单例(测试 / 显式重建)。 */
400
+ function resetProxy() {
401
+ if (cached && cached.agent && cached.agent.destroy) { try { cached.agent.destroy() } catch { /* 已经关了 */ } }
402
+ cached = null
403
+ cachedKey = null
404
+ }
405
+
406
+ /** 把代理 URL 变成可进日志的样子。⚠ 不打印凭据:URL 里可能带用户名密码。 */
407
+ function describeUrl(v) {
408
+ const u = parseProxyUrl(v)
409
+ if (!u) return '(无法解析)'
410
+ return `${u.protocol}//${u.hostname}:${u.port || (u.protocol === 'https:' ? 443 : 80)}${u.username ? '(带认证)' : ''}`
411
+ }
412
+
413
+ /** 给 doctor / 启动日志用的一句话描述;没配可用代理返回 ''。 */
414
+ function proxyDescribe(env = process.env) {
415
+ const p = proxyFromEnv(env)
416
+ return p ? describeUrl(p) : ''
417
+ }
418
+
419
+ module.exports = {
420
+ PROXY_ENV_KEYS: [...PROXY_KEYS, ...NO_PROXY_KEYS],
421
+ proxyFromEnv, noProxyFromEnv, proxyWarning, shouldBypass,
422
+ TunnelAgent, createProxyFetch,
423
+ getProxyFetch, getProxyAgent, resetProxy, proxyDescribe,
424
+ }
package/src/core/store.js CHANGED
@@ -152,6 +152,18 @@ class StateStore {
152
152
  this.data = next
153
153
  }
154
154
 
155
+ // Model/project change: drop the session, its stats and any runtime model
156
+ // override in one file replacement, so the new configuration is followed
157
+ // and no session from the previous backend can be resumed.
158
+ resetGroupSession(chatId) {
159
+ const groups = this.data.groups || {}
160
+ const group = { ...(groups[chatId] || {}) }
161
+ for (const key of ['sessionId', 'tainted', 'sessionStats', 'provider', 'failover']) delete group[key]
162
+ const next = { ...this.data, groups: { ...groups, [chatId]: group } }
163
+ atomicWriteJSON(this.file, next, { mode: 0o600 })
164
+ this.data = next
165
+ }
166
+
155
167
  getProvider(chatId) { return this._group(chatId).provider || null }
156
168
  // ⚠ 人主动切模型(/model · 控制台)连带作废「额度临时切换」记录:人说了算,引擎之后不再自作主张切回。
157
169
  setProvider(chatId, name) { const g = this._group(chatId); g.provider = name; delete g.failover; this._save() }