dsh-tap 0.20.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.
Files changed (121) hide show
  1. package/AGENTS.md +99 -0
  2. package/CHANGELOG.md +751 -0
  3. package/LICENSE +21 -0
  4. package/README.en.md +59 -0
  5. package/README.md +256 -0
  6. package/cordis.patch.yml +385 -0
  7. package/core/bridge.js +698 -0
  8. package/core/json-store.js +91 -0
  9. package/core/rotation.js +108 -0
  10. package/core/usage-meter.js +176 -0
  11. package/docs/diagnosis-cache-quota.md +181 -0
  12. package/docs/diagnosis-qoder-flash.md +67 -0
  13. package/docs/diagnosis-trae-3003.md +302 -0
  14. package/docs/goals/bridge-port-host-split.md +91 -0
  15. package/docs/goals/desktop-adaptation.md +106 -0
  16. package/docs/goals/qoder-cn-provider-design.md +308 -0
  17. package/docs/goals/settings-card-ux-redesign-plan.md +1680 -0
  18. package/docs/goals/settings-card-ux-redesign.md +162 -0
  19. package/docs/goals/trae-agent-v3.md +41 -0
  20. package/docs/goals/trae-work-cn-repair.md +44 -0
  21. package/docs/goals/v0.8-/351/242/235/345/272/246/345/217/257/350/247/201-/346/250/241/345/236/213/345/212/250/346/200/201/345/214/226-/345/244/232/346/234/215/345/212/241/345/225/206.md +85 -0
  22. package/docs/pitfalls.md +105 -0
  23. package/docs/reverse/trae-cloud-api.md +218 -0
  24. package/docs/reverse/trae-model-catalog.md +129 -0
  25. package/docs/reverse/traework-cn.md +520 -0
  26. package/docs/rules/STATE.md +198 -0
  27. package/docs/rules/content-moderation.md +54 -0
  28. package/docs/rules/dev-role-boundary.md +94 -0
  29. package/docs/rules/extra-providers.md +42 -0
  30. package/docs/rules/gateway-facts.md +91 -0
  31. package/docs/rules/oauth-handshake.md +76 -0
  32. package/docs/rules/prompt-cache.md +93 -0
  33. package/docs/rules/quota-signals.md +125 -0
  34. package/docs/rules/routing.md +84 -0
  35. package/docs/rules/templates/oauth-reverse-checklist.md +42 -0
  36. package/docs/rules/trae-surface.md +242 -0
  37. package/docs/rules/ua-validation.md +81 -0
  38. package/host-config.js +282 -0
  39. package/index.js +2306 -0
  40. package/lib/client.js +2893 -0
  41. package/local-scan.js +104 -0
  42. package/package.json +82 -0
  43. package/providers/ark/index.js +11 -0
  44. package/providers/bailian/index.js +10 -0
  45. package/providers/bigmodel/index.js +11 -0
  46. package/providers/codebuddy/agenttool.js +122 -0
  47. package/providers/codebuddy/catalog.js +227 -0
  48. package/providers/codebuddy/errors.js +44 -0
  49. package/providers/codebuddy/headers.js +36 -0
  50. package/providers/codebuddy/images.js +125 -0
  51. package/providers/codebuddy/index.js +123 -0
  52. package/providers/codebuddy/oauth.js +279 -0
  53. package/providers/deepseek/index.js +11 -0
  54. package/providers/moonshot/index.js +11 -0
  55. package/providers/openai-compat.js +177 -0
  56. package/providers/openrouter/index.js +27 -0
  57. package/providers/qoder/catalog.js +145 -0
  58. package/providers/qoder/cosy.js +419 -0
  59. package/providers/qoder/gateway.js +563 -0
  60. package/providers/qoder/index.js +116 -0
  61. package/providers/qoder/oauth.js +364 -0
  62. package/providers/qoder/qoder_auth.wasm +0 -0
  63. package/providers/qoder/quota.js +56 -0
  64. package/providers/qwen/index.js +15 -0
  65. package/providers/tool-pairing.js +129 -0
  66. package/providers/trae/catalog.js +103 -0
  67. package/providers/trae/errors.js +85 -0
  68. package/providers/trae/gateway.js +853 -0
  69. package/providers/trae/index.js +126 -0
  70. package/providers/trae/oauth.js +443 -0
  71. package/providers/trae/quota.js +75 -0
  72. package/providers/trae/remote.js +365 -0
  73. package/scripts/capture-cache.mjs +65 -0
  74. package/scripts/capture-traffic.mjs +83 -0
  75. package/scripts/hermes-probe-dev-role.mjs +263 -0
  76. package/scripts/measure-latency.mjs +253 -0
  77. package/scripts/probe-ark-thinking.mjs +298 -0
  78. package/scripts/probe-cache-decline.mjs +292 -0
  79. package/scripts/probe-cache-ttl.mjs +221 -0
  80. package/scripts/probe-cache.mjs +156 -0
  81. package/scripts/probe-codebuddy-efforts.mjs +355 -0
  82. package/scripts/probe-codebuddy-tier-wiring.mjs +244 -0
  83. package/scripts/probe-effort-gaps.mjs +133 -0
  84. package/scripts/probe-media.mjs +115 -0
  85. package/scripts/probe-moderation.mjs +159 -0
  86. package/scripts/probe-oauth.mjs +617 -0
  87. package/scripts/probe-qoder-attribution-arm8.mjs +92 -0
  88. package/scripts/probe-qoder-attribution-arm9.mjs +102 -0
  89. package/scripts/probe-qoder-attribution.mjs +266 -0
  90. package/scripts/probe-qoder-flash-confirm.mjs +94 -0
  91. package/scripts/probe-qoder-live.mjs +344 -0
  92. package/scripts/probe-qoder-matrix.mjs +274 -0
  93. package/scripts/probe-qoder-null-content.mjs +153 -0
  94. package/scripts/probe-qoder-pairing.mjs +308 -0
  95. package/scripts/probe-qoder-quota.mjs +162 -0
  96. package/scripts/probe-qoder-thinking-config.mjs +52 -0
  97. package/scripts/probe-qoder-thinking-efforts.mjs +269 -0
  98. package/scripts/probe-quota.mjs +136 -0
  99. package/scripts/probe-quota2.mjs +144 -0
  100. package/scripts/probe-routing.mjs +226 -0
  101. package/scripts/probe-trae-3003-diagnosis.mjs +139 -0
  102. package/scripts/probe-trae-agent-v3.mjs +399 -0
  103. package/scripts/probe-trae-efforts.mjs +149 -0
  104. package/scripts/probe-trae-live.mjs +147 -0
  105. package/scripts/probe-trae-max-effort.mjs +179 -0
  106. package/scripts/probe-trae-model-routing.mjs +381 -0
  107. package/scripts/probe-trae-thinking-scene.mjs +238 -0
  108. package/scripts/probe-trae-transport-outage.mjs +176 -0
  109. package/scripts/probe-ua.mjs +508 -0
  110. package/scripts/trae-model-catalog.mjs +632 -0
  111. package/scripts/verify-agents-md.mjs +45 -0
  112. package/scripts/verify-bridge.mjs +1041 -0
  113. package/scripts/verify-core-generic.mjs +302 -0
  114. package/scripts/verify-desktop-acceptance.mjs +184 -0
  115. package/scripts/verify-host-config.mjs +265 -0
  116. package/scripts/verify-models.mjs +390 -0
  117. package/scripts/verify-providers.mjs +255 -0
  118. package/scripts/verify-qoder-provider.mjs +1020 -0
  119. package/scripts/verify-rotation.mjs +308 -0
  120. package/scripts/verify-trae-model-catalog.mjs +284 -0
  121. package/scripts/verify-trae-provider.mjs +1451 -0
