mslxdff 0.1.194 → 0.1.199

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.
@@ -78,6 +78,7 @@
78
78
  | zcode 接入 | `-provider zcode login [--bigmodel]` | ZCode(智谱官方编程工作台)免费额度授权:打印 chat.z.ai 授权链接→浏览器登录→CLI 轮询(`oauth/cli/init`→`poll`)拿 JWT,落盘 `auths/zcode-<uid>.json`+state 并把内置目录(`GLM-5.3`/`GLM-5.3-Flash`/`GLM-5.2`/`GLM-5-Turbo`)并入 allowlist;`zcode/<modelId>` 前缀路由(Anthropic 网关,恒上游 stream,**免验证码**);`--bigmodel` 切 bigmodel.cn;恒 local-only 不借出 |
79
79
  | zcode 额度 | `-provider zcode quota [--json]` | 查 ZCode 套餐名/状态/有效期 + 各模型余量(`billing/balance` 分组表格);空套餐给领取指引、401 给重登指引 |
80
80
  | raccoon 接入 | `-provider raccoon login` | Raccoon(商汤小浣熊)扫码登录:生成 `qrcode_code`→终端二维码 + `login/mp?code=` 链接→**用微信「扫一扫」扫**并确认(浏览器直接打开链接会 404,站点 SPA 无该路由)→轮询拿 token,落盘 `auths/raccoon-<uid>.json`+state(`keys` 一律以文档为准,续期后自愈)并把兜底 6 模型并入 allowlist;`raccoon/<modelId>` 前缀路由(**OpenAI 兼容网关**,**免验证码**);多号自动轮换:请求内撞积分不足/限流就当场换下一个号,**暂不支持手动指定用哪个号**;积分不足长冷 1h、限流短冷 30s;恒 local-only 不借出 |
81
+ | raccoon 密码登录 | `-provider raccoon login --password` | Raccoon **手机号+密码登录**(多号免扫码):走 IAM 明文端点(`/api/iam/v1/auth/login/password/phone`,**不用**官方那条要加密手机号的 web 端点);TTY 下密码**关回显**(逐键 `*`),非 TTY 须 `--password=<密码>`;默认 `--nation 86`;失败给人话(「手机号或密码不正确」),回执回显手机号但**打码**、**不回显密码**;落盘/确认/领奖与扫码登录完全同路 |
81
82
  | raccoon 积分 | `-provider raccoon quota [--json]` / `checkin` | 逐号查积分五分项 / 领「桌面端登录奖励」——**这家没有每日签到**:每日 300 与注册礼包 3000 由服务端自动发(无端点,每日 300 当天 23:59:59 清零),唯一要领的登录奖励 3000 是**每号一次性**且 `login` 时已自动领掉,`checkin` 只是补领兜底;判据只认账单、不认上游 `code:0`(重复领会虚回成功);未登录给 login 指引;daemon 每日 09:00 兜底扫描未领过的号(`MSLXDFF_RACCOON_CHECKIN=0` 关,`_CHECKIN_HOUR` 改点) |
82
83
  | WorkBuddy 成长任务 | `-workbuddy growth [--json]` / `-wb growth` | 成长任务全自动(拉列表→参与→触发→领奖,串行 1.2s/1s,已领幂等跳过);可自动 `chat_5`/`automation_1`/`skill_1`/`Model_chat_GLM5.2`,需客户端任务标 MANUAL 不发包;daemon 每日 09:30 自动(`MSLXDFF_WORKBUDDY_GROWTH=0` 关,`_GROWTH_HOUR`/`_GROWTH_MODEL` 可配) |
83
84
  | WorkBuddy 猫猫旅行 | `-workbuddy travel [--json]` / `-wb travel` | 无猫自动同意协议+领养(+300,门槛未达自动补一次对话解锁);到站领奖 / 空闲派出(location 4)/ 旅行中跳过 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.194",
3
+ "version": "0.1.199",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -60,13 +60,15 @@ export async function runSerialTrial(ctx, deps = {}) {
60
60
  }
61
61
 
62
62
  let lastErr = viaRouteLastErr;
63
- // 指定 free 模型的同池互备:order 里带了兄弟模型,但只有**额度类**失败才许继续换。
64
- // 参数错/5xx/空转等仍按"显式指定即报错"的旧口径收场,别把客户端自己的错伪装成换模型成功。
65
- const canFailover = (err) => canFallback && (!poolFallback || isPoolSwitchWorthy(err ?? lastErr));
63
+ // BUG-42 同池互备判据:poolWorthy 记「本模型直连时被上游判过额度/终局」,接力揉的 502 冲不掉它(详见 ADR-0061)。
64
+ let poolWorthy = false;
65
+ const canFailover = (err) =>
66
+ canFallback && (!poolFallback || isPoolSwitchWorthy(err ?? lastErr) || poolWorthy);
66
67
  // 请求级空轮等待累计(跨候选):阶梯是「每发候选」各算的,不设顶就会 N×(2+8+30) 把客户端吊在门外
67
68
  let requestWaitedMs = 0;
