refactor(zcode): 删掉本地 MCP 服务器与整套执行门禁 —— MCP 已内置网关

## 删了什么

**本地 MCP 服务器**(工具面已内置于网关 `POST /api/v1/mcp`,见 457d160):

    mcp/server.mjs          stdio JSON-RPC 入口
    lib/tools.mjs           11 个工具(手抄网关语义 —— 已抓到两次抄错)
    lib/mcp-rpc.mjs         手写协议层
    test/{tools,mcp-rpc,generic-mcp}.test.mjs

**执行门禁整条链**(用户裁定:直接移除):

    lib/action-tools.mjs    run_command / write_file
    lib/approval.mjs        授权判定
    lib/grants-file.mjs     「一直同意」跨进程持久化
    lib/hook-policy.mjs     档位判定
    hooks/permission.mjs    PermissionRequest 钩子
    hooks/hooks.json        钩子注册
    test/{action-tools,approval,grants-file,hook-policy}.test.mjs
    test/manual/{gate-e2e.py,permission-e2e.mjs,gate-e2e-evidence.json}

## 为什么执行门禁可以整条删,而不是留着

`run_command` / `write_file` 的门禁是**双进程审批**设计:MCP 进程问人、
ZCode 钩子进程等回答、中间靠落盘授权表对齐。三样都依赖**本地 MCP 进程**。
进程没了之后:

    没有任何代码装载 buildActionTools   ← 实测确认(只剩测试在测它)

也就是说它已经是死代码,而死代码 + 它的判据会让人误以为「这个平台有执行面」。
留着比删掉更危险。

本平台现在的姿态是**失败关闭**:平台自带 32 项危险工具被禁用,
AgentMail 侧不提供任何执行类工具 ⇒ 模型没有执行面。

## detectModeEnforcement 重写

它原本有三条依据,现在只剩一条还成立:

1. ~~平台 PermissionRequest 钩子~~ —— 目录整个删了。**留着「钩子是否注册」
   的判据只会说谎。**
2. ~~我们自己的门禁~~ —— 删了。
3. ✅ `--disallowed-tools` 禁用清单 —— 仍在,且现在是**唯一**那道。

报 `native` 的含义随之收窄为「该档位真的**没有执行面**」,而不是以前那个
「有人会来问」。降级路径(清单被清空 ⇒ advisory)仍有效,实测:

    正常配置  → native   | 平台自带危险工具已禁用 32 项…⇒ 模型无任何执行面
    清单清空  → advisory | 禁用清单自检未通过…禁用清单就是唯一那道

## 清单与 package.json

`.zcode-plugin/plugin.json` 去掉 `mcpServers` 与 `hooks`(两者的目标都已不存在)。
`package.json` 去掉 `main` 与 `verify`(已无本地入口)。

## 误删与自查

删 `test/permission-grants.test.mjs` 时**误删了一个仍在使用的共用库的测试**
—— `lib/permission-grants.js` 四方同源,dsh / opencode / pi 都还在用。
`deploy/check-shared-libs.sh` 立刻报「共用测试缺失」把它抓出来,已恢复。
若没有那道检查,这会是一个静默的覆盖损失。

## 验证

    node --test 'test/*.test.mjs'      293/293 绿(原 352,删掉 59 格死代码判据)
    deploy/check-shared-libs.sh         四方同源 rc=0
    detectModeEnforcement 实测           native / advisory 两条路径都对

## 后续

本目录现在只剩**邮件驱动**(SSE 订阅 → 起一轮 → 回信)与共用库。
若将来要在 zcode 侧恢复执行能力,需要重新设计门禁 —— 现有形状不能复用,
因为它的双进程模型随本地 MCP 进程一起消失了。
This commit is contained in:
2026-10-02 14:14:06 +08:00
parent 2f17f62871
commit d09ef395b4
23 changed files with 68 additions and 3732 deletions

View File

