dsh-connect-trae 3.0.1 → 3.4.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/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";
@@ -478,8 +478,19 @@ const TRAE_ACCOUNTS_REFRESH_PATH = "/plugins/dsh-connect-trae/accounts/refresh";
478
478
  */
479
479
  const TRAE_ACCOUNT_CREDITS_PATH = "/plugins/dsh-connect-trae/account-credits";
480
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
+ /**
481
490
  * Plugin-owned daily check-in claim endpoint. POST, loopback-only, and the only
482
- * route in this plugin that changes upstream account state.
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.
483
494
  */
484
495
  const TRAE_CHECKIN_PATH = "/plugins/dsh-connect-trae/checkin";
485
496
  /** Query parameter naming the region a card request addresses. */
@@ -1636,26 +1647,35 @@ var TraeCredentialStore = class {
1636
1647
  };
1637
1648
  //#endregion
1638
1649
  //#region src/refresh.ts
1639
- const REFRESH_CONTRACT = {
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 = {
1640
1660
  cn: {
1641
- path: "/cloudide/api/v3/trae/oauth/ExchangeToken",
1642
- clientId: "ono9krqynydwx5",
1643
- deviceInfo: false
1661
+ path: TRAE_EXCHANGE_TOKEN_PATH,
1662
+ clientId: DESKTOP_CLIENT_ID,
1663
+ platformCode: "IDE_PC"
1644
1664
  },
1645
1665
  sg: {
1646
- path: "/cloudide/api/v3/trae/oauth/ExchangeToken",
1647
- clientId: "ono9krqynydwx5",
1648
- deviceInfo: false
1666
+ path: TRAE_EXCHANGE_TOKEN_PATH,
1667
+ clientId: DESKTOP_CLIENT_ID,
1668
+ platformCode: "IDE_PC"
1649
1669
  },
1650
1670
  solo: {
1651
- path: "/cloudide/api/v3/trae/oauth/ExchangeToken",
1652
- clientId: "ono9krqynydwx5",
1653
- deviceInfo: false
1671
+ path: TRAE_EXCHANGE_TOKEN_PATH,
1672
+ clientId: SOLO_CLIENT_ID,
1673
+ platformCode: "SOLO_PC"
1654
1674
  },
1655
1675
  "solo-sg": {
1656
- path: "/trae/api/v3/oauth/ExchangeToken",
1657
- clientId: "en1oxy7wnw8j9n",
1658
- deviceInfo: true
1676
+ path: TRAE_EXCHANGE_TOKEN_PATH,
1677
+ clientId: SOLO_CLIENT_ID,
1678
+ platformCode: "SOLO_PC"
1659
1679
  }
1660
1680
  };
1661
1681
  function normalizeHost(host) {
@@ -1663,49 +1683,173 @@ function normalizeHost(host) {
1663
1683
  if (value === "") throw new Error("Trae refresh host is missing");
1664
1684
  return value.replace(/\/$/, "");
1665
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
+ }
1666
1690
  /**
1667
- * Exchange a refresh token for a fresh access token, following the calling
1668
- * edition's verified contract. `device` is only used by editions whose
1669
- * official client sends a DeviceInfo body; when it cannot be resolved the
1670
- * field is omitted rather than sent empty.
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.
1671
1698
  */
1672
- async function refreshTraeCredential(credential, signal, device) {
1673
- const contract = REFRESH_CONTRACT[credential.edition];
1674
- if (contract === void 0) throw new Error(`Trae ${credential.edition} refresh contract is not verified`);
1675
- if (credential.refreshToken === void 0) throw new Error("Trae refresh token is missing");
1676
- const body = {
1677
- ClientID: contract.clientId,
1678
- ClientSecret: "-",
1679
- RefreshToken: credential.refreshToken,
1680
- UserID: credential.userId
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
1681
1730
  };
1682
- if (contract.deviceInfo && device !== void 0) body["DeviceInfo"] = {
1731
+ }
1732
+ /** The `DeviceInfo` block the current contract sends. */
1733
+ function traeDeviceInfoBody(contract, device, platform) {
1734
+ const os = osInfo(platform);
1735
+ return {
1683
1736
  DeviceID: device.deviceId,
1684
1737
  MachineID: device.machineId,
1685
- PlatformCode: credential.edition === "solo-sg" ? "SOLO_PC" : "TRAE",
1738
+ PlatformCode: contract.platformCode,
1686
1739
  DeviceType: "PC",
1687
- DeviceName: hostname()
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()}`
1688
1748
  };
1689
- const response = await fetch(`${normalizeHost(credential.host)}${contract.path}`, {
1690
- method: "POST",
1691
- headers: { "Content-Type": "application/json" },
1692
- body: JSON.stringify(body),
1693
- signal: signal ?? AbortSignal.timeout(3e4)
1694
- });
1695
- if (!response.ok) throw new Error(`Trae token refresh failed (http ${response.status})`);
1696
- const result = (await response.json()).Result;
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;
1697
1793
  const accessToken = typeof result?.["Token"] === "string" ? result["Token"] : "";
1698
- if (accessToken === "") throw new Error("Trae token refresh returned no token");
1794
+ if (accessToken === "") throw new Error(`${label} returned no token`);
1699
1795
  const expiry = result?.["TokenExpireAt"];
1700
1796
  const expiresAtMs = typeof expiry === "number" ? expiry : typeof expiry === "string" ? Date.parse(expiry) : NaN;
1701
- if (!Number.isFinite(expiresAtMs)) throw new Error("Trae token refresh returned an invalid expiry");
1797
+ if (!Number.isFinite(expiresAtMs)) throw new Error(`${label} returned an invalid expiry`);
1702
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;
1703
1802
  return {
1704
1803
  accessToken,
1705
1804
  ...refreshToken === void 0 ? {} : { refreshToken },
1706
- expiresAtMs
1805
+ expiresAtMs,
1806
+ ...refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs }
1707
1807
  };
1708
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
+ }
1709
1853
  //#endregion
1710
1854
  //#region src/identity.ts
1711
1855
  function nonEmpty(value) {
@@ -1716,6 +1860,33 @@ function deviceCenterId(storage) {
1716
1860
  const ids = Object.keys(storage).filter((key) => key.startsWith(prefix)).map((key) => key.slice(25)).filter(Boolean);
1717
1861
  return ids.length === 1 ? ids[0] : void 0;
1718
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
+ }
1719
1890
  /** Read stable identity from Trae-owned files without generating impersonated IDs. */
1720
1891
  async function readTraeIdentity(candidate, options = {}) {
1721
1892
  const platform = options.platform ?? process.platform;
@@ -1754,6 +1925,15 @@ async function readTraeIdentity(candidate, options = {}) {
1754
1925
  const deviceBrand = platform === "darwin" ? nonEmpty(env["TRAE_DEVICE_BRAND"]) : void 0;
1755
1926
  const deviceCpu = cpus()[0]?.model.split(" ")[0];
1756
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);
1757
1937
  return {
1758
1938
  edition: candidate.edition,
1759
1939
  machineId,
@@ -1762,6 +1942,9 @@ async function readTraeIdentity(candidate, options = {}) {
1762
1942
  ...buildVersion === void 0 ? {} : { buildVersion },
1763
1943
  ...deviceBrand === void 0 ? {} : { deviceBrand },
1764
1944
  ...deviceCpu === void 0 ? {} : { deviceCpu },
1945
+ ...deviceName === void 0 ? {} : { deviceName },
1946
+ ...deviceModel === void 0 ? {} : { deviceModel },
1947
+ ...deviceKeyPair === void 0 ? {} : { deviceKeyPair },
1765
1948
  osVersion,
1766
1949
  platform
1767
1950
  };
@@ -3920,6 +4103,21 @@ const TRAE_PAY_BASE = "https://api.trae.cn";
3920
4103
  function asNumber(value) {
3921
4104
  return typeof value === "number" && Number.isFinite(value) ? value : void 0;
3922
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
+ }
3923
4121
  function parseUsageSnapshot(payload) {
3924
4122
  const summaryRaw = payload["usage_summary"];
3925
4123
  const summary = {
@@ -3943,7 +4141,7 @@ function parseUsageSnapshot(payload) {
3943
4141
  packs.push({
3944
4142
  displayDesc: typeof pack["display_desc"] === "string" ? pack["display_desc"] : "",
3945
4143
  entitlementId: typeof base?.["entitlement_id"] === "string" ? base["entitlement_id"] : "",
3946
- endTimeMs: asNumber(base?.["end_time"]) ?? 0,
4144
+ endTimeMs: epochToMs(asNumber(base?.["end_time"])),
3947
4145
  currency: asNumber(base?.["currency"]) ?? 0,
3948
4146
  ...availableEndpoint === void 0 ? {} : { availableEndpoint },
3949
4147
  ...creditsLimit === void 0 ? {} : { creditsLimit },
@@ -4220,6 +4418,32 @@ async function readModelTestBody(req) {
4220
4418
  }
4221
4419
  return ids;
4222
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
+ }
4223
4447
  /** Loopback browser origins only; other devices are refused until trusted origins exist. */
4224
4448
  function loopbackOrigin(req) {
4225
4449
  const origin = req.headers.origin;
@@ -4232,6 +4456,17 @@ function loopbackOrigin(req) {
4232
4456
  }
4233
4457
  }
4234
4458
  /**
4459
+ * Whether a pack's expiry is one the card can actually use.
4460
+ *
4461
+ * One predicate, shared by the per-pack projection and the bucket summary, so
4462
+ * the two can never disagree about which packs count as dated. `0` and the
4463
+ * non-finite values are excluded: a `0` here would read as 1970 and sort ahead
4464
+ * of every real date, and `NaN`/`Infinity` are not dates at all.
4465
+ */
4466
+ function usableExpiry(endTimeMs) {
4467
+ return typeof endTimeMs === "number" && Number.isFinite(endTimeMs) && endTimeMs > 0;
4468
+ }
4469
+ /**
4235
4470
  * Map the credit snapshot to the card's compact credit document.
4236
4471
  *
4237
4472
  * Exported so the "is another account usable?" probe (issue #25) derives its
@@ -4239,13 +4474,14 @@ function loopbackOrigin(req) {
4239
4474
  * — a second implementation of the Work/general split would be free to disagree
4240
4475
  * with the number shown next to it.
4241
4476
  */
4242
- function toCredits(snapshot) {
4477
+ function toCredits(snapshot, nowMs = Date.now()) {
4243
4478
  const { totalAmount, consumedAmount } = snapshot.summary;
4244
4479
  const credit = (value) => Math.round(value * 1e4) / 1e4;
4245
4480
  const accounts = snapshot.packs.map((pack) => ({
4246
4481
  displayDesc: pack.displayDesc,
4247
4482
  remain: credit(Math.max(0, (pack.creditsLimit ?? 0) - (pack.consumedCredits ?? 0))),
4248
- size: pack.creditsLimit ?? 0
4483
+ size: pack.creditsLimit ?? 0,
4484
+ ...usableExpiry(pack.endTimeMs) ? { expiresAtMs: pack.endTimeMs } : {}
4249
4485
  }));
4250
4486
  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);
4251
4487
  return {
@@ -4254,9 +4490,74 @@ function toCredits(snapshot) {
4254
4490
  available: totalAmount - consumedAmount,
4255
4491
  workAvailable: remaining(1),
4256
4492
  generalAvailable: remaining(0),
4257
- accounts
4493
+ accounts,
4494
+ ...summarisePackExpiry(accounts, nowMs)
4258
4495
  };
4259
4496
  }
4497
+ /** Empty bucket, used whenever no pack qualifies. */
4498
+ const NO_EXPIRING_CREDIT = {
4499
+ total: 0,
4500
+ packs: 0
4501
+ };
4502
+ /**
4503
+ * Sum the credit lapsing inside `horizonMs` of `nowMs`.
4504
+ *
4505
+ * Only positive balances count: a lapsed pack with nothing left is not a loss,
4506
+ * and including it would make every long-lived account look permanently at
4507
+ * risk. Only *future* expiries count too — see {@link expiredPacks}.
4508
+ */
4509
+ function expiringWithin(packs, nowMs, horizonMs) {
4510
+ return sumExpiring(packs, (expiresAt) => expiresAt > nowMs && expiresAt - nowMs <= horizonMs);
4511
+ }
4512
+ /**
4513
+ * Sum the credit whose pack has already lapsed but still carries a balance.
4514
+ *
4515
+ * Kept apart from {@link expiringWithin} because a lapsed pack satisfies every
4516
+ * forward-looking horizon at once (`expiresAt - now` is negative, hence "within
4517
+ * 24 hours"), which would make a long-dead pack look permanently imminent.
4518
+ */
4519
+ function expiredPacks(packs, nowMs) {
4520
+ return sumExpiring(packs, (expiresAt) => expiresAt <= nowMs);
4521
+ }
4522
+ /** Shared accumulator over packs whose expiry passes `matches`. */
4523
+ function sumExpiring(packs, matches) {
4524
+ let total = 0;
4525
+ let count = 0;
4526
+ let earliestExpiresAtMs;
4527
+ for (const pack of packs) {
4528
+ const expiresAt = pack.expiresAtMs;
4529
+ if (!usableExpiry(expiresAt) || pack.remain <= 0) continue;
4530
+ if (!matches(expiresAt)) continue;
4531
+ total += pack.remain;
4532
+ count += 1;
4533
+ if (earliestExpiresAtMs === void 0 || expiresAt < earliestExpiresAtMs) earliestExpiresAtMs = expiresAt;
4534
+ }
4535
+ if (count === 0) return NO_EXPIRING_CREDIT;
4536
+ return {
4537
+ total,
4538
+ packs: count,
4539
+ ...earliestExpiresAtMs === void 0 ? {} : { earliestExpiresAtMs }
4540
+ };
4541
+ }
4542
+ /**
4543
+ * Bucket the card's credit by how soon it lapses.
4544
+ *
4545
+ * Returns no `expiry` at all when not one pack carried a usable end time, so a
4546
+ * caller can tell "nothing is expiring" apart from "we were never told when
4547
+ * anything expires" — the second must not render as reassurance. Uses the same
4548
+ * {@link usableExpiry} predicate as the per-pack projection, so a summary is
4549
+ * never produced from a set of dates the card itself would not carry.
4550
+ */
4551
+ function summarisePackExpiry(accounts, nowMs) {
4552
+ if (!accounts.some((pack) => usableExpiry(pack.expiresAtMs))) return {};
4553
+ const DAY = 864e5;
4554
+ return { expiry: {
4555
+ within24h: expiringWithin(accounts, nowMs, DAY),
4556
+ within3d: expiringWithin(accounts, nowMs, 3 * DAY),
4557
+ within7d: expiringWithin(accounts, nowMs, 7 * DAY),
4558
+ expired: expiredPacks(accounts, nowMs)
4559
+ } };
4560
+ }
4260
4561
  /** Map the check-in answer to the card's compact document. */
4261
4562
  function toCheckin(status) {
4262
4563
  return {
@@ -4471,6 +4772,33 @@ function registerTraeUsageRoute(ctx, deps) {
4471
4772
  }
4472
4773
  }
4473
4774
  });
4775
+ /**
4776
+ * Per-account status for one row of the card's account table (CN:
4777
+ * check-in + balances; ai: subscription). Read-only, one upstream call per
4778
+ * account, fetched once per table render. `accountId` rides in the query
4779
+ * (a GET, like every other read-only sibling). Absent `accountStateOf`
4780
+ * the route answers 503 so the table degrades to bare rows.
4781
+ */
4782
+ const disposeAccountStatus = ctx.webServer.register({
4783
+ kind: "exact",
4784
+ path: TRAE_ACCOUNT_STATUS_PATH,
4785
+ handler: async (req, res) => {
4786
+ if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
4787
+ if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
4788
+ if (deps.accountStateOf === void 0) return json(res, 503, { error: "account status unavailable" });
4789
+ const region = requestRegion(req, res);
4790
+ if (region === void 0) return;
4791
+ const accountId = new URL(req.url ?? "/", "http://localhost").searchParams.get("accountId");
4792
+ if (accountId === null || accountId.trim() === "") return json(res, 400, { error: "missing accountId" });
4793
+ try {
4794
+ const state = await deps.accountStateOf(region, accountId);
4795
+ if (state === void 0) return json(res, 404, { error: "unknown account" });
4796
+ json(res, 200, state);
4797
+ } catch (error) {
4798
+ json(res, 500, { error: safeMessage(error) });
4799
+ }
4800
+ }
4801
+ });
4474
4802
  const disposeAccounts = ctx.webServer.register({
4475
4803
  kind: "exact",
4476
4804
  path: TRAE_ACCOUNTS_REFRESH_PATH,
@@ -4566,6 +4894,27 @@ function registerTraeUsageRoute(ctx, deps) {
4566
4894
  if (region === void 0) return;
4567
4895
  if (region !== "cn") return json(res, 404, { error: "check-in is not available for the international region" });
4568
4896
  try {
4897
+ let accountId;
4898
+ try {
4899
+ accountId = await readAccountBody(req);
4900
+ } catch {
4901
+ accountId = void 0;
4902
+ }
4903
+ if (accountId !== void 0) {
4904
+ if (deps.claimCheckinOf === void 0) return json(res, 503, { error: "check-in by account unavailable" });
4905
+ const outcome = await deps.claimCheckinOf(region, accountId);
4906
+ if (outcome === void 0) return json(res, 404, { error: "unknown account" });
4907
+ const { claim, checkin: after } = outcome;
4908
+ const deviceClaimed = claim.code === CHECKIN_DEVICE_ALREADY_CLAIMED;
4909
+ return json(res, 200, {
4910
+ claimed: claim.claimed,
4911
+ alreadyCheckedIn: after.checkedIn,
4912
+ ...deviceClaimed ? { deviceCheckedIn: true } : {},
4913
+ code: claim.code,
4914
+ message: claim.message,
4915
+ checkin: toCheckin(after)
4916
+ });
4917
+ }
4569
4918
  const client = deps.client(region);
4570
4919
  const current = await client.checkinStatus();
4571
4920
  if (!current.enabled) return json(res, 409, { error: "check-in is not enabled for this account" });
@@ -4605,6 +4954,7 @@ function registerTraeUsageRoute(ctx, deps) {
4605
4954
  disposeModelsTest();
4606
4955
  disposeAccounts();
4607
4956
  disposeAccountCredits();
4957
+ disposeAccountStatus();
4608
4958
  disposeUsage();
4609
4959
  };
4610
4960
  }, "dsh-connect-trae: Web usage route");
@@ -5026,16 +5376,27 @@ function apply(ctx, config) {
5026
5376
  }], hint ?? (region === "ai" ? "solo-sg" : "cn"));
5027
5377
  };
5028
5378
  /**
5029
- * Resolution failures degrade to omitting DeviceInfo (its being required
5030
- * is unverified, docs/INTL_SG_EVIDENCE.md §5) rather than failing the
5031
- * refresh.
5379
+ * The current refresh contract signs a DeviceProof with the install's own
5380
+ * registered keypair, so every field the server reads must come from the
5381
+ * SAME installation the credential belongs to.
5382
+ *
5383
+ * Resolution failures leave `deviceKeyPair` absent, and the refresh then
5384
+ * degrades to the superseded contract instead of sending an unsigned
5385
+ * request the server answers with `20405 Device proof required`.
5032
5386
  */
5033
5387
  const refreshDeviceSafe = async () => {
5034
5388
  try {
5035
5389
  const value = await identity();
5036
5390
  return {
5037
5391
  deviceId: value.deviceId,
5038
- machineId: value.machineId
5392
+ machineId: value.machineId,
5393
+ ...value.deviceName === void 0 ? {} : { deviceName: value.deviceName },
5394
+ ...value.deviceModel === void 0 ? {} : { deviceModel: value.deviceModel },
5395
+ ...value.deviceBrand === void 0 ? {} : { deviceBrand: value.deviceBrand },
5396
+ ...value.deviceCpu === void 0 ? {} : { deviceCpu: value.deviceCpu },
5397
+ ...value.osVersion === void 0 ? {} : { osVersion: value.osVersion },
5398
+ ...value.appVersion === void 0 ? {} : { appVersion: value.appVersion },
5399
+ ...value.deviceKeyPair === void 0 ? {} : { deviceKeyPair: value.deviceKeyPair }
5039
5400
  };
5040
5401
  } catch {
5041
5402
  return;
@@ -5093,16 +5454,17 @@ function apply(ctx, config) {
5093
5454
  ctx.logger.warn("dsh-connect-trae: Raw gateway unavailable; continuing with native SOLO tool-call channel", error);
5094
5455
  }
5095
5456
  })();
5457
+ const usageClient = new TraeUsageClient({
5458
+ credential: () => store.resolve(),
5459
+ deviceId: async () => (await identity()).deviceId
5460
+ });
5096
5461
  stacks[region] = {
5097
5462
  region,
5098
5463
  store,
5099
5464
  catalog,
5100
5465
  shim,
5101
5466
  delegating,
5102
- usageClient: new TraeUsageClient({
5103
- credential: () => store.resolve(),
5104
- deviceId: async () => (await identity()).deviceId
5105
- }),
5467
+ usageClient,
5106
5468
  /**
5107
5469
  * Credit balance of one named account, WITHOUT rebinding the store
5108
5470
  * (issue #25). The card asks this only after it saw the bound account
@@ -5119,6 +5481,39 @@ function apply(ctx, config) {
5119
5481
  if (credential === void 0) return void 0;
5120
5482
  return toCredits(await new TraeUsageClient({ credential: async () => credential }).snapshot());
5121
5483
  },
5484
+ accountStateOf: async (accountId) => {
5485
+ const state = { id: accountId };
5486
+ const selectedCredential = await store.current();
5487
+ const client = selectedCredential !== void 0 && traeAccountId(selectedCredential) === accountId ? usageClient : (() => {
5488
+ const credential = store.credentialOf(accountId);
5489
+ return new TraeUsageClient({
5490
+ credential: async () => credential,
5491
+ deviceId: async () => (await identity()).deviceId
5492
+ });
5493
+ })();
5494
+ if (region === "ai") {
5495
+ state.payStatus = await client.payStatus();
5496
+ return state;
5497
+ }
5498
+ const [snapshot, checkin] = await Promise.allSettled([client.snapshot(), client.checkinStatus()]);
5499
+ if (snapshot.status === "fulfilled") state.credits = toCredits(snapshot.value);
5500
+ if (checkin.status === "fulfilled") state.checkin = toCheckin(checkin.value);
5501
+ return state;
5502
+ },
5503
+ claimCheckinOf: async (accountId) => {
5504
+ const selectedCredential = await store.current();
5505
+ const client = selectedCredential !== void 0 && traeAccountId(selectedCredential) === accountId ? usageClient : (() => {
5506
+ const credential = store.credentialOf(accountId);
5507
+ return new TraeUsageClient({
5508
+ credential: async () => credential,
5509
+ deviceId: async () => (await identity()).deviceId
5510
+ });
5511
+ })();
5512
+ return {
5513
+ claim: await client.claimCheckin(),
5514
+ checkin: await client.checkinStatus()
5515
+ };
5516
+ },
5122
5517
  discoverModels: async (signal) => {
5123
5518
  const merged = mergeTraeModelSources(await withDirectoryRetry(() => remoteCatalog.fetchModels(signal)), await solo.fetchModels(signal));
5124
5519
  wire.callableKeys.clear();
@@ -5222,6 +5617,8 @@ function apply(ctx, config) {
5222
5617
  probeModels: (region, ids) => stacks[region].probeModels(ids),
5223
5618
  rawDiagnostic: (region) => stacks[region].rawDiagnostic(),
5224
5619
  creditsOfAccount: (region, accountId) => stacks[region].creditsOfAccount(accountId),
5620
+ accountStateOf: (region, accountId) => stacks[region].accountStateOf(accountId),
5621
+ claimCheckinOf: (region, accountId) => stacks[region].claimCheckinOf(accountId),
5225
5622
  liveLogin: (region) => liveLoginOf(region)
5226
5623
  }));
5227
5624
  /**
@@ -5412,4 +5809,4 @@ function apply(ctx, config) {
5412
5809
  });
5413
5810
  }
5414
5811
  //#endregion
5415
- 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, 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 };
5812
+ 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 };