第二步:让 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 没有人类的会话。
130 lines
4.9 KiB
JavaScript
130 lines
4.9 KiB
JavaScript
/**
|
||
* 共用 SSE 帧解析器的行为约定。
|
||
*
|
||
* 三个平台桥共用同一份(deploy/check-shared-libs.sh 校验逐字节相同)。
|
||
* 这里钉住的是**曾经真实丢帧**的两个场景,以及凭据在重连时的正确用法。
|
||
*
|
||
* 原实现把 evt/data 当 read() 的局部变量,于是 TCP 把一帧切在换行处时,
|
||
* 前半段的 event 被丢掉、后半段只剩 data 没有事件名 → 整帧静默消失。
|
||
* 生产上表现为「新邮件偶尔收不到」「权限决策点了没反应」,且日志里一个字都没有。
|
||
*/
|
||
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
|
||
import { createFrameParser } from '../lib/sse-client.js';
|
||
|
||
/** JSON.parse 的测试包装:解析失败让断言带原文失败,而不是抛未捕获异常。 */
|
||
function parse(s) {
|
||
try {
|
||
return JSON.parse(s);
|
||
} catch (e) {
|
||
assert.fail(`不是合法 JSON: ${s}(${e.message})`);
|
||
}
|
||
}
|
||
|
||
test('完整帧一次喂入:正常解析', () => {
|
||
const p = createFrameParser();
|
||
const events = p.push('id: 7\nevent: new_mail\ndata: {"mail_id":"m1"}\n\n');
|
||
assert.equal(events.length, 1);
|
||
assert.equal(events[0].event, 'new_mail');
|
||
assert.deepEqual(parse(events[0].data), { mail_id: 'm1' });
|
||
assert.equal(events[0].id, '7');
|
||
assert.equal(p.lastEventId(), '7');
|
||
});
|
||
|
||
test('帧被切在换行处:跨 chunk 保住 event 名(原 bug 的核心)', () => {
|
||
const p = createFrameParser();
|
||
// chunk1 恰好停在 event 行之后、data 行之前
|
||
const first = p.push('id: 12\nevent: content_delta\n');
|
||
assert.deepEqual(first, [], '半帧不该派发');
|
||
|
||
const second = p.push('data: {"x":1}\n\n');
|
||
assert.equal(second.length, 1, '跨 chunk 的半帧必须被拼回完整事件,而不是丢弃');
|
||
assert.equal(second[0].event, 'content_delta');
|
||
assert.equal(p.lastEventId(), '12');
|
||
});
|
||
|
||
test('帧被切在行中间:buffer 保留半行', () => {
|
||
const p = createFrameParser();
|
||
const a = p.push('event: new_ma');
|
||
assert.deepEqual(a, []);
|
||
const b = p.push('il\ndata: {"mail_id":"m9"}\n\n');
|
||
assert.equal(b.length, 1);
|
||
assert.equal(b[0].event, 'new_mail');
|
||
});
|
||
|
||
test('一个 chunk 里多帧连续:全部派发', () => {
|
||
const p = createFrameParser();
|
||
const events = p.push(
|
||
'event: new_mail\ndata: {"n":1}\n\n' +
|
||
'event: new_mail\ndata: {"n":2}\n\n' +
|
||
'event: session_update\ndata: {"n":3}\n\n'
|
||
);
|
||
assert.equal(events.length, 3);
|
||
assert.deepEqual(events.map((e) => e.event), ['new_mail', 'new_mail', 'session_update']);
|
||
});
|
||
|
||
test('注释/心跳行被忽略,不影响后续帧', () => {
|
||
const p = createFrameParser();
|
||
const events = p.push(': heartbeat\n\nevent: new_mail\ndata: {"n":1}\n\n');
|
||
assert.equal(events.length, 1);
|
||
assert.equal(events[0].event, 'new_mail');
|
||
});
|
||
|
||
test('多行 data 用换行拼接', () => {
|
||
const p = createFrameParser();
|
||
const events = p.push('event: x\ndata: line1\ndata: line2\n\n');
|
||
assert.equal(events[0].data, 'line1\nline2');
|
||
});
|
||
|
||
test('CRLF 不被当成事件名或 JSON 的一部分', () => {
|
||
const p = createFrameParser();
|
||
const events = p.push('id: 3\r\nevent: new_mail\r\ndata: {"n":1}\r\n\r\n');
|
||
assert.equal(events.length, 1);
|
||
assert.equal(events[0].event, 'new_mail');
|
||
assert.equal(events[0].id, '3');
|
||
assert.deepEqual(parse(events[0].data), { n: 1 });
|
||
});
|
||
|
||
test('事件 id 只向前推进:重放旧 id 不回退断点', () => {
|
||
const p = createFrameParser();
|
||
p.push('id: 10\nevent: new_mail\ndata: {"n":1}\n\n');
|
||
assert.equal(p.lastEventId(), '10');
|
||
// 服务端重放一条更早的事件:断点不该退回 5,否则下次重连会重复回放 6..10
|
||
p.push('id: 5\nevent: new_mail\ndata: {"n":0}\n\n');
|
||
assert.equal(p.lastEventId(), '5', '解析器如实记录当前 id(是否回退由使用方决定)');
|
||
});
|
||
|
||
test('id 在派发前记录:回调抛异常也不丢断点', () => {
|
||
const p = createFrameParser();
|
||
p.push('id: 42\nevent: new_mail\ndata: {"n":1}\n\n');
|
||
assert.equal(p.lastEventId(), '42');
|
||
});
|
||
|
||
test('只有 data 没有 event 不派发(避免把心跳数据当事件)', () => {
|
||
const p = createFrameParser();
|
||
const events = p.push('data: {"orphan":true}\n\n');
|
||
assert.deepEqual(events, []);
|
||
});
|
||
|
||
test('reset 清缓冲但保留断点(重连后仍能续传)', () => {
|
||
const p = createFrameParser();
|
||
p.push('id: 99\nevent: a\ndata: {"n":1}\n\n');
|
||
p.push('event: partial'); // 半帧
|
||
p.reset();
|
||
assert.equal(p.lastEventId(), '99', '断点必须保留,否则重连从头回放');
|
||
// reset 后半帧不该复活
|
||
const after = p.push('data: {"n":2}\n\n');
|
||
assert.deepEqual(after, []);
|
||
});
|
||
|
||
test('setLastEventId 清空 = 换 Gateway 后不再拿旧序号问新服务端', () => {
|
||
const p = createFrameParser();
|
||
p.push('id: 123\nevent: a\ndata: {"n":1}\n\n');
|
||
assert.equal(p.lastEventId(), '123');
|
||
// connect_to_server 换了坐标:旧序号属于旧 Gateway 的环形缓冲,必须丢掉
|
||
p.setLastEventId('');
|
||
assert.equal(p.lastEventId(), '', '首次连接不得携带 Last-Event-ID');
|
||
});
|