@ -1,329 +0,0 @@
/**
* 执行工具(lib/action-tools.mjs)的测试。
*
* 这些工具是**唯一**能动机器的路径(平台自带的 Bash/Write/Edit/js 已被
* `--disallowed-tools` 禁掉),所以每条测试都必须同时验两件事:
*
* 1. 结果对不对(执行了 / 返回了什么)
* 2. **在没获批准时,副作用真的没有发生**
*
* 第 2 条不能只看「抛错了」—— 抛错之后照样写文件是最糟的实现方式,
* 而只验抛错完全发现不了。所以拒绝场景一律配一个文件系统断言。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm, stat, mkdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { buildActionTools } from '../lib/action-tools.mjs';
import { createGrantStore } from '../lib/permission-grants.js';
/** 假 SSE:立刻发 connected,测试自己投喂决策。 */
function makeSSE() {
const s = { onEvent: null, stopped: false };
return {
state: s,
factory: ({ onEvent }) => {
s.onEvent = onEvent;
queueMicrotask(() => onEvent('connected', {}));
return { stop: () => { s.stopped = true; } };
}
};
}
function makeClient({ decision, fail } = {}) {
const state = { requests: [] };
return {
state,
client: {
baseURL: 'http://gw.test',
authHeaders: () => ({}),
async post(path, body) {
state.requests.push({ path, body });
if (fail) throw fail;
return {};
}
}
};
}
/** 人都同意场景:请求受理后立刻投喂「同意」。 */
function approving({ decision = '同意' } = {}) {
const sse = makeSSE();
const c = makeClient();
const orig = c.client.post;
c.client.post = async (p, b) => {
await orig(p, b);
queueMicrotask(() => sse.state.onEvent('permission_decision', { relay_key: b.relay_key, decision }));
return {};
};
return { ...c, factory: sse.factory };
}
async function withTools(env, fn, opts = {}) {
const dir = await mkdtemp(join(tmpdir(), 'zc-act-'));
const c = opts.client || makeClient();
const tools = buildActionTools({
client: c.client,
env: { AGENTMAIL_SESSION_ID: 'sess-1', AGENTMAIL_WORKSPACE_ROOT: dir, ...env },
grants: opts.grants || null,
createSSE: opts.createSSE,
log: () => {}
});
const byName = new Map(tools.map(t => [t.name, t]));
try {
return await fn({ byName, dir, client: c });
} finally {
await rm(dir, { recursive: true, force: true });
}
}
// ─── 工具面本身 ─────────────────────────────────────────────────────────
test('★ 工具面只暴露两个执行工具,且都声明为 destructive', async () => {
await withTools({}, async ({ byName }) => {
assert.deepEqual([...byName.keys()].sort(), ['run_command', 'write_file']);
for (const [name, t] of byName) {
assert.equal(t.annotations.readOnlyHint, false, `${name} 不该声称只读`);
// destructiveHint 必须为真:plan 档下平台的判定是
// 「permissionName==="mcp" && !destructive → allow」,声明成非破坏性会让
// 这两个工具在只读档被平台放行 —— 那时我们的门禁也会拒,但平台那层
// 已经先把话说错了。
assert.equal(t.annotations.destructiveHint, true, `${name} 必须声明为破坏性`);
}
});
});
// ─── run_command ────────────────────────────────────────────────────────
test('★ 获批准后真的执行,并返回退出码与输出', async () => {
const c = approving();
await withTools({ AGENTMAIL_PERMISSION_MODE: 'workspace' }, async ({ byName }) => {
const out = await byName.get('run_command').run({ command: 'echo hello; echo err >&2' });
assert.match(out, /退出码:0/);
assert.match(out, /hello/);
assert.match(out, /err/);
}, { client: c, createSSE: c.factory });
});
test('★ 拒绝时抛错、且命令真的没执行', async () => {
await withTools({ AGENTMAIL_PERMISSION_MODE: 'plan' }, async ({ byName, dir }) => {
const marker = join(dir, 'should-not-exist.txt');
await assert.rejects(
() => byName.get('run_command').run({ command: `touch ${marker}` }),
/未获批准/
);
assert.equal(existsSync(marker), false, '被拒的命令仍然产生了副作用');
});
});
test('★ 命令非零退出不是工具失败:原样把退出码与 stderr 交给模型', async () => {
// 抛错会让模型以为工具坏了并重试;而 `grep` 没匹配到、测试失败、
// 编译报错都是**正常的命令结果**,模型靠 stderr 判断下一步。
const c = approving();
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => {
const out = await byName.get('run_command').run({ command: 'echo boom >&2; exit 7' });
assert.match(out, /退出码:7/);
assert.match(out, /boom/);
}, { client: c, createSSE: c.factory });
});
test('★ 超时被当作命令结果报告(不能挂死整轮)', async () => {
const c = approving();
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => {
const out = await byName.get('run_command').run({ command: 'sleep 5', timeout_ms: 300 });
assert.match(out, /退出码:(SIGTERM|null)/);
assert.match(out, /超时被终止/);
assert.match(out, /上限 300ms/);
}, { client: c, createSSE: c.factory });
});
test('★ 输出过长时截断并明确说明截断了多少', async () => {
const c = approving();
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => {
const out = await byName.get('run_command').run({ command: `seq 1 20000` });
assert.match(out, /被截断,省略 \d+ 字符/);
assert.ok(out.length < 20000, '截断没生效');
}, { client: c, createSSE: c.factory });
});
test('★ 工作目录默认是本会话工作区,可用 cwd 覆盖', async () => {
const c = approving();
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName, dir }) => {
const out = await byName.get('run_command').run({ command: 'pwd' });
assert.match(out, new RegExp(dir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
}, { client: c, createSSE: c.factory });
});
test('空命令被拒(不浪费一次人工审批)', async () => {
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => {
await assert.rejects(() => byName.get('run_command').run({ command: ' ' }), /command 不能为空/);
});
});
// ─── write_file ─────────────────────────────────────────────────────────
test('★ 获批准后真的写入文件(含自动建父目录)', async () => {
const c = approving();
await withTools({ AGENTMAIL_PERMISSION_MODE: 'workspace' }, async ({ byName, dir }) => {
const target = join(dir, 'deep', 'nested', 'a.txt');
const out = await byName.get('write_file').run({ path: target, content: '内容' });
assert.match(out, /已写入/);
assert.equal(await readFile(target, 'utf8'), '内容');
}, { client: c, createSSE: c.factory });
});
test('★ 拒绝时抛错、且不创建文件也不创建目录', async () => {
await withTools({ AGENTMAIL_PERMISSION_MODE: 'plan' }, async ({ byName, dir }) => {
const target = join(dir, 'deep', 'x.txt');
await assert.rejects(() => byName.get('write_file').run({ path: target, content: 'x' }), /未获批准/);
assert.equal(existsSync(target), false, '被拒的写入仍然产生了文件');
assert.equal(existsSync(join(dir, 'deep')), false, '被拒的写入仍然创建了目录');
});
});
test('★ 保护目录:即使有人批准也拒,而且**根本不发审批请求**', async () => {
// 这不是不信任人,而是防自我强化:邮件驱动的 Agent 可能被来信诱导去改
// 网关数据库/服务单元/自己的插件代码,改完下一轮就换了一套规则。
// 所以这道判定必须在门禁**之前**,且不能消耗人的注意力。
const c = approving();
for (const target of [
'/opt/agentmail/data/agentmail.db',
'/opt/agentmail/plugins/zcode-mail-bridge/x.mjs',
'/etc/systemd/system/homeagent.service',
'/etc/agentmail/pi.env',
'/root/.agentmail-zcode/secret'
]) {
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => {
await assert.rejects(
() => byName.get('write_file').run({ path: target, content: 'x' }),
/平台保护目录/,
`${target} 应该被保护`
);
}, { client: c, createSSE: c.factory });
}
// 反向对照:保护目录外真的写了(否则上面全绿可能只是因为全都写不进去)。
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName, dir }) => {
const p = join(dir, 'ok.txt');
await byName.get('write_file').run({ path: p, content: 'ok' });
assert.equal(await readFile(p, 'utf8'), 'ok');
}, { client: c, createSSE: c.factory });
});
test('★ 保护判定不能被路径花招绕过(大小写/相对路径/..)', async () => {
for (const target of [
'/opt/agentmail/data/../data/agentmail.db',
'/opt/agentmail/./data/x',
'/etc/systemd/system/../system/x.service'
]) {
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => {
await assert.rejects(() => byName.get('write_file').run({ path: target, content: 'x' }), /平台保护目录/);
});
}
});
test('★ 相对路径按工作区解析(不能靠相对路径逃出工作区之外)', async () => {
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName, dir }) => {
const out = await byName.get('write_file').run({ path: 'sub/rel.txt', content: 'r' });
assert.match(out, new RegExp('sub/rel.txt'));
assert.equal(await readFile(join(dir, 'sub', 'rel.txt'), 'utf8'), 'r');
const st = await stat(join(dir, 'sub', 'rel.txt'));
assert.ok(st.isFile());
});
});
test('content 必须是字符串(否则会写出 "[object Object]")', async () => {
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => {
await assert.rejects(() => byName.get('write_file').run({ path: 'x.txt', content: { a: 1 } }), /必须是字符串/);
await assert.rejects(() => byName.get('write_file').run({ content: 'x' }), /path 不能为空/);
});
});
// ─── 门禁接线 ───────────────────────────────────────────────────────────
test('★ 授权请求里带上了人真正需要看的信息(命令原文 / 用途 / 目标路径)', async () => {
const c = approving();
await withTools({ AGENTMAIL_PERMISSION_MODE: 'workspace' }, async ({ byName, client }) => {
await byName.get('run_command').run({ command: 'rm -rf /tmp/x', purpose: '清理临时文件' });
const body = client.state.requests.at(-1).body;
assert.equal(body.session_id, 'sess-1');
assert.match(body.question, /rm -rf \/tmp\/x/, '批准人必须看到命令原文');
assert.match(body.context, /清理临时文件/, '用途要带给批准人');
assert.match(body.relay_key, /sess-1/);
}, { client: c, createSSE: c.factory });
});
test('★ 「一直同意」命中时不再打扰人(同一会话同一工具)', async () => {
const grants = createGrantStore();
grants.grant('sess-1', 'run_command', '一直同意');
const c = makeClient();
await withTools({ AGENTMAIL_PERMISSION_MODE: 'workspace' }, async ({ byName }) => {
const out = await byName.get('run_command').run({ command: 'echo granted' });
assert.match(out, /granted/);
}, { client: c, grants });
assert.equal(c.state.requests.length, 0, '已有授权却仍然发了审批请求');
});
test('★ full 档不打扰人(发件人已声明全权)', async () => {
const c = makeClient();
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => {
const out = await byName.get('run_command').run({ command: 'echo full' });
assert.match(out, /full/);
}, { client: c });
assert.equal(c.state.requests.length, 0);
});
test('★ 网关不可达时 fail closed(不执行、不写文件)', async () => {
const fail = Object.assign(new Error('ECONNREFUSED'), { status: 502 });
const c = makeClient({ fail });
const sse = makeSSE();
await withTools({ AGENTMAIL_PERMISSION_MODE: 'workspace' }, async ({ byName, dir }) => {
const marker = join(dir, 'nope.txt');
await assert.rejects(() => byName.get('run_command').run({ command: `touch ${marker}` }), /未获批准/);
assert.equal(existsSync(marker), false);
}, { client: c, createSSE: sse.factory });
});
// ─── 等待窗口必须容得下「人真的来点一下」────────────────────────────────
// 这一组来自一个实测缺陷:工具在等授权,客户端(ZCode)默认 30 秒就把这次
// MCP 调用掐了,模型于是回报「30 秒内未获批准」——看起来像人没理它,
// 实际是门禁的等待窗口被截断,而且**表现得完全正常**。
test('★ 授权等待被夹到 MCP 调用超时之下(并留下可发现的痕迹)', async () => {
const { resolveWaitMs, resolveMcpTimeoutMs } = await import('../lib/action-tools.mjs');
// 清单里声明的时间(本插件自己的清单,实测生效:40 秒的命令没被砍)
const declared = resolveMcpTimeoutMs();
assert.ok(declared && declared >= 60000, `清单应声明一个够长的 timeoutMs,实际 ${declared}`);
// 配置想等 90 分钟,但 MCP 只给 10 分钟 → 应夹到 10 分钟减余量
const capped = resolveWaitMs({ AGENTMAIL_PERMISSION_WAIT_MS: '5400000' }, 600000);
assert.ok(capped.waitMs < 600000, '必须小于 MCP 超时,否则调用会先被杀掉');
assert.ok(capped.waitMs >= 600000 - 120000, '也不该夹得过小(人需要时间点同意)');
assert.equal(capped.capped, true, '被夹小这件事必须能被发现(要写日志)');
// 边界:配置正好等于上限 → 不算被夹(它本来就 settle 得掉)
const onEdge = resolveWaitMs({ AGENTMAIL_PERMISSION_WAIT_MS: String(600000 - 30000) }, 600000);
assert.equal(onEdge.capped, false);
assert.equal(onEdge.waitMs, 570000);
// 反向对照:配置本来就比 MCP 超时小 → 原样使用,不报「被夹」
const fine = resolveWaitMs({ AGENTMAIL_PERMISSION_WAIT_MS: '120000' }, 600000);
assert.equal(fine.waitMs, 120000);
assert.equal(fine.capped, false);
// 反向对照:读不到清单时不猜,沿用配置(并在日志里说没校到)
const unknown = resolveWaitMs({ AGENTMAIL_PERMISSION_WAIT_MS: '540000' }, null);
assert.equal(unknown.waitMs, 540000);
assert.equal(unknown.capped, false);
});
test('★ 清单里的 timeoutMs 必须真的存在且够长(否则门禁没有可行窗口)', async () => {
const { resolveMcpTimeoutMs } = await import('../lib/action-tools.mjs');
const t = resolveMcpTimeoutMs();
assert.ok(t, '插件清单的 mcpServers.agentmail 必须有 timeoutMs');
// 默认 30 秒的 MCP 超时下,人根本来不及看到请求 —— 所以必须显式声明一个大的。
assert.ok(t > 300000, `timeoutMs=${t} 太短,人工审批窗口不够`);
});

