magpie-marvis 0.1.8 → 0.2.0

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/CHANGELOG.md CHANGED
@@ -1,21 +1,40 @@
1
- # 更新记录
2
-
3
- > 每个版本一节,**一条一行**,说用户能感知的变化。
4
- > 格式与规矩见 [UPDATING.md](./UPDATING.md)。
5
-
6
- ## 0.1.8 — 2026-11-06
7
-
8
- **修复**
9
-
10
- - **浏览器登录「授权码已被使用过」(`-109`)**:根因是 **QQ 会把 `redirect_uri` 的查询串整个丢掉** ——
11
- 落地页收到的是 `?code=...&state=...`,**没有 `login_type`**,服务端因此判参数无效。
12
- 改成用 CDP `Fetch.requestPaused` 在**跳转前**拦下回调,自己从 URL 取 `code` 再带 `login_type` 换。
13
- 页面同时显示「授权完成」,用户不用对着 `-109` 的 JSON 发愣
14
- - **换 token 带了鉴权头**:`marvis_oauth` 是**免鉴权**端点,带 `Ual-Access-*` 签名头会直接 400 空响应
15
- - **`deviceGuid()` 永远取不到注册表**:JS 里 `\S` `\T` 是无效转义,反斜杠被吃掉,
16
- `reg` 收到 `HKCUSOFTWARETencentMarvisEnv` → 「无效参数」。改用 `String.raw`
17
- - **本机导入的 guid 与 token 不同源**:服务端按 `(openid, guid, token)` 三元组校验,
18
- 现在优先从**同一段内存**里取 `guid=`
19
- - 去掉 `--disable-blink-features=AutomationControlled`(Edge 会弹「不受支持的命令行标记」警告条),
20
- 改用 CDP `Page.addScriptToEvaluateOnNewDocument` 抹掉 `navigator.webdriver`
21
-
1
+ # 更新记录
2
+
3
+ > 每个版本一节,**一条一行**,说用户能感知的变化。
4
+ > 格式与规矩见 [UPDATING.md](./UPDATING.md)。
5
+
6
+ ## 0.2.0 — 2026-11-06
7
+
8
+ **修复(逆向客户端主进程后对齐)**
9
+
10
+ - **换取请求改在页面上下文里发**:`yybadaccess` 的 WAF 做 TLS 指纹校验,
11
+ Node 的 undici(OpenSSL)和 Chromium(BoringSSL)指纹不同 ——
12
+ 带头的请求从 Node 发出去一律 **400 空响应**。
13
+ - **补 `Referer`**:客户端重放时会把原始请求头整份复制(`reqHeaders` / `cookies` / `referer`),
14
+ 缺 referer 服务端判来源不合法。
15
+ - **去掉 `Ual-Access-Businessid`**:实测它会让 WAF 直接 400(这是我这轮踩的最大坑——
16
+ 加头反而更糟,不加头时至少还能拿到 `-109` JSON)。
17
+ - 浏览器先开 `about:blank`、装好钩子再 `Page.navigate` —— 否则首次请求漏过去,拦截永远不触发。
18
+
19
+ **修复(这是 `-109` 的真正根因)**
20
+
21
+ - **`redirect_uri` 少了 `?login_type=QC`**。Marvis 渲染层的 `Bpe(e)` 返回的是
22
+ `.../marvis_oauth?login_type=${e}`,我写成了 `.../marvis_oauth`。
23
+ 服务端靠这个参数知道「用哪个 app 的 `client_secret` 去换 code」——
24
+ 少了它,**哪怕 code 是真的也一律 `-109 RC_PARAMS_INVALID`**(是参数无效,不是 code 无效)。
25
+ - 浏览器登录改为**正常走完回调请求**(带 Cookie、带正确 `redirect_uri`),
26
+ 只读第一个响应体;不再拦截后用 Node 重放(重放丢 Cookie,同样 `-109`)
27
+ - 微信登录同步修正
28
+
29
+ - **浏览器登录「授权码已被使用过」(`-109`)**:根因是 **QQ 会把 `redirect_uri` 的查询串整个丢掉** ——
30
+ 落地页收到的是 `?code=...&state=...`,**没有 `login_type`**,服务端因此判参数无效。
31
+ 改成用 CDP `Fetch.requestPaused` 在**跳转前**拦下回调,自己从 URL 取 `code` 再带 `login_type` 换。
32
+ 页面同时显示「授权完成」,用户不用对着 `-109` 的 JSON 发愣
33
+ - **换 token 带了鉴权头**:`marvis_oauth` 是**免鉴权**端点,带 `Ual-Access-*` 签名头会直接 400 空响应
34
+ - **`deviceGuid()` 永远取不到注册表**:JS 里 `\S` `\T` 是无效转义,反斜杠被吃掉,
35
+ `reg` 收到 `HKCUSOFTWARETencentMarvisEnv` → 「无效参数」。改用 `String.raw`
36
+ - **本机导入的 guid 与 token 不同源**:服务端按 `(openid, guid, token)` 三元组校验,
37
+ 现在优先从**同一段内存**里取 `guid=`
38
+ - 去掉 `--disable-blink-features=AutomationControlled`(Edge 会弹「不受支持的命令行标记」警告条),
39
+ 改用 CDP `Page.addScriptToEvaluateOnNewDocument` 抹掉 `navigator.webdriver`
40
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magpie-marvis",
3
- "version": "0.1.8",
3
+ "version": "0.2.0",
4
4
  "description": "magpie / OpenCode provider plugin for Tencent Marvis: its DeepSeek V4 Pro, Kimi K2.5, Claude Opus/Sonnet 4.6, GPT-5.4 and Hunyuan models through the Marvis model gateway — no API key to paste, sign in with QQ or WeChat.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
