magicrew-auth 0.3.5 → 0.3.7

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/docs/internals.md CHANGED
@@ -1,661 +1,716 @@
1
- # 实现细节与协议取证
2
-
3
- README 里所有「为什么」都在这。取证日期统一是 **2026-10-27**,除非另注。
4
-
5
- ---
6
-
7
- ## 一、两站是同一份东西
8
-
9
- | | 国内 · 超级麦吉 | 国际 · MagiCrew |
10
- |---|---|---|
11
- | 网页 | `www.letsmagic.cn` | `www.magicrew.ai` |
12
- | 业务 API + 模型网关 | `i-magic-service.letsmagic.cn` | `magic-service.magicrew.ai` |
13
- | `MAGIC_APP_ENV` | `saas-prod` | `international-prod` |
14
- | `MAGIC_APP_SHA` | `fb90a86cd576dd10f25efdf914485a3bfcc0a13d` | **同一个值** |
15
- | `MAGIC_APP_VERSION` | `5.0.157` | `5.0.157` |
16
- | 套餐支付 | ALIPAY | STRIPE |
17
-
18
- `MAGIC_APP_SHA` 相同 ⇒ 前端是同一份构建,两站差别只在服务端注入的 `MAGIC_*` 环境变量。
19
- 后端是同一个 Go 服务,逐字符同构:
20
-
21
- ```
22
- GET / → hello, magic-service!
23
- GET /v1/models → {"code":403,"message":"token无效",...}
24
- GET /api/v1/flows/models → {"code":2179,"message":"auth_token 不存在",...}
25
- ```
26
-
27
- ### 站点地址是怎么找到的
28
-
29
- 客户端是个纯 webview 壳,`app.asar` 里只有区域枚举和 `WebUrl`,**没有 API 地址**:
30
-
31
- ```js
32
- const Tp = {
33
- ChinaRegion: { WebUrl: "https://www.letsmagic.cn", defaultLocale: zhCN },
34
- internationalRegion: { WebUrl: "https://www.magicrew.ai", defaultLocale: enUS },
35
- }
36
- ```
37
-
38
- 真正的地址在**运行时配置注入页**里 —— 任意非营销路由(比如 `/workspace`)返回的 HTML 内嵌了一份 `window` 配置:
39
-
40
- ```bash
41
- curl -s https://www.magicrew.ai/workspace | grep -o '"MAGIC_[A-Z_]*":"[^"]*"'
42
- ```
43
-
44
- ```json
45
- "MAGIC_SERVICE_BASE_URL": "https://magic-service.magicrew.ai"
46
- "MAGIC_SOCKET_BASE_URL": "wss://magic-service.magicrew.ai"
47
- "MAGIC_GATEWAY_ADDRESS": "https://api.magicrew.ai"
48
- ```
49
-
50
- ⚠ **`api.magicrew.ai` 不是这套 API**。它是 `MAGIC_GATEWAY_ADDRESS`(teamshare 网关),
51
- 对 `/v1/models`、`/api/v1/*` 一律回 `not match rule path`。它就是那个「看起来最像、
52
- 试了才发现全不对」的坑。
53
-
54
- 国内站同位置是 `i-magic-service.letsmagic.cn`,`MAGIC_GATEWAY_ADDRESS: https://api.teamshare.cn`。
55
-
56
- ---
57
-
58
- ## 二、三层凭证
59
-
60
- ```
61
- 网页 JWT 30 天 换密钥、读模型目录和积分
62
- mgw_ api_key 2 小时 真正请求时的 Bearer
63
- refresh_token 7 天 PUT 一次换新的 api_key;每换一次轮换一次
64
- ```
65
-
66
- ```bash
67
- # ① 登录
68
- POST /api/v1/sessions
69
- { type:"phone_password", phone, password, state_code, organization_code, invite_code }
70
-
71
- # ② 签发网关密钥(要 JWT)
72
- POST /api/v1/model-gateway/tokens → { api_key, refresh_token, *_expires_at }
73
-
74
- # ③ 续期(不需要登录态;refresh_token 一次性,必须串行)
75
- PUT /api/v1/model-gateway/tokens { refresh_token }
76
- ```
77
-
78
- `mgw_` key 是**站点绑定**的:国际站的 key 拿去国内站回 `{"code":403,"message":"token无效"}`。
79
-
80
- ### JWT 的形状
81
-
82
- magic 自己那套,不是标准 JWT:每段 base64 **带 `==` 填充**,签名段是 64 位 **hex**(不是 base64url)。
83
-
84
- ```
85
- header {"alg":"HS256","type":"JWT"}
86
- payload {"iss":<组织id>,"exp":<30天后>,"iat":<现在>,"jti":<会话id>,
87
- "cus":{"device_id":"","tenant_id":<18位数字>}}
88
- ```
89
-
90
- ### ⚠ `organization-code` 是另一个东西
91
-
92
- JWT 里只有 `cus.tenant_id`(18 位数字,例 `700000000000000001`),而服务端要的
93
- `organization-code` 是另一套 12 位短串(例 `orgcode67890`)。两者不能互推。
94
-
95
- 这个值在浏览器的 local storage:
96
-
97
- ```
98
- MAGIC:user:organizationCode → {"key":"organizationCode","value":"orgcode67890"}
99
- MAGIC:user:teamshareOrganizationCode → 同值
100
- ```
101
-
102
- 错误码分得很清:
103
-
104
- | 情况 | 返回 |
105
- |---|---|
106
- | 不带 `organization-code` | `{"code":2182,"message":"用户所在组织不存在"}` |
107
- | 带任意非空值(token 无效) | `{"code":3035,"message":"登录失败"}` |
108
- | 带对的值 + 有效 JWT | `{"code":1000,"message":"ok",...}` |
109
-
110
- 注意:**带错的码也还是 3035**,和「码对但 token 无效」同一个错误码 —— 所以不能靠错误码判断是码错还是 token 错。
111
-
112
- ### ⚠ 国际站 `/v1/*` 强制要 `organization-code`
113
-
114
- 这是两站唯一的网关层差异:
115
-
116
- ```bash
117
- # 国际站,不带 organization-code
118
- POST /v1/chat/completions
119
- → HTTP 500 "Internal Server Error." ← 22 字节纯文本,不是 JSON
120
-
121
- # 带上就正常
122
- POST /v1/chat/completions -H "organization-code: <code>"
123
- → HTTP 200 {"id":"...","choices":[...]}
124
- ```
125
-
126
- 国内站不需要(一直是 `i-magic-service.letsmagic.cn/v1` 裸调)。
127
- 插件在 `loader` 里按 `SITE.needsOrgOnGateway` 决定带不带。
128
-
129
- **CORS 预检能问出允许的头名**,这条路对所有站点通用:
130
-
131
- ```bash
132
- curl -s -X OPTIONS https://magic-service.magicrew.ai/api/v1/flows/models \
133
- -H "Origin: https://www.magicrew.ai" \
134
- -H "Access-Control-Request-Method: GET" \
135
- -H "Access-Control-Request-Headers: authorization,organization-code" -D - -o /dev/null
136
- ```
137
-
138
- 两站返回的 `Access-Control-Allow-Headers` 完全一致:
139
-
140
- ```
141
- DNT,Keep-Alive,User-Agent,Cache-Control,Content-Type,Authorization,
142
- application-code,organization-code,x-forwarded-user,token,request-id,
143
- Language,api-key,X-Magic-Image-Process,X-App-Id,X-Timestamp,X-Nonce,X-Sign
144
- ```
145
-
146
- ---
147
-
148
- ## 三、接口清单(实测)
149
-
150
- ### 业务 API(要 JWT + organization-code)
151
-
152
- | 方法 | 路径 | 说明 |
153
- |---|---|---|
154
- | POST | `/api/v1/sessions` | 登录;`invite_code` 字段在此 |
155
- | POST/PUT | `/api/v1/model-gateway/tokens` | 签发 / 续期 `mgw_` key |
156
- | GET | `/api/v1/flows/models` | 账号当前可选的语言模型 |
157
- | POST | `/api/v1/products/model-points` | 每个模型的积分单价 |
158
- | GET | `/api/v1/quota/points/total` | 积分余额 |
159
- | POST | `/api/v1/quota/points/records/queries` | 积分流水(注意是 POST,GET 回 405) |
160
- | GET | `/api/v1/onboarding/tasks` | 新手任务列表 |
161
- | PUT | `/api/v1/onboarding/tasks/{code}/completion` | 标记完成;`9303` = 不支持手动完成 |
162
- | GET | `/api/v1/invitations/info` | 自己的邀请码 + 已邀请人数 |
163
- | GET | `/api/v1/invitations/activity` | 邀请活动配置 |
164
- | GET | `/api/v1/invitations/rewards/total-points` | 邀请已赚积分 |
165
-
166
- ### 模型网关(要 `mgw_` key)
167
-
168
- | 方法 | 路径 | 说明 |
169
- |---|---|---|
170
- | GET | `/v1/models` | 全量(含生图/向量);国内 44、国际 65 |
171
- | POST | `/v1/chat/completions` | OpenAI 兼容;国际站另需 `organization-code` |
172
-
173
- ### `/api/v1/flows/models` 的返回
174
-
175
- ```json
176
- {"code":1000,"message":"ok","data":{"models":[
177
- {"value":"mimo-v2.6-flash","label":" MiMo V2.6 Flash","vision":true,
178
- "tags":[{"type":1,"value":"Magic"}],"configs":{"temperature":0.5}}
179
- ]}}
180
- ```
181
-
182
- `label` 前面常带前导空格,`name` 要 `.trim()`。
183
- **不返回 `context_length`** —— 所以上下文统一按 256K 保守填。
184
-
185
- ---
186
-
187
- ## 四、邀请码
188
-
189
- ### 链接格式
190
-
191
- ```
192
- https://www.magicrew.ai/?invitation=<code>
193
- https://www.letsmagic.cn/?invitation=<code>
194
- ```
195
-
196
- 上游前端 `constants-BGteNvs6.js` 原文:`a = (e,t) => \`${e}/?${i}=${t}\``,其中 `i = "invitation"`。
197
-
198
- ### 消费时机
199
-
200
- `LoginService.ts` 里那两行注释是全部答案(原文):
201
-
202
- ```ts
203
- // PS: 改到在无组织下创建组织的时候再消费邀请码
204
- // values.invite_code = consumeInvitationCookie()
205
- ```
206
-
207
- ⇒ **不是登录那一下生效**,是「该账号还没有组织、这次登录顺手建个人组织」时生效。
208
- 老账号组织早就有,传了也不会再记账。
209
-
210
- 字段名来自 `types/login.ts`:`invite_code?: string`。
211
- 错误码 `2152` = 邀请码已被使用(`constants/api.ts`)。
212
-
213
- ### 接口只认客户端登录的 JWT
214
-
215
- 同一账号签的两把 JWT:
216
-
217
- | 来源 | 长度 | `/api/v1/invitations/info` |
218
- |---|---|---|
219
- | 网页 local storage(354 字符) | 354 | `{"code":2155,"message":"用户不存在"}` |
220
- | 桌面客户端(438 字符) | 438 | `{"code":1000,"data":{"invite_code":"..."}}` |
221
-
222
- 差别在 payload 里带不带用户字段。所以插件把**上次登录成功的那把 JWT** 落盘到
223
- `~/.cache/magpie/magicrew-session-<siteId>.json`,专供「用我自己的邀请码」这一项;
224
- 从没登录过就没文件,选这项直接报错。
225
-
226
- ### 这个路径是怎么找到的(可复用)
227
-
228
- 盲试 30 余条候选路径全 404,最后从**客户端缓存**里读出来的:
229
-
230
- ```bash
231
- C="$APPDATA/Magic/Cache/Cache_Data/data_1"
232
- grep -a -o -E 'https://magic-service\.magicrew\.ai/api/v1/[a-zA-Z0-9/_.-]*' "$C" | sort -u
233
- grep -a -o -E '"invite_code":"[A-Za-z0-9]+"' "$C"
234
- ```
235
-
236
- Electron 的 `Cache/Cache_Data/data_*` 里留着客户端访问过的全部 URL 和响应体。
237
- **这比扒 JS chunk 有效得多** —— chunk 是按路由懒加载、名字哈希化的;
238
- 缓存目录里是「它真的请求过什么」。
239
-
240
- 同一份缓存里还能顺手捞出别的接口:`/api/v1/settings/all`、`/api/v1/im/messages/page`、
241
- `/api/v1/auth/status`、`/api/v1/notifications/counts` 等 20 余条。
242
-
243
- ---
244
-
245
- ## 五、一个包两个供应商
246
-
247
- magpie 把包里的**每个具名导出**当成一个独立插件。所以:
248
-
249
- ```js
250
- export const MagicrewAuthPlugin = makePlugin(SITES.cn)
251
- export const MagicrewIntlAuthPlugin = makePlugin(SITES.intl)
252
- ```
253
-
254
- 两个 provider id 不同(`magicrew` / `magicrew-intl`),于是:
255
-
256
- - 供应商列表里两行
257
- - 用量概览里两张卡
258
- - 模型目录各拉各的
259
- - 登录态各存各的,互不覆盖
260
-
261
- 对照实现:`@magpie-community/opencode-workbuddy-auth` 用 `makePlugin(site)` 做两个导出
262
- (WorkBuddy / WorkBuddy AI),`opencode-qoder-auth` 用同一个模式做四个导出。
263
-
264
- **`auth` 里不存站点**:所有 host 都来自闭包里的 `SITE`。这是这套写法的关键 ——
265
- 一旦把站点塞进 `auth`,同一个 provider id 就被两个站抢,登录态互相覆盖。
266
-
267
- ⚠ `magicrew` 这个 id 不能改 —— 已装的用户靠它认领登录态。
268
-
269
- ---
270
-
271
- ## 六、调试
272
-
273
- ```bash
274
- # 不花钱的自测(config / models / usage / loader 头)
275
- node tests/multi-provider.mjs
276
- bun tests/multi-provider.mjs # magpie 实际跑在 bun 上,用它更准
277
-
278
- # 真打一发(会花积分)
279
- MAGICREW_INTL_JWT=... MAGICREW_INTL_ORG=... node tests/smoke.mjs full
280
- ```
281
-
282
- ⚠ **Node 连不上 `magic-service.magicrew.ai`,bun 可以。** 同一台机器、同一个 IP:
283
- `curl` 通、`bun fetch` 通、`node fetch` 与 `node https.get` 一律 `UND_ERR_CONNECT_TIMEOUT`
284
- (TCP 和 TLS 握手都成功,卡在 HTTP 响应)。所以验证一律用 bun 跑,别被 Node 的超时误导。
285
-
286
- ⚠ **中文请求体别走 shell 内联**:Git Bash 把中文按 GBK 传给 `curl.exe`,服务端按 UTF-8 解,
287
- 结果是一律 `HTTP 500 Internal Server Error.`(22 字节纯文本)。看起来像接口挂了,其实是编码。
288
- 把 body 写成 UTF-8 文件再 `--data-binary @file`。
289
-
290
- `max_tokens` 的实测(`glm-5.3-flash`,中文提问):
291
-
292
- | max_tokens | 结果 |
293
- |---|---|
294
- | 64 | `finish_reason:"length"`、`content:""`、reasoning 吃满 |
295
- | 512 | 正常出话(reasoning 208 / total 233) |
296
-
297
- ---
298
-
299
- ## 七、客户端服务地址对照(供以后换站参考)
300
-
301
- ```bash
302
- curl -s https://www.magicrew.ai/workspace | grep -o '"MAGIC_[A-Z_]*":"[^"]*"'
303
- ```
304
-
305
- | 变量 | 国内 | 国际 |
306
- |---|---|---|
307
- | `MAGIC_SERVICE_BASE_URL` | `https://i-magic-service.letsmagic.cn` | `https://magic-service.magicrew.ai` |
308
- | `MAGIC_SOCKET_BASE_URL` | `wss://i-magic-service.letsmagic.cn` | `wss://magic-service.magicrew.ai` |
309
- | `MAGIC_SERVICE_KEEWOOD_BASE_URL` | `https://i-keewood-service.teamshare.cn` | `https://keewood-service.magicrew.ai` |
310
- | `MAGIC_SERVICE_TEAMSHARE_BASE_URL` | `https://i-teamshare-service.teamshare.cn` | `https://teamshare-service.magicrew.ai` |
311
- | `MAGIC_GATEWAY_ADDRESS` | `https://api.teamshare.cn` | `https://api.magicrew.ai` |
312
- | `MAGIC_PUBLIC_CDN_URL` | `https://public-cdn.letsmagic.cn` | `https://public-cdn.magicrew.ai` |
313
- | `MAGIC_CDNHOST` | `https://cdn.letsmagic.cn/__assets__/packages` | `https://cdn.magicrew.ai/__assets__/packages` |
314
-
315
- ## 八、客户端安装包
316
-
317
- 两站的包**同版本号、不同构建**(sha512 不同,差 104 字节,差的是 `app-update.yml` 那行更新源):
318
-
319
- ```
320
- 国内 https://cdn.letsmagic.cn/download/latest.yml
321
- 116003728 B sha512 u7F2Y0c8vUdtEyUvQouQLe4f2uulGgUiD0ye0jOnKUPKNNNYodF5QPI3v3G7jK88FlfqK5vL1CgciSD5Bu136w==
322
-
323
- 国际 https://public-cdn.magicrew.ai/download/global/latest.yml
324
- 116003832 B sha512 KqHznUXQLCC6II82JAtY1VxzOFqx/VnEkdrxU0HUZHtUPu7pED9p0LvOD2EtAQJdrY4DthfYnfu2QCqI0oPdvQ==
325
- ```
326
-
327
- ⚠ 路径里必须有 **`/global/`**。`public-cdn.magicrew.ai/download/latest.yml`(不带 `global`)
328
- 和 `cdn.letsmagic.cn/download/latest.yml` **逐字节相同** —— 那是国内清单的镜像,
329
- 少一个目录名就落到国内包上。这是「从国际站网页下载,装出来是国内版」的原因。
330
-
331
- 装完自查:
332
-
333
- ```bash
334
- cat "<安装目录>/resources/app-update.yml"
335
- # 国内:url: https://cdn.letsmagic.cn/download
336
- # 国际:url: https://public-cdn.magicrew.ai/download/global
337
- ```
338
-
339
- ---
340
-
341
- ## 九、为什么做不到「点一下全自动」
342
-
343
- 对比 OfficeAce:它能做到「浏览器登录完,magpie 窗口自动继续」,因为华为云 OAuth 的
344
- `redirect_uri` 指向一个**可控的服务**(`${CLAW_BASE}/v1/claw/auth/callback`),
345
- 插件轮询那个服务取结果。magicrew 没有这条通道:
346
-
347
- **① 登录类型只有 13 种,全是「拿凭据换 token」。**
348
- `frontend/magic-web/src/types/login.ts` 的 `LoginType` 枚举,注释写明「与API强绑定,禁止修改」:
349
-
350
- ```
351
- phone_password / phone_captcha / email / DingTalk / DingTalkAvoid / wecom /
352
- Lark / LarkRedirect / wechat_official_account / wechat_app / dingtalk_app /
353
- redirect / apple_login / google_login / feishu_oauth
354
- ```
355
-
356
- 没有设备码、没有插件授权、没有一次性 ticket。
357
-
358
- **② 没有 WorkBuddy 那套 `/plugin/auth/*` 协议。**
359
- WorkBuddy 的 `browserSignIn` 走的是
360
- `POST /v2/plugin/auth/state`(开登录会话)→ 打开 `authUrl` → `GET /v2/plugin/auth/token?state=`
361
- 轮询拿 token。magicrew 这两个端点都不存在。
362
-
363
- **③ 试过的三个候选,都不行。**
364
-
365
- | 端点 | 结果 |
366
- |---|---|
367
- | `/api/v1/auth/status` | 要 authorization,缺了回 `3103 authorization 不合法` |
368
- | `/api/v1/auth/environment` | `{"code":1000,"data":{"login_code":null}}` —— 有 `login_code` 字段,但没有配套的兑现接口 |
369
- | `/api/v1/aggregation/login-data` | 连不通 |
370
-
371
- **④ 网页登录成功后 token 只在 `www.magicrew.ai` 的 localStorage 里。**
372
- 插件读不到别的源的存储;`redirect` 字段也不能收任意回调地址(那是第三方登录的 provider code)。
373
-
374
- ⇒ 能做的最短路径:**开官方登录页 + 把结果送进剪贴板 + 插件读剪贴板**。
375
- 剪贴板的读法三平台各一条命令,实测可用:
376
-
377
- ```js
378
- win32 powershell -NoProfile -NonInteractive -Command Get-Clipboard -Raw
379
- darwin pbpaste
380
- linux xclip -selection clipboard -o
381
- ```
382
-
383
- ⚠ `authorize()` 里**不能 await** 读剪贴板 —— 起 powershell 要几百毫秒,而它必须立刻返回。
384
- 做法是把「读剪贴板」这个 promise 递下去,`callback()` 里才落地。
385
-
386
- ⚠ callback 里要拿「打开页面前的剪贴板内容」当 `skip` —— 否则用户上一次复制的东西会被当成结果。
387
-
388
- ## 十、两把 JWT 的差别(**不构成权限差别**)
389
-
390
- 同一账号签的两把,段数都是 3、header 都是 `{"alg":"HS256","type":"JWT"}`,签名段都是 64 位 hex。
391
- 唯一的差别是 `cus.device_id`:
392
-
393
- | 来源 | 长度 | 各段 | `cus.device_id` |
394
- |---|---|---|---|
395
- | 桌面客户端 / `POST /api/v1/sessions` 带 `device` | 438 | 40 / 332 / 64 | 64 位 hex |
396
- | 网页 localStorage | 354 | 40 / 244 / 64 | `""` |
397
-
398
- ### ⚠ 更正(2026-10-27,实测推翻前一条结论)
399
-
400
- **这里原先写着「网页那把不被网关接受,一律 2155 用户不存在」—— 那是错的。**
401
-
402
- 错因:当时手里那把网页 JWT 已经过期(`iat` 是几小时前,中途在同一账号上登录过多次,
403
- 服务端按 `jti` 只认最新会话)。换一把新鲜的重新测:
404
-
405
- ```
406
- 网页 token(354 字符,cus.device_id 为空)
407
- /api/v1/quota/points/total → 1000 ok, total_points 1538
408
- /api/v1/invitations/info → 1000 ok, invite_code IVC8BAWYXRY1
409
- /api/v1/flows/models → 1000 ok, 27 个
410
- /api/v1/model-gateway/tokens → 1000 ok, 签出 mgw_ key
411
- ```
412
-
413
- 四条全通。**`device_id` 为空不构成任何限制**,两种 token 权限完全一样。
414
-
415
- ⇒ 「登录后把自己的邀请码滚上去」这件事,网页登录也滚得动。实现里那句
416
- 「滚不动就保留旧值」的兜底仍然留着(网络抖动、token 过期都会让那一次失败),
417
- 但不再是常态。
418
-
419
- 教训:**判断 token 失效要看 `iat` / `jti`,不要只看一个字段就下结论。**
420
- `2155 用户不存在` 同时可能是「token 过期」和「组织码不对得上」,先确认手里这把是不是新的。
421
-
422
- ---
423
-
424
- ## 十一、浏览器登录怎么做到全自动
425
-
426
- ### 先说不能用 `opened: true` 的地方
427
-
428
- `opened: true` 的语义是「**我已经自己把页面开出来了,你别再开**」(HOOKS.md 陷阱 3,
429
- 原文场景是 Grok 的 CLI 自己开页面)。所以:
430
-
431
- | authorize 里做了什么 | 该传什么 |
432
- |---|---|
433
- | 只返回 url,让 magpie 去开 | **不传** `opened` |
434
- | 自己 spawn 了浏览器 | `opened: true` |
435
-
436
- 传错的后果很具体:**点了登录,什么都不弹**。本项目 0.2.0 的中间版本踩过这个坑 ——
437
- 密码登录和浏览器登录都传了 `opened: true`,两处都点了没反应。
438
-
439
- ### 自己拉浏览器 + CDP 读凭据
440
-
441
- ```
442
- launchLoginBrowser(url)
443
- → findBrowser() Edge / Chrome / Brave / Chromium,按平台列路径逐个 existsSync
444
- → spawn(exe, [
445
- --user-data-dir=~/.cache/magpie/magicrew-login-profile,
446
- --remote-debugging-port=0, ← 0 = 让系统挑空闲端口
447
- --no-first-run, --no-default-browser-check,
448
- url,
449
- ])
450
- → 等 <user-data-dir>/DevToolsActivePort 出现,第一行是端口
451
- ```
452
-
453
- ⚠ 启动前**必须删掉** `DevToolsActivePort` —— 上一次残留的文件会让 `waitPort` 立刻
454
- 返回一个已经失效的旧端口。
455
-
456
- ```js
457
- waitForCreds(session, hostFragment)
458
- → 每 1.5s:连 /json/list → 找 URL 含 hostFragment 的 page target
459
- → WebSocket → Runtime.evaluate(READ_CREDS) → 有值就返回
460
- ```
461
-
462
- `READ_CREDS` 一次求值取两个值:
463
-
464
- ```js
465
- const pick = (k) => { const raw = localStorage.getItem(k); ... JSON.parse(raw).value ... }
466
- JSON.stringify({ jwt: pick("MAGIC:user:authorization"), org: pick("MAGIC:user:organizationCode") })
467
- ```
468
-
469
- ### 三个实测踩到的坑
470
-
471
- **① target 不能退回到 `pages[0]`。**
472
- 浏览器刚起来时第一个 page target 还是 `about:blank`,在它上面访问 `localStorage`
473
- 抛 SecurityError,被 `catch` 吞掉→看起来像「一直读不到」。必须只认 URL 匹配的 target,
474
- 匹配不上返回 null 让轮询继续。实测:启动 → 页面就绪只需 **1.5 秒**,但必须等到它跳转。
475
-
476
- **② profile 目录不能删。**
477
- 独立 profile 意味着用户在这个窗口里要重新登录一次(没有他日常浏览器的 cookie)。
478
- 下次登录还用它,登录态就还在 —— 所以目录固定为
479
- `~/.cache/magpie/magicrew-login-profile`,只 `kill()` 进程,不删目录。
480
-
481
- **③ `authorize()` 里可以 await 启动浏览器(~270ms),但不能 await 用户操作。**
482
- `launchLoginBrowser` 实测 273ms 返回,可接受;`waitForCreds` 必须放到 `callback()` 里。
483
-
484
- ### 实测数据(2026-10-27,本机 Edge)
485
-
486
- | 步骤 | 结果 |
487
- |---|---|
488
- | findBrowser | `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe` |
489
- | launchLoginBrowser | 273 ms,port=55428 |
490
- | 页面就绪(location.host === www.magicrew.ai) | 1518 ms |
491
- | 写入两个 localStorage 后 waitForCreds | 1 ms 读到 |
492
- | localStorage.clear() 后 | 空串 |
493
-
494
- ### 降级链
495
-
496
- ```
497
- 找到浏览器 → 自己拉 + CDP 读 全自动
498
- 找不到 → magpie 开系统浏览器 + 剪贴板 用户粘一行
499
- 都没有 → 报错,让用户改用「登录态(JWT + 组织码)」
500
- ```
501
-
502
- > 0.2.0 里那条「密码登录」方法已经删掉了:网页登录拿到的 token 一样能签发
503
- > `mgw_` key(见第十节的更正),再让用户在小窗里输手机号密码是多余的一步。
504
-
505
- ---
506
-
507
- ## 十二、流式请求必须补 `stream_options.include_usage`
508
-
509
- **症状**:agent 里反复出现
510
-
511
- ```
512
- 模型请求重试 (3/5) · Stream ended without finish_reason
513
- ```
514
-
515
- 用户手动终止后,magpie 记 `499`(客户端断开,属于终止的后果,不是原因)。
516
-
517
- **实测(2026-10-27,deepseek-v4-flash)**:magicrew 的流式返回本身是完整的 ——
518
- `[DONE]` 前有 `choices[0].finish_reason = "stop"`,经我们的 loader 原样转发也没丢。
519
- 真正缺的是**收尾 chunk**:
520
-
521
- | 请求 | finish_reason | usage | `[DONE]` 前的收尾 chunk |
522
- |---|---|---|---|
523
- | 不带 `stream_options` | `stop` | **无** | **无** |
524
- | 带 `stream_options: {include_usage:true}` | `stop` | 有 | 有(`{"choices":[],"usage":{...}}`) |
525
-
526
- magpie 新版靠那条**只含 usage、choices 为空**的收尾 chunk 判定「流正常结束了」。
527
- 缺了就报 `Stream ended without finish_reason` 并重试。
528
-
529
- **修法**(`src/hooks/loader.mjs`):流式请求且调用方没指定 `stream_options` 时,补上
530
- `{ include_usage: true }`。upstream 支持,无害;实测补完后
531
-
532
- ```
533
- finish_reason=stop | usage=有 | 收尾chunk=true | [DONE]=true
534
- ```
535
-
536
- ⚠ 只对 `stream: true` 生效,非流式请求不加(那是多余的字段,有的上游会 400)。
537
-
538
- ⚠ 这个坑的判据是「看流里有没有那条 choices 为空的收尾」,不是「看 finish_reason 在不在」——
539
- 后者一直在,容易误判成上游的问题。
540
-
541
- ---
542
-
543
- ## 十三、签新 key 会吊销旧 key —— 别随便签
544
-
545
- **这是 `Stream ended without finish_reason` 的真正根因**(第十二节记的是排查过程中顺手补的防御项)。
546
-
547
- ### 现象
548
-
549
- - agent 里反复 `Stream ended without finish_reason`,重试,用户终止 → magpie 记 `499`
550
- - magpie 里 provider 显示「已登录」,模型列表也正常
551
- - 但插件拿 `access` 去打上游,回 `{"code":403,"message":"token无效"}`
552
-
553
- ### 根因
554
-
555
- `POST /api/v1/model-gateway/tokens` **一个账号只保留一把 `mgw_` key,签新的就把旧的顶掉**。
556
- 排查过程中反复签 key 验证接口,每签一次就让 magpie 存的那把失效一次 —— 于是
557
- `plugin-auth.json` 里的 `access` 还在(`expires` 甚至没到期),但它已经不被上游承认了。
558
-
559
- 链路因此变成:插件用死 key → 上游 403 → magpie 把 `{"code":403,...}` 这个 **JSON** 回给 agent
560
- → agent 读流读到非 SSE 内容 → 判 `Stream ended without finish_reason` → 重试。
561
-
562
- **那个报错是 403 的下游症状,不是流式的问题。** 判据:看插件发出的请求上游回什么,
563
- 而不是盯着流里有没有 `finish_reason`。
564
-
565
- ### 修法
566
-
567
- 用 `refresh_token` 换一把新的,写回 `plugin-auth.json`(magpie 直接读文件,**不用重启**):
568
-
569
- ```bash
570
- curl -X PUT <host>/api/v1/model-gateway/tokens \
571
- -H 'content-type: application/json' \
572
- -d '{"refresh_token":"<plugin-auth.json 里的 refresh>"}'
573
- ```
574
-
575
- ⚠ **body 只能有 `refresh_token` 这一个字段**。塞别的(或用 authorization 头发)会回:
576
-
577
- ```
578
- {"code":5003,"message":"Request body must only contain refresh_token"}
579
- ```
580
-
581
- 换出来的 `api_key` 立刻可用,同时**又会吊销上一把** —— 所以换完要立刻写回,别在中途用二次。
582
-
583
- ### 教训
584
-
585
- - **调试期间不要反复签 key**。要看接口通不通,用**已有的** `access` 打;真要签,签完立刻写回。
586
- - `expires` 没到期 ≠ key 有效。这个网关的吊销是**服务端即时**的,本地那个时间戳只是"理论到期"。
587
- - 排查上游问题时,**先看上游的原始响应**,再看下游报什么 —— 下游的报错经常是上游错误的二次加工。
588
-
589
- ---
590
-
591
- ## 十四、三层凭据的有效期与保活
592
-
593
- | 凭据 | 有效期 | 从哪来 | 失效后果 |
594
- |---|---|---|---|
595
- | 网页 JWT | **30 天**(`exp - iat = 2592000`) | 登录 | 业务接口全挂,要重新登录 |
596
- | `mgw_` api_key | **2 小时** | `POST /api/v1/model-gateway/tokens` | 请求 403 `token无效` |
597
- | refresh_token | **7 天**,用一次轮换一次 | 同上 | 换不了新 key,要重新登录 |
598
-
599
- ### ⚠ 更正(2026-10-27 晚,实测推翻早前的说法)
600
-
601
- 早前这里写着「**api_key 签一把顶一把,新的签出来那一刻旧的当场死**」——
602
- **这条没有验证充分,不能再当结论用。**
603
-
604
- 实测反例:同一个账号、两把不同时间签发的 key **同时有效**:
605
-
606
- ```
607
- mgw_55ac…(18:08 用 refresh_token 换的)→ /v1/models 正常返回模型列表
608
- mgw_8f9a…(18:14 又一次续期换的) → 网关请求 HTTP 200 正常回复
609
- ```
610
-
611
- 两把都活着。所以「一签就吊销」不成立。
612
-
613
- 那第十三节那次 `mgw_63d4…` 为什么 403?现在只能说**不确定**,可能的原因三种,没分辨开:
614
-
615
- 1. 它真的过期了(2 小时),而本地 `expires` 字段显示还没到 —— 说明 `expires` 不可信
616
- 2. `PUT`(用 refresh_token 续期)会轮换/吊销旧的 `api_key`,而 `POST`(签发)不会
617
- 3. 别的吊销条件(重新登录、并发续期)
618
-
619
- **要判定需要一次受控实验**:同一账号 POST 签两把、隔着时间各测一次,再把 PUT 也测一遍。
620
- 在那之前,插件按「可能有吊销」来设计(只留一把、只走一个出口)是安全的,
621
- 但**不要把它当成已知事实往下传**。
622
-
623
- ### 保活的正确说法
624
-
625
- 不管吊销机制是什么,**只留一把、只有一个出口**这个设计依然成立 ——
626
- 理由从「否则互相吊销」改成「否则本地会分叉,说不清手里那把是不是服务端认的」。
627
-
628
- ### 保活链路(0.3.2 起)
629
-
630
- ```
631
- 每次请求前 ensureKey(c, getAuth)
632
- ├─ 文件里的 key 还能用(剩余 > 10 分钟)→ 直接用
633
- └─ 不够了 → renewOnce(同站点串行)
634
- ├─ 有 refresh_token → PUT 换新的 → 覆盖落盘
635
- └─ 没有 / 失败 → 退回 magpie 存的那把
636
-
637
- 每天一次 checkin 里 ensureKey(c, getAuth, { leadMs: 24h })
638
- └─ 用 24 小时提前量 → 保证每天真的续一次
639
- refresh_token 因此一直新鲜,不会因闲置 7 天失效
640
- ```
641
-
642
- **为什么 checkin 要强制续**:api_key 只活 2 小时,`ensureKey` 的默认提前量是 10 分钟 ——
643
- 一整天没有请求的话,key 会静静过期;更麻烦的是 refresh_token 只活 7 天且**用一次才轮换**,
644
- 长期不用就会闲置到期。挂在 checkin 上(magpie 保证每账号每天跑一次)正好补这个洞。
645
-
646
- **落盘**:`~/.cache/magpie/magicrew-key-<siteId>.json`,只保留最新一把:
647
-
648
- ```json
649
- { "apiKey": "mgw_…", "refreshToken": "…", "expiresAt": 1793102896000, "at": "2026-10-27T18:20:00.000Z" }
650
- ```
651
-
652
- 旧的一旦被覆盖就删了 —— 服务端那边它也已经失效,留着只会让下一个读的人用错。
653
-
654
- ### 首次运行怎么自举
655
-
656
- key-store 是空的(第一次跑或文件被清)时,从 magpie 的 `plugin-auth.json` 里
657
- **接管一次** `access` / `refresh` / `expires` 并落盘,之后完全自管。接管来的那把如果
658
- 已经不够新鲜,紧接着就走续期。
1
+ # 实现细节与协议取证
2
+
3
+ README 里所有「为什么」都在这。取证日期统一是 **2026-10-27**,除非另注。
4
+
5
+ ---
6
+
7
+ ## 一、两站是同一份东西
8
+
9
+ | | 国内 · 超级麦吉 | 国际 · MagiCrew |
10
+ |---|---|---|
11
+ | 网页 | `www.letsmagic.cn` | `www.magicrew.ai` |
12
+ | 业务 API + 模型网关 | `i-magic-service.letsmagic.cn` | `magic-service.magicrew.ai` |
13
+ | `MAGIC_APP_ENV` | `saas-prod` | `international-prod` |
14
+ | `MAGIC_APP_SHA` | `fb90a86cd576dd10f25efdf914485a3bfcc0a13d` | **同一个值** |
15
+ | `MAGIC_APP_VERSION` | `5.0.157` | `5.0.157` |
16
+ | 套餐支付 | ALIPAY | STRIPE |
17
+
18
+ `MAGIC_APP_SHA` 相同 ⇒ 前端是同一份构建,两站差别只在服务端注入的 `MAGIC_*` 环境变量。
19
+ 后端是同一个 Go 服务,逐字符同构:
20
+
21
+ ```
22
+ GET / → hello, magic-service!
23
+ GET /v1/models → {"code":403,"message":"token无效",...}
24
+ GET /api/v1/flows/models → {"code":2179,"message":"auth_token 不存在",...}
25
+ ```
26
+
27
+ ### 站点地址是怎么找到的
28
+
29
+ 客户端是个纯 webview 壳,`app.asar` 里只有区域枚举和 `WebUrl`,**没有 API 地址**:
30
+
31
+ ```js
32
+ const Tp = {
33
+ ChinaRegion: { WebUrl: "https://www.letsmagic.cn", defaultLocale: zhCN },
34
+ internationalRegion: { WebUrl: "https://www.magicrew.ai", defaultLocale: enUS },
35
+ }
36
+ ```
37
+
38
+ 真正的地址在**运行时配置注入页**里 —— 任意非营销路由(比如 `/workspace`)返回的 HTML 内嵌了一份 `window` 配置:
39
+
40
+ ```bash
41
+ curl -s https://www.magicrew.ai/workspace | grep -o '"MAGIC_[A-Z_]*":"[^"]*"'
42
+ ```
43
+
44
+ ```json
45
+ "MAGIC_SERVICE_BASE_URL": "https://magic-service.magicrew.ai"
46
+ "MAGIC_SOCKET_BASE_URL": "wss://magic-service.magicrew.ai"
47
+ "MAGIC_GATEWAY_ADDRESS": "https://api.magicrew.ai"
48
+ ```
49
+
50
+ ⚠ **`api.magicrew.ai` 不是这套 API**。它是 `MAGIC_GATEWAY_ADDRESS`(teamshare 网关),
51
+ 对 `/v1/models`、`/api/v1/*` 一律回 `not match rule path`。它就是那个「看起来最像、
52
+ 试了才发现全不对」的坑。
53
+
54
+ 国内站同位置是 `i-magic-service.letsmagic.cn`,`MAGIC_GATEWAY_ADDRESS: https://api.teamshare.cn`。
55
+
56
+ ---
57
+
58
+ ## 二、三层凭证
59
+
60
+ ```
61
+ 网页 JWT 30 天 换密钥、读模型目录和积分
62
+ mgw_ api_key 2 小时 真正请求时的 Bearer
63
+ refresh_token 7 天 PUT 一次换新的 api_key;每换一次轮换一次
64
+ ```
65
+
66
+ ```bash
67
+ # ① 登录
68
+ POST /api/v1/sessions
69
+ { type:"phone_password", phone, password, state_code, organization_code, invite_code }
70
+
71
+ # ② 签发网关密钥(要 JWT)
72
+ POST /api/v1/model-gateway/tokens → { api_key, refresh_token, *_expires_at }
73
+
74
+ # ③ 续期(不需要登录态;refresh_token 一次性,必须串行)
75
+ PUT /api/v1/model-gateway/tokens { refresh_token }
76
+ ```
77
+
78
+ `mgw_` key 是**站点绑定**的:国际站的 key 拿去国内站回 `{"code":403,"message":"token无效"}`。
79
+
80
+ ### JWT 的形状
81
+
82
+ magic 自己那套,不是标准 JWT:每段 base64 **带 `==` 填充**,签名段是 64 位 **hex**(不是 base64url)。
83
+
84
+ ```
85
+ header {"alg":"HS256","type":"JWT"}
86
+ payload {"iss":<组织id>,"exp":<30天后>,"iat":<现在>,"jti":<会话id>,
87
+ "cus":{"device_id":"","tenant_id":<18位数字>}}
88
+ ```
89
+
90
+ ### ⚠ `organization-code` 是另一个东西
91
+
92
+ JWT 里只有 `cus.tenant_id`(18 位数字,例 `700000000000000001`),而服务端要的
93
+ `organization-code` 是另一套 12 位短串(例 `orgcode67890`)。两者不能互推。
94
+
95
+ 这个值在浏览器的 local storage:
96
+
97
+ ```
98
+ MAGIC:user:organizationCode → {"key":"organizationCode","value":"orgcode67890"}
99
+ MAGIC:user:teamshareOrganizationCode → 同值
100
+ ```
101
+
102
+ 错误码分得很清:
103
+
104
+ | 情况 | 返回 |
105
+ |---|---|
106
+ | 不带 `organization-code` | `{"code":2182,"message":"用户所在组织不存在"}` |
107
+ | 带任意非空值(token 无效) | `{"code":3035,"message":"登录失败"}` |
108
+ | 带对的值 + 有效 JWT | `{"code":1000,"message":"ok",...}` |
109
+
110
+ 注意:**带错的码也还是 3035**,和「码对但 token 无效」同一个错误码 —— 所以不能靠错误码判断是码错还是 token 错。
111
+
112
+ ### ⚠ 国际站 `/v1/*` 强制要 `organization-code`
113
+
114
+ 这是两站唯一的网关层差异:
115
+
116
+ ```bash
117
+ # 国际站,不带 organization-code
118
+ POST /v1/chat/completions
119
+ → HTTP 500 "Internal Server Error." ← 22 字节纯文本,不是 JSON
120
+
121
+ # 带上就正常
122
+ POST /v1/chat/completions -H "organization-code: <code>"
123
+ → HTTP 200 {"id":"...","choices":[...]}
124
+ ```
125
+
126
+ 国内站不需要(一直是 `i-magic-service.letsmagic.cn/v1` 裸调)。
127
+ 插件在 `loader` 里按 `SITE.needsOrgOnGateway` 决定带不带。
128
+
129
+ **CORS 预检能问出允许的头名**,这条路对所有站点通用:
130
+
131
+ ```bash
132
+ curl -s -X OPTIONS https://magic-service.magicrew.ai/api/v1/flows/models \
133
+ -H "Origin: https://www.magicrew.ai" \
134
+ -H "Access-Control-Request-Method: GET" \
135
+ -H "Access-Control-Request-Headers: authorization,organization-code" -D - -o /dev/null
136
+ ```
137
+
138
+ 两站返回的 `Access-Control-Allow-Headers` 完全一致:
139
+
140
+ ```
141
+ DNT,Keep-Alive,User-Agent,Cache-Control,Content-Type,Authorization,
142
+ application-code,organization-code,x-forwarded-user,token,request-id,
143
+ Language,api-key,X-Magic-Image-Process,X-App-Id,X-Timestamp,X-Nonce,X-Sign
144
+ ```
145
+
146
+ ---
147
+
148
+ ## 三、接口清单(实测)
149
+
150
+ ### 业务 API(要 JWT + organization-code)
151
+
152
+ | 方法 | 路径 | 说明 |
153
+ |---|---|---|
154
+ | POST | `/api/v1/sessions` | 登录;`invite_code` 字段在此 |
155
+ | POST/PUT | `/api/v1/model-gateway/tokens` | 签发 / 续期 `mgw_` key |
156
+ | GET | `/api/v1/flows/models` | 账号当前可选的语言模型 |
157
+ | POST | `/api/v1/products/model-points` | 每个模型的积分单价 |
158
+ | GET | `/api/v1/quota/points/total` | 积分余额 |
159
+ | POST | `/api/v1/quota/points/records/queries` | 积分流水(注意是 POST,GET 回 405) |
160
+ | GET | `/api/v1/onboarding/tasks` | 新手任务列表 |
161
+ | PUT | `/api/v1/onboarding/tasks/{code}/completion` | 标记完成;`9303` = 不支持手动完成 |
162
+ | GET | `/api/v1/invitations/info` | 自己的邀请码 + 已邀请人数 |
163
+ | GET | `/api/v1/invitations/activity` | 邀请活动配置 |
164
+ | GET | `/api/v1/invitations/rewards/total-points` | 邀请已赚积分 |
165
+
166
+ ### 模型网关(要 `mgw_` key)
167
+
168
+ | 方法 | 路径 | 说明 |
169
+ |---|---|---|
170
+ | GET | `/v1/models` | 全量(含生图/向量);国内 44、国际 65 |
171
+ | POST | `/v1/chat/completions` | OpenAI 兼容;国际站另需 `organization-code` |
172
+
173
+ ### `/api/v1/flows/models` 的返回
174
+
175
+ ```json
176
+ {"code":1000,"message":"ok","data":{"models":[
177
+ {"value":"mimo-v2.6-flash","label":" MiMo V2.6 Flash","vision":true,
178
+ "tags":[{"type":1,"value":"Magic"}],"configs":{"temperature":0.5}}
179
+ ]}}
180
+ ```
181
+
182
+ `label` 前面常带前导空格,`name` 要 `.trim()`。
183
+ **不返回 `context_length`** —— 所以上下文统一按 256K 保守填。
184
+
185
+ ---
186
+
187
+ ## 四、邀请码
188
+
189
+ ### 链接格式
190
+
191
+ ```
192
+ https://www.magicrew.ai/?invitation=<code>
193
+ https://www.letsmagic.cn/?invitation=<code>
194
+ ```
195
+
196
+ 上游前端 `constants-BGteNvs6.js` 原文:`a = (e,t) => \`${e}/?${i}=${t}\``,其中 `i = "invitation"`。
197
+
198
+ ### 消费时机
199
+
200
+ `LoginService.ts` 里那两行注释是全部答案(原文):
201
+
202
+ ```ts
203
+ // PS: 改到在无组织下创建组织的时候再消费邀请码
204
+ // values.invite_code = consumeInvitationCookie()
205
+ ```
206
+
207
+ ⇒ **不是登录那一下生效**,是「该账号还没有组织、这次登录顺手建个人组织」时生效。
208
+ 老账号组织早就有,传了也不会再记账。
209
+
210
+ 字段名来自 `types/login.ts`:`invite_code?: string`。
211
+ 错误码 `2152` = 邀请码已被使用(`constants/api.ts`)。
212
+
213
+ ### 接口只认客户端登录的 JWT
214
+
215
+ 同一账号签的两把 JWT:
216
+
217
+ | 来源 | 长度 | `/api/v1/invitations/info` |
218
+ |---|---|---|
219
+ | 网页 local storage(354 字符) | 354 | `{"code":2155,"message":"用户不存在"}` |
220
+ | 桌面客户端(438 字符) | 438 | `{"code":1000,"data":{"invite_code":"..."}}` |
221
+
222
+ 差别在 payload 里带不带用户字段。所以插件把**上次登录成功的那把 JWT** 落盘到
223
+ `~/.cache/magpie/magicrew-session-<siteId>.json`,专供「用我自己的邀请码」这一项;
224
+ 从没登录过就没文件,选这项直接报错。
225
+
226
+ ### 这个路径是怎么找到的(可复用)
227
+
228
+ 盲试 30 余条候选路径全 404,最后从**客户端缓存**里读出来的:
229
+
230
+ ```bash
231
+ C="$APPDATA/Magic/Cache/Cache_Data/data_1"
232
+ grep -a -o -E 'https://magic-service\.magicrew\.ai/api/v1/[a-zA-Z0-9/_.-]*' "$C" | sort -u
233
+ grep -a -o -E '"invite_code":"[A-Za-z0-9]+"' "$C"
234
+ ```
235
+
236
+ Electron 的 `Cache/Cache_Data/data_*` 里留着客户端访问过的全部 URL 和响应体。
237
+ **这比扒 JS chunk 有效得多** —— chunk 是按路由懒加载、名字哈希化的;
238
+ 缓存目录里是「它真的请求过什么」。
239
+
240
+ 同一份缓存里还能顺手捞出别的接口:`/api/v1/settings/all`、`/api/v1/im/messages/page`、
241
+ `/api/v1/auth/status`、`/api/v1/notifications/counts` 等 20 余条。
242
+
243
+ ---
244
+
245
+ ## 五、一个包两个供应商
246
+
247
+ magpie 把包里的**每个具名导出**当成一个独立插件。所以:
248
+
249
+ ```js
250
+ export const MagicrewAuthPlugin = makePlugin(SITES.cn)
251
+ export const MagicrewIntlAuthPlugin = makePlugin(SITES.intl)
252
+ ```
253
+
254
+ 两个 provider id 不同(`magicrew` / `magicrew-intl`),于是:
255
+
256
+ - 供应商列表里两行
257
+ - 用量概览里两张卡
258
+ - 模型目录各拉各的
259
+ - 登录态各存各的,互不覆盖
260
+
261
+ 对照实现:`@magpie-community/opencode-workbuddy-auth` 用 `makePlugin(site)` 做两个导出
262
+ (WorkBuddy / WorkBuddy AI),`opencode-qoder-auth` 用同一个模式做四个导出。
263
+
264
+ **`auth` 里不存站点**:所有 host 都来自闭包里的 `SITE`。这是这套写法的关键 ——
265
+ 一旦把站点塞进 `auth`,同一个 provider id 就被两个站抢,登录态互相覆盖。
266
+
267
+ ⚠ `magicrew` 这个 id 不能改 —— 已装的用户靠它认领登录态。
268
+
269
+ ---
270
+
271
+ ## 六、调试
272
+
273
+ ```bash
274
+ # 不花钱的自测(config / models / usage / loader 头)
275
+ node tests/multi-provider.mjs
276
+ bun tests/multi-provider.mjs # magpie 实际跑在 bun 上,用它更准
277
+
278
+ # 真打一发(会花积分)
279
+ MAGICREW_INTL_JWT=... MAGICREW_INTL_ORG=... node tests/smoke.mjs full
280
+ ```
281
+
282
+ ⚠ **Node 连不上 `magic-service.magicrew.ai`,bun 可以。** 同一台机器、同一个 IP:
283
+ `curl` 通、`bun fetch` 通、`node fetch` 与 `node https.get` 一律 `UND_ERR_CONNECT_TIMEOUT`
284
+ (TCP 和 TLS 握手都成功,卡在 HTTP 响应)。所以验证一律用 bun 跑,别被 Node 的超时误导。
285
+
286
+ ⚠ **中文请求体别走 shell 内联**:Git Bash 把中文按 GBK 传给 `curl.exe`,服务端按 UTF-8 解,
287
+ 结果是一律 `HTTP 500 Internal Server Error.`(22 字节纯文本)。看起来像接口挂了,其实是编码。
288
+ 把 body 写成 UTF-8 文件再 `--data-binary @file`。
289
+
290
+ `max_tokens` 的实测(`glm-5.3-flash`,中文提问):
291
+
292
+ | max_tokens | 结果 |
293
+ |---|---|
294
+ | 64 | `finish_reason:"length"`、`content:""`、reasoning 吃满 |
295
+ | 512 | 正常出话(reasoning 208 / total 233) |
296
+
297
+ ---
298
+
299
+ ## 七、客户端服务地址对照(供以后换站参考)
300
+
301
+ ```bash
302
+ curl -s https://www.magicrew.ai/workspace | grep -o '"MAGIC_[A-Z_]*":"[^"]*"'
303
+ ```
304
+
305
+ | 变量 | 国内 | 国际 |
306
+ |---|---|---|
307
+ | `MAGIC_SERVICE_BASE_URL` | `https://i-magic-service.letsmagic.cn` | `https://magic-service.magicrew.ai` |
308
+ | `MAGIC_SOCKET_BASE_URL` | `wss://i-magic-service.letsmagic.cn` | `wss://magic-service.magicrew.ai` |
309
+ | `MAGIC_SERVICE_KEEWOOD_BASE_URL` | `https://i-keewood-service.teamshare.cn` | `https://keewood-service.magicrew.ai` |
310
+ | `MAGIC_SERVICE_TEAMSHARE_BASE_URL` | `https://i-teamshare-service.teamshare.cn` | `https://teamshare-service.magicrew.ai` |
311
+ | `MAGIC_GATEWAY_ADDRESS` | `https://api.teamshare.cn` | `https://api.magicrew.ai` |
312
+ | `MAGIC_PUBLIC_CDN_URL` | `https://public-cdn.letsmagic.cn` | `https://public-cdn.magicrew.ai` |
313
+ | `MAGIC_CDNHOST` | `https://cdn.letsmagic.cn/__assets__/packages` | `https://cdn.magicrew.ai/__assets__/packages` |
314
+
315
+ ## 八、客户端安装包
316
+
317
+ 两站的包**同版本号、不同构建**(sha512 不同,差 104 字节,差的是 `app-update.yml` 那行更新源):
318
+
319
+ ```
320
+ 国内 https://cdn.letsmagic.cn/download/latest.yml
321
+ 116003728 B sha512 u7F2Y0c8vUdtEyUvQouQLe4f2uulGgUiD0ye0jOnKUPKNNNYodF5QPI3v3G7jK88FlfqK5vL1CgciSD5Bu136w==
322
+
323
+ 国际 https://public-cdn.magicrew.ai/download/global/latest.yml
324
+ 116003832 B sha512 KqHznUXQLCC6II82JAtY1VxzOFqx/VnEkdrxU0HUZHtUPu7pED9p0LvOD2EtAQJdrY4DthfYnfu2QCqI0oPdvQ==
325
+ ```
326
+
327
+ ⚠ 路径里必须有 **`/global/`**。`public-cdn.magicrew.ai/download/latest.yml`(不带 `global`)
328
+ 和 `cdn.letsmagic.cn/download/latest.yml` **逐字节相同** —— 那是国内清单的镜像,
329
+ 少一个目录名就落到国内包上。这是「从国际站网页下载,装出来是国内版」的原因。
330
+
331
+ 装完自查:
332
+
333
+ ```bash
334
+ cat "<安装目录>/resources/app-update.yml"
335
+ # 国内:url: https://cdn.letsmagic.cn/download
336
+ # 国际:url: https://public-cdn.magicrew.ai/download/global
337
+ ```
338
+
339
+ ---
340
+
341
+ ## 九、为什么做不到「点一下全自动」
342
+
343
+ 对比 OfficeAce:它能做到「浏览器登录完,magpie 窗口自动继续」,因为华为云 OAuth 的
344
+ `redirect_uri` 指向一个**可控的服务**(`${CLAW_BASE}/v1/claw/auth/callback`),
345
+ 插件轮询那个服务取结果。magicrew 没有这条通道:
346
+
347
+ **① 登录类型只有 13 种,全是「拿凭据换 token」。**
348
+ `frontend/magic-web/src/types/login.ts` 的 `LoginType` 枚举,注释写明「与API强绑定,禁止修改」:
349
+
350
+ ```
351
+ phone_password / phone_captcha / email / DingTalk / DingTalkAvoid / wecom /
352
+ Lark / LarkRedirect / wechat_official_account / wechat_app / dingtalk_app /
353
+ redirect / apple_login / google_login / feishu_oauth
354
+ ```
355
+
356
+ 没有设备码、没有插件授权、没有一次性 ticket。
357
+
358
+ **② 没有 WorkBuddy 那套 `/plugin/auth/*` 协议。**
359
+ WorkBuddy 的 `browserSignIn` 走的是
360
+ `POST /v2/plugin/auth/state`(开登录会话)→ 打开 `authUrl` → `GET /v2/plugin/auth/token?state=`
361
+ 轮询拿 token。magicrew 这两个端点都不存在。
362
+
363
+ **③ 试过的三个候选,都不行。**
364
+
365
+ | 端点 | 结果 |
366
+ |---|---|
367
+ | `/api/v1/auth/status` | 要 authorization,缺了回 `3103 authorization 不合法` |
368
+ | `/api/v1/auth/environment` | `{"code":1000,"data":{"login_code":null}}` —— 有 `login_code` 字段,但没有配套的兑现接口 |
369
+ | `/api/v1/aggregation/login-data` | 连不通 |
370
+
371
+ **④ 网页登录成功后 token 只在 `www.magicrew.ai` 的 localStorage 里。**
372
+ 插件读不到别的源的存储;`redirect` 字段也不能收任意回调地址(那是第三方登录的 provider code)。
373
+
374
+ ⇒ 能做的最短路径:**开官方登录页 + 把结果送进剪贴板 + 插件读剪贴板**。
375
+ 剪贴板的读法三平台各一条命令,实测可用:
376
+
377
+ ```js
378
+ win32 powershell -NoProfile -NonInteractive -Command Get-Clipboard -Raw
379
+ darwin pbpaste
380
+ linux xclip -selection clipboard -o
381
+ ```
382
+
383
+ ⚠ `authorize()` 里**不能 await** 读剪贴板 —— 起 powershell 要几百毫秒,而它必须立刻返回。
384
+ 做法是把「读剪贴板」这个 promise 递下去,`callback()` 里才落地。
385
+
386
+ ⚠ callback 里要拿「打开页面前的剪贴板内容」当 `skip` —— 否则用户上一次复制的东西会被当成结果。
387
+
388
+ ## 十、两把 JWT 的差别(**不构成权限差别**)
389
+
390
+ 同一账号签的两把,段数都是 3、header 都是 `{"alg":"HS256","type":"JWT"}`,签名段都是 64 位 hex。
391
+ 唯一的差别是 `cus.device_id`:
392
+
393
+ | 来源 | 长度 | 各段 | `cus.device_id` |
394
+ |---|---|---|---|
395
+ | 桌面客户端 / `POST /api/v1/sessions` 带 `device` | 438 | 40 / 332 / 64 | 64 位 hex |
396
+ | 网页 localStorage | 354 | 40 / 244 / 64 | `""` |
397
+
398
+ ### ⚠ 更正(2026-10-27,实测推翻前一条结论)
399
+
400
+ **这里原先写着「网页那把不被网关接受,一律 2155 用户不存在」—— 那是错的。**
401
+
402
+ 错因:当时手里那把网页 JWT 已经过期(`iat` 是几小时前,中途在同一账号上登录过多次,
403
+ 服务端按 `jti` 只认最新会话)。换一把新鲜的重新测:
404
+
405
+ ```
406
+ 网页 token(354 字符,cus.device_id 为空)
407
+ /api/v1/quota/points/total → 1000 ok, total_points 1538
408
+ /api/v1/invitations/info → 1000 ok, invite_code IVC8BAWYXRY1
409
+ /api/v1/flows/models → 1000 ok, 27 个
410
+ /api/v1/model-gateway/tokens → 1000 ok, 签出 mgw_ key
411
+ ```
412
+
413
+ 四条全通。**`device_id` 为空不构成任何限制**,两种 token 权限完全一样。
414
+
415
+ ⇒ 「登录后把自己的邀请码滚上去」这件事,网页登录也滚得动。实现里那句
416
+ 「滚不动就保留旧值」的兜底仍然留着(网络抖动、token 过期都会让那一次失败),
417
+ 但不再是常态。
418
+
419
+ 教训:**判断 token 失效要看 `iat` / `jti`,不要只看一个字段就下结论。**
420
+ `2155 用户不存在` 同时可能是「token 过期」和「组织码不对得上」,先确认手里这把是不是新的。
421
+
422
+ ---
423
+
424
+ ## 十一、浏览器登录怎么做到全自动
425
+
426
+ ### 先说不能用 `opened: true` 的地方
427
+
428
+ `opened: true` 的语义是「**我已经自己把页面开出来了,你别再开**」(HOOKS.md 陷阱 3,
429
+ 原文场景是 Grok 的 CLI 自己开页面)。所以:
430
+
431
+ | authorize 里做了什么 | 该传什么 |
432
+ |---|---|
433
+ | 只返回 url,让 magpie 去开 | **不传** `opened` |
434
+ | 自己 spawn 了浏览器 | `opened: true` |
435
+
436
+ 传错的后果很具体:**点了登录,什么都不弹**。本项目 0.2.0 的中间版本踩过这个坑 ——
437
+ 密码登录和浏览器登录都传了 `opened: true`,两处都点了没反应。
438
+
439
+ ### 先读本机浏览器(首选路径,2026-10-27 加)
440
+
441
+ magicrew 没有 OAuth 回调(第九节),所以「用户在自己浏览器里登录完了」这件事,插件原本
442
+ 只能靠「自己起的那个窗口」知道 —— 于是常有人报「网页登了、magpie 里却没多出账号」。
443
+ 其实凭据就在本地:Chromium 系把 localStorage 明文写在
444
+
445
+ ```
446
+ <User Data>/<Default|Profile N>/Local Storage/leveldb/*.log|*.ldb
447
+ ```
448
+
449
+ 读法(`src/local-browser.mjs`):
450
+
451
+ ```
452
+ leveldb 目录全部读成 latin1 文本
453
+ → 以站点 host 出现的位置开窗(2000 字节)
454
+ → 窗口内取 `"key":"authorization"` 的 jwt、`"key":"organizationCode2?"` 的组织码
455
+ ```
456
+
457
+ 三个必须注意的点:
458
+
459
+ 1. **按 origin 截断**,否则串号:两个站点的记录可能紧挨着(用户两个都登过),窗口会把
460
+ 国际站的 org 配到国内站的 jwt 上。截断点 = 下一个**不同站点**的 origin(同 origin 的
461
+ 多条记录不截断)。
462
+ 2. **每把 jwt 配它自己后面的那个 org**,不要配「窗口里第一个 org」—— 同一站点存过多把
463
+ 凭据时(旧值还没被 compaction 掉)会配错。
464
+ 3. **过期、空组织码直接丢**;多把有效凭据取 `iat` 最大的。
465
+
466
+ 实测(2026-10-27,本机 Edge):**27 MB / 29 ms**,`www.letsmagic.cn` 命中、
467
+ `www.magicrew.ai` 不命中(没在那里登录过),两站不串。
468
+
469
+ 读到之后还要防「重复加号」:`~/.cache/magpie/magicrew-orgs-<站点>.json` 记着**加过的 org**,
470
+ 命中就不再自动接 —— 用户点登录多半是想加新账号,自动接上同一个会被 magpie 按
471
+ 「同 accountId + uid → 替换旧账号」处理掉,看起来又像「没加上」。
472
+
473
+ 判据:`node tests/local-browser.mjs`(两站分离 / 过期过滤 / iat 取最大 / org 配对 /
474
+ knownOrgs 去重)。真机:`node tests/smoke.mjs` 仍能跑通(凭据从同一个地方读)。
475
+
476
+ ### 自己拉浏览器 + CDP 读凭据
477
+
478
+ ```
479
+ launchLoginBrowser(url)
480
+ → findBrowser() Edge / Chrome / Brave / Chromium,按平台列路径逐个 existsSync
481
+ → spawn(exe, [
482
+ --user-data-dir=~/.cache/magpie/magicrew-login-<pid>-<ts>-<rand>, ← 一次性,见下面 ②
483
+ --remote-debugging-port=0, ← 0 = 让系统挑空闲端口
484
+ --no-first-run, --no-default-browser-check,
485
+ url,
486
+ ])
487
+ → 等 <user-data-dir>/DevToolsActivePort 出现,第一行是端口
488
+ ```
489
+
490
+ ⚠ 启动前**必须删掉** `DevToolsActivePort` —— 上一次残留的文件会让 `waitPort` 立刻
491
+ 返回一个已经失效的旧端口。
492
+
493
+ ```js
494
+ waitForCreds(session, hostFragment)
495
+ → 每 1.5s:连 /json/list → 找 URL 含 hostFragment 的 page target
496
+ → WebSocket → Runtime.evaluate(READ_CREDS) → 有值就返回
497
+ ```
498
+
499
+ `READ_CREDS` 一次求值取两个值:
500
+
501
+ ```js
502
+ const pick = (k) => { const raw = localStorage.getItem(k); ... JSON.parse(raw).value ... }
503
+ JSON.stringify({ jwt: pick("MAGIC:user:authorization"), org: pick("MAGIC:user:organizationCode") })
504
+ ```
505
+
506
+ ### 三个实测踩到的坑
507
+
508
+ **① target 不能退回到 `pages[0]`。**
509
+ 浏览器刚起来时第一个 page target 还是 `about:blank`,在它上面访问 `localStorage`
510
+ 抛 SecurityError,被 `catch` 吞掉→看起来像「一直读不到」。必须只认 URL 匹配的 target,
511
+ 匹配不上返回 null 让轮询继续。实测:启动 → 页面就绪只需 **1.5 秒**,但必须等到它跳转。
512
+
513
+ **② profile 目录必须一次性,用完删。**(2026-10-27 更正 —— 这里原先写的是「不能删」)
514
+
515
+ 原先固定复用 `~/.cache/magpie/magicrew-login-profile`,理由是「登录态留着,下次不用重登」。
516
+ 那会制造一个很隐蔽的串号 bug:**添加第二个账号时,浏览器一打开,localStorage 里就躺着
517
+ 上一个账号的 jwt + org**,`waitForCreds` 第一轮(1.5 秒内)命中 → 立刻关窗;magpie 拿到的
518
+ `accountId` / `uid` 与已有账号一模一样,按契约「同一 accountId + 同一 uid → 替换旧账号」,
519
+ 于是**不新增账号**。用户看到的就是「网页闪一下就没了,登录成功但 magpie 里没多出账号」。
520
+
521
+ 现在的做法:
522
+
523
+ ```
524
+ newProfileDir() → magicrew-login-<pid>-<ts36>-<rand>,每次登录一个
525
+ sweepProfiles() → launch 开头先清:历史复用目录一律删、一次性目录超过 2h 删
526
+ (只认本插件造的名字,且只动目录 —— 同前缀的 .log 不动)
527
+ closeSession() → 关窗后删掉这一次的目录;删不掉留给下次 sweep
528
+ waitForCreds(..., { baseline }) → 第二道防线:窗口打开时就有的凭据不算登录成功
529
+ ```
530
+
531
+ 代价是每次都要在这个窗口里重新登录一次 —— 独立 profile 本来也没有他日常浏览器的 cookie。
532
+
533
+ 判据(可复跑):`node tools/verify-profile-scope.mjs` —— 窗口打开 4 秒后
534
+ `READ_CREDS` 必须返回**空串**,关窗后目录必须不存在。单元测试见 `tests/login-profile.mjs`。
535
+
536
+ **③ `authorize()` 里可以 await 启动浏览器(~270ms),但不能 await 用户操作。**
537
+ `launchLoginBrowser` 实测 273ms 返回,可接受;`waitForCreds` 必须放到 `callback()` 里。
538
+
539
+ ### 实测数据(2026-10-27,本机 Edge)
540
+
541
+ | 步骤 | 结果 |
542
+ |---|---|
543
+ | findBrowser | `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe` |
544
+ | launchLoginBrowser | 273 ms,port=55428 |
545
+ | 页面就绪(location.host === www.magicrew.ai) | 1518 ms |
546
+ | 写入两个 localStorage 后 waitForCreds | 1 ms 读到 |
547
+ | localStorage.clear() 后 | 空串 |
548
+
549
+ ### 降级链
550
+
551
+ ```
552
+ 找到浏览器 → 自己拉 + CDP 读 全自动
553
+ 找不到 → magpie 开系统浏览器 + 剪贴板 用户粘一行
554
+ 都没有 → 报错,让用户改用「登录态(JWT + 组织码)」
555
+ ```
556
+
557
+ > 0.2.0 里那条「密码登录」方法已经删掉了:网页登录拿到的 token 一样能签发
558
+ > `mgw_` key(见第十节的更正),再让用户在小窗里输手机号密码是多余的一步。
559
+
560
+ ---
561
+
562
+ ## 十二、流式请求必须补 `stream_options.include_usage`
563
+
564
+ **症状**:agent 里反复出现
565
+
566
+ ```
567
+ 模型请求重试 (3/5) · Stream ended without finish_reason
568
+ ```
569
+
570
+ 用户手动终止后,magpie 记 `499`(客户端断开,属于终止的后果,不是原因)。
571
+
572
+ **实测(2026-10-27,deepseek-v4-flash)**:magicrew 的流式返回本身是完整的 ——
573
+ `[DONE]` 前有 `choices[0].finish_reason = "stop"`,经我们的 loader 原样转发也没丢。
574
+ 真正缺的是**收尾 chunk**:
575
+
576
+ | 请求 | finish_reason | usage | `[DONE]` 前的收尾 chunk |
577
+ |---|---|---|---|
578
+ | 不带 `stream_options` | `stop` | **无** | **无** |
579
+ | 带 `stream_options: {include_usage:true}` | `stop` | 有 | 有(`{"choices":[],"usage":{...}}`) |
580
+
581
+ magpie 新版靠那条**只含 usage、choices 为空**的收尾 chunk 判定「流正常结束了」。
582
+ 缺了就报 `Stream ended without finish_reason` 并重试。
583
+
584
+ **修法**(`src/hooks/loader.mjs`):流式请求且调用方没指定 `stream_options` 时,补上
585
+ `{ include_usage: true }`。upstream 支持,无害;实测补完后
586
+
587
+ ```
588
+ finish_reason=stop | usage=有 | 收尾chunk=true | [DONE]=true
589
+ ```
590
+
591
+ ⚠ 只对 `stream: true` 生效,非流式请求不加(那是多余的字段,有的上游会 400)。
592
+
593
+ ⚠ 这个坑的判据是「看流里有没有那条 choices 为空的收尾」,不是「看 finish_reason 在不在」——
594
+ 后者一直在,容易误判成上游的问题。
595
+
596
+ ---
597
+
598
+ ## 十三、签新 key 会吊销旧 key —— 别随便签
599
+
600
+ **这是 `Stream ended without finish_reason` 的真正根因**(第十二节记的是排查过程中顺手补的防御项)。
601
+
602
+ ### 现象
603
+
604
+ - agent 里反复 `Stream ended without finish_reason`,重试,用户终止 → magpie 记 `499`
605
+ - magpie 里 provider 显示「已登录」,模型列表也正常
606
+ - 但插件拿 `access` 去打上游,回 `{"code":403,"message":"token无效"}`
607
+
608
+ ### 根因
609
+
610
+ `POST /api/v1/model-gateway/tokens` **一个账号只保留一把 `mgw_` key,签新的就把旧的顶掉**。
611
+ 排查过程中反复签 key 验证接口,每签一次就让 magpie 存的那把失效一次 —— 于是
612
+ `plugin-auth.json` 里的 `access` 还在(`expires` 甚至没到期),但它已经不被上游承认了。
613
+
614
+ 链路因此变成:插件用死 key → 上游 403 → magpie 把 `{"code":403,...}` 这个 **JSON** 回给 agent
615
+ → agent 读流读到非 SSE 内容 → 判 `Stream ended without finish_reason` → 重试。
616
+
617
+ **那个报错是 403 的下游症状,不是流式的问题。** 判据:看插件发出的请求上游回什么,
618
+ 而不是盯着流里有没有 `finish_reason`。
619
+
620
+ ### 修法
621
+
622
+ 用 `refresh_token` 换一把新的,写回 `plugin-auth.json`(magpie 直接读文件,**不用重启**):
623
+
624
+ ```bash
625
+ curl -X PUT <host>/api/v1/model-gateway/tokens \
626
+ -H 'content-type: application/json' \
627
+ -d '{"refresh_token":"<plugin-auth.json 里的 refresh>"}'
628
+ ```
629
+
630
+ ⚠ **body 只能有 `refresh_token` 这一个字段**。塞别的(或用 authorization 头发)会回:
631
+
632
+ ```
633
+ {"code":5003,"message":"Request body must only contain refresh_token"}
634
+ ```
635
+
636
+ 换出来的 `api_key` 立刻可用,同时**又会吊销上一把** —— 所以换完要立刻写回,别在中途用二次。
637
+
638
+ ### 教训
639
+
640
+ - **调试期间不要反复签 key**。要看接口通不通,用**已有的** `access` 打;真要签,签完立刻写回。
641
+ - `expires` 没到期 ≠ key 有效。这个网关的吊销是**服务端即时**的,本地那个时间戳只是"理论到期"。
642
+ - 排查上游问题时,**先看上游的原始响应**,再看下游报什么 —— 下游的报错经常是上游错误的二次加工。
643
+
644
+ ---
645
+
646
+ ## 十四、三层凭据的有效期与保活
647
+
648
+ | 凭据 | 有效期 | 从哪来 | 失效后果 |
649
+ |---|---|---|---|
650
+ | 网页 JWT | **30 天**(`exp - iat = 2592000`) | 登录 | 业务接口全挂,要重新登录 |
651
+ | `mgw_` api_key | **2 小时** | `POST /api/v1/model-gateway/tokens` | 请求 403 `token无效` |
652
+ | refresh_token | **7 天**,用一次轮换一次 | 同上 | 换不了新 key,要重新登录 |
653
+
654
+ ### ⚠ 更正(2026-10-27 晚,实测推翻早前的说法)
655
+
656
+ 早前这里写着「**api_key 签一把顶一把,新的签出来那一刻旧的当场死**」——
657
+ **这条没有验证充分,不能再当结论用。**
658
+
659
+ 实测反例:同一个账号、两把不同时间签发的 key **同时有效**:
660
+
661
+ ```
662
+ mgw_55ac…(18:08 用 refresh_token 换的)→ /v1/models 正常返回模型列表
663
+ mgw_8f9a…(18:14 又一次续期换的) → 网关请求 HTTP 200 正常回复
664
+ ```
665
+
666
+ 两把都活着。所以「一签就吊销」不成立。
667
+
668
+ 那第十三节那次 `mgw_63d4…` 为什么 403?现在只能说**不确定**,可能的原因三种,没分辨开:
669
+
670
+ 1. 它真的过期了(2 小时),而本地 `expires` 字段显示还没到 —— 说明 `expires` 不可信
671
+ 2. `PUT`(用 refresh_token 续期)会轮换/吊销旧的 `api_key`,而 `POST`(签发)不会
672
+ 3. 别的吊销条件(重新登录、并发续期)
673
+
674
+ **要判定需要一次受控实验**:同一账号 POST 签两把、隔着时间各测一次,再把 PUT 也测一遍。
675
+ 在那之前,插件按「可能有吊销」来设计(只留一把、只走一个出口)是安全的,
676
+ 但**不要把它当成已知事实往下传**。
677
+
678
+ ### 保活的正确说法
679
+
680
+ 不管吊销机制是什么,**只留一把、只有一个出口**这个设计依然成立 ——
681
+ 理由从「否则互相吊销」改成「否则本地会分叉,说不清手里那把是不是服务端认的」。
682
+
683
+ ### 保活链路(0.3.2 起)
684
+
685
+ ```
686
+ 每次请求前 ensureKey(c, getAuth)
687
+ ├─ 文件里的 key 还能用(剩余 > 10 分钟)→ 直接用
688
+ └─ 不够了 → renewOnce(同站点串行)
689
+ ├─ 有 refresh_token → PUT 换新的 → 覆盖落盘
690
+ └─ 没有 / 失败 → 退回 magpie 存的那把
691
+
692
+ 每天一次 checkin 里 ensureKey(c, getAuth, { leadMs: 24h })
693
+ └─ 用 24 小时提前量 → 保证每天真的续一次
694
+ refresh_token 因此一直新鲜,不会因闲置 7 天失效
695
+ ```
696
+
697
+ **为什么 checkin 要强制续**:api_key 只活 2 小时,`ensureKey` 的默认提前量是 10 分钟 ——
698
+ 一整天没有请求的话,key 会静静过期;更麻烦的是 refresh_token 只活 7 天且**用一次才轮换**,
699
+ 长期不用就会闲置到期。挂在 checkin 上(magpie 保证每账号每天跑一次)正好补这个洞。
700
+
701
+ **落盘**:`~/.cache/magpie/magicrew-key-<siteId>.json`,只保留最新一把:
702
+
703
+ ```json
704
+ { "apiKey": "mgw_…", "refreshToken": "…", "expiresAt": 1793102896000, "at": "2026-10-27T18:20:00.000Z" }
705
+ ```
706
+
707
+ 旧的一旦被覆盖就删了 —— 服务端那边它也已经失效,留着只会让下一个读的人用错。
708
+
709
+ ### 首次运行怎么自举
710
+
711
+ key-store 是空的(第一次跑或文件被清)时,从 magpie 的 `plugin-auth.json` 里
712
+ **接管一次** `access` / `refresh` / `expires` 并落盘,之后完全自管。接管来的那把如果
713
+ 已经不够新鲜,紧接着就走续期。
659
714
 
660
715
  ---
661
716