dsh-connect-trae 3.5.1 → 3.6.1

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
@@ -829,6 +829,24 @@ const TRAE_ACCOUNT_STATUS_PATH = "/plugins/dsh-connect-trae/account-status";
829
829
  * account row.
830
830
  */
831
831
  const TRAE_CHECKIN_PATH = "/plugins/dsh-connect-trae/checkin";
832
+ /**
833
+ * Plugin-owned account transfer endpoint: batch export and import of a region's
834
+ * stored credentials (issue #37).
835
+ *
836
+ * One route with an `action` query parameter (`export` | `preview` | `import`),
837
+ * mirroring the sibling `dsh-connect-workbuddy` plugin's transfer route so the
838
+ * two cards behave the same way for a user who has both installed. The vault
839
+ * document never leaves the Host: the browser receives parsed answers, and a
840
+ * preview carries display fields only — never token material.
841
+ */
842
+ const TRAE_TRANSFER_PATH = "/plugins/dsh-connect-trae/transfer";
843
+ /** Read the transfer action off a request URL; unknown/absent means undefined. */
844
+ function transferActionOf(url) {
845
+ const at = url.indexOf("?");
846
+ if (at === -1) return void 0;
847
+ const value = new URLSearchParams(url.slice(at + 1)).get("action");
848
+ return value === "export" || value === "preview" || value === "import" ? value : void 0;
849
+ }
832
850
  /** Query parameter naming the region a card request addresses. */
833
851
  const TRAE_REGION_PARAM = "region";
834
852
  /** Every region, in card tab order. */
