mslxdff 0.1.230 → 0.1.232

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.230",
3
+ "version": "0.1.232",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,16 +1,20 @@
1
- // AgentRouter 定制供应商:OpenAI 兼容聚合网关,有三道硬门槛(2026-10-21 现网 curl 实测):
1
+ // AgentRouter 定制供应商:OpenAI 兼容聚合网关,有四道硬门槛(2026-10-21 现网 curl 实测):
2
2
  // ① 客户端身份白名单 —— UA 为 `mslxdff`/`curl`/浏览器一律 401 `unauthorized client detected`,
3
3
  // 只放行 `opencode/*` 与 `claude-cli/<ver> (external…)`;
4
4
  // ② 模型分端点 —— `deepseek-v4-flash` 吃 /v1/chat/completions,`gpt-6.1-sol`/`gpt-6-astra`
5
5
  // 对该端点回 400「请改用 /v1/responses」,只在 responses 通道可用;
6
6
  // ③ 内容闸 —— 正文含 `x-anthropic` 字样稳定 500 `sensitive words detected`(连 user 消息也拦,
7
- // 与 workbuddy 只扫 system/assistant 不同),见 stripAgentRouterSensitive。
7
+ // 与 workbuddy 只扫 system/assistant 不同),见 stripAgentRouterSensitive;
8
+ // ④ 传输层 —— 上游对 Node/undici 的**新建 TCP 连接**选择性丢包(现网对照:undici 0/4~0/6
9
+ // 全 connect 超时,curl 同刻 100% 200,走系统代理亦不稳)⇒ 本家 fetchImpl 缺省换
10
+ // curl-fetch 子进程通道(`MSLXDFF_AGENTROUTER_FETCH=undici` 可切回)。
8
11
  // 故不复用 generic(其 UA 写死 `mslxdff`、responses 判定只认 models.dev npm 索引),这里自组装,
9
12
  // 底层仍复用 base.js 的 runner 与 responses-channel 的协议互转。
10
13
  import { createKeyRing } from "./keyring.js";
11
14
  import { loadProviderBaseUrl, loadProviderKeys } from "../state.js";
12
15
  import { createChatRunner, createAgent, envInt, getUndici, joinUrl, collectApiKeysGeneric } from "./base.js";
13
16
  import { createResponsesChannel, resolveResponsesPath } from "./responses-channel.js";
17
+ import { createCurlFetch } from "./curl-fetch.js";
14
18
  import { compatFetch } from "../compat.js";
15
19
  import { opencodeUa } from "../opencode-identity.js";
16
20
  import { joinModelId } from "./model-id.js";
