@magpie-community/opencode-trae-auth 0.2.9 → 0.2.11

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 +17 -0
  2. package/index.mjs +145 -18
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -46,6 +46,23 @@ authorization page: `device_id` (19 digits) and `machine_id` (32 hex). Every
46
46
  request then sends that same device. The relays saw requests dropped when the
47
47
  device was new each time.
48
48
 
49
+ ### Enterprise (SaaS) accounts
50
+
51
+ Since 0.2.10 the authorization page also offers Trae's enterprise (SaaS / SSO)
52
+ sign-in; before, the plugin asked the page to hide it (`hide_saas_login`), so
53
+ an enterprise subscription could only sign in as a personal account. A host
54
+ the sign-in names for the account is kept when it is an https host on Trae's
55
+ own domains (`trae.cn`, `trae.ai`, `mchost.guru`): a model host other than the
56
+ consumer site's own (`www`, `api`, `grow…`) takes the account's model
57
+ requests, and a token host the callback names (`authHost`) takes its
58
+ renewals. Any other host is ignored and the consumer hosts are used, as for a
59
+ personal account, which is unchanged. The account's tenant and login-type
60
+ fields are kept with the sign-in; nothing sends them.
61
+
62
+ **Not tried with a real enterprise account.** Which hosts and fields an
63
+ enterprise sign-in sends back is not known yet; if yours doesn't work, open an
64
+ issue with what the sign-in page showed.
65
+
49
66
  ### The international realm's region
50
67
 
51
68
  Trae Global has two deployments. The sign-in's `AIRegion` (or a US auth host
