@magpie-community/opencode-trae-auth 0.2.2 → 0.2.3
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 +25 -5
- package/index.mjs +151 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -133,10 +133,28 @@ replaces either.
|
|
|
133
133
|
|
|
134
134
|
## Renewal
|
|
135
135
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
136
|
+
Trae CN (since 0.2.3) renews the JWT as its clients do, bound to the
|
|
137
|
+
account's device:
|
|
138
|
+
|
|
139
|
+
- **Where:** `POST api.trae.cn/trae/api/v3/oauth/ExchangeToken`, with the
|
|
140
|
+
refresh token, the device (`DeviceInfo`: its id, machine id and public
|
|
141
|
+
key) and a `DeviceProof`.
|
|
142
|
+
- **The device key:** an ECDSA P-256 key pair made for each account at
|
|
143
|
+
sign-in (or at the first renewal of one signed in before 0.2.3), kept
|
|
144
|
+
with the sign-in.
|
|
145
|
+
- **The proof:** the key's ECDSA-SHA256 signature (base64) of
|
|
146
|
+
`POST\n/trae/api/v3/oauth/ExchangeToken\n{ClientID}\n{RefreshToken}\n{Timestamp}\n{Nonce}`.
|
|
147
|
+
- **As whom:** TRAE SOLO CN (`en1oxy7wnw8j9n`, `SOLO_PC`) first, then
|
|
148
|
+
Trae CN's IDE (`ono9krqynydwx5`, `IDE_PC`).
|
|
149
|
+
- **The refresh token:** kept; this renewal doesn't spend it.
|
|
150
|
+
|
|
151
|
+
A token the sign-in page gives is renewed this way at once. When Trae
|
|
152
|
+
turns the device-bound renewal away, the plugin renews as before 0.2.3.
|
|
153
|
+
|
|
154
|
+
Trae Global, and Trae CN as a fallback, renew at the realm's auth host's
|
|
155
|
+
`/cloudide/api/v3/trae/oauth/ExchangeToken` (`api.trae.cn`,
|
|
156
|
+
`growsg-normal.trae.ai`). There Trae issues a new refresh token each time
|
|
157
|
+
and spends the old one, so only one renewal runs at a time.
|
|
140
158
|
|
|
141
159
|
magpie renews the JWT ten minutes before it ends (`refreshLead`) through
|
|
142
160
|
`auth.refresh`. Each request also checks it, two minutes before the end, for
|
|
@@ -198,7 +216,9 @@ end), its device (`x-device-id`, `x-device-brand`, `x-device-type`,
|
|
|
198
216
|
`x-os-version`, `x-app-version`) and a client's User-Agent (TRAE SOLO
|
|
199
217
|
CN's Electron shell). Since 0.2.1: Bun's own User-Agent and the chat's
|
|
200
218
|
headers got 9074 「当前参与用户太多,请稍后再试」 every time
|
|
201
|
-
(yetone/magpie#808).
|
|
219
|
+
(yetone/magpie#808). Since 0.2.3 the JWT is also bound to the device (see
|
|
220
|
+
Renewal): a JWT renewed at `/cloudide/…` carries no device, and the claim
|
|
221
|
+
answered it 9074 even with the client's headers.
|
|
202
222
|
|
|
203
223
|
- **Status:** `POST api.trae.cn/trae/api/v2/ug/checkin_credits/status`,
|
|
204
224
|
body `{"req_source":1}` (magpie's `{}` is sent as that): `enable`,
|
package/index.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
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 { randomBytes, randomUUID, randomInt } from "node:crypto"
|
|
12
|
+
import { generateKeyPairSync, randomBytes, randomUUID, randomInt, sign } from "node:crypto"
|
|
13
13
|
import { createServer, STATUS_CODES } from "node:http"
|
|
14
14
|
|
|
15
15
|
const SITES = {
|
|
@@ -35,6 +35,21 @@ const SITES = {
|
|
|
35
35
|
api: "https://trae-api-cn.mchost.guru", // the models
|
|
36
36
|
},
|
|
37
37
|
clientId: "ono9krqynydwx5", // Trae CN's IDE
|
|
38
|
+
// the clients' own renewal, which binds the JWT to a device: ExchangeToken
|
|
39
|
+
// under /trae/api/v3/oauth, signed with the device's P-256 key
|
|
40
|
+
// (DeviceProof). A JWT renewed at /cloudide/… carries no device, and
|
|
41
|
+
// Trae CN answers its check-in claim 9074 「当前参与用户太多」 every time,
|
|
42
|
+
// while the client's own token is let through (mintonight's side-by-side
|
|
43
|
+
// on yetone/magpie#808). Tried as TRAE SOLO CN first, the one
|
|
44
|
+
// qilimixingkong/trae-checkin found the server takes, then as Trae CN's
|
|
45
|
+
// IDE. It doesn't spend the refresh token.
|
|
46
|
+
deviceExchange: {
|
|
47
|
+
path: "/trae/api/v3/oauth/ExchangeToken",
|
|
48
|
+
as: [
|
|
49
|
+
{ clientId: "en1oxy7wnw8j9n", platform: "SOLO_PC", version: "0.1.64" },
|
|
50
|
+
{ clientId: "ono9krqynydwx5", platform: "IDE_PC", version: "3.3.99" },
|
|
51
|
+
],
|
|
52
|
+
},
|
|
38
53
|
appId: "6eefa01c-1036-4c7e-9ca5-d891f63bfcd8",
|
|
39
54
|
ideVersion: "3.3.65", // named to the authorization page
|
|
40
55
|
ideVersionCode: "20260401",
|
|
@@ -220,7 +235,19 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
|
220
235
|
// device is the machine an account signs in from, made once at sign-in and
|
|
221
236
|
// named to trae.cn's authorization page, then sent with every request: a
|
|
222
237
|
// fresh one each time is what the relays saw requests dropped for.
|
|
223
|
-
const newDevice = () => ({ deviceId: digits(19), machineId: randomBytes(16).toString("hex") })
|
|
238
|
+
const newDevice = () => ({ deviceId: digits(19), machineId: randomBytes(16).toString("hex"), ...newDeviceKey() })
|
|
239
|
+
|
|
240
|
+
// newDeviceKey is the device's key pair, an ECDSA P-256 one as the clients
|
|
241
|
+
// keep (iCubeAuthInfo://icube-dc:<device>), whose public half goes with a
|
|
242
|
+
// renewal and whose private half signs it (DeviceProof). An account signed
|
|
243
|
+
// in before 0.2.3 has none and is given one at its next renewal.
|
|
244
|
+
function newDeviceKey() {
|
|
245
|
+
const { publicKey, privateKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" })
|
|
246
|
+
return {
|
|
247
|
+
devicePublicKey: publicKey.export({ type: "spki", format: "pem" }),
|
|
248
|
+
devicePrivateKey: privateKey.export({ type: "pkcs8", format: "pem" }),
|
|
249
|
+
}
|
|
250
|
+
}
|
|
224
251
|
|
|
225
252
|
// whenOf reads one of Trae's expiry times: seconds, milliseconds or ISO
|
|
226
253
|
function whenOf(v) {
|
|
@@ -270,6 +297,7 @@ function toAuth(s, site) {
|
|
|
270
297
|
clientId: s.clientId || site.clientId,
|
|
271
298
|
deviceId: s.deviceId,
|
|
272
299
|
machineId: s.machineId,
|
|
300
|
+
...(s.devicePrivateKey ? { devicePublicKey: s.devicePublicKey, devicePrivateKey: s.devicePrivateKey } : {}),
|
|
273
301
|
// the model host the sign-in named, if it named one
|
|
274
302
|
...(site.modelHost(s.api) ? { api: s.api } : {}),
|
|
275
303
|
...(s.region ? { region: s.region } : {}),
|
|
@@ -287,6 +315,8 @@ function fromAuth(auth, site) {
|
|
|
287
315
|
clientId: auth.clientId || site.clientId,
|
|
288
316
|
deviceId: auth.deviceId || "",
|
|
289
317
|
machineId: auth.machineId || "",
|
|
318
|
+
devicePublicKey: auth.devicePublicKey || "",
|
|
319
|
+
devicePrivateKey: auth.devicePrivateKey || "",
|
|
290
320
|
api: auth.api || "",
|
|
291
321
|
region: site.regionOf(auth),
|
|
292
322
|
}
|
|
@@ -387,6 +417,92 @@ async function exchange(refresh, clientId, site, host = site.hosts.auth) {
|
|
|
387
417
|
throw new Error(`${site.realm}: renewing the sign-in: ${statusLine(res.status)} ${msg}`.trim())
|
|
388
418
|
}
|
|
389
419
|
|
|
420
|
+
// proofOf is a renewal's DeviceProof: the device key's ECDSA-SHA256
|
|
421
|
+
// signature (DER, base64) of the method, path, client, refresh token, time
|
|
422
|
+
// and nonce, one to a line, as the clients sign it
|
|
423
|
+
function proofOf(path, clientId, refresh, privateKey, at = Math.floor(Date.now() / 1000), nonce = randomBytes(16).toString("hex")) {
|
|
424
|
+
const text = ["POST", path, clientId, refresh, String(at), nonce].join("\n")
|
|
425
|
+
return { Signature: sign("sha256", Buffer.from(text), privateKey).toString("base64"), Timestamp: at, Nonce: nonce }
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// deviceInfo is the device a renewal names, its public key with it
|
|
429
|
+
function deviceInfo(a, as, site) {
|
|
430
|
+
return {
|
|
431
|
+
DeviceID: a.deviceId,
|
|
432
|
+
MachineID: a.machineId,
|
|
433
|
+
PlatformCode: as.platform,
|
|
434
|
+
DeviceType: "PC",
|
|
435
|
+
DeviceName: "",
|
|
436
|
+
DeviceModel: "",
|
|
437
|
+
ClientVersion: as.version,
|
|
438
|
+
DevicePublicKey: a.devicePublicKey,
|
|
439
|
+
DeviceBrand: site.brand,
|
|
440
|
+
DeviceCPU: "AMD",
|
|
441
|
+
OSInfo: "Windows",
|
|
442
|
+
OSVersion: "10.0.22631",
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// boundExchange renews the JWT as the clients do (site.deviceExchange), so
|
|
447
|
+
// that it is bound to the account's device; an account with no device key
|
|
448
|
+
// is given one first. It tries each client the site names, and throws the
|
|
449
|
+
// last answer when none takes it.
|
|
450
|
+
async function boundExchange(a, site, host = site.hosts.auth) {
|
|
451
|
+
const dx = site.deviceExchange
|
|
452
|
+
const d = a.devicePrivateKey && a.devicePublicKey ? a : { ...a, ...newDeviceKey() }
|
|
453
|
+
let last
|
|
454
|
+
for (const as of dx.as) {
|
|
455
|
+
let res
|
|
456
|
+
try {
|
|
457
|
+
res = await fetch(host.replace(/\/+$/, "") + dx.path, {
|
|
458
|
+
method: "POST",
|
|
459
|
+
headers: { "Content-Type": "application/json", ...(a.token ? { Authorization: `Cloud-IDE-JWT ${a.token}` } : {}) },
|
|
460
|
+
body: JSON.stringify({
|
|
461
|
+
ClientID: as.clientId,
|
|
462
|
+
ClientSecret: "",
|
|
463
|
+
RefreshToken: a.refresh,
|
|
464
|
+
DeviceInfo: deviceInfo(d, as, site),
|
|
465
|
+
DeviceProof: proofOf(dx.path, as.clientId, a.refresh, d.devicePrivateKey),
|
|
466
|
+
IDEVersion: as.version,
|
|
467
|
+
}),
|
|
468
|
+
signal: AbortSignal.timeout(20_000),
|
|
469
|
+
})
|
|
470
|
+
} catch (e) {
|
|
471
|
+
last = new Error(`${site.realm}: renewing the sign-in for its device: ` + (e?.message ?? e))
|
|
472
|
+
continue
|
|
473
|
+
}
|
|
474
|
+
const text = await res.text()
|
|
475
|
+
const v = parseJSON(text)
|
|
476
|
+
const r = v.Result ?? v.result ?? {}
|
|
477
|
+
const token = r.Token ?? r.token ?? ""
|
|
478
|
+
if (res.ok && token) {
|
|
479
|
+
return {
|
|
480
|
+
token,
|
|
481
|
+
refresh: r.RefreshToken ?? r.refreshToken ?? a.refresh,
|
|
482
|
+
expires: whenOf(r.TokenExpireAt ?? r.tokenExpireAt) || jwtExp(token),
|
|
483
|
+
devicePublicKey: d.devicePublicKey,
|
|
484
|
+
devicePrivateKey: d.devicePrivateKey,
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
const e = errorOf(text)
|
|
488
|
+
last = new Error(`${site.realm}: renewing the sign-in for its device: ${statusLine(res.status)} ${e.message || text.trim().slice(0, 200)}`.trim())
|
|
489
|
+
}
|
|
490
|
+
throw last
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// renewal renews an account's JWT: bound to its device where the site can
|
|
494
|
+
// (boundExchange), else, or when Trae turns that away, at /cloudide/… as
|
|
495
|
+
// before 0.2.3 (exchange). A device key made for the account goes with
|
|
496
|
+
// either, so the next renewal signs with the same one.
|
|
497
|
+
async function renewal(a, site) {
|
|
498
|
+
if (!site.deviceExchange) return exchange(a.refresh, a.clientId, site)
|
|
499
|
+
const keys = a.devicePrivateKey && a.devicePublicKey ? {} : newDeviceKey()
|
|
500
|
+
try {
|
|
501
|
+
return await boundExchange({ ...a, ...keys }, site)
|
|
502
|
+
} catch {}
|
|
503
|
+
return { ...(await exchange(a.refresh, a.clientId, site)), ...keys }
|
|
504
|
+
}
|
|
505
|
+
|
|
390
506
|
// ---- the browser sign-in --------------------------------------------------------
|
|
391
507
|
|
|
392
508
|
const page = (ok, title, text) => `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${title}</title>
|
|
@@ -418,7 +534,16 @@ async function signedIn(q, device, site) {
|
|
|
418
534
|
}
|
|
419
535
|
if (!s.token) throw new Error(`${site.webHost} sent back no token`)
|
|
420
536
|
if (!s.refresh) throw new Error(`${site.webHost} sent back no refresh token`)
|
|
421
|
-
|
|
537
|
+
s = { ...s, ...device }
|
|
538
|
+
// the token the page gives is bound to no device: it is renewed for this
|
|
539
|
+
// one at once, so a check-in takes it; when Trae won't, it does as is
|
|
540
|
+
if (site.deviceExchange) {
|
|
541
|
+
try {
|
|
542
|
+
const x = await boundExchange(s, site)
|
|
543
|
+
s = { ...s, token: x.token, refresh: x.refresh, expires: x.expires || s.expires }
|
|
544
|
+
} catch {}
|
|
545
|
+
}
|
|
546
|
+
return s
|
|
422
547
|
}
|
|
423
548
|
|
|
424
549
|
// browserSignIn is the IDE's sign-in: trae.cn's authorization page, back
|
|
@@ -1451,8 +1576,9 @@ const makePlugin = (site) => async ({ client }) => {
|
|
|
1451
1576
|
let r = renewing.get(key)
|
|
1452
1577
|
if (!r) {
|
|
1453
1578
|
r = (async () => {
|
|
1454
|
-
const x = await
|
|
1579
|
+
const x = await renewal(a, site)
|
|
1455
1580
|
const got = { ...a, token: x.token, refresh: x.refresh, expires: x.expires || jwtExp(x.token) || Date.now() + DAY, clientId: x.clientId || a.clientId, renewed: true }
|
|
1581
|
+
if (x.devicePrivateKey) Object.assign(got, { devicePublicKey: x.devicePublicKey, devicePrivateKey: x.devicePrivateKey })
|
|
1456
1582
|
renewed.set(a.uid || a.name, { was: a.refresh, got })
|
|
1457
1583
|
if (keep) await save(toAuth(got, site))
|
|
1458
1584
|
return got
|
|
@@ -1465,10 +1591,11 @@ const makePlugin = (site) => async ({ client }) => {
|
|
|
1465
1591
|
// fresh is the account with a token that has a while to go, renewed
|
|
1466
1592
|
// when it hasn't
|
|
1467
1593
|
const fresh = async (getAuth) => {
|
|
1468
|
-
|
|
1594
|
+
let a = fromAuth(await getAuth(), site)
|
|
1469
1595
|
if (!a) throw new Expired(`${site.realm}: not signed in`)
|
|
1470
|
-
|
|
1471
|
-
|
|
1596
|
+
// a renewal the sign-in doesn't hold yet. A device-bound one keeps the
|
|
1597
|
+
// refresh token, so the token tells it, and it is renewed in its turn
|
|
1598
|
+
if (newerHere(a)) a = { ...renewed.get(a.uid || a.name).got, renewed: false }
|
|
1472
1599
|
if (!a.expires || a.expires - Date.now() > EARLY_MS || !a.refresh) return a
|
|
1473
1600
|
try {
|
|
1474
1601
|
return await renew(a, true)
|
|
@@ -1485,9 +1612,22 @@ const makePlugin = (site) => async ({ client }) => {
|
|
|
1485
1612
|
const a = fromAuth(auth, site)
|
|
1486
1613
|
if (!a || !a.refresh) return undefined
|
|
1487
1614
|
const last = renewed.get(a.uid || a.name)
|
|
1488
|
-
|
|
1489
|
-
const
|
|
1490
|
-
|
|
1615
|
+
// a device key made for the account is kept with the sign-in
|
|
1616
|
+
const out = (s) => ({
|
|
1617
|
+
access: s.token,
|
|
1618
|
+
refresh: s.refresh,
|
|
1619
|
+
expires: s.expires,
|
|
1620
|
+
...(s.devicePrivateKey && s.devicePrivateKey !== a.devicePrivateKey ? { devicePublicKey: s.devicePublicKey, devicePrivateKey: s.devicePrivateKey } : {}),
|
|
1621
|
+
})
|
|
1622
|
+
if (newerHere(a)) return out(last.got)
|
|
1623
|
+
return out(await renew(a, false))
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
// newerHere: this account was renewed here since the sign-in a was read
|
|
1627
|
+
// from, to a token that runs longer
|
|
1628
|
+
const newerHere = (a) => {
|
|
1629
|
+
const last = renewed.get(a.uid || a.name)
|
|
1630
|
+
return !!last && last.was === a.refresh && last.got.token !== a.token && (last.got.expires || 0) > (a.expires || 0)
|
|
1491
1631
|
}
|
|
1492
1632
|
|
|
1493
1633
|
// post asks one of Trae's JSON pages with the account
|
|
@@ -1819,4 +1959,4 @@ const makePlugin = (site) => async ({ client }) => {
|
|
|
1819
1959
|
export const TraeCNAuthPlugin = makePlugin(SITES["trae-cn"])
|
|
1820
1960
|
export const TraeGlobalAuthPlugin = makePlugin(SITES["trae-global"])
|
|
1821
1961
|
|
|
1822
|
-
export const _internal = { HOSTS: SITES["trae-cn"].hosts, MODELS: SITES["trae-cn"].models, TextTools, NativeCalls, looseJSON, glmCall, toolNamed, traeMessages, chatBody, credits, dollarUsageOf, whenOf, newDevice, SITES }
|
|
1962
|
+
export const _internal = { HOSTS: SITES["trae-cn"].hosts, MODELS: SITES["trae-cn"].models, TextTools, NativeCalls, looseJSON, glmCall, toolNamed, traeMessages, chatBody, credits, dollarUsageOf, whenOf, newDevice, proofOf, SITES }
|
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.3",
|
|
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",
|