dsh-account-pool 0.1.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/README.md +362 -0
- package/cordis.patch.yml +26 -0
- package/lib/accounts.js +576 -0
- package/lib/client.js +2049 -0
- package/lib/headers.js +76 -0
- package/lib/index.js +1142 -0
- package/lib/selector.js +343 -0
- package/lib/shim.js +370 -0
- package/lib/tasks.js +429 -0
- package/lib/trae-accounts.js +453 -0
- package/lib/trae-bridge.js +266 -0
- package/lib/trae-storage.js +215 -0
- package/lib/trae-upstream.js +485 -0
- package/lib/upstream.js +610 -0
- package/lib/usage.js +420 -0
- package/package.json +68 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trae SSE → OpenAI chat-completions SSE 协议转换。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么必须有这一层
|
|
5
|
+
*
|
|
6
|
+
* shim 对 pi-ai 承诺的是 OpenAI 格式的流(`data: {"choices":[{"delta":...}]}`)。
|
|
7
|
+
* WorkBuddy 的上游本来就是 OpenAI 格式,直通即可;**Trae 的上游是自己的
|
|
8
|
+
* 命名事件格式**(`event:metadata` / `event:output` / `event:done`…)。
|
|
9
|
+
*
|
|
10
|
+
* 没有这一层时 shim 把 Trae 原生事件原样转发,pi-ai 认不出任何一个,
|
|
11
|
+
* 等不到 `delta`,撑到流空闲超时判失败——表现为「很慢、几乎都失败」,
|
|
12
|
+
* 而直连上游实测 1.5 秒就正常出字。协议转换是 Trae 接入的必要环节,
|
|
13
|
+
* 参照实现(dsh-connect-trae 的 solo-bridge)也是这么做的。
|
|
14
|
+
*
|
|
15
|
+
* ## 事件映射
|
|
16
|
+
*
|
|
17
|
+
* ```text
|
|
18
|
+
* Trae → OpenAI chunk
|
|
19
|
+
* ─────────────────────────────────────────────────────────
|
|
20
|
+
* output {response, → delta.content
|
|
21
|
+
* reasoning_content, delta.reasoning_content
|
|
22
|
+
* tool_calls[]} delta.tool_calls
|
|
23
|
+
* token_usage {prompt_tokens…} → (累积,附在末尾 chunk 上)
|
|
24
|
+
* error {code,message} → 流错误(上游失败要暴露,不能装成功)
|
|
25
|
+
* done {finish_reason} → finish_reason chunk + data: [DONE]
|
|
26
|
+
* 其余(metadata/timing…) → 忽略
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { randomUUID } from 'node:crypto'
|
|
31
|
+
|
|
32
|
+
/** 上游错误,转换层抛出、shim 捕获后按失败处置(换号或回错)。 */
|
|
33
|
+
export class TraeUpstreamError extends Error {
|
|
34
|
+
constructor(message, code) {
|
|
35
|
+
super(message)
|
|
36
|
+
this.name = 'TraeUpstreamError'
|
|
37
|
+
this.code = code
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 容错 JSON 解析:Trae 的 data 偶有非 JSON 行,不能让它断流。 */
|
|
42
|
+
function parseJson(text) {
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(text)
|
|
45
|
+
} catch {
|
|
46
|
+
return undefined
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 把上游的原始响应体转换成 OpenAI SSE 流。
|
|
52
|
+
*
|
|
53
|
+
* @param {object} response 上游 fetch 的 Response(body 为 Trae 命名事件流)
|
|
54
|
+
* @param {string} model DSH 请求的模型名(原样回填进 chunk,便于 pi-ai 对账)
|
|
55
|
+
* @returns {object} 带 OpenAI 格式 body 的新 Response
|
|
56
|
+
*/
|
|
57
|
+
export function bridgeTraeSseToOpenAI(response, model) {
|
|
58
|
+
const source = response.body
|
|
59
|
+
if (source === null) {
|
|
60
|
+
return new Response(null, { status: 502 })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const id = `chatcmpl-${randomUUID().replaceAll('-', '').slice(0, 24)}`
|
|
64
|
+
const created = Math.floor(Date.now() / 1000)
|
|
65
|
+
const decoder = new TextDecoder()
|
|
66
|
+
const encoder = new TextEncoder()
|
|
67
|
+
|
|
68
|
+
/** 工具调用是否出现过:决定 finish_reason 用 tool_calls 还是 stop。 */
|
|
69
|
+
let sawToolCalls = false
|
|
70
|
+
/** 是否已发过 finish chunk:done 与流 EOF 可能都触发,只发一次。 */
|
|
71
|
+
let emittedFinish = false
|
|
72
|
+
/** 上游是否已明确结束(done 事件)。 */
|
|
73
|
+
let upstreamEnded = false
|
|
74
|
+
/** 上游业务错误:error 事件或 code>=4000 都算,不能伪装成成功。 */
|
|
75
|
+
let upstreamError
|
|
76
|
+
/** token_usage 累积:OpenAI 惯例附在最后一个 chunk 上。 */
|
|
77
|
+
let usage
|
|
78
|
+
|
|
79
|
+
/** 组一个 OpenAI chunk 的字节序列。 */
|
|
80
|
+
const chunk = (delta, finishReason = null) => encoder.encode(
|
|
81
|
+
`data: ${JSON.stringify({
|
|
82
|
+
id,
|
|
83
|
+
object: 'chat.completion.chunk',
|
|
84
|
+
created,
|
|
85
|
+
model,
|
|
86
|
+
choices: [{ index: 0, delta, finish_reason: finishReason }],
|
|
87
|
+
...(usage === undefined ? {} : { usage }),
|
|
88
|
+
})}\n\n`,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 处理一条 Trae 事件。
|
|
93
|
+
*
|
|
94
|
+
* Trae 事件的 data 有两种形态:裸字符串(progress_notice)和 JSON 对象,
|
|
95
|
+
* 都要容错。
|
|
96
|
+
*/
|
|
97
|
+
const consume = (event) => {
|
|
98
|
+
if (event.data === '[DONE]') {
|
|
99
|
+
// Trae 偶尔也发 [DONE];真正收尾在流 EOF 处统一处理
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
const payload = parseJson(event.data)
|
|
103
|
+
|
|
104
|
+
// —— 错误:error 事件,或任何带 code>=4000 的载荷 ——
|
|
105
|
+
const code = typeof payload?.code === 'number' ? payload.code : undefined
|
|
106
|
+
if (event.event === 'error' || (code !== undefined && code >= 4000)) {
|
|
107
|
+
upstreamError = new TraeUpstreamError(
|
|
108
|
+
typeof payload?.message === 'string' && payload.message !== ''
|
|
109
|
+
? payload.message
|
|
110
|
+
: `Trae 上游错误(code ${code ?? '?'})`,
|
|
111
|
+
code,
|
|
112
|
+
)
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// —— 用量 ——
|
|
117
|
+
if (event.event === 'token_usage') {
|
|
118
|
+
usage = {
|
|
119
|
+
...(typeof payload?.prompt_tokens === 'number' ? { prompt_tokens: payload.prompt_tokens } : {}),
|
|
120
|
+
...(typeof payload?.completion_tokens === 'number' ? { completion_tokens: payload.completion_tokens } : {}),
|
|
121
|
+
...(typeof payload?.total_tokens === 'number' ? { total_tokens: payload.total_tokens } : {}),
|
|
122
|
+
}
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// —— 结束 ——
|
|
127
|
+
if (event.event === 'done' || typeof payload?.finish_reason === 'string') {
|
|
128
|
+
upstreamEnded = true
|
|
129
|
+
if (!emittedFinish) {
|
|
130
|
+
emittedFinish = true
|
|
131
|
+
if (upstreamError !== undefined) {
|
|
132
|
+
controller.error(upstreamError)
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
// 工具调用过的流 finish_reason 要用 tool_calls,pi-ai 靠它走工具循环
|
|
136
|
+
controller.enqueue(chunk({}, sawToolCalls ? 'tool_calls' : (payload?.finish_reason ?? 'stop')))
|
|
137
|
+
}
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// —— 增量内容 ——
|
|
142
|
+
if (
|
|
143
|
+
event.event === 'output'
|
|
144
|
+
|| payload?.response !== undefined
|
|
145
|
+
|| payload?.reasoning_content !== undefined
|
|
146
|
+
) {
|
|
147
|
+
const delta = {}
|
|
148
|
+
const text = typeof payload?.response === 'string' ? payload.response : ''
|
|
149
|
+
const reasoning = typeof payload?.reasoning_content === 'string' ? payload.reasoning_content : ''
|
|
150
|
+
if (text !== '') delta.content = text
|
|
151
|
+
if (reasoning !== '') delta.reasoning_content = reasoning
|
|
152
|
+
// tool_calls:Trae 用 function_call{name,arguments},转回 OpenAI 的 function 形态
|
|
153
|
+
const calls = normalizeToolCalls(payload?.tool_calls)
|
|
154
|
+
if (calls.length > 0) {
|
|
155
|
+
sawToolCalls = true
|
|
156
|
+
delta.tool_calls = calls
|
|
157
|
+
}
|
|
158
|
+
// 全空的 delta 不发:pi-ai 对空 chunk 宽容,但没必要制造噪音
|
|
159
|
+
if (Object.keys(delta).length > 0) controller.enqueue(chunk(delta))
|
|
160
|
+
}
|
|
161
|
+
// metadata / timing_cost / progress_notice 等与内容无关,忽略
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let controller
|
|
165
|
+
const stream = new ReadableStream({
|
|
166
|
+
start(c) {
|
|
167
|
+
controller = c
|
|
168
|
+
},
|
|
169
|
+
async pull() {
|
|
170
|
+
// 读取由 start 里的异步循环驱动;pull 不使用
|
|
171
|
+
},
|
|
172
|
+
cancel(reason) {
|
|
173
|
+
return source.cancel(reason)
|
|
174
|
+
},
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
// 驱动循环:读上游 → 解析 SSE → 转换 → 推给消费者
|
|
178
|
+
void (async () => {
|
|
179
|
+
const reader = source.getReader()
|
|
180
|
+
let buffer = ''
|
|
181
|
+
let currentEvent
|
|
182
|
+
const lines = []
|
|
183
|
+
|
|
184
|
+
/** 逐行喂 SSE;空行 = 一条事件结束。 */
|
|
185
|
+
const feed = (line) => {
|
|
186
|
+
if (line === '') {
|
|
187
|
+
if (lines.length > 0) {
|
|
188
|
+
const dataLines = []
|
|
189
|
+
for (const l of lines) {
|
|
190
|
+
if (l.startsWith('data:')) dataLines.push(l.slice(5).trimStart())
|
|
191
|
+
}
|
|
192
|
+
consume({ event: currentEvent, data: dataLines.join('\n') })
|
|
193
|
+
lines.length = 0
|
|
194
|
+
currentEvent = undefined
|
|
195
|
+
}
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
if (line.startsWith('event:')) currentEvent = line.slice(6).trim()
|
|
199
|
+
lines.push(line)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
while (true) {
|
|
204
|
+
const next = await reader.read()
|
|
205
|
+
if (next.done) break
|
|
206
|
+
buffer += decoder.decode(next.value, { stream: true })
|
|
207
|
+
let idx
|
|
208
|
+
while ((idx = buffer.indexOf('\n')) !== -1) {
|
|
209
|
+
const line = buffer.slice(0, idx).replace(/\r$/, '')
|
|
210
|
+
buffer = buffer.slice(idx + 1)
|
|
211
|
+
feed(line)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (buffer !== '') feed(buffer)
|
|
215
|
+
|
|
216
|
+
// 上游 EOF:即使没有显式 done,也要合成 finish(Trae 有时省略)
|
|
217
|
+
if (!upstreamEnded) {
|
|
218
|
+
emittedFinish = true
|
|
219
|
+
if (upstreamError !== undefined) {
|
|
220
|
+
controller.error(upstreamError)
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
controller.enqueue(chunk({}, sawToolCalls ? 'tool_calls' : 'stop'))
|
|
224
|
+
}
|
|
225
|
+
if (upstreamError !== undefined) {
|
|
226
|
+
// done 已到但错误在先:仍要以失败收场
|
|
227
|
+
controller.error(upstreamError)
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
|
|
231
|
+
controller.close()
|
|
232
|
+
} catch (error) {
|
|
233
|
+
controller.error(error)
|
|
234
|
+
} finally {
|
|
235
|
+
reader.releaseLock()
|
|
236
|
+
}
|
|
237
|
+
})()
|
|
238
|
+
|
|
239
|
+
return new Response(stream, { status: 200, headers: { 'Content-Type': 'text/event-stream' } })
|
|
240
|
+
|
|
241
|
+
/** tool_calls 归一化:Trae 的 function_call 形态 → OpenAI 形态。 */
|
|
242
|
+
function normalizeToolCalls(value) {
|
|
243
|
+
if (!Array.isArray(value)) return []
|
|
244
|
+
const calls = []
|
|
245
|
+
for (const raw of value) {
|
|
246
|
+
if (typeof raw !== 'object' || raw === null) continue
|
|
247
|
+
const fn = typeof raw.function_call === 'object' && raw.function_call !== null
|
|
248
|
+
? raw.function_call
|
|
249
|
+
: typeof raw.function === 'object' && raw.function !== null
|
|
250
|
+
? raw.function
|
|
251
|
+
: {}
|
|
252
|
+
const name = typeof fn.name === 'string' ? fn.name : ''
|
|
253
|
+
if (name === '') continue // 上游要求 Name 必填,空的不转
|
|
254
|
+
calls.push({
|
|
255
|
+
index: typeof raw.index === 'number' ? raw.index : calls.length,
|
|
256
|
+
...(typeof raw.id === 'string' ? { id: raw.id } : {}),
|
|
257
|
+
type: 'function',
|
|
258
|
+
function: {
|
|
259
|
+
name,
|
|
260
|
+
...(typeof fn.arguments === 'string' ? { arguments: fn.arguments } : {}),
|
|
261
|
+
},
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
return calls
|
|
265
|
+
}
|
|
266
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 解析 Trae 桌面端的 storage.json 凭证。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么走这条路
|
|
5
|
+
*
|
|
6
|
+
* Trae 授权页是把 token **fetch 投递到 `127.0.0.1:18080`** 的(不是导航跳转)。
|
|
7
|
+
* 当 DSH 跑在 NAS、浏览器在另一台电脑时,那个 127.0.0.1 指的是**用户自己的
|
|
8
|
+
* 电脑**,请求被拒 → 页面报「网络错误」,而且因为是 fetch 而非导航,
|
|
9
|
+
* 地址栏不会出现带 token 的链接,**连手动复制都拿不到**。
|
|
10
|
+
*
|
|
11
|
+
* 所以改为「从桌面端已登录的凭证里读」:
|
|
12
|
+
* Trae 桌面端把登录态写在 `storage.json` 的 `iCubeAuthInfo://icube.cloudide`
|
|
13
|
+
* 字段里(AES-CBC 加密)。用户把这个文件拷过来即可,不需要在 NAS 上
|
|
14
|
+
* 复现整个 OAuth 流程。
|
|
15
|
+
*
|
|
16
|
+
* ## 关键结论:解密不依赖本机
|
|
17
|
+
*
|
|
18
|
+
* 密钥完全由「硬编码盐值 + 密文自带的随机数」派生:
|
|
19
|
+
*
|
|
20
|
+
* ```text
|
|
21
|
+
* salt = SALT_A xor SALT_B (或 aes-private 用 SALT_C xor SALT_D)
|
|
22
|
+
* first = sha512(random) (random 取自密文第 7..38 字节)
|
|
23
|
+
* derived = sha512(first + salt)
|
|
24
|
+
* key = derived[0..16] iv = derived[16..32]
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* 没有机器码、没有 DPAPI、没有系统密钥——**任何机器上都能解**。
|
|
28
|
+
* 明文前 64 字节是 sha512 校验值,用来验证解密正确。
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { createDecipheriv, createHash } from 'node:crypto'
|
|
32
|
+
|
|
33
|
+
/** storage.json 里存放鉴权信息的键。 */
|
|
34
|
+
export const AUTH_STORAGE_KEY = 'iCubeAuthInfo://icube.cloudide'
|
|
35
|
+
|
|
36
|
+
/** 设备中心 id 的键前缀,用于取 deviceId。 */
|
|
37
|
+
const DEVICE_CENTER_PREFIX = 'iCubeAuthInfo://icube-dc:'
|
|
38
|
+
|
|
39
|
+
// 硬编码盐值。这些是 Trae 客户端自身的常量,不是可配置项。
|
|
40
|
+
const SALT_A = Uint8Array.from([82, 9, 106, 213, 48, 54, 165, 56, 191, 64, 163, 158, 129, 243, 215, 251, 124, 227, 57, 130, 155, 47, 255, 135, 52, 142, 67, 68, 196, 222, 233, 203, 84, 123, 148, 50, 166, 194, 35, 61, 238, 76, 149, 11, 66, 250, 195, 78, 8, 46, 161, 102, 40, 217, 36, 178, 118, 91, 162, 73, 109, 139, 209, 37])
|
|
41
|
+
const SALT_B = Uint8Array.from([31, 221, 168, 51, 136, 7, 199, 49, 177, 18, 16, 89, 39, 128, 236, 95, 96, 81, 127, 169, 25, 181, 74, 13, 45, 229, 122, 159, 147, 201, 156, 239, 160, 224, 59, 77, 174, 42, 245, 176, 200, 235, 187, 60, 131, 83, 153, 97, 23, 43, 4, 126, 186, 119, 214, 38, 225, 105, 20, 99, 85, 33, 12, 125])
|
|
42
|
+
const SALT_C = Uint8Array.from([191, 192, 216, 250, 122, 246, 220, 97, 31, 254, 98, 27, 8, 72, 71, 176, 135, 99, 96, 18, 127, 101, 203, 104, 211, 102, 191, 125, 37, 72, 150, 156, 51, 229, 121, 35, 17, 153, 141, 177, 110, 131, 150, 128, 172, 255, 254, 6, 18, 140, 55, 62, 236, 249, 135, 64, 135, 12, 117, 4, 89, 149, 168, 209])
|
|
43
|
+
const SALT_D = Uint8Array.from([246, 204, 26, 232, 232, 70, 129, 109, 223, 146, 169, 242, 23, 241, 105, 145, 50, 196, 165, 42, 254, 120, 3, 54, 244, 207, 209, 85, 53, 6, 138, 106, 175, 148, 31, 204, 186, 186, 165, 182, 87, 142, 49, 10, 39, 110, 26, 154, 86, 56, 173, 125, 18, 64, 198, 225, 99, 99, 83, 82, 191, 134, 76, 170])
|
|
44
|
+
|
|
45
|
+
/** 两种密文头,对应两套盐值。 */
|
|
46
|
+
const HEADER_AES = Buffer.from([0x74, 0x63, 0x05, 0x10, 0x00, 0x00])
|
|
47
|
+
const HEADER_AES_PRIVATE = Buffer.from([18, 57, 32, 32, 2, 3])
|
|
48
|
+
|
|
49
|
+
/** 逐字节异或。 */
|
|
50
|
+
function xor(a, b) {
|
|
51
|
+
return Buffer.from(a.map((value, index) => value ^ (b[index] ?? 0)))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function saltFor(header) {
|
|
55
|
+
if (header.equals(HEADER_AES)) return xor(SALT_A, SALT_B)
|
|
56
|
+
if (header.equals(HEADER_AES_PRIVATE)) return xor(SALT_C, SALT_D)
|
|
57
|
+
throw new Error('无法识别的凭证加密头(可能不是 Trae 的 storage.json)')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 解密 `iCubeAuthInfo://icube.cloudide` 字段的值。
|
|
62
|
+
*
|
|
63
|
+
* @param {string} encoded base64 密文
|
|
64
|
+
* @returns {string} 明文 JSON 字符串
|
|
65
|
+
*/
|
|
66
|
+
export function decryptAuthValue(encoded) {
|
|
67
|
+
const buffer = Buffer.from(String(encoded ?? '').trim(), 'base64')
|
|
68
|
+
if (buffer.length <= 102) throw new Error('凭证密文太短,可能复制不完整')
|
|
69
|
+
|
|
70
|
+
const salt = saltFor(buffer.subarray(0, 6))
|
|
71
|
+
const random = buffer.subarray(6, 38)
|
|
72
|
+
const encrypted = buffer.subarray(38)
|
|
73
|
+
|
|
74
|
+
const first = createHash('sha512').update(random).digest()
|
|
75
|
+
const derived = createHash('sha512').update(Buffer.concat([first, salt])).digest()
|
|
76
|
+
const decipher = createDecipheriv('aes-128-cbc', derived.subarray(0, 16), derived.subarray(16, 32))
|
|
77
|
+
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()])
|
|
78
|
+
|
|
79
|
+
if (decrypted.length < 64) throw new Error('凭证明文太短')
|
|
80
|
+
// 前 64 字节是明文自身的 sha512,用来验证解密是否成功
|
|
81
|
+
const expected = decrypted.subarray(0, 64)
|
|
82
|
+
const plaintext = decrypted.subarray(64)
|
|
83
|
+
const actual = createHash('sha512').update(plaintext).digest()
|
|
84
|
+
if (!expected.equals(actual)) {
|
|
85
|
+
throw new Error('凭证完整性校验失败(文件可能被改动或复制不完整)')
|
|
86
|
+
}
|
|
87
|
+
return plaintext.toString('utf8')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 值是明文 JSON 就直接用,是密文就解。 */
|
|
91
|
+
function readAuthJson(value) {
|
|
92
|
+
const text = String(value ?? '').trim()
|
|
93
|
+
if (text === '') throw new Error('凭证字段为空')
|
|
94
|
+
const plaintext = text.startsWith('{') ? text : decryptAuthValue(text)
|
|
95
|
+
let parsed
|
|
96
|
+
try {
|
|
97
|
+
parsed = JSON.parse(plaintext)
|
|
98
|
+
} catch {
|
|
99
|
+
throw new Error('凭证解密后不是合法 JSON')
|
|
100
|
+
}
|
|
101
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
102
|
+
throw new Error('凭证结构异常')
|
|
103
|
+
}
|
|
104
|
+
return parsed
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 取第一个非空字符串。 */
|
|
108
|
+
function firstString(...values) {
|
|
109
|
+
for (const value of values) {
|
|
110
|
+
if (typeof value === 'string' && value.trim() !== '') return value.trim()
|
|
111
|
+
}
|
|
112
|
+
return undefined
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 时间字段可能是秒或毫秒,统一成毫秒。 */
|
|
116
|
+
function toMs(value) {
|
|
117
|
+
// 数字:秒(<1e12)或毫秒
|
|
118
|
+
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
|
119
|
+
return value > 1e12 ? value : value * 1000
|
|
120
|
+
}
|
|
121
|
+
// 字符串:可能是纯数字时间戳,也可能是 ISO 日期。
|
|
122
|
+
// Trae 各版本字段类型不统一——只处理数字会丢掉 ISO 形式,
|
|
123
|
+
// 导致 expiresAtMs 落成 0、token 被误判为已过期而立刻触发刷新。
|
|
124
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
125
|
+
const numeric = Number(value)
|
|
126
|
+
if (Number.isFinite(numeric) && numeric > 0) {
|
|
127
|
+
return numeric > 1e12 ? numeric : numeric * 1000
|
|
128
|
+
}
|
|
129
|
+
const parsed = Date.parse(value)
|
|
130
|
+
return Number.isFinite(parsed) ? parsed : undefined
|
|
131
|
+
}
|
|
132
|
+
return undefined
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 从 storage.json 文本里取设备标识。 */
|
|
136
|
+
function readDeviceId(storage) {
|
|
137
|
+
const keys = Object.keys(storage).filter(key => key.startsWith(DEVICE_CENTER_PREFIX))
|
|
138
|
+
// 只有一个候选时才用,避免多个设备 id 时猜错
|
|
139
|
+
if (keys.length !== 1) return { deviceId: '', machineId: '' }
|
|
140
|
+
const deviceId = keys[0].slice(DEVICE_CENTER_PREFIX.length)
|
|
141
|
+
const machineId = firstString(storage['telemetry.machineId']) ?? ''
|
|
142
|
+
return { deviceId, machineId }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 解析 Trae 桌面端的 storage.json。
|
|
147
|
+
*
|
|
148
|
+
* 支持两种输入:
|
|
149
|
+
* 1. 完整的 storage.json 内容(含 iCubeAuthInfo://icube.cloudide 键)
|
|
150
|
+
* 2. 直接就是那个字段的值(密文或明文 JSON)
|
|
151
|
+
*
|
|
152
|
+
* @param {string} text 文件内容或字段值
|
|
153
|
+
* @returns {object} 归一化后的凭证
|
|
154
|
+
*/
|
|
155
|
+
export function parseTraeStorage(text) {
|
|
156
|
+
const raw = String(text ?? '').trim()
|
|
157
|
+
if (raw === '') throw new Error('内容为空')
|
|
158
|
+
|
|
159
|
+
let authValue
|
|
160
|
+
let device = { deviceId: '', machineId: '' }
|
|
161
|
+
|
|
162
|
+
if (raw.startsWith('{')) {
|
|
163
|
+
let document
|
|
164
|
+
try {
|
|
165
|
+
document = JSON.parse(raw)
|
|
166
|
+
} catch {
|
|
167
|
+
throw new Error('不是合法的 JSON')
|
|
168
|
+
}
|
|
169
|
+
if (document[AUTH_STORAGE_KEY] !== undefined) {
|
|
170
|
+
// 情况 1:完整 storage.json
|
|
171
|
+
authValue = document[AUTH_STORAGE_KEY]
|
|
172
|
+
device = readDeviceId(document)
|
|
173
|
+
} else if (document.token !== undefined || document.accessToken !== undefined) {
|
|
174
|
+
// 情况 2:已经是鉴权对象本身
|
|
175
|
+
authValue = raw
|
|
176
|
+
} else {
|
|
177
|
+
throw new Error(
|
|
178
|
+
`这份 JSON 里没有 ${AUTH_STORAGE_KEY} 字段,`
|
|
179
|
+
+ '可能不是 Trae 的 storage.json(或选错了 edition 的文件)',
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
} else {
|
|
183
|
+
// 密文
|
|
184
|
+
authValue = raw
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const auth = readAuthJson(authValue)
|
|
188
|
+
const accessToken = firstString(auth.token, auth.accessToken)
|
|
189
|
+
if (accessToken === undefined) throw new Error('凭证里没有 token/accessToken')
|
|
190
|
+
|
|
191
|
+
const account = typeof auth.account === 'object' && auth.account !== null ? auth.account : undefined
|
|
192
|
+
const refreshToken = firstString(auth.refreshToken) ?? ''
|
|
193
|
+
const userId = firstString(auth.userId, auth.uid) ?? ''
|
|
194
|
+
|
|
195
|
+
// 区域:优先凭证自带的 userRegion(大小写都可能),据它决定上游主机
|
|
196
|
+
const regionRaw = firstString(
|
|
197
|
+
typeof auth.userRegion === 'string' ? auth.userRegion : undefined,
|
|
198
|
+
auth.userRegion?.region,
|
|
199
|
+
auth.region,
|
|
200
|
+
)
|
|
201
|
+
const region = regionRaw?.toLowerCase() === 'sg' ? 'sg' : 'cn'
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
accessToken,
|
|
205
|
+
refreshToken,
|
|
206
|
+
userId,
|
|
207
|
+
accountName: firstString(account?.username) ?? '',
|
|
208
|
+
host: firstString(auth.host) ?? '',
|
|
209
|
+
region,
|
|
210
|
+
expiresAtMs: toMs(auth.expiredAt ?? auth.expiresAt) ?? 0,
|
|
211
|
+
refreshExpiresAtMs: toMs(auth.refreshExpiredAt ?? auth.refreshExpiresAt),
|
|
212
|
+
deviceId: device.deviceId,
|
|
213
|
+
machineId: device.machineId,
|
|
214
|
+
}
|
|
215
|
+
}
|