@@ -80,7 +84,10 @@ export function createAgentRouterProvider({
80
84
  } = {}) {
81
85
  if (id !== "agentrouter") throw new Error(`agentrouter provider id mismatch: ${id}`);
82
86
  const resolvedBase = resolveAgentRouterBase(baseUrl);
83
- if (!fetchImpl) fetchImpl = UndiciFetch || compatFetch;
87
+ // 传输层缺省走 curl 子进程(见文件头门槛④):上游对 undici 新建连接选择性丢包,
88
+ // 实测 curl 同刻稳定。`MSLXDFF_AGENTROUTER_FETCH=undici` 切回 undici(配好上游修复后或对照排查用)。
89
+ const useCurl = !fetchImpl && String(process.env.MSLXDFF_AGENTROUTER_FETCH || "curl").toLowerCase() !== "undici";
90
+ if (!fetchImpl) fetchImpl = useCurl ? createCurlFetch() : (UndiciFetch || compatFetch);
84
91
 
85
92
  const ring = createKeyRing(
86
93
  collectApiKeysGeneric(id, apiKeys, apiKey, (pid) => loadProviderKeys(pid, file ? { file } : {})),
@@ -0,0 +1,155 @@
1
+ // curl-fetch 适配器:fetch(url, init) → curl.exe 子进程 → 标准 Response
2
+ // 背景(ADR-0066 门槛④,2026-10-21 现网取证):agentrouter 对 Node/undici 新建 TCP 连接
3
+ // 选择性丢包(undici 0/4~0/6 全 connect 超时、curl 同刻 100% 200),供应商传输层整体换 curl。
4
+ // 契约:只消费 init 的 method/headers/body(string)/signal;其余字段(如 undici dispatcher)忽略。
5
+ // 失败语义:拿不到响应头即抛 Error(交上层 runner 的 network 重试/冷却,与 fetch 抛错同语义)。
6
+ // ⚠ 流式关键教训(2026-10-21 本机踩坑):stdout 必须**立即挂 data 监听进流式(flowing)模式**
7
+ // ——等消费者 pull 才读会让 curl 写满管道后卡住/退出,res.text() 永不返回;
8
+ // 统一 chunk 队列:header 阶段从队列文本判就绪,body 阶段同一队列续吐,零丢字节。
9
+ // ⚠ 弹窗教训(2026-10-21 用户机实测):daemon 是无控制台后台进程,spawn 不加 `windowsHide`
10
+ // 时 Windows 会给每个 console 子进程(curl.exe)新开一个控制台窗口——流式+重试+预热
11
+ // 叠加就是窗口轰炸。
12
+ // ⚠ 100-continue 教训(2026-10-21 同批,Claude Code 大 body 实锤):body 超 ~1KB 时 curl 自动
13
+ // 带 `Expect: 100-continue`,`-D` 头文件先落 `HTTP/1.1 100 Continue` 伪段;若把它当终局
14
+ // 交卷,`new Response(status=100)` 直接 RangeError → 上层 502 → key 冷却 30s 连锁。
15
+ // 双保险:请求侧 `-H "Expect:"` 关掉该机制 + 头解析侧跳过所有 1xx/CONNECT 伪段。
16
+ import { spawn } from "node:child_process";
17
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+
21
+ const DEFAULT_CURL = process.env.MSLXDFF_CURL_PATH || "curl.exe";
22
+ const HEAD_WAIT_MS = Number(process.env.MSLXDFF_CURL_HEAD_WAIT_MS) || 15_000; // 等响应头的上限(连接失败由 curl 退出/EOF 提前揭穿)
23
+ // curl 侧整请求硬闸(防无 signal 调用方遗留僵尸进程;runner 的 connectTimeoutMs 通常先到期掐进程)
24
+ const FALLBACK_MAX_TIME_S = Number(process.env.MSLXDFF_CURL_MAX_TIME_S) || 600;
25
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
26
+ const safeRead = (fp) => { try { return readFileSync(fp, "latin1"); } catch { return ""; } };
27
+
28
+ /** 1xx 中间应答段(含代理 CONNECT 的 `200 Connection established` 伪应答——curl 虽按 RFC 记
29
+ * 它为 1xx 语义,现网写出的是 `HTTP/1.1 200 Connection established`,两者都不算终局)。 */
30
+ const INTERMEDIATE_SEGMENT = /^HTTP\/[\d.]+[ \t]+(?:1\d{2}|200[ \t]+Connection established)/i;
31
+
32
+ /** 解析 curl -D 头文件(代理 CONNECT / 1xx 中间应答都是前置伪段,取最后一个非伪段)。 */
33
+ export function parseHeaderBlob(blob) {
34
+ const segs = String(blob || "").split(/\r?\n\r?\n/).filter((s) => /^HTTP\//i.test(s.trim()));
35
+ // 从后往前找第一个「终局」段(非 1xx / 非 CONNECT 伪应答);全是伪段则回退最后一段
36
+ let last = segs.length ? segs[segs.length - 1] : String(blob || "");
37
+ for (let i = segs.length - 1; i >= 0; i--) {
38
+ if (!INTERMEDIATE_SEGMENT.test(segs[i].trim())) { last = segs[i]; break; }
39
+ }
40
+ const m = last.match(/HTTP\/[\d.]+[ \t]+(\d{3})/i);
41
+ // 伪段状态(1xx)绝不能外泄给 new Response(RangeError → 上层假 502);归 0 按无头失败处理
42
+ let status = m ? Number(m[1]) : 0;
43
+ if (status && status < 200) status = 0;
44
+ const headers = new Headers();
45
+ for (const line of last.split(/\r?\n/).slice(1)) {
46
+ const i = line.indexOf(":");
47
+ if (i > 0) headers.set(line.slice(0, i).trim(), line.slice(i + 1).trim());
48
+ }
49
+ return { status, headers };
50
+ }
51
+
52
+ /**
53
+ * 头块是否已完整可交卷:存在至少一个以空行收尾的 HTTP 段,且**最后一个完整段不是**
54
+ * 1xx 中间应答/代理 CONNECT 伪应答(`-H "Expect:"` 之外的第二道保险:防上游无视 Expect
55
+ * 仍回 100,或代理链回 CONNECT)。
56
+ */
57
+ export function headerBlobReady(blob) {
58
+ const segs = String(blob || "").split(/\r?\n\r?\n/);
59
+ if (segs.length < 2) return false;
60
+ const lastFull = segs[segs.length - 2];
61
+ if (!/^HTTP\//i.test(String(lastFull || "").trim())) return false;
62
+ if (INTERMEDIATE_SEGMENT.test(String(lastFull).trim())) return false;
63
+ return true;
64
+ }
65
+
66
+ /**
67
+ * 建 fetch 兼容函数。
68
+ * @param {{curlPath?: string, headWaitMs?: number}} [opts]
69
+ */
70
+ export function createCurlFetch({ curlPath = DEFAULT_CURL, headWaitMs = HEAD_WAIT_MS } = {}) {
71
+ const tmpRoot = mkdtempSync(join(tmpdir(), "mslxdff-curl-"));
72
+ return async function curlFetch(url, init = {}) {
73
+ const dir = mkdtempSync(join(tmpRoot, "r"));
74
+ const headerFile = join(dir, "hdr.txt");
75
+ // `-H "Expect:"`:显式移除 curl 对大 body(>1KB)自动附加的 Expect 头,杜绝 100-continue 往返
76
+ const args = ["-sS", "-N", "--http1.1", "--max-time", String(FALLBACK_MAX_TIME_S), "-H", "Expect:", "-D", headerFile];
77
+ const body = init.body != null ? String(init.body) : null;
78
+ if (body !== null) {
79
+ const method = init.method && init.method !== "GET" ? init.method : "POST";
80
+ args.push("-X", method, "--data-binary", "@-");
81
+ } else if (init.method && init.method !== "GET") {
82
+ args.push("-X", init.method);
83
+ }
84
+ const headers = init.headers || {};
85
+ for (const k of Object.keys(headers)) {
86
+ const v = headers[k];
87
+ if (typeof v === "string" && v) args.push("-H", `${k}: ${v}`);
88
+ }
89
+ args.push(String(url));
90
+
91
+ // windowsHide:daemon(无控制台后台进程)下不加这项,Windows 会**每次 spawn 弹一个 curl 控制台窗口**
92
+ const child = spawn(curlPath, args, { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
93
+ child.stdin.on("error", () => {}); // EPIPE:curl 启动即败时写 body 不崩主流程
94
+ let stderr = "";
95
+ child.stderr.on("data", (c) => { stderr += c.toString(); });
96
+ if (body !== null) child.stdin.end(body);
97
+
98
+ // ① spawn 即流式:stdout 全部进队列(header 就绪前的 body 字节也在其中,不丢)
99
+ const chunks = [];
100
+ let eof = false;
101
+ let streamErr = null;
102
+ let waiters = [];
103
+ child.stdout.on("data", (c) => {
104
+ chunks.push(c);
105
+ if (waiters.length) waiters.shift()({ value: chunks.shift(), done: false });
106
+ });
107
+ child.stdout.on("error", (e) => { streamErr = e; eof = true; wakeAll(); });
108
+ child.stdout.on("end", () => { eof = true; wakeAll(); });
109
+ child.on("error", (e) => { streamErr = e; eof = true; wakeAll(); });
110
+ function wakeAll() { const ws = waiters; waiters = []; for (const w of ws) w({ value: undefined, done: true }); }
111
+ const kill = () => { try { child.kill(); } catch {} };
112
+
113
+ // ② 等响应头(-D 文件出现完整非-CONNECT 段;curl 退出/EOF 提前结束等待)
114
+ const deadline = Date.now() + headWaitMs;
115
+ let blob = "";
116
+ for (;;) {
117
+ blob = safeRead(headerFile);
118
+ if (headerBlobReady(blob)) break;
119
+ if (eof || streamErr) break; // 无头 EOF:下面按失败处理
120
+ if (Date.now() >= deadline) { kill(); break; }
121
+ await sleep(20);
122
+ }
123
+ const { status, headers: resHeaders } = parseHeaderBlob(blob);
124
+ if (!status) {
125
+ kill();
126
+ try { rmSync(dir, { recursive: true, force: true }); } catch {}
127
+ const err = new Error(`curl-fetch: 无响应头(${String(url)})${streamErr ? ` err=${streamErr.message}` : ""}${stderr ? ` stderr=${stderr.slice(0, 160)}` : ""}`);
128
+ err._curlNoHeader = true;
129
+ throw err; // 交上层 network 重试/冷却(等价 fetch 的连接类抛错)
130
+ }
131
+
132
+ // ③ Response.body:先吐队列积压、再随 data 事件续吐;signal/cancel 杀进程
133
+ const cleanup = () => { try { rmSync(dir, { recursive: true, force: true }); } catch {} };
134
+ const stream = new ReadableStream({
135
+ async pull(controller) {
136
+ if (chunks.length) { controller.enqueue(new Uint8Array(chunks.shift())); return; }
137
+ if (streamErr) { cleanup(); controller.error(streamErr); return; }
138
+ if (eof) { cleanup(); controller.close(); return; }
139
+ const r = await new Promise((res) => waiters.push(res));
140
+ if (r.done) {
141
+ if (streamErr) { cleanup(); controller.error(streamErr); return; }
142
+ cleanup(); controller.close(); return;
143
+ }
144
+ controller.enqueue(new Uint8Array(r.value));
145
+ },
146
+ cancel() { kill(); cleanup(); },
147
+ });
148
+ const onAbort = () => kill();
149
+ if (init.signal) {
150
+ if (init.signal.aborted) kill();
151
+ else init.signal.addEventListener("abort", onAbort, { once: true });
152
+ }
153
+ return new Response(stream, { status, statusText: "", headers: resHeaders });
154
+ };
155
+ }
@@ -1,8 +1,9 @@
1
1
  import { appendEvent } from "../logs.js";
2
2
  import { splitModelId, DEFAULT_PROVIDER, joinModelId } from "./model-id.js";
3
3
  import { isModelAllowed as stateIsAllowed, loadProviderAllowedModels as stateLoadAllowed, loadProviderAllowAnyModels as stateLoadAllowAny } from "../state.js";
4
- // 定制预热名单:registry customProviders 9 家 + 默认 opencode;openrouter/自配 generic 不预热(按需自拉)
5
- const CUSTOM_PREHEAT_IDS = new Set(["opencode", "workbuddy", "traework", "cline", "codearts", "qoder", "globalqwenwork", "qwenwork", "zcode", "raccoon", "agentrouter"]);
4
+ // 定制预热名单:registry customProviders + 默认 opencode;openrouter/自配 generic 不预热(按需自拉)
5
+ // agentrouter 已于 2026-10-21 随 registry 一并摘除(见 src/providers/registry.js 注释)。
6
+ const CUSTOM_PREHEAT_IDS = new Set(["opencode", "workbuddy", "traework", "cline", "codearts", "qoder", "globalqwenwork", "qwenwork", "zcode", "raccoon"]);
6
7
  function isPreheatDisabled() {
7
8
  const raw = process.env.MSLXDFF_PREHEAT;
8
9
  if (raw === undefined || raw === null || raw === "") return false;
@@ -60,13 +60,9 @@ export const customProviders = [
60
60
  match: (id, baseUrl) => id === "raccoon" || String(baseUrl).includes("xiaohuanxiong.com"),
61
61
  load: () => import("./raccoon/index.js").then((m) => m.createRaccoonProvider),
62
62
  },
63
- {
64
- id: "agentrouter",
65
- // 客户端身份白名单(UA 非 opencode/claude-cli 恒 401)+ gpt-6* 只吃 /v1/responses,
66
- // generic 的写死 UA 与 models.dev 判定都覆盖不到,故走定制工厂。
67
- match: (id, baseUrl) => id === "agentrouter" || String(baseUrl).includes("agentrouter"),
68
- load: () => import("./agentrouter.js").then((m) => m.createAgentRouterProvider),
69
- },
63
+ // agentrouter 定制工厂已于 2026-10-21 摘除:现网成功率仅 ~48%(502/400/422/500 高发),
64
+ // 从注册表 + 预热名单移除后网关不再解析 agentrouter/* 模型。工厂文件 src/providers/agentrouter.js
65
+ // 与其测试保留,日后要重新启用只需在此加回一条 { id:"agentrouter", ... } 并同步 dispatcher 预热名单。
70
66
  ];
71
67
 
72
68
  // 供 bench/probe 等需要定制化解析模型列表的场景
@@ -53,6 +53,18 @@ export function parseRateLimitResetMs(bodyText, clock = Date.now) {
53
53
  return ms > 0 ? ms : 0;
54
54
  }
55
55
 
56
+ // 账号级风控封禁:腾讯 workbuddy `403 {"code":11140,"msg":"request illegal",displayMsg:"…restricted due to unusual activity/账号存在异常行为…"}`。
57
+ // 与 auth 失败、额度耗尽、限流都不同:**刷新 token 无效**(2026-10-21 实测:refresh 成功换发新 token 后照样 11140,
58
+ // 且换新 UA/完整客户端头集合同样 11140——判的是账号本体,不是凭据或请求指纹)。
59
+ // 命中即应长冷却该号并如实把 403 透回,绝不进 refresh→重打 的连锁(旧链每个用户请求白烧 号数×2 次上游调用 + 号数 次 refresh)。
60
+ export function isAccountRestricted(status, bodyText) {
61
+ if (status !== 403) return false;
62
+ const t = String(bodyText || "");
63
+ if (/"code"\s*:\s*11140\b/.test(t)) return true;
64
+ const lower = t.toLowerCase();
65
+ return lower.includes("request illegal") || lower.includes("restricted due to unusual activity") || t.includes("账号存在异常行为");
66
+ }
67
+
56
68
  export function isAuthError(status, bodyText) {
57
69
  if (status === 401 || status === 403) return true;
58
70
  const t = String(bodyText || "").toLowerCase();
@@ -86,10 +98,14 @@ export function createAuthService({
86
98
  }
87
99
  const p = (async () => {
88
100
  const url = joinUrl(resolvedBase, "/v2/plugin/auth/token/refresh");
101
+ // 回退到 2026-10-21 17:09 实测**可用**的旧头形(refresh 成功换发过新 token):refresh 无活体抓包,
102
+ // 不赌未验证口径;11140 只打 chat、refresh 不在风控画像主战场。X-Refresh-Token 是 refresh 特有凭据头。
103
+ // X-Auth-Refresh-Source: "plugin" 是 CLI bundle 三条 refreshSession 逐字共有的真值(@7064132/@7126898/@7144850),补上无副作用。
89
104
  const headers = {
90
105
  "Content-Type": "application/json",
91
106
  Authorization: `Bearer ${key}`,
92
107
  "X-Refresh-Token": rt,
108
+ "X-Auth-Refresh-Source": "plugin",
93
109
  "X-User-Id": uid,
94
110
  "X-Domain": auth.domain || "www.codebuddy.cn",
95
111
  "User-Agent": "CLI/2.115.0 WorkBuddy/2.115.0",
@@ -1,5 +1,5 @@
1
- import { joinUrl } from "../base.js";
2
- import { isAuthError, isInsufficientStatus, isRateLimitedStatus, parseRateLimitResetMs } from "./auth.js";
1
+ import { joinUrl, envInt } from "../base.js";
2
+ import { isAuthError, isInsufficientStatus, isRateLimitedStatus, parseRateLimitResetMs, isAccountRestricted } 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";
@@ -8,21 +8,12 @@ import { attemptOnceSdk } from "./sdk-chat.js";
8
8
  import { sanitizeToolSequence } from "./sanitize-tools.js";
9
9
  import { rewriteWorkbuddyPayload } from "./payload.js";
10
10
  import { resolveEngineMode } from "../../upstream-engine/mode.js";
11
+ import { buildWorkbuddyHeaders } from "./client-headers.js";
11
12
 
13
+ // 出站指纹单一真值:官方桌面端口径见 client-headers.js(含每请求随机 conversation/trace id)。
14
+ // 真机 chat 的 Accept 是 application/json(SSE 由 body.stream 协商、非 Accept),故不传 accept 用默认值。
12
15
  function buildAuthHeaders(key, auth) {
13
- const h = {
14
- "Content-Type": "application/json",
15
- Accept: "text/event-stream",
16
- "User-Agent": "CLI/2.115.0 WorkBuddy/2.115.0",
17
- Origin: "https://www.codebuddy.cn",
18
- Referer: "https://www.codebuddy.cn/",
19
- "X-Product": "SaaS",
20
- };
21
- if (key) h["Authorization"] = `Bearer ${key}`;
22
- if (auth?.uid) h["X-User-Id"] = auth.uid;
23
- h["X-Domain"] = auth?.domain || "www.codebuddy.cn";
24
- if (auth?.enterpriseId) { h["X-Enterprise-Id"] = auth.enterpriseId; h["X-Tenant-Id"] = auth.enterpriseId; }
25
- return h;
16
+ return buildWorkbuddyHeaders(key, auth);
26
17
  }
27
18
 
28
19
  function withUidHeader(res, uid) {
@@ -81,6 +72,12 @@ export function createChatService({
81
72
  clock = Date.now,
82
73
  cooldownMs = 30_000,
83
74
  } = {}) {
75
+ // 封号 11140 冷却时长(env MSLXDFF_WORKBUDDY_RESTRICTED_COOLDOWN_MS,缺省 15min):
76
+ // 腾讯"异常行为"限制可长可短,30s 档太短会反复白烧;15min 内人工申诉/自动解封后再回来。
77
+ const restrictedCooldownMs = envInt("MSLXDFF_WORKBUDDY_RESTRICTED_COOLDOWN_MS", 900_000);
78
+ // 最近一次「账号封禁 11140」记账 { at, uids, message }——让冷却期内到达的请求如实回 403
79
+ // (旧表现:keyring 全冷却 → 笼统 "all API keys are in cooldown" 冒 502,用户/客户端看不出是封号)。
80
+ let lastRestricted = null;
84
81
  const getBalance = balanceCache?.getCachedBalance || (() => null);
85
82
  const setBalance = balanceCache?.setCachedBalance || (() => {});
86
83
  const doLog = (opts) => {
@@ -132,6 +129,11 @@ export function createChatService({
132
129
  if (res.status < 400) return reshapeWorkbuddySse(res);
133
130
  let txt = "";
134
131
  try { txt = await res.text(); } catch {}
132
+ // 账号封禁 11140:判账号本体,refresh 无效(2026-10-21 实测刷出新 token 照样 11140),
133
+ // 原样上抛让 runChat 走「长冷却 + 换号」,绝不进 refresh 连锁白烧上游。
134
+ if (isAccountRestricted(res.status, txt)) {
135
+ throw failErr(`workbuddy account restricted (11140) for ${auth?.uid || ""}: ${txt.slice(0, 160)}`, _t0(), clock, { restricted: true, status: res.status });
136
+ }
135
137
  if (!isAuthError(res.status, txt)) return rewrapBody(res, txt);
136
138
  const newKey = await authService?.refreshTokenFor?.(key, auth);
137
139
  if (!newKey) throw failErr(`workbuddy refresh failed for ${auth?.uid || ""}: ${txt.slice(0, 120)}`, _t0(), clock);
@@ -141,6 +143,10 @@ export function createChatService({
141
143
  catch (e2) { throw e2; }
142
144
  let stillTxt = "";
143
145
  if (res2.status >= 400) { try { stillTxt = await res2.text(); } catch {} }
146
+ // 刷新后仍被封:同属账号级限制,标记 restricted 供上层长冷却。
147
+ if (isAccountRestricted(res2.status, stillTxt)) {
148
+ throw failErr(`workbuddy account restricted (11140) for ${auth?.uid || ""}: ${stillTxt.slice(0, 160)}`, _t0(), clock, { restricted: true, status: res2.status });
149
+ }
144
150
  if (stillTxt) res2 = rewrapBody(res2, stillTxt);
145
151
  const stillAuth = isAuthError(res2.status, stillTxt);
146
152
  const uid2 = auth2.uid || auth.uid;
@@ -186,6 +192,12 @@ export function createChatService({
186
192
  return res;
187
193
  } catch (e) {
188
194
  e._t = e._t || { attempts: [], waitMs: 0, totalMs: Math.round(nowMs(clock) - t0) };
195
+ // pin 模式撞封号:长冷却 + 把 403 原文透回(refresh 无效,别冒 502 让客户端以为网关坏了)。
196
+ if (e?._t?.restricted || e?.restricted) {
197
+ try { activeRing.onError(key, restrictedCooldownMs); } catch {}
198
+ doLog({ uid: auth.uid, model: _modelVal, totalMs: Math.round(nowMs(clock) - t0), balanceHit: false, error: String(e.message).slice(0, 160) });
199
+ return errRes(403, String(e.message), "account-restricted", auth.uid, t0, clock);
200
+ }
189
201
  if (!(e instanceof Error) || !String(e.message).includes("refresh failed")) try { activeRing.onError(key); } catch {}
190
202
  throw e;
191
203
  }
@@ -197,7 +209,13 @@ export function createChatService({
197
209
  for (let triedCount = 0; triedCount < maxTries; triedCount++) {
198
210
  const key = activeRing.next();
199
211
  if (!key) {
200
- if (triedCount === 0) throw failErr(`${id}: all API keys are in cooldown (last error < ${cooldownMs}ms ago) — provider temporarily unavailable`, t0, clock, { cooldownMs });
212
+ if (triedCount === 0) {
213
+ // 封号冷却窗口内的重复请求:如实回 403(账号问题),别抛笼统 cooldown 冒成 502。
214
+ if (lastRestricted && clock() - lastRestricted.at < restrictedCooldownMs) {
215
+ return errRes(403, `workbuddy: all account(s) restricted (11140): ${String(lastRestricted.message).slice(0, 160)}`, "account-restricted", "", t0, clock);
216
+ }
217
+ throw failErr(`${id}: all API keys are in cooldown (last error < ${cooldownMs}ms ago) — provider temporarily unavailable`, t0, clock, { cooldownMs });
218
+ }
201
219
  break;
202
220
  }
203
221
  const auth = authForKey(key);
@@ -218,7 +236,12 @@ export function createChatService({
218
236
  try { res = await withRefresh(url, body, key, auth); }
219
237
  catch (e) {
220
238
  e._t = e._t || { attempts: [], waitMs: 0, totalMs: Math.round(nowMs(clock) - t0) };
221
- try { activeRing.onError(key); } catch {}
239
+ // 封号 11140:长冷却本号换下一个;冷却档走 restrictedCooldownMs(缺省 15min),别用 30s 短档反复白烧。
240
+ // restricted 标记挂在 failErr 的 _t 上;SDK 通道对 4xx 抛的 AI_APICallError 带 statusCode/responseBody,一并识别。
241
+ if (e?._t?.restricted || e?.restricted || isAccountRestricted(e?.statusCode, e?.responseBody)) {
242
+ lastRestricted = { at: clock(), uid, message: String(e.responseBody || e.message || "").slice(0, 200) };
243
+ try { activeRing.onError(key, restrictedCooldownMs); } catch {}
244
+ } else try { activeRing.onError(key); } catch {}
222
245
  lastErr = e;
223
246
  continue;
224
247
  }
@@ -256,7 +279,18 @@ export function createChatService({
256
279
  doLog({ uid, model: _modelVal, totalMs: Math.round(nowMs(clock) - t0), balanceHit: false });
257
280
  return res;
258
281
  }
259
- if (lastErr) throw lastErr;
282
+ if (lastErr) {
283
+ // 全号皆被封 11140:上游报文带可申诉的 displayMsg/displayTips,透回原文比模糊包装更利于用户自查;
284
+ // 状态码用上游的 403(旧链是 502,客户端侧像网关坏了,实为账号问题)。
285
+ if (lastErr._t?.restricted || lastErr.restricted) {
286
+ lastRestricted = { at: clock(), uid: "", message: String(lastErr.message || "") };
287
+ const uids = [...tried].filter((x) => x && x.includes("-")).join(",");
288
+ const reason = `workbuddy all account(s) restricted (11140): ${uids || "n/a"} — ${String(lastErr.message).slice(0, 200)}`;
289
+ const r = errRes(403, reason, "account-restricted", uids, t0, clock);
290
+ return r;
291
+ }
292
+ throw lastErr;
293
+ }
260
294
  throw failErr(`${id}: all workbuddy accounts exhausted or unavailable`, t0, clock);
261
295
  }
262
296
 
@@ -0,0 +1,148 @@
1
+ // WorkBuddy 出站请求头——照抄本机官方桌面端 5.8.3 的组装真值(buildVlmGatewayHeaders +
2
+ // applyGatewayHeaders + applyTraceHeaders 三函数,逆向自 app.asar @89622109,见 .scratch/wb-chat-headers.txt)。
3
+ // 为什么要它:2026-10-21 三号集体撞 `403 11140 账号存在异常行为` 封号,且换 UA 到官方完整口径也照样 11140
4
+ // ——判的是账号本体,但旧出站指纹是**自造杂交串**:UA `CLI/2.115.0 WorkBuddy/2.115.0`(真机是
5
+ // `WorkBuddy/5.8.3 WorkBuddy/5.8.3`)、缺 X-IDE-*/X-Private-Data/x-codebuddy-request/X-Conversation-*/
6
+ // traceparent 全链追踪头、还多带真机 chat 明确排除的 Origin/Referer。裸指纹 + 桌面登录号 = 风控画像。
7
+ // 对齐口径降低「登新号又被带进沟」的概率。⚠ 对齐是降风险非保命,封号是账号级、refresh 已证实无效。
8
+ // 版本随客户端自动更新会漂移:MSLXDFF_WORKBUDDY_APP_VERSION 覆盖(缺省 5.8.3);
9
+ // MSLXDFF_WORKBUDDY_CLI_VERSION 非空才追加 ` CLI/<v>` 段(真机 buildVlmGatewayHeaders 的 i 参数可为空)。
10
+ // 一键回退旧口径:MSLXDFF_WORKBUDDY_LEGACY_HEADERS=1。
11
+ import { randomBytes } from "node:crypto";
12
+
13
+ const APP_VERSION = () => (process.env.MSLXDFF_WORKBUDDY_APP_VERSION || "5.8.3").trim();
14
+ // CLI 段版本取自本机活体日志(a06ef5f8 实时 chat 请求 UA `WorkBuddy/5.8.3 WorkBuddy/5.8.3 CLI/2.161.0`)。
15
+ const CLI_VERSION = () => {
16
+ const v = (process.env.MSLXDFF_WORKBUDDY_CLI_VERSION ?? "").trim();
17
+ return v === "" ? "2.161.0" : v; // 缺省跟本机真值;显式空串仍可关掉该段
18
+ };
19
+ const isLegacy = () => process.env.MSLXDFF_WORKBUDDY_LEGACY_HEADERS === "1";
20
+
21
+ function hex(n) { return randomBytes(n).toString("hex"); }
22
+
23
+ // 真机 X-Request-ID 家族是 UUIDv7(毫秒时间戳打头 48bit + 版本位 7 + 变体位 10xx),
24
+ // 活体日志证实:`01a15dcaf9a97a86a215ac50f6bcdfff` 第 13 位恒为 '7'。裸 hex 版本位 15/16 概率错、
25
+ // 时间戳前缀是乱码——风控一眼假。按 https://www.rfc-editor.org/rfc/rfc9562 组真 v7。
26
+ function uuidv7hex() {
27
+ const b = Buffer.from(randomBytes(16));
28
+ const ts = Date.now();
29
+ b[0] = Math.floor(ts / 2 ** 40) & 0xff;
30
+ b[1] = Math.floor(ts / 2 ** 32) & 0xff;
31
+ b[2] = Math.floor(ts / 2 ** 24) & 0xff;
32
+ b[3] = Math.floor(ts / 2 ** 16) & 0xff;
33
+ b[4] = Math.floor(ts / 2 ** 8) & 0xff;
34
+ b[5] = ts & 0xff;
35
+ b[6] = (b[6] & 0x0f) | 0x70; // version 7
36
+ b[8] = (b[8] & 0x3f) | 0x80; // variant 10xx
37
+ return b.toString("hex");
38
+ }
39
+ // 真机 X-Conversation-ID 家族是 UUIDv4(带横线,版本位 4 / 变体位 8~b),活体:`b84c6a36-bd21-461a-b9be-…`。
40
+ function uuidv4dashed() {
41
+ const b = Buffer.from(randomBytes(16));
42
+ b[6] = (b[6] & 0x0f) | 0x40;
43
+ b[8] = (b[8] & 0x3f) | 0x80;
44
+ const h = b.toString("hex");
45
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
46
+ }
47
+
48
+ // 真机 CLI trace 头(bundle @4733511 trace_start_hook,**不是** Electron 代理链的 applyTraceHeaders):
49
+ // 只设 X-Trace-ID/X-B3-TraceId(=同 traceId 32hex)/X-B3-SpanId(16hex)/X-B3-Sampled=1/b3(<trace32>-<span16>-1 三段)。
50
+ // 无 traceparent、无 X-B3-ParentSpanId——那两个是 Electron web 转发链的形状,chat 实发者是 CLI 子进程,别抄错链路。
51
+ function traceHeaders() {
52
+ const n = hex(16), r = hex(8);
53
+ return {
54
+ "X-Trace-ID": n,
55
+ "X-B3-TraceId": n,
56
+ "X-B3-SpanId": r,
57
+ "X-B3-Sampled": "1",
58
+ b3: `${n}-${r}-1`,
59
+ };
60
+ }
61
+
62
+ // 网关态:每次调用是一条新逻辑会话。id 形状取自活体日志(2026-10-21 a06ef5f8 正常发 200 的 CLI 请求):
63
+ // 会话 id = **UUIDv4 带横线**(`b84c6a36-bd21-461a-b9be-…`);请求 id = **UUIDv7 去横线**(第 13 位恒 '7')。
64
+ // bundle 逐字(@5997642 buildConversationHeaders):X-Conversation-ID=会话 id;X-Request-ID=X-Conversation-Message-ID
65
+ // =同一 uuidv7 双写;X-Conversation-Request-ID=**另一个** uuidv7(turn 级生成器)。网关一调用=新会话=新 turn,
66
+ // 两值各生成一份即可(真机一 turn 多请求时才复用 Conversation-Request-ID)。
67
+ // ⚠ X-Root-Request-ID / X-Parent-Conversation-ID 真机是**条件头**(meta 有值才设——sub-agent/续聊轮才带)。
68
+ // 网关永远首请求,带上=把每条请求伪造成续聊,画像反常 → 不带。X-Session-ID 保留(活体 config/日志有同值头)。
69
+ function conversationHeaders() {
70
+ const conv = uuidv4dashed();
71
+ const req = uuidv7hex();
72
+ const turn = uuidv7hex();
73
+ return {
74
+ "X-Conversation-ID": conv,
75
+ "X-Session-ID": conv,
76
+ "X-Request-ID": req,
77
+ "X-Conversation-Request-ID": turn,
78
+ "X-Conversation-Message-ID": req,
79
+ "X-Agent-Type": "main",
80
+ "acp-connection-id": req,
81
+ };
82
+ }
83
+
84
+ function legacyHeaders(key, auth, accept) {
85
+ const h = {
86
+ "Content-Type": "application/json",
87
+ Accept: accept,
88
+ "User-Agent": "CLI/2.115.0 WorkBuddy/2.115.0",
89
+ Origin: "https://www.codebuddy.cn",
90
+ Referer: "https://www.codebuddy.cn/",
91
+ "X-Product": "SaaS",
92
+ };
93
+ if (key) h["Authorization"] = `Bearer ${key}`;
94
+ if (auth?.uid) h["X-User-Id"] = auth.uid;
95
+ h["X-Domain"] = auth?.domain || "www.codebuddy.cn";
96
+ if (auth?.enterpriseId) { h["X-Enterprise-Id"] = auth.enterpriseId; h["X-Tenant-Id"] = auth.enterpriseId; }
97
+ return h;
98
+ }
99
+
100
+ // 官方桌面端完整口径。三处出口(chat / refresh / config)共用,保证**一个号一张脸**:
101
+ // chat 带每请求 conversation/trace id;refresh、config 无会话 → withConversation:false。
102
+ // 真机 buildVlmGatewayHeaders 的 Accept 是 application/json(SSE 靠 body.stream 协商),故 accept 默认即此。
103
+ export function buildWorkbuddyHeaders(key, auth, { accept = "application/json", withConversation = true } = {}) {
104
+ if (isLegacy()) return legacyHeaders(key, auth, accept);
105
+ const app = APP_VERSION(), cli = CLI_VERSION();
106
+ const ua = [`WorkBuddy/${app}`, `WorkBuddy/${app}`, ...(cli ? [`CLI/${cli}`] : [])].join(" ");
107
+ const h = {
108
+ "Content-Type": "application/json",
109
+ Accept: accept,
110
+ "Accept-Encoding": "identity",
111
+ "User-Agent": ua,
112
+ "X-IDE-Type": "WorkBuddy",
113
+ "X-IDE-Name": "WorkBuddy",
114
+ "X-IDE-Version": app,
115
+ "X-Private-Data": process.env.MSLXDFF_WORKBUDDY_PRIVATE_DATA || "false",
116
+ "X-Product": "SaaS",
117
+ "x-requested-with": "XMLHttpRequest",
118
+ "x-codebuddy-request": "1",
119
+ "X-Agent-Intent": "craft",
120
+ "X-Agent-Purpose": "conversation",
121
+ ...(withConversation ? conversationHeaders() : {}),
122
+ ...(withConversation ? traceHeaders() : {}),
123
+ };
124
+ if (key) h["Authorization"] = `Bearer ${key}`;
125
+ if (auth?.uid) h["X-User-Id"] = auth.uid;
126
+ h["X-Domain"] = auth?.domain || "www.codebuddy.cn";
127
+ if (auth?.enterpriseId) { h["X-Enterprise-Id"] = auth.enterpriseId; h["X-Tenant-Id"] = auth.enterpriseId; }
128
+ return h;
129
+ }
130
+
131
+ // GET /v3/config 专用:活体真值=本机 CLI 子进程(好号 a06ef5f8 正常工作中)2026-10-21 17:29 实发的 6 头集
132
+ //(客户端日志逐字:authorization/user-agent(WorkBuddy/5.8.3 WorkBuddy/5.8.3 CLI/2.161.0)/x-domain/x-product/x-request-id/x-user-id)。
133
+ // ⚠ config 的 x-request-id=`b1f4cb939a57486d86d16b74bec13511` 第13位='4'、第17位='8' → 是**去横线 UUIDv4**,
134
+ // 与 chat 的 UUIDv7 生成器不同(两条链路各自的 id 策略)。**无 Content-Type(GET)、无 X-IDE-*、无 trace、无 Origin/Referer**——
135
+ // 别拿 chat 全量头套它(2026-10-21 曾 over-align,按活体回退)。
136
+ export function buildWorkbuddyConfigHeaders(key, auth) {
137
+ const app = APP_VERSION(), cli = CLI_VERSION();
138
+ const ua = [`WorkBuddy/${app}`, `WorkBuddy/${app}`, ...(cli ? [`CLI/${cli}`] : [])].join(" ");
139
+ const h = {
140
+ "User-Agent": ua,
141
+ "X-Domain": auth?.domain || "www.codebuddy.cn",
142
+ "X-Product": "SaaS",
143
+ "X-Request-ID": uuidv4dashed().replace(/-/g, ""),
144
+ };
145
+ if (key) h["Authorization"] = `Bearer ${key}`;
146
+ if (auth?.uid) h["X-User-Id"] = auth.uid;
147
+ return h;
148
+ }
@@ -73,6 +73,22 @@ export function readDesktopAccount(fp) {
73
73
  accessToken: j.auth.accessToken,
74
74
  refreshToken: j.auth.refreshToken || "",
75
75
  expiresAt: exp,
76
- domain: j.auth.domain || "www.codebuddy.cn",
76
+ // domain 以 token 的 iss realm 为准(比 auth.domain 更贴近 cop 网关鉴权域);auth.domain 兜底。
77
+ // 注:从桌面 info 导入不等于能对话——2026-10-21 一台桌面态 token 对网关直连仍 401(疑似内嵌 CLI 与
78
+ // 桌面 Electron 双鉴权);入池正解是设备授权流 `-provider workbuddy login`,本函数只负责解析登录态字段。
79
+ domain: issDomain(j.auth.accessToken) || j.auth.domain || "www.codebuddy.cn",
77
80
  };
78
81
  }
82
+
83
+ // 从 JWT payload.iss(如 https://www.codebuddy.cn/auth/realms/copilot)提取主机名;解析失败返回 ""。
84
+ export function issDomain(token) {
85
+ try {
86
+ const payload = String(token || "").split(".")[1];
87
+ if (!payload) return "";
88
+ let b = payload.replace(/-/g, "+").replace(/_/g, "/");
89
+ while (b.length % 4) b += "=";
90
+ const iss = JSON.parse(Buffer.from(b, "base64").toString("utf8"))?.iss;
91
+ if (typeof iss !== "string") return "";
92
+ return new URL(iss).host || "";
93
+ } catch { return ""; }
94
+ }
@@ -1,5 +1,7 @@
1
1
  import { joinModelId } from "../model-id.js";
2
2
  import { joinUrl } from "../base.js";
3
+ import { isAccountRestricted } from "./auth.js";
4
+ import { buildWorkbuddyConfigHeaders } from "./client-headers.js";
3
5
 
4
6
  function creditsValue(c) {
5
7
  if (!c || !String(c).trim()) return 0;
@@ -28,16 +30,8 @@ export function createModelsService({
28
30
  const controller = new AbortController();
29
31
  const timer = setTimeout(() => controller.abort(new Error(`${id} models timed out`)), 15_000);
30
32
  try {
31
- const headers = {
32
- Accept: "application/json",
33
- "X-User-Id": useAuth?.uid || "",
34
- "X-Domain": useAuth?.domain || "www.codebuddy.cn",
35
- "X-Product": "SaaS",
36
- "User-Agent": "CLI/2.115.0 WorkBuddy/2.115.0",
37
- Origin: "https://www.codebuddy.cn",
38
- Referer: "https://www.codebuddy.cn/",
39
- };
40
- if (useKey) headers["Authorization"] = `Bearer ${useKey}`;
33
+ // config 端点按活体真值只发 6 头(GET 简头通道,勿套 chat 全量头——2026-10-21 实测回退)。
34
+ const headers = buildWorkbuddyConfigHeaders(useKey, useAuth);
41
35
  const opts = { headers, signal: controller.signal };
42
36
  if (dispatcher) opts.dispatcher = dispatcher;
43
37
  return await fetchImpl(`${joinUrl(baseUrl, modelsPath)}`, opts);
@@ -65,6 +59,8 @@ export function createModelsService({
65
59
  if (!r.ok) {
66
60
  let t = "";
67
61
  try { t = await r.clone().text(); } catch {}
62
+ // 封号 11140:refresh 无效(判账号本体),跳过刷新直接换下一个 key,别白烧 refresh 端点。
63
+ if (isAccountRestricted(r.status, t)) continue;
68
64
  if (isAuthError?.(r.status, t)) {
69
65
  const newKey = await refreshTokenFor?.(key, auth);
70
66
  if (newKey && !tried.has(newKey)) {
@@ -98,16 +94,8 @@ export function createModelsService({
98
94
  try {
99
95
  const key = getKey ? getKey() : "";
100
96
  const auth = getAuth ? getAuth(key) : null;
101
- const headers = {
102
- Accept: "application/json",
103
- "X-User-Id": auth?.uid || "",
104
- "X-Domain": auth?.domain || "www.codebuddy.cn",
105
- "X-Product": "SaaS",
106
- "User-Agent": "CLI/2.115.0 WorkBuddy/2.115.0",
107
- Origin: "https://www.codebuddy.cn",
108
- Referer: "https://www.codebuddy.cn/",
109
- };
110
- if (key) headers["Authorization"] = `Bearer ${key}`;
97
+ // 与 listModels 同一口径:config 简头集(活体真值)。
98
+ const headers = buildWorkbuddyConfigHeaders(key, auth);
111
99
  const opts = { headers };
112
100
  if (dispatcher) opts.dispatcher = dispatcher;
113
101
  const res = await fetchImpl(joinUrl(baseUrl, modelsPath), opts);