@magpie-community/opencode-trae-auth 0.2.12 → 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 +31 -3
  2. package/index.mjs +324 -11
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -128,7 +128,16 @@ agent's `POST https://trae-api-cn.mchost.guru/api/agent/v3/llm_utils_chat`:
128
128
  - GLM also writes its own template, `<tool_call>name<arg_key>k</arg_key>
129
129
  <arg_value>v</arg_value></tool_call>` (a string kept as written, other
130
130
  types read as JSON), and sometimes a whole call into a native call's
131
- name; both are read as the call.
131
+ name; both are read as the call. A name that holds the wrapper with
132
+ GLM's tags where its service stopped mid-call — `{"name":"bash",
133
+ "arguments":{"command</arg_key><arg_value>ls</arg_value>` — is read too:
134
+ the tags are the service's own handoff, so what stands between them is
135
+ the model's text (a quote left unescaped read as the quote it is, a
136
+ comma inside a value kept, a field's closing tag ending the value before
137
+ it), and the fields the native call did carry are kept beside the ones
138
+ the name's own text fills in. A name whose tool the request doesn't
139
+ have, or one no field can be read from, goes on as written, as any other
140
+ unknown tool does.
132
141
  - A block is read as models get it wrong too: every quote escaped
133
142
  (`{\"name\":…}`), quotes or backslashes in a string left unescaped, an
134
143
  opener with the name as an attribute (`<tool_call name="bash">` with
@@ -279,10 +288,29 @@ answered it 9074 even with the client's headers.
279
288
  `checked_in`, `credits`.
280
289
  - **Claim:** `POST api.trae.cn/trae/api/v2/ug/checkin_credits/claim`,
281
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).
282
309
  - **What comes back:** Trae's answer as it is. A 401 or code 1001 marks
283
310
  the account for a new sign-in. Code 9095 means this device has checked
284
- in today; nothing sends another device id to get round it, nor to get
285
- 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.
286
314
 
287
315
  Only `api.trae.cn`'s `/trae/api/` pages are sent this way; any other URL
288
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)
@@ -1493,6 +1612,165 @@ function glmCall(s, tools = []) {
1493
1612
  return { name, arguments: args }
1494
1613
  }
1495
1614
 
