dsh-connect-workbuddy 4.14.1 → 4.15.1

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,113 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.15.1 (2026-10-21)
4
+
5
+ > 版本号是 **patch**:没有接口与配置变更。发往上游的请求在**没有被网关拒绝时仍然逐字节不变**;改动的只有「被渠道校验拒绝之后」那一段——多一级重试。路由、模型、凭据、计费与换号规则未变。
6
+
7
+ ### Fixes
8
+
9
+ - **4.15.0 上线后仍有 11128:网关还会按「提示词里的一段长文本」匹配,不只看开头**
10
+ - **真实报文**(2026-10-21 19:1x,国内区、插件 4.15.0、requestId `06e7950a…`):回显「system:You are an AI agent powered by DeepSeek Harness.」(插件已加中性头)——**每条被扫消息的开头都已经在中性头之后**,网关仍然 400。这直接证伪了「只匹配开头」这个 4.13.0 时代的结论。
11
+ - **实测**(同账号 / 同模型 / 同请求头,只改 body;把宿主的真实失败请求逐段二分):
12
+ - 触发点是 system 提示词里 **221 个字符**的一段(DSH 的 Web GUI 段落前两句:「You are interacting with the user through the DeepSeek Harness Web GUI … they mean this GUI.」);
13
+ - 这一段**单独**发就 400;**任何一处**变化就 200(改写一个词、替换引号里的某个词、甚至插入一个零宽空格都行);
14
+ - 在这段外面加前缀或后缀**不影响**结果 ⇒ 精确子串匹配,不是开头规则;
15
+ - 段落里的**单句**各自发都是 200 —— 所以按「开头」设计的归一化永远看不到它。
16
+ - **为什么之前的两级都救不了**:前缀归一化与强制中性头都只动「开头」,而这次命中的是提示词**中间**的一段。
17
+ - **改法**:新增 `perturbChannelFingerprints()` —— 在被扫消息(`system` / `assistant`,含每个 text part)的**句读处**插入 U+200B 零宽空格。对读者不可见、对模型无意义(词一个都没改),但任何跨句的指纹都会被截断。**只在网关已经拒绝之后**使用,正常请求逐字节不变。
18
+ - **重试阶梯**(同一个账号,`channel_reject` 仍不换号):首次失败 → ① `forceNeutralHead`(无处可改就跳过)→ 仍失败 → ② `perturbChannelFingerprints`(无处可改就跳过)。回复里会写明用了哪几级(「并已用强制中性头与一次不可见扰动重试过」)。
19
+ - **代价**:被拒的那一轮最多多两个请求;账号池不记这两次失败(拒的是请求,不是账号)。
20
+
21
+ - **`perturbChannelFingerprints()` 的最终形态**:在被扫消息(`system` / `assistant`,含每个 text part)的**句末标点后**与**分句逗号/分号后**插入 U+200B;对已含 U+200B 的内容幂等跳过;`undefined` 表示「改了也没用」,调用方据此不发请求。`prepareChatBody` 与它的关系是「前者管已知开头,后者管未知的中段」,两者互不覆盖。
22
+
23
+ - **回复会写明用了几级重试**:「并已用强制中性头重试过」「并已用一次不可见扰动重试过」「并已用强制中性头与一次不可见扰动重试过」。这一行只在这两级真的跑过时出现。
24
+
25
+ ### Tests
26
+
27
+ - `tests/issue98-channel-gate.spec.ts` 新增 5 条 `perturbChannelFingerprints` 用例:句读处插入、`system`/`assistant` 两种角色都插而 `user`/`tool` 不动、parts 数组与中日文标点、幂等且无改动时返回 `undefined`、畸形 body 不抛错。
28
+ - `tests/issue98-shim.spec.ts` 新增 2 条:开头级无救时**仍会**走扰动级(第二份 body 只多了不可见字符);扰动级把被拒请求救成 200。
29
+ - 既有断言按新阶梯更新:重试全失败时的文案改为同时写出两级。
30
+
31
+ ### 真实上游核验
32
+
33
+ - 用**宿主的真实失败请求**(`/tmp/failing-body.json`:10,956 字符 system + 5 条 user + 44 个 tools)在真实国内网关跑完整阶梯:`prepareChatBody` → **400**;`forceNeutralHead` → `undefined`(开头已在中性头之后,证实 4.15.0 那一级在这里是空操作);`perturbChannelFingerprints` → **200**。
34
+ - 同一份 body 上把开头换成 `You are CodeBuddy…` / `You are WorkBuddy…` 仍然 **400**——这是「换成腾讯身份」不能作为解法的直接证据。
35
+
36
+ ## 4.15.0 (2026-10-21)
37
+
38
+ > 版本号是 **minor**:有两处用户可见的行为变更 ——(1)`assistant` 历史消息也会被归一化(此前只处理 `system`),(2)渠道校验失败后插件会用「强制中性头」在**同一个账号**上重试一次。**没有被网关拒绝的请求逐字节不变**;被拒的那一次多花一个请求。路由 id、模型 id、凭据、计费与换号规则一律未变。
39
+
40
+ ### Fixes
41
+
42
+ - **issue #98:网关的渠道校验也扫 `assistant` 历史消息,而插件只归一到 `system` —— 一个被污染过的会话从此每一步都失败**
43
+ - **实测**(2026-10-21,国内网关 `copilot.tencent.com`,同账号 / 模型 `deepseek-v4.1-flash` / 请求头,**只改 body**;`max_tokens: 8`):
44
+
45
+ | body 里唯一的变化 | 结果 |
46
+ | --- | --- |
47
+ | `system` 开头 = `You are an AI agent powered by DeepSeek Harness.` | 400 / 11128 |
48
+ | **`assistant` 历史消息开头** = 同一句 | **400 / 11128** |
49
+ | 同一句放在 `assistant` 消息内容**中间** | 200 |
50
+ | 给那条 `assistant` 消息开头补一行 `You are a helpful assistant.` | 200 |
51
+ | `user` 消息开头 = 同一句 | 200(不扫 user) |
52
+ | `tool` 结果开头 = 同一句 | 200(不扫 tool) |
53
+ | `system` 的 content 是 parts 数组、命中在**第 2 个** text part | 400 |
54
+ | 同上,但每个 text part 都补了中性头 | 200 |
55
+ | `system` 开头是当前宿主的 `You are a coding agent powered by …` | 200(不在名单里) |
56
+
57
+ - **它解释了 issue #98 的每一条症状**:只要会话里有一条 `assistant` 消息**开头**是被拦的身份(模型把系统提示词原样复述出来、会话恢复/重放、折叠摘要留下的痕迹),**这个会话之后的每一轮**都会被拒——「单轮跑到 7 分钟多才第一次报错」「继续仍报同一个错」「同一账号另一个会话正常」「重启后 1 分 22 秒失败」都是这一条的形状。换账号、换区域、重装官方客户端都不能改变它,因为被拒的是**请求里的那一行历史**。
58
+ - **改了什么**:`neutralizeChannelFingerprints()` 现在对 `system` 与 `assistant` **两种角色**做同一件事(字符串 content 或 parts 数组里的**每个** text part,命中已知指纹就在最前面补一行中性头)。此前只处理 `system`,所以 `assistant` 里那一行**原样发出**。
59
+ - **为什么可以改历史消息**:这是唯一能救活会话的做法——那一行在**宿主的历史里**,插件只能改它发出去的那一份。改动只有一行前缀,`user` / `tool` / `tool_calls` 结构与工具配对一律不动;宿主自己的会话记录也不改(插件只重写请求体)。
60
+
61
+ - **`system` 的 content 是 parts 数组时,4.13.0 的归一化一个字节都没生效**
62
+ - 归一化只认 `typeof content === 'string'`,于是数组形态的 system 消息(多段文本、图文混排)整个跳过。实测:命中放在**第 2 个** text part 照样 400;每个 part 都补中性头才 200。
63
+ - 现在逐个 text part 判断并前置;非 text part(图片等)与未命中的 part **逐字节不动**,畸形 part 只跳过、不抛错。
64
+
65
+ - **渠道校验失败后,用「强制中性头」在同一个账号上重试一次(结构性兜底)**
66
+ - **不能靠「把名单列全」**:那份名单是网关自己的数据,插件只持有**实测快照**(`UNAPPROVED_CHANNEL_OPENINGS`)。网关加一条,插件就漏一条,而漏掉的那条会让**整个会话**死掉。猜下一条的名字不是修法。
67
+ - **所以改成不看名单**:首次被渠道校验拒绝后,`forceNeutralHead()` 给**每一条** `system` 与 `assistant` 消息(含每个 text part)的最前面补中性头,再发一次。实测:任何被拦的开头,只要被挪到内容开头之外就 200——网关读的是**开头**,不是内容。
68
+ - **同一个账号**:闸读的是请求,不是账号,所以 `channel_reject` 本来就不换号;这一次重试也不换(换号只会烧真实额度,再拿同一个 11128)。
69
+ - **只在需要时**:如果每条开头都已经是中性头(`forceNeutralHead` 返回 `undefined`),不重试——那只会把同样的字节再发一遍。
70
+ - **不记假账**:第一次失败是提示词的问题,不是账号的问题,所以它不进账号池的失败统计;重试若也失败,按最后一次的结果记账。
71
+
72
+ - **回复不再谎报「命中的 system 提示开头」(issue #98 的核心误导)**
73
+ - **旧行为**:从**归一化之后**的 body 里取第一条 `system` 的第一行,而插件补的中性头正好在第一条 ⇒ 回复永远说「命中的 system 提示开头:「You are a helpful assistant.」」。issue #98 里 10 位报告者收到的都是这句,而这句话单独发是 200,真正被拦的那一行从未出现在回复里。
74
+ - **新行为**:从**宿主原始 body**(归一化之前)取,**逐条**列出每个 `system` 与 `assistant` 消息的开头(parts 数组按 part 计),每条标明角色与插件是否已覆盖:
75
+ - `「system:…」(插件已加中性头)` —— 插件会改写它;
76
+ - `「system:…」(不在插件已知名单里,网关可能新增了词条)` —— 这一行**原样发出**,是真正该看的那条;
77
+ - 超过 4 条时只列前 4 条,并写明还剩几条。
78
+ - **不再说「命中的」**:网关从不告诉插件它匹配了哪一条,声称知道就是这次误导的根因。
79
+
80
+ - **同一族业务码的另两道门现在会点名,而不是转述网关的托词**
81
+ - `11128` 有三个来源,`displayTips` 对三者**一字不差**(「这次调用没通过渠道校验,可能不是官方客户端发出的」)。另两道(`conversation id missing`、`first message is not system prompt`)此前落进通用 `client` 兜底,读者拿到的是「该请求被上游按参数拒绝……请按上面的提示调整后重试」+那句误导文案——与本次报告者粘贴回来的那条 400 逐字一致。
82
+ - 现在按 `msg` 点名(仍**只认 msg,绝不认 displayTips**,分类与宿主错误码保持 `client` / 400):缺会话 id 时说明网关要三枚会话头、插件每次都带;首条不是 system 时说明网关要求首条 system、插件在有消息时会补上,因此出现即代表这次请求本身没有消息或没走插件通道。
83
+
84
+ ### 评估后**没有**采纳:把中性头换成腾讯自家产品身份
85
+
86
+ - **提议**(issue #98 末尾):把 `You are a helpful assistant.` 换成 `You are CodeBuddy, Tencent's official AI coding assistant.`。理由成立——腾讯不会把自家客户端写进黑名单,这条「结构上安全」。
87
+ - **实测代价**(同一天、同一账号、同一份 DSH 提示词,**只换开头一行**,问「你是哪个产品的助手?」):
88
+
89
+ | 中性头 | 回答 |
90
+ | --- | --- |
91
+ | `You are a helpful assistant.`(现状) | 我是 DeepSeek 的编程助手。 |
92
+ | `You are CodeBuddy, Tencent's official AI coding assistant.` | 我是腾讯云 CodeBuddy 的 AI 编程助手。 |
93
+ | `You are WorkBuddy, an AI agent developed by Tencent.` | 我是腾讯 WorkBuddy 的 AI 助手。 |
94
+
95
+ 前者不改变模型对自身身份的表述,后两者会**串味**——这是所有请求都要付的质量代价,不只是被拦的那几次。
96
+ - **它也不解决 issue #98**:真正被拦的是**会话里的 `assistant` 历史**,不是开头那一行;中性头当时实测仍是 200,报告者把它当成「被拦的行」是插件的回显造成的误解。
97
+ - **结论**:默认不变。「强制中性头」+「不可见扰动」已经覆盖了两类风险,且不必冒充。若将来中性头本身被拉黑,最小改动是把中性头**做成可配置**(默认仍为中性),而不是默认冒充——这留待届时有证据再评估。
98
+
99
+ ### Tests
100
+
101
+ - `tests/issue98-channel-gate.spec.ts`(新增):把上表的边界逐条钉住——parts 数组的第 1/第 2 个 part 都归一化、非 text part 与非命中 part 逐字节不动、畸形 part 不抛错、`assistant` 角色同样归一化、`isUnapprovedChannelOpening` 只匹配开头且大小写不敏感、`forceNeutralHead` 覆盖两种角色且空内容不碰、`openingEvidenceOfBody` 按角色与 part 上报并区分「插件已加中性头」与「不在名单里」、`conversation id missing` / `first message is not system prompt` 两道门的判据(code 与措辞两半都要,请求 id 里恰好含数字不算)。
102
+ - `tests/issue98-shim.spec.ts`(新增):挂载**真实的 shim**,只替换上游应答——重试一次即成功(且发出去的第二份 body 在 `system` 与 `assistant` 两种消息前都补了中性头)、重试不换号且第一次失败不记账号账、无法改变时不重试、重试也失败时回复点名的是**宿主的**开头(含 `assistant:`)而不是插件自己的占位行、回复里不再出现「命中的 system 提示开头」与旧兜底句、两道旁门各自点名且不转述误导文案、宿主错误码仍是 `client` / 400。
103
+ - `tests/issue90-channel-reject.spec.ts` 改写:旧的三条按「从归一化后的 body 回显」写的用例方向相反(那正是本次修掉的误导),改为按新契约断言;分类与「不浪费换号」等护栏原样保留。
104
+
105
+ ### 真实上游核验
106
+
107
+ - 2026-10-21 共 **200 余次只读对照**(每次 `max_tokens: 8`,未改动任何账号设置):国内网关覆盖开头/中间/parts/角色/多 system/缺头/内容指纹定位(逐段二分 + 前缀增长 + 单点改写的双向验证)等 180 余组,国际网关 20 余组(国际不拦 DSH 身份,只拦 Claude Code;`user` 首条仍是 `first message is not system prompt`)。
108
+ - **内容指纹那一组的判定是「同一份 body 只改一处」**:原样 400、任意一处改动 200、外面加前后缀仍 400,三种同时成立才敢下「精确子串」的结论。
109
+ - 关键结论都经过**双向**确认:既能复现 400,也能用同一份 body 只改开头一行拿到 200(否则无法排除账号、区域或时间窗的影响)。
110
+
3
111
  ## 4.14.1 (2026-10-21)
4
112
 
5
113
  > 版本号是 **patch**:没有接口、配置项或请求结构的变更,发往上游的请求体**一个字节都没变**。本版只改「哪些上游条目算作模型」这一处判断,以及它带出的一个目录退化路径。
package/lib/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { C as defaultDesktopAuthDirs, F as WorkBuddyUpstreamClient, O as legacyWorkbuddyOwnAuthPath, S as defaultDesktopAuthCandidates, _t as hostPeersInfo, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, lt as findWorkbuddyAppExecutableWithSource, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, rt as WORKBUDDY_APP_EXECUTABLE_ENV, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, y as WorkBuddyCredentialStore } from "./host-heartbeat-D1WnqQY2.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, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, pt as findWorkbuddyAppExecutableWithSource, r as isHeartbeatProcessAlive, st as WORKBUDDY_APP_EXECUTABLE_ENV, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, xt as hostPeersInfo, y as WorkBuddyCredentialStore } from "./host-heartbeat-DtQIBMYm.js";
3
3
  import { realpathSync } from "node:fs";
4
4
  import { fileURLToPath } from "node:url";
5
5
  //#region src/bin.ts
@@ -1598,8 +1598,15 @@ const CHANNEL_REJECT_MARKERS = ["unapproved channel", "illegal api invocation"];
1598
1598
  * be read as the prompt rule: a confidently wrong diagnosis is worse here than
1599
1599
  * the generic `client` reply, because it sends the user to rewrite a system
1600
1600
  * prompt that is not at fault.
1601
+ *
1602
+ * Each is also exported as its own predicate ({@link isConversationIdGateBody},
1603
+ * {@link isFirstMessageGateBody}) so the reply can NAME the gate instead of
1604
+ * relaying the upstream's `displayTips` — which is the SAME misleading
1605
+ * sentence for all three members of the `11128` family.
1601
1606
  */
1602
- const OTHER_CHANNEL_CODE_GATES = ["conversation id missing", "first message is not system prompt"];
1607
+ const CONVERSATION_ID_GATE_MARKER = "conversation id missing";
1608
+ const FIRST_MESSAGE_GATE_MARKER = "first message is not system prompt";
1609
+ const OTHER_CHANNEL_CODE_GATES = [CONVERSATION_ID_GATE_MARKER, FIRST_MESSAGE_GATE_MARKER];
1603
1610
  /**
1604
1611
  * Whether a failure body is the MODEL-scoped throttle rather than an account
1605
1612
  * limit.
@@ -1637,52 +1644,57 @@ function isChannelRejectBody(body) {
1637
1644
  return CHANNEL_REJECT_MARKERS.some((marker) => lower.includes(marker));
1638
1645
  }
1639
1646
  /**
1640
- * The OPENINGS of the `system` messages in a prepared request body.
1647
+ * Whether a failure body is the CONVERSATION-ID gate (`11128`).
1641
1648
  *
1642
- * This is the evidence the channel gate actually reacts to, and echoing it is
1643
- * what turns "the upstream refused" into "the upstream refused because of this
1644
- * line" — the reader can then fix the prompt or report the new opening, instead
1645
- * of cycling accounts that cannot change a byte of it.
1649
+ * One of the two gates that share the channel gate's business code, and one
1650
+ * of the two this plugin's diagnosis EXCLUDES: the prompt is not at fault
1651
+ * here, the request headers are — which is why it must never be reported as
1652
+ * "the gateway refused your system prompt".
1646
1653
  *
1647
- * Each entry is the first non-empty line, clipped: a fingerprint lives at the
1648
- * START of the content, so the rest of a 10 KB system prompt is noise that
1649
- * would bury the one sentence worth reading. At most three entries are kept —
1650
- * the gate scans every `system` message, but a body with more than three of them
1651
- * is already worth showing compactly.
1654
+ * It is exported anyway, so a reply can NAME it. The upstream's `displayTips`
1655
+ * for all three `11128` gates is the identical sentence ("这次调用没通过渠道
1656
+ * 校验,可能不是官方客户端发出的"), so the tips cannot tell them apart — only
1657
+ * `msg` can. Hence the wording is the criterion and the code is the
1658
+ * corroboration, never the other way round.
1652
1659
  *
1653
- * Never throws: a malformed body is the upstream's to reject, and losing the
1654
- * failure measurement is not an acceptable price for reading an excerpt.
1660
+ * BOTH halves are required, and the code is matched in its quoted-key form
1661
+ * (`"code":11128`): a request id is hexadecimal and can contain the digits
1662
+ * `11128` by coincidence, and a bare substring search over an arbitrary
1663
+ * failure body is how a red herring gets promoted into a diagnosis.
1655
1664
  */
1656
- function systemOpeningsOfBody(bodyJson) {
1657
- let parsed;
1658
- try {
1659
- parsed = JSON.parse(bodyJson);
1660
- } catch {
1661
- return [];
1662
- }
1663
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return [];
1664
- const messages = parsed["messages"];
1665
- if (!Array.isArray(messages)) return [];
1666
- const openings = [];
1667
- for (const value of messages) {
1668
- if (openings.length >= 3) break;
1669
- if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
1670
- const message = value;
1671
- const role = message["role"];
1672
- if (typeof role !== "string" || role.trim().toLowerCase() !== "system") continue;
1673
- const content = message["content"];
1674
- if (typeof content !== "string") continue;
1675
- const opening = firstLineOf(content).replace(/\s+/gu, " ").trim();
1676
- if (opening !== "") openings.push(clip(opening, SYSTEM_OPENING_LIMIT));
1677
- }
1678
- return openings;
1665
+ function isConversationIdGateBody(body) {
1666
+ const lower = body.toLowerCase();
1667
+ return lower.includes(CHANNEL_REJECT_CODE) && lower.includes(CONVERSATION_ID_GATE_MARKER);
1668
+ }
1669
+ /**
1670
+ * Whether a failure body is the FIRST-MESSAGE gate (`11128`).
1671
+ *
1672
+ * The global gateway's own rule: a chat request must OPEN with a system
1673
+ * message, and a user-first body is refused with `11128` / "first message is
1674
+ * not system prompt". The plugin's `ensureSystemHead()` satisfies it, so a
1675
+ * body carrying this wording says the request never had a system head — not
1676
+ * that a system prompt was fingerprinted.
1677
+ *
1678
+ * Excluded from the prompt diagnosis for the same reason as
1679
+ * {@link isConversationIdGateBody}, and named by the same rule: the CODE plus
1680
+ * the WORDING, case-insensitively, code in its quoted-key form.
1681
+ */
1682
+ function isFirstMessageGateBody(body) {
1683
+ const lower = body.toLowerCase();
1684
+ return lower.includes(CHANNEL_REJECT_CODE) && lower.includes(FIRST_MESSAGE_GATE_MARKER);
1679
1685
  }
1686
+ /**
1687
+ * The OPENINGS of the `system` messages in a prepared request body.
1688
+ *
1689
+ * REMOVED in favour of {@link systemOpeningEvidenceOfBody}, which answers the
1690
+ * same question ("what did the gate actually see?") for BOTH string and
1691
+ * array-shaped content and can also say whether an opening is the plugin's own
1692
+ * neutral head. Two functions that both claim to describe what the gate read
1693
+ * are two truths that drift — and the array-shaped case is exactly where the
1694
+ * old one was blind.
1695
+ */
1680
1696
  /** Longest system-prompt opening echoed back to the user, in characters. */
1681
1697
  const SYSTEM_OPENING_LIMIT = 120;
1682
- function firstLineOf(content) {
1683
- const newline = content.indexOf("\n");
1684
- return newline === -1 ? content : content.slice(0, newline);
1685
- }
1686
1698
  function clip(text, limit) {
1687
1699
  return text.length <= limit ? text : `${text.slice(0, limit - 1)}…`;
1688
1700
  }
@@ -1980,31 +1992,389 @@ const WORKBUDDY_FALLBACK_SYSTEM_PROMPT = "You are a helpful assistant.";
1980
1992
  * snapshot, not a theory of what the gate "means"; a gateway adding a new
1981
1993
  * opening is not caught until a body carries one, and the prepend only wins
1982
1994
  * while the gate keeps matching at offset 0.
1995
+ *
1996
+ * THE LIST CANNOT BE COMPLETE, AND THAT IS WHY {@link forceNeutralHead}
1997
+ * EXISTS. This is a snapshot of the upstream's own list, taken by measuring
1998
+ * openings one at a time; the upstream can extend it at any time and the
1999
+ * plugin cannot see it. So the plugin's own prepend is a best-effort filter,
2000
+ * not a guarantee — and the self-healing retry is what covers the openings
2001
+ * nobody has measured yet.
1983
2002
  */
1984
2003
  const UNAPPROVED_CHANNEL_OPENINGS = ["you are an ai agent powered by deepseek harness", "you are claude code, anthropic's official cli for claude"];
1985
2004
  /**
1986
- * Move every `system` message off the content fingerprint above by prepending
1987
- * the placeholder head.
2005
+ * Whether this text OPENS with an opening the gateways refuse.
2006
+ *
2007
+ * The gate is a PREFIX match on the START of a `system` message, so this is
2008
+ * the one predicate that decides it — used by the request path (which prepends
2009
+ * the neutral head on a hit) and by the failure path (which names the
2010
+ * offending line).
2011
+ *
2012
+ * THREE MEASURED RULES are folded in here, all from the live CN gateway on
2013
+ * 2026-10-21 (account `ba217455…`, `deepseek-v4.1-flash`, every other request
2014
+ * field held fixed):
2015
+ *
2016
+ * 1. Only the START counts — the same sentence mid-content answers 200.
2017
+ * 2. LEADING WHITESPACE is trimmed before the comparison — " <banned>" and
2018
+ * "\n<banned>" both answer 200 today.
2019
+ * 3. The comparison is CASE-INSENSITIVE — the lower-cased sentence fails too.
2020
+ *
2021
+ * (2) is a gift that can be withdrawn, so the comparison is made the way a
2022
+ * trimming gate would make it even while the gateway is lenient: a rule that
2023
+ * only holds while the gateway forgets to trim is a rule waiting to break.
2024
+ *
2025
+ * The list itself stays the measured snapshot. It is deliberately NOT
2026
+ * extended by guesswork: an opening nobody measured is an opening nobody knows
2027
+ * the gateway refuses, and inventing entries would neutralise prompts that
2028
+ * answer 200 today.
2029
+ */
2030
+ function isUnapprovedChannelOpening(text) {
2031
+ const opening = text.replace(/^\s+/u, "").toLowerCase();
2032
+ return UNAPPROVED_CHANNEL_OPENINGS.some((prefix) => opening.startsWith(prefix));
2033
+ }
2034
+ /**
2035
+ * Whether this text already begins with the plugin's own neutral head.
2036
+ *
2037
+ * The question the failure path has to answer per system message: "is the
2038
+ * line the gateway refused OURS, or the HOST's?" — the plugin's head is the
2039
+ * one line it has ever added to a prompt, so it is the only opening that
2040
+ * cannot be a third-party-agent fingerprint.
2041
+ */
2042
+ function startsWithNeutralHead(text) {
2043
+ return text.replace(/^\s+/u, "").toLowerCase().startsWith(WORKBUDDY_FALLBACK_SYSTEM_PROMPT.toLowerCase());
2044
+ }
2045
+ /**
2046
+ * The roles whose message OPENINGS the gateway scans.
2047
+ *
2048
+ * MEASURED on the live CN gateway (2026-10-21, every other request field held
2049
+ * fixed): `system` and `assistant` are scanned; `user` and `tool` are not (a
2050
+ * body whose `user` or `tool` content opens with the banned identity answers
2051
+ * 200). That second role is the one the plugin missed until 4.15.0, and it is
2052
+ * why issue #98's sessions stayed broken: an `assistant` history message that
2053
+ * opens with the fingerprint poisons EVERY later turn of that session, on
2054
+ * every account and in every region.
1988
2055
  *
1989
- * A no-op for a body with no system message and for every system message whose
1990
- * content starts with anything else, so a request that the gateways already
1991
- * answer keeps its prompt byte-for-byte — only the messages the gate would
1992
- * refuse grow, by one short line.
2056
+ * `developer` is not listed because `prepareChatBody` rewrites it to `system`
2057
+ * before this module looks at the body — and because both gateways refuse
2058
+ * `developer` outright, so neutralising its content would not save the request.
2059
+ */
2060
+ function scansOpening(role) {
2061
+ if (typeof role !== "string") return false;
2062
+ const name = role.trim().toLowerCase();
2063
+ return name === "system" || name === "assistant";
2064
+ }
2065
+ /**
2066
+ * Move every scanned message (`system` and `assistant`) off the content
2067
+ * fingerprint above by prepending the placeholder head.
2068
+ *
2069
+ * A no-op for a body whose messages start with anything else, so a request the
2070
+ * gateways already answer keeps its prompt byte-for-byte — only the messages
2071
+ * the gate would refuse grow, by one short line.
2072
+ *
2073
+ * BOTH ROLES, because the gate reads both. Until 4.15.0 this only handled
2074
+ * `system`, and the failure that left behind is issue #98: a conversation
2075
+ * whose HISTORY contains an assistant message opening with the banned identity
2076
+ * (the model echoing its own prompt, a restored session, a compaction
2077
+ * artefact) was refused on every single turn, and no account or region could
2078
+ * change a byte of it.
2079
+ *
2080
+ * ARRAY CONTENT IS HANDLED PART BY PART, and that is not a nicety. Measured on
2081
+ * the live CN gateway (2026-10-21): when a scanned message's `content` is an
2082
+ * array of parts, the gate reads EVERY TEXT PART of it — a banned opening in
2083
+ * the FIRST part is refused, and a banned opening in the SECOND part is
2084
+ * refused just the same. Prepending to the first part only would therefore
2085
+ * fix the first half of the failure and leave the second half to the users who
2086
+ * report it again.
1993
2087
  */
1994
2088
  function neutralizeChannelFingerprints(obj) {
1995
2089
  const messages = obj["messages"];
1996
2090
  if (!Array.isArray(messages)) return;
1997
2091
  for (const value of messages) {
2092
+ if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
2093
+ const message = value;
2094
+ if (!scansOpening(message["role"])) continue;
2095
+ const content = message["content"];
2096
+ if (typeof content === "string") {
2097
+ if (!isUnapprovedChannelOpening(content)) continue;
2098
+ message["content"] = `${WORKBUDDY_FALLBACK_SYSTEM_PROMPT}\n\n${content}`;
2099
+ continue;
2100
+ }
2101
+ if (!Array.isArray(content)) continue;
2102
+ for (const part of content) {
2103
+ if (typeof part !== "object" || part === null || Array.isArray(part)) continue;
2104
+ const record = part;
2105
+ const type = record["type"];
2106
+ if (typeof type !== "string" || type.trim().toLowerCase() !== "text") continue;
2107
+ const text = record["text"];
2108
+ if (typeof text !== "string" || !isUnapprovedChannelOpening(text)) continue;
2109
+ record["text"] = `${WORKBUDDY_FALLBACK_SYSTEM_PROMPT}\n\n${text}`;
2110
+ }
2111
+ }
2112
+ }
2113
+ /**
2114
+ * A body in which EVERY scanned opening (`system` and `assistant`) is moved
2115
+ * behind the neutral head.
2116
+ *
2117
+ * THE ONE-SHOT SELF-HEALING RETRY'S BODY BUILDER. The gateway's fingerprint
2118
+ * list is a SNAPSHOT the upstream can extend at any time, and the plugin
2119
+ * cannot see it: the two entries in {@link UNAPPROVED_CHANNEL_OPENINGS} are
2120
+ * what was MEASURED on 2026-10-21, not a contract. So the day the gateway
2121
+ * adds an entry, this plugin's own prepend stops matching and the banned
2122
+ * opening goes out on the wire unchanged — exactly the dead end issue #98
2123
+ * describes, and exactly what a longer guess-list cannot prevent (nobody can
2124
+ * measure the next entry before it bites).
2125
+ *
2126
+ * The remedy is a second attempt that does not depend on knowing the
2127
+ * fingerprint at all: this builds a body in which EVERY scanned message,
2128
+ * whatever it opens with, gets the neutral head in front. The gateway matches
2129
+ * the START of a scanned message, so a neutral first line moves the whole
2130
+ * opening out of the matched region — whether the banned text is a known
2131
+ * entry, a new one, or a fingerprint this plugin has never heard of.
2132
+ *
2133
+ * `assistant` messages are included for the measured reason
2134
+ * {@link neutralizeChannelFingerprints} documents: a history message that opens
2135
+ * with a refused identity is what keeps a whole session failing, so a retry
2136
+ * that only moved the system prompt would spend a request to be refused for the
2137
+ * same reason.
2138
+ *
2139
+ * WHEN IT IS USED: only AFTER the gateway refused the first attempt with the
2140
+ * channel gate. A request the gateway already answers keeps its bytes
2141
+ * unchanged, because this function is never called for it — the normal path
2142
+ * is {@link prepareChatBody}, which only touches the openings it has MEASURED
2143
+ * as fingerprinted.
2144
+ *
2145
+ * `undefined` means "this body cannot change": every scanned opening already
2146
+ * starts with the neutral head, so a retry would send the same bytes and
2147
+ * spend a request for the same refusal. The caller uses that to avoid
2148
+ * spending the round trip.
2149
+ *
2150
+ * Empty contents are skipped: an empty string cannot open with anything, so it
2151
+ * cannot be the culprit, and an assistant message that carries `tool_calls`
2152
+ * with `content: ""` is a replay shape this function has no reason to touch.
2153
+ *
2154
+ * Never throws. A body it cannot read is the upstream's to reject, and a
2155
+ * parse error must not turn a retry into a crash.
2156
+ */
2157
+ function forceNeutralHead(bodyJson) {
2158
+ let parsed;
2159
+ try {
2160
+ parsed = JSON.parse(bodyJson);
2161
+ } catch {
2162
+ return;
2163
+ }
2164
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
2165
+ const obj = parsed;
2166
+ const messages = obj["messages"];
2167
+ if (!Array.isArray(messages)) return void 0;
2168
+ let changed = false;
2169
+ for (const value of messages) {
2170
+ if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
2171
+ const message = value;
2172
+ if (!scansOpening(message["role"])) continue;
2173
+ const content = message["content"];
2174
+ if (typeof content === "string") {
2175
+ if (content.trim() === "" || startsWithNeutralHead(content)) continue;
2176
+ message["content"] = `${WORKBUDDY_FALLBACK_SYSTEM_PROMPT}\n\n${content}`;
2177
+ changed = true;
2178
+ continue;
2179
+ }
2180
+ if (!Array.isArray(content)) continue;
2181
+ for (const part of content) {
2182
+ if (typeof part !== "object" || part === null || Array.isArray(part)) continue;
2183
+ const record = part;
2184
+ const type = record["type"];
2185
+ if (typeof type !== "string" || type.trim().toLowerCase() !== "text") continue;
2186
+ const text = record["text"];
2187
+ if (typeof text !== "string" || text.trim() === "" || startsWithNeutralHead(text)) continue;
2188
+ record["text"] = `${WORKBUDDY_FALLBACK_SYSTEM_PROMPT}\n\n${text}`;
2189
+ changed = true;
2190
+ }
2191
+ }
2192
+ if (!changed) return void 0;
2193
+ return JSON.stringify(obj);
2194
+ }
2195
+ /**
2196
+ * The invisible character used to break a content fingerprint.
2197
+ *
2198
+ * U+200B ZERO WIDTH SPACE: it renders as nothing, it is not whitespace for the
2199
+ * gateway's own matching, and a model reads straight through it. Measured on
2200
+ * the live CN gateway: putting one inside the refused span turns 400 into 200,
2201
+ * while the text the model reads is unchanged.
2202
+ */
2203
+ const ZERO_WIDTH_SPACE = "​";
2204
+ /**
2205
+ * A body whose scanned message TEXT is perturbed just enough to break an exact
2206
+ * content fingerprint.
2207
+ *
2208
+ * THE SECOND HALF OF ISSUE #98, found on 2026-10-21 after 4.15.0 shipped. The
2209
+ * gateway does not only match the OPENING of a scanned message; it also matches
2210
+ * one long substring INSIDE the system prompt. Measured byte-exactly on a real
2211
+ * failed request (CN gateway, `deepseek-v4.1-flash`, the host's own body):
2212
+ *
2213
+ * - the refusal is reproducible, and it is not the opening: the plugin had
2214
+ * already prepended the neutral head, and the request still answered 400;
2215
+ * - bisecting the host's 10,956-character system prompt by lines and then by
2216
+ * sentences found a 221-character span: the DSH Web-GUI paragraph's first
2217
+ * two sentences ("You are interacting with the user through the DeepSeek
2218
+ * Harness Web GUI … they mean this GUI.");
2219
+ * - that span ALONE reproduces the 400, and the SAME span with ANY change
2220
+ * inside it answers 200 — one character rewritten, one quoted word
2221
+ * replaced, even a zero-width space inserted. An outer prefix or suffix
2222
+ * does not help, so the match is an exact substring, not an opening rule;
2223
+ * - the paragraph's individual sentences each answer 200, which is why the
2224
+ * plugin's opening-based normalisation never saw this.
2225
+ *
2226
+ * SO THE RESCUE CANNOT BE "prepend a line" (that is {@link forceNeutralHead},
2227
+ * and it is a no-op here). Nor can it be a list of refused spans: the list is
2228
+ * the upstream's own data, it already grew once without notice, and it will
2229
+ * grow again. What DOES generalise is the observation that an exact-substring
2230
+ * match dies on any perturbation inside it — so this inserts a zero-width space
2231
+ * at every sentence and clause boundary of the scanned messages, which is
2232
+ * invisible to the reader, meaningless to the model, and inside the refused
2233
+ * span with near-certainty for any prose fingerprint.
2234
+ *
2235
+ * WHEN IT IS USED: only as the last retry, after the gateway already refused
2236
+ * the request AND the neutral head could not change anything. Requests the
2237
+ * gateway answers keep their bytes untouched.
2238
+ *
2239
+ * Idempotent: a body that already carries U+200B in a scanned message is left
2240
+ * alone, so a retry never stacks perturbations. Returns `undefined` when
2241
+ * nothing would change, which is the caller's signal not to spend a request.
2242
+ *
2243
+ * Never throws: a malformed body is the upstream's to reject, and a parse error
2244
+ * must not turn a rescue into a crash.
2245
+ */
2246
+ function perturbChannelFingerprints(bodyJson) {
2247
+ let parsed;
2248
+ try {
2249
+ parsed = JSON.parse(bodyJson);
2250
+ } catch {
2251
+ return;
2252
+ }
2253
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
2254
+ const obj = parsed;
2255
+ const messages = obj["messages"];
2256
+ if (!Array.isArray(messages)) return void 0;
2257
+ let changed = false;
2258
+ const perturb = (text) => {
2259
+ if (text.includes(ZERO_WIDTH_SPACE)) return void 0;
2260
+ const next = text.replace(/([.!?])(\s+)/gu, `$1${ZERO_WIDTH_SPACE}$2`).replace(/([。!?])/gu, `$1${ZERO_WIDTH_SPACE}`).replace(/([,;])(\s+)/gu, `$1${ZERO_WIDTH_SPACE}$2`);
2261
+ return next === text ? void 0 : next;
2262
+ };
2263
+ for (const value of messages) {
2264
+ if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
2265
+ const message = value;
2266
+ if (!scansOpening(message["role"])) continue;
2267
+ const content = message["content"];
2268
+ if (typeof content === "string") {
2269
+ const next = perturb(content);
2270
+ if (next !== void 0) {
2271
+ message["content"] = next;
2272
+ changed = true;
2273
+ }
2274
+ continue;
2275
+ }
2276
+ if (!Array.isArray(content)) continue;
2277
+ for (const part of content) {
2278
+ if (typeof part !== "object" || part === null || Array.isArray(part)) continue;
2279
+ const record = part;
2280
+ const type = record["type"];
2281
+ if (typeof type !== "string" || type.trim().toLowerCase() !== "text") continue;
2282
+ const text = record["text"];
2283
+ if (typeof text !== "string") continue;
2284
+ const next = perturb(text);
2285
+ if (next !== void 0) {
2286
+ record["text"] = next;
2287
+ changed = true;
2288
+ }
2289
+ }
2290
+ }
2291
+ if (!changed) return void 0;
2292
+ return JSON.stringify(obj);
2293
+ }
2294
+ /**
2295
+ * The openings of every scanned message in a body, one entry per TEXT PART.
2296
+ *
2297
+ * `system` AND `assistant`, because both are read by the gate (measured; see
2298
+ * {@link scansOpening}). `developer` counts as a system message because the
2299
+ * plugin rewrites `developer` → `system` on the wire.
2300
+ *
2301
+ * Array content contributes ONE ENTRY PER PART, for the same reason the request
2302
+ * path neutralises every part: the gate reads every text part, so "which line
2303
+ * tripped it" can be any of them.
2304
+ *
2305
+ * Capped at {@link SYSTEM_EVIDENCE_LIMIT} entries, and never throws — losing
2306
+ * the failure measurement is not an acceptable price for reading an excerpt.
2307
+ */
2308
+ function openingEvidenceOfBody(bodyJson) {
2309
+ let parsed;
2310
+ try {
2311
+ parsed = JSON.parse(bodyJson);
2312
+ } catch {
2313
+ return [];
2314
+ }
2315
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return [];
2316
+ const messages = parsed["messages"];
2317
+ if (!Array.isArray(messages)) return [];
2318
+ const evidence = [];
2319
+ for (let index = 0; index < messages.length; index++) {
2320
+ if (evidence.length >= SYSTEM_EVIDENCE_LIMIT) break;
2321
+ const value = messages[index];
1998
2322
  if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
1999
2323
  const message = value;
2000
2324
  const role = message["role"];
2001
- if (typeof role !== "string" || role.trim().toLowerCase() !== "system") continue;
2325
+ if (typeof role !== "string") continue;
2326
+ const roleName = role.trim().toLowerCase();
2327
+ if (roleName !== "system" && roleName !== "assistant" && roleName !== "developer") continue;
2328
+ const roleOfEvidence = roleName === "assistant" ? "assistant" : "system";
2002
2329
  const content = message["content"];
2003
- if (typeof content !== "string") continue;
2004
- const opening = content.replace(/^\s+/, "").toLowerCase();
2005
- if (!UNAPPROVED_CHANNEL_OPENINGS.some((prefix) => opening.startsWith(prefix))) continue;
2006
- message["content"] = `${WORKBUDDY_FALLBACK_SYSTEM_PROMPT}\n\n${content}`;
2330
+ const texts = [];
2331
+ if (typeof content === "string") texts.push(content);
2332
+ else if (Array.isArray(content)) for (const part of content) {
2333
+ if (typeof part !== "object" || part === null || Array.isArray(part)) continue;
2334
+ const record = part;
2335
+ const type = record["type"];
2336
+ if (typeof type !== "string" || type.trim().toLowerCase() !== "text") continue;
2337
+ const text = record["text"];
2338
+ if (typeof text === "string") texts.push(text);
2339
+ }
2340
+ for (const text of texts) {
2341
+ if (evidence.length >= SYSTEM_EVIDENCE_LIMIT) break;
2342
+ const opening = firstNonEmptyLineOf(text).replace(/\s+/gu, " ").trim();
2343
+ if (opening === "") continue;
2344
+ evidence.push({
2345
+ opening: clip(opening, SYSTEM_OPENING_LIMIT),
2346
+ role: roleOfEvidence,
2347
+ neutralized: startsWithNeutralHead(opening) || isUnapprovedChannelOpening(opening),
2348
+ messageIndex: index
2349
+ });
2350
+ }
2007
2351
  }
2352
+ return evidence;
2353
+ }
2354
+ /**
2355
+ * How many openings the failure path reports.
2356
+ *
2357
+ * Eight is generous: the gate scans every system message and every text part
2358
+ * of it, so a body with more than a handful of openings is not a prompt the
2359
+ * reader can act on anyway — the reply names the first ones and the failure
2360
+ * body carries the rest.
2361
+ *
2362
+ * The cap is HERE and not in the reply: this function reports everything it
2363
+ * found (up to eight) and the reply renders as many of them as fit, saying
2364
+ * how many were left out. Truncating the evidence before the reply sees it
2365
+ * would make "N more" unanswerable.
2366
+ */
2367
+ const SYSTEM_EVIDENCE_LIMIT = 8;
2368
+ /**
2369
+ * The first line of a text that carries something.
2370
+ *
2371
+ * Blank lines are SKIPPED: the neutral head is followed by an empty line, so
2372
+ * the head itself is the line worth reporting, and a content that opens with
2373
+ * blank lines still has an opening.
2374
+ */
2375
+ function firstNonEmptyLineOf(content) {
2376
+ for (const line of content.split("\n")) if (line.trim() !== "") return line;
2377
+ return "";
2008
2378
  }
2009
2379
  /**
2010
2380
  * Make the conversation open with a system message, prepending the fallback
@@ -4863,7 +5233,7 @@ function generationOf(version) {
4863
5233
  //#endregion
4864
5234
  //#region src/version.ts
4865
5235
  /** The npm package version this build was produced from. */
4866
- const WORKBUDDY_CONNECT_VERSION = "4.14.1";
5236
+ const WORKBUDDY_CONNECT_VERSION = "4.15.1";
4867
5237
  //#endregion
4868
5238
  //#region src/host-heartbeat.ts
4869
5239
  /**
@@ -5021,4 +5391,4 @@ async function clearHostHeartbeat() {
5021
5391
  await rm(workbuddyHostHeartbeatPath(), { force: true });
5022
5392
  }
5023
5393
  //#endregion
5024
- 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 _, hostPeersInfo as _t, readHostHeartbeat as a, deriveAtRestKey as at, WorkBuddyEncryptedCredentialError as b, writeFileAtomic as bt, WORKBUDDY_CONNECT_VERSION as c, findWorkbuddyAppExecutable as ct, FALLBACK_WORKBUDDY_MODELS_GLOBAL as d, isWorkbuddyBundle as dt, NATIVE_MODALITY_BY_MODEL_ID as et, WorkBuddyCatalog as f, macosBundleExecutable as ft, ENCRYPTED_CREDENTIAL_CODE as g, workbuddyAppExecutableCandidates as gt, fallbackModelsFor as h, readAtRestKey as ht, processStartTimeMs as i, clearAtRestKeyCache as it, workbuddyOwnAuthPath as j, parseWorkBuddyAuth as k, piAiRuntimeInfo as l, findWorkbuddyAppExecutableWithSource as lt, deriveCatalog as m, openEncryptedField as mt, clearHostHeartbeat as n, nativeModalityOf as nt, workbuddyHostHeartbeatPath as o, deriveAtRestKeyId as ot, applyContextBudgets as p, macosNestedAppBundles as pt, REFUSES_OFF_MODEL_IDS as q, isHeartbeatProcessAlive as r, WORKBUDDY_APP_EXECUTABLE_ENV as rt, writeHostHeartbeat as s, fetchAtRestKeyPayload as st, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, imageDefaultFor as tt, FALLBACK_WORKBUDDY_MODELS as u, isEncryptedFieldWrapper as ut, WORKBUDDY_AUTH_FILE_ENV as v, resolveDshHome as vt, defaultDesktopAuthPath as w, authFileName as x, WorkBuddyCredentialStore as y, withFileLock as yt, knownRegionOf as z };
5394
+ export { lastRefusalFor as $, workbuddyAccountId as A, isCredentialRejectedError as B, defaultDesktopAuthDirs as C, withFileLock as Ct, isVaultAccountId as D, isEncryptedCredentialError as E, WorkBuddyUpstreamClient as F, parseCreditMultiplier as G, knownRegionOf as H, classifyUpstreamError as I, perturbChannelFingerprints as J, parseReasoning as K, declaredTools as L, CREDENTIAL_REJECTED_CODE as M, WORKBUDDY_FALLBACK_SYSTEM_PROMPT as N, legacyWorkbuddyOwnAuthPath as O, WorkBuddyCredentialRejectedError as P, defaultOffModelIds as Q, forceNeutralHead as R, defaultDesktopAuthCandidates as S, resolveDshHome as St, hasEncryptedCredentialFields as T, modelIdOfBody as U, isFirstMessageGateBody as V, openingEvidenceOfBody as W, regionOf as X, prepareChatBody as Y, REFUSES_OFF_MODEL_IDS as Z, WORKBUDDY_AUTH_FILENAME as _, macosNestedAppBundles as _t, readHostHeartbeat as a, imageDefaultFor as at, WorkBuddyEncryptedCredentialError as b, workbuddyAppExecutableCandidates as bt, WORKBUDDY_CONNECT_VERSION as c, clearAtRestKeyCache as ct, FALLBACK_WORKBUDDY_MODELS_GLOBAL as d, fetchAtRestKeyPayload as dt, offDefaultFor as et, WorkBuddyCatalog as f, findWorkbuddyAppExecutable as ft, ENCRYPTED_CREDENTIAL_CODE as g, macosBundleExecutable as gt, fallbackModelsFor as h, isWorkbuddyBundle as ht, processStartTimeMs as i, NATIVE_MODALITY_BY_MODEL_ID as it, workbuddyOwnAuthPath as j, parseWorkBuddyAuth as k, piAiRuntimeInfo as l, deriveAtRestKey as lt, deriveCatalog as m, isEncryptedFieldWrapper as mt, clearHostHeartbeat as n, recordRefusal as nt, workbuddyHostHeartbeatPath as o, nativeModalityOf as ot, applyContextBudgets as p, findWorkbuddyAppExecutableWithSource as pt, parseUpstreamModel as q, isHeartbeatProcessAlive as r, withEffectiveOff as rt, writeHostHeartbeat as s, WORKBUDDY_APP_EXECUTABLE_ENV as st, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, offRefused as tt, FALLBACK_WORKBUDDY_MODELS as u, deriveAtRestKeyId as ut, WORKBUDDY_AUTH_FILE_ENV as v, openEncryptedField as vt, defaultDesktopAuthPath as w, writeFileAtomic as wt, authFileName as x, hostPeersInfo as xt, WorkBuddyCredentialStore as y, readAtRestKey as yt, isConversationIdGateBody as z };
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
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, a as readHostHeartbeat, at as deriveAtRestKey, b as WorkBuddyEncryptedCredentialError, bt as writeFileAtomic, c as WORKBUDDY_CONNECT_VERSION, ct as findWorkbuddyAppExecutable, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, dt as isWorkbuddyBundle, et as NATIVE_MODALITY_BY_MODEL_ID, f as WorkBuddyCatalog, ft as macosBundleExecutable, g as ENCRYPTED_CREDENTIAL_CODE, gt as workbuddyAppExecutableCandidates, h as fallbackModelsFor, ht as readAtRestKey, i as processStartTimeMs, it as clearAtRestKeyCache, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, m as deriveCatalog, mt as openEncryptedField, n as clearHostHeartbeat, nt as nativeModalityOf, o as workbuddyHostHeartbeatPath, ot as deriveAtRestKeyId, p as applyContextBudgets, pt as macosNestedAppBundles, q as REFUSES_OFF_MODEL_IDS, r as isHeartbeatProcessAlive, rt as WORKBUDDY_APP_EXECUTABLE_ENV, s as writeHostHeartbeat, st as fetchAtRestKeyPayload, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as imageDefaultFor, u as FALLBACK_WORKBUDDY_MODELS, ut as isEncryptedFieldWrapper, v as WORKBUDDY_AUTH_FILE_ENV, vt as resolveDshHome, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, yt as withFileLock, z as knownRegionOf } from "./host-heartbeat-D1WnqQY2.js";
1
+ import { $ as lastRefusalFor, A as workbuddyAccountId, B as isCredentialRejectedError, C as defaultDesktopAuthDirs, Ct as withFileLock, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as parseCreditMultiplier, H as knownRegionOf, I as classifyUpstreamError, J as perturbChannelFingerprints, K as parseReasoning, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, Q as defaultOffModelIds, R as forceNeutralHead, S as defaultDesktopAuthCandidates, St as resolveDshHome, T as hasEncryptedCredentialFields, U as modelIdOfBody, V as isFirstMessageGateBody, W as openingEvidenceOfBody, X as regionOf, Y as prepareChatBody, Z as REFUSES_OFF_MODEL_IDS, _ as WORKBUDDY_AUTH_FILENAME, _t as macosNestedAppBundles, a as readHostHeartbeat, at as imageDefaultFor, b as WorkBuddyEncryptedCredentialError, bt as workbuddyAppExecutableCandidates, c as WORKBUDDY_CONNECT_VERSION, ct as clearAtRestKeyCache, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, dt as fetchAtRestKeyPayload, et as offDefaultFor, f as WorkBuddyCatalog, ft as findWorkbuddyAppExecutable, g as ENCRYPTED_CREDENTIAL_CODE, gt as macosBundleExecutable, h as fallbackModelsFor, ht as isWorkbuddyBundle, i as processStartTimeMs, it as NATIVE_MODALITY_BY_MODEL_ID, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, lt as deriveAtRestKey, m as deriveCatalog, mt as isEncryptedFieldWrapper, n as clearHostHeartbeat, nt as recordRefusal, o as workbuddyHostHeartbeatPath, ot as nativeModalityOf, p as applyContextBudgets, q as parseUpstreamModel, r as isHeartbeatProcessAlive, rt as withEffectiveOff, s as writeHostHeartbeat, st as WORKBUDDY_APP_EXECUTABLE_ENV, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as offRefused, u as FALLBACK_WORKBUDDY_MODELS, ut as deriveAtRestKeyId, v as WORKBUDDY_AUTH_FILE_ENV, vt as openEncryptedField, w as defaultDesktopAuthPath, wt as writeFileAtomic, x as authFileName, y as WorkBuddyCredentialStore, yt as readAtRestKey, z as isConversationIdGateBody } from "./host-heartbeat-DtQIBMYm.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";
@@ -2960,6 +2960,43 @@ function clippedUpstreamText(text, limit) {
2960
2960
  return `${cut.trimEnd()}…`;
2961
2961
  }
2962
2962
  /**
2963
+ * The channel-gate reply's evidence sentence: what the gateway actually read.
2964
+ *
2965
+ * Read from the HOST's body, not from the prepared one, because the prepared
2966
+ * body has the plugin's neutral head in front of every opening it recognises —
2967
+ * so the first line it could report is the plugin's own placeholder, which is
2968
+ * exactly the false lead issue #98's reporters were given. They were told the
2969
+ * gateway had refused `You are a helpful assistant.`, a line that returns 200
2970
+ * when sent alone.
2971
+ *
2972
+ * Each entry is labelled with its ROLE (`system` / `assistant`) because both are
2973
+ * scanned and the difference is what tells the reader where to look: an
2974
+ * `assistant` entry is a history message, i.e. the session is poisoned and every
2975
+ * later turn will fail the same way until that line is changed.
2976
+ *
2977
+ * Each entry also says whether the plugin's own neutralisation already covers
2978
+ * it. An entry marked "not in the plugin's list" is the actionable one: that
2979
+ * opening goes on the wire verbatim, so either the host wrote it or the
2980
+ * gateway's list grew.
2981
+ *
2982
+ * Capped at {@link CHANNEL_EVIDENCE_LIMIT} entries: a session can carry dozens
2983
+ * of system/assistant messages, and a wall of them would bury the one that
2984
+ * matters. The remainder is counted instead of listed.
2985
+ */
2986
+ function channelEvidenceText(bodyJson) {
2987
+ const entries = openingEvidenceOfBody(bodyJson);
2988
+ if (entries.length === 0) return "(请求里没有可读的 system 或 assistant 消息开头。)";
2989
+ const shown = entries.slice(0, CHANNEL_EVIDENCE_LIMIT);
2990
+ const rendered = shown.map((entry) => {
2991
+ const label = entry.neutralized ? "(插件已加中性头)" : "(不在插件已知名单里,网关可能新增了词条)";
2992
+ return `「${entry.role}:${entry.opening}」${label}`;
2993
+ }).join("、");
2994
+ const hidden = entries.length - shown.length;
2995
+ return `请求里被网关扫描的消息开头:${rendered}${hidden > 0 ? `……另有 ${hidden} 条未列出。` : "。"}`;
2996
+ }
2997
+ /** How many message openings the channel-gate reply lists. */
2998
+ const CHANNEL_EVIDENCE_LIMIT = 4;
2999
+ /**
2963
3000
  * The line prepended to an answer that came from a failover account.
2964
3001
  *
2965
3002
  * Block-quoted and bracketed so it reads as machinery rather than as the model's
@@ -3284,6 +3321,56 @@ function createWorkBuddyShim(options) {
3284
3321
  const controller = new AbortController();
3285
3322
  res.on("close", () => controller.abort());
3286
3323
  let result = await client.chatStream(credential, prepared, controller.signal);
3324
+ /**
3325
+ * The retry ladder that does not depend on knowing the fingerprint
3326
+ * (issue #98, both halves).
3327
+ *
3328
+ * The gateway refuses the request for data the plugin cannot see: a list of
3329
+ * refused `system`/`assistant` OPENINGS (the plugin holds a measured
3330
+ * SNAPSHOT, `UNAPPROVED_CHANNEL_OPENINGS`) and, since 2026-10-21, at least
3331
+ * one long substring INSIDE the prompt. Every account and every region
3332
+ * sends the same bytes, so without a rescue the only outcome is a dead end
3333
+ * the reader cannot get out of.
3334
+ *
3335
+ * Two rungs, both on the SAME credential (the gate reads the request, not
3336
+ * the account, which is why `channel_reject` is not failover-worthy):
3337
+ *
3338
+ * 1. `forceNeutralHead` — move every scanned opening behind the neutral
3339
+ * head. Measured: whatever the banned opening is, moving it off the
3340
+ * start of the content answers 200.
3341
+ * 2. `perturbChannelFingerprints` — insert zero-width spaces at the
3342
+ * sentence boundaries of the scanned messages, which breaks an
3343
+ * exact-substring fingerprint without changing a word the model reads.
3344
+ * This is the rung that rescues the inside-the-prompt fingerprints,
3345
+ * where rung 1 is a no-op.
3346
+ *
3347
+ * Both return `undefined` when they would change nothing; that is the
3348
+ * signal not to spend a request on a body that would be refused identically.
3349
+ *
3350
+ * Nothing has been written to the client at this point (`!ok` means the
3351
+ * upstream answered non-2xx before any stream started), so a retry cannot
3352
+ * splice two responses together. The first attempt's failure is NOT booked
3353
+ * as an account failure: it was the request that was refused, not the
3354
+ * credential.
3355
+ */
3356
+ let retriedWithForcedHead = false;
3357
+ let retriedWithPerturbation = false;
3358
+ if (!result.ok && result.kind === "channel_reject" && !controller.signal.aborted) {
3359
+ const forced = forceNeutralHead(prepared);
3360
+ if (forced !== void 0) {
3361
+ logger?.warn("dsh-connect-workbuddy: the channel gate refused the body; retrying once with the neutral head forced in front of every opening");
3362
+ result = await client.chatStream(credential, forced, controller.signal);
3363
+ retriedWithForcedHead = true;
3364
+ }
3365
+ if (!result.ok && result.kind === "channel_reject" && !controller.signal.aborted) {
3366
+ const rescued = perturbChannelFingerprints(forced ?? prepared);
3367
+ if (rescued !== void 0) {
3368
+ logger?.warn("dsh-connect-workbuddy: the channel gate refused the body again; retrying once with an invisible perturbation of the scanned text");
3369
+ result = await client.chatStream(credential, rescued, controller.signal);
3370
+ retriedWithPerturbation = true;
3371
+ }
3372
+ }
3373
+ }
3287
3374
  const triedAccountIds = [];
3288
3375
  /**
3289
3376
  * The account the request STARTED on, and the reason it was abandoned.
@@ -3390,9 +3477,12 @@ function createWorkBuddyShim(options) {
3390
3477
  if (detail?.upstreamCode !== void 0) meta.push(`code ${detail.upstreamCode}`);
3391
3478
  if (detail?.requestId !== void 0) meta.push(`requestId ${detail.requestId}`);
3392
3479
  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 里`;
3480
+ const evidence = channelEvidenceText(raw);
3481
+ const retries = [];
3482
+ if (retriedWithForcedHead) retries.push("强制中性头");
3483
+ if (retriedWithPerturbation) retries.push("一次不可见扰动");
3484
+ const retryNote = retries.length === 0 ? "" : `,并已用${retries.join("与")}重试过`;
3485
+ const message = `workbuddy 上游失败:${failoverReasonText(result)}(http ${result.status})${note}${stamp}。${evidence}该请求被 WorkBuddy 网关的渠道校验拦截,上游把它描述成「不是官方客户端发出的」,但那是误导性文案:网关匹配的是每一条 system 与 assistant 消息的开头,与账号、积分、额度、网络都无关,换账号或换区域也不会改变结果。插件已对已知的第三方 agent 指纹做归一化` + retryNote + ";若上面标着「不在插件已知名单里」的开头来自宿主或其他插件,请修改该提示;新的指纹可反馈到 issue 里";
3396
3486
  writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, message, hostErrorCode(result.kind));
3397
3487
  return;
3398
3488
  }
@@ -3401,10 +3491,11 @@ function createWorkBuddyShim(options) {
3401
3491
  if (detail?.upstreamCode !== void 0) meta.push(`code ${detail.upstreamCode}`);
3402
3492
  if (detail?.requestId !== void 0) meta.push(`requestId ${detail.requestId}`);
3403
3493
  const stamp = meta.length === 0 ? "" : `(${meta.join(",")})`;
3494
+ const siblingGate = isConversationIdGateBody(result.message) ? "该请求被上游以「conversation id missing」拒绝:网关要求每个请求带上会话 id 头(X-Conversation-ID、X-Conversation-Request-ID、X-Conversation-Message-ID),插件每次都会带上这三个头;仍出现说明这次请求没有走插件的聊天通道,请升级插件或反馈 issue" : isFirstMessageGateBody(result.message) ? "该请求被上游以「first message is not system prompt」拒绝:网关要求首条消息是 system 消息;插件在请求带消息时会保证这一点,所以出现这条说明这次请求本身没有消息,或没有走插件的聊天通道,请升级插件或反馈 issue" : void 0;
3404
3495
  const refusedTier = reasoningTierRefusal(result);
3405
3496
  const receipt = refusedTier ?? detail?.tips ?? detail?.displayMsg ?? clippedUpstreamText(result.message, 400);
3406
3497
  const advice = refusedTier !== void 0 ? "这不是账号或网络问题:该模型实测拒绝「off」推理档位,请到模型卡片里取消该行的「可关闭思考」勾选后重试" : result.kind === "client" ? "该请求被上游按参数拒绝:与网络、账号、额度都无关,换账号也不会改变结果,请按上面的提示调整后重试" : shortfall === void 0 ? "" : "建议换一个模型继续,或稍后等待该账号的额度恢复";
3407
- const body = advice === "" ? receipt : `${receipt.replace(/[。.]$/u, "")}。${advice}`;
3498
+ const body = siblingGate !== void 0 ? siblingGate : advice === "" ? receipt : `${receipt.replace(/[。.]$/u, "")}。${advice}`;
3408
3499
  writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, `workbuddy 上游失败:${failoverReasonText(result)}(http ${result.status})${note}${why}${stamp}。${body}`, hostErrorCode(result.kind));
3409
3500
  return;
3410
3501
  }
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.14.1",
5
+ "version": "4.15.1",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",