magicrew-auth 0.3.28 → 0.3.30

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/AGENTS.md CHANGED
@@ -54,7 +54,7 @@ curl -s "https://usemagpie.ai/api/latest?channel=stable" | grep -o '"version":"[
54
54
  | 1 | 改好了国内站,国际站用户报同样的错 | **两站是同一个 Go 服务、逐字符同构**,改一处就要两站都验;`SITES.cn` / `SITES.intl` 只是 host 不同 | internals 一 |
55
55
  | 2 | 国际站 `/v1/chat/completions` 回 `HTTP 500` + 22 字节裸文本(不是 JSON) | 国际站网关**强制**要 `organization-code` 头,缺了就是这个 500 | internals 一 / 五 |
56
56
  | 3 | 换了 key 之后老 key 立刻失效、用户被要求重登 | **「签新 key 会吊销旧 key」已被实测推翻(2026-10-28)** —— 见下方「最容易误信的旧说法」。仍然只留一把、只走 `src/keys.mjs` 的唯一出口,理由是**本地别分叉**,不是互相吊销 | internals 十四 |
57
- | 4 | 刷新 token 之后两处 refresh_token 不一致、用户莫名掉登录 | 只允许**一个**出口持有 refresh_token;`src/hooks/refresh.mjs` 绕过出口就会分叉 | internals 二 / 十四 |
57
+ | 4 | 刷新 token 之后两处 refresh_token 不一致、用户莫名掉登录 | 只允许**一个**出口持有 refresh_token;`src/hooks/refresh.mjs` 必须走 `keys.mjs` 的 `renewViaStore()`(0.3.29 起已收敛,与 `ensureKey` 共用 `renewOnce` 锁和同一个 key-store)。若改回自调 `refreshGatewayKey` 就会分叉 | internals 二 / 十四 |
58
58
  | 5 | agent 说「上下文放不下」,而实际模型能吃更长 | 上下文窗口**必须查 magpie 的模型库**,不能填保守值(早前一刀切 256K,实际 1M) | internals 十六 |
59
59
  | 6 | 流式回复拿到了,但 `usage` 永远是空、额度不扣 | 流式请求必须补 `stream_options.include_usage`,否则上游不回 usage | internals 十二 |
60
60
  | 7 | 模型被标成免费、Free first 一直派到它 | `free` 只认厂商声明(`input_points === 0`),**不能从 `rate === 0` 反推**;价格缺失 = 不列,不猜 | internals 十六 · HOOKS 十一之二 |
@@ -72,6 +72,9 @@ curl -s "https://usemagpie.ai/api/latest?channel=stable" | grep -o '"version":"[
72
72
  | 19 | 单轮回 `400 ... tools.N.function.parameters/properties/functions/items: invalid JSON Schema`,但请求里**没有**空 `items` | 上游对 **`items` 子树内部**比根节点严:`anyOf`/`oneOf`/`allOf`/`not`/`const`/`if`+`then`+`else`/`{}` 全 400,**tuple 形式的 `items`(数组)国际站拒**,而同样形态放在顶层 parameters 里 200。命中来源是 **MCP 工具 schema**(REA MCP 7 个工具的 `functions` 数组)。判据:同一请求只把 `items` 子树里的 `anyOf` 摊平 → 200;改完**必须复查没留下空 `{}`**(删 `not`/`const` 会删空)。⚠ **上游 2026-10-29 00:25 起放宽了这层校验(raw 也 200),同一夜里还出现过波动** —— 判要不要改写请看 `_probe/items-subtree-stability.mjs` 的多轮统计,别信单次探针。改写已做(`loosenItemsSchema`,**只动 `items` 子树**、顶层 union 不动) | internals 十七 · `tests/body-rewrite.mjs` · `_probe/items-subtree-upstream.mjs` |
73
73
  | 20 | 邮箱验证码登录报「phone 字段是必须的」或「captcha 字段是必须的」 | 邮箱和手机是**两套端点**:发信 `/v4/user/send-email` vs `/send-sms`,换登录态 `/v4/user/login-email` vs `/login`;邮箱那条验证码字段叫 **`captcha`**(不是 `code`)。判据:往 `/v4/user/login` 发任何 type 都回「phone 字段是必须的」 | `src/captcha.mjs` 头 · CHANGELOG 0.3.28 |
74
74
  | 21 | 无头登录「旋转验证连试 4 张图都没过」,而这号本来就是自己的 | 先分清两件事:① **已注册的号服务端根本不出图**(`/captcha/generate` 对已注册账号直接回 `business_token`)—— 所以必须**先取图、再判识图模型**,一上来就 `if (!vision) throw` 会把这条免费通道堵死;② 识图模型 `max_tokens` 太小会把推理答案饿死(`workbuddy/deepseek-v4.1-flash` 给 400 → `content:null`,判官抓空串) | CHANGELOG 0.3.28 · `src/vision.mjs` |
75
+ | 22 | 同一账号有的请求 200、有的 403 `token无效`(`retryable=false`),换个时间又好了 | key 有两条签发路径时 store 与 magpie 各存一把,**静态挑一把必有一半踩空**。0.3.29 起 `keyCandidates()` 把 store 两槽 + `auth.access` 逐把打 chat,命中的写回 store;全被拒才交 402 让 magpie 换号。判据:`GET /v1/models` 对死 key **放行**,逐把探活必须用 `/v1/chat/completions` | CHANGELOG 0.3.29 · `tests/key-failover.mjs` |
76
+ | 23 | 插件列表里别人有「已自动更新」徽章,magicrew 没有 | magpie 只自动更新包名以 `@magpie-community/` 开头的包(`internal/plugin/updates.go` 的 `Official()` 硬编码前缀);第三方 npm / git 源永远只提示。本插件用 `src/auto-maintenance.mjs` 复刻:读 `plugin-updates.json` 的 `waiting` → 调 `magpie plugin update` → 成功后补 `updated[]`(徽章读它)。判据:`plugin-updates.json` 的 waiting 有 `package:"Magicrew", git:true`;`findMagpieExe()` 能解析到 magpie 可执行文件。⚠ 更新后要重启 magpie 才加载新代码 | CHANGELOG 0.3.30 · `tests/auto-maintenance.mjs` |
77
+ | 24 | 新装插件后没有任何签到记录,`plugin-checkin.json` 里没有 magicrew | magpie 的 `PluginCheckinOn()`:`pluginCheckins` 没有显式值时默认 **false**(只有 WorkBuddy / Trae / MiniMax / Qoder 有内置开关)。插件只能在加载时把 `settings.json` 的 `pluginCheckins[provider]` 补成 true —— **只在键不存在时补**,用户显式 false 不覆盖。判据:`settings.json` 的 `pluginCheckins` 有 `magicrew` / `magicrew-intl` 键 | CHANGELOG 0.3.30 · `src/auto-maintenance.mjs` |
75
78
 
76
79
  **坑 14 的两个附带判据**(都实测过):
77
80
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,157 @@
1
1
  # 更新记录
2
2
 