package/index.mjs CHANGED
@@ -79,11 +79,19 @@ const SITES = {
79
79
  // the model host for an account: Trae CN's own hosts serve no models,
80
80
  // so only a host that is one is taken, else the one gateway
81
81
  apiOf: (a) => {
82
- const h = String(a?.api ?? "").replace(/\/+$/, "")
83
- return /mchost\.guru|trae-api-/i.test(h) ? h : SITES["trae-cn"].hosts.api
82
+ const h = traeHost(a?.api)
83
+ return h && SITES["trae-cn"].modelHost(h) ? h : SITES["trae-cn"].hosts.api
84
+ },
85
+ // whether a host named by a sign-in is one of the model hosts: an https
86
+ // host on mchost.guru or named trae-api-…, as the consumer gateway is,
87
+ // or another trae.cn host than the consumer site's own (www, api,
88
+ // grow…), which an enterprise (SaaS) sign-in may name for its tenant
89
+ modelHost: (h) => {
90
+ const t = traeHost(h)
91
+ if (!t) return false
92
+ const n = new URL(t).hostname
93
+ return /(^|\.)mchost\.guru$|^trae-api-/i.test(n) || (/\.trae\.cn$/i.test(n) && !/^(www|api|grow[^.]*)\./i.test(n))
84
94
  },
85
- // whether a host named by a sign-in is one of the model hosts
86
- modelHost: (h) => /mchost\.guru|trae-api-/i.test(String(h ?? "")),
87
95
  // ownPage: url is one of Trae CN's own JSON pages on api.trae.cn
88
96
  // (/trae/api/…: the daily check-in, credits), which the account's fetch
89
97
  // sends as the account rather than as a chat
@@ -166,15 +174,22 @@ const SITES = {
166
174
  // the model host: the account's region (or a US host the sign-in named)
167
175
  // selects the US deployment, else the SG one
168
176
  apiOf: (a) => {
169
- const h = String(a?.api ?? "").replace(/\/+$/, "")
177
+ const h = traeHost(a?.api)
178
+ const n = h ? new URL(h).hostname : ""
170
179
  const region = String(a?.region ?? "").toUpperCase()
171
- if (/coreva/i.test(h) || /api-us(?:[-.]|$)/i.test(h) || /^US(?:[-_ ]|$)/.test(region)) return SITES["trae-global"].hosts.us
172
- if (/coresg/i.test(h)) return h
180
+ if (/coreva/i.test(n) || /api-us(?:[-.]|$)/i.test(n) || /^US(?:[-_ ]|$)/.test(region)) return SITES["trae-global"].hosts.us
181
+ if (/coresg/i.test(n)) return h
182
+ // another trae.ai host than the site's own (www, api-…, grow…): one an
183
+ // enterprise (SaaS) sign-in named for its tenant
184
+ if (/\.trae\.ai$/i.test(n) && !/^(www\.|api[-.]|grow)/i.test(n)) return h
173
185
  return SITES["trae-global"].hosts.api
174
186
  },
175
187
  // a sign-in names a host of Trae's, which can be an auth host
176
188
  // (api-us-east), not the chat host
177
- modelHost: (h) => /trae\.ai/i.test(String(h ?? "")),
189
+ modelHost: (h) => {
190
+ const t = traeHost(h)
191
+ return !!t && /(^|\.)trae\.ai$/i.test(new URL(t).hostname)
192
+ },
178
193
  // Trae Global serves no own JSON pages through the account's fetch
179
194
  ownPage: () => false,
180
195
  // the account's region, from the sign-in's user info
@@ -213,6 +228,46 @@ const SITES = {
213
228
  },
214
229
  }
215
230
 
231
+ // traeHost is a host a sign-in named, when it is a well-formed https one
232
+ // on Trae's own domains (no port, no credentials), as origin plus any path;
233
+ // "" for anything else, which is then not taken
234
+ const TRAE_DOMAINS = ["trae.cn", "trae.ai", "mchost.guru"]
235
+ function traeHost(h) {
236
+ try {
237
+ const u = new URL(String(h ?? "").trim())
238
+ if (u.protocol !== "https:" || u.username || u.password || u.port || u.search || u.hash) return ""
239
+ const n = u.hostname.toLowerCase()
240
+ if (!TRAE_DOMAINS.some((d) => n === d || n.endsWith("." + d))) return ""
241
+ return (u.origin + u.pathname).replace(/\/+$/, "")
242
+ } catch {
243
+ return ""
244
+ }
245
+ }
246
+
247
+ // authHostOf is the host a sign-in named for its tokens, when it named one
248
+ // on the site's own domain (an enterprise's may differ from the consumer
249
+ // one); "" when it named none, and the site's own is used
250
+ function authHostOf(h, site) {
251
+ const t = traeHost(h)
252
+ if (!t) return ""
253
+ const n = new URL(t).hostname
254
+ return n === site.webHost || n.endsWith("." + site.webHost) ? t : ""
255
+ }
256
+
257
+ // accountOf keeps the account fields of the sign-in's user info that say
258
+ // what kind of account it is (tenant, enterprise, login type), plain values
259
+ // only and never anything that looks like a secret, for later use; nothing
260
+ // sends them
261
+ function accountOf(info) {
262
+ const out = {}
263
+ for (const [k, v] of Object.entries(info ?? {})) {
264
+ if (!/tenant|enterprise|saas|login|org|team|sso|^ent|usertype|accounttype/i.test(k)) continue
265
+ if (/token|secret|key|password|cookie|session|jwt/i.test(k)) continue
266
+ if (v === null || ["string", "number", "boolean"].includes(typeof v)) out[k] = v
267
+ }
268
+ return out
269
+ }
270
+
216
271
  const SIGN_IN_TIMEOUT = 10 * 60 * 1000
217
272
  const EARLY_MS = 2 * 60 * 1000 // a token this close to its end is renewed before a request
218
273
  const LEAD_MS = 10 * 60 * 1000 // and this close, magpie renews it ahead of time (auth.refresh)
@@ -301,6 +356,11 @@ function toAuth(s, site) {
301
356
  // the model host the sign-in named, if it named one
302
357
  ...(site.modelHost(s.api) ? { api: s.api } : {}),
303
358
  ...(s.region ? { region: s.region } : {}),
359
+ // the host the sign-in named, its own token host if it named one, and
360
+ // what kind of account it is (an enterprise one's tenant), kept as given
361
+ ...(traeHost(s.host) ? { host: traeHost(s.host) } : {}),
362
+ ...(authHostOf(s.authHost, site) ? { authHost: authHostOf(s.authHost, site) } : {}),
363
+ ...(s.account && Object.keys(s.account).length ? { account: s.account } : {}),
304
364
  }
305
365
  }
