dsh-connect-trae 3.0.0 → 3.3.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/CHANGELOG.md +196 -0
- package/README.en.md +3 -2
- package/README.md +4 -2
- package/lib/client.js +966 -341
- package/lib/index.d.ts +227 -11
- package/lib/index.js +711 -223
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -4,11 +4,11 @@ import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completio
|
|
|
4
4
|
import { resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
|
|
5
5
|
import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
|
|
6
6
|
import { readFile, rm, stat } from "node:fs/promises";
|
|
7
|
-
import { createDecipheriv, createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
7
|
+
import { createDecipheriv, createHash, randomBytes, randomUUID, sign, timingSafeEqual } from "node:crypto";
|
|
8
8
|
import { dirname, join } from "node:path";
|
|
9
9
|
import { withFileLock, writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
|
|
10
10
|
import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
|
|
11
|
-
import { cpus, homedir, hostname, release } from "node:os";
|
|
11
|
+
import { cpus, homedir, hostname, release, userInfo } from "node:os";
|
|
12
12
|
import { createServer } from "node:http";
|
|
13
13
|
import { Readable } from "node:stream";
|
|
14
14
|
import { execFile } from "node:child_process";
|
|
@@ -452,6 +452,213 @@ function createTraeAdapter(options) {
|
|
|
452
452
|
};
|
|
453
453
|
}
|
|
454
454
|
//#endregion
|
|
455
|
+
//#region src/status-paths.ts
|
|
456
|
+
/** Plugin-owned usage endpoint consumed by its browser half. */
|
|
457
|
+
const TRAE_USAGE_PATH = "/plugins/dsh-connect-trae/usage";
|
|
458
|
+
/** Plugin-owned live model refresh endpoint. */
|
|
459
|
+
const TRAE_MODELS_REFRESH_PATH = "/plugins/dsh-connect-trae/models/refresh";
|
|
460
|
+
/**
|
|
461
|
+
* Plugin-owned availability probe: POST the model ids to test, get one verdict
|
|
462
|
+
* per model. Unlike every read-only sibling this route SPENDS quota — a real
|
|
463
|
+
* (minimal) chat call per model — so it is user-initiated only, and the card
|
|
464
|
+
* labels the button as consuming a small amount.
|
|
465
|
+
*/
|
|
466
|
+
const TRAE_MODELS_TEST_PATH = "/plugins/dsh-connect-trae/models/test";
|
|
467
|
+
/** Plugin-owned local account rescan endpoint. */
|
|
468
|
+
const TRAE_ACCOUNTS_REFRESH_PATH = "/plugins/dsh-connect-trae/accounts/refresh";
|
|
469
|
+
/**
|
|
470
|
+
* General balance of EVERY account in a region, for the composer panel's table.
|
|
471
|
+
*
|
|
472
|
+
* Separate from the usage route on purpose: the usage document backs the
|
|
473
|
+
* 5-minute readout, and multiplying its upstream reads by the account count
|
|
474
|
+
* would charge a healthy account for something it never looks at — the same
|
|
475
|
+
* principle that keeps `creditAlternatives` firing only when a balance hits
|
|
476
|
+
* zero. This one is paid when the panel is actually opened, which is the only
|
|
477
|
+
* place the table is shown.
|
|
478
|
+
*/
|
|
479
|
+
const TRAE_ACCOUNT_CREDITS_PATH = "/plugins/dsh-connect-trae/account-credits";
|
|
480
|
+
/**
|
|
481
|
+
* Per-account status (check-in / credits / subscription) for the account table.
|
|
482
|
+
*
|
|
483
|
+
* Each row of the account table reads its OWN state through this route — a CN
|
|
484
|
+
* row carries its check-in state and balance, an ai row its subscription.
|
|
485
|
+
* POST with the accountId in the body: one upstream read per account, paid
|
|
486
|
+
* only when the table is actually shown.
|
|
487
|
+
*/
|
|
488
|
+
const TRAE_ACCOUNT_STATUS_PATH = "/plugins/dsh-connect-trae/account-status";
|
|
489
|
+
/**
|
|
490
|
+
* Plugin-owned daily check-in claim endpoint. POST, loopback-only, and the only
|
|
491
|
+
* route in this plugin that changes upstream account state. The accountId in
|
|
492
|
+
* the body names WHICH account to claim for, so the button can sit in each
|
|
493
|
+
* account row.
|
|
494
|
+
*/
|
|
495
|
+
const TRAE_CHECKIN_PATH = "/plugins/dsh-connect-trae/checkin";
|
|
496
|
+
/** Query parameter naming the region a card request addresses. */
|
|
497
|
+
const TRAE_REGION_PARAM = "region";
|
|
498
|
+
/** Every region, in card tab order. */
|
|
499
|
+
const TRAE_REGIONS = ["cn", "ai"];
|
|
500
|
+
/**
|
|
501
|
+
* Address one region's status route. The two regions are separate provider
|
|
502
|
+
* stacks; every card request carries the region whose tab the user is on.
|
|
503
|
+
*/
|
|
504
|
+
function withTraeRegion(path, region) {
|
|
505
|
+
return `${path}?${TRAE_REGION_PARAM}=${region}`;
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Read the region parameter off a status-route URL. Absent means the domestic
|
|
509
|
+
* tab (`cn`); a present-but-unknown value returns undefined so the route can
|
|
510
|
+
* answer 400 instead of guessing.
|
|
511
|
+
*/
|
|
512
|
+
function regionOfTraeStatusUrl(url) {
|
|
513
|
+
const at = url.indexOf("?");
|
|
514
|
+
const value = at === -1 ? null : new URLSearchParams(url.slice(at + 1)).get(TRAE_REGION_PARAM);
|
|
515
|
+
if (value === null || value === "") return "cn";
|
|
516
|
+
return TRAE_REGIONS.includes(value) ? value : void 0;
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Peel ONE `{get(): T}` live reference, the shape DSH 0.1.7 delivers a
|
|
520
|
+
* volatile-marked settings field as (see {@link unwrapVolatileDeep}).
|
|
521
|
+
*/
|
|
522
|
+
function unwrapVolatile(value) {
|
|
523
|
+
if (value !== null && typeof value === "object" && typeof value.get === "function") return value.get();
|
|
524
|
+
return value;
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Deep copy of a value with every `{get(): T}` live reference replaced by the
|
|
528
|
+
* value it resolves to.
|
|
529
|
+
*
|
|
530
|
+
* DSH 0.1.7 hands volatile-marked fields back as live references, and a live
|
|
531
|
+
* reference is still `typeof === 'object'` — so it passes a naive object check
|
|
532
|
+
* and every lookup on it is `undefined`. Worse, spreading one (`{ ...ref }`)
|
|
533
|
+
* does NOT read the field: it produces `{ get: <function> }`. Any merge that
|
|
534
|
+
* spreads a resolved field to preserve its siblings — the card's "write one
|
|
535
|
+
* region, keep the other" write, or the account map's spread — would otherwise
|
|
536
|
+
* DROP every sibling and leak a function into the document.
|
|
537
|
+
*
|
|
538
|
+
* Both halves therefore unwrap before touching a resolved field. This lives in
|
|
539
|
+
* the node-free host↔client bridge because the browser half needs it too.
|
|
540
|
+
*
|
|
541
|
+
* Non-reference values are recursed into so a nested volatile field is caught
|
|
542
|
+
* as well; arrays and objects are rebuilt rather than mutated, so the caller's
|
|
543
|
+
* value is never touched.
|
|
544
|
+
*/
|
|
545
|
+
function unwrapVolatileDeep(value) {
|
|
546
|
+
if (value === null || typeof value !== "object") return value;
|
|
547
|
+
if (typeof value.get === "function") return unwrapVolatileDeep(value.get());
|
|
548
|
+
if (Array.isArray(value)) return value.map((entry) => unwrapVolatileDeep(entry));
|
|
549
|
+
const source = value;
|
|
550
|
+
const out = {};
|
|
551
|
+
for (const key of Object.keys(source)) out[key] = unwrapVolatileDeep(source[key]);
|
|
552
|
+
return out;
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Read the per-region account selections out of a settings value.
|
|
556
|
+
*
|
|
557
|
+
* SHARED because `accounts` now has TWO writers: the card, and the composer
|
|
558
|
+
* panel's account table (2.14.0). Two writers of one field must use one merge
|
|
559
|
+
* rule, or they drift — and the rule is not obvious.
|
|
560
|
+
*
|
|
561
|
+
* Deep-unwraps first: on DSH 0.1.7 a volatile field arrives as a `{get(): T}`
|
|
562
|
+
* live reference, which passes the `typeof === 'object'` check and would be
|
|
563
|
+
* returned as if it were the map. Every lookup on it is then `undefined`, and
|
|
564
|
+
* the merge (`{ ...configured, [region]: id }`) would spread a reference into
|
|
565
|
+
* `{get: <function>}` — dropping the other region's selection and leaking a
|
|
566
|
+
* function into the settings document.
|
|
567
|
+
*/
|
|
568
|
+
function configuredAccountsOf(configured) {
|
|
569
|
+
const accounts = unwrapVolatileDeep(configured)?.accounts;
|
|
570
|
+
return typeof accounts === "object" && accounts !== null ? accounts : {};
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Whether the host-side "follow the Trae app's sign-in" switch is on (issue
|
|
574
|
+
* #29). Absent reads as OFF, so an existing configuration keeps the strict
|
|
575
|
+
* bind it shipped with and the switch is an opt-in — the same opt-out
|
|
576
|
+
* semantics as {@link regionEnabledOf}, for the same reason: a config written
|
|
577
|
+
* before this field existed cannot ask for behavior it never declared.
|
|
578
|
+
*
|
|
579
|
+
* Reads the whole settings section (or the bare field); deep-unwraps first
|
|
580
|
+
* because on DSH 0.1.7 a volatile field arrives as a `{get(): T}` live
|
|
581
|
+
* reference, whose `typeof === 'object'` would otherwise pass a naive check.
|
|
582
|
+
*/
|
|
583
|
+
function followAppLoginEnabled(configured) {
|
|
584
|
+
return unwrapVolatileDeep(configured)?.followAppLogin === true;
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Build the next `regions` settings value for the card's save. The write
|
|
588
|
+
* targets ONLY the signed-in account's region slot; every other region's slot
|
|
589
|
+
* is carried over untouched, so switching accounts never clobbers the other
|
|
590
|
+
* region's picks. Tolerates any stored shape (absent, non-object) by starting
|
|
591
|
+
* from an empty document.
|
|
592
|
+
*/
|
|
593
|
+
function nextRegionSlots(regions, region, slot) {
|
|
594
|
+
const unwrapped = unwrapVolatileDeep(regions);
|
|
595
|
+
return {
|
|
596
|
+
...typeof unwrapped === "object" && unwrapped !== null && !Array.isArray(unwrapped) ? unwrapped : {},
|
|
597
|
+
[region]: slot
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Narrow a settings value to the `regions` map. Accepts EITHER the whole
|
|
602
|
+
* settings section (`{ regions: {...}, ... }`) or the `regions` map itself, and
|
|
603
|
+
* unwraps the former. This tolerance is deliberate: passing the whole section
|
|
604
|
+
* where the map was expected was a real shipped bug — the lookup then read
|
|
605
|
+
* `section['cn']` (absent), so the card's checkbox reported `true` forever and
|
|
606
|
+
* clicking it appeared to do nothing even though the write succeeded.
|
|
607
|
+
*
|
|
608
|
+
* Unwraps live references first: on DSH 0.1.7 the `regions` field arrives as a
|
|
609
|
+
* `{get(): T}` reference, which passes the `typeof === 'object'` check below
|
|
610
|
+
* and would otherwise be returned as if it were the map — making every region
|
|
611
|
+
* read back as absent (and the enabled flag read as "on" forever).
|
|
612
|
+
*/
|
|
613
|
+
function regionsMapOf(value) {
|
|
614
|
+
const unwrapped = unwrapVolatileDeep(value);
|
|
615
|
+
if (typeof unwrapped !== "object" || unwrapped === null || Array.isArray(unwrapped)) return {};
|
|
616
|
+
const record = unwrapped;
|
|
617
|
+
const nested = record["regions"];
|
|
618
|
+
if (typeof nested === "object" && nested !== null && !Array.isArray(nested)) return nested;
|
|
619
|
+
return record;
|
|
620
|
+
}
|
|
621
|
+
/** One region's stored slot as a plain object; any other shape reads as empty. */
|
|
622
|
+
function regionSlotOf(value, region) {
|
|
623
|
+
const slot = unwrapVolatile(regionsMapOf(value)[region]);
|
|
624
|
+
return typeof slot === "object" && slot !== null && !Array.isArray(slot) ? slot : {};
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Whether one region's provider is switched on. Opt-out semantics: only an
|
|
628
|
+
* explicit `false` disables it, so a config written before this switch existed
|
|
629
|
+
* (and the pre-region-split flat fields, which never carry `enabled`) keep both
|
|
630
|
+
* providers running exactly as before. The Host reads the same rule through
|
|
631
|
+
* `regionStateOf`, so card and Host can never disagree about a region's state.
|
|
632
|
+
*
|
|
633
|
+
* `value` may be the whole settings section or the `regions` map (see
|
|
634
|
+
* {@link regionsMapOf}).
|
|
635
|
+
*/
|
|
636
|
+
function regionEnabledOf(value, region) {
|
|
637
|
+
return regionSlotOf(value, region)["enabled"] !== false;
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Build the next `regions` settings value for a provider on/off toggle. ONLY
|
|
641
|
+
* the target region's `enabled` flag changes: every other field of that slot
|
|
642
|
+
* (its directory, selection, image opt-ins, context budgets) and every other
|
|
643
|
+
* region's slot are carried over verbatim, so switching a provider off never
|
|
644
|
+
* discards the user's model picks and switching it back on restores them.
|
|
645
|
+
*
|
|
646
|
+
* This is deliberately separate from {@link nextRegionSlots}: that helper
|
|
647
|
+
* writes a whole slot from a signed-in tab's draft, while this one must work
|
|
648
|
+
* for a region that is signed OUT — which is precisely the region a user wants
|
|
649
|
+
* to switch off (no international install, no international account).
|
|
650
|
+
*
|
|
651
|
+
* `value` may be the whole settings section or the `regions` map; the RETURN
|
|
652
|
+
* value is always the `regions` map, i.e. exactly what `settingsScope.set(
|
|
653
|
+
* 'regions', ...)` needs.
|
|
654
|
+
*/
|
|
655
|
+
function nextRegionEnabled(value, region, enabled) {
|
|
656
|
+
return nextRegionSlots(regionsMapOf(value), region, {
|
|
657
|
+
...regionSlotOf(value, region),
|
|
658
|
+
enabled
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
//#endregion
|
|
455
662
|
//#region src/decrypt.ts
|
|
456
663
|
const TRAE_AUTH_STORAGE_KEY = "iCubeAuthInfo://icube.cloudide";
|
|
457
664
|
const SALT_A = Uint8Array.from([
|
|
@@ -1171,13 +1378,52 @@ var TraeCredentialStore = class {
|
|
|
1171
1378
|
return this.edition === "auto" ? all.filter((candidate) => candidate.source === "desktop" || candidate.edition === cliEdition) : all.filter((candidate) => candidate.edition === this.edition && (candidate.source === "desktop" || candidate.edition === cliEdition));
|
|
1172
1379
|
}
|
|
1173
1380
|
/**
|
|
1174
|
-
* Deterministic default when no account is explicitly selected: the
|
|
1175
|
-
*
|
|
1176
|
-
*
|
|
1177
|
-
*
|
|
1381
|
+
* Deterministic default when no account is explicitly selected: the account
|
|
1382
|
+
* the Trae desktop app is signed in to RIGHT NOW, else the first discovered
|
|
1383
|
+
* one. This is NOT credit-seeking — it never reorders accounts to find one
|
|
1384
|
+
* with general credits. The plugin bills exactly the account the user
|
|
1385
|
+
* selected, the app's current sign-in when nothing has been selected, and
|
|
1386
|
+
* only then the first account.
|
|
1387
|
+
*
|
|
1388
|
+
* The app's live sign-in outranks the plugin's own refreshed copies (issue
|
|
1389
|
+
* #29): after the user switches accounts in Trae, the desktop storage file
|
|
1390
|
+
* holds the NEW login while the plugin's copy of the old one still resolves,
|
|
1391
|
+
* so a bare `credentials[0]` kept defaulting to a sign-in the app has
|
|
1392
|
+
* abandoned — and `dsh-connect-workbuddy`, whose `preferred()` ranks the live
|
|
1393
|
+
* file first, followed the app instead. Ordering only: the VALUE behind an
|
|
1394
|
+
* account id is still whichever copy expires later (`readAll`), so a refresh
|
|
1395
|
+
* the plugin performed is never discarded by this preference.
|
|
1178
1396
|
*/
|
|
1179
1397
|
preferred(credentials) {
|
|
1180
|
-
return credentials[0];
|
|
1398
|
+
return credentials.find((credential) => credential.source === "desktop") ?? credentials[0];
|
|
1399
|
+
}
|
|
1400
|
+
/**
|
|
1401
|
+
* The account the Trae app is signed in to right now, when that differs from
|
|
1402
|
+
* the account this store is bound to (issue #29).
|
|
1403
|
+
*
|
|
1404
|
+
* This is a READ, not a rebind: `current()` keeps resolving the bound
|
|
1405
|
+
* account verbatim so billing never moves without a decision — the store
|
|
1406
|
+
* only reports the mismatch, and the card (hint row) or the host (follow
|
|
1407
|
+
* switch) turns it into a settings write the user can see and undo. Absent
|
|
1408
|
+
* when nothing is live, when the live account IS the bound one, or when the
|
|
1409
|
+
* binding itself already fell through to the live account.
|
|
1410
|
+
*/
|
|
1411
|
+
async liveLogin() {
|
|
1412
|
+
if (this.accountId === void 0 || this.accountId === "") return void 0;
|
|
1413
|
+
const credentials = await this.readAll();
|
|
1414
|
+
const live = this.preferred(credentials);
|
|
1415
|
+
if (live === void 0) return void 0;
|
|
1416
|
+
const liveId = traeAccountId(live);
|
|
1417
|
+
if (liveId === this.accountId) return void 0;
|
|
1418
|
+
return {
|
|
1419
|
+
id: liveId,
|
|
1420
|
+
accountName: live.accountName ?? (live.userId || `${live.edition} account`),
|
|
1421
|
+
edition: live.edition,
|
|
1422
|
+
region: regionOfCredential(live),
|
|
1423
|
+
source: live.source,
|
|
1424
|
+
tokenExpiresAtMs: live.expiresAtMs,
|
|
1425
|
+
selected: false
|
|
1426
|
+
};
|
|
1181
1427
|
}
|
|
1182
1428
|
async accounts() {
|
|
1183
1429
|
const credentials = await this.readAll();
|
|
@@ -1401,26 +1647,35 @@ var TraeCredentialStore = class {
|
|
|
1401
1647
|
};
|
|
1402
1648
|
//#endregion
|
|
1403
1649
|
//#region src/refresh.ts
|
|
1404
|
-
|
|
1650
|
+
/** Path of the current token-exchange endpoint, relative to the credential host. */
|
|
1651
|
+
const TRAE_EXCHANGE_TOKEN_PATH = "/trae/api/v3/oauth/ExchangeToken";
|
|
1652
|
+
/** Path of the superseded endpoint; only reachable as the fallback. */
|
|
1653
|
+
const TRAE_LEGACY_EXCHANGE_TOKEN_PATH = "/cloudide/api/v3/trae/oauth/ExchangeToken";
|
|
1654
|
+
/** Shared desktop ClientID; the current contract also uses it for `cn` / `sg`. */
|
|
1655
|
+
const DESKTOP_CLIENT_ID = "ono9krqynydwx5";
|
|
1656
|
+
/** SOLO ClientID; every SOLO package uses it, CN and international alike. */
|
|
1657
|
+
const SOLO_CLIENT_ID = "en1oxy7wnw8j9n";
|
|
1658
|
+
/** The current contract: `/trae/api/v3`, per-edition ClientID, always proofed. */
|
|
1659
|
+
const CURRENT_CONTRACT = {
|
|
1405
1660
|
cn: {
|
|
1406
|
-
path:
|
|
1407
|
-
clientId:
|
|
1408
|
-
|
|
1661
|
+
path: TRAE_EXCHANGE_TOKEN_PATH,
|
|
1662
|
+
clientId: DESKTOP_CLIENT_ID,
|
|
1663
|
+
platformCode: "IDE_PC"
|
|
1409
1664
|
},
|
|
1410
1665
|
sg: {
|
|
1411
|
-
path:
|
|
1412
|
-
clientId:
|
|
1413
|
-
|
|
1666
|
+
path: TRAE_EXCHANGE_TOKEN_PATH,
|
|
1667
|
+
clientId: DESKTOP_CLIENT_ID,
|
|
1668
|
+
platformCode: "IDE_PC"
|
|
1414
1669
|
},
|
|
1415
1670
|
solo: {
|
|
1416
|
-
path:
|
|
1417
|
-
clientId:
|
|
1418
|
-
|
|
1671
|
+
path: TRAE_EXCHANGE_TOKEN_PATH,
|
|
1672
|
+
clientId: SOLO_CLIENT_ID,
|
|
1673
|
+
platformCode: "SOLO_PC"
|
|
1419
1674
|
},
|
|
1420
1675
|
"solo-sg": {
|
|
1421
|
-
path:
|
|
1422
|
-
clientId:
|
|
1423
|
-
|
|
1676
|
+
path: TRAE_EXCHANGE_TOKEN_PATH,
|
|
1677
|
+
clientId: SOLO_CLIENT_ID,
|
|
1678
|
+
platformCode: "SOLO_PC"
|
|
1424
1679
|
}
|
|
1425
1680
|
};
|
|
1426
1681
|
function normalizeHost(host) {
|
|
@@ -1428,49 +1683,173 @@ function normalizeHost(host) {
|
|
|
1428
1683
|
if (value === "") throw new Error("Trae refresh host is missing");
|
|
1429
1684
|
return value.replace(/\/$/, "");
|
|
1430
1685
|
}
|
|
1686
|
+
/** `OSInfo` value the official client sends (a bare platform word). */
|
|
1687
|
+
function osInfo(platform = process.platform) {
|
|
1688
|
+
return platform === "darwin" ? "mac" : platform === "win32" ? "windows" : platform;
|
|
1689
|
+
}
|
|
1431
1690
|
/**
|
|
1432
|
-
*
|
|
1433
|
-
*
|
|
1434
|
-
*
|
|
1435
|
-
*
|
|
1691
|
+
* Build the exact string the official client signs.
|
|
1692
|
+
*
|
|
1693
|
+
* The separator is a NEWLINE, not a space: the shipped bundle joins the six
|
|
1694
|
+
* fields with `.join("\n")`. This is load-bearing rather than cosmetic — a
|
|
1695
|
+
* space-joined message of identical fields is rejected with `20403 Token device
|
|
1696
|
+
* not match`, which is indistinguishable from a wrong key and sends debugging
|
|
1697
|
+
* in entirely the wrong direction.
|
|
1436
1698
|
*/
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1699
|
+
function traeDeviceProofMessage(input) {
|
|
1700
|
+
return [
|
|
1701
|
+
"POST",
|
|
1702
|
+
input.path,
|
|
1703
|
+
input.clientId,
|
|
1704
|
+
input.refreshToken,
|
|
1705
|
+
String(input.timestamp),
|
|
1706
|
+
input.nonce
|
|
1707
|
+
].join("\n");
|
|
1708
|
+
}
|
|
1709
|
+
/**
|
|
1710
|
+
* Sign a device proof with the install's own private key.
|
|
1711
|
+
*
|
|
1712
|
+
* `timestamp`/`nonce` are injectable so tests can pin a deterministic signature;
|
|
1713
|
+
* the defaults are the whole-second timestamp and 16 random bytes the official
|
|
1714
|
+
* client uses.
|
|
1715
|
+
*/
|
|
1716
|
+
function signTraeDeviceProof(input) {
|
|
1717
|
+
const timestamp = input.timestamp ?? Math.floor(Date.now() / 1e3);
|
|
1718
|
+
const nonce = input.nonce ?? randomBytes(16).toString("hex");
|
|
1719
|
+
const message = traeDeviceProofMessage({
|
|
1720
|
+
path: input.path,
|
|
1721
|
+
clientId: input.clientId,
|
|
1722
|
+
refreshToken: input.refreshToken,
|
|
1723
|
+
timestamp,
|
|
1724
|
+
nonce
|
|
1725
|
+
});
|
|
1726
|
+
return {
|
|
1727
|
+
Signature: sign("sha256", Buffer.from(message), input.privateKeyPEM).toString("base64"),
|
|
1728
|
+
Timestamp: timestamp,
|
|
1729
|
+
Nonce: nonce
|
|
1446
1730
|
};
|
|
1447
|
-
|
|
1731
|
+
}
|
|
1732
|
+
/** The `DeviceInfo` block the current contract sends. */
|
|
1733
|
+
function traeDeviceInfoBody(contract, device, platform) {
|
|
1734
|
+
const os = osInfo(platform);
|
|
1735
|
+
return {
|
|
1448
1736
|
DeviceID: device.deviceId,
|
|
1449
1737
|
MachineID: device.machineId,
|
|
1450
|
-
PlatformCode:
|
|
1738
|
+
PlatformCode: contract.platformCode,
|
|
1451
1739
|
DeviceType: "PC",
|
|
1452
|
-
DeviceName:
|
|
1740
|
+
...device.deviceName === void 0 ? {} : { DeviceName: device.deviceName },
|
|
1741
|
+
...device.deviceModel === void 0 ? {} : { DeviceModel: device.deviceModel },
|
|
1742
|
+
...device.appVersion === void 0 ? {} : { ClientVersion: device.appVersion },
|
|
1743
|
+
...device.deviceKeyPair === void 0 ? {} : { DevicePublicKey: device.deviceKeyPair.publicKeyPEM },
|
|
1744
|
+
...device.deviceBrand === void 0 ? {} : { DeviceBrand: device.deviceBrand },
|
|
1745
|
+
...device.deviceCpu === void 0 ? {} : { DeviceCPU: device.deviceCpu },
|
|
1746
|
+
OSInfo: os,
|
|
1747
|
+
OSVersion: device.osVersion ?? `${os} ${hostname()}`
|
|
1453
1748
|
};
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1749
|
+
}
|
|
1750
|
+
/** The current contract's complete request body. */
|
|
1751
|
+
function currentBody(credential, contract, device) {
|
|
1752
|
+
return {
|
|
1753
|
+
ClientID: contract.clientId,
|
|
1754
|
+
ClientSecret: "",
|
|
1755
|
+
RefreshToken: credential.refreshToken,
|
|
1756
|
+
DeviceInfo: traeDeviceInfoBody(contract, device),
|
|
1757
|
+
DeviceProof: signTraeDeviceProof({
|
|
1758
|
+
path: contract.path,
|
|
1759
|
+
clientId: contract.clientId,
|
|
1760
|
+
refreshToken: credential.refreshToken ?? "",
|
|
1761
|
+
privateKeyPEM: device.deviceKeyPair.privateKeyPEM
|
|
1762
|
+
}),
|
|
1763
|
+
...device.appVersion === void 0 ? {} : { IDEVersion: device.appVersion }
|
|
1764
|
+
};
|
|
1765
|
+
}
|
|
1766
|
+
/**
|
|
1767
|
+
* The superseded shape, kept as a fallback for installs the current contract
|
|
1768
|
+
* cannot serve: a machine whose `storage.json` carries no device keypair (an
|
|
1769
|
+
* older install that has never refreshed on the new contract, or a CLI-only
|
|
1770
|
+
* home) has nothing to sign a proof with.
|
|
1771
|
+
*
|
|
1772
|
+
* The ClientID split is the SAME one the current contract uses, and that is not
|
|
1773
|
+
* a coincidence — it is enforced by the OAuth CLIENT, not by the endpoint: a
|
|
1774
|
+
* SOLO-package refresh token is refused by the DESKTOP client with `10101
|
|
1775
|
+
* refresh token is not matched to the client`, and vice versa, on this path
|
|
1776
|
+
* exactly as on the current one. Verified 2026-10-11 across all four editions:
|
|
1777
|
+
* each answers 200 here with its own ClientID and 400 with the other one.
|
|
1778
|
+
*
|
|
1779
|
+
* The pre-3.1.0 code hardcoded the desktop ClientID on this path, which is why
|
|
1780
|
+
* an international SOLO account had no working fallback at all.
|
|
1781
|
+
*/
|
|
1782
|
+
function legacyBody(credential, clientId) {
|
|
1783
|
+
return {
|
|
1784
|
+
ClientID: clientId,
|
|
1785
|
+
ClientSecret: "-",
|
|
1786
|
+
RefreshToken: credential.refreshToken,
|
|
1787
|
+
UserID: credential.userId
|
|
1788
|
+
};
|
|
1789
|
+
}
|
|
1790
|
+
/** Parse one ExchangeToken response into an outcome, or throw with the reason. */
|
|
1791
|
+
function outcomeOf(payload, label) {
|
|
1792
|
+
const result = payload.Result;
|
|
1462
1793
|
const accessToken = typeof result?.["Token"] === "string" ? result["Token"] : "";
|
|
1463
|
-
if (accessToken === "") throw new Error(
|
|
1794
|
+
if (accessToken === "") throw new Error(`${label} returned no token`);
|
|
1464
1795
|
const expiry = result?.["TokenExpireAt"];
|
|
1465
1796
|
const expiresAtMs = typeof expiry === "number" ? expiry : typeof expiry === "string" ? Date.parse(expiry) : NaN;
|
|
1466
|
-
if (!Number.isFinite(expiresAtMs)) throw new Error(
|
|
1797
|
+
if (!Number.isFinite(expiresAtMs)) throw new Error(`${label} returned an invalid expiry`);
|
|
1467
1798
|
const refreshToken = typeof result?.["RefreshToken"] === "string" && result["RefreshToken"] !== "" ? result["RefreshToken"] : void 0;
|
|
1799
|
+
const refreshExpiry = result?.["RefreshExpireAt"];
|
|
1800
|
+
const parsedRefreshExpiry = typeof refreshExpiry === "string" ? Date.parse(refreshExpiry) : NaN;
|
|
1801
|
+
const refreshExpiresAtMs = typeof refreshExpiry === "number" && Number.isFinite(refreshExpiry) && refreshExpiry > 0 ? refreshExpiry : Number.isFinite(parsedRefreshExpiry) ? parsedRefreshExpiry : void 0;
|
|
1468
1802
|
return {
|
|
1469
1803
|
accessToken,
|
|
1470
1804
|
...refreshToken === void 0 ? {} : { refreshToken },
|
|
1471
|
-
expiresAtMs
|
|
1805
|
+
expiresAtMs,
|
|
1806
|
+
...refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs }
|
|
1472
1807
|
};
|
|
1473
1808
|
}
|
|
1809
|
+
/** POST one refresh body and parse it, labelling failures with the path tried. */
|
|
1810
|
+
async function postExchangeToken(host, path, body, signal, label) {
|
|
1811
|
+
const response = await fetch(`${normalizeHost(host)}${path}`, {
|
|
1812
|
+
method: "POST",
|
|
1813
|
+
headers: { "Content-Type": "application/json" },
|
|
1814
|
+
body: JSON.stringify(body),
|
|
1815
|
+
signal: signal ?? AbortSignal.timeout(3e4)
|
|
1816
|
+
});
|
|
1817
|
+
if (!response.ok) throw new Error(`${label} failed (http ${response.status})`);
|
|
1818
|
+
return outcomeOf(await response.json(), label);
|
|
1819
|
+
}
|
|
1820
|
+
/**
|
|
1821
|
+
* Exchange a refresh token for a fresh access token.
|
|
1822
|
+
*
|
|
1823
|
+
* The CURRENT contract is attempted first whenever it is possible at all — it is
|
|
1824
|
+
* what the shipped clients use. It needs the install's device keypair; without
|
|
1825
|
+
* one there is nothing to sign, so the request is skipped rather than sent to be
|
|
1826
|
+
* rejected with `20405`.
|
|
1827
|
+
*
|
|
1828
|
+
* The SUPERSEDED contract is then tried as a fallback, which is what keeps older
|
|
1829
|
+
* installs and CLI-only machines working; it carries no device block but the
|
|
1830
|
+
* SAME per-edition ClientID, because that split belongs to the OAuth client
|
|
1831
|
+
* rather than to the endpoint (see {@link legacyBody}). When both are impossible
|
|
1832
|
+
* or both fail, the error names every attempt so a "sign in again" verdict is
|
|
1833
|
+
* diagnosable rather than mysterious.
|
|
1834
|
+
*/
|
|
1835
|
+
async function refreshTraeCredential(credential, signal, device) {
|
|
1836
|
+
const contract = CURRENT_CONTRACT[credential.edition];
|
|
1837
|
+
if (contract === void 0) throw new Error(`Trae ${credential.edition} refresh contract is not verified`);
|
|
1838
|
+
if (credential.refreshToken === void 0) throw new Error("Trae refresh token is missing");
|
|
1839
|
+
const attempts = [];
|
|
1840
|
+
if (device?.deviceKeyPair === void 0) attempts.push(`${contract.path}: no device keypair in this install's storage`);
|
|
1841
|
+
else try {
|
|
1842
|
+
return await postExchangeToken(credential.host, contract.path, currentBody(credential, contract, device), signal, "Trae token refresh");
|
|
1843
|
+
} catch (error) {
|
|
1844
|
+
attempts.push(`${contract.path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1845
|
+
}
|
|
1846
|
+
try {
|
|
1847
|
+
return await postExchangeToken(credential.host, TRAE_LEGACY_EXCHANGE_TOKEN_PATH, legacyBody(credential, contract.clientId), signal, "Trae token refresh (legacy)");
|
|
1848
|
+
} catch (error) {
|
|
1849
|
+
attempts.push(`${TRAE_LEGACY_EXCHANGE_TOKEN_PATH}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1850
|
+
}
|
|
1851
|
+
throw new Error(`Trae token refresh failed — ${attempts.join("; ")}`);
|
|
1852
|
+
}
|
|
1474
1853
|
//#endregion
|
|
1475
1854
|
//#region src/identity.ts
|
|
1476
1855
|
function nonEmpty(value) {
|
|
@@ -1481,6 +1860,33 @@ function deviceCenterId(storage) {
|
|
|
1481
1860
|
const ids = Object.keys(storage).filter((key) => key.startsWith(prefix)).map((key) => key.slice(25)).filter(Boolean);
|
|
1482
1861
|
return ids.length === 1 ? ids[0] : void 0;
|
|
1483
1862
|
}
|
|
1863
|
+
/**
|
|
1864
|
+
* Read the install's device keypair out of `storage.json`.
|
|
1865
|
+
*
|
|
1866
|
+
* Tolerant on purpose: an older install (or one that has never refreshed on the
|
|
1867
|
+
* new contract) simply has no keypair, and the caller must still be able to
|
|
1868
|
+
* resolve an identity for the chat/usage paths — only a refresh then degrades.
|
|
1869
|
+
* A malformed entry is treated as absent rather than failing the read.
|
|
1870
|
+
*/
|
|
1871
|
+
function deviceKeyPairOf(storage) {
|
|
1872
|
+
const key = Object.keys(storage).find((name) => name.startsWith("iCubeAuthInfo://icube-dc:"));
|
|
1873
|
+
if (key === void 0) return void 0;
|
|
1874
|
+
const encoded = storage[key];
|
|
1875
|
+
if (typeof encoded !== "string" || encoded.trim() === "") return void 0;
|
|
1876
|
+
try {
|
|
1877
|
+
const plaintext = parseTraeAuthValue(encoded);
|
|
1878
|
+
if (typeof plaintext !== "object" || plaintext === null || Array.isArray(plaintext)) return void 0;
|
|
1879
|
+
const value = plaintext;
|
|
1880
|
+
const privateKeyPEM = nonEmpty(value["privateKeyPEM"]);
|
|
1881
|
+
const publicKeyPEM = nonEmpty(value["publicKeyPEM"]);
|
|
1882
|
+
return privateKeyPEM === void 0 || publicKeyPEM === void 0 ? void 0 : {
|
|
1883
|
+
privateKeyPEM,
|
|
1884
|
+
publicKeyPEM
|
|
1885
|
+
};
|
|
1886
|
+
} catch {
|
|
1887
|
+
return;
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1484
1890
|
/** Read stable identity from Trae-owned files without generating impersonated IDs. */
|
|
1485
1891
|
async function readTraeIdentity(candidate, options = {}) {
|
|
1486
1892
|
const platform = options.platform ?? process.platform;
|
|
@@ -1519,6 +1925,15 @@ async function readTraeIdentity(candidate, options = {}) {
|
|
|
1519
1925
|
const deviceBrand = platform === "darwin" ? nonEmpty(env["TRAE_DEVICE_BRAND"]) : void 0;
|
|
1520
1926
|
const deviceCpu = cpus()[0]?.model.split(" ")[0];
|
|
1521
1927
|
const osVersion = `${platform === "darwin" ? "macOS" : platform === "win32" ? "Windows" : platform} ${release()}`;
|
|
1928
|
+
const deviceName = (() => {
|
|
1929
|
+
try {
|
|
1930
|
+
return nonEmpty(userInfo().username);
|
|
1931
|
+
} catch {
|
|
1932
|
+
return;
|
|
1933
|
+
}
|
|
1934
|
+
})();
|
|
1935
|
+
const deviceModel = nonEmpty(env["TRAE_DEVICE_MODEL"]);
|
|
1936
|
+
const deviceKeyPair = deviceKeyPairOf(storage);
|
|
1522
1937
|
return {
|
|
1523
1938
|
edition: candidate.edition,
|
|
1524
1939
|
machineId,
|
|
@@ -1527,6 +1942,9 @@ async function readTraeIdentity(candidate, options = {}) {
|
|
|
1527
1942
|
...buildVersion === void 0 ? {} : { buildVersion },
|
|
1528
1943
|
...deviceBrand === void 0 ? {} : { deviceBrand },
|
|
1529
1944
|
...deviceCpu === void 0 ? {} : { deviceCpu },
|
|
1945
|
+
...deviceName === void 0 ? {} : { deviceName },
|
|
1946
|
+
...deviceModel === void 0 ? {} : { deviceModel },
|
|
1947
|
+
...deviceKeyPair === void 0 ? {} : { deviceKeyPair },
|
|
1530
1948
|
osVersion,
|
|
1531
1949
|
platform
|
|
1532
1950
|
};
|
|
@@ -3685,6 +4103,21 @@ const TRAE_PAY_BASE = "https://api.trae.cn";
|
|
|
3685
4103
|
function asNumber(value) {
|
|
3686
4104
|
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
3687
4105
|
}
|
|
4106
|
+
/**
|
|
4107
|
+
* Normalize an upstream epoch to milliseconds.
|
|
4108
|
+
*
|
|
4109
|
+
* The pack's `end_time` arrives in SECONDS while {@link TraeUsagePack.endTimeMs}
|
|
4110
|
+
* names milliseconds. Measured against a live CN account: `1788340660` is
|
|
4111
|
+
* 2026-09-02, and reading it as milliseconds would place the pack in 1970 — a
|
|
4112
|
+
* date that sorts ahead of every real one and reads as "long expired". The
|
|
4113
|
+
* magnitude test is the same one `auth.ts` applies to stored expiry fields, so
|
|
4114
|
+
* an upstream that ever switches to milliseconds is passed through instead of
|
|
4115
|
+
* being multiplied into the year 57000.
|
|
4116
|
+
*/
|
|
4117
|
+
function epochToMs(value) {
|
|
4118
|
+
if (value === void 0 || !Number.isFinite(value) || value <= 0) return 0;
|
|
4119
|
+
return value > 0xe8d4a51000 ? value : value * 1e3;
|
|
4120
|
+
}
|
|
3688
4121
|
function parseUsageSnapshot(payload) {
|
|
3689
4122
|
const summaryRaw = payload["usage_summary"];
|
|
3690
4123
|
const summary = {
|
|
@@ -3708,7 +4141,7 @@ function parseUsageSnapshot(payload) {
|
|
|
3708
4141
|
packs.push({
|
|
3709
4142
|
displayDesc: typeof pack["display_desc"] === "string" ? pack["display_desc"] : "",
|
|
3710
4143
|
entitlementId: typeof base?.["entitlement_id"] === "string" ? base["entitlement_id"] : "",
|
|
3711
|
-
endTimeMs: asNumber(base?.["end_time"])
|
|
4144
|
+
endTimeMs: epochToMs(asNumber(base?.["end_time"])),
|
|
3712
4145
|
currency: asNumber(base?.["currency"]) ?? 0,
|
|
3713
4146
|
...availableEndpoint === void 0 ? {} : { availableEndpoint },
|
|
3714
4147
|
...creditsLimit === void 0 ? {} : { creditsLimit },
|
|
@@ -3927,170 +4360,6 @@ var TraeUsageClient = class {
|
|
|
3927
4360
|
}
|
|
3928
4361
|
};
|
|
3929
4362
|
//#endregion
|
|
3930
|
-
//#region src/status-paths.ts
|
|
3931
|
-
/** Plugin-owned usage endpoint consumed by its browser half. */
|
|
3932
|
-
const TRAE_USAGE_PATH = "/plugins/dsh-connect-trae/usage";
|
|
3933
|
-
/** Plugin-owned live model refresh endpoint. */
|
|
3934
|
-
const TRAE_MODELS_REFRESH_PATH = "/plugins/dsh-connect-trae/models/refresh";
|
|
3935
|
-
/**
|
|
3936
|
-
* Plugin-owned availability probe: POST the model ids to test, get one verdict
|
|
3937
|
-
* per model. Unlike every read-only sibling this route SPENDS quota — a real
|
|
3938
|
-
* (minimal) chat call per model — so it is user-initiated only, and the card
|
|
3939
|
-
* labels the button as consuming a small amount.
|
|
3940
|
-
*/
|
|
3941
|
-
const TRAE_MODELS_TEST_PATH = "/plugins/dsh-connect-trae/models/test";
|
|
3942
|
-
/** Plugin-owned local account rescan endpoint. */
|
|
3943
|
-
const TRAE_ACCOUNTS_REFRESH_PATH = "/plugins/dsh-connect-trae/accounts/refresh";
|
|
3944
|
-
/**
|
|
3945
|
-
* General balance of EVERY account in a region, for the composer panel's table.
|
|
3946
|
-
*
|
|
3947
|
-
* Separate from the usage route on purpose: the usage document backs the
|
|
3948
|
-
* 5-minute readout, and multiplying its upstream reads by the account count
|
|
3949
|
-
* would charge a healthy account for something it never looks at — the same
|
|
3950
|
-
* principle that keeps `creditAlternatives` firing only when a balance hits
|
|
3951
|
-
* zero. This one is paid when the panel is actually opened, which is the only
|
|
3952
|
-
* place the table is shown.
|
|
3953
|
-
*/
|
|
3954
|
-
const TRAE_ACCOUNT_CREDITS_PATH = "/plugins/dsh-connect-trae/account-credits";
|
|
3955
|
-
/**
|
|
3956
|
-
* Plugin-owned daily check-in claim endpoint. POST, loopback-only, and the only
|
|
3957
|
-
* route in this plugin that changes upstream account state.
|
|
3958
|
-
*/
|
|
3959
|
-
const TRAE_CHECKIN_PATH = "/plugins/dsh-connect-trae/checkin";
|
|
3960
|
-
/** Query parameter naming the region a card request addresses. */
|
|
3961
|
-
const TRAE_REGION_PARAM = "region";
|
|
3962
|
-
/** Every region, in card tab order. */
|
|
3963
|
-
const TRAE_REGIONS = ["cn", "ai"];
|
|
3964
|
-
/**
|
|
3965
|
-
* Address one region's status route. The two regions are separate provider
|
|
3966
|
-
* stacks; every card request carries the region whose tab the user is on.
|
|
3967
|
-
*/
|
|
3968
|
-
function withTraeRegion(path, region) {
|
|
3969
|
-
return `${path}?${TRAE_REGION_PARAM}=${region}`;
|
|
3970
|
-
}
|
|
3971
|
-
/**
|
|
3972
|
-
* Read the region parameter off a status-route URL. Absent means the domestic
|
|
3973
|
-
* tab (`cn`); a present-but-unknown value returns undefined so the route can
|
|
3974
|
-
* answer 400 instead of guessing.
|
|
3975
|
-
*/
|
|
3976
|
-
function regionOfTraeStatusUrl(url) {
|
|
3977
|
-
const at = url.indexOf("?");
|
|
3978
|
-
const value = at === -1 ? null : new URLSearchParams(url.slice(at + 1)).get(TRAE_REGION_PARAM);
|
|
3979
|
-
if (value === null || value === "") return "cn";
|
|
3980
|
-
return TRAE_REGIONS.includes(value) ? value : void 0;
|
|
3981
|
-
}
|
|
3982
|
-
/**
|
|
3983
|
-
* Peel ONE `{get(): T}` live reference, the shape DSH 0.1.7 delivers a
|
|
3984
|
-
* volatile-marked settings field as (see {@link unwrapVolatileDeep}).
|
|
3985
|
-
*/
|
|
3986
|
-
function unwrapVolatile(value) {
|
|
3987
|
-
if (value !== null && typeof value === "object" && typeof value.get === "function") return value.get();
|
|
3988
|
-
return value;
|
|
3989
|
-
}
|
|
3990
|
-
/**
|
|
3991
|
-
* Deep copy of a value with every `{get(): T}` live reference replaced by the
|
|
3992
|
-
* value it resolves to.
|
|
3993
|
-
*
|
|
3994
|
-
* DSH 0.1.7 hands volatile-marked fields back as live references, and a live
|
|
3995
|
-
* reference is still `typeof === 'object'` — so it passes a naive object check
|
|
3996
|
-
* and every lookup on it is `undefined`. Worse, spreading one (`{ ...ref }`)
|
|
3997
|
-
* does NOT read the field: it produces `{ get: <function> }`. Any merge that
|
|
3998
|
-
* spreads a resolved field to preserve its siblings — the card's "write one
|
|
3999
|
-
* region, keep the other" write, or the account map's spread — would otherwise
|
|
4000
|
-
* DROP every sibling and leak a function into the document.
|
|
4001
|
-
*
|
|
4002
|
-
* Both halves therefore unwrap before touching a resolved field. This lives in
|
|
4003
|
-
* the node-free host↔client bridge because the browser half needs it too.
|
|
4004
|
-
*
|
|
4005
|
-
* Non-reference values are recursed into so a nested volatile field is caught
|
|
4006
|
-
* as well; arrays and objects are rebuilt rather than mutated, so the caller's
|
|
4007
|
-
* value is never touched.
|
|
4008
|
-
*/
|
|
4009
|
-
function unwrapVolatileDeep(value) {
|
|
4010
|
-
if (value === null || typeof value !== "object") return value;
|
|
4011
|
-
if (typeof value.get === "function") return unwrapVolatileDeep(value.get());
|
|
4012
|
-
if (Array.isArray(value)) return value.map((entry) => unwrapVolatileDeep(entry));
|
|
4013
|
-
const source = value;
|
|
4014
|
-
const out = {};
|
|
4015
|
-
for (const key of Object.keys(source)) out[key] = unwrapVolatileDeep(source[key]);
|
|
4016
|
-
return out;
|
|
4017
|
-
}
|
|
4018
|
-
/**
|
|
4019
|
-
* Build the next `regions` settings value for the card's save. The write
|
|
4020
|
-
* targets ONLY the signed-in account's region slot; every other region's slot
|
|
4021
|
-
* is carried over untouched, so switching accounts never clobbers the other
|
|
4022
|
-
* region's picks. Tolerates any stored shape (absent, non-object) by starting
|
|
4023
|
-
* from an empty document.
|
|
4024
|
-
*/
|
|
4025
|
-
function nextRegionSlots(regions, region, slot) {
|
|
4026
|
-
const unwrapped = unwrapVolatileDeep(regions);
|
|
4027
|
-
return {
|
|
4028
|
-
...typeof unwrapped === "object" && unwrapped !== null && !Array.isArray(unwrapped) ? unwrapped : {},
|
|
4029
|
-
[region]: slot
|
|
4030
|
-
};
|
|
4031
|
-
}
|
|
4032
|
-
/**
|
|
4033
|
-
* Narrow a settings value to the `regions` map. Accepts EITHER the whole
|
|
4034
|
-
* settings section (`{ regions: {...}, ... }`) or the `regions` map itself, and
|
|
4035
|
-
* unwraps the former. This tolerance is deliberate: passing the whole section
|
|
4036
|
-
* where the map was expected was a real shipped bug — the lookup then read
|
|
4037
|
-
* `section['cn']` (absent), so the card's checkbox reported `true` forever and
|
|
4038
|
-
* clicking it appeared to do nothing even though the write succeeded.
|
|
4039
|
-
*
|
|
4040
|
-
* Unwraps live references first: on DSH 0.1.7 the `regions` field arrives as a
|
|
4041
|
-
* `{get(): T}` reference, which passes the `typeof === 'object'` check below
|
|
4042
|
-
* and would otherwise be returned as if it were the map — making every region
|
|
4043
|
-
* read back as absent (and the enabled flag read as "on" forever).
|
|
4044
|
-
*/
|
|
4045
|
-
function regionsMapOf(value) {
|
|
4046
|
-
const unwrapped = unwrapVolatileDeep(value);
|
|
4047
|
-
if (typeof unwrapped !== "object" || unwrapped === null || Array.isArray(unwrapped)) return {};
|
|
4048
|
-
const record = unwrapped;
|
|
4049
|
-
const nested = record["regions"];
|
|
4050
|
-
if (typeof nested === "object" && nested !== null && !Array.isArray(nested)) return nested;
|
|
4051
|
-
return record;
|
|
4052
|
-
}
|
|
4053
|
-
/** One region's stored slot as a plain object; any other shape reads as empty. */
|
|
4054
|
-
function regionSlotOf(value, region) {
|
|
4055
|
-
const slot = unwrapVolatile(regionsMapOf(value)[region]);
|
|
4056
|
-
return typeof slot === "object" && slot !== null && !Array.isArray(slot) ? slot : {};
|
|
4057
|
-
}
|
|
4058
|
-
/**
|
|
4059
|
-
* Whether one region's provider is switched on. Opt-out semantics: only an
|
|
4060
|
-
* explicit `false` disables it, so a config written before this switch existed
|
|
4061
|
-
* (and the pre-region-split flat fields, which never carry `enabled`) keep both
|
|
4062
|
-
* providers running exactly as before. The Host reads the same rule through
|
|
4063
|
-
* `regionStateOf`, so card and Host can never disagree about a region's state.
|
|
4064
|
-
*
|
|
4065
|
-
* `value` may be the whole settings section or the `regions` map (see
|
|
4066
|
-
* {@link regionsMapOf}).
|
|
4067
|
-
*/
|
|
4068
|
-
function regionEnabledOf(value, region) {
|
|
4069
|
-
return regionSlotOf(value, region)["enabled"] !== false;
|
|
4070
|
-
}
|
|
4071
|
-
/**
|
|
4072
|
-
* Build the next `regions` settings value for a provider on/off toggle. ONLY
|
|
4073
|
-
* the target region's `enabled` flag changes: every other field of that slot
|
|
4074
|
-
* (its directory, selection, image opt-ins, context budgets) and every other
|
|
4075
|
-
* region's slot are carried over verbatim, so switching a provider off never
|
|
4076
|
-
* discards the user's model picks and switching it back on restores them.
|
|
4077
|
-
*
|
|
4078
|
-
* This is deliberately separate from {@link nextRegionSlots}: that helper
|
|
4079
|
-
* writes a whole slot from a signed-in tab's draft, while this one must work
|
|
4080
|
-
* for a region that is signed OUT — which is precisely the region a user wants
|
|
4081
|
-
* to switch off (no international install, no international account).
|
|
4082
|
-
*
|
|
4083
|
-
* `value` may be the whole settings section or the `regions` map; the RETURN
|
|
4084
|
-
* value is always the `regions` map, i.e. exactly what `settingsScope.set(
|
|
4085
|
-
* 'regions', ...)` needs.
|
|
4086
|
-
*/
|
|
4087
|
-
function nextRegionEnabled(value, region, enabled) {
|
|
4088
|
-
return nextRegionSlots(regionsMapOf(value), region, {
|
|
4089
|
-
...regionSlotOf(value, region),
|
|
4090
|
-
enabled
|
|
4091
|
-
});
|
|
4092
|
-
}
|
|
4093
|
-
//#endregion
|
|
4094
4363
|
//#region src/web-status.ts
|
|
4095
4364
|
/**
|
|
4096
4365
|
* The upstream's business code for "该设备今日已参与签到" — this DEVICE already
|
|
@@ -4149,6 +4418,32 @@ async function readModelTestBody(req) {
|
|
|
4149
4418
|
}
|
|
4150
4419
|
return ids;
|
|
4151
4420
|
}
|
|
4421
|
+
/**
|
|
4422
|
+
* Read the accountId a per-account route needs from a POST body. A bounded,
|
|
4423
|
+
* single-string reader — the same families of guards the model-test body uses,
|
|
4424
|
+
* because the claim route here is a state-changing endpoint.
|
|
4425
|
+
*/
|
|
4426
|
+
async function readAccountBody(req) {
|
|
4427
|
+
const chunks = [];
|
|
4428
|
+
let size = 0;
|
|
4429
|
+
for await (const chunk of req) {
|
|
4430
|
+
const buffer = chunk;
|
|
4431
|
+
size += buffer.byteLength;
|
|
4432
|
+
if (size > 16384) throw new Error("request body too large");
|
|
4433
|
+
chunks.push(buffer);
|
|
4434
|
+
}
|
|
4435
|
+
const text = Buffer.concat(chunks).toString("utf8").trim();
|
|
4436
|
+
if (text === "") throw new Error("empty body");
|
|
4437
|
+
let parsed;
|
|
4438
|
+
try {
|
|
4439
|
+
parsed = JSON.parse(text);
|
|
4440
|
+
} catch {
|
|
4441
|
+
throw new Error("invalid JSON body");
|
|
4442
|
+
}
|
|
4443
|
+
const accountId = typeof parsed === "object" && parsed !== null ? parsed["accountId"] : void 0;
|
|
4444
|
+
if (typeof accountId !== "string" || accountId.trim() === "") throw new Error("body must carry an accountId string");
|
|
4445
|
+
return accountId.trim();
|
|
4446
|
+
}
|
|
4152
4447
|
/** Loopback browser origins only; other devices are refused until trusted origins exist. */
|
|
4153
4448
|
function loopbackOrigin(req) {
|
|
4154
4449
|
const origin = req.headers.origin;
|
|
@@ -4174,7 +4469,8 @@ function toCredits(snapshot) {
|
|
|
4174
4469
|
const accounts = snapshot.packs.map((pack) => ({
|
|
4175
4470
|
displayDesc: pack.displayDesc,
|
|
4176
4471
|
remain: credit(Math.max(0, (pack.creditsLimit ?? 0) - (pack.consumedCredits ?? 0))),
|
|
4177
|
-
size: pack.creditsLimit ?? 0
|
|
4472
|
+
size: pack.creditsLimit ?? 0,
|
|
4473
|
+
...typeof pack.endTimeMs === "number" && Number.isFinite(pack.endTimeMs) && pack.endTimeMs > 0 ? { expiresAtMs: pack.endTimeMs } : {}
|
|
4178
4474
|
}));
|
|
4179
4475
|
const remaining = (endpoint) => snapshot.packs.filter((pack) => pack.availableEndpoint === endpoint).reduce((sum, pack) => credit(sum + Math.max(0, (pack.creditsLimit ?? 0) - (pack.consumedCredits ?? 0))), 0);
|
|
4180
4476
|
return {
|
|
@@ -4233,6 +4529,10 @@ async function traeWebUsage(deps, region) {
|
|
|
4233
4529
|
message: safeMessage(error)
|
|
4234
4530
|
};
|
|
4235
4531
|
}
|
|
4532
|
+
let appLogin;
|
|
4533
|
+
try {
|
|
4534
|
+
appLogin = await deps.liveLogin?.(region);
|
|
4535
|
+
} catch {}
|
|
4236
4536
|
const account = {
|
|
4237
4537
|
accountId: accounts.find((item) => item.selected)?.id ?? "",
|
|
4238
4538
|
accountName: credential.accountName ?? credential.userId,
|
|
@@ -4240,6 +4540,7 @@ async function traeWebUsage(deps, region) {
|
|
|
4240
4540
|
region,
|
|
4241
4541
|
enabled,
|
|
4242
4542
|
accounts,
|
|
4543
|
+
...appLogin === void 0 ? {} : { appLogin },
|
|
4243
4544
|
models: deps.displayModels(region).map((model) => ({
|
|
4244
4545
|
...model,
|
|
4245
4546
|
...model.input === void 0 ? {} : { input: [...model.input] }
|
|
@@ -4395,6 +4696,33 @@ function registerTraeUsageRoute(ctx, deps) {
|
|
|
4395
4696
|
}
|
|
4396
4697
|
}
|
|
4397
4698
|
});
|
|
4699
|
+
/**
|
|
4700
|
+
* Per-account status for one row of the card's account table (CN:
|
|
4701
|
+
* check-in + balances; ai: subscription). Read-only, one upstream call per
|
|
4702
|
+
* account, fetched once per table render. `accountId` rides in the query
|
|
4703
|
+
* (a GET, like every other read-only sibling). Absent `accountStateOf`
|
|
4704
|
+
* the route answers 503 so the table degrades to bare rows.
|
|
4705
|
+
*/
|
|
4706
|
+
const disposeAccountStatus = ctx.webServer.register({
|
|
4707
|
+
kind: "exact",
|
|
4708
|
+
path: TRAE_ACCOUNT_STATUS_PATH,
|
|
4709
|
+
handler: async (req, res) => {
|
|
4710
|
+
if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
|
|
4711
|
+
if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
|
|
4712
|
+
if (deps.accountStateOf === void 0) return json(res, 503, { error: "account status unavailable" });
|
|
4713
|
+
const region = requestRegion(req, res);
|
|
4714
|
+
if (region === void 0) return;
|
|
4715
|
+
const accountId = new URL(req.url ?? "/", "http://localhost").searchParams.get("accountId");
|
|
4716
|
+
if (accountId === null || accountId.trim() === "") return json(res, 400, { error: "missing accountId" });
|
|
4717
|
+
try {
|
|
4718
|
+
const state = await deps.accountStateOf(region, accountId);
|
|
4719
|
+
if (state === void 0) return json(res, 404, { error: "unknown account" });
|
|
4720
|
+
json(res, 200, state);
|
|
4721
|
+
} catch (error) {
|
|
4722
|
+
json(res, 500, { error: safeMessage(error) });
|
|
4723
|
+
}
|
|
4724
|
+
}
|
|
4725
|
+
});
|
|
4398
4726
|
const disposeAccounts = ctx.webServer.register({
|
|
4399
4727
|
kind: "exact",
|
|
4400
4728
|
path: TRAE_ACCOUNTS_REFRESH_PATH,
|
|
@@ -4490,6 +4818,27 @@ function registerTraeUsageRoute(ctx, deps) {
|
|
|
4490
4818
|
if (region === void 0) return;
|
|
4491
4819
|
if (region !== "cn") return json(res, 404, { error: "check-in is not available for the international region" });
|
|
4492
4820
|
try {
|
|
4821
|
+
let accountId;
|
|
4822
|
+
try {
|
|
4823
|
+
accountId = await readAccountBody(req);
|
|
4824
|
+
} catch {
|
|
4825
|
+
accountId = void 0;
|
|
4826
|
+
}
|
|
4827
|
+
if (accountId !== void 0) {
|
|
4828
|
+
if (deps.claimCheckinOf === void 0) return json(res, 503, { error: "check-in by account unavailable" });
|
|
4829
|
+
const outcome = await deps.claimCheckinOf(region, accountId);
|
|
4830
|
+
if (outcome === void 0) return json(res, 404, { error: "unknown account" });
|
|
4831
|
+
const { claim, checkin: after } = outcome;
|
|
4832
|
+
const deviceClaimed = claim.code === CHECKIN_DEVICE_ALREADY_CLAIMED;
|
|
4833
|
+
return json(res, 200, {
|
|
4834
|
+
claimed: claim.claimed,
|
|
4835
|
+
alreadyCheckedIn: after.checkedIn,
|
|
4836
|
+
...deviceClaimed ? { deviceCheckedIn: true } : {},
|
|
4837
|
+
code: claim.code,
|
|
4838
|
+
message: claim.message,
|
|
4839
|
+
checkin: toCheckin(after)
|
|
4840
|
+
});
|
|
4841
|
+
}
|
|
4493
4842
|
const client = deps.client(region);
|
|
4494
4843
|
const current = await client.checkinStatus();
|
|
4495
4844
|
if (!current.enabled) return json(res, 409, { error: "check-in is not enabled for this account" });
|
|
@@ -4529,6 +4878,7 @@ function registerTraeUsageRoute(ctx, deps) {
|
|
|
4529
4878
|
disposeModelsTest();
|
|
4530
4879
|
disposeAccounts();
|
|
4531
4880
|
disposeAccountCredits();
|
|
4881
|
+
disposeAccountStatus();
|
|
4532
4882
|
disposeUsage();
|
|
4533
4883
|
};
|
|
4534
4884
|
}, "dsh-connect-trae: Web usage route");
|
|
@@ -4837,6 +5187,7 @@ const Config = z.object({
|
|
|
4837
5187
|
]).default("auto").description("Trae edition hint")),
|
|
4838
5188
|
accountId: z.string().description("Deprecated: pre-split account selector, attributed to its own region"),
|
|
4839
5189
|
showPointsInMainUi: asVolatile(z.boolean().default(false).description("在输入框工具栏显示积分读数「Trae CN · <数值>」(仅当选中的模型属于本插件时出现,可点击查看账号与积分明细)")),
|
|
5190
|
+
followAppLogin: asVolatile(z.boolean().default(false).description("跟随 Trae 桌面端当前登录账号换绑(打开后每次配置生效时重绑到 App 现在登录的账号)")),
|
|
4840
5191
|
accounts: asVolatile(accountSelectionConfig.description("Per-region account selections, keyed cn | ai")),
|
|
4841
5192
|
regions: asVolatile(z.dict(regionStateConfig).default({}).description("Per-region model directory and selection, keyed cn | ai")),
|
|
4842
5193
|
lastCatalog: z.array(modelConfig).description("Deprecated: pre-region-split CN model directory"),
|
|
@@ -4949,16 +5300,27 @@ function apply(ctx, config) {
|
|
|
4949
5300
|
}], hint ?? (region === "ai" ? "solo-sg" : "cn"));
|
|
4950
5301
|
};
|
|
4951
5302
|
/**
|
|
4952
|
-
*
|
|
4953
|
-
*
|
|
4954
|
-
*
|
|
5303
|
+
* The current refresh contract signs a DeviceProof with the install's own
|
|
5304
|
+
* registered keypair, so every field the server reads must come from the
|
|
5305
|
+
* SAME installation the credential belongs to.
|
|
5306
|
+
*
|
|
5307
|
+
* Resolution failures leave `deviceKeyPair` absent, and the refresh then
|
|
5308
|
+
* degrades to the superseded contract instead of sending an unsigned
|
|
5309
|
+
* request the server answers with `20405 Device proof required`.
|
|
4955
5310
|
*/
|
|
4956
5311
|
const refreshDeviceSafe = async () => {
|
|
4957
5312
|
try {
|
|
4958
5313
|
const value = await identity();
|
|
4959
5314
|
return {
|
|
4960
5315
|
deviceId: value.deviceId,
|
|
4961
|
-
machineId: value.machineId
|
|
5316
|
+
machineId: value.machineId,
|
|
5317
|
+
...value.deviceName === void 0 ? {} : { deviceName: value.deviceName },
|
|
5318
|
+
...value.deviceModel === void 0 ? {} : { deviceModel: value.deviceModel },
|
|
5319
|
+
...value.deviceBrand === void 0 ? {} : { deviceBrand: value.deviceBrand },
|
|
5320
|
+
...value.deviceCpu === void 0 ? {} : { deviceCpu: value.deviceCpu },
|
|
5321
|
+
...value.osVersion === void 0 ? {} : { osVersion: value.osVersion },
|
|
5322
|
+
...value.appVersion === void 0 ? {} : { appVersion: value.appVersion },
|
|
5323
|
+
...value.deviceKeyPair === void 0 ? {} : { deviceKeyPair: value.deviceKeyPair }
|
|
4962
5324
|
};
|
|
4963
5325
|
} catch {
|
|
4964
5326
|
return;
|
|
@@ -5016,16 +5378,17 @@ function apply(ctx, config) {
|
|
|
5016
5378
|
ctx.logger.warn("dsh-connect-trae: Raw gateway unavailable; continuing with native SOLO tool-call channel", error);
|
|
5017
5379
|
}
|
|
5018
5380
|
})();
|
|
5381
|
+
const usageClient = new TraeUsageClient({
|
|
5382
|
+
credential: () => store.resolve(),
|
|
5383
|
+
deviceId: async () => (await identity()).deviceId
|
|
5384
|
+
});
|
|
5019
5385
|
stacks[region] = {
|
|
5020
5386
|
region,
|
|
5021
5387
|
store,
|
|
5022
5388
|
catalog,
|
|
5023
5389
|
shim,
|
|
5024
5390
|
delegating,
|
|
5025
|
-
usageClient
|
|
5026
|
-
credential: () => store.resolve(),
|
|
5027
|
-
deviceId: async () => (await identity()).deviceId
|
|
5028
|
-
}),
|
|
5391
|
+
usageClient,
|
|
5029
5392
|
/**
|
|
5030
5393
|
* Credit balance of one named account, WITHOUT rebinding the store
|
|
5031
5394
|
* (issue #25). The card asks this only after it saw the bound account
|
|
@@ -5042,6 +5405,39 @@ function apply(ctx, config) {
|
|
|
5042
5405
|
if (credential === void 0) return void 0;
|
|
5043
5406
|
return toCredits(await new TraeUsageClient({ credential: async () => credential }).snapshot());
|
|
5044
5407
|
},
|
|
5408
|
+
accountStateOf: async (accountId) => {
|
|
5409
|
+
const state = { id: accountId };
|
|
5410
|
+
const selectedCredential = await store.current();
|
|
5411
|
+
const client = selectedCredential !== void 0 && traeAccountId(selectedCredential) === accountId ? usageClient : (() => {
|
|
5412
|
+
const credential = store.credentialOf(accountId);
|
|
5413
|
+
return new TraeUsageClient({
|
|
5414
|
+
credential: async () => credential,
|
|
5415
|
+
deviceId: async () => (await identity()).deviceId
|
|
5416
|
+
});
|
|
5417
|
+
})();
|
|
5418
|
+
if (region === "ai") {
|
|
5419
|
+
state.payStatus = await client.payStatus();
|
|
5420
|
+
return state;
|
|
5421
|
+
}
|
|
5422
|
+
const [snapshot, checkin] = await Promise.allSettled([client.snapshot(), client.checkinStatus()]);
|
|
5423
|
+
if (snapshot.status === "fulfilled") state.credits = toCredits(snapshot.value);
|
|
5424
|
+
if (checkin.status === "fulfilled") state.checkin = toCheckin(checkin.value);
|
|
5425
|
+
return state;
|
|
5426
|
+
},
|
|
5427
|
+
claimCheckinOf: async (accountId) => {
|
|
5428
|
+
const selectedCredential = await store.current();
|
|
5429
|
+
const client = selectedCredential !== void 0 && traeAccountId(selectedCredential) === accountId ? usageClient : (() => {
|
|
5430
|
+
const credential = store.credentialOf(accountId);
|
|
5431
|
+
return new TraeUsageClient({
|
|
5432
|
+
credential: async () => credential,
|
|
5433
|
+
deviceId: async () => (await identity()).deviceId
|
|
5434
|
+
});
|
|
5435
|
+
})();
|
|
5436
|
+
return {
|
|
5437
|
+
claim: await client.claimCheckin(),
|
|
5438
|
+
checkin: await client.checkinStatus()
|
|
5439
|
+
};
|
|
5440
|
+
},
|
|
5045
5441
|
discoverModels: async (signal) => {
|
|
5046
5442
|
const merged = mergeTraeModelSources(await withDirectoryRetry(() => remoteCatalog.fetchModels(signal)), await solo.fetchModels(signal));
|
|
5047
5443
|
wire.callableKeys.clear();
|
|
@@ -5092,6 +5488,49 @@ function apply(ctx, config) {
|
|
|
5092
5488
|
rawDiagnostic: () => rawDiagnostic()
|
|
5093
5489
|
};
|
|
5094
5490
|
}
|
|
5491
|
+
/**
|
|
5492
|
+
* The app's current sign-in when it differs from the bound account (issue
|
|
5493
|
+
* #29). Pure read: the store resolves the bound account verbatim, so billing
|
|
5494
|
+
* never moves from here — the card's hint and the follow switch below are
|
|
5495
|
+
* the only writers.
|
|
5496
|
+
*/
|
|
5497
|
+
const liveLoginOf = async (region) => {
|
|
5498
|
+
try {
|
|
5499
|
+
const live = await stacks[region].store.liveLogin();
|
|
5500
|
+
if (live === void 0 || live.region !== region) return void 0;
|
|
5501
|
+
return {
|
|
5502
|
+
id: live.id,
|
|
5503
|
+
accountName: live.accountName,
|
|
5504
|
+
edition: live.edition,
|
|
5505
|
+
region: live.region
|
|
5506
|
+
};
|
|
5507
|
+
} catch {
|
|
5508
|
+
return;
|
|
5509
|
+
}
|
|
5510
|
+
};
|
|
5511
|
+
/**
|
|
5512
|
+
* Persist `accounts.<region>` = the app's live sign-in and rebind the store
|
|
5513
|
+
* (issue #29). The ONE Host-side path that moves the binding, driven by the
|
|
5514
|
+
* follow switch in {@link maybeFollowAppLogin}; the card's one-click hint
|
|
5515
|
+
* goes through its own settings write instead (one writer per path). Returns
|
|
5516
|
+
* the applied login so the caller can report what moved.
|
|
5517
|
+
*
|
|
5518
|
+
* The write goes through `settings.update` (the volatile write gate), not a
|
|
5519
|
+
* direct config mutation: `apply()` receives a snapshot, and the settings
|
|
5520
|
+
* event (`loader/volatile-update`) is what re-runs `applySelection` with the
|
|
5521
|
+
* updated document — the same channel every card write already uses.
|
|
5522
|
+
*/
|
|
5523
|
+
const followAppLogin = async (region) => {
|
|
5524
|
+
const live = await liveLoginOf(region);
|
|
5525
|
+
if (live === void 0 || writeSettings === void 0) return void 0;
|
|
5526
|
+
const configured = configuredAccountsOf(current());
|
|
5527
|
+
if (configured[region] === live.id) return live;
|
|
5528
|
+
await writeSettings({ accounts: {
|
|
5529
|
+
...configured,
|
|
5530
|
+
[region]: live.id
|
|
5531
|
+
} });
|
|
5532
|
+
return live;
|
|
5533
|
+
};
|
|
5095
5534
|
ctx.inject(["webServer"], (webCtx) => registerTraeUsageRoute(webCtx, {
|
|
5096
5535
|
store: (region) => stacks[region].store,
|
|
5097
5536
|
client: (region) => stacks[region].usageClient,
|
|
@@ -5101,7 +5540,10 @@ function apply(ctx, config) {
|
|
|
5101
5540
|
discoverModels: (region, signal) => stacks[region].discoverModels(signal),
|
|
5102
5541
|
probeModels: (region, ids) => stacks[region].probeModels(ids),
|
|
5103
5542
|
rawDiagnostic: (region) => stacks[region].rawDiagnostic(),
|
|
5104
|
-
creditsOfAccount: (region, accountId) => stacks[region].creditsOfAccount(accountId)
|
|
5543
|
+
creditsOfAccount: (region, accountId) => stacks[region].creditsOfAccount(accountId),
|
|
5544
|
+
accountStateOf: (region, accountId) => stacks[region].accountStateOf(accountId),
|
|
5545
|
+
claimCheckinOf: (region, accountId) => stacks[region].claimCheckinOf(accountId),
|
|
5546
|
+
liveLogin: (region) => liveLoginOf(region)
|
|
5105
5547
|
}));
|
|
5106
5548
|
/**
|
|
5107
5549
|
* Live registration handles per region, filled once the shim is listening.
|
|
@@ -5157,13 +5599,59 @@ function apply(ctx, config) {
|
|
|
5157
5599
|
}
|
|
5158
5600
|
} catch {}
|
|
5159
5601
|
})();
|
|
5602
|
+
let writeSettings;
|
|
5160
5603
|
ctx.inject(["settings"], (settingsCtx) => {
|
|
5161
5604
|
const settings = settingsCtx.settings;
|
|
5162
5605
|
ctx.effect(() => settings.configure({ auto: true }, ctx.fiber));
|
|
5606
|
+
writeSettings = async (patch) => {
|
|
5607
|
+
const revision = () => settings.describe().find((entry) => entry.ns === settingsNs)?.revision;
|
|
5608
|
+
const conflict = (error) => typeof error === "object" && error !== null && error.code === "SETTINGS_CONFLICT";
|
|
5609
|
+
try {
|
|
5610
|
+
await settings.update(settingsNs, patch, revision());
|
|
5611
|
+
} catch (error) {
|
|
5612
|
+
if (!conflict(error)) throw error;
|
|
5613
|
+
await settings.update(settingsNs, patch, revision());
|
|
5614
|
+
}
|
|
5615
|
+
};
|
|
5616
|
+
ctx.effect(() => () => {
|
|
5617
|
+
writeSettings = void 0;
|
|
5618
|
+
});
|
|
5163
5619
|
});
|
|
5164
5620
|
ctx.on("loader/volatile-update", () => {
|
|
5165
5621
|
applySelection(current());
|
|
5622
|
+
maybeFollowAppLogin();
|
|
5166
5623
|
});
|
|
5624
|
+
/**
|
|
5625
|
+
* Follow the app's sign-in on every settings change when the switch is ON
|
|
5626
|
+
* (issue #29). Runs AFTER `applySelection` so this pass binds the previous
|
|
5627
|
+
* document, reads the app's live login against it, and — if they differ —
|
|
5628
|
+
* writes `accounts.<region>` once; that write emits `volatile-update` again,
|
|
5629
|
+
* which re-runs `applySelection` on the NEW document. The loop terminates on
|
|
5630
|
+
* its own the moment `liveLogin()` reports no mismatch (the second pass binds
|
|
5631
|
+
* the live account, so `preferred` and `current` agree). The guard below
|
|
5632
|
+
* additionally forbids two writes in flight, so a stuck mismatch can never
|
|
5633
|
+
* turn into a write storm: the next settings change retries from scratch.
|
|
5634
|
+
*
|
|
5635
|
+
* The switch reads FALSE for an existing config (see {@link Config}), so a
|
|
5636
|
+
* user who never opted in never gets a rebind — the card's one-click hint is
|
|
5637
|
+
* their path instead.
|
|
5638
|
+
*/
|
|
5639
|
+
let followInFlight = false;
|
|
5640
|
+
const maybeFollowAppLogin = async () => {
|
|
5641
|
+
if (followInFlight || !followAppLoginEnabled(current())) return;
|
|
5642
|
+
followInFlight = true;
|
|
5643
|
+
try {
|
|
5644
|
+
for (const region of REGION_KEYS) {
|
|
5645
|
+
if (!regionEnabled(current(), region)) continue;
|
|
5646
|
+
await followAppLogin(region);
|
|
5647
|
+
}
|
|
5648
|
+
} catch (error) {
|
|
5649
|
+
ctx.logger.warn("dsh-connect-trae: follow-app-login rebind skipped", error);
|
|
5650
|
+
} finally {
|
|
5651
|
+
followInFlight = false;
|
|
5652
|
+
}
|
|
5653
|
+
};
|
|
5654
|
+
maybeFollowAppLogin();
|
|
5167
5655
|
let stopped = false;
|
|
5168
5656
|
ctx.effect(() => () => {
|
|
5169
5657
|
stopped = true;
|
|
@@ -5245,4 +5733,4 @@ function apply(ctx, config) {
|
|
|
5245
5733
|
});
|
|
5246
5734
|
}
|
|
5247
5735
|
//#endregion
|
|
5248
|
-
export { Config, FALLBACK_TRAE_MODELS, FALLBACK_TRAE_MODELS_AI, REGION_GATEWAYS, SseDecoder, TRAE_AI_PROVIDER, TRAE_CHECKIN_PATH, TRAE_CN_AGENT_TASK_PATH, TRAE_CN_TITLE_PATH, TRAE_MODELS_TEST_PATH, TRAE_MODEL_DETAIL_FUNCTIONS, TRAE_MODEL_DETAIL_PATH, TRAE_PAY_BASE, TRAE_PROBE_TIMEOUT_MS, TRAE_PROVIDER, TRAE_PROVIDERS, TRAE_PROVIDER_DISPLAY_NAMES, TRAE_RAW_CHAT_V1_PATH, TRAE_RAW_CHAT_V2_PATH, TRAE_REASONING_EFFORTS, TRAE_REGIONS, TRAE_REGION_PARAM, TRAE_SETTINGS_NS, TRAE_SOLO_CHAT_PATH, TRAE_SOLO_FUNCTION, TRAE_SOLO_MODELS_PATH, TRAE_SOLO_REMOTE_BASE, TRAE_STATE_DB_FILENAME, TRAE_STREAM_IDLE_TIMEOUT_MS, TRAE_USAGE_PATH, TRAE_VERSION_CODE_FALLBACK, TraeCatalog, TraeCredentialStore, TraeDelegatingUpstreamClient, TraeFallbackUpstreamClient, TraeGatedUpstreamClient, TraeRawCapabilityController, TraeRawCapabilityState, TraeRawChatUpstreamClient, TraeSoloBridge, TraeSoloRemoteCatalogClient, TraeSoloUpstreamClient, TraeUsageClient, UnconfiguredTraeUpstreamClient, apply, applyContextBudgets, applyImageSelection, applyReasoningEffort, asVolatile, bridgeTraeSoloStream, buildTraeAgentTaskBody, buildTraeCnHeaders, buildTraeFusionRawChatEnvelope, buildTraeModelDetailRequest, buildTraeRawChatDraft, buildTraeRawChatRuntimeConfig, classifyProbeError, classifyProbeResult, classifyTraeRawChatFailure, createTraeAdapter, createTraeRawGateway, createTraeShim, decodeRawChatChunk, decodeTraeEvent, decryptTraeStorageValue, deriveCatalog, describeIdShape, describeNameShape, discoveredCatalog, fallbackModelsFor, hashTraeRawChatArg, identityHeaders, inject, legacyTraeOwnAuthPath, maskUserPath, mergeTraeModelSources, name, nextRegionEnabled, nextRegionSlots, normalizeTraeCredential, normalizeTraeVersionCode, parseObservedModelConfig, parseReasoningCapability, parseTraeAuthValue, parseTraeCachedModel, parseTraeModelExtraConfigLogLine, parseTraeRawChatBehaviorConfig, parseTraeRemoteModel, parseTraeStorageDocument, pickTraeStorageIdentity, prepareSoloBody, probeBody, probeModelsSequentially, probeOneModel, probeTraeRawChatCapability, rawCapabilityDiagnostic, rawCapabilityFingerprint, readTraeCachedModel, readTraeCliIdentity, readTraeIdentity, refreshTraeCredential, regionEnabled, regionEnabledOf, regionOfCredential, regionOfEdition, regionOfHost, regionOfTraeProvider, regionOfTraeStatusUrl, regionOfUserRegion, regionStateOf, registerTraeUsageRoute, resolveTraeIdentity, resolveTraeRawRuntime, sanitizeCatalog, settingsNamespaceOf, traeEndpoint, traeInputModalities, traeModelDisplayName, traeOwnAuthPath, traeRawChatExtraInfo, traeStateDatabaseCandidates, traeStorageCandidates, traeWebUsage, traeWindowsAppNames, unwrapVolatile, unwrapVolatileDeep, withTraeRegion };
|
|
5736
|
+
export { Config, FALLBACK_TRAE_MODELS, FALLBACK_TRAE_MODELS_AI, REGION_GATEWAYS, SseDecoder, TRAE_ACCOUNT_STATUS_PATH, TRAE_AI_PROVIDER, TRAE_CHECKIN_PATH, TRAE_CN_AGENT_TASK_PATH, TRAE_CN_TITLE_PATH, TRAE_MODELS_TEST_PATH, TRAE_MODEL_DETAIL_FUNCTIONS, TRAE_MODEL_DETAIL_PATH, TRAE_PAY_BASE, TRAE_PROBE_TIMEOUT_MS, TRAE_PROVIDER, TRAE_PROVIDERS, TRAE_PROVIDER_DISPLAY_NAMES, TRAE_RAW_CHAT_V1_PATH, TRAE_RAW_CHAT_V2_PATH, TRAE_REASONING_EFFORTS, TRAE_REGIONS, TRAE_REGION_PARAM, TRAE_SETTINGS_NS, TRAE_SOLO_CHAT_PATH, TRAE_SOLO_FUNCTION, TRAE_SOLO_MODELS_PATH, TRAE_SOLO_REMOTE_BASE, TRAE_STATE_DB_FILENAME, TRAE_STREAM_IDLE_TIMEOUT_MS, TRAE_USAGE_PATH, TRAE_VERSION_CODE_FALLBACK, TraeCatalog, TraeCredentialStore, TraeDelegatingUpstreamClient, TraeFallbackUpstreamClient, TraeGatedUpstreamClient, TraeRawCapabilityController, TraeRawCapabilityState, TraeRawChatUpstreamClient, TraeSoloBridge, TraeSoloRemoteCatalogClient, TraeSoloUpstreamClient, TraeUsageClient, UnconfiguredTraeUpstreamClient, apply, applyContextBudgets, applyImageSelection, applyReasoningEffort, asVolatile, bridgeTraeSoloStream, buildTraeAgentTaskBody, buildTraeCnHeaders, buildTraeFusionRawChatEnvelope, buildTraeModelDetailRequest, buildTraeRawChatDraft, buildTraeRawChatRuntimeConfig, classifyProbeError, classifyProbeResult, classifyTraeRawChatFailure, configuredAccountsOf, createTraeAdapter, createTraeRawGateway, createTraeShim, decodeRawChatChunk, decodeTraeEvent, decryptTraeStorageValue, deriveCatalog, describeIdShape, describeNameShape, discoveredCatalog, fallbackModelsFor, followAppLoginEnabled, hashTraeRawChatArg, identityHeaders, inject, legacyTraeOwnAuthPath, maskUserPath, mergeTraeModelSources, name, nextRegionEnabled, nextRegionSlots, normalizeTraeCredential, normalizeTraeVersionCode, parseObservedModelConfig, parseReasoningCapability, parseTraeAuthValue, parseTraeCachedModel, parseTraeModelExtraConfigLogLine, parseTraeRawChatBehaviorConfig, parseTraeRemoteModel, parseTraeStorageDocument, pickTraeStorageIdentity, prepareSoloBody, probeBody, probeModelsSequentially, probeOneModel, probeTraeRawChatCapability, rawCapabilityDiagnostic, rawCapabilityFingerprint, readTraeCachedModel, readTraeCliIdentity, readTraeIdentity, refreshTraeCredential, regionEnabled, regionEnabledOf, regionOfCredential, regionOfEdition, regionOfHost, regionOfTraeProvider, regionOfTraeStatusUrl, regionOfUserRegion, regionStateOf, registerTraeUsageRoute, resolveTraeIdentity, resolveTraeRawRuntime, sanitizeCatalog, settingsNamespaceOf, traeEndpoint, traeInputModalities, traeModelDisplayName, traeOwnAuthPath, traeRawChatExtraInfo, traeStateDatabaseCandidates, traeStorageCandidates, traeWebUsage, traeWindowsAppNames, unwrapVolatile, unwrapVolatileDeep, withTraeRegion };
|