magicrew-auth 0.3.5 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # 更新记录
2
2
 
3
+ ## 0.3.6
4
+
5
+ **修:添加第二个账号时网页闪一下就关,magpie 里没多出账号**
6
+
7
+ - 症状:点「添加账号」→ 浏览器正常弹出 → 一两秒内自己关掉;用户确实也登录成功了,但账号列表里没有新账号
8
+ - 根因:登录浏览器**复用**同一个 profile(`~/.cache/magpie/magicrew-login-profile`)。第二个窗口一打开,
9
+ localStorage 里就是上一个账号的 `MAGIC:user:authorization` / `organizationCode`,`waitForCreds`
10
+ 第一轮(1.5 秒)读到它 → 判定登录完成 → 关窗;magpie 拿到的 `accountId` / `uid` 与已有账号完全相同,
11
+ 按契约「同 accountId + 同 uid → 替换旧账号」→ 不新增
12
+ - 修法:每次登录一个**一次性** profile(`magicrew-login-<pid>-<ts>-<rand>`),`closeSession()` 关窗后删;
13
+ `launchLoginBrowser()` 开头先 `sweepProfiles()` 清掉历史复用目录与超过 2 小时的一次性目录
14
+ (只认本插件造的名字,且只动目录 —— 同前缀的 `.log` 不碰)
15
+ - 第二道防线:`waitForCreds(..., { baseline })` —— 窗口打开时就存在的凭据不算登录成功
16
+ - 代价:每次都要在窗口里重新登录一次(独立 profile 本来也没有日常 cookie),换来「读到的凭据一定是本次的」
17
+ - 判据:`node tools/_verify_profile_scope.mjs`(窗口打开 4 秒后 `READ_CREDS` 必须是空串、关窗后目录必须不存在)
18
+ + `node tests/login-profile.mjs`
19
+
3
20
  ## 0.3.5
4
21
 
5
22
  **卡片上多一个「承载」徽章:`magpie.carries`**
package/README.md CHANGED
@@ -115,6 +115,10 @@
115
115
  `--remote-debugging-port`),所以登录成功后能用 CDP 在页面上下文里把它自己的
116
116
  localStorage 读走,拿到 JWT 和组织码,签完密钥就关窗。
117
117
 
118
+ **每次登录都是一个新的 profile,用完就删。** 这是必须的:共用一个 profile 的话,
119
+ 添加第二个账号时窗口一打开,页面里已经躺着上一个账号的凭据 —— 插件会以为登录完成、
120
+ 立刻关窗,而 magpie 拿到的还是旧账号(同账号 → 替换,不新增)。
121
+
118
122
  > **为什么必须自己拉浏览器?** 因为 magicrew 没有 OAuth 回调通道 —— 它的登录类型只有 13 种、全是「拿凭据换 token」,没有设备码、没有插件授权、没有一次性 ticket;也没有 WorkBuddy 那种 `/v2/plugin/auth/state` + `/v2/plugin/auth/token` 协议。网页登录成功后 token 只在 `www.magicrew.ai` 的 localStorage 里,而 magpie 打开的是你系统默认那个浏览器,插件够不到它的存储。自己起一个,就够得到了。
119
123
  >
120
124
  > 本机没装 Chrome / Edge 时自动降级:改用系统浏览器打开,登录完把凭证送进剪贴板,插件从剪贴板读。
@@ -162,6 +166,8 @@ magpie plugin list
162
166
 
163
167
  **两个站要装两次吗?** 不用。一个包导出两个插件,装一次两个供应商都在,各自登录。
164
168
 
169
+ **添加第二个账号时,网页闪一下就关了、magpie 里也没多出账号?** 0.3.5 及更早会这样:登录浏览器复用同一个 profile,第二个窗口一打开就读到上一个账号的凭据,于是立刻「登录成功」并关窗 —— 加到的是已有账号。0.3.6 起每次登录用一个一次性 profile(关窗即删),不会再串号。
170
+
165
171
  **npm 上叫 `magicrew-auth`,为什么卡片显示 `Magicrew`?** 卡片标题取包内 `package.json` 的 `name`(`Magicrew`),而 npm 拒绝含大写字母的新包名。发布时用 `publish-npm.mjs` 临时换成 `magicrew-auth`、发完还原 —— 所以 npm 页面是小写包名,git 源装出来是 `Magicrew`。功能完全一样。
166
172
 
167
173
  **为什么模型列表会变?** 因为清单是从 `/api/v1/flows/models` 实时拉的。订阅档位变了、平台方上下架变了,清单就跟着变 —— 插件里没有需要维护的模型表。
package/docs/internals.md CHANGED
@@ -442,7 +442,7 @@ linux xclip -selection clipboard -o
442
442
  launchLoginBrowser(url)
443
443
  → findBrowser() Edge / Chrome / Brave / Chromium,按平台列路径逐个 existsSync
