Files
MailUI4Agents/plugins/dsh-mail-bridge/lib/relay-key.js
JianFeeeee 630b5bfdd7 fix(bridges): 续谈失败静默 + duplicate_relay 静默挂死(两个都是「人那边什么都收不到」)
同一类问题在两个地方:出事的当下看不出来,表现是「信发出去了,然后再无音讯」。

## 1)pi 的续谈失败不回失败信(实测缺口)

模型侧 402(余额不足)时,**新会话**那条路会回一封「处理失败」,而**续谈**那条路
只写日志就 `throw` —— 发件人什么都收不到。邮件驱动的会话没有本地界面可以看,
没有这封信就等于静默挂死。复现条件很普通:往一条**已存在**的会话再发一封信。

修法与邻居一致:续谈失败也回失败信。但**不能复用**共用库的 `renderFailureReport`
——那段文案说「划定范围内的模型全部调用失败」并建议「调整可用模型范围」,
而续谈是**故意不降级**的(换模型=换会话=丢掉上下文,而上下文正是发件人指定
这条会话的原因)。照抄等于让人去调一个在这里无效的旋钮,他会去改配置,
然后发现依然失败。新增 `renderResumeFailure`:点明是续谈、附上游错误原文、
建议「确实要换模型就新建一条会话」。

**活体验证**(模型侧仍是 402,失败本身就是测试条件):发一封进 pi 的已有会话,
5 秒内收到失败信,内容含 402 原文且不再出现「调整模型范围」。

顺带把 pi 里 2 处没 clamp 的 relay_key 收敛(上一轮审计只看了权限键)。

## 2)duplicate_relay:只有 zcode 认,另三桥会等一个永远不会来的决策

网关对重复的 relay_key 回 **HTTP 200 `{status:"duplicate_relay"}` 并提前返回**:
不建请求、不发邮件、**永远不会有人来决策**。zcode 桥认它并当场失败,而
pi/opencode/dsh 把它当成功,接着等 `permission_decision` 事件 —— pi 那句
`await new Promise(...)` 连超时都没有。这是 zcode 上一轮那个缺陷的同类,
只是发生在另三个桥上。

- `lib/relay-key.js`(**共用**,四处逐字节同源)新增 `isDuplicateRelay` /
  `DUPLICATE_RELAY_STATUS`:它长得像成功(200),所以必须单独认;对「发信」
  那一侧重复就该当成功(幂等),但对「等一个决定」那一侧它与故障后果相同。
- pi / opencode / dsh 三桥在权限转发处接上判据并**当场拒绝**
  (各自用自己的拒绝形状:`block: true` / `output.status = "deny"` / `'rejected'`)。
- zcode 里那份本地实现收敛到共用库(同一判据不该有两个定义)。

## 3)新增接线断言(带判据自检)

`test/permission-forward-wiring.test.mjs`(pi/opencode/dsh 三份同一内容):
纯函数测试对这类缺口天生无能为力(函数是对的,只是没人调用它),所以它读源码
验形态,钉住「判据在、落在权限转发这条路上、给出本桥形状的拒绝」。

三条自检都在写的过程中抓到了我自己的错:
- 第一次 `ROOT` 算错 → 过滤后 0 个桥、循环全不跑而「全绿」→ 加了
  「找不到装着各桥的目录就判红」;
- 顺序判据写成「在文件里最早的 await 之前」,量到了别处的等待 → 三桥全红,
  改成「必须在上报之后」;
- dsh 是**两段式**(`.then` 里抛、`catch` 的 `duplicateRelay` 分支里拒),
  第一版抽取套错了分支 → 永远找不到 `return 'rejected'`。
扰动验证:把 pi 的判据禁用后该条变红,还原即绿(改动前后都核对了字节数)。

而 dsh 那条也暴露了:我把返回形状写成了 opencode 的 `{status:'deny'}`,
**`tsc` 没报错**(返回类型是宽联合),只有对着邻居读才发现 DSH 要的是
`'rejected'` 字符串 + `noteDenial`。

## 4)部署脚本:zcode 分支现在会重启驱动

