@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.
- package/README.md +31 -3
- package/index.mjs +324 -11
- 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
|
|
285
|
-
|
|
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
|
-
|
|
744
|
-
|
|
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
|
|
1688
|
-
// that call, its arguments the name's when it gave
|
|
1689
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|