View File

@ -1,354 +0,0 @@
/**
* 授权往返(lib/approval.mjs)的测试。
*
* 这是全项目最该被测死的一块:它决定「什么算同意」。所以每一组都配了
* **反向对照** —— 不是只验「同意时放行了」,而要同时验「别的任何东西都不放行」。
*
* 时序靠注入的假 SSE 控制:真网关的 SSE 是扇出的,假实现只需要保留
* `onEvent` 回调并在合适的时候投喂事件,就能精确复现「先建连、再发请求、
* 决策在请求之后到达」以及几个边界(决策在请求之前就到了 / 一直没到 /
* 来的是别人的决策)。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { requestApproval, tierOf, hasLocalUi } from '../lib/approval.mjs';
import { createGrantStore } from '../lib/permission-grants.js';
/** 可控的假 SSE:把 onEvent 抓住,测试自己决定何时投喂什么。 */
function makeSSE() {
const s = { onEvent: null, connected: false, stopped: false };
const factory = ({ onEvent }) => {
s.onEvent = onEvent;
// 真实现在建连后立刻下发 connected;这里用 microtask 复现「不等它也能跑」。
queueMicrotask(() => {
s.connected = true;
onEvent('connected', {});
});
return { stop: () => { s.stopped = true; } };
};
return { factory, s };
}
/** 记录请求体;可选在请求成功后投喂一条决定。
* `onRequest` 的**返回值会被当作 HTTP 响应体**返回给被测代码 ——
* 这一点至关重要:网关的幂等命中是一个 200 + `{status:"duplicate_relay"}`,
* 判据就看它。之前这里硬编码 `return {}`,把响应体丢了,于是「重复请求」
* 那条测试变成干等到超时,而失败信息看起来像被测代码的 bug。
*/
function makeClient({ onRequest } = {}) {
const state = { requests: [] };
const client = {
baseURL: 'http://gw.test',
authHeaders: () => ({ 'X-Agent-Secret': 's' }),
async post(path, body) {
state.requests.push({ path, body });
if (onRequest) {
const res = await onRequest(state, body);
return res === undefined ? {} : res;
}
return {};
}
};
return { client, state };
}
const base = env => ({
toolName: 'run_command',
question: '要执行一条命令',
context: 'echo hi',
sessionId: 'sess-1',
log: () => {},
env,
...env
});
test('★ plan 档直接拒绝执行类工具,且根本不发请求', async () => {
const { factory } = makeSSE();
const { client, state } = makeClient();
const r = await requestApproval({
...base({ tier: 'plan', createSSE: factory })
});
assert.equal(r.allowed, false);
assert.equal(r.via, 'tier');
assert.match(r.reason, /plan 档/);
// 反向对照:不该在「注定拒绝」的档位上去打扰人。
assert.equal(state.requests.length, 0, 'plan 档不该发出授权请求');
});
test('★ full 档直接放行', async () => {
const { client } = makeClient();
const r = await requestApproval({ toolName: 'run_command', tier: 'full', sessionId: 's1' });
assert.equal(r.allowed, true);
assert.equal(r.via, 'tier');
});
test('★ 「一直同意」命中时不发请求(钩子与工具共用同一张表)', async () => {
const grants = createGrantStore();
grants.grant('sess-1', 'run_command', '一直同意');
const { client, state } = makeClient();
const r = await requestApproval({ ...base({}), client, tier: 'workspace', grants });
assert.equal(r.allowed, true);
assert.equal(r.via, 'grant');
assert.equal(state.requests.length, 0);
});
test('★ 人同意 → 放行,且请求里带上了 relay_key 与选项', async () => {
const { factory, s } = makeSSE();
const { client, state } = makeClient({
onRequest: async st => {
// 真网关是「先受理、后有人决策」,所以决策必须晚于请求。
queueMicrotask(() => s.onEvent('permission_decision', { relay_key: st.requests[0].body.relay_key, decision: '同意', decided_by: 'gui-lab' }));
}
});
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 1000 });
assert.equal(r.allowed, true);
assert.equal(r.via, 'human');
assert.equal(r.decidedBy, 'gui-lab');
const sent = state.requests[0].body;
assert.equal(sent.session_id, 'sess-1');
assert.ok(sent.relay_key, '请求必须带 relay_key(决定回执怎么配对)');
assert.deepEqual(sent.options, ['同意', '一直同意', '拒绝']);
assert.equal(s.stopped, true, 'SSE 必须被关掉(否则短命进程不退出)');
});
test('★ 「一直同意」放行并落进授权表;「同意」不落', async () => {
for (const [decision, shouldPersist] of [
['一直同意', true],
['同意', false]
]) {
const { factory, s } = makeSSE();
const grants = createGrantStore();
const { client } = makeClient({
onRequest: async st => {
queueMicrotask(() => s.onEvent('permission_decision', { relay_key: st.requests[0].body.relay_key, decision }));
}
});
const r = await requestApproval({ ...base({}), client, tier: 'workspace', grants, createSSE: factory, waitMs: 1000 });
assert.equal(r.allowed, true, decision);
assert.equal(
grants.isGranted('sess-1', 'run_command'),
shouldPersist,
`${decision} 的落表行为不对`
);
}
});
test('★ 人拒绝 → 不放行,且原因里带上决策人', async () => {
const { factory, s } = makeSSE();
const { client } = makeClient({
onRequest: async st => {
queueMicrotask(() =>
s.onEvent('permission_decision', {
relay_key: st.requests[0].body.relay_key,
decision: '拒绝',
decided_by: 'gui-lab',
note: '这条命令会删数据'
})
);
}
});
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 1000 });
assert.equal(r.allowed, false);
assert.equal(r.via, 'human');
assert.match(r.reason, /拒绝/);
assert.match(r.reason, /gui-lab/);
assert.match(r.reason, /会删数据/);
});
test('★ 反向对照:一切「不是明确同意」的文本都不放行', async () => {
// 判据是「在放行白名单里」,不是「不等于拒绝」。所以拒绝、看不懂的东西、
// 平台自己的 shutdown 哨兵、空串都不能放行。
//
// 注意白名单本身是共用库的前缀匹配(`^同意|一直同意|allow|approve|always|yes`,
// 四个桥共用同一份)。所以「不同意」不放行(前缀不是同意),而「同意吧」放行 ——
// 后者是刻意接受的:决策文本来自界面按钮,前缀匹配是为了容错,不是为了放宽。
// 这里把两类都钉住,避免哪天有人把前缀匹配改成 includes 而无人发现
// (那会让「我不同意」变成同意)。
for (const decision of ['', 'maybe', 'ok?', 'shutdown', 'deny', '拒绝', '不同意', '否', 'no', undefined, null]) {
const { factory, s } = makeSSE();
const { client } = makeClient({
onRequest: async st => {
queueMicrotask(() => s.onEvent('permission_decision', { relay_key: st.requests[0].body.relay_key, decision }));
}
});
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 300 });
assert.equal(r.allowed, false, `decision=${JSON.stringify(decision)} 不该放行`);
}
// 反向对照的对照:确实在白名单里的必须放行,否则上面全绿可能只是因为门槛坏死了。
for (const decision of ['同意', '一直同意', 'allow', 'yes']) {
const { factory, s } = makeSSE();
const { client } = makeClient({
onRequest: async st => {
queueMicrotask(() => s.onEvent('permission_decision', { relay_key: st.requests[0].body.relay_key, decision }));
}
});
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 300 });
assert.equal(r.allowed, true, `decision=${JSON.stringify(decision)} 应当放行`);
}
});
test('★ 超时 → 拒绝(不能靠「没消息就是好消息」)', async () => {
const { factory } = makeSSE();
const { client } = makeClient(); // 从不投喂决策
const t0 = Date.now();
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 120 });
assert.equal(r.allowed, false);
assert.equal(r.via, 'timeout');
assert.match(r.reason, /超时/);
assert.ok(Date.now() - t0 >= 100, '必须真的等过,而不是立刻返回');
});
test('★ 别人的决策不能拿来用(relay_key 配对)', async () => {
// 同一个 Agent 可能同时有多个调用在等(模型并行发起两个动作)。
// 若不按 relay_key 过滤,B 的同意会放行 A。
const { factory, s } = makeSSE();
const { client } = makeClient({
onRequest: async st => {
const mine = st.requests[0].body.relay_key;
queueMicrotask(() => {
s.onEvent('permission_decision', { relay_key: `${mine}-other`, decision: '同意' });
setTimeout(() => s.onEvent('permission_decision', { relay_key: mine, decision: '拒绝' }), 30);
});
}
});
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 1000 });
assert.equal(r.allowed, false, '拿到别人的「同意」就是越权放行');
assert.match(r.reason, /拒绝/);
});
test('★ 永久失败(409 无人可问)当场拒绝,并把服务端建议带给模型', async () => {
const { factory } = makeSSE();
const err = Object.assign(new Error('409'), {
status: 409,
body: { error: '本线索内找不到可决策的人类', suggestion: '请让发件人把档位改成 full' }
});
const { client } = makeClient({
onRequest: async () => {
throw err;
}
});
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 1000 });
assert.equal(r.allowed, false);
assert.equal(r.via, 'permanent-failure');
assert.match(r.reason, /找不到可决策的人类/);
assert.match(r.reason, /改成 full/, '服务端的建议必须原样带给模型,否则它只能盲试');
});
test('★ 暂时失败:没有本地界面时必须拒绝(fail closed)', async () => {
const { factory } = makeSSE();
const { client } = makeClient({
onRequest: async () => {
throw new Error('ECONNREFUSED');
}
});
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 1000 });
assert.equal(r.allowed, false);
assert.equal(r.via, 'transport');
assert.match(r.reason, /没有本地界面/);
});
test('★ 暂时失败:有本地界面时明确说明没有放行', async () => {
// 桌面模式下平台自己还有流程,所以这里不放行是安全的 —— 但**不能说**放行了。
const { factory } = makeSSE();
const { client } = makeClient({
onRequest: async () => {
throw new Error('ECONNREFUSED');
}
});
const r = await requestApproval({ toolName: 'Bash', tier: 'workspace', client, createSSE: factory, waitMs: 1000 });
assert.equal(r.allowed, false);
assert.match(r.reason, /授权询问失败/);
});
test('★ 「有没有本地界面」由调用方传的 sessionId 判定(单一事实来源)', async () => {
// 反向对照:同一个暂时失败,在「有会话」与「没会话」下必须给出不同的拒绝理由。
// 这里刻意把 process.env.AGENTMAIL_SESSION_ID 设成反的,验证模块**不看它** ——
// 两个事实来源不一致时,谁也说不清到底算有界面还是没界面。
const prev = process.env.AGENTMAIL_SESSION_ID;
process.env.AGENTMAIL_SESSION_ID = '来自进程环境的干扰值';
try {
const mk = () => {
const { factory } = makeSSE();
const { client } = makeClient({ onRequest: async () => { throw new Error('boom'); } });
return { factory, client };
};
const a = mk();
const withSession = await requestApproval({
...base({}), client: a.client, tier: 'workspace', createSSE: a.factory, waitMs: 500
});
assert.match(withSession.reason, /没有本地界面/, '带会话 = 邮件驱动,必须 fail closed');
const b = mk();
const noSession = await requestApproval({
toolName: 'Bash', tier: 'workspace', client: b.client, createSSE: b.factory, waitMs: 500
});
assert.doesNotMatch(noSession.reason, /没有本地界面/, '不带会话 = 有界面,不该说成没界面');
} finally {
if (prev === undefined) delete process.env.AGENTMAIL_SESSION_ID;
else process.env.AGENTMAIL_SESSION_ID = prev;
}
});
test('tierOf / hasLocalUi 的判据', () => {
assert.equal(tierOf({}), 'workspace');
assert.equal(tierOf({ AGENTMAIL_PERMISSION_MODE: 'full' }), 'full');
// 认不出来的值 → workspace(共用库的约定),不是「免问」
assert.equal(tierOf({ AGENTMAIL_PERMISSION_MODE: 'FULL' }), 'workspace');
// 有会话 id = 邮件驱动 = 没有本地界面
assert.equal(hasLocalUi({}), true);
assert.equal(hasLocalUi({ AGENTMAIL_SESSION_ID: 'sess-1' }), false);
assert.equal(hasLocalUi({ AGENTMAIL_SESSION_ID: ' ' }), true, '空白串不算会话');
});
// ─── 幂等键必须按「这一次调用」唯一 ─────────────────────────────────────
// 一个实测缺陷,失败方式极隐蔽:键取成「会话+工具」之后,同一会话里**第二次**
// run_command 被网关判成重复请求 → HTTP 200 duplicate_relay → 请求**没发出去**、
// 永远没人来决策 → 工具干等到被 MCP 调用超时砍掉 → 模型回报「30 秒内未获批准」。
// 从状态码到措辞全都看不出问题,归因还完全错了(像是人没理它)。
test('★ 同一会话同一工具的两次调用必须用不同的幂等键', async () => {
const keys = [];
for (let i = 0; i < 2; i++) {
const { factory, s } = makeSSE();
const { client } = makeClient({
onRequest: async st => {
keys.push(st.requests[0].body.relay_key);
queueMicrotask(() => s.onEvent('permission_decision', { relay_key: st.requests[0].body.relay_key, decision: '同意' }));
}
});
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 500 });
assert.equal(r.allowed, true);
}
assert.equal(keys.length, 2);
assert.notEqual(keys[0], keys[1], '两次调用的键相同 ⇒ 第二次会被网关当重复丢弃');
// 键里仍保留会话与工具,便于事后从邮件反查(但唯一性来自随机尾)
assert.match(keys[0], /sess-1/);
assert.match(keys[0], /run_command/);
});
test('★ 网关判为重复请求时当场拒绝(不能干等到被超时砍掉)', async () => {
const { factory } = makeSSE();
const { client } = makeClient({
onRequest: async () => ({ status: 'duplicate_relay', detail: '该权限询问已转发过,本次调用未产生新邮件' })
});
const t0 = Date.now();
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 60000 });
const dt = Date.now() - t0;
assert.equal(r.allowed, false);
assert.equal(r.via, 'duplicate-relay');
assert.match(r.reason, /重复/);
assert.match(r.reason, /没有人会看到这次询问/);
assert.ok(dt < 5000, `必须立刻返回,实际等了 ${dt}ms(说明它在干等一个永远不会来的决策)`);
});
test('★ 反向对照:正常的 200(非 duplicate_relay)仍要等决策', async () => {
// 否则上面那条可能只是因为「任何 200 都被当成重复」。
const { factory } = makeSSE();
const { client } = makeClient({
onRequest: async () => ({ status: 'pending' })
});
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 150 });
assert.equal(r.via, 'timeout', '非重复的正常请求应该等,然后超时');
});