1615
+ // Trae reads a model's call for it and hands a call it could not read on as
1616
+ // the native call: the wrapper the client's prompt asked for, with GLM's own
1617
+ // template tags where the model wrote them and a field's close left behind
1618
+ // (``{"name":"bash","arguments":{"command</arg_key><arg_value>ls</arg_value>``).
1619
+ // The name then holds the call the model meant, and the call's own arguments
1620
+ // — where the model wrote them — hold the rest of it.
1621
+ const NAMECALL = /^\s*\{\s*"name"\s*:\s*"([^"\\]*)"\s*,\s*"(?:arguments|input|parameters)"\s*:\s*\{/
1622
+ // a wrapper the service put text in front of (`bash_abstract_command_0>{…}`):
1623
+ // the same shape, wherever it starts
1624
+ const NAMEHEAD = /\{\s*"name"\s*:\s*"([^"\\]*)"\s*,\s*"(?:arguments|input|parameters)"\s*:\s*\{/
1625
+ const NAMETAG = /<\/?arg_(?:key|value)\s*>/
1626
+
1627
+ // nameCall is the call a name holds when it is one the model wrote there
1628
+ // ({name, arguments}), null when it isn't: a name without the wrapper, one
1629
+ // whose tool the request doesn't have, or one no field could be read from.
1630
+ // The wrapper and the tags are the service's own handoff, so what stands
1631
+ // between them is the model's text as it wrote it — a quote it left
1632
+ // unescaped is read as the quote it is, the way repairedJSON reads a file's
1633
+ // content. A value is JSON where the tool's schema says the field is no
1634
+ // string and the text is one; else the text, cut where the value was cut.
1635
+ function nameCall(s, tools = []) {
1636
+ const text = String(s ?? "")
1637
+ // a wrapper the service put text in front of (`bash_abstract_command_0>{…}`)
1638
+ // is the call it holds, that text being no name the request has
1639
+ const at = text.indexOf("{")
1640
+ if (at < 0) return null
1641
+ const m = NAMECALL.exec(text.slice(at))
1642
+ if (!m) return null
1643
+ const name = m[1]
1644
+ const tool = tools.map((t) => t.function ?? t).find((t) => t?.name === name)
1645
+ if (!tool) return null
1646
+ const fields = nameFields(text.slice(at + m[0].length))
1647
+ const args = {}
1648
+ for (const [key, value] of fields) {
1649
+ // a field the schema says is no string is its JSON where it is one (a
1650
+ // number the model wrote, an object), else the text it is
1651
+ if (tool.parameters?.properties?.[key]?.type !== "string") {
1652
+ try {
1653
+ args[key] = JSON.parse(value.trim())
1654
+ continue
1655
+ } catch {}
1656
+ }
1657
+ args[key] = value
1658
+ }
1659
+ return Object.keys(args).length ? { name, arguments: args } : null
1660
+ }
1661
+
1662
+ // nameFields reads a wrapper's body as its fields: a key, then its value.
1663
+ // A tag stands where the service cut a colon or a quote, so a value ends at
1664
+ // the tag that ends it, at the next key, or at the structure that closes it
1665
+ // — and at a closing tag (`</description>`) whose name is a field's, because
1666
+ // what follows such a tag is the service's own text, not the model's.
1667
+ // What none of those reached is no field's text and is left out, so a call
1668
+ // cut in the middle keeps what it has.
1669
+ function nameFields(body) {
1670
+ const out = []
1671
+ let i = 0
1672
+ for (let n = 0; n < 64 && i < body.length; n++) {
1673
+ // between fields: blanks, a comma, or the tags that stand for either
1674
+ i += (body.slice(i).match(/^[\s,]+|^<\/?[a-z_]+\s*>/) || [""])[0].length
1675
+ if (body[i] !== '"') break
1676
+ const key = nameString(body, i, () => true)
1677
+ i = key.i
1678
+ if (!key.text) break
1679
+ const colon = body.slice(i).match(/^\s*:\s*/)
1680
+ if (colon) i += colon[0].length
1681
+ // a value the service cut at its tag: what stands after the tag is the
1682
+ // model's text (limit</arg_key><arg_value>55)
1683
+ i += (body.slice(i).match(/^(?:<\/?[a-z_]+\s*>)+/) || [""])[0].length
1684
+ let value
1685
+ if (body[i] === '"') {
1686
+ const v = nameString(body, i, (rest) => endsValue(rest))
1687
+ value = v.text
1688
+ i = v.i
1689
+ if (!value && !v.closed) break // an open quote with nothing in it
1690
+ } else {
1691
+ const rest = body.slice(i)
1692
+ const stop = valueEnd(rest)
1693
+ value = rest.slice(0, stop).replace(/^[\s,:]+/, "")
1694
+ // the field's own close: the structure that follows it, and a quote
1695
+ // the service cut it at where an escape isn't what stands before it
1696
+ value = value.replace(/[\s,}\]]+$/, "").replace(/(?<!\\)"$/, "").replace(/[\s,}\]]+$/, "")
1697
+ i += stop
1698
+ // nothing introduced a value (no colon, no quote, no tag) and none
1699
+ // stands: the wrapper is cut at the key, and an empty call read out
1700
+ // of it would be a call the model never wrote
1701
+ if (!value) break
1702
+ }
1703
+ out.push([key.text, value])
1704
+ }
1705
+ return out
1706
+ }
1707
+
1708
+ // endsValue is what may stand after a string's closing quote: the structure
1709
+ // that closes the field (`,`, `}`, `]`, the end), a tag that stands for one,
1710
+ // or the next field's key.
1711
+ function endsValue(rest) {
1712
+ if (/^\s*(?:[,}\]$]|<\/?[a-z_]+\s*>)/.test(rest)) return true
1713
+ return /^\s*"(?:[^"\\]|\\.)*"\s*(?::|<\/?[a-z_]+\s*>)/.test(rest)
1714
+ }
1715
+
1716
+ // valueEnd is where an unquoted value runs out: the next key, a tag that
1717
+ // ends it, or the structure that closes the field. A comma inside it is the
1718
+ // model's own (sed -n 1,9p), so only a brace or a bracket closes a field.
1719
+ function valueEnd(rest) {
1720
+ const stops = [
1721
+ rest.search(/"(?:(?:[^"\\<>]*)"\s*(?::|,|\s*<\/?[a-z_]+\s*>)|[^"\\<>]*<\/?[a-z_]+\s*>)/),
1722
+ rest.search(/<\/?[a-z_]+\s*>/),
1723
+ rest.search(/[}\]]/),
1724
+ ].filter((x) => x >= 0)
1725
+ return stops.length ? Math.min(...stops) : rest.length
1726
+ }
1727
+
1728
+ // nameString reads the string at s[i] === '"', its escapes kept as written.
1729
+ // ends says, for what follows a quote, whether that quote ended the string:
1730
+ // a quote the model left unescaped inside a value is escaped as read on.
1731
+ // `closed` is whether the model's own quote ended it, where the read ran
1732
+ // into a tag or the end of the text.
1733
+ function nameString(s, i, ends) {
1734
+ i++ // the opening quote
1735
+ let src = ""
1736
+ while (i < s.length) {
1737
+ const ch = s[i]
1738
+ if (ch === "\\") {
1739
+ src += s[i] + (s[i + 1] ?? "")
1740
+ i += 2
1741
+ continue
1742
+ }
1743
+ if (ch === "<") {
1744
+ // a tag ends the run: the service cut the value at it, whether it is
1745
+ // one of its own (`</arg_value>`) or a field's (`</description>`),
1746
+ // for neither is text the model wrote
1747
+ if (/^<\/?[a-z_][\w-]*\s*>/.test(s.slice(i))) break
1748
+ }
1749
+ if (ch === '"') {
1750
+ if (ends(s.slice(i + 1))) {
1751
+ i++
1752
+ return { text: decode(src), i, closed: true }
1753
+ }
1754
+ src += '\\"'
1755
+ i++
1756
+ continue
1757
+ }
1758
+ src += ch
1759
+ i++
1760
+ }
1761
+ return { text: decode(src), i, closed: false }
1762
+ }
1763
+
1764
+ // decode is a string's own source as the string: the escapes the model wrote
1765
+ // are the characters they stand for, a newline among them
1766
+ function decode(src) {
1767
+ try {
1768
+ return JSON.parse('"' + escapeRaw(src) + '"')
1769
+ } catch {
1770
+ return src
1771
+ }
1772
+ }
1773
+
1496
1774
  // toolNamed is the request's own name for a tool a model named otherwise