306
366
 
@@ -319,6 +379,9 @@ function fromAuth(auth, site) {
319
379
  devicePrivateKey: auth.devicePrivateKey || "",
320
380
  api: auth.api || "",
321
381
  region: site.regionOf(auth),
382
+ host: auth.host || "",
383
+ authHost: authHostOf(auth.authHost, site),
384
+ account: auth.account && typeof auth.account === "object" ? auth.account : {},
322
385
  }
323
386
  }
324
387
 
@@ -405,6 +468,7 @@ async function exchange(refresh, clientId, site, host = site.hosts.auth) {
405
468
  name: String(info.ScreenName ?? info.screenName ?? ""),
406
469
  api: info.Host ?? "",
407
470
  region: site.regionOf(info),
471
+ account: accountOf(info),
408
472
  }
409
473
  }
410
474
  const e = errorOf(text)
@@ -495,12 +559,14 @@ async function boundExchange(a, site, host = site.hosts.auth) {
495
559
  // before 0.2.3 (exchange). A device key made for the account goes with
496
560
  // either, so the next renewal signs with the same one.
497
561
  async function renewal(a, site) {
498
- if (!site.deviceExchange) return exchange(a.refresh, a.clientId, site)
562
+ // the token host the sign-in named, else the site's
563
+ const host = authHostOf(a.authHost, site) || site.hosts.auth
564
+ if (!site.deviceExchange) return exchange(a.refresh, a.clientId, site, host)
499
565
  const keys = a.devicePrivateKey && a.devicePublicKey ? {} : newDeviceKey()
500
566
  try {
501
- return await boundExchange({ ...a, ...keys }, site)
567
+ return await boundExchange({ ...a, ...keys }, site, host)
502
568
  } catch {}
503
- return { ...(await exchange(a.refresh, a.clientId, site)), ...keys }
569
+ return { ...(await exchange(a.refresh, a.clientId, site, host)), ...keys }
504
570
  }
505
571
 
506
572
  // ---- the browser sign-in --------------------------------------------------------
@@ -527,10 +593,15 @@ async function signedIn(q, device, site) {
527
593
  name: String(info.ScreenName ?? info.screenName ?? ""),
528
594
  api: host,
529
595
  region: site.regionOf(info),
596
+ host,
597
+ // a token host the callback named, if it named one
598
+ authHost: authHostOf(q.get("authHost") || q.get("auth_host") || info.AuthHost || info.authHost, site),
599
+ account: accountOf(info),
530
600
  }
601
+ const authHost = s.authHost || site.hosts.auth
531
602
  if (!s.token && s.refresh) {
532
- const x = await exchange(s.refresh, s.clientId, site)
533
- s = { ...s, ...x, uid: x.uid || s.uid, name: x.name || s.name, api: x.api || s.api, region: x.region || s.region }
603
+ const x = await exchange(s.refresh, s.clientId, site, authHost)
604
+ s = { ...s, ...x, uid: x.uid || s.uid, name: x.name || s.name, api: x.api || s.api, region: x.region || s.region, host: s.host || x.api, account: { ...x.account, ...s.account } }
534
605
  }
535
606
  if (!s.token) throw new Error(`${site.webHost} sent back no token`)
536
607
  if (!s.refresh) throw new Error(`${site.webHost} sent back no refresh token`)
@@ -539,7 +610,7 @@ async function signedIn(q, device, site) {
539
610
  // one at once, so a check-in takes it; when Trae won't, it does as is
540
611
  if (site.deviceExchange) {
541
612
  try {
542
- const x = await boundExchange(s, site)
613
+ const x = await boundExchange(s, site, authHost)
543
614
  s = { ...s, token: x.token, refresh: x.refresh, expires: x.expires || s.expires }
544
615
  } catch {}
545
616
  }
@@ -616,7 +687,9 @@ async function browserSignIn(site) {
616
687
  x_env: "",
617
688
  x_app_version: site.ideVersion,
618
689
  x_app_type: "stable",
619
- hide_saas_login: "true",
690
+ // the enterprise (SaaS) sign-in is offered beside the personal one: an
691
+ // enterprise subscription signs in there (@shad70w0 on X)
692
+ hide_saas_login: "false",
620
693
  })
621
694
  return {
622
695
  url: `${site.hosts.web}/authorization?${q}`,
@@ -949,6 +1022,7 @@ class TextTools {
949
1022
  this.tools = tools
950
1023
  this.sent = "" // the text given out so far, to tell a block in code from a call
951
1024
  this.bad = new Set() // where the waiting block's JSON was tried and not read, not tried again
1025
+ this.mark = 0 // where in sent the text after the last call starts
952
1026
  }
953
1027
  push(s, end = false) {
954
1028
  this.buf += s
@@ -971,6 +1045,27 @@ class TextTools {
971
1045
  const i = om ? om.index : -1
972
1046
  const d = this.buf.search(DSML_AT)
973
1047
  const r = this.buf.search(REASON_AT)
1048
+ const sm = this.buf.match(STRAY_AT)
1049
+ const s = sm ? sm.index : -1
1050
+ if (s >= 0 && [i, d, r].every((x) => x < 0 || s < x)) {
1051
+ // a close with no call open, with what GLM left before it
1052
+ // (}}</tool_call>, magpie#1945): not the answer, dropped; in code,
1053
+ // shown
1054
+ const head = this.buf.slice(0, s)
1055
+ // only a tail: a close with nothing before it since the last call
1056
+ // (or the start), or with GLM's braces, quotes or > right before
1057
+ // it; a close a sentence names in passing stays
1058
+ const tail = /^[}\]"'>]/.test(sm[0]) || !(this.sent + text + head).slice(this.mark).trim()
1059
+ if (!tail || quoted(this.sent + text + head)) {
1060
+ text += this.buf.slice(0, s + sm[0].length)
1061
+ this.buf = this.buf.slice(s + sm[0].length)
1062
+ continue
1063
+ }
1064
+ text += head
1065
+ this.buf = this.buf.slice(s + sm[0].length)
1066
+ this.after = true
1067
+ continue
1068
+ }
974
1069
  if (r >= 0 && (i < 0 || r < i) && (d < 0 || r < d)) {
975
1070
  const o = objectEnd(this.buf, r)
976
1071
  if (o === -1 && !end) {
@@ -1022,6 +1117,7 @@ class TextTools {
1022
1117
  stop = body.length
1023
1118
  }
1024
1119
  calls.push({ name: inv[1], arguments: dsmlArgs(body.slice(0, stop)) })
1120
+ this.mark = this.sent.length + text.length
1025
1121
  this.buf = body.slice(c && c.index === stop ? stop + c[0].length : stop)
1026
1122
  this.after = true
1027
1123
  continue
@@ -1063,6 +1159,7 @@ class TextTools {
1063
1159
  return { text, calls, reasoning, error: b.error }
1064
1160
  }
1065
1161
  calls.push(b.call)
1162
+ this.mark = this.sent.length + text.length
1066
1163
  this.buf = b.rest
1067
1164
  this.after = true
1068
1165
  }
@@ -1083,6 +1180,9 @@ class TextTools {
1083
1180
  const at = [this.buf.search(OPEN_AT), this.buf.search(OPEN_HOLD), this.buf.search(DSML_AT), this.buf.search(REASON_AT)].filter((x) => x >= 0)
1084
1181
  let keep = at.length ? this.buf.length - Math.min(...at) : 0
1085
1182
  if (!keep) for (let k = Math.min(MARK_MAX - 1, this.buf.length); k > 0; k--) if (MARKS.some((m) => m.startsWith(this.buf.slice(-k)))) { keep = k; break }
1183
+ // and braces, brackets, quotes or a > a close may still come after
1184
+ const sh = this.buf.slice(0, this.buf.length - keep).match(STRAY_HOLD)
1185
+ if (sh) keep = this.buf.length - sh.index
1086
1186
  text += this.buf.slice(0, this.buf.length - keep)
1087
1187
  this.buf = this.buf.slice(this.buf.length - keep)
1088
1188
  }
@@ -1132,8 +1232,27 @@ class TextTools {
1132
1232
  const v = callJSON(body.slice(0, o))
1133
1233
  // a name alone may have its parameters still to come
1134
1234
  if (v && !end && Object.keys(v).length === 1 && "<parameter".startsWith(body.slice(o).replace(/^\s*>?\s*/, ""))) return { wait: true }
1135
- if (v) return { call: { name: String(v.name), arguments: callArgs(v) }, rest: body.slice(o) }
1136
- this.bad.add(":" + o)
1235
+ if (v) {
1236
+ // what follows the object up to the close: GLM leaves braces,
1237
+ // brackets, quotes or a > of its own there (}}</tool_call>,
1238
+ // "></tool_call>, magpie#1945), dropped with the close; held
1239
+ // while only those have come
1240
+ const tail = body.slice(o)
1241
+ const junk = tail.match(TAIL_JUNK)[0]
1242
+ const next = tail.slice(junk.length)
1243
+ const call = { name: String(v.name), arguments: callArgs(v) }
1244
+ if (!end && (!next || ["</tool_call>", "</toolcall>"].some((k) => k.startsWith(next)))) return { wait: true }
1245
+ const [c, n] = closeAt(next)
1246
+ if (c === 0) return { call, rest: next.slice(n) }
1247
+ // after blanks alone, or with no close to come: the call, and
1248
+ // what follows read on as before. Other text before the close
1249
+ // right after the object means the object ended early, at a
1250
+ // quote inside a string left unescaped: that cut is wrong (a
1251
+ // heredoc's command cut short, magpie#1945), so it is read up to
1252
+ // the close below, or not at all
1253
+ if (!/\S/.test(junk) && !/^[^\s<]/.test(next)) return { call, rest: tail }
1254
+ if (close < 0 && end) return { call, rest: tail }
1255
+ } else this.bad.add(":" + o)
1137
1256
  }
1138
1257
  // not whole, or not JSON as it stands: read up to a close (a file's
1139
1258
  // content may hold one), or at the end all of it
@@ -1168,6 +1287,14 @@ const UNREADABLE = "the model's tool call, written as text, can't be read; it is
1168
1287
  // also writes it without the underscore, <toolcall> (plugins#38)
1169
1288
  const OPEN_AT = /<tool_?call(?:(\s[^<>{}]*)?>|(?=\s*\{))/
1170
1289
  const OPEN_HOLD = /<tool_?call(?:\s[^<>{}\n]{0,200})?$/
1290
+ // what GLM leaves between a call's JSON and its close: blanks, closing
1291
+ // braces and brackets, quotes, a > (magpie#1945)
1292
+ const TAIL_JUNK = /^[\s}\]"'>]*/
1293
+ // a close with no call open, and that leftover before it: the tail of a
1294
+ // call Trae's service read itself, or of one already read (magpie#1945)
1295
+ const STRAY_AT = /[}\]"'>]*\s*<\/tool_?calls?>/
1296
+ // the end of text that may be such a tail still being written
1297
+ const STRAY_HOLD = /[}\]"'>][\s}\]"'>]*$/
1171
1298
  // closeAt is where the next close is from i, </tool_call> or </toolcall>,
1172
1299
  // and its length: [-1, 0] when there is none
1173
1300
  function closeAt(s, i = 0) {
@@ -2347,4 +2474,4 @@ const makePlugin = (site) => async ({ client }) => {
2347
2474
  export const TraeCNAuthPlugin = makePlugin(SITES["trae-cn"])
2348
2475
  export const TraeGlobalAuthPlugin = makePlugin(SITES["trae-global"])
2349
2476
 
2350
- export const _internal = { 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 }
2477
+ 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 }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-trae-auth",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
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",