68
69
  candidate: for (let idx = 0; idx < order.length; idx++) {
69
70
  const model = order[idx];
71
+ poolWorthy = false; // 每候选独立记账:上一发 429 不代表这一发也 429
70
72
  handlerCtx.model = model;
71
73
  handlerCtx.orderLen = order.length;
72
74
  handlerCtx.idx = idx;
@@ -154,11 +156,11 @@ export async function runSerialTrial(ctx, deps = {}) {
154
156
  upBody = upRaw.replace(/\s+/g, " ").slice(0, 400);
155
157
  const upMsg = upBody || `upstream ${upRes.status}`;
156
158
  lastErr = { model, upstream: upRes, status: upRes.status, message: upMsg };
157
- // 终局判决短路(判据与现网取证见 terminal-verdict.js):上游把「这个模型你别想用」——免费层身份
158
- // 闸门 / 模型 id 不存在——写死在错误里时,换组员撞的是同一堵墙(实测组员回同一条 FreeTierError),
159
- // 接力只会把 6s 的真相拖成 70s 的 502 "peers failed"。逃生阀 MSLXDFF_TERMINAL_SHORTCIRCUIT=0。
159
+ // 终局判决短路(判据与现网取证见 terminal-verdict.js)
160
160
  const verdict = classifyTerminalUpstream(upRes.status, upRaw);
161
161
  const terminalOn = verdict.terminal && !terminalShortcircuitOff();
162
+ // BUG-42 记账点:本机直连时上游判过额度/终局 ⇒ 换兄弟有账可依(接力冲不掉,详见 ADR-0061)
163
+ if (isPoolSwitchWorthy({ status: upRes.status, terminal: verdict.terminal })) poolWorthy = true;
162
164
  // 一行话把「判决」与「原文」一起落 errors.log(不分两行:同一发失败在 errors.log 里只该有一条)
163
165
  logError(model, upRes.status, `upstream ${upRes.status}${upBody ? ` body=${upBody.slice(0, 300)}` : ""}${terminalOn ? ` · 终局判决 ${verdict.kind}(跳过组员接力)` : ""}`);
164
166
  evt("upstream-error", { reqId, model, status: upRes.status, message: upMsg.slice(0, 300), timing: upRes._t ?? null, ...upstreamEcho(upRes) });
@@ -19,12 +19,16 @@
19
19
  // 防上游把同一判决挪到 400/404 就失明。
20
20
  const FREE_TIER_PATTERNS = [
21
21
  /FreeTierError/i,
22
- // 额度耗尽(具名判决)。裸 "Rate limit exceeded" 不在此列——那可能只是这一发撞上限流,换号换节点
23
- // 就恢复;只有上游**具名**说"你的免费额度用完了"才是对同源组员同样成立的判决。
24
- // 实测(errors.log,2026-10-16):muse-spark / ll-alpha 的 429 体是
25
- // `{"error":{"type":"FreeUsageLimitError","message":"Rate limit exceeded..."}}`,旧判据漏掉它
26
- // ⇒ 白跑组员接力 70s 才回错。escape hatch 同 MSLXDFF_TERMINAL_SHORTCIRCUIT=0。
27
- /FreeUsageLimitError/i,
22
+ // ⚠ `FreeUsageLimitError` 曾列在此(BUG-8 于 2026-10-16 加入)**现已移除**(2026-10-17 纠正):
23
+ // 它是**限额/限流**判决,不是身份闸门。三条独立实证都证明「换出口/换组员就恢复」⇒ 属可接力:
24
+ // ① 本仓自测记录(warp-pool/verdict.js:9-10):同一身份连发 3 次 429 FreeUsageLimitError,
25
+ // **身份一换、同一个请求立刻 200**(限额按出口 IP 计);
26
+ // ② warp-pool 的决策表(verdict.js:62)对 429 FreeUsage 明写「强制换出口、跳过冷却」;
27
+ // ③ v6 池(v6pool-claim-rotate.test.js:140)同样据它换源地址。
28
+ // 更根本:ADR-0058 §2 本就把「401/402/**429**/5xx」明列为**可恢复、接力照常**;本文件头 :9-11 也是
29
+ // 同一口径。BUG-8 把 429 的具名文案判成终局,偏离了本模块自己写下的边界 —— 现网代价是
30
+ // 组员额度还在、仍被 `peersSkipped:true` 一刀跳过(2026-10-18 events.log 满屏实证)。
31
+ // 改由 TRANSIENT_MARKERS 显式排除(不依赖 message 里偶然出现的 "rate limit")。
28
32
  // 具名资格判决:上游明说「你没订阅这个模型套餐」。它比 free 层判决更硬——免费层判决还可能随
29
33
  // 上游口径变化,套餐缺失则是**换号换节点都不会变**的判决,属本判据射程内却曾被漏判。
30
34
  // 实测(errors.log,2026-10-17 两次逐字相同,cline/cline-pass/deepseek-v4.1-flash):
@@ -56,6 +60,11 @@ const MODEL_GONE_PATTERNS = [
56
60
  // 反向护栏:形状像终局但语义是「这个号/这一发不行」,换号换节点可能就好——绝不终局。
57
61
  // 只认明确凭据/限流语义词,不收裸 `token`:`max_tokens` 之类的参数报错会被它误判成可接力。
58
62
  const TRANSIENT_MARKERS = [
63
+ // 限额/限流具名判决 ⇒ **可恢复**(换出口换组员就恢复,见 FREE_TIER_PATTERNS 上方三条实证)。
64
+ // 显式列出而非指望 message 里的 "rate limit" 字样:上游改文案时这层不能跟着失明
65
+ // (`FreeUsageLimitError` 的类名比它的 message 稳定得多)。
66
+ // GoUsageLimitError 与它同族(warp-pool/verdict.js:25 把两者并列),同样按出口计、同样可换。
67
+ /FreeUsageLimitError|GoUsageLimitError/i,
59
68
  /security_reject/i,
60
69
  /安全校验拒绝/,
61
70
  /isQueued/i,
@@ -47,6 +47,7 @@ export async function handleProvider(args) {
47
47
  console.error(" mslxdff -provider zcode login [--bigmodel] ZCode 授权(智谱免费额度,Start/Coding Plan)");
48
48
  console.error(" mslxdff -provider zcode quota [--json] 查看套餐/余量(未登录给重登指引)");
49
49
  console.error(" mslxdff -provider raccoon login Raccoon(商汤小浣熊)扫码登录(终端二维码,免验证码)");
50
+ console.error(" mslxdff -provider raccoon login --password Raccoon 手机号+密码登录(多号免扫码;密码输入不回显)");
50
51
  console.error(" mslxdff -provider raccoon quota|checkin [--json] 查积分余额 / 领每日登录积分");
51
52
  console.error(" mslxdff -provider traework login|checkin|quota [--json] SOLO 国内号:登录 / 每日签到领积分 / 查签到与积分额度(quota 只读,不烧配额)");
52
53
  console.error(" mslxdff -provider openrouter clear remove all keys");
@@ -9,6 +9,7 @@ import { compatFetch } from "../../../compat.js";
9
9
  import {
10
10
  buildQrUrl,
11
11
  cancelQrSession,
12
+ confirmRaccoonSession,
12
13
  createQrSession,
13
14
  fetchRaccoonUserInfo,
14
15
  pollQrLogin,
@@ -26,6 +27,15 @@ const RELOGIN_HINT = " (可重试:mslxdff -provider raccoon login)";
26
27
  export async function handleRaccoonLogin(id, sub, rest = [], deps = {}) {
27
28
  const pid = normalizeProviderId(id) || String(id || "").toLowerCase();
28
29
  if (pid !== "raccoon") return false;
30
+ // `login --password` 走手机号+密码(多账号免扫码);无该旗标仍是扫码。分派必须在
31
+ // LOGIN_SUBS 判定之前,且只在此处转交 —— 扫码路径本身零改动。
32
+ if (LOGIN_SUBS.has(sub) && rest.some((x) => {
33
+ const s = String(x);
34
+ return s === "--password" || s.startsWith("--password=") || s === "-p" || s === "--phone" || s.startsWith("--phone=");
35
+ })) {
36
+ const { handleRaccoonPasswordLogin } = await import("./raccoon-password.js");
37
+ return handleRaccoonPasswordLogin(id, sub, rest, deps);
38
+ }
29
39
  if (!LOGIN_SUBS.has(sub)) return false;
30
40
 
31
41
  const fetchImpl = deps.fetchImpl || compatFetch;
@@ -92,7 +102,29 @@ export async function handleRaccoonLogin(id, sub, rest = [], deps = {}) {
92
102
  return true;
93
103
  }
94
104
 
95
- const credential = out.credential;
105
+ return await finishRaccoonLogin({ credential: out.credential, fetchImpl, deps, log });
106
+ }
107
+
108
+ /**
109
+ * 登录成功后的收尾四步(**扫码与密码两条路共用,单一源**):
110
+ * confirm 会话 → 取用户信息 → 落盘 + 补 allowlist → 领一次性登录奖励 → 打印回执。
111
+ *
112
+ * 抽出来的理由不只是省行数:这四步的顺序有硬约束(必须先 confirm 才能用 token;领奖必须在
113
+ * access_token 刚签发的窗口内),任何一条路径自己重写一遍都会漏掉其中一步且难以察觉。
114
+ *
115
+ * @param {object} a
116
+ * @param {object} a.credential 已签发的凭据(两种登录方式同形)
117
+ * @param {Function} a.fetchImpl
118
+ * @param {object} a.deps 与原 CLI 依赖注入同形(dir/file/deviceId/saveAccount/claimReward/skipReward/env)
119
+ * @param {Function} a.log
120
+ * @param {string} [a.methodNote] 登录方式说明行(如密码登录标注手机号),纯展示
121
+ * @returns {Promise<boolean>} 恒 true(表示「已处理」,交还 CLI 派发链)
122
+ */
123
+ export async function finishRaccoonLogin({ credential, fetchImpl, deps = {}, log, methodNote = "" }) {
124
+ // 新登录的会话是「未确认」状态:不先 POST /iam/confirm,此后所有业务端点(含下面的领奖励、
125
+ // 以及 daemon 的 chat)都会撞 `400 code 200901 iam_confirm_required`。登录时当场确认一次,
126
+ // 让落盘的凭据从一开始就是可用的;确认失败不阻断(chat.js 有懒重试兜底),只在末尾如实提示。
127
+ const confirmed = await confirmRaccoonSession(credential, { fetchImpl });
96
128
  const info = await fetchRaccoonUserInfo(credential, { fetchImpl });
97
129
  const uid = resolveRaccoonUid({
98
130
  userId: info.userId,
@@ -129,7 +161,9 @@ export async function handleRaccoonLogin(id, sub, rest = [], deps = {}) {
129
161
 
130
162
  log("");
131
163
  log(`✅ 登录成功!uid=${uid} 昵称=${info.name || "(空)"} token=${raccoonTokenFingerprint(credential.access_token)}…`);
164
+ if (methodNote) log(methodNote);
132
165
  log(` 凭据: ${saved.file}`);
166
+ if (!confirmed) log(" ⚠ 会话确认(iam/confirm)暂未成功 —— 不影响使用(调用时会自动重试确认),若持续报错请重跑 login");
133
167
  if (allow.added.length) log(` allowlist 已补齐 ${allow.added.length} 个模型:${allow.added.join(", ")}`);
134
168
  if (reward) {
135
169
  const line = {
@@ -0,0 +1,162 @@
1
+ // mslxdff -provider raccoon login --password —— 手机号 + 密码登录(多账号免扫码)。
2
+ //
3
+ // 为什么需要它:扫码登录一次只能加一个号,且要手机在手边;密码登录可以在同一个终端里
4
+ // 连着把多个号加完(脚本化也行),是「批量补号」的正路。
5
+ //
6
+ // 安全:密码**只经手本进程内存**,绝不落盘、绝不打印、绝不进 argv(除非用户显式 `--password=xxx`,
7
+ // 那种情况会在回执里提示一次「密码已进 shell 历史」)。落盘的是 token,与扫码登录同一条路。
8
+ import { compatFetch } from "../../../compat.js";
9
+ import { loginRaccoonWithPassword, normalizePhone } from "../../../providers/raccoon/password-login.js";
10
+ import { finishRaccoonLogin } from "./raccoon-login.js";
11
+
12
+ const RELOGIN_HINT = " (可重试:mslxdff -provider raccoon login --password)";
13
+
14
+ /**
15
+ * 从 rest 里取 `--key=value` 或 `--key value` 两种写法。
16
+ * **裸旗标(无值)返回 undefined**,不是空串 —— 这样 `?? deps.x` 才能正常回落,
17
+ * 否则 `"" ?? deps.x` 恒取空串,把「只写了 --password 想让程序询问」误判成「显式给了空密码」。
18
+ */
19
+ function flagValue(rest, ...names) {
20
+ for (let i = 0; i < rest.length; i++) {
21
+ const cur = String(rest[i]);
22
+ for (const name of names) {
23
+ if (cur === name) {
24
+ const next = rest[i + 1];
25
+ return typeof next === "string" && next.length > 0 && !next.startsWith("-") ? next : undefined;
26
+ }
27
+ if (cur.startsWith(`${name}=`)) {
28
+ const v = cur.slice(name.length + 1);
29
+ return v.length > 0 ? v : undefined;
30
+ }
31
+ }
32
+ }
33
+ return undefined;
34
+ }
35
+
36
+ /**
37
+ * TTY 隐藏输入:**不碰 readline**(它会自己接管 stdin 的原始模式,与我们抢同一份字节),
38
+ * 直接开 raw mode 逐字符读,每个键回显一个 `*` 给出「确实收到了」的可见反馈。
39
+ * 非 TTY 返回 null(由调用方转成「必须用 flag」的指引,绝不从管道偷读)。
40
+ */
41
+ async function promptHidden(label) {
42
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return null;
43
+ return await new Promise((resolve) => {
44
+ const wasRaw = Boolean(process.stdin.isRaw);
45
+ let buf = "";
46
+ const done = (value) => {
47
+ process.stdin.removeListener("data", onData);
48
+ try { process.stdin.setRawMode(wasRaw); } catch {}
49
+ process.stdin.pause();
50
+ process.stdout.write("\n");
51
+ resolve(value);
52
+ };
53
+ const onData = (chunk) => {
54
+ for (const ch of String(chunk)) {
55
+ if (ch === "\r" || ch === "\n") return done(buf);
56
+ if (ch === "\u0003") return done(null); // Ctrl-C
57
+ if (ch === "\u007f" || ch === "\b") { // 退格
58
+ if (buf.length > 0) { buf = buf.slice(0, -1); process.stdout.write("\b \b"); }
59
+ continue;
60
+ }
61
+ if (ch < " ") continue; // 忽略其余控制字符
62
+ buf += ch;
63
+ process.stdout.write("*");
64
+ }
65
+ };
66
+ process.stdout.write(label);
67
+ try { process.stdin.setRawMode(true); } catch {}
68
+ process.stdin.resume();
69
+ process.stdin.setEncoding("utf8");
70
+ process.stdin.on("data", onData);
71
+ });
72
+ }
73
+
74
+ async function promptVisible(label) {
75
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return null;
76
+ const { createInterface } = await import("../../../readline-compat.js");
77
+ const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
78
+ try {
79
+ return await rl.question(label);
80
+ } finally {
81
+ rl.close();
82
+ }
83
+ }
84
+
85
+ export async function handleRaccoonPasswordLogin(id, sub, rest = [], deps = {}) {
86
+ const fetchImpl = deps.fetchImpl || compatFetch;
87
+ const log = deps.log || ((m) => console.log(m));
88
+ const exit = deps.exit || ((code) => process.exit(code));
89
+
90
+ log("=".repeat(60));
91
+ log(" Raccoon 登录(商汤小浣熊 · 手机号 + 密码)");
92
+ log("=".repeat(60));
93
+ log("");
94
+
95
+ let phone = flagValue(rest, "--phone") ?? deps.phone;
96
+ // `--password` 既是「模式开关」也可携带值:`--password`(裸)→ 交互询问,`--password=xxx` → 直接用。
97
+ let password = flagValue(rest, "--password", "-p") ?? deps.password;
98
+ const nationCode = flagValue(rest, "--nation", "--nation-code", "--area") || deps.nationCode || "86";
99
+
100
+ if (!phone) {
101
+ const asked = await promptVisible(" 手机号: ");
102
+ if (asked === null) {
103
+ log("❌ 非交互环境(非 TTY)无法询问手机号 —— 请用 --phone 传入");
104
+ log(RELOGIN_HINT);
105
+ exit(1);
106
+ return true;
107
+ }
108
+ phone = asked;
109
+ }
110
+ const cleanPhone = normalizePhone(phone);
111
+ if (!cleanPhone) {
112
+ log("❌ 手机号不能为空(只填数字即可)");
113
+ log(RELOGIN_HINT);
114
+ exit(1);
115
+ return true;
116
+ }
117
+
118
+ if (!password) {
119
+ const asked = await promptHidden(" 密码(输入不回显): ");
120
+ if (asked === null) {
121
+ log("❌ 非交互环境(非 TTY)无法安全询问密码 —— 请用 --password 传入");
122
+ log(RELOGIN_HINT);
123
+ exit(1);
124
+ return true;
125
+ }
126
+ password = asked;
127
+ }
128
+
129
+ if (String(password || "").length === 0) {
130
+ log("❌ 密码不能为空");
131
+ log(RELOGIN_HINT);
132
+ exit(1);
133
+ return true;
134
+ }
135
+
136
+ log("🔐 正在登录…");
137
+ let credential;
138
+ try {
139
+ credential = await loginRaccoonWithPassword(
140
+ { phone: cleanPhone, password, nationCode },
141
+ { fetchImpl, env: deps.env },
142
+ );
143
+ } catch (error) {
144
+ log("");
145
+ log(`❌ 登录失败:${error?.message ?? error}`);
146
+ log(RELOGIN_HINT);
147
+ exit(1);
148
+ return true;
149
+ }
150
+
151
+ // 密码已用完,立刻从本进程的作用域里抹掉(降内存残留;不是安全边界,是习惯)
152
+ password = undefined;
153
+
154
+ const masked = cleanPhone.length > 4 ? `${"*".repeat(cleanPhone.length - 4)}${cleanPhone.slice(-4)}` : cleanPhone;
155
+ return await finishRaccoonLogin({
156
+ credential,
157
+ fetchImpl,
158
+ deps,
159
+ log,
160
+ methodNote: ` 登录方式: 密码登录(手机号 ${masked})`,
161
+ });
162
+ }
@@ -11,6 +11,7 @@ import {
11
11
  } from "./const.js";
12
12
  import { effortFromBody } from "../../thinking-effort.js";
13
13
  import { RACCOON_CHAT_ACCEPT, raccoonAuthHeaders } from "./headers.js";
14
+ import { confirmRaccoonSession } from "./login.js";
14
15
  import {
15
16
  aggregateRaccoonSse,
16
17
  classifyRaccoonFailure,
@@ -18,6 +19,9 @@ import {
18
19
  raccoonErrorResponse,
19
20
  } from "./sse.js";
20
21
 
22
+ /** 上游「新会话未确认」闸门码(2026-10-18 取证):先 POST /iam/confirm 再重试。 */
23
+ const IAM_CONFIRM_REQUIRED = 200901;
24
+
21
25
  /** 首帧窥探上限:超过这个字节数还没形成完整 SSE 帧就不再等(避免慢首包把请求卡住)。 */
22
26
  const PEEK_MAX_BYTES = 8192;
23
27
 
@@ -144,21 +148,33 @@ export async function forwardRaccoonChat({
144
148
  const wireBody = buildRaccoonWireBody(body, { effort, env });
145
149
  const modelId = wireBody.model;
146
150
 
147
- let res;
148
- try {
149
- res = await fetchImpl(raccoonChatUrl(), {
150
- method: "POST",
151
- headers: raccoonAuthHeaders(credential, { accept: RACCOON_CHAT_ACCEPT, env }),
152
- body: JSON.stringify(wireBody),
153
- signal: timeoutSignal(timeoutMs),
154
- });
155
- } catch (e) {
156
- return raccoonErrorResponse({ kind: "server", message: String(e?.message || e).slice(0, 160) });
151
+ // ⚠ 新会话未确认闸门(2026-10-18 上游改版,与扫码签发同批):新登录的 token 属于「未确认会话」,
152
+ // 任何业务端点都先回 `400 {code:200901 iam_confirm_required}`,必须先 POST /iam/confirm 确认一次才放行。
153
+ // 桌面端旧 token 早已确认故无感 —— 这正是「同一家、旧号能用、新登的号全 400、重登也没用」的根因。
154
+ // 官方前端做法是「撞 200901 → confirm → 重试原请求」,此处对齐:至多重试一次,防打旋。
155
+ let res = null;
156
+ let failPayload = null;
157
+ for (let attempt = 0; attempt < 2; attempt++) {
158
+ try {
159
+ res = await fetchImpl(raccoonChatUrl(), {
160
+ method: "POST",
161
+ headers: raccoonAuthHeaders(credential, { accept: RACCOON_CHAT_ACCEPT, env }),
162
+ body: JSON.stringify(wireBody),
163
+ signal: timeoutSignal(timeoutMs),
164
+ });
165
+ } catch (e) {
166
+ return raccoonErrorResponse({ kind: "server", message: String(e?.message || e).slice(0, 160) });
167
+ }
168
+ if (res.ok) break;
169
+ failPayload = await res.json().catch(() => null);
170
+ if (attempt === 0 && res.status === 400 && Number(failPayload?.code) === IAM_CONFIRM_REQUIRED) {
171
+ if (await confirmRaccoonSession(credential, { fetchImpl, env })) continue; // 确认成功 → 重试一次
172
+ }
173
+ break;
157
174
  }
158
175
 
159
176
  if (!res.ok) {
160
- const payload = await res.json().catch(() => null);
161
- return raccoonErrorResponse({ ...classifyRaccoonFailure(res.status, payload), model: modelId });
177
+ return raccoonErrorResponse({ ...classifyRaccoonFailure(res.status, failPayload), model: modelId });
162
178
  }
163
179
 
164
180
  // 业务错误惯例:HTTP 200 + JSON 信封(code 非 0)→ 同样按错误分类,不谎报成功
@@ -23,6 +23,14 @@ export const RACCOON_IAM_PREFIX = "/api/iam/v1";
23
23
  export const raccoonModelCatalogUrl = () => `${RACCOON_ORIGIN}${RACCOON_LLM_PREFIX}/model_catalog`;
24
24
  export const raccoonChatUrl = () => `${RACCOON_ORIGIN}${RACCOON_LLM_PREFIX}/chat/completions`;
25
25
  export const raccoonRefreshUrl = () => `${RACCOON_ORIGIN}${RACCOON_AUTH_PREFIX}/refresh`;
26
+ /**
27
+ * IAM 会话确认端点(2026-10-18 故障取证 + 官方前端 c6.js 实证)。
28
+ * 上游把网页/IAM 登录的会话划入「先确认再放行」:新登录的 token 打业务端点回
29
+ * `400 {"code":200901,"message":"iam_confirm_required"}`,先带 Bearer POST 本端点
30
+ * (body `{}` 即可)确认会话后业务端点恢复 200。官方前端的处理同样是
31
+ * 「撞 200901 → confirm(true) → 重试原请求」。
32
+ */
33
+ export const raccoonIamConfirmUrl = () => `${RACCOON_ORIGIN}${RACCOON_AUTH_PREFIX}/iam/confirm`;
26
34
  export const raccoonUserInfoUrl = () => `${RACCOON_ORIGIN}${RACCOON_AUTH_PREFIX}/user_info`;
27
35
  export const raccoonBalanceUrl = () => `${RACCOON_ORIGIN}${RACCOON_POINTS_PREFIX}/balance`;
28
36
  export const raccoonBillsUrl = (limit = 50) =>
@@ -36,6 +44,31 @@ export const raccoonQrPollUrl = () => `${RACCOON_ORIGIN}${RACCOON_IAM_PREFIX}/au
36
44
  /** 取消二维码(PUT,body `{ qrcode_code, status: "canceled" }`)——过期/离开页面时礼貌回收。 */
37
45
  export const raccoonQrCancelUrl = () => `${RACCOON_ORIGIN}${RACCOON_IAM_PREFIX}/auth/mp/qrcode`;
38
46
 
47
+ /**
48
+ * 密码登录(手机号 + 密码)。**同一 IAM 族的明文端点**。
49
+ * ⚠ 官方前端(app.asar module 726xx → `loginWithPassword`)打的是旧 web 通道
50
+ * `POST /api/web/auth/v1/login_with_password`,但那条**要求手机号加密**:
51
+ * 明文发送回 `400 100003 params_encryted_error: param phone encrypted error`。
52
+ * 本机实测 IAM 同族端点收**明文**三字段并直通业务校验(错密码回
53
+ * `401 AUTH_ACCOUNT_OR_PASSWORD_INVALID`),故走这条 —— 不需要复刻官方加密公钥。
54
+ * body 契约(实测逐条确认):`{ nation_code: "86", phone, password }`
55
+ * · nation_code 必须是**字符串**(数字 86 与 `+86` 中的数字形式被判 body invalid;`"+86"` 字符串可用)
56
+ * · 缺 nation_code / 空 phone / 空 password → `400 COMMON_INVALID_ARGUMENT`
57
+ * ⚠ 密码只在此处作为请求体出现,**绝不落盘、绝不进日志**。
58
+ */
59
+ export const raccoonPasswordLoginUrl = () => `${RACCOON_ORIGIN}${RACCOON_IAM_PREFIX}/auth/login/password/phone`;
60
+
61
+ /**
62
+ * 密码登录失败码 → 人话。命中即抛「可操作」的错误,未命中一律透出上游 message。
63
+ * 表里只放**实测见过**的码,不臆造(上游加了新码就走 default 兜底)。
64
+ */
65
+ export const RACCOON_PASSWORD_ERRORS = {
66
+ AUTH_ACCOUNT_OR_PASSWORD_INVALID: "手机号或密码不正确",
67
+ AUTH_ACCOUNT_NOT_FOUND: "该手机号未注册过小浣熊(先去官网注册,或改用扫码登录)",
68
+ AUTH_ACCOUNT_LOCKED: "账号已被锁定,请到官网处理后再试",
69
+ COMMON_INVALID_ARGUMENT: "登录参数不完整(需要 国家码 + 手机号 + 密码)",
70
+ };
71
+
39
72
  /**
40
73
  * 扫码登录深链接:终端编成二维码,**必须用微信「扫一扫」**打开确认。
41
74
  * code 与 scan_path 都由 `POST /auth/mp/qrcode` 下发(scan_path 实测为 `/login/mp`),
@@ -14,6 +14,7 @@ import {
14
14
  raccoonLoginGrantUrl,
15
15
  } from "./const.js";
16
16
  import { raccoonAuthHeaders } from "./headers.js";
17
+ import { confirmRaccoonSession } from "./login.js";
17
18
  import { parseRaccoonEnvelope, raccoonEnvelopeText } from "./envelope.js";
18
19
 
19
20
  const num = (v) => {
@@ -22,20 +23,33 @@ const num = (v) => {
22
23
  return undefined;
23
24
  };
24
25
 
26
+ /** 上游「新会话未确认」闸门码:先 POST /iam/confirm 再重试一次(与 chat.js 同款兜底)。 */
27
+ const IAM_CONFIRM_REQUIRED = 200901;
28
+
25
29
  async function request(url, { credential, fetchImpl = fetch, env = process.env, method = "GET", body, timeoutMs = RACCOON_REQUEST_TIMEOUT_MS } = {}) {
26
- const res = await fetchImpl(url, {
27
- method,
28
- headers: raccoonAuthHeaders(credential, { env }),
29
- ...(body === undefined ? {} : { body: JSON.stringify(body) }),
30
- signal: AbortSignal.timeout(timeoutMs),
31
- });
32
- let parsed;
33
- try {
34
- parsed = await res.json();
35
- } catch {
36
- throw new Error(`raccoon: 响应不是 JSON(HTTP ${res.status})`);
30
+ const once = async () => {
31
+ const res = await fetchImpl(url, {
32
+ method,
33
+ headers: raccoonAuthHeaders(credential, { env }),
34
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
35
+ signal: AbortSignal.timeout(timeoutMs),
36
+ });
37
+ let parsed;
38
+ try {
39
+ parsed = await res.json();
40
+ } catch {
41
+ throw new Error(`raccoon: 响应不是 JSON(HTTP ${res.status})`);
42
+ }
43
+ return { res, envelope: parseRaccoonEnvelope(parsed, res.status) };
44
+ };
45
+ let { res, envelope } = await once();
46
+ // 未确认会话闸门:确认一次后重试(老号 / CLI 直连都可能撞上,登录时已确认的号不会走到这)
47
+ if (res.status === 400 && Number(envelope.code) === IAM_CONFIRM_REQUIRED) {
48
+ if (await confirmRaccoonSession(credential, { fetchImpl, env })) {
49
+ ({ res, envelope } = await once());
50
+ }
37
51
  }
38
- return parseRaccoonEnvelope(parsed, res.status);
52
+ return envelope;
39
53
  }
40
54
 
41
55
  /** 余额五分项(缺项即省略,合计优先取上游的 available_points)。 */
@@ -8,6 +8,7 @@ import {
8
8
  RACCOON_LOGIN_POLL_INTERVAL_MS,
9
9
  RACCOON_LOGIN_TIMEOUT_MS,
10
10
  RACCOON_REQUEST_TIMEOUT_MS,
11
+ raccoonIamConfirmUrl,
11
12
  raccoonQrCancelUrl,
12
13
  raccoonQrCreateUrl,
13
14
  raccoonQrPageUrl,
@@ -224,6 +225,35 @@ export async function refreshRaccoonCredential(credential, opts = {}) {
224
225
  };
225
226
  }
226
227
 
228
+ /**
229
+ * IAM 会话确认(POST /api/web/auth/v1/iam/confirm,body `{}`,带 Bearer)。
230
+ *
231
+ * 2026-10-18 故障取证:上游新登录的会话未确认前,业务端点一律回
232
+ * `400 {"code":200901,"message":"iam_confirm_required"}`(桌面端旧会话早已确认,
233
+ * 所以官方 App 无感)。官方前端(c6.js)的处理是「撞 200901 → confirm → 重试原请求」,
234
+ * 我们对齐同款:chat.js 兜底重试 + 登录成功后主动确认一次。
235
+ * 返回是否确认成功;任何失败都吞掉返回 false(由调用方的懒重试兜底,绝不阻断主流程)。
236
+ */
237
+ export async function confirmRaccoonSession(credential, { fetchImpl = fetch, env = process.env, timeoutMs = RACCOON_REQUEST_TIMEOUT_MS } = {}) {
238
+ try {
239
+ const res = await fetchImpl(raccoonIamConfirmUrl(), {
240
+ method: "POST",
241
+ headers: raccoonAuthHeaders(credential, { env }),
242
+ body: "{}",
243
+ signal: AbortSignal.timeout(timeoutMs),
244
+ });
245
+ let parsed = null;
246
+ try {
247
+ parsed = await res.json();
248
+ } catch {
249
+ parsed = null;
250
+ }
251
+ return res.ok && isRaccoonOk(parseRaccoonEnvelope(parsed, res.status));
252
+ } catch {
253
+ return false;
254
+ }
255
+ }
256
+
227
257
  /** 取用户信息(uid/昵称/手机号/office_identity);任何失败都返回空对象,不阻断登录。 */
228
258
  export async function fetchRaccoonUserInfo(credential, opts = {}) {
229
259
  const { fetchImpl = fetch, env = process.env, timeoutMs = RACCOON_REQUEST_TIMEOUT_MS } = opts;
@@ -0,0 +1,87 @@
1
+ // raccoon 密码登录(手机号 + 密码)—— 多账号自动化添加的捷径(免扫码)。
2
+ // 契约与「为什么不用官方前端那条加密端点」见 const.js 的 raccoonPasswordLoginUrl 注释。
3
+ //
4
+ // ⚠ 安全红线:密码**只作为请求体出现一次**。本模块不落盘、不打印、不记日志;
5
+ // 抛出的错误里也绝不带上请求体原文(上游 message 是它自己的文案,安全)。
6
+ import {
7
+ RACCOON_PASSWORD_ERRORS,
8
+ RACCOON_REQUEST_TIMEOUT_MS,
9
+ raccoonPasswordLoginUrl,
10
+ } from "./const.js";
11
+ import { raccoonAnonymousHeaders } from "./headers.js";
12
+ import { isRaccoonOk, parseRaccoonEnvelope, raccoonEnvelopeText } from "./envelope.js";
13
+ import { decodeJwtExpMs } from "./auth.js";
14
+
15
+ /** nation_code 归一:接受 `"86"` / `"+86"` / `86`,统一成不带加号的数字串。 */
16
+ export function normalizeNationCode(raw) {
17
+ const s = String(raw ?? "").trim().replace(/^\+/, "");
18
+ return /^\d{1,4}$/.test(s) ? s : "";
19
+ }
20
+
21
+ /** 手机号归一:只留数字(去空格、连字符、括号)。空结果由调用方判错。 */
22
+ export function normalizePhone(raw) {
23
+ return String(raw ?? "").replace(/[^\d]/g, "");
24
+ }
25
+
26
+ /** 上游登录失败 → 人话 Error。命中码表用它,否则透出上游 message(绝不带请求体)。 */
27
+ function passwordLoginError(envelope, status) {
28
+ const key = String(envelope?.code ?? "");
29
+ const friendly = RACCOON_PASSWORD_ERRORS[key];
30
+ const err = new Error(`raccoon: 密码登录失败:${friendly || raccoonEnvelopeText(envelope, `HTTP ${status}`)}`);
31
+ err.code = envelope?.code;
32
+ err.status = status;
33
+ return err;
34
+ }
35
+
36
+ /**
37
+ * 手机号 + 密码 → credential(与扫码登录**同一形状**,可直接喂 saveRaccoonAccount)。
38
+ *
39
+ * 返回 `{ access_token, refresh_token, expires_at?, office_identity? }`;
40
+ * 失败抛「可操作」的错误(码表命中给人话,未命中透出上游文案)。
41
+ * 本地校验先于出网:手机号/密码为空当场报错,不白跑一次请求。
42
+ */
43
+ export async function loginRaccoonWithPassword({ phone, password, nationCode = "86" } = {}, {
44
+ fetchImpl = fetch,
45
+ env = process.env,
46
+ timeoutMs = RACCOON_REQUEST_TIMEOUT_MS,
47
+ } = {}) {
48
+ const nc = normalizeNationCode(nationCode);
49
+ const ph = normalizePhone(phone);
50
+ if (!nc) throw new Error("raccoon: 国家码不合法(用 86 这类数字,或 +86)");
51
+ if (!ph) throw new Error("raccoon: 手机号不能为空");
52
+ if (String(password ?? "").length === 0) throw new Error("raccoon: 密码不能为空");
53
+
54
+ let res;
55
+ try {
56
+ res = await fetchImpl(raccoonPasswordLoginUrl(), {
57
+ method: "POST",
58
+ headers: raccoonAnonymousHeaders({ env }),
59
+ body: JSON.stringify({ nation_code: nc, phone: ph, password: String(password) }),
60
+ signal: AbortSignal.timeout(timeoutMs),
61
+ });
62
+ } catch (error) {
63
+ throw new Error(`raccoon: 密码登录请求失败:${error?.message ?? error}`);
64
+ }
65
+
66
+ let parsed;
67
+ try {
68
+ parsed = await res.json();
69
+ } catch {
70
+ throw new Error(`raccoon: 密码登录响应不是 JSON(HTTP ${res.status})`);
71
+ }
72
+ const envelope = parseRaccoonEnvelope(parsed, res.status);
73
+ if (!isRaccoonOk(envelope)) throw passwordLoginError(envelope, res.status);
74
+
75
+ const data = envelope.data ?? {};
76
+ const accessToken = typeof data.access_token === "string" ? data.access_token : "";
77
+ if (!accessToken) throw new Error("raccoon: 密码登录响应缺少 access_token");
78
+ const refreshToken = typeof data.refresh_token === "string" ? data.refresh_token : "";
79
+ const officeIdentity = typeof data.office_identity === "string" ? data.office_identity : "";
80
+ const expMs = decodeJwtExpMs(accessToken);
81
+ return {
82
+ access_token: accessToken,
83
+ refresh_token: refreshToken,
84
+ ...(expMs === undefined ? {} : { expires_at: String(expMs) }),
85
+ ...(officeIdentity ? { office_identity: officeIdentity } : {}),
86
+ };
87
+ }
@@ -27,6 +27,32 @@ export function isInsufficientStatus(status, bodyText, cached) {
27
27
  return false;
28
28
  }
29
29
 
30
+ // 频率限制:上游「这个号这一秒用量超了」——该换号顶,但**不是**这个号没额度了。
31
+ // 与 isInsufficientStatus 严格分开:后者命中会被 chat.js 把该号余额标 0(永久跳过),
32
+ // 而腾讯 workbuddy 的 6004 是「使用量已超出频率限制,将在 <时间> 重置」+ 中文措辞,
33
+ // 既不含任何英文字样也没有额度码——历史上正是这里不认,导致 429 原样吐给客户端、从不换号。
34
+ // Note: 为什么 6004 必须走独立判定而不是并入 insufficient — 见 .agents/notes/implemented/bug-fix/2026-10-18-workbuddy-6004-rate-limit-rotate.md
35
+ export function isRateLimitedStatus(status, bodyText) {
36
+ if (status !== 429) return false;
37
+ const t = String(bodyText || "");
38
+ if (/"code"\s*:\s*6004\b/.test(t)) return true;
39
+ const lower = t.toLowerCase();
40
+ if (lower.includes("frequency limit") || lower.includes("rate limit") || lower.includes("too many requests")) return true;
41
+ if (t.includes("频率限制") || t.includes("请求过于频繁") || t.includes("使用量已超出")) return true;
42
+ return false;
43
+ }
44
+
45
+ // 上游 msg 里的重置时刻("将在 2026-10-18 21:14:48 UTC+8 重置")→ 建议冷却毫秒数;
46
+ // 解析不出返回 0,调用方退回到固定冷却档,绝不因此把号判死。
47
+ export function parseRateLimitResetMs(bodyText, clock = Date.now) {
48
+ const m = String(bodyText || "").match(/(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})\s*(?:UTC\+8)?\s*重置/);
49
+ if (!m) return 0;
50
+ const at = Date.parse(`${m[1]}T${m[2]}+08:00`);
51
+ if (!Number.isFinite(at)) return 0;
52
+ const ms = at - clock();
53
+ return ms > 0 ? ms : 0;
54
+ }
55
+
30
56
  export function isAuthError(status, bodyText) {
31
57
  if (status === 401 || status === 403) return true;
32
58
  const t = String(bodyText || "").toLowerCase();
@@ -35,7 +61,6 @@ export function isAuthError(status, bodyText) {
35
61
  if (status === 400 && t.includes("token")) return true;
36
62
  return false;
37
63
  }
38
-
39
64
  export function createAuthService({
40
65
  baseUrl,
41
66
  fetchImpl,
@@ -1,5 +1,5 @@
1
1
  import { joinUrl } from "../base.js";
2
- import { isAuthError, isInsufficientStatus } from "./auth.js";
2
+ import { isAuthError, isInsufficientStatus, isRateLimitedStatus, parseRateLimitResetMs } from "./auth.js";
3
3
  import { appendRotationLog as defaultAppend } from "./rotation-log.js";
4
4
  import { createTransport } from "../../transport/index.js";
5
5
  import { dispatcherFetch } from "../../upstream-engine/sdk/attempt.js";
@@ -232,7 +232,23 @@ export function createChatService({
232
232
  lastErr = failErr(`workbuddy insufficient for ${uid}: ${txt.slice(0, 120)}`, t0, clock);
233
233
  continue;
234
234
  }
235
- res = rewrapBody(res, txt);
235
+ // 频率限制(腾讯 code 6004):这个号撞了限流,但**额度还在**(total 可能还有几千点)。
236
+ // 必须换下一个号顶,且绝不能 setBalance(total:0) —— 那会把好号永久跳过,
237
+ // 是「429 原样吐回客户端、从不换号」在 2026-10-17 实锤的那条老路。
238
+ // Note: 6004 的判据与冷却分档 — 见 .agents/notes/implemented/bug-fix/2026-10-18-workbuddy-6004-rate-limit-rotate.md
239
+ if (isRateLimitedStatus(res.status, txt)) {
240
+ const resetMs = parseRateLimitResetMs(txt, clock);
241
+ // 冷却时长取「上游给的重置时刻」与固定下限的较大者;解析不出就只按固定档,
242
+ // 且**封顶 10 分钟**:6004 的重置时间可能远在数小时后,照抄会把号冻到下班。
243
+ const coolMs = Math.min(Math.max(resetMs || 0, cooldownMs), 10 * 60_000);
244
+ try { activeRing.onError(key, coolMs); } catch {}
245
+ doLog({ uid, model: _modelVal, totalMs: Math.round(nowMs(clock) - t0), balanceHit: false, error: `rate-limited(${coolMs}ms) ${txt.slice(0, 120)}` });
246
+ lastErr = failErr(`workbuddy rate limited for ${uid}: ${txt.slice(0, 120)}`, t0, clock);
247
+ if (activeRing.available() > 0) continue;
248
+ res = rewrapBody(res, txt);
249
+ } else {
250
+ res = rewrapBody(res, txt);
251
+ }
236
252
  }
237
253
  if (res.status === 401 || res.status === 403 || res.status === 429 || res.status >= 500) try { activeRing.onError(key); } catch {}
238
254
  res = withUidHeader(res, uid);
@@ -213,5 +213,5 @@ export function createWorkbuddyProvider({
213
213
  }
214
214
 
215
215
  // re-export submodules for tests that import directly
216
- export { decodeJwtExp, isAuthError, isInsufficientStatus } from "./auth.js";
216
+ export { decodeJwtExp, isAuthError, isInsufficientStatus, isRateLimitedStatus, parseRateLimitResetMs } from "./auth.js";
217
217
  export { createBalanceCache } from "./balance.js";