Files
MailUI4Agents/plugins/zcode-mail-bridge/test/relay-key.test.mjs
JianFeeeee c774904c0c feat(zcode): 授权桥 —— PermissionRequest 钩子把危险工具授权交给人
第二步:让 ZCode 上的 Bash/Write/Edit 授权走 AgentMail 的人工审批,
而不是只靠本地界面。

钩子契约从 CLI 产物里逆出来(不猜协议):
- 输入走 stdin:{hook_event_name, tool_name, tool_input, session_id, permission_mode…}
- 输出走 stdout,schema **严格**:{"decision":"approve"} / {"decision":"block","reason"}
  多一个键就会报 "Hook stdout failed HookJSONOutput schema validation"
- 空输出 / 不以 { 开头 = 不表态;exit 2 = 拒绝;其它非零 = 钩子失败
- 注入的环境变量含 ZCODE_PLUGIN_ROOT / ZCODE_PLUGIN_DATA / ZCODE_SESSION_ID
  (MCP 配置里用 ZCODE_SESSION_ID 反而会抛「需要运行时会话上下文」)

档位判定与 pi 桥逐条对齐(plan 直接拒 / workspace 问人 / full 批准),
判定逻辑抽成纯函数 lib/hook-policy.mjs 以便穷举:
其中 full 档必须**返回批准而不是不表态** —— 钩子一旦触发说明 ZCode 本会去问人,
不表态等于让那个询问照常发生,full 档就退化成了 workspace 档。

钩子自己开 SSE 等决定,不依赖桥进程:网关的 SSE 是扇出的
(clients 按唯一 id 存,SendToAgent 推给该 Agent 的所有客户端),
一次性进程也能订阅到自己那条 permission_decision。这样交互模式下同样可用
(人自己开着 ZCode 干活时并没有桥在跑)。先建连再发请求是有意的:
反过来会有一个窗口,人在窗口内点的同意推送给当时还不存在的客户端。

fail closed 但区分模式:永久失败(409/4xx)一律拒绝;暂时失败在
AGENTMAIL_SESSION_ID 非空(邮件驱动、没有本地界面兜底)时拒绝,
交互模式则不表态让人就地决定。

「一直同意」落盘(lib/grants-file.mjs):钩子是一个事件一个进程,
不落盘那个选项就是骗人的。判定仍交给共用的 permission-grants.js。

共用模块同源范围扩到 9 个(新增 permission-mode / relay-key /
permission-grants / sse-client)—— 档位语义与决策判定分叉会让「同意」
在 ZCode 上悄悄变成另一种意思。

验证:
- 单元 229/229(新增 hook-policy 14 项、grants-file 8 项,含反向对照)
- 共用模块四方同源检查通过
- 授权桥端到端 5/5,全部带反向对照:
  同意→approve;拒绝→block 且原因必须来自人的拒绝(不能是超时兜底);
  plan 档拒绝且**不产生**任何权限邮件;无人可问(409)→fail closed;
  非守卫工具→不表态
- `zcode plugins list` → agentmail@inline [enabled],hooks: 1,
  mcp: plugin:agentmail:agentmail

我自己写错的两处判据(都已修,值得记下):
1. 待决权限列表里有历史积压(实测 6 条,含其它 Agent 的条目),
   只按「第一条新的」取会拿到无关请求 —— 于是人点了同意而钩子在等自己那条,
   最后超时。第一版还把这个超时误报成「拒绝路径通过」。
   现在按「启动前快照差集 + session_id + agent_name」三重过滤。
2. 「无人可问」控制组最初传了个非 UUID 的 session id,走的是 400(参数错),
   验不到 409 那条真实路径。改为真的造一条只有 Agent 没有人类的会话。
2026-09-12 14:09:10 +08:00

195 lines
7.3 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.

/**
* 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);
});