dsh-connect-workbuddy 4.13.1 → 4.13.2

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,39 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.13.2 (2026-10-21)
4
+
5
+ > 版本号是 **patch**:没有接口、配置项或请求结构的变更。发往上游的请求体**一个字节都没变**——4.13.0 的「渠道指纹归一化」照旧在跑,本版只改**网关已经拒绝之后**的那一段:把 11128 从「通用参数拒绝」里拆出来单独归类,并把**被拒的那条 system 提示开头**回显给读者。路由 id、模型 id、凭据、计费与换号规则一律未变(换号规则只有一处「不再多问一次」的减少,见下)。
6
+
7
+ ### Fixes
8
+
9
+ - **issue #87 / #88 / #89 / #90 的后半段:11128 仍被归进「通用参数拒绝」,读者拿到的还是那句「请按上面的提示调整后重试」,而「上面的提示」是网关的误导文案**
10
+ - **报告**:4.13.0 把「为什么被拒」查清了(网关按 system 提示**开头**匹配第三方 agent 清单),并让这类请求不再发出去。但**清单是实测快照**:网关新增词条时,请求照样被拒,而插件此时给出的仍然是那句通用回复——`workbuddy 上游失败:请求被上游拒绝(http 400)(code 11128,requestId …)。这次调用没通过渠道校验,可能不是官方客户端发出的。请用官方版本重新发送。该请求被上游按参数拒绝:与网络、账号、额度都无关,换账号也不会改变结果,请按上面的提示调整后重试`。四份报告里的一共五位用户(@huaizhuanghub、@ShiningYangYXN、@yuhang0529、@haohui421 等)在这句上花了大量时间:换账号、换区域、装官方客户端、改 UA。
11
+ - **真实原因:`11128` 这一族里有三道门共用一个业务码,而插件只认其中一道。** 网关的 `displayTips` 对**每一道**都写成同一句「这次调用没通过渠道校验,可能不是官方客户端发出的」,所以那句话从来不是判据,只是一句放之四海皆准的托词。本版要处理的那一道是**提示开头命中**(4.13.0 已定位),而另外两道是**请求头**缺 conversation id(issue #66 已修)与**首条消息不是 system**。三道门共用 `11128`,**只有 body 能区分**。
12
+ - **插件此前的三个错处,逐条对应**:
13
+ 1. **归类错了**:11128 落在 `client` 兜底类,读者拿到的是「按参数拒绝,换账号也没用」——这句本身没错,但**它没说是什么参数被拒**,于是提示词从来不在嫌疑名单上;而「不是官方客户端发出的」那句托词被原样转述,用户信了它。
14
+ 2. **证据没有回显**:被拒的证据在**我们刚发出去的请求**里,不在上游的回复里。插件手上明明有那几行字,却只转述了上游的托词。
15
+ 3. **探针那一侧也退化成通用失败**:探针用的是中性 system 提示(`probeRequestBody`),所以「测试能过、聊天被拒」正是这个闸的特征,而卡片当时把同一种拒绝印成「✗ 测试失败」——那一句在账号旁边,读者只会去换账号。
16
+ - **修法一:把「提示开头命中」这一道单独归类(`channel_reject`)**。判据是**两半都要**:body 里同时有 `"code":11128` **和** msg 的措辞(`unapproved channel` / `illegal api invocation`,大小写不敏感)。只看 code 会连另外两道门一起认领——那会让读者去改写一条根本没问题的 system 提示,比通用回复更糟;所以另外两道门(`conversation id missing`、`first message is not system prompt`)被**显式排除**。**只认 `msg`、绝不认 `displayTips`**:那句托词是全族共用的,认它等于把三道门混成一道。判据放在 `classifyUpstreamError()` 里**策略拒绝之后、429/14003 之前**,所以真正的内容拒绝(`11140`)仍走它更具体的回复。
17
+ - **修法二:回显被拒的那一行**。新增 `systemOpeningsOfBody()`:从**实际发出去的 body**(`prepareChatBody` 归一化之后的那一份)里取每条 system 消息的**第一行非空内容**,最多回显 3 条、每条截到 120 字符,取的是**上真实发送的字节**(所以命中已知指纹时回显的是插件加的那行占位语,不是宿主原文——这正是「证据」二字的含义)。读不出 body 时不编造,直接省略这段,但结论句仍然成立。
18
+ - **修法三:三条行为跟着这个归类走**,因为它们都属于「上游拒的是提示词,不是账号」这一件事:
19
+ - **不再换号**(`isFailoverWorthy`):网关读的是**每条 account 都会发的那同一句提示**,换到下一个账号只会晚一轮拿到同一个 11128,而每换一次就烧一次真实额度、还往一个从没出问题的凭据上记一笔假账。
20
+ - **不把账号踢出池**(`exclusionOf`):与 `policy-rejected` 同处理——测量照记(卡片要能解释发生了什么),但账号仍是候选。把它 bench 掉既浪费一个好账号,又把真正的解法(改提示词)藏起来。
21
+ - **探针与账号表用各自的说法**(`channel-rejected` 这个 outcome):卡片那一行的文案是「网关拦的是提示词本身,不是这个账号——某条 system 消息的开头命中了它的第三方 agent 过滤,换账号或换区域都会得到同一个答案(测试能过、聊天被拒,正是这个原因)」,语气用 `warn` 而不是 `bad`:账号本身没问题,一行 `bad` 会把读者送回「换账号」。
22
+ - **回复文本长什么样**(形状与其它失败一致,报告者会原样粘进 issue,所以外框不变):`workbuddy 上游失败:被网关的渠道校验拦截(不是账号、额度或网络问题)(http 400)(code 11128,requestId …)。命中的 system 提示开头:「You are an AI agent powered by DeepSeek Harness.」。该请求被 WorkBuddy 网关的渠道校验拦截,上游把它描述成「不是官方客户端发出的」,但那是误导性文案:网关匹配的是 system 提示的开头,与账号、积分、额度、网络都无关,换账号或换区域也不会改变结果。若这段开头来自宿主或其他插件,请修改该提示;插件已对已知的第三方 agent 指纹做归一化,新的指纹可反馈到 issue 里`
23
+ - **宿主那一侧也核过了(本版刻意**不**改的地方)**:这个失败写给宿主的 `code` 就是 `channel_reject`(**不翻译**),状态 400 ⇒ 宿主分类为 `INVALID_REQUEST`。这一类**不在**宿主的原地重试集合(`RATE_LIMIT` / `SERVER` / `TIMEOUT` / `TRANSPORT` / `EMPTY_RESPONSE`)里——这是对的:闸的判决对同一段提示是确定的,重试只是把同一串字节再发一遍。宿主 `failureMessage()` 只对 `QUOTA` / `ACCOUNT_QUOTA` / `AUTH` 等少数几类**丢掉插件自己的文案**,本类不在其中,所以上面那句原样到达读者。
24
+ - **代价与边界(如实登记)**:① 回显的是**提示开头**,不是整个提示——闸只看开头,其余内容无辜,不该被抄进 issue;② 只对**body 能区分**的那一道门生效,另外两道门(请求头缺 id、首条不是 system)维持原分类,它们各有各的修法;③ 提示开头若本身就是敏感内容,读者粘进 issue 时要注意(插件不做脱敏,**上游原文回显与 issue 传播是使用者自己的决定**);④ 4.13.0 的「已知指纹归一化」清单仍是实测快照,本版**不扩大**它——归一化是修复,单独归类是**兜底**,两者不互相替代。
25
+
26
+ ### Tests
27
+
28
+ - **`tests/issue90-channel-reject.spec.ts` 新增 24 条用例**(含 3 组):把「认得这道门」与「**认错**这道门」分开钉,后者才是本版的真正难点:
29
+ - **认门(6 条)**:真实 CN 网关的 11128 报文逐字节捕获 → 归为 `channel_reject`(修复前是 `client`);只认 `msg` 不认 `displayTips`(三道门共用的那句托词不足以认领任何一道);另外两道 11128 门**保持原分类**(认错会让读者去改一条没问题的提示);code 与措辞**两半都要**(换个业务码引同样的英文短语不是这道门);真正的内容拒绝(`11140`)仍走它更具体的回复;请求 id 里恰好含 `11128` 不被误认。
30
+ - **探针与账号池(2 条)**:探针把这个拒绝报成 `channel-rejected` 而不是 `failed`(后者在卡片上是「✗ 测试失败」,是唯一会引人去换账号的标签);`exclusionOf()` 对它返回 `undefined`——账号不被踢出池。
31
+ - **回显取什么(6 条)**:每条 system 消息各取第一行;**取的是归一化之后真正上线的字节**(已知指纹回显的是占位行,不是宿主原文);病态长行截到 120 字符而不是把整个提示倒出来;最多 3 条;读不出来的 body 一律返回 `[]` 且**不抛**(丢掉测量不是可以接受的代价);`content` 是数组而非字符串的消息被跳过。
32
+ - **渲染出来的回复(6 条)**:真的挂起 **shim**(真 body 准备 + 真失败渲染),只把上游应答换成固定报文:回复里有被拒的那一行、有 `code 11128`、有 `requestId`、有「换账号或换区域也不会改变结果」、有「误导性文案」,并且**没有**旧那句「换账号也不会改变结果,请按上面的提示调整后重试」;回显的是**归一化后**的内容;宿主没给 system 消息时回显插件补的那行占位语(`ensureSystemHead` 保证有头,所以这是离「没有提示可引」最近的一种);**没有把一次换号花掉**(failover 回调被调用 0 次,且回复不声称「已尝试 N 个账号」——那是 issue #78 的老坑);宿主面拿到的是 400 + `channel_reject`。
33
+ - **卡片文案(2 条)**:中英文都与「策略拒绝」「通用失败」不同(共用字符串会让「测试能过、聊天被拒」读成内容拒绝,而那有完全相反的下一步);**所有 `row.probe*` 文案里不含 `**` 粗体标记**——探针结论是当作纯文本渲染进 `<p>` 的,带标记就会在屏幕上显示成字面的星号。
34
+ - **宿主那一侧(2 条,issue #84 的做法)**:用**真实 pi-ai 客户端**打**真实 shim**,再问**宿主自己的判定函数**——`isQuotaExceededError()` 必须为假(为真则宿主会用自己的「额度已用尽」顶掉插件的句子,正是 issue #84 的那个坑),且文本里必须有「命中的 system 提示开头」与「换账号或换区域也不会改变结果」,**且不能有**「请用官方版本重新发送」那句托词。
35
+ - **变异验证**:`git checkout src/upstream.ts src/shim.ts` 回退修复后,本文件 **17 条**失败(归类回 `client`、回显消失、换号又发生、探针退回 `failed`、宿主侧被判成额度失败);仍然通过的 7 条正是护栏(另外两道 11128 门的原分类、`11140` 的原分类、账号不被踢出池、不浪费换号、卡片文案互不相同、文案不带 `**`)。
36
+
3
37
  ## 4.13.1 (2026-10-21)
4
38
 
5
39
  > 版本号是 **patch**:没有接口、配置项或请求结构的变更。发往上游的请求体**一个字节都没变**——修的是「请求根本没被发出去」这条本地失败路径:目录里没有 reasoning 元数据的模型,此前在组装请求时就被本地判定失败(`UNSUPPORTED_REASONING_EFFORT`),网络一个字节都没出。路由 id、模型 id、凭据、计费与换号规则一律未变。
package/lib/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { $ as WORKBUDDY_APP_EXECUTABLE_ENV, C as defaultDesktopAuthDirs, F as WorkBuddyUpstreamClient, O as legacyWorkbuddyOwnAuthPath, S as defaultDesktopAuthCandidates, a as readHostHeartbeat, at as findWorkbuddyAppExecutableWithSource, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, pt as hostPeersInfo, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, y as WorkBuddyCredentialStore } from "./host-heartbeat-CwdR6teZ.js";
2
+ import { C as defaultDesktopAuthDirs, F as WorkBuddyUpstreamClient, O as legacyWorkbuddyOwnAuthPath, S as defaultDesktopAuthCandidates, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as WORKBUDDY_APP_EXECUTABLE_ENV, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, mt as hostPeersInfo, o as workbuddyHostHeartbeatPath, ot as findWorkbuddyAppExecutableWithSource, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, y as WorkBuddyCredentialStore } from "./host-heartbeat-BJAL9pEe.js";
3
3
  import { realpathSync } from "node:fs";
4
4
  import { fileURLToPath } from "node:url";
5
5
  //#region src/bin.ts
package/lib/client.js CHANGED
@@ -902,6 +902,7 @@ window.__ModuleLoader__.load({
902
902
  case "model-busy": return retryDue(probe, nowMs, 12e4) ? void 0 : "model-busy";
903
903
  case "out-of-credit": return retryDue(probe, nowMs) ? void 0 : "out-of-credit";
904
904
  case "policy-rejected": return;
905
+ case "channel-rejected": return;
905
906
  default: return "unusable";
906
907
  }
907
908
  }
@@ -1016,6 +1017,7 @@ window.__ModuleLoader__.load({
1016
1017
  case "model-busy": return t("row.poolExcludedModelBusy");
1017
1018
  case "out-of-credit": return t("row.poolExcludedOutOfCredit");
1018
1019
  case "credential-rejected": return t("row.probeCredentialRejected");
1020
+ case "channel-rejected": return t("row.probeChannelRejected");
1019
1021
  case "not-found": return t("row.probeNotFound");
1020
1022
  case "unavailable": return t("row.probeUnavailable");
1021
1023
  default: return t("row.probeFailed");
@@ -1204,6 +1206,7 @@ window.__ModuleLoader__.load({
1204
1206
  "out-of-credit",
1205
1207
  "credential-rejected",
1206
1208
  "policy-rejected",
1209
+ "channel-rejected",
1207
1210
  "unavailable",
1208
1211
  "not-found",
1209
1212
  "failed"
@@ -4823,6 +4826,10 @@ window.__ModuleLoader__.load({
4823
4826
  text: t("row.probePolicyRejected"),
4824
4827
  tone: "bad"
4825
4828
  };
4829
+ case "channel-rejected": return {
4830
+ text: t("row.probeChannelRejected"),
4831
+ tone: "warn"
4832
+ };
4826
4833
  case "not-found": return {
4827
4834
  text: t("row.probeNotFound"),
4828
4835
  tone: "bad"
@@ -7211,6 +7218,7 @@ window.__ModuleLoader__.load({
7211
7218
  "row.probeOutOfCreditAt": "✗ Not enough credits · quota refreshes {at}",
7212
7219
  "row.probeCredentialRejected": "✗ WorkBuddy refused this account's token — sign in again or pick another account",
7213
7220
  "row.probePolicyRejected": "✗ WorkBuddy's server refused this request under its content policy — re-signing in won't help; try the desktop app or another region/account",
7221
+ "row.probeChannelRejected": "✗ The gateway refused the PROMPT itself, not this account — the opening of a system message matched its third-party-agent filter, so another account or region answers the same way",
7214
7222
  "row.probeNotFound": "✗ The upstream does not know this model",
7215
7223
  "row.probeUnavailable": "✗ The upstream could not be reached — no usable answer came back",
7216
7224
  "row.probeFailed": "✗ Test failed",
@@ -7536,6 +7544,7 @@ window.__ModuleLoader__.load({
7536
7544
  "row.probeOutOfCreditAt": "✗ 积分不足 · 积分包 {at} 刷新",
7537
7545
  "row.probeCredentialRejected": "✗ WorkBuddy 拒绝了这个账号的 token——请重新登录或换一个账号",
7538
7546
  "row.probePolicyRejected": "✗ WorkBuddy 服务端按内容策略拒绝了这次请求——重新登录无效;可在桌面端验证或切换区域/账号",
7547
+ "row.probeChannelRejected": "✗ 网关拦的是提示词本身,不是这个账号——某条 system 消息的开头命中了它的第三方 agent 过滤,换账号或换区域都会得到同一个答案(测试能过、聊天被拒,正是这个原因)",
7539
7548
  "row.probeNotFound": "✗ 上游不认识这个模型",
7540
7549
  "row.probeUnavailable": "✗ 连不上上游——上游没有给出可用答复",
7541
7550
  "row.probeFailed": "✗ 测试失败",
@@ -1281,6 +1281,44 @@ const POLICY_REJECT_MARKERS = ["request illegal", "\"code\":11140"];
1281
1281
  */
1282
1282
  const MODEL_BUSY_CODE = "\"code\":14003";
1283
1283
  /**
1284
+ * Business code of the gateway's CHANNEL gate (`11128`, "Illegal API invocation
1285
+ * from an unapproved channel").
1286
+ *
1287
+ * The quoted-key form for the same reason as the markers above: a request id is
1288
+ * hexadecimal and can contain the digits `11128` by coincidence.
1289
+ *
1290
+ * A DIFFERENT gate from the one {@link conversationHeaders} works around, which
1291
+ * also answers `11128` (`conversation id missing`). The code is the same
1292
+ * because the gateway reuses it for every rule in that family; the BODY is what
1293
+ * tells them apart, so {@link isChannelRejectBody} requires both halves.
1294
+ */
1295
+ const CHANNEL_REJECT_CODE = "\"code\":11128";
1296
+ /**
1297
+ * Wording that identifies the CHANNEL half of `11128` (the other halves being
1298
+ * `conversation id missing` and `first message is not system prompt`).
1299
+ *
1300
+ * Matched against the upstream's `msg`, NEVER against `displayTips`. That is not
1301
+ * a style choice: the tips are the SAME misleading sentence for every member of
1302
+ * the `11128` family — "这次调用没通过渠道校验,可能不是官方客户端发出的" — which is
1303
+ * why matching them would claim the conversation-id gate too and blame the
1304
+ * prompt's opening for a header problem this plugin already fixes.
1305
+ *
1306
+ * Lower-cased before the search. Two spellings are listed because the observed
1307
+ * bodies name the code and the phrase together
1308
+ * (`{"code":11128,"msg":"Illegal API invocation from an unapproved channel"}`),
1309
+ * and either half is enough to identify it.
1310
+ */
1311
+ const CHANNEL_REJECT_MARKERS = ["unapproved channel", "illegal api invocation"];
1312
+ /**
1313
+ * The other gates that answer the SAME business code.
1314
+ *
1315
+ * Excluded explicitly so a body that also carries the channel wording can never
1316
+ * be read as the prompt rule: a confidently wrong diagnosis is worse here than
1317
+ * the generic `client` reply, because it sends the user to rewrite a system
1318
+ * prompt that is not at fault.
1319
+ */
1320
+ const OTHER_CHANNEL_CODE_GATES = ["conversation id missing", "first message is not system prompt"];
1321
+ /**
1284
1322
  * Whether a failure body is the MODEL-scoped throttle rather than an account
1285
1323
  * limit.
1286
1324
  *
@@ -1300,6 +1338,72 @@ const MODEL_BUSY_CODE = "\"code\":14003";
1300
1338
  function isModelBusyBody(body) {
1301
1339
  return body.includes(MODEL_BUSY_CODE);
1302
1340
  }
1341
+ /**
1342
+ * Whether a failure body is the gateway's CHANNEL gate rather than the other
1343
+ * `11128` ({@link conversationHeaders}' `conversation id missing`).
1344
+ *
1345
+ * Both halves are required. The code alone would claim the conversation-id gate
1346
+ * too, and its reply would then blame the prompt's opening for a request-header
1347
+ * problem the plugin already fixed — a confidently wrong diagnosis is worse
1348
+ * here than the generic `client` reply, because it sends the user to rewrite a
1349
+ * system prompt that is not at fault.
1350
+ */
1351
+ function isChannelRejectBody(body) {
1352
+ if (!body.includes(CHANNEL_REJECT_CODE)) return false;
1353
+ const lower = body.toLowerCase();
1354
+ if (OTHER_CHANNEL_CODE_GATES.some((marker) => lower.includes(marker))) return false;
1355
+ return CHANNEL_REJECT_MARKERS.some((marker) => lower.includes(marker));
1356
+ }
1357
+ /**
1358
+ * The OPENINGS of the `system` messages in a prepared request body.
1359
+ *
1360
+ * This is the evidence the channel gate actually reacts to, and echoing it is
1361
+ * what turns "the upstream refused" into "the upstream refused because of this
1362
+ * line" — the reader can then fix the prompt or report the new opening, instead
1363
+ * of cycling accounts that cannot change a byte of it.
1364
+ *
1365
+ * Each entry is the first non-empty line, clipped: a fingerprint lives at the
1366
+ * START of the content, so the rest of a 10 KB system prompt is noise that
1367
+ * would bury the one sentence worth reading. At most three entries are kept —
1368
+ * the gate scans every `system` message, but a body with more than three of them
1369
+ * is already worth showing compactly.
1370
+ *
1371
+ * Never throws: a malformed body is the upstream's to reject, and losing the
1372
+ * failure measurement is not an acceptable price for reading an excerpt.
1373
+ */
1374
+ function systemOpeningsOfBody(bodyJson) {
1375
+ let parsed;
1376
+ try {
1377
+ parsed = JSON.parse(bodyJson);
1378
+ } catch {
1379
+ return [];
1380
+ }
1381
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return [];
1382
+ const messages = parsed["messages"];
1383
+ if (!Array.isArray(messages)) return [];
1384
+ const openings = [];
1385
+ for (const value of messages) {
1386
+ if (openings.length >= 3) break;
1387
+ if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
1388
+ const message = value;
1389
+ const role = message["role"];
1390
+ if (typeof role !== "string" || role.trim().toLowerCase() !== "system") continue;
1391
+ const content = message["content"];
1392
+ if (typeof content !== "string") continue;
1393
+ const opening = firstLineOf(content).replace(/\s+/gu, " ").trim();
1394
+ if (opening !== "") openings.push(clip(opening, SYSTEM_OPENING_LIMIT));
1395
+ }
1396
+ return openings;
1397
+ }
1398
+ /** Longest system-prompt opening echoed back to the user, in characters. */
1399
+ const SYSTEM_OPENING_LIMIT = 120;
1400
+ function firstLineOf(content) {
1401
+ const newline = content.indexOf("\n");
1402
+ return newline === -1 ? content : content.slice(0, newline);
1403
+ }
1404
+ function clip(text, limit) {
1405
+ return text.length <= limit ? text : `${text.slice(0, limit - 1)}…`;
1406
+ }
1303
1407
  /** Classify an upstream failure from its HTTP status and body excerpt. */
1304
1408
  function classifyUpstreamError(status, body) {
1305
1409
  if (status === 402) return "hard_credit";
@@ -1308,6 +1412,7 @@ function classifyUpstreamError(status, body) {
1308
1412
  if (body.includes(CREDITS_EXHAUSTED_CODE)) return "hard_credit";
1309
1413
  for (const marker of SESSION_DEAD_MARKERS) if (body.includes(marker)) return "session_dead";
1310
1414
  for (const marker of POLICY_REJECT_MARKERS) if (body.includes(marker)) return "policy_reject";
1415
+ if (isChannelRejectBody(body)) return "channel_reject";
1311
1416
  if (isModelBusyBody(body)) return "model_busy";
1312
1417
  if (status === 429) return "soft_rate";
1313
1418
  if (status === 404) return "not_found";
@@ -4410,7 +4515,7 @@ function generationOf(version) {
4410
4515
  //#endregion
4411
4516
  //#region src/version.ts
4412
4517
  /** The npm package version this build was produced from. */
4413
- const WORKBUDDY_CONNECT_VERSION = "4.13.1";
4518
+ const WORKBUDDY_CONNECT_VERSION = "4.13.2";
4414
4519
  //#endregion
4415
4520
  //#region src/host-heartbeat.ts
4416
4521
  /**
@@ -4568,4 +4673,4 @@ async function clearHostHeartbeat() {
4568
4673
  await rm(workbuddyHostHeartbeatPath(), { force: true });
4569
4674
  }
4570
4675
  //#endregion
4571
- export { WORKBUDDY_APP_EXECUTABLE_ENV as $, workbuddyAccountId as A, modelIdOfBody as B, defaultDesktopAuthDirs as C, isVaultAccountId as D, isEncryptedCredentialError as E, WorkBuddyUpstreamClient as F, regionOf as G, parseReasoning as H, classifyUpstreamError as I, lastRefusalFor as J, REFUSES_OFF_MODEL_IDS as K, declaredTools as L, CREDENTIAL_REJECTED_CODE as M, WORKBUDDY_FALLBACK_SYSTEM_PROMPT as N, legacyWorkbuddyOwnAuthPath as O, WorkBuddyCredentialRejectedError as P, withEffectiveOff as Q, isCredentialRejectedError as R, defaultDesktopAuthCandidates as S, hasEncryptedCredentialFields as T, parseUpstreamModel as U, parseCreditMultiplier as V, prepareChatBody as W, offRefused as X, offDefaultFor as Y, recordRefusal as Z, WORKBUDDY_AUTH_FILENAME as _, readHostHeartbeat as a, findWorkbuddyAppExecutableWithSource as at, WorkBuddyEncryptedCredentialError as b, WORKBUDDY_CONNECT_VERSION as c, macosBundleExecutable as ct, FALLBACK_WORKBUDDY_MODELS_GLOBAL as d, readAtRestKey as dt, clearAtRestKeyCache as et, WorkBuddyCatalog as f, workbuddyAppExecutableCandidates as ft, ENCRYPTED_CREDENTIAL_CODE as g, writeFileAtomic as gt, fallbackModelsFor as h, withFileLock as ht, processStartTimeMs as i, findWorkbuddyAppExecutable as it, workbuddyOwnAuthPath as j, parseWorkBuddyAuth as k, piAiRuntimeInfo as l, macosNestedAppBundles as lt, deriveCatalog as m, resolveDshHome as mt, clearHostHeartbeat as n, deriveAtRestKeyId as nt, workbuddyHostHeartbeatPath as o, isEncryptedFieldWrapper as ot, applyContextBudgets as p, hostPeersInfo as pt, defaultOffModelIds as q, isHeartbeatProcessAlive as r, fetchAtRestKeyPayload as rt, writeHostHeartbeat as s, isWorkbuddyBundle as st, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, deriveAtRestKey as tt, FALLBACK_WORKBUDDY_MODELS as u, openEncryptedField as ut, WORKBUDDY_AUTH_FILE_ENV as v, defaultDesktopAuthPath as w, authFileName as x, WorkBuddyCredentialStore as y, knownRegionOf as z };
4676
+ export { withEffectiveOff as $, workbuddyAccountId as A, modelIdOfBody as B, defaultDesktopAuthDirs as C, isVaultAccountId as D, isEncryptedCredentialError as E, WorkBuddyUpstreamClient as F, regionOf as G, parseReasoning as H, classifyUpstreamError as I, defaultOffModelIds as J, systemOpeningsOfBody as K, declaredTools as L, CREDENTIAL_REJECTED_CODE as M, WORKBUDDY_FALLBACK_SYSTEM_PROMPT as N, legacyWorkbuddyOwnAuthPath as O, WorkBuddyCredentialRejectedError as P, recordRefusal as Q, isCredentialRejectedError as R, defaultDesktopAuthCandidates as S, hasEncryptedCredentialFields as T, parseUpstreamModel as U, parseCreditMultiplier as V, prepareChatBody as W, offDefaultFor as X, lastRefusalFor as Y, offRefused as Z, WORKBUDDY_AUTH_FILENAME as _, writeFileAtomic as _t, readHostHeartbeat as a, findWorkbuddyAppExecutable as at, WorkBuddyEncryptedCredentialError as b, WORKBUDDY_CONNECT_VERSION as c, isWorkbuddyBundle as ct, FALLBACK_WORKBUDDY_MODELS_GLOBAL as d, openEncryptedField as dt, WORKBUDDY_APP_EXECUTABLE_ENV as et, WorkBuddyCatalog as f, readAtRestKey as ft, ENCRYPTED_CREDENTIAL_CODE as g, withFileLock as gt, fallbackModelsFor as h, resolveDshHome as ht, processStartTimeMs as i, fetchAtRestKeyPayload as it, workbuddyOwnAuthPath as j, parseWorkBuddyAuth as k, piAiRuntimeInfo as l, macosBundleExecutable as lt, deriveCatalog as m, hostPeersInfo as mt, clearHostHeartbeat as n, deriveAtRestKey as nt, workbuddyHostHeartbeatPath as o, findWorkbuddyAppExecutableWithSource as ot, applyContextBudgets as p, workbuddyAppExecutableCandidates as pt, REFUSES_OFF_MODEL_IDS as q, isHeartbeatProcessAlive as r, deriveAtRestKeyId as rt, writeHostHeartbeat as s, isEncryptedFieldWrapper as st, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, clearAtRestKeyCache as tt, FALLBACK_WORKBUDDY_MODELS as u, macosNestedAppBundles as ut, WORKBUDDY_AUTH_FILE_ENV as v, defaultDesktopAuthPath as w, authFileName as x, WorkBuddyCredentialStore as y, knownRegionOf as z };
package/lib/index.d.ts CHANGED
@@ -609,7 +609,27 @@ type UpstreamErrorKind = 'hard_credit' |
609
609
  * retrying, or switch to a less busy model." Both halves are rendered to the
610
610
  * user; the second one is the actual remedy.
611
611
  */
612
- 'model_busy' | 'session_dead' | 'policy_reject' | 'not_found' | 'server' | 'client';
612
+ 'model_busy' | 'session_dead' | 'policy_reject' |
613
+ /**
614
+ * The gateway's channel gate refused the request itself (business code
615
+ * 11128, "Illegal API invocation from an unapproved channel").
616
+ *
617
+ * Deliberately its own kind, because its `displayTips` — "this call failed
618
+ * channel verification, possibly because it was not sent from an official
619
+ * client" — names the account or the client version, and NEITHER is the
620
+ * cause. The gate matches the OPENING of a `system` message against a list of
621
+ * known third-party-agent prompts, so the same credential answers 200 to a
622
+ * probe and 400 to a real chat request one second later (issues #87 / #88 /
623
+ * #89 / #90). Filing it as `client` was technically true and practically
624
+ * useless: the generic "refused on its parameters" sentence left the reader
625
+ * to try another account, which cannot change a byte of the prompt.
626
+ *
627
+ * NOT failover-worthy for the same reason: every account sends the same
628
+ * system prompt, so a second account answers the same 11128 one round trip
629
+ * later and each attempt teaches the pool a false fact about a credential
630
+ * that was never the problem.
631
+ */
632
+ 'channel_reject' | 'not_found' | 'server' | 'client';
613
633
  /** Reasoning capability as the upstream catalog declares it. */
614
634
  interface WorkBuddyReasoning {
615
635
  supportedEfforts?: readonly string[];
@@ -2034,8 +2054,16 @@ declare function probeRequestBody(modelId: string, inputTokens?: number): string
2034
2054
  * loaded, everywhere, for everyone; a 6004 says THIS ACCOUNT exceeded a volume
2035
2055
  * limit. Reporting both as `rate-limited` made the pool bench every account for
2036
2056
  * the cooldown of one, which is the outage issue #56 describes.
2057
+ *
2058
+ * `channel-rejected` is a third split, and it is the one that answers the
2059
+ * question the probe itself raises: the probe's own system prompt is a neutral
2060
+ * one-liner, so it can return `ok` on a model whose real chat requests are all
2061
+ * refused with `11128`. Filing that as `failed` (the generic bucket) made the
2062
+ * pair "test passes, chat fails, every account" look like a flaky upstream
2063
+ * instead of what it is — the gateway objecting to the PROMPT, not the account
2064
+ * (issues #87–#90).
2037
2065
  */
2038
- type WorkBuddyProbeOutcome = 'ok' | 'rate-limited' | 'model-busy' | 'out-of-credit' | 'credential-rejected' | 'policy-rejected' | 'unavailable' | 'not-found' | 'failed';
2066
+ type WorkBuddyProbeOutcome = 'ok' | 'rate-limited' | 'model-busy' | 'out-of-credit' | 'credential-rejected' | 'policy-rejected' | 'channel-rejected' | 'unavailable' | 'not-found' | 'failed';
2039
2067
  /**
2040
2068
  * One probe result, as the card renders it.
2041
2069
  *
@@ -2122,9 +2150,10 @@ declare function cooldownOf(input: {
2122
2150
  * answer means. The extra splits here are `credential-rejected`, which the
2123
2151
  * upstream signals with a non-JSON 401/403 edge page and which needs completely
2124
2152
  * different advice (re-auth, not "wait"), `policy-rejected`, a JSON 403
2125
- * content-policy refusal where re-auth advice would be actively misleading, and
2153
+ * content-policy refusal where re-auth advice would be actively misleading,
2126
2154
  * `model-busy`, the model-scoped 14003 throttle that must NOT be benched as an
2127
- * account failure (issue #56).
2155
+ * account failure (issue #56), and `channel-rejected`, the gateway's 11128 gate
2156
+ * on the PROMPT's opening, which no account can change.
2128
2157
  */
2129
2158
  declare function outcomeOfFailure(status: number, body: string): WorkBuddyProbeOutcome;
2130
2159
  /**
@@ -2822,7 +2851,17 @@ type WorkBuddyWebProbeOutcome = 'ok' | 'rate-limited' |
2822
2851
  * to learn what the first one already said. The remedy is the upstream's own:
2823
2852
  * wait about a minute, or switch to a less busy model.
2824
2853
  */
2825
- 'model-busy' | 'out-of-credit' | 'credential-rejected' | 'policy-rejected' | 'unavailable' | 'not-found' | 'failed';
2854
+ 'model-busy' | 'out-of-credit' | 'credential-rejected' | 'policy-rejected' |
2855
+ /**
2856
+ * The gateway's channel gate refused the request itself (upstream code 11128).
2857
+ *
2858
+ * Kept apart from `failed` because its whole point is the question it answers:
2859
+ * the probe passes and the chat fails on the SAME model and the SAME account,
2860
+ * because the gate matches the OPENING of a `system` message and the probe's
2861
+ * is a neutral one-liner. A generic failure would leave the user swapping
2862
+ * accounts that cannot change a byte of their prompt (issues #87–#90).
2863
+ */
2864
+ 'channel-rejected' | 'unavailable' | 'not-found' | 'failed';
2826
2865
  /** One model's probe result, as the card renders it. */
2827
2866
  interface WorkBuddyWebProbeResult {
2828
2867
  modelId: string;
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as WORKBUDDY_APP_EXECUTABLE_ENV, A as workbuddyAccountId, B as modelIdOfBody, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as regionOf, H as parseReasoning, I as classifyUpstreamError, J as lastRefusalFor, K as REFUSES_OFF_MODEL_IDS, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, Q as withEffectiveOff, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as parseUpstreamModel, V as parseCreditMultiplier, W as prepareChatBody, X as offRefused, Y as offDefaultFor, Z as recordRefusal, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as macosBundleExecutable, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, dt as readAtRestKey, et as clearAtRestKeyCache, f as WorkBuddyCatalog, ft as workbuddyAppExecutableCandidates, g as ENCRYPTED_CREDENTIAL_CODE, gt as writeFileAtomic, h as fallbackModelsFor, ht as withFileLock, i as processStartTimeMs, it as findWorkbuddyAppExecutable, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, lt as macosNestedAppBundles, m as deriveCatalog, mt as resolveDshHome, n as clearHostHeartbeat, nt as deriveAtRestKeyId, o as workbuddyHostHeartbeatPath, ot as isEncryptedFieldWrapper, p as applyContextBudgets, q as defaultOffModelIds, r as isHeartbeatProcessAlive, rt as fetchAtRestKeyPayload, s as writeHostHeartbeat, st as isWorkbuddyBundle, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as deriveAtRestKey, u as FALLBACK_WORKBUDDY_MODELS, ut as openEncryptedField, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-CwdR6teZ.js";
1
+ import { $ as withEffectiveOff, A as workbuddyAccountId, B as modelIdOfBody, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as regionOf, H as parseReasoning, I as classifyUpstreamError, J as defaultOffModelIds, K as systemOpeningsOfBody, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, Q as recordRefusal, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as parseUpstreamModel, V as parseCreditMultiplier, W as prepareChatBody, X as offDefaultFor, Y as lastRefusalFor, Z as offRefused, _ as WORKBUDDY_AUTH_FILENAME, _t as writeFileAtomic, a as readHostHeartbeat, at as findWorkbuddyAppExecutable, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as isWorkbuddyBundle, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, dt as openEncryptedField, et as WORKBUDDY_APP_EXECUTABLE_ENV, f as WorkBuddyCatalog, ft as readAtRestKey, g as ENCRYPTED_CREDENTIAL_CODE, gt as withFileLock, h as fallbackModelsFor, ht as resolveDshHome, i as processStartTimeMs, it as fetchAtRestKeyPayload, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, lt as macosBundleExecutable, m as deriveCatalog, n as clearHostHeartbeat, nt as deriveAtRestKey, o as workbuddyHostHeartbeatPath, p as applyContextBudgets, pt as workbuddyAppExecutableCandidates, q as REFUSES_OFF_MODEL_IDS, r as isHeartbeatProcessAlive, rt as deriveAtRestKeyId, s as writeHostHeartbeat, st as isEncryptedFieldWrapper, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as clearAtRestKeyCache, u as FALLBACK_WORKBUDDY_MODELS, ut as macosNestedAppBundles, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-BJAL9pEe.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
4
4
  import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
@@ -2276,6 +2276,7 @@ const KIND_STATUS = {
2276
2276
  model_busy: 429,
2277
2277
  session_dead: 401,
2278
2278
  policy_reject: 403,
2279
+ channel_reject: 400,
2279
2280
  not_found: 502,
2280
2281
  server: 502,
2281
2282
  client: 400
@@ -2344,6 +2345,13 @@ const KIND_STATUS = {
2344
2345
  * reset is hours away, so the retry budget cannot produce an answer and must
2345
2346
  * not be spent. One word, two opposite temporal claims — so the spelling
2346
2347
  * follows the clock, not the status code both share.
2348
+ *
2349
+ * `channel_reject` is not listed, and the reason is the same rule applied to a
2350
+ * third clock: the gate's verdict is DETERMINISTIC for a given prompt, so an
2351
+ * in-place retry would spend the budget re-sending bytes it already refused, and
2352
+ * cross-provider failover cannot help either (every account sends that same
2353
+ * prompt). Its 400 already places it outside the host's retry set, so `type`
2354
+ * alone, untranslated, is the honest spelling.
2347
2355
  */
2348
2356
  const KIND_HOST_CODE = { soft_rate: "quota_exceeded" };
2349
2357
  /**
@@ -2916,6 +2924,7 @@ function failoverReasonText(failure) {
2916
2924
  case "model_busy": return "该模型当前繁忙(不是账号问题)";
2917
2925
  case "session_dead": return "登录已失效";
2918
2926
  case "policy_reject": return "被服务端策略拒绝";
2927
+ case "channel_reject": return "被网关的渠道校验拦截(不是账号、额度或网络问题)";
2919
2928
  case "not_found": return "模型不可用";
2920
2929
  case "server": return "上游异常";
2921
2930
  case "client": return "请求被上游拒绝";
@@ -3247,7 +3256,7 @@ function createWorkBuddyShim(options) {
3247
3256
  * which another account may well survive.
3248
3257
  */
3249
3258
  function isFailoverWorthy(kind) {
3250
- return kind !== "client" && kind !== "model_busy";
3259
+ return kind !== "client" && kind !== "model_busy" && kind !== "channel_reject";
3251
3260
  }
3252
3261
  async function chatCompletions(req, res) {
3253
3262
  if (!isJsonContentType(req)) {
@@ -3375,6 +3384,18 @@ function createWorkBuddyShim(options) {
3375
3384
  writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, message, hostErrorCode(result.kind));
3376
3385
  return;
3377
3386
  }
3387
+ if (result.kind === "channel_reject") {
3388
+ const detail = result.detail;
3389
+ const meta = [];
3390
+ if (detail?.upstreamCode !== void 0) meta.push(`code ${detail.upstreamCode}`);
3391
+ if (detail?.requestId !== void 0) meta.push(`requestId ${detail.requestId}`);
3392
+ const stamp = meta.length === 0 ? "" : `(${meta.join(",")})`;
3393
+ const openings = systemOpeningsOfBody(prepared);
3394
+ const evidence = openings.length === 0 ? "" : `命中的 system 提示开头:${openings.map((opening) => `「${opening}」`).join("、")}。`;
3395
+ const message = `workbuddy 上游失败:${failoverReasonText(result)}(http ${result.status})${note}${stamp}。${evidence}该请求被 WorkBuddy 网关的渠道校验拦截,上游把它描述成「不是官方客户端发出的」,但那是误导性文案:网关匹配的是 system 提示的开头,与账号、积分、额度、网络都无关,换账号或换区域也不会改变结果。若这段开头来自宿主或其他插件,请修改该提示;插件已对已知的第三方 agent 指纹做归一化,新的指纹可反馈到 issue 里`;
3396
+ writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, message, hostErrorCode(result.kind));
3397
+ return;
3398
+ }
3378
3399
  const detail = result.detail;
3379
3400
  const meta = [];
3380
3401
  if (detail?.upstreamCode !== void 0) meta.push(`code ${detail.upstreamCode}`);
@@ -3734,9 +3755,10 @@ function cooldownOf(input) {
3734
3755
  * answer means. The extra splits here are `credential-rejected`, which the
3735
3756
  * upstream signals with a non-JSON 401/403 edge page and which needs completely
3736
3757
  * different advice (re-auth, not "wait"), `policy-rejected`, a JSON 403
3737
- * content-policy refusal where re-auth advice would be actively misleading, and
3758
+ * content-policy refusal where re-auth advice would be actively misleading,
3738
3759
  * `model-busy`, the model-scoped 14003 throttle that must NOT be benched as an
3739
- * account failure (issue #56).
3760
+ * account failure (issue #56), and `channel-rejected`, the gateway's 11128 gate
3761
+ * on the PROMPT's opening, which no account can change.
3740
3762
  */
3741
3763
  function outcomeOfFailure(status, body) {
3742
3764
  if (status === 401 || status === 403) {
@@ -3748,6 +3770,7 @@ function outcomeOfFailure(status, body) {
3748
3770
  case "model_busy": return "model-busy";
3749
3771
  case "hard_credit": return "out-of-credit";
3750
3772
  case "session_dead": return "credential-rejected";
3773
+ case "channel_reject": return "channel-rejected";
3751
3774
  case "not_found": return "not-found";
3752
3775
  default: return status === 0 || status >= 500 ? "unavailable" : "failed";
3753
3776
  }
@@ -4096,6 +4119,7 @@ function exclusionOf(probe, nowMs) {
4096
4119
  case "model-busy": return retryDue(probe, nowMs, 12e4) ? void 0 : "model-busy";
4097
4120
  case "out-of-credit": return retryDue(probe, nowMs) ? void 0 : "out-of-credit";
4098
4121
  case "policy-rejected": return;
4122
+ case "channel-rejected": return;
4099
4123
  default: return "unusable";
4100
4124
  }
4101
4125
  }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-connect-workbuddy",
3
3
  "displayName": "DSH Connect WorkBuddy",
4
4
  "description": "把本机登录的 WorkBuddy 模型(国内版与国际版)接入 DeepSeek Harness:每次刷新按上游目录自动读取每个模型的积分倍率,支持逐模型图片输入开关、多账号切换与账号池自动换号,并提供每日签到、卡片与输入框里的胶囊积分读数等只读积分概览。",
5
- "version": "4.13.1",
5
+ "version": "4.13.2",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",