View File

@ -1,169 +0,0 @@
/**
* 通用化(去 ZCode 影子)的看守判据。
*
* ★ 2026-10-02 新增。这三条改动的判别力此前**无人看守**:
* 变异验证时「把 action-tools 挂回 server.mjs」与「platform 硬编码回 zcode」
* 都能通过全套测试 —— 因为没有一条判据看 server.mjs 实际挂了什么、
* 也没有一条看注册时上报的 platform。改动本身是对的,但没有判据就等于
* 下次谁都能悄悄改回去。
*
* 这里验的都是**结构**(源码 + 模块行为),不联网。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { GatewayClient } from '../lib/gateway.mjs';
import { buildTools, indexTools } from '../lib/tools.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..');
/**
* server.mjs 真实挂载的工具清单。
*
* 走真实模块(main 会挂 stdio,但工具集合只依赖 client/agentName),
* 复刻它那段装配逻辑 —— 直接 import 会启动服务器。
*/
function mountedToolNames() {
const client = new GatewayClient({
AGENTMAIL_AGENT_NAME: 'probe',
AGENTMAIL_AGENT_KEY: 'k'
});
return [...indexTools(buildTools({ client, agentName: client.agentName })).keys()];
}
test('★ MCP 面**不含**会动机器的工具(run_command / write_file 已移除)', () => {
const names = mountedToolNames();
// 这两个是「会动机器」的。它们的门禁为 ZCode headless 双进程审批设计,
// 脱离该宿主后语义不成立 —— 挂在通用 MCP 服务上等于给人一个没门禁的旁路。
assert.ok(!names.includes('run_command'), 'run_command 不得回到通用 MCP 面');
assert.ok(!names.includes('write_file'), 'write_file 不得回到通用 MCP 面');
// 工具清单就这些 —— 挂进来的每个工具都是产品决策,不是实现细节
assert.deepEqual(names.sort(), [
'connect_to_server',
'download_attachment',
'forward_mail',
'list_contacts',
'read_inbox',
'read_mail',
'read_thread',
'send_mail',
'session_participants',
'suggest_address',
'upload_attachment'
]);
});
test('★ server.mjs 源码不 import action-tools / grants-file(结构层钉死,不只验运行结果)', async () => {
const src = await readFile(join(ROOT, 'mcp', 'server.mjs'), 'utf8');
assert.doesNotMatch(src, /action-tools/, 'server.mjs 不该再 import 执行类工具');
assert.doesNotMatch(src, /grants-file/, '授权表是 ZCode 钩子的东西,不属通用 MCP 面');
// 反向对照:它必须真的挂上了邮件工具,否则「没挂 action-tools」可能只是空文件
assert.match(src, /buildTools/, 'server.mjs 必须装配邮件工具');
});
test('★ platform 可配置且默认 mcp(通用服务不冒充任何宿主)', () => {
// 默认
const dflt = new GatewayClient({ AGENTMAIL_AGENT_NAME: 'a', AGENTMAIL_AGENT_KEY: 'k' });
assert.equal(dflt.platform, 'mcp');
// 可配置:宿主可自报平台名(如 codex / claude-code),便于服务端统计
const custom = new GatewayClient({
AGENTMAIL_AGENT_NAME: 'a',
AGENTMAIL_AGENT_KEY: 'k',
AGENTMAIL_MCP_PLATFORM: 'my-host'
});
assert.equal(custom.platform, 'my-host');
// 空白值不得变成空字符串(会让服务端统计出一个空平台)
const blank = new GatewayClient({
AGENTMAIL_AGENT_NAME: 'a',
AGENTMAIL_AGENT_KEY: 'k',
AGENTMAIL_MCP_PLATFORM: ' '
});
assert.equal(blank.platform, 'mcp', '空白 platform 必须回落默认值,不能是空串');
});
test('★ 注册载荷带可配置 platform,且源码里不残留 zcode 字面量', async () => {
const gw = await readFile(join(ROOT, 'lib', 'gateway.mjs'), 'utf8');
// 注册时上报的是 client.platform,不是硬编码
assert.match(gw, /platform: this\.platform/, 'register 必须用可配置的 platform');
assert.doesNotMatch(gw, /platform: 'zcode'/, 'register 不得硬编码 zcode');
// 面向用户的文案不得把宿主名写死 —— 通用服务提着 ZCode 说「请在 ZCode 的
// 插件设置里填写」,会让人去一个不存在的界面找配置。
const tools = await readFile(join(ROOT, 'lib', 'tools.mjs'), 'utf8');
assert.doesNotMatch(tools, /请在 ZCode 的插件设置里/, '错误文案不得指向 ZCode 插件设置');
});
test('★ connect_to_server 对 secret-only 的 Agent 也能注册(实测 400 过)', async () => {
// 根因:`/agent/register` 只认 `Authorization: Bearer` 或 **body 里的 secret**,
// 不认 `X-Agent-Secret` 头。而 connect_to_server 早前只发了那个头 ⇒
// dsh / pi 这类 secret-only 的 Agent 调它必得 400,且模型看不出该改什么。
//
// 这条是**行为**判据:真起一个 fetch 替身,按 secret-only 装配调用该工具,
// 检查请求体里确实带了 secret。
const src = await readFile(join(ROOT, 'lib', 'tools.mjs'), 'utf8');
assert.match(
src,
/secret:\s*client\.agentSecret/,
'connect_to_server 在没有 Bearer 时必须把 secret 放进请求体'
);
// 端到端:真调一次工具,拦截 fetch 看它发出去什么。
let captured = null;
const fakeFetch = async (url, init) => {
captured = { url, init };
return {
ok: true,
status: 200,
text: async () => JSON.stringify({ status: 'registered' })
};
};
const realFetch = globalThis.fetch;
globalThis.fetch = fakeFetch;
try {
const tools = buildTools({
client: {
baseURL: 'http://fake',
agentName: 'dsh',
agentKey: '',
agentSecret: 'sekrit',
platform: 'mcp',
checkConfig: () => [],
get: async () => ({}),
post: async () => ({}),
uploadFile: async () => ({}),
downloadFile: async () => Buffer.alloc(0)
},
agentName: 'dsh'
});
const byName = indexTools(tools);
await byName.get('connect_to_server').run({});
assert.ok(captured, 'connect_to_server 应当真的发出请求');
const body = JSON.parse(captured.init.body);
assert.equal(body.secret, 'sekrit', 'secret-only 装配时 body 必须带 secret');
assert.equal(body.platform, 'mcp', 'platform 仍应是可配置值');
// 反向对照:没有 secret 时不能硬塞空串(那是另一种错)
const tools2 = buildTools({
client: {
baseURL: 'http://fake', agentName: 'a', agentKey: 'key', agentSecret: '',
platform: 'mcp', checkConfig: () => [], get: async () => ({}), post: async () => ({}),
uploadFile: async () => ({}), downloadFile: async () => Buffer.alloc(0)
},
agentName: 'a'
});
captured = null;
await indexTools(tools2).get('connect_to_server').run({});
assert.equal(
JSON.parse(captured.init.body).secret,
undefined,
'有 Bearer 时 body 不该塞 secret'
);
} finally {
globalThis.fetch = realFetch;
}
});

