@magpie-community/opencode-trae-auth 0.2.21 → 0.2.22
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 +60 -18
- package/index.mjs +95 -30
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -354,12 +354,15 @@ an empty answer.
|
|
|
354
354
|
Trae CN gives credits for a daily check-in (每日签到); Trae Global has no
|
|
355
355
|
such page, so this is CN only. magpie can press it
|
|
356
356
|
once a day for each account (Settings, or the switch on the usage card),
|
|
357
|
-
through `auth.checkin`: `claimed` when
|
|
358
|
-
|
|
357
|
+
through `auth.checkin`: `claimed` when the status says today's is in after
|
|
358
|
+
the claim, `done` when it said so before it, `inactive` when the page is
|
|
359
359
|
disabled, `ineligible` when the claim answers 9095 (this device has
|
|
360
|
-
checked in today) or 9074 (
|
|
361
|
-
|
|
362
|
-
|
|
360
|
+
checked in today) or 9074 (Trae did not take it for this account's
|
|
361
|
+
device) or answers code 0 while the read-back still says the day is out,
|
|
362
|
+
and `failed` for a turned-away sign-in or any answer that got nowhere,
|
|
363
|
+
which is tried again later that day. An `ineligible` is the day's answer:
|
|
364
|
+
magpie asks that account no more until tomorrow, and only reads its
|
|
365
|
+
status.
|
|
363
366
|
It asks Trae CN's own pages through
|
|
364
367
|
this plugin's fetch, which sends them as the account, as Trae CN's IDE
|
|
365
368
|
(3.3.104) sends them: its Cloud-IDE-JWT (renewed first when near its
|
|
@@ -390,10 +393,27 @@ answered it 9074 even with the client's headers.
|
|
|
390
393
|
plugin's, on its own device, got 9074 (yetone/magpie#808, #1920); that
|
|
391
394
|
this header is what changes it hasn't been tried on a real account
|
|
392
395
|
yet. Every other account keeps its own device, and a file that can't be
|
|
393
|
-
read or made out, or holds no single device, changes nothing.
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
396
|
+
read or made out, or holds no single device, changes nothing.
|
|
397
|
+
- **Every profile is read (0.2.21):** a machine can hold more than one
|
|
398
|
+
Trae CN client profile — the IDE's and TRAE SOLO CN's each write their
|
|
399
|
+
own `storage.json`, and each names the account that profile is signed in
|
|
400
|
+
as, with that account's device. So every profile is read and the one
|
|
401
|
+
signed in as the account answers with its device, which is how two
|
|
402
|
+
accounts can each check in on a real client's device. Before, only the
|
|
403
|
+
first profile was read and it decided for all of them: a second
|
|
404
|
+
account's own profile was never seen, and one profile signed in as
|
|
405
|
+
someone else (or one file that didn't parse) hid every profile after it.
|
|
406
|
+
A profile that isn't signed in as the account, or that names no single
|
|
407
|
+
device, is passed over rather than ending the search. Files: macOS
|
|
408
|
+
`~/Library/Application Support/Trae CN/…` and `…/TRAE SOLO CN/…`, Windows
|
|
409
|
+
`%APPDATA%\Trae CN\…`, `…\TRAE SOLO CN\…` (or `trae-cn`), Linux
|
|
410
|
+
`~/.config/trae-cn/…`, `…/Trae CN/…`, `…/TRAE SOLO CN/…`. The IDE's
|
|
411
|
+
folder has been seen in a real install (macOS and Windows); TRAE SOLO
|
|
412
|
+
CN's name and its `iCubeAuthInfo://` keys are read as the same client
|
|
413
|
+
of the same realm writes them, which hasn't been checked against real
|
|
414
|
+
files — harmless if they differ, since a folder that isn't there, or a
|
|
415
|
+
key that isn't named, is simply not read. A client started with its own
|
|
416
|
+
`--user-data-dir` is not found: there is no list of those to read.
|
|
397
417
|
- **Every other account's device (0.2.16):** its own device id, in the
|
|
398
418
|
client's shape. Trae CN answered a claim on the plugin's 19-digit id
|
|
399
419
|
9074, while the same account and token on a 16-digit id were taken
|
|
@@ -404,15 +424,37 @@ answered it 9074 even with the client's headers.
|
|
|
404
424
|
keep the 19-digit device it signed in with.
|
|
405
425
|
- **What comes back:** Trae's answer as it is. A 401 or code 1001 marks
|
|
406
426
|
the account for a new sign-in. Code 9095 means this device has checked
|
|
407
|
-
in today, and that holds for the day.
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
427
|
+
in today, and that holds for the day. Code 9074 means Trae did not take
|
|
428
|
+
the claim for this account's device: its own words say the pool is busy,
|
|
429
|
+
which two measurements do not bear out — the same account, token and
|
|
430
|
+
headers were taken on a 16-digit id where a 19-digit one got 9074
|
|
431
|
+
(plugins#194), and on 2026-10-23 the same account and headers were taken
|
|
432
|
+
on a device id Trae had never seen while this account's own id answered
|
|
433
|
+
9074. The message says so, and does not send people to Trae CN's app,
|
|
434
|
+
which is not where the problem is. Signing the account in to a Trae CN
|
|
435
|
+
client on this machine is the one thing seen to change a 9074, but it
|
|
436
|
+
hasn't been tried on a real account, so it isn't named in the message;
|
|
437
|
+
nor is it named when the claim already rode a client's device, where it
|
|
438
|
+
would only tell the user to do what they did. No device id is ever
|
|
439
|
+
changed or rotated to get round a 9095 or a 9074, and no account is
|
|
440
|
+
given another account's device or its own client's device unless a
|
|
441
|
+
client is signed in as that account.
|
|
442
|
+
- **A claim's code 0 is not the answer (0.2.22):** Trae answers a
|
|
443
|
+
claim code 0 for one that registered nothing, so the status is read back
|
|
444
|
+
after every claim and only a day that is in is reported as `claimed`.
|
|
445
|
+
Since 0.2.22 one that isn't is an `ineligible` — the day's answer, not a
|
|
446
|
+
`failed` to be tried again: a `failed` is retried with backoff for the
|
|
447
|
+
rest of the day and each retry is another claim, which for an account
|
|
448
|
+
Trae keeps answering code 0 would turn one claim a day into many, and a
|
|
449
|
+
status that lags a claim that did land would read as not in once and
|
|
450
|
+
claim again. Nothing is claimed again that day; the status alone is
|
|
451
|
+
read, so a lag that catches up is still reported as `done`. The credit a
|
|
452
|
+
claim granted is its `credits`, or its `extra_credits` when it names
|
|
453
|
+
only that; Trae CN's claim usually names neither
|
|
454
|
+
(`{"code":0,"message":"success"}`), and then the read-back's `credits`
|
|
455
|
+
is the credit. The two are never added: the status carries
|
|
456
|
+
`extra_credits` beside `credits` (100 and 100 on a real account) while
|
|
457
|
+
the day gives 100.
|
|
416
458
|
|
|
417
459
|
Only `api.trae.cn`'s `/trae/api/` pages are sent this way; any other URL
|
|
418
460
|
that isn't a chat request is still refused (400). Needs 0.1.4 or later.
|
package/index.mjs
CHANGED
|
@@ -826,9 +826,16 @@ const ownClient = {
|
|
|
826
826
|
paths: () => clientStoragePaths(process.platform, homedir(), process.env),
|
|
827
827
|
}
|
|
828
828
|
|
|
829
|
+
// CLIENT_PROFILES are the folder names a Trae CN client profile is kept
|
|
830
|
+
// under: the IDE's own, and TRAE SOLO CN's, which is a client of the same
|
|
831
|
+
// realm and signs in its own account. A profile started with its own
|
|
832
|
+
// --user-data-dir is found only if it is one of these; there is no list of
|
|
833
|
+
// them to read, and nothing is guessed from a folder's contents.
|
|
834
|
+
const CLIENT_PROFILES = ["Trae CN", "TRAE SOLO CN", "trae-cn"]
|
|
835
|
+
|
|
829
836
|
function clientStoragePaths(platform, home, env) {
|
|
830
837
|
const file = (root, name) => join(root, name, "User", "globalStorage", "storage.json")
|
|
831
|
-
if (platform === "darwin") return
|
|
838
|
+
if (platform === "darwin") return CLIENT_PROFILES.filter((n) => n !== "trae-cn").map((n) => file(join(home, "Library", "Application Support"), n))
|
|
832
839
|
if (platform === "win32") {
|
|
833
840
|
// the two roots are often the same folder: APPDATA and the home's own
|
|
834
841
|
// AppData\Roaming. They are compared as one system spells them, so a
|
|
@@ -840,19 +847,28 @@ function clientStoragePaths(platform, home, env) {
|
|
|
840
847
|
if (roots.some((was) => spell(was) === spell(r))) continue
|
|
841
848
|
roots.push(r)
|
|
842
849
|
}
|
|
843
|
-
return roots.flatMap((r) =>
|
|
850
|
+
return roots.flatMap((r) => CLIENT_PROFILES.map((n) => file(r, n)))
|
|
844
851
|
}
|
|
845
|
-
if (platform === "linux") return
|
|
852
|
+
if (platform === "linux") return CLIENT_PROFILES.map((n) => file(env.XDG_CONFIG_HOME || join(home, ".config"), n))
|
|
846
853
|
return []
|
|
847
854
|
}
|
|
848
855
|
|
|
849
|
-
// clientDevice is the device id of
|
|
850
|
-
//
|
|
851
|
-
//
|
|
852
|
-
//
|
|
853
|
-
//
|
|
854
|
-
//
|
|
855
|
-
//
|
|
856
|
+
// clientDevice is the device id of a Trae CN client profile on this machine
|
|
857
|
+
// that is signed in as uid, else undefined. It only reads: nothing of the
|
|
858
|
+
// client's is written, kept or sent but that id, and the client's own token
|
|
859
|
+
// is read past, never used. Never an id made up or changed: an account no
|
|
860
|
+
// profile is signed in as never gets a client's id.
|
|
861
|
+
//
|
|
862
|
+
// A machine can hold more than one profile — Trae CN and TRAE SOLO CN each
|
|
863
|
+
// write one, and a client started with its own --user-data-dir writes
|
|
864
|
+
// another — and each profile's storage.json names the account it is signed
|
|
865
|
+
// in as, with that account's device. So every profile is read and the one
|
|
866
|
+
// signed in as uid answers with its device, which is what lets two accounts
|
|
867
|
+
// each check in on a real client's device. A profile that isn't signed in
|
|
868
|
+
// as uid, that can't be read or made out, or that names no single device
|
|
869
|
+
// is passed over rather than ending the search: before, the first profile
|
|
870
|
+
// read decided for all of them, so a second account's own profile was never
|
|
871
|
+
// seen (and one unreadable file hid every profile after it).
|
|
856
872
|
async function clientDevice(uid) {
|
|
857
873
|
if (!uid) return undefined
|
|
858
874
|
for (const p of ownClient.paths()) {
|
|
@@ -862,14 +878,19 @@ async function clientDevice(uid) {
|
|
|
862
878
|
} catch {
|
|
863
879
|
continue // not installed here
|
|
864
880
|
}
|
|
881
|
+
let doc
|
|
882
|
+
try {
|
|
883
|
+
doc = JSON.parse(text)
|
|
884
|
+
} catch {
|
|
885
|
+
continue // not a file we can make out: the next profile may be
|
|
886
|
+
}
|
|
865
887
|
try {
|
|
866
|
-
const doc = JSON.parse(text)
|
|
867
888
|
const ids = Object.keys(doc ?? {}).filter((k) => k.startsWith(DC_KEY)).map((k) => k.slice(DC_KEY.length))
|
|
868
|
-
if (ids.length !== 1 || !/^\d{8,20}$/.test(ids[0]))
|
|
889
|
+
if (ids.length !== 1 || !/^\d{8,20}$/.test(ids[0])) continue
|
|
869
890
|
const who = clientAuth(doc[AUTH_KEY])?.userId
|
|
870
|
-
|
|
891
|
+
if (who != null && String(who) === String(uid)) return ids[0]
|
|
871
892
|
} catch {
|
|
872
|
-
|
|
893
|
+
continue // a sign-in that doesn't decode: the next profile may be
|
|
873
894
|
}
|
|
874
895
|
}
|
|
875
896
|
return undefined
|
|
@@ -2874,34 +2895,78 @@ const makePlugin = (site) => async ({ client }) => {
|
|
|
2874
2895
|
return new Response(text, { status: res.status, headers })
|
|
2875
2896
|
}
|
|
2876
2897
|
|
|
2877
|
-
//
|
|
2878
|
-
//
|
|
2879
|
-
//
|
|
2898
|
+
// checkinStatus reads the check-in's status page: its body, or an outcome
|
|
2899
|
+
// to give back as it is (a turned-away sign-in, Trae's own error, a reply
|
|
2900
|
+
// that isn't one). checkin reads it before the claim, and again after it.
|
|
2901
|
+
const checkinStatus = async (getAuth, a0) => {
|
|
2902
|
+
const res = await ownRequest(getAuth, a0, site.hosts.auth + site.checkin.status.path, "POST", "{}")
|
|
2903
|
+
const text = await res.text()
|
|
2904
|
+
let body
|
|
2905
|
+
try { body = JSON.parse(text) } catch {}
|
|
2906
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return { outcome: { outcome: "failed", message: `check-in status: unreadable reply ${text.trim().slice(0, 120) || "(empty)"}` } }
|
|
2907
|
+
const e = errorOf(body)
|
|
2908
|
+
if (res.status === 401 || lapsedCode(e.code, site)) return { outcome: { outcome: "failed", message: e.message || `${a0.name || a0.uid}: Trae's sign-in has expired` } }
|
|
2909
|
+
if (res.status !== 200 || (e.code && String(e.code) !== "0")) return { outcome: { outcome: "failed", message: e.message || statusLine(res.status) } }
|
|
2910
|
+
return { body }
|
|
2911
|
+
}
|
|
2912
|
+
|
|
2880
2913
|
const checkin = async (getAuth) => {
|
|
2881
2914
|
if (!site.checkin) return { outcome: "inactive", message: `${site.realm} has no daily check-in` }
|
|
2882
2915
|
const a0 = fromAuth(await getAuth(), site)
|
|
2883
2916
|
if (!a0) return { outcome: "failed", message: "not signed in" }
|
|
2884
|
-
const
|
|
2885
|
-
|
|
2886
|
-
const
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
if (!body || typeof body !== "object" || Array.isArray(body)) return { outcome: "failed", message: `check-in status: unreadable reply ${statusText.trim().slice(0, 120) || "(empty)"}` }
|
|
2890
|
-
const e = errorOf(body)
|
|
2891
|
-
if (status.status === 401 || lapsedCode(e.code, site)) return { outcome: "failed", message: e.message || `${a0.name || a0.uid}: Trae's sign-in has expired` }
|
|
2892
|
-
if (status.status !== 200 || (e.code && String(e.code) !== "0")) return { outcome: "failed", message: e.message || statusLine(status.status) }
|
|
2893
|
-
if (body?.checked_in) return { outcome: "done", ...(Number(body.credits) > 0 ? { credit: Number(body.credits) } : {}) }
|
|
2894
|
-
if (!body?.enable) return { outcome: "inactive" }
|
|
2917
|
+
const first = await checkinStatus(getAuth, a0)
|
|
2918
|
+
if (first.outcome) return first.outcome
|
|
2919
|
+
const body = first.body
|
|
2920
|
+
if (body.checked_in) return { outcome: "done", ...(Number(body.credits) > 0 ? { credit: Number(body.credits) } : {}) }
|
|
2921
|
+
if (!body.enable) return { outcome: "inactive" }
|
|
2895
2922
|
const claim = await ownRequest(getAuth, a0, site.hosts.auth + site.checkin.claim.path, "POST", "{}")
|
|
2896
2923
|
const claimText = await claim.text()
|
|
2897
2924
|
let answer
|
|
2898
2925
|
try { answer = JSON.parse(claimText) } catch {}
|
|
2899
2926
|
const ce = errorOf(answer)
|
|
2900
2927
|
if (claim.status === 401 || lapsedCode(ce.code, site)) return { outcome: "failed", message: ce.message || `${a0.name || a0.uid}: Trae's sign-in has expired` }
|
|
2928
|
+
// 9095: this device is in for today, whoever took it — the day's answer
|
|
2901
2929
|
if (String(ce.code) === "9095") return { outcome: "ineligible", message: ce.message || "this device has checked in today" }
|
|
2902
|
-
|
|
2930
|
+
// 9074: Trae did not take this claim for this account's device. Its own
|
|
2931
|
+
// words say the pool is busy, which measurements do not bear out: the
|
|
2932
|
+
// same account, token and headers were taken on a 16-digit id where a
|
|
2933
|
+
// 19-digit one got 9074 (plugins#194), and on 2026-10-23 the same
|
|
2934
|
+
// account and headers were taken on a device id Trae had never seen
|
|
2935
|
+
// while this account's own id answered 9074. So the message says what
|
|
2936
|
+
// Trae said and that the device is what it refused, rather than
|
|
2937
|
+
// sending people to check in in Trae CN's app, which is not where the
|
|
2938
|
+
// problem is. No device id is made up or rotated to get round this:
|
|
2939
|
+
// the account keeps its own until a client of its own is here to sign
|
|
2940
|
+
// it in. Nothing is said of signing one in: it is the one thing seen
|
|
2941
|
+
// to change a 9074, but it hasn't been tried on a real account, and
|
|
2942
|
+
// on a claim that already rode a client's device it is what the user
|
|
2943
|
+
// did — the advice would only send them back to do it again.
|
|
2944
|
+
if (String(ce.code) === "9074") return { outcome: "ineligible", message: `${ce.message} (9074) — Trae CN did not take this check-in for this account's device` }
|
|
2903
2945
|
if (claim.status !== 200 || (ce.code && String(ce.code) !== "0")) return { outcome: "failed", message: ce.message || statusLine(claim.status) }
|
|
2904
2946
|
if (!answer || typeof answer !== "object" || Array.isArray(answer)) return { outcome: "failed", message: `check-in claim: unreadable reply ${claimText.trim().slice(0, 120) || "(empty)"}` }
|
|
2947
|
+
// a claim's own code 0 is not proof the day is in: Trae answers it for
|
|
2948
|
+
// a claim that registered nothing, which cli2api works around by asking
|
|
2949
|
+
// the status again (internal/providers/trae/checkin.go). So success is
|
|
2950
|
+
// what the status page says after the claim, and one that didn't land
|
|
2951
|
+
// is never reported as claimed.
|
|
2952
|
+
//
|
|
2953
|
+
// Nor is it a `failed`. magpie retries a failed check-in with backoff
|
|
2954
|
+
// for the rest of the day, up to every 30 minutes, and each retry is
|
|
2955
|
+
// another claim: a claim answered code 0 while the status still says
|
|
2956
|
+
// the day is out is the very case that would multiply, since code 0
|
|
2957
|
+
// for a claim that registered nothing is this plugin's own premise —
|
|
2958
|
+
// an account that claimed once a day would claim many times. A status
|
|
2959
|
+
// that lags a claim that did land reads as not in once, which is a
|
|
2960
|
+
// false failure and a second claim. So once a claim has been sent,
|
|
2961
|
+
// what the read-back says is the day's answer and nothing is claimed
|
|
2962
|
+
// again: `ineligible`, as a 9095 is. A read-back that itself comes to
|
|
2963
|
+
// nothing — the network, an answer that isn't one — is the same: the
|
|
2964
|
+
// claim was made, and whether the day is in is not known, so it is
|
|
2965
|
+
// read again on the next press rather than claimed again.
|
|
2966
|
+
const noMore = (o) => (o.outcome === "failed" ? { outcome: "ineligible", message: `${o.message} — nothing is claimed again today` } : o)
|
|
2967
|
+
const back = await checkinStatus(getAuth, a0)
|
|
2968
|
+
if (back.outcome) return noMore(back.outcome)
|
|
2969
|
+
if (!back.body.checked_in) return { outcome: "ineligible", message: `${site.realm}: the claim answered success, but the day's check-in is not in — nothing is claimed again today` }
|
|
2905
2970
|
// The credit is the day's credits, never credits plus extra_credits.
|
|
2906
2971
|
// Trae CN's real claim answers {"code":0,"message":"success"}, with no
|
|
2907
2972
|
// credit at all (plugins#194), so the status's credits stand in. Its
|
|
@@ -2912,7 +2977,7 @@ const makePlugin = (site) => async ({ client }) => {
|
|
|
2912
2977
|
// taken first; one naming extra_credits alone is that (#260). Either
|
|
2913
2978
|
// may be absent, and Number(undefined) is NaN, which reads as none.
|
|
2914
2979
|
const n = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0)
|
|
2915
|
-
const credit = n(answer.credits) || n(answer.extra_credits) || n(body.credits)
|
|
2980
|
+
const credit = n(answer.credits) || n(answer.extra_credits) || n(back.body.credits)
|
|
2916
2981
|
return { outcome: "claimed", ...(credit > 0 ? { credit } : {}) }
|
|
2917
2982
|
}
|
|
2918
2983
|
|
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.22",
|
|
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",
|