@@ -0,0 +1,1041 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Offline regression for the stream bridge (index.js): spins a mock gateway
4
+ * plus the real bridge on loopback and asserts end-to-end behavior. Every
5
+ * assertion waits for the response to FINISH (connection end), never just
6
+ * the first byte — a queued request that hangs looks exactly like a slow
7
+ * one until you demand completion.
8
+ *
9
+ * Covered:
10
+ * 1. non-streaming inbound (stream:false / absent) → aggregated
11
+ * chat.completion JSON (the gateway is stream-only, error 11101)
12
+ * 2. stream:true inbound → SSE passed through verbatim
13
+ * 3. session-attribution headers: injected from the incoming session id,
14
+ * preserved when the caller already set one, absent otherwise
15
+ * 4. per-session FIFO concurrency: excess requests queue and COMPLETE in
16
+ * waves (regression: release() once stranded the queue →永久挂起)
17
+ * 5. no session id → no gating, all requests run concurrently
18
+ * 6. non-chat paths (/agenttool/*) pass through untouched
19
+ * 7. CODEBUDDY_BRIDGE_LOG forensics (hash-only in/out records)
20
+ * 8. usage metering: every chat request's usage.credit is accumulated into
21
+ * codebuddy-plugin-usage.json and served via action:'usage'
22
+ * 9. developer-role messages are rewritten to system on the way out
23
+ * (regression: gateway moderation content_filter on developer role)
24
+ * 10. chunked multibyte integrity: a body split so chunk boundaries fall
25
+ * inside multibyte chars arrives byte-identical upstream
26
+ * (regression 踩坑 #28: per-chunk implicit utf8 decoding corrupted
27
+ * chars into 3×U+FFFD and drifted the outbound prefix every request)
28
+ * 11. bridge listen EADDRINUSE degrades to a warning, never crashes
29
+ * (regression: an unhandled 'error' event took the whole process down)
30
+ * 12. settings POST responses mask plaintext apiKeys (regression: four POST
31
+ * paths shipped the raw file layer — GET was masked in f9aeeaa, POST
32
+ * was not), and oauth-start rejects an upstream-poisoned authUrl
33
+ * before oauthPending activates (https + login-site-family gate;
34
+ * loopback mock pairs pass)
35
+ * 13. loopback gates + input guards (audit [6][7][8][18][22][29]):
36
+ * bridge Host gate 403s non-loopback Host before proxying (loopback
37
+ * names still pass), the settings route is Host/Origin-guarded on GET
38
+ * as well as POST, trae-model-sync dbPath is path/extension-checked,
39
+ * __proto__-family model ids are rejected, and plaintext http
40
+ * baseURLs outside loopback fail validation without persisting
41
+ * 14. G8 per-model reasoning_effort injection (effortByModel level → wire
42
+ * value; caller-set wins, off/table-less/unknown inject nothing)
43
+ * 15. cancellation & guardrails: mid-stream client disconnect aborts the
44
+ * upstream fetch; a queued waiter whose caller hung up never fires
45
+ * upstream and the aborted holder's slot is really released; bodies
46
+ * past 32MB get a real 413 JSON (was: silent req.destroy); aggregated
47
+ * non-streaming responses carry the captured usage (was: always {});
48
+ * a silent upstream fails fast with 502 + first-byte reason
49
+ * (upstreamFirstByteTimeoutMs, symmetric with the trae gateway)
50
+ * 16. OAuth state machine: logout() terminates an in-flight poll
51
+ * (generation guard — a post-logout authorization never signs back
52
+ * in; uninterrupted poll still completes); a failed refresh and an
53
+ * expired refreshExpiresAt both surface needsRelogin in oauthStatus
54
+ *
55
+ * Usage: node scripts/verify-bridge.mjs (no network, no credentials)
56
+ */
57
+
58
+ import { createServer } from 'node:http'
59
+ import { connect } from 'node:net'
60
+ import { EventEmitter } from 'node:events'
61
+ import { mkdtempSync, writeFileSync, readFileSync } from 'node:fs'
62
+ import { tmpdir } from 'node:os'
63
+ import { join, dirname } from 'node:path'
64
+ import { fileURLToPath } from 'node:url'
65
+
66
+ const ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
67
+ const UPSTREAM_LATENCY_MS = 250
68
+
69
+ process.env.DSH_HOME = mkdtempSync(join(tmpdir(), 'dsh-bridge-test-'))
70
+ process.env.CODEBUDDY_API_KEY = 'ck_bridge_test_key'
71
+ process.env.CODEBUDDY_BRIDGE_LOG = join(process.env.DSH_HOME, 'bridge-log.jsonl')
72
+
73
+ // ---------------------------------------------------------------- mock gateway
74
+
75
+ const arrivals = [] // {path, stream, headers, at} in upstream-arrival order
76
+ // [12] oauth-start 门禁用:auth/state 响应里的 authUrl(null → 回环默认值,
77
+ // 即通过门禁的正例;置为投毒值即负例)。
78
+ let oauthStateAuthUrl = null
79
+ // [15] 取消传播用:model 'hang' = 永远不出响应头(首字节超时/排队 waiter 用
80
+ // 例);model 'drip' = 发一块后沉默(流式中途断连用例)。两者记录桥何时
81
+ // 丢弃上游连接(res close 且 writableEnded=false = 对端中止,不是正常完成)。
82
+ const hangCloses = []
83
+ // [16] OAuth 状态机用:token 轮询在 pending(11217)与 success(发令牌)间
84
+ // 切换,refresh 在 ok 与 fail(401+12153,对齐 bogus refresh 实测)间切换。
85
+ let oauthTokenMode = 'pending'
86
+ let oauthRefreshMode = 'ok'
87
+ // [17] 目录思考强度声明:'off' = /v3/config 500(启动同步失败,静态兜底——
88
+ // 与前面各节的既有前提一致);'on' = 发目录 fixture(含新形态 supportedEfforts
89
+ // 与 legacy 形态各一条,用于锁定档位表的来源与优先级)。
90
+ let catalogMode = 'off'
91
+ const CATALOG_FIXTURE = {
92
+ models: [
93
+ {
94
+ id: 'glm-5.3-flash', name: 'GLM 5.3 Flash', maxInputTokens: 1000000, maxOutputTokens: 32000, supportsImages: true,
95
+ reasoning: { canDisableThinking: true, defaultEffort: 'high', summary: 'auto', supportedEfforts: ['low', 'high', 'max'] },
96
+ },
97
+ {
98
+ id: 'hy4-preview', name: 'Hunyuan Hy4 Preview', maxInputTokens: 1000000, maxOutputTokens: 64000, supportsImages: true,
99
+ reasoning: { canDisableThinking: false, defaultEffort: 'high', summary: 'auto', supportedEfforts: ['high'] },
100
+ },
101
+ {
102
+ id: 'mock-legacy', name: 'Mock Legacy Model', maxInputTokens: 200000, maxOutputTokens: 48000,
103
+ reasoning: { effort: 'medium', summary: 'auto' },
104
+ },
105
+ ],
106
+ agents: [{ name: 'cli', models: ['glm-5.3-flash', 'hy4-preview', 'mock-legacy'] }],
107
+ }
108
+ const upstream = createServer((req, res) => {
109
+ // [17] 目录端点(GET,无 body):开闸才发目录,否则 500 走静态兜底。
110
+ if (req.url.startsWith('/v3/config')) {
111
+ if (catalogMode !== 'on') {
112
+ res.writeHead(500, { 'Content-Type': 'application/json' })
113
+ res.end('{"code":1,"msg":"catalog unavailable"}')
114
+ return
115
+ }
116
+ res.writeHead(200, { 'Content-Type': 'application/json' })
117
+ res.end(JSON.stringify({ code: 0, data: CATALOG_FIXTURE }))
118
+ return
119
+ }
120
+ // OAuth 设备流第一步(安全审计回归锁 [12]):authUrl 可投毒。
121
+ if (req.url.startsWith('/v2/plugin/auth/state')) {
122
+ res.writeHead(200, { 'Content-Type': 'application/json' })
123
+ res.end(JSON.stringify({
124
+ code: 0,
125
+ data: {
126
+ state: 'st-verify',
127
+ authUrl: oauthStateAuthUrl ?? `http://127.0.0.1:${upstream.address()?.port}/authorize`,
128
+ },
129
+ }))
130
+ return
131
+ }
132
+ // [16] OAuth 设备流后续端点:refresh 必须先于 token 匹配(前缀包含关系)。
133
+ if (req.url.startsWith('/v2/plugin/auth/token/refresh')) {
134
+ res.writeHead(oauthRefreshMode === 'fail' ? 401 : 200, { 'Content-Type': 'application/json' })
135
+ res.end(JSON.stringify(oauthRefreshMode === 'fail'
136
+ ? { code: 12153, msg: 'invalid refresh token' }
137
+ : { code: 0, data: { accessToken: 'at-refreshed', expiresIn: 3600, refreshToken: 'rt-new', domain: '' } }))
138
+ return
139
+ }
140
+ if (req.url.startsWith('/v2/plugin/auth/token')) {
141
+ res.writeHead(200, { 'Content-Type': 'application/json' })
142
+ res.end(JSON.stringify(oauthTokenMode === 'success'
143
+ ? { code: 0, data: { accessToken: 'at-polled', expiresIn: 3600, refreshToken: 'rt-polled', refreshExpiresAt: 30 * 86400, domain: '' } }
144
+ : { code: 11217, msg: 'pending' }))
145
+ return
146
+ }
147
+ if (req.url.startsWith('/v2/plugin/login/account')) {
148
+ res.writeHead(200, { 'Content-Type': 'application/json' })
149
+ res.end(JSON.stringify({ code: 0, data: { uid: 'u-verify', nickname: 'verify-account' } }))
150
+ return
151
+ }
152
+ // Buffer 收集 + 一次解码:mock 自身不能带踩坑 #28 的缺陷,否则分片
153
+ // 用例的损坏源是 mock 而不是被测桥,断言就测不到真东西。
154
+ const chunks = []
155
+ req.on('data', (c) => chunks.push(c))
156
+ req.on('end', () => {
157
+ const raw = Buffer.concat(chunks).toString('utf8')
158
+ let parsed = null
159
+ try { parsed = JSON.parse(raw) } catch { /* non-JSON body */ }
160
+ arrivals.push({
161
+ path: req.url,
162
+ raw,
163
+ stream: parsed?.stream ?? null,
164
+ sessionId: req.headers['session_id'] ?? null,
165
+ clientRequestId: req.headers['x-client-request-id'] ?? null,
166
+ affinity: req.headers['x-session-affinity'] ?? null,
167
+ authorization: req.headers.authorization ?? null,
168
+ at: Date.now(),
169
+ })
170
+ setTimeout(() => {
171
+ if (req.url.endsWith('/chat/completions')) {
172
+ // [15] 挂起模式:bridge 侧中止/超时时连接被丢弃,res close 为证。
173
+ if (parsed?.model === 'hang' || parsed?.model === 'drip') {
174
+ res.on('close', () => { hangCloses.push({ model: parsed.model, at: Date.now(), writableEnded: res.writableEnded }) })
175
+ if (parsed.model === 'hang') return // 永远不出响应头
176
+ res.writeHead(200, { 'Content-Type': 'text/event-stream' })
177
+ res.write('data: {"id":"mock","choices":[{"index":0,"delta":{"content":"partial "}}]}\n\n')
178
+ return // 一块之后沉默
179
+ }
180
+ res.writeHead(200, { 'Content-Type': 'text/event-stream' })
181
+ res.end([
182
+ 'data: {"id":"mock","choices":[{"index":0,"delta":{"content":"hello "}}]}',
183
+ '',
184
+ 'data: {"id":"mock","choices":[{"index":0,"delta":{"content":"world"}}]}',
185
+ '',
186
+ 'data: {"id":"mock","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5,"prompt_cache_hit_tokens":0,"prompt_cache_miss_tokens":3,"credit":0.01}}',
187
+ '',
188
+ 'data: [DONE]',
189
+ '',
190
+ ].join('\n'))
191
+ } else {
192
+ res.writeHead(200, { 'Content-Type': 'application/json' })
193
+ res.end(JSON.stringify({ code: 0, results: [{ url: 'https://example.com' }] }))
194
+ }
195
+ }, UPSTREAM_LATENCY_MS)
196
+ })
197
+ })
198
+
199
+ // ---------------------------------------------------------------- test harness
200
+
201
+ let failures = 0
202
+ function check(label, cond, detail = '') {
203
+ if (cond) {
204
+ console.log(` ok ${label}`)
205
+ } else {
206
+ failures++
207
+ console.log(` FAIL ${label}${detail ? ` — ${detail}` : ''}`)
208
+ }
209
+ }
210
+
211
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
212
+
213
+ /** Ephemeral port: bind 0, read it, release. */
214
+ async function freePort() {
215
+ const probe = createServer()
216
+ await new Promise((r) => probe.listen(0, '127.0.0.1', r))
217
+ const { port } = probe.address()
218
+ await new Promise((r) => probe.close(r))
219
+ return port
220
+ }
221
+
222
+ async function main() {
223
+ const upstreamPort = await freePort()
224
+ await new Promise((r) => upstream.listen(upstreamPort, '127.0.0.1', r))
225
+ const bridgePort = await freePort()
226
+
227
+ const { apply } = await import(new URL('../index.js', import.meta.url).href)
228
+ // Route registrations are captured so the settings route can be driven
229
+ // in-process (the usage view is asserted through it, like the card does).
230
+ const routes = {}
231
+ const ctx = {
232
+ inject: (_deps, cb) => cb({ webServer: { register: (r) => { routes[r.path] = r.handler } } }),
233
+ web: { registerSearchProvider: () => () => {}, registerFetchProvider: () => () => {} },
234
+ on: () => {},
235
+ }
236
+ apply(ctx, {
237
+ baseURL: `http://127.0.0.1:${upstreamPort}`,
238
+ bridgePort,
239
+ maxConcurrentPerSession: 2,
240
+ })
241
+ await sleep(200) // let the bridge bind
242
+
243
+ const bridge = `http://127.0.0.1:${bridgePort}`
244
+ const setLimit = (n) =>
245
+ writeFileSync(
246
+ join(process.env.DSH_HOME, 'codebuddy-plugin.json'),
247
+ JSON.stringify({ maxConcurrentPerSession: n }) + '\n',
248
+ )
249
+ const chat = (body, headers = {}) =>
250
+ fetch(`${bridge}/v2/chat/completions`, {
251
+ method: 'POST',
252
+ headers: { 'Content-Type': 'application/json', ...headers },
253
+ body: JSON.stringify(body),
254
+ })
255
+ /** Race a promise against a hard timeout; resolves 'TIMEOUT' instead of rejecting. */
256
+ const withTimeout = (p, ms) => Promise.race([p, sleep(ms).then(() => 'TIMEOUT')])
257
+
258
+ /** Drive a captured settings-route handler with a mock req/res pair. */
259
+ const callRoute = (routesMap, body, headerOverrides = {}) => new Promise((resolve, reject) => {
260
+ const handler = routesMap['/dsh-tap/settings']
261
+ if (!handler) return reject(new Error('settings route not registered'))
262
+ const req = new EventEmitter()
263
+ req.method = body ? 'POST' : 'GET'
264
+ // sameOrigin() gate: origin host must match the Host header.
265
+ req.headers = { host: '127.0.0.1:3080', origin: 'http://127.0.0.1:3080', ...headerOverrides }
266
+ const res = {
267
+ status: 0,
268
+ writeHead(s) { this.status = s },
269
+ end(b) {
270
+ let json = null
271
+ try { json = JSON.parse(b) } catch { /* non-JSON */ }
272
+ resolve({ status: this.status, json })
273
+ },
274
+ }
275
+ handler(req, res)
276
+ if (body) {
277
+ // 真实 webServer 喂 Buffer 分片;设置路由已按踩坑 #28 改为
278
+ // Buffer.concat 后一次解码,emit 字符串会令其直接抛错。
279
+ const bytes = Buffer.from(JSON.stringify(body))
280
+ req.emit('data', bytes.subarray(0, 5))
281
+ req.emit('data', bytes.subarray(5))
282
+ req.emit('end')
283
+ }
284
+ })
285
+
286
+ // ---------------------------------------------------------- 1. aggregation
287
+ console.log('\n[1] non-streaming inbound → aggregated chat.completion JSON')
288
+ {
289
+ const before = arrivals.length
290
+ const res = await chat({ model: 'm1', stream: false, messages: [] })
291
+ const text = await res.text() // reading to END proves the connection closed
292
+ let json = null
293
+ try { json = JSON.parse(text) } catch { /* SSE is not JSON */ }
294
+ check('status 200', res.status === 200, `got ${res.status}`)
295
+ check('response is a chat.completion, not SSE', json?.object === 'chat.completion', text.slice(0, 80))
296
+ check('content aggregated from stream chunks', json?.choices?.[0]?.message?.content === 'hello world',
297
+ JSON.stringify(json?.choices?.[0]?.message))
298
+ check('finish_reason preserved', json?.choices?.[0]?.finish_reason === 'stop')
299
+ check('upstream was forced to stream', arrivals[before]?.stream === true)
300
+ }
301
+ {
302
+ const res = await chat({ model: 'm1', messages: [] }) // stream absent = OpenAI default false
303
+ const json = await res.json().catch(() => null)
304
+ check('stream absent also aggregates', json?.object === 'chat.completion')
305
+ }
306
+
307
+ // ---------------------------------------------------------- 2. SSE passthrough
308
+ console.log('\n[2] stream:true inbound → SSE passed through')
309
+ {
310
+ const res = await chat({ model: 'm1', stream: true, messages: [] })
311
+ const text = await res.text()
312
+ check('content-type is event-stream', (res.headers.get('content-type') ?? '').includes('text/event-stream'))
313
+ check('body is SSE with [DONE]', text.includes('data:') && text.includes('[DONE]'))
314
+ }
315
+
316
+ // ---------------------------------------------------------- 3. session headers
317
+ console.log('\n[3] session-attribution headers')
318
+ {
319
+ const before = arrivals.length
320
+ await (await chat({ model: 'm1', stream: true, messages: [] }, { 'X-Session-ID': 'sess-A' })).text()
321
+ const a = arrivals[before]
322
+ check('injected from X-Session-ID', a?.sessionId === 'sess-A' && a?.clientRequestId === 'sess-A' && a?.affinity === 'sess-A',
323
+ JSON.stringify({ s: a?.sessionId, c: a?.clientRequestId, a: a?.affinity }))
324
+ }
325
+ {
326
+ const before = arrivals.length
327
+ await (await chat({ model: 'm1', stream: true, messages: [] }, { 'X-Session-ID': 'sess-B', session_id: 'caller-set' })).text()
328
+ const a = arrivals[before]
329
+ check('caller-set value wins per header (no overwrite, no drop)', a?.sessionId === 'caller-set', `got ${a?.sessionId}`)
330
+ check('missing headers filled with the extracted id', a?.clientRequestId === 'sess-B' && a?.affinity === 'sess-B',
331
+ JSON.stringify({ c: a?.clientRequestId, a: a?.affinity }))
332
+ }
333
+ {
334
+ const before = arrivals.length
335
+ await (await chat({ model: 'm1', stream: true, messages: [] })).text()
336
+ const a = arrivals[before]
337
+ check('no session id → nothing injected', a?.sessionId === null && a?.clientRequestId === null)
338
+ check('host-side credential, caller auth never forwarded', a?.authorization === 'Bearer ck_bridge_test_key',
339
+ a?.authorization ?? 'none')
340
+ }
341
+
342
+ // ---------------------------------------------------------- 4. FIFO limiter
343
+ console.log('\n[4] per-session concurrency: excess queues and COMPLETES in waves')
344
+ {
345
+ setLimit(2)
346
+ arrivals.length = 0
347
+ const t0 = Date.now()
348
+ const mk = async (name) => {
349
+ const res = await chat({ model: 'm1', stream: true, messages: [] }, { 'X-Session-ID': 'sess-C' })
350
+ await res.text() // completion, not first byte
351
+ return { name, ms: Date.now() - t0 }
352
+ }
353
+ const results = await withTimeout(Promise.all([mk('R1'), mk('R2'), mk('R3'), mk('R4')]), 5000)
354
+ check('all four requests COMPLETE (no stranded queue)', results !== 'TIMEOUT')
355
+ if (results !== 'TIMEOUT') {
356
+ const wave1 = results.filter((r) => r.ms < UPSTREAM_LATENCY_MS * 1.8).length
357
+ const wave2 = results.filter((r) => r.ms >= UPSTREAM_LATENCY_MS * 1.8).length
358
+ check('two waves of two', wave1 === 2 && wave2 === 2, JSON.stringify(results))
359
+ check('upstream saw exactly four, all tagged sess-C',
360
+ arrivals.length === 4 && arrivals.every((a) => a.sessionId === 'sess-C'))
361
+ }
362
+ }
363
+ {
364
+ // The exact historical deadlock: limit=1, second request hung forever.
365
+ setLimit(1)
366
+ const t0 = Date.now()
367
+ const mk = async (name) => {
368
+ const res = await chat({ model: 'm1', stream: true, messages: [] }, { 'X-Session-ID': 'sess-D' })
369
+ await res.text()
370
+ return { name, ms: Date.now() - t0 }
371
+ }
372
+ const p1 = mk('R1')
373
+ await sleep(50)
374
+ const p2 = mk('R2')
375
+ const r2 = await withTimeout(p2, 4000)
376
+ check('limit=1: queued request completes after the first (deadlock regression)', r2 !== 'TIMEOUT')
377
+ if (r2 !== 'TIMEOUT') {
378
+ const r1 = await p1
379
+ check('limit=1: strictly serialized', r2.ms > r1.ms && r2.ms >= UPSTREAM_LATENCY_MS * 1.8,
380
+ JSON.stringify([r1, r2]))
381
+ }
382
+ setLimit(2)
383
+ }
384
+
385
+ // ---------------------------------------------------------- 5. no session id
386
+ console.log('\n[5] no session id → no gating')
387
+ {
388
+ arrivals.length = 0
389
+ const t0 = Date.now()
390
+ const mk = async () => {
391
+ const res = await chat({ model: 'm1', stream: true, messages: [] })
392
+ await res.text()
393
+ return Date.now() - t0
394
+ }
395
+ const times = await withTimeout(Promise.all([mk(), mk(), mk()]), 4000)
396
+ check('three anonymous requests all complete concurrently',
397
+ times !== 'TIMEOUT' && times.every((ms) => ms < UPSTREAM_LATENCY_MS * 1.8), JSON.stringify(times))
398
+ }
399
+
400
+ // ---------------------------------------------------------- 6. path passthrough
401
+ console.log('\n[6] non-chat paths pass through untouched')
402
+ {
403
+ const before = arrivals.length
404
+ const res = await fetch(`${bridge}/agenttool/v1/search`, {
405
+ method: 'POST',
406
+ headers: { 'Content-Type': 'application/json' },
407
+ body: JSON.stringify({ query: 'x' }),
408
+ })
409
+ const json = await res.json().catch(() => null)
410
+ const a = arrivals[before]
411
+ check('agenttool response verbatim', json?.code === 0 && json?.results?.[0]?.url === 'https://example.com')
412
+ check('no session headers on non-chat paths', a?.sessionId === null && a?.clientRequestId === null)
413
+ check('body bytes forwarded untouched', a?.raw === JSON.stringify({ query: 'x' }))
414
+ }
415
+
416
+ // ---------------------------------------------------------- 7. request forensics log
417
+ console.log('\n[7] CODEBUDDY_BRIDGE_LOG forensics')
418
+ {
419
+ // A title-shaped payload must be classified by its prompt marker.
420
+ const before = arrivals.length
421
+ await (await chat({
422
+ model: 'm1',
423
+ stream: true,
424
+ messages: [
425
+ { role: 'system', content: 'Create a concise title for an AI coding-assistant session from the supplied human messages.' },
426
+ { role: 'user', content: 'Generate the session title from this JSON array of human messages:\n[{"seq":1,"text":"hi"}]' },
427
+ ],
428
+ }, { authorization: 'Bearer dsh-codebuddy-bridge' })).text()
429
+ check('title-shaped request reached upstream', arrivals.length === before + 1)
430
+
431
+ const records = (await import('node:fs')).readFileSync(process.env.CODEBUDDY_BRIDGE_LOG, 'utf8')
432
+ .split('\n').filter(Boolean).map((l) => JSON.parse(l))
433
+ const ins = records.filter((r) => r.dir === 'in' && r.path === '/v2/chat/completions')
434
+ const outs = records.filter((r) => r.dir === 'out')
435
+ const inSeqs = new Set(records.filter((r) => r.dir === 'in').map((r) => r.seq))
436
+ check('every chat request logged in+out', ins.length > 0
437
+ && ins.every((r) => outs.some((o) => o.seq === r.seq))
438
+ && outs.every((o) => inSeqs.has(o.seq)),
439
+ `in=${ins.length} out=${outs.length}`)
440
+ check('in records carry body hash + shape, no message text',
441
+ ins.every((r) => r.chat?.bodySha && r.chat.msgsSha && !JSON.stringify(r).includes('hello world')))
442
+ check('usage captured from the SSE stream',
443
+ outs.some((r) => r.usage?.prompt_tokens === 3 && r.usage?.total_tokens === 5),
444
+ JSON.stringify(outs[0]?.usage))
445
+ const title = ins.find((r) => r.chat?.marker === 'session-title')
446
+ check('title marker classified', Boolean(title))
447
+ check('sentinel authorization classified, never logged verbatim',
448
+ title?.hdr?.authorization === 'sentinel')
449
+ check('session id logged with injected outbound headers',
450
+ ins.some((r) => r.sessionIn === 'sess-C')
451
+ && outs.some((r) => r.sessionOut?.session_id === 'sess-C'))
452
+ check('non-chat path logged with raw body hash',
453
+ records.some((r) => r.dir === 'in' && r.path === '/agenttool/v1/search' && r.bodySha))
454
+ }
455
+
456
+ // ---------------------------------------------------------- 8. usage metering
457
+ console.log('\n[8] usage metering → action:usage')
458
+ {
459
+ // Chat requests so far: 2 aggregated + 14 SSE = 16, mock credit 0.01 each
460
+ // (15 plain chat + 1 title from section 7).
461
+ const res = await callRoute(routes, { action: 'usage' })
462
+ check('usage action answers ok', res.status === 200 && res.json?.ok === true,
463
+ `HTTP ${res.status}`)
464
+ const u = res.json?.usage
465
+ check('every billed chat request metered', u?.totalRequests === 16, `got ${u?.totalRequests}`)
466
+ check('credit accumulated (16 × 0.01)', u?.totalCredit === 0.16, `got ${u?.totalCredit}`)
467
+ const now = new Date()
468
+ const day = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
469
+ check('today bucket matches', u?.today?.day === day && u?.today?.requests === 16)
470
+ check('recent rows newest-first, title kind classified',
471
+ u?.recent?.length === 16 && u.recent[0]?.kind === 'title' && u.recent[0]?.credit === 0.01
472
+ && u.recent[0]?.hit === 0 && u.recent[0]?.miss === 3,
473
+ JSON.stringify(u?.recent?.[0]))
474
+ check('turns gap-grouped into one row (all requests <45s apart)',
475
+ u?.turns?.length === 1 && u.turns[0]?.requests === 16
476
+ && u.turns[0]?.kinds?.includes('chat') && u.turns[0]?.kinds?.includes('title'),
477
+ JSON.stringify(u?.turns))
478
+ check('bridge state exposed in usage view',
479
+ res.json?.bridge?.running === true && res.json?.bridge?.port === bridgePort
480
+ && res.json?.bridge?.lastError === null)
481
+ check('quota snapshot present (mock upstream → nulls, no throw)',
482
+ typeof res.json?.quota?.fetchedAt === 'number')
483
+ // Debounced persistence: the file appears within a few seconds.
484
+ const { readFileSync, existsSync } = await import('node:fs')
485
+ const usagePath = join(process.env.DSH_HOME, 'codebuddy-plugin-usage.json')
486
+ let persisted = null
487
+ for (let i = 0; i < 16 && !persisted; i++) {
488
+ if (existsSync(usagePath)) {
489
+ try { persisted = JSON.parse(readFileSync(usagePath, 'utf8')) } catch { /* mid-write */ }
490
+ }
491
+ if (!persisted) await sleep(500)
492
+ }
493
+ check('usage.json persisted with totals + day bucket + recent rows',
494
+ persisted?.totalRequests === 16 && persisted?.days?.[day]?.requests === 16
495
+ && Array.isArray(persisted?.recent) && persisted.recent.length === 16)
496
+ }
497
+
498
+ // --------------------------------------------- 9. developer-role rewrite
499
+ // pi-ai serializes the system prompt as role "developer" for reasoning
500
+ // models; since 2026-08-18 the gateway's moderation answers such payloads
501
+ // with finish_reason=content_filter. The bridge rewrites developer→system.
502
+ console.log('\n[9] developer-role messages rewrite to system upstream')
503
+ {
504
+ const before = arrivals.length
505
+ const res = await chat({
506
+ model: 'm1',
507
+ stream: true,
508
+ messages: [
509
+ { role: 'developer', content: 'You are a test agent.' },
510
+ { role: 'user', content: 'ping' },
511
+ ],
512
+ }, { authorization: 'Bearer dsh-codebuddy-bridge' })
513
+ await res.text()
514
+ const a = arrivals[before]
515
+ const forwarded = JSON.parse(a?.raw ?? 'null')
516
+ check('request reached upstream', Boolean(a))
517
+ check('developer role rewritten to system',
518
+ forwarded?.messages?.[0]?.role === 'system'
519
+ && forwarded?.messages?.[0]?.content === 'You are a test agent.'
520
+ && forwarded?.messages?.every((m) => m.role !== 'developer'),
521
+ JSON.stringify(forwarded?.messages?.map((m) => m.role)))
522
+ }
523
+
524
+ // -------------------------------------- 10. chunked multibyte integrity
525
+ // 踩坑 #28 回归锁:TCP 分片落在多字节中文字符中间时,逐分片隐式 utf8
526
+ // 解码(旧 `rawBody += c`)会把它替换成 3×U+FFFD,出站前缀逐请求漂移,
527
+ // 网关内容寻址缓存只能命中到损坏点(v4-flash"缓存命中率下降快"根因,
528
+ // 证据链 docs/diagnosis-cache-decline.md)。fetch 无法控制分片边界,所以
529
+ // 用原生 socket 把同一请求体按几种切法各写一遍,断言 mock 网关收到的
530
+ // 字节与整块发送逐字节一致。
531
+ console.log('\n[10] chunked multibyte body arrives intact')
532
+ {
533
+ const filler = '前缀稳定。'.repeat(400) // 3-byte chars × 400
534
+ const body = JSON.stringify({
535
+ model: 'm1',
536
+ stream: true,
537
+ messages: [
538
+ { role: 'system', content: filler },
539
+ { role: 'user', content: `读取)这)些)中)文)括)号)并回答:${filler}` },
540
+ ],
541
+ })
542
+ const writeChunked = (chunks) =>
543
+ new Promise((resolve, reject) => {
544
+ const sock = connect(bridgePort, '127.0.0.1', () => {
545
+ sock.write('POST /v2/chat/completions HTTP/1.1\r\n')
546
+ sock.write(`Host: 127.0.0.1:${bridgePort}\r\n`)
547
+ sock.write('Content-Type: application/json\r\n')
548
+ sock.write(`Content-Length: ${Buffer.byteLength(body)}\r\n`)
549
+ sock.write('Connection: close\r\n')
550
+ sock.write('\r\n')
551
+ for (const c of chunks) sock.write(c)
552
+ })
553
+ // Consume the response: without a 'data' listener the socket stays
554
+ // paused, the FIN is never read, and 'close' never fires (the test
555
+ // itself then hangs — not the bridge).
556
+ sock.on('data', () => {})
557
+ sock.on('error', reject)
558
+ sock.on('close', () => resolve())
559
+ })
560
+ const bytes = Buffer.from(body)
561
+ // 切点 1/2/5 故意落在多字节序列中间;最后一段留大块保证走多分片。
562
+ const splitAt = [1, 2, 5, 1300, 7000, 12000]
563
+ const chunks = []
564
+ let prev = 0
565
+ for (const at of splitAt) {
566
+ chunks.push(bytes.subarray(prev, at))
567
+ prev = at
568
+ }
569
+ chunks.push(bytes.subarray(prev))
570
+ const before = arrivals.length
571
+ await writeChunked(chunks)
572
+ // 桥转发 + mock 网关 250ms 延迟后才会记录该请求
573
+ for (let i = 0; i < 40 && arrivals.length < before + 1; i++) await sleep(100)
574
+ const a = arrivals[before]
575
+ check('chunked request reached upstream', Boolean(a))
576
+ check('body bytes identical to whole-write (no U+FFFD, no drift)',
577
+ a?.raw === body && !a?.raw.includes('\uFFFD'),
578
+ `len ${a?.raw?.length} vs ${body.length}, FFFD count ${(a?.raw?.match(/\uFFFD/g) ?? []).length}`)
579
+ check('multibyte chars all intact', (a?.raw.match(/)/g) ?? []).length === (body.match(/)/g) ?? []).length)
580
+ }
581
+
582
+ // ---------------------------------------------------------- 10. EADDRINUSE
583
+ console.log('\n[11] bridge listen EADDRINUSE degrades, never crashes')
584
+ {
585
+ const squatter = createServer()
586
+ await new Promise((r) => squatter.listen(0, '127.0.0.1', r))
587
+ const occupiedPort = squatter.address().port
588
+ // A second apply() on the occupied port stands in for the second dsh
589
+ // process (bridgeRuntime is per-process module state; production runs one
590
+ // plugin instance per process, so last-apply-wins is correct here).
591
+ const routes2 = {}
592
+ const ctx2 = {
593
+ inject: (_deps, cb) => cb({ webServer: { register: (r) => { routes2[r.path] = r.handler } } }),
594
+ web: { registerSearchProvider: () => () => {}, registerFetchProvider: () => () => {} },
595
+ on: () => {},
596
+ }
597
+ apply(ctx2, { baseURL: `http://127.0.0.1:${upstreamPort}`, bridgePort: occupiedPort })
598
+ await sleep(300)
599
+ // Reaching this line at all is half the regression: the process survived.
600
+ const res = await callRoute(routes2, { action: 'usage' })
601
+ check('occupied port: process alive, route still answers', res.status === 200 && res.json?.ok === true)
602
+ check('bridge state reports EADDRINUSE and not running',
603
+ res.json?.bridge?.running === false && res.json?.bridge?.lastError === 'EADDRINUSE'
604
+ && res.json?.bridge?.port === occupiedPort,
605
+ JSON.stringify(res.json?.bridge))
606
+ const stillUp = await withTimeout(chat({ model: 'm1', stream: true, messages: [] }).then((r) => r.text()), 3000)
607
+ check('first bridge keeps serving afterwards', stillUp !== 'TIMEOUT' && stillUp.includes('[DONE]'))
608
+ await new Promise((r) => squatter.close(r))
609
+ }
610
+
611
+ // ------------------------------------------- 12. POST masking + authUrl gate
612
+ // 安全审计回归锁(两补丁):
613
+ // a) index.js maskedUserLayer——四个 POST 响应(apiKeysAdd 走通用 patch、
614
+ // modelSetEnabled / modelSetLimits 走层叠重读;traeModelSetEnabled 同型,
615
+ // 无 Trae 目录时不可驱动,形状与另两条层叠路径逐字相同)曾原样回传
616
+ // 文件层——明文 apiKey 下发浏览器(GET 视图 f9aeeaa 已脱敏,POST 漏网)。
617
+ // b) providers/codebuddy/oauth.js assertSafeAuthUrl——上游投毒 authUrl
618
+ // (钓鱼域 / javascript: 串)在置位 oauthPending 之前响亮失败,
619
+ // oauthStatus 不外泄该 URL,组合根 oauth-start 回 502。
620
+ console.log('\n[12] settings POST responses mask keys; oauth-start gates authUrl')
621
+ {
622
+ const PLAIN = 'ck_secret_plaintext_2f9b5678'
623
+ const MASKED = 'ck_s…5678' // maskKey:首 4 … 尾 4
624
+
625
+ // 12a. 通用 patch 路径(apiKeysAdd)
626
+ let res = await callRoute(routes, { patch: { apiKeysAdd: { name: 'verify-mask', key: PLAIN } } })
627
+ const added = Array.isArray(res.json?.user?.apiKeys)
628
+ && res.json.user.apiKeys.find((k) => k?.name === 'verify-mask')
629
+ check('apiKeysAdd response ships masked key only',
630
+ res.json?.ok === true && added?.key === MASKED, JSON.stringify(added))
631
+ check('plaintext key absent from entire POST response', !JSON.stringify(res.json).includes(PLAIN))
632
+
633
+ // 12b. modelSetEnabled 层叠路径
634
+ res = await callRoute(routes, { patch: { modelSetEnabled: { id: 'deepseek-v3', enabled: false } } })
635
+ check('modelSetEnabled response ships masked key only',
636
+ res.json?.ok === true
637
+ && res.json?.user?.apiKeys?.some((k) => k?.name === 'verify-mask' && k?.key === MASKED)
638
+ && !JSON.stringify(res.json).includes(PLAIN),
639
+ JSON.stringify(res.json?.user?.apiKeys))
640
+
641
+ // 12c. modelSetLimits 层叠路径
642
+ res = await callRoute(routes, { patch: { modelSetLimits: { id: 'deepseek-v3', contextWindow: 65536 } } })
643
+ check('modelSetLimits response ships masked key only',
644
+ res.json?.ok === true
645
+ && res.json?.user?.apiKeys?.some((k) => k?.name === 'verify-mask' && k?.key === MASKED)
646
+ && !JSON.stringify(res.json).includes(PLAIN))
647
+
648
+ // 12d. oauth-start:上游投毒 authUrl(https 但钓鱼域)→ 502,pending 不激活
649
+ oauthStateAuthUrl = 'https://evil.example.com/authorize'
650
+ res = await callRoute(routes, { action: 'oauth-start' })
651
+ check('poisoned authUrl (foreign host) rejected with 502 + reason',
652
+ res.status === 502 && res.json?.ok === false
653
+ && /evil\.example\.com/.test(res.json?.error ?? ''),
654
+ `HTTP ${res.status} ${JSON.stringify(res.json)}`)
655
+ res = await callRoute(routes, { action: 'oauth-status' })
656
+ check('rejected start leaves no pending authUrl leak',
657
+ res.json?.oauth?.pending === false && res.json?.oauth?.authUrl === ''
658
+ && !JSON.stringify(res.json).includes('evil.example.com'))
659
+
660
+ // 12e. oauth-start:javascript: 串(scheme 门)
661
+ oauthStateAuthUrl = 'javascript:alert(document.domain)'
662
+ res = await callRoute(routes, { action: 'oauth-start' })
663
+ check('javascript: authUrl rejected (scheme gate)',
664
+ res.status === 502 && /https/.test(res.json?.error ?? ''),
665
+ `HTTP ${res.status} ${JSON.stringify(res.json)}`)
666
+
667
+ // 12f. 正例:回环对(mock baseURL 与 authUrl 同为 127.0.0.1)放行
668
+ oauthStateAuthUrl = null
669
+ res = await callRoute(routes, { action: 'oauth-start' })
670
+ check('loopback pair passes the gate (200 + authUrl)',
671
+ res.status === 200 && res.json?.ok === true
672
+ && /^http:\/\/127\.0\.0\.1:\d+\/authorize$/.test(res.json?.authUrl ?? ''),
673
+ `HTTP ${res.status} ${res.json?.authUrl}`)
674
+ }
675
+
676
+ // ---------------------------------------------- 13. loopback gates + guards
677
+ // 安全审计回归锁([6][7][8][18][22][29]):
678
+ // a) core/bridge.js Host 门——桥携带活凭据,非回环 Host(DNS rebinding/
679
+ // 伪造)在读 body/转发之前 403;回环名(localhost)照常代理。
680
+ // b) index.js settings 路由本地门——GET([7]:此前完全裸奔)与 POST 都
681
+ // 先过回环 Host + Origin 一致判定。
682
+ // c) trae-model-sync dbPath 纵深防御、modelSetEnabled 原型键黑名单、
683
+ // validateBaseURL 明文 http 仅限回环(拒绝不落盘)。
684
+ console.log('\n[13] loopback Host gates + input guards')
685
+ {
686
+ // 13a. bridge:伪造 Host 拒绝(fetch 不允许自定义 Host 头,走原生 socket)
687
+ const rawRequest = (hostLine) => new Promise((resolve, reject) => {
688
+ const sock = connect(bridgePort, '127.0.0.1', () => {
689
+ sock.write(`GET /v2/models HTTP/1.1\r\n${hostLine}Connection: close\r\n\r\n`)
690
+ })
691
+ let head = ''
692
+ sock.on('data', (d) => { head += d.toString('utf8') })
693
+ sock.on('error', reject)
694
+ sock.on('close', () => resolve(head))
695
+ })
696
+ const arrivalsBefore = arrivals.length
697
+ const evil = await rawRequest('Host: evil.example.com\r\n')
698
+ check('bridge refuses non-loopback Host with 403',
699
+ /HTTP\/1\.1 403/.test(evil), evil.split('\r\n')[0] || '(no response)')
700
+ check('refused request never proxied upstream', arrivals.length === arrivalsBefore,
701
+ `arrivals ${arrivalsBefore} → ${arrivals.length}`)
702
+ const ok = await rawRequest(`Host: localhost:${bridgePort}\r\n`)
703
+ check('loopback Host (localhost) still proxied',
704
+ /HTTP\/1\.1 200/.test(ok), ok.split('\r\n')[0] || '(no response)')
705
+
706
+ // 13b. settings 路由:GET 与 POST 一体设防
707
+ let res = await callRoute(routes, null, { host: '192.168.1.5:3080' })
708
+ check('settings GET with LAN Host → 403', res.status === 403, `HTTP ${res.status}`)
709
+ res = await callRoute(routes, null, { host: 'attacker.example:3080' })
710
+ check('settings GET with rebinding Host → 403', res.status === 403, `HTTP ${res.status}`)
711
+ res = await callRoute(routes, null, { origin: 'http://evil.example:3080' })
712
+ check('settings with cross-site Origin → 403', res.status === 403, `HTTP ${res.status}`)
713
+ res = await callRoute(routes, null)
714
+ check('loopback GET still answers 200', res.status === 200 && res.json?.value != null,
715
+ `HTTP ${res.status}`)
716
+
717
+ // 13c. 输入门:dbPath 路径防御
718
+ res = await callRoute(routes, { action: 'trae-model-sync', dbPath: '../../etc/passwd' })
719
+ check('trae-model-sync relative dbPath → 400 + reason',
720
+ res.status === 400 && /dbPath/.test(res.json?.error ?? ''), JSON.stringify(res.json))
721
+ res = await callRoute(routes, { action: 'trae-model-sync', dbPath: '/tmp/secret.txt' })
722
+ check('trae-model-sync non-.db/.vscdb extension → 400', res.status === 400, `HTTP ${res.status}`)
723
+ res = await callRoute(routes, { action: 'trae-model-sync', dbPath: '/tmp/no-such-state.vscdb' })
724
+ check('valid-shaped dbPath falls through to the normal sync error path',
725
+ res.status === 200 && res.json?.ok === false, `HTTP ${res.status} ok=${res.json?.ok}`)
726
+
727
+ // 13c. 原型污染键与明文 http baseURL
728
+ res = await callRoute(routes, { patch: { modelSetEnabled: { id: '__proto__', enabled: true, profile: {} } } })
729
+ check('modelSetEnabled rejects __proto__ id → 400', res.status === 400, `HTTP ${res.status}`)
730
+ res = await callRoute(routes, { patch: { modelSetEnabled: { id: 'x.constructor', enabled: true, profile: {} } } })
731
+ check('modelSetEnabled rejects prototype-segment id → 400', res.status === 400, `HTTP ${res.status}`)
732
+ res = await callRoute(routes, { patch: { baseURL: 'http://attacker.example/v2' } })
733
+ check('plaintext http to non-loopback baseURL → 400 (validateBaseURL)',
734
+ res.status === 400 && /回环/.test(res.json?.error ?? ''), JSON.stringify(res.json))
735
+ res = await callRoute(routes, null)
736
+ check('rejected patches persisted nothing (baseURL unchanged)',
737
+ res.json?.value?.baseURL === `http://127.0.0.1:${upstreamPort}`, res.json?.value?.baseURL)
738
+ }
739
+
740
+ // ------------------------------------------------- 14. G8 思考强度注入
741
+ // 文件层 effortByModel 只存档位名;桥出站按静态清单 reasoningEfforts 表
742
+ // 注入线值——调用方显式携带不覆盖、off(线值 null)不注入、无表模型与
743
+ // 非法档位不注入。
744
+ console.log('\n[14] G8 per-model reasoning_effort injection')
745
+ {
746
+ const layerPath = join(process.env.DSH_HOME, 'codebuddy-plugin.json')
747
+ const writeLayer = (obj) => writeFileSync(layerPath, JSON.stringify(obj) + '\n')
748
+
749
+ writeLayer({ effortByModel: { 'deepseek-v4-pro': 'high' } })
750
+ let before = arrivals.length
751
+ await chat({ model: 'deepseek-v4-pro', stream: false, messages: [] })
752
+ check('effortByModel high → reasoning_effort injected upstream',
753
+ arrivals.slice(before).some((a) => a.raw.includes('"reasoning_effort":"high"')))
754
+
755
+ before = arrivals.length
756
+ await chat({ model: 'deepseek-v4-pro', stream: false, reasoning_effort: 'low', messages: [] })
757
+ check('caller-set reasoning_effort never overridden',
758
+ arrivals.slice(before).some((a) => a.raw.includes('"reasoning_effort":"low"'))
759
+ && !arrivals.slice(before).some((a) => a.raw.includes('"reasoning_effort":"high"')))
760
+
761
+ writeLayer({ effortByModel: { 'deepseek-v4-pro': 'off' } })
762
+ before = arrivals.length
763
+ await chat({ model: 'deepseek-v4-pro', stream: false, messages: [] })
764
+ check('off (wire null) injects nothing',
765
+ !arrivals.slice(before).some((a) => a.raw.includes('reasoning_effort')))
766
+
767
+ writeLayer({ effortByModel: { 'deepseek-v3': 'high', 'deepseek-v4-pro': 'bogus' } })
768
+ before = arrivals.length
769
+ await chat({ model: 'deepseek-v3', stream: false, messages: [] })
770
+ await chat({ model: 'deepseek-v4-pro', stream: false, messages: [] })
771
+ check('table-less model and unknown level both inject nothing',
772
+ !arrivals.slice(before).some((a) => a.raw.includes('reasoning_effort')))
773
+
774
+ writeLayer({})
775
+ }
776
+
777
+ // ------------------------- 15. 取消传播 / 413 / 聚合 usage / 首字节超时
778
+ // a) 流式中途客户端断连 → 桥中止上游 fetch(mock 'drip':一块后沉默,
779
+ // 上游连接被丢弃以 res close 且 writableEnded=false 为证)。
780
+ // b) 排队 waiter 断连后不被唤醒发上游;占用者中止后槽位真正归还
781
+ // (新请求能完成——旧实现槽位随挂死请求永久泄漏)。
782
+ // c) 请求体超 32MB → 413 JSON(旧行为 req.destroy():客户端只见连接
783
+ // 被重置,拿不到任何状态码)。
784
+ // d) 非流式聚合回传流里抓到的 usage(旧行为恒 {})。
785
+ // e) 上游收下不出头 → 首字节超时快速 502(与 trae 侧护栏对称)。
786
+ console.log('\n[15] cancellation propagation / 413 / aggregated usage / first-byte timeout')
787
+ {
788
+ // 15a. 流式中途断连
789
+ const hangBefore = hangCloses.length
790
+ const ac1 = new AbortController()
791
+ const res1 = await fetch(`${bridge}/v2/chat/completions`, {
792
+ method: 'POST',
793
+ headers: { 'Content-Type': 'application/json' },
794
+ body: JSON.stringify({ model: 'drip', stream: true, messages: [] }),
795
+ signal: ac1.signal,
796
+ })
797
+ const reader1 = res1.body.getReader()
798
+ await withTimeout(reader1.read(), 3000) // 第一块到达 = 流已建立
799
+ ac1.abort()
800
+ await reader1.read().catch(() => {}) // 客户端读取侧收尾
801
+ let abortedUpstream = false
802
+ for (let i = 0; i < 30 && !abortedUpstream; i++) {
803
+ await sleep(100)
804
+ abortedUpstream = hangCloses.slice(hangBefore).some((c) => c.model === 'drip' && c.writableEnded === false)
805
+ }
806
+ check('mid-stream client disconnect aborts the upstream fetch', abortedUpstream,
807
+ JSON.stringify(hangCloses.slice(hangBefore)))
808
+
809
+ // 15b. 排队 waiter 不泄漏
810
+ setLimit(1)
811
+ arrivals.length = 0
812
+ const sessQ = { 'X-Session-ID': 'sess-Q' }
813
+ const ac2 = new AbortController() // R1:'hang' 占住唯一槽位
814
+ const r1p = fetch(`${bridge}/v2/chat/completions`, {
815
+ method: 'POST',
816
+ headers: { 'Content-Type': 'application/json', ...sessQ },
817
+ body: JSON.stringify({ model: 'hang', stream: true, messages: [] }),
818
+ signal: ac2.signal,
819
+ }).then((r) => r.text()).catch(() => 'aborted')
820
+ for (let i = 0; i < 40 && !arrivals.some((a) => a.sessionId === 'sess-Q'); i++) await sleep(50)
821
+ check('holder request reached upstream (the one slot is occupied)',
822
+ arrivals.filter((a) => a.sessionId === 'sess-Q').length === 1, JSON.stringify(arrivals.length))
823
+ const ac3 = new AbortController() // R2:排队后断连
824
+ const r2p = fetch(`${bridge}/v2/chat/completions`, {
825
+ method: 'POST',
826
+ headers: { 'Content-Type': 'application/json', ...sessQ },
827
+ body: JSON.stringify({ model: 'm1', stream: true, messages: [] }),
828
+ signal: ac3.signal,
829
+ }).then((r) => r.text()).catch(() => 'aborted')
830
+ await sleep(150) // 足够进队列
831
+ ac3.abort()
832
+ await r2p
833
+ await sleep(400) // 若被错误唤醒,早已发到上游
834
+ check('queued waiter never fires upstream after its caller disconnects',
835
+ arrivals.filter((a) => a.sessionId === 'sess-Q').length === 1,
836
+ JSON.stringify(arrivals.map((a) => a.sessionId)))
837
+ ac2.abort() // R1 断连 → 槽位归还
838
+ await r1p
839
+ const r3 = await withTimeout(chat({ model: 'm1', stream: true, messages: [] }, sessQ).then((r) => r.text()), 4000)
840
+ check('limiter slot released after the aborted holder (fresh same-session request completes)',
841
+ r3 !== 'TIMEOUT')
842
+ setLimit(2)
843
+
844
+ // 15c. 超 32MB → 413(fetch/undici 对“边发边收早回”的容忍度不一,走裸 socket)
845
+ const bigLen = 33 * 1024 * 1024
846
+ const head413 = await new Promise((resolve, reject) => {
847
+ const sock = connect(bridgePort, '127.0.0.1', () => {
848
+ sock.write(`POST /v2/chat/completions HTTP/1.1\r\nHost: 127.0.0.1:${bridgePort}\r\nContent-Type: application/json\r\nContent-Length: ${bigLen}\r\nConnection: close\r\n\r\n`)
849
+ sock.write(Buffer.alloc(bigLen, 120)) // 'x' × 33MB
850
+ })
851
+ let out = ''
852
+ sock.on('data', (d) => { out += d.toString('utf8') })
853
+ // 413 在收满 32MB 即回;剩余字节撞在关闭中的 socket 上不算失败。
854
+ sock.on('error', (err) => { out ? resolve(out) : reject(err) })
855
+ sock.on('close', () => resolve(out))
856
+ })
857
+ check('body past 32MB gets a real 413 JSON (was: silent req.destroy)',
858
+ /HTTP\/1\.1 413/.test(head413) && /too large/.test(head413), head413.slice(0, 140))
859
+
860
+ // 15d. 聚合 usage 回传
861
+ const resU = await chat({ model: 'm1', stream: false, messages: [] })
862
+ const jsonU = await resU.json().catch(() => null)
863
+ check('aggregated chat.completion carries the captured usage (was: always {})',
864
+ jsonU?.usage?.total_tokens === 5 && jsonU?.usage?.credit === 0.01, JSON.stringify(jsonU?.usage))
865
+
866
+ // 15e. 首字节超时(层文件压到 schema 下限 1000ms;'hang' 永远不出头)
867
+ writeFileSync(join(process.env.DSH_HOME, 'codebuddy-plugin.json'),
868
+ JSON.stringify({ maxConcurrentPerSession: 2, upstreamFirstByteTimeoutMs: 1000 }) + '\n')
869
+ const t0 = Date.now()
870
+ const resT = await chat({ model: 'hang', stream: true, messages: [] })
871
+ const msT = Date.now() - t0
872
+ const jsonT = await resT.json().catch(() => null)
873
+ check('silent upstream fails fast with 502 + first-byte reason (default window is 45s)',
874
+ resT.status === 502 && /first-byte timeout/.test(jsonT?.error?.message ?? '') && msT < 5000,
875
+ `HTTP ${resT.status} in ${msT}ms: ${JSON.stringify(jsonT)}`)
876
+ setLimit(2) // 恢复层文件(顺带清掉临时超时档)
877
+ }
878
+
879
+ // --------------------------------------------- 16. OAuth 状态机回归
880
+ // a) logout() 终止进行中的 poll(代际失效):logout 后浏览器才完成的授权
881
+ // 不得写回令牌库(旧实现 while 只看 deadline——logout 后 10 分钟内
882
+ // 授权完成会被重新登进)。附正例对照:未打断的 poll 正常登录。
883
+ // b) refresh 实败(/token/refresh 401)→ oauthStatus 暴露 needsRelogin
884
+ // (旧实现 signedIn 只看 accessToken 在不在:UI 显示已登录、聊天
885
+ // 全 503 而无从察觉)。
886
+ // c) refreshExpiresAt 已过期(此前存了从不检查的字段)→ 无需 refresh
887
+ // 尝试即暴露 needsRelogin。
888
+ console.log('\n[16] OAuth state machine: logout kills the poll; refresh failure surfaces needsRelogin')
889
+ {
890
+ const authFile = join(process.env.DSH_HOME, 'codebuddy-plugin-auth.json')
891
+ const readAuthFile = () => { try { return JSON.parse(readFileSync(authFile, 'utf8')) } catch { return {} } }
892
+
893
+ // 16a. logout 终止 poll
894
+ let res = await callRoute(routes, { action: 'oauth-start' })
895
+ check('oauth-start activates pending', res.status === 200 && res.json?.ok === true)
896
+ res = await callRoute(routes, { action: 'oauth-status' })
897
+ check('pending after start', res.json?.oauth?.pending === true, JSON.stringify(res.json?.oauth))
898
+ res = await callRoute(routes, { action: 'oauth-logout' })
899
+ check('logout clears pending immediately', res.json?.oauth?.pending === false)
900
+ oauthTokenMode = 'success' // 浏览器在 logout 之后才完成授权
901
+ await sleep(2600) // >2 个轮询周期:poll 若没死早已写入令牌
902
+ res = await callRoute(routes, { action: 'oauth-status' })
903
+ check('authorization landing after logout never signs back in',
904
+ res.json?.oauth?.signedIn === false && !readAuthFile()?.auth?.accessToken,
905
+ JSON.stringify({ oauth: res.json?.oauth, file: readAuthFile() }))
906
+
907
+ // 正例对照:未被 logout 打断的 poll 正常完成登录
908
+ res = await callRoute(routes, { action: 'oauth-start' })
909
+ check('restart after logout works (pending again)', res.status === 200 && res.json?.ok === true)
910
+ let signed = false
911
+ for (let i = 0; i < 40 && !signed; i++) { await sleep(200); signed = Boolean(readAuthFile()?.auth?.accessToken) }
912
+ check('uninterrupted poll completes login (control)', signed)
913
+ res = await callRoute(routes, { action: 'oauth-status' })
914
+ check('control login visible in status, needsRelogin false',
915
+ res.json?.oauth?.signedIn === true && res.json?.oauth?.needsRelogin === false, JSON.stringify(res.json?.oauth))
916
+ res = await callRoute(routes, { action: 'oauth-logout' })
917
+ oauthTokenMode = 'pending'
918
+ check('control cleanup: signed out again', res.json?.oauth?.signedIn === false)
919
+
920
+ // 16b. refresh 实败 → needsRelogin
921
+ await callRoute(routes, { patch: { authMode: 'oauth' } })
922
+ writeFileSync(authFile, JSON.stringify({
923
+ auth: { accessToken: 'at-expired', expiresAt: Date.now() - 1000, refreshToken: 'rt-bogus', refreshExpiresAt: Date.now() + 86400_000, domain: '' },
924
+ account: { uid: 'u-verify', nickname: 'verify' },
925
+ }) + '\n')
926
+ oauthRefreshMode = 'fail'
927
+ res = await callRoute(routes, { action: 'oauth-status' })
928
+ check('before any refresh attempt: signedIn true, needsRelogin false',
929
+ res.json?.oauth?.signedIn === true && res.json?.oauth?.needsRelogin === false, JSON.stringify(res.json?.oauth))
930
+ const chatRes = await chat({ model: 'm1', stream: true, messages: [] })
931
+ await chatRes.text()
932
+ check('chat with an unrefreshable token → 503 credential unavailable', chatRes.status === 503, `got ${chatRes.status}`)
933
+ res = await callRoute(routes, { action: 'oauth-status' })
934
+ check('refresh failure exposed as needsRelogin (signedIn stays literally true)',
935
+ res.json?.oauth?.needsRelogin === true && res.json?.oauth?.signedIn === true, JSON.stringify(res.json?.oauth))
936
+
937
+ // 16c. refreshExpiresAt 过期(先 logout 清掉 16b 的粘性信号以隔离路径)
938
+ await callRoute(routes, { action: 'oauth-logout' })
939
+ writeFileSync(authFile, JSON.stringify({
940
+ auth: { accessToken: 'at-live', expiresAt: Date.now() + 3600_000, refreshToken: 'rt', refreshExpiresAt: Date.now() - 1000, domain: '' },
941
+ }) + '\n')
942
+ res = await callRoute(routes, { action: 'oauth-status' })
943
+ check('expired refreshExpiresAt alone surfaces needsRelogin (no attempt needed)',
944
+ res.json?.oauth?.needsRelogin === true && res.json?.oauth?.signedIn === true, JSON.stringify(res.json?.oauth))
945
+
946
+ // 收尾:登出并复原模式(本测试自建的层/令牌不向后传染)
947
+ await callRoute(routes, { action: 'oauth-logout' })
948
+ await callRoute(routes, { patch: { authMode: null } })
949
+ oauthRefreshMode = 'ok'
950
+ res = await callRoute(routes, { action: 'oauth-status' })
951
+ check('cleanup: signed out, mode restored, signal cleared',
952
+ res.json?.oauth?.signedIn === false && res.json?.oauth?.needsRelogin === false, JSON.stringify(res.json?.oauth))
953
+ }
954
+
955
+ // ------------------------------------- 17. 目录声明的思考档位(2026-09-22)
956
+ // 网关 /v3/config 自 2026-09 起对新模型发能力清单
957
+ // (supportedEfforts/canDisableThinking/defaultEffort)。契约:
958
+ // a) 档位表 = 目录声明优先、静态 reasoningEfforts 兜底(legacy 形态不出表);
959
+ // b) 档位表进 settings.yaml 镜像的 models 条目(宿主 Effort 选择器据此出档);
960
+ // c) 桥出站按同一张表注入;未声明档位/无表模型不注入;
961
+ // d) 没有实测"关思考"拼写 → 不出 off(canDisableThinking 也不凭空造线值)。
962
+ console.log('\n[17] catalog-declared reasoning tiers → model-list / mirror / bridge injection')
963
+ {
964
+ const layerPath = join(process.env.DSH_HOME, 'codebuddy-plugin.json')
965
+ const writeLayer = (obj) => writeFileSync(layerPath, JSON.stringify(obj) + '\n')
966
+
967
+ let res = await callRoute(routes, { action: 'model-list' })
968
+ check('before sync (catalog unreachable): model-list reports the failure, publishes no tiers',
969
+ res.status === 502 && res.json?.efforts === undefined, `${res.status} ${JSON.stringify(res.json?.error)}`)
970
+
971
+ catalogMode = 'on'
972
+ res = await callRoute(routes, { action: 'model-sync' })
973
+ check('model-sync pulls the catalog (3 models)', res.json?.sync?.ok === true && res.json?.sync?.count === 3,
974
+ JSON.stringify(res.json?.sync))
975
+
976
+ res = await callRoute(routes, { action: 'model-list' })
977
+ const listed = res.json
978
+ const flash = (listed?.catalog?.models ?? []).find((m) => m.id === 'glm-5.3-flash')
979
+ check('catalog entry carries the effort declaration verbatim',
980
+ JSON.stringify(flash?.supportedEfforts) === '["low","high","max"]'
981
+ && flash?.canDisableThinking === true && flash?.defaultEffort === 'high' && flash?.reasoningEffort === 'high',
982
+ JSON.stringify(flash))
983
+ check('declared tiers → efforts map (low/high/max, no off without a proven wire)',
984
+ JSON.stringify(listed?.efforts?.['glm-5.3-flash']) === '["low","high","max"]',
985
+ JSON.stringify(listed?.efforts?.['glm-5.3-flash']))
986
+ check('single-tier declaration survives (hy4-preview → ["high"])',
987
+ JSON.stringify(listed?.efforts?.['hy4-preview']) === '["high"]',
988
+ JSON.stringify(listed?.efforts?.['hy4-preview']))
989
+ check('legacy declaration (no supportedEfforts) yields no tier row',
990
+ listed?.efforts?.['mock-legacy'] === undefined, JSON.stringify(listed?.efforts?.['mock-legacy']))
991
+ check('static-only rows keep their patch table (auto stays table-less)',
992
+ listed?.efforts?.['auto'] === undefined && Array.isArray(listed?.efforts?.['hy3-preview']))
993
+ check('static table still governs models the catalog does not declare (deepseek-v4-pro)',
994
+ JSON.stringify(listed?.efforts?.['deepseek-v4-pro']) === '["low","medium","high","max"]',
995
+ JSON.stringify(listed?.efforts?.['deepseek-v4-pro']))
996
+
997
+ // 宿主 Effort 选择器的数据源 = settings.yaml 镜像里的 reasoningEfforts。
998
+ const mirror = readFileSync(join(process.env.DSH_HOME, 'settings.yaml'), 'utf8')
999
+ check('mirror carries reasoningEfforts for the catalog model (host Effort picker)',
1000
+ /glm-5\.3-flash[\s\S]{0,400}?reasoningEfforts/.test(mirror)
1001
+ && /reasoningEfforts:[\s\S]{0,200}?high: high/.test(mirror), mirror.slice(0, 0))
1002
+
1003
+ writeLayer({ effortByModel: { 'glm-5.3-flash': 'max' } })
1004
+ let before = arrivals.length
1005
+ await chat({ model: 'glm-5.3-flash', stream: false, messages: [] })
1006
+ check('catalog tier max → reasoning_effort injected upstream',
1007
+ arrivals.slice(before).some((a) => a.raw.includes('"reasoning_effort":"max"')),
1008
+ arrivals.slice(before).map((a) => a.raw).join(' | ').slice(0, 200))
1009
+
1010
+ writeLayer({ effortByModel: { 'glm-5.3-flash': 'medium', 'hy4-preview': 'high', 'mock-legacy': 'high' } })
1011
+ before = arrivals.length
1012
+ await chat({ model: 'glm-5.3-flash', stream: false, messages: [] })
1013
+ await chat({ model: 'hy4-preview', stream: false, messages: [] })
1014
+ await chat({ model: 'mock-legacy', stream: false, messages: [] })
1015
+ const tail = arrivals.slice(before)
1016
+ check('undeclared level (medium) injects nothing',
1017
+ !tail[0].raw.includes('reasoning_effort'), tail[0].raw.slice(0, 160))
1018
+ check('declared single tier still injects (hy4-preview high)',
1019
+ tail[1].raw.includes('"reasoning_effort":"high"'), tail[1].raw.slice(0, 160))
1020
+ check('legacy catalog model without a table injects nothing',
1021
+ !tail[2].raw.includes('reasoning_effort'), tail[2].raw.slice(0, 160))
1022
+
1023
+ writeLayer({ effortByModel: { 'glm-5.3-flash': 'off' } })
1024
+ before = arrivals.length
1025
+ await chat({ model: 'glm-5.3-flash', stream: false, messages: [] })
1026
+ check('off is not a declared tier → injects nothing (no invented disable wire)',
1027
+ !arrivals.slice(before).some((a) => a.raw.includes('reasoning_effort')))
1028
+
1029
+ // 收尾:关闸并清层(本节之后没有其它断言,仍保持测试自净的纪律)。
1030
+ catalogMode = 'off'
1031
+ writeLayer({})
1032
+ }
1033
+
1034
+ console.log(failures === 0 ? '\nall bridge checks passed' : `\n${failures} check(s) FAILED`)
1035
+ process.exit(failures === 0 ? 0 : 1)
1036
+ }
1037
+
1038
+ main().catch((err) => {
1039
+ console.error(`verify-bridge crashed: ${err.stack ?? err.message}`)
1040
+ process.exit(1)
1041
+ })