View File

@ -1,98 +0,0 @@
/**
* 「一直同意」的跨进程持久化测试。
*
* 这个功能的判据只有一条最要紧:**下一个进程还认不认**。
* 钩子一封(一次工具调用)一个进程,所以「记在内存里」等于没记。
*/
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$/);
});

View File

@ -1,98 +0,0 @@
/**
* 授权钩子策略层的测试。
*
* 档位判定是**给产品定的、不是给平台定的**:同一条「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}`);
});

View File

@ -1,32 +0,0 @@
/**
* read_inbox 必须收窄到**自己那条会话**(用户:「你还是没修好不同 session agent
* 收件箱隔离的问题」)。
*
* zcode 的特殊之处:一轮一个进程,驱动已经把本轮邮件会话注入 AGENTMAIL_SESSION_ID
* (授权钩子本来就用它)⇒ 直接读 env 即可,天然并发安全。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const tools = readFileSync(join(HERE, '..', 'lib', 'tools.mjs'), 'utf8');
const index = readFileSync(join(HERE, '..', 'src', 'index.mjs'), 'utf8');
test('驱动确实注入了本轮邮件会话', () => {
assert.match(index, /AGENTMAIL_SESSION_ID: sessionId/, '驱动要把邮件会话传下去');
});
test('read_inbox 会拼上会话收窄,且在调用时读 env', () => {
assert.match(tools, /const mailSessionID = process\.env\.AGENTMAIL_SESSION_ID \|\| ''/, '调用时读(不是 import 时读死)');
assert.match(tools, /mail\/inbox\?status=\$\{encodeURIComponent\(status\)\}&limit=\$\{limit\}\$\{scope\}/);
assert.match(tools, /session_id=\$\{encodeURIComponent\(mailSessionID\)\}/);
});
test('★ 判据自检:旧写法(不带 env、不带 scope)必须判红', () => {
const old = '`/mail/inbox?status=${encodeURIComponent(status)}&limit=${limit}`';
assert.ok(!/\$\{scope\}/.test(old));
assert.ok(!/AGENTMAIL_SESSION_ID/.test(old));
});

View File

@ -41,15 +41,15 @@ test('★ 通过软链指向自己 → 仍是入口(生产布局就是软链
});
test('★ 目录软链(current → <时间戳>)下的完整路径同样成立', async () => {
// 生产的软链在**目录**这一层:/opt/.../<name>/current/mcp/server.mjs
// 生产的软链在**目录**这一层:/opt/.../<name>/current/src/index.mjs
const dir = await mkdtemp(join(tmpdir(), 'zc-is-main-d-'));
try {
await mkdir(join(dir, '20260101-000000', 'mcp'), { recursive: true });
const real = join(dir, '20260101-000000', 'mcp', 'server.mjs');
await mkdir(join(dir, '20260101-000000', 'src'), { recursive: true });
const real = join(dir, "20260101-000000", "src", "index.mjs");
await writeFile(real, '// x\n', 'utf8');
await symlink('20260101-000000', join(dir, 'current'));
assert.equal(
isMainModule(pathToFileURL(real).href, join(dir, 'current', 'mcp', 'server.mjs')),
isMainModule(pathToFileURL(real).href, join(dir, "current", "src", "index.mjs")),
true
);
} finally {

View File

@ -1,212 +0,0 @@
/**
* MCP 协议层的测试。
*
* 这一层是手写的,所以它必须被穷举 —— 否则「工具没出现」「模型收不到错误」
* 这类问题只能连上 ZCode 才能发现,而那时线索要少得多。
*
* 每条断言都对应一个**真实的失败模式**,不是为覆盖率写的。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { handleMessage, handleLine, RPC_ERROR } from '../lib/mcp-rpc.mjs';
const TOOLS = [
{ name: 'read_inbox', description: '读收件箱', inputSchema: { type: 'object' } },
{ name: 'send_mail', description: '发信', inputSchema: { type: 'object' } }
];
/** 造一个 ctx;`call` 默认成功,可换成抛错来验失败路径。 */
const makeCtx = (impl = async () => '结果文本') => ({
tools: TOOLS,
call: impl
});
test('initialize 回显客户端给的协议版本', async () => {
const out = await handleMessage(
{ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-03-26' } },
makeCtx()
);
assert.equal(out.result.protocolVersion, '2025-03-26');
assert.deepEqual(out.result.capabilities, { tools: { listChanged: false } });
assert.equal(out.result.serverInfo.name, 'agentmail');
});
test('initialize 缺参数时用默认版本兜底,而不是崩', async () => {
const out = await handleMessage({ jsonrpc: '2.0', id: 1, method: 'initialize' }, makeCtx());
assert.ok(out.result.protocolVersion);
});
test('notifications/initialized 不回响应(回了会让后续调用错配)', async () => {
const out = await handleMessage(
{ jsonrpc: '2.0', method: 'notifications/initialized' },
makeCtx()
);
assert.equal(out, null);
});
test('任何无 id 的消息都不回响应', async () => {
const out = await handleMessage({ jsonrpc: '2.0', method: 'tools/list' }, makeCtx());
assert.equal(out, null);
});
test('tools/list 只暴露 name/description/inputSchema/annotations(多带的字段会被客户端拒绝)', async () => {
const out = await handleMessage({ jsonrpc: '2.0', id: 2, method: 'tools/list' }, makeCtx());
assert.equal(out.result.tools.length, 2);
for (const t of out.result.tools) {
assert.deepEqual(Object.keys(t).sort(), ['description', 'inputSchema', 'name']);
}
});
test('★ annotations 必须透传(ZCode 靠它算风险等级,plan 档据此放行)', async () => {
// 漏传的后果不是「少个提示」而是「工具在该档下全被拒」:
// ZCode 的 MCP 工具 needsApproval 恒为真,只有 plan 档的
// 「!destructive → allow」能放行,而 destructive 正是从 annotations 读的。
const ctx = {
tools: [{ name: 'read_inbox', description: 'd', inputSchema: {}, annotations: { readOnlyHint: true, destructiveHint: false } }],
call: async () => 'x'
};
const out = await handleMessage({ jsonrpc: '2.0', id: 3, method: 'tools/list' }, ctx);
assert.deepEqual(out.result.tools[0].annotations, { readOnlyHint: true, destructiveHint: false });
});
test('★ 反向对照:没有注解的工具不该凭空多出 annotations 字段', async () => {
const out = await handleMessage({ jsonrpc: '2.0', id: 4, method: 'tools/list' }, makeCtx());
assert.equal('annotations' in out.result.tools[0], false);
});
test('tools/call 成功时回 content 文本数组', async () => {
const out = await handleMessage(
{ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'read_inbox', arguments: {} } },
makeCtx()
);
assert.deepEqual(out.result, { content: [{ type: 'text', text: '结果文本' }] });
assert.equal(out.result.isError, undefined);
});
test('tools/call 把 arguments 原样交给工具', async () => {
let seen = null;
const ctx = makeCtx(async (name, args) => {
seen = { name, args };
return 'ok';
});
await handleMessage(
{
jsonrpc: '2.0',
id: 4,
method: 'tools/call',
params: { name: 'send_mail', arguments: { to: 'admin@/tmp', subject: 's' } }
},
ctx
);
assert.deepEqual(seen, { name: 'send_mail', args: { to: 'admin@/tmp', subject: 's' } });
});
test('tools/call 缺 arguments 时当空对象,不抛错', async () => {
let seen = null;
const ctx = makeCtx(async (name, args) => {
seen = args;
return 'ok';
});
const out = await handleMessage(
{ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'read_inbox' } },
ctx
);
assert.deepEqual(seen, {});
assert.equal(out.result.isError, undefined);
});
test('★ 工具执行失败回 result+isError,不回 JSON-RPC error', async () => {
// 判据的关键:模型必须能看到失败原因。若回 JSON-RPC error,
// 客户端只会显示一次协议错误,模型拿不到「为什么失败」,
// 也就无法改正(opencode 上连试 6 次发不出附件就是这个后果)。
const ctx = makeCtx(async () => {
throw new Error('HTTP 409:附件已随其他邮件发出');
});
const out = await handleMessage(
{ jsonrpc: '2.0', id: 6, method: 'tools/call', params: { name: 'send_mail', arguments: {} } },
ctx
);
assert.equal(out.error, undefined, '不该是 JSON-RPC error');
assert.equal(out.result.isError, true);
assert.match(out.result.content[0].text, /附件已随其他邮件发出/);
});
test('★ 反向对照:成功时绝不带 isError', async () => {
// 与上一条构成对照:同样的入参、同样的方法,只翻转工具行为,
// isError 必须跟着翻转。否则「总是 isError」也会让上一条通过。
const ok = await handleMessage(
{ jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'send_mail', arguments: {} } },
makeCtx()
);
const bad = await handleMessage(
{ jsonrpc: '2.0', id: 8, method: 'tools/call', params: { name: 'send_mail', arguments: {} } },
makeCtx(async () => {
throw new Error('x');
})
);
assert.equal(ok.result.isError, undefined);
assert.equal(bad.result.isError, true);
});
test('tools/call 未知工具名回 INVALID_PARAMS', async () => {
const out = await handleMessage(
{ jsonrpc: '2.0', id: 9, method: 'tools/call', params: { name: 'not_a_tool' } },
makeCtx()
);
assert.equal(out.error.code, RPC_ERROR.INVALID_PARAMS);
assert.equal(out.result, undefined);
});
test('tools/call 缺 name 回 INVALID_PARAMS', async () => {
const out = await handleMessage(
{ jsonrpc: '2.0', id: 10, method: 'tools/call', params: {} },
makeCtx()
);
assert.equal(out.error.code, RPC_ERROR.INVALID_PARAMS);
});
test('未知方法回 METHOD_NOT_FOUND', async () => {
const out = await handleMessage({ jsonrpc: '2.0', id: 11, method: 'x/y' }, makeCtx());
assert.equal(out.error.code, RPC_ERROR.METHOD_NOT_FOUND);
});
test('ping 有响应', async () => {
const out = await handleMessage({ jsonrpc: '2.0', id: 12, method: 'ping' }, makeCtx());
assert.deepEqual(out.result, {});
});
test('缺 method 回 INVALID_REQUEST', async () => {
const out = await handleMessage({ jsonrpc: '2.0', id: 13 }, makeCtx());
assert.equal(out.error.code, RPC_ERROR.INVALID_REQUEST);
});
test('id 原样回显(含 0 与字符串 id)', async () => {
for (const id of [0, 'abc', 42]) {
const out = await handleMessage({ jsonrpc: '2.0', id, method: 'ping' }, makeCtx());
assert.equal(out.id, id);
}
});
// ─── handleLine:分帧与解析 ───────────────────────────────────────
test('handleLine 空行不产生响应', async () => {
assert.equal(await handleLine('', makeCtx()), null);
assert.equal(await handleLine(' ', makeCtx()), null);
});
test('handleLine 非法 JSON 回带 id=null 的解析错误', async () => {
// 必须回:不回的话客户端会一直等这一条的响应。
const out = await handleLine('{not json', makeCtx());
const parsed = JSON.parse(out);
assert.equal(parsed.error.code, RPC_ERROR.PARSE);
assert.equal(parsed.id, null);
});
test('handleLine 输出是单行(换行会破坏分帧)', async () => {
const out = await handleLine(
JSON.stringify({ jsonrpc: '2.0', id: 14, method: 'tools/call', params: { name: 'read_inbox' } }),
makeCtx(async () => '多行\n文本\n在此')
);
assert.equal(out.includes('\n'), false, '响应里不能有裸换行(应被转义进 JSON 字符串)');
assert.match(JSON.parse(out).result.content[0].text, /多行\n文本/);
});