1497
1775
  // (Read for read, list-files for list_files); name itself when the request
1498
1776
  // has none like it. A tool it has none of goes on as named: the agent tells
@@ -1684,12 +1962,21 @@ class NativeCalls {
1684
1962
  }
1685
1963
  take(tools = []) {
1686
1964
  return this.calls.filter((c) => c.name).map(({ id, name, arguments: a }) => {
1687
- // a name that holds the whole call, as JSON or in GLM's template:
1688
- // that call, its arguments the name's when it gave none of its own
1689
- const w = name.trimStart().startsWith("{") ? looseJSON(name) : name.includes("<arg_key>") ? glmCall(name, tools) : null
1965
+ // a name that holds the whole call, as the client's wrapper or in
1966
+ // GLM's template: that call, its arguments the name's when it gave
1967
+ // none of its own. The wrapper is read as JSON where it is whole —
1968
+ // its nesting is its own — and as the service's handoff where it was
1969
+ // left mid-call, a wrapper written with a space (`{ "name"`) whole
1970
+ // like any other
1971
+ const w = name.trimStart().startsWith("{") ? looseJSON(name) ?? nameCall(name, tools)
1972
+ : NAMEHEAD.test(name) ? nameCall(name, tools)
1973
+ : name.includes("<arg_key>") ? glmCall(name, tools) : null
1690
1974
  if (w?.name) {
1691
1975
  name = String(w.name)
1692
- if (!a || a.trim() === "{}") a = callArgs(w)
1976
+ // the name's own arguments are the model's; what the name read fills
1977
+ // the fields they left (the wrapper's text is the whole call where
1978
+ // Trae moved the arguments with it, and the rest where it did not)
1979
+ a = nameArgs(a, w)
1693
1980
  }
1694
1981
  const v = a && !done(a) ? looseJSON(a) : null
1695
1982
  return { id: id || callId(), name, arguments: v ? JSON.stringify(v) : a || "{}" }
@@ -1697,6 +1984,25 @@ class NativeCalls {
1697
1984
  }
1698
1985
  }
1699
1986
 
1987
+ // nameArgs is the arguments of a call a name held: the native call's own
1988
+ // where it carried them, else the name's, and the two together where each
1989
+ // has a field the other hasn't — the service moves the arguments it read
1990
+ // into the native call and leaves the rest in the name. The name's side is
1991
+ // read the way main reads a whole name (`callArgs`: `arguments`, `input`,
1992
+ // `parameters`, arguments given as a string, or the flat fields), so what
1993
+ // main already read keeps reading the same. The two stand together only as
1994
+ // two sets of fields; a call's own arguments that are not that — text the
1995
+ // service cut, or a value that isn't a call's fields — go on as they stand,
1996
+ // which is what main passed on and what the agent can answer.
1997
+ function nameArgs(native, fromName) {
1998
+ const a = typeof native === "string" ? native.trim() : ""
1999
+ const named = callArgs(fromName)
2000
+ if (!a || a === "{}") return named
2001
+ const own = looseJSON(a), side = looseJSON(named)
2002
+ if (!own || !side || typeof own !== "object" || typeof side !== "object" || Array.isArray(own) || Array.isArray(side)) return a
2003
+ return JSON.stringify({ ...side, ...own })
2004
+ }
2005
+
1700
2006
  // resent says next is the call's whole arguments sent again after a
1701
2007
  // partial snapshot, not a piece of them: Trae resends a snapshot whose
1702
2008
  // punctuation isn't the first one's (− for -, ′ for '), so it doesn't
@@ -2141,9 +2447,16 @@ const makePlugin = (site) => async ({ client }) => {
2141
2447
  const ug = ugPage(url)
2142
2448
  let sent = method === "GET" || method === "HEAD" ? undefined : typeof body === "string" ? body : "{}"
2143
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
2144
2457
  const res = await fetch(url, {
2145
2458
  method,
2146
- 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" }),
2147
2460
  body: sent,
2148
2461
  signal: signal ?? AbortSignal.timeout(20_000),
2149
2462
  })
@@ -2496,4 +2809,4 @@ const makePlugin = (site) => async ({ client }) => {
2496
2809
  export const TraeCNAuthPlugin = makePlugin(SITES["trae-cn"])
2497
2810
  export const TraeGlobalAuthPlugin = makePlugin(SITES["trae-global"])
2498
2811
 
2499
- export const _internal = { traeHost, accountOf, HOSTS: SITES["trae-cn"].hosts, MODELS: SITES["trae-cn"].models, TextTools, NativeCalls, looseJSON, glmCall, 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.12",
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",