444
444
  → spawn(exe, [
445
- --user-data-dir=~/.cache/magpie/magicrew-login-profile,
445
+ --user-data-dir=~/.cache/magpie/magicrew-login-<pid>-<ts>-<rand>, ← 一次性,见下面 ②
446
446
  --remote-debugging-port=0, ← 0 = 让系统挑空闲端口
447
447
  --no-first-run, --no-default-browser-check,
448
448
  url,
@@ -473,10 +473,28 @@ JSON.stringify({ jwt: pick("MAGIC:user:authorization"), org: pick("MAGIC:user:or
473
473
  抛 SecurityError,被 `catch` 吞掉→看起来像「一直读不到」。必须只认 URL 匹配的 target,
474
474
  匹配不上返回 null 让轮询继续。实测:启动 → 页面就绪只需 **1.5 秒**,但必须等到它跳转。
475
475
 
476
- **② profile 目录不能删。**
477
- 独立 profile 意味着用户在这个窗口里要重新登录一次(没有他日常浏览器的 cookie)。
478
- 下次登录还用它,登录态就还在 —— 所以目录固定为
479
- `~/.cache/magpie/magicrew-login-profile`,只 `kill()` 进程,不删目录。
476
+ **② profile 目录必须一次性,用完删。**(2026-10-27 更正 —— 这里原先写的是「不能删」)
477
+
478
+ 原先固定复用 `~/.cache/magpie/magicrew-login-profile`,理由是「登录态留着,下次不用重登」。
479
+ 那会制造一个很隐蔽的串号 bug:**添加第二个账号时,浏览器一打开,localStorage 里就躺着
480
+ 上一个账号的 jwt + org**,`waitForCreds` 第一轮(1.5 秒内)命中 → 立刻关窗;magpie 拿到的
481
+ `accountId` / `uid` 与已有账号一模一样,按契约「同一 accountId + 同一 uid → 替换旧账号」,
482
+ 于是**不新增账号**。用户看到的就是「网页闪一下就没了,登录成功但 magpie 里没多出账号」。
483
+
484
+ 现在的做法:
485
+
486
+ ```
487
+ newProfileDir() → magicrew-login-<pid>-<ts36>-<rand>,每次登录一个
488
+ sweepProfiles() → launch 开头先清:历史复用目录一律删、一次性目录超过 2h 删
489
+ (只认本插件造的名字,且只动目录 —— 同前缀的 .log 不动)
490
+ closeSession() → 关窗后删掉这一次的目录;删不掉留给下次 sweep
491
+ waitForCreds(..., { baseline }) → 第二道防线:窗口打开时就有的凭据不算登录成功
492
+ ```
493
+
494
+ 代价是每次都要在这个窗口里重新登录一次 —— 独立 profile 本来也没有他日常浏览器的 cookie。
495
+
496
+ 判据(可复跑):`node tools/_verify_profile_scope.mjs` —— 窗口打开 4 秒后
497
+ `READ_CREDS` 必须返回**空串**,关窗后目录必须不存在。单元测试见 `tests/login-profile.mjs`。
480
498
 
481
499
  **③ `authorize()` 里可以 await 启动浏览器(~270ms),但不能 await 用户操作。**
482
500
  `launchLoginBrowser` 实测 273ms 返回,可接受;`waitForCreds` 必须放到 `callback()` 里。
@@ -656,129 +674,129 @@ mgw_8f9a…(18:14 又一次续期换的) → 网关请求 HTTP 200 正
656
674
  key-store 是空的(第一次跑或文件被清)时,从 magpie 的 `plugin-auth.json` 里
657
675
  **接管一次** `access` / `refresh` / `expires` 并落盘,之后完全自管。接管来的那把如果
658
676
  已经不够新鲜,紧接着就走续期。
659
-
660
- ---
661
-
662
- ## 十五、key 按账号隔离 + 双槽(0.3.3)
663
-
664
- ### 为什么必须按账号分文件
665
-
666
- 同一个站点可以登多个账号(magpie 里显示成 `magicrew` / `magicrew#1` / …)。
667
- key 若按站点存一份,第二个账号登录时会把第一个账号的 key 覆盖掉 —— 第一个账号从此 403,
668
- 而且看起来像"随机失效"。
669
-
670
- 落盘:`~/.cache/magpie/magicrew-key-<站点>__<账号>.json`,账号用 `org`(组织码,登录后固定)。
671
-
672
- ### 双槽怎么转
673
-
674
- ```
675
- a 槽 ← 正在用
676
- b 槽 ← 上一次的,还活着(回退用)
677
-
678
- 续期: b 槽写新的 → current 切到 b → a 槽那把留着自然过期
679
- 403 : 当前那把标失效(expiresAt=0)→ current 切到另一槽 → 重试一次
680
- 两槽都空:从 magpie 的 plugin-auth.json 接管一次
681
- ```
682
-
683
- **为什么敢双槽**:实测同一账号两把 `mgw_` key 可以同时有效。
684
- **为什么不需要"闲置池"**:实测一把 key 并发 6 个请求全成功 —— 它是账号级无状态凭据,
685
- 不存在"被占用 / 打架"。
686
-
687
- ### 实测记录(2026-10-27)
688
-
689
- ```
690
- 续期前 槽a = mgw_55accec4… | refreshToken: 有
691
- 续期后 槽b = mgw_06713b5e… | 到期 20:23:17
692
- 回退槽 = mgw_55accec4… ← 保留
693
- 新 key 打上游 /v1/models → 正常返回模型列表
694
-
695
- 账号隔离:h97gkbu8bruj 与 otherOrg999 各自一份文件,互不覆盖
696
-
697
- 并发:同一把 key 发 6 个 chat.completions → #0✓ #1✓ #2✓ #3✓ #4✓ #5✓(18.1s)
698
- ```
699
-
700
- ### ⚠ 已知未解决
701
-
702
- `403 自动切槽`在实测里没触发成功过一次(单独测 `switchSlot` 是好的,接进 loader 后
703
- 没观察到切换)。下次排查先看:`key.apiKey` 与 `switchSlot` 里比较的那把是否同一个对象、
704
- `body` 是否真的是字符串。
705
-
706
- ### ⚠ 升级到按账号命名时,旧文件必须清掉
707
-
708
- 0.3.2 之前文件叫 `magicrew-key-<站点>.json`(按站点);
709
- 0.3.3 起叫 `magicrew-key-<站点>__<账号>.json`(按账号)。
710
-
711
- 两套命名并存时,旧文件**不会被新代码读到**(文件名不匹配),但它会让人误判现场:
712
- 排查时看到 `magicrew-key-magicrew.json` 里躺着一把旧 key,会以为那是在用的。
713
-
714
- 实测踩到:旧文件残留 + magpie 重启,网关连续 502 `token无效`;
715
- 删掉旧文件后立刻恢复 200。**升级时先 `rm ~/.cache/magpie/magicrew-key-*` 再重启。**
716
-
717
- ### ⚠ 重启时序
718
-
719
- magpie 刚起来时 `getAuth()` 可能还返回 null(账号未加载完),此时
720
- `accountOf(auth)` 会退成 `default`,读不到该账号的文件 → 用 magpie 暂存那把(可能死)→ 403。
721
- 几次请求后账号加载完就正常。**排查时若看到 `__default.json`,就是这个时序。**
722
-
723
- ---
724
-
725
- ## 十六、上下文窗口必须查库,不能填保守值
726
-
727
- **症状**:`pi-ai detected context overflow for model "magicrew/deepseek-v4.1-flash"`,
728
- 而会话实际还没到模型的真实上限。
729
-
730
- **根因**:magicrew 这两个接口**都不返回上下文窗口**:
731
-
732
- ```
733
- GET /api/v1/flows/models → value / label / vision / tags / configs
734
- GET /v1/models → id / object / created_at / owner_by
735
- ```
736
-
737
- (早前记的「/v1/models 有 context_length」是看错了 —— 那是 magpie 网关自己补的,
738
- 不是上游给的。)
739
-
740
- 于是插件一律填 `256_000` 当保守值。对 `deepseek-v4.1-flash` 来说这是错的:
741
- 它真实窗口 **1,048,576**,填 256K 等于把容量砍掉四分之三,agent 拿这个数判断"放不下"
742
- 就拒绝了 —— 保守值在这里反而变成了故障源。
743
-
744
- **修法**(0.3.4):向 magpie 自己的模型库查 —— `~/.cache/magpie/models.json`,
745
- 每条带 `limit.context`,magpie 自己会更新它。
746
-
747
- 匹配顺序:全名 → 尾段(`deepseek-ai/deepseek-v4.1-flash` ↔ `deepseek-v4.1-flash`)→
748
- 同尾段取最大(同一个模型挂多个厂商前缀时,取真能收的那个)。查不到才退回 256K。
749
-
750
- 实测值(2026-10-27):
751
-
752
- | 模型 | context |
753
- |---|---|
754
- | deepseek-v4.1-flash | 1,048,576 |
755
- | deepseek-v4-flash | 1,048,576 |
756
- | qwen3.5-flash | 1,000,000 |
757
- | kimi-k3.1-pro | 1,048,576 |
758
- | mimo-v2.6-flash | 1,048,576 |
759
- | glm-5.3-flash | 262,144 |
760
-
761
- ⚠ 保守值的方向要想清楚:**填小了会让 agent 过早拒绝(这次就是这个)**,
762
- 填大了会让 agent 以为有余量、请求超了上游才报错。有权威来源时用权威来源,
763
- 没有时才选保守 —— 而"保守"在两者之间是矛盾的,得看哪个代价更小。
764
-
765
- ### 上下文窗口的优先级(0.3.5 起)
766
-
767
- ```
768
- ① magpie GUI 里「上下文窗口」那一行手选的 ← 最优先,magpie 直接采用
769
- ② ~/.cache/magpie/models.json 的 limit.context ← 插件查到的真实值
770
- ③ 兜底 1_048_576
771
- ```
772
-
773
- **兜底为什么给大不给小**:
774
- - 给 1M 而实际只有 128K → 上游超限报错,**用户看得见**
775
- - 给 256K 而实际有 1M → agent 主动拒绝本可以处理的对话,**静默损失能力**,
776
- 表现成 `pi-ai detected context overflow`,比报错难查得多
777
-
778
- 参照官方社区插件:
779
- - **Qoder** 纯硬编码(`{ id: "ultimate", context: 1_000_000 }`)
780
- - **WorkBuddy** 硬编码一张 `[id, name, context, efforts]` 表,再从自家 `/v3/config`
781
- 的 `maxInputTokens` 覆盖(`m.context = d.maxInputTokens`)
782
-
783
- magicrew 既不返回 `maxInputTokens`(`flows/models` 的 `configs` 只有 temperature),
784
- `/v1/models` 也只有 `id / object / created_at / owner_by` —— 所以走模型库这条路。
677
+
678
+ ---
679
+
680
+ ## 十五、key 按账号隔离 + 双槽(0.3.3)
681
+
682
+ ### 为什么必须按账号分文件
683
+
684
+ 同一个站点可以登多个账号(magpie 里显示成 `magicrew` / `magicrew#1` / …)。
685
+ key 若按站点存一份,第二个账号登录时会把第一个账号的 key 覆盖掉 —— 第一个账号从此 403,
686
+ 而且看起来像"随机失效"。
687
+
688
+ 落盘:`~/.cache/magpie/magicrew-key-<站点>__<账号>.json`,账号用 `org`(组织码,登录后固定)。
689
+
690
+ ### 双槽怎么转
691
+
692
+ ```
693
+ a 槽 ← 正在用
694
+ b 槽 ← 上一次的,还活着(回退用)
695
+
696
+ 续期: b 槽写新的 → current 切到 b → a 槽那把留着自然过期
697
+ 403 : 当前那把标失效(expiresAt=0)→ current 切到另一槽 → 重试一次
698
+ 两槽都空:从 magpie 的 plugin-auth.json 接管一次
699
+ ```
700
+
701
+ **为什么敢双槽**:实测同一账号两把 `mgw_` key 可以同时有效。
702
+ **为什么不需要"闲置池"**:实测一把 key 并发 6 个请求全成功 —— 它是账号级无状态凭据,
703
+ 不存在"被占用 / 打架"。
704
+
705
+ ### 实测记录(2026-10-27)
706
+
707
+ ```
708
+ 续期前 槽a = mgw_55accec4… | refreshToken: 有
709
+ 续期后 槽b = mgw_06713b5e… | 到期 20:23:17
710
+ 回退槽 = mgw_55accec4… ← 保留
711
+ 新 key 打上游 /v1/models → 正常返回模型列表
712
+
713
+ 账号隔离:h97gkbu8bruj 与 otherOrg999 各自一份文件,互不覆盖
714
+
715
+ 并发:同一把 key 发 6 个 chat.completions → #0✓ #1✓ #2✓ #3✓ #4✓ #5✓(18.1s)
716
+ ```
717
+
718
+ ### ⚠ 已知未解决
719
+
720
+ `403 自动切槽`在实测里没触发成功过一次(单独测 `switchSlot` 是好的,接进 loader 后
721
+ 没观察到切换)。下次排查先看:`key.apiKey` 与 `switchSlot` 里比较的那把是否同一个对象、
722
+ `body` 是否真的是字符串。
723
+
724
+ ### ⚠ 升级到按账号命名时,旧文件必须清掉
725
+
726
+ 0.3.2 之前文件叫 `magicrew-key-<站点>.json`(按站点);
727
+ 0.3.3 起叫 `magicrew-key-<站点>__<账号>.json`(按账号)。
728
+
729
+ 两套命名并存时,旧文件**不会被新代码读到**(文件名不匹配),但它会让人误判现场:
730
+ 排查时看到 `magicrew-key-magicrew.json` 里躺着一把旧 key,会以为那是在用的。
731
+
732
+ 实测踩到:旧文件残留 + magpie 重启,网关连续 502 `token无效`;
733
+ 删掉旧文件后立刻恢复 200。**升级时先 `rm ~/.cache/magpie/magicrew-key-*` 再重启。**
734
+
735
+ ### ⚠ 重启时序
736
+
737
+ magpie 刚起来时 `getAuth()` 可能还返回 null(账号未加载完),此时
738
+ `accountOf(auth)` 会退成 `default`,读不到该账号的文件 → 用 magpie 暂存那把(可能死)→ 403。
739
+ 几次请求后账号加载完就正常。**排查时若看到 `__default.json`,就是这个时序。**
740
+
741
+ ---
742
+
743
+ ## 十六、上下文窗口必须查库,不能填保守值
744
+
745
+ **症状**:`pi-ai detected context overflow for model "magicrew/deepseek-v4.1-flash"`,
746
+ 而会话实际还没到模型的真实上限。
747
+
748
+ **根因**:magicrew 这两个接口**都不返回上下文窗口**:
749
+
750
+ ```
751
+ GET /api/v1/flows/models → value / label / vision / tags / configs
752
+ GET /v1/models → id / object / created_at / owner_by
753
+ ```
754
+
755
+ (早前记的「/v1/models 有 context_length」是看错了 —— 那是 magpie 网关自己补的,
756
+ 不是上游给的。)
757
+
758
+ 于是插件一律填 `256_000` 当保守值。对 `deepseek-v4.1-flash` 来说这是错的:
759
+ 它真实窗口 **1,048,576**,填 256K 等于把容量砍掉四分之三,agent 拿这个数判断"放不下"
760
+ 就拒绝了 —— 保守值在这里反而变成了故障源。
761
+
762
+ **修法**(0.3.4):向 magpie 自己的模型库查 —— `~/.cache/magpie/models.json`,
763
+ 每条带 `limit.context`,magpie 自己会更新它。
764
+
765
+ 匹配顺序:全名 → 尾段(`deepseek-ai/deepseek-v4.1-flash` ↔ `deepseek-v4.1-flash`)→
766
+ 同尾段取最大(同一个模型挂多个厂商前缀时,取真能收的那个)。查不到才退回 256K。
767
+
768
+ 实测值(2026-10-27):
769
+
770
+ | 模型 | context |
771
+ |---|---|
772
+ | deepseek-v4.1-flash | 1,048,576 |
773
+ | deepseek-v4-flash | 1,048,576 |
774
+ | qwen3.5-flash | 1,000,000 |
775
+ | kimi-k3.1-pro | 1,048,576 |
776
+ | mimo-v2.6-flash | 1,048,576 |
777
+ | glm-5.3-flash | 262,144 |
778
+
779
+ ⚠ 保守值的方向要想清楚:**填小了会让 agent 过早拒绝(这次就是这个)**,
780
+ 填大了会让 agent 以为有余量、请求超了上游才报错。有权威来源时用权威来源,
781
+ 没有时才选保守 —— 而"保守"在两者之间是矛盾的,得看哪个代价更小。
782
+
783
+ ### 上下文窗口的优先级(0.3.5 起)
784
+
785
+ ```
786
+ ① magpie GUI 里「上下文窗口」那一行手选的 ← 最优先,magpie 直接采用
787
+ ② ~/.cache/magpie/models.json 的 limit.context ← 插件查到的真实值
788
+ ③ 兜底 1_048_576
789
+ ```
790
+
791
+ **兜底为什么给大不给小**:
792
+ - 给 1M 而实际只有 128K → 上游超限报错,**用户看得见**
793
+ - 给 256K 而实际有 1M → agent 主动拒绝本可以处理的对话,**静默损失能力**,
794
+ 表现成 `pi-ai detected context overflow`,比报错难查得多
795
+
796
+ 参照官方社区插件:
797
+ - **Qoder** 纯硬编码(`{ id: "ultimate", context: 1_000_000 }`)
798
+ - **WorkBuddy** 硬编码一张 `[id, name, context, efforts]` 表,再从自家 `/v3/config`
799
+ 的 `maxInputTokens` 覆盖(`m.context = d.maxInputTokens`)
800
+
801
+ magicrew 既不返回 `maxInputTokens`(`flows/models` 的 `configs` 只有 temperature),
802
+ `/v1/models` 也只有 `id / object / created_at / owner_by` —— 所以走模型库这条路。
@@ -0,0 +1,77 @@
1
+ # 发 npm:`magicrew-auth`
2
+
3
+ > 线上:https://www.npmjs.com/package/magicrew-auth
4
+ > 首次发布 2026-10-27,`0.3.5`。**这个包名已经是我们的,之后只增版本,不再改名。**
5
+
6
+ ## 一条命令
7
+
8
+ ```bash
9
+ npm run publish:npm # 真发
10
+ npm run publish:npm -- --dry-run # 预演,看包名与打包面,不上传
11
+ ```
12
+
13
+ 脚本是 `publish-npm.mjs`,做的事只有一件:**发布那一下把 `name` 换成 `magicrew-auth`,跑完还原。**
14
+
15
+ ## 为什么必须换名
16
+
17
+ registry 拒绝含大写字母的**新**包名,而 `package.json` 的 `name` 要同时喂两个地方:
18
+
19
+ | 谁读 | 读哪份 | 决定什么 |
20
+ |---|---|---|
21
+ | `bun`(`magpie plugin add github:…`) | 仓库里的 `package.json` | 依赖键 → **卡片标题 `Magicrew`** |
22
+ | `npm publish` | 同一份 | npm 上的**包名** → 必须小写 `magicrew-auth` |
23
+
24
+ 所以 npm 装的卡片标题是 `magicrew-auth`,git 装的卡片标题是 `Magicrew` —— 功能一样,只是标题。
25
+ FAQ 里对用户写过这条,别把它当 bug 修。
26
+
27
+ 还原是硬要求:脚本在 `finally` 之外还挂了 `exit` / `SIGINT` 钩子。实测跑完
28
+ `sha256sum package.json` 与跑前逐字节一致。
29
+
30
+ ## 发之前的三条
31
+
32
+ 1. **bump `version`** —— 同版本号 publish 会被 registry 拒。
33
+ 2. **`npm view magicrew-auth version` 看现在线上的版本**,别发重复。
34
+ 3. README 会随包发出去,成为 magpie 卡片正文 —— **npm 用户的卡片只有发版才更新**
35
+ (git 源用户 push 即见)。所以 README 改完要跟着一个版本走。
36
+
37
+ ## 发完之后会看到 `0.0.0-stage`,别慌
38
+
39
+ registry 上会先多出一个占位版本:
40
+
41
+ ```json
42
+ { "version": "0.0.0-stage", "stub": true,
43
+ "description": "Temporary package placeholder for staged publishing" }
44
+ ```
45
+
46
+ `dist-tags.latest` 可能先指向它,几秒后指向真版本;`npm stage list` 显示「没有 staged 版本」。
47
+ 判据:
48
+
49
+ ```bash
50
+ curl -s https://registry.npmjs.org/magicrew-auth | python -c "import json,sys;d=json.load(sys.stdin);print(d['dist-tags'])"
51
+ # {'latest': '0.3.5'}
52
+ curl -s -o /dev/null -w '%{http_code}\n' https://registry.npmjs.org/magicrew-auth/0.3.5
53
+ # 200
54
+ ```
55
+
56
+ ## 验收(2026-10-27 实测)
57
+
58
+ ```
59
+ npm view magicrew-auth version → 0.3.5
60
+ npm view magicrew-auth dist-tags → { latest: '0.3.5' }
61
+ npm i magicrew-auth && node -e "import('magicrew-auth')"
62
+ → exports: MagicrewAuthPlugin, MagicrewIntlAuthPlugin, SITES, _internal
63
+ tarball 26 files / 118.5 kB,README 是当期版本
64
+ ```
65
+
66
+ ## ⚠ npm 搜索搜不到,不等于包有问题
67
+
68
+ magpie 的发现搜索是 `/-/v1/search?text=<query> opencode`。实测:
69
+
70
+ | 查询 | 结果 |
71
+ |---|---|
72
+ | `text=magicrew` | total 1,命中 |
73
+ | `text=magicrew opencode` | total 18500,**前 250 条里没有它** |
74
+ | `text=letsmagic` | total 1,命中 |
75
+
76
+ 零下载的新包在混合查询里会被 opencode 生态的高下载包淹没。**能保证被发现的路径是 GitHub topic**
77
+ (仓库已带 `magpie-plugin` / `magpie` / `opencode` → Discover → Unofficial)。搜索排名随下载量积累会好转。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magicrew-auth",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
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",
@@ -13,22 +13,69 @@
13
13
  // 页面是我们自己开的,告诉 magpie 「别再开一次」。
14
14
  //
15
15
  // ⚠ 独立 profile 意味着用户要在这个窗口里登一次(没有他日常浏览器的 cookie)。
16
- // 所以 profile 目录**不删**,下次还能复用登录态:~/.cache/magpie/magicrew-login-profile
16
+ // **每次登录一个一次性 profile,用完删** —— 为什么不能复用,见下面 newProfileDir()。
17
17
 
18
18
  import { spawn } from "node:child_process"
19
- import { existsSync, readFileSync, mkdirSync } from "node:fs"
19
+ import { existsSync, readFileSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs"
20
20
  import { homedir } from "node:os"
21
21
  import { join } from "node:path"
22
22
 
23
- /** 复用同一个 profile:第二次登录时里面已经有 cookie 了 */
24
- function profileDir() {
25
- const d = join(homedir(), ".cache", "magpie", "magicrew-login-profile")
26
- try {
27
- mkdirSync(d, { recursive: true })
28
- } catch {}
23
+ const BASE = () => join(homedir(), ".cache", "magpie")
24
+
25
+ /**
26
+ * 每次登录一个**一次性** profile 目录,登录结束就删。
27
+ *
28
+ * ⚠ 这里曾经刻意复用同一个目录(旧注释:`复用同一个 profile:第二次登录时里面已经有 cookie 了`),
29
+ * 那是个 bug —— 现象是「添加第二个账号时,网页正确弹出、然后瞬间关闭,
30
+ * magpie 里也没多出账号」:
31
+ *
32
+ * ① 复用目录里的 localStorage 还躺着**上一个账号**的 jwt + org;
33
+ * ② 浏览器一打开,`waitForCreds` 第一轮(1.5 秒内)就读到它 → 判定「登录完成」→ 关窗;
34
+ * ③ magpie 拿到的 accountId / uid 与已有账号一模一样,按契约
35
+ * (同一 accountId + 同一 uid → 替换旧账号)不会新增账号。
36
+ *
37
+ * 一次性目录把这条路彻底堵死:读到的凭据只可能是本次登录写进去的。
38
+ * 代价是每次都要在这个窗口里重新登一次 —— 独立 profile 本来也没有他日常浏览器的 cookie。
39
+ */
40
+ export function newProfileDir() {
41
+ const d = join(
42
+ BASE(),
43
+ `magicrew-login-${process.pid}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
44
+ )
45
+ mkdirSync(d, { recursive: true })
29
46
  return d
30
47
  }
31
48
 
49
+ /**
50
+ * 清残留:超过 maxAgeMs 的一次性 profile,以及历史上那个**复用**目录
51
+ * (`magicrew-login-profile`,里面装着别的账号的 cookie —— 正是上面那个 bug 的来源)。
52
+ * 只删本插件自己造的名字,别的一概不碰。
53
+ */
54
+ export function sweepProfiles({ maxAgeMs = 2 * 3600_000 } = {}) {
55
+ let names = []
56
+ try {
57
+ names = readdirSync(BASE())
58
+ } catch {
59
+ return 0
60
+ }
61
+ let removed = 0
62
+ for (const name of names) {
63
+ const legacy = name === "magicrew-login-profile"
64
+ if (!legacy && !/^magicrew-login-/.test(name)) continue
65
+ const p = join(BASE(), name)
66
+ try {
67
+ // ⚠ 只动目录:同前缀的日志文件(magicrew-login-*.log)不是 profile
68
+ const st = statSync(p)
69
+ if (!st.isDirectory()) continue
70
+ // 遗留的复用目录不管多新都删;一次性目录只在过期后删(可能正有别的账号在登)
71
+ if (!legacy && Date.now() - st.mtimeMs < maxAgeMs) continue
72
+ rmSync(p, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
73
+ removed++
74
+ } catch {}
75
+ }
76
+ return removed
77
+ }
78
+
32
79
  /** 按平台找浏览器;找不到返回 null,调用方降级 */
33
80
  export function findBrowser() {
34
81
  const plat = process.platform
@@ -91,8 +138,11 @@ export async function launchLoginBrowser(url) {
91
138
  const exe = findBrowser()
92
139
  if (!exe) throw new Error("本机没找到 Chrome / Edge / Bravo / Chromium")
93
140
 
94
- const dir = profileDir()
95
- // ⚠ 上一次的残留会让这次「看起来起来了、其实什么都没做」:
141
+ // 先扫掉上一次登录留下的临时 profile(含历史复用目录),再开一个全新的
142
+ sweepProfiles()
143
+ const dir = newProfileDir()
144
+ // ⚠ 残留会让这次「看起来起来了、其实什么都没做」—— 一次性目录里通常没有,
145
+ // 但 sweep 失败、或目录被复用时它们就是根因,清一遍几乎不花钱:
96
146
  // DevToolsActivePort —— 不清掉,waitPort 会立刻返回一个已失效的旧端口
97
147
  // SingletonLock / SingletonCookie / SingletonSocket / lockfile
98
148
  // —— 不清掉,新进程判定 profile 被占用,把 URL 转交给
@@ -128,6 +178,9 @@ export async function launchLoginBrowser(url) {
128
178
  try {
129
179
  child.kill()
130
180
  } catch {}
181
+ try {
182
+ rmSync(dir, { recursive: true, force: true, maxRetries: 2, retryDelay: 150 })
183
+ } catch {}
131
184
  throw new Error("浏览器进程起来了,但 8 秒内没写出调试端口 DevToolsActivePort(多半是 profile 被一个已退出的实例占着)")
132
185
  }
133
186
  return { port, dir, child }
@@ -229,16 +282,31 @@ const READ_CREDS = `(() => {
229
282
 
230
283
  /**
231
284
  * 盯着那个浏览器,直到页面里的 localStorage 出现完整凭据。
232
- * @returns {Promise<{jwt:string, org:string} | null>}
285
+ *
286
+ * `baseline` = 「窗口打开时就已经在 localStorage 里的那份」。传进来的话,
287
+ * 只有**跟它不一样**的凭据才算登录成功。一次性 profile 下它恒为空,
288
+ * 留着是第二道防线:万一哪天又改回复用目录,这条判据能挡住「读到上一个账号」。
289
+ *
290
+ * @param {{jwt:string, org:string}|null} [opts.baseline]
291
+ * @param {(port:number, host:string, expr:string)=>Promise<string>} [opts.probe] 测试注入用
292
+ * @returns {Promise<{jwt:string, org:string, name?:string} | null>}
233
293
  */
234
- export async function waitForCreds(session, hostFragment, { timeout = 300_000, interval = 1500 } = {}) {
294
+ export async function waitForCreds(
295
+ session,
296
+ hostFragment,
297
+ { timeout = 300_000, interval = 1500, baseline = null, probe = evaluate } = {},
298
+ ) {
299
+ const base = baseline ?? session?.baseline ?? null
235
300
  const deadline = Date.now() + timeout
236
301
  while (Date.now() < deadline) {
237
302
  try {
238
- const raw = await evaluate(session.port, hostFragment, READ_CREDS)
303
+ const raw = await probe(session.port, hostFragment, READ_CREDS)
239
304
  if (raw) {
240
305
  const o = JSON.parse(raw)
241
- if (o?.jwt?.startsWith("eyJ") && o?.org) return o
306
+ if (o?.jwt?.startsWith("eyJ") && o?.org) {
307
+ const same = base && o.jwt === base.jwt && o.org === base.org
308
+ if (!same) return o
309
+ }
242
310
  }
243
311
  } catch {
244
312
  // 页面还在加载 / target 换了 —— 下一轮再试
@@ -249,13 +317,17 @@ export async function waitForCreds(session, hostFragment, { timeout = 300_000, i
249
317
  }
250
318
 
251
319
  /**
252
- * 关掉这个浏览器窗口。profile 留着,下次复用登录态。
320
+ * 关掉这个浏览器窗口,然后把这一次的临时 profile 删掉。
253
321
  *
254
322
  * 两条路都要走:
255
- * ① CDP 的 `Browser.close` —— 走浏览器自己的退出流程,cookie 会被刷到磁盘
256
- * (profile 里没有落盘的 cookie,下次登录还得再来一次)
323
+ * ① CDP 的 `Browser.close` —— 走浏览器自己的退出流程,进程干净退出
257
324
  * ② 兜底 kill —— ①在 2 秒内没关掉就强杀整棵进程树
258
- * (Windows 上 kill() 只杀主进程,渲染/GPU 子进程会留着占住 profile)
325
+ * (Windows 上 kill() 只杀主进程,渲染/GPU 子进程会留着占住 profile,
326
+ * 后面的 rmSync 也会因为文件锁失败)
327
+ *
328
+ * ⚠ profile 是**一次性**的,删掉是对的:里面只有这次登录的 cookie,
329
+ * 留着只会让下一次登录读到上一个账号(见 newProfileDir 的注释)。
330
+ * 删不掉不影响正确性 —— 下次 launch 会先 sweepProfiles()。
259
331
  */
260
332
  export async function closeSession(session) {
261
333
  if (!session) return
@@ -302,21 +374,35 @@ export async function closeSession(session) {
302
374
 
303
375
  // ② 兜底:还活着就强杀整棵进程树
304
376
  const pid = session.child?.pid
305
- if (!pid) return
306
- try {
307
- if (process.platform === "win32") {
308
- await new Promise((resolve) => {
309
- const k = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true })
310
- k.on("exit", resolve)
311
- k.on("error", resolve)
312
- setTimeout(resolve, 3000)
313
- })
314
- } else {
315
- try {
316
- session.child.kill("SIGKILL")
317
- } catch {}
377
+ if (pid) {
378
+ try {
379
+ if (process.platform === "win32") {
380
+ await new Promise((resolve) => {
381
+ const k = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true })
382
+ k.on("exit", resolve)
383
+ k.on("error", resolve)
384
+ setTimeout(resolve, 3000)
385
+ })
386
+ } else {
387
+ try {
388
+ session.child.kill("SIGKILL")
389
+ } catch {}
390
+ }
391
+ } catch {}
392
+ }
393
+
394
+ // ③ 删掉这一次的临时 profile。文件锁通常要等子进程死透才放,
395
+ // 所以给它几次机会;删不掉就算了,下次 launch 会 sweep。
396
+ const dir = session.dir
397
+ if (!dir) return
398
+ for (let i = 0; i < 5; i++) {
399
+ try {
400
+ rmSync(dir, { recursive: true, force: true, maxRetries: 2, retryDelay: 150 })
401
+ return
402
+ } catch {
403
+ await new Promise((r) => setTimeout(r, 200))
318
404
  }
319
- } catch {}
405
+ }
320
406
  }
321
407
 
322
- export const _internal = { READ_CREDS, profileDir, waitPort, evaluate }
408
+ export const _internal = { READ_CREDS, newProfileDir, sweepProfiles, waitPort, evaluate }