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/CHANGELOG.md +79 -0
- package/README.md +1 -0
- package/lib/client.js +517 -0
- package/lib/index.d.ts +42 -0
- package/lib/index.js +526 -26
- package/package.json +1 -1
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
|
-
|
|
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
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
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
|
-
|
|
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
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
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)
|
|
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
|
+
"version": "3.6.1",
|
|
6
6
|
"author": "dingminhua",
|
|
7
7
|
"copyright": "Copyright (c) 2026 LaoDing",
|
|
8
8
|
"license": "MIT",
|