diff --git a/plugins/dsh-mail-bridge/lib/relay-key.d.ts b/plugins/dsh-mail-bridge/lib/relay-key.d.ts new file mode 100644 index 0000000..d1e3f48 --- /dev/null +++ b/plugins/dsh-mail-bridge/lib/relay-key.d.ts @@ -0,0 +1,20 @@ +/** 服务端 relay_key 列宽(字节)。 */ +export const RELAY_KEY_MAX_BYTES: number; + +/** UTF-8 字节数。 */ +export function byteLength(s: string | null | undefined): number; + +/** 按字节截断,回退到最近的字符边界(不产生半个字符)。 */ +export function truncateToBytes(s: string | null | undefined, maxBytes: number): string; + +/** + * 把 relay_key 收敛到服务端能接受的长度。 + * 未超限时原样返回;超限时保留可读前缀 + `:sha256:<原始键的完整哈希>`。 + */ +export function clampRelayKey(key: string | null | undefined, limit?: number): string; + +/** + * 这次失败是不是「永远不会成功」。 + * 4xx(除 408 / 429)= 永久;408 / 429 / 5xx / 无 status = 暂时。 + */ +export function isPermanentFailure(err: { status?: number | string } | null | undefined): boolean; diff --git a/plugins/dsh-mail-bridge/lib/relay-key.js b/plugins/dsh-mail-bridge/lib/relay-key.js new file mode 100644 index 0000000..73b4237 --- /dev/null +++ b/plugins/dsh-mail-bridge/lib/relay-key.js @@ -0,0 +1,127 @@ +/** + * 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; +} diff --git a/plugins/dsh-mail-bridge/test/relay-key.test.mjs b/plugins/dsh-mail-bridge/test/relay-key.test.mjs new file mode 100644 index 0000000..ec6e545 --- /dev/null +++ b/plugins/dsh-mail-bridge/test/relay-key.test.mjs @@ -0,0 +1,194 @@ +/** + * lib/relay-key.js 的测试 —— 四个平台逐字节共用。 + * + * 事故背景(生产实测):pi 会话里 bash 的 relay_key 突然超过服务端 160 字节 + * 列宽,返回 400。真实会话文件里 toolCallId 有两种形态: + * toolu_bdrk_01F6roEBHa8nic1mYiyLgNWK 35 字节 + * toolu_bdrk_01FsWUWhEs4arnEWo44gqzLC~sig1:CAISoQIK… 437 ~ 13601 字节 + * 启用 extended thinking 时 Bedrock 把思考签名拼进了 toolCallId。 + * + * 更严重的是那次 400 被归入「暂时失败 → 让位给本地决策」,而邮件驱动的 + * worker 没有 TUI —— 那次 bash 没有任何人批准就执行了。 + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; + +import { + RELAY_KEY_MAX_BYTES, + byteLength, + truncateToBytes, + clampRelayKey, + isPermanentFailure, +} from '../lib/relay-key.js'; + +// ─── byteLength ─── + +test('byteLength 算的是 UTF-8 字节而不是字符数', () => { + assert.equal(byteLength('abc'), 3); + assert.equal(byteLength('中文'), 6); // 每个 3 字节 + assert.equal(byteLength(''), 0); + assert.equal(byteLength(null), 0); + assert.equal(byteLength(undefined), 0); +}); + +// ─── truncateToBytes ─── + +test('未超限时原样返回', () => { + assert.equal(truncateToBytes('abcdef', 10), 'abcdef'); + assert.equal(truncateToBytes('abcdef', 6), 'abcdef'); +}); + +test('ASCII 按字节精确截断', () => { + assert.equal(truncateToBytes('abcdef', 3), 'abc'); +}); + +test('不切出半个多字节字符', () => { + // '中文' = 6 字节。上限 4 时不能切出 '中' + 半个 '文' + const out = truncateToBytes('中文', 4); + assert.equal(out, '中'); + assert.equal(byteLength(out) <= 4, true); + // 结果必须能无损往返(有半个字符时会变成 U+FFFD) + assert.equal(out.includes('\uFFFD'), false); +}); + +test('截断结果的字节数永不超上限(扫一遍长度)', () => { + const s = '会话abc标识def中文gh'; + for (let limit = 0; limit <= byteLength(s) + 2; limit++) { + const out = truncateToBytes(s, limit); + assert.equal(byteLength(out) <= limit, true, `limit=${limit} 时超了`); + assert.equal(out.includes('\uFFFD'), false, `limit=${limit} 时切出了半个字符`); + } +}); + +test('上限 0 或负数返回空串', () => { + assert.equal(truncateToBytes('abc', 0), ''); + assert.equal(truncateToBytes('abc', -5), ''); +}); + +// ─── clampRelayKey ─── + +test('正常长度的键原样返回(不能改写已合规的键)', () => { + // 生产上真实的 pi 键:36 字节会话 id + ':' + 35 字节 toolCallId = 72 + const key = '01a05a5e-8abb-7bf4-bc87-47eadae619a8:toolu_bdrk_01CJevE1rw69DyVWSJv3n3eA'; + assert.equal(byteLength(key) <= RELAY_KEY_MAX_BYTES, true); + assert.equal(clampRelayKey(key), key); +}); + +test('恰好等于上限时原样返回(边界不能差一)', () => { + const key = 'k'.repeat(RELAY_KEY_MAX_BYTES); + assert.equal(clampRelayKey(key), key); +}); + +test('超一个字节就收敛', () => { + const key = 'k'.repeat(RELAY_KEY_MAX_BYTES + 1); + const out = clampRelayKey(key); + assert.notEqual(out, key); + assert.equal(byteLength(out) <= RELAY_KEY_MAX_BYTES, true); +}); + +test('收敛后一定不超上限(用真实的带签名 toolCallId 长度)', () => { + // 生产实测 437 ~ 13601 字节都出现过 + for (const n of [437, 1000, 5493, 13601]) { + const key = `01a05a5e-8abb-7bf4-bc87-47eadae619a8:toolu_bdrk_01X~sig1:${'A'.repeat(n)}`; + const out = clampRelayKey(key); + assert.equal(byteLength(out) <= RELAY_KEY_MAX_BYTES, true, `n=${n} 时超了`); + } +}); + +test('同一输入永远得到同一输出(幂等键的根本要求)', () => { + const key = `sess:${'x'.repeat(500)}`; + assert.equal(clampRelayKey(key), clampRelayKey(key)); +}); + +test('不同输入不撞键 —— 这正是不能直接截断的理由', () => { + // 两个键前 160 字节完全相同,只有尾部不同。 + // 直接截断会让它们变成同一个键,第二次询问被服务端当重复请求丢掉。 + const common = 'a'.repeat(300); + const k1 = `${common}:call-1`; + const k2 = `${common}:call-2`; + assert.notEqual(clampRelayKey(k1), clampRelayKey(k2)); +}); + +test('收敛结果保留可读前缀(日志里还能 grep 出会话)', () => { + const sid = '01a05a5e-8abb-7bf4-bc87-47eadae619a8'; + const out = clampRelayKey(`${sid}:toolu_bdrk_01X~sig1:${'A'.repeat(900)}`); + assert.equal(out.startsWith(sid), true); + assert.match(out, /:sha256:[0-9a-f]{64}$/); +}); + +test('哈希是原始键的完整 sha256(不是截断后的)', () => { + const key = `sess:${'y'.repeat(400)}`; + const expect = createHash('sha256').update(key, 'utf8').digest('hex'); + assert.equal(clampRelayKey(key).endsWith(`:sha256:${expect}`), true); +}); + +test('含中文的超长键不切出半个字符', () => { + const key = `会话标识:${'中'.repeat(300)}`; + const out = clampRelayKey(key); + assert.equal(byteLength(out) <= RELAY_KEY_MAX_BYTES, true); + assert.equal(out.includes('\uFFFD'), false); +}); + +test('上限小到装不下哈希时退化为截断哈希(仍然确定)', () => { + const key = 'z'.repeat(500); + const out = clampRelayKey(key, 20); + assert.equal(byteLength(out) <= 20, true); + assert.equal(out, clampRelayKey(key, 20)); +}); + +test('空键与 null 不炸', () => { + assert.equal(clampRelayKey(''), ''); + assert.equal(clampRelayKey(null), ''); + assert.equal(clampRelayKey(undefined), ''); +}); + +// ─── isPermanentFailure ─── + +test('400 是永久失败 —— 事故的核心(原来被当暂时失败让位)', () => { + assert.equal(isPermanentFailure({ status: 400 }), true); +}); + +test('409 是永久失败(这条链上没有人类,永远不会有人点头)', () => { + assert.equal(isPermanentFailure({ status: 409 }), true); +}); + +test('401 是永久失败:密钥无效要人去后台登记,不是等一等就好', () => { + // 本会话实测:opencode 拿着已撤销的密钥重试了 18 小时,2690 次 401 + assert.equal(isPermanentFailure({ status: 401 }), true); +}); + +test('403 / 404 / 422 都是永久失败', () => { + for (const s of [403, 404, 422]) { + assert.equal(isPermanentFailure({ status: s }), true, `${s} 应当是永久`); + } +}); + +test('408 与 429 是暂时失败(超时与限流等一会儿真的可能成功)', () => { + assert.equal(isPermanentFailure({ status: 408 }), false); + assert.equal(isPermanentFailure({ status: 429 }), false); +}); + +test('5xx 是暂时失败(服务端的问题)', () => { + for (const s of [500, 502, 503, 504]) { + assert.equal(isPermanentFailure({ status: s }), false, `${s} 应当是暂时`); + } +}); + +test('没有 status 的错误按暂时处理(网络层:DNS / 连接被拒)', () => { + assert.equal(isPermanentFailure(new Error('fetch failed')), false); + assert.equal(isPermanentFailure({}), false); + assert.equal(isPermanentFailure(null), false); + assert.equal(isPermanentFailure(undefined), false); +}); + +test('status 是字符串时也能判(HTTP 客户端可能挂上字符串)', () => { + assert.equal(isPermanentFailure({ status: '400' }), true); + assert.equal(isPermanentFailure({ status: '503' }), false); +}); + +test('2xx / 3xx 不算永久失败(本不该走到这里,但不能误判成永久)', () => { + assert.equal(isPermanentFailure({ status: 200 }), false); + assert.equal(isPermanentFailure({ status: 302 }), false); +}); diff --git a/plugins/homeagent-mail-bridge/relay_key.go b/plugins/homeagent-mail-bridge/relay_key.go new file mode 100644 index 0000000..5eec983 --- /dev/null +++ b/plugins/homeagent-mail-bridge/relay_key.go @@ -0,0 +1,111 @@ +package main + +// relay_key 长度收敛 —— 三个 Node 插件里 `lib/relay-key.js` 的 Go 对应物。 +// +// **不能共用那个文件**(homeagent 是 Go 子进程插件),但语义必须一致 —— +// 因此这里把那边的注释与判据原样搬过来,`relay_key_test.go` 逐条钉住。 +// +// # 为什么需要它 +// +// 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。 +// 同一条会话里两种形态混着出现,于是权限询问随机成功随机失败。 +// +// homeagent 侧的键目前都是自己拼的("homeagent:" + mailID),长度可控。 +// 但 mailID 来自 Gateway、replyTo 来自邮件头,都不是本地常量 —— 而 +// 「这个值一直很短」正是 pi 侧那次事故的前提假设。 + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" +) + +// RelayKeyMaxBytes 服务端 relay_key 列宽(字节)。与 gateway 侧 160 保持一致。 +const RelayKeyMaxBytes = 160 + +// hashSuffixBytes = len(":sha256:") + 64 位 hex +const hashSuffixBytes = 8 + 64 + +// truncateToBytes 按字节截断,回退到最近的字符边界(不产生半个字符)。 +// +// Go 的 string 是 UTF-8 字节序列,直接 s[:n] 会切出无效字节。 +// 键里可能有中文(邮件主题派生的键),按 rune 数算会超字节上限, +// 按字节硬切会切出半个字符。 +func truncateToBytes(s string, maxBytes int) string { + if maxBytes <= 0 { + return "" + } + if len(s) <= maxBytes { + return s + } + end := maxBytes + // UTF-8 续字节是 10xxxxxx。若切点落在字符中间,往前退到该字符首字节之前。 + for end > 0 && s[end]&0xc0 == 0x80 { + end-- + } + return s[:end] +} + +// ClampRelayKey 把 relay_key 收敛到服务端能接受的长度。 +// +// 未超限时**原样返回** —— 这一点很重要:绝大多数键本来就合规, +// 改写它们会让插件升级前后算出不同的键,等于把已发出的询问变成新询问。 +// +// 超限时用 sha256 而不是直接截断:直接截断会让两次不同的调用撞成同一个键 +// (前缀相同后缀被切掉),而这个键的全部意义是幂等 —— 撞键意味着第二次 +// 询问被服务端当成重复请求丢掉。保留可读前缀让日志里 grep 会话 id 仍然有用。 +func ClampRelayKey(key string) string { + return clampRelayKeyTo(key, RelayKeyMaxBytes) +} + +func clampRelayKeyTo(key string, limit int) string { + if len(key) <= limit { + return key + } + sum := sha256.Sum256([]byte(key)) + hash := hex.EncodeToString(sum[:]) + // 上限小到装不下哈希时只留哈希(截断哈希仍然确定,只是碰撞面变大; + // 这条路径在真实配置下不会走到 —— 160 远大于 72)。 + if limit <= hashSuffixBytes { + return truncateToBytes(hash, limit) + } + return fmt.Sprintf("%s:sha256:%s", truncateToBytes(key, limit-hashSuffixBytes), hash) +} + +// IsPermanentFailure 这次失败是不是「永远不会成功」。 +// +// # 为什么必须分类 +// +// 插件在请求失败时有两条路:重试/让位,或当场表态。原来除 409 之外 +// 一律当「暂时失败」—— 而 400(请求本身不合法)重试一万次也是 400。 +// 邮件驱动的会话没有本地 UI,让位等于让守卫消失。 +// +// # 判据 +// +// - 4xx(除 408 / 429)= 永久:请求本身有问题,重试不会变好 +// - 408 / 429 = 暂时:超时与限流,等一会儿真的可能成功 +// - 5xx = 暂时:服务端的问题 +// - status <= 0(网络层错误、DNS、连接被拒)= 暂时 +// +// 401 归到永久:密钥无效要人去后台重新登记,不是等一等就好的事 +// (实测过一次 —— opencode 拿着已撤销的密钥重试了 18 小时,2690 次 401)。 +func IsPermanentFailure(status int) bool { + if status <= 0 { + return false + } + if status == http.StatusRequestTimeout || status == http.StatusTooManyRequests { + return false + } + return status >= 400 && status < 500 +} diff --git a/plugins/homeagent-mail-bridge/relay_key_test.go b/plugins/homeagent-mail-bridge/relay_key_test.go new file mode 100644 index 0000000..310e0c4 --- /dev/null +++ b/plugins/homeagent-mail-bridge/relay_key_test.go @@ -0,0 +1,244 @@ +package main + +// relay_key.go 的测试 —— 逐条对齐 Node 侧 test/relay-key.test.mjs。 +// +// 事故背景(生产实测):pi 会话里 bash 的 relay_key 突然超过服务端 160 字节 +// 列宽,返回 400。真实会话文件里 toolCallId 有两种形态: +// +// toolu_bdrk_01F6roEBHa8nic1mYiyLgNWK 35 字节 +// toolu_bdrk_01FsWUWhEs4arnEWo44gqzLC~sig1:CAISoQIK… 437 ~ 13601 字节 +// +// 启用 extended thinking 时 Bedrock 把思考签名拼进了 toolCallId。 +// 更严重的是那次 400 被归入「暂时失败 → 让位给本地决策」,而邮件驱动的 +// worker 没有 TUI —— 那次 bash 没有任何人批准就执行了。 + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + "testing" + "unicode/utf8" +) + +// ─── truncateToBytes ─── + +func TestTruncateToBytes_UnderLimit(t *testing.T) { + if got := truncateToBytes("abcdef", 10); got != "abcdef" { + t.Fatalf("未超限应原样返回,得到 %q", got) + } + if got := truncateToBytes("abcdef", 6); got != "abcdef" { + t.Fatalf("恰好等于上限应原样返回,得到 %q", got) + } +} + +func TestTruncateToBytes_ASCII(t *testing.T) { + if got := truncateToBytes("abcdef", 3); got != "abc" { + t.Fatalf("want abc, got %q", got) + } +} + +func TestTruncateToBytes_NoHalfRune(t *testing.T) { + // "中文" = 6 字节。上限 4 时不能切出 "中" + 半个 "文" + got := truncateToBytes("中文", 4) + if got != "中" { + t.Fatalf("want 中, got %q", got) + } + if !utf8.ValidString(got) { + t.Fatal("截断结果不是合法 UTF-8") + } +} + +func TestTruncateToBytes_AllLimitsStayValid(t *testing.T) { + s := "会话abc标识def中文gh" + for limit := 0; limit <= len(s)+2; limit++ { + got := truncateToBytes(s, limit) + if len(got) > limit { + t.Fatalf("limit=%d 时超了:%d 字节", limit, len(got)) + } + if !utf8.ValidString(got) { + t.Fatalf("limit=%d 时切出了半个字符", limit) + } + } +} + +func TestTruncateToBytes_ZeroOrNegative(t *testing.T) { + if got := truncateToBytes("abc", 0); got != "" { + t.Fatalf("上限 0 应返回空串,得到 %q", got) + } + if got := truncateToBytes("abc", -5); got != "" { + t.Fatalf("负上限应返回空串,得到 %q", got) + } +} + +// ─── ClampRelayKey ─── + +func TestClampRelayKey_NormalKeyUnchanged(t *testing.T) { + // homeagent 侧真实的键形状 + key := "homeagent:53e4c9ea-878c-4479-b2f7-58b4be36cf96" + if len(key) > RelayKeyMaxBytes { + t.Fatalf("这个键本来就该合规,长度 %d", len(key)) + } + if got := ClampRelayKey(key); got != key { + t.Fatalf("合规的键必须原样返回(否则升级前后算出不同键),得到 %q", got) + } +} + +func TestClampRelayKey_ExactlyAtLimit(t *testing.T) { + key := strings.Repeat("k", RelayKeyMaxBytes) + if got := ClampRelayKey(key); got != key { + t.Fatal("恰好等于上限时应原样返回(边界不能差一)") + } +} + +func TestClampRelayKey_OneByteOver(t *testing.T) { + key := strings.Repeat("k", RelayKeyMaxBytes+1) + got := ClampRelayKey(key) + if got == key { + t.Fatal("超一个字节就该收敛") + } + if len(got) > RelayKeyMaxBytes { + t.Fatalf("收敛后仍然超限:%d 字节", len(got)) + } +} + +func TestClampRelayKey_RealWorldLengths(t *testing.T) { + // 生产实测 437 ~ 13601 字节都出现过 + for _, n := range []int{437, 1000, 5493, 13601} { + key := "homeagent:toolu_bdrk_01X~sig1:" + strings.Repeat("A", n) + got := ClampRelayKey(key) + if len(got) > RelayKeyMaxBytes { + t.Fatalf("n=%d 时超了:%d 字节", n, len(got)) + } + } +} + +func TestClampRelayKey_Deterministic(t *testing.T) { + key := "sess:" + strings.Repeat("x", 500) + if ClampRelayKey(key) != ClampRelayKey(key) { + t.Fatal("同一输入必须得到同一输出(幂等键的根本要求)") + } +} + +// 这正是不能直接截断的理由:两个键前 160 字节完全相同, +// 直接截断会让它们变成同一个键,第二次询问被服务端当重复请求丢掉。 +func TestClampRelayKey_NoCollisionOnSharedPrefix(t *testing.T) { + common := strings.Repeat("a", 300) + k1 := common + ":call-1" + k2 := common + ":call-2" + if ClampRelayKey(k1) == ClampRelayKey(k2) { + t.Fatal("前缀相同尾部不同的两个键不能撞成一个") + } +} + +func TestClampRelayKey_KeepsReadablePrefix(t *testing.T) { + prefix := "homeagent:53e4c9ea-878c-4479-b2f7-58b4be36cf96" + got := ClampRelayKey(prefix + ":" + strings.Repeat("A", 900)) + if !strings.HasPrefix(got, prefix) { + t.Fatalf("收敛结果应保留可读前缀(日志里还能 grep),得到 %q", got) + } + if !strings.Contains(got, ":sha256:") { + t.Fatalf("收敛结果应带 sha256 后缀,得到 %q", got) + } +} + +func TestClampRelayKey_HashIsOfOriginalKey(t *testing.T) { + key := "sess:" + strings.Repeat("y", 400) + sum := sha256.Sum256([]byte(key)) + want := hex.EncodeToString(sum[:]) + if !strings.HasSuffix(ClampRelayKey(key), ":sha256:"+want) { + t.Fatal("哈希必须是原始键的完整 sha256,不是截断后的") + } +} + +func TestClampRelayKey_CJKNoHalfRune(t *testing.T) { + key := "会话标识:" + strings.Repeat("中", 300) + got := ClampRelayKey(key) + if len(got) > RelayKeyMaxBytes { + t.Fatalf("超限:%d 字节", len(got)) + } + if !utf8.ValidString(got) { + t.Fatal("切出了半个字符") + } +} + +func TestClampRelayKey_TinyLimitFallsBackToHash(t *testing.T) { + key := strings.Repeat("z", 500) + got := clampRelayKeyTo(key, 20) + if len(got) > 20 { + t.Fatalf("超限:%d 字节", len(got)) + } + if got != clampRelayKeyTo(key, 20) { + t.Fatal("退化路径也必须确定") + } +} + +func TestClampRelayKey_Empty(t *testing.T) { + if got := ClampRelayKey(""); got != "" { + t.Fatalf("空键应返回空串,得到 %q", got) + } +} + +// ─── IsPermanentFailure ─── + +func TestIsPermanentFailure_400(t *testing.T) { + // 事故的核心:原来 400 被当暂时失败让位,那条 bash 无人批准就执行了 + if !IsPermanentFailure(400) { + t.Fatal("400 必须是永久失败") + } +} + +func TestIsPermanentFailure_409(t *testing.T) { + // 这条链上没有人类,永远不会有人点头 + if !IsPermanentFailure(409) { + t.Fatal("409 必须是永久失败") + } +} + +func TestIsPermanentFailure_401(t *testing.T) { + // 实测:opencode 拿着已撤销的密钥重试了 18 小时,2690 次 401 + if !IsPermanentFailure(401) { + t.Fatal("401 必须是永久失败:密钥无效要人去后台登记") + } +} + +func TestIsPermanentFailure_Other4xx(t *testing.T) { + for _, s := range []int{403, 404, 422} { + if !IsPermanentFailure(s) { + t.Fatalf("%d 应当是永久失败", s) + } + } +} + +func TestIsPermanentFailure_TimeoutAndRateLimit(t *testing.T) { + if IsPermanentFailure(408) { + t.Fatal("408 是暂时失败(超时,等一会儿可能成功)") + } + if IsPermanentFailure(429) { + t.Fatal("429 是暂时失败(限流,等一会儿可能成功)") + } +} + +func TestIsPermanentFailure_5xx(t *testing.T) { + for _, s := range []int{500, 502, 503, 504} { + if IsPermanentFailure(s) { + t.Fatalf("%d 是暂时失败(服务端的问题)", s) + } + } +} + +func TestIsPermanentFailure_NoStatus(t *testing.T) { + // 网络层错误:DNS 解析失败、连接被拒,都没有 HTTP 状态码 + if IsPermanentFailure(0) { + t.Fatal("无 status 应按暂时处理") + } + if IsPermanentFailure(-1) { + t.Fatal("负 status 应按暂时处理") + } +} + +func TestIsPermanentFailure_Success(t *testing.T) { + // 本不该走到这里,但不能误判成永久 + if IsPermanentFailure(200) || IsPermanentFailure(302) { + t.Fatal("2xx / 3xx 不算永久失败") + } +} diff --git a/plugins/opencode-mail-bridge/lib/relay-key.js b/plugins/opencode-mail-bridge/lib/relay-key.js new file mode 100644 index 0000000..73b4237 --- /dev/null +++ b/plugins/opencode-mail-bridge/lib/relay-key.js @@ -0,0 +1,127 @@ +/** + * 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; +} diff --git a/plugins/opencode-mail-bridge/test/relay-key.test.mjs b/plugins/opencode-mail-bridge/test/relay-key.test.mjs new file mode 100644 index 0000000..ec6e545 --- /dev/null +++ b/plugins/opencode-mail-bridge/test/relay-key.test.mjs @@ -0,0 +1,194 @@ +/** + * lib/relay-key.js 的测试 —— 四个平台逐字节共用。 + * + * 事故背景(生产实测):pi 会话里 bash 的 relay_key 突然超过服务端 160 字节 + * 列宽,返回 400。真实会话文件里 toolCallId 有两种形态: + * toolu_bdrk_01F6roEBHa8nic1mYiyLgNWK 35 字节 + * toolu_bdrk_01FsWUWhEs4arnEWo44gqzLC~sig1:CAISoQIK… 437 ~ 13601 字节 + * 启用 extended thinking 时 Bedrock 把思考签名拼进了 toolCallId。 + * + * 更严重的是那次 400 被归入「暂时失败 → 让位给本地决策」,而邮件驱动的 + * worker 没有 TUI —— 那次 bash 没有任何人批准就执行了。 + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; + +import { + RELAY_KEY_MAX_BYTES, + byteLength, + truncateToBytes, + clampRelayKey, + isPermanentFailure, +} from '../lib/relay-key.js'; + +// ─── byteLength ─── + +test('byteLength 算的是 UTF-8 字节而不是字符数', () => { + assert.equal(byteLength('abc'), 3); + assert.equal(byteLength('中文'), 6); // 每个 3 字节 + assert.equal(byteLength(''), 0); + assert.equal(byteLength(null), 0); + assert.equal(byteLength(undefined), 0); +}); + +// ─── truncateToBytes ─── + +test('未超限时原样返回', () => { + assert.equal(truncateToBytes('abcdef', 10), 'abcdef'); + assert.equal(truncateToBytes('abcdef', 6), 'abcdef'); +}); + +test('ASCII 按字节精确截断', () => { + assert.equal(truncateToBytes('abcdef', 3), 'abc'); +}); + +test('不切出半个多字节字符', () => { + // '中文' = 6 字节。上限 4 时不能切出 '中' + 半个 '文' + const out = truncateToBytes('中文', 4); + assert.equal(out, '中'); + assert.equal(byteLength(out) <= 4, true); + // 结果必须能无损往返(有半个字符时会变成 U+FFFD) + assert.equal(out.includes('\uFFFD'), false); +}); + +test('截断结果的字节数永不超上限(扫一遍长度)', () => { + const s = '会话abc标识def中文gh'; + for (let limit = 0; limit <= byteLength(s) + 2; limit++) { + const out = truncateToBytes(s, limit); + assert.equal(byteLength(out) <= limit, true, `limit=${limit} 时超了`); + assert.equal(out.includes('\uFFFD'), false, `limit=${limit} 时切出了半个字符`); + } +}); + +test('上限 0 或负数返回空串', () => { + assert.equal(truncateToBytes('abc', 0), ''); + assert.equal(truncateToBytes('abc', -5), ''); +}); + +// ─── clampRelayKey ─── + +test('正常长度的键原样返回(不能改写已合规的键)', () => { + // 生产上真实的 pi 键:36 字节会话 id + ':' + 35 字节 toolCallId = 72 + const key = '01a05a5e-8abb-7bf4-bc87-47eadae619a8:toolu_bdrk_01CJevE1rw69DyVWSJv3n3eA'; + assert.equal(byteLength(key) <= RELAY_KEY_MAX_BYTES, true); + assert.equal(clampRelayKey(key), key); +}); + +test('恰好等于上限时原样返回(边界不能差一)', () => { + const key = 'k'.repeat(RELAY_KEY_MAX_BYTES); + assert.equal(clampRelayKey(key), key); +}); + +test('超一个字节就收敛', () => { + const key = 'k'.repeat(RELAY_KEY_MAX_BYTES + 1); + const out = clampRelayKey(key); + assert.notEqual(out, key); + assert.equal(byteLength(out) <= RELAY_KEY_MAX_BYTES, true); +}); + +test('收敛后一定不超上限(用真实的带签名 toolCallId 长度)', () => { + // 生产实测 437 ~ 13601 字节都出现过 + for (const n of [437, 1000, 5493, 13601]) { + const key = `01a05a5e-8abb-7bf4-bc87-47eadae619a8:toolu_bdrk_01X~sig1:${'A'.repeat(n)}`; + const out = clampRelayKey(key); + assert.equal(byteLength(out) <= RELAY_KEY_MAX_BYTES, true, `n=${n} 时超了`); + } +}); + +test('同一输入永远得到同一输出(幂等键的根本要求)', () => { + const key = `sess:${'x'.repeat(500)}`; + assert.equal(clampRelayKey(key), clampRelayKey(key)); +}); + +test('不同输入不撞键 —— 这正是不能直接截断的理由', () => { + // 两个键前 160 字节完全相同,只有尾部不同。 + // 直接截断会让它们变成同一个键,第二次询问被服务端当重复请求丢掉。 + const common = 'a'.repeat(300); + const k1 = `${common}:call-1`; + const k2 = `${common}:call-2`; + assert.notEqual(clampRelayKey(k1), clampRelayKey(k2)); +}); + +test('收敛结果保留可读前缀(日志里还能 grep 出会话)', () => { + const sid = '01a05a5e-8abb-7bf4-bc87-47eadae619a8'; + const out = clampRelayKey(`${sid}:toolu_bdrk_01X~sig1:${'A'.repeat(900)}`); + assert.equal(out.startsWith(sid), true); + assert.match(out, /:sha256:[0-9a-f]{64}$/); +}); + +test('哈希是原始键的完整 sha256(不是截断后的)', () => { + const key = `sess:${'y'.repeat(400)}`; + const expect = createHash('sha256').update(key, 'utf8').digest('hex'); + assert.equal(clampRelayKey(key).endsWith(`:sha256:${expect}`), true); +}); + +test('含中文的超长键不切出半个字符', () => { + const key = `会话标识:${'中'.repeat(300)}`; + const out = clampRelayKey(key); + assert.equal(byteLength(out) <= RELAY_KEY_MAX_BYTES, true); + assert.equal(out.includes('\uFFFD'), false); +}); + +test('上限小到装不下哈希时退化为截断哈希(仍然确定)', () => { + const key = 'z'.repeat(500); + const out = clampRelayKey(key, 20); + assert.equal(byteLength(out) <= 20, true); + assert.equal(out, clampRelayKey(key, 20)); +}); + +test('空键与 null 不炸', () => { + assert.equal(clampRelayKey(''), ''); + assert.equal(clampRelayKey(null), ''); + assert.equal(clampRelayKey(undefined), ''); +}); + +// ─── isPermanentFailure ─── + +test('400 是永久失败 —— 事故的核心(原来被当暂时失败让位)', () => { + assert.equal(isPermanentFailure({ status: 400 }), true); +}); + +test('409 是永久失败(这条链上没有人类,永远不会有人点头)', () => { + assert.equal(isPermanentFailure({ status: 409 }), true); +}); + +test('401 是永久失败:密钥无效要人去后台登记,不是等一等就好', () => { + // 本会话实测:opencode 拿着已撤销的密钥重试了 18 小时,2690 次 401 + assert.equal(isPermanentFailure({ status: 401 }), true); +}); + +test('403 / 404 / 422 都是永久失败', () => { + for (const s of [403, 404, 422]) { + assert.equal(isPermanentFailure({ status: s }), true, `${s} 应当是永久`); + } +}); + +test('408 与 429 是暂时失败(超时与限流等一会儿真的可能成功)', () => { + assert.equal(isPermanentFailure({ status: 408 }), false); + assert.equal(isPermanentFailure({ status: 429 }), false); +}); + +test('5xx 是暂时失败(服务端的问题)', () => { + for (const s of [500, 502, 503, 504]) { + assert.equal(isPermanentFailure({ status: s }), false, `${s} 应当是暂时`); + } +}); + +test('没有 status 的错误按暂时处理(网络层:DNS / 连接被拒)', () => { + assert.equal(isPermanentFailure(new Error('fetch failed')), false); + assert.equal(isPermanentFailure({}), false); + assert.equal(isPermanentFailure(null), false); + assert.equal(isPermanentFailure(undefined), false); +}); + +test('status 是字符串时也能判(HTTP 客户端可能挂上字符串)', () => { + assert.equal(isPermanentFailure({ status: '400' }), true); + assert.equal(isPermanentFailure({ status: '503' }), false); +}); + +test('2xx / 3xx 不算永久失败(本不该走到这里,但不能误判成永久)', () => { + assert.equal(isPermanentFailure({ status: 200 }), false); + assert.equal(isPermanentFailure({ status: 302 }), false); +}); diff --git a/plugins/pi-mail-bridge/lib/relay-key.js b/plugins/pi-mail-bridge/lib/relay-key.js new file mode 100644 index 0000000..73b4237 --- /dev/null +++ b/plugins/pi-mail-bridge/lib/relay-key.js @@ -0,0 +1,127 @@ +/** + * 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; +} diff --git a/plugins/pi-mail-bridge/test/relay-key.test.mjs b/plugins/pi-mail-bridge/test/relay-key.test.mjs new file mode 100644 index 0000000..ec6e545 --- /dev/null +++ b/plugins/pi-mail-bridge/test/relay-key.test.mjs @@ -0,0 +1,194 @@ +/** + * lib/relay-key.js 的测试 —— 四个平台逐字节共用。 + * + * 事故背景(生产实测):pi 会话里 bash 的 relay_key 突然超过服务端 160 字节 + * 列宽,返回 400。真实会话文件里 toolCallId 有两种形态: + * toolu_bdrk_01F6roEBHa8nic1mYiyLgNWK 35 字节 + * toolu_bdrk_01FsWUWhEs4arnEWo44gqzLC~sig1:CAISoQIK… 437 ~ 13601 字节 + * 启用 extended thinking 时 Bedrock 把思考签名拼进了 toolCallId。 + * + * 更严重的是那次 400 被归入「暂时失败 → 让位给本地决策」,而邮件驱动的 + * worker 没有 TUI —— 那次 bash 没有任何人批准就执行了。 + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; + +import { + RELAY_KEY_MAX_BYTES, + byteLength, + truncateToBytes, + clampRelayKey, + isPermanentFailure, +} from '../lib/relay-key.js'; + +// ─── byteLength ─── + +test('byteLength 算的是 UTF-8 字节而不是字符数', () => { + assert.equal(byteLength('abc'), 3); + assert.equal(byteLength('中文'), 6); // 每个 3 字节 + assert.equal(byteLength(''), 0); + assert.equal(byteLength(null), 0); + assert.equal(byteLength(undefined), 0); +}); + +// ─── truncateToBytes ─── + +test('未超限时原样返回', () => { + assert.equal(truncateToBytes('abcdef', 10), 'abcdef'); + assert.equal(truncateToBytes('abcdef', 6), 'abcdef'); +}); + +test('ASCII 按字节精确截断', () => { + assert.equal(truncateToBytes('abcdef', 3), 'abc'); +}); + +test('不切出半个多字节字符', () => { + // '中文' = 6 字节。上限 4 时不能切出 '中' + 半个 '文' + const out = truncateToBytes('中文', 4); + assert.equal(out, '中'); + assert.equal(byteLength(out) <= 4, true); + // 结果必须能无损往返(有半个字符时会变成 U+FFFD) + assert.equal(out.includes('\uFFFD'), false); +}); + +test('截断结果的字节数永不超上限(扫一遍长度)', () => { + const s = '会话abc标识def中文gh'; + for (let limit = 0; limit <= byteLength(s) + 2; limit++) { + const out = truncateToBytes(s, limit); + assert.equal(byteLength(out) <= limit, true, `limit=${limit} 时超了`); + assert.equal(out.includes('\uFFFD'), false, `limit=${limit} 时切出了半个字符`); + } +}); + +test('上限 0 或负数返回空串', () => { + assert.equal(truncateToBytes('abc', 0), ''); + assert.equal(truncateToBytes('abc', -5), ''); +}); + +// ─── clampRelayKey ─── + +test('正常长度的键原样返回(不能改写已合规的键)', () => { + // 生产上真实的 pi 键:36 字节会话 id + ':' + 35 字节 toolCallId = 72 + const key = '01a05a5e-8abb-7bf4-bc87-47eadae619a8:toolu_bdrk_01CJevE1rw69DyVWSJv3n3eA'; + assert.equal(byteLength(key) <= RELAY_KEY_MAX_BYTES, true); + assert.equal(clampRelayKey(key), key); +}); + +test('恰好等于上限时原样返回(边界不能差一)', () => { + const key = 'k'.repeat(RELAY_KEY_MAX_BYTES); + assert.equal(clampRelayKey(key), key); +}); + +test('超一个字节就收敛', () => { + const key = 'k'.repeat(RELAY_KEY_MAX_BYTES + 1); + const out = clampRelayKey(key); + assert.notEqual(out, key); + assert.equal(byteLength(out) <= RELAY_KEY_MAX_BYTES, true); +}); + +test('收敛后一定不超上限(用真实的带签名 toolCallId 长度)', () => { + // 生产实测 437 ~ 13601 字节都出现过 + for (const n of [437, 1000, 5493, 13601]) { + const key = `01a05a5e-8abb-7bf4-bc87-47eadae619a8:toolu_bdrk_01X~sig1:${'A'.repeat(n)}`; + const out = clampRelayKey(key); + assert.equal(byteLength(out) <= RELAY_KEY_MAX_BYTES, true, `n=${n} 时超了`); + } +}); + +test('同一输入永远得到同一输出(幂等键的根本要求)', () => { + const key = `sess:${'x'.repeat(500)}`; + assert.equal(clampRelayKey(key), clampRelayKey(key)); +}); + +test('不同输入不撞键 —— 这正是不能直接截断的理由', () => { + // 两个键前 160 字节完全相同,只有尾部不同。 + // 直接截断会让它们变成同一个键,第二次询问被服务端当重复请求丢掉。 + const common = 'a'.repeat(300); + const k1 = `${common}:call-1`; + const k2 = `${common}:call-2`; + assert.notEqual(clampRelayKey(k1), clampRelayKey(k2)); +}); + +test('收敛结果保留可读前缀(日志里还能 grep 出会话)', () => { + const sid = '01a05a5e-8abb-7bf4-bc87-47eadae619a8'; + const out = clampRelayKey(`${sid}:toolu_bdrk_01X~sig1:${'A'.repeat(900)}`); + assert.equal(out.startsWith(sid), true); + assert.match(out, /:sha256:[0-9a-f]{64}$/); +}); + +test('哈希是原始键的完整 sha256(不是截断后的)', () => { + const key = `sess:${'y'.repeat(400)}`; + const expect = createHash('sha256').update(key, 'utf8').digest('hex'); + assert.equal(clampRelayKey(key).endsWith(`:sha256:${expect}`), true); +}); + +test('含中文的超长键不切出半个字符', () => { + const key = `会话标识:${'中'.repeat(300)}`; + const out = clampRelayKey(key); + assert.equal(byteLength(out) <= RELAY_KEY_MAX_BYTES, true); + assert.equal(out.includes('\uFFFD'), false); +}); + +test('上限小到装不下哈希时退化为截断哈希(仍然确定)', () => { + const key = 'z'.repeat(500); + const out = clampRelayKey(key, 20); + assert.equal(byteLength(out) <= 20, true); + assert.equal(out, clampRelayKey(key, 20)); +}); + +test('空键与 null 不炸', () => { + assert.equal(clampRelayKey(''), ''); + assert.equal(clampRelayKey(null), ''); + assert.equal(clampRelayKey(undefined), ''); +}); + +// ─── isPermanentFailure ─── + +test('400 是永久失败 —— 事故的核心(原来被当暂时失败让位)', () => { + assert.equal(isPermanentFailure({ status: 400 }), true); +}); + +test('409 是永久失败(这条链上没有人类,永远不会有人点头)', () => { + assert.equal(isPermanentFailure({ status: 409 }), true); +}); + +test('401 是永久失败:密钥无效要人去后台登记,不是等一等就好', () => { + // 本会话实测:opencode 拿着已撤销的密钥重试了 18 小时,2690 次 401 + assert.equal(isPermanentFailure({ status: 401 }), true); +}); + +test('403 / 404 / 422 都是永久失败', () => { + for (const s of [403, 404, 422]) { + assert.equal(isPermanentFailure({ status: s }), true, `${s} 应当是永久`); + } +}); + +test('408 与 429 是暂时失败(超时与限流等一会儿真的可能成功)', () => { + assert.equal(isPermanentFailure({ status: 408 }), false); + assert.equal(isPermanentFailure({ status: 429 }), false); +}); + +test('5xx 是暂时失败(服务端的问题)', () => { + for (const s of [500, 502, 503, 504]) { + assert.equal(isPermanentFailure({ status: s }), false, `${s} 应当是暂时`); + } +}); + +test('没有 status 的错误按暂时处理(网络层:DNS / 连接被拒)', () => { + assert.equal(isPermanentFailure(new Error('fetch failed')), false); + assert.equal(isPermanentFailure({}), false); + assert.equal(isPermanentFailure(null), false); + assert.equal(isPermanentFailure(undefined), false); +}); + +test('status 是字符串时也能判(HTTP 客户端可能挂上字符串)', () => { + assert.equal(isPermanentFailure({ status: '400' }), true); + assert.equal(isPermanentFailure({ status: '503' }), false); +}); + +test('2xx / 3xx 不算永久失败(本不该走到这里,但不能误判成永久)', () => { + assert.equal(isPermanentFailure({ status: 200 }), false); + assert.equal(isPermanentFailure({ status: 302 }), false); +});