@magpie-community/opencode-trae-auth 0.2.13 → 0.2.14

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.
Files changed (3) hide show
  1. package/README.md +21 -2
  2. package/index.mjs +133 -7
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -288,10 +288,29 @@ answered it 9074 even with the client's headers.
288
288
  `checked_in`, `credits`.
289
289
  - **Claim:** `POST api.trae.cn/trae/api/v2/ug/checkin_credits/claim`,
290
290
  the same, only while it is on and today's isn't in.
291
+ - **The device, when Trae CN's own client is here (0.2.14):** the
292
+ account this machine's Trae CN desktop client is signed in as checks in
293
+ on the client's own device, the one Trae's device service registered:
294
+ `x-device-id` is the id in the client's
295
+ `globalStorage/storage.json` key `iCubeAuthInfo://icube-dc:<id>`,
296
+ `x-device-type` and `x-os-version` are this machine's, and no
297
+ `x-device-brand` is sent. The account is matched by the `userId` in the
298
+ client's sign-in (`iCubeAuthInfo://icube.cloudide`, plain JSON or the
299
+ client's fixed codec, as dingminhua/dsh-connect-trae reads it). The file
300
+ is only read: nothing in it is written, and the client's token is never
301
+ used. On one account the client's claim went through while the
302
+ plugin's, on its own device, got 9074 (yetone/magpie#808, #1920); that
303
+ this header is what changes it hasn't been tried on a real account
304
+ yet. Every other account keeps its own device, and a file that can't be
305
+ read or made out, or holds no single device, changes nothing. Files:
306
+ macOS `~/Library/Application Support/Trae CN/…`, Windows
307
+ `%APPDATA%\Trae CN\…` (or `trae-cn`), Linux `~/.config/trae-cn/…`
308
+ (or `Trae CN`; not seen on a real Linux install).
291
309
  - **What comes back:** Trae's answer as it is. A 401 or code 1001 marks
292
310
  the account for a new sign-in. Code 9095 means this device has checked
293
- in today; nothing sends another device id to get round it, nor to get
294
- round a 9074.
311
+ in today. No device id is ever made up, changed or rotated to get round
312
+ a 9095 or a 9074, and no account is given a device that isn't its
313
+ own client's.
295
314
 
296
315
  Only `api.trae.cn`'s `/trae/api/` pages are sent this way; any other URL
297
316
  that isn't a chat request is still refused (400). Needs 0.1.4 or later.
package/index.mjs CHANGED
@@ -9,8 +9,11 @@
9
9
  // The account's fetch also sends Trae CN's own pages on api.trae.cn
10
10
  // (/trae/api/…) as the account, for magpie's daily check-in (每日签到:
11
11
  // /trae/api/v2/ug/checkin_credits/status, then /claim).
12
- import { createHash, generateKeyPairSync, randomBytes, randomUUID, randomInt, sign } from "node:crypto"
12
+ import { createDecipheriv, createHash, generateKeyPairSync, randomBytes, randomUUID, randomInt, sign } from "node:crypto"
13
+ import { readFile } from "node:fs/promises"
13
14
  import { createServer, STATUS_CODES } from "node:http"
15
+ import { homedir, release, version as osVersion } from "node:os"
16
+ import { join } from "node:path"
14
17
 
