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.
- package/AGENTS.md +99 -0
- package/CHANGELOG.md +751 -0
- package/LICENSE +21 -0
- package/README.en.md +59 -0
- package/README.md +256 -0
- package/cordis.patch.yml +385 -0
- package/core/bridge.js +698 -0
- package/core/json-store.js +91 -0
- package/core/rotation.js +108 -0
- package/core/usage-meter.js +176 -0
- package/docs/diagnosis-cache-quota.md +181 -0
- package/docs/diagnosis-qoder-flash.md +67 -0
- package/docs/diagnosis-trae-3003.md +302 -0
- package/docs/goals/bridge-port-host-split.md +91 -0
- package/docs/goals/desktop-adaptation.md +106 -0
- package/docs/goals/qoder-cn-provider-design.md +308 -0
- package/docs/goals/settings-card-ux-redesign-plan.md +1680 -0
- package/docs/goals/settings-card-ux-redesign.md +162 -0
- package/docs/goals/trae-agent-v3.md +41 -0
- package/docs/goals/trae-work-cn-repair.md +44 -0
- 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
- package/docs/pitfalls.md +105 -0
- package/docs/reverse/trae-cloud-api.md +218 -0
- package/docs/reverse/trae-model-catalog.md +129 -0
- package/docs/reverse/traework-cn.md +520 -0
- package/docs/rules/STATE.md +198 -0
- package/docs/rules/content-moderation.md +54 -0
- package/docs/rules/dev-role-boundary.md +94 -0
- package/docs/rules/extra-providers.md +42 -0
- package/docs/rules/gateway-facts.md +91 -0
- package/docs/rules/oauth-handshake.md +76 -0
- package/docs/rules/prompt-cache.md +93 -0
- package/docs/rules/quota-signals.md +125 -0
- package/docs/rules/routing.md +84 -0
- package/docs/rules/templates/oauth-reverse-checklist.md +42 -0
- package/docs/rules/trae-surface.md +242 -0
- package/docs/rules/ua-validation.md +81 -0
- package/host-config.js +282 -0
- package/index.js +2306 -0
- package/lib/client.js +2893 -0
- package/local-scan.js +104 -0
- package/package.json +82 -0
- package/providers/ark/index.js +11 -0
- package/providers/bailian/index.js +10 -0
- package/providers/bigmodel/index.js +11 -0
- package/providers/codebuddy/agenttool.js +122 -0
- package/providers/codebuddy/catalog.js +227 -0
- package/providers/codebuddy/errors.js +44 -0
- package/providers/codebuddy/headers.js +36 -0
- package/providers/codebuddy/images.js +125 -0
- package/providers/codebuddy/index.js +123 -0
- package/providers/codebuddy/oauth.js +279 -0
- package/providers/deepseek/index.js +11 -0
- package/providers/moonshot/index.js +11 -0
- package/providers/openai-compat.js +177 -0
- package/providers/openrouter/index.js +27 -0
- package/providers/qoder/catalog.js +145 -0
- package/providers/qoder/cosy.js +419 -0
- package/providers/qoder/gateway.js +563 -0
- package/providers/qoder/index.js +116 -0
- package/providers/qoder/oauth.js +364 -0
- package/providers/qoder/qoder_auth.wasm +0 -0
- package/providers/qoder/quota.js +56 -0
- package/providers/qwen/index.js +15 -0
- package/providers/tool-pairing.js +129 -0
- package/providers/trae/catalog.js +103 -0
- package/providers/trae/errors.js +85 -0
- package/providers/trae/gateway.js +853 -0
- package/providers/trae/index.js +126 -0
- package/providers/trae/oauth.js +443 -0
- package/providers/trae/quota.js +75 -0
- package/providers/trae/remote.js +365 -0
- package/scripts/capture-cache.mjs +65 -0
- package/scripts/capture-traffic.mjs +83 -0
- package/scripts/hermes-probe-dev-role.mjs +263 -0
- package/scripts/measure-latency.mjs +253 -0
- package/scripts/probe-ark-thinking.mjs +298 -0
- package/scripts/probe-cache-decline.mjs +292 -0
- package/scripts/probe-cache-ttl.mjs +221 -0
- package/scripts/probe-cache.mjs +156 -0
- package/scripts/probe-codebuddy-efforts.mjs +355 -0
- package/scripts/probe-codebuddy-tier-wiring.mjs +244 -0
- package/scripts/probe-effort-gaps.mjs +133 -0
- package/scripts/probe-media.mjs +115 -0
- package/scripts/probe-moderation.mjs +159 -0
- package/scripts/probe-oauth.mjs +617 -0
- package/scripts/probe-qoder-attribution-arm8.mjs +92 -0
- package/scripts/probe-qoder-attribution-arm9.mjs +102 -0
- package/scripts/probe-qoder-attribution.mjs +266 -0
- package/scripts/probe-qoder-flash-confirm.mjs +94 -0
- package/scripts/probe-qoder-live.mjs +344 -0
- package/scripts/probe-qoder-matrix.mjs +274 -0
- package/scripts/probe-qoder-null-content.mjs +153 -0
- package/scripts/probe-qoder-pairing.mjs +308 -0
- package/scripts/probe-qoder-quota.mjs +162 -0
- package/scripts/probe-qoder-thinking-config.mjs +52 -0
- package/scripts/probe-qoder-thinking-efforts.mjs +269 -0
- package/scripts/probe-quota.mjs +136 -0
- package/scripts/probe-quota2.mjs +144 -0
- package/scripts/probe-routing.mjs +226 -0
- package/scripts/probe-trae-3003-diagnosis.mjs +139 -0
- package/scripts/probe-trae-agent-v3.mjs +399 -0
- package/scripts/probe-trae-efforts.mjs +149 -0
- package/scripts/probe-trae-live.mjs +147 -0
- package/scripts/probe-trae-max-effort.mjs +179 -0
- package/scripts/probe-trae-model-routing.mjs +381 -0
- package/scripts/probe-trae-thinking-scene.mjs +238 -0
- package/scripts/probe-trae-transport-outage.mjs +176 -0
- package/scripts/probe-ua.mjs +508 -0
- package/scripts/trae-model-catalog.mjs +632 -0
- package/scripts/verify-agents-md.mjs +45 -0
- package/scripts/verify-bridge.mjs +1041 -0
- package/scripts/verify-core-generic.mjs +302 -0
- package/scripts/verify-desktop-acceptance.mjs +184 -0
- package/scripts/verify-host-config.mjs +265 -0
- package/scripts/verify-models.mjs +390 -0
- package/scripts/verify-providers.mjs +255 -0
- package/scripts/verify-qoder-provider.mjs +1020 -0
- package/scripts/verify-rotation.mjs +308 -0
- package/scripts/verify-trae-model-catalog.mjs +284 -0
- 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
|
+
})
|