dsh-connect-trae 3.0.1 → 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/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;
@@ -4245,7 +4469,8 @@ function toCredits(snapshot) {
4245
4469
  const accounts = snapshot.packs.map((pack) => ({
4246
4470
  displayDesc: pack.displayDesc,
4247
4471
  remain: credit(Math.max(0, (pack.creditsLimit ?? 0) - (pack.consumedCredits ?? 0))),
4248
- size: pack.creditsLimit ?? 0
4472
+ size: pack.creditsLimit ?? 0,
4473
+ ...typeof pack.endTimeMs === "number" && Number.isFinite(pack.endTimeMs) && pack.endTimeMs > 0 ? { expiresAtMs: pack.endTimeMs } : {}
4249
4474
  }));
4250
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);
4251
4476
  return {
@@ -4471,6 +4696,33 @@ function registerTraeUsageRoute(ctx, deps) {
4471
4696
  }
4472
4697
  }
4473
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
+ });
4474
4726
  const disposeAccounts = ctx.webServer.register({
4475
4727
  kind: "exact",
4476
4728
  path: TRAE_ACCOUNTS_REFRESH_PATH,
@@ -4566,6 +4818,27 @@ function registerTraeUsageRoute(ctx, deps) {
4566
4818
  if (region === void 0) return;
4567
4819
  if (region !== "cn") return json(res, 404, { error: "check-in is not available for the international region" });
4568
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
+ }
4569
4842
  const client = deps.client(region);
4570
4843
  const current = await client.checkinStatus();
4571
4844
  if (!current.enabled) return json(res, 409, { error: "check-in is not enabled for this account" });
@@ -4605,6 +4878,7 @@ function registerTraeUsageRoute(ctx, deps) {
4605
4878
  disposeModelsTest();
4606
4879
  disposeAccounts();
4607
4880
  disposeAccountCredits();
4881
+ disposeAccountStatus();
4608
4882
  disposeUsage();
4609
4883
  };
4610
4884
  }, "dsh-connect-trae: Web usage route");
@@ -5026,16 +5300,27 @@ function apply(ctx, config) {
5026
5300
  }], hint ?? (region === "ai" ? "solo-sg" : "cn"));
5027
5301
  };
5028
5302
  /**
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.
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`.
5032
5310
  */
5033
5311
  const refreshDeviceSafe = async () => {
5034
5312
  try {
5035
5313
  const value = await identity();
5036
5314
  return {
5037
5315
  deviceId: value.deviceId,
5038
- 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 }
5039
5324
  };
5040
5325
  } catch {
5041
5326
  return;
@@ -5093,16 +5378,17 @@ function apply(ctx, config) {
5093
5378
  ctx.logger.warn("dsh-connect-trae: Raw gateway unavailable; continuing with native SOLO tool-call channel", error);
5094
5379
  }
5095
5380
  })();
5381
+ const usageClient = new TraeUsageClient({
5382
+ credential: () => store.resolve(),
5383
+ deviceId: async () => (await identity()).deviceId
5384
+ });
5096
5385
  stacks[region] = {
5097
5386
  region,
5098
5387
  store,
5099
5388
  catalog,
5100
5389
  shim,
5101
5390
  delegating,
5102
- usageClient: new TraeUsageClient({
5103
- credential: () => store.resolve(),
5104
- deviceId: async () => (await identity()).deviceId
5105
- }),
5391
+ usageClient,
5106
5392
  /**
5107
5393
  * Credit balance of one named account, WITHOUT rebinding the store
5108
5394
  * (issue #25). The card asks this only after it saw the bound account
@@ -5119,6 +5405,39 @@ function apply(ctx, config) {
5119
5405
  if (credential === void 0) return void 0;
5120
5406
  return toCredits(await new TraeUsageClient({ credential: async () => credential }).snapshot());
5121
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
+ },
5122
5441
  discoverModels: async (signal) => {
5123
5442
  const merged = mergeTraeModelSources(await withDirectoryRetry(() => remoteCatalog.fetchModels(signal)), await solo.fetchModels(signal));
5124
5443
  wire.callableKeys.clear();
@@ -5222,6 +5541,8 @@ function apply(ctx, config) {
5222
5541
  probeModels: (region, ids) => stacks[region].probeModels(ids),
5223
5542
  rawDiagnostic: (region) => stacks[region].rawDiagnostic(),
5224
5543
  creditsOfAccount: (region, accountId) => stacks[region].creditsOfAccount(accountId),
5544
+ accountStateOf: (region, accountId) => stacks[region].accountStateOf(accountId),
5545
+ claimCheckinOf: (region, accountId) => stacks[region].claimCheckinOf(accountId),
5225
5546
  liveLogin: (region) => liveLoginOf(region)
5226
5547
  }));
5227
5548
  /**
@@ -5412,4 +5733,4 @@ function apply(ctx, config) {
5412
5733
  });
5413
5734
  }
5414
5735
  //#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 };
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 };
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-connect-trae",
3
3
  "displayName": "DSH Connect Trae",
4
4
  "description": "把本机登录的 Trae 模型接入 DeepSeek Harness:国内版与国际版双供应商并行,提供用量/积分概览与每日签到领取。",
5
- "version": "3.0.1",
5
+ "version": "3.3.0",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",