15
18
  const SITES = {
16
19
  // Trae CN (trae.cn): the authorization page sends the browser back to a
@@ -739,9 +742,12 @@ const ugUA = (site) =>
739
742
  // ugHeaders are the headers Trae CN's IDE (3.3.104) sends a growth page
740
743
  // with, and only those: its JWT, then its device (fb: x-device-id,
741
744
  // x-device-brand, x-device-type, x-os-version, x-app-version), not the
742
- // chat's tokens and versions.
743
- function ugHeaders(a, site, extra = {}) {
744
- return {
745
+ // chat's tokens and versions. own is this machine's Trae CN client, when
746
+ // it is signed in as this very account (ownClient): its device id and
747
+ // this machine's OS go instead of the account's, and no brand is named
748
+ // (fb leaves it out when it has no model to name).
749
+ function ugHeaders(a, site, extra = {}, own) {
750
+ const h = {
745
751
  "Content-Type": "application/json",
746
752
  Authorization: `Cloud-IDE-JWT ${a.token}`,
747
753
  "User-Agent": ugUA(site),
@@ -750,8 +756,121 @@ function ugHeaders(a, site, extra = {}) {
750
756
  "x-device-type": "Windows",
751
757
  "x-os-version": "10.0.22631",
752
758
  "x-app-version": site.clientVersion,
753
- ...extra,
754
759
  }
760
+ if (own) {
761
+ h["x-device-id"] = own.deviceId
762
+ h["x-device-type"] = own.type
763
+ h["x-os-version"] = own.os
764
+ delete h["x-device-brand"]
765
+ }
766
+ return { ...h, ...extra }
767
+ }
768
+
769
+ // ---- this machine's own Trae CN client ----------------------------------------------
770
+
771
+ // Trae CN's desktop client keeps its state in a VS Code-style
772
+ // globalStorage/storage.json. The device id Trae's device service gave
773
+ // it is in a key's name, iCubeAuthInfo://icube-dc:<digits> (15 or 16
774
+ // digits seen); its sign-in is the value of iCubeAuthInfo://icube.cloudide,
775
+ // a JSON envelope (userId, token, …) stored as is or obfuscated with the
776
+ // client's fixed AES-128-CBC codec. Seen in real clients' files
777
+ // (yetone/magpie#808: the same id in the client's own did= query;
778
+ // yetone/magpie#1705) and read the same way by dingminhua/dsh-connect-trae
779
+ // (MIT), whose codec this is.
780
+ const DC_KEY = "iCubeAuthInfo://icube-dc:"
781
+ const AUTH_KEY = "iCubeAuthInfo://icube.cloudide"
782
+
783
+ // ownClient.paths are where storage.json may be, in order; tests replace it
784
+ const ownClient = {
785
+ paths: () => clientStoragePaths(process.platform, homedir(), process.env),
786
+ }
787
+
788
+ function clientStoragePaths(platform, home, env) {
789
+ const file = (root, name) => join(root, name, "User", "globalStorage", "storage.json")
790
+ if (platform === "darwin") return [file(join(home, "Library", "Application Support"), "Trae CN")]
791
+ if (platform === "win32") {
792
+ const roots = [env.APPDATA, join(home, "AppData", "Roaming")].filter((r, i, all) => r && all.indexOf(r) === i)
793
+ return roots.flatMap((r) => ["Trae CN", "trae-cn"].map((n) => file(r, n)))
794
+ }
795
+ if (platform === "linux") return ["trae-cn", "Trae CN"].map((n) => file(env.XDG_CONFIG_HOME || join(home, ".config"), n))
796
+ return []
797
+ }
798
+
799
+ // clientDevice is the device id of this machine's Trae CN client when the
800
+ // client is signed in as uid, else undefined. It only reads: nothing of
801
+ // the client's is written, kept or sent but that id, and the client's own
802
+ // token is read past, never used. A file it can't read or make out, or one
803
+ // with no single device, names no device, and the account keeps its own.
804
+ // Never an id made up or changed: an account the client isn't signed in
805
+ // as never gets the client's id.
806
+ async function clientDevice(uid) {
807
+ if (!uid) return undefined
808
+ for (const p of ownClient.paths()) {
809
+ let text
810
+ try {
811
+ text = await readFile(p, "utf8")
812
+ } catch {
813
+ continue // not installed here
814
+ }
815
+ try {
816
+ const doc = JSON.parse(text)
817
+ const ids = Object.keys(doc ?? {}).filter((k) => k.startsWith(DC_KEY)).map((k) => k.slice(DC_KEY.length))
818
+ if (ids.length !== 1 || !/^\d{8,20}$/.test(ids[0])) return undefined
819
+ const who = clientAuth(doc[AUTH_KEY])?.userId
820
+ return who != null && String(who) === String(uid) ? ids[0] : undefined
821
+ } catch {
822
+ return undefined
823
+ }
824
+ }
825
+ return undefined
826
+ }
827
+
828
+ // clientAuth is the client's sign-in envelope, as stored or decoded
829
+ function clientAuth(v) {
830
+ if (typeof v !== "string" || !v.trim()) return undefined
831
+ const t = v.trim()
832
+ return JSON.parse(t.startsWith("{") ? t : clientDecode(t))
833
+ }
834
+
835
+ const xorOf = (a, b) => Buffer.from(a.map((x, i) => x ^ b[i]))
836
+ const CODEC = {
837
+ "746305100000": xorOf(
838
+ [82, 9, 106, 213, 48, 54, 165, 56, 191, 64, 163, 158, 129, 243, 215, 251, 124, 227, 57, 130, 155, 47, 255, 135, 52, 142, 67, 68, 196, 222, 233, 203, 84, 123, 148, 50, 166, 194, 35, 61, 238, 76, 149, 11, 66, 250, 195, 78, 8, 46, 161, 102, 40, 217, 36, 178, 118, 91, 162, 73, 109, 139, 209, 37],
839
+ [31, 221, 168, 51, 136, 7, 199, 49, 177, 18, 16, 89, 39, 128, 236, 95, 96, 81, 127, 169, 25, 181, 74, 13, 45, 229, 122, 159, 147, 201, 156, 239, 160, 224, 59, 77, 174, 42, 245, 176, 200, 235, 187, 60, 131, 83, 153, 97, 23, 43, 4, 126, 186, 119, 214, 38, 225, 105, 20, 99, 85, 33, 12, 125],
840
+ ),
841
+ "123920200203": xorOf(
842
+ [191, 192, 216, 250, 122, 246, 220, 97, 31, 254, 98, 27, 8, 72, 71, 176, 135, 99, 96, 18, 127, 101, 203, 104, 211, 102, 191, 125, 37, 72, 150, 156, 51, 229, 121, 35, 17, 153, 141, 177, 110, 131, 150, 128, 172, 255, 254, 6, 18, 140, 55, 62, 236, 249, 135, 64, 135, 12, 117, 4, 89, 149, 168, 209],
843
+ [246, 204, 26, 232, 232, 70, 129, 109, 223, 146, 169, 242, 23, 241, 105, 145, 50, 196, 165, 42, 254, 120, 3, 54, 244, 207, 209, 85, 53, 6, 138, 106, 175, 148, 31, 204, 186, 186, 165, 182, 87, 142, 49, 10, 39, 110, 26, 154, 86, 56, 173, 125, 18, 64, 198, 225, 99, 99, 83, 82, 191, 134, 76, 170],
844
+ ),
845
+ }
846
+
847
+ // clientDecode undoes the client's codec: a 6-byte header naming the salt,
848
+ // 32 random bytes, then AES-128-CBC under sha512(sha512(random) + salt);
849
+ // the plaintext is led by its own sha512, which is checked
850
+ function clientDecode(b64) {
851
+ const buf = Buffer.from(b64, "base64")
852
+ const salt = CODEC[buf.subarray(0, 6).toString("hex")]
853
+ if (!salt || buf.length <= 102) throw new Error("not the client's codec")
854
+ const key = createHash("sha512").update(Buffer.concat([createHash("sha512").update(buf.subarray(6, 38)).digest(), salt])).digest()
855
+ const d = createDecipheriv("aes-128-cbc", key.subarray(0, 16), key.subarray(16, 32))
856
+ const out = Buffer.concat([d.update(buf.subarray(38)), d.final()])
857
+ const body = out.subarray(64)
858
+ if (out.length < 64 || !createHash("sha512").update(body).digest().equals(out.subarray(0, 64))) throw new Error("the client's value doesn't check")
859
+ return body.toString("utf8")
860
+ }
861
+
862
+ // thisMachine is how the client names this machine's OS: its type, and
863
+ // the OS's own version (Windows' product name, as "Windows 11 Home China"
864
+ // was seen, else its build; elsewhere the kernel's release)
865
+ function thisMachine(platform = process.platform) {
866
+ if (platform === "win32") {
867
+ let v = ""
868
+ try {
869
+ v = osVersion()
870
+ } catch {}
871
+ return { type: "windows", os: /^Windows \d/.test(v) ? v : `Windows ${release()}` }
872
+ }
873
+ return { type: platform === "darwin" ? "mac" : platform, os: `${platform === "darwin" ? "macOS" : platform} ${release()}` }
755
874
  }
756
875
 
757
876
  // ugPage: url is one of Trae's growth pages (the check-in)
@@ -2328,9 +2447,16 @@ const makePlugin = (site) => async ({ client }) => {
2328
2447
  const ug = ugPage(url)
2329
2448
  let sent = method === "GET" || method === "HEAD" ? undefined : typeof body === "string" ? body : "{}"
2330
2449
  if (ug && sent !== undefined && /^\s*(\{\s*\})?\s*$/.test(sent)) sent = JSON.stringify({ req_source: 1 })
2450
+ // the account this machine's own Trae CN client is signed in as checks
2451
+ // in on the client's device, the one Trae registered: on one account
2452
+ // the client's own claim went through while the plugin's, on a device
2453
+ // it had made up, came back 9074 (yetone/magpie#808, #1920). Every
2454
+ // other account keeps its own device; none is given the client's.
2455
+ const deviceId = ug && site.id === "trae-cn" ? await clientDevice(a.uid) : undefined
2456
+ const own = deviceId ? { deviceId, ...thisMachine() } : undefined
2331
2457
  const res = await fetch(url, {
2332
2458
  method,
2333
- headers: ug ? ugHeaders(a, site, { Accept: "application/json" }) : ideHeaders(a, site, { Accept: "application/json" }),
2459
+ headers: ug ? ugHeaders(a, site, { Accept: "application/json" }, own) : ideHeaders(a, site, { Accept: "application/json" }),
2334
2460
  body: sent,
2335
2461
  signal: signal ?? AbortSignal.timeout(20_000),
2336
2462
  })
@@ -2683,4 +2809,4 @@ const makePlugin = (site) => async ({ client }) => {
2683
2809
  export const TraeCNAuthPlugin = makePlugin(SITES["trae-cn"])
2684
2810
  export const TraeGlobalAuthPlugin = makePlugin(SITES["trae-global"])
2685
2811
 
2686
- export const _internal = { traeHost, accountOf, HOSTS: SITES["trae-cn"].hosts, MODELS: SITES["trae-cn"].models, TextTools, NativeCalls, looseJSON, glmCall, nameCall, toolNamed, unwrapped, traeMessages, chatBody, credits, dollarUsageOf, whenOf, newDevice, proofOf, SITES, SESSION, conversationID }
2812
+ export const _internal = { ownClient, clientStoragePaths, clientDevice, clientDecode, thisMachine, traeHost, accountOf, HOSTS: SITES["trae-cn"].hosts, MODELS: SITES["trae-cn"].models, TextTools, NativeCalls, looseJSON, glmCall, nameCall, toolNamed, unwrapped, traeMessages, chatBody, credits, dollarUsageOf, whenOf, newDevice, proofOf, SITES, SESSION, conversationID }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-trae-auth",
3
- "version": "0.2.13",
3
+ "version": "0.2.14",
4
4
  "description": "OpenCode provider plugins for Trae: Trae CN (trae.cn) and Trae Global (trae.ai), free tier included, with Trae's own browser sign-in",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",