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/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.headers(token),
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
+ }
@@ -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
- * The plugin does not read a desktop install: it opens a dedicated browser
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), which Trae's check-in route requires and which therefore must
12
- * belong to this web session rather than to a machine install.
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