`redeploy-plugin.sh` 的 zcode 分支只切软链(宿主是 ZCode 应用,不能重启它),
但**驱动是我们自己的 unit** —— 不重启它,进程里跑的还是切换前的代码。
这个由刚写的 `check-deploy-drift.mjs` 当场抓到(它比进程启动时刻与软链切换时刻),
而当时所有其它检查都是绿的。已补上重启并验证。

## 复查

四桥全量 413 / 321 / 370 / 380 全绿;共用库四方同源;部署漂移四项全通过;
四桥真发真收冒烟(dsh/opencode/zcode 正常回信;pi 因模型侧 402 回失败信 ——
这正是上面第 1 条要修的路径)。

另:写这段时踩到一个自伤 —— 用 `npx asar extract-file <asar> dist/index.html`
检查包内容时,它把文件**写进了 cwd**,正好覆盖掉 Vite 的源码模板
`client/electron/index.html`(下次构建会拿被污染的模板去构建)。已还原并重建,
产物哈希与之前一致。要看 asar 内容请用 `@electron/asar` 的 API(返回 Buffer),
别用这个 CLI 子命令。
2026-09-12 23:13:48 +08:00

166 lines
6.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* relay_key 长度收敛 —— 四个平台共用。
*
* ## 为什么需要它
*
* relay_key 是免配额通道的幂等键,服务端列宽 160 字节(超了返回 400
* 插件按「会话 id + 某个平台侧调用 id」拼这个键平常七十来字节很安全。
*
* 但生产上踩到一次pi 会话里 bash 的 relay_key 突然超限,报文
* 「relay_key 过长(上限 160 字节)」。查真实会话文件后发现 toolCallId
* 有两种形态:
*
* toolu_bdrk_01F6roEBHa8nic1mYiyLgNWK 35 字节
* toolu_bdrk_01FsWUWhEs4arnEWo44gqzLC~sig1:CAISoQIK… 437 ~ 13601 字节
*
* 启用 extended thinking 时 Bedrock 把**思考签名**拼进了 toolCallId。
* 同一条会话里两种形态混着出现,于是同一个 Agent 的权限询问随机成功随机失败。
*
* 后果不只是「这一次没送达」:那次失败被归入「暂时失败 → 让位给本地决策」,
* 而邮件驱动的 worker 没有 TUI没有人可问 —— 那次 bash 调用**没有任何人
* 批准就执行了**。守卫形同虚设。
*
* ## 为什么用哈希而不是直接截断
*
* 直接截断会让两次不同的调用撞成同一个键(前缀相同后缀被切掉),
* 而这个键的全部意义是幂等:撞键意味着第二次询问被服务端当成重复请求丢掉。
* sha256 的碰撞概率可以忽略,且**同样的输入永远得到同样的输出** ——
* 这一点是必须的:插件重启后重放同一轮,必须算出同一个键。
*
* ## 为什么保留可读前缀
*
* 纯哈希在日志里没法看出是哪条会话。保留前缀让 `grep 会话id` 仍然有用。
* 前缀按**字节**截断并回退到字符边界 —— 键里可能有中文(邮件主题派生的键),
* 按字符数算会超字节上限,按字节硬切会切出半个字符。
*/
import { createHash } from 'node:crypto';
/** 服务端 relay_key 列宽(字节)。与 gateway 侧 160 保持一致。 */
export const RELAY_KEY_MAX_BYTES = 160;
/** `:sha256:` + 64 位 hex */
const HASH_SUFFIX_BYTES = 8 + 64;
/**
* UTF-8 字节数。
*
* @param {string} s
* @returns {number}
*/
export function byteLength(s) {
return Buffer.byteLength(String(s ?? ''), 'utf8');
}
/**
* 按字节截断,回退到最近的字符边界(不产生半个字符)。
*
* @param {string} s
* @param {number} maxBytes
* @returns {string}
*/
export function truncateToBytes(s, maxBytes) {
const str = String(s ?? '');
if (maxBytes <= 0) return '';
const buf = Buffer.from(str, 'utf8');
if (buf.length <= maxBytes) return str;
let end = maxBytes;
// UTF-8 续字节是 10xxxxxx。若第一个被丢掉的字节是续字节
// 说明切点落在字符中间 —— 往前退到该字符的首字节之前。
while (end > 0 && (buf[end] & 0xc0) === 0x80) end--;
return buf.subarray(0, end).toString('utf8');
}
/**
* 把 relay_key 收敛到服务端能接受的长度。
*
* 未超限时**原样返回** —— 这一点很重要:绝大多数键本来就合规,
* 改写它们会让插件升级前后算出不同的键,等于把已发出的询问变成新询问。
*
* @param {string} key 原始键
* @param {number} [limit] 上限字节数,默认 RELAY_KEY_MAX_BYTES
* @returns {string} 长度不超过 limit 的键
*/
export function clampRelayKey(key, limit = RELAY_KEY_MAX_BYTES) {
const raw = String(key ?? '');
if (byteLength(raw) <= limit) return raw;
const hash = createHash('sha256').update(raw, 'utf8').digest('hex');
const suffix = `:sha256:${hash}`;
// 上限小到装不下哈希时只留哈希(截断哈希仍然确定,只是碰撞面变大;
// 这条路径在真实配置下不会走到 —— 160 远大于 72
if (limit <= HASH_SUFFIX_BYTES) return truncateToBytes(hash, limit);
const prefix = truncateToBytes(raw, limit - HASH_SUFFIX_BYTES);
return `${prefix}${suffix}`;
}
/**
* 这次失败是不是「永远不会成功」。
*
* ## 为什么必须分类
*
* 插件在权限询问发送失败时有两条路:让位给平台本地决策,或当场 block。
* 原来除 409 之外一律当「暂时失败」让位 —— 而 400请求本身不合法
* 重试一万次也是 400。邮件驱动的会话**没有本地 UI**,让位等于让守卫消失:
* 生产实测一次 bash 就这样在无人批准的情况下执行了。
*
* ## 判据
*
* - 4xx除 408 / 429= 永久:请求本身有问题,重试不会变好
* - 408 / 429 = 暂时:超时与限流,等一会儿真的可能成功
* - 5xx = 暂时:服务端的问题
* - 无 status网络层错误、DNS、连接被拒= 暂时
*
* 401 归到永久:密钥无效要人去后台重新登记,不是等一等就好的事
* (本会话实测过一次 —— opencode 拿着已撤销的密钥重试了 18 小时)。
*
* @param {{status?: number}} err
* @returns {boolean} true = 永久失败,插件必须当场表态
*/
export function isPermanentFailure(err) {
const status = Number(err?.status);
if (!Number.isFinite(status) || status <= 0) return false; // 网络层错误 → 暂时
if (status === 408 || status === 429) return false; // 超时 / 限流 → 暂时
return status >= 400 && status < 500;
}
/**
* 网关把重复的 relay_key 判为**幂等命中**时的回包标识。
*
* 两个出口都会这么答(权限询问与代发邮件),且都是 **HTTP 200 且提前返回**
* 不建请求、不发邮件,**也永远不会有人来决策**。
*/
export const DUPLICATE_RELAY_STATUS = 'duplicate_relay';
/**
* 这个回包是不是「重复键,什么都没发生」。
*
* ## 为什么必须单独认它
*
* 它长得像成功200所以「发完就等决策」的实现会一直等下去。实测过的形态
* pi 的 worker 在 `post('/permission/request')` 之后无条件
* `await new Promise(resolve => pending.set(relayKey, resolve))`,而那个 resolve
* 只由 `permission_decision` 事件触发 —— 重复的键永远不会带来决策,
* 于是那封邮件**静默挂死**(模型干等,人以为在跑)。
*
* 什么时候会重复(都是**正常**的重试,不是故障):
*
* - 插件重启后重放同一轮(键是确定性的,这正是它的设计目的)
* - SDK / 上游重放同一个 tool call
* - 上一次询问已经被人决定过,而这一侧没收到那个决策(重启、断线)
*
* 对「发信」那一侧,重复就该当成功(幂等,这正是网关返回 200 的意思);
* 但对「等一个决定」那一侧,它与故障的后果完全一样:永远等不到。
* 所以两边的处置必须分开写,而不是共用一个「发成功了」的判定。
*
* @param {unknown} res 网关的响应体(不是 HTTP 响应对象)
* @returns {boolean}
*/
export function isDuplicateRelay(res) {
return Boolean(
res && typeof res === 'object' && !Array.isArray(res) && res.status === DUPLICATE_RELAY_STATUS
);
}