From 2ad16a3577b008a5e12c7607dab8baa68a7ec6de Mon Sep 17 00:00:00 2001 From: MYCF711 Date: Wed, 4 Nov 2026 06:33:00 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E7=BD=91=E9=A1=B5=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E7=9A=84=E7=BB=93=E6=9E=9C=E5=B0=91=E4=BA=86=20refresh?= =?UTF-8?q?=EF=BC=8Cmagpie=20=E4=B8=80=E6=A0=BC=E9=83=BD=E4=B8=8D=E5=AD=98?= =?UTF-8?q?=EF=BC=880.2.36=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「{user} 已登录,但 magpie 无法列出它」的真因不是国际站的 cookie,是登录返回值。 magpie 的插件宿主 host.js 的 save() 只有两条出口: if ("refresh" in result) setAuth(oauth) else if ("key" in result) setAuth(api) 本插件的 success 只有 access/expires/accountId/uid —— 两条都不命中,宿主既不保存 也不报错,界面照样显示「已登录」,而 plugin-auth.json 里没有记录,随后的 providers?wait=1 里就没有这个 account。 国内站与百智云一直用 API Key 方式登录(type: api,走 api 分支),所以「网页登录 从来没存下来过」一直没暴露,国际站第一个撞上。 - 新增 src/credential.mjs 的 signInSuccess():refresh 与 access 同值、expires 0; 五处 success 全部改走它 - tests/signin-contract.test.mjs:锁 refresh 位,并禁止生产代码再出现裸的 type: "success" - 国际站删掉「用百智云账号开通」:实测它 302 到自己的 /oauth/authorize (client_id 空),注入百智云会话也只落到首页,不会签发 session - 桌面导入按域取 cookie 并恢复给国际站:桌面端的 mc_base_url 是二选一的一个值, monkeycode-cookies.json 装的是当前那一站;读到另一站时明确说清 - AGENTS 0.6/0.7、docs/internals、README 同步实测结论 --- AGENTS.md | 20 ++++++- CHANGELOG.md | 12 ++++ README.md | 6 +- docs/internals.md | 78 ++++++++++++++++++++++--- package.json | 2 +- src/baizhi/runtime.mjs | 7 +-- src/credential.mjs | 40 +++++++++++++ src/monkeycode/desktop-session.mjs | 34 +++++++++-- src/monkeycode/runtime.mjs | 63 +++++++++++--------- src/monkeycode/signing.mjs | 2 +- tests/multi-provider.test.mjs | 31 +++++++--- tests/signin-contract.test.mjs | 93 ++++++++++++++++++++++++++++++ 12 files changed, 332 insertions(+), 56 deletions(-) create mode 100644 tests/signin-contract.test.mjs diff --git a/AGENTS.md b/AGENTS.md index 7340cba..d5f5ebc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,16 +130,32 @@ SSO 路径(`signInWithBaizhiSession`)保持可用。 ### 0.7 国际版网页登录必须能成功,且列出模型 -**现状(2026-11-04,未修复)**:用户在国际版点网页登录,界面报 +**现状(2026-11-04,已定位并修复,待真机二次确认)**:用户在国际版点网页登录,界面报 **「MonkeyCode 国际版 已登录,但 magpie 无法列出它,请反馈此问题」**。 +**根因(已确认,是宿主契约不是本插件猜测)**:magpie 插件宿主的 `save()` 只有两条出口 —— +`if ("refresh" in result) setAuth(oauth)` / `else if ("key" in result) setAuth(api)`。 +本插件所有网页登录都返回 `{type:"success", access, expires, accountId, uid}`:没有 `refresh` +也没有 `key`,**两条都不命中 ⇒ 不保存、也不报错**。magpie 于是显示「已登录」,而 +`plugin-auth.json` 里没有记录,`providers?wait=1` 里就没有这个 account,界面打这句提示。 +为什么国内站没暴露:它一直是 **API Key** 方式登录(`type: "api"`,走 api 分支), +一次网页登录都没真正落过盘。修法见 `src/credential.mjs` 的 `signInSuccess()` 与 +`tests/signin-contract.test.mjs`(裸 `type: "success"` 已被单测禁止)。 + **已知事实**: +- 国际站登录页只有三种方式:**GitHub / Google / 账号密码**(2026-11-04 无头读 DOM: + 文案 `GitHub 登录` / `Google 登录` / `账号密码登录`,另有「当前访问的是国际版」提示) - `https://monkeycode-ai.net/` → 200;`/login` → 200 但**不返 Set-Cookie**(SPA,cookie 由 JS 下发) +- **国际站不认百智云 SSO**:`GET /api/v1/users/login` 302 到它**自己的** `/oauth/authorize` + (`client_id=` 为空,不经过 `baizhi.cloud`);把百智云会话注入窗口走一遍只落到国际站首页, + 不会签发 session ⇒ 国际站**不提供**「用百智云账号开通」入口 +- 纯 API 的两条:`POST /api/v1/users/password-login`(`{email,password,captcha_token}`, + 缺 token 回 **403**;`POST /api/v1/public/captcha/challenge` 回 201,形状与国内站一致) - `https://monkeycode-ai.net/api/v1/users/models` 无 cookie / 国内 cookie → **401** - 国内站同一路径 → 200 -**因此根因只可能是这两类之一**,改之前先分清: +**当初的排查框架(保留,别拿它当结论)**:根因不是下面这两类,是宿主「两条出口都不命中」的保存契约。 1. **会话没拿到**:窗口里的登录没真正完成(cookie 名不同,或 `accept()` 太早返回) 2. **会话拿到了但目录拉不到**:`catalog` 打错了 baseUrl / 路径,或 `.net` 的模型接口不是 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fc07e9..061f2ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,18 @@ > 每个版本一节,**一条一行**,说用户能感知的变化。 > 格式与规矩见 [UPDATING.md](./UPDATING.md)。 +## 0.2.36 — 2026-11-04 + +**「已登录,但 magpie 无法列出它」的真因找到了:登录结果少了 `refresh`,magpie 一格都没存** + +- 根因(不是猜测,是宿主契约):magpie 的插件宿主 `save()` 只有两条出口 —— `if ("refresh" in result) setAuth(oauth)` 或 `else if ("key" in result) setAuth(api)`。本插件所有网页登录返回的都是 `{type:"success", access, expires, accountId, uid}`:**没有 `refresh`,也没有 `key`**,两条都不命中 ⇒ 宿主既不保存也不报错,magpie 照样显示「已登录」,账号却没落盘。紧接着的 `providers?wait=1` 里找不到这个 account,界面就打「{user} 已登录,但 magpie 无法列出它,请反馈此问题」 +- 为什么以前没暴露:国内站与百智云一直是用 **API Key** 方式登录的(`plugin-auth.json` 里 `type: "api"`),走的是 api 分支;**一次网页登录都没真正存下来过**。国际站只能用网页 / GitHub / Google / 密码登录,于是第一个撞上 +- 修法:新增 `signInSuccess()` 统一拼 oauth 登录结果(`refresh` 与 `access` 同值、`expires: 0`),五处 success 全部改走它;**单测锁住「生产代码里不许再出现裸的 `type: "success"`」**,下一个登录方式不会原样复制这个静默失败 +- 国际站的登录方式实测(无头浏览器读 DOM):**GitHub / Google / 账号密码**三种,页面明确写「当前访问的是国际版」。两条纯 API 的路仍在:账号密码登录(captcha 形状与国内站一致,`{email,password}` 不带 token 回 403 而不是 400)+ 桌面客户端导入 +- **国际站删掉「用百智云账号开通」**:实测 `GET https://monkeycode-ai.net/api/v1/users/login` 302 到它**自己的** `/oauth/authorize`(`client_id=` 是空的,不经过 `baizhi.cloud`);把百智云会话注入那个窗口再走一遍,最终只落到国际站首页,**不会**签发 `monkeycode_ai_session`。留着只会让人对着一个必然超时的入口等 240 秒 +- 「从本机客户端导入」**改按域读 cookie,并恢复给国际站**(0.2.33 删它是因为当时桌面端指向国内站):桌面客户端的 `mc_base_url` 是国内站 / 国际站二选一的一个值,`monkeycode-cookies.json` 里装的是**当前那一站**的会话。现在按本站 host 过滤,读到另一站的登录态时会明确说「桌面客户端登录的是另一个站点」,而不是让上游 401 把人引去怀疑「登录过期」 +- `AGENTS.md` 0.7 从「未修复」改为修复记录(保留原判据);0.6 补上国际站登录方式与「不认百智云 SSO」的实测结论 + ## 0.2.35 — 2026-11-04 **百智云也能用手机号 + 验证码登录了:两个请求,不开窗口** diff --git a/README.md b/README.md index 94933b7..221045e 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,11 @@ magpie plugin list ## 常见问题 -**「从本机客户端导入」说没读到登录态?** 桌面版要**先登录成功**才会把会话写到本机。打开桌面版登一次,再回来点。 +**「从本机客户端导入」说没读到登录态?** 桌面版要**先登录成功**才会把会话写到本机。打开桌面版登一次,再回来点。桌面版的服务器是**国内站 / 国际站二选一**的,插件只认本站那份 —— 装的是另一站时代码会说「桌面客户端登录的是另一个站点」,切换过去重登一次即可。 + +**国际版点网页登录,界面报「已登录,但 magpie 无法列出它」?** 0.2.36 已修。根因不在国际站:magpie 的插件宿主只在登录结果带 `refresh` 字段时才把 oauth 登录写进 `plugin-auth.json`,缺这一格时界面照旧显示成功、账号却没落盘。升级后重登一次即可。 + +**国际版为什么没有「用百智云账号开通」?** 国际站的登录入口是它自己的(GitHub / Google / 账号密码),`/api/v1/users/login` 302 到它自己的授权页、不经过百智云 —— 那条入口在国际站上只会空等。国内站不受影响。 **导入时说「桌面版里的登录可能已经过期」?** 桌面版的会话有有效期(通常几天到一个月)。重新打开桌面版,它会自己续期;或者直接用「浏览器登录」。 diff --git a/docs/internals.md b/docs/internals.md index 41f19c2..7b15be3 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -12,16 +12,53 @@ | provider id | `monkeycode` | `monkeycodeintl` | | 平台 | `https://monkeycode-ai.com` | `https://monkeycode-ai.net` | | 推理网关 | `https://proxy.monkeycode-ai.com/v1` | `https://proxy.monkeycode-ai.net/v1` | -| 会话 cookie | `monkeycode_ai_session` | 同名(**未真机验证**,见下) | +| 会话 cookie | `monkeycode_ai_session` | 同名(**推断,未读到值**,见下) | +| 登录方式 | 微信 / 支付宝 / 抖音 / GitHub / 观猹 / 账号密码(百智云 SSO) | **GitHub / Google / 账号密码**(2026-11-04 无头读 DOM) | +| 百智云 SSO | `GET /api/v1/users/login` → 302 `baizhi.cloud/oauth/authorize` | **不认**:302 到它自己的 `/oauth/authorize`,`client_id=` 为空 | | 接口路径 | `/api/v1/...` | 逐字相同 | 两站是同一程序的两套部署:`GET /v1/models` 在两边都返回 405(逐字节一致的行为),平台接口路径也完全相同。 **账号池不互通** —— 国内站的 cookie 打国际站回 401,这是上游 RPC 层真机踩过并写进注释的事实。 -未验证项(照抄上游标注,不当作已确认): +国际站已确认的事实(2026-11-04): -- 国际站换 Key 端点的响应字段、cookie 名是否与国内一致; -- 国际站账号级登录本仓没有真机跑通(上游源码里同时存在「真机验过」与「只有国内账号」两种说法)。 +- 登录页只有 **GitHub / Google / 账号密码** 三种,页面明写「当前访问的是国际版」(无头浏览器读 `document.body.innerText`) +- `GET /api/v1/users/login` → 302 `Location: /oauth/authorize?client_id=&redirect_uri=https%3A%2F%2Fmonkeycode-ai.net%2Fapi%2Fv1%2Fusers%2Fbaizhi%2Fcallback...` + —— **`client_id` 是空的,且是相对自己站点的路径**,不经过 `baizhi.cloud` +- 把百智云会话注入一次性 Chromium 再走这条路:最终只落到国际站首页,**不会**签发国际站 session + ⇒ 国际站**不提供**「用百智云账号开通」入口(`src/monkeycode/runtime.mjs` 里那个 method 被 `site.id` 门住) +- `POST /api/v1/public/captcha/challenge` 回 201、形状与国内站相同; + `POST /api/v1/users/password-login {email,password}` 缺 `captcha_token` 回 **403**(与国内站同一套语义) +- 未登录时**不下发**任何会话 cookie(`/`、`/login`、`/oauth/authorize` 三条都不带 `Set-Cookie`) + +仍未验证项(照抄上游标注,不当作已确认): + +- 国际站换 Key 端点(`/api/v1/users/ohmyagent/api-keys`)的**响应字段**是否与国内一致 + —— 路径存在(无 cookie 回 401 而不是 404),但没有国际站账号跑完过一次 +- 国际站会话 cookie 的**名字**:`monkeycode_ai_session` 只是推断。间接证据是报错形态 + 「{user} 已登录,但 magpie 无法列出它」—— 说明 `accept()` 曾经命中过(否则会是「登录超时」); + 但这不足以排除「插件把别的 cookie 当成了会话」。真机判据见 AGENTS 0.7 + +### 1.1 登录结果必须带 `refresh`(否则 magpie 一格都不存) + +magpie 的插件宿主(`host.js` 的 `save()`)只有两条保存出口: + +```js +if ("refresh" in result) setAuth(key, { type: "oauth", refresh, access, expires, ...extra }) +else if ("key" in result) setAuth(key, { type: "api", key, metadata }) +``` + +**两条都不命中时它既不保存也不报错**:`plugin-auth.json` 里没有记录,而界面照样显示「已登录」, +紧接着的 `providers?wait=1` 里找不到这个 account,就打出 +「{user} 已登录,但 magpie 无法列出它,请反馈此问题」。 + +本包没有 refresh 端点(凭据是长期静态材料),但**这一格必须占上**:统一由 +`src/credential.mjs` 的 `signInSuccess()` 填 `refresh`(与 `access` 同值)与 `expires: 0`。 +`tests/signin-contract.test.mjs` 锁住两件事:`signInSuccess` 一定带 `refresh`; +**生产代码里不许再出现裸的 `type: "success"`**。 + +这条的隐蔽性在于:国内站与百智云一直用 **API Key** 方式登录(`type: "api"`,走 api 分支), +所以「网页登录从来没存下来过」这件事一直没被发现,直到国际站(只能用网页/密码登录)撞上。 ## 2. 鉴权:两套凭据,各管一段 @@ -62,9 +99,24 @@ signature = "v1=" + hex(HMAC-SHA256(signing_secret, prompt)) ↘ GET /api/v1/users/status → data.user.{id,nickname} ``` -- **为什么不能「读到 cookie 就算成功」**:窗口一开,服务端就下发匿名 `monkeycode_ai_session`。把它当登录完成,用户刚登完却看到「会话已失效」,窗口还被收掉 —— 等于白登一次。 +- **为什么不能「读到 cookie 就算成功」**:窗口一开,服务端就下发匿名 `monkeycode_ai_session`。把它当登录完成,用户刚登完却看到「会话已失效」,窗口还被收掉 —— 等于白登一次。(国际站未登录时**不下发** cookie,这一条对它退化成「读到就是登完了」,但仍按同一套链路判。) - **身份只用 `data.user.id`**:兑换响应里的 `id` 是「这把 Key 的记录 id」,同一账号每兑换一次就变。拿它判重会让「同一账号第二次登录」变成新账号(每登一次多一张卡 + 多签发一把 Key)。 - 窗口是可见的 1024×800、一次性 profile,关掉即回收(含临时目录)。 +- **cookie 按本站域取**(`cookieDomains: [new URL(site.platformBaseUrl).hostname]`):两站同名 `monkeycode_ai_session`,不过滤域就会把另一站(或持久 profile 里上一个号)的会话当自己的。 +- **登录结果必须带 `refresh`**:见 §1.1 —— 少了它,magpie 会把这次登录显示成成功、却一格都不存。 + +### 从本机客户端导入(两个站都提供,按域判) + +桌面客户端(`%APPDATA%\com.chaitin.baizhi.monkeycode\`)的 `mc_base_url` 是**国内站 / 国际站二选一的一个值**, +`monkeycode-cookies.json` 里装的是**当前那一站**的会话: + +- `readDesktopMonkeyCodeSession({ cookieDomains: [host] })` 只认本站; +- 读到的是**另一站**的登录态时,报「本机桌面客户端登录的是另一个站点」,而不是把 401 甩给用户 + 让人去怀疑「桌面版登录过期了」(`domainMatches()` 去前导点后按精确/子域匹配); +- 没写 `domain` 的老格式放行 —— 不做无根据的排除。 + +⚠ 这个文件会被客户端在切站 / 重登时删掉或重写(2026-11-04 真机:05:14 还在,05:43 就没有了), +所以「读不到」是常态而非异常。 ### API Key 录入 @@ -78,7 +130,7 @@ oma_… 裸 token(缺密钥,请 解析层**不校验前缀**(`oma_` 不是白名单),只要求 token 形状 `^[A-Za-z0-9_.\-/+=]{16,}$` —— 上游将来换前缀时,写死前缀会把新 Key 直接判成「凭据不完整」。 -### 用百智云账号开通 MonkeyCode(OAuth SSO) +### 用百智云账号开通 MonkeyCode(OAuth SSO,**只给国内站**) MonkeyCode 的登录页第一项就是「百智云登录 - 推荐」,它是一段可以直接 GET 的入口 (2026-11-03 从登录页 `` 与 302 链实测): @@ -92,8 +144,18 @@ GET https://monkeycode-ai.com/api/v1/users/login → MonkeyCode 签发自己的 `monkeycode_ai_session` ``` -所以只要有百智云的会话 cookie,就能在受控浏览器里**零交互**换出一个 MonkeyCode 账号 -(首次登录由 MonkeyCode 自动建号)。插件里有两条入口: +⚠ **国际站不是这条链**(2026-11-04 实测): + +``` +GET https://monkeycode-ai.net/api/v1/users/login + → 302 Location: /oauth/authorize?client_id=&redirect_uri=https%3A%2F%2Fmonkeycode-ai.net%2Fapi%2Fv1%2Fusers%2Fbaizhi%2Fcallback&… + ← 相对它自己的站点,且 client_id 为空;窗口里注入百智云会话走完只落到国际站首页 +``` + +所以这个 method 被 `site.id === "monkeycode"` 门住:留在国际站上只会让用户对着一个 +必然超时的入口等 240 秒。国际站要登录,用它自己的 GitHub / Google / 账号密码。 + +国内站这条路不受影响,插件里有两条入口: - **用百智云账号开通**:窗口开在 `baizhi.cloud/console/`,用户登录/换号,插件读到两枚 cookie 后在**无头**浏览器里注入 cookie、访问上面的入口,等 `monkeycode_ai_session` diff --git a/package.json b/package.json index c8efce9..941a190 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "magpie-monkeycode", - "version": "0.2.35", + "version": "0.2.36", "description": "MonkeyCode 国内站 / 国际站 + 百智云 magpie 插件:三家独立供应商,浏览器登录或 API Key 接入,网关签名与积分额度。", "type": "module", "main": "./index.mjs", diff --git a/src/baizhi/runtime.mjs b/src/baizhi/runtime.mjs index dd894a0..a0595fe 100644 --- a/src/baizhi/runtime.mjs +++ b/src/baizhi/runtime.mjs @@ -13,7 +13,7 @@ import { usage as buildUsage, usageWindow } from "../quota.mjs" import { createUsageCache } from "../usage-cache.mjs" -import { parseCredential, hasToken } from "../credential.mjs" +import { parseCredential, hasToken, signInSuccess } from "../credential.mjs" import { findBrowser } from "../browser-login.mjs" import { openInSystemBrowser } from "../system-open.mjs" import { BROWSER_LOGIN_TIMEOUT_MS, openControlledBrowser, profileDirFor, startBrowserLogin } from "../session-login.mjs" @@ -158,8 +158,7 @@ async function finishSignIn(cookie, site, { fetchImpl } = {}) { return { type: "failed", error: `百智云:已读到网页登录态,但换取 API Key 失败(${exchanged.error})` } } const profile = await fetchProfile(cookie, site, { fetchImpl }) - return { - type: "success", + return signInSuccess({ access: JSON.stringify({ access_token: exchanged.apiKey, session_cookie: cookie, @@ -168,7 +167,7 @@ async function finishSignIn(cookie, site, { fetchImpl } = {}) { expires: 0, accountId: profile?.name ?? "", uid: profile?.userId ?? "", - } + }) } /** diff --git a/src/credential.mjs b/src/credential.mjs index 555f32c..53a68c7 100644 --- a/src/credential.mjs +++ b/src/credential.mjs @@ -51,6 +51,46 @@ export function parseCredential(raw) { return TOKEN.test(text) ? { access_token: text } : undefined } +/** + * 拼一个 oauth 登录的 `success` 结果。 + * + * ⚠ **`refresh` 不是可选项,是「存不存得下」的开关。** + * magpie 的插件宿主(`host.js` 的 `save()`)只认这个形状: + * + * ```js + * if ("refresh" in result) setAuth(key, { type: "oauth", refresh, access, expires, ...extra }) + * else if ("key" in result || apiKey) setAuth(key, { type: "api", key, metadata }) + * ``` + * + * 两条都不命中时它**既不保存也不报错**:`saved` 照常回 `{ok:true}`,magpie 于是 + * 显示「已登录」,而账号根本没落盘 —— 紧接着的 `providers?wait=1` 里自然找不到这个 + * account,卡片打出一句「{user} 已登录,但 magpie 无法列出它,请反馈此问题」。 + * + * 这个包没有 refresh 端点(凭据是长期静态材料),但 `refresh` 位必须占上: + * 放同一串 JSON,`secretOf()` 才有值、同账号判重才按秘密比对。`expires: 0` + * 表示永不到期,`auth.refresh` 恒返回 undefined,不会有人拿它去续期。 + * + * @param {object} fields + * @param {string} fields.access 存进 `auth.access` 的凭据字符串 + * @param {string} [fields.accountId] 卡片上的**名字**(不是身份) + * @param {string} [fields.uid] 平台账号 id(身份,magpie 按它判重) + * @param {string} [fields.message] 给用户看的补充说明 + */ +export function signInSuccess({ access, accountId, uid, message, ...rest }) { + return { + type: "success", + access, + // 与 access 同值:没有 refresh_token 的厂商,两格装的是同一份静态凭据。 + refresh: access, + // 0 = 永不到期(长期静态材料);调用方给 expires 就以它为准。 + expires: 0, + ...rest, + ...(typeof accountId === "string" && accountId.length > 0 ? { accountId } : {}), + ...(typeof uid === "string" && uid.length > 0 ? { uid } : {}), + ...(typeof message === "string" && message.length > 0 ? { message } : {}), + } +} + /** 凭据是否有可用的推理 Key。 */ export function hasToken(credential) { return Boolean(credential && typeof credential.access_token === "string" && credential.access_token.length > 0) diff --git a/src/monkeycode/desktop-session.mjs b/src/monkeycode/desktop-session.mjs index cdeaeae..df7695c 100644 --- a/src/monkeycode/desktop-session.mjs +++ b/src/monkeycode/desktop-session.mjs @@ -72,13 +72,32 @@ function expiredAt(entry, now = Date.now()) { return ms <= now } +/** + * 一条 cookie 的域是否属于本站。 + * + * 桌面客户端的 cookie 文件是**按当前 `mc_base_url` 写的一份**(客户端设置里 + * 国内站 / 国际站二选一),所以同一个 `monkeycode-cookies.json` 里装的可能是 + * `.com` 也可能是 `.net` 的会话。不比对域就会把一站的会话拿去打另一站 —— + * 上游一律回 401,界面只能猜「桌面版登录过期了」,而真因是**拿错了站**。 + * + * 规则:去前导点后完全相同,或是它的子域(`www.monkeycode-ai.net`)。 + * 没写 `domain` 的老格式放行(不做无根据的排除)。 + */ +export function domainMatches(domain, host) { + if (typeof domain !== "string" || domain.trim().length === 0) return true + if (typeof host !== "string" || host.trim().length === 0) return true + const d = domain.trim().replace(/^\./, "").toLowerCase() + const h = host.trim().toLowerCase() + return d === h || d.endsWith(`.${h}`) +} + /** * 解析客户端 cookie 文件,返回 `name=value; name2=value2`。 * - * 只保留调用方点名的 cookie 名;同名的取**最后一条未过期**的 + * 只保留调用方点名的 cookie 名与站点;同名的取**最后一条未过期**的 * (客户端会同时存多个域的 `sl-session`,前面的可能是别的子域)。 */ -export function cookiesFromJar(text, { names, now = Date.now() } = {}) { +export function cookiesFromJar(text, { names, domains, now = Date.now() } = {}) { let parsed try { parsed = JSON.parse(text) @@ -87,12 +106,14 @@ export function cookiesFromJar(text, { names, now = Date.now() } = {}) { } const list = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.cookies) ? parsed.cookies : [] const wanted = Array.isArray(names) && names.length > 0 ? names : [] + const hosts = Array.isArray(domains) && domains.length > 0 ? domains : [] const picked = new Map() for (const entry of list) { const name = typeof entry?.name === "string" ? entry.name.trim() : "" const value = typeof entry?.value === "string" ? entry.value : "" if (name.length === 0 || value.length === 0) continue if (wanted.length > 0 && !wanted.includes(name)) continue + if (hosts.length > 0 && !hosts.some((host) => domainMatches(entry?.domain, host))) continue if (expiredAt(entry, now)) continue picked.set(name, value) } @@ -110,11 +131,12 @@ export function cookiesFromJar(text, { names, now = Date.now() } = {}) { * @param {object} options * @param {string} options.cookieFile 文件名,见 `DESKTOP_COOKIE_FILES` * @param {string[]} options.cookieNames 需要的 cookie 名(全部到齐才算命中) + * @param {string[]} [options.cookieDomains] 只认这些域(本站 host);不传则不限 * @param {string} [options.appDir] 覆盖目录名 * @param {NodeJS.ProcessEnv} [options.env] * @returns {{ cookie: string, file: string } | undefined} */ -export function readDesktopCookie({ cookieFile, cookieNames, appDir = DESKTOP_APP_DIR, env = process.env, home, now = Date.now() } = {}) { +export function readDesktopCookie({ cookieFile, cookieNames, cookieDomains, appDir = DESKTOP_APP_DIR, env = process.env, home, now = Date.now() } = {}) { if (typeof cookieFile !== "string" || cookieFile.length === 0) return undefined const names = Array.isArray(cookieNames) ? cookieNames : [] // `home` 必须能一路传到底:单测要用它把「按 homedir 兜底」关掉(否则测试会读到 @@ -129,7 +151,7 @@ export function readDesktopCookie({ cookieFile, cookieNames, appDir = DESKTOP_AP } catch { continue } - const cookie = cookiesFromJar(text, { names, now }) + const cookie = cookiesFromJar(text, { names, domains: cookieDomains, now }) if (cookie === undefined) continue return { cookie, file } } @@ -140,6 +162,10 @@ export function readDesktopCookie({ cookieFile, cookieNames, appDir = DESKTOP_AP * 读 MonkeyCode 平台会话(`monkeycode_ai_session`)。 * * 这是「用户装过桌面版并登录过」的唯一判据 —— 后续用它换网关凭据。 + * + * `cookieDomains` 传本站 host(调用方从 `site.platformBaseUrl` 取):桌面客户端 + * 的 `mc_base_url` 是**一个**值,`.com` 与 `.net` 的会话装在同一个文件里, + * 不比对域就会拿错站的 cookie。 */ export function readDesktopMonkeyCodeSession(options = {}) { return readDesktopCookie({ cookieFile: DESKTOP_COOKIE_FILES.monkeycode, cookieNames: ["monkeycode_ai_session"], ...options }) diff --git a/src/monkeycode/runtime.mjs b/src/monkeycode/runtime.mjs index fa02904..9ecd33e 100644 --- a/src/monkeycode/runtime.mjs +++ b/src/monkeycode/runtime.mjs @@ -74,7 +74,7 @@ const LOGIN_AUTOPILOT_JS = `(() => { import { claimCheckin, cookieOf, exchangeSessionForKey, fetchPlan, fetchProfile, fetchWallet, PLAN_LABELS, } from "./credits.mjs" -import { FALLBACK_SYSTEM, gatewayHeaders, isUsableCredential, parseBodyQuietly, parseCredential, signingPrompt } from "./signing.mjs" +import { FALLBACK_SYSTEM, gatewayHeaders, isUsableCredential, parseBodyQuietly, parseCredential, signInSuccess, signingPrompt } from "./signing.mjs" const SDK = "@ai-sdk/anthropic" const MAX_OUTPUT = 64_000 @@ -271,15 +271,15 @@ async function signInWithBrowser(site, { fetchImpl, signal } = {}) { const cookie = credential.session_cookie const profile = await fetchProfile(cookie, site, { fetchImpl }) const nickname = profile?.nickname - return { - type: "success", + return signInSuccess({ // MonkeyCode 的凭据是「一次性签发的静态材料」,没有 refresh_token —— - // access 存整份 JSON,refresh 留空(auth.refresh 会直接返回 undefined)。 + // 整份 JSON 进 access;refresh 由 signInSuccess 填同一份(那一格是 + // magpie 的「存不存得下」开关,见 src/credential.mjs)。 access: JSON.stringify({ ...credential, ...(profile?.userId === undefined ? {} : { user_id: profile.userId }) }), expires: 0, accountId: nickname ?? "", uid: profile?.userId ?? "", - } + }) } /** @@ -383,8 +383,7 @@ export function makePlugin(site) { return { type: "failed", error: `登录成功,但换取网关凭据失败(${issued.error})` } } const profile = await fetchProfile(session.cookie, site) - return { - type: "success", + return signInSuccess({ access: JSON.stringify({ access_token: issued.apiKey, signing_secret: issued.signingSecret, @@ -394,7 +393,7 @@ export function makePlugin(site) { expires: 0, accountId: profile?.nickname ?? "", uid: profile?.userId ?? "", - } + }) } catch (error) { return { type: "failed", error: error instanceof Error ? error.message : String(error) } } @@ -402,11 +401,10 @@ export function makePlugin(site) { } }, }, - // ⚠ 「从本机客户端导入」**只在国内站提供**。桌面客户端导出的 - // `monkeycode-cookies.json` 是**国内站**的会话;拿它去打国际站的 - // `/api/v1/users/models` 实测一律 401(AGENTS 0.6)。国际站要登, - // 必须走国际站自己的登录(0.2 的一次性窗口 / 0.7 的判据)。 - ...(site.id === "monkeycode" ? [ + // 「从本机客户端导入」两站都提供 —— 但**必须按域取 cookie**:桌面客户端的 + // `mc_base_url` 是二选一的一个值,`.com` 与 `.net` 的会话装在同一个 + // `monkeycode-cookies.json` 里。不比对域就会拿错站(上游一律 401, + // 界面只能猜「桌面版登录过期」,真因却是拿错了站)。 { type: "oauth", label: `${site.displayName} 从本机客户端导入`, @@ -422,18 +420,28 @@ export function makePlugin(site) { * `callback()`:铁律 2 要求 authorize 立刻返回,网络请求不能放这儿。 */ async authorize() { - const desk = readDesktopMonkeyCodeSession() + // 只认**本站**的域:桌面客户端的 `mc_base_url` 是二选一的一个值, + // `monkeycode-cookies.json` 里装的可能是另一站的会话。 + const host = new URL(site.platformBaseUrl).hostname + const desk = readDesktopMonkeyCodeSession({ cookieDomains: [host] }) if (desk === undefined) { - const help = "没读到本机 MonkeyCode 桌面客户端的登录态。" - + "先用桌面版登录一次(登录成功后它会把会话写到本机),再回来点这个;" - + "也可以改用「浏览器登录」或「API Key」。" + // 「没读到我这一站」有两种情况,界面必须分开说 —— 否则用户 + // 明明登着桌面版,却被告知「没登录过」,只能反复重登。 + const anySite = readDesktopMonkeyCodeSession() + const help = anySite === undefined + ? "没读到本机 MonkeyCode 桌面客户端的登录态。" + + "先用桌面版登录一次(登录成功后它会把会话写到本机),再回来点这个;" + + "也可以改用「浏览器登录」或「API Key」。" + : "本机桌面客户端登录的是**另一个站点**(国内站 / 国际站二选一)," + + `不是 ${site.displayName}。请在桌面版里把服务器切到 ${site.displayName} 并登录一次,` + + "或者直接用「浏览器登录」。" return { url: site.loginUrl ?? `${site.platformBaseUrl}/`, opened: false, method: "auto", instructions: help, async callback() { - return { type: "failed", error: "本机没有 MonkeyCode 桌面客户端的登录态" } + return { type: "failed", error: help } }, } } @@ -454,8 +462,7 @@ export function makePlugin(site) { } } const profile = await fetchProfile(desk.cookie, site) - return { - type: "success", + return signInSuccess({ access: JSON.stringify({ access_token: issued.apiKey, signing_secret: issued.signingSecret, @@ -465,7 +472,7 @@ export function makePlugin(site) { expires: 0, accountId: profile?.nickname ?? "", uid: profile?.userId ?? "", - } + }) } catch (error) { return { type: "failed", error: error instanceof Error ? error.message : String(error) } } @@ -473,7 +480,12 @@ export function makePlugin(site) { } }, }, - ] : []), + // ⚠ 这个入口**只给国内站**:国际站不认百智云 SSO(2026-11-04 实测)。 + // 国际站的 `GET /api/v1/users/login` 302 到**它自己的** `/oauth/authorize` + // (`client_id=` 是空的,不经过 baizhi.cloud);把百智云会话注入窗口再走一遍, + // 最终只落到国际站首页,**不会**签发 `monkeycode_ai_session`。 + // 留在国际站上只会让用户对着一个必然超时的入口等 240 秒。 + ...(site.id === "monkeycode" ? [ { type: "oauth", label: `${site.displayName} 用百智云账号开通`, @@ -560,8 +572,7 @@ export function makePlugin(site) { try { const signed = await signInWithBaizhiSession(baizhiCookie, site) const duplicated = existingAccountWithUid(site.id, signed.userId ?? "") - return { - type: "success", + return signInSuccess({ access: JSON.stringify(signed.credential), expires: 0, accountId: signed.nickname ?? "", @@ -571,7 +582,7 @@ export function makePlugin(site) { ...(duplicated ? { message: "这个百智云账号已经开通过 MonkeyCode(同一账号,凭据已更新)。要加新号,请换一个百智云账号登录,或用「添加另一个账号」。" } : {}), - } + }) } catch (error) { return { type: "failed", error: error instanceof Error ? error.message : String(error) } } @@ -579,6 +590,7 @@ export function makePlugin(site) { } }, }, + ] : []), { type: "oauth", label: `${site.displayName} 浏览器登录`, @@ -601,7 +613,6 @@ export function makePlugin(site) { } }, }, - ], /** diff --git a/src/monkeycode/signing.mjs b/src/monkeycode/signing.mjs index 715c9d2..309a83d 100644 --- a/src/monkeycode/signing.mjs +++ b/src/monkeycode/signing.mjs @@ -161,7 +161,7 @@ export function shouldRotate(failureClass) { } // 凭据解析已抽到 src/credential.mjs(百智云也用它);这里原样透出,调用方不必改 import。 -export { maskKey, parseCredential } from "../credential.mjs" +export { maskKey, parseCredential, signInSuccess } from "../credential.mjs" /** 凭据是否可用(两个字段都要有)。 */ export function isUsableCredential(credential) { diff --git a/tests/multi-provider.test.mjs b/tests/multi-provider.test.mjs index 039c11e..d890d2a 100644 --- a/tests/multi-provider.test.mjs +++ b/tests/multi-provider.test.mjs @@ -122,14 +122,27 @@ test("三家都同时提供 API Key 与至少一种 oauth,且每个 authorize } }) -test("MonkeyCode 提供「用百智云账号开通」入口(SSO,不是另存一个百智云账号)", async () => { - for (const factory of [entry.MonkeyCodeAuthPlugin, entry.MonkeyCodeIntlAuthPlugin]) { - const plugin = await factory({ client: {} }) - const labels = plugin.auth.methods.map((method) => method.label) - assert.ok(labels.some((label) => label.includes("百智云账号开通")), "缺 SSO 入口") - const sso = plugin.auth.methods.find((method) => method.label.includes("用百智云账号开通")) - const flow = await sso.authorize() - // 窗口开在百智云控制台:用户在里面登录/换号。 - assert.equal(flow.url, "https://baizhi.cloud/console/") +test("「用百智云账号开通」只给国内站 —— 国际站不认百智云 SSO(2026-11-04 实测)", async () => { + const cn = await entry.MonkeyCodeAuthPlugin({ client: {} }) + const sso = cn.auth.methods.find((method) => method.label.includes("用百智云账号开通")) + assert.ok(sso, "国内站缺 SSO 入口") + const flow = await sso.authorize() + // 窗口开在百智云控制台:用户在里面登录/换号。 + assert.equal(flow.url, "https://baizhi.cloud/console/") + + // 国际站的 `GET /api/v1/users/login` 302 到**它自己的** `/oauth/authorize` + // (`client_id=` 为空,不经过 baizhi.cloud):注入百智云会话走一遍,只落到 + // 国际站首页,不会签发 monkeycode_ai_session。留着这个入口 = 让用户对着一个 + // 必然超时的流程等 240 秒。 + const intl = await entry.MonkeyCodeIntlAuthPlugin({ client: {} }) + assert.equal( + intl.auth.methods.some((method) => method.label.includes("百智云账号开通")), + false, + "国际站不该有百智云 SSO 入口", + ) + // 它自己的登录方式仍在:浏览器登录覆盖 GitHub / Google / 账号密码;另有纯 API 的账号密码与桌面导入。 + const intlLabels = intl.auth.methods.map((method) => method.label).join(" ") + for (const kept of ["API Key", "账号密码登录", "从本机客户端导入", "浏览器登录"]) { + assert.ok(intlLabels.includes(kept), `国际站缺 ${kept} 入口`) } }) diff --git a/tests/signin-contract.test.mjs b/tests/signin-contract.test.mjs new file mode 100644 index 0000000..d7253ae --- /dev/null +++ b/tests/signin-contract.test.mjs @@ -0,0 +1,93 @@ +// 单测不许读开发机上的真实账号缓存(可能是「探测后全不可用 ⇒ 空表」)。 +process.env.MAGPIE_MONKEYCODE_CACHE_DIR = "C:\\__magpie_test_cache__" +// tests/signin-contract.test.mjs —— oauth 登录结果必须存得下 +// +// 锁的是 2026-11-04 真机踩到的那条:**卡片说「已登录」,列表里却没有它。** +// +// magpie 的插件宿主(`host.js` 的 `save()`)只认两条出口: +// +// if ("refresh" in result) setAuth(key, { type: "oauth", refresh, access, expires, ...extra }) +// else if ("key" in result || apiKey) setAuth(key, { type: "api", key, metadata }) +// +// 我们的 success 一直只给 `access`(没有 `refresh`,也没有 `key`)—— 两条都不命中, +// 宿主**既不保存也不报错**:`saved` 照常回 ok,magpie 显示「已登录」,而账号没落盘。 +// 紧接着的 `providers?wait=1` 里当然找不到这个 account,界面就打出 +// 「{user} 已登录,但 magpie 无法列出它,请反馈此问题」。 +// +// 所以这里锁两件事: +// ① `signInSuccess()` 一定带上 `refresh`(与 access 同值)与 type +// ② **生产代码里不许再出现裸的 `type: "success"`** —— 一律走 signInSuccess, +// 否则下一次新增登录方式会原样复制这个静默失败 + +import test from "node:test" +import assert from "node:assert/strict" +import { readFile, readdir } from "node:fs/promises" +import { join, dirname } from "node:path" +import { fileURLToPath } from "node:url" + +import { signInSuccess } from "../src/credential.mjs" + +const SRC = join(dirname(fileURLToPath(import.meta.url)), "..", "src") + +test("signInSuccess 带上 refresh(等于 access),且 type 是 success", () => { + const result = signInSuccess({ + access: '{"access_token":"oma_testtoken","signing_secret":"secret"}', + accountId: "观猹员 0001", + uid: "01a0edd5-0000-4000-8000-000000000000", + }) + assert.equal(result.type, "success") + assert.equal(result.access, result.refresh) + assert.equal(result.expires, 0) + assert.equal(result.accountId, "观猹员 0001") + assert.equal(result.uid, "01a0edd5-0000-4000-8000-000000000000") +}) + +test("refresh 位必须真的在对象上(in 检测,不看值)", () => { + // magpie 用的是 `"refresh" in result`:属性存在即命中,值为 undefined 也算。 + const result = signInSuccess({ access: "sk-test" }) + assert.ok("refresh" in result) + // 空账号名 / 空 uid 不写进结果:宿主用 whoOf() 判重,空串会把两个不同账号并成一个。 + assert.equal("accountId" in result, false) + assert.equal("uid" in result, false) +}) + +test("message 只在有内容时才出现", () => { + assert.equal("message" in signInSuccess({ access: "sk-test", message: "" }), false) + assert.equal(signInSuccess({ access: "sk-test", message: "已更新" }).message, "已更新") +}) + +test("生产代码里没有裸的 type: \"success\"(signInSuccess 之外)", async () => { + const files = [] + async function walk(dir) { + for (const item of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, item.name) + if (item.isDirectory()) await walk(path) + else if (item.name.endsWith(".mjs")) files.push(path) + } + } + await walk(SRC) + assert.ok(files.length > 10, "src 下的模块没找全") + + const offenders = [] + for (const file of files) { + const text = await readFile(file, "utf8") + // credential.mjs 是 signInSuccess 的老家,它必须写这个字面量。 + const lines = text.split("\n") + lines.forEach((line, index) => { + const trimmed = line.trim() + // 注释里举例不算:account-name.mjs 的文档注释就写着这个形状。 + if (trimmed.startsWith("//") || trimmed.startsWith("*")) return + if (line.includes('type: "success"') && !file.endsWith("credential.mjs")) { + offenders.push(`${file.slice(SRC.length + 1)}:${index + 1}`) + } + }) + } + assert.deepEqual(offenders, [], "这些地方直接返回了 success,magpie 不会保存它们;请改用 signInSuccess()") +}) + +test("所有导出 entry 的插件都是工厂(signInSuccess 没破坏入口)", async () => { + const entry = await import("../index.mjs") + for (const name of ["MonkeyCodeAuthPlugin", "MonkeyCodeIntlAuthPlugin", "BaizhiAuthPlugin"]) { + assert.equal(typeof entry[name], "function") + } +}) From 01a20c982d2b60aa7987c1de2b92622b8b73cd63 Mon Sep 17 00:00:00 2001 From: MYCF711 Date: Wed, 4 Nov 2026 06:36:32 +0800 Subject: [PATCH 2/2] =?UTF-8?q?test:=20AGENTS=200.7=20=E7=9A=84=E4=B8=A4?= =?UTF-8?q?=E6=9D=A1=E5=88=A4=E6=8D=AE=E5=81=9A=E6=88=90=E4=B8=80=E6=9D=A1?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=EF=BC=88scripts/check-intl-signin.mjs?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 看两处事实:plugin-auth.json 里有没有 monkeycodeintl 的记录(含 uid)、 本机网关 /v1/models 里有没有 monkeycodeintl/… 。只读、不发写请求; 可选 --cookie 验一次平台会话。退出码 2 = 有判据不成立。 --- CHANGELOG.md | 1 + scripts/check-intl-signin.mjs | 121 ++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 scripts/check-intl-signin.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 061f2ea..96583a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - **国际站删掉「用百智云账号开通」**:实测 `GET https://monkeycode-ai.net/api/v1/users/login` 302 到它**自己的** `/oauth/authorize`(`client_id=` 是空的,不经过 `baizhi.cloud`);把百智云会话注入那个窗口再走一遍,最终只落到国际站首页,**不会**签发 `monkeycode_ai_session`。留着只会让人对着一个必然超时的入口等 240 秒 - 「从本机客户端导入」**改按域读 cookie,并恢复给国际站**(0.2.33 删它是因为当时桌面端指向国内站):桌面客户端的 `mc_base_url` 是国内站 / 国际站二选一的一个值,`monkeycode-cookies.json` 里装的是**当前那一站**的会话。现在按本站 host 过滤,读到另一站的登录态时会明确说「桌面客户端登录的是另一个站点」,而不是让上游 401 把人引去怀疑「登录过期」 - `AGENTS.md` 0.7 从「未修复」改为修复记录(保留原判据);0.6 补上国际站登录方式与「不认百智云 SSO」的实测结论 +- 新增 `node scripts/check-intl-signin.mjs`:把 AGENTS 0.7 的两条判据做成一条命令 —— 看 `plugin-auth.json` 里有没有 `monkeycodeintl` 的记录(含 uid),再看本机网关 `/v1/models` 里有没有 `monkeycodeintl/…`。只读、不发写请求;可选 `--cookie '…'` 验一次平台会话。退出码 2 = 有判据不成立 ## 0.2.35 — 2026-11-04 diff --git a/scripts/check-intl-signin.mjs b/scripts/check-intl-signin.mjs new file mode 100644 index 0000000..b69003d --- /dev/null +++ b/scripts/check-intl-signin.mjs @@ -0,0 +1,121 @@ +#!/usr/bin/env node +// AGENTS 0.7 的判据,做成一条命令。 +// +// 「国际版网页登录必须能成功,且列出模型」由两条独立的事实组成,缺一不可: +// +// ① 登录**存下来了**:`~/.config/magpie/plugin-auth.json` 里有 `monkeycodeintl` +// 的记录,且带 uid(magpie 按 uid 建 account;没有 account 的 provider +// 就是那句「已登录,但 magpie 无法列出它」) +// ② magpie 真的把它摆上了货架:本机网关 `GET /v1/models` 里有 `monkeycodeintl/…` +// +// 只读,不发写请求,不改任何配置。可选地拿一份 cookie 去验国际站的会话 +// (`--cookie 'monkeycode_ai_session=…'`),这是判据里那条 curl 的等价物。 +// +// 用法: +// node scripts/check-intl-signin.mjs +// node scripts/check-intl-signin.mjs --cookie 'monkeycode_ai_session=…' +// node scripts/check-intl-signin.mjs --gateway http://127.0.0.1:3425 +// +// 退出码:0 = 两条判据都成立;2 = 有判据不成立(打印是哪一条)。 + +import { readFileSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" + +const PROVIDER = "monkeycodeintl" +const PLATFORM = "https://monkeycode-ai.net" + +function argsOf(argv) { + const out = { cookie: undefined, gateway: "http://127.0.0.1:3425" } + for (let i = 0; i < argv.length; i += 1) { + if (argv[i] === "--cookie") out.cookie = argv[(i += 1)] + else if (argv[i] === "--gateway") out.gateway = argv[(i += 1)] ?? out.gateway + } + return out +} + +/** magpie 的账号库(只读)。缺文件 / 坏 JSON 都当「没有」。 */ +function readAuth() { + try { + return JSON.parse(readFileSync(join(homedir(), ".config", "magpie", "plugin-auth.json"), "utf8")) + } catch { + return {} + } +} + +/** 一个 provider 的所有账号 key(第一个存 provider id,其余 `id#`)。 */ +function keysOf(auth, provider) { + return Object.keys(auth ?? {}).filter((key) => key === provider || key.startsWith(`${provider}#`)) +} + +const args = argsOf(process.argv.slice(2)) +const auth = readAuth() +const keys = keysOf(auth, PROVIDER) +const record = keys.length > 0 ? auth[keys[0]] : undefined +const uid = typeof record?.uid === "string" ? record.uid : "" +const accountId = typeof record?.accountId === "string" ? record.accountId : "" + +console.log(`判据 ① 登录有没有落盘 · ${PROVIDER}`) +if (keys.length === 0) { + console.log(" ✗ plugin-auth.json 里没有这个 provider 的记录") + console.log(" ⇒ 网页登录没存下来(或者根本没登)。这正是「已登录但 magpie 无法列出它」的形态。") +} else { + console.log(` ✓ 记录在:type=${record?.type ?? "?"} accountId=${accountId || "(空)"} uid=${uid || "(空)"}`) + if (record?.type !== "oauth") { + console.log(` ! 这条是 ${record?.type} 方式存的;网页登录应当存成 oauth(带 refresh)。`) + } + if (uid.length === 0) { + console.log(" ✗ uid 是空的:magpie 按 uid 建 account,空 uid 的账号可能不被列出") + } +} + +// ② 网关里有没有它的模型。magpie 的 id 是 `/`。 +let listed = [] +try { + const response = await fetch(`${args.gateway}/v1/models`, { signal: AbortSignal.timeout(10_000) }) + const payload = await response.json() + listed = (payload?.data ?? []) + .map((model) => String(model?.id ?? "")) + .filter((id) => id.startsWith(`${PROVIDER}/`)) +} catch (error) { + console.log(`判据 ② 网关列没列出模型 · 连不上 ${args.gateway}(${error?.message ?? error})`) + process.exitCode = 2 +} + +if (process.exitCode !== 2) { + console.log(`判据 ② magpie 会不会列出它 · ${PROVIDER}`) + if (listed.length === 0) { + console.log(` ✗ ${args.gateway}/v1/models 里没有 ${PROVIDER}/… 的模型`) + } else { + console.log(` ✓ 列出 ${listed.length} 个,例如 ${listed.slice(0, 3).join("、")}`) + } +} + +// 可选:拿一份会话 cookie 验平台侧认证(判据里那条 curl 的等价物)。 +if (args.cookie !== undefined) { + console.log("附加 国际站的会话 cookie 能不能过认证") + const cookie = String(args.cookie).trim().replace(/^cookie\s*:\s*/i, "") + try { + const response = await fetch(`${PLATFORM}/api/v1/users/status`, { + headers: { cookie, accept: "application/json", referer: `${PLATFORM}/` }, + signal: AbortSignal.timeout(30_000), + }) + const text = await response.text() + if (response.ok) { + let name = "" + try { + const parsed = JSON.parse(text) + name = parsed?.data?.user?.name ?? parsed?.data?.user?.nickname ?? "" + } catch {} + console.log(` ✓ HTTP ${response.status}${name ? ` · ${name}` : ""}`) + } else { + console.log(` ✗ HTTP ${response.status} · ${text.slice(0, 120)}`) + process.exitCode = 2 + } + } catch (error) { + console.log(` ✗ 请求失败:${error?.message ?? error}`) + process.exitCode = 2 + } +} + +if (keys.length === 0 || listed.length === 0) process.exitCode = 2