@@ -1581,7 +1599,24 @@ function regionOfCredential(credential) {
1581
1599
  //#region src/auth.ts
1582
1600
  /** Host used for CLI tokens, which carry no host claim of their own. */
1583
1601
  const CLI_DEFAULT_HOST = "https://api.trae.cn";
1584
- const OWN_VERSION = 1;
1602
+ /**
1603
+ * On-disk format of the plugin-owned per-region file.
1604
+ *
1605
+ * Version 1 held exactly ONE credential (`{ version, credential }`), which was
1606
+ * enough while every account came from a Trae install the plugin can discover
1607
+ * itself. Import (issue #37) breaks that assumption: a user can hold several
1608
+ * accounts as transferable files, and they have to live somewhere. Version 2
1609
+ * keeps the same per-region file but stores `{ version, credentials: [...] }`
1610
+ * instead, so the file stays the unit of region isolation (see
1611
+ * {@link traeOwnAuthPath}) while holding as many accounts as were imported.
1612
+ *
1613
+ * Version 1 remains READ (see {@link parseOwn}), so an existing install keeps
1614
+ * working and upgrades itself the next time a credential is written; it is
1615
+ * never written again.
1616
+ */
1617
+ const OWN_VERSION = 2;
1618
+ /** The superseded single-credential layout, still read for migration. */
1619
+ const OWN_VERSION_SINGLE = 1;
1585
1620
  const TRAE_AUTH_FILENAME = ".trae-auth.json";
1586
1621
  /** Prefix of the plugin-owned per-region credential copies. */
1587
1622
  const TRAE_OWN_PREFIX = ".trae-auth";
@@ -1644,27 +1679,68 @@ function traeAccountId(credential) {
1644
1679
  const stable = `${credential.edition}\0${credential.userId || credential.accountName || "unknown"}`;
1645
1680
  return createHash("sha256").update(stable).digest("hex").slice(0, 24);
1646
1681
  }
1682
+ /** One stored credential, validated. Returns undefined for a malformed row. */
1683
+ function credentialOfStored(stored) {
1684
+ if (typeof stored !== "object" || stored === null || Array.isArray(stored)) return void 0;
1685
+ const record = stored;
1686
+ const edition = record["edition"];
1687
+ if (edition !== "cn" && edition !== "sg" && edition !== "solo" && edition !== "solo-sg") return void 0;
1688
+ return normalizeTraeCredential({
1689
+ token: record["accessToken"],
1690
+ refreshToken: record["refreshToken"],
1691
+ userId: record["userId"],
1692
+ host: record["host"],
1693
+ userRegion: record["userRegion"],
1694
+ account: record["accountName"] === void 0 ? void 0 : { username: record["accountName"] },
1695
+ expiredAt: record["expiresAtMs"],
1696
+ refreshExpiredAt: record["refreshExpiredAtMs"]
1697
+ }, edition, "dsh");
1698
+ }
1699
+ /**
1700
+ * Read the plugin-owned file into its stored credentials.
1701
+ *
1702
+ * Accepts BOTH layouts: version 2 (`credentials: [...]`, the import-capable
1703
+ * shape) and the superseded version 1 (`credential: {...}`). A version-1 file
1704
+ * keeps working until the next write upgrades it, so no migration step is
1705
+ * needed and an older install's file is never rejected. A malformed row is
1706
+ * dropped rather than failing the whole file — one bad imported account must
1707
+ * not hide the rest.
1708
+ */
1647
1709
  function parseOwn(text) {
1648
1710
  try {
1649
1711
  const document = JSON.parse(text);
1650
- if (document.version !== OWN_VERSION || typeof document.credential !== "object" || document.credential === null) return void 0;
1651
- const stored = document.credential;
1652
- const edition = stored["edition"];
1653
- if (edition !== "cn" && edition !== "sg" && edition !== "solo" && edition !== "solo-sg") return void 0;
1654
- return normalizeTraeCredential({
1655
- token: stored["accessToken"],
1656
- refreshToken: stored["refreshToken"],
1657
- userId: stored["userId"],
1658
- host: stored["host"],
1659
- userRegion: stored["userRegion"],
1660
- account: stored["accountName"] === void 0 ? void 0 : { username: stored["accountName"] },
1661
- expiredAt: stored["expiresAtMs"],
1662
- refreshExpiredAt: stored["refreshExpiresAtMs"]
1663
- }, edition, "dsh");
1712
+ if (document.version === OWN_VERSION) {
1713
+ if (!Array.isArray(document.credentials)) return [];
1714
+ return document.credentials.map(credentialOfStored).filter((value) => value !== void 0);
1715
+ }
1716
+ if (document.version === OWN_VERSION_SINGLE) {
1717
+ const single = credentialOfStored(document.credential);
1718
+ return single === void 0 ? [] : [single];
1719
+ }
1720
+ return [];
1664
1721
  } catch {
1665
- return;
1722
+ return [];
1666
1723
  }
1667
1724
  }
1725
+ /**
1726
+ * Write the plugin-owned file: every credential of the region, in the version-2
1727
+ * array layout.
1728
+ *
1729
+ * One writer for both paths that persist to this file (a refresh, and an
1730
+ * import) so the read-modify-write in `refreshNow` and the additive write in
1731
+ * {@link TraeCredentialStore.importAccounts} cannot drift apart. Always writes
1732
+ * `OWN_VERSION`, which is what upgrades a version-1 file on its first write.
1733
+ */
1734
+ async function writeOwn(path, credentials) {
1735
+ const document = {
1736
+ version: OWN_VERSION,
1737
+ credentials: credentials.map((credential) => ({ ...credential }))
1738
+ };
1739
+ await writeFileAtomic(path, `${JSON.stringify(document, null, 2)}\n`, {
1740
+ mode: 384,
1741
+ dirMode: 448
1742
+ });
1743
+ }
1668
1744
  var TraeCredentialStore = class {
1669
1745
  storagePathOverride;
1670
1746
  edition;
@@ -1791,6 +1867,7 @@ var TraeCredentialStore = class {
1791
1867
  region: regionOfCredential(live),
1792
1868
  source: live.source,
1793
1869
  tokenExpiresAtMs: live.expiresAtMs,
1870
+ ...live.refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs: live.refreshExpiresAtMs },
1794
1871
  selected: false
1795
1872
  };
1796
1873
  }
@@ -1805,6 +1882,7 @@ var TraeCredentialStore = class {
1805
1882
  region: regionOfCredential(credential),
1806
1883
  source: credential.source,
1807
1884
  tokenExpiresAtMs: credential.expiresAtMs,
1885
+ ...credential.refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs: credential.refreshExpiresAtMs },
1808
1886
  selected: selectedExists ? traeAccountId(credential) === this.accountId : credential === defaultSelected
1809
1887
  }));
1810
1888
  }
@@ -1832,6 +1910,46 @@ var TraeCredentialStore = class {
1832
1910
  if (accountId === "") return void 0;
1833
1911
  return (await this.readAll()).find((credential) => traeAccountId(credential) === accountId);
1834
1912
  }
