Files
MailUI4Agents/plugins/zcode-mail-bridge/test/manual/driver-real-e2e.mjs
JianFeeeee 3a6d572020 feat(zcode): 工具加 MCP 注解 + headless 档位映射改为 plan(否则一个工具都用不了)
## 逆出 ZCode 的 MCP 权限判定,并据此让工具真的可用

逐字逆自 CLI 产物:

  Ari():  annotations.readOnlyHint === true → riskLevel "low"
          annotations.destructiveHint === true → riskLevel "high"
          needsApproval = true   ← **硬编码为真,与注解无关**
  checkBuildMode(): needsApproval || destructive || sideEffectScope !== "none" → ask
  checkPlanMode():  permissionName === "mcp" && !destructive → allow

两条合起来的结论不直观但很关键:

- **build 档下每一个 MCP 工具都要审批**(needsApproval 恒真),而 headless
  模式没有交互式审批客户端 ⇒ 全被拒。实测:模型连 read_inbox 都调不动,
  只能从提示词里猜;更糟的是它**绕道**用 Bash 去读网关的 sqlite WAL 文件
  (它自己在回信里如实交代了这件事)。
- **plan 档下只要不声明 destructive,MCP 工具直接放行**。

于是两处改动:

1. `lib/tools.mjs` 给每个工具加真实注解(读类 readOnlyHint,写类
   destructiveHint:false——它们确实不破坏任何东西);`lib/mcp-rpc.mjs` 透传
   annotations。**漏传不是"少个提示",而是工具在该档下全被拒**。
2. `src/turn-mode.mjs` 的 workspace 档映射从 build 改为 **plan**。
   build 在本环境等于「什么都不能做」,那不是保守而是不可用;plan 才是真的
   fail-closed:危险的自带工具被平台直接拒,能用的只有我们声明为非破坏性的工具。
   日志会明确写出为什么退档。可用 `AGENTMAIL_ZCODE_MODE_MAP` 覆盖
   (平台修好钩子后只改配置就能恢复 build,不必等发版)。

## 真模型验证

场景 A 的判据同时加强:**正文本标记只出现在邮件正文里**(驱动的提示词只带主题
与 mail_id),所以模型必须真的读信才可能答对。通过 —— 约 20-30 秒一轮。

反过来说,早先那版「通过」是假的:标记在主题里,模型从提示词抄一遍就行。

## 仍然做不到的(见 README 已知缺口)

授权桥(PermissionRequest 钩子)在本版本(3.10.2 / CLI 0.16.5)**不可用**:
有时根本不触发,触发时在 ~5ms 内失败且**命令从未被 spawn**
(用「钩子写 marker 文件」的副作用验证,process 与 command 两种类型都一样)。
所以 workspace 档「危险操作问人」目前在 headless 下无法实现。

单元 329/329。
2026-09-12 16:49:28 +08:00

