@thincoder/core 0.10.4 → 0.10.5
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/CHANGELOG.md +31 -1
- package/README.md +1 -1
- package/advisor/compaction.mjs +11 -6
- package/advisor/run.mjs +4 -4
- package/agent/assemble.mjs +1 -1
- package/agent/chat-call.mjs +1 -1
- package/agent/child-marks.mjs +2 -2
- package/agent/digest-account.mjs +5 -4
- package/agent/dispatch-gates.mjs +8 -0
- package/agent/dispatch.mjs +39 -18
- package/agent/family-tools.mjs +22 -11
- package/agent/helpers.mjs +4 -3
- package/agent/light-round.mjs +66 -0
- package/agent/post-turn.mjs +8 -4
- package/agent/run-stages.mjs +5 -3
- package/agent/run-start.mjs +3 -2
- package/agent/setup-reminders.mjs +1 -1
- package/agent/setup.mjs +18 -3
- package/agent/subagent-event.mjs +1 -1
- package/agent/suspension.mjs +8 -5
- package/agent/timers.mjs +56 -5
- package/agent/turn-loop.mjs +5 -3
- package/agent/write-gate.mjs +7 -7
- package/agent-tools/advisor-async.mjs +2 -2
- package/agent-tools/advisor-settle.mjs +2 -2
- package/agent-tools/advisor.mjs +1 -1
- package/agent-tools/async-discard.mjs +34 -0
- package/agent-tools/async-settle.mjs +3 -2
- package/agent-tools/audit-block.mjs +1 -1
- package/agent-tools/bash-async.mjs +1 -1
- package/agent-tools/batch-lifecycle.mjs +35 -14
- package/agent-tools/batch-paths.mjs +4 -4
- package/agent-tools/batch-skeleton.mjs +29 -11
- package/agent-tools/batch.mjs +28 -15
- package/agent-tools/browser-async.mjs +198 -0
- package/agent-tools/checkpoint.mjs +1 -1
- package/agent-tools/design-slots.mjs +232 -0
- package/agent-tools/digest-budget.mjs +1 -1
- package/agent-tools/manifest.mjs +193 -0
- package/agent-tools/parent-channel.mjs +7 -4
- package/agent-tools/review-facts.mjs +13 -13
- package/agent-tools/settings.mjs +1 -1
- package/agent-tools/skill.mjs +1 -1
- package/agent-tools/subagent-async.mjs +21 -12
- package/agent-tools/subagent-scheduler.mjs +8 -5
- package/agent-tools/subagent-spawn.mjs +29 -55
- package/agent-tools/subagent.mjs +21 -8
- package/agent-tools/timer.mjs +186 -12
- package/agent-tools/zero-write-watch.mjs +2 -2
- package/agent-tools.mjs +2 -1
- package/agent.mjs +1 -1
- package/attachments.mjs +1 -1
- package/config-io.mjs +18 -13
- package/config-migrate.mjs +13 -29
- package/config-presets.mjs +33 -29
- package/config.mjs +68 -25
- package/context-degrade.mjs +1 -1
- package/context-echo.mjs +1 -1
- package/context-push.mjs +2 -2
- package/context.mjs +1 -1
- package/conventions.mjs +2 -2
- package/declaration.mjs +18 -2
- package/file-links.mjs +1 -1
- package/file-refs.mjs +1 -1
- package/history-window.mjs +1 -1
- package/i18n.mjs +2 -0
- package/ledger-cmd.mjs +62 -16
- package/ledger-db.mjs +6 -0
- package/ledger-surface.mjs +49 -5
- package/ledger-tools.mjs +16 -8
- package/ledger.mjs +9 -16
- package/log.mjs +1 -1
- package/manifest-discovery.mjs +1 -1
- package/manifest-schema.mjs +1 -1
- package/mcp/transport-stdio.mjs +1 -1
- package/mcp.mjs +1 -1
- package/memory/code-index.mjs +1 -1
- package/memory/code-search.mjs +1 -1
- package/memory/code-sync.mjs +1 -1
- package/memory/docs.mjs +6 -2
- package/memory/file-list.mjs +1 -1
- package/memory/file-walk.mjs +17 -5
- package/memory/maintain.mjs +410 -0
- package/memory/memory-tool.mjs +21 -6
- package/memory/schema.mjs +19 -7
- package/memory/sweep.mjs +8 -5
- package/memory/sync-tail.mjs +16 -10
- package/model-ref.mjs +16 -13
- package/model-specs.mjs +21 -9
- package/package.json +1 -1
- package/peer-instances.mjs +1 -1
- package/permission.mjs +33 -0
- package/process-probe.mjs +2 -2
- package/prompts/advisor-design.md +4 -2
- package/prompts/advisor-round1.md +1 -1
- package/prompts/advisor-round2.md +1 -1
- package/prompts/advisor-round3.md +1 -1
- package/prompts/common.md +36 -2
- package/prompts/consult-base.md +1 -1
- package/prompts/discipline-engineering.md +32 -15
- package/prompts/discipline-normal.md +46 -2
- package/prompts/persona-eng-designer.md +6 -6
- package/prompts/persona-engineering.md +14 -6
- package/prompts/persona-normal.md +2 -0
- package/prompts/subagent-base.md +3 -0
- package/provider/core.mjs +6 -0
- package/provider/errors.mjs +5 -3
- package/provider/google.mjs +9 -8
- package/provider/list-models.mjs +3 -2
- package/provider/sse.mjs +3 -0
- package/provider-flows.mjs +14 -15
- package/proxy-chunked.mjs +102 -0
- package/proxy-target.mjs +54 -0
- package/proxy-transport.mjs +298 -0
- package/proxy.mjs +17 -248
- package/session-gc.mjs +2 -2
- package/session-lifecycle.mjs +18 -14
- package/session-slot-claims.mjs +2 -2
- package/session-slots-manifest.mjs +1 -1
- package/session-slots.mjs +3 -3
- package/team-ledger.mjs +282 -0
- package/team.mjs +223 -0
- package/tool-docs/bash.md +1 -0
- package/tool-docs/batch.md +1 -1
- package/tool-docs/browser.md +48 -0
- package/tool-docs/ledger.md +3 -3
- package/tool-docs/manifest.md +16 -0
- package/tool-docs/memory.md +1 -0
- package/tool-docs/notify_parent.md +5 -0
- package/tool-docs/process.md +4 -4
- package/tool-docs/question.md +1 -0
- package/tool-docs/subagent.md +4 -3
- package/tool-docs/team_ledger.md +9 -0
- package/tool-docs/timer.md +6 -0
- package/tool-docs/wait_for.md +1 -0
- package/tools/browser.mjs +161 -0
- package/tools/git.mjs +1 -1
- package/tools/index.mjs +3 -0
- package/tools/ops.mjs +26 -10
- package/tools/repomap.mjs +1 -1
- package/tools/write-path.mjs +2 -2
- package/traces/trace-cleanup.mjs +1 -1
- package/traces/trace-store.mjs +1 -1
- package/vision-reader.mjs +10 -16
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* proxy-transport.mjs — 传输面(2026-10-08 拆档——原 `proxy.mjs` 迁出;#1037):
|
|
3
|
+
* CONNECT 隧道 ∥ 经典转发 ∥ 流式响应。`FETCH_TIMEOUT` = CONNECT/TLS 与经典转发建连超时(15s;
|
|
4
|
+
* 响应头阶段另有 `opts._headerTimeoutMs`)。
|
|
5
|
+
* 响应体 `Transfer-Encoding: chunked` ⇒ 解码器剥帧(`proxy-chunked.mjs`;#1065);单 token 已知
|
|
6
|
+
* `content-encoding`(gzip ∥ x-gzip ∥ deflate ∥ br)⇒ 剥帧后接解压器(#1067)。chunked 径背压:
|
|
7
|
+
* body 前最后一级可写流拒收 ⇒ 暂停上游源,排空 ⇒ 恢复(#1087)。
|
|
8
|
+
* 零依赖:Node built-ins only(net, tls, stream, url, zlib)。
|
|
9
|
+
*/
|
|
10
|
+
import { connect } from "node:net"
|
|
11
|
+
import { connect as tlsConnect } from "node:tls"
|
|
12
|
+
import { PassThrough } from "node:stream"
|
|
13
|
+
import { URL } from "node:url"
|
|
14
|
+
import { createBrotliDecompress, createGunzip, createInflate } from "node:zlib"
|
|
15
|
+
import { abortError, timeoutError } from "./abort-provenance.mjs"
|
|
16
|
+
import { createChunkedDecoder } from "./proxy-chunked.mjs"
|
|
17
|
+
import { destroyBody } from "./stream-destroy.mjs"
|
|
18
|
+
|
|
19
|
+
export const FETCH_TIMEOUT = 15_000
|
|
20
|
+
|
|
21
|
+
/** 响应头终止序列(头阶段按字节累积——#1065 K5:chunked 首段须以字节交接)。 */
|
|
22
|
+
const HEADER_END = Buffer.from("\r\n\r\n")
|
|
23
|
+
|
|
24
|
+
/** content-encoding 解压器族(#1067——单 token 且已列 ⇒ 解压;identity ∥ 未知 ∥ 多 token 列表透传)。 */
|
|
25
|
+
const CE_DECODERS = { gzip: createGunzip, "x-gzip": createGunzip, deflate: createInflate, br: createBrotliDecompress }
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 在已建立的 socket 上发 HTTP 请求,响应头到齐即 resolve(流式)。
|
|
29
|
+
* 返回 Response-like: { ok, status, headers: Headers, body: PassThrough(异步迭代), text(): Promise<string> }
|
|
30
|
+
* body 边收边吐(SSE 流式消费方可逐 chunk 读取);text() 消费流到底(非流式调用方用)。
|
|
31
|
+
* 响应头 `Transfer-Encoding: chunked` ⇒ 体经 `proxy-chunked.mjs` 解码器剥帧后入 body(#1065;
|
|
32
|
+
* 流式保形、畸形帧回退透传、`0` 块 ⇒ body 结束);单 token 已知 `content-encoding` ⇒ 剥帧后接
|
|
33
|
+
* 解压器(#1067);chunked 径背压 ⇒ 暂停上游源 / 排空恢复(#1087);非 chunked 无 CE 径零动。
|
|
34
|
+
* opts.signal 全程有效:abort 即 destroy socket 并 reject/终止流。
|
|
35
|
+
* absoluteForm: 请求行发绝对 URI(http:// 目标的经典代理转发用),默认发 origin-form。
|
|
36
|
+
* 2026-08-31 会诊 #4:timeout 只覆盖"响应头阶段";settle 后 body 空闲 > bodyIdleMs 即
|
|
37
|
+
* 断流(原实现头部 timer 到齐即清,流式中途停摆会无限挂起直到用户 Ctrl+C)。
|
|
38
|
+
* bodyIdleMs 默认 120s(provider 长生成用 0 即禁用,由调用方决定)。
|
|
39
|
+
* 导出供测试(裸 socket,无需 TLS/CONNECT);生产路径走 tunnelHttps / proxyFetch。
|
|
40
|
+
*/
|
|
41
|
+
export function streamHttpResponse(sock, urlStr, opts = {}, timeout = FETCH_TIMEOUT, absoluteForm = false, bodyIdleMs = 120_000) {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const target = new URL(urlStr)
|
|
44
|
+
const method = opts.method ?? "GET"
|
|
45
|
+
const headers = opts.headers ?? {}
|
|
46
|
+
const signal = opts.signal
|
|
47
|
+
|
|
48
|
+
if (signal?.aborted) { sock.destroy(); return reject(abortError(signal, "provider", "proxy")) }
|
|
49
|
+
|
|
50
|
+
const body = new PassThrough()
|
|
51
|
+
let settled = false
|
|
52
|
+
let headerBuf = Buffer.alloc(0) // 头阶段按字节累积(#1065 K5——替代 utf8 往返)
|
|
53
|
+
let idleTimer = null
|
|
54
|
+
let sink = null // body 前解压器(单 token 已知 content-encoding 时建立——#1067;无则 null)
|
|
55
|
+
let sawInput = false // 解压器输入门(#1067):零输入(空体)+ CE 在场 ⇒ 不解压(零误报)
|
|
56
|
+
let pausedUpstream = false // 背压旗(#1087):sock.pause() 在场,幂等守卫
|
|
57
|
+
|
|
58
|
+
const timer = setTimeout(() => fail(timeoutError("Response timeout", "provider", "proxy-header")), timeout)
|
|
59
|
+
const onAbort = () => { sock.destroy(); fail(abortError(signal, "provider", "proxy")) }
|
|
60
|
+
signal?.addEventListener("abort", onAbort, { once: true })
|
|
61
|
+
|
|
62
|
+
function cleanup() {
|
|
63
|
+
clearTimeout(timer)
|
|
64
|
+
clearTimeout(idleTimer)
|
|
65
|
+
signal?.removeEventListener("abort", onAbort)
|
|
66
|
+
}
|
|
67
|
+
/** 头部阶段失败 reject;resolve 后失败则终止 body 流(for-await 抛出,不挂起) */
|
|
68
|
+
function fail(err) {
|
|
69
|
+
cleanup()
|
|
70
|
+
if (!settled) { settled = true; reject(err) }
|
|
71
|
+
else destroyBody(body, err)
|
|
72
|
+
}
|
|
73
|
+
/** 背压暂停上游源(#1087——幂等:排空前只暂停一次) */
|
|
74
|
+
function pauseUpstream() {
|
|
75
|
+
if (!pausedUpstream) { pausedUpstream = true; sock.pause() }
|
|
76
|
+
}
|
|
77
|
+
/** 排空恢复上游源(#1087——暂停态对偶分支,幂等) */
|
|
78
|
+
function resumeUpstream() {
|
|
79
|
+
if (pausedUpstream) { pausedUpstream = false; sock.resume() }
|
|
80
|
+
}
|
|
81
|
+
/** body 结束(幂等——`0` 块 ∥ 对端 close 两径都会到达;#1065):带 CE 且已收输入 ⇒ 先结解压器
|
|
82
|
+
* (flush 后经 pipe 结 body);零输入(空体 + CE 在场)⇒ 直结 body(sawInput 门——#1067,零误报)。 */
|
|
83
|
+
function endBody() {
|
|
84
|
+
if (sink && sawInput) {
|
|
85
|
+
if (!sink.destroyed && !sink.writableEnded) sink.end()
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
if (!body.destroyed && !body.writableEnded) body.end()
|
|
89
|
+
}
|
|
90
|
+
/** 推入 body 前最后一级可写流(带 CE = 解压器 ∥ 无 CE = body——#1087 暂停触发级);
|
|
91
|
+
* `write()` 返 false ⇒ 暂停上游源(排空后 'drain' 恢复);终止后不再写(watchdog ∥ abort ∥
|
|
92
|
+
* `0` 块后余包,与非 chunked 径 pipe 自摘语义齐)。 */
|
|
93
|
+
function pushDownstream(chunk) {
|
|
94
|
+
const target = sink ?? body
|
|
95
|
+
if (target.destroyed || target.writableEnded) return
|
|
96
|
+
if (sink) sawInput = true
|
|
97
|
+
if (target.write(chunk) === false) pauseUpstream()
|
|
98
|
+
}
|
|
99
|
+
/** body 阶段空闲看门狗:每次数据到达重置;无数据超时 → 断流(流式消费方抛错) */
|
|
100
|
+
function armIdle() {
|
|
101
|
+
clearTimeout(idleTimer)
|
|
102
|
+
if (bodyIdleMs > 0) idleTimer = setTimeout(() => destroyBody(body, timeoutError("Response body timeout (idle)", "provider", "proxy-body-idle")), bodyIdleMs)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
sock.on("data", (d) => {
|
|
106
|
+
if (settled) return // 理论上不会发生(settle 后摘掉本监听器),防御
|
|
107
|
+
headerBuf = headerBuf.length > 0 ? Buffer.concat([headerBuf, d]) : d
|
|
108
|
+
const idx = headerBuf.indexOf(HEADER_END)
|
|
109
|
+
if (idx < 0) return
|
|
110
|
+
|
|
111
|
+
const headerText = headerBuf.subarray(0, idx).toString("utf8")
|
|
112
|
+
const statusMatch = headerText.match(/^HTTP\/\d\.\d (\d+)/)
|
|
113
|
+
const status = statusMatch ? Number(statusMatch[1]) : 502
|
|
114
|
+
const respHeaders = {}
|
|
115
|
+
for (const line of headerText.split("\r\n").slice(1)) {
|
|
116
|
+
const ci = line.indexOf(":")
|
|
117
|
+
if (ci > 0) respHeaders[line.slice(0, ci).trim().toLowerCase()] = line.slice(ci + 1).trim()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 头到齐:摘掉头阶段监听;剩余字节(字节副本)按 TE 分径交接
|
|
121
|
+
sock.removeAllListeners("data")
|
|
122
|
+
settled = true
|
|
123
|
+
cleanup()
|
|
124
|
+
armIdle()
|
|
125
|
+
const remaining = Buffer.from(headerBuf.subarray(idx + 4))
|
|
126
|
+
// 判定按 Transfer-Encoding 末段 token(大小写不敏感);头缺席而帧在场(协议违规)不解码
|
|
127
|
+
const chunked = (respHeaders["transfer-encoding"] ?? "").split(",").pop().trim().toLowerCase() === "chunked"
|
|
128
|
+
// 内容编码解压阶段(#1067——TE 剥帧后接,chunked ∥ 非 chunked 两径同点):单 token 且已列编码
|
|
129
|
+
// 建解压器;identity ∥ 头缺席 ∥ 未知编码 ∥ 多 token 列表 ⇒ 透传零动。请求头零改(不发
|
|
130
|
+
// Accept-Encoding——上游自发压缩才走本阶段)。
|
|
131
|
+
const ceTokens = (respHeaders["content-encoding"] ?? "").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean)
|
|
132
|
+
const ceFactory = ceTokens.length === 1 ? CE_DECODERS[ceTokens[0]] : undefined
|
|
133
|
+
if (ceFactory) {
|
|
134
|
+
sink = ceFactory()
|
|
135
|
+
// 解压失败 ⇒ body 以明错误终止(部分解码已发生——无「原字节」可回退);解压器 ⇒ body 走 pipe
|
|
136
|
+
// (自带背压:body 慢 ⇒ 解压器停 ⇒ 其可写侧涨满 ⇒ 推送分支暂停源)。
|
|
137
|
+
sink.on("error", (e) => fail(new Error(`Response body decompression failed (content-encoding: ${ceTokens[0]}): ${e.message}`)))
|
|
138
|
+
sink.pipe(body)
|
|
139
|
+
}
|
|
140
|
+
if (chunked) {
|
|
141
|
+
// chunked 径:解码器剥帧 ⇒ 逐段推入 body 前最后一级(带 CE = 解压器;函数式改写——body 本体零替换);
|
|
142
|
+
// 不再 pipe——body 由解码器 onData/onEnd 驱动
|
|
143
|
+
const decoder = createChunkedDecoder({ onData: pushDownstream, onEnd: endBody })
|
|
144
|
+
sock.on("data", (c) => decoder.push(c)) // 推送分支 = 背压暂停点(#1087:write() 返 false ⇒ sock.pause())
|
|
145
|
+
decoder.push(remaining)
|
|
146
|
+
} else if (sink) {
|
|
147
|
+
// 非 chunked 径 · 带 CE:源 ⇒ 解压器(推送分支同形——背压暂停点同判)
|
|
148
|
+
if (remaining.length > 0) pushDownstream(remaining)
|
|
149
|
+
sock.on("data", pushDownstream)
|
|
150
|
+
} else {
|
|
151
|
+
// 非 chunked 径 · 无 CE:原样直写 + pipe(现状语义,零动)
|
|
152
|
+
if (remaining.length > 0) body.write(remaining)
|
|
153
|
+
sock.pipe(body)
|
|
154
|
+
}
|
|
155
|
+
// 空闲看门狗随数据到达重置。注意必须用 'readable' 而非 'data':
|
|
156
|
+
// 'data' 监听把流切成 flowing 模式,会抢走 readSSE/for-await 未来得及认领的
|
|
157
|
+
// 已写缓冲(2026-08-31 实测:分包响应头的 remaining 首段被吞,readSSE 等不到 ack)。
|
|
158
|
+
// 'readable' 不改变流模式,数据仍由消费方按需拉取。
|
|
159
|
+
body.on("readable", armIdle)
|
|
160
|
+
// 背压排空 = 消费进度信号(#1087——防「暂停期无数据到达」把慢而健康的消费者误杀)。
|
|
161
|
+
body.on("drain", armIdle)
|
|
162
|
+
// 背压对偶分支:排空 ⇒ 恢复上游源。带 CE 时恢复信号在解压器(body 经 pipe 先恢复解压器——链传导);
|
|
163
|
+
// 无 CE 时 body 即最后一级 ⇒ 其 drain 即恢复点。
|
|
164
|
+
if (sink) sink.on("drain", resumeUpstream)
|
|
165
|
+
else body.on("drain", resumeUpstream)
|
|
166
|
+
// body 结束后才移除 abort 监听(流式中途 abort 要能终止流);空闲看门狗一并清
|
|
167
|
+
body.on("close", () => {
|
|
168
|
+
clearTimeout(idleTimer)
|
|
169
|
+
signal?.removeEventListener("abort", onAbort)
|
|
170
|
+
// 停止门(#1087 评审 F3):body 终止时上游仍在背压暂停态 ⇒ 先恢复再销毁源——
|
|
171
|
+
// 被暂停的 sock 不因 body 消亡自行关闭(悬挂口)。
|
|
172
|
+
if (pausedUpstream) { pausedUpstream = false; sock.resume(); sock.destroy() }
|
|
173
|
+
})
|
|
174
|
+
signal?.addEventListener("abort", onAbort, { once: true })
|
|
175
|
+
|
|
176
|
+
resolve({
|
|
177
|
+
ok: status >= 200 && status < 400,
|
|
178
|
+
status,
|
|
179
|
+
headers: new Headers(respHeaders),
|
|
180
|
+
body,
|
|
181
|
+
text: async () => {
|
|
182
|
+
const chunks = []
|
|
183
|
+
for await (const c of body) chunks.push(c)
|
|
184
|
+
return Buffer.concat(chunks).toString("utf8")
|
|
185
|
+
},
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
sock.on("close", () => {
|
|
189
|
+
if (!settled) fail(new Error("Connection closed before response"))
|
|
190
|
+
else endBody()
|
|
191
|
+
})
|
|
192
|
+
// 头部阶段的传输错误统一包装为稳定契约(对端 RST 会抛原生 ECONNRESET,调用方难判别);
|
|
193
|
+
// resolve 后的 body 阶段保留原始错误终止流
|
|
194
|
+
sock.on("error", (e) => {
|
|
195
|
+
if (!settled) fail(new Error(`Connection closed before response (${e.code ?? e.message})`))
|
|
196
|
+
else fail(e)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
// 写请求(absoluteForm:代理转发时请求行为绝对 URI)
|
|
200
|
+
const requestTarget = absoluteForm ? urlStr : `${target.pathname}${target.search}`
|
|
201
|
+
const lines = [`${method} ${requestTarget} HTTP/1.1`]
|
|
202
|
+
for (const [k, v] of Object.entries({ ...headers, Host: target.hostname })) lines.push(`${k}: ${v}`)
|
|
203
|
+
lines.push("Connection: close", "", "")
|
|
204
|
+
sock.write(lines.join("\r\n"))
|
|
205
|
+
if (opts.body) sock.write(opts.body)
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* HTTPS request through HTTP CONNECT proxy tunnel.
|
|
211
|
+
* CONNECT + TLS 建立后交给 streamHttpResponse — 响应头到齐即 resolve,body 为流式。
|
|
212
|
+
* 2026-08-31 会诊 #1/#4:
|
|
213
|
+
* - TLS 默认全量证书校验(rejectUnauthorized: true)——走代理的流量(含 API key)
|
|
214
|
+
* 不得在未验证链路上传输;确需自签/内网代理时 opts.insecureTls=true 显式放行。
|
|
215
|
+
* - CONNECT/TLS 阶段用 timeout(默认 15s,建隧道快);**响应头超时**用
|
|
216
|
+
* opts._headerTimeoutMs(默认 60s)——原实现共用 15s,DeepSeek 排队 TTFB>15s
|
|
217
|
+
* 即误报 "Response timeout",与直连 600s 语义割裂。
|
|
218
|
+
*/
|
|
219
|
+
export function tunnelHttps(urlStr, opts, proxyUri, timeout = FETCH_TIMEOUT) {
|
|
220
|
+
return new Promise((resolve, reject) => {
|
|
221
|
+
let target, proxy
|
|
222
|
+
try {
|
|
223
|
+
target = new URL(urlStr)
|
|
224
|
+
proxy = new URL(proxyUri)
|
|
225
|
+
} catch {
|
|
226
|
+
// 融合(§2.5 #74——取双侧并集):VSC 侧的**坏代理串友好报错**并入核内——
|
|
227
|
+
// 原生 `Invalid URL` 不解释期望形态,调用方拿到的可观测文案退化。
|
|
228
|
+
return reject(new Error(`Invalid proxy URI: "${proxyUri}" — expected http://host:port`))
|
|
229
|
+
}
|
|
230
|
+
const signal = opts?.signal
|
|
231
|
+
const headerTimeoutMs = Number.isFinite(opts?._headerTimeoutMs) ? opts._headerTimeoutMs : 60_000
|
|
232
|
+
const bodyIdleMs = opts?._bodyIdleMs ?? 120_000
|
|
233
|
+
|
|
234
|
+
if (signal?.aborted) return reject(abortError(signal, "provider", "proxy"))
|
|
235
|
+
|
|
236
|
+
const sock = connect({ host: proxy.hostname, port: Number(proxy.port) || 3128 })
|
|
237
|
+
const timer = setTimeout(() => { sock.destroy(); reject(new Error("Proxy CONNECT timeout")) }, timeout)
|
|
238
|
+
const onAbort = () => { sock.destroy(); reject(abortError(signal, "provider", "proxy")) }
|
|
239
|
+
signal?.addEventListener("abort", onAbort, { once: true })
|
|
240
|
+
sock.on("connect", () => sock.write(`CONNECT ${target.hostname}:${target.port || 443} HTTP/1.1\r\nHost: ${target.hostname}\r\n\r\n`))
|
|
241
|
+
|
|
242
|
+
let buf = ""
|
|
243
|
+
sock.on("data", d => {
|
|
244
|
+
buf += d.toString()
|
|
245
|
+
const end = buf.indexOf("\r\n\r\n")
|
|
246
|
+
if (end < 0) return
|
|
247
|
+
const statusLine = buf.slice(0, end).split("\r\n")[0]
|
|
248
|
+
buf = buf.slice(end + 4)
|
|
249
|
+
if (!statusLine.includes("200")) { sock.destroy(); clearTimeout(timer); return reject(new Error(`Proxy CONNECT: ${statusLine}`)) }
|
|
250
|
+
sock.removeAllListeners("data"); clearTimeout(timer)
|
|
251
|
+
|
|
252
|
+
// 安全默认:TLS 全量校验。企业中间人代理场景显式 opts.insecureTls=true 才放行。
|
|
253
|
+
const tlsSock = tlsConnect({ socket: sock, servername: target.hostname, rejectUnauthorized: opts?.insecureTls !== true })
|
|
254
|
+
if (buf) tlsSock.unshift(Buffer.from(buf))
|
|
255
|
+
tlsSock.on("secureConnect", () => {
|
|
256
|
+
// TLS 之后的请求/响应阶段:abort 交由 streamHttpResponse 接管
|
|
257
|
+
signal?.removeEventListener("abort", onAbort)
|
|
258
|
+
streamHttpResponse(tlsSock, urlStr, opts, headerTimeoutMs, false, bodyIdleMs).then(resolve, reject)
|
|
259
|
+
})
|
|
260
|
+
tlsSock.on("error", e => { sock.destroy(); reject(e) })
|
|
261
|
+
})
|
|
262
|
+
sock.on("error", e => { clearTimeout(timer); reject(new Error(`Proxy CONNECT failed (${e.code ?? e.message})`)) })
|
|
263
|
+
// 代理干净 FIN 关闭(无 error)时也要 reject,不能卡满超时
|
|
264
|
+
sock.on("close", () => { clearTimeout(timer); reject(new Error("Proxy connection closed before tunnel established")) })
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** TCP 直连代理(http:// 目标的经典转发用):超时/abort/对端关闭都有稳定 reject */
|
|
269
|
+
export function tcpConnectProxy(proxyUri, signal, timeout) {
|
|
270
|
+
return new Promise((resolve, reject) => {
|
|
271
|
+
let proxy
|
|
272
|
+
try {
|
|
273
|
+
proxy = new URL(proxyUri)
|
|
274
|
+
} catch {
|
|
275
|
+
// 融合(§2.5 #74):同 tunnelHttps——坏代理串友好报错。
|
|
276
|
+
return reject(new Error(`Invalid proxy URI: "${proxyUri}" — expected http://host:port`))
|
|
277
|
+
}
|
|
278
|
+
const sock = connect({ host: proxy.hostname, port: Number(proxy.port) || 3128 })
|
|
279
|
+
if (signal?.aborted) { sock.destroy(); return reject(abortError(signal, "provider", "proxy")) }
|
|
280
|
+
const onAbort = () => sock.destroy()
|
|
281
|
+
signal?.addEventListener("abort", onAbort, { once: true })
|
|
282
|
+
const timer = setTimeout(() => { sock.destroy(); reject(new Error("Proxy CONNECT timeout")) }, timeout)
|
|
283
|
+
const onError = (e) => { clearTimeout(timer); reject(new Error(`Proxy CONNECT failed (${e.code ?? e.message})`)) }
|
|
284
|
+
const onClose = () => {
|
|
285
|
+
clearTimeout(timer)
|
|
286
|
+
reject(signal?.aborted ? abortError(signal, "provider", "proxy") : new Error("Proxy connection closed before tunnel established"))
|
|
287
|
+
}
|
|
288
|
+
sock.once("connect", () => {
|
|
289
|
+
clearTimeout(timer)
|
|
290
|
+
signal?.removeEventListener("abort", onAbort)
|
|
291
|
+
sock.removeListener("error", onError)
|
|
292
|
+
sock.removeListener("close", onClose)
|
|
293
|
+
resolve(sock)
|
|
294
|
+
})
|
|
295
|
+
sock.once("error", onError)
|
|
296
|
+
sock.once("close", onClose)
|
|
297
|
+
})
|
|
298
|
+
}
|
package/proxy.mjs
CHANGED
|
@@ -1,264 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* proxy.mjs —
|
|
3
|
-
*
|
|
2
|
+
* proxy.mjs — 代理出口:`injectProxy`(逐渠注入)∥ `proxyFetch`(统一出口编排;直连分支 = 断流通道建设点)
|
|
3
|
+
* + 兼容再出口(`proxy-target.mjs` ∥ `proxy-transport.mjs`——消费面 import 零改;2026-10-08 拆档 · #1037)。
|
|
4
|
+
* 零依赖:Node built-ins only(url)。
|
|
4
5
|
*/
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import { IDLE_ABORT, destroyBody } from "./stream-destroy.mjs";
|
|
6
|
+
import { URL } from "node:url"
|
|
7
|
+
import { normalizeProxy } from "./config.mjs"
|
|
8
|
+
import { IDLE_ABORT } from "./stream-destroy.mjs"
|
|
9
|
+
import { isLoopbackTarget, resolveProxyConfig, resolveWebProxy } from "./proxy-target.mjs"
|
|
10
|
+
import { FETCH_TIMEOUT, streamHttpResponse, tcpConnectProxy, tunnelHttps } from "./proxy-transport.mjs"
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Resolve proxy URI.
|
|
16
|
-
* New format: { proxy: { uri: "http://host:port", web: true, model: false } }
|
|
17
|
-
* Old format: { proxy: "http://host:port" } — backwards compatible, web=true model=false
|
|
18
|
-
* Env vars: HTTPS_PROXY, HTTP_PROXY, ALL_PROXY
|
|
19
|
-
*
|
|
20
|
-
* @returns {{ uri: string|null, web: boolean, model: boolean }}
|
|
21
|
-
*/
|
|
22
|
-
export function resolveProxyConfig(ctx) {
|
|
23
|
-
const cfgProxy = ctx?.agent?.config?.proxy
|
|
24
|
-
if (!cfgProxy) {
|
|
25
|
-
const uri = process.env.HTTPS_PROXY || process.env.HTTP_PROXY || process.env.ALL_PROXY || null
|
|
26
|
-
return { uri, web: !!uri, model: false }
|
|
27
|
-
}
|
|
28
|
-
if (typeof cfgProxy === "string") {
|
|
29
|
-
// Backward compat: bare string → web only
|
|
30
|
-
return { uri: cfgProxy, web: true, model: false }
|
|
31
|
-
}
|
|
32
|
-
return {
|
|
33
|
-
uri: cfgProxy.uri || cfgProxy.url || null,
|
|
34
|
-
web: cfgProxy.web !== false,
|
|
35
|
-
model: cfgProxy.model === true,
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** Convenience: resolve proxy URI for web tools (websearch/fetch) */
|
|
40
|
-
export function resolveWebProxy(ctx) {
|
|
41
|
-
const cfg = resolveProxyConfig(ctx)
|
|
42
|
-
return (cfg.uri && cfg.web) ? cfg.uri : null
|
|
43
|
-
}
|
|
12
|
+
export { isLoopbackTarget, resolveProxyConfig, resolveWebProxy, streamHttpResponse, tunnelHttps }
|
|
44
13
|
|
|
45
14
|
/**
|
|
46
15
|
* Inject the resolved proxy URI into each provider as provider.proxyUri
|
|
47
16
|
* (consumed by chat() in provider/core.mjs).
|
|
48
|
-
*
|
|
17
|
+
* 逐渠独立:per-provider `proxy: true` ∧ **在案** `proxy.uri` ⇒ 注入(无全局闸——D-PX1,2026-10-08)。
|
|
18
|
+
* env 回落不供模型代理(D-PX4):盘上 `proxy` 字段缺席 ⇒ 零注入(web 面 env 回落照旧——`resolveWebProxy`)。
|
|
19
|
+
* 判定归一化单源 = `normalizeProxy`(#1082,2026-10-10):`""` ∥ 空对象 ∥ 非法型 ⇒ 零注入(`!= null` 过闸后
|
|
20
|
+
* 的 env 回供理论口封死——模型代理永不吃 env)。
|
|
49
21
|
*/
|
|
50
22
|
export function injectProxy(providers, config) {
|
|
51
|
-
const
|
|
23
|
+
const uri = normalizeProxy(config?.proxy)?.uri ?? null
|
|
52
24
|
for (const p of providers ?? []) {
|
|
53
|
-
p.proxyUri = p.proxy &&
|
|
25
|
+
p.proxyUri = p.proxy && uri ? uri : undefined
|
|
54
26
|
}
|
|
55
27
|
}
|
|
56
28
|
|
|
57
|
-
/**
|
|
58
|
-
* 在已建立的 socket 上发 HTTP 请求,响应头到齐即 resolve(流式)。
|
|
59
|
-
* 返回 Response-like: { ok, status, headers: Headers, body: PassThrough(异步迭代), text(): Promise<string> }
|
|
60
|
-
* body 边收边吐(SSE 流式消费方可逐 chunk 读取);text() 消费流到底(非流式调用方用)。
|
|
61
|
-
* opts.signal 全程有效:abort 即 destroy socket 并 reject/终止流。
|
|
62
|
-
* absoluteForm: 请求行发绝对 URI(http:// 目标的经典代理转发用),默认发 origin-form。
|
|
63
|
-
* 2026-08-31 会诊 #4:timeout 只覆盖"响应头阶段";settle 后 body 空闲 > bodyIdleMs 即
|
|
64
|
-
* 断流(原实现头部 timer 到齐即清,流式中途停摆会无限挂起直到用户 Ctrl+C)。
|
|
65
|
-
* bodyIdleMs 默认 120s(provider 长生成用 0 即禁用,由调用方决定)。
|
|
66
|
-
* 导出供测试(裸 socket,无需 TLS/CONNECT);生产路径走 tunnelHttps / proxyFetch。
|
|
67
|
-
*/
|
|
68
|
-
export function streamHttpResponse(sock, urlStr, opts = {}, timeout = FETCH_TIMEOUT, absoluteForm = false, bodyIdleMs = 120_000) {
|
|
69
|
-
return new Promise((resolve, reject) => {
|
|
70
|
-
const target = new URL(urlStr)
|
|
71
|
-
const method = opts.method ?? "GET"
|
|
72
|
-
const headers = opts.headers ?? {}
|
|
73
|
-
const signal = opts.signal
|
|
74
|
-
|
|
75
|
-
if (signal?.aborted) { sock.destroy(); return reject(abortError(signal, "provider", "proxy")) }
|
|
76
|
-
|
|
77
|
-
const body = new PassThrough()
|
|
78
|
-
let settled = false
|
|
79
|
-
let headerBuf = ""
|
|
80
|
-
let idleTimer = null
|
|
81
|
-
|
|
82
|
-
const timer = setTimeout(() => fail(timeoutError("Response timeout", "provider", "proxy-header")), timeout)
|
|
83
|
-
const onAbort = () => { sock.destroy(); fail(abortError(signal, "provider", "proxy")) }
|
|
84
|
-
signal?.addEventListener("abort", onAbort, { once: true })
|
|
85
|
-
|
|
86
|
-
function cleanup() {
|
|
87
|
-
clearTimeout(timer)
|
|
88
|
-
clearTimeout(idleTimer)
|
|
89
|
-
signal?.removeEventListener("abort", onAbort)
|
|
90
|
-
}
|
|
91
|
-
/** 头部阶段失败 reject;resolve 后失败则终止 body 流(for-await 抛出,不挂起) */
|
|
92
|
-
function fail(err) {
|
|
93
|
-
cleanup()
|
|
94
|
-
if (!settled) { settled = true; reject(err) }
|
|
95
|
-
else destroyBody(body, err)
|
|
96
|
-
}
|
|
97
|
-
/** body 阶段空闲看门狗:每次数据到达重置;无数据超时 → 断流(流式消费方抛错) */
|
|
98
|
-
function armIdle() {
|
|
99
|
-
clearTimeout(idleTimer)
|
|
100
|
-
if (bodyIdleMs > 0) idleTimer = setTimeout(() => destroyBody(body, timeoutError("Response body timeout (idle)", "provider", "proxy-body-idle")), bodyIdleMs)
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
sock.on("data", (d) => {
|
|
104
|
-
if (settled) return // 理论上不会发生(settle 后摘掉本监听器),防御
|
|
105
|
-
headerBuf += d.toString("utf8")
|
|
106
|
-
const idx = headerBuf.indexOf("\r\n\r\n")
|
|
107
|
-
if (idx < 0) return
|
|
108
|
-
|
|
109
|
-
const headerText = headerBuf.slice(0, idx)
|
|
110
|
-
const statusMatch = headerText.match(/^HTTP\/\d\.\d (\d+)/)
|
|
111
|
-
const status = statusMatch ? Number(statusMatch[1]) : 502
|
|
112
|
-
const respHeaders = {}
|
|
113
|
-
for (const line of headerText.split("\r\n").slice(1)) {
|
|
114
|
-
const ci = line.indexOf(":")
|
|
115
|
-
if (ci > 0) respHeaders[line.slice(0, ci).trim().toLowerCase()] = line.slice(ci + 1).trim()
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// 头到齐:摘掉头阶段监听,剩余字节推入 body,后续数据直接 pipe
|
|
119
|
-
sock.removeAllListeners("data")
|
|
120
|
-
settled = true
|
|
121
|
-
cleanup()
|
|
122
|
-
armIdle()
|
|
123
|
-
const remaining = headerBuf.slice(idx + 4)
|
|
124
|
-
if (remaining) body.write(Buffer.from(remaining, "utf8"))
|
|
125
|
-
sock.pipe(body)
|
|
126
|
-
// 空闲看门狗随数据到达重置。注意必须用 'readable' 而非 'data':
|
|
127
|
-
// 'data' 监听把流切成 flowing 模式,会抢走 readSSE/for-await 未来得及认领的
|
|
128
|
-
// 已写缓冲(2026-08-31 实测:分包响应头的 remaining 首段被吞,readSSE 等不到 ack)。
|
|
129
|
-
// 'readable' 不改变流模式,数据仍由消费方按需拉取。
|
|
130
|
-
body.on("readable", armIdle)
|
|
131
|
-
// body 结束后才移除 abort 监听(流式中途 abort 要能终止流);空闲看门狗一并清
|
|
132
|
-
body.on("close", () => {
|
|
133
|
-
clearTimeout(idleTimer)
|
|
134
|
-
signal?.removeEventListener("abort", onAbort)
|
|
135
|
-
})
|
|
136
|
-
signal?.addEventListener("abort", onAbort, { once: true })
|
|
137
|
-
|
|
138
|
-
resolve({
|
|
139
|
-
ok: status >= 200 && status < 400,
|
|
140
|
-
status,
|
|
141
|
-
headers: new Headers(respHeaders),
|
|
142
|
-
body,
|
|
143
|
-
text: async () => {
|
|
144
|
-
const chunks = []
|
|
145
|
-
for await (const c of body) chunks.push(c)
|
|
146
|
-
return Buffer.concat(chunks).toString("utf8")
|
|
147
|
-
},
|
|
148
|
-
})
|
|
149
|
-
})
|
|
150
|
-
sock.on("close", () => {
|
|
151
|
-
if (!settled) fail(new Error("Connection closed before response"))
|
|
152
|
-
else body.end()
|
|
153
|
-
})
|
|
154
|
-
// 头部阶段的传输错误统一包装为稳定契约(对端 RST 会抛原生 ECONNRESET,调用方难判别);
|
|
155
|
-
// resolve 后的 body 阶段保留原始错误终止流
|
|
156
|
-
sock.on("error", (e) => {
|
|
157
|
-
if (!settled) fail(new Error(`Connection closed before response (${e.code ?? e.message})`))
|
|
158
|
-
else fail(e)
|
|
159
|
-
})
|
|
160
|
-
|
|
161
|
-
// 写请求(absoluteForm:代理转发时请求行为绝对 URI)
|
|
162
|
-
const requestTarget = absoluteForm ? urlStr : `${target.pathname}${target.search}`
|
|
163
|
-
const lines = [`${method} ${requestTarget} HTTP/1.1`]
|
|
164
|
-
for (const [k, v] of Object.entries({ ...headers, Host: target.hostname })) lines.push(`${k}: ${v}`)
|
|
165
|
-
lines.push("Connection: close", "", "")
|
|
166
|
-
sock.write(lines.join("\r\n"))
|
|
167
|
-
if (opts.body) sock.write(opts.body)
|
|
168
|
-
})
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* HTTPS request through HTTP CONNECT proxy tunnel.
|
|
173
|
-
* CONNECT + TLS 建立后交给 streamHttpResponse — 响应头到齐即 resolve,body 为流式。
|
|
174
|
-
* 2026-08-31 会诊 #1/#4:
|
|
175
|
-
* - TLS 默认全量证书校验(rejectUnauthorized: true)——走代理的流量(含 API key)
|
|
176
|
-
* 不得在未验证链路上传输;确需自签/内网代理时 opts.insecureTls=true 显式放行。
|
|
177
|
-
* - CONNECT/TLS 阶段用 timeout(默认 15s,建隧道快);**响应头超时**用
|
|
178
|
-
* opts._headerTimeoutMs(默认 60s)——原实现共用 15s,DeepSeek 排队 TTFB>15s
|
|
179
|
-
* 即误报 "Response timeout",与直连 600s 语义割裂。
|
|
180
|
-
*/
|
|
181
|
-
export function tunnelHttps(urlStr, opts, proxyUri, timeout = FETCH_TIMEOUT) {
|
|
182
|
-
return new Promise((resolve, reject) => {
|
|
183
|
-
let target, proxy
|
|
184
|
-
try {
|
|
185
|
-
target = new URL(urlStr)
|
|
186
|
-
proxy = new URL(proxyUri)
|
|
187
|
-
} catch {
|
|
188
|
-
// 融合(§2.5 #74——取双侧并集):VSC 侧的**坏代理串友好报错**并入核内——
|
|
189
|
-
// 原生 `Invalid URL` 不解释期望形态,调用方拿到的可观测文案退化。
|
|
190
|
-
return reject(new Error(`Invalid proxy URI: "${proxyUri}" — expected http://host:port`))
|
|
191
|
-
}
|
|
192
|
-
const signal = opts?.signal
|
|
193
|
-
const headerTimeoutMs = Number.isFinite(opts?._headerTimeoutMs) ? opts._headerTimeoutMs : 60_000
|
|
194
|
-
const bodyIdleMs = opts?._bodyIdleMs ?? 120_000
|
|
195
|
-
|
|
196
|
-
if (signal?.aborted) return reject(abortError(signal, "provider", "proxy"))
|
|
197
|
-
|
|
198
|
-
const sock = connect({ host: proxy.hostname, port: Number(proxy.port) || 3128 })
|
|
199
|
-
const timer = setTimeout(() => { sock.destroy(); reject(new Error("Proxy CONNECT timeout")) }, timeout)
|
|
200
|
-
const onAbort = () => { sock.destroy(); reject(abortError(signal, "provider", "proxy")) }
|
|
201
|
-
signal?.addEventListener("abort", onAbort, { once: true })
|
|
202
|
-
sock.on("connect", () => sock.write(`CONNECT ${target.hostname}:${target.port || 443} HTTP/1.1\r\nHost: ${target.hostname}\r\n\r\n`))
|
|
203
|
-
|
|
204
|
-
let buf = ""
|
|
205
|
-
sock.on("data", d => {
|
|
206
|
-
buf += d.toString()
|
|
207
|
-
const end = buf.indexOf("\r\n\r\n")
|
|
208
|
-
if (end < 0) return
|
|
209
|
-
const statusLine = buf.slice(0, end).split("\r\n")[0]
|
|
210
|
-
buf = buf.slice(end + 4)
|
|
211
|
-
if (!statusLine.includes("200")) { sock.destroy(); clearTimeout(timer); return reject(new Error(`Proxy CONNECT: ${statusLine}`)) }
|
|
212
|
-
sock.removeAllListeners("data"); clearTimeout(timer)
|
|
213
|
-
|
|
214
|
-
// 安全默认:TLS 全量校验。企业中间人代理场景显式 opts.insecureTls=true 才放行。
|
|
215
|
-
const tlsSock = tlsConnect({ socket: sock, servername: target.hostname, rejectUnauthorized: opts?.insecureTls !== true })
|
|
216
|
-
if (buf) tlsSock.unshift(Buffer.from(buf))
|
|
217
|
-
tlsSock.on("secureConnect", () => {
|
|
218
|
-
// TLS 之后的请求/响应阶段:abort 交由 streamHttpResponse 接管
|
|
219
|
-
signal?.removeEventListener("abort", onAbort)
|
|
220
|
-
streamHttpResponse(tlsSock, urlStr, opts, headerTimeoutMs, false, bodyIdleMs).then(resolve, reject)
|
|
221
|
-
})
|
|
222
|
-
tlsSock.on("error", e => { sock.destroy(); reject(e) })
|
|
223
|
-
})
|
|
224
|
-
sock.on("error", e => { clearTimeout(timer); reject(new Error(`Proxy CONNECT failed (${e.code ?? e.message})`)) })
|
|
225
|
-
// 代理干净 FIN 关闭(无 error)时也要 reject,不能卡满超时
|
|
226
|
-
sock.on("close", () => { clearTimeout(timer); reject(new Error("Proxy connection closed before tunnel established")) })
|
|
227
|
-
})
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/** TCP 直连代理(http:// 目标的经典转发用):超时/abort/对端关闭都有稳定 reject */
|
|
231
|
-
function tcpConnectProxy(proxyUri, signal, timeout) {
|
|
232
|
-
return new Promise((resolve, reject) => {
|
|
233
|
-
let proxy
|
|
234
|
-
try {
|
|
235
|
-
proxy = new URL(proxyUri)
|
|
236
|
-
} catch {
|
|
237
|
-
// 融合(§2.5 #74):同 tunnelHttps——坏代理串友好报错。
|
|
238
|
-
return reject(new Error(`Invalid proxy URI: "${proxyUri}" — expected http://host:port`))
|
|
239
|
-
}
|
|
240
|
-
const sock = connect({ host: proxy.hostname, port: Number(proxy.port) || 3128 })
|
|
241
|
-
if (signal?.aborted) { sock.destroy(); return reject(abortError(signal, "provider", "proxy")) }
|
|
242
|
-
const onAbort = () => sock.destroy()
|
|
243
|
-
signal?.addEventListener("abort", onAbort, { once: true })
|
|
244
|
-
const timer = setTimeout(() => { sock.destroy(); reject(new Error("Proxy CONNECT timeout")) }, timeout)
|
|
245
|
-
const onError = (e) => { clearTimeout(timer); reject(new Error(`Proxy CONNECT failed (${e.code ?? e.message})`)) }
|
|
246
|
-
const onClose = () => {
|
|
247
|
-
clearTimeout(timer)
|
|
248
|
-
reject(signal?.aborted ? abortError(signal, "provider", "proxy") : new Error("Proxy connection closed before tunnel established"))
|
|
249
|
-
}
|
|
250
|
-
sock.once("connect", () => {
|
|
251
|
-
clearTimeout(timer)
|
|
252
|
-
signal?.removeEventListener("abort", onAbort)
|
|
253
|
-
sock.removeListener("error", onError)
|
|
254
|
-
sock.removeListener("close", onClose)
|
|
255
|
-
resolve(sock)
|
|
256
|
-
})
|
|
257
|
-
sock.once("error", onError)
|
|
258
|
-
sock.once("close", onClose)
|
|
259
|
-
})
|
|
260
|
-
}
|
|
261
|
-
|
|
262
29
|
/**
|
|
263
30
|
* Generic fetch with proxy support.
|
|
264
31
|
* No proxy → native fetch. Proxy + HTTPS → CONNECT tunnel. Proxy + HTTP → 经典代理转发(绝对 URI 请求行)。
|
|
@@ -267,6 +34,8 @@ function tcpConnectProxy(proxyUri, signal, timeout) {
|
|
|
267
34
|
* (`readSSE` ∥ `parseGeminiStream`)经 `terminateBody` 恢复有效;proxy 两分支不挂(自有 `_bodyIdleMs`)。
|
|
268
35
|
*/
|
|
269
36
|
export async function proxyFetch(urlStr, opts, proxyUri) {
|
|
37
|
+
// Loopback 目标永不经代理(NO_PROXY 语义——#1026:本地网关被塞进代理 ⇒ 403 假红):
|
|
38
|
+
if (proxyUri && isLoopbackTarget(urlStr)) proxyUri = null
|
|
270
39
|
if (!proxyUri) {
|
|
271
40
|
const idleAbort = new AbortController()
|
|
272
41
|
const signal = opts?.signal ? AbortSignal.any([opts.signal, idleAbort.signal]) : idleAbort.signal
|
package/session-gc.mjs
CHANGED
|
@@ -130,7 +130,7 @@ export async function gcResidue({ dir = sessionsDir(), prefix, now = Date.now(),
|
|
|
130
130
|
return result
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
/** ②
|
|
133
|
+
/** ② 显式面清运分支(§6.12 / §6.17 补充——存量通路):全目录孤儿 sidecar 记录目录
|
|
134
134
|
*(`{prefix}.{N}.d`——主文件不存在 ∧ 该槽非活跃)⇒ 逐条**回收**(rename 进 `sessions-trash/<批次>/`,同 ① 语义)。
|
|
135
135
|
* 与 ① 两面之差:① = 自动面(单前缀 / 当前 cwd / **带 30 天保留期**——只治本前缀未来新增);
|
|
136
136
|
* 本函数 = **用户显式面**(跳前缀遍历全目录 / **不设保留期**——存量孤儿跨多前缀且多为近期孤儿,
|
|
@@ -355,7 +355,7 @@ export async function runSessionGc(args, { dir = sessionsDir(), prefix = null, c
|
|
|
355
355
|
}
|
|
356
356
|
out(`Moved ${r.deleted.length} files for ${t.hash} into the recycle bin (${r.batch})${r.skipped?.length ? ` — skipped ${r.skipped.length} (locked/racing, kept in place)` : ""}.`)
|
|
357
357
|
}
|
|
358
|
-
// ②
|
|
358
|
+
// ② 显式面清运分支(孤儿 sidecar 记录目录——存量通路;与 ① 同语义:rename 进回收批)
|
|
359
359
|
if (confirmTarget === "--all" && orphanDirs.candidates.length) {
|
|
360
360
|
const moved = await sweepOrphanRecordDirs({ dir, now, probeFn, entries: loopEntries })
|
|
361
361
|
out(`Orphan record dirs (.d): moved ${moved.recycled.length}/${moved.candidates.length} into the recycle bin (${trashRootFor(dir)}).`)
|