View File

@ -1,61 +0,0 @@
/**
* 五个读类端点的请求都要带上**自己那条邮件会话**(zcode)。
*
* read_inbox 早就有这一维(缺陷:列表按 Agent 列且按契约标已读 ⇒ A 会话标掉
* B 会话的未读 ⇒ 静默丢信)。服务端现在拿它多干一件事:**由这条会话反查工作区**,
* 只有同工作区的会话才放行 —— 一个 Agent 同时服务所有工作区,不收窄时在 TrueAgent
* 里干活的 worker 能读到 agentmail 的整条线索(用户 2026-09-14 报的越界)。
*
* 服务端语义由 server/internal/repo/workspace_scope_test.go 负责;这里只验接线。
*
* 判据两侧都钉:**包住了**(withScope(...) 的整句存在)与**没包住**(去掉包装的
* 那句不存在)。只验前者的话,把 withScope 写成恒等函数也能过。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const tools = readFileSync(join(HERE, '..', 'lib', 'tools.mjs'), 'utf8');
// 每个端点两句话:包住的 / 没包住的。
const ENDPOINTS = [
['read_mail',
'withScope(`/agent/mail/${encodeURIComponent(id)}?body_limit=0`)',
'client.get(`/agent/mail/${encodeURIComponent(id)}?body_limit=0`)'],
['read_thread',
'withScope(`/agent/mail/${encodeURIComponent(id)}/thread${qs}`)',
'client.get(`/agent/mail/${encodeURIComponent(id)}/thread${qs}`)'],
['suggest_address',
'withScope(`/agent/contacts/suggest?${qs.toString()}`)',
'client.get(`/agent/contacts/suggest?${qs.toString()}`)'],
['list_contacts',
'withScope(`/agent/contacts?limit=${limit}`)',
'client.get(`/agent/contacts?limit=${limit}`)'],
['session_participants',
'withScope(`/agent/sessions/${encodeURIComponent(sid)}/participants`)',
'client.get(`/agent/sessions/${encodeURIComponent(sid)}/participants`)'],
];
for (const [name, scoped, bare] of ENDPOINTS) {
test(`★ ${name} 的请求走 withScope(...)`, () => {
assert.ok(bare.includes('client.get('), '夹具形状不对:对照组必须是没包住的那句');
assert.ok(tools.includes(scoped), `${name} 的 URL 没有包在 withScope 里:${scoped}`);
assert.ok(!tools.includes(bare), `${name} 还有一处没包住的写法:${bare}`);
});
}
test('★ forward_mail 也带上收窄(它读的是原文)', () => {
const scoped = 'withScope(`/mail/${encodeURIComponent(str(a.mail_id))}/forward`)';
const bare = 'client.post(\n `/mail/${encodeURIComponent(str(a.mail_id))}/forward`,';
assert.ok(tools.includes(scoped), '转发的 URL 没有包在 withScope 里');
assert.ok(!tools.includes(bare), '转发还有一处没包住的写法');
});
test('withScope 在调用时读 env,且自己判断分隔符', () => {
assert.match(tools, /const sid = process\.env\.AGENTMAIL_SESSION_ID \|\| ''/, '调用时读,不是 import 时读死');
assert.ok(tools.includes("path.includes('?') ? '&' : '?'"), '已有查询串要用 & 分隔');
assert.match(tools, /if \(!sid\) return path/, '拿不到会话就原样返回,不拼半截 URL');
});

View File

@ -1,244 +0,0 @@
/**
* 工具层的测试。
*
* 用假客户端,不发真请求 —— 这里要验的是**参数处理与渲染**,
* 那才是各平台容易走样的地方(真请求由端到端演练覆盖)。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFile, writeFile, mkdtemp } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { buildTools, indexTools } from '../lib/tools.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
/** 造一个假客户端:记录调用,按需返回。 */
function fakeClient({ config = [], responses = {} } = {}) {
const calls = [];
return {
calls,
baseURL: 'http://fake',
agentName: 'zcode',
checkConfig: () => config,
async get(path) {
calls.push({ method: 'GET', path });
for (const key of Object.keys(responses)) {
if (path.startsWith(key)) return responses[key];
}
return {};
},
async post(path, body) {
calls.push({ method: 'POST', path, body });
return { mail_id: 'sent-1', session_id: 'sess-1' };
},
async uploadFile() {
return { attachment_id: 'att-1', filename: 'a.txt', size_bytes: 12 };
},
async downloadFile() {
return Buffer.from('hello');
}
};
}
const toolsOf = client => indexTools(buildTools({ client, agentName: 'zcode' }));
test('工具数量与名称稳定(改名会破坏跨平台一致性)', () => {
const t = toolsOf(fakeClient());
assert.deepEqual([...t.keys()].sort(), [
'connect_to_server',
'download_attachment',
'forward_mail',
'list_contacts',
'read_inbox',
'read_mail',
'read_thread',
'send_mail',
'session_participants',
'suggest_address',
'upload_attachment'
]);
});
test('★ 与 pi 桥的工具名逐一对齐(少一个就会让某平台「不会回信」)', async () => {
// 跨平台一致性是被真实问题逼出来的约定:模型在某个平台上找不到
// 熟悉的工具名,行为就与其它平台不同。这条断言让「改名」在 CI 里红,
// 而不是等到某个平台的演练才发现。
const piSrc = await readFile(
join(HERE, '../../pi-mail-bridge/src/tools.mjs'),
'utf8'
).catch(() => null);
if (piSrc === null) {
// pi 桥不在旁边(例如插件被单独拷走)时无法对照 ——
// 明确说明跳过,而不是假装通过。
assert.ok(true, '跳过:找不到 pi 桥源码用于对照');
return;
}
const piNames = new Set([...piSrc.matchAll(/name:\s*'([a-z_]+)'/g)].map(m => m[1]));
const mine = new Set(toolsOf(fakeClient()).keys());
const missing = [...piNames].filter(n => !mine.has(n));
assert.deepEqual(missing, [], `本插件缺少 pi 桥有的工具:${missing.join(', ')}`);
});
// ─── send_mail 的参数处理 ─────────────────────────────────────────
test('★ send_mail 接受 JSON 字符串形式的 attachment_ids(opencode 上连试 6 次失败的形状)', async () => {
const c = fakeClient();
await toolsOf(c).get('send_mail').run({
to: 'admin@/tmp',
subject: 's',
body: 'b',
attachment_ids: '["10e73e9f-1"]' // ← 模型实际会这么写
});
const sent = c.calls.find(x => x.path === '/mail/send');
assert.deepEqual(sent.body.attachment_ids, ['10e73e9f-1']);
});
test('send_mail 也接受数组、单 id、逗号分隔', async () => {
for (const [input, want] of [
[['a', 'b'], ['a', 'b']],
['a', ['a']],
['a, b', ['a', 'b']],
['a b', ['a', 'b']]
]) {
const c = fakeClient();
await toolsOf(c).get('send_mail').run({
to: 'x@/p',
subject: 's',
body: 'b',
attachment_ids: input
});
assert.deepEqual(c.calls.find(x => x.path === '/mail/send').body.attachment_ids, want);
}
});
test('★ 没有附件时不带 attachment_ids 字段(带空数组会被服务端当「要挂附件」)', async () => {
for (const input of [undefined, null, '', [], ['', null]]) {
const c = fakeClient();
await toolsOf(c).get('send_mail').run({
to: 'x@/p',
subject: 's',
body: 'b',
attachment_ids: input
});
const body = c.calls.find(x => x.path === '/mail/send').body;
assert.equal('attachment_ids' in body, false, `输入 ${JSON.stringify(input)} 时不该带`);
}
});
test('send_mail 缺必填字段时明确报错(且不发请求)', async () => {
const t = toolsOf(fakeClient());
for (const args of [{}, { to: 'x@/p' }, { to: 'x@/p', subject: 's' }]) {
await assert.rejects(() => t.get('send_mail').run(args), /缺少必填字段/);
}
});
test('send_mail 只透传有值的可选字段', async () => {
const c = fakeClient();
await toolsOf(c).get('send_mail').run({
to: 'x@/p',
subject: 's',
body: 'b',
cc: '',
reply_to: 'm1',
session_alias: '',
max_rounds: 5
});
const body = c.calls.find(x => x.path === '/mail/send').body;
assert.deepEqual(Object.keys(body).sort(), ['body', 'max_rounds', 'reply_to', 'subject', 'to']);
});
// ─── read_inbox ───────────────────────────────────────────────────
test('read_inbox 只把本次列出来的未读标为已读', async () => {
const c = fakeClient({
responses: {
'/mail/inbox': {
mails: [
{ mail_id: 'm1', subject: '一', body: 'x', from: 'pi' },
{ mail_id: 'm2', subject: '二', body: 'y', from: 'dsh' }
]
}
}
});
const out = await toolsOf(c).get('read_inbox').run({});
assert.match(out, /一/);
const mark = c.calls.find(x => x.path === '/mail/read');
assert.ok(mark, '应该标记已读');
assert.deepEqual(mark.body.mail_ids, ['m1', 'm2']);
});
test('read_inbox 空收件箱给出可读文本', async () => {
const c = fakeClient({ responses: { '/mail/inbox': { mails: [] } } });
assert.match(await toolsOf(c).get('read_inbox').run({}), /收件箱为空/);
});
test('★ 标记已读失败不影响读取结果', async () => {
// 正文已经拿到了,代价只是下次重复看到 —— 比丢掉这次读取轻得多。
const c = fakeClient({ responses: { '/mail/inbox': { mails: [{ mail_id: 'm1', subject: '一', body: 'x' }] } } });
c.post = async () => {
throw new Error('500');
};
const out = await toolsOf(c).get('read_inbox').run({});
assert.match(out, /一/);
});
// ─── 配置缺失 ─────────────────────────────────────────────────────
test('★ 未配置密钥时每次调用都明确报错(而不是收到 401 再猜)', async () => {
const c = fakeClient({ config: ['AGENTMAIL_AGENT_KEY'] });
const t = toolsOf(c);
await assert.rejects(
() => t.get('read_inbox').run({}),
/未配置完成.*AGENTMAIL_AGENT_KEY/
);
// 关键:真的一次请求都没发出去
assert.equal(c.calls.length, 0);
});
test('每个工具都受配置校验保护(漏一个就会发出匿名请求)', async () => {
const c = fakeClient({ config: ['AGENTMAIL_AGENT_NAME'] });
const t = toolsOf(c);
const argsByName = {
read_inbox: {},
read_mail: { mail_id: 'm' },
read_thread: { mail_id: 'm' },
send_mail: { to: 'x@/p', subject: 's', body: 'b' },
forward_mail: { mail_id: 'm', to: 'x@/p' },
upload_attachment: { file_path: '/tmp/x' },
download_attachment: { attachment_id: 'a', save_path: '/tmp/y' },
suggest_address: {},
list_contacts: {},
connect_to_server: {},
session_participants: { session_id: 's' }
};
for (const [name, tool] of t) {
if (name === 'connect_to_server') {
// 它是唯一**刻意**绕过 guard 的工具:配置缺失时它负责说清楚缺什么
// (见 lib/tools.mjs 里的注释),所以要断言另一种行为。
const out = await tool.run({});
assert.match(out, /未配置完成|已连接/, name);
continue;
}
await assert.rejects(() => tool.run(argsByName[name]), /未配置完成/, name);
}
assert.equal(c.calls.length, 0, '任何工具都不该在缺配置时发出请求');
});
// ─── 附件 ─────────────────────────────────────────────────────────
test('upload_attachment 提示必须把 id 带进 send_mail 才发得出去', async () => {
// 用真文件:这里要连真实路径一起验(读文件 → multipart 上传),
// 把 uploadLocalFile 抹掉就测不到「路径写错」这种最常见的失败。
const dir = await mkdtemp(join(tmpdir(), 'zc-upload-'));
const filePath = join(dir, 'a.txt');
await writeFile(filePath, 'hello');
const out = await toolsOf(fakeClient()).get('upload_attachment').run({ file_path: filePath });
assert.match(out, /att-1/);
assert.match(out, /attachment_ids/);
});
test('download_attachment 报告落盘路径与大小', async () => {
const out = await toolsOf(fakeClient())
.get('download_attachment')
.run({ attachment_id: 'a1', save_path: '/tmp/out.bin' });
assert.match(out, /\/tmp\/out\.bin/);
});