@@ -198,11 +198,12 @@ async function getJson(url, timeoutMs = 3000) {
198
198
  * @param {{name:string,path:string}} o.browser findBrowser() 的返回值
199
199
  * @param {string} o.url 登录页
200
200
  * @param {RegExp} o.match 回调地址的判据
201
+ * @param {Record<string,string>} [o.extraHeaders] 注入到**每个请求**的头(MarvisExt 等)
201
202
  * @param {number} [o.timeoutMs] 默认 5 分钟
202
203
  * @param {AbortSignal} [o.signal]
203
204
  * @returns {Promise<{url:string, body:string, browser:string}>} 回调页面的地址与正文
204
205
  */
205
- export async function openIsolatedLogin({ browser, url, match, timeoutMs = 5 * 60_000, signal }) {
206
+ export async function openIsolatedLogin({ browser, url, match, extraHeaders, timeoutMs = 5 * 60_000, signal }) {
206
207
  if (!browser?.path) throw new Error("没有找到可用的 Chromium 内核浏览器(Chrome / Edge / 360 / QQ 浏览器都行)")
207
208
 
208
209
  const profile = mkdtempSync(join(tmpdir(), "magpie-marvis-login-"))
@@ -223,7 +224,9 @@ export async function openIsolatedLogin({ browser, url, match, timeoutMs = 5 * 6
223
224
  "--exclude-switches=enable-automation",
224
225
  `--user-agent=${CHROME_UA}`,
225
226
  "--lang=zh-CN",
226
- url,
227
+ // ⚠ 必须先开 about:blank:钩子(Fetch.enable / setExtraHTTPHeaders)要在
228
+ // 导航**之前**装好,否则首次请求漏过去,拦截永远不触发。
229
+ "about:blank",
227
230
  ],
228
231
  { stdio: "ignore", windowsHide: false },
229
232
  )
@@ -295,22 +298,38 @@ export async function openIsolatedLogin({ browser, url, match, timeoutMs = 5 * 6
295
298
  acceptLanguage: "zh-CN,zh;q=0.9",
296
299
  platform: "Win32",
297
300
  }).catch(() => {})
301
+ // ⚠⚠ 关键:服务端对 `yybadaccess.3g.qq.com` 的**每个**请求都校验
302
+ // `Ual-Access-MarvisExt`(uskey 设备指纹)。没有它就返回
303
+ // 「请使用 Marvis 客户端」—— 这正是浏览器登录一直失败的原因。
304
+ // Marvis 的 BrowserWindow 用 `session.webRequest.onBeforeSendHeaders` 注入,
305
+ // 这里用 CDP 的 `Network.setExtraHTTPHeaders` 达到同样效果。
306
+ if (extraHeaders && Object.keys(extraHeaders).length > 0) {
307
+ await send(5, "Network.setExtraHTTPHeaders", { headers: extraHeaders }).catch(() => {})
308
+ }
309
+
298
310
  // 顺手抹掉 navigator.webdriver(比 --disable-blink-features 更干净,不带警告条)
299
311
  await send(3, "Page.addScriptToEvaluateOnNewDocument", {
300
312
  source: "Object.defineProperty(navigator,'webdriver',{get:()=>undefined})",
301
313
  }).catch(() => {})
302
314
 
303
- // ⚠ 核心:**在跳转发生前**把回调请求拦下来。
304
- // 实测 QQ 会把 redirect_uri 的查询串整个丢掉 —— 落地页收到的
305
- // `?code=...&state=...` 里**没有 login_type**,服务端直接 `-109 RC_PARAMS_INVALID`。
306
- // 所以不能靠那个页面的响应,只能自己从 URL 里取 code,再带上 login_type 换一次。
307
- await send(4, "Fetch.enable", {
315
+ // ⚠⚠ 核心:**在跳转发生前**拦下回调,然后在**页面上下文里**发换取请求。
316
+ //
317
+ // 为什么不能从 Node 发:`yybadaccess` 的 WAF 做 **TLS 指纹**校验 ——
318
+ // Node 的 undici(OpenSSL)和 Chromium(BoringSSL)指纹不同,
319
+ // 带 `Ual-Access-MarvisExt` 的请求从 Node 发出去一律 **400 空响应**。
320
+ // 在页面里 `fetch` 出去 = Chromium 的 TLS 栈 + 同源 Cookie,服务端才认。
321
+ await send(6, "Fetch.enable", {
308
322
  patterns: [{ urlPattern: "*marvis_client_login/marvis_oauth*", requestStage: "Request" }],
309
323
  }).catch(() => {})
310
324
 
325
+ // 钩子装好,现在才导航到授权页
326
+ await send(7, "Page.navigate", { url }).catch(() => {})
327
+
311
328
  let nextId = 10
312
- /** @type {{url:string} | null} */
313
- let hit = null
329
+ /** @type {string | null} */
330
+ let pausedId = null
331
+ /** @type {string} */
332
+ let pausedUrl = ""
314
333
 
315
334
  ws.addEventListener("message", (event) => {
316
335
  let msg
@@ -319,34 +338,91 @@ export async function openIsolatedLogin({ browser, url, match, timeoutMs = 5 * 6
319
338
  } catch {
320
339
  return
321
340
  }
322
- if (msg.method === "Fetch.requestPaused" && !hit) {
341
+ if (msg.method === "Fetch.requestPaused" && !pausedId) {
323
342
  const { requestId, request } = msg.params ?? {}
324
343
  const u = String(request?.url ?? "")
325
344
  if (u && match.test(u)) {
326
- hit = { url: u }
327
- // 让页面显示一句「授权完成」,避免用户对着 -109 的 JSON 发愣
328
- send(nextId++, "Fetch.fulfillRequest", {
329
- requestId,
330
- responseCode: 200,
331
- responseHeaders: [{ name: "content-type", value: "text/html; charset=utf-8" }],
332
- body: Buffer.from(
333
- "<!doctype html><meta charset=utf-8><title>Marvis 授权完成</title>" +
334
- "<body style=\"font:16px/1.6 system-ui;padding:48px;color:#111\">" +
335
- "<h2>授权完成</h2><p>回到 magpie 即可,窗口会自动关闭。</p></body>",
336
- "utf8",
337
- ).toString("base64"),
338
- }).catch(() => {})
345
+ pausedId = requestId
346
+ pausedUrl = u
339
347
  }
340
348
  }
341
349
  })
342
350
 
343
351
  while (Date.now() < deadline) {
344
352
  if (signal?.aborted) throw new Error("登录已取消")
345
- if (hit) return { url: hit.url, body: "", browser: browser.name }
353
+ if (pausedId) break
346
354
  await new Promise((r) => setTimeout(r, 400))
347
355
  }
356
+ if (!pausedId) {
357
+ throw new Error("登录超时:没有等到授权回调。请在打开的窗口里完成 QQ / 微信 授权。")
358
+ }
359
+
360
+ // 先把挂起的导航放行(用一张静态页顶掉,避免页面白屏),再在页面里换
361
+ await send(nextId++, "Fetch.fulfillRequest", {
362
+ requestId: pausedId,
363
+ responseCode: 200,
364
+ responseHeaders: [{ name: "content-type", value: "text/html; charset=utf-8" }],
365
+ body: Buffer.from(
366
+ '<!doctype html><meta charset="utf-8"><title>Marvis</title>' +
367
+ '<body style="font:16px/1.6 system-ui;padding:48px;color:#111">' +
368
+ "<h2>正在完成授权…</h2><p>回到 magpie 即可。</p></body>",
369
+ "utf8",
370
+ ).toString("base64"),
371
+ }).catch(() => {})
372
+
373
+ // 在页面上下文里发换取请求(Chromium 的 TLS 指纹 + 同源 Cookie + 注入的头)
374
+ //
375
+ // ⚠ 还要补 `Referer`:客户端主进程重放时会把**原始请求头**整份复制过去
376
+ // (字符串表里的 `reqHeaders` / `cookies` / `referer` / `ALL_REQ_HEADERS`),
377
+ // 其中 referer 是 QQ 授权页本身的地址。缺它服务端会判来源不合法。
378
+ // fetch 的 Referer 属于禁止头,只能走 CDP 注入。
379
+ try {
380
+ const origin = new URL(pausedUrl).origin
381
+ await send(nextId++, "Network.setExtraHTTPHeaders", {
382
+ headers: { Referer: `${origin}/oauth2.0/show?which=Login&display=pc` },
383
+ })
384
+ } catch {
385
+ /* 注入失败不致命,继续试 */
386
+ }
387
+
388
+ const exchangeUrl = pausedUrl
389
+ const expr = `(async () => {
390
+ try {
391
+ const r = await fetch(${JSON.stringify(exchangeUrl)}, {
392
+ method: "GET",
393
+ credentials: "include",
394
+ headers: { "Accept": "application/json" },
395
+ });
396
+ return JSON.stringify({ __status: r.status, __body: await r.text() });
397
+ } catch (e) {
398
+ return JSON.stringify({ __error: String(e && e.message || e) });
399
+ }
400
+ })()`
401
+
402
+ /** @type {any} */
403
+ let out = null
404
+ for (let i = 0; i < 20; i++) {
405
+ try {
406
+ const r = await send(nextId++, "Runtime.evaluate", {
407
+ expression: expr,
408
+ awaitPromise: true,
409
+ returnByValue: true,
410
+ })
411
+ const raw = r?.result?.value
412
+ if (raw) {
413
+ out = JSON.parse(String(raw))
414
+ break
415
+ }
416
+ } catch {
417
+ /* 页面还在导航 */
418
+ }
419
+ await new Promise((r) => setTimeout(r, 500))
420
+ }
421
+
422
+ if (!out) throw new Error("换取登录态超时:页面里没能完成请求。请重新走一遍登录。")
423
+ if (out.__error) throw new Error(`页面内换取失败:${out.__error}`)
348
424
 
349
- throw new Error("登录超时:没有等到授权回调。请在打开的窗口里完成 QQ / 微信 授权。")
425
+ return { url: exchangeUrl, body: String(out.__body ?? ""), browser: browser.name }
350
426
  } finally {
351
427
  try {
352
428
  ws.close()
@@ -17,7 +17,17 @@ import { createRequire } from "node:module"
17
17
 
18
18
  const require = createRequire(import.meta.url)
19
19
 
20
- /** redirect_uri —— 与 Marvis 客户端逐字一致 */
20
+ /**
21
+ * redirect_uri 的**基址**(不带查询串)。
22
+ *
23
+ * ⚠⚠ 授权时必须拼上 `?login_type=<QC|WX>` —— 这是整个登录的关键:
24
+ * 服务端要靠它知道「用哪个 app 的 client_secret 去换 code」。
25
+ * 少了它,哪怕 code 是真的,也会一律回 `-109 RC_PARAMS_INVALID`
26
+ * (`RC_PARAMS_INVALID` = 参数无效,不是 code 无效)。
27
+ *
28
+ * 对照 Marvis 渲染层的 `Bpe(e)`:
29
+ * `${base}/${n}/marvis_oauth?login_type=${e}` // e = QC | WX
30
+ */
21
31
  const REDIRECT = `${BASE}/marvis_client_login/marvis_oauth`
22
32
 
23
33
  /**
@@ -30,7 +40,8 @@ export function authorizeUrl(loginType, guid) {
30
40
  if (loginType === "WX") {
31
41
  const p = new URLSearchParams({
32
42
  appid: WX_APP_ID,
33
- redirect_uri: REDIRECT,
43
+ // ⚠ 同样必须带 ?login_type=WX
44
+ redirect_uri: `${REDIRECT}?login_type=${loginType}`,
34
45
  response_type: "code",
35
46
  scope: "snsapi_login",
36
47
  state: guid,
@@ -43,7 +54,8 @@ export function authorizeUrl(loginType, guid) {
43
54
  display: "pc",
44
55
  response_type: "code",
45
56
  client_id: QQ_APP_ID,
46
- redirect_uri: REDIRECT,
57
+ // ⚠ 必须带 ?login_type=QC,见 REDIRECT 上方说明
58
+ redirect_uri: `${REDIRECT}?login_type=${loginType}`,
47
59
  state: String(Math.floor(Math.random() * 1e9)),
48
60
  })
49
61
  return `https://graph.qq.com/oauth2.0/authorize?${p}`