@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.
Files changed (3) hide show
  1. package/README.md +60 -18
  2. package/index.mjs +95 -30
  3. 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 Trae grants the credits, `done`
358
- when the status says today's is in already, `inactive` when the page is
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 (to be done in Trae CN's own app; not asked
361
- again that day), and `failed` for a turned-away sign-in or any other
362
- answer, which is tried again later that day.
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. Files:
394
- macOS `~/Library/Application Support/Trae CN/…`, Windows
395
- `%APPDATA%\Trae CN\…` (or `trae-cn`), Linux `~/.config/trae-cn/…`
396
- (or `Trae CN`; not seen on a real Linux install).
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. No device id is ever changed or
408
- rotated to get round a 9095 or a 9074, and no account is given another
409
- account's device or its own client's device unless the client is signed
410
- in as that account. The credit a claim granted is its `credits`, or
411
- its `extra_credits` when it names only that; Trae CN's claim usually
412
- names neither (`{"code":0,"message":"success"}`), and then the status
413
- answer's `credits` is the credit. The two are never added: the status
414
- carries `extra_credits` beside `credits` (100 and 100 on a real
415
- account) while the day gives 100.
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 [file(join(home, "Library", "Application Support"), "Trae CN")]
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) => ["Trae CN", "trae-cn"].map((n) => file(r, n)))
850
+ return roots.flatMap((r) => CLIENT_PROFILES.map((n) => file(r, n)))
844
851
  }
845
- if (platform === "linux") return ["trae-cn", "Trae CN"].map((n) => file(env.XDG_CONFIG_HOME || join(home, ".config"), n))
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 this machine's Trae CN client when the
850
- // client is signed in as uid, else undefined. It only reads: nothing of
851
- // the client's is written, kept or sent but that id, and the client's own
852
- // token is read past, never used. A file it can't read or make out, or one
853
- // with no single device, names no device, and the account keeps its own.
854
- // Never an id made up or changed: an account the client isn't signed in
855
- // as never gets the client's id.
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])) return undefined
889
+ if (ids.length !== 1 || !/^\d{8,20}$/.test(ids[0])) continue
869
890
  const who = clientAuth(doc[AUTH_KEY])?.userId
870
- return who != null && String(who) === String(uid) ? ids[0] : undefined
891
+ if (who != null && String(who) === String(uid)) return ids[0]
871
892
  } catch {
872
- return undefined
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
- // checkin asks Trae CN's own pages as magpie's check-in host does, with
2878
- // the same account, device and renewal as any own page. Trae Global has
2879
- // no such page.
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 base = site.hosts.auth + site.checkin.status.path
2885
- const status = await ownRequest(getAuth, a0, base, "POST", "{}")
2886
- const statusText = await status.text()
2887
- let body
2888
- try { body = JSON.parse(statusText) } catch {}
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
- if (String(ce.code) === "9074") return { outcome: "ineligible", message: `${ce.message} — check in in the Trae CN app` }
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.21",
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",