dsh-llm-adapters 1.2.0 → 1.2.2
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/DEVELOPMENT.md +92 -24
- package/FAQ.md +47 -10
- package/README.md +39 -25
- package/client.js +1802 -1733
- package/lib/index.js +733 -302
- package/lib/types/account-store.d.ts +17 -8
- package/lib/types/adapter.d.ts +8 -2
- package/lib/types/auth.d.ts +24 -11
- package/lib/types/codearts-oauth.d.ts +19 -0
- package/lib/types/codearts.d.ts +1 -5
- package/lib/types/config.d.ts +0 -5
- package/lib/types/index.d.ts +5 -6
- package/lib/types/raccoon.d.ts +79 -6
- package/lib/types/trae-client.d.ts +52 -0
- package/lib/types/trae-endpoints.d.ts +2 -0
- package/lib/types/trae-login.d.ts +5 -3
- package/lib/types/trae.d.ts +29 -3
- package/lib/types/types.d.ts +19 -1
- package/package.json +3 -2
- package/src/account-store.ts +20 -8
- package/src/adapter.ts +31 -3
- package/src/api.ts +15 -3
- package/src/auth.ts +62 -17
- package/src/codearts-oauth.ts +36 -5
- package/src/codearts.ts +24 -29
- package/src/config.ts +258 -264
- package/src/index.ts +6 -29
- package/src/raccoon.ts +163 -50
- package/src/trae-client.ts +312 -0
- package/src/trae-endpoints.ts +4 -0
- package/src/trae-login.ts +5 -3
- package/src/trae.ts +191 -3
- package/src/types.ts +20 -1
- package/lib/types/codearts-window.d.ts +0 -65
- package/src/codearts-window.ts +0 -136
package/src/raccoon.ts
CHANGED
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
|
12
|
+
import { createHash } from 'node:crypto'
|
|
12
13
|
import type { AccountStore } from './account-store.ts'
|
|
13
|
-
import { RaccoonAuth } from './auth.ts'
|
|
14
|
+
import { RaccoonAuth, decodeJwtFacts } from './auth.ts'
|
|
14
15
|
import { RaccoonQrLogin } from './raccoon-login.ts'
|
|
15
16
|
import type { LoginStatus } from './login-page.ts'
|
|
16
17
|
import {
|
|
@@ -47,6 +48,8 @@ export interface RaccoonProviderOptions {
|
|
|
47
48
|
fetchImpl?: typeof fetch
|
|
48
49
|
/** Injectable OpenRouter lookup for tests; defaults to the live catalog. */
|
|
49
50
|
openRouter?: OpenRouterLookup
|
|
51
|
+
/** Injectable session-id bookkeeping for tests; defaults to the real one. */
|
|
52
|
+
sessionIds?: RaccoonSessionIds
|
|
50
53
|
}
|
|
51
54
|
|
|
52
55
|
function numeric(value: unknown): number | undefined {
|
|
@@ -98,6 +101,118 @@ function raccoonHeaders(token: string): Record<string, string> {
|
|
|
98
101
|
}
|
|
99
102
|
}
|
|
100
103
|
|
|
104
|
+
/**
|
|
105
|
+
* The upstream code for the account's token-throughput wall.
|
|
106
|
+
*
|
|
107
|
+
* It reads like a request-rate complaint (`您的模型请求频率超限`) and is not
|
|
108
|
+
* one: measured on a live account, ten large requests in ten seconds hit it
|
|
109
|
+
* while fifty small ones did not, and the window refills by throughput rather
|
|
110
|
+
* than by request count. The gateway sends no `Retry-After` and no
|
|
111
|
+
* `x-ratelimit-*` header, so nothing here can compute a wait either.
|
|
112
|
+
*/
|
|
113
|
+
export const RACCOON_RATE_LIMIT_CODE = 'user_model_rate_limited'
|
|
114
|
+
|
|
115
|
+
/** Wording the gateway has used alongside that code. */
|
|
116
|
+
const RATE_LIMIT_WORDS = ['user_model_rate_limited', 'rate_limit', '频率超限']
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Whether a rejection is the token wall rather than a generic failure.
|
|
120
|
+
*
|
|
121
|
+
* Matched on the status, the code and the wording, because the same condition
|
|
122
|
+
* arrives as an HTTP 429 and as an error frame inside a 200 stream.
|
|
123
|
+
*/
|
|
124
|
+
export function isRaccoonRateLimited(status: number | undefined, text: string): boolean {
|
|
125
|
+
if (status === 429) return true
|
|
126
|
+
const lower = text.toLowerCase()
|
|
127
|
+
return RATE_LIMIT_WORDS.some(word => lower.includes(word.toLowerCase()))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The wall, as an error the caller can tell apart from a generic 429.
|
|
132
|
+
*
|
|
133
|
+
* A subclass rather than a flag: the in-band path and the HTTP path would
|
|
134
|
+
* otherwise both be caught by the same handler, and the second pass would wrap
|
|
135
|
+
* the message the first pass had already rewritten.
|
|
136
|
+
*/
|
|
137
|
+
export class RaccoonRateLimitedError extends ProviderHttpError {
|
|
138
|
+
constructor(message: string) {
|
|
139
|
+
super(message, 429)
|
|
140
|
+
this.name = 'RaccoonRateLimitedError'
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The message a user sees once the wall is hit.
|
|
146
|
+
*
|
|
147
|
+
* The raw text says "retry later" and gives no reason, which is why this reads
|
|
148
|
+
* as the plugin being broken. The honest answer names the cause and the one
|
|
149
|
+
* lever the user actually has on this side.
|
|
150
|
+
*/
|
|
151
|
+
function raccoonRateLimited(detail: string): RaccoonRateLimitedError {
|
|
152
|
+
return new RaccoonRateLimitedError(
|
|
153
|
+
'小浣熊账号的模型吞吐已达上限(不是每分钟请求数,而是按账号的 token 速率)。'
|
|
154
|
+
+ '一轮对话带的上下文越大越快撞到,撞到后额度按吞吐缓慢回血,上游也没有给恢复时间。'
|
|
155
|
+
+ '稍等一会儿再发;把该模型的上下文窗口调小,同一会话能撑得更久。'
|
|
156
|
+
+ `上游原文:${detail}`,
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The `error` object a compatible gateway puts in an SSE frame. */
|
|
161
|
+
function frameFailure(frame: Record<string, unknown>): { code?: string, message?: string } | undefined {
|
|
162
|
+
const error = frame.error
|
|
163
|
+
if (error === undefined) return undefined
|
|
164
|
+
if (typeof error === 'string') return { message: error }
|
|
165
|
+
if (error === null || typeof error !== 'object') return undefined
|
|
166
|
+
const record = error as Record<string, unknown>
|
|
167
|
+
const code = typeof record.code === 'string' ? record.code : undefined
|
|
168
|
+
const message = typeof record.message === 'string' ? record.message : undefined
|
|
169
|
+
return {
|
|
170
|
+
...code === undefined ? {} : { code },
|
|
171
|
+
...message === undefined ? {} : { message },
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* One conversation's session id, stable across the turns of that conversation.
|
|
177
|
+
*
|
|
178
|
+
* The official client never opens a session per request — measured on its own
|
|
179
|
+
* logs, 125 calls over 26 minutes all carried one `sess-0-53927d74` — while an
|
|
180
|
+
* implementation that mints a fresh id per request tells the gateway that one
|
|
181
|
+
* account opened hundreds of sessions within minutes.
|
|
182
|
+
*
|
|
183
|
+
* The id is a pure function of (account, model) rather than a random value, so a
|
|
184
|
+
* restarted or hot-reloaded process continues the same conversation instead of
|
|
185
|
+
* presenting a new session, and two models stay two conversations. The principal
|
|
186
|
+
* itself never leaves the process — only a truncated digest of it does.
|
|
187
|
+
*/
|
|
188
|
+
export class RaccoonSessionIds {
|
|
189
|
+
/**
|
|
190
|
+
* @param principal - a value that is stable for one account and identifies it
|
|
191
|
+
* without being the token itself (the JWT's `sub`).
|
|
192
|
+
*/
|
|
193
|
+
constructor(private readonly principal: () => Promise<string | undefined>) {}
|
|
194
|
+
|
|
195
|
+
async for(model: string): Promise<string | undefined> {
|
|
196
|
+
const principal = await this.principal()
|
|
197
|
+
if (principal === undefined) return undefined
|
|
198
|
+
return raccoonSessionId(principal, model)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* `sess-<sequence>-<8 hex>`, the shape the official client sends.
|
|
204
|
+
*
|
|
205
|
+
* The sequence is the official client's process-local counter and is pinned to 0
|
|
206
|
+
* here on purpose: an incrementing value would make one conversation look like a
|
|
207
|
+
* different session after a restart, which is the one thing this id exists to
|
|
208
|
+
* prevent. Identity lives in the digest, which is truncated on purpose — the id
|
|
209
|
+
* must separate accounts without naming them on the wire.
|
|
210
|
+
*/
|
|
211
|
+
export function raccoonSessionId(principal: string, model: string): string {
|
|
212
|
+
const digest = createHash('sha256').update(`${principal}\0${model}`).digest('hex').slice(0, 8)
|
|
213
|
+
return `sess-0-${digest}`
|
|
214
|
+
}
|
|
215
|
+
|
|
101
216
|
/** Whether the gateway accepts a token harvested from the browser. */
|
|
102
217
|
export async function raccoonAccessTokenValid(
|
|
103
218
|
token: string,
|
|
@@ -156,12 +271,16 @@ export class RaccoonProvider {
|
|
|
156
271
|
readonly catalog: ModelCatalog
|
|
157
272
|
private quotaCache: { at: number, value: QuotaSnapshot } | undefined
|
|
158
273
|
private quotaInflight: Promise<QuotaSnapshot> | undefined
|
|
159
|
-
private checkinInflight: Promise<CheckinStatus> | undefined
|
|
160
274
|
private loginFlow: RaccoonQrLogin | undefined
|
|
161
275
|
private readonly openRouter: OpenRouterLookup
|
|
276
|
+
private readonly sessionIds: RaccoonSessionIds
|
|
162
277
|
|
|
163
278
|
constructor(private readonly options: RaccoonProviderOptions) {
|
|
164
279
|
this.openRouter = options.openRouter ?? new OpenRouterCatalog(options.fetchImpl ?? fetch)
|
|
280
|
+
this.sessionIds = options.sessionIds ?? new RaccoonSessionIds(async () => {
|
|
281
|
+
const stored = await options.store.raccoon()
|
|
282
|
+
return stored === undefined ? undefined : decodeJwtFacts(stored.accessToken).subject
|
|
283
|
+
})
|
|
165
284
|
this.catalog = new ModelCatalog(
|
|
166
285
|
() => this.fetchModels(),
|
|
167
286
|
options.ttlMs,
|
|
@@ -225,6 +344,19 @@ export class RaccoonProvider {
|
|
|
225
344
|
return raccoonHeaders(token)
|
|
226
345
|
}
|
|
227
346
|
|
|
347
|
+
/**
|
|
348
|
+
* The chat headers, including the session id the gateway counts sessions by.
|
|
349
|
+
*
|
|
350
|
+
* Only chat carries it: the catalogue, quota and check-in routes are plain
|
|
351
|
+
* business calls the official client sends outside any session, so leaving
|
|
352
|
+
* them alone keeps this change to the one route it was measured on.
|
|
353
|
+
*/
|
|
354
|
+
private async chatHeaders(token: string, model: string): Promise<Record<string, string>> {
|
|
355
|
+
const base = raccoonHeaders(token)
|
|
356
|
+
const sessionId = await this.sessionIds.for(model).catch(() => undefined)
|
|
357
|
+
return sessionId === undefined ? base : { ...base, 'x-raccoon-session-id': sessionId }
|
|
358
|
+
}
|
|
359
|
+
|
|
228
360
|
/** Run one JSON call; a rejected token means this login needs a new scan. */
|
|
229
361
|
private async authorizedJson<T>(
|
|
230
362
|
path: string,
|
|
@@ -371,6 +503,15 @@ export class RaccoonProvider {
|
|
|
371
503
|
return undefined
|
|
372
504
|
}
|
|
373
505
|
|
|
506
|
+
/**
|
|
507
|
+
* Read today's grant. **Read-only on purpose.**
|
|
508
|
+
*
|
|
509
|
+
* The gateway has no claim endpoint: points are issued on the day's first
|
|
510
|
+
* website visit. Replaying that visit (`setting_info`) can trigger the grant
|
|
511
|
+
* from here, but doing so takes a state-changing action on the account without
|
|
512
|
+
* the user asking for it in that moment, so the plugin only reports. Claiming
|
|
513
|
+
* is the website's job, and the card links there.
|
|
514
|
+
*/
|
|
374
515
|
async checkinStatus(): Promise<CheckinStatus> {
|
|
375
516
|
const grant = await this.dailyGrant()
|
|
376
517
|
if (grant !== undefined) {
|
|
@@ -393,53 +534,6 @@ export class RaccoonProvider {
|
|
|
393
534
|
}
|
|
394
535
|
}
|
|
395
536
|
|
|
396
|
-
async claimCheckin(): Promise<CheckinStatus> {
|
|
397
|
-
// `setting_info` is the call the website makes on load; it is also the one
|
|
398
|
-
// that carries the point-grant popups. Ask as the web client so the server
|
|
399
|
-
// treats it as a normal page visit rather than a desktop API call.
|
|
400
|
-
await this.authorizedJson('/office/v3/setting_info', {}, {
|
|
401
|
-
'x-client-platform': 'web',
|
|
402
|
-
'x-client-version': '131.0.0.0',
|
|
403
|
-
})
|
|
404
|
-
const grant = await this.dailyGrant()
|
|
405
|
-
if (grant === undefined) {
|
|
406
|
-
return {
|
|
407
|
-
available: true,
|
|
408
|
-
claimed: false,
|
|
409
|
-
deviceClaimed: false,
|
|
410
|
-
message: '每日积分尚未到账;请打开网页签到',
|
|
411
|
-
url: RACCOON_CHECKIN_URL,
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
this.invalidateQuota()
|
|
415
|
-
return {
|
|
416
|
-
available: true,
|
|
417
|
-
claimed: true,
|
|
418
|
-
deviceClaimed: false,
|
|
419
|
-
...grant.points === undefined ? {} : { reward: grant.points },
|
|
420
|
-
...grant.grantedAt === undefined ? {} : { grantedAt: grant.grantedAt },
|
|
421
|
-
message: '每日积分已到账',
|
|
422
|
-
url: RACCOON_CHECKIN_URL,
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
/**
|
|
427
|
-
* One automatic check-in pass: report today's grant, or ask the website path
|
|
428
|
-
* to issue it. Shared so overlapping timers cannot spend two call slots.
|
|
429
|
-
*/
|
|
430
|
-
async ensureDailyCheckin(): Promise<CheckinStatus> {
|
|
431
|
-
if (this.checkinInflight !== undefined) return this.checkinInflight
|
|
432
|
-
this.checkinInflight = (async () => {
|
|
433
|
-
const status = await this.checkinStatus()
|
|
434
|
-
return status.claimed ? status : this.claimCheckin()
|
|
435
|
-
})()
|
|
436
|
-
try {
|
|
437
|
-
return await this.checkinInflight
|
|
438
|
-
} finally {
|
|
439
|
-
this.checkinInflight = undefined
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
|
|
443
537
|
async *stream(
|
|
444
538
|
options: GenerateOptions,
|
|
445
539
|
model: string,
|
|
@@ -465,7 +559,7 @@ export class RaccoonProvider {
|
|
|
465
559
|
try {
|
|
466
560
|
yield* openAiStream({
|
|
467
561
|
url: `${RACCOON_BASE}/llm/v2/chat/completions`,
|
|
468
|
-
headers: this.
|
|
562
|
+
headers: await this.chatHeaders(token, model),
|
|
469
563
|
body,
|
|
470
564
|
signal: options.signal,
|
|
471
565
|
fetchImpl: this.options.fetchImpl,
|
|
@@ -474,11 +568,30 @@ export class RaccoonProvider {
|
|
|
474
568
|
this.invalidateQuota()
|
|
475
569
|
onUnauthorized?.()
|
|
476
570
|
},
|
|
571
|
+
// The same wall arrives inside a 200 stream as an error frame. Without
|
|
572
|
+
// this hook it would surface as a generic 502 and read as a plugin bug.
|
|
573
|
+
onFrame: (frame) => {
|
|
574
|
+
const failure = frameFailure(frame)
|
|
575
|
+
if (failure === undefined) return
|
|
576
|
+
const detail = failure.message ?? failure.code ?? '上游返回错误'
|
|
577
|
+
throw isRaccoonRateLimited(undefined, `${failure.code ?? ''} ${detail}`)
|
|
578
|
+
? raccoonRateLimited(detail)
|
|
579
|
+
: new ProviderHttpError(detail, 502)
|
|
580
|
+
},
|
|
477
581
|
})
|
|
478
582
|
} catch (error) {
|
|
479
583
|
if (error instanceof ProviderHttpError && (error.status === 401 || error.status === 403)) {
|
|
480
584
|
throw new Error('小浣熊登录已失效,请在设置页点「打开扫码登录」。')
|
|
481
585
|
}
|
|
586
|
+
// The HTTP path: `openAiStream` throws the status before any frame is read.
|
|
587
|
+
// The in-band path already rewrote its own error, so it is left alone.
|
|
588
|
+
// Only the status is trusted here: matching on the wording would re-wrap a
|
|
589
|
+
// 502 that the in-band hook deliberately classified as a gateway failure.
|
|
590
|
+
if (error instanceof ProviderHttpError
|
|
591
|
+
&& !(error instanceof RaccoonRateLimitedError)
|
|
592
|
+
&& error.status === 429) {
|
|
593
|
+
throw raccoonRateLimited(error.message)
|
|
594
|
+
}
|
|
482
595
|
throw error
|
|
483
596
|
}
|
|
484
597
|
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The local Trae desktop client's sign-in state.
|
|
3
|
+
*
|
|
4
|
+
* Trae's check-in route insists on the device id its own client registered, so
|
|
5
|
+
* the website login cannot use it. When the client is installed and already
|
|
6
|
+
* signed in, the plugin can reuse that state instead:
|
|
7
|
+
*
|
|
8
|
+
* - the `Cloud-IDE-JWT` stored under `iCubeAuthInfo://icube.cloudide`;
|
|
9
|
+
* - the 16-digit device id encoded in the `iCubeAuthInfo://icube-dc:<id>` key;
|
|
10
|
+
* - `telemetry.machineId`, the value the client sends as `x-machine-id`.
|
|
11
|
+
*
|
|
12
|
+
* The stored value uses the client's own AES-CBC envelope: six magic bytes, a
|
|
13
|
+
* 32-byte key material, then the ciphertext. The AES key and IV are derived
|
|
14
|
+
* from that key material with SHA-512 and a fixed salt, and the plaintext is a
|
|
15
|
+
* SHA-512 digest followed by the JSON document. Nothing here writes to the
|
|
16
|
+
* client, and the token is never returned by the settings API.
|
|
17
|
+
*
|
|
18
|
+
* @module dsh-llm-adapters/trae-client
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { createDecipheriv, createHash } from 'node:crypto'
|
|
22
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
23
|
+
import { homedir } from 'node:os'
|
|
24
|
+
import { join } from 'node:path'
|
|
25
|
+
import type { TraeRegion } from './brand.ts'
|
|
26
|
+
import type { ClientLoginStatus } from './types.ts'
|
|
27
|
+
|
|
28
|
+
/** What the provider stores after importing a client login. */
|
|
29
|
+
export interface TraeClientLoginState {
|
|
30
|
+
region: TraeRegion
|
|
31
|
+
token: string
|
|
32
|
+
deviceId: string
|
|
33
|
+
machineId: string
|
|
34
|
+
userId?: string
|
|
35
|
+
name?: string
|
|
36
|
+
expiresAt?: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The settings-facing half (no token) plus the internal state when usable. */
|
|
40
|
+
export interface TraeClientDetection {
|
|
41
|
+
status: ClientLoginStatus
|
|
42
|
+
state?: TraeClientLoginState
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface TraeClientReadOptions {
|
|
46
|
+
platform?: NodeJS.Platform
|
|
47
|
+
env?: NodeJS.ProcessEnv
|
|
48
|
+
home?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const AUTH_PROVIDER_KEY = 'iCubeAuthInfo://icube.cloudide'
|
|
52
|
+
const DEVICE_KEY_PREFIX = 'iCubeAuthInfo://icube-dc:'
|
|
53
|
+
const MACHINE_KEY = 'telemetry.machineId'
|
|
54
|
+
const SERVICE_MACHINE_KEY = 'storage.serviceMachineId'
|
|
55
|
+
|
|
56
|
+
const AES_MAGIC = Buffer.from([0x74, 0x63, 0x05, 0x10, 0x00, 0x00])
|
|
57
|
+
const AES_PRIVATE_MAGIC = Buffer.from([0x12, 0x39, 0x20, 0x20, 0x02, 0x03])
|
|
58
|
+
const KEY_OFFSET = 6
|
|
59
|
+
const KEY_LENGTH = 32
|
|
60
|
+
const HASH_LENGTH = 64
|
|
61
|
+
/** Aue ^ Due from the client's byteCrypto module. */
|
|
62
|
+
const AES_SALT = Buffer.from(
|
|
63
|
+
'4dd4c2e6b83162090e52b3c7a6733ba41cb2462b829ab58a196b39db57177524'
|
|
64
|
+
+ 'f49baf7f08e8d68d26a72e37c1a95a2f1f05a51892aef2949732b62a38aadd58',
|
|
65
|
+
'hex',
|
|
66
|
+
)
|
|
67
|
+
/** Tue ^ Iue, the AES_PRIVATE variant of the same envelope. */
|
|
68
|
+
const AES_PRIVATE_SALT = Buffer.from(
|
|
69
|
+
'490cc21292b05d0cc06ccbe91fb92e21b5a7c538811dc85e27a96e28104e1cf6'
|
|
70
|
+
+ '9c7166efab232807390da78a8b91e49c44b49a43feb941a1e46f2656e613e47b',
|
|
71
|
+
'hex',
|
|
72
|
+
)
|
|
73
|
+
const DEVICE_RE = /^\d{16}$/
|
|
74
|
+
|
|
75
|
+
function record(value: unknown): Record<string, unknown> | undefined {
|
|
76
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
77
|
+
? value as Record<string, unknown>
|
|
78
|
+
: undefined
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function text(value: unknown): string | undefined {
|
|
82
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Accept epoch seconds, epoch milliseconds, or an ISO date. */
|
|
86
|
+
function epochMs(value: unknown): number | undefined {
|
|
87
|
+
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
|
88
|
+
return value < 1e12 ? Math.round(value * 1_000) : Math.round(value)
|
|
89
|
+
}
|
|
90
|
+
if (typeof value === 'string') {
|
|
91
|
+
if (/^\d+$/.test(value.trim())) return epochMs(Number(value))
|
|
92
|
+
const parsed = Date.parse(value)
|
|
93
|
+
return Number.isFinite(parsed) ? parsed : undefined
|
|
94
|
+
}
|
|
95
|
+
return undefined
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function jwtExpiry(token: string): number | undefined {
|
|
99
|
+
const parts = token.split('.')
|
|
100
|
+
if (parts.length < 2) return undefined
|
|
101
|
+
try {
|
|
102
|
+
const base64 = parts[1]!.replace(/-/g, '+').replace(/_/g, '/')
|
|
103
|
+
const padded = base64 + '='.repeat((4 - base64.length % 4) % 4)
|
|
104
|
+
const payload: unknown = JSON.parse(Buffer.from(padded, 'base64').toString('utf8'))
|
|
105
|
+
return epochMs(record(payload)?.exp)
|
|
106
|
+
} catch {
|
|
107
|
+
return undefined
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Open one `iCubeAuthInfo` value.
|
|
113
|
+
*
|
|
114
|
+
* The digest check is the client's own integrity check: the first 64 plaintext
|
|
115
|
+
* bytes are SHA-512 of the document that follows. A malformed or changed
|
|
116
|
+
* envelope is rejected instead of being parsed as garbage.
|
|
117
|
+
*/
|
|
118
|
+
function decryptAuthValue(value: string): unknown {
|
|
119
|
+
const bytes = Buffer.from(value, 'base64')
|
|
120
|
+
if (bytes.length < KEY_OFFSET + KEY_LENGTH + HASH_LENGTH) {
|
|
121
|
+
throw new Error('数据长度不足')
|
|
122
|
+
}
|
|
123
|
+
const magic = bytes.subarray(0, KEY_OFFSET)
|
|
124
|
+
const salt = magic.equals(AES_MAGIC)
|
|
125
|
+
? AES_SALT
|
|
126
|
+
: magic.equals(AES_PRIVATE_MAGIC) ? AES_PRIVATE_SALT : undefined
|
|
127
|
+
if (salt === undefined) throw new Error('未知的加密格式')
|
|
128
|
+
const keyMaterial = bytes.subarray(KEY_OFFSET, KEY_OFFSET + KEY_LENGTH)
|
|
129
|
+
const mixed = createHash('sha512').update(keyMaterial).digest()
|
|
130
|
+
const derived = createHash('sha512').update(Buffer.concat([mixed, salt])).digest()
|
|
131
|
+
const decipher = createDecipheriv(
|
|
132
|
+
'aes-128-cbc',
|
|
133
|
+
derived.subarray(0, 16),
|
|
134
|
+
derived.subarray(16, 32),
|
|
135
|
+
)
|
|
136
|
+
const plain = Buffer.concat([
|
|
137
|
+
decipher.update(bytes.subarray(KEY_OFFSET + KEY_LENGTH)),
|
|
138
|
+
decipher.final(),
|
|
139
|
+
])
|
|
140
|
+
if (plain.length < HASH_LENGTH) throw new Error('解密结果为空')
|
|
141
|
+
const body = plain.subarray(HASH_LENGTH)
|
|
142
|
+
const digest = createHash('sha512').update(body).digest()
|
|
143
|
+
if (!digest.equals(plain.subarray(0, HASH_LENGTH))) throw new Error('校验失败')
|
|
144
|
+
return JSON.parse(body.toString('utf8'))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The client's data directory, most specific first.
|
|
149
|
+
*
|
|
150
|
+
* `DSH_TRAE_CLIENT_DIR` is a portable/test override; the region-specific
|
|
151
|
+
* variables let one machine keep separate CN and international installs.
|
|
152
|
+
*/
|
|
153
|
+
export function traeClientDataDirs(
|
|
154
|
+
region: TraeRegion,
|
|
155
|
+
options: TraeClientReadOptions = {},
|
|
156
|
+
): string[] {
|
|
157
|
+
const platform = options.platform ?? process.platform
|
|
158
|
+
const env = options.env ?? process.env
|
|
159
|
+
const home = options.home ?? homedir()
|
|
160
|
+
const name = region === 'cn' ? 'Trae CN' : 'Trae'
|
|
161
|
+
const out: string[] = []
|
|
162
|
+
const add = (directory: string | undefined): void => {
|
|
163
|
+
if (directory !== undefined && directory !== '') out.push(directory)
|
|
164
|
+
}
|
|
165
|
+
add(env[region === 'cn' ? 'DSH_TRAE_CN_CLIENT_DIR' : 'DSH_TRAE_GLOBAL_CLIENT_DIR'])
|
|
166
|
+
add(env['DSH_TRAE_CLIENT_DIR'])
|
|
167
|
+
if (platform === 'win32') {
|
|
168
|
+
const appData = env['APPDATA'] ?? join(home, 'AppData', 'Roaming')
|
|
169
|
+
add(join(appData, name))
|
|
170
|
+
} else if (platform === 'darwin') {
|
|
171
|
+
add(join(home, 'Library', 'Application Support', name))
|
|
172
|
+
} else {
|
|
173
|
+
const config = env['XDG_CONFIG_HOME'] ?? join(home, '.config')
|
|
174
|
+
add(join(config, name))
|
|
175
|
+
}
|
|
176
|
+
return [...new Set(out)]
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function storageFile(userDataDir: string): string {
|
|
180
|
+
return join(userDataDir, 'User', 'globalStorage', 'storage.json')
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function authValueOf(storage: Record<string, unknown>): string | undefined {
|
|
184
|
+
const direct = text(storage[AUTH_PROVIDER_KEY])
|
|
185
|
+
if (direct !== undefined) return direct
|
|
186
|
+
for (const key of Object.keys(storage)) {
|
|
187
|
+
if (key.startsWith('iCubeAuthInfo://') && key.endsWith('.cloudide')) {
|
|
188
|
+
const value = text(storage[key])
|
|
189
|
+
if (value !== undefined) return value
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return undefined
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The client has one device id per machine; the last valid key is current. */
|
|
196
|
+
function deviceIdOf(storage: Record<string, unknown>): string | undefined {
|
|
197
|
+
const keys = Object.keys(storage).filter(key => key.startsWith(DEVICE_KEY_PREFIX))
|
|
198
|
+
for (let index = keys.length - 1; index >= 0; index--) {
|
|
199
|
+
const value = text(keys[index]!.slice(DEVICE_KEY_PREFIX.length))
|
|
200
|
+
if (value !== undefined && DEVICE_RE.test(value)) return value
|
|
201
|
+
}
|
|
202
|
+
return undefined
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function regionMismatch(region: TraeRegion, userInfo: Record<string, unknown>): boolean {
|
|
206
|
+
const account = record(userInfo.account)
|
|
207
|
+
const userRegion = record(userInfo.userRegion)
|
|
208
|
+
const evidence = (text(userRegion?.region) ?? text(account?.storeRegion))?.toUpperCase()
|
|
209
|
+
if (evidence === undefined) return false
|
|
210
|
+
return region === 'cn' ? evidence !== 'CN' : evidence === 'CN'
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Parse one `storage.json` document; exported for tests and diagnostics. */
|
|
214
|
+
export function parseTraeClientStorage(
|
|
215
|
+
region: TraeRegion,
|
|
216
|
+
storage: Record<string, unknown>,
|
|
217
|
+
): TraeClientDetection {
|
|
218
|
+
const encrypted = authValueOf(storage)
|
|
219
|
+
if (encrypted === undefined) {
|
|
220
|
+
return {
|
|
221
|
+
status: {
|
|
222
|
+
available: true,
|
|
223
|
+
signedIn: false,
|
|
224
|
+
error: '本机 Trae 客户端尚未登录。请先在客户端完成登录。',
|
|
225
|
+
},
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
let parsed: unknown
|
|
229
|
+
try {
|
|
230
|
+
parsed = decryptAuthValue(encrypted)
|
|
231
|
+
} catch (error) {
|
|
232
|
+
return {
|
|
233
|
+
status: {
|
|
234
|
+
available: true,
|
|
235
|
+
signedIn: false,
|
|
236
|
+
error: `读取本机 Trae 客户端登录态失败:${error instanceof Error ? error.message : String(error)}`,
|
|
237
|
+
},
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const userInfo = record(parsed)
|
|
241
|
+
const token = text(userInfo?.token)
|
|
242
|
+
if (userInfo === undefined || token === undefined) {
|
|
243
|
+
return {
|
|
244
|
+
status: {
|
|
245
|
+
available: true,
|
|
246
|
+
signedIn: false,
|
|
247
|
+
error: '本机 Trae 客户端登录态里没有令牌,请先在客户端重新登录。',
|
|
248
|
+
},
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const account = record(userInfo.account)
|
|
252
|
+
const name = text(account?.username) ?? text(account?.name)
|
|
253
|
+
const userId = text(userInfo.userId)
|
|
254
|
+
const expiresAt = epochMs(userInfo.expiredAt) ?? jwtExpiry(token)
|
|
255
|
+
const identity = {
|
|
256
|
+
...name === undefined ? {} : { name },
|
|
257
|
+
...userId === undefined ? {} : { userId },
|
|
258
|
+
...expiresAt === undefined ? {} : { expiresAt },
|
|
259
|
+
}
|
|
260
|
+
const unavailable = (error: string): TraeClientDetection => ({
|
|
261
|
+
status: { available: true, signedIn: false, ...identity, error },
|
|
262
|
+
})
|
|
263
|
+
if (regionMismatch(region, userInfo)) {
|
|
264
|
+
return unavailable(region === 'cn'
|
|
265
|
+
? '本机 Trae 客户端登录的是国际版账号,请切换到国际版后再复用。'
|
|
266
|
+
: '本机 Trae 客户端登录的是国内版账号,请切换到国内版后再复用。')
|
|
267
|
+
}
|
|
268
|
+
if (expiresAt !== undefined && expiresAt <= Date.now()) {
|
|
269
|
+
return unavailable('本机 Trae 客户端登录已过期,请先在客户端重新登录。')
|
|
270
|
+
}
|
|
271
|
+
const deviceId = deviceIdOf(storage)
|
|
272
|
+
if (deviceId === undefined) {
|
|
273
|
+
return unavailable('本机 Trae 客户端登录缺少 16 位设备号,请先在客户端重新登录。')
|
|
274
|
+
}
|
|
275
|
+
const machineId = text(storage[MACHINE_KEY]) ?? text(storage[SERVICE_MACHINE_KEY])
|
|
276
|
+
if (machineId === undefined) {
|
|
277
|
+
return unavailable('本机 Trae 客户端登录缺少机器码,请先在客户端重新登录。')
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
status: { available: true, signedIn: true, ...identity },
|
|
281
|
+
state: { region, token, deviceId, machineId, ...identity },
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Read the currently selected region's client state from this machine. */
|
|
286
|
+
export function readTraeClientLogin(
|
|
287
|
+
region: TraeRegion,
|
|
288
|
+
options: TraeClientReadOptions = {},
|
|
289
|
+
): TraeClientDetection {
|
|
290
|
+
let available = false
|
|
291
|
+
let error: string | undefined
|
|
292
|
+
for (const directory of traeClientDataDirs(region, options)) {
|
|
293
|
+
const file = storageFile(directory)
|
|
294
|
+
if (!existsSync(file)) continue
|
|
295
|
+
available = true
|
|
296
|
+
try {
|
|
297
|
+
const raw = readFileSync(file, 'utf8').replace(/^\uFEFF/, '')
|
|
298
|
+
const storage = record(JSON.parse(raw))
|
|
299
|
+
if (storage === undefined) throw new Error('storage.json 不是对象')
|
|
300
|
+
return parseTraeClientStorage(region, storage)
|
|
301
|
+
} catch (cause) {
|
|
302
|
+
error = `读取本机 Trae 客户端登录态失败:${cause instanceof Error ? cause.message : String(cause)}`
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return {
|
|
306
|
+
status: {
|
|
307
|
+
available,
|
|
308
|
+
signedIn: false,
|
|
309
|
+
...error === undefined ? {} : { error },
|
|
310
|
+
},
|
|
311
|
+
}
|
|
312
|
+
}
|
package/src/trae-endpoints.ts
CHANGED
|
@@ -35,6 +35,8 @@ export interface TraeRegionProfile {
|
|
|
35
35
|
* the dollar allowance instead.
|
|
36
36
|
*/
|
|
37
37
|
usageV2: boolean
|
|
38
|
+
/** Daily check-in exists only on the mainland deployment. */
|
|
39
|
+
checkin: boolean
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
export const TRAE_REGION_PROFILES: Record<TraeRegion, TraeRegionProfile> = {
|
|
@@ -49,6 +51,7 @@ export const TRAE_REGION_PROFILES: Record<TraeRegion, TraeRegionProfile> = {
|
|
|
49
51
|
timezone: 'Asia/Shanghai',
|
|
50
52
|
language: 'zh-cn',
|
|
51
53
|
usageV2: true,
|
|
54
|
+
checkin: true,
|
|
52
55
|
},
|
|
53
56
|
global: {
|
|
54
57
|
id: 'global',
|
|
@@ -61,6 +64,7 @@ export const TRAE_REGION_PROFILES: Record<TraeRegion, TraeRegionProfile> = {
|
|
|
61
64
|
timezone: 'Asia/Singapore',
|
|
62
65
|
language: 'en',
|
|
63
66
|
usageV2: false,
|
|
67
|
+
checkin: false,
|
|
64
68
|
},
|
|
65
69
|
}
|
|
66
70
|
|
package/src/trae-login.ts
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Trae sign-in through the provider's own website.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* This flow does not read a desktop install: it opens a dedicated browser
|
|
5
5
|
* profile, lets the user sign in on trae.cn / trae.ai, and then takes the
|
|
6
6
|
* session the website itself created. Two things come out of that session:
|
|
7
7
|
*
|
|
8
8
|
* - the `Cloud-IDE-JWT` token, fetched with the page's own cookies through
|
|
9
9
|
* `/cloudide/api/v3/common/GetUserToken` — the same call the site makes;
|
|
10
10
|
* - the device fingerprint (`__msuuid__` from the page, or one generated in
|
|
11
|
-
* the page),
|
|
12
|
-
*
|
|
11
|
+
* the page), stored with the web login.
|
|
12
|
+
*
|
|
13
|
+
* The check-in route does not accept this web fingerprint, so check-in is
|
|
14
|
+
* enabled only for the separate client-login import in `trae-client.ts`.
|
|
13
15
|
*
|
|
14
16
|
* Cookies are captured over the DevTools protocol and kept in the plugin's own
|
|
15
17
|
* account store so the short-lived JWT can be renewed later; the dedicated
|