290 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* 真模型端到端:**唯一用真 ZCode + 真模型跑的一段**。
*
* 前两个脚本分别验了「授权钩子」(桩 stdin/stdout与「驱动」桩 CLI
* 这个验的是它们合起来、并且模型真的在其中的那一环:
*
* 人来信 → 驱动起一轮 ZCode真模型→ 模型调 read_inbox 读信
* → 模型调 Bash触发 PermissionRequest 钩子)
* → 钩子把授权询问发给人类 → 人在界面上点同意
* → 钩子收到决定并放行 → bash 执行 → 模型把结论说出来
* → 驱动把最终文本回信 → 人收到回信
*
* 用**本地 llmsproxy**`http://127.0.0.1:8081/v1`)作为模型,不走 Z.AI OAuth ——
* 服务器上没有浏览器,让 headless 依赖一次人工登录不合适。
*
* # 判据设计
*
* - **A不需要授权**:让模型把一句话原样回过来。判据是回信里出现那个唯一标记 ——
* 这同时证明「模型读了信」「模型产出了文本」「驱动把文本回了出去」。
* - **B需要授权**:让模型用 bash 打出一个唯一标记。判据是**三个观测点都要成立**
* ① 出现了一条属于本会话的待决权限(钩子真的把询问发出去了)
* ② 人点同意后回信里出现那个标记(工具真的执行了)
* ③ 该次 bash 审批请求只出现一次(不是每次工具都问,也不是没问)
* ②单独成立不够 —— 模型可以不用 bash、直接编出那句话。所以还要看它有没有真的
* 走过审批(①),以及最终工作目录里有没有痕迹(下面用文件系统核对)。
*
* 用法: node test/manual/driver-real-e2e.mjs [--scenario a|b|all]
*/
import { spawn } from 'node:child_process';
import { mkdtemp, rm, readFile, readdir } from 'node:fs/promises';
import { mkdirSync } 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 DRIVER = join(HERE, '../../src/index.mjs');
const GATEWAY = process.env.GATEWAY || 'http://127.0.0.1:8180';
const HUMAN = { username: 'gui-lab', password: 'gui123456' };
const SCENARIO = (process.argv.includes('--scenario')
? process.argv[process.argv.indexOf('--scenario') + 1]
: 'all') || 'all';
const results = [];
const record = (name, state, detail) => {
results.push({ name, state, detail });
const icon = state === '通过' ? '✓' : state === '失败' ? '✗' : '?';
console.log(` ${icon} ${name}${detail ? ` —— ${detail}` : ''}`);
};
const sleep = ms => new Promise(r => setTimeout(r, ms));
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}`);
return (res.headers.getSetCookie?.() ?? []).map(c => c.split(';')[0]).join('; ');
}
/** 人类发信给 zcode返回 session_id。 */
async function humanSend(cookie, subject, body) {
const res = await fetch(`${GATEWAY}/api/v1/me/mail/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookie },
body: JSON.stringify({ to: 'zcode', subject, body })
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(`发信失败 HTTP ${res.status} ${JSON.stringify(data).slice(0, 200)}`);
return data;
}
/** 收件箱里按唯一标记找 zcode 的回信。 */
async function findReply(cookie, marker, timeoutMs) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const res = await fetch(`${GATEWAY}/api/v1/me/mail/inbox?limit=30`, { headers: { Cookie: cookie } });
if (res.ok) {
const data = await res.json().catch(() => ({}));
const hit = (data.mails || []).find(
m =>
String(m.subject || '').includes(marker) &&
String(m.from_name || '') === 'zcode' &&
!String(m.subject || '').startsWith('处理失败')
);
if (hit) return hit;
}
await sleep(1000);
}
return null;
}
async function findFailure(cookie, marker, timeoutMs) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const res = await fetch(`${GATEWAY}/api/v1/me/mail/inbox?limit=30`, { headers: { Cookie: cookie } });
if (res.ok) {
const data = await res.json().catch(() => ({}));
const hit = (data.mails || []).find(
m => String(m.subject || '').includes(marker) && String(m.subject || '').startsWith('处理失败')
);
if (hit) return hit;
}
await sleep(1000);
}
return null;
}
/** 等一条属于指定会话的待决权限(按启动前快照差集 + session_id 过滤)。 */
async function waitPending(cookie, sessionId, before, timeoutMs) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const res = await fetch(`${GATEWAY}/api/v1/permission/pending?all=true`, { headers: { Cookie: cookie } });
if (res.ok) {
const { requests } = await res.json().catch(() => ({ requests: [] }));
const hit = (requests || []).find(
r => !before.has(r.mail_id) && r.agent_name === 'zcode' && (!sessionId || r.session_id === sessionId)
);
if (hit) return hit;
}
await sleep(800);
}
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: 'real-e2e' })
});
return res.ok;
}
async function main() {
const work = await mkdtemp(join(tmpdir(), 'zc-real-'));
const cfgDir = join(work, 'cfg');
const wsRoot = join(work, 'ws');
mkdirSync(cfgDir, { recursive: true });
mkdirSync(wsRoot, { recursive: true });
let env = { ...process.env };
try {
env.AGENTMAIL_AGENT_SECRET = (await readFile('/root/gotmp/zcode-agent-secret.txt', 'utf8')).trim();
} catch {}
if (!env.AGENTMAIL_AGENT_SECRET) {
console.error('拿不到 zcode 凭据');
process.exit(2);
}
env = {
...env,
AGENTMAIL_GATEWAY_URL: GATEWAY,
AGENTMAIL_AGENT_NAME: 'zcode',
// 真 CLI不是桩走 ~/.zcode/cli/config.json 里配的本地 llmsproxy
AGENTMAIL_ZCODE_CLI: '/opt/ZCode/resources/glm/zcode.cjs',
AGENTMAIL_CONFIG_DIR: cfgDir,
AGENTMAIL_WORKSPACE_ROOT: wsRoot,
AGENTMAIL_TURN_TIMEOUT_MS: '420000',
AGENTMAIL_PERMISSION_WAIT_MS: '150000'
};
const cookie = await login();
console.log('已登录人类账号\n');
// 启动前快照(避免把历史待决请求当成我们刚建的)
const before = new Set();
{
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 || []) before.add(r.mail_id);
if (before.size) console.log(`启动前已有 ${before.size} 条历史待决请求(已排除)\n`);
}
const driver = spawn(process.execPath, [DRIVER], { env, stdio: ['ignore', 'pipe', 'pipe'] });
const log = [];
driver.stdout.on('data', d => log.push(d.toString()));
driver.stderr.on('data', d => log.push(d.toString()));
const stopAndWait = async () => {
if (driver.exitCode !== null) return;
const done = new Promise(r => driver.once('exit', r));
driver.kill('SIGTERM');
await Promise.race([done, sleep(6000)]);
};
try {
let ready = false;
for (let i = 0; i < 40 && !ready; i++) {
await sleep(300);
ready = log.join('').includes('驱动就绪');
if (driver.exitCode !== null) break;
}
if (!ready) {
record('驱动启动', '失败', log.join('').slice(-300) || `退出码 ${driver.exitCode}`);
return;
}
console.log('驱动就绪(真 CLI + 本地模型)\n');
// ── A. 不需要授权:模型读信 → 产出文本 → 驱动回信 ──────────
if (SCENARIO === 'a' || SCENARIO === 'all') {
// 两个标记分工不同:
// subjectTag —— 只用来**定位回信**(收件箱是跨轮次共享的持久状态)
// bodyTag —— 只出现在**邮件正文**里,用来**证明模型真读了信**
// 驱动的提示词只带主题与 mail_id、不带正文所以主题里放 bodyTag 就等于
// 把答案送给模型(早先的版本正是如此,那条判据什么也没证明)。
const subjectTag = `SUBJ-${Date.now()}`;
const bodyTag = `ZC-A-${Date.now()}`;
console.log(`── A: 模型必须自己读信(正文标记 ${bodyTag}`);
await humanSend(
cookie,
`真模型验证A ${subjectTag}`,
`请把下面这一行原样回给我,不要改动:${bodyTag}`
);
const reply = await findReply(cookie, subjectTag, 300000);
if (!reply) {
const fail = await findFailure(cookie, marker, 3000);
record('A · 模型读信并回信', '失败', fail ? `收到失败信:${String(fail.body).slice(0, 80)}` : '5 分钟内没有回信');
} else if (String(reply.body || '').includes(bodyTag)) {
record('A · 模型读信并回信', '通过', `回信含正文标记(必须读信才可能答对),正文 ${String(reply.body).length}`);
} else {
record('A · 模型读信并回信', '失败', `回信正文没有正文本标记:${String(reply.body).slice(0, 80)}`);
}
}
// ── B. 需要授权:模型调 Bash → 钩子问人 → 人同意 → 执行 ────
if (SCENARIO === 'b' || SCENARIO === 'all') {
const marker = `ZC-B-${Date.now()}`;
console.log(`\n── B: 需要授权的工具(标记 ${marker}`);
const sent = await humanSend(
cookie,
`真模型验证B ${marker}`,
`请用 bash 工具执行这条命令,并把它的**原始输出**回给我(不要自己编):\n\necho ${marker}\n\n` +
`执行完请只回一行,内容就是那条命令的输出。`
);
const sessionId = sent.session_id;
// ① 钩子应当把授权请求发给人类
const pending = await waitPending(cookie, sessionId, before, 300000);
if (!pending) {
record('B① · 授权询问送达人类', '失败', '5 分钟内没有收到该会话的待决权限');
} else {
record('B① · 授权询问送达人类', '通过', `问题:${String(pending.question).slice(0, 40)}`);
before.add(pending.mail_id);
// ② 人点同意 → 工具应当执行 → 回信里出现标记
const okDecide = await decide(cookie, pending.mail_id, '同意');
if (!okDecide) record('B② · 决策接口', '失败', 'decide 返回非 2xx');
const reply = await findReply(cookie, marker, 300000);
if (!reply) {
const fail = await findFailure(cookie, marker, 3000);
record('B② · 批准后工具执行且回信', '失败', fail ? `失败信:${String(fail.body).slice(0, 80)}` : '5 分钟内没有回信');
} else if (String(reply.body || '').includes(marker)) {
record('B② · 批准后工具执行且回信', '通过', `回信含标记(说明命令真的跑了):${String(reply.body).slice(0, 60)}`);
} else {
record('B② · 批准后工具执行且回信', '失败', `回信里没有标记:${String(reply.body).slice(0, 90)}`);
}
}
// ③ 该工具在本次会话只该问一次(不是每次调用都问,也不是没问)
await sleep(2000);
const res = await fetch(`${GATEWAY}/api/v1/permission/pending?all=true`, { headers: { Cookie: cookie } });
const { requests } = await res.json().catch(() => ({ requests: [] }));
const mine = (requests || []).filter(r => r.session_id === sessionId && r.agent_name === 'zcode');
if (mine.length === 0) {
record('B③ · 同一会话不重复追问', '通过', '没有遗留的待决权限');
} else {
record('B③ · 同一会话不重复追问', '无法判定', `仍有 ${mine.length} 条待决(可能是模型又发起了新的工具调用)`);
}
}
} finally {
await stopAndWait();
await rm(work, { recursive: true, force: true });
}
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} 无法判定`);
console.log('\n驱动日志尾部');
console.log(` ${log.join('').split('\n').slice(-12).join('\n ')}`);
process.exit(fail > 0 ? 1 : 0);
}
main().catch(e => {
console.error('验证脚本自身出错:', e);
process.exit(2);
});