3
+ ## 0.3.30 — 2026-10-29
4
+
5
+ **默认开启自动签到 · 插件自身自动更新**
6
+
7
+ ### 一、默认开启自动签到
8
+
9
+ magpie 的插件签到开关 `pluginCheckins` 默认是**关**的:`PluginCheckinOn()`
10
+ 在没有显式值时只跟随厂商内置开关(WorkBuddy / Trae / MiniMax / Qoder),
11
+ magicrew 没有内置开关,于是新装插件不会自动签到。
12
+
13
+ 插件加载时读 `~/.config/magpie/settings.json`:**只有这个 provider 从未被
14
+ 用户设置过**,才补一个 `pluginCheckins[provider] = true`。用户显式关掉的
15
+ (false)绝不覆盖;settings.json 读不出来也不覆盖。config 钩子先补一次,
16
+ usage 钩子再兜一次(防止 magpie 启动时把内存里的旧 settings 写回)。
17
+
18
+ ### 二、插件自身自动更新
19
+
20
+ magpie 的自动更新只认 `@magpie-community/*`(源码 `internal/plugin/updates.go`
21
+ 的 `Official()` 硬编码这个前缀)—— 第三方插件无论 npm 源还是 git 源,都只会
22
+ 在插件列表里提示「有新提交」,等用户点更新。这就是为什么社区包(Qoder、
23
+ CodeArts、商汤小浣熊、OpenCode Zen Free)有「已自动更新」徽章,而本插件没有。
24
+
25
+ 插件没法改 magpie 的判断,只能在插件侧复刻同一套机制:
26
+
27
+ 1. `config` 加载 15 秒后、以及每次签到成功后,读 magpie 写好的
28
+ `~/.config/magpie/plugin-updates.json`,看 `waiting` 里有没有
29
+ `package === "Magicrew" && git === true` 的自己;
30
+ 2. 有就调本机 magpie 的 CLI:`magpie plugin update`(进程树里找 magpie,
31
+ 也可以用环境变量 `MAGPIE_EXE` 明示;找不到就记 `no-magpie-exe` 并放弃,
32
+ 不伪造徽章);
33
+ 3. CLI 成功退出(code 0)后,在 `plugin-updates.json` 的 `updated[]` 里补一条
34
+ `{package:"Magicrew", from, to, at}` —— magpie 的「已自动更新」徽章
35
+ (`plugin.LastUpdated()`)读的就是这个记录。
36
+
37
+ 节流:同一版本 6 小时内最多试一次;成功后按 `to` 版本记住不再重复。所有失败
38
+ 都只写状态文件,不影响签到/额度/登录。
39
+
40
+ ### 边界(必须知道)
41
+
42
+ - `magpie plugin update` 是 CLI 进程内的 `plugin.Update()`,会重装**所有**
43
+ 非路径插件;它不会重启正在运行的 magpie —— 更新文件后,下次启动 magpie
44
+ 才会加载新代码。插件列表的「已自动更新」徽章会立即出现(读的是文件记录)。
45
+ - 找不到 magpie 可执行文件时不会自动更新,也不会显示徽章;设 `MAGPIE_EXE`
46
+ 可指定。
47
+ - 这一步是插件对 magpie 的两个配置文件(settings.json / plugin-updates.json)
48
+ 的读写 —— magpie 没有给插件提供这两个能力的 API(GUI API 只服务于它自己的
49
+ WebView,不是 TCP 端口)。写 settings 只在「该键不存在」时发生,且先备份为
50
+ `settings.json.bak-magicrew-auto`。
51
+
52
+ ### 验证
53
+
54
+ - `tests/auto-maintenance.mjs` 20 条断言 0 失败:默认签到只补缺失、显式 false
55
+ 不覆盖、坏 JSON 不覆盖;`pickSelfUpdate` 只认自己且只认 git;`noteUpdated`
56
+ 去重/截断;`kick` 的 no-update / no-exe / throttled / done / dryRun / 成功
57
+ - 本机真实 `plugin-updates.json` 的 dryRun:命中 `Magicrew 272b812 → 050b0f4`,
58
+ 解析到 `E:\Downloads\magpie-windows-amd64.exe`;`ensureCheckinDefault` 对
59
+ `magicrew` / `magicrew-intl` 均返回 explicit:true(已是用户开着的,不改)
60
+ - `magpie plugin list` CLI 正常(只读)
61
+
62
+ ## 0.3.29 — 2026-10-29
63
+
64
+ **同一账号多把 key 逐把试 · 全部被拒改 402 · 续期收敛到 key-store**
65
+
66
+ (核心改动来自 PR #12,作者 @baijiu666;合入时补了五处,见文末「合入时补的五处」。)
67
+
68
+ ### 症状
69
+
70
+ ZCode 经 magpie 网关调 `magicrew-intl/deepseek-v4.1-flash` 整轮失败:
71
+
72
+ ```
73
+ provider_code=403 reason=auth_failed status=403 retryable=false
74
+ 界面显示:token无效
75
+ ```
76
+
77
+ `retryable=false` ⇒ magpie 不换号,错误直接抛给客户端。同一 provider 下其他账号明明还有余额。
78
+
79
+ ### 根因:key 有两条签发路径,本地各存一把、静态挑一把
80
+
81
+ - `src/hooks/refresh.mjs` —— magpie 的续期,签发后只交给 magpie,**不写** key-store
82
+ - `src/keys.mjs` 的 `ensureKey` —— 请求路径自己续期,写 key-store
83
+
84
+ 两边各存一把;api_key 只活 2 小时,两条路各自什么时候续、续到哪把,本地并不一致。
85
+ 静态挑其中一把,踩空就是 403「token无效」。
86
+
87
+ PR 提交者 2026-10-29 逐把打 `/v1/chat/completions` 实测(5 个国际站账号):
88
+
89
+ | 账号 | `auth.access` | store 当前槽 |
90
+ |---|---|---|
91
+ | magicrew-intl | ✅ | ❌ |
92
+ | magicrew-intl#z983c8 | ✅ | ❌ |
93
+ | magicrew-intl#3oqx5z | ❌ | ✅ |
94
+ | magicrew-intl#h2mcxi | ❌ | ✅ |
95
+ | magicrew-intl#8amjca | ❌ | ❌ |
96
+
97
+ **5 个里 3 个两路状态不一致,方向还各不相同** —— 静态顺序(先 store 或先 auth)都救不了。
98
+
99
+ ⚠ 不要把这条读成「服务端同一账号只认最新签发的那把」—— 2026-10-28 的受控实验
100
+ 已经推翻过这个说法:同账号相隔 67 秒签的两把同时有效;12 把逐把探活,403 **全部**
101
+ 对应 `expiresAt` 已过。真正的失效原因是**手里那把过期了**,而两条路径让本地不知道
102
+ 自己手里的是哪把。逐把试解决的是这个「不知道」。
103
+
104
+ 补一条取证经验:对这种死 key,`GET /v1/models` 是**放行**的,只有 chat 才 403。逐把探活必须用 chat。
105
+
106
+ ### 修法(三处)
107
+
108
+ 1. **`src/keys.mjs` 新增 `keyCandidates()`** —— 同一账号的所有候选 key(store 当前槽 / 另一槽 / `auth.access`),去重后按 `expiresAt` 倒序。到期越晚 = 签发越晚 = 越可能是活着的那把。配套 `adoptKey()` 把命中的那把写回 store。
109
+
110
+ 2. **`src/hooks/loader.mjs` 逐把试 + 全挂改 402** —— `isKeyRejected()` 认两种形态:`HTTP 403`,以及 **`HTTP 200` + `{"code":403,"message":"token无效"}`**(绝大多数是这种,只看状态码会漏)。全部被拒时返回 **402** 而不是 403 —— 402 在 magpie 里是 `failCredit`,会把这个账号 rest 30 分钟、去问下一个账号 / 组的下一个成员,与 0.3.26「积分不足改写 402」同一条通路。SSE 一律不碰。
111
+
112
+ 3. **`src/hooks/refresh.mjs` 收敛到 key-store** —— 改调 `renewViaStore()`,由 store 统一持有 `refresh_token` 并写回新 key,magpie 拿到的是同一把;且与 `ensureKey` 共用 `renewOnce` 锁(按 siteId+account 去重),避免两条路径同时续期、后到的那次用废 token 被误判成「登录已失效」。
113
+
114
+ ### 验证(PR 提交者实测,2026-10-29)
115
+
116
+ `makeLoader().fetch` 直接喂真实账号(不经过 magpie):
117
+
118
+ ```
119
+ ① store 那把已死 / auth 活的(z983c8) → HTTP 200 ✅
120
+ ② 两把都死(8amjca) → HTTP 402 + 可读原因(交给 magpie 换号)
121
+ ③ auth 已死 / store 活的(h2mcxi) → HTTP 200 ✅
122
+ ```
123
+
124
+ 全账号跑一遍:国际站 5/5 → 200;国内站 `magicrew` / `lky0b4` / `0ok4oa` → 200;`4rm3fp`(积分 -28)→ 402「可用积分已不足」(0.3.26 的改写,正确)。
125
+
126
+ 经网关三条链路 `magicrew/…`、`magicrew-intl/…`、`group/never-stuck` 全部 200。
127
+
128
+ ### 验证(合入后复核,2026-10-29 02:15)
129
+
130
+ 两站各取真实账号(国际 `magicrew-intl#m2orth` / 国内 `magicrew#3wbzad`),隔离 HOME、
131
+ 复制真实 key 构造四场景,用 magpie 自带的 bun 直打上游:
132
+
133
+ | 场景 | 国际站 | 国内站 |
134
+ |---|---|---|
135
+ | A 基线(store 活 + auth 活) | 200 | 200 |
136
+ | B store 死 / auth 活 | 200,且写回 store 另一槽 | 200,且写回 |
137
+ | C store 活 / auth 死 | 200(命中 store 那把,不重写) | 200 |
138
+ | D 三把全死 | 402 + 可读原因 | 402 + 可读原因 |
139
+
140
+ `tests/key-failover.mjs` 25 条断言把以上全部离线钉死(`node tests/key-failover.mjs` 退出 0)。
141
+
142
+ ### 边界
143
+
144
+ - 不跨账号借身份:只用**本账号**的 key;本账号全挂就交 402 让 magpie 决定换谁,与 0.3.23 `accountFor()` 的保守立场一致
145
+ - 只有第一把被拒时才会多发请求;命中那把只走 1 次
146
+
147
+ ### 合入时补的五处
148
+
149
+ 1. **`renewViaStore` 会试两处 refresh_token** —— 升级上来的第一次续期,store 那份可能早被旧的 `refresh.mjs` 轮换废了;只认 store 会让用户莫名被要求重新登录。现在 store → `auth.refresh` 依次试,谁活谁写回。
150
+ 2. **`adoptKey` 空 store 时保留 `auth.refresh`** —— 首次接管、续期失败但 `auth.access` 还能用时,PR 初版写回 store 会丢掉 refresh_token,2 小时后没人能续。
151
+ 3. **`isKeyRejected` 只认业务错误码** —— PR 初版用文本正则,正常回复正文里恰好出现 `unauthorized` / `token无效` 就会被当成 key 拒绝,白白重发(可能重复计费)。现在先 `JSON.parse`,只看 `code` / `message` 字段;有 `choices` 的正常响应绝不判。
152
+ 4. **被拒响应的 body 显式取消** —— 逐把试时,每条死连接的响应体不会一直挂着等 GC。
153
+ 5. **`refresh.mjs` 不再要求 `auth.refresh` 存在** —— refresh_token 只躺在 key-store 里时(`ensureKey` 续过、magpie 那份已轮换),PR 初版直接 `return undefined`,key 到期就掉登录。
154
+
3
155
  ## 0.3.28 — 2026-10-29
4
156
 
5
157
  **邮箱验证码登录(内置)· 已有账号不再过旋转图 · 识图模型被 `max_tokens` 饿死的修复**
package/README.md CHANGED
@@ -31,6 +31,8 @@
31
31
  - **积分耗尽自动换号** —— 上游用 HTTP 400 报「可用积分已不足」时,插件把它改写为语义正确的 **402 Payment Required**,magpie 会 rest 这个账号并继续问同 provider 的下一个账号 / 路由组的下一个成员
32
32
  - **登录一次管 30 天** —— 网页 JWT 顶 30 天,`mgw_` api_key 每 2 小时自续,refresh_token 每次续期时自滚
33
33
  - **新手奖励 8 项全自动** —— 国内站 8 项 500 积分全部拿得到;第 8 项(完成第一次 AI 工作)由插件替你发一条消息,花掉约 2 积分
34
+ - **默认开启自动签到** —— 安装后自动把本 provider 的插件签到设为开(`pluginCheckins` 缺省时补 true;你手动关掉的不会被改回)
35
+ - **自动更新** —— 复用 magpie 的插件更新机制:发现自己的新版本后调 `magpie plugin update`,并在成功后写「已自动更新」记录;更新完要重启一次 magpie 才会加载新代码
34
36
  - **邀请码自动滚** —— 第一次注册带作者的码,之后每次登录都记住「上一个登录账号」的码,拿到新的就覆盖旧的
35
37
 
36
38
  ---
package/docs/internals.md CHANGED
@@ -822,7 +822,7 @@ store 槽 a expires 2026-10-28T14:41:03.000Z → 200
822
822
  不管吊销机制是什么,**只留一把、只有一个出口**这个设计依然成立 ——
823
823
  理由从「否则互相吊销」改成「否则本地会分叉,说不清手里那把是不是服务端认的」。
824
824
 
825
- ### 保活链路(2026-10-27 逐版本核对后的实况)
825
+ ### 保活链路(0.3.29 起;2026-10-29 逐版本核对)
826
826
 
827
827
  ```
828
828
  每次请求前 ensureKey(c, getAuth) ← src/hooks/loader.mjs
@@ -831,13 +831,40 @@ store 槽 a expires 2026-10-28T14:41:03.000Z → 200
831
831
  ├─ 有 refresh_token → PUT 换新的 → 覆盖落盘
832
832
  └─ 没有 / 失败 → 退回 magpie 存的那把
833
833
 
834
+ keyCandidates(c, auth) ← 同一账号的 store 两槽 + auth.access
835
+ └─ 逐把打 /v1/chat/completions;被拒换下一把,命中写回 store(adoptKey)
836
+
834
837
  到期前 10 分钟 auth.refresh(auth) ← magpie 按 refreshLead 主动调度
835
- └─ src/hooks/refresh.mjs 直接调 api.refreshGatewayKey(auth.refresh)
836
- ⚠ 不经过 keys.mjs,也不读 key-store
838
+ └─ src/hooks/refresh.mjs → keys.mjs 的 renewViaStore()
839
+ 与 ensureKey 共用 renewOnce 锁和同一个 key-store;
840
+ refresh_token 先试 store 那份、再试 magpie 那份(升级兼容)
837
841
 
838
842
  每天一次 checkin → runCheckin() ← 领积分 / 补首任务 / 新手福利,**不碰 key**
839
843
  ```
840
844
 
845
+ ⚠ 0.3.29 之前 refresh.mjs 自己调 `api.refreshGatewayKey(auth.refresh)`、不写 key-store:
846
+ 两条签发路径各存一把 key,而 key 只活 2 小时,两条路各自续到哪把、哪把已经过期,
847
+ 本地并不一致。2026-10-29 实测 5 个国际站账号里 3 个「静态挑一把」踩空,表现是
848
+ 403 `token无效` 且 `retryable=false`(magpie 不换号,整轮直接抛给客户端)。
849
+ `keyCandidates()` 的逐把试与 `renewViaStore()` 的收敛就是为这两条路分别兜底。
850
+
851
+ ⚠ 别把这里读成「服务端同一账号只认最新签发的那把」—— 2026-10-28 的受控实验
852
+ 已经推翻过:同账号相隔 67 秒签的两把同时有效;12 把逐把探活,403 全部对应
853
+ `expiresAt` 已过。真正失效的是**手里那把过期了**,不是后来签的把它顶掉了。
854
+
855
+ ### 自动维护(0.3.30 起)
856
+
857
+ 插件有两个 magpie 没替它做的事,都在 `src/auto-maintenance.mjs`:
858
+
859
+ | 能力 | magpie 的现状 | 插件怎么补 |
860
+ |---|---|---|
861
+ | 默认开启签到 | `PluginCheckinOn()`:`pluginCheckins` 无显式值时默认 **false**(只有 WorkBuddy / Trae / MiniMax / Qoder 有内置开关) | 插件加载时读 `~/.config/magpie/settings.json`,只在键不存在时补 true;用户显式 false 不覆盖 |
862
+ | 插件自身自动更新 | `Official()` 只认 `@magpie-community/*`;第三方 git / npm 源只提示、不自动装 | 读 `plugin-updates.json` 的 `waiting`;有自己就调 `magpie plugin update`;成功后补 `updated[]`(「已自动更新」徽章读它) |
863
+
864
+ ⚠ GUI 的 `/api/...` 不在 gateway 端口 3425 上(那里是 `/v1`,`/api/plugins/updates`
865
+ 会回 `/v1` 的 404 文案);插件拿不到桌面 WebView 的 API,所以只能走文件 + CLI。
866
+ ⚠ `magpie plugin update` 不重启运行中的 magpie:更新文件后要下次启动才加载新代码。
867
+
841
868
  ### checkin 现在做四件事(0.3.11 起)
842
869
 
843
870
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magicrew-auth",
3
- "version": "0.3.28",
3
+ "version": "0.3.30",
4
4
  "description": "超级麦吉 / MagiCrew for magpie and OpenCode — one package, two providers: letsmagic.cn (国内) and magicrew.ai (international). Password or web-JWT sign-in, the account's own live model list, points balance, newcomer rewards, invite codes.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
@@ -0,0 +1,253 @@
1
+ // 自动维护 —— 「默认开启签到」与「插件自身自动更新」
2
+ //
3
+ // 这两个能力本质上都踩在 magpie 的边界上,先把边界说清楚:
4
+ //
5
+ // ① 签到:magpie 的 `pluginCheckins`(设置 → 插件签到)**默认是关的**
6
+ // (`PluginCheckinOn()`:没有显式值时跟随厂商内置开关,magicrew 没有内置
7
+ // 开关,于是 false)。插件没有官方 API 能声明的默认值,只能在插件加载时
8
+ // 读 `~/.config/magpie/settings.json`:**只有这个 provider 从未被用户设置过**
9
+ // 才补一个 true。用户显式关掉的,绝不覆盖。
10
+ //
11
+ // ② 自动更新:magpie 只对 `@magpie-community/*` 的包自动更新
12
+ // (`plugin.Official()` 硬编码这个前缀),git 源的第三方插件永远只提示、
13
+ // 等用户点「更新」。我们不在那个 scope 里,所以只能由插件自己复刻这套
14
+ // 机制:读 magpie 写好的 `plugin-updates.json`,确认「有我自己」之后调用
15
+ // magpie 的 CLI(`magpie plugin update`)——它会把插件目录更新到最新;
16
+ // 成功后在 `plugin-updates.json` 的 `updated[]` 里补一条,magpie 的
17
+ // 「已自动更新」徽章读的就是这个记录(`plugin.LastUpdated()`)。
18
+ //
19
+ // ⚠ 边界(都是实测/源码结论,别想当然):
20
+ // · 插件进程拿不到 GUI 的 HTTP 端口 —— `MAGPIE_ADDR` 指向 3425,那是
21
+ // **gateway**(`/api/plugins/updates` 在那里会回 `/v1` 的 404 文案),
22
+ // GUI API 走的是桌面窗口自己的 WebView,不是 TCP 端口。
23
+ // · `magpie plugin update` 是 CLI 进程内的 `plugin.Update()`,会更新
24
+ // **所有** 非路径插件并重装;它不会重启正在运行的 magpie,所以更新后
25
+ // 要等 magpie 下次启动才加载新代码。这是这个兜底能做到的极限。
26
+ // · 找不到 magpie 可执行文件时只记录 `no-magpie-exe`,不伪造徽章、不硬编码
27
+ // 本机路径。可以设环境变量 `MAGPIE_EXE` 明示。
28
+ //
29
+ // 两个动作都是**尽力而为**:失败不抛、不拖慢签到/额度/登录。
30
+
31
+ import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, copyFileSync } from "node:fs"
32
+ import { homedir } from "node:os"
33
+ import { join, dirname, basename } from "node:path"
34
+ import { execFileSync, spawn } from "node:child_process"
35
+
36
+ const PKG_NAME = "Magicrew"
37
+ const AUTO_DIR = () => join(homedir(), ".config", "magpie")
38
+ const CACHE_DIR = () => join(homedir(), ".cache", "magpie")
39
+ const THROTTLE_MS = 6 * 3600_000 // 同一次更新失败后,6 小时内不再试
40
+
41
+ function readJson(path, fallback) {
42
+ try {
43
+ return JSON.parse(readFileSync(path, "utf8"))
44
+ } catch {
45
+ return fallback
46
+ }
47
+ }
48
+
49
+ /** 原子写:先写同目录临时文件再 rename,避免把 magpie 的 JSON 写半截 */
50
+ function writeJsonAtomic(path, value, { backup = false } = {}) {
51
+ mkdirSync(dirname(path), { recursive: true })
52
+ if (backup && existsSync(path)) {
53
+ try {
54
+ writeFileSync(path + ".bak-magicrew-auto", readFileSync(path))
55
+ } catch {}
56
+ }
57
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`
58
+ writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n")
59
+ renameSync(tmp, path)
60
+ }
61
+
62
+ /** 这个 provider 的签到是否从未被用户设置过;是就补 true(并返回 changed) */
63
+ export function ensureCheckinDefault(provider, { dir = AUTO_DIR() } = {}) {
64
+ const file = join(dir, "settings.json")
65
+ let s
66
+ if (existsSync(file)) {
67
+ s = readJson(file, null)
68
+ // 文件在但读不出来:绝不覆盖用户的设置
69
+ if (!s || typeof s !== "object" || Array.isArray(s)) return { changed: false, reason: "settings-unreadable" }
70
+ } else {
71
+ s = {}
72
+ }
73
+ const table = s.pluginCheckins && typeof s.pluginCheckins === "object" && !Array.isArray(s.pluginCheckins)
74
+ ? s.pluginCheckins
75
+ : {}
76
+ if (Object.hasOwn(table, provider)) return { changed: false, reason: "explicit", on: table[provider] === true }
77
+ s.pluginCheckins = { ...table, [provider]: true }
78
+ writeJsonAtomic(file, s, { backup: true })
79
+ return { changed: true, on: true }
80
+ }
81
+
82
+ /** magpie 的 waiting 里有没有「我自己」的新版本(git 源只有 commit,没有 npm version) */
83
+ export function pickSelfUpdate(waiting, { pkg = PKG_NAME } = {}) {
84
+ if (!Array.isArray(waiting)) return null
85
+ return waiting.find((w) => w && w.package === pkg && w.git === true) || null
86
+ }
87
+
88
+ /** 补一条「自动更新记录」—— magpie 的「已自动更新」徽章读 plugin-updates.json 的 updated[] */
89
+ export function noteUpdated(pkg, from, to, { dir = AUTO_DIR(), now = new Date() } = {}) {
90
+ const file = join(dir, "plugin-updates.json")
91
+ const u = readJson(file, null)
92
+ if (!u || typeof u !== "object" || Array.isArray(u)) return false
93
+ u.updated = Array.isArray(u.updated) ? u.updated : []
94
+ const last = u.updated[u.updated.length - 1]
95
+ // 同一次更新重复写会让徽章的计时一直刷新;24 小时内同 from/to 只记一次
96
+ if (last && last.package === pkg && last.from === from && last.to === to) {
97
+ const at = Date.parse(last.at)
98
+ if (Number.isFinite(at) && now.getTime() - at < 24 * 3600_000) return true
99
+ }
100
+ u.updated.push({ package: pkg, from, to, at: now.toISOString() })
101
+ if (u.updated.length > 20) u.updated = u.updated.slice(-20)
102
+ writeJsonAtomic(file, u, { backup: true })
103
+ return true
104
+ }
105
+
106
+ /** 自动更新的节流状态(每个站点一份) */
107
+ export function readAutoState(site, { cacheDir = CACHE_DIR() } = {}) {
108
+ return readJson(join(cacheDir, `magicrew-auto-${site}.json`), {}) || {}
109
+ }
110
+
111
+ function writeAutoState(site, state, { cacheDir = CACHE_DIR() } = {}) {
112
+ try {
113
+ writeJsonAtomic(join(cacheDir, `magicrew-auto-${site}.json`), state)
114
+ } catch {}
115
+ }
116
+
117
+ /**
118
+ * 找本机 magpie 可执行文件。顺序:
119
+ * 1. 环境变量 MAGPIE_EXE(最可靠,给测试和自定义安装用)
120
+ * 2. 祖父进程:插件跑在 magpie-bun.exe 里,它的父进程就是 magpie 主程序
121
+ * 3. PATH 里的 magpie / magpie.exe / magpie-windows-amd64.exe
122
+ * 找不到返回 ""
123
+ */
124
+ export function findMagpieExe({ env = process.env, ppid = process.ppid, platform = process.platform } = {}) {
125
+ const resolve = (p) => {
126
+ if (!p || !existsSync(p)) return ""
127
+ // magpie 的 `magpie.cmd` 只是同目录 exe 的包装(Settings 的 Command line
128
+ // 会生成它);spawn 一个 .cmd 在 Windows 上还要 shell,直接解析到 exe。
129
+ if (/\.(cmd|bat)$/i.test(p)) {
130
+ for (const n of ["magpie-windows-amd64.exe", "magpie.exe"]) {
131
+ const q = join(dirname(p), n)
132
+ if (existsSync(q)) return q
133
+ }
134
+ }
135
+ return p
136
+ }
137
+ if (env.MAGPIE_EXE) {
138
+ const p = resolve(env.MAGPIE_EXE)
139
+ if (p) return p
140
+ }
141
+ const exists = (p) => !!resolve(p)
142
+ const tryExec = (cmd, args, timeout = 2500) => {
143
+ try {
144
+ return execFileSync(cmd, args, {
145
+ encoding: "utf8",
146
+ timeout,
147
+ windowsHide: true,
148
+ stdio: ["ignore", "pipe", "ignore"],
149
+ }).trim()
150
+ } catch {
151
+ return ""
152
+ }
153
+ }
154
+ if (platform === "win32") {
155
+ const ps = [
156
+ "$p = Get-CimInstance Win32_Process -Filter \"ProcessId=" + Number(ppid) + "\"",
157
+ "if ($p) { $q = Get-CimInstance Win32_Process -Filter \"ProcessId=$($p.ParentProcessId)\"; if ($q) { $q.ExecutablePath } }",
158
+ ].join("; ")
159
+ const out = tryExec("powershell.exe", ["-NoProfile", "-Command", ps])
160
+ // 只认名字里带 magpie 的进程 —— 测试/宿主不在 magpie 下时,
161
+ // 父进程可能是 bash / node,不能把它们的路径当 magpie
162
+ if (exists(out) && /magpie/i.test(basename(out))) return resolve(out)
163
+ } else {
164
+ const out = tryExec("ps", ["-p", String(ppid), "-o", "ppid="])
165
+ const grand = out.split(/\s+/)[0]
166
+ if (grand) {
167
+ const path = tryExec("ps", ["-p", grand, "-o", "comm="])
168
+ if (exists(path) && /magpie/i.test(basename(path))) return resolve(path)
169
+ }
170
+ }
171
+ for (const name of ["magpie-windows-amd64.exe", "magpie.exe", "magpie"]) {
172
+ const lines = tryExec(platform === "win32" ? "where.exe" : "which", [name], 1500).split(/\r?\n/)
173
+ for (const line of lines) {
174
+ const p = line.trim()
175
+ if (exists(p) && /magpie/i.test(basename(p))) return resolve(p)
176
+ }
177
+ }
178
+ return ""
179
+ }
180
+
181
+ /**
182
+ * 检查并(尽力)自动更新。
183
+ * @param {{id:string}} c 站点上下文(用 c.id 存节流状态)
184
+ * @param {{dir?:string, cacheDir?:string, pkg?:string, exe?:string, dryRun?:boolean, spawnFn?:Function}} opts
185
+ * @returns {Promise<object>} 结果只用于日志/测试,不抛
186
+ */
187
+ export async function kick(c, opts = {}) {
188
+ const dir = opts.dir || AUTO_DIR()
189
+ const cacheDir = opts.cacheDir || CACHE_DIR()
190
+ const pkg = opts.pkg || PKG_NAME
191
+ try {
192
+ const updates = readJson(join(dir, "plugin-updates.json"), null)
193
+ const waiting = pickSelfUpdate(updates?.waiting, { pkg })
194
+ if (!waiting) return { skipped: "no-update" }
195
+
196
+ const state = readAutoState(c.id, { cacheDir })
197
+ const now = Date.now()
198
+ if (state.done && state.done.to === waiting.latest) return { skipped: "done" }
199
+ if (state.lastAttempt && state.latest === waiting.latest && now - state.lastAttempt < THROTTLE_MS) {
200
+ return { skipped: "throttled" }
201
+ }
202
+
203
+ const exe = opts.exe !== undefined ? opts.exe : findMagpieExe(opts)
204
+ if (!exe) {
205
+ writeAutoState(c.id, { ...state, lastAttempt: now, latest: waiting.latest, reason: "no-magpie-exe" }, { cacheDir })
206
+ return { skipped: "no-magpie-exe", waiting }
207
+ }
208
+ if (opts.dryRun) return { wouldRun: exe, waiting }
209
+
210
+ let child
211
+ try {
212
+ child = (opts.spawnFn || spawn)(exe, ["plugin", "update"], {
213
+ detached: true,
214
+ stdio: "ignore",
215
+ windowsHide: true,
216
+ env: process.env,
217
+ })
218
+ } catch (e) {
219
+ writeAutoState(c.id, { ...state, lastAttempt: now, latest: waiting.latest, reason: String(e?.message ?? e) }, { cacheDir })
220
+ return { skipped: "spawn-failed", error: String(e?.message ?? e) }
221
+ }
222
+ child.on?.("exit", (code) => {
223
+ if (code === 0) {
224
+ noteUpdated(pkg, waiting.version, waiting.latest, { dir })
225
+ writeAutoState(c.id, {
226
+ ...state,
227
+ lastAttempt: now,
228
+ latest: waiting.latest,
229
+ done: { from: waiting.version, to: waiting.latest, at: new Date().toISOString() },
230
+ }, { cacheDir })
231
+ } else {
232
+ writeAutoState(c.id, { ...state, lastAttempt: now, latest: waiting.latest, reason: `exit-${code}` }, { cacheDir })
233
+ }
234
+ })
235
+ child.unref?.()
236
+ return { started: true, waiting }
237
+ } catch (e) {
238
+ return { skipped: "error", error: String(e?.message ?? e) }
239
+ }
240
+ }
241
+
242
+ /** 插件加载时的初始化:补默认签到 + 延迟检查更新(不阻塞 config) */
243
+ export function initAutoMaintenance(c) {
244
+ try {
245
+ ensureCheckinDefault(c.PROVIDER)
246
+ } catch {}
247
+ try {
248
+ const t = setTimeout(() => {
249
+ kick(c).catch(() => {})
250
+ }, 15_000)
251
+ t.unref?.()
252
+ } catch {}
253
+ }
@@ -8,6 +8,7 @@
8
8
  // 签到逻辑在独立模块里**按需加载**:它挂了,登录/模型/额度照常工作。
9
9
 
10
10
  import { rollInvite } from "../store.mjs"
11
+ import { kick } from "../auto-maintenance.mjs"
11
12
 
12
13
  export function makeCheckin(c) {
13
14
  return async (getAuth) => {
@@ -20,7 +21,12 @@ export function makeCheckin(c) {
20
21
  // rollInvite 内部自带超时和 try/catch,失败不影响签到。
21
22
  await rollInvite(c.id, auth.jwt, auth.org, c.host)
22
23
  const { runCheckin } = await import("../tasks.mjs")
23
- return await runCheckin({ ...auth, host: c.host })
24
+ const result = await runCheckin({ ...auth, host: c.host })
25
+ // 签到成功后再给自动更新一次机会(config 启动时已试过一次;
26
+ // 这里覆盖「magpie 长期不重启」的情况)。kick 自己有 6 小时节流,
27
+ // 且不阻塞签到结果。
28
+ kick(c).catch(() => {})
29
+ return result
24
30
  } catch (e) {
25
31
  return { outcome: "failed", message: String(e?.message ?? e) }
26
32
  }
@@ -5,13 +5,19 @@
5
5
  // ⚠ 三条不许改:
6
6
  // · 用 ??= 不用 =,否则覆盖用户在 plugins.json 里手配的值
7
7
  // · models 必须有内容 —— 未登录也要有,空表会让卡片显示 "0 models"
8
- // · 不要在这里发网络请求:它是配置,不是钩子
8
+ // · 不要在这里发**业务**网络请求:它是配置,不是钩子(本地默认设置与
9
+ // 延迟的自动维护见 src/auto-maintenance.mjs,失败不影响 config)
9
10
 
10
11
  import { SDK } from "../context.mjs"
11
12
  import { readSnapshot } from "../store.mjs"
13
+ import { initAutoMaintenance } from "../auto-maintenance.mjs"
12
14
 
13
15
  export function makeConfig(c) {
14
16
  return async (cfg) => {
17
+ // 默认开启本 provider 的签到;并在后台检查插件自身更新。
18
+ // 两者都尽力而为,失败不影响 config(见 src/auto-maintenance.mjs 的边界说明)。
19
+ initAutoMaintenance(c)
20
+
15
21
  cfg.provider ??= {}
16
22
  cfg.provider[c.PROVIDER] ??= {
17
23
  name: c.SHORT,
@@ -14,7 +14,45 @@
14
14
  // ⚠ 不要在这里读流、不要手拼 x-api-key ——
15
15
  // 流由 magpie 处理,头由 SDK 包自动加。见 README「最小交付面」。
16
16
 
17
- import { ensureKey, accountOf } from "../keys.mjs"
17
+ import { ensureKey, accountOf, keyCandidates, adoptKey } from "../keys.mjs"
18
+
19
+ /**
20
+ * 上游「这个账号不行」的两种形态(2026-10-29 实测):
21
+ *
22
+ * · HTTP 403
23
+ * · **HTTP 200 + `{"code":403,"message":"token无效"}`** ← 绝大多数是这种
24
+ *
25
+ * 所以光看状态码会漏。只解析 JSON(SSE 一律不碰,读流会破坏它)。
26
+ */
27
+ async function isKeyRejected(res) {
28
+ if (!res) return false
29
+ if (res.status === 401 || res.status === 403) return true
30
+ const ct = res.headers.get("content-type") || ""
31
+ if (res.status !== 200 || !ct.includes("application/json")) return false
32
+ let text = ""
33
+ try {
34
+ text = await res.clone().text()
35
+ } catch {
36
+ return false
37
+ }
38
+ if (text.length > 8192) return false
39
+ let body
40
+ try {
41
+ body = JSON.parse(text)
42
+ } catch {
43
+ return false
44
+ }
45
+ // 只认**业务错误码**。正常 chat 完成的 JSON 是 {choices:[…]},没有顶层 code;
46
+ // 用文本正则会把模型回复里恰好出现的 "unauthorized" 当成 key 拒绝 ——
47
+ // 那会白白重发请求(可能重复计费),甚至最后把正常响应丢掉换成 402。
48
+ const code = Number(body?.code ?? body?.error?.code)
49
+ if (code === 401 || code === 403) return true
50
+ const msg = String(body?.message ?? body?.error?.message ?? "")
51
+ if (!msg) return false
52
+ if (/token无效|token 无效|invalid token/i.test(msg)) return true
53
+ // 兜底:只有 message、没有 code 的少量形态;有 choices 的正常响应绝不判
54
+ return !Array.isArray(body?.choices) && /unauthorized/i.test(msg)
55
+ }
18
56
 
19
57
  /**
20
58
  * 把 JSON Schema 里的 `additionalProperties` **非布尔值**原地换成 `true`。
@@ -447,6 +485,8 @@ export async function rewriteCreditRefusal(res) {
447
485
  }
448
486
 
449
487
  export function makeLoader(c) {
488
+ /** 已经写回 store 的 key —— 避免每个请求都去读一次 store */
489
+ const adopted = new Set()
450
490
  return async (getAuth) => {
451
491
  const auth = await getAuth()
452
492
  if (!auth) return {}
@@ -489,12 +529,60 @@ export function makeLoader(c) {
489
529
  // 四处请求体改写,全在 rewriteRequestBody 里(那里有实测出处)
490
530
  body = rewriteRequestBody(body)
491
531
 
492
- return rewriteCreditRefusal(await fetch(url, {
493
- method: init?.method ?? "POST",
494
- headers,
495
- body,
496
- signal: init?.signal ?? req?.signal ?? AbortSignal.timeout(300_000),
497
- }))
532
+ const signal = init?.signal ?? req?.signal ?? AbortSignal.timeout(300_000)
533
+ const sendWith = (apiKey) =>
534
+ fetch(url, {
535
+ method: init?.method ?? "POST",
536
+ headers: { ...headers, authorization: `Bearer ${apiKey}` },
537
+ body,
538
+ signal,
539
+ })
540
+
541
+ // 同一账号可能握着两把 key(magpie 的 refresh 一条、插件 store 一条),
542
+ // 哪把还有效本地无法预判 —— 静态挑必然有一半踩空,只能逐把试。
543
+ // 详见 src/keys.mjs 的 keyCandidates 注释(2026-10-29 五个账号的实测表)。
544
+ // 传 fresh 而不是 getAuth:候选与下面 adoptKey 必须落在同一个账号上,
545
+ // 不能让两次 getAuth() 返回不同结果。
546
+ const cands = await keyCandidates(c, fresh)
547
+ if (!cands.length) throw new Error(`${c.SHORT} 未登录(拿不到可用的 key)`)
548
+
549
+ for (const cand of cands) {
550
+ const res = await sendWith(cand.apiKey)
551
+ if (!(await isKeyRejected(res))) {
552
+ // 这把能用 —— 写回 store,让 ensureKey / 额度那几路也用同一把
553
+ if (!adopted.has(cand.apiKey)) {
554
+ adopted.add(cand.apiKey)
555
+ adoptKey(c, fresh, cand.apiKey, cand.expiresAt)
556
+ }
557
+ return rewriteCreditRefusal(res)
558
+ }
559
+ // 这把被拒:响应不再使用,显式取消 body —— 否则逐把试时
560
+ // 每条死连接的响应体都挂着,等 GC 才释放。
561
+ try {
562
+ await res.body?.cancel()
563
+ } catch {}
564
+ }
565
+
566
+ // 这个账号的 key 全被拒。上游给的是 403「token无效」,magpie 会判成
567
+ // 「不可重试」直接抛给客户端(ZCode 显示 auth_failed / token无效)。
568
+ // 改写成 402:magpie 的 failure(402) 是 failCredit → 把这个账号 rest 掉、
569
+ // 去问下一个账号 / 组的下一个成员。与 0.3.26 的「积分不足」同一条通路。
570
+ return new Response(
571
+ JSON.stringify({
572
+ error: {
573
+ message:
574
+ `${c.SHORT}:本账号的 ${cands.length} 把密钥均已被上游拒绝,已交给 magpie 换下一个账号。` +
575
+ `(常见于登录 / 续期分叉;请重新登录这个账号)`,
576
+ type: "account_unavailable",
577
+ code: 402,
578
+ },
579
+ }),
580
+ {
581
+ status: 402,
582
+ statusText: "Payment Required",
583
+ headers: { "content-type": "application/json" },
584
+ },
585
+ )
498
586
  },
499
587
  }
500
588
  }
@@ -3,24 +3,31 @@
3
3
  // magpie 在 api_key 到期前(refreshLead)调一次,**返回的字段合并进旧账号**,
4
4
  // 不是整体替换(client.auth.set 才是替换,两者不同)。
5
5
  //
6
- // ⚠ 一次性 refresh_token 必须串行 —— magpie 已按账号串行化,插件里别再并发;
6
+ // ⚠ 一次性 refresh_token 必须串行 —— magpie 已按账号串行化;插件里
7
+ // renewViaStore 与 ensureKey 共用 renewOnce 的同一把锁,也不会并发。
7
8
  // 服务端每次换完会轮换,必须用返回的新值。
8
9
 
9
- import { refreshGatewayKey } from "../api.mjs"
10
+ import { renewViaStore } from "../keys.mjs"
10
11
 
11
12
  export function makeRefresh(c) {
12
13
  return async (auth) => {
13
- if (!auth?.refresh) return undefined
14
+ // 不要求 auth.refresh 存在:refresh_token 可能只躺在 key-store 里
15
+ //(0.3.29 之前 ensureKey 续过、magpie 那份已经轮换废了)。由 renewViaStore
16
+ // 两处都试,这里先别把它挡掉。
17
+ if (!auth) return undefined
14
18
  try {
15
- const k = await refreshGatewayKey(auth.refresh, c.host)
19
+ // ⚠ 必须走 store —— 由它统一持有 refresh_token 并把新 key 写回。
20
+ // 自己再调一次 refreshGatewayKey 会让两条路径各存一把,
21
+ // 哪把还有效本地无法预判,请求侧就会 403「token无效」(2026-10-29 实测)。
22
+ const k = await renewViaStore(c, auth)
16
23
  return {
17
24
  access: k.apiKey,
18
- // 服务端每次 PUT 都轮换 refresh_token,必须用新的
25
+ // 服务端每次换完都轮换 refresh_token,必须用返回的新值
19
26
  refresh: k.refreshToken ?? auth.refresh,
20
- expires: Date.now() + 2 * 3600_000,
27
+ expires: k.expiresAt || Date.now() + 2 * 3600_000,
21
28
  }
22
29
  } catch (e) {
23
- if (/invalid|expired|401|403/i.test(String(e?.message))) {
30
+ if (/invalid|expired|401|403|token/i.test(String(e?.message))) {
24
31
  throw Object.assign(new Error("登录已失效,请重新登录"), { signIn: "expired" })
25
32
  }
26
33
  throw e
@@ -25,13 +25,23 @@
25
25
  // 宿主支持余额之前的临时协议,见 CHANGELOG 0.3.20。
26
26
 
27
27
  import { fetchPoints, SITES } from "../api.mjs"
28
+ import { ensureCheckinDefault } from "../auto-maintenance.mjs"
28
29
 
29
30
  // 每日登录积分:国内 +200/天、国际 +50/天(实测,见 docs/internals.md)。
30
31
  // 今日批次的分母,只用于展示「今日还剩 / 今日额度」。
31
32
  const DAILY_LOGIN_GIFT = { cn: 200, intl: 50 }
32
33
 
33
34
  export function makeUsage(c) {
35
+ let ensuredCheckin = false
34
36
  return async (getAuth) => {
37
+ // config 已经补过默认签到;这里再兜一次 —— magpie 启动时可能先
38
+ // Save 了内存里的旧 settings,把 config 那次覆盖掉。只补「从未设置过」的。
39
+ if (!ensuredCheckin) {
40
+ ensuredCheckin = true
41
+ try {
42
+ ensureCheckinDefault(c.PROVIDER)
43
+ } catch {}
44
+ }
35
45
  const auth = await getAuth()
36
46
  if (!auth) return { error: "未登录", windows: [] }
37
47
  try {
package/src/keys.mjs CHANGED
@@ -2,21 +2,16 @@
2
2
  //
3
3
  // 登录成功 → issueAndStore() 签一把放进「另一个槽」,切过去
4
4
  // loader → ensureKey() 快过期就把新的签到另一个槽,切过去;旧的留着当回退
5
- // refresh → ⚠ **不经过本模块**。src/hooks/refresh.mjs 自己调
6
- // api.refreshGatewayKey(auth.refresh),magpie 拿到的不是这一把。
5
+ // refresh → renewViaStore() 续期、写回 store;magpie 拿到的就是 store 里这一把
7
6
  //
8
7
  // **每个账号一套槽,互不干扰** —— 同站点登两个账号时,第二个人登录不会碰第一个人的 key。
9
8
  // 账号标识用 `org`(组织码),拿不到才退回 accountId。
10
9
  //
11
- // ⚠ 上面那条 refresh 是「唯一出口」这个设计里**唯一没落地的口子**(2026-10-27 核对):
12
- // refresh_token 是一次性轮换的,两条路径各用各的那份会分叉 ——
13
- // ensureKey 轮换后 store 里是新值,refresh.mjs 手里 magpie 的还是旧值,
14
- // 再调就是 401 → magpie 判定「登录已失效」→ 让用户重新登录。
15
- //
16
- // 目前不会触发,因为 **store 从来没被写过**(本机 magicrew-key-*.json 一个都没有,
17
- // 说明 ensureKey 从没执行过 —— 没发过模型请求)。但这意味着:
18
- // **若将来要给 checkin 补 ensureKey,必须先把这个钩子也收敛进来**,
19
- // 否则就是亲手把「一条路」掰成两条,用「多写一句文档」换「用户莫名掉登录」。
10
+ // ⚠ refresh 这条口子曾经是「唯一出口」设计里唯一没落地的地方(2026-10-27 核对):
11
+ // refresh.mjs 自己调 api.refreshGatewayKey(auth.refresh),拿到的 key 只交给 magpie,
12
+ // 与 store 各存一份 —— refresh_token 一次性轮换,两条路径必然分叉。
13
+ // 0.3.29(PR #12)起已收敛到 renewViaStore():与 ensureKey 共用 renewOnce 锁和
14
+ // 同一个 key-store,refresh_token 也只从这里轮换出去。
20
15
 
21
16
  import { issueGatewayKey, refreshGatewayKey } from "./api.mjs"
22
17
  import {
@@ -84,6 +79,150 @@ export function accountFor(c, auth) {
84
79
  return direct
85
80
  }
86
81
 
82
+ /**
83
+ * 这个账号**所有**可能能用的 key,按到期时间从晚到早排。
84
+ *
85
+ * 为什么需要「多把」:key 有两条签发路径 —— magpie 的 `auth.refresh` 一条
86
+ * (src/hooks/refresh.mjs)、本模块的 `ensureKey` 一条,两边各写各的。
87
+ * api_key 只活 2 小时,两条路各自什么时候续、续到哪把,本地并不一致 ——
88
+ * 静态「只挑一把」必然有一半概率踩空。
89
+ *
90
+ * 2026-10-29 实测(5 个国际站账号,逐把打 /v1/chat/completions):
91
+ *
92
+ * magicrew-intl auth.access ✅ store 槽 b ❌
93
+ * magicrew-intl#z983c8 auth.access ✅ store 槽 b ❌
94
+ * magicrew-intl#3oqx5z auth.access ❌ store 槽 a ✅
95
+ * magicrew-intl#h2mcxi auth.access ❌ store 槽 b ✅
96
+ * magicrew-intl#8amjca auth.access ❌ store 槽 a ❌ ← 两把都死
97
+ *
98
+ * **5 个里 3 个两路状态不一致,方向还各不相同** —— 先 store 或先 auth 都救不了。
99
+ *
100
+ * ⚠ 不要读成「服务端同一账号只认最新签发的那把」。2026-10-28 的受控实验已经
101
+ * 推翻过这个说法:同账号相隔 67 秒签的两把同时有效;12 把逐把探活,403 全部
102
+ * 对应 `expiresAt` 已过。真正的原因是**手里那把过期了**,而两条路径让本地
103
+ * 不知道自己手里的是哪把。逐把试解决的就是这个「不知道」。
104
+ *
105
+ * 到期越晚 = 签发越晚 = 越可能是活着的那把,所以按 expiresAt 倒序试。
106
+ *
107
+ * @param {object} c 站点上下文
108
+ * @param {Function|object} getAuth magpie 的取账号函数,或直接给 auth 对象
109
+ */
110
+ export async function keyCandidates(c, getAuth) {
111
+ let auth = null
112
+ try {
113
+ auth = typeof getAuth === "function" ? await getAuth() : getAuth
114
+ } catch {}
115
+ const account = accountFor(c, auth)
116
+ const st = readStore(c.id, account)
117
+
118
+ const out = []
119
+ const byKey = new Map()
120
+ const add = (k) => {
121
+ if (!k?.apiKey) return
122
+ const rec = { apiKey: k.apiKey, expiresAt: Number(k.expiresAt) || 0 }
123
+ const prev = byKey.get(rec.apiKey)
124
+ if (prev) {
125
+ // 同一把 key 从两路拿到时,保留更晚的到期时间 —— 否则可能被排到死 key 后面
126
+ if (rec.expiresAt > prev.expiresAt) prev.expiresAt = rec.expiresAt
127
+ return
128
+ }
129
+ byKey.set(rec.apiKey, rec)
130
+ out.push(rec)
131
+ }
132
+ add(currentKey(st))
133
+ add(st?.keys?.[otherSlot(st.current)])
134
+ if (auth?.access) add({ apiKey: auth.access, expiresAt: Number(auth.expires) || 0 })
135
+
136
+ return out.sort((a, b) => Number(b.expiresAt || 0) - Number(a.expiresAt || 0))
137
+ }
138
+
139
+ /** 把实测能用的那把写回 store —— 下次直接命中,不再逐把试错 */
140
+ export function adoptKey(c, auth, apiKey, expiresAt) {
141
+ try {
142
+ const account = accountFor(c, auth)
143
+ const st = readStore(c.id, account)
144
+ if (currentKey(st)?.apiKey === apiKey) return
145
+ const slot = otherSlot(st.current)
146
+ writeStore(c.id, account, {
147
+ current: slot,
148
+ // 空 store 首次接管时 refresh_token 只在 magpie 手里 —— 这里必须接住,
149
+ // 否则 2 小时后没人能续,用户被要求重新登录。
150
+ refreshToken: st.refreshToken || auth?.refresh || "",
151
+ keys: { ...st.keys, [slot]: { apiKey, expiresAt: Number(expiresAt) || 0 } },
152
+ at: st.at,
153
+ })
154
+ } catch {}
155
+ }
156
+
157
+ /**
158
+ * 续期并**写回 store** —— 给 src/hooks/refresh.mjs 用。
159
+ *
160
+ * 收敛成一条路径的原因:refresh.mjs 原来自己调 `refreshGatewayKey`,
161
+ * 拿到的 key 只交给 magpie,插件 store 里还是旧的。两条路径各存一份,
162
+ * 哪把是新签的、哪把已经过期,本地说不清 —— 2026-10-29 实测 5 个账号里
163
+ * 3 个两路状态不一致,就是这么来的。
164
+ *
165
+ * ⚠ refresh_token 是一次性的:谁续谁负责写回,不能让两边各存各的。
166
+ */
167
+ export async function renewViaStore(c, auth) {
168
+ const account = accountFor(c, auth)
169
+
170
+ // 与 ensureKey 共用同一把锁(renewOnce 按 siteId+account 去重)——
171
+ // refresh_token 一次性,两条路径同时续期会让后到的那次用废 token → 误判掉登录。
172
+ let err = null
173
+ const rec = await renewOnce(c.id, account, async () => {
174
+ try {
175
+ const st = readStore(c.id, account)
176
+
177
+ // refresh_token 也可能有两份:store 里的(ensureKey 续过)与 magpie 手里的
178
+ // (0.3.29 之前的 refresh.mjs 续过)。两把都是一次性,只有一把还活着 ——
179
+ // 升级上来的第一次尤其:store 那份可能早被轮换废了,auth.refresh 才是活的。
180
+ const tokens = []
181
+ const seen = new Set()
182
+ for (const t of [st.refreshToken, auth?.refresh]) {
183
+ if (!t || seen.has(t)) continue
184
+ seen.add(t)
185
+ tokens.push(t)
186
+ }
187
+ if (!tokens.length) throw new Error("没有 refresh_token")
188
+
189
+ let lastErr = null
190
+ for (const refreshToken of tokens) {
191
+ try {
192
+ const k = await refreshGatewayKey(refreshToken, c.host)
193
+ const next = { apiKey: k.apiKey, expiresAt: toEpochMs(k.apiKeyExpiresAt) }
194
+ const slot = otherSlot(st.current)
195
+ const nextRefresh = k.refreshToken || refreshToken
196
+ writeStore(c.id, account, {
197
+ current: slot,
198
+ refreshToken: nextRefresh,
199
+ keys: { ...st.keys, [slot]: next },
200
+ at: st.at,
201
+ })
202
+ return { ...next, refreshToken: nextRefresh }
203
+ } catch (e) {
204
+ lastErr = e
205
+ }
206
+ }
207
+ throw lastErr ?? new Error("续期失败")
208
+ } catch (e) {
209
+ err = e
210
+ throw e
211
+ }
212
+ })
213
+
214
+ if (!rec) throw err ?? new Error("续期失败")
215
+ // 并发时可能复用 ensureKey 的 rec(它只返回 apiKey/expiresAt)——
216
+ // 从 store 补上最新 refresh_token,免得 magpie 那份落后一轮。
217
+ if (!rec.refreshToken) {
218
+ try {
219
+ const st2 = readStore(c.id, account)
220
+ if (st2.refreshToken) rec.refreshToken = st2.refreshToken
221
+ } catch {}
222
+ }
223
+ return rec
224
+ }
225
+
87
226
  /**
88
227
  * 拿到这个账号一把能用的 key。**永不抛** —— 续不上就交出手里那把(哪怕不新鲜),
89
228
  * 让请求自己去撞上游的错误,比在这里抛更能说明问题。