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 没有人类的会话。
This commit is contained in:
@ -6,6 +6,7 @@
|
||||
"name": "AgentMail"
|
||||
},
|
||||
"license": "AGPL-3.0-only",
|
||||
"hooks": "hooks",
|
||||
"mcpServers": {
|
||||
"agentmail": {
|
||||
"command": "node",
|
||||
|
||||
156
plugins/zcode-mail-bridge/README.md
Normal file
156
plugins/zcode-mail-bridge/README.md
Normal file
@ -0,0 +1,156 @@
|
||||
# zcode-mail-bridge —— AgentMail 的 ZCode 适配
|
||||
|
||||
让 [ZCode](https://zcode.z.ai)(z.ai 的 Electron 客户端)成为 AgentMail 里
|
||||
一个能收发邮件、传附件、**把危险工具授权交给人**的 Agent。
|
||||
|
||||
ZCode 用**插件**扩展能力(`.zcode-plugin/plugin.json` 声明
|
||||
`skills` / `commands` / `hooks` / `mcpServers`),所以适配它的正确形状是一个插件,
|
||||
而不是又一个常驻桥进程。本目录就是那个插件。
|
||||
|
||||
## 组成
|
||||
|
||||
```
|
||||
.zcode-plugin/plugin.json 插件清单(MCP 服务器 + hooks 目录)
|
||||
mcp/server.mjs MCP 服务器入口(stdio,换行分隔 JSON-RPC)
|
||||
hooks/permission.mjs PermissionRequest 钩子:把授权问给人、等决定、回结论
|
||||
hooks/hooks.json 钩子注册(matcher + 进程型钩子 + 超时)
|
||||
lib/mcp-rpc.mjs 协议层(纯函数,可穷举测试)
|
||||
lib/tools.mjs 11 个 AgentMail 工具(与另三个桥同名同参)
|
||||
lib/gateway.mjs 网关 HTTP 客户端
|
||||
lib/hook-policy.mjs 档位判定(纯函数)
|
||||
lib/grants-file.mjs 「一直同意」的跨进程持久化
|
||||
lib/{addressing,inbox-format,bounded,discovery,attachment-ids,
|
||||
permission-mode,relay-key,permission-grants,sse-client}.js
|
||||
← 与 pi/dsh/opencode 三桥**逐字节同源**(见下)
|
||||
test/ 单元测试(含继承的共用测试)
|
||||
test/manual/permission-e2e.mjs 授权桥的端到端验证(真去点同意/拒绝)
|
||||
```
|
||||
|
||||
## 两条能力线
|
||||
|
||||
### 1)MCP 工具面
|
||||
|
||||
模型通过 `mcp__agentmail__<工具名>` 调用:
|
||||
|
||||
`read_inbox` `read_mail` `read_thread` `send_mail` `forward_mail`
|
||||
`upload_attachment` `download_attachment` `suggest_address` `list_contacts`
|
||||
`session_participants` `connect_to_server`
|
||||
|
||||
工具名、参数名与渲染文本都与 pi / dsh / opencode 三桥一致,渲染直接复用共用的
|
||||
`inbox-format` / `discovery` —— 同一封邮件在任何平台上看起来都该是同一个样子。
|
||||
`test/tools.test.mjs` 里有一条断言直接拿 pi 桥的工具名做对照:少一个就会让某个平台
|
||||
的行为与其它平台不同,而那种问题只在单一平台复现,排查代价最高。
|
||||
|
||||
### 2)授权桥(`PermissionRequest` 钩子)
|
||||
|
||||
ZCode 决定某个工具需要授权时触发钩子(事件 JSON 走 stdin),我们回一个结论走 stdout:
|
||||
|
||||
```jsonc
|
||||
{"decision":"approve"} // 放行
|
||||
{"decision":"block","reason":"…"} // 拒绝,且模型能看到原因
|
||||
// 什么都不输出 // 不表态,退回 ZCode 自己的权限流程
|
||||
```
|
||||
|
||||
档位语义与 pi 桥逐条对齐(同一条邮件派给不同 Agent,行为必须一致):
|
||||
|
||||
| 档位 | 守卫工具(`Bash`/`Write`/`Edit`/`ApplyPatch`) | 其它工具 |
|
||||
|---|---|---|
|
||||
| `plan` | 直接拒绝,文案与 pi 桥同源 | 不表态(读与查本来就允许) |
|
||||
| `workspace`(默认) | 发邮件问人,等决定 | 不表态 |
|
||||
| `full` | 批准(该档语义就是免掉询问) | 不表态 |
|
||||
|
||||
**只有明确同意才放行**:看不懂的决策文本一律当拒绝(判定交给共用的
|
||||
`permission-grants.js`)。「一直同意」落盘存到
|
||||
`$AGENTMAIL_ZCODE_GRANTS_FILE`(或 `$AGENTMAIL_CONFIG_DIR` / `$ZCODE_PLUGIN_DATA` 下的
|
||||
`permission-grants.json`)—— 钩子是**一个事件一个进程**,不落盘的话那个选项就是骗人的。
|
||||
|
||||
**失败一律 fail closed,但区分模式**:
|
||||
|
||||
- 永久失败(409 无人可问 / 其它 4xx)→ 拒绝并说明原因
|
||||
- 暂时失败(5xx / 网络)+ `AGENTMAIL_SESSION_ID` 非空(邮件驱动)→ 拒绝
|
||||
(邮件驱动的会话没有本地界面兜底,退回本地决策等于守卫消失)
|
||||
- 暂时失败 + 交互模式 → 不表态,让人就地决定
|
||||
|
||||
钩子**自己开 SSE** 等决定,不找桥进程要 —— 网关的 SSE 是扇出的
|
||||
(`clients` 按唯一 id 存,`SendToAgent` 推给该 Agent 的所有客户端),
|
||||
所以一次性进程也能订阅到自己那条 `permission_decision`。
|
||||
这样**交互模式下这个功能同样可用**(人自己开着 ZCode 干活时并没有桥在跑)。
|
||||
|
||||
## 配置
|
||||
|
||||
插件读与其它三桥**同名**的环境变量:
|
||||
|
||||
| 变量 | 说明 |
|
||||
|---|---|
|
||||
| `AGENTMAIL_GATEWAY_URL` | 网关地址,默认 `http://127.0.0.1:8180` |
|
||||
| `AGENTMAIL_AGENT_NAME` | 本 Agent 在 AgentMail 里的名字(如 `zcode`) |
|
||||
| `AGENTMAIL_AGENT_KEY` | 管理员签发的 Agent 密钥 |
|
||||
| `AGENTMAIL_AGENT_SECRET` | 没有密钥时的兜底(`X-Agent-Secret`,服务端两条路都认) |
|
||||
| `AGENTMAIL_SESSION_ID` | **仅邮件驱动时**由驱动进程注入:本会话的 AgentMail 会话 id,兼作「有无本地界面」的判据 |
|
||||
| `AGENTMAIL_PERMISSION_MODE` | 档位(`plan`/`workspace`/`full`),由驱动按邮件的 `permission_mode` 注入 |
|
||||
| `AGENTMAIL_PERMISSION_WAIT_MS` | 等人工决策的上限,默认 540000(9 分钟,须小于钩子的 `timeoutMs`) |
|
||||
|
||||
密钥怎么给:ZCode 的插件 `userConfig` **不支持** `sensitive` 值(官方文档明说
|
||||
「sensitive 值当前无法在界面输入或持久化」),所以密钥走 **ZCode 进程的环境变量**
|
||||
(systemd `EnvironmentFile`),由 MCP 服务器与钩子继承。
|
||||
`userConfig` 只适合放非机密项。
|
||||
|
||||
### 安装(本地目录,无需 marketplace)
|
||||
|
||||
ZCode 的发现源之一是 `plugins.dirs`(配置里的「inline directories」)。
|
||||
|
||||
```jsonc
|
||||
// ~/.zcode/cli/config.json
|
||||
{ "plugins": { "enabled": true, "dirs": ["/opt/agentmail/plugins/zcode-mail-bridge/current"] } }
|
||||
```
|
||||
|
||||
装完用 `node <zcode.cjs> plugins list` 自查,应当看到:
|
||||
|
||||
```
|
||||
- agentmail@inline [enabled]
|
||||
inline/inline: <插件目录>
|
||||
skills: 0, commands: 0, hooks: 1, mcp: plugin:agentmail:agentmail
|
||||
```
|
||||
|
||||
## 谁在维护"同源"
|
||||
|
||||
`deploy/check-shared-libs.sh` 会逐个字节比对上面那 9 个共用模块(及其测试)
|
||||
与 opencode 基准。**该脚本曾有假绿**:本机 PATH 上的 `diff` 是鸿蒙 SDK 工具链里的
|
||||
`diff`,不认 `-q` 且对内容不同的文件**仍返回 0**,于是检查器一直是永真输出。
|
||||
现已改用 `cmp -s` 并在开头自检(判据本身必须先被证明能发现差异)。
|
||||
|
||||
改了共用模块的正规流程:改 `opencode-mail-bridge/lib/` 下的基准,
|
||||
跑 `deploy/check-shared-libs.sh` 看它报错,再逐字拷到其余三处。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
# 单元 + 继承的共用测试
|
||||
node --test 'test/*.test.mjs'
|
||||
|
||||
# 共用模块四方同源(含判据自检)
|
||||
bash ../../deploy/check-shared-libs.sh
|
||||
|
||||
# 授权桥端到端:真建会话 → 起钩子 → 以人类身份点同意/拒绝 → 验钩子结论
|
||||
node test/manual/permission-e2e.mjs
|
||||
```
|
||||
|
||||
`permission-e2e.mjs` 的判据设计:正向(同意→approve)之外还有四条反向对照
|
||||
(拒绝→block 且原因必须来自人的拒绝、plan 档拒绝且**不产生**任何权限邮件、
|
||||
无人可问→fail closed、非守卫工具→不表态)。它必须按「启动前快照差集 +
|
||||
`session_id` + `agent_name`」三重过滤待决请求 —— 待决列表里有历史积压
|
||||
(实测 6 条,含其它 Agent 的),只取「第一条新的」会拿到一条无关请求,
|
||||
于是人点了同意而钩子在等自己那条,最后超时。
|
||||
|
||||
## 已知缺口
|
||||
|
||||
- **邮件驱动还没做**:目前是「模型侧工具面 + 授权桥」。让 ZCode 收到来信就自动开工,
|
||||
需要一个驱动进程(订阅 SSE → 起 ZCode 会话 → 把最终回复当回信发出),
|
||||
并把 `AGENTMAIL_SESSION_ID` / `AGENTMAIL_PERMISSION_MODE` 注入会话。
|
||||
- **`mode_enforcement` 仍是 `advisory`**:授权桥已经能真的拦截(钩子返回 block 会拒绝工具),
|
||||
但库里的档位声明还没提为 `native`。
|
||||
- **生产路径**:当前 `plugins.dirs` 指向仓库工作副本,按项目纪律应改为
|
||||
`/opt/agentmail/plugins/zcode-mail-bridge/current` 的快照 + 原子切换
|
||||
(等 ZCode 重启不影响在跑的登录流程时再做)。
|
||||
- **闭源**:ZCode 是闭源客户端(deb 里 `License: unknown`),本插件的协议层
|
||||
(MCP 分帧、钩子 schema)是从其产物里实测逆出来的,版本升级可能破坏。
|
||||
18
plugins/zcode-mail-bridge/hooks/hooks.json
Normal file
18
plugins/zcode-mail-bridge/hooks/hooks.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PermissionRequest": [
|
||||
{
|
||||
"matcher": "Bash|Write|Edit|ApplyPatch",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "process",
|
||||
"command": "node",
|
||||
"args": ["${ZCODE_PLUGIN_ROOT}/hooks/permission.mjs"],
|
||||
"timeoutMs": 600000,
|
||||
"statusMessage": "正在向 AgentMail 征求授权…"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
270
plugins/zcode-mail-bridge/hooks/permission.mjs
Normal file
270
plugins/zcode-mail-bridge/hooks/permission.mjs
Normal file
@ -0,0 +1,270 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ZCode `PermissionRequest` 钩子 —— AgentMail 的授权桥。
|
||||
*
|
||||
* # 它在链路里的位置
|
||||
*
|
||||
* ZCode 决定某个工具需要授权时会触发本钩子,并把事件 JSON 写到 stdin:
|
||||
*
|
||||
* { hook_event_name: "PermissionRequest", tool_name: "Bash",
|
||||
* tool_input: {...}, session_id: "...", permission_mode: "...", ... }
|
||||
*
|
||||
* 我们在 stdout 回一个结论(这是从 CLI 产物里逆出来的 schema,不是猜的):
|
||||
*
|
||||
* {"decision":"approve"} 放行
|
||||
* {"decision":"block","reason":"..."} 拒绝(并让模型看到原因)
|
||||
* 什么都不输出 不表态,退回 ZCode 自己的权限流程
|
||||
*
|
||||
* schema 是**严格**的:多一个键就会让 ZCode 报
|
||||
* "Hook stdout failed HookJSONOutput schema validation",
|
||||
* 所以这里只输出这两个键。
|
||||
*
|
||||
* # 为什么自己开 SSE,而不是找桥要
|
||||
*
|
||||
* 决策是人点出来的,通过网关的 `permission_decision` 事件下发。
|
||||
* 钩子是**一次性进程**,没有常驻连接可用;而网关的 SSE 是**扇出**的
|
||||
* (`clients` 按唯一 id 存,`SendToAgent` 推给该 Agent 的所有客户端),
|
||||
* 所以钩子可以自己订阅、拿到自己那条决定、然后退出。
|
||||
*
|
||||
* 这样做的直接好处:**交互模式下也能用** —— 人自己开着 ZCode 干活时并没有
|
||||
* 桥进程在跑,若改成「问桥要结论」,这个功能就只在邮件驱动时才存在。
|
||||
*
|
||||
* # 失败一律 fail closed(但区分模式)
|
||||
*
|
||||
* 只有明确同意才放行;看不懂的决策文本一律当拒绝(判定交给共用库)。
|
||||
* 暂时性失败(5xx / 网络)分两种:邮件驱动的会话没有本地界面兜底,
|
||||
* 所以驳回并说明;交互模式则退回 ZCode 自己的权限流程,让人就地决定。
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { GatewayClient } from '../lib/gateway.mjs';
|
||||
import { createSSEClient } from '../lib/sse-client.js';
|
||||
import { clampRelayKey, isPermanentFailure } from '../lib/relay-key.js';
|
||||
import { isApproval, isAlwaysDecision } from '../lib/permission-grants.js';
|
||||
import {
|
||||
decidePolicy,
|
||||
describeToolCall,
|
||||
PERMISSION_EVENT
|
||||
} from '../lib/hook-policy.mjs';
|
||||
import { createFileGrantStore, grantsFilePath } from '../lib/grants-file.mjs';
|
||||
|
||||
const log = (...parts) => console.error('[agentmail-hook]', ...parts);
|
||||
|
||||
/** 等待人工决策的上限。必须**小于** hooks.json 里的 timeoutMs,
|
||||
* 否则会是 ZCode 先把钩子杀掉(报成「钩子失败」),而不是我们给出结论。 */
|
||||
const WAIT_MS = Number(process.env.AGENTMAIL_PERMISSION_WAIT_MS || 540000);
|
||||
|
||||
/** 回一个结论并退出。stdout 只允许出现这一个 JSON 对象。 */
|
||||
function emit(obj) {
|
||||
if (obj !== null) process.stdout.write(`${JSON.stringify(obj)}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const approve = () => emit({ decision: 'approve' });
|
||||
const block = reason => emit({ decision: 'block', reason });
|
||||
const noOpinion = () => emit(null);
|
||||
|
||||
function readHookInput() {
|
||||
try {
|
||||
return JSON.parse(readFileSync(0, 'utf8'));
|
||||
} catch (e) {
|
||||
log('stdin 不是合法 JSON:', e?.message || e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 等 SSE 建连完成。
|
||||
*
|
||||
* 必须先连上再发权限请求:反过来会有一个窗口 —— 人恰好在窗口内点了同意,
|
||||
* 而事件推送给了当时还不存在的客户端,于是这条决定永远等不到
|
||||
* (表现为「明明点了同意,工具还是被拒」)。服务端在 AddClient 时会立刻下发
|
||||
* 一个 `connected` 事件,就用它做信号。
|
||||
*/
|
||||
function waitConnected(sseState) {
|
||||
return new Promise(resolve => {
|
||||
const timer = setTimeout(() => {
|
||||
log('SSE 建连等待超时,仍然继续(可能错过极早到达的决策)');
|
||||
resolve();
|
||||
}, 5000);
|
||||
sseState.onConnected = () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function askHuman({ client, baseURL, input, toolName, relayKey, sessionId }) {
|
||||
const sseState = { onConnected: null };
|
||||
const decisions = [];
|
||||
let waiter = null;
|
||||
|
||||
const sse = createSSEClient({
|
||||
authHeaders: () => client.authHeaders(),
|
||||
baseURL,
|
||||
path: '/api/v1/events/stream',
|
||||
log,
|
||||
onEvent: (evt, data) => {
|
||||
if (evt === 'connected' && sseState.onConnected) sseState.onConnected();
|
||||
if (evt !== 'permission_decision') return;
|
||||
// 只认自己那条:同一 Agent 可能有多个钩子进程同时在等
|
||||
// (模型并行发起两个 Bash),按 relay_key 配对才不会互相拿到对方的决定。
|
||||
if (data?.relay_key && data.relay_key !== relayKey) return;
|
||||
if (waiter) {
|
||||
const w = waiter;
|
||||
waiter = null;
|
||||
w(data);
|
||||
} else {
|
||||
decisions.push(data);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await waitConnected(sseState);
|
||||
|
||||
// 不传 `to`:决策人由服务端按 会话 owner → 线索里最近的人类 → 409 解析。
|
||||
// 插件只有本地上下文,猜不出「这条 Agent 链最初是谁派的活」。
|
||||
await client.post('/permission/request', {
|
||||
question: `是否允许执行 ${toolName}?`,
|
||||
options: ['同意', '一直同意', '拒绝'],
|
||||
context: [
|
||||
describeToolCall(toolName, input.tool_input),
|
||||
process.env.AGENTMAIL_MAIL_SUBJECT
|
||||
? `\n触发任务:${process.env.AGENTMAIL_MAIL_SUBJECT}`
|
||||
: '',
|
||||
process.env.AGENTMAIL_REPLY_TO ? `任务来自:${process.env.AGENTMAIL_REPLY_TO}` : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
session_id: sessionId || '',
|
||||
relay_key: relayKey
|
||||
});
|
||||
|
||||
const decision = decisions.shift() ?? (await new Promise(resolve => {
|
||||
// 挂上等待者;超时后也要把 waiter 摘掉,否则后续事件会去 resolve
|
||||
// 一个已经没人听的 promise(并让 sse.stop 之后的日志显得诡异)。
|
||||
waiter = resolve;
|
||||
setTimeout(() => {
|
||||
if (waiter !== resolve) return;
|
||||
waiter = null;
|
||||
resolve(null);
|
||||
}, WAIT_MS).unref?.();
|
||||
}));
|
||||
return decision;
|
||||
} finally {
|
||||
sse.stop();
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const input = readHookInput();
|
||||
if (!input) return noOpinion();
|
||||
|
||||
const toolName = input.tool_name;
|
||||
const mode = process.env.AGENTMAIL_PERMISSION_MODE;
|
||||
const policy = decidePolicy({
|
||||
event: input.hook_event_name,
|
||||
toolName,
|
||||
mode
|
||||
});
|
||||
log(`事件 ${input.hook_event_name} 工具 ${toolName} 档位 ${mode || '(默认)'} → ${policy.action}`);
|
||||
|
||||
if (policy.action === 'none') return noOpinion();
|
||||
if (policy.action === 'approve') return approve();
|
||||
if (policy.action === 'block') return block(policy.reason);
|
||||
|
||||
// ── 问人 ──
|
||||
const client = new GatewayClient(process.env);
|
||||
const missing = client.checkConfig();
|
||||
if (missing.length) {
|
||||
log(`未配置:${missing.join('、')}`);
|
||||
// 没配好就无法问人。交互模式下退回本地流程仍然可用;
|
||||
// 邮件驱动的会话没有本地界面,必须当场说清楚而不是静默挂住。
|
||||
return process.env.AGENTMAIL_SESSION_ID
|
||||
? block(`AgentMail 授权桥未配置(缺少 ${missing.join('、')}),无法征求授权,已拒绝 ${toolName}。`)
|
||||
: noOpinion();
|
||||
}
|
||||
|
||||
const sessionId = process.env.AGENTMAIL_SESSION_ID || '';
|
||||
const relayKey = clampRelayKey(
|
||||
`${sessionId || input.session_id || 'zcode'}:${input.tool_use_id || toolName}`
|
||||
);
|
||||
|
||||
// 「一直同意」要真的记住:钩子一封一进程,所以授权表落盘。
|
||||
const grants = createFileGrantStore(grantsFilePath(process.env));
|
||||
const grantScope = sessionId || input.session_id || '';
|
||||
if (grants.isGranted(grantScope, toolName)) {
|
||||
log(`${toolName} 在本会话已获「一直同意」(${grantsFilePath(process.env)}),直接放行`);
|
||||
return approve();
|
||||
}
|
||||
|
||||
let decision;
|
||||
try {
|
||||
decision = await askHuman({ client, baseURL: client.baseURL, input, toolName, relayKey, sessionId });
|
||||
} catch (e) {
|
||||
// 409 = 服务端判定这条任务链上没有人类,永远不会有人来点头。
|
||||
// 永久失败(4xx)同样不会因重试而改变 —— 两者都必须当场拒绝,
|
||||
// 让模型从工具报错里看到原因并自己改道(挂死时连重试机会都没有)。
|
||||
if (isPermanentFailure(e)) {
|
||||
const b = e?.body && typeof e.body === 'object' ? e.body : {};
|
||||
const reason = [
|
||||
b.error || `权限询问无法送达(HTTP ${e?.status})`,
|
||||
typeof e?.body === 'string' ? e.body : '',
|
||||
b.detail || '',
|
||||
b.suggestion || ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
log(`权限询问永久失败,当场拒绝 ${relayKey}:${reason}`);
|
||||
return block(reason);
|
||||
}
|
||||
const detail = e?.message || String(e);
|
||||
log(`权限询问暂时失败:${detail}`);
|
||||
if (process.env.AGENTMAIL_SESSION_ID) {
|
||||
// 邮件驱动:没有本地界面兜底,退回本地决策等于守卫消失。
|
||||
return block(
|
||||
`无法把 ${toolName} 的授权请求送达给人(${detail})。` +
|
||||
`这条会话由邮件驱动、没有本地界面,因此不放行。` +
|
||||
`请改用不需要授权的方式完成,或在回信里说明需要人工执行哪一步。`
|
||||
);
|
||||
}
|
||||
return noOpinion();
|
||||
}
|
||||
|
||||
if (decision === null) {
|
||||
return block(
|
||||
`等待授权超时(${Math.round(WAIT_MS / 1000)} 秒内没有人决策),未执行 ${toolName}。`
|
||||
);
|
||||
}
|
||||
|
||||
const text = decision.decision ?? '';
|
||||
if (isApproval(text)) {
|
||||
if (isAlwaysDecision(text) && grants.grant(grantScope, toolName, text)) {
|
||||
log(`记下「一直同意」:会话 ${grantScope} 的 ${toolName} 后续免批`);
|
||||
}
|
||||
log(`授权 ${relayKey} 获批(${text}${decision.decided_by ? ` by ${decision.decided_by}` : ''})`);
|
||||
return approve();
|
||||
}
|
||||
|
||||
return block(
|
||||
[
|
||||
`用户拒绝了这次 ${toolName} 调用。`,
|
||||
decision.note ? `说明:${decision.note}` : '',
|
||||
decision.decided_by ? `(由 ${decision.decided_by} 决定)` : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
);
|
||||
}
|
||||
|
||||
main().catch(e => {
|
||||
// 钩子自身崩了**不能**静默退回 ZCode 的权限流程 —— 那在有本地界面时是
|
||||
// 合理兜底,在邮件驱动时等于守卫消失。所以这里区分模式,并把原因写在
|
||||
// stderr(进 ZCode 日志)供人排查。
|
||||
log('钩子异常:', e?.stack || e);
|
||||
if (process.env.AGENTMAIL_SESSION_ID) {
|
||||
block(`AgentMail 授权钩子内部错误:${e?.message || e}。未执行工具。`);
|
||||
}
|
||||
noOpinion();
|
||||
});
|
||||
102
plugins/zcode-mail-bridge/lib/grants-file.mjs
Normal file
102
plugins/zcode-mail-bridge/lib/grants-file.mjs
Normal file
@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 「一直同意」的跨进程持久化。
|
||||
*
|
||||
* # 为什么需要文件
|
||||
*
|
||||
* ZCode 的钩子是**一个事件一个进程** —— 批准完就退出。pi 桥那边的授权集合活在
|
||||
* 常驻 worker 里(靠会话快照跨 worker),而这里没有可依附的常驻内存:
|
||||
* 不落盘的话「一直同意」只在本次调用有效,而下一次调用是个新进程,
|
||||
* 会再问一遍 —— 那个选项就成了骗人的(pi 桥的注释里原话是
|
||||
* 「否则这个选项在骗人」)。
|
||||
*
|
||||
* # 判定语义不在这里
|
||||
*
|
||||
* 「什么是同意」「什么是一直同意」全部来自共用的 `lib/permission-grants.js`
|
||||
* (逐字节同源)。本模块只管把结果存下来,不自己写判定正则 ——
|
||||
* 那正是各平台会悄悄分叉的地方。
|
||||
*
|
||||
* # 并发
|
||||
*
|
||||
* 读-改-写。同一会话的两次授权请求几乎不会同时发生(ZCode 串行执行工具),
|
||||
* 且写入是原子替换(临时文件 + rename),所以最坏情况是「后写覆盖先写」,
|
||||
* 不会读到半截 JSON。真要并发也只会多问一次,不会漏判。
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { isAlwaysDecision } from './permission-grants.js';
|
||||
|
||||
/**
|
||||
* 授权表文件的位置。
|
||||
*
|
||||
* 优先用显式配置,其次插件数据目录(ZCode 会注入 `ZCODE_PLUGIN_DATA`),
|
||||
* 最后退回家目录下的固定名。三者都不存在的情况极罕见,
|
||||
* 但必须有确定答案 —— 退回家目录至少让功能可用。
|
||||
*/
|
||||
export function grantsFilePath(env = process.env) {
|
||||
if (env.AGENTMAIL_ZCODE_GRANTS_FILE) return env.AGENTMAIL_ZCODE_GRANTS_FILE;
|
||||
if (env.AGENTMAIL_CONFIG_DIR) return join(env.AGENTMAIL_CONFIG_DIR, 'permission-grants.json');
|
||||
if (env.ZCODE_PLUGIN_DATA) return join(env.ZCODE_PLUGIN_DATA, 'permission-grants.json');
|
||||
return join(homedir(), '.agentmail-zcode', 'permission-grants.json');
|
||||
}
|
||||
|
||||
/** 读盘。文件不存在或内容坏掉都当空表 —— 授权表读不出来不该让钩子崩。 */
|
||||
export function loadGrants(filePath) {
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(filePath, 'utf8'));
|
||||
const out = new Map();
|
||||
for (const [session, tools] of Object.entries(raw?.sessions ?? {})) {
|
||||
if (Array.isArray(tools)) out.set(session, new Set(tools.filter(t => typeof t === 'string')));
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
function saveGrants(filePath, grants) {
|
||||
const sessions = {};
|
||||
for (const [session, tools] of grants) sessions[session] = [...tools];
|
||||
const payload = { version: 1, sessions };
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
// 原子替换:直接覆盖写会让并发读者看到半截 JSON(而上面的 load 会把它
|
||||
// 当成空表,于是刚给的授权静默消失)。
|
||||
const tmp = `${filePath}.tmp-${process.pid}`;
|
||||
writeFileSync(tmp, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
||||
renameSync(tmp, filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于文件的授权表。
|
||||
*
|
||||
* @param {string} filePath
|
||||
*/
|
||||
export function createFileGrantStore(filePath) {
|
||||
const grants = loadGrants(filePath);
|
||||
|
||||
return {
|
||||
isGranted(sessionId, toolName) {
|
||||
if (!sessionId || !toolName) return false;
|
||||
return grants.get(sessionId)?.has(toolName) ?? false;
|
||||
},
|
||||
|
||||
/** 只在决策文本确实是「一直同意」时落盘(判定交给共用库)。 */
|
||||
grant(sessionId, toolName, decision) {
|
||||
if (!sessionId || !toolName) return false;
|
||||
if (!isAlwaysDecision(decision)) return false;
|
||||
let set = grants.get(sessionId);
|
||||
if (!set) grants.set(sessionId, (set = new Set()));
|
||||
set.add(toolName);
|
||||
saveGrants(filePath, grants);
|
||||
return true;
|
||||
},
|
||||
|
||||
/** 撤销整条会话的免批(换模型重开会话时用)。 */
|
||||
revokeSession(sessionId) {
|
||||
if (!grants.delete(sessionId)) return false;
|
||||
saveGrants(filePath, grants);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
92
plugins/zcode-mail-bridge/lib/hook-policy.mjs
Normal file
92
plugins/zcode-mail-bridge/lib/hook-policy.mjs
Normal file
@ -0,0 +1,92 @@
|
||||
/**
|
||||
* ZCode 授权钩子的**判定策略**(纯函数,不碰 I/O)。
|
||||
*
|
||||
* 语义与 pi 桥的 `permissionExtension()` 逐条对齐 —— 档位判定是给产品定的,
|
||||
* 不是给平台定的:同一个「plan 档」在 ZCode 上必须是同一个意思,
|
||||
* 否则同一封邮件派到两个 Agent 上会得到两种行为,而人只会以为自己派错了。
|
||||
*
|
||||
* 只把「该做什么」算出来,真正的 I/O(问人、等决定、写 stdout)留在钩子入口,
|
||||
* 于是这里可以被穷举测试。
|
||||
*/
|
||||
|
||||
import {
|
||||
normalizeMode,
|
||||
DEFAULT_MODE,
|
||||
MODE_FULL,
|
||||
MODE_PLAN
|
||||
} from './permission-mode.js';
|
||||
|
||||
/**
|
||||
* 被守卫的工具名。
|
||||
*
|
||||
* 对应 pi 桥的 `GUARDED = new Set(['bash', 'write', 'edit'])`。
|
||||
* ZCode 的工具名是首字母大写,且 `Write`/`Edit` 有一个来自 `ApplyPatch` 的别名,
|
||||
* 所以这里做大小写无关匹配并收进 `applypatch`。
|
||||
*/
|
||||
const GUARDED = new Set(['bash', 'write', 'edit', 'applypatch']);
|
||||
|
||||
/** ZCode 的钩子事件名(七个之一)。本模块只关心这一个。 */
|
||||
export const PERMISSION_EVENT = 'PermissionRequest';
|
||||
|
||||
export function isGuardedTool(toolName) {
|
||||
return GUARDED.has(String(toolName ?? '').trim().toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* 算出这次钩子该采取的动作。
|
||||
*
|
||||
* @param {{event?: string, toolName?: string, mode?: string}} input
|
||||
* @returns {{action: 'none'|'approve'|'block'|'ask', reason?: string}}
|
||||
*
|
||||
* - `none`:不表态。ZCode 会继续它自己的权限流程(该问谁就问谁)——
|
||||
* 这是「不该由我们插手」的唯一正确表达方式;返回 approve 会越权放行,
|
||||
* 返回空字符串 stdout 也一样是「不表态」,但显式写出来更清楚。
|
||||
* - `approve` / `block`:直接给结论。
|
||||
* - `ask`:交给 AgentMail 问人,等决定。
|
||||
*/
|
||||
export function decidePolicy({ event, toolName, mode } = {}) {
|
||||
if (event !== PERMISSION_EVENT) return { action: 'none' };
|
||||
|
||||
// 非守卫工具不表态。钩子的 matcher 已经在 hooks.json 里限定了范围,
|
||||
// 这里再判一次是纵深防御:matcher 被人改宽时不会静默变成「什么都批准」。
|
||||
if (!isGuardedTool(toolName)) return { action: 'none' };
|
||||
|
||||
const m = normalizeMode(mode) || DEFAULT_MODE;
|
||||
|
||||
// full 档:发件人已声明全权,pi 桥在这一档直接不拦截。
|
||||
// ZCode 上「不拦截」的等价物就是批准 —— 钩子一旦触发,ZCode 本会去问人,
|
||||
// 而我们正是要在这一档免掉那个询问。返回 none 会退回询问,语义就反了。
|
||||
if (m === MODE_FULL) return { action: 'approve' };
|
||||
|
||||
// plan 档:该档语义是「只读不动手」,没什么可问人的。
|
||||
// 文案与 pi 桥同源,模型收到的措辞一致。
|
||||
if (m === MODE_PLAN) {
|
||||
return {
|
||||
action: 'block',
|
||||
reason:
|
||||
`plan 档下不允许执行 ${toolName}。本档只允许读与查,请把方案写在回信里。` +
|
||||
`如需动手请让发件人把档位改成 workspace。`
|
||||
};
|
||||
}
|
||||
|
||||
return { action: 'ask' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一次工具调用摘要成人能判断的文本。
|
||||
*
|
||||
* 与 pi 桥的 `describeToolCall` 同源(同样的字段截断长度),
|
||||
* 差别只在 ZCode 的入参字段名(它给的是 `tool_input`)。
|
||||
*/
|
||||
export function describeToolCall(toolName, toolInput) {
|
||||
const input = toolInput && typeof toolInput === 'object' ? toolInput : {};
|
||||
const name = String(toolName ?? '').toLowerCase();
|
||||
if (name === 'bash') {
|
||||
return `命令:\n${String(input.command ?? '').slice(0, 800)}`;
|
||||
}
|
||||
if (name === 'write' || name === 'edit' || name === 'applypatch') {
|
||||
const p = input.file_path ?? input.path ?? input.filePath ?? '(未给出)';
|
||||
return `文件:${p}`;
|
||||
}
|
||||
return JSON.stringify(input).slice(0, 800);
|
||||
}
|
||||
105
plugins/zcode-mail-bridge/lib/permission-grants.js
Normal file
105
plugins/zcode-mail-bridge/lib/permission-grants.js
Normal file
@ -0,0 +1,105 @@
|
||||
// 权限免批(「一直同意」)的纯逻辑 —— 所有平台插件共用。
|
||||
//
|
||||
// # 这是什么
|
||||
//
|
||||
// 权限询问默认是**每次都问**:模型每调一次 bash 就发一封邮件等人点头。
|
||||
// 这在「跑一条命令看看」的场景下是对的,在「审查这个工程」的场景下是灾难 ——
|
||||
// 实测同一条会话被问了 15 次 bash,人点了 15 次「同意」,全是同一类操作。
|
||||
//
|
||||
// 「一直同意」就是人对此的回答:这条会话里这个工具,别再问了。
|
||||
//
|
||||
// # 为什么需要一个独立模块
|
||||
//
|
||||
// 因为它的**作用域**是唯一容易搞错的地方,而搞错的后果是静默的越权:
|
||||
//
|
||||
// - 作用域太宽(全局 / 只按工具名)→ 人为「审查 llmsproxy」批准的 bash,
|
||||
// 会静默授权另一个发件人派来的另一条任务。那不是他批准的东西。
|
||||
// - 作用域太窄(按 toolCallId)→ 等于没有免批,每条命令还是一封邮件。
|
||||
//
|
||||
// 正确的粒度是 **(会话, 工具名)**:人看到的那句「是否允许执行 bash?」
|
||||
// 就是在这个粒度上提的问,授权范围不该超出提问范围。
|
||||
//
|
||||
// # 为什么只在内存里
|
||||
//
|
||||
// 会话结束(进程重启)即失效,这是有意的。长期免批该由平台自己的 settings
|
||||
// 管(pi 的 settings.json、opencode 的 permission 配置),不该让一个守护进程
|
||||
// 的内存变成事实上的安全策略 —— 那种策略没人能审计,重启后又悄悄消失。
|
||||
|
||||
/**
|
||||
* 判定一个决策文本是不是「永久同意」。
|
||||
*
|
||||
* **必须精确匹配**,不能用前缀匹配。`/^同意/` 会把「同意」也算成 always,
|
||||
* 于是人点一次单次授权,后面所有命令都不再问了 —— 那是把单次授权
|
||||
* 静默升级成永久授权,比不实现这个功能危险得多。
|
||||
*
|
||||
* @param {string} decision 人点的选项原文
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAlwaysDecision(decision) {
|
||||
return /^(一直同意|always|allow-always|allow_always)$/i.test(String(decision ?? '').trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* 判定一个决策文本是不是「同意」(含永久同意)。
|
||||
*
|
||||
* fail closed:认不出的文本一律当拒绝。空串、`shutdown`(关停时唤醒等待者
|
||||
* 用的哨兵值)、以及任何没见过的选项都走这一支 —— 放行一个没人批准的
|
||||
* 危险操作,比让它失败严重得多。
|
||||
*
|
||||
* @param {string} decision
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isApproval(decision) {
|
||||
return /^(同意|一直同意|allow|approve|always|yes)/i.test(String(decision ?? '').trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* 免批授权表:`会话 id -> Set<工具名>`。
|
||||
*
|
||||
* 用 Map<string, Set<string>> 而不是 Set<`${session}:${tool}`>:
|
||||
* 会话结束时要能一次清掉它的全部授权(`revokeSession`),
|
||||
* 拼接键的话得遍历整张表按前缀删,而工具名里出现 `:` 就会误删。
|
||||
*/
|
||||
export function createGrantStore() {
|
||||
/** @type {Map<string, Set<string>>} */
|
||||
const grants = new Map();
|
||||
|
||||
return {
|
||||
/** 这条会话的这个工具是否已获免批。 */
|
||||
isGranted(sessionId, toolName) {
|
||||
if (!sessionId || !toolName) return false;
|
||||
return grants.get(sessionId)?.has(toolName) ?? false;
|
||||
},
|
||||
|
||||
/**
|
||||
* 记下一条免批授权。只在决策文本确实是「一直同意」时才记 ——
|
||||
* 判定交给 isAlwaysDecision,调用方不要自己写正则。
|
||||
* @returns {boolean} 是否真的记下了(便于调用方决定要不要打日志)
|
||||
*/
|
||||
grant(sessionId, toolName, decision) {
|
||||
if (!sessionId || !toolName) return false;
|
||||
if (!isAlwaysDecision(decision)) return false;
|
||||
let set = grants.get(sessionId);
|
||||
if (!set) grants.set(sessionId, (set = new Set()));
|
||||
set.add(toolName);
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* 撤销整条会话的免批。
|
||||
*
|
||||
* 换模型重开会话时必须调:授权是人对**那次**上下文的判断,
|
||||
* 新会话重跑一遍提示,不该继承上一条的授权。
|
||||
*/
|
||||
revokeSession(sessionId) {
|
||||
grants.delete(sessionId);
|
||||
},
|
||||
|
||||
/** 仅用于测试与诊断:当前授权总数。 */
|
||||
size() {
|
||||
let n = 0;
|
||||
for (const set of grants.values()) n += set.size;
|
||||
return n;
|
||||
},
|
||||
};
|
||||
}
|
||||
239
plugins/zcode-mail-bridge/lib/permission-mode.js
Normal file
239
plugins/zcode-mail-bridge/lib/permission-mode.js
Normal file
@ -0,0 +1,239 @@
|
||||
/**
|
||||
* 权限档位 → 平台原生配置的翻译 —— 四个平台共用的判据。
|
||||
*
|
||||
* ## 分工
|
||||
*
|
||||
* **AgentMail 声明,平台执行,插件只翻译。** 这个模块是「翻译」那一步的
|
||||
* 唯一实现:把 `plan` / `workspace` / `full` 翻成各平台原生的沙箱/审批配置。
|
||||
*
|
||||
* 为什么不让插件自己按工具名猜着拦:那会同时违反 I-1(平台原生信号是唯一
|
||||
* 真相来源)与 I-4(插件只搬运不决策),而且四个插件对「workspace 到底管
|
||||
* 什么」必然各猜一套 —— 同一封 workspace 档的邮件在 A 平台被拦、在 B 平台放行。
|
||||
*
|
||||
* ## 为什么必须「向更严取整」
|
||||
*
|
||||
* 平台表达不出精确档位时,一律往更严的方向走,并如实上报自己做到了什么
|
||||
* (native / advisory)。pi 就是例子:write/edit 能查 `input.path` 判断越界,
|
||||
* 而 bash 命令要碰哪些文件是解析不出来的 —— 于是 workspace 档下 pi 只能
|
||||
* 「每条 bash 都问人」,比声明的更严。
|
||||
*
|
||||
* 不定这条规则的后果:不同插件会朝不同方向取整,而往宽松取整是静默失效
|
||||
* (人以为收紧了,实际没有)。
|
||||
*/
|
||||
|
||||
/** 只读:查资料、读代码、出方案,一个字都不许写。 */
|
||||
export const MODE_PLAN = 'plan';
|
||||
/** 本目录内可动手,越界要问人。默认档。 */
|
||||
export const MODE_WORKSPACE = 'workspace';
|
||||
/** 自动放行,不问人。 */
|
||||
export const MODE_FULL = 'full';
|
||||
|
||||
/** 全部合法档位,按宽松程度递增。顺序是 modeAtMost 的依据。 */
|
||||
export const MODES = [MODE_PLAN, MODE_WORKSPACE, MODE_FULL];
|
||||
|
||||
/** 没有显式指定时的档位。与 Gateway 的 DefaultPermissionMode 必须一致。 */
|
||||
export const DEFAULT_MODE = MODE_WORKSPACE;
|
||||
|
||||
/** 平台有原生拦截点,档位被完整执行。 */
|
||||
export const ENFORCE_NATIVE = 'native';
|
||||
/**
|
||||
* 平台有原生拦截点,但覆盖不完整(有已知缺口)。
|
||||
*
|
||||
* 实测例子:DSH 的 Landlock 沙箱受内核 ABI 版本限制,能拦下大部分写入与命令
|
||||
* 执行,但并非全部路径。只给 native / advisory 两个取值会逼出一个假陈述:
|
||||
* 标 native 是高估(人会当成硬保证),标 advisory 是低估(它确实在拦)。
|
||||
*/
|
||||
export const ENFORCE_PARTIAL = 'partial';
|
||||
/** 平台没有拦截点,档位只写进提示词。 */
|
||||
export const ENFORCE_ADVISORY = 'advisory';
|
||||
|
||||
/**
|
||||
* 把外部输入收敛成合法档位。
|
||||
*
|
||||
* 非法值 → 默认档(**不是** full)。拼错一个档位名不该换来更大的权限。
|
||||
* 与 Gateway 的 NormalizePermissionMode 同语义。
|
||||
*
|
||||
* @param {unknown} mode
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizeMode(mode) {
|
||||
return MODES.includes(mode) ? mode : DEFAULT_MODE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收敛强制力取值。空串或非法值 → advisory。
|
||||
*
|
||||
* 保守方向是 advisory 而不是 native:不能替一个没自报过的平台宣称
|
||||
* 「档位在这里是被强制的」。
|
||||
*
|
||||
* 但**显式自报的值一律原样保留**(含 partial):那是平台自己的事实陈述,
|
||||
* 把它降级到任一极端都是在替它说假话。
|
||||
*
|
||||
* @param {unknown} e
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizeEnforcement(e) {
|
||||
return e === ENFORCE_NATIVE || e === ENFORCE_PARTIAL || e === ENFORCE_ADVISORY
|
||||
? e
|
||||
: ENFORCE_ADVISORY;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取两个档位里更严的那一个。
|
||||
*
|
||||
* 先归一化再比较 —— 两个脏值都变成默认档,于是结果与参数顺序无关(可交换)。
|
||||
* Gateway 侧的 ModeAtMost 曾因为「modeRank 把未知值当最严、Normalize 把它
|
||||
* 归到默认档」而不可交换,单元测试当场抓到。两边保持同一套语义。
|
||||
*
|
||||
* @param {string} a
|
||||
* @param {string} b
|
||||
* @returns {string}
|
||||
*/
|
||||
export function modeAtMost(a, b) {
|
||||
const na = normalizeMode(a);
|
||||
const nb = normalizeMode(b);
|
||||
return MODES.indexOf(na) <= MODES.indexOf(nb) ? na : nb;
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一档会不会产生权限邮件(即需不需要人来点头)。
|
||||
*
|
||||
* 只有 workspace 档需要人:plan 档当场拒绝、full 档自动放行,两者都不问人。
|
||||
* 插件据此决定要不要把平台的权限钩子接到 `/permission/request`。
|
||||
*
|
||||
* @param {string} mode
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function modeNeedsHuman(mode) {
|
||||
return normalizeMode(mode) === MODE_WORKSPACE;
|
||||
}
|
||||
|
||||
/**
|
||||
* DSH 的沙箱模式。
|
||||
*
|
||||
* 三档与 DSH 原生的三档**一一对应** —— 这不是巧合,是同一个问题的同一个答案
|
||||
* (见 `@deepseek-ai/dsh-sandbox-policy` 的 SANDBOX_MODES)。
|
||||
*
|
||||
* @param {string} mode
|
||||
* @returns {'read-only'|'workspace-write'|'danger-full-access'}
|
||||
*/
|
||||
export function dshSandboxMode(mode) {
|
||||
switch (normalizeMode(mode)) {
|
||||
case MODE_PLAN: return 'read-only';
|
||||
case MODE_FULL: return 'danger-full-access';
|
||||
default: return 'workspace-write';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DSH 的审批策略。
|
||||
*
|
||||
* 关键实测:`danger-full-access` 对应 `approval: "never"`,而
|
||||
* `ApprovalService.decide()` 里 `if (effectivePolicy === "never") return "rejected"`
|
||||
* **在 waterfall 之前短路** —— 于是 `approval/request` 钩子根本不触发。
|
||||
*
|
||||
* 这解释了一个此前查不清的现象:本机 dsh 配了 `defaultPreset: danger-full-access`,
|
||||
* 所以整个权限转邮件链路从来没在 dsh 上跑起来过。
|
||||
*
|
||||
* @param {string} mode
|
||||
* @returns {'ask'|'never'}
|
||||
*/
|
||||
export function dshApprovalPolicy(mode) {
|
||||
return modeNeedsHuman(mode) ? 'ask' : 'never';
|
||||
}
|
||||
|
||||
/**
|
||||
* pi 侧应当守卫的工具名。
|
||||
*
|
||||
* pi 只有 `tool_call` 钩子能 `{block:true}`,没有沙箱 —— 所以档位靠
|
||||
* 「拦哪些工具」表达:
|
||||
*
|
||||
* - plan 拦 bash/write/edit(读类工具 read/grep/find/ls 不拦)
|
||||
* - workspace 拦同样三个,但 write/edit 可以查 `input.path` 判越界,
|
||||
* bash 无法判断 → 一律问人(向更严取整)
|
||||
* - full 不拦
|
||||
*
|
||||
* @param {string} mode
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function piGuardedTools(mode) {
|
||||
return normalizeMode(mode) === MODE_FULL ? [] : ['bash', 'write', 'edit'];
|
||||
}
|
||||
|
||||
/**
|
||||
* pi 在某档位下,某次工具调用该不该直接拒绝(不问人)。
|
||||
*
|
||||
* plan 档下所有被守卫的工具都直接拒绝 —— 该档语义就是「这轮不动手」,
|
||||
* 没什么可问人的,模型该把方案写在回信里。
|
||||
*
|
||||
* workspace 档返回 false(走问人流程)。full 档不会进到这里。
|
||||
*
|
||||
* @param {string} mode
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function piBlocksOutright(mode) {
|
||||
return normalizeMode(mode) === MODE_PLAN;
|
||||
}
|
||||
|
||||
/**
|
||||
* 给模型看的档位说明,放进提示词。
|
||||
*
|
||||
* 三种强制力必须说三种话:
|
||||
* - native :「会被拦下」—— 模型可以依赖它
|
||||
* - partial:「大部分会被拦下,但有已知缺口」—— 不能依赖它
|
||||
* - advisory:「平台不拦,靠你自己遵守」
|
||||
* 把 partial 当成 native 会让模型以为越界一定被拦,于是不必自己小心;
|
||||
* 当成 advisory 又会让它以为平台完全没有拦截机制,在本可以依赖的边界上过度保守。
|
||||
*
|
||||
* @param {{mode: string, enforcement: string, workspace?: string}} ctx
|
||||
* @returns {string}
|
||||
*/
|
||||
export function modeBriefing({ mode, enforcement, workspace }) {
|
||||
const m = normalizeMode(mode);
|
||||
const e = normalizeEnforcement(enforcement);
|
||||
const dir = workspace ? `\`${workspace}\`` : '本任务的工作目录';
|
||||
|
||||
if (m === MODE_FULL) {
|
||||
return '本任务权限档位:full(全权)。工具调用不需要额外授权。';
|
||||
}
|
||||
|
||||
if (m === MODE_PLAN) {
|
||||
if (e === ENFORCE_NATIVE) {
|
||||
return [
|
||||
'本任务权限档位:plan(只读)。',
|
||||
'写文件、改文件、执行命令都会被平台拦下 —— 这一档只用来查与想。',
|
||||
'请把结论、方案、需要人工执行的步骤写在回信里。需要动手请让发件人把档位改成 workspace。',
|
||||
].join('\n');
|
||||
}
|
||||
if (e === ENFORCE_PARTIAL) {
|
||||
return [
|
||||
'本任务权限档位:plan(只读)。',
|
||||
'平台会拦截写文件、改文件与执行命令,但**拦截覆盖不完整**(沙箱能力受平台/内核限制,有已知缺口)。',
|
||||
'因此不要把「会被拦下」当成保证:请主动只查与想,把结论、方案与需要人工执行的步骤写在回信里。',
|
||||
'需要动手请让发件人把档位改成 workspace。',
|
||||
].join('\n');
|
||||
}
|
||||
return [
|
||||
'本任务权限档位:plan(只读)。',
|
||||
'**这个平台无法强制这一档**,所以约束靠你自己遵守:请不要写文件、改文件或执行命令。',
|
||||
'请把结论、方案、需要人工执行的步骤写在回信里。需要动手请让发件人把档位改成 workspace。',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
if (e === ENFORCE_NATIVE) {
|
||||
return [
|
||||
`本任务权限档位:workspace。可以在 ${dir} 内读写,越出该目录的写入与命令执行会先向人类请求授权。`,
|
||||
'授权可能需要等待,也可能被拒绝 —— 被拒绝时请换一条不需要越界的做法,或在回信里说明需要人工执行哪一步。',
|
||||
].join('\n');
|
||||
}
|
||||
if (e === ENFORCE_PARTIAL) {
|
||||
return [
|
||||
`本任务权限档位:workspace。请把改动限制在 ${dir} 内;越界写入与命令执行会向人类请求授权。`,
|
||||
'但**沙箱覆盖不完整**(有已知缺口),不要依赖「越界一定被拦」:请主动守住边界,需要改该目录之外的东西时先在回信里说明。',
|
||||
].join('\n');
|
||||
}
|
||||
return [
|
||||
`本任务权限档位:workspace。请把改动限制在 ${dir} 内。`,
|
||||
'**这个平台无法强制这一档**,所以边界靠你自己遵守:需要改该目录之外的东西时,不要直接动手,先在回信里说明。',
|
||||
].join('\n');
|
||||
}
|
||||
127
plugins/zcode-mail-bridge/lib/relay-key.js
Normal file
127
plugins/zcode-mail-bridge/lib/relay-key.js
Normal file
@ -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;
|
||||
}
|
||||
179
plugins/zcode-mail-bridge/lib/sse-client.js
Normal file
179
plugins/zcode-mail-bridge/lib/sse-client.js
Normal file
@ -0,0 +1,179 @@
|
||||
/**
|
||||
* 共用 SSE 客户端:跨 TCP 分片保帧状态 + Last-Event-ID 断点续传。
|
||||
*
|
||||
* 三平台桥原本各自手写 SSE 解析,且都有同一个 bug:
|
||||
* - `evt` / `data` 是每次 `read()` 的局部变量,TCP 把一帧
|
||||
* `event: xxx\ndata: {...}\n\n` 切在换行处时,第一段只剩
|
||||
* `event:` 而第二段只有 `data:` —— 整帧被静默丢弃。
|
||||
* - 重连不带 `Last-Event-ID`,断线期间的事件只在服务端环形
|
||||
* 缓冲里等着,永远回放不出来(Gateway 有 per-agent ring buffer,
|
||||
* pi 与 homeagent 已正确利用,DSH/opencode 没有)。
|
||||
*
|
||||
* 这个模块把 pi 桥 `src/gateway.mjs` 里那份验证过的实现抽成共用件,
|
||||
* 三桥逐字节同源(deploy/check-shared-libs.sh 校验)。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 增量 SSE 帧解析器。
|
||||
*
|
||||
* `push(chunk)` 可以喂任意切分的文本片段,返回本次完整解析出的事件数组。
|
||||
* 所有跨帧状态(缓冲、当前 event/data/id)都保存在闭包里,**不随 chunk 重置** ——
|
||||
* 这正是原实现丢帧的根因。
|
||||
*
|
||||
* 协议细节:
|
||||
* - `:` 开头 = 注释/心跳,忽略
|
||||
* - `id:` / `event:` / `data:` 各取字段;`data:` 后的单个空格是分隔符
|
||||
* - 多行 data 用 `\n` 拼接
|
||||
* - 空行 = 帧结束;只有 event 与 data 都非空才派发(与旧行为一致)
|
||||
* - 兼容 CRLF
|
||||
* - `lastEventId` 在**派发之前**记下:回调抛异常也不该让断点回退。
|
||||
*
|
||||
* @returns {{push: (chunk: string) => Array<{event: string, data: string, id: string}>,
|
||||
* reset: () => void,
|
||||
* lastEventId: () => string,
|
||||
* setLastEventId: (id: string) => void}}
|
||||
*/
|
||||
export function createFrameParser() {
|
||||
let buffer = '';
|
||||
let lastEventId = '';
|
||||
let curEvent = '';
|
||||
let curData = '';
|
||||
let curId = '';
|
||||
|
||||
function push(chunk) {
|
||||
buffer += chunk;
|
||||
const events = [];
|
||||
const lines = buffer.split('\n');
|
||||
// 最后一段可能是被切断的半行,留到下一个 chunk
|
||||
buffer = lines.pop() ?? '';
|
||||
|
||||
for (let line of lines) {
|
||||
if (line.length > 0 && line.charAt(line.length - 1) === '\r') {
|
||||
line = line.slice(0, -1);
|
||||
}
|
||||
if (line.startsWith(':')) continue;
|
||||
|
||||
if (line.startsWith('id:')) {
|
||||
curId = line.slice(3).trim();
|
||||
} else if (line.startsWith('event:')) {
|
||||
curEvent = line.slice(6).trim();
|
||||
} else if (line.startsWith('data:')) {
|
||||
let value = line.slice(5);
|
||||
if (value.startsWith(' ')) value = value.slice(1);
|
||||
curData = curData.length > 0 ? `${curData}\n${value}` : value;
|
||||
} else if (line === '') {
|
||||
if (curEvent.length > 0 && curData.length > 0) {
|
||||
if (curId.length > 0) lastEventId = curId;
|
||||
events.push({ event: curEvent, data: curData, id: curId });
|
||||
}
|
||||
curEvent = '';
|
||||
curData = '';
|
||||
curId = '';
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
buffer = '';
|
||||
curEvent = '';
|
||||
curData = '';
|
||||
curId = '';
|
||||
}
|
||||
|
||||
return {
|
||||
push,
|
||||
reset,
|
||||
lastEventId: () => lastEventId,
|
||||
setLastEventId: (id) => { lastEventId = id || ''; },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} deps
|
||||
* @param {() => Record<string,string>} deps.authHeaders 认证头(每次重连重取,密钥可能已换)
|
||||
* @param {string} deps.baseURL Gateway 基地址(不带末尾 /)
|
||||
* @param {string} deps.path SSE 路径(如 /api/v1/events/stream)
|
||||
* @param {(evt: string, data: any) => void} deps.onEvent 事件分发回调
|
||||
* @param {(msg: string) => void} [deps.log] 日志回调(默认 console.error)
|
||||
* @returns {{stop: () => void}} stop() 终止重连与在途请求
|
||||
*/
|
||||
export function createSSEClient({ authHeaders, baseURL, path, onEvent, log = console.error }) {
|
||||
const controller = new AbortController();
|
||||
const parser = createFrameParser();
|
||||
|
||||
function stop() {
|
||||
controller.abort();
|
||||
}
|
||||
|
||||
function reconnect(delay) {
|
||||
if (controller.signal.aborted) return;
|
||||
setTimeout(() => connect(), delay);
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
const headers = { ...authHeaders(), Accept: 'text/event-stream' };
|
||||
// 只有 lastEventId 非空(= 已经收过事件)时才是重连:首次连接不带,
|
||||
// 否则服务端会把环形缓冲里的旧事件全回放一遍,插件重启后重复处理一批已处理的邮件。
|
||||
const lastEventID = parser.lastEventId();
|
||||
if (lastEventID) {
|
||||
headers['Last-Event-ID'] = lastEventID;
|
||||
log(`SSE 重连,从事件 ${lastEventID} 之后续传`);
|
||||
}
|
||||
|
||||
fetch(`${baseURL}${path}`, { headers, signal: controller.signal })
|
||||
.then((res) => {
|
||||
if (!res.ok || !res.body) {
|
||||
log(`SSE 建连失败: HTTP ${res.status}`);
|
||||
return reconnect(5000);
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
function read() {
|
||||
reader.read().then(({ done, value }) => {
|
||||
if (done) {
|
||||
parser.reset();
|
||||
return reconnect(3000);
|
||||
}
|
||||
for (const ev of parser.push(decoder.decode(value, { stream: true }))) {
|
||||
try {
|
||||
onEvent(ev.event, JSON.parse(ev.data));
|
||||
} catch (e) {
|
||||
log(`SSE 事件处理失败: ${e?.message || e}`);
|
||||
}
|
||||
}
|
||||
read();
|
||||
}).catch((e) => {
|
||||
if (controller.signal.aborted) return;
|
||||
log(`SSE 读取中断: ${e?.message || e}`);
|
||||
parser.reset();
|
||||
reconnect(5000);
|
||||
});
|
||||
}
|
||||
read();
|
||||
})
|
||||
.catch((e) => {
|
||||
if (controller.signal.aborted) return;
|
||||
log(`SSE 连接错误: ${e?.message || e}`);
|
||||
parser.reset();
|
||||
reconnect(5000);
|
||||
});
|
||||
}
|
||||
|
||||
connect();
|
||||
|
||||
return {
|
||||
stop,
|
||||
/**
|
||||
* 清掉断点(不终止连接)。
|
||||
*
|
||||
* 换 Gateway 地址时必须调:lastEventID 是**旧** Gateway 环形缓冲里的序号,
|
||||
* 拿去问新 Gateway 会命中一段完全无关的历史(或直接被拒),
|
||||
* 得到的事件属于别人的会话。
|
||||
*/
|
||||
reset: () => parser.setLastEventId(''),
|
||||
};
|
||||
}
|
||||
98
plugins/zcode-mail-bridge/test/grants-file.test.mjs
Normal file
98
plugins/zcode-mail-bridge/test/grants-file.test.mjs
Normal file
@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 「一直同意」的跨进程持久化测试。
|
||||
*
|
||||
* 这个功能的判据只有一条最要紧:**下一个进程还认不认**。
|
||||
* 钩子一封(一次工具调用)一个进程,所以「记在内存里」等于没记。
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createFileGrantStore, grantsFilePath } from '../lib/grants-file.mjs';
|
||||
|
||||
const tmpFile = async () => join(await mkdtemp(join(tmpdir(), 'zc-grants-')), 'g.json');
|
||||
|
||||
test('★ 授权跨进程存活(新 store 读同一个文件仍认账)', async () => {
|
||||
const f = await tmpFile();
|
||||
const s1 = createFileGrantStore(f);
|
||||
assert.equal(s1.isGranted('sess-1', 'Bash'), false);
|
||||
assert.equal(s1.grant('sess-1', 'Bash', '一直同意'), true);
|
||||
|
||||
// 模拟下一个钩子进程
|
||||
const s2 = createFileGrantStore(f);
|
||||
assert.equal(s2.isGranted('sess-1', 'Bash'), true);
|
||||
await rm(join(f, '..'), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('「同意」是单次,不落盘', async () => {
|
||||
const f = await tmpFile();
|
||||
const s = createFileGrantStore(f);
|
||||
assert.equal(s.grant('sess-1', 'Bash', '同意'), false);
|
||||
assert.equal(createFileGrantStore(f).isGranted('sess-1', 'Bash'), false);
|
||||
await rm(join(f, '..'), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('★ 反向对照:同一个文件里,一直同意与单次同意必须分道扬镳', async () => {
|
||||
// 只翻转决策文本,落盘结果必须不同 —— 否则「什么都记下来」也会让上一条通过。
|
||||
const f = await tmpFile();
|
||||
const s = createFileGrantStore(f);
|
||||
assert.equal(s.grant('sess-a', 'Bash', '一直同意'), true);
|
||||
assert.equal(s.grant('sess-b', 'Bash', '同意'), false);
|
||||
const back = createFileGrantStore(f);
|
||||
assert.equal(back.isGranted('sess-a', 'Bash'), true);
|
||||
assert.equal(back.isGranted('sess-b', 'Bash'), false);
|
||||
await rm(join(f, '..'), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('授权按会话隔离,不跨会话泄漏', async () => {
|
||||
const f = await tmpFile();
|
||||
const s = createFileGrantStore(f);
|
||||
s.grant('sess-1', 'Bash', '一直同意');
|
||||
const back = createFileGrantStore(f);
|
||||
assert.equal(back.isGranted('sess-1', 'Bash'), true);
|
||||
assert.equal(back.isGranted('sess-2', 'Bash'), false);
|
||||
await rm(join(f, '..'), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('授权按工具隔离(bash 的免批不放行 write)', async () => {
|
||||
const f = await tmpFile();
|
||||
const s = createFileGrantStore(f);
|
||||
s.grant('s', 'Bash', '一直同意');
|
||||
const back = createFileGrantStore(f);
|
||||
assert.equal(back.isGranted('s', 'Bash'), true);
|
||||
assert.equal(back.isGranted('s', 'Write'), false);
|
||||
await rm(join(f, '..'), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('撤销会话后不再免批', async () => {
|
||||
const f = await tmpFile();
|
||||
const s = createFileGrantStore(f);
|
||||
s.grant('s', 'Bash', '一直同意');
|
||||
assert.equal(s.revokeSession('s'), true);
|
||||
assert.equal(createFileGrantStore(f).isGranted('s', 'Bash'), false);
|
||||
await rm(join(f, '..'), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('文件不存在或内容损坏都当空表,不抛错', async () => {
|
||||
const f = await tmpFile();
|
||||
assert.equal(createFileGrantStore(f).isGranted('s', 'Bash'), false);
|
||||
await writeFile(f, '{ 这不是 JSON', 'utf8');
|
||||
assert.equal(createFileGrantStore(f).isGranted('s', 'Bash'), false);
|
||||
await writeFile(f, '{"sessions":{"s":"not-an-array"}}', 'utf8');
|
||||
assert.equal(createFileGrantStore(f).isGranted('s', 'Bash'), false);
|
||||
await rm(join(f, '..'), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('文件位置按 显式 > AGENTMAIL_CONFIG_DIR > ZCODE_PLUGIN_DATA > 家目录 解析', () => {
|
||||
const p = grantsFilePath({
|
||||
AGENTMAIL_ZCODE_GRANTS_FILE: '/x/g.json',
|
||||
AGENTMAIL_CONFIG_DIR: '/c',
|
||||
ZCODE_PLUGIN_DATA: '/d'
|
||||
});
|
||||
assert.equal(p, '/x/g.json');
|
||||
assert.equal(grantsFilePath({ AGENTMAIL_CONFIG_DIR: '/c', ZCODE_PLUGIN_DATA: '/d' }), '/c/permission-grants.json');
|
||||
assert.equal(grantsFilePath({ ZCODE_PLUGIN_DATA: '/d' }), '/d/permission-grants.json');
|
||||
assert.match(grantsFilePath({}), /permission-grants\.json$/);
|
||||
});
|
||||
98
plugins/zcode-mail-bridge/test/hook-policy.test.mjs
Normal file
98
plugins/zcode-mail-bridge/test/hook-policy.test.mjs
Normal file
@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 授权钩子策略层的测试。
|
||||
*
|
||||
* 档位判定是**给产品定的、不是给平台定的**:同一条「plan 档」在 ZCode 上
|
||||
* 必须与 pi 桥同义。这层是纯函数,所以可以被穷举 —— 真去起一个 ZCode 会话
|
||||
* 验一遍的代价高得多,而档位判断错了的后果是「有人以为自己在只读档,
|
||||
* 实际被跑了命令」。
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { decidePolicy, isGuardedTool, describeToolCall, PERMISSION_EVENT } from '../lib/hook-policy.mjs';
|
||||
|
||||
const call = (toolName, mode) => decidePolicy({ event: PERMISSION_EVENT, toolName, mode });
|
||||
|
||||
test('非 PermissionRequest 事件一律不表态', () => {
|
||||
for (const event of ['PreToolUse', 'PostToolUse', 'Stop', undefined, '']) {
|
||||
assert.equal(decidePolicy({ event, toolName: 'Bash', mode: 'workspace' }).action, 'none');
|
||||
}
|
||||
});
|
||||
|
||||
test('守卫工具名大小写无关,且含 ApplyPatch 别名', () => {
|
||||
for (const n of ['Bash', 'bash', 'BASH', 'Write', 'edit', 'ApplyPatch']) {
|
||||
assert.equal(isGuardedTool(n), true, n);
|
||||
}
|
||||
for (const n of ['Read', 'Grep', 'Glob', 'mcp__agentmail__send_mail', '', null]) {
|
||||
assert.equal(isGuardedTool(n), false, String(n));
|
||||
}
|
||||
});
|
||||
|
||||
test('未在守卫表里的工具不表态(退回 ZCode 自己的权限流程)', () => {
|
||||
for (const n of ['Read', 'Grep', 'WebFetch']) {
|
||||
assert.equal(call(n, 'workspace').action, 'none', n);
|
||||
}
|
||||
});
|
||||
|
||||
test('workspace 档:问人', () => {
|
||||
assert.equal(call('Bash', 'workspace').action, 'ask');
|
||||
});
|
||||
|
||||
test('档位省略时按默认(workspace)处理', () => {
|
||||
assert.equal(call('Bash', undefined).action, 'ask');
|
||||
assert.equal(call('Bash', '').action, 'ask');
|
||||
});
|
||||
|
||||
test('★ full 档:批准,而不是不表态', () => {
|
||||
// 判据的关键。ZCode 的钩子一旦被触发,说明 ZCode **本会**去问人;
|
||||
// 「不表态」等于让那个询问照常发生 —— 而 full 档的语义正是免掉它。
|
||||
// 若这里返回 none,full 档就变成了 workspace 档(发件人以为给了全权,
|
||||
// 结果每一步还在等人点)。pi 桥在该档是「不拦截」,ZCode 上的等价物就是批准。
|
||||
assert.equal(call('Bash', 'full').action, 'approve');
|
||||
});
|
||||
|
||||
test('★ plan 档:直接拒绝,且文案与 pi 桥同源', () => {
|
||||
const r = call('Bash', 'plan');
|
||||
assert.equal(r.action, 'block');
|
||||
assert.match(r.reason, /plan 档下不允许执行 Bash/);
|
||||
assert.match(r.reason, /把方案写在回信里/);
|
||||
assert.match(r.reason, /改成 workspace/);
|
||||
});
|
||||
|
||||
test('plan 档对非守卫工具仍然不表态(读与查本来就允许)', () => {
|
||||
assert.equal(call('Read', 'plan').action, 'none');
|
||||
});
|
||||
|
||||
test('★ 反向对照:只翻转档位,结论必须跟着变', () => {
|
||||
// 同样的工具名,三个档必须给出三个不同结论。
|
||||
// 没有这条,「无论什么档都返回 ask」也会让上面的断言通过。
|
||||
const results = ['plan', 'workspace', 'full'].map(m => call('Bash', m).action);
|
||||
assert.deepEqual(results, ['block', 'ask', 'approve']);
|
||||
});
|
||||
|
||||
test('未知档位按默认处理,不会静默变成 full', () => {
|
||||
// 拼错的档位若被当成 full,等于把一个打字错误变成「免授权」。
|
||||
assert.equal(call('Bash', 'worjspace').action, 'ask');
|
||||
});
|
||||
|
||||
// ─── 摘要文本 ─────────────────────────────────────────────────────
|
||||
test('Bash 的摘要给出命令本身', () => {
|
||||
const s = describeToolCall('Bash', { command: 'rm -rf /tmp/x' });
|
||||
assert.match(s, /rm -rf \/tmp\/x/);
|
||||
});
|
||||
|
||||
test('Write/Edit 的摘要给出文件路径(三种字段名都认)', () => {
|
||||
for (const key of ['file_path', 'path', 'filePath']) {
|
||||
assert.match(describeToolCall('Write', { [key]: '/tmp/a.txt' }), /\/tmp\/a\.txt/, key);
|
||||
}
|
||||
});
|
||||
|
||||
test('缺字段时给出可读的占位而不是崩', () => {
|
||||
assert.match(describeToolCall('Write', {}), /未给出/);
|
||||
assert.equal(typeof describeToolCall('Bash', undefined), 'string');
|
||||
});
|
||||
|
||||
test('过长命令被截断(写进邮件正文的东西不能无限长)', () => {
|
||||
const s = describeToolCall('Bash', { command: 'x'.repeat(5000) });
|
||||
assert.ok(s.length < 900, `实际长度 ${s.length}`);
|
||||
});
|
||||
379
plugins/zcode-mail-bridge/test/manual/permission-e2e.mjs
Normal file
379
plugins/zcode-mail-bridge/test/manual/permission-e2e.mjs
Normal file
@ -0,0 +1,379 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 授权钩子的端到端验证 —— 不需要 ZCode 登录也能跑。
|
||||
*
|
||||
* 为什么要单独验这一层:钩子是本插件里**唯一会放行危险工具**的地方。
|
||||
* 单测能证明「档位判定正确」,但证明不了「人点了同意,钩子真的收到了那条决定」——
|
||||
* 中间隔着 SSE 扇出、relay_key 配对、决策文案判定三处真实耦合。
|
||||
*
|
||||
* # 判据设计
|
||||
*
|
||||
* - **正向**:真建一条含人类的会话 → 起钩子 → 人点「同意」→ 钩子必须输出 approve
|
||||
* - **反向对照 1**:同一路径改点「拒绝」→ 必须输出 block(证明它不是恒 approve)
|
||||
* - **反向对照 2**:plan 档 → 必须**不产生任何权限邮件**就拒绝(证明档位真的在拦)
|
||||
* - **反向对照 3**:无人可问(无会话)→ 必须 fail closed 拒绝
|
||||
* - **反向对照 4**:非守卫工具(Read)→ 必须不表态(证明不是恒输出)
|
||||
*
|
||||
* 三态结论:某一项无法判定时明确报「无法判定」,不并入通过。
|
||||
*
|
||||
* 用法:node test/manual/permission-e2e.mjs [--keep]
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const HOOK = join(HERE, '../../hooks/permission.mjs');
|
||||
const GATEWAY = process.env.GATEWAY || 'http://127.0.0.1:8180';
|
||||
const HUMAN = { username: 'gui-lab', password: 'gui123456' };
|
||||
const KEEP = process.argv.includes('--keep');
|
||||
|
||||
const results = [];
|
||||
const record = (name, state, detail) => {
|
||||
results.push({ name, state, detail });
|
||||
const icon = state === '通过' ? '✓' : state === '失败' ? '✗' : '?';
|
||||
console.log(` ${icon} ${name}${detail ? ` —— ${detail}` : ''}`);
|
||||
};
|
||||
|
||||
async function login() {
|
||||
const res = await fetch(`${GATEWAY}/api/v1/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(HUMAN)
|
||||
});
|
||||
if (!res.ok) throw new Error(`登录失败 HTTP ${res.status}`);
|
||||
const cookie = (res.headers.getSetCookie?.() ?? []).map(c => c.split(';')[0]).join('; ');
|
||||
if (!cookie) throw new Error('登录成功但没拿到 cookie');
|
||||
return cookie;
|
||||
}
|
||||
|
||||
/** agent 身份发一封邮件,得到一条含人类的会话。 */
|
||||
async function openSession(agent) {
|
||||
const res = await fetch(`${GATEWAY}/api/v1/mail/send`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Agent-Name': agent.name,
|
||||
...(agent.key ? { Authorization: `Bearer ${agent.key}` } : { 'X-Agent-Secret': agent.secret })
|
||||
},
|
||||
body: JSON.stringify({
|
||||
to: HUMAN.username,
|
||||
subject: `授权桥验证 ${new Date().toISOString().slice(11, 19)}`,
|
||||
body: '这是一封用于验证授权钩子的邮件,无需处理。'
|
||||
})
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.session_id) {
|
||||
throw new Error(`建会话失败 HTTP ${res.status} ${JSON.stringify(data).slice(0, 200)}`);
|
||||
}
|
||||
return { sessionId: data.session_id, mailId: data.mail_id, subject: `授权桥验证` };
|
||||
}
|
||||
|
||||
/**
|
||||
* 以人类身份等一条**属于本会话**的待决权限。
|
||||
*
|
||||
* 必须同时按「不在启动前快照里」+「session_id 是本会话」+「agent 是 zcode」三重过滤:
|
||||
* `/permission/pending` 返回的是所有历史待决请求(本机实测积压了 6 条,
|
||||
* 里面还有 pi 桥的小写 `bash` 条目)。只按「第一条新的」取,会取到一条**无关**的
|
||||
* 旧请求 —— 于是人在界面上点了同意,钩子却在等自己那条,最后超时。
|
||||
* 第一版脚本就是这么错的:它把「钩子超时」报成了「拒绝路径通过」。
|
||||
*/
|
||||
async function waitPending(cookie, timeoutMs, { before, sessionId } = {}) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const res = await fetch(`${GATEWAY}/api/v1/permission/pending`, { headers: { Cookie: cookie } });
|
||||
if (res.ok) {
|
||||
const { requests } = await res.json().catch(() => ({ requests: [] }));
|
||||
const fresh = (requests || []).find(
|
||||
r =>
|
||||
!before?.has(r.mail_id) &&
|
||||
r.agent_name === 'zcode' &&
|
||||
(!sessionId || r.session_id === sessionId)
|
||||
);
|
||||
if (fresh) return fresh;
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 400));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function decide(cookie, mailId, decision) {
|
||||
const res = await fetch(`${GATEWAY}/api/v1/permission/decide`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Cookie: cookie },
|
||||
body: JSON.stringify({ mail_id: mailId, decision, note: `e2e:${decision}` })
|
||||
});
|
||||
const body = await res.text();
|
||||
return { ok: res.ok, status: res.status, body: body.slice(0, 200) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 跑一次钩子。
|
||||
*
|
||||
* 返回值区分三种情况:输出 approve / 输出 block / 没有输出(不表态)。
|
||||
* 把「进程崩了」与「明确拒绝」分开记 —— 两者在业务上后果完全不同。
|
||||
*/
|
||||
function runHook({ input, env, timeoutMs = 90000 }) {
|
||||
return new Promise(resolve => {
|
||||
const child = spawn(process.execPath, [HOOK], {
|
||||
env: { ...process.env, ...env },
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
let out = '';
|
||||
let err = '';
|
||||
let done = false;
|
||||
const finish = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
const trimmed = out.trim();
|
||||
let parsed = null;
|
||||
if (trimmed) {
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
parsed = { __unparsable: trimmed.slice(0, 200) };
|
||||
}
|
||||
}
|
||||
resolve({ stdout: trimmed, parsed, stderr: err.trim().split('\n').slice(-4).join('\n'), code: child.exitCode });
|
||||
};
|
||||
child.stdout.on('data', d => (out += d));
|
||||
child.stderr.on('data', d => (err += d));
|
||||
child.on('close', () => {
|
||||
if (!done) {
|
||||
// close 之后 exitCode 才是最终值;这里直接读即可
|
||||
finish();
|
||||
}
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL');
|
||||
finish();
|
||||
}, timeoutMs);
|
||||
child.on('close', () => clearTimeout(timer));
|
||||
child.stdin.end(JSON.stringify(input));
|
||||
});
|
||||
}
|
||||
|
||||
const hookInput = (toolName, toolUseId) => ({
|
||||
hook_event_name: 'PermissionRequest',
|
||||
tool_name: toolName,
|
||||
tool_input: { command: 'echo e2e' },
|
||||
session_id: 'zcode-session-probe',
|
||||
tool_use_id: toolUseId,
|
||||
permission_mode: 'default',
|
||||
cwd: '/tmp'
|
||||
});
|
||||
|
||||
async function main() {
|
||||
const agent = { name: 'zcode' };
|
||||
// 密钥从部署环境读;没有就退回 secret
|
||||
try {
|
||||
agent.key = readFileSync('/etc/agentmail/zcode.env', 'utf8').match(
|
||||
/AGENTMAIL_AGENT_KEY=(.+)/
|
||||
)?.[1]?.trim();
|
||||
} catch {}
|
||||
if (!agent.key) {
|
||||
try {
|
||||
agent.secret = readFileSync('/root/gotmp/zcode-agent-secret.txt', 'utf8').trim();
|
||||
} catch {}
|
||||
}
|
||||
if (!agent.key && !agent.secret) {
|
||||
console.error('拿不到 zcode 的凭据,无法验证');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const cookie = await login();
|
||||
console.log('已登录人类账号,开始验证\n');
|
||||
|
||||
const grantsFile = join(await mkdtemp(join(tmpdir(), 'zc-e2e-')), 'grants.json');
|
||||
const baseEnv = {
|
||||
AGENTMAIL_GATEWAY_URL: GATEWAY,
|
||||
AGENTMAIL_AGENT_NAME: 'zcode',
|
||||
...(agent.key ? { AGENTMAIL_AGENT_KEY: agent.key } : { AGENTMAIL_AGENT_SECRET: agent.secret }),
|
||||
AGENTMAIL_ZCODE_GRANTS_FILE: grantsFile,
|
||||
AGENTMAIL_PERMISSION_WAIT_MS: '60000'
|
||||
};
|
||||
|
||||
const seen = new Set();
|
||||
// 启动前先快照一次:待决列表里有历史积压(含其他 Agent 的条目),
|
||||
// 不排除掉就会把「别人的旧请求」当成我们自己刚建的。
|
||||
{
|
||||
const res = await fetch(`${GATEWAY}/api/v1/permission/pending?all=true`, {
|
||||
headers: { Cookie: cookie }
|
||||
});
|
||||
const { requests } = await res.json().catch(() => ({ requests: [] }));
|
||||
for (const r of requests || []) seen.add(r.mail_id);
|
||||
console.log(`启动前已有 ${seen.size} 条历史待决请求(已排除)\n`);
|
||||
}
|
||||
|
||||
// ── 1. 正向:workspace 档 + 同意 ─────────────────────────────
|
||||
try {
|
||||
const { sessionId, subject } = await openSession(agent);
|
||||
const hookPromise = runHook({
|
||||
input: hookInput('Bash', `toolu-approve-${Date.now()}`),
|
||||
env: {
|
||||
...baseEnv,
|
||||
AGENTMAIL_SESSION_ID: sessionId,
|
||||
AGENTMAIL_PERMISSION_MODE: 'workspace',
|
||||
AGENTMAIL_MAIL_SUBJECT: subject
|
||||
}
|
||||
});
|
||||
const pending = await waitPending(cookie, 30000, { before: seen, sessionId });
|
||||
if (!pending) {
|
||||
record('workspace 档 · 同意 → approve', '无法判定', '30 秒内没等到本会话的待决权限邮件');
|
||||
} else {
|
||||
seen.add(pending.mail_id);
|
||||
const d = await decide(cookie, pending.mail_id, '同意');
|
||||
const r = await hookPromise;
|
||||
if (!d.ok) {
|
||||
record('workspace 档 · 同意 → approve', '无法判定', `决策接口 HTTP ${d.status}`);
|
||||
} else if (r.parsed?.decision === 'approve') {
|
||||
record('workspace 档 · 同意 → approve', '通过', '钩子收到决定并放行');
|
||||
} else {
|
||||
record('workspace 档 · 同意 → approve', '失败', `钩子输出 ${r.stdout || '(空)'} code=${r.code}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
record('workspace 档 · 同意 → approve', '失败', e.message);
|
||||
}
|
||||
|
||||
// ── 2. 反向对照:同一路径改点拒绝 ────────────────────────────
|
||||
try {
|
||||
const { sessionId, subject } = await openSession(agent);
|
||||
const hookPromise = runHook({
|
||||
input: hookInput('Bash', `toolu-deny-${Date.now()}`),
|
||||
env: {
|
||||
...baseEnv,
|
||||
AGENTMAIL_SESSION_ID: sessionId,
|
||||
AGENTMAIL_PERMISSION_MODE: 'workspace',
|
||||
AGENTMAIL_MAIL_SUBJECT: subject
|
||||
}
|
||||
});
|
||||
const pending = await waitPending(cookie, 30000, { before: seen, sessionId });
|
||||
if (!pending) {
|
||||
record('反向对照 · 拒绝 → block', '无法判定', '没等到本会话的待决权限邮件');
|
||||
} else {
|
||||
seen.add(pending.mail_id);
|
||||
await decide(cookie, pending.mail_id, '拒绝');
|
||||
const r = await hookPromise;
|
||||
// 判据必须验**原因来自人的拒绝**,不能只验「输出是 block」——
|
||||
// 超时也会输出 block。第一版就因此把超时误报成了通过。
|
||||
if (r.parsed?.decision === 'block' && /拒绝/.test(r.parsed.reason || '')) {
|
||||
record('反向对照 · 拒绝 → block', '通过', '钩子把人的拒绝转成了拒绝');
|
||||
} else if (r.parsed?.decision === 'block') {
|
||||
record(
|
||||
'反向对照 · 拒绝 → block',
|
||||
'失败',
|
||||
`block 但不是人拒绝造成的(原因:${(r.parsed.reason || '').slice(0, 50)})`
|
||||
);
|
||||
} else {
|
||||
record('反向对照 · 拒绝 → block', '失败', `钩子输出 ${r.stdout || '(空)'}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
record('反向对照 · 拒绝 → block', '失败', e.message);
|
||||
}
|
||||
|
||||
// ── 3. 反向对照:plan 档必须不产生邮件就拒绝 ─────────────────
|
||||
try {
|
||||
// 隔离一个全新会话:不然「有没有产生新请求」会被别处的请求干扰,
|
||||
// 而判据一旦看错对象,就会把「别人的旧请求」当成我们的泄漏(第一版即如此)。
|
||||
const { sessionId } = await openSession(agent);
|
||||
const r = await runHook({
|
||||
input: hookInput('Bash', `toolu-plan-${Date.now()}`),
|
||||
env: { ...baseEnv, AGENTMAIL_SESSION_ID: sessionId, AGENTMAIL_PERMISSION_MODE: 'plan' },
|
||||
timeoutMs: 20000
|
||||
});
|
||||
if (r.parsed?.decision !== 'block') {
|
||||
record('反向对照 · plan 档直接拒绝', '失败', `钩子输出 ${r.stdout || '(空)'}`);
|
||||
} else if (!/plan 档/.test(r.parsed.reason || '')) {
|
||||
record('反向对照 · plan 档直接拒绝', '失败', `拒绝原因不是档位判定:${r.parsed.reason}`);
|
||||
} else {
|
||||
const leaked = await waitPending(cookie, 3000, { before: seen, sessionId });
|
||||
if (leaked) {
|
||||
seen.add(leaked.mail_id);
|
||||
record('反向对照 · plan 档直接拒绝', '失败', `仍产生了权限邮件 ${leaked.mail_id}`);
|
||||
} else {
|
||||
record('反向对照 · plan 档直接拒绝', '通过', '按档位拒绝,且未给任何人发权限邮件');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
record('反向对照 · plan 档直接拒绝', '失败', e.message);
|
||||
}
|
||||
|
||||
// ── 4. 反向对照:无人可问 → fail closed ─────────────────────
|
||||
try {
|
||||
// 造一条**只有 Agent、没有人类**的会话(zcode → pi),这才是真实的 409 场景:
|
||||
// 服务端按 会话 owner → 线索里最近的人类 解析不出决策人。
|
||||
// 传个不存在的 session id 会走 400(参数错),验不到这条路径。
|
||||
const res = await fetch(`${GATEWAY}/api/v1/mail/send`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Agent-Name': agent.name,
|
||||
...(agent.key ? { Authorization: `Bearer ${agent.key}` } : { 'X-Agent-Secret': agent.secret })
|
||||
},
|
||||
body: JSON.stringify({
|
||||
to: 'pi',
|
||||
subject: `无人可问控制组 ${Date.now()}`,
|
||||
body: '仅用于验证:这条会话上没有人类。'
|
||||
})
|
||||
});
|
||||
const noHumanSession = (await res.json().catch(() => ({})))?.session_id;
|
||||
if (!noHumanSession) {
|
||||
record('反向对照 · 无人可问 → 拒绝', '无法判定', '未能造出无人类的会话');
|
||||
} else {
|
||||
const r = await runHook({
|
||||
input: hookInput('Bash', `toolu-nohuman-${Date.now()}`),
|
||||
env: {
|
||||
...baseEnv,
|
||||
AGENTMAIL_SESSION_ID: noHumanSession,
|
||||
AGENTMAIL_PERMISSION_MODE: 'workspace'
|
||||
},
|
||||
timeoutMs: 60000
|
||||
});
|
||||
if (r.parsed?.decision === 'block') {
|
||||
record('反向对照 · 无人可问 → 拒绝', '通过', (r.parsed.reason || '').split('\n')[0].slice(0, 70));
|
||||
} else if (r.parsed === null) {
|
||||
record('反向对照 · 无人可问 → 拒绝', '失败', '不表态等于放行(邮件驱动下不允许)');
|
||||
} else {
|
||||
record('反向对照 · 无人可问 → 拒绝', '失败', `钩子输出了 ${r.stdout}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
record('反向对照 · 无人可问 → 拒绝', '失败', e.message);
|
||||
}
|
||||
|
||||
// ── 5. 反向对照:非守卫工具不表态 ───────────────────────────
|
||||
try {
|
||||
const r = await runHook({
|
||||
input: hookInput('Read', `toolu-read-${Date.now()}`),
|
||||
env: { ...baseEnv, AGENTMAIL_PERMISSION_MODE: 'workspace' },
|
||||
timeoutMs: 20000
|
||||
});
|
||||
if (r.parsed === null && r.code === 0) {
|
||||
record('反向对照 · Read 不表态', '通过', '无输出即无意见(退回 ZCode 自己的流程)');
|
||||
} else {
|
||||
record('反向对照 · Read 不表态', '失败', `输出 ${r.stdout || '(空)'} code=${r.code}`);
|
||||
}
|
||||
} catch (e) {
|
||||
record('反向对照 · Read 不表态', '失败', e.message);
|
||||
}
|
||||
|
||||
// ── 小结 ────────────────────────────────────────────────────
|
||||
const pass = results.filter(r => r.state === '通过').length;
|
||||
const fail = results.filter(r => r.state === '失败').length;
|
||||
const unknown = results.filter(r => r.state === '无法判定').length;
|
||||
console.log(`\n结果:${pass} 通过 / ${fail} 失败 / ${unknown} 无法判定`);
|
||||
|
||||
if (!KEEP) await rm(join(grantsFile, '..'), { recursive: true, force: true });
|
||||
process.exit(fail > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch(e => {
|
||||
console.error('验证脚本自身出错:', e);
|
||||
process.exit(2);
|
||||
});
|
||||
156
plugins/zcode-mail-bridge/test/permission-grants.test.mjs
Normal file
156
plugins/zcode-mail-bridge/test/permission-grants.test.mjs
Normal file
@ -0,0 +1,156 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
isAlwaysDecision,
|
||||
isApproval,
|
||||
createGrantStore,
|
||||
} from '../lib/permission-grants.js';
|
||||
|
||||
// ─── isAlwaysDecision ───
|
||||
//
|
||||
// 这个函数是整个模块里最危险的一处:判宽了就把单次授权静默升级成永久授权。
|
||||
|
||||
test('「一直同意」判为永久', () => {
|
||||
assert.equal(isAlwaysDecision('一直同意'), true);
|
||||
});
|
||||
|
||||
test('「同意」不是永久 —— 前缀匹配会把单次授权升级成永久', () => {
|
||||
// /^同意/ 之类的正则会让这条过,那意味着人点一次「同意」,
|
||||
// 后面所有命令都不再问 —— 静默越权。
|
||||
assert.equal(isAlwaysDecision('同意'), false);
|
||||
});
|
||||
|
||||
test('always / allow-always 判为永久(英文界面)', () => {
|
||||
for (const d of ['always', 'Always', 'ALWAYS', 'allow-always', 'allow_always']) {
|
||||
assert.equal(isAlwaysDecision(d), true, d);
|
||||
}
|
||||
});
|
||||
|
||||
test('allow / approve / yes 不是永久', () => {
|
||||
for (const d of ['allow', 'approve', 'yes']) {
|
||||
assert.equal(isAlwaysDecision(d), false, d);
|
||||
}
|
||||
});
|
||||
|
||||
test('「拒绝」不是永久', () => {
|
||||
assert.equal(isAlwaysDecision('拒绝'), false);
|
||||
});
|
||||
|
||||
test('两侧空白不影响判定(界面传过来的值可能带空格)', () => {
|
||||
assert.equal(isAlwaysDecision(' 一直同意 '), true);
|
||||
});
|
||||
|
||||
test('空值与 null 不是永久', () => {
|
||||
for (const d of ['', ' ', null, undefined]) {
|
||||
assert.equal(isAlwaysDecision(d), false, String(d));
|
||||
}
|
||||
});
|
||||
|
||||
test('「一直同意吧」这类多余后缀不判为永久(精确匹配)', () => {
|
||||
// 精确匹配的取舍:宁可漏判(多问一次)也不误判(静默永久放行)
|
||||
assert.equal(isAlwaysDecision('一直同意吧'), false);
|
||||
});
|
||||
|
||||
// ─── isApproval ───
|
||||
|
||||
test('同意与一直同意都是放行', () => {
|
||||
assert.equal(isApproval('同意'), true);
|
||||
assert.equal(isApproval('一直同意'), true);
|
||||
});
|
||||
|
||||
test('英文放行选项', () => {
|
||||
for (const d of ['allow', 'approve', 'always', 'yes', 'Allow']) {
|
||||
assert.equal(isApproval(d), true, d);
|
||||
}
|
||||
});
|
||||
|
||||
test('拒绝不是放行', () => {
|
||||
assert.equal(isApproval('拒绝'), false);
|
||||
});
|
||||
|
||||
test('fail closed:认不出的文本一律当拒绝', () => {
|
||||
// 关停哨兵、空值、乱码都必须落到拒绝一侧(N-9)
|
||||
for (const d of ['shutdown', '', null, undefined, '也许吧', 'maybe']) {
|
||||
assert.equal(isApproval(d), false, String(d));
|
||||
}
|
||||
});
|
||||
|
||||
// ─── createGrantStore ───
|
||||
|
||||
test('未授权时不放行', () => {
|
||||
const s = createGrantStore();
|
||||
assert.equal(s.isGranted('sess-1', 'bash'), false);
|
||||
});
|
||||
|
||||
test('点「一直同意」后同会话同工具免批', () => {
|
||||
const s = createGrantStore();
|
||||
assert.equal(s.grant('sess-1', 'bash', '一直同意'), true);
|
||||
assert.equal(s.isGranted('sess-1', 'bash'), true);
|
||||
});
|
||||
|
||||
test('点「同意」不产生免批 —— 这正是修复前的 bug', () => {
|
||||
const s = createGrantStore();
|
||||
assert.equal(s.grant('sess-1', 'bash', '同意'), false);
|
||||
assert.equal(s.isGranted('sess-1', 'bash'), false);
|
||||
});
|
||||
|
||||
test('授权不跨工具:批了 bash 不等于批了 write', () => {
|
||||
const s = createGrantStore();
|
||||
s.grant('sess-1', 'bash', '一直同意');
|
||||
assert.equal(s.isGranted('sess-1', 'write'), false);
|
||||
});
|
||||
|
||||
test('授权不跨会话:这是防越权的关键', () => {
|
||||
// 人为「审查 llmsproxy」这条会话批准的 bash,不该授权
|
||||
// 另一个发件人派来的另一条任务
|
||||
const s = createGrantStore();
|
||||
s.grant('sess-1', 'bash', '一直同意');
|
||||
assert.equal(s.isGranted('sess-2', 'bash'), false);
|
||||
});
|
||||
|
||||
test('revokeSession 清掉整条会话的全部授权', () => {
|
||||
const s = createGrantStore();
|
||||
s.grant('sess-1', 'bash', '一直同意');
|
||||
s.grant('sess-1', 'write', '一直同意');
|
||||
s.grant('sess-2', 'bash', '一直同意');
|
||||
assert.equal(s.size(), 3);
|
||||
|
||||
s.revokeSession('sess-1');
|
||||
assert.equal(s.isGranted('sess-1', 'bash'), false);
|
||||
assert.equal(s.isGranted('sess-1', 'write'), false);
|
||||
// 别的会话不受影响
|
||||
assert.equal(s.isGranted('sess-2', 'bash'), true);
|
||||
assert.equal(s.size(), 1);
|
||||
});
|
||||
|
||||
test('工具名里含 : 不会导致误删(这是不用拼接键的原因)', () => {
|
||||
const s = createGrantStore();
|
||||
s.grant('sess-1', 'mcp:bash', '一直同意');
|
||||
s.grant('sess-1:extra', 'bash', '一直同意');
|
||||
s.revokeSession('sess-1');
|
||||
// 拼接键实现(`${session}:${tool}` 按前缀删)会把下面这条一起删掉
|
||||
assert.equal(s.isGranted('sess-1:extra', 'bash'), true);
|
||||
});
|
||||
|
||||
test('空会话 id / 空工具名不产生授权(防止一个空键放行一切)', () => {
|
||||
const s = createGrantStore();
|
||||
assert.equal(s.grant('', 'bash', '一直同意'), false);
|
||||
assert.equal(s.grant('sess-1', '', '一直同意'), false);
|
||||
assert.equal(s.isGranted('', 'bash'), false);
|
||||
assert.equal(s.isGranted('sess-1', ''), false);
|
||||
assert.equal(s.size(), 0);
|
||||
});
|
||||
|
||||
test('重复授权同一对不重复计数', () => {
|
||||
const s = createGrantStore();
|
||||
s.grant('sess-1', 'bash', '一直同意');
|
||||
s.grant('sess-1', 'bash', '一直同意');
|
||||
assert.equal(s.size(), 1);
|
||||
});
|
||||
|
||||
test('revokeSession 对没授权过的会话是安全的空操作', () => {
|
||||
const s = createGrantStore();
|
||||
s.revokeSession('never-seen');
|
||||
assert.equal(s.size(), 0);
|
||||
});
|
||||
215
plugins/zcode-mail-bridge/test/permission-mode.test.mjs
Normal file
215
plugins/zcode-mail-bridge/test/permission-mode.test.mjs
Normal file
@ -0,0 +1,215 @@
|
||||
/**
|
||||
* lib/permission-mode.js 的测试 —— 四个平台逐字节共用。
|
||||
*
|
||||
* 这些判据编码了六条 opencode 实测结论。不实测就写代码会做出「看起来对但
|
||||
* 管不住」的东西,所以每条结论都在这里钉死,改坏了会当场失败。
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
MODE_PLAN, MODE_WORKSPACE, MODE_FULL, MODES, DEFAULT_MODE,
|
||||
ENFORCE_NATIVE, ENFORCE_PARTIAL, ENFORCE_ADVISORY,
|
||||
normalizeMode, normalizeEnforcement, modeAtMost, modeNeedsHuman,
|
||||
dshSandboxMode, dshApprovalPolicy,
|
||||
piGuardedTools, piBlocksOutright, modeBriefing,
|
||||
} from '../lib/permission-mode.js';
|
||||
|
||||
// ─── 归一化 ───
|
||||
|
||||
test('合法档位原样返回', () => {
|
||||
for (const m of MODES) assert.equal(normalizeMode(m), m);
|
||||
});
|
||||
|
||||
test('非法档位 fail-closed 到默认档,不是 full', () => {
|
||||
for (const bad of ['', 'FULL', 'full-access', 'workspace-write', null, undefined, 42, {}]) {
|
||||
assert.equal(normalizeMode(bad), DEFAULT_MODE, `${String(bad)} 应当归到默认档`);
|
||||
}
|
||||
assert.notEqual(DEFAULT_MODE, MODE_FULL, '默认档不能是 full');
|
||||
});
|
||||
|
||||
test('强制力保守方向是 advisory', () => {
|
||||
for (const ok of [ENFORCE_NATIVE, ENFORCE_PARTIAL, ENFORCE_ADVISORY]) {
|
||||
assert.equal(normalizeEnforcement(ok), ok, `${ok} 是平台自报的事实,必须原样保留`);
|
||||
}
|
||||
for (const bad of ['', 'NATIVE', 'enforced', null, undefined]) {
|
||||
assert.equal(normalizeEnforcement(bad), ENFORCE_ADVISORY);
|
||||
}
|
||||
});
|
||||
|
||||
test('档位顺序必须是 plan < workspace < full(modeAtMost 的依据)', () => {
|
||||
assert.deepEqual(MODES, [MODE_PLAN, MODE_WORKSPACE, MODE_FULL]);
|
||||
});
|
||||
|
||||
// ─── modeAtMost ───
|
||||
|
||||
test('modeAtMost 取更严的一档', () => {
|
||||
assert.equal(modeAtMost(MODE_PLAN, MODE_FULL), MODE_PLAN);
|
||||
assert.equal(modeAtMost(MODE_FULL, MODE_PLAN), MODE_PLAN);
|
||||
assert.equal(modeAtMost(MODE_WORKSPACE, MODE_FULL), MODE_WORKSPACE);
|
||||
assert.equal(modeAtMost(MODE_FULL, MODE_FULL), MODE_FULL);
|
||||
});
|
||||
|
||||
// Gateway 侧曾因为「未知值当最严 vs 归到默认档」两套语义而不可交换,
|
||||
// 单元测试当场抓到。两边保持同一套语义。
|
||||
test('modeAtMost 可交换(脏值也不例外)', () => {
|
||||
const all = [...MODES, 'garbage', '', null];
|
||||
for (const a of all) {
|
||||
for (const b of all) {
|
||||
assert.equal(modeAtMost(a, b), modeAtMost(b, a),
|
||||
`不可交换:(${a},${b})`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('脏值不得把 plan 抬成更宽松的档', () => {
|
||||
assert.equal(modeAtMost('garbage', MODE_PLAN), MODE_PLAN);
|
||||
});
|
||||
|
||||
// ─── modeNeedsHuman ───
|
||||
|
||||
test('只有 workspace 档需要人点头', () => {
|
||||
assert.equal(modeNeedsHuman(MODE_PLAN), false, 'plan 档当场拒绝,不问人');
|
||||
assert.equal(modeNeedsHuman(MODE_WORKSPACE), true);
|
||||
assert.equal(modeNeedsHuman(MODE_FULL), false, 'full 档自动放行,不问人');
|
||||
});
|
||||
|
||||
test('脏档位按默认档处理,即需要人(宁可多问一次)', () => {
|
||||
assert.equal(modeNeedsHuman('garbage'), true);
|
||||
assert.equal(modeNeedsHuman(''), true);
|
||||
});
|
||||
|
||||
// ─── DSH ───
|
||||
|
||||
test('DSH 三档与原生沙箱一一对应', () => {
|
||||
assert.equal(dshSandboxMode(MODE_PLAN), 'read-only');
|
||||
assert.equal(dshSandboxMode(MODE_WORKSPACE), 'workspace-write');
|
||||
assert.equal(dshSandboxMode(MODE_FULL), 'danger-full-access');
|
||||
});
|
||||
|
||||
// 关键实测:danger-full-access → approval:"never" → decide() 在 waterfall
|
||||
// 之前短路 return "rejected",approval/request 钩子根本不触发。
|
||||
test('DSH 审批策略只在 workspace 档是 ask', () => {
|
||||
assert.equal(dshApprovalPolicy(MODE_WORKSPACE), 'ask');
|
||||
assert.equal(dshApprovalPolicy(MODE_PLAN), 'never');
|
||||
assert.equal(dshApprovalPolicy(MODE_FULL), 'never');
|
||||
});
|
||||
|
||||
test('DSH 脏档位按默认档(workspace-write + ask)', () => {
|
||||
assert.equal(dshSandboxMode('garbage'), 'workspace-write');
|
||||
assert.equal(dshApprovalPolicy('garbage'), 'ask');
|
||||
});
|
||||
|
||||
// ─── pi ───
|
||||
|
||||
test('pi 在 full 档不守卫任何工具', () => {
|
||||
assert.deepEqual(piGuardedTools(MODE_FULL), []);
|
||||
});
|
||||
|
||||
test('pi 在 plan / workspace 档守卫 bash / write / edit', () => {
|
||||
for (const m of [MODE_PLAN, MODE_WORKSPACE]) {
|
||||
const g = piGuardedTools(m);
|
||||
assert.ok(g.includes('bash'));
|
||||
assert.ok(g.includes('write'));
|
||||
assert.ok(g.includes('edit'));
|
||||
}
|
||||
});
|
||||
|
||||
test('pi 不守卫读类工具', () => {
|
||||
const g = piGuardedTools(MODE_WORKSPACE);
|
||||
for (const t of ['read', 'grep', 'find', 'ls']) {
|
||||
assert.equal(g.includes(t), false, `${t} 是读类工具,不该守卫`);
|
||||
}
|
||||
});
|
||||
|
||||
test('pi 在 plan 档直接拒绝,不走问人流程', () => {
|
||||
assert.equal(piBlocksOutright(MODE_PLAN), true);
|
||||
assert.equal(piBlocksOutright(MODE_WORKSPACE), false);
|
||||
assert.equal(piBlocksOutright(MODE_FULL), false);
|
||||
});
|
||||
|
||||
// ─── modeBriefing ───
|
||||
|
||||
test('full 档的说明不提授权', () => {
|
||||
const s = modeBriefing({ mode: MODE_FULL, enforcement: ENFORCE_NATIVE });
|
||||
assert.match(s, /full/);
|
||||
assert.equal(/授权/.test(s.replace('不需要额外授权', '')), false);
|
||||
});
|
||||
|
||||
// advisory 与 native 措辞必须不同:假装 advisory 是强制的会让模型以为
|
||||
// 越界会被拦,于是不必自己小心 —— 那比做不到本身更危险。
|
||||
test('advisory 必须明说平台无法强制这一档', () => {
|
||||
const adv = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_ADVISORY });
|
||||
const nat = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_NATIVE });
|
||||
assert.match(adv, /无法强制/);
|
||||
assert.equal(/无法强制/.test(nat), false, 'native 不该说无法强制');
|
||||
assert.notEqual(adv, nat, '两种强制力的措辞必须不同');
|
||||
});
|
||||
|
||||
test('workspace 档的 advisory 版同样明说', () => {
|
||||
const adv = modeBriefing({ mode: MODE_WORKSPACE, enforcement: ENFORCE_ADVISORY, workspace: '/tmp/x' });
|
||||
assert.match(adv, /无法强制/);
|
||||
assert.match(adv, /\/tmp\/x/, '要带上具体目录');
|
||||
});
|
||||
|
||||
test('native 的 workspace 说明要交代「授权可能被拒」', () => {
|
||||
const s = modeBriefing({ mode: MODE_WORKSPACE, enforcement: ENFORCE_NATIVE, workspace: '/srv/app' });
|
||||
assert.match(s, /\/srv\/app/);
|
||||
assert.match(s, /拒绝/, '被拒时该怎么办必须说清楚,否则模型会反复重试');
|
||||
});
|
||||
|
||||
test('plan 档的说明必须告诉模型「把方案写在回信里」', () => {
|
||||
for (const e of [ENFORCE_NATIVE, ENFORCE_ADVISORY]) {
|
||||
const s = modeBriefing({ mode: MODE_PLAN, enforcement: e });
|
||||
assert.match(s, /回信/, '不给出路的话模型只会反复撞墙');
|
||||
}
|
||||
});
|
||||
|
||||
test('缺 workspace 时用兜底措辞,不出现 undefined', () => {
|
||||
const s = modeBriefing({ mode: MODE_WORKSPACE, enforcement: ENFORCE_NATIVE });
|
||||
assert.equal(/undefined/.test(s), false);
|
||||
assert.equal(/`` /.test(s), false);
|
||||
});
|
||||
|
||||
test('脏输入不炸且按默认档', () => {
|
||||
const s = modeBriefing({ mode: 'garbage', enforcement: 'garbage' });
|
||||
assert.match(s, /workspace/);
|
||||
assert.match(s, /无法强制/, '脏强制力按 advisory 处理');
|
||||
});
|
||||
|
||||
// ─── partial:有拦截点但覆盖不完整 ───
|
||||
//
|
||||
// 这个取值的全部意义就是「不许说假话」。因此判据不是「措辞好看」,
|
||||
// 而是它与两个极端的说法**都不同**,且明确交代「不要依赖会被拦」。
|
||||
test('partial 三档措辞两两不同(不能与任一极端混同)', () => {
|
||||
for (const mode of [MODE_PLAN, MODE_WORKSPACE]) {
|
||||
const nat = modeBriefing({ mode, enforcement: ENFORCE_NATIVE });
|
||||
const par = modeBriefing({ mode, enforcement: ENFORCE_PARTIAL });
|
||||
const adv = modeBriefing({ mode, enforcement: ENFORCE_ADVISORY });
|
||||
assert.notEqual(par, nat, `${mode}: partial 不能与 native 同措辞(那是高估)`);
|
||||
assert.notEqual(par, adv, `${mode}: partial 不能与 advisory 同措辞(那是低估)`);
|
||||
}
|
||||
});
|
||||
|
||||
test('partial 必须交代「覆盖不完整」且不得说「无法强制」', () => {
|
||||
for (const mode of [MODE_PLAN, MODE_WORKSPACE]) {
|
||||
const par = modeBriefing({ mode, enforcement: ENFORCE_PARTIAL });
|
||||
assert.match(par, /不完整|缺口/, `${mode}: 必须说清覆盖不完整`);
|
||||
assert.equal(/无法强制/.test(par), false,
|
||||
`${mode}: partial 平台确实在拦,「无法强制」是错的`);
|
||||
}
|
||||
});
|
||||
|
||||
test('partial 不能把「会被拦下」当成保证', () => {
|
||||
const plan = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_PARTIAL });
|
||||
// native 版说「都会被平台拦下」,partial 版必须收回这个承诺
|
||||
assert.equal(/都会被平台拦下/.test(plan), false,
|
||||
'partial 下承诺「都会拦下」会让模型不必自己小心 —— 那是 native 才成立的话');
|
||||
assert.match(plan, /不要依赖|主动/, '必须给出「主动自律」的指引');
|
||||
});
|
||||
|
||||
test('partial 与 native 一样要求把方案写在回信里(出路不能消失)', () => {
|
||||
const s = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_PARTIAL });
|
||||
assert.match(s, /回信/);
|
||||
});
|
||||
194
plugins/zcode-mail-bridge/test/relay-key.test.mjs
Normal file
194
plugins/zcode-mail-bridge/test/relay-key.test.mjs
Normal file
@ -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);
|
||||
});
|
||||
129
plugins/zcode-mail-bridge/test/sse-client.test.mjs
Normal file
129
plugins/zcode-mail-bridge/test/sse-client.test.mjs
Normal file
@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 共用 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');
|
||||
});
|
||||
Reference in New Issue
Block a user