1913
+ /**
1914
+ * Store imported credentials (issue #37), replacing any entry with the same
1915
+ * account id and leaving every other entry alone.
1916
+ *
1917
+ * Why an additive write rather than the refresh path: an imported credential
1918
+ * has NO desktop file behind it, so unlike a refresh result it cannot be
1919
+ * rediscovered from the machine. If it were written the way a refresh is —
1920
+ * as the whole document — importing account B would delete account A, and a
1921
+ * later refresh would delete both. `readAll`'s desktop-first preference is
1922
+ * therefore also not enough on its own: these entries survive only because
1923
+ * `readOwns` reads them back and the dedupe keeps the longest-lived copy.
1924
+ *
1925
+ * Returns the account ids actually written, so the caller can report how many
1926
+ * landed without re-reading the store.
1927
+ */
1928
+ async importAccounts(credentials) {
1929
+ if (credentials.length === 0) return [];
1930
+ const ownPath = this.ownAuthPath();
1931
+ const written = [];
1932
+ await withFileLock(ownPath, async () => {
1933
+ const existing = await readFile(ownPath, "utf8").then(parseOwn, () => []);
1934
+ const byId = /* @__PURE__ */ new Map();
1935
+ for (const item of existing) byId.set(traeAccountId(item), item);
1936
+ for (const credential of credentials) {
1937
+ const stored = {
1938
+ ...credential,
1939
+ source: "dsh"
1940
+ };
1941
+ const id = traeAccountId(stored);
1942
+ byId.set(id, stored);
1943
+ written.push(id);
1944
+ }
1945
+ await writeOwn(ownPath, [...byId.values()]);
1946
+ });
1947
+ if (written.length > 0) {
1948
+ this.accountId = written[written.length - 1];
1949
+ this.inflight = void 0;
1950
+ }
1951
+ return written;
1952
+ }
1835
1953
  async resolve() {
1836
1954
  const credential = await this.current();
1837
1955
  if (credential === void 0) throw new Error(`trae: no signed-in account found (${this.candidates().map((item) => item.path).join(" or ")})`);
@@ -1976,8 +2094,7 @@ var TraeCredentialStore = class {
1976
2094
  async readOwns() {
1977
2095
  const copies = [];
1978
2096
  for (const path of this.ownCandidates()) try {
1979
- const parsed = parseOwn(await readFile(path, "utf8"));
1980
- if (parsed !== void 0) copies.push(parsed);
2097
+ copies.push(...parseOwn(await readFile(path, "utf8")));
1981
2098
  } catch {}
1982
2099
  return copies;
1983
2100
  }
@@ -1999,13 +2116,11 @@ var TraeCredentialStore = class {
1999
2116
  };
2000
2117
  const ownPath = this.ownAuthPath();
2001
2118
  await withFileLock(ownPath, async () => {
2002
- await writeFileAtomic(ownPath, `${JSON.stringify({
2003
- version: OWN_VERSION,
2004
- credential: refreshed
2005
- }, null, 2)}\n`, {
2006
- mode: 384,
2007
- dirMode: 448
2008
- });
2119
+ const existing = await readFile(ownPath, "utf8").then(parseOwn, () => []);
2120
+ const refreshedId = traeAccountId(refreshed);
2121
+ const entries = existing.filter((item) => traeAccountId(item) !== refreshedId);
2122
+ entries.push(refreshed);
2123
+ await writeOwn(ownPath, entries);
2009
2124
  });
2010
2125
  return refreshed;
2011
2126
  } catch (error) {
@@ -2926,6 +3041,23 @@ function wireCreditMultiplier(config) {
2926
3041
  if (typeof data !== "object" || data === null) return void 0;
2927
3042
  return finitePositive$1(data["rate"]);
2928
3043
  }
3044
+ /**
3045
+ * Thrown by {@link TraeSoloUpstreamClient.fetchModels} when every requested
3046
+ * directory function answered HTTP 200 but with an EMPTY `config_info_list`
3047
+ * (no rows at all), so the aggregate roster is empty. This is issue #39's root
3048
+ * signal: the upstream has published NO SOLO function config for this account /
3049
+ * route. It is a server-side, usually transient gap — the reporter's roster
3050
+ * returned empty and later self-resolved with rows per function — NOT a local
3051
+ * credential, plugin or account fault, and not something to re-login for. The
3052
+ * caller can catch this type to tell "the upstream published no config" apart
3053
+ * from "all directory functions errored" (which surfaces as a generic failure).
3054
+ */
3055
+ var TraeEmptySoloDirectory = class extends Error {
3056
+ name = "TraeEmptySoloDirectory";
3057
+ constructor() {
3058
+ super("Trae’s server returned NO SOLO function config (an empty directory for every requested function) — an upstream-side gap that usually clears on its own; retry later instead of re-checking credentials or the plugin");
3059
+ }
3060
+ };
2929
3061
  var TraeSoloUpstreamClient = class {
2930
3062
  options;
2931
3063
  fetchImpl;
@@ -2982,7 +3114,10 @@ var TraeSoloUpstreamClient = class {
2982
3114
  this.collectModels(list, directoryFunction, byId);
2983
3115
  }
2984
3116
  const models = [...byId.values()];
2985
- if (models.length === 0) throw new Error(`Trae SOLO models response contained no models (${failures.join("; ") || "empty directory"})`);
3117
+ if (models.length === 0) {
3118
+ if (failures.length === 0) throw new TraeEmptySoloDirectory();
3119
+ throw new Error(`Trae SOLO models response contained no models (${failures.join("; ")})`);
3120
+ }
2986
3121
  return models;
2987
3122
  }
2988
3123
  /** Merge one function's config list into the shared catalogue (first wins). */
@@ -3208,9 +3343,20 @@ function decodeTraeEvent(event) {
3208
3343
  * two different statements, and a user hits the second without touching the
3209
3344
  * first (issue #33).
3210
3345
  * - `4023` — the model name is unknown on the route that was asked.
3346
+ * - `2001` — the upstream has not published its function config for the route
3347
+ * that was asked. Evidence is issue #39: on a live CN account every SOLO
3348
+ * function's `get_detail_param` answered HTTP 200 with `config_info_list:
3349
+ * []` (an empty directory), and each chat call failed with
3350
+ * `{"code":2001,"message":"no function config found for appId=...,
3351
+ * function=solo_work_remote"}` (and the variant `[LLMUtilsChat.resolveByUsage]
3352
+ * function is empty, cannot resolve model by usage=`). The roster came back
3353
+ * empty and then self-resolved later with rows per function, so this is a
3354
+ * TRANSIENT server-side gap the user can wait out or retry — never a local
3355
+ * credential, plugin or account fault, and not something to re-login for.
3211
3356
  */
3212
3357
  const TRAE_ERROR_HINTS = {
3213
3358
  1005: "Trae requires a paid plan for this model; the current account's plan does not cover it",
3359
+ 2001: "Trae has not published its function config for this route (an upstream condition, not a local credential or config problem)",
3214
3360
  4001: "Trae does not serve this model under the SOLO function the request used (config_name rejected)",
3215
3361
  4008: "Trae reports the account's SOLO quota is exhausted for this request",
3216
3362
  4011: "Trae throttled the SOLO channel for this account (a rate limit, not the credit balance)",
@@ -4796,6 +4942,268 @@ var TraeUsageClient = class {
4796
4942
  }
4797
4943
  };
4798
4944
  //#endregion
4945
+ //#region src/transfer.ts
4946
+ /**
4947
+ * Account transfer: the export / import file format shared with sibling
4948
+ * credential managers for the same upstream (dsh-connect-workbuddy ships the
4949
+ * identical contract so export files interoperate).
4950
+ *
4951
+ * 参考:通行实现 —— 文件是一个 JSON 数组,每项为一条凭据记录,顶层字段
4952
+ * snake_case(`access_token` / `refresh_token` / `uid` / `nickname` /
4953
+ * `email` / `domain` / `expiresAt`(ms) / `refreshExpiresAt`(ms) /
4954
+ * `auth_raw`),导入按 account id 去重(同 id 覆盖),缺 `access_token`
4955
+ * 的记录跳过;预览只回脱敏字段(含 `hasToken` 布尔量),不回明文。解析
4956
+ * 函数不依赖文件系统,便于无 UI 环境单测;落库由 Host 侧的 store 完成。
4957
+ *
4958
+ * 互通 / 单位边界:`expiresAt` 一族按契约是**毫秒**。workbuddy 的 vault 文档
4959
+ * 存的是秒,需要在导出/导入边界换算;trae 的 `TraeCredential.expiresAtMs`
4960
+ * (见 auth.ts 的 `timeToMs`)**本身就是毫秒**,所以这里**不做任何
4961
+ * 乘除**——刻意与 workbuddy 不同,读者对比两文件时不要误以为换算被漏掉了。
4962
+ * 导出时 `auth_raw` 直接携带原始认证文档(桌面 storage 文档或 `{auth,
4963
+ * account}` 双键块),供写入桌面认证文件的工具原样消费。
4964
+ *
4965
+ * @module dsh-connect-trae/transfer
4966
+ */
4967
+ /**
4968
+ * Parse transfer-file text: a JSON array, every element an object.
4969
+ *
4970
+ * The error strings carry the position (1-based item number) because a file
4971
+ * from another tool can be big, and "第 N 项" is what lets the user find the
4972
+ * broken record in their editor.
4973
+ */
4974
+ function parseTransferFile(text) {
4975
+ if (text.trim() === "") throw new Error("transfer file is empty");
4976
+ let parsed;
4977
+ try {
4978
+ parsed = JSON.parse(text);
4979
+ } catch (error) {
4980
+ throw new Error("transfer file is not valid JSON: " + (error instanceof Error ? error.message : String(error)));
4981
+ }
4982
+ if (!Array.isArray(parsed)) throw new Error("transfer file must be a JSON array of account records");
4983
+ for (const [index, item] of parsed.entries()) if (typeof item !== "object" || item === null || Array.isArray(item)) throw new Error("transfer file item " + (index + 1) + " is not an account object");
4984
+ return parsed;
4985
+ }
4986
+ /** One string field of a record, tolerating the sibling format's odd shapes. */
4987
+ function displayString(value) {
4988
+ if (typeof value === "string") return value;
4989
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
4990
+ return "";
4991
+ }
4992
+ /** Whether a record carries a usable token (a non-empty string). */
4993
+ function hasToken(record) {
4994
+ const token = record["access_token"];
4995
+ return typeof token === "string" && token !== "";
4996
+ }
4997
+ /**
4998
+ * The transfer file's desensitized preview: display fields only.
4999
+ *
5000
+ * Fields come from EXTERNAL files, so a display field may hold an object or an
5001
+ * array (a sibling tool can store the desktop app's encrypted-wrapper nickname
5002
+ * verbatim); every preview field is normalized to a string or emptied rather
5003
+ * than letting a non-renderable value reach the card.
5004
+ */
5005
+ function previewTransferFile(text) {
5006
+ const records = parseTransferFile(text);
5007
+ return {
5008
+ accounts: records.map((record, index) => ({
5009
+ index,
5010
+ uid: displayString(record["uid"]),
5011
+ nickname: displayString(record["nickname"]),
5012
+ email: displayString(record["email"]),
5013
+ hasToken: hasToken(record)
5014
+ })),
5015
+ total: records.length
5016
+ };
5017
+ }
5018
+ /**
5019
+ * Positive-match region claim for an IMPORTED record: the record's own
5020
+ * `userRegion` claim wins, the host suffix is the fallback, and anything
5021
+ * unrecognised (empty, misspelt, or other-region) yields `undefined` — never a
5022
+ * default.
5023
+ *
5024
+ * This is the security-critical difference from `regionOfCredential`, which
5025
+ * classifies anything unrecognised as `cn` as a last resort (via the edition
5026
+ * label). That fallback is right for upstream-sourced data, whose host/region
5027
+ * came out of the real Trae install, but wrong for an import whose values are
5028
+ * simply whatever the file's author wrote: a spoofed or misspelt `domain`
5029
+ * would otherwise be admitted into the CN vault and cross-bill the CN provider
5030
+ * stack with a credential that belongs to no real CN account. So this helper
5031
+ * reuses `regionOfUserRegion` / `regionOfHost` — both of which already return
5032
+ * `undefined` on unrecognised input — and deliberately does NOT consult the
5033
+ * edition fallback.
5034
+ */
5035
+ function knownRegionOfCredential(credential) {
5036
+ return regionOfUserRegion(credential.userRegion) ?? regionOfHost(credential.host);
5037
+ }
5038
+ /**
5039
+ * Project one credential into the shared transfer record shape.
5040
+ *
5041
+ * `expiresAt` / `refreshExpiresAt` are emitted in epoch MILLISECONDS — the
5042
+ * transfer contract's unit. Unlike workbuddy — whose vault stored seconds and
5043
+ * had to convert here — trae's `expiresAtMs` is already milliseconds, so the
5044
+ * value is carried through verbatim (documented above; do not rescale).
5045
+ * `auth_raw` carries the original auth document so a tool that writes desktop
5046
+ * auth files can consume it verbatim.
5047
+ */
5048
+ function traeTransferRecordOf(credential, authRawDocument) {
5049
+ return {
5050
+ access_token: credential.accessToken,
5051
+ ...credential.refreshToken !== void 0 && credential.refreshToken !== "" ? { refresh_token: credential.refreshToken } : {},
5052
+ ...credential.userId !== "" ? { uid: credential.userId } : {},
5053
+ ...credential.accountName !== void 0 ? { nickname: credential.accountName } : {},
5054
+ ...credential.host !== "" ? { domain: credential.host } : {},
5055
+ ...credential.expiresAtMs > 0 ? { expiresAt: credential.expiresAtMs } : {},
5056
+ ...credential.refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAt: credential.refreshExpiresAtMs },
5057
+ auth_raw: authRawDocument
5058
+ };
5059
+ }
5060
+ /** The `userId` claim from a record: the flat `uid`, else recovered from `auth_raw`. */
5061
+ function userIdOfRaw(record) {
5062
+ const flat = displayString(record["uid"]);
5063
+ if (flat !== "") return flat;
5064
+ const root = rawRoot(record["auth_raw"]);
5065
+ if (root === void 0) return "";
5066
+ return displayString(root["userId"]) ?? "";
5067
+ }
5068
+ /** The `accountName` claim: the flat `nickname`, else recovered from `auth_raw`. */
5069
+ function accountNameOfRaw(record) {
5070
+ const flat = displayString(record["nickname"]);
5071
+ if (flat !== "") return flat;
5072
+ const root = rawRoot(record["auth_raw"]);
5073
+ if (root === void 0) return void 0;
5074
+ const fromAccount = displayString((typeof root["account"] === "object" && root["account"] !== null ? root["account"] : void 0)?.["username"]);
5075
+ if (fromAccount !== "") return fromAccount;
5076
+ return displayString(root["username"]);
5077
+ }
5078
+ /** The raw `userRegion` claim string from `auth_raw` (object `{region}` or bare value). */
5079
+ function userRegionOfRaw(raw) {
5080
+ const root = rawRoot(raw);
5081
+ if (root === void 0) return void 0;
5082
+ const value = root["userRegion"];
5083
+ const claim = typeof value === "object" && value !== null && !Array.isArray(value) ? value["region"] : value;
5084
+ return typeof claim === "string" && claim.trim() !== "" ? claim.trim() : void 0;
5085
+ }
5086
+ /**
5087
+ * A record's `auth_raw` root, tolerating both the nested `{auth, account}` block
5088
+ * this plugin exports and a raw desktop storage document: `account` may be a
5089
+ * sub-object (the block shape) or absent (flat document shape).
5090
+ */
5091
+ function rawRoot(raw) {
5092
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return void 0;
5093
+ return raw;
5094
+ }
5095
+ /**
5096
+ * Edition derived from an imported record's `domain` host, positively.
5097
+ *
5098
+ * The shared transfer format has no edition field (only `domain`), so a
5099
+ * re-import must re-derive the routing label from the host. Only the two base
5100
+ * editions are ever assigned — a flat `domain` + `uid` cannot distinguish the
5101
+ * SOLO variants — so this is inherently lossy for SOLO accounts; that is
5102
+ * acceptable because the region gate (see `mergeTransferRecords`) never
5103
+ * consults edition, and the account id's edition component only affects whether
5104
+ * a re-imported account converges on the same stored id (which it does as long
5105
+ * as the host is positively classified).
5106
+ */
5107
+ function editionOfDomain(host) {
5108
+ const region = regionOfHost(host);
5109
+ if (region === "ai") return "sg";
5110
+ if (region === "cn") return "cn";
5111
+ }
5112
+ /**
5113
+ * Project one transfer record into a credential, or undefined when the record
5114
+ * carries no token (the importer's skip rule).
5115
+ *
5116
+ * `userId` empty is tolerated at THIS layer because a token-only record still
5117
+ * describes a usable credential; the Host's region gate decides whether it is
5118
+ * representable, and the store's account id (`edition \0 userId || accountName`)
5119
+ * decides whether it is addressable. An unopenable `auth_raw` is ignored — the
5120
+ * flat fields are the contract, `auth_raw` is extra fidelity for the write-back
5121
+ * path.
5122
+ *
5123
+ * `edition` is restored positively from the domain host (see `editionOfDomain`),
5124
+ * defaulting to `cn` ONLY as a neutral placeholder when the host is not
5125
+ * classifiable — this is safe because the region gate never consults edition,
5126
+ * and such a record is always `skipped` there before edition could matter.
5127
+ */
5128
+ function credentialOfTransferRecord(record) {
5129
+ const accessToken = record["access_token"];
5130
+ if (typeof accessToken !== "string" || accessToken === "") return void 0;
5131
+ const refreshToken = typeof record["refresh_token"] === "string" ? record["refresh_token"] : "";
5132
+ const expiresAtMs = typeof record["expiresAt"] === "number" && Number.isFinite(record["expiresAt"]) && record["expiresAt"] > 0 ? record["expiresAt"] : 0;
5133
+ const refreshExpiresAtMs = typeof record["refreshExpiresAt"] === "number" && Number.isFinite(record["refreshExpiresAt"]) && record["refreshExpiresAt"] > 0 ? record["refreshExpiresAt"] : void 0;
5134
+ const userId = userIdOfRaw(record);
5135
+ const accountName = accountNameOfRaw(record);
5136
+ const host = typeof record["domain"] === "string" ? record["domain"] : "";
5137
+ const edition = editionOfDomain(host) ?? "cn";
5138
+ const userRegion = userRegionOfRaw(record["auth_raw"]);
5139
+ return {
5140
+ accessToken,
5141
+ ...refreshToken === "" ? {} : { refreshToken },
5142
+ userId,
5143
+ ...accountName === void 0 ? {} : { accountName },
5144
+ host,
5145
+ ...userRegion === void 0 ? {} : { userRegion },
5146
+ expiresAtMs,
5147
+ ...refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs },
5148
+ edition
5149
+ };
5150
+ }
5151
+ /** Default gate so the merge is secure even when the caller omits the judge. */
5152
+ const defaultRegionJudge = knownRegionOfCredential;
5153
+ /**
5154
+ * Pure merge: fold selected records into a CHANGES map keyed by account id.
5155
+ *
5156
+ * Mirrors the sibling merger's contract: a record with the same account id
5157
+ * OVERWRITES the stored entry (the imported tokens replace what is stored —
5158
+ * re-importing a re-login must refresh it; the write layer stores the record
5159
+ * atomically, so "overwrite" needs no knowledge of the incumbent), and a
5160
+ * record without a token is skipped. The region gate lives HERE rather than in
5161
+ * the route so it is testable without a Host.
5162
+ *
5163
+ * The region gate is a POSITIVE match: a record is kept only when its OWN
5164
+ * region claim is positively known to equal the target region. `regionOfCredential`
5165
+ * classifies anything unrecognised as `cn`, which is fine for upstream-sourced
5166
+ * data but WRONG for an imported file whose host/region are author-controlled —
5167
+ * a spoofed or misspelt value would otherwise be admitted into the CN vault.
5168
+ * Here the injected `knownRegionOfCredential` (defaulting to the exported
5169
+ * positive-match helper, which never consults the edition fallback) returns
5170
+ * `undefined` for an empty, unrecognised, or other-region claim, and such a
5171
+ * record counts as `skipped` rather than being silently imported cross-region.
5172
+ *
5173
+ * The caller writes one store file per map entry, so a selection that is
5174
+ * already stored rewrites those files with identical content — idempotent,
5175
+ * and the steady state of importing the same file twice.
5176
+ */
5177
+ function mergeTransferRecords(records, indexes, region, knownRegionOfCredential = defaultRegionJudge) {
5178
+ const changes = /* @__PURE__ */ new Map();
5179
+ let imported = 0;
5180
+ let skipped = 0;
5181
+ for (const index of indexes) {
5182
+ const record = records[index];
5183
+ if (record === void 0) {
5184
+ skipped += 1;
5185
+ continue;
5186
+ }
5187
+ const credential = credentialOfTransferRecord(record);
5188
+ if (credential === void 0) {
5189
+ skipped += 1;
5190
+ continue;
5191
+ }
5192
+ if (knownRegionOfCredential(credential) !== region) {
5193
+ skipped += 1;
5194
+ continue;
5195
+ }
5196
+ const id = traeAccountId(credential);
5197
+ changes.set(id, credential);
5198
+ imported += 1;
5199
+ }
5200
+ return {
5201
+ changes,
5202
+ imported,
5203
+ skipped
5204
+ };
5205
+ }
5206
+ //#endregion
4799
5207
  //#region src/web-status.ts
4800
5208
  /**
4801
5209
  * The upstream's business code for "该设备今日已参与签到" — this DEVICE already
@@ -4880,6 +5288,35 @@ async function readAccountBody(req) {
4880
5288
  if (typeof accountId !== "string" || accountId.trim() === "") throw new Error("body must carry an accountId string");
4881
5289
  return accountId.trim();
4882
5290
  }
5291
+ /**
5292
+ * Read a transfer-file POST body: the raw file TEXT plus the record indexes the
5293
+ * user selected, or (for `export`) the account ids to include.
5294
+ *
5295
+ * The ceiling is much larger than the other readers' because this body IS a
5296
+ * user-chosen file rather than a couple of ids — a credential export from a
5297
+ * sibling tool can legitimately be hundreds of kilobytes. It is still bounded so
5298
+ * a malformed or hostile request cannot make the Host buffer without limit.
5299
+ */
5300
+ async function readTransferBody(req) {
5301
+ const chunks = [];
5302
+ let size = 0;
5303
+ for await (const chunk of req) {
5304
+ const buffer = chunk;
5305
+ size += buffer.byteLength;
5306
+ if (size > 4194304) throw new Error("request body too large");
5307
+ chunks.push(buffer);
5308
+ }
5309
+ const text = Buffer.concat(chunks).toString("utf8").trim();
5310
+ if (text === "") return {};
5311
+ let parsed;
5312
+ try {
5313
+ parsed = JSON.parse(text);
5314
+ } catch {
5315
+ throw new Error("invalid JSON body");
5316
+ }
5317
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("body must be a JSON object");
5318
+ return parsed;
5319
+ }
4883
5320
  /** Loopback browser origins only; other devices are refused until trusted origins exist. */
4884
5321
  function loopbackOrigin(req) {
4885
5322
  const origin = req.headers.origin;
@@ -5049,6 +5486,7 @@ async function traeWebUsage(deps, region) {
5049
5486
  accountId: accounts.find((item) => item.selected)?.id ?? "",
5050
5487
  accountName: credential.accountName ?? credential.userId,
5051
5488
  tokenExpiresAtMs: credential.expiresAtMs,
5489
+ ...credential.refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs: credential.refreshExpiresAtMs },
5052
5490
  region,
5053
5491
  enabled,
5054
5492
  accounts,
@@ -5250,6 +5688,67 @@ function registerTraeUsageRoute(ctx, deps) {
5250
5688
  }
5251
5689
  }
5252
5690
  });
5691
+ /**
5692
+ * Batch credential export / import (issue #37).
5693
+ *
5694
+ * The Host owns every byte of the credential store; the browser only receives
5695
+ * parsed answers. `preview` necessarily returns identifiers the card shows
5696
+ * (uid / nickname / email) plus a `hasToken` boolean — never a token — and
5697
+ * `import` re-parses the file server-side rather than trusting the preview,
5698
+ * so a tampered browser cannot smuggle in a record the preview never showed.
5699
+ */
5700
+ const disposeTransfer = ctx.webServer.register({
5701
+ kind: "exact",
5702
+ path: TRAE_TRANSFER_PATH,
5703
+ handler: async (req, res) => {
5704
+ if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
5705
+ if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
5706
+ const region = requestRegion(req, res);
5707
+ if (region === void 0) return;
5708
+ const action = transferActionOf(req.url ?? "");
5709
+ if (action === void 0) return json(res, 400, { error: "unknown transfer action" });
5710
+ try {
5711
+ const body = await readTransferBody(req);
5712
+ if (action === "export") {
5713
+ const wanted = new Set(Array.isArray(body["accountIds"]) ? body["accountIds"].filter((id) => typeof id === "string") : []);
5714
+ const store = deps.store(region);
5715
+ const records = [];
5716
+ for (const account of await store.accounts()) {
5717
+ if (!wanted.has(account.id)) continue;
5718
+ const credential = await store.credentialOf(account.id).catch(() => void 0);
5719
+ if (credential === void 0) continue;
5720
+ records.push(traeTransferRecordOf(credential, {
5721
+ auth: {
5722
+ accessToken: credential.accessToken,
5723
+ ...credential.refreshToken === void 0 ? {} : { refreshToken: credential.refreshToken },
5724
+ expiresAt: credential.expiresAtMs > 0 ? Math.floor(credential.expiresAtMs / 1e3) : 0,
5725
+ ...credential.refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAt: Math.floor(credential.refreshExpiresAtMs / 1e3) },
5726
+ host: credential.host,
5727
+ ...credential.userRegion === void 0 ? {} : { userRegion: credential.userRegion }
5728
+ },
5729
+ account: {
5730
+ userId: credential.userId,
5731
+ ...credential.accountName === void 0 ? {} : { username: credential.accountName },
5732
+ edition: credential.edition
5733
+ }
5734
+ }));
5735
+ }
5736
+ return json(res, 200, { accounts: records });
5737
+ }
5738
+ const text = typeof body["text"] === "string" ? body["text"] : "";
5739
+ if (action === "preview") return json(res, 200, previewTransferFile(text));
5740
+ const indexes = Array.isArray(body["indexes"]) ? body["indexes"].filter((index) => typeof index === "number" && Number.isInteger(index)) : [];
5741
+ const { changes, imported, skipped } = mergeTransferRecords(parseTransferFile(text), indexes, region);
5742
+ if (changes.size > 0) await deps.store(region).importAccounts([...changes.values()]);
5743
+ json(res, 200, {
5744
+ imported,
5745
+ skipped
5746
+ });
5747
+ } catch (error) {
5748
+ json(res, 400, { error: safeMessage(error) });
5749
+ }
5750
+ }
5751
+ });
5253
5752
  const disposeRefresh = ctx.webServer.register({
5254
5753
  kind: "exact",
5255
5754
  path: TRAE_MODELS_REFRESH_PATH,
@@ -5387,6 +5886,7 @@ function registerTraeUsageRoute(ctx, deps) {
5387
5886
  return () => {
5388
5887
  disposeCheckin();
5389
5888
  disposeRefresh();
5889
+ disposeTransfer();
5390
5890
  disposeModelsTest();
5391
5891
  disposeAccounts();
5392
5892
  disposeAccountCredits();
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.5.1",
5
+ "version": "3.6.1",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",