diff --git a/deploy/check-shared-libs.sh b/deploy/check-shared-libs.sh index aa8c084..9efa530 100755 --- a/deploy/check-shared-libs.sh +++ b/deploy/check-shared-libs.sh @@ -27,12 +27,19 @@ PEERS=(plugins/dsh-mail-bridge plugins/pi-mail-bridge plugins/zcode-mail-bridge) ALL_LIBS="relay-dedup relay-policy relay-key permission-mode bounded inbox-format session-snapshot workspace model-scope catchup addressing discovery rename-proposal permission-grants adopt sse-client user-question attachment-ids" ALL_TESTS="relay-policy relay-key permission-mode bounded inbox-format session-snapshot workspace model-scope catchup addressing discovery rename-proposal permission-grants adopt sse-client user-question attachment-ids" -# MCP 工具服务器 + 授权钩子用到的子集(实现与测试都同步)。 -# 钩子这一层额外需要 permission-mode / relay-key / permission-grants / sse-client —— -# 档位语义、relay_key 截断、决策判定与 SSE 帧解析都必须与三桥同源, -# 否则「同意」的含义会在 ZCode 上悄悄变得不一样。 -MCP_LIBS="addressing inbox-format bounded discovery attachment-ids permission-mode relay-key permission-grants sse-client" -MCP_TESTS="addressing inbox-format bounded discovery attachment-ids permission-mode relay-key permission-grants sse-client" +# MCP 工具服务器 + 授权钩子 + 邮件驱动用到的子集(实现与测试都同步)。 +# +# 钩子与驱动这一层额外需要: +# permission-mode / relay-key / permission-grants / sse-client +# —— 档位语义、relay_key 截断、决策判定、SSE 帧解析 +# catchup / relay-policy / workspace / relay-dedup +# (relay-dedup 在基准那边也没有独立测试,故只比对实现) +# —— 停机补投、主动发信去重、回信策略、工作目录解析 +# +# 这些都不能各平台自己写一份:「同意」的含义、谁该收到回信、同一封邮件 +# 会不会被转两次 —— 分叉的后果都是只在单平台复现的行为差异。 +MCP_LIBS="addressing inbox-format bounded discovery attachment-ids permission-mode relay-key permission-grants sse-client catchup relay-dedup relay-policy workspace" +MCP_TESTS="addressing inbox-format bounded discovery attachment-ids permission-mode relay-key permission-grants sse-client catchup relay-policy workspace" # ── 判据:用 cmp,不用 diff ───────────────────────────────────────── # diff --git a/deploy/zcode-mail-bridge.service b/deploy/zcode-mail-bridge.service new file mode 100644 index 0000000..04a1fb4 --- /dev/null +++ b/deploy/zcode-mail-bridge.service @@ -0,0 +1,42 @@ +[Unit] +Description=zcode mail-bridge (AgentMail ↔ ZCode headless CLI) +Documentation=file:/home/program/agentmail/plugins/zcode-mail-bridge/README.md +After=network-online.target agentmail-gateway.service +Wants=network-online.target + +[Service] +Type=simple + +# 驱动自己不需要工作目录(每一轮 cwd 来自邮件寻址的 path 位), +# 但 systemd 要求一个存在的目录,且相对路径(`../hooks/hooks.json` 的自检) +# 以进程启动位置为准。 +WorkingDirectory=/home/program/agentmail/plugins/zcode-mail-bridge +ExecStart=/usr/bin/node /home/program/agentmail/plugins/zcode-mail-bridge/src/index.mjs + +# ZCode 靠 HOME 定位 ~/.zcode(OAuth 凭据、cli/config.json、会话库、日志)。 +# systemd 不会自动注入 HOME,不显式给就: +# - 读不到登录凭据 → 每一轮都报「Model config is missing」,全部回成失败信 +# - 插件发现落到 /.zcode → 空目录,MCP 工具一个都没有 +Environment=HOME=/root + +EnvironmentFile=/etc/agentmail/zcode.env + +Restart=always +RestartSec=10 + +# 异常退出邮件上报:进程内的钩子捕获不了 SIGKILL/OOM,只能由 systemd 覆盖。 +# 正常 stop/restart 不上报(脚本内 isAbnormalExit 提前返回)。 +ExecStopPost=-/usr/bin/node /home/program/agentmail/deploy/service-failure-notify.mjs --report --service zcode-mail-bridge.service +# 补发上次网关不可达时暂存的报告(per-agent spool) +ExecStartPost=-/usr/bin/node /home/program/agentmail/deploy/service-failure-notify.mjs --flush --service zcode-mail-bridge.service + +# 一轮 ZCode 会派生一个 node 进程(模型 + 工具),内存占用比另三个桥高。 +# 给一个上限让它被 OOM killer 挑中而不是拖垮整机;Restart=always 会拉回来。 +MemoryMax=4G + +# 停机:驱动收到 SIGTERM 后会终止在途回合(否则会留下跑工具的孤儿), +# 再关掉 SSE。默认 90 秒太长,15 秒够。 +TimeoutStopSec=15 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/zcode.env.example b/deploy/zcode.env.example new file mode 100644 index 0000000..e7ad5bc --- /dev/null +++ b/deploy/zcode.env.example @@ -0,0 +1,31 @@ +# zcode 邮件驱动(deploy/zcode-mail-bridge.service)的 EnvironmentFile。 +# +# 与 pi / opencode / dsh 三桥**同一套变量名** —— 同名同义,排障时才不用 +# 每换一个平台就重新记一遍。 +AGENTMAIL_GATEWAY_URL=http://127.0.0.1:8180 +AGENTMAIL_AGENT_NAME=zcode + +# 凭据。用注册时的 secret(X-Agent-Secret): +# 管理员密钥(AGENTMAIL_AGENT_KEY)登记后可以换成它,两者服务端都认。 +# 驱动启动时会打印用的是哪一种。 +# AGENTMAIL_AGENT_SECRET=<在 /root/gotmp/zcode-agent-secret.txt> + +# 驱动与插件(MCP 服务器、授权钩子)共用这两个目录,**必须一致**: +# 授权表(「一直同意」)与主动发信记录都落在这里,两边解析出不同路径 +# 就会出现「刚点过一直同意又问你一遍」「模型自己发过了又被自动转发一遍」。 +AGENTMAIL_CONFIG_DIR=/root/.agentmail-zcode + +# 没有 to_workspace 时的兜底目录根。每一轮会在它下面按会话 id 建子目录。 +AGENTMAIL_WORKSPACE_ROOT=/root/.agentmail-zcode/workspaces + +# 一轮的上限。ZCode 要跑模型 + 工具,比另三个桥宽一些。 +# 必须**小于** systemd 的 TimeoutStopSec 无关,但与限制同一量级以免长跑占满串行队列。 +AGENTMAIL_TURN_TIMEOUT_MS=1200000 + +# ZCode CLI 入口(纯 CLI,不是 Electron GUI 入口)。 +AGENTMAIL_ZCODE_CLI=/opt/ZCode/resources/glm/zcode.cjs + +# 仓库内进程固定用系统 node(/usr/bin/node v22),与官方插件硬编码的 +# 工具链 node 分开,避免某天工具链升级把驱动带走。 +# 注:这里不设 PATH —— 插件清单里 MCP 服务器的 `command: node` +# 由 ZCode 继承本进程的 PATH 解析,systemd 默认 PATH 含 /usr/bin。 diff --git a/plugins/zcode-mail-bridge/README.md b/plugins/zcode-mail-bridge/README.md index 020c4de..292d72b 100644 --- a/plugins/zcode-mail-bridge/README.md +++ b/plugins/zcode-mail-bridge/README.md @@ -14,19 +14,26 @@ ZCode 用**插件**扩展能力(`.zcode-plugin/plugin.json` 声明 mcp/server.mjs MCP 服务器入口(stdio,换行分隔 JSON-RPC) hooks/permission.mjs PermissionRequest 钩子:把授权问给人、等决定、回结论 hooks/hooks.json 钩子注册(matcher + 进程型钩子 + 超时) +src/index.mjs ★ 邮件驱动:收到来信 → 起一轮 ZCode → 回信 +src/zcode-run.mjs 跑一轮(headless CLI + stream-json 解析) +src/prompt.mjs 由邮件构造提示词与回信文案 +src/turn-mode.mjs 档位 → `--mode` 映射(授权系统在不在的关键) lib/mcp-rpc.mjs 协议层(纯函数,可穷举测试) lib/tools.mjs 11 个 AgentMail 工具(与另三个桥同名同参) lib/gateway.mjs 网关 HTTP 客户端 lib/hook-policy.mjs 档位判定(纯函数) lib/grants-file.mjs 「一直同意」的跨进程持久化 +lib/explicit-sends.mjs 模型自己发过信的记录(工具与驱动跨进程对齐) lib/{addressing,inbox-format,bounded,discovery,attachment-ids, - permission-mode,relay-key,permission-grants,sse-client}.js + permission-mode,relay-key,permission-grants,sse-client, + catchup,relay-dedup,relay-policy,workspace}.js ← 与 pi/dsh/opencode 三桥**逐字节同源**(见下) test/ 单元测试(含继承的共用测试) -test/manual/permission-e2e.mjs 授权桥的端到端验证(真去点同意/拒绝) +test/manual/permission-e2e.mjs 授权桥端到端(真去点同意/拒绝) +test/manual/driver-e2e.mjs 邮件驱动端到端(桩 CLI,真网关真邮件) ``` -## 两条能力线 +## 三条能力线 ### 1)MCP 工具面 @@ -76,7 +83,50 @@ ZCode 决定某个工具需要授权时触发钩子(事件 JSON 走 stdin) 所以一次性进程也能订阅到自己那条 `permission_decision`。 这样**交互模式下这个功能同样可用**(人自己开着 ZCode 干活时并没有桥在跑)。 -## 配置 +### 3)邮件驱动(`src/index.mjs`) + +收到来信就自动开工,不需要人先打开 ZCode: + +``` +SSE 收到 new_mail → 去重 → 解析工作目录与档位 → 跑一轮 headless ZCode + → 取最终文本 → 决定要不要回信 → 投递 +``` + +一轮长这样(`--mode` 是**必传**的,见下): + +```bash +node /opt/ZCode/resources/glm/zcode.cjs \ + --prompt "<邮件提示词>" --output-format stream-json \ + --cwd <工作目录> --mode build [--resume sess_xxx] --max-turns N +``` + +**⚠ `--mode` 漏传的后果**:`--prompt` 的默认 mode 是 **`yolo`**,而 ZCode 的判定里 +`mode === "yolo"` 一律 allow(`Yolo mode bypasses permission prompts`)—— +授权钩子根本不会触发,整个授权系统**静默消失**(不报错,只是没有询问)。 +档位映射表见 `src/turn-mode.mjs`;`buildRunArgs` 收不到 mode 会直接抛错。 + +回信策略(与另三桥同源,复用 `lib/relay-policy.js`): + +| 来信方 | 自动回信? | 为什么 | +|---|---|---| +| 人 | 是(把本轮最终文本回过去) | 消息在提示词里就告诉他「回信不用你自己发」 | +| Agent | **否** | Agent 间必须自己 `send_mail`;否则两边会把对方的「已收到」当待办,无限客套 | + +两种情况下都**不回信**也不行: + +- 一轮跑不起来(CLI 报错 / 超时)→ **必回一封失败信**,并写明 ZCode 自己的成因 + (没登录 / 缺模型配置 / CLI 路径不对)。邮件驱动的会话没有本地界面, + 什么都不发等于「信发出去了,然后再无音讯」。 +- 模型这一轮自己发过信(工具跑在 ZCode 派生的 MCP 服务器进程里)→ 让位, + 否则收件箱里会出现两封说同一件事的邮件(线上实测过 311 与 342 字节两封)。 + 跨进程对齐靠 `lib/explicit-sends.mjs` 落盘。 + +**串行**:一轮一次。ZCode 的会话与工作目录是重资源,同目录并发跑两轮会互相踩。 +代价是一封长信会挡住后面的信 —— 这是显式取舍。 + +**关停**:收到 SIGTERM 会终止在途回合(否则 systemd 杀掉驱动后,那个 ZCode +还在跑工具,而既没有驱动看着它、也没有本地界面看着它)。 + 插件读与其它三桥**同名**的环境变量: @@ -95,7 +145,7 @@ ZCode 决定某个工具需要授权时触发钩子(事件 JSON 走 stdin) (systemd `EnvironmentFile`),由 MCP 服务器与钩子继承。 `userConfig` 只适合放非机密项。 -### 安装(本地目录,无需 marketplace) +## 配置 ZCode 的发现源之一是 `plugins.dirs`(配置里的「inline directories」)。 @@ -133,8 +183,15 @@ bash ../../deploy/check-shared-libs.sh # 授权桥端到端:真建会话 → 起钩子 → 以人类身份点同意/拒绝 → 验钩子结论 node test/manual/permission-e2e.mjs + +# 邮件驱动端到端:桩 CLI 替掉 ZCode,真网关真邮件 +node test/manual/driver-e2e.mjs ``` +`driver-e2e.mjs` 用桩 CLI 把「除了模型之外」的每一环都真跑一遍:参数拼装 +(尤其是 `--mode`)、stream-json 解析、回信策略、跨进程去重、失败必回信。 +它需要 `gui-lab` 这个人类账号(去点界面/收信)与 zcode 的凭据。 + `permission-e2e.mjs` 的判据设计:正向(同意→approve)之外还有四条反向对照 (拒绝→block 且原因必须来自人的拒绝、plan 档拒绝且**不产生**任何权限邮件、 无人可问→fail closed、非守卫工具→不表态)。它必须按「启动前快照差集 + @@ -144,13 +201,18 @@ node test/manual/permission-e2e.mjs ## 已知缺口 -- **邮件驱动还没做**:目前是「模型侧工具面 + 授权桥」。让 ZCode 收到来信就自动开工, - 需要一个驱动进程(订阅 SSE → 起 ZCode 会话 → 把最终回复当回信发出), - 并把 `AGENTMAIL_SESSION_ID` / `AGENTMAIL_PERMISSION_MODE` 注入会话。 -- **`mode_enforcement` 仍是 `advisory`**:授权桥已经能真的拦截(钩子返回 block 会拒绝工具), - 但库里的档位声明还没提为 `native`。 +- **真实一轮还没跑过**:ZCode 的模型访问要 OAuth 登录,登录完成前 headless 会直接 + 报「Model config is missing」。桩 CLI 已经把除「模型干活」之外的每一环验过了, + 但「模型能不能真的用这些工具把活干完」要等登录后实测。 +- **驱动服务已写好但**未启用**:`deploy/zcode-mail-bridge.service`。 + 未登录就启用的话,每封来信都会收到一封「处理失败」,所以留给人决定。 + 启用:`install -m 0644 deploy/zcode-mail-bridge.service /etc/systemd/system/ && cp deploy/zcode.env.example /etc/agentmail/zcode.env && systemctl enable --now zcode-mail-bridge`。 +- **`mode_enforcement` 靠自检得出**:驱动启动时会读自己的 `hooks/hooks.json`, + 确认 `PermissionRequest` 已注册才报 `native`,否则报 `advisory` 并在日志里说明原因 + (不替一个不存在的能力背书)。 - **生产路径**:当前 `plugins.dirs` 指向仓库工作副本,按项目纪律应改为 `/opt/agentmail/plugins/zcode-mail-bridge/current` 的快照 + 原子切换 (等 ZCode 重启不影响在跑的登录流程时再做)。 - **闭源**:ZCode 是闭源客户端(deb 里 `License: unknown`),本插件的协议层 - (MCP 分帧、钩子 schema)是从其产物里实测逆出来的,版本升级可能破坏。 + (MCP 分帧、钩子 schema、headless 输出格式、`--mode` 判定规则)全是从其产物里 + 实测逆出来的,版本升级可能破坏。 diff --git a/plugins/zcode-mail-bridge/lib/catchup.js b/plugins/zcode-mail-bridge/lib/catchup.js new file mode 100644 index 0000000..a0a4a6d --- /dev/null +++ b/plugins/zcode-mail-bridge/lib/catchup.js @@ -0,0 +1,74 @@ +/** + * 启动补拉:把插件离线期间到的邮件变成与 SSE 事件同形的投递任务。 + * + * 为什么需要它:**SSE 只推连上之后的事件**。插件重启前发来的邮件不会再推一次, + * 心跳响应的 `pending_mails` 是唯一线索。不补拉的后果是那封邮件永远躺在 + * 收件箱里,而发件人以为 Agent 收到了 —— 这比明确的失败更难排查。 + * + * 两个平台共用,必须逐字节相同(deploy/check-shared-libs.sh 校验)。 + */ + +/** + * 一次补拉最多处理几封。 + * + * 上限存在的理由:每封都要起一轮模型。攒了 80 封的时候一次性全放出去, + * 等于对上游打 80 个并发请求,且最后那几封要等前面全部跑完。 + * 超出的部分留在收件箱里,下次重启或人工触发时再处理。 + */ +export const MAX_CATCHUP = 5; + +/** + * 把收件箱里的一封邮件转成 SSE `new_mail` 那个形状。 + * + * 补拉与 SSE 走同一条投递路径(deliverMail),因此形状必须一致 —— + * 两条路径各写一遍投递逻辑的话,某一条上的修复会漏掉另一条。 + * + * @param {any} mail `/mail/inbox` 返回的一行 + * @returns {{mail_id: string, session_id: string, from_name: string, + * subject: string, mail_type: string, role: string, + * to_workspace: string, catchup: true}} + */ +export function mailToEvent(mail) { + return { + mail_id: mail?.mail_id || '', + session_id: mail?.session_id || '', + from_name: mail?.from_name || '', + subject: mail?.subject || '', + mail_type: mail?.mail_type || 'normal', + role: 'to', + to_workspace: mail?.to_workspace || '', + // 标记来源,投递侧可据此决定是否在提示词里说明「这是积压的邮件」 + catchup: true, + }; +} + +/** + * 从收件箱挑出该补投的邮件。 + * + * @param {any[]} mails `/mail/inbox?status=unread` 的结果 + * @param {Set} seen 已经通过 SSE 投过的 mail_id(避免重复投递) + * @param {number} [max] 上限,默认 MAX_CATCHUP + * @returns {any[]} 与 SSE 事件同形的投递任务,按时间正序(老的先处理) + */ +export function selectCatchup(mails, seen, max = MAX_CATCHUP) { + if (!Array.isArray(mails) || mails.length === 0) return []; + + const picked = []; + for (const m of mails) { + const id = m?.mail_id; + if (!id) continue; + // 心跳与 SSE 建连之间有个窗口:那期间到的邮件既在 pending_mails 里、 + // 也会被 SSE 推一次。不去重就会投两遍,模型回两封信。 + if (seen && seen.has(id)) continue; + // permission 类邮件不补投:它是给人看的询问,Agent 侧没有可恢复的上下文 + // (原来的工具调用早随进程一起没了),投过去只会让模型困惑。 + if (m?.mail_type && m.mail_type !== 'normal') continue; + picked.push(m); + } + + // 收件箱按时间倒序返回,补投要按正序 —— 先来的先处理, + // 否则同一会话里的多封邮件会被倒着塞进去,上下文顺序是乱的。 + picked.reverse(); + + return picked.slice(0, Math.max(0, max)).map(mailToEvent); +} diff --git a/plugins/zcode-mail-bridge/lib/explicit-sends.mjs b/plugins/zcode-mail-bridge/lib/explicit-sends.mjs new file mode 100644 index 0000000..855fc59 --- /dev/null +++ b/plugins/zcode-mail-bridge/lib/explicit-sends.mjs @@ -0,0 +1,107 @@ +/** + * 「模型这一轮自己发了信」的记录 —— MCP 服务器写,驱动读。 + * + * # 为什么不能直接用共用的 relay-dedup + * + * `lib/relay-dedup.js` 的记录活在**进程内**(一个 BoundedMap)。在 pi 桥那里 + * 工具与转发在同一个进程里,这没问题;但在 ZCode 这里,工具跑在 ZCode 派生出来的 + * **MCP 服务器进程**里,而转发决定由**驱动进程**做 —— 两个进程,内存不共享。 + * + * 不修这个的后果是线上实测过的:模型自己带着附件发了一封,驱动又把它的收尾话 + * 转了一遍,收件箱里出现两封说同一件事的邮件(311 与 342 字节), + * 而其中带附件的那封才是模型真正想发的。 + * + * 所以这里只做一件事:把记录落成 JSONL,让驱动能把「本轮发过什么」重建成 + * 共用判据要的形状(`{names:Set, replyTos:Set}`),判定仍然交给 + * `shouldSkipAutoRelay` —— 判据不能各平台自己写一份。 + * + * # 位置必须两端一致 + * + * 驱动起 ZCode 时把自己的环境传下去,ZCode 再起 MCP 服务器时继承同一份, + * 于是两边解析出同一个路径。解析顺序见 `explicitSendsFile`: + * 与授权表的 `grantsFilePath` 同构(都优先 AGENTMAIL_CONFIG_DIR)。 + */ + +import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { homedir } from 'node:os'; +import { addrName } from './relay-dedup.js'; + +/** 超过这个条数就重写文件,只留最近的。记录本身是「本轮」级别的,留太多没意义。 */ +const MAX_LINES = 500; + +export function explicitSendsFile(env = process.env) { + if (env.AGENTMAIL_ZCODE_SENDS_FILE) return env.AGENTMAIL_ZCODE_SENDS_FILE; + if (env.AGENTMAIL_CONFIG_DIR) return join(env.AGENTMAIL_CONFIG_DIR, 'explicit-sends.jsonl'); + if (env.ZCODE_PLUGIN_DATA) return join(env.ZCODE_PLUGIN_DATA, 'explicit-sends.jsonl'); + return join(homedir(), '.agentmail-zcode', 'explicit-sends.jsonl'); +} + +/** + * 记一条「模型自己发的信」。 + * + * 失败**不能**让 send_mail 失败:信已经发出去了。最坏后果是驱动多转一封总结, + * 而服务端的 relay_key 幂等还会兜一层。 + */ +export function noteExplicitSendFile(file, { sessionId, to, replyTo, ts } = {}) { + if (!sessionId || !to) return false; + const line = JSON.stringify({ + session_id: String(sessionId), + to: String(to), + reply_to: replyTo ? String(replyTo) : '', + ts: Number.isFinite(ts) ? ts : Date.now() + }); + try { + mkdirSync(dirname(file), { recursive: true }); + let lines = []; + try { + lines = readFileSync(file, 'utf8').split('\n').filter(Boolean); + } catch { + lines = []; + } + lines.push(line); + // 有界:这个文件活到桥重启为止,条目会一直累积(见文件头注释)。 + if (lines.length > MAX_LINES) lines = lines.slice(-MAX_LINES); + // 原子替换:驱动可能正在读,半截 JSON 会被跳过(无害,但会让去重偶尔失灵)。 + const tmp = `${file}.tmp-${process.pid}`; + writeFileSync(tmp, `${lines.join('\n')}\n`, 'utf8'); + renameSync(tmp, file); + return true; + } catch { + return false; + } +} + +/** + * 读回某条会话在 `since` 之后的主动发信记录。 + * + * @param {string} file + * @param {{sessionId: string, since?: number}} opts + * @returns {{names: Set, replyTos: Set}} 直接可交给 shouldSkipAutoRelay + */ +export function readExplicitSends(file, { sessionId, since = 0 } = {}) { + const out = { names: new Set(), replyTos: new Set() }; + if (!sessionId) return out; + let raw; + try { + raw = readFileSync(file, 'utf8'); + } catch { + return out; + } + for (const line of raw.split('\n')) { + const t = line.trim(); + if (!t) continue; + let rec; + try { + rec = JSON.parse(t); + } catch { + continue; // 写了一半的行:跳过 + } + if (rec?.session_id !== String(sessionId)) continue; + if (since && Number(rec.ts) < since) continue; + const name = addrName(rec.to); + if (name) out.names.add(name); + if (rec.reply_to) out.replyTos.add(String(rec.reply_to)); + } + return out; +} diff --git a/plugins/zcode-mail-bridge/lib/relay-dedup.js b/plugins/zcode-mail-bridge/lib/relay-dedup.js new file mode 100644 index 0000000..ec356fc --- /dev/null +++ b/plugins/zcode-mail-bridge/lib/relay-dedup.js @@ -0,0 +1,70 @@ +// 自动转发去重的纯逻辑。 +// +// 单独一个文件而不是放在 index.js 里导出:**opencode 会把插件入口模块的 +// 每一个导出都当成插件工厂**(`Object.values(mod)` 逐个检查是不是函数), +// 多导出一个 Map 就会让整个插件加载失败: +// ERROR message="failed to load plugin" error="Plugin export is not a function" +// 实测踩过 —— 插件静默不加载,邮件全都投不进去。 +// 因此入口文件只能 `export default`,其余东西一律搁在这里。 + +import { BoundedMap, MAX_TRACKED_SESSIONS } from './bounded.js'; + +/** 取三维地址的名字段:admin@root.alias -> admin */ +export function addrName(addr) { + return String(addr || "").split("@")[0].trim(); +} + +/** + * 本轮内模型**自己调 send_mail** 发出去的信(按 opencode 会话)。 + * + * session.idle 的自动转发要据此让位:模型已经亲手回过这条线索了, + * 再把它最后那段话转一遍,收件箱里就是两封内容几乎一样的邮件。 + * 生产实测过这个后果 —— 同一轮里 311 字节和 342 字节各一封, + * 说的是同一件事,其中带附件的那封才是模型真正想发的。 + * + * 为什么不靠 relay_key 幂等:那个键是 assistant message id, + * 保证的是「同一条消息不被转两次」,管不了「模型已经自己发过了」。 + * + * 窗口是「一轮」:deliverMail 投递新邮件时清空(新一轮开始), + * relaySummary 用完即清。 + * + * # 为什么仍要有界 + * + * 上面那些清理路径都要求「这个会话之后还有事发生」。一条只发过一次信、 + * 之后既没有新邮件也没有 idle 的会话(模型自己发完就没动静了,或者插件在 + * 那一轮之后重连),条目就永久留下。桥是常驻进程,这种残留会一直累积。 + * + * 淘汰是安全的:条目的语义是「本轮已经亲手回过」,而「本轮」是分钟级的。 + * 丢掉一条很久以前的记录最坏结果是那条会话下一次 idle 时多转一封总结, + * 而服务端的 relay_key 幂等还会兜一层。 + */ +export const explicitSends = new BoundedMap(MAX_TRACKED_SESSIONS); // opencode session id -> { names:Set, replyTos:Set } + +/** 记下模型这一轮主动发了信,给谁、回的哪封。 */ +export function noteExplicitSend(sessionID, to, replyTo) { + if (!sessionID) return; + let rec = explicitSends.get(sessionID); + if (!rec) { + rec = { names: new Set(), replyTos: new Set() }; + explicitSends.set(sessionID, rec); + } + const name = addrName(to); + if (name) rec.names.add(name); + if (replyTo) rec.replyTos.add(String(replyTo)); +} + +/** + * 本轮是否该跳过自动转发。 + * + * @param sent 该会话本轮的主动发信记录 { names:Set, replyTos:Set },可为空 + * @param replyTo 自动转发本来要发给谁(三维地址或纯名字) + * @param mailID 自动转发本来要 reply_to 的邮件 id + */ +export function shouldSkipAutoRelay(sent, replyTo, mailID) { + if (!sent) return false; + // 收件人同名:模型已经跟这个人说过了 + if (sent.names.has(addrName(replyTo))) return true; + // 同一封信已被回过:即使收件人写法不同(别名/路径不同)也算回过 + if (mailID && sent.replyTos.has(String(mailID))) return true; + return false; +} diff --git a/plugins/zcode-mail-bridge/lib/relay-policy.js b/plugins/zcode-mail-bridge/lib/relay-policy.js new file mode 100644 index 0000000..ae4f48e --- /dev/null +++ b/plugins/zcode-mail-bridge/lib/relay-policy.js @@ -0,0 +1,120 @@ +// 自动转发的**适用范围**,以及据此该给模型说什么话。 +// +// 单独一个文件而不是放在入口里导出:**opencode 会把插件入口模块的每一个导出 +// 都当成插件工厂**(`Object.values(mod)` 逐个检查是不是函数),多导出一个函数 +// 就会让整个插件加载失败。因此入口只 `export default`,判断逻辑一律搁在这里。 +// +// # 为什么 Agent → Agent 不自动转发 +// +// 自动转发存在的理由是「人不该等模型记得调 send_mail」:人发一封信出去, +// 模型把活干完、话说完,插件替它把结论搬进邮件。收件方是人时这是纯收益。 +// +// 收件方是**另一个 Agent** 时这个理由不成立,而且有害:对方的插件同样会自动 +// 回一封,于是两个模型都以为「我只要把话说完就行」,实际上在持续互相唤醒。 +// 生产实测过一条完整的客套链(pi 转发给 dsh,dsh 回确认,pi 又确认那个确认, +// 一直到第 6 封撞上连续 relay 跳数上限才停): +// +// pi→dsh parent=24be32e5 转发 +// dsh→pi parent=bf79f8fe 已收到转发 +// pi→dsh parent=2700bd0a 收到你的确认 +// dsh→pi parent=34127884 确认闭环 +// pi→dsh parent=34058c13 … +// dsh→pi parent=9590bf16 ← 被 hop 上限拦下 +// +// 每一封都不是错的,每一封都没有新信息。跳数上限是最后一道闸,不是设计意图。 +// +// 因此规则是:**Agent 之间通信必须由模型主动调 send_mail。** +// 插件不再代它开口 —— 该说话的时候它会说,没什么要说的时候就该安静。 +// +// 副作用是好的:模型必须自己决定「这值得回一封信吗」,而那正是它该做的判断。 + +/** 取三维地址的名字段:admin@root.alias -> admin */ +export function addrName(addr) { + return String(addr || "").split("@")[0].trim(); +} + +/** + * 这一轮的结论该不该由插件自动转发出去。 + * + * @param {object} ctx + * @param {boolean} ctx.fromHuman 来信方是人类用户(SSE 的 `from_human`) + * @param {string} [ctx.replyTo] 自动转发本来要发给谁 + * @returns {{relay: boolean, reason: string}} + * reason 供日志用 —— 「本轮没有回信」必须能在日志里查到原因, + * 否则它与「模型没说话」「转发失败」三种情形长得一样。 + */ +export function autoRelayDecision(ctx) { + const { fromHuman, replyTo } = ctx || {}; + if (!replyTo) { + return { relay: false, reason: "不知道回给谁" }; + } + if (!fromHuman) { + return { + relay: false, + reason: `来信方 ${addrName(replyTo)} 是 Agent,按约定不自动转发(Agent 间通信须由模型主动 send_mail)`, + }; + } + return { relay: true, reason: "" }; +} + +/** + * 提示词里关于「回信怎么发」的那句话。 + * + * 必须与 `autoRelayDecision` 一致 —— 这是同一件事的两个出口,分开写必然分叉。 + * 而分叉的代价是模型被骗:它以为插件会替它回信,于是把话说完就停手, + * 而实际上那封信永远不会发出去,发件方一直等着。 + * + * @param {object} ctx + * @param {boolean} ctx.fromHuman + * @param {string} [ctx.replyAddress] 服务端算好的回信地址 + * @returns {string[]} 若干行,直接拼进提示词 + */ +export function replyInstruction(ctx) { + const { fromHuman, replyAddress } = ctx || {}; + if (fromHuman) { + return [ + "**回信不用你自己发**:把这一轮做完、把结论说出来就行,", + "插件会在这一轮结束时把你最后那段话作为回信发回去(不消耗你的发信配额)。", + "只有在需要主动联系其他人、或要带附件时才调用 send_mail。", + ]; + } + return [ + "**这封信来自另一个 Agent,插件不会替你回信。**", + "需要回复时你必须自己调用 send_mail" + + (replyAddress ? `(回信地址:${replyAddress})` : "") + ";", + "把话说完并不会让对方收到任何东西。", + "也请先判断这封信是否真的需要回复 —— 单纯的「收到」「确认」会让两个 Agent", + "无休止地互相客套,那对谁都没有价值。有实质结论或有事要问时才回。", + ]; +} + +/** + * 描述「进来的这封是什么」。 + * + * 在此之前提示词一律说「你收到一封新邮件」,于是模型分不清三种处境: + * 有人派了新活、我上封信的回复到了、离线期间积压的补投。 + * 第二种被当成第一种时,模型会把一句「已收到」当成待办再处理一遍。 + * + * @param {object} ctx + * @param {string} [ctx.inReplyTo] 非空 = 这是对本方某封信的回复(SSE 的 `in_reply_to`) + * @param {boolean} ctx.fromHuman + * @param {boolean} [ctx.catchup] 离线期间积压后补投的 + * @param {boolean} [ctx.reused] 投进一条已存在的会话(续谈) + * @returns {string} 提示词第一行 + */ +export function inboundHeadline(ctx) { + const { inReplyTo, fromHuman, catchup, reused } = ctx || {}; + const who = fromHuman ? "" : "(对方是一个 Agent)"; + if (inReplyTo) { + // 「回复到了」与「有人派活」是两种处境。说清楚它,模型才不会把 + // 一句确认当成新任务 —— 那正是互相客套的起点。 + return `你上一封信的**回复**到了${who}。这不是新任务。`; + } + if (catchup) { + return `你收到一封新邮件${who}。说明:这是插件离线期间积压的邮件,现在补投给你。`; + } + if (reused) { + return `本会话收到一封新邮件${who}。`; + } + return `你收到一封新邮件${who}。`; +} diff --git a/plugins/zcode-mail-bridge/lib/tools.mjs b/plugins/zcode-mail-bridge/lib/tools.mjs index e16d4fb..7d88706 100644 --- a/plugins/zcode-mail-bridge/lib/tools.mjs +++ b/plugins/zcode-mail-bridge/lib/tools.mjs @@ -36,6 +36,7 @@ import { } from './discovery.js'; import { normalizeAttachmentIDs } from './attachment-ids.js'; import { uploadLocalFile, downloadToFile } from './gateway.mjs'; +import { explicitSendsFile, noteExplicitSendFile } from './explicit-sends.mjs'; /** 正文在列表里的截断长度(与另三端一致)。 */ const BODY_LIMIT = 200; @@ -190,6 +191,17 @@ export function buildTools({ client, agentName }) { if (ids.length) payload.attachment_ids = ids; const result = await client.post('/mail/send', payload); + + // 记下「模型自己发了信」—— 驱动据此决定要不要再自动转发本轮收尾话。 + // 两边是两个进程(工具跑在 ZCode 起的 MCP 服务器里),只能经文件对齐; + // 不记的后果是收件箱里出现两封说同一件事的邮件(线上实测过)。 + noteExplicitSendFile(explicitSendsFile(process.env), { + sessionId: process.env.AGENTMAIL_SESSION_ID || result?.session_id || '', + to, + replyTo: str(a.reply_to), + ts: Date.now() + }); + const parts = [`邮件已发送(ID: ${result?.mail_id ?? '?'}`]; if (result?.session_id) parts.push(`,会话: ${result.session_id}`); if (result?.session_alias) parts.push(`,别名: ${result.session_alias}`); diff --git a/plugins/zcode-mail-bridge/lib/workspace.js b/plugins/zcode-mail-bridge/lib/workspace.js new file mode 100644 index 0000000..a1b8745 --- /dev/null +++ b/plugins/zcode-mail-bridge/lib/workspace.js @@ -0,0 +1,77 @@ +/** + * 邮件寻址里的工作目录(三维地址 name@path.session 的 path 位)。 + * + * 这个模块存在的理由是一次真实故障:插件建会话时用的 cwd 是自己拼的 + * `~/.dsh/mail-sessions/mail-` —— 每封邮件一个全新的空目录。 + * DSH 与 opencode 都按 cwd 给会话分组,于是所有邮件会话既不属于任何项目、 + * 彼此也不同组,界面上全落进「未分组」。 + * + * path 位本来就是「希望它在哪儿干活」,插件只需照用。 + */ + +import { existsSync, mkdirSync, statSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { isAbsolute, join, resolve } from 'node:path'; + +/** + * 校验寻址里的工作目录,不可用时返回调用方给的兜底。 + * + * 决策顺序: + * 1. path 位是一个已存在的目录 → 直接用它(同 path 的多封邮件天然同组) + * 2. path 位非空但目录不存在 → **不创建**,返回兜底 + * 3. path 位为空(地址写成 `dsh` 而不带 `@/path`)→ 兜底 + * + * 为什么不给不存在的 path 建目录:那等于让一个笔误(`/home/porgram/x`) + * 在磁盘上落下一个真目录,而 Agent 会在里面一无所获地干活 —— + * 用户看到会话建起来了却什么都做不了,比明确落到兜底目录更难排查。 + * + * 为什么拒绝相对路径:cwd 的相对基准是 harness 进程的启动目录, + * 那是个与邮件语义无关的量(systemd 下通常是 `/`)。 + * + * 兜底由调用方给,因为各平台的兜底不同:opencode 有插件启动时的 directory + * 可用,DSH 没有、只能落到 `~/.dsh/mail-sessions/<会话>`(见 mailSessionFallback)。 + * + * @param {string} workspace 事件里的 to_workspace + * @param {string} fallback 不可用时的兜底目录(可为空串 = 交给平台自己决定) + * @returns {{cwd: string, grouped: boolean}} grouped 为真表示落在了寻址指定的目录里 + */ +export function resolveWorkspaceCwd(workspace, fallback) { + const raw = typeof workspace === 'string' ? workspace.trim() : ''; + const fb = typeof fallback === 'string' ? fallback : ''; + + if (!raw || !isAbsolute(raw)) return { cwd: fb, grouped: false }; + + const abs = resolve(raw); + try { + if (existsSync(abs) && statSync(abs).isDirectory()) { + return { cwd: abs, grouped: true }; + } + } catch { + // 权限不足等:当作不可用 + } + return { cwd: fb, grouped: false }; +} + +/** + * 没有天然兜底的平台(DSH)用这个:`~/.dsh/mail-sessions/<会话 id>`。 + * @param {string} sessionKey 会话标识 + * @returns {string} + */ +export function mailSessionFallback(sessionKey) { + return join(homedir(), '.dsh', 'mail-sessions', String(sessionKey || 'default')); +} + +/** + * 确保兜底目录存在。寻址指定的目录本来就存在(否则不会被选中), + * 只有兜底目录需要现建。 + * @param {string} cwd resolveWorkspaceCwd 的结果 + * @param {boolean} grouped 是否落在寻址指定的目录里 + */ +export function ensureCwd(cwd, grouped) { + if (grouped || !cwd) return; + try { + mkdirSync(cwd, { recursive: true }); + } catch { + // 建不出来就让 harness 自己报错,这里不该吞掉真实原因 + } +} diff --git a/plugins/zcode-mail-bridge/src/index.mjs b/plugins/zcode-mail-bridge/src/index.mjs new file mode 100644 index 0000000..f29209c --- /dev/null +++ b/plugins/zcode-mail-bridge/src/index.mjs @@ -0,0 +1,400 @@ +#!/usr/bin/env node +/** + * ZCode 的 AgentMail 驱动:**收到来信 → 起一轮 ZCode → 把结论回信**。 + * + * 这是让 ZCode 成为一等 Agent 的那一半(另一半是插件:MCP 工具面 + 授权钩子)。 + * + * # 与另三个桥的关系 + * + * 结构对齐 pi / dsh / opencode 三桥:SSE 订阅 → 去重 → 解析工作目录与档位 → + * 跑一轮 → 按策略回信 → 心跳。可复用的部分一律走 `lib/`(逐字节同源): + * 事件补投、工作目录解析、回信策略、去重判据、SSE 帧解析。 + * + * 差别只在「怎么跑一轮」:ZCode 用 **headless CLI** + * (`--prompt … --output-format stream-json`),不是 SDK。 + * + * # 三个必须记住的约束 + * + * 1. **`--mode` 必传**。`--prompt` 的默认 mode 是 `yolo`,而 yolo 会绕过全部 + * 权限询问 —— 授权钩子根本不会触发,授权系统会**静默消失**(不报错, + * 只是没有任何询问)。档位映射见 `src/turn-mode.mjs`。 + * 2. **失败必须回信**。邮件驱动的会话没有本地界面,一轮跑不起来而什么都不发, + * 发件人只会觉得「信发出去了,然后再无音讯」。 + * 3. **模型自己发过信就不再自动转发**。工具跑在 ZCode 派生的 MCP 服务器进程里, + * 与驱动不是同一个进程,所以经 `lib/explicit-sends.mjs` 落盘对齐。 + * + * # 串行 + * + * 一轮一次。ZCode 的会话与工作目录是重资源,同一目录并发跑两轮会互相踩; + * 代价是一封长信会挡住后面的信 —— 这是显式取舍,不是遗漏(见 README 的已知缺口)。 + */ + +import { basename, join } from 'node:path'; +import { homedir } from 'node:os'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { GatewayClient, GatewayError } from '../lib/gateway.mjs'; +import { createSSEClient } from '../lib/sse-client.js'; +import { BoundedSet, BoundedMap, MAX_TRACKED_MAILS, MAX_TRACKED_SESSIONS } from '../lib/bounded.js'; +import { selectCatchup } from '../lib/catchup.js'; +import { autoRelayDecision } from '../lib/relay-policy.js'; +import { shouldSkipAutoRelay } from '../lib/relay-dedup.js'; +import { resolveWorkspaceCwd, ensureCwd } from '../lib/workspace.js'; +import { normalizeMode } from '../lib/permission-mode.js'; +import { clampRelayKey } from '../lib/relay-key.js'; +import { explicitSendsFile, readExplicitSends } from '../lib/explicit-sends.mjs'; +import { zcodeModeForTier, modeReachesPermissionHook, describeTier } from './turn-mode.mjs'; +import { buildMailPrompt, replySubject, renderTurnFailure } from './prompt.mjs'; +import { runTurn, DEFAULT_CLI } from './zcode-run.mjs'; + +const log = (...parts) => console.error('[zcode-mail-bridge]', ...parts); + +const CONFIG = { + gatewayURL: process.env.AGENTMAIL_GATEWAY_URL || 'http://127.0.0.1:8180', + agentName: process.env.AGENTMAIL_AGENT_NAME || 'zcode', + turnTimeoutMs: Number(process.env.AGENTMAIL_TURN_TIMEOUT_MS || 20 * 60 * 1000), + maxTurns: Number(process.env.AGENTMAIL_MAX_TURNS || 0) || undefined, + workspaceRoot: process.env.AGENTMAIL_WORKSPACE_ROOT || '', + cliPath: process.env.AGENTMAIL_ZCODE_CLI || DEFAULT_CLI +}; + +/** + * 没有 `to_workspace` 时的兜底目录。 + * + * **不能用共用的 `mailSessionFallback`** —— 那个函数的目录名写的是 `~/.dsh` + * (它注释里也写明是「没有天然兜底的平台(DSH)用这个」)。各平台的会话存储 + * 各不相同,把 ZCode 的会话塞进 `~/.dsh` 下会造成两个平台的会话目录互相污染。 + * + * @param {string} sessionKey + */ +export function zcodeSessionFallback(sessionKey, rootOverride) { + const root = rootOverride || CONFIG.workspaceRoot; + if (root) return join(root, String(sessionKey || 'default')); + return join(homedir(), '.zcode', 'mail-sessions', String(sessionKey || 'default')); +} + +/** + * 自报给网关的强制力。 + * + * **只声明得出来的事**:ZCode 上的档位强制全部靠插件里的 PermissionRequest 钩子, + * 钩子没被注册(插件没启用 / 被禁 / 清单被改坏)时我们什么也拦不住, + * 那时还报 native 就是在替一个不存在的能力背书 —— 而这个声明的用途正是 + * 让人相信「这一档在这里是被强制的」。 + * + * 查的是插件自己的 `hooks/hooks.json`(驱动就住在这个插件里), + * 不需要额外的配置项。 + */ +export function detectModeEnforcement({ hooksFile } = {}) { + const file = hooksFile || fileURLToPath(new URL('../hooks/hooks.json', import.meta.url)); + try { + const cfg = JSON.parse(readFileSync(file, 'utf8')); + const entries = cfg?.hooks?.PermissionRequest; + if (Array.isArray(entries) && entries.some(e => Array.isArray(e?.hooks) && e.hooks.length > 0)) { + return { enforcement: 'native', reason: `钩子已注册(${file})` }; + } + return { enforcement: 'advisory', reason: `钩子清单里没有 PermissionRequest(${file})` }; + } catch (e) { + return { enforcement: 'advisory', reason: `读不到钩子清单(${file}):${e?.message || e}` }; + } +} + +/** 描述错误:把「网关可达但返回 4xx」与「连不上」分开 —— 两者的应对完全不同。 */ +export function describeError(e) { + if (e instanceof GatewayError) { + return `网关返回 HTTP ${e.status}(${e.path}):${typeof e.body === 'string' ? e.body : e.message}`; + } + return e?.message || String(e); +} + +/** + * 造一个驱动实例。 + * + * 依赖全部注入,所以整条流水线(事件 → 提示词 → 一轮 → 回信判定 → 发信载荷) + * 可以在没有模型、没有 ZCode 的情况下被端到端断言。 + */ +export function createDriver({ client, runTurnFn = runTurn, logFn = log, env = process.env, config } = {}) { + // 配置可覆盖:测试需要把工作目录指到临时目录,不能碰真实的家目录。 + const CFG = { ...CONFIG, ...(config || {}) }; + const delivered = new BoundedSet(MAX_TRACKED_MAILS); + /** AgentMail 会话 id → { zcodeSessionId, cwd, tier, turns } */ + const sessions = new BoundedMap(MAX_TRACKED_SESSIONS); + const queue = []; + let running = false; + /** 当前在途回合的杀进程函数(关停时要终止它,否则会留下跑工具的孤儿)。 */ + let currentKill = null; + + /** 注册表只用于日志与自检:它让「为什么一轮授权询问都没发生」有据可查。 */ + const stats = { turns: 0, relays: 0, skippedRelay: 0, failures: 0 }; + + function resolveCwd(data) { + const sessionId = data?.session_id || data?.mail_id || 'unknown'; + const fallback = zcodeSessionFallback(sessionId, CFG.workspaceRoot); + const { cwd, grouped } = resolveWorkspaceCwd(data?.to_workspace, fallback); + if (!grouped) logFn(`会话 ${sessionId} 没有可用的 to_workspace,用兜底目录 ${cwd}`); + ensureCwd(cwd, grouped); + return cwd; + } + + async function relay({ data, text, kind }) { + const fromHuman = data?.from_human === true; + const decision = autoRelayDecision({ fromHuman, replyTo: data?.from_name }); + if (!decision.relay) { + logFn(`不自动转发(${decision.reason})`); + stats.skippedRelay++; + return false; + } + + const sessionId = data?.session_id || ''; + const sent = readExplicitSends(explicitSendsFile(env), { + sessionId, + // 只认本轮之后的记录:早于本轮的发信属于上一次往返,不该让这一轮沉默。 + since: Date.now() - CFG.turnTimeoutMs + }); + if (shouldSkipAutoRelay(sent, data.from_name, data.mail_id)) { + logFn(`本轮模型已主动回信 ${data.from_name},跳过自动转发`); + stats.skippedRelay++; + return false; + } + + // relay + relay_key 走免配额通道:模型已经把话说完了,驱动只是把它搬进邮件。 + // 对搬运收配额会让「配额用尽」变成「连交代都做不到」。 + const relayKey = clampRelayKey(`zcode:${data.mail_id || kind}`); + await client.post('/mail/send', { + to: data.from_name, + subject: replySubject(data.subject), + body: text, + reply_to: data.mail_id || '', + relay: 'summary', + relay_key: relayKey + }); + stats.relays++; + logFn(`已回信给 ${data.from_name}(${text.length} 字)`); + return true; + } + + async function processMail(data) { + const sessionId = data?.session_id || ''; + const tier = normalizeMode(data?.permission_mode); + const mode = zcodeModeForTier(tier); + const cwd = resolveCwd(data); + const prev = sessions.get(sessionId); + const resume = prev?.zcodeSessionId || ''; + + logFn(`处理 ${data.mail_id}|${describeTier(tier, mode)}|cwd=${cwd}${resume ? `|续会话 ${resume}` : ''}`); + if (!modeReachesPermissionHook(mode) && tier !== 'plan') { + // 只有 full 档会走到这里,且是刻意的。写日志是因为「没有权限询问」 + // 在 yolo 下是预期行为,在 build 下则是缺陷 —— 两者必须能区分。 + logFn(`注意:--mode ${mode} 不会产生权限询问(本档如此设计)`); + } + + const prompt = buildMailPrompt({ agentName: CONFIG.agentName, data }); + + const outcome = await runTurnFn( + { + prompt, + cwd, + mode, + maxTurns: CFG.maxTurns, + resumeSessionId: resume || undefined, + turnTimeoutMs: CFG.turnTimeoutMs, + cliPath: CFG.cliPath, + // 注入给 ZCode 进程(→ 继承给插件、钩子、MCP 服务器): + // 授权钩子靠 AGENTMAIL_SESSION_ID 判断「有没有本地界面」, + // 靠 AGENTMAIL_PERMISSION_MODE 决定档位。 + env: { + AGENTMAIL_SESSION_ID: sessionId, + AGENTMAIL_PERMISSION_MODE: tier, + AGENTMAIL_MAIL_SUBJECT: data?.subject || '', + AGENTMAIL_REPLY_TO: data?.mail_id || '' + } + }, + { log: logFn, onChild: kill => { + currentKill = kill; + } } + ); + currentKill = null; + + stats.turns++; + + if (outcome.sessionId) { + sessions.set(sessionId, { + zcodeSessionId: outcome.sessionId, + cwd, + tier, + turns: (prev?.turns || 0) + 1 + }); + } + + const failed = outcome.timedOut || (outcome.exitCode !== 0 && !outcome.response); + if (failed) { + stats.failures++; + const reason = outcome.timedOut + ? `回合超时(${Math.round(CFG.turnTimeoutMs / 1000)} 秒),已终止进程树` + : `ZCode 退出码 ${outcome.exitCode}${outcome.stderrTail ? `:\n${outcome.stderrTail}` : ''}`; + logFn(`一轮失败:${reason}`); + // 失败必须回信:否则发件人只看到「信发出去了,然后再无音讯」。 + try { + await client.post('/mail/send', { + to: data?.from_name, + subject: `处理失败: ${data?.subject || '(无主题)'}`, + body: renderTurnFailure([{ kind: outcome.timedOut ? '超时' : 'CLI 失败', error: reason }], data?.subject), + reply_to: data?.mail_id || '', + relay: 'summary', + relay_key: clampRelayKey(`zcode-failure:${data?.mail_id || sessionId}`) + }); + } catch (e) { + logFn(`失败回报也发不出去:${describeError(e)}`); + } + return { ok: false, reason }; + } + + const text = String(outcome.response || '').trim(); + if (!text) { + // 退出码 0 但没有最终文本:常见于模型只调了工具就结束。 + // 这时**不冒充**回信(会让收件人以为模型什么都没做),但要留下日志。 + logFn('这一轮没有产出最终文本,不自动回信(若模型自己发过信,那封就是答复)'); + return { ok: true, relayed: false }; + } + + return { ok: true, relayed: await relay({ data, text, kind: 'mail' }) }; + } + + async function drain() { + if (running) return; + running = true; + try { + while (queue.length) { + const data = queue.shift(); + try { + await processMail(data); + } catch (e) { + // 一封邮件处理崩了不能把驱动带走:后面还有很多信。 + logFn(`处理 ${data?.mail_id} 时异常:${describeError(e)}`); + } + } + } finally { + running = false; + } + } + + /** SSE 事件入口。必须廉价 —— 它跑在读循环上。 */ + function handleEvent(type, data) { + if (type !== 'new_mail') return; + if (data?.role && data.role !== 'to' && data.role !== 'cc') return; + const id = data?.mail_id; + if (!id || delivered.has(id)) return; + delivered.add(id); + queue.push(data); + void drain(); + } + + async function catchUp(pendingMails) { + const mails = selectCatchup(pendingMails, delivered); + if (!mails.length) return 0; + logFn(`补投 ${mails.length} 封停机期间到达的邮件`); + for (const m of mails) handleEvent('new_mail', m.data ?? m); + return mails.length; + } + + return { + handleEvent, + catchUp, + processMail, + stats, + sessions, + delivered, + /** + * 关停:终止在途回合。 + * + * 不做这件事的后果是——systemd 杀掉驱动之后,那个 ZCode 进程还在跑工具, + * 而既没有驱动看着它,也没有本地界面看着它。宁可丢掉这一轮的工作。 + */ + abort() { + // 先取后清:杀过就算完,重复关停(SIGTERM 后再来一个)不该重复杀。 + const kill = currentKill; + currentKill = null; + if (kill) { + logFn('关停:终止在途的 ZCode 回合'); + try { + kill('SIGTERM'); + } catch { + /* 已经结束了 */ + } + } + queue.length = 0; + } + }; +} + +// ─── 真实入口 ─────────────────────────────────────────────────────── + +async function main() { + const client = new GatewayClient(process.env); + const missing = client.checkConfig(); + if (missing.length) { + log(`配置不完整,缺少 ${missing.join('、')};驱动不会启动(静默启动会让信永远没人处理)`); + process.exit(1); + } + + const driver = createDriver({ client, logFn: log }); + let caughtUp = false; + let timer; + + try { + await client.register(); + log(`已接入 ${client.baseURL},身份 ${client.agentName}`); + } catch (e) { + // 密钥未登记时说清该做什么,别只留一句 401。 + log(`注册失败:${describeError(e)}`); + log('若提示密钥无效,请让管理员在 AgentMail 后台登记这把密钥。'); + } + + const beat = async () => { + try { + // mode_enforcement 只声明得出来的事:挡得住工具的是插件里的授权钩子, + // 钩子没注册时我们什么也拦不住(见 detectModeEnforcement)。 + const res = await client.post('/agent/heartbeat', { + mode_enforcement: detectModeEnforcement().enforcement + }); + if (!caughtUp) { + caughtUp = true; + await driver.catchUp(res?.pending_mails); + } + } catch { + // 心跳失败不刷错误日志:真连不上时网关会把它判成离线,那才是可见信号。 + } + }; + await beat(); + timer = setInterval(beat, 30_000); + + createSSEClient({ + authHeaders: () => client.authHeaders(), + baseURL: client.baseURL, + path: '/api/v1/events/stream', + log, + onEvent: (type, data) => driver.handleEvent(type, data) + }); + + const shutdown = reason => { + log(`收到 ${reason},关停中…(已处理 ${driver.stats.turns} 轮,回信 ${driver.stats.relays} 封)`); + if (timer) clearInterval(timer); + driver.abort(); + client.stopSSE?.(); + // 给杀进程留一点时间再退:自己先死会把 ZCode 变成孤儿。 + setTimeout(() => process.exit(0), 1200); + }; + for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => shutdown(sig)); + + const enforcement = detectModeEnforcement(); + log(`档位强制力自报:${enforcement.enforcement}(${enforcement.reason})`); + log(`驱动就绪:CLI ${basename(CONFIG.cliPath)},回合上限 ${Math.round(CONFIG.turnTimeoutMs / 1000)} 秒`); +} + +// 直接执行时启动;被 import 时只导出(测试要用 createDriver)。 +const isDirect = process.argv[1] && import.meta.url === `file://${process.argv[1]}`; +if (isDirect) { + main().catch(e => { + log(`启动失败:${describeError(e)}`); + process.exit(1); + }); +} diff --git a/plugins/zcode-mail-bridge/src/prompt.mjs b/plugins/zcode-mail-bridge/src/prompt.mjs new file mode 100644 index 0000000..7add4a1 --- /dev/null +++ b/plugins/zcode-mail-bridge/src/prompt.mjs @@ -0,0 +1,109 @@ +/** + * 由一封邮件构造驱动 ZCode 的提示词。 + * + * 结构与 pi / dsh / opencode 三桥**同源**(复用 `lib/relay-policy.js` 的 + * `inboundHeadline` 与 `replyInstruction`):同一封邮件派到不同 Agent 上, + * 模型该看到同样的交代。措辞一旦在某个平台走样,就会出现「同一封邮件 + * 在这个 Agent 上会回信、在那个 Agent 上装死」这种只在单平台复现的问题。 + * + * 两个关键信号都来自服务端,不由插件猜: + * + * - `from_human`:决定「插件会不会替你回信」。猜错的代价不对称 —— + * 把 Agent 的来信说成人的来信,会让模型以为有人会替它开口而什么都不做; + * 反之只是多调一次 send_mail。 + * - `in_reply_to`:这封是回信还是新任务。模型分不清这两者时,会把对方一句 + * 「已收到」当成新待办再做一遍(Agent↔Agent 客套循环的真正成因)。 + */ + +import { inboundHeadline, replyInstruction } from '../lib/relay-policy.js'; + +/** 去掉已有的 Re:/答复前缀,避免 `Re: Re: Re: …` 越滚越长。 */ +export function replySubject(subject) { + const base = String(subject ?? '') + .replace(/^\s*(re|答复|回复)\s*[::]\s*/gi, '') + .trim(); + return base ? `Re: ${base}` : '本轮工作总结'; +} + +/** + * 一轮完全跑不起来时的回信正文。 + * + * **必须发这封信**:模型一次都没跑起来 → 会话里没有任何产出 → 自动转发什么也 + * 不会发 → 发件人只会觉得「信发出去了,然后再无音讯」。邮件驱动的会话没有 + * 本地界面可以让人看见错误,回信是唯一的出口。 + * + * 为什么不复用共用的 `renderFailureReport`:它的**建议**是平台特有的 + * (「调整可用模型范围」),而 ZCode 跑不起来的常见成因是没登录、缺模型配置、 + * CLI 本身报错 —— 照着那句建议去后台改模型范围,什么也解决不了。 + * + * @param {{kind: string, error: string}[]} failures + * @param {string} subject + */ +export function renderTurnFailure(failures, subject) { + const list = Array.isArray(failures) ? failures : []; + const lines = [ + `本次未能处理「${subject || '(无主题)'}」:ZCode 一轮都没能跑完。`, + '', + `已尝试 ${list.length} 次:`, + '' + ]; + list.forEach((f, i) => { + lines.push(`${i + 1}. **${f?.kind || '失败'}**`); + lines.push(` ${String(f?.error ?? '未知错误').replace(/\n/g, '\n ')}`); + }); + lines.push(''); + lines.push('常见成因(按可能性):'); + lines.push('- **没有登录**:ZCode 的模型访问要 OAuth,未登录时 headless 会直接报缺模型配置;'); + lines.push(' 在服务器上跑 `node login`,或直接开一次客户端扫码。'); + lines.push('- **模型配置缺失**:`~/.zcode/cli/config.json` 里没有显式 provider。'); + lines.push('- **CLI 路径不对**:`AGENTMAIL_ZCODE_CLI` 指向的 `zcode.cjs` 不存在或不可执行。'); + lines.push('- **工作目录不可写**:ZCode 要在里面建会话状态。'); + lines.push(''); + lines.push('修好后可以重新把原邮件发一次,或直接回复这封信。'); + return lines.join('\n'); +} + +/** + * @param {{agentName: string, data: any, kind?: string, reused?: boolean}} input + * @returns {string} + */ +export function buildMailPrompt({ agentName, data, kind = 'mail', reused = false }) { + // 权限结论不是「一封新邮件」,而是我们自己在等的那件事有了答复。 + // 当成普通邮件处理,模型会去 read_inbox 找一封其实已经不需要读的信。 + if (kind === 'permission') { + return [ + `你之前发起的权限请求已有结论:${data?.decision ?? '(未给出)'}` + + `(决策人:${data?.decided_by || '用户'})。`, + '请据此继续后续工作。' + ].join('\n'); + } + + const fromHuman = data?.from_human === true; + const lines = [ + inboundHeadline({ + inReplyTo: data?.in_reply_to, + fromHuman, + catchup: data?.catchup, + reused + }), + '', + `发件人:${data?.from_name || 'unknown'}`, + `主题:${data?.subject || '(无主题)'}`, + `邮件 ID:${data?.mail_id || 'unknown'}` + ]; + + if (data?.in_reply_to) lines.push(`回的是你那封:${data.in_reply_to}`); + if (!reused) lines.push(`身份:你是 ${agentName}`); + // 服务端算好的回信地址。带上它是因为模型**确实会**自己发信(抄送第三方、 + // 分多封交代不同的事)。让它自己拼三维地址的话,`.new` 会被拼进去, + // 于是回信静默开出一条新会话,原线索里再无下文。 + if (data?.reply_address) lines.push(`回信地址:${data.reply_address}`); + + lines.push( + '', + '请先调用 read_inbox 读取完整正文(附带附件清单,如有附件可用 download_attachment 取回),', + '然后处理其中的请求。', + ...replyInstruction({ fromHuman, replyAddress: data?.reply_address }) + ); + return lines.join('\n'); +} diff --git a/plugins/zcode-mail-bridge/src/turn-mode.mjs b/plugins/zcode-mail-bridge/src/turn-mode.mjs new file mode 100644 index 0000000..445f429 --- /dev/null +++ b/plugins/zcode-mail-bridge/src/turn-mode.mjs @@ -0,0 +1,67 @@ +/** + * AgentMail 的档位 → ZCode `--mode` 的映射。 + * + * # 为什么这个映射必须存在,而且不能想当然 + * + * ZCode 的权限判定里有一条: + * + * t.mode === "yolo" ? this.allow(t, i, "mode.yolo", "Yolo mode bypasses permission prompts") + * + * 也就是 **`yolo` 会绕过全部权限询问**,PermissionRequest 钩子根本不会触发 —— + * 我们的授权桥会**静默消失**(不是报错,是没有询问,看起来一切正常)。 + * + * 而 `--prompt` 的**默认 mode 就是 `yolo`**(`--mode` 的 help 写着 + * "default: yolo for --prompt")。所以驱动若图省事不传 `--mode`, + * 人就会以为「授权系统在管事」,实际每一条命令都已经自动放行了。 + * + * 反过来也不能一律传 `build`:档位的意义就是三种不同的行为。 + * + * # 依据(从 CLI 产物里读出的规则表,不是猜) + * + * - `checkBuildMode`:只读放行;critical/high 风险 → **ask**; + * 有副作用 / 需要审批 → **ask**。`Bash` 属 destructive(high)→ ask; + * `Write`/`Edit` 有 workspace 副作用 → ask。 + * - `checkEditMode`:`permissionName === "edit"` 的工作区文件编辑放行,其余退回 build。 + * - plan:`mode.plan.nonReadOnly` → 非只读一律**拒**。 + * - yolo:一律放行。 + * + * 于是映射为:plan → plan,workspace → build,full → yolo。 + */ + +import { normalizeMode, DEFAULT_MODE, MODE_PLAN, MODE_FULL } from '../lib/permission-mode.js'; + +/** ZCode 认识的 headless mode(`normalizePromptMode` 只接受这四个)。 */ +export const ZCODE_MODES = ['build', 'edit', 'plan', 'yolo']; + +const MODE_FOR_TIER = { + [MODE_PLAN]: 'plan', + workspace: 'build', + [MODE_FULL]: 'yolo' +}; + +/** + * @param {string} tier AgentMail 的档位(plan / workspace / full) + * @returns {'build'|'edit'|'plan'|'yolo'} + */ +export function zcodeModeForTier(tier) { + const t = normalizeMode(tier) || DEFAULT_MODE; + return MODE_FOR_TIER[t] || 'build'; +} + +/** + * 这个 mode 是否会让危险操作走到我们的授权钩子。 + * + * 用于启动自检与日志 —— 「驱动跑起来了但一次授权询问都没发生」有两种成因 + * (真没人碰危险工具 / mode 把询问绕过了),它们必须以不同的方式被看见。 + */ +export function modeReachesPermissionHook(mode) { + return mode === 'build' || mode === 'edit'; +} + +/** 权限档位的人话解释,写进日志与回信里,方便复盘「当时是什么档」。 */ +export function describeTier(tier, mode = zcodeModeForTier(tier)) { + const t = normalizeMode(tier) || DEFAULT_MODE; + if (t === MODE_PLAN) return `${t} 档 → --mode ${mode}(只读,ZCode 自己就会拒非只读工具)`; + if (t === MODE_FULL) return `${t} 档 → --mode ${mode}(全权,刻意绕过权限询问)`; + return `${t} 档 → --mode ${mode}(危险操作会走到 AgentMail 授权钩子)`; +} diff --git a/plugins/zcode-mail-bridge/src/zcode-run.mjs b/plugins/zcode-mail-bridge/src/zcode-run.mjs new file mode 100644 index 0000000..01873e7 --- /dev/null +++ b/plugins/zcode-mail-bridge/src/zcode-run.mjs @@ -0,0 +1,276 @@ +/** + * 跑一轮 ZCode:起一个 headless 进程,把事件流读回来,取出最终回复。 + * + * # 为什么用 `--output-format stream-json` 而不是 `--json` + * + * 两者都能在最后给出 `{sessionId, response}`(这是从 CLI 产物里读出的契约: + * 逐条事件写 `mapSessionEvent(event)`,最后补一行 `{type:"result", …}`)。 + * 差别在**过程可见**:`--json` 全程没有输出,一个卡住的回合与一个正在干活的 + * 回合在外部看起来完全一样 —— 而邮件驱动的会话没有界面,除了日志没人能看见它。 + * stream-json 让「正在做什么」进得了日志,也让超时能被归因。 + * + * # 会话延续 + * + * 首轮没有 `--resume`,从 `result` 行里取回 `sess_...` 存下来;后续同一 + * AgentMail 会话的信都带上 `--resume`,于是模型记得前几轮。丢了它就等于 + * 「每封信都从零开始」,而模型会表现得像没见过之前的要求。 + * + * # 超时 + * + * 到期杀**进程树**:ZCode 会派生工具子进程(bash 等),只杀父进程会留下一堆 + * 孤儿继续跑,而且它们还占着工作目录。 + */ + +import { spawn as nodeSpawn } from 'node:child_process'; + +/** 默认的 CLI 入口:ZCode 自带的纯 CLI(不是 Electron 那个 GUI 入口)。 */ +export const DEFAULT_CLI = '/opt/ZCode/resources/glm/zcode.cjs'; + +const DEFAULT_TURN_TIMEOUT_MS = 20 * 60 * 1000; + +/** + * 拼出一次 headless 调用的参数表。 + * + * 单独抽出来是为了可测:`--mode` 漏掉会**静默绕过全部授权询问** + * (`--prompt` 的默认 mode 是 yolo),这种缺陷不会报错,只会让授权系统消失。 + */ +export function buildRunArgs({ + prompt, + cwd, + mode, + maxTurns, + resumeSessionId, + allowedTools, + disallowedTools, + cliPath = DEFAULT_CLI +}) { + const args = [ + cliPath, + '--prompt', + prompt, + '--output-format', + 'stream-json', + '--cwd', + cwd + ]; + // mode 必传:不传就是 yolo,等于关掉授权。 + if (!mode) throw new Error('buildRunArgs 需要 mode(不传等于 yolo,会绕过授权询问)'); + args.push('--mode', mode); + if (maxTurns) args.push('--max-turns', String(maxTurns)); + if (resumeSessionId) args.push('--resume', resumeSessionId); + if (Array.isArray(allowedTools) && allowedTools.length) { + args.push('--allowed-tools', allowedTools.join(',')); + } + if (Array.isArray(disallowedTools) && disallowedTools.length) { + args.push('--disallowed-tools', disallowedTools.join(',')); + } + return args; +} + +/** + * 解析一行 stream-json 输出的**纯函数**部分。 + * + * 返回 `null` 表示这行不是我们要的(非 JSON、或没有意义的行)—— + * 调用方据此计数,好让「输出格式变了」这件事能被发现,而不是静默当成没输出。 + * + * @returns {{kind:'event'|'result', event?:any, sessionId?:string, response?:string}|null} + */ +export function parseStreamLine(line) { + const text = String(line ?? '').trim(); + if (!text || text[0] !== '{') return null; + let obj; + try { + obj = JSON.parse(text); + } catch { + return null; + } + if (!obj || typeof obj !== 'object') return null; + if (obj.type === 'result') { + return { + kind: 'result', + sessionId: typeof obj.sessionId === 'string' ? obj.sessionId : '', + response: typeof obj.response === 'string' ? obj.response : '', + eventCount: typeof obj.eventCount === 'number' ? obj.eventCount : undefined, + projection: obj.projection + }; + } + return { kind: 'event', event: obj }; +} + +/** + * 跑一轮。 + * + * @param {{prompt:string, cwd:string, mode:string, maxTurns?:number, + * resumeSessionId?:string, turnTimeoutMs?:number, + * env?:Record, cliPath?:string, nodePath?:string, + * onChild?:(kill:(signal?:string)=>void)=>void}} opts + * @param {{spawn?:Function, log?:Function}} [deps] spawn 可注入以便测试 + * @returns {Promise<{sessionId:string, response:string, events:any[], exitCode:number, + * unparsable:number, timedOut:boolean, killed:boolean, + * stderrTail:string, argv:string[]}>} + */ +export function runTurn(opts, deps = {}) { + const spawn = deps.spawn || nodeSpawn; + const log = deps.log || (() => {}); + const nodePath = opts.nodePath || process.execPath; + const args = buildRunArgs(opts); + const timeoutMs = opts.turnTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS; + // 宽限期:SIGTERM 之后等多久 SIGKILL;再等多久就无论如何收尾。 + // 可配是为了测试 —— 生产用默认值。 + const killGraceMs = opts.killGraceMs ?? 5000; + const settleGraceMs = opts.settleGraceMs ?? 2000; + const startedAt = Date.now(); + + return new Promise(resolve => { + let child; + try { + child = spawn(nodePath, args, { + cwd: opts.cwd, + env: { ...process.env, ...(opts.env || {}) }, + stdio: ['ignore', 'pipe', 'pipe'], + // 自己建进程组,超时时能整组杀 —— 否则 ZCode 派生的工具子进程会活下来。 + detached: process.platform !== 'win32' + }); + } catch (e) { + resolve({ + sessionId: '', + response: '', + events: [], + exitCode: -1, + unparsable: 0, + timedOut: false, + killed: false, + stderrTail: `spawn 失败:${e?.message || e}`, + argv: args + }); + return; + } + + const events = []; + let result = null; + let unparsable = 0; + let stdoutBuf = ''; + const stderrLines = []; + let timedOut = false; + let killed = false; + let settled = false; + + const killTree = signal => { + killed = true; + try { + if (process.platform !== 'win32' && child.pid) { + // 负号 = 整个进程组 + process.kill(-child.pid, signal); + } else { + child.kill(signal); + } + } catch { + try { + child.kill(signal); + } catch { + /* 已经没了 */ + } + } + }; + + const timer = setTimeout(() => { + timedOut = true; + log(`回合超时(${Math.round(timeoutMs / 1000)} 秒),终止进程树`); + killTree('SIGTERM'); + // 给它一点时间优雅退出;不走就强杀(工具子进程不会自己收手)。 + // + // 这几个定时器**刻意不 unref**:在途的回合应该让进程活着。 + // unref 过的定时器会让「没有其它活跃句柄」的进程直接退出, + // 于是收尾这段代码根本没机会跑(测试里就是这样暴露的)。 + setTimeout(() => killTree('SIGKILL'), killGraceMs); + // ★ 最后兵底:**必须**让这一轮结束。 + // + // 没有这一步时,一个既不退也不报错的孩子会让 Promise 永不 settle —— + // 驱动会对那封信永远挂住,而且队列是串行的,后面的信全都不再被处理。 + // 杀掉进程本来就应该算「这一轮结束了」,而不是「再等等看」。 + setTimeout(() => { + if (settled) return; + log('强杀后仍未退出,按超时收尾(不再等)'); + finish(-1); + }, killGraceMs + settleGraceMs); + }, timeoutMs); + + const finish = exitCode => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ + sessionId: result?.sessionId || '', + response: result?.response || '', + events, + exitCode, + unparsable, + timedOut, + killed, + stderrTail: stderrLines.slice(-8).join('\n'), + argv: args + }); + }; + + // 把「怎么杀」交给调用方:驱动在收到 SIGTERM 时要能终止在途的回合。 + // 不交出去的话,systemd 杀掉驱动之后那个 ZCode 进程还在跑工具, + // 而没有任何人(也没有任何界面)看着它。 + if (typeof opts.onChild === 'function') { + try { + opts.onChild(killTree); + } catch { + /* 调用方自己的问题,不影响这一轮 */ + } + } + + child.stdout?.on('data', chunk => { + stdoutBuf += chunk.toString('utf8'); + let nl; + while ((nl = stdoutBuf.indexOf('\n')) >= 0) { + const line = stdoutBuf.slice(0, nl); + stdoutBuf = stdoutBuf.slice(nl + 1); + const parsed = parseStreamLine(line); + if (!parsed) { + // 空行是正常的(CLI 用空格分隔事件),只统计真的不像 JSON 的行。 + if (line.trim()) unparsable++; + continue; + } + if (parsed.kind === 'result') { + result = parsed; + } else { + events.push(parsed.event); + } + } + }); + + child.stderr?.on('data', chunk => { + for (const line of chunk.toString('utf8').split('\n')) { + const t = line.trim(); + if (!t) continue; + stderrLines.push(t); + // 实时转出去 —— 邮件驱动没有界面,日志是唯一能看见它在干什么的地方。 + log(t); + } + }); + + child.on('error', e => { + stderrLines.push(`进程错误:${e?.message || e}`); + finish(-1); + }); + + child.on('close', code => { + // 收尾:最后一行可能没有换行 + if (stdoutBuf.trim()) { + const parsed = parseStreamLine(stdoutBuf); + if (parsed?.kind === 'result') result = parsed; + else if (parsed?.kind === 'event') events.push(parsed.event); + else unparsable++; + } + log( + `回合结束:退出码 ${code},事件 ${events.length} 条,` + + `会话 ${result?.sessionId || '(无)'},耗时 ${Math.round((Date.now() - startedAt) / 1000)} 秒` + ); + finish(code ?? -1); + }); + }); +} diff --git a/plugins/zcode-mail-bridge/test/catchup.test.mjs b/plugins/zcode-mail-bridge/test/catchup.test.mjs new file mode 100644 index 0000000..23d38ab --- /dev/null +++ b/plugins/zcode-mail-bridge/test/catchup.test.mjs @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { MAX_CATCHUP, mailToEvent, selectCatchup } from '../lib/catchup.js'; + +const mail = (over = {}) => ({ + mail_id: 'm1', + session_id: 's1', + from_name: 'admin', + subject: '主题', + mail_type: 'normal', + to_workspace: '/tmp/ws', + ...over, +}); + +test('mailToEvent 产出与 SSE new_mail 同形的对象', () => { + const ev = mailToEvent(mail()); + // 投递侧读的就是这几个键,形状不一致会让补拉那条路径静默地少带信息 + for (const k of ['mail_id', 'session_id', 'from_name', 'subject', 'mail_type', 'to_workspace']) { + assert.ok(k in ev, `缺少 ${k}`); + } + assert.equal(ev.role, 'to'); + assert.equal(ev.catchup, true); +}); + +test('mailToEvent 对缺字段的行给出空串而非 undefined', () => { + const ev = mailToEvent({}); + assert.equal(ev.mail_id, ''); + assert.equal(ev.to_workspace, ''); + assert.equal(ev.mail_type, 'normal'); +}); + +test('已经通过 SSE 投过的不再补投', () => { + const mails = [mail({ mail_id: 'a' }), mail({ mail_id: 'b' })]; + const got = selectCatchup(mails, new Set(['a'])); + assert.deepEqual(got.map(e => e.mail_id), ['b']); +}); + +test('按时间正序补投(收件箱是倒序返回的)', () => { + // 收件箱:新的在前 + const mails = [mail({ mail_id: 'new' }), mail({ mail_id: 'mid' }), mail({ mail_id: 'old' })]; + const got = selectCatchup(mails, new Set()); + assert.deepEqual( + got.map(e => e.mail_id), + ['old', 'mid', 'new'], + '先来的邮件必须先处理,否则同一会话里的上下文顺序是乱的', + ); +}); + +test('permission 类邮件不补投', () => { + const mails = [mail({ mail_id: 'p', mail_type: 'permission' }), mail({ mail_id: 'n' })]; + const got = selectCatchup(mails, new Set()); + assert.deepEqual(got.map(e => e.mail_id), ['n']); +}); + +test('超过上限的部分留在收件箱里', () => { + const mails = Array.from({ length: MAX_CATCHUP + 4 }, (_, i) => mail({ mail_id: 'm' + i })); + const got = selectCatchup(mails, new Set()); + assert.equal(got.length, MAX_CATCHUP, '一次补拉不该把几十封邮件同时放出去'); +}); + +test('上限可显式压到 0(用于禁用补拉)', () => { + const got = selectCatchup([mail()], new Set(), 0); + assert.deepEqual(got, []); +}); + +test('空输入与非数组不炸', () => { + assert.deepEqual(selectCatchup([], new Set()), []); + assert.deepEqual(selectCatchup(undefined, new Set()), []); + assert.deepEqual(selectCatchup(null, new Set()), []); +}); + +test('没有 mail_id 的行跳过', () => { + const got = selectCatchup([mail({ mail_id: '' }), mail({ mail_id: 'ok' })], new Set()); + assert.deepEqual(got.map(e => e.mail_id), ['ok']); +}); + +test('seen 传 undefined 时不去重也不报错', () => { + const got = selectCatchup([mail({ mail_id: 'x' })], undefined); + assert.deepEqual(got.map(e => e.mail_id), ['x']); +}); diff --git a/plugins/zcode-mail-bridge/test/driver.test.mjs b/plugins/zcode-mail-bridge/test/driver.test.mjs new file mode 100644 index 0000000..84a9e66 --- /dev/null +++ b/plugins/zcode-mail-bridge/test/driver.test.mjs @@ -0,0 +1,369 @@ +/** + * 驱动的整条流水线测试(不需要模型、不需要 ZCode)。 + * + * 判据集中在几件**错了就会静默出错**的事上: + * + * - 说错「会不会替你回信」→ 要么发件人等一封永远不来的信,要么收到两封重复邮件 + * - 忘了带 `--mode` → 授权询问全部消失(见 turn-mode 的测试) + * - 一轮跑不起来却不回信 → 发件人只看到「信发出去了,然后再无音讯」 + * - 去重失效 → 同一封信被处理两遍 + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createDriver } from '../src/index.mjs'; +import { explicitSendsFile, noteExplicitSendFile } from '../lib/explicit-sends.mjs'; + +/** 假网关客户端:记下发出去的每一封信。 */ +function fakeClient() { + const sent = []; + return { + sent, + baseURL: 'http://fake', + agentName: 'zcode', + authHeaders: () => ({ 'X-Agent-Name': 'zcode' }), + checkConfig: () => [], + async post(path, body) { + if (path === '/mail/send') { + sent.push(body); + return { mail_id: `out-${sent.length}` }; + } + return {}; + }, + async get() { + return {}; + } + }; +} + +/** 一封来自人的来信。 */ +const humanMail = (over = {}) => ({ + mail_id: 'in-1', + session_id: 'sess-1', + role: 'to', + from_human: true, + from_name: 'gui-lab', + subject: '帮我看看日志', + permission_mode: 'workspace', + to_workspace: '', + reply_address: 'gui-lab@/work.sess-1', + ...over +}); + +async function harness({ turn = { sessionId: 'sess_z1', response: '结论:是磁盘满了', exitCode: 0 }, env } = {}) { + const dir = await mkdtemp(join(tmpdir(), 'zc-driver-')); + const client = fakeClient(); + const logs = []; + const calls = []; + const driver = createDriver({ + client, + logFn: (...a) => logs.push(a.join(' ')), + env: env || {}, + config: { workspaceRoot: dir, cliPath: '/fake/zcode.cjs', turnTimeoutMs: 60_000 }, + runTurnFn: async (opts, deps) => { + calls.push({ opts, deps }); + // 第三个参数是调用序号:测试里常要「第一次失败、第二次成功」 + return typeof turn === 'function' ? turn(opts, deps, calls.length) : turn; + } + }); + return { driver, client, logs, calls, dir, cleanup: () => rm(dir, { recursive: true, force: true }) }; +} + +test('★ 人来信 + 模型没自己发 → 自动回信', async () => { + const h = await harness(); + try { + await h.driver.processMail(humanMail()); + assert.equal(h.client.sent.length, 1, '必须回一封信'); + const mail = h.client.sent[0]; + assert.equal(mail.to, 'gui-lab'); + assert.equal(mail.body, '结论:是磁盘满了'); + assert.equal(mail.reply_to, 'in-1'); + assert.equal(mail.subject, 'Re: 帮我看看日志'); + // 走免配额通道:模型已经把话说完了,驱动只是搬运 + assert.equal(mail.relay, 'summary'); + assert.ok(mail.relay_key, '要有幂等键'); + } finally { + await h.cleanup(); + } +}); + +test('★ Agent 来信 → 不自动转发(Agent 间必须自己 send_mail)', async () => { + // 反向对照:同一封邮件只翻转 from_human,回信行为必须跟着翻转。 + // 不这么做的话,两个 Agent 会互相把对方的「已收到」当成待办,无限客套下去。 + const h = await harness(); + try { + await h.driver.processMail(humanMail({ from_human: false, from_name: 'pi' })); + assert.equal(h.client.sent.length, 0, 'Agent 来信不该被自动回信'); + assert.ok( + h.logs.some(l => /不自动转发/.test(l)), + `日志里应说明原因:${h.logs.join(' | ')}` + ); + } finally { + await h.cleanup(); + } +}); + +test('★ 模型这一轮自己发过信 → 让位,不重复转发', async () => { + // 线上实测过后果:收件箱里两封说同一件事的邮件(311 与 342 字节)。 + const env = { AGENTMAIL_ZCODE_SENDS_FILE: join(await mkdtemp(join(tmpdir(), 'zc-sends-')), 'sends.jsonl') }; + noteExplicitSendFile(env.AGENTMAIL_ZCODE_SENDS_FILE, { + sessionId: 'sess-1', + to: 'gui-lab', + replyTo: 'in-1' + }); + const h = await harness({ env }); + try { + await h.driver.processMail(humanMail()); + assert.equal(h.client.sent.length, 0, '模型已亲手回过,驱动不该再发一封'); + assert.ok(h.logs.some(l => /跳过自动转发/.test(l))); + } finally { + await h.cleanup(); + } +}); + +test('★ 反向对照:另一条会话的主动发信不该让本会话沉默', async () => { + // 去重不能按「有人发过信」一刀切,必须按会话配对。 + const dir = await mkdtemp(join(tmpdir(), 'zc-sends-')); + const env = { AGENTMAIL_ZCODE_SENDS_FILE: join(dir, 'sends.jsonl') }; + noteExplicitSendFile(env.AGENTMAIL_ZCODE_SENDS_FILE, { + sessionId: 'sess-OTHER', + to: 'gui-lab', + replyTo: 'in-1' + }); + const h = await harness({ env }); + try { + await h.driver.processMail(humanMail()); + assert.equal(h.client.sent.length, 1, '别的会话发过信不该影响这一封'); + } finally { + await h.cleanup(); + await rm(dir, { recursive: true, force: true }); + } +}); + +test('★★ 一轮跑不起来 → 必须回一封失败信', async () => { + // 邮件驱动的会话没有本地界面:什么都不发等于「信发出去了,然后再无音讯」。 + const h = await harness({ + turn: { sessionId: '', response: '', exitCode: 1, stderrTail: 'Model config is missing.' } + }); + try { + await h.driver.processMail(humanMail()); + assert.equal(h.client.sent.length, 1, '失败也必须回信'); + const mail = h.client.sent[0]; + assert.match(mail.subject, /处理失败/); + assert.match(mail.body, /Model config is missing/); + // 失败信的正文要给出**这个平台**的成因,而不是别处的建议 + assert.match(mail.body, /没有登录/); + assert.match(mail.body, /AGENTMAIL_ZCODE_CLI/); + assert.equal(h.driver.stats.failures, 1); + } finally { + await h.cleanup(); + } +}); + +test('超时也算失败,且原因写明超时', async () => { + const h = await harness({ + turn: { sessionId: '', response: '', exitCode: -1, timedOut: true } + }); + try { + await h.driver.processMail(humanMail()); + assert.match(h.client.sent[0].body, /超时/); + assert.doesNotMatch(h.client.sent[0].body, /CLI 失败/); + } finally { + await h.cleanup(); + } +}); + +test('退出码 0 但没有最终文本 → 不冒充回信', async () => { + const h = await harness({ turn: { sessionId: 's', response: ' ', exitCode: 0 } }); + try { + const r = await h.driver.processMail(humanMail()); + assert.equal(h.client.sent.length, 0); + assert.equal(r.relayed, false); + assert.ok(h.logs.some(l => /没有产出最终文本/.test(l))); + } finally { + await h.cleanup(); + } +}); + +// ─── 提示词与档位怎么传下去 ───────────────────────────────────────── +test('提示词里带上回信地址与邮件 id(模型自己发信时要拼对地址)', async () => { + const h = await harness(); + try { + await h.driver.processMail(humanMail()); + const prompt = h.calls[0].opts.prompt; + assert.match(prompt, /gui-lab@\/work\.sess-1/); + assert.match(prompt, /in-1/); + // 人来信:告诉模型插件会替它回信 + assert.match(prompt, /回信不用你自己发/); + } finally { + await h.cleanup(); + } +}); + +test('Agent 来信的提示词必须说清「插件不会替你回信」', async () => { + const h = await harness(); + try { + await h.driver.processMail(humanMail({ from_human: false, from_name: 'pi' })); + const prompt = h.calls[0].opts.prompt; + assert.match(prompt, /不会替你回信/); + assert.doesNotMatch(prompt, /回信不用你自己发/); + } finally { + await h.cleanup(); + } +}); + +test('★ 档位随邮件传下去,并作为 --mode 与钩子环境变量注入', async () => { + for (const [tier, mode] of [ + ['plan', 'plan'], + ['workspace', 'build'], + ['full', 'yolo'] + ]) { + const h = await harness(); + try { + await h.driver.processMail(humanMail({ permission_mode: tier })); + assert.equal(h.calls[0].opts.mode, mode, `档位 ${tier} 应映射到 ${mode}`); + const env = h.calls[0].opts.env; + // 钩子靠这两个变量决定档位与「有没有本地界面」 + assert.equal(env.AGENTMAIL_PERMISSION_MODE, tier); + assert.equal(env.AGENTMAIL_SESSION_ID, 'sess-1'); + } finally { + await h.cleanup(); + } + } +}); + +test('工作目录取 to_workspace;没有就用兜底目录', async () => { + const h = await harness(); + try { + await h.driver.processMail(humanMail()); + assert.ok(h.calls[0].opts.cwd.startsWith(h.dir), `兜底目录应在 ${h.dir} 下,实际 ${h.calls[0].opts.cwd}`); + + const real = await mkdtemp(join(tmpdir(), 'zc-ws-')); + await h.driver.processMail(humanMail({ mail_id: 'in-2', to_workspace: real })); + assert.equal(h.calls[1].opts.cwd, real); + await rm(real, { recursive: true, force: true }); + } finally { + await h.cleanup(); + } +}); + +test('★ 同一会话的第二封信带上 --resume(否则模型每封信都从零开始)', async () => { + const h = await harness(); + try { + await h.driver.processMail(humanMail()); + assert.equal(h.calls[0].opts.resumeSessionId, undefined, '首轮不该带 resume'); + await h.driver.processMail(humanMail({ mail_id: 'in-2' })); + assert.equal(h.calls[1].opts.resumeSessionId, 'sess_z1', '第二轮要续上同一个 ZCode 会话'); + } finally { + await h.cleanup(); + } +}); + +// ─── 事件入口 ─────────────────────────────────────────────────────── +test('SSE 事件:重复的 mail_id 只处理一次', async () => { + const h = await harness(); + try { + h.driver.handleEvent('new_mail', humanMail()); + h.driver.handleEvent('new_mail', humanMail()); + await new Promise(r => setTimeout(r, 20)); + assert.equal(h.calls.length, 1, '同一封信被处理了两遍'); + } finally { + await h.cleanup(); + } +}); + +test('抄送给自己也处理(role=cc),其它角色忽略', async () => { + const h = await harness(); + try { + h.driver.handleEvent('new_mail', humanMail({ mail_id: 'cc-1', role: 'cc' })); + h.driver.handleEvent('new_mail', humanMail({ mail_id: 'x-1', role: 'from' })); + await new Promise(r => setTimeout(r, 20)); + assert.equal(h.calls.length, 1); + assert.equal(h.calls[0].opts.prompt.includes('cc-1'), true); + } finally { + await h.cleanup(); + } +}); + +test('非 new_mail 事件被忽略(permission_decision 由钩子自己处理)', async () => { + const h = await harness(); + try { + h.driver.handleEvent('permission_decision', { relay_key: 'k' }); + h.driver.handleEvent('session_archived', { session_id: 's' }); + await new Promise(r => setTimeout(r, 20)); + assert.equal(h.calls.length, 0); + } finally { + await h.cleanup(); + } +}); + +test('一封邮件处理崩了不会带走驱动(后面的信照常处理)', async () => { + const h = await harness({ + turn: (opts, deps, n) => { + if (n === 1) throw new Error('boom'); + return { sessionId: 's', response: '第二封处理好了', exitCode: 0 }; + } + }); + try { + h.driver.handleEvent('new_mail', humanMail()); + h.driver.handleEvent('new_mail', humanMail({ mail_id: 'in-2' })); + await new Promise(r => setTimeout(r, 50)); + assert.equal(h.client.sent.length, 1); + assert.match(h.client.sent[0].body, /第二封处理好了/); + } finally { + await h.cleanup(); + } +}); + +test('★ 关停时终止在途回合(不留下跑工具的孤儿)', async () => { + // systemd 杀掉驱动后,那个 ZCode 进程还在跑工具,而既没有驱动看着它、 + // 也没有本地界面看着它 —— 宁可丢掉这一轮的工作。 + const signals = []; + let release; + const h = await harness({ + turn: async (opts, deps) => { + // 真实现会把「怎么杀」通过 deps.onChild 交出来(见 zcode-run.mjs) + deps.onChild(sig => signals.push(sig)); + await new Promise(r => (release = r)); + return { sessionId: 's', response: 'x', exitCode: 0 }; + } + }); + try { + const p = h.driver.processMail(humanMail()); + await new Promise(r => setTimeout(r, 20)); + assert.equal(typeof release, 'function', '回合应已开始'); + + h.driver.abort(); + assert.deepEqual(signals, ['SIGTERM'], 'abort 必须终止在途回合'); + // 幂等:重复关停不该再杀一次 + h.driver.abort(); + assert.deepEqual(signals, ['SIGTERM']); + + release(); + await p; + } finally { + await h.cleanup(); + } +}); + +// ─── 主动发信记录的落盘 ───────────────────────────────────────────── +test('★ explicit-sends 文件位置两端一致(驱动与 MCP 服务器必须解析出同一路径)', async () => { + const env = { AGENTMAIL_CONFIG_DIR: '/tmp/agentmail-cfg' }; + assert.equal(explicitSendsFile(env), '/tmp/agentmail-cfg/explicit-sends.jsonl'); + assert.equal(explicitSendsFile({ ZCODE_PLUGIN_DATA: '/d' }), '/d/explicit-sends.jsonl'); + assert.match(explicitSendsFile({}), /explicit-sends\.jsonl$/); +}); + +test('读回的记录形状可直接交给共用去重判据', async () => { + const dir = await mkdtemp(join(tmpdir(), 'zc-sends-')); + const f = join(dir, 'sends.jsonl'); + noteExplicitSendFile(f, { sessionId: 's1', to: 'gui-lab@/p', replyTo: 'm1' }); + const { readExplicitSends } = await import('../lib/explicit-sends.mjs'); + const rec = readExplicitSends(f, { sessionId: 's1' }); + assert.ok(rec.names.has('gui-lab')); + assert.ok(rec.replyTos.has('m1')); + await rm(dir, { recursive: true, force: true }); +}); diff --git a/plugins/zcode-mail-bridge/test/manual/driver-e2e.mjs b/plugins/zcode-mail-bridge/test/manual/driver-e2e.mjs new file mode 100644 index 0000000..c3b0855 --- /dev/null +++ b/plugins/zcode-mail-bridge/test/manual/driver-e2e.mjs @@ -0,0 +1,322 @@ +#!/usr/bin/env node +/** + * 邮件驱动的端到端验证 —— 用**桩 CLI** 替掉 ZCode,不需要模型、不需要登录。 + * + * # 它验的是什么 + * + * 除了「ZCode 收到提示词后能不能干活」(那需要模型),链路上其余每一环都真的跑: + * + * SSE 订阅 → 收到 new_mail → 去重 → 解析工作目录与档位 → 拼参数起一轮 + * → 解析 stream-json → 判定要不要自动回信 → 真的把回信投到网关 + * + * 桩 CLI 与真 ZCode 的差别只在「谁来产出那段最终文本」,而参数拼装、 + * 输出解析、回信策略、去重都是同一份代码。 + * + * # 判据(每条都配反向对照) + * + * 1. 人来信 → 驱动回信,且回信内容来自 CLI 的 `response` + * 2. **反向**:Agent 来信 → 驱动不回信(Agent 间必须自己 send_mail) + * 3. **反向**:桩 CLI 报错 → 驱动必须回一封失败信(否则发件人白等) + * 4. 参数核对:桩 CLI 把它收到的 argv 与环境变量落盘,逐项断言 + * ——`--mode` 尤其重要(漏了就是 yolo,授权会被绕过) + * + * 用法:node test/manual/driver-e2e.mjs [--keep] + */ + +import { spawn } from 'node:child_process'; +import { mkdtemp, rm, readFile, writeFile, mkdir } 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 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}` : ''}`); +}; + +const sleep = ms => new Promise(r => setTimeout(r, ms)); + +/** 桩 CLI:说 stream-json,并把收到的参数落盘供断言。 */ +const STUB = `#!/usr/bin/env node +// 桩 ZCode CLI:只做两件事 —— 把收到的参数落盘,然后按契约吐事件。 +import { writeFileSync, appendFileSync } from 'node:fs'; +const argv = process.argv.slice(2); +const rec = { + argv, + session: process.env.AGENTMAIL_SESSION_ID || '', + tier: process.env.AGENTMAIL_PERMISSION_MODE || '', + subject: process.env.AGENTMAIL_MAIL_SUBJECT || '' +}; +appendFileSync(process.env.STUB_LOG, JSON.stringify(rec) + '\\n'); +if (process.env.STUB_MODE === 'fail') { + // 用 exitCode 而不是 process.exit():后者会截断还没刷进管道的 stderr, + // 于是驱动收到的 stderrTail 时有时无 —— 判据就跟着时灵时不灵(实测过)。 + process.stderr.write('Model config is missing. Create /root/.zcode/cli/config.json\\n'); + process.exitCode = 3; +} else { +const prompt = argv[argv.indexOf('--prompt') + 1] || ''; +const marker = (prompt.match(/\\[MARKER:[^\\]]+\\]/) || ['(无标记)'])[0]; +process.stdout.write(JSON.stringify({ type: 'tool.call.started', toolName: 'Read' }) + '\\n'); +process.stdout.write(JSON.stringify({ + type: 'result', + sessionId: 'sess_stub_1', + response: '桩回答:收到 ' + marker, + eventCount: 1 +}) + '\\n'); +} +`; + +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。 + * + * 注意路径是 `/me/mail/send`(人类登录态)—— `/mail/send` 是 **Agent** 路由, + * 拿 cookie 去调会得到「Missing Authorization: Bearer …」, + * 而那条报错恰恰是在提示走错了路由表。 + */ +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, 160)}`); + return data; +} + +/** + * 以人类身份读收件箱,找主题含**唯一标记**的回信。 + * + * 必须用唯一标记(而不是「驱动验证(人)」这种片段)来定位: + * 收件箱是**跨轮次共享的持久状态**,上一轮的回信还在里面。 + * 只按片段找,第二轮会命中第一轮那封,于是「正文里有没有本轮标记」必然失败, + * 而后面的参数核对也会因为驱动其实还没开始跑而变成「无法判定」。 + * 实测踩过。 + */ +async function findReply(cookie, subjectFragment, timeoutMs = 40000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const res = await fetch(`${GATEWAY}/api/v1/me/mail/inbox?limit=40`, { headers: { Cookie: cookie } }); + if (res.ok) { + const data = await res.json().catch(() => ({})); + const hit = (data.mails || []).find( + m => String(m.subject || '').includes(subjectFragment) && String(m.from_name || '') === 'zcode' + ); + if (hit) return hit; + } + await sleep(700); + } + return null; +} + +async function main() { + const work = await mkdtemp(join(tmpdir(), 'zc-drv-e2e-')); + const stubCli = join(work, 'stub-zcode.mjs'); + const stubLog = join(work, 'stub-log.jsonl'); + const cfgDir = join(work, 'cfg'); + const wsRoot = join(work, 'ws'); + await writeFile(stubCli, STUB, 'utf8'); + await writeFile(stubLog, '', 'utf8'); + mkdirSync(cfgDir, { recursive: true }); + mkdirSync(wsRoot, { recursive: true }); + + // 凭据:优先用 /etc/agentmail/zcode.env,否则用注册时存的 secret + let env = { ...process.env }; + try { + const e = await readFile('/etc/agentmail/zcode.env', 'utf8'); + const key = e.match(/AGENTMAIL_AGENT_KEY=(.+)/)?.[1]?.trim(); + if (key) env.AGENTMAIL_AGENT_KEY = key; + } catch {} + if (!env.AGENTMAIL_AGENT_KEY) { + try { + env.AGENTMAIL_AGENT_SECRET = (await readFile('/root/gotmp/zcode-agent-secret.txt', 'utf8')).trim(); + } catch { + console.error('拿不到 zcode 凭据,无法验证'); + process.exit(2); + } + } + env = { + ...env, + AGENTMAIL_GATEWAY_URL: GATEWAY, + AGENTMAIL_AGENT_NAME: 'zcode', + AGENTMAIL_ZCODE_CLI: stubCli, + AGENTMAIL_CONFIG_DIR: cfgDir, + AGENTMAIL_WORKSPACE_ROOT: wsRoot, + AGENTMAIL_TURN_TIMEOUT_MS: '60000', + STUB_LOG: stubLog + }; + + const cookie = await login(); + console.log('已登录人类账号,启动驱动…\n'); + + const driver = spawn(process.execPath, [DRIVER], { env, stdio: ['ignore', 'pipe', 'pipe'] }); + const driverLog = []; + driver.stdout.on('data', d => driverLog.push(d.toString())); + driver.stderr.on('data', d => driverLog.push(d.toString())); + const stop = () => { + if (!driver.killed) driver.kill('SIGTERM'); + }; + /** + * 停掉一个驱动并**等它真的退出**。 + * + * 只发 SIGTERM 不等退出会留下一个仍然订阅着 SSE 的旧进程:它还会处理 + * 后面发的邮件,于是同一个 mail_id 会被两个驱动各回一次 + * (一个用旧配置、一个用新配置),而判据取到哪一封取决于时序 —— 实测就是 + * 这样时灵时不灵的。 + */ + const stopAndWait = async d => { + if (!d || d.exitCode !== null) return; + const done = new Promise(r => d.once('exit', r)); + d.kill('SIGTERM'); + await Promise.race([done, sleep(4000)]); + }; + + try { + // 等驱动接上 SSE(日志里会说就绪) + let ready = false; + for (let i = 0; i < 40 && !ready; i++) { + await sleep(250); + ready = driverLog.join('').includes('驱动就绪'); + if (driver.exitCode !== null) break; + } + if (!ready) { + record('驱动启动', '失败', driverLog.join('').slice(-400) || `退出码 ${driver.exitCode}`); + return; + } + record('驱动启动并接上 SSE', '通过', '自报强制力见日志'); + + // ── 1. 人来信 → 回信 ────────────────────────────────────── + const marker1 = `[MARKER:h-${Date.now()}]`; + const subj1 = `驱动验证(人)${marker1}`; + await humanSend(cookie, subj1, `请回复 ${marker1}`); + const reply1 = await findReply(cookie, marker1); + if (!reply1) { + record('人来信 → 驱动回信', '失败', '40 秒内没收到回信'); + } else if (String(reply1.body || '').includes(marker1)) { + record('人来信 → 驱动回信', '通过', `回信正文含标记(主题 ${reply1.subject})`); + } else { + record('人来信 → 驱动回信', '失败', `回信正文没有标记:${String(reply1.body).slice(0, 60)}`); + } + + // ── 2. 参数核对(桩 CLI 落盘的那一行)────────────────────── + const lines = (await readFile(stubLog, 'utf8')).split('\n').filter(Boolean).map(l => JSON.parse(l)); + const first = lines[0]; + if (!first) { + record('参数核对', '无法判定', '桩 CLI 没有记录到调用'); + } else { + const modeIdx = first.argv.indexOf('--mode'); + const mode = modeIdx >= 0 ? first.argv[modeIdx + 1] : '(未传)'; + // 人的来信默认档位是 workspace → build;漏传就是 yolo,授权会被绕过 + if (mode === 'build') { + record('★ --mode 传对了', '通过', `--mode ${mode}(workspace 档)`); + } else { + record('★ --mode 传对了', '失败', `实际 --mode ${mode}(漏传即 yolo,会绕过授权)`); + } + const outputFormat = first.argv[first.argv.indexOf('--output-format') + 1]; + record('--output-format stream-json', outputFormat === 'stream-json' ? '通过' : '失败', outputFormat); + record( + '钩子要的环境变量已注入', + first.tier === 'workspace' && first.session ? '通过' : '失败', + `tier=${first.tier} session=${first.session ? '有' : '无'}` + ); + } + + // ── 3. 反向对照:Agent 来信不回信 ───────────────────────── + const marker3 = `[MARKER:a-${Date.now()}]`; + const agentSubj = `驱动验证(Agent)${marker3}`; + // 用 zcode 自己以外的 Agent 发(pi 的密钥从环境文件读) + let sentAsAgent = false; + try { + const piEnv = await readFile('/etc/agentmail/pi.env', 'utf8'); + const piKey = piEnv.match(/AGENTMAIL_AGENT_KEY=(.+)/)?.[1]?.trim(); + if (piKey) { + const res = await fetch(`${GATEWAY}/api/v1/mail/send`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Agent-Name': 'pi', Authorization: `Bearer ${piKey}` }, + body: JSON.stringify({ to: 'zcode', subject: agentSubj, body: `来自 Agent 的来信 ${marker3}` }) + }); + sentAsAgent = res.ok; + if (!res.ok) console.log(` (Agent 发信失败 HTTP ${res.status})`); + } + } catch {} + + if (!sentAsAgent) { + record('反向对照 · Agent 来信不回信', '无法判定', '没发出 Agent 来信(缺 pi 凭据?)'); + } else { + await sleep(6000); + const leaked = await findReply(cookie, marker3, 1500); + const stubLines = (await readFile(stubLog, 'utf8')).split('\n').filter(Boolean).length; + if (leaked) { + record('反向对照 · Agent 来信不回信', '失败', `驱动给 Agent 回信了:${String(leaked.body).slice(0, 50)}`); + } else if (stubLines < 2) { + // 更硬的判据:Agent 来信**根本没起一轮**(不是起了轮但没回) + record('反向对照 · Agent 来信不回信', '通过', '既没回信,也没为它起一轮'); + } else { + record('反向对照 · Agent 来信不回信', '通过', `起了 ${stubLines} 轮但没有回信(交由模型自己 send_mail)`); + } + } + + // ── 4. 反向对照:CLI 失败 → 必须回失败信 ─────────────────── + await stopAndWait(driver); + await writeFile(stubLog, '', 'utf8'); + const env2 = { ...env, STUB_MODE: 'fail' }; + const driver2 = spawn(process.execPath, [DRIVER], { env: env2, stdio: ['ignore', 'pipe', 'pipe'] }); + const log2 = []; + driver2.stdout.on('data', d => log2.push(d.toString())); + driver2.stderr.on('data', d => log2.push(d.toString())); + try { + for (let i = 0; i < 40; i++) { + await sleep(250); + if (log2.join('').includes('驱动就绪') || driver2.exitCode !== null) break; + } + const marker4 = `[MARKER:f-${Date.now()}]`; + await humanSend(cookie, `驱动验证(失败)${marker4}`, '这封会失败'); + const failReply = await findReply(cookie, marker4, 40000); + if (failReply && String(failReply.body || '').includes('Model config is missing')) { + record('★ 反向对照 · CLI 失败必回失败信', '通过', `主题「${failReply.subject}」`); + } else if (failReply) { + record('★ 反向对照 · CLI 失败必回失败信', '通过', '回了失败信(正文未含原始报错)'); + } else { + record('★ 反向对照 · CLI 失败必回失败信', '失败', '40 秒内没有失败回信 —— 发件人会白等'); + } + } finally { + await stopAndWait(driver2); + } + } finally { + stop(); + await sleep(500); + if (KEEP) console.log(`\n工作目录保留在 ${work}`); + else 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(` ${driverLog.join('').split('\n').slice(-8).join('\n ')}`); + process.exit(fail > 0 ? 1 : 0); +} + +main().catch(e => { + console.error('验证脚本自身出错:', e); + process.exit(2); +}); diff --git a/plugins/zcode-mail-bridge/test/manual/permission-e2e.mjs b/plugins/zcode-mail-bridge/test/manual/permission-e2e.mjs index 0798ae0..49c2c29 100644 --- a/plugins/zcode-mail-bridge/test/manual/permission-e2e.mjs +++ b/plugins/zcode-mail-bridge/test/manual/permission-e2e.mjs @@ -20,6 +20,7 @@ */ import { spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; import { mkdtemp, rm } from 'node:fs/promises'; import { readFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -194,7 +195,9 @@ async function main() { 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' + // 必须**明显小于**下面 runHook 的杀进程上限:两者相等时钩子会在 + // 正要输出结论的瞬间被 SIGKILL,于是「不表态」与「来不及答」分不开。 + AGENTMAIL_PERMISSION_WAIT_MS: '20000' }; const seen = new Set(); @@ -306,42 +309,28 @@ async function main() { // ── 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: '仅用于验证:这条会话上没有人类。' - }) + // 用「**合法 UUID 但不存在**的会话」。实测服务端稳定回 409: + // {"error":"权限询问无法送达:该任务链上没有人类用户", …} + // + // 为什么不真的造一条「只有 Agent、没有人类」的会话:那不可靠 —— + // 发信可能落进一条**已有会话**(实测 zcode→pi 落进了带 gui-lab 的会话), + // 于是服务端正常受理、钩子开始等人,而人不会来,最后得到的是超时 block。 + // 那样这条控制组就验不到 409 那条路径,还容易被误读成通过。 + const ghost = randomUUID(); + const r = await runHook({ + input: hookInput('Bash', `toolu-nohuman-${Date.now()}`), + env: { ...baseEnv, AGENTMAIL_SESSION_ID: ghost, AGENTMAIL_PERMISSION_MODE: 'workspace' }, + timeoutMs: 60000 }); - const noHumanSession = (await res.json().catch(() => ({})))?.session_id; - if (!noHumanSession) { - record('反向对照 · 无人可问 → 拒绝', '无法判定', '未能造出无人类的会话'); + if (r.parsed?.decision === 'block' && /没有人类|无人可问|无法送达/.test(r.parsed.reason || '')) { + record('反向对照 · 无人可问 → 拒绝', '通过', (r.parsed.reason || '').split('\n')[0].slice(0, 60)); + } else if (r.parsed?.decision === 'block') { + // 时间到也是 block,但那不是这条控制组要验的东西 —— 明确报「无法判定」 + record('反向对照 · 无人可问 → 拒绝', '无法判定', `block 但不是 409 造成的:${(r.parsed.reason || '').slice(0, 50)}`); + } else if (r.parsed === null) { + 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}`); - } + record('反向对照 · 无人可问 → 拒绝', '失败', `钩子输出了 ${r.stdout}`); } } catch (e) { record('反向对照 · 无人可问 → 拒绝', '失败', e.message); diff --git a/plugins/zcode-mail-bridge/test/prompt.test.mjs b/plugins/zcode-mail-bridge/test/prompt.test.mjs new file mode 100644 index 0000000..a3a6dda --- /dev/null +++ b/plugins/zcode-mail-bridge/test/prompt.test.mjs @@ -0,0 +1,111 @@ +/** + * 提示词与回信文案的测试。 + * + * 这层没有 I/O,但它决定模型看到什么 —— 而模型看到的东西错了,表现是 + * 「这个 Agent 就是不回信」或「两个 Agent 无限客套」,都不会报错。 + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildMailPrompt, replySubject, renderTurnFailure } from '../src/prompt.mjs'; + +const mail = (over = {}) => ({ + mail_id: 'm1', + from_name: 'gui-lab', + from_human: true, + subject: '帮我看看', + reply_address: 'gui-lab@/w.alias', + ...over +}); + +test('新任务:说明这是新邮件,并给出发件人/主题/邮件 id', () => { + const p = buildMailPrompt({ agentName: 'zcode', data: mail() }); + assert.match(p, /gui-lab/); + assert.match(p, /帮我看看/); + assert.match(p, /m1/); + assert.match(p, /身份:你是 zcode/); +}); + +test('★ 回信不是新任务:带 in_reply_to 时要说清是回哪封', () => { + // 模型分不清「新任务」与「回信」时,会把对方一句「已收到」再当待办做一遍。 + const p = buildMailPrompt({ agentName: 'zcode', data: mail({ in_reply_to: 'm0' }) }); + assert.match(p, /回的是你那封:m0/); +}); + +test('★ 人来信 vs Agent 来信:回信责任必须不同', () => { + const fromHuman = buildMailPrompt({ agentName: 'z', data: mail({ from_human: true }) }); + const fromAgent = buildMailPrompt({ agentName: 'z', data: mail({ from_human: false }) }); + assert.match(fromHuman, /回信不用你自己发/); + assert.match(fromAgent, /不会替你回信/); + // 反向对照:两者不能出现对方的措辞 + assert.doesNotMatch(fromHuman, /不会替你回信/); + assert.doesNotMatch(fromAgent, /回信不用你自己发/); +}); + +test('★ from_human 缺失时按「不是人」处理(宁多一次 send_mail,不许诺空头回信)', () => { + const p = buildMailPrompt({ agentName: 'z', data: mail({ from_human: undefined }) }); + assert.match(p, /不会替你回信/); +}); + +test('补投的邮件标注 catchup(模型该知道这不是刚发生的)', () => { + const p = buildMailPrompt({ agentName: 'z', data: mail({ catchup: true }) }); + assert.ok(p.length > 0); + // 复用会话时不重复交代身份(省 token,且身份没变过) + const reused = buildMailPrompt({ agentName: 'z', data: mail(), reused: true }); + assert.doesNotMatch(reused, /身份:你是/); +}); + +test('权限结论走单独路径,不写成「新邮件」', () => { + const p = buildMailPrompt({ + agentName: 'z', + kind: 'permission', + data: { decision: '同意', decided_by: 'gui-lab' } + }); + assert.match(p, /同意/); + assert.match(p, /gui-lab/); + assert.doesNotMatch(p, /read_inbox/); +}); + +// ─── 回信主题 ───────────────────────────────────────────────────── +test('Re: 前缀不会越滚越长', () => { + assert.equal(replySubject('帮我看看'), 'Re: 帮我看看'); + assert.equal(replySubject('Re: 帮我看看'), 'Re: 帮我看看'); + assert.equal(replySubject('RE:帮我看看'), 'Re: 帮我看看'); + assert.equal(replySubject('回复: 帮我看看'), 'Re: 帮我看看'); +}); + +test('空主题回落到「本轮工作总结」', () => { + for (const s of ['', ' ', undefined, null]) { + assert.equal(replySubject(s), '本轮工作总结'); + } +}); + +// ─── 失败回信 ───────────────────────────────────────────────────── +test('★ 失败回信给出 ZCode 自己的成因,而不是别处的建议', () => { + // 共用库那份 renderFailureReport 的建议是「调整可用模型范围」—— + // 对 ZCode 而言那条建议什么也解决不了(它的常见成因是没登录)。 + const body = renderTurnFailure([{ kind: 'CLI 失败', error: 'Model config is missing.' }], '帮我看看'); + assert.match(body, /Model config is missing/); + assert.match(body, /没有登录/); + assert.match(body, /~\/\.zcode\/cli\/config\.json/); + assert.match(body, /AGENTMAIL_ZCODE_CLI/); + assert.doesNotMatch(body, /调整可用模型范围/); +}); + +test('失败回信列出每一次尝试', () => { + const body = renderTurnFailure( + [ + { kind: '超时', error: '回合超时' }, + { kind: 'CLI 失败', error: '退出码 7' } + ], + 's' + ); + assert.match(body, /已尝试 2 次/); + assert.match(body, /超时/); + assert.match(body, /退出码 7/); +}); + +test('失败回信在没有任何尝试记录时也不崩', () => { + const body = renderTurnFailure(undefined, undefined); + assert.match(body, /已尝试 0 次/); +}); diff --git a/plugins/zcode-mail-bridge/test/relay-policy.test.mjs b/plugins/zcode-mail-bridge/test/relay-policy.test.mjs new file mode 100644 index 0000000..20f0d4b --- /dev/null +++ b/plugins/zcode-mail-bridge/test/relay-policy.test.mjs @@ -0,0 +1,131 @@ +/** + * 自动转发适用范围的判定(lib/relay-policy.js)。 + * + * 三个函数是同一件事的三个出口,必须一起看: + * - autoRelayDecision 插件该不该替模型把结论发出去 + * - replyInstruction 提示词里怎么跟模型说这件事 + * - inboundHeadline 进来的这封是新活、是回复、还是补投 + * + * 分开写必然分叉,而分叉的代价是模型被骗:以为插件会替它回信,于是把话说完 + * 就停手,那封信却永远不会发出去。所以这里逐条钉住它们的一致性。 + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + addrName, + autoRelayDecision, + replyInstruction, + inboundHeadline, +} from '../lib/relay-policy.js'; + +// ─── addrName ─── + +test('addrName 取三维地址的名字段', () => { + assert.equal(addrName('pi@/home/program/agentmail.某别名'), 'pi'); + assert.equal(addrName('jianf'), 'jianf'); + assert.equal(addrName(' dsh@/x '), 'dsh'); + assert.equal(addrName(''), ''); + assert.equal(addrName(undefined), ''); +}); + +// ─── autoRelayDecision ─── + +test('人类来信 → 自动转发', () => { + const d = autoRelayDecision({ fromHuman: true, replyTo: 'jianf' }); + assert.equal(d.relay, true); +}); + +test('Agent 来信 → 不自动转发', () => { + const d = autoRelayDecision({ fromHuman: false, replyTo: 'dsh' }); + assert.equal(d.relay, false, + 'Agent 间通信必须由模型主动 send_mail —— 两边都自动回会无休止互相唤醒'); + assert.match(d.reason, /dsh/, '日志要说清是谁'); + assert.match(d.reason, /Agent/); +}); + +test('不知道回给谁 → 不转发,且理由与「对方是 Agent」区分得开', () => { + const d = autoRelayDecision({ fromHuman: true, replyTo: '' }); + assert.equal(d.relay, false); + assert.match(d.reason, /不知道回给谁/, + '「本轮没有回信」有三种原因,日志里必须能分辨'); +}); + +test('replyTo 带三维地址时也能认出 Agent 名', () => { + const d = autoRelayDecision({ fromHuman: false, replyTo: 'opencode@/home/x.别名' }); + assert.equal(d.relay, false); + assert.match(d.reason, /opencode/); +}); + +test('缺省参数不抛错(畸形事件不该弄死投递)', () => { + assert.equal(autoRelayDecision().relay, false); + assert.equal(autoRelayDecision({}).relay, false); +}); + +// ─── replyInstruction 与 autoRelayDecision 的一致性 ─── + +test('人类来信的提示词承诺「插件会替你发」,且这与决策一致', () => { + const lines = replyInstruction({ fromHuman: true }); + const text = lines.join('\n'); + assert.match(text, /回信不用你自己发/); + assert.equal(autoRelayDecision({ fromHuman: true, replyTo: 'jianf' }).relay, true, + '承诺了就必须真的做'); +}); + +test('Agent 来信的提示词必须明说「插件不会替你回信」', () => { + const text = replyInstruction({ fromHuman: false }).join('\n'); + assert.match(text, /不会替你回信/); + assert.match(text, /send_mail/, '必须给出唯一可行的做法'); + assert.doesNotMatch(text, /回信不用你自己发/, + '这句话在 Agent → Agent 时是假的 —— 说了它模型就会把话说完然后停手'); +}); + +test('Agent 来信的提示词要劝阻纯客套', () => { + const text = replyInstruction({ fromHuman: false }).join('\n'); + assert.match(text, /收到|确认/, '要点名那种没有信息量的回复'); + assert.match(text, /互相客套|无休止/, '要说清后果,否则模型不知道为什么被劝阻'); +}); + +test('Agent 来信时把回信地址带进提示词(有就带)', () => { + const withAddr = replyInstruction({ fromHuman: false, replyAddress: 'dsh@/x.别名' }).join('\n'); + assert.match(withAddr, /dsh@\/x\.别名/, + '要它自己发信却不给地址,它会拼一个 .new 出来 —— 那会静默开新会话'); + const without = replyInstruction({ fromHuman: false }).join('\n'); + assert.doesNotMatch(without, /(回信地址:)/, '没有地址时不该留一个空括号'); +}); + +// ─── inboundHeadline ─── + +test('回复到了 → 明说「这不是新任务」', () => { + const h = inboundHeadline({ inReplyTo: 'm-1', fromHuman: false }); + assert.match(h, /回复/); + assert.match(h, /不是新任务/, + '把回复当新任务处理正是互相客套的起点'); +}); + +test('回复的标题优先于续谈/补投标记', () => { + const h = inboundHeadline({ inReplyTo: 'm-1', fromHuman: true, reused: true, catchup: true }); + assert.match(h, /回复/, 'in_reply_to 是最强信号'); +}); + +test('Agent 来信在标题里就标出来', () => { + assert.match(inboundHeadline({ fromHuman: false }), /Agent/); + assert.doesNotMatch(inboundHeadline({ fromHuman: true }), /Agent/, + '人类来信不该带这个括号 —— 那是噪音'); +}); + +test('补投要说明,否则模型按「刚到的」语气回', () => { + const h = inboundHeadline({ fromHuman: true, catchup: true }); + assert.match(h, /积压|补投/); +}); + +test('续谈与新会话的措辞不同', () => { + assert.match(inboundHeadline({ fromHuman: true, reused: true }), /本会话/); + assert.match(inboundHeadline({ fromHuman: true, reused: false }), /你收到/); +}); + +test('缺省参数不抛错', () => { + assert.equal(typeof inboundHeadline(), 'string'); + assert.equal(typeof inboundHeadline({}), 'string'); +}); diff --git a/plugins/zcode-mail-bridge/test/turn-mode.test.mjs b/plugins/zcode-mail-bridge/test/turn-mode.test.mjs new file mode 100644 index 0000000..c1b8af9 --- /dev/null +++ b/plugins/zcode-mail-bridge/test/turn-mode.test.mjs @@ -0,0 +1,68 @@ +/** + * 档位 → ZCode `--mode` 映射的测试。 + * + * 这个映射是**授权系统存不存在**的开关:ZCode 的判定里 yolo 一律 allow, + * 而 `--prompt` 的默认 mode 就是 yolo。映射写错不会报错,只会让全部授权询问 + * 静默消失 —— 所以它是本项目里少数几个「错一个值等于功能整体失效」的地方。 + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { zcodeModeForTier, modeReachesPermissionHook, describeTier, ZCODE_MODES } from '../src/turn-mode.mjs'; + +test('三个档位映射到三个不同的 mode', () => { + assert.equal(zcodeModeForTier('plan'), 'plan'); + assert.equal(zcodeModeForTier('workspace'), 'build'); + assert.equal(zcodeModeForTier('full'), 'yolo'); +}); + +test('★ 只有 full 档会得到 yolo', () => { + // 反向对照:如果任何其它档位(含拼错的、空的、未知的、大小写不对的) + // 也能得到 yolo,那就意味着一个打字错误会关掉整个授权系统。 + for (const tier of ['plan', 'workspace', '', undefined, 'worjspace', 'default', 'FULL', 'Full']) { + assert.notEqual( + zcodeModeForTier(tier), + 'yolo', + `档位 ${JSON.stringify(tier)} 不该得到 yolo(实际 ${zcodeModeForTier(tier)})` + ); + } +}); + +test('★ 大写 FULL 不认,落在安全侧', () => { + // 共用库的 normalizeMode 是严格匹配的(只认小写),实测 FULL → workspace。 + // 这是**刻意保留**的好性质:认不出来时不会掉进「免授权」那一档, + // 而是退回 default。这条断言把它钉住 —— 哪天有人「顺手」改成大小写不敏感, + // 就会有一个打字错误变成全权授权的风险面。 + assert.equal(zcodeModeForTier('FULL'), 'build'); + assert.equal(zcodeModeForTier('PLAN'), 'build'); +}); + +test('未知档位退回 build(安全侧),不是 yolo', () => { + assert.equal(zcodeModeForTier('nonsense'), 'build'); + assert.equal(zcodeModeForTier(undefined), 'build'); +}); + +test('产出的 mode 必须是 ZCode 认识的值', () => { + for (const tier of ['plan', 'workspace', 'full', 'x', undefined]) { + assert.ok(ZCODE_MODES.includes(zcodeModeForTier(tier)), tier); + } +}); + +test('只有 build / edit 会让危险操作走到授权钩子', () => { + assert.equal(modeReachesPermissionHook('build'), true); + assert.equal(modeReachesPermissionHook('edit'), true); + // plan 由 ZCode 自己就拒了;yolo 直接放行 —— 两者都不产生询问 + assert.equal(modeReachesPermissionHook('plan'), false); + assert.equal(modeReachesPermissionHook('yolo'), false); +}); + +test('★ 反向对照:plan 与 full 都不产生询问,但原因不同', () => { + // 两条路都不产生 PermissionRequest,却在日志里必须能区分: + // 一个是「只读,ZCode 拒了」,一个是「全权,刻意不问」。 + const plan = describeTier('plan'); + const full = describeTier('full'); + assert.notEqual(plan, full); + assert.match(plan, /只读/); + assert.match(full, /全权/); + assert.match(describeTier('workspace'), /授权钩子/); +}); diff --git a/plugins/zcode-mail-bridge/test/workspace.test.mjs b/plugins/zcode-mail-bridge/test/workspace.test.mjs new file mode 100644 index 0000000..4913747 --- /dev/null +++ b/plugins/zcode-mail-bridge/test/workspace.test.mjs @@ -0,0 +1,128 @@ +/** + * 工作目录解析的回归测试。 + * + * 这是「dsh 指定工作目录完全失效,所有对话都落在未分组下」那次故障的直接回归: + * 插件曾无视寻址里的 path 位,每封邮件自己拼一个 ~/.dsh/mail-sessions/mail-, + * 而 DSH 按 cwd 分组,于是所有邮件会话既不属于任何项目、彼此也不同组。 + * + * node --test test/ + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir, homedir } from 'node:os'; +import { join } from 'node:path'; +import { resolveWorkspaceCwd, ensureCwd, mailSessionFallback } from '../lib/workspace.js'; + +// 兜底目录现在由调用方给(各平台不同)。DSH 用 mailSessionFallback, +// opencode 用插件启动时的 directory。 +const fallbackOf = key => mailSessionFallback(key); + +test('存在的绝对路径直接用作 cwd', () => { + const dir = mkdtempSync(join(tmpdir(), 'ws-test-')); + try { + const got = resolveWorkspaceCwd(dir, fallbackOf('mail-1')); + assert.equal(got.cwd, dir); + assert.equal(got.grouped, true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('不变量:同一 path 的多封邮件得到同一个 cwd(这才能同组)', () => { + const dir = mkdtempSync(join(tmpdir(), 'ws-test-')); + try { + const a = resolveWorkspaceCwd(dir, fallbackOf('mail-aaa')); + const b = resolveWorkspaceCwd(dir, fallbackOf('mail-bbb')); + assert.equal(a.cwd, b.cwd, 'fallbackKey 不同却应得到同一个 cwd'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('path 为空时回退到兜底目录', () => { + const got = resolveWorkspaceCwd('', fallbackOf('mail-2')); + assert.equal(got.cwd, fallbackOf('mail-2')); + assert.equal(got.grouped, false); +}); + +test('path 缺失/非字符串时回退', () => { + for (const v of [undefined, null, 42, {}]) { + const got = resolveWorkspaceCwd(v, fallbackOf('mail-3')); + assert.equal(got.grouped, false); + assert.equal(got.cwd, fallbackOf('mail-3')); + } +}); + +test('不变量:不存在的目录不创建,回退到兜底', () => { + // 一个笔误(/home/porgram/x)不该在磁盘上落下真目录 —— + // Agent 会在里面一无所获地干活,比明确回退更难排查。 + const got = resolveWorkspaceCwd('/nonexistent/path/xyz-should-not-exist', fallbackOf('mail-4')); + assert.equal(got.grouped, false); + assert.equal(got.cwd, fallbackOf('mail-4')); +}); + +test('不变量:相对路径被拒绝', () => { + // cwd 的相对基准是 harness 进程的启动目录,systemd 下通常是 /, + // 那是个与邮件语义完全无关的量。 + for (const rel of ['relative/path', './x', '../y', 'src']) { + const got = resolveWorkspaceCwd(rel, fallbackOf('mail-5')); + assert.equal(got.grouped, false, `${rel} 不该被当作工作目录`); + } +}); + +test('指向文件而非目录时回退', () => { + const dir = mkdtempSync(join(tmpdir(), 'ws-test-')); + const file = join(dir, 'a-file'); + writeFileSync(file, 'x'); + try { + const got = resolveWorkspaceCwd(file, fallbackOf('mail-6')); + assert.equal(got.grouped, false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('两端空白被修掉', () => { + const dir = mkdtempSync(join(tmpdir(), 'ws-test-')); + try { + const got = resolveWorkspaceCwd(` ${dir} `, fallbackOf('mail-7')); + assert.equal(got.cwd, dir); + assert.equal(got.grouped, true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('ensureCwd 只建兜底目录,不碰寻址指定的目录', () => { + const base = mkdtempSync(join(tmpdir(), 'ws-ensure-')); + try { + const target = join(base, 'made-by-ensure'); + ensureCwd(target, false); + // 建出来了 + const got = resolveWorkspaceCwd(target, ''); + assert.equal(got.grouped, true, 'ensureCwd 应已创建该目录'); + + // grouped=true 时不该创建(那种目录本来就存在) + const never = join(base, 'should-not-exist'); + ensureCwd(never, true); + assert.equal(resolveWorkspaceCwd(never, '').grouped, false); + } finally { + rmSync(base, { recursive: true, force: true }); + } +}); + +test('兜底为空串时返回空 cwd(交给平台自己决定)', () => { + // opencode 没配 directory 时就是这种情况:session.create 不带 query.directory, + // 由平台按自己的默认规则选目录。比硬塞一个我们猜的路径好。 + const got = resolveWorkspaceCwd('', ''); + assert.equal(got.cwd, ''); + assert.equal(got.grouped, false); +}); + +test('mailSessionFallback 同一 key 稳定、不同 key 不同', () => { + assert.equal(mailSessionFallback('a'), mailSessionFallback('a')); + assert.notEqual(mailSessionFallback('a'), mailSessionFallback('b')); + assert.match(mailSessionFallback('a'), /mail-sessions/); +}); diff --git a/plugins/zcode-mail-bridge/test/zcode-run.test.mjs b/plugins/zcode-mail-bridge/test/zcode-run.test.mjs new file mode 100644 index 0000000..b861bf8 --- /dev/null +++ b/plugins/zcode-mail-bridge/test/zcode-run.test.mjs @@ -0,0 +1,207 @@ +/** + * 跑一轮的测试:参数拼装 + stream-json 解析 + 假进程的整轮行为。 + * + * 不需要真的 ZCode、也不需要模型 —— 用可注入的 spawn 造一个说同样协议的假进程。 + * 这样「--mode 忘了传」「结果行没解析对」「超时不杀进程树」这些问题 + * 都能在本地断言,而不是等到线上某封信没人回。 + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { buildRunArgs, parseStreamLine, runTurn } from '../src/zcode-run.mjs'; + +/** 造一个假子进程:按脚本吐 stdout/stderr,然后以指定退出码关闭。 */ +function fakeSpawn({ stdout = '', stderr = '', exitCode = 0, onStart, neverExit = false } = {}) { + const calls = []; + const fn = (file, args, opts) => { + calls.push({ file, args, opts }); + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.pid = 4242; + child.kill = () => true; + onStart?.(child, { file, args, opts }); + setImmediate(() => { + if (stdout) child.stdout.emit('data', Buffer.from(stdout)); + if (stderr) child.stderr.emit('data', Buffer.from(stderr)); + if (!neverExit) { + child.exitCode = exitCode; + child.emit('close', exitCode); + } + }); + return child; + }; + fn.calls = calls; + return fn; +} + +// ─── 参数拼装 ───────────────────────────────────────────────────── +test('★ 参数里必须带 --mode(不传等于默认 yolo,会绕过授权)', () => { + const args = buildRunArgs({ prompt: 'p', cwd: '/tmp', mode: 'build' }); + assert.ok(args.includes('--mode')); + assert.equal(args[args.indexOf('--mode') + 1], 'build'); +}); + +test('★ 漏传 mode 直接抛错,而不是悄悄退回默认', () => { + // 这是刻意的:静默退回意味着「授权系统消失但没人发现」。 + assert.throws(() => buildRunArgs({ prompt: 'p', cwd: '/tmp' }), /mode/); +}); + +test('默认用 stream-json,便于把过程写进日志', () => { + const args = buildRunArgs({ prompt: 'p', cwd: '/tmp', mode: 'build' }); + assert.equal(args[args.indexOf('--output-format') + 1], 'stream-json'); +}); + +test('resume 只在有时才带(首轮不该带空 --resume)', () => { + const first = buildRunArgs({ prompt: 'p', cwd: '/tmp', mode: 'build' }); + assert.equal(first.includes('--resume'), false); + const next = buildRunArgs({ prompt: 'p', cwd: '/tmp', mode: 'build', resumeSessionId: 'sess_1' }); + assert.equal(next[next.indexOf('--resume') + 1], 'sess_1'); +}); + +test('maxTurns 与工具黑白名单按需传递', () => { + const args = buildRunArgs({ + prompt: 'p', + cwd: '/tmp', + mode: 'plan', + maxTurns: 6, + allowedTools: ['Read', 'Grep'], + disallowedTools: ['Bash'] + }); + assert.equal(args[args.indexOf('--max-turns') + 1], '6'); + assert.equal(args[args.indexOf('--allowed-tools') + 1], 'Read,Grep'); + assert.equal(args[args.indexOf('--disallowed-tools') + 1], 'Bash'); +}); + +// ─── 解析 ───────────────────────────────────────────────────────── +test('result 行取出 sessionId 与 response', () => { + const r = parseStreamLine( + JSON.stringify({ type: 'result', sessionId: 'sess_abc', response: '做完了', eventCount: 3 }) + ); + assert.deepEqual(r, { kind: 'result', sessionId: 'sess_abc', response: '做完了', eventCount: 3, projection: undefined }); +}); + +test('普通事件行归为 event', () => { + const e = parseStreamLine(JSON.stringify({ type: 'tool.call.started', toolName: 'Bash' })); + assert.equal(e.kind, 'event'); + assert.equal(e.event.toolName, 'Bash'); +}); + +test('非 JSON / 空行返回 null(不抛)', () => { + for (const line of ['', ' ', 'not json', '[1,2]', 'null', '42']) { + assert.equal(parseStreamLine(line), null, JSON.stringify(line)); + } +}); + +test('★ 输出格式变了会被计数,而不是静默当成没输出', async () => { + // 若 CLI 换掉了输出格式,所有的行都会变成不可解析 —— 那时必须能看见 + // 「解析不了的行有 N 条」,否则现象是「回合跑完了但什么都没回」。 + const spawn = fakeSpawn({ stdout: 'human readable output\nmore text\n' }); + const r = await runTurn({ prompt: 'p', cwd: '/tmp', mode: 'build' }, { spawn }); + assert.equal(r.unparsable, 2); + assert.equal(r.response, ''); +}); + +// ─── 整轮行为 ───────────────────────────────────────────────────── +test('整轮:解析事件、取出最终回复与会话 id', async () => { + const spawn = fakeSpawn({ + stdout: + JSON.stringify({ type: 'tool.call.started', toolName: 'Read' }) + + '\n' + + JSON.stringify({ type: 'result', sessionId: 'sess_9', response: '结论:可以' }) + + '\n' + }); + const r = await runTurn({ prompt: 'p', cwd: '/tmp', mode: 'build' }, { spawn }); + assert.equal(r.sessionId, 'sess_9'); + assert.equal(r.response, '结论:可以'); + assert.equal(r.events.length, 1); + assert.equal(r.exitCode, 0); +}); + +test('最后一行没有换行也能收到', async () => { + const spawn = fakeSpawn({ stdout: JSON.stringify({ type: 'result', sessionId: 's', response: 'ok' }) }); + const r = await runTurn({ prompt: 'p', cwd: '/tmp', mode: 'build' }, { spawn }); + assert.equal(r.response, 'ok'); +}); + +test('分块到达(一条 JSON 被切成两半)也能拼回来', async () => { + const payload = JSON.stringify({ type: 'result', sessionId: 'sess_split', response: '完整' }); + const half = Math.floor(payload.length / 2); + const spawn = fakeSpawn({ + onStart: child => { + setImmediate(() => { + child.stdout.emit('data', Buffer.from(payload.slice(0, half))); + child.stdout.emit('data', Buffer.from(`${payload.slice(half)}\n`)); + child.emit('close', 0); + }); + }, + neverExit: true + }); + const r = await runTurn({ prompt: 'p', cwd: '/tmp', mode: 'build' }, { spawn }); + assert.equal(r.response, '完整'); +}); + +test('非零退出码原样带出(不吞成成功)', async () => { + const spawn = fakeSpawn({ stdout: '', stderr: 'boom\n', exitCode: 7 }); + const r = await runTurn({ prompt: 'p', cwd: '/tmp', mode: 'build' }, { spawn }); + assert.equal(r.exitCode, 7); + assert.match(r.stderrTail, /boom/); +}); + +test('spawn 本身失败时给可读结果,而不是抛出去', async () => { + const spawn = () => { + throw new Error('ENOENT'); + }; + const r = await runTurn({ prompt: 'p', cwd: '/tmp', mode: 'build' }, { spawn }); + assert.equal(r.exitCode, -1); + assert.match(r.stderrTail, /ENOENT/); +}); + +test('★ 超时会标记 timedOut 并杀进程树', async () => { + const signals = []; + const spawn = fakeSpawn({ + neverExit: true, + onStart: child => { + child.kill = sig => { + signals.push(sig); + }; + } + }); + // 孩子永不退出:这是最坏情况 —— 既不退也不报错。 + const r = await runTurn( + { prompt: 'p', cwd: '/tmp', mode: 'build', turnTimeoutMs: 60, killGraceMs: 60, settleGraceMs: 60 }, + { spawn } + ); + assert.equal(r.timedOut, true, '必须报告超时'); + assert.equal(r.exitCode, -1); + // ★ 更关键的是**它一定会结束**:不结束的话驱动会对这封信永远挂住, + // 而队列是串行的,后面的信全都不再被处理。 + // 升级阶梯:先礼后兵。只断言「杀过」会漏掉「SIGTERM 之后没升级」—— + // 那种情况下要等满宽限期才能收尾,而工具子进程可能已经跑完了坏事。 + assert.deepEqual(signals, ['SIGTERM', 'SIGKILL']); +}); + +test('进程组隔离:非 win32 平台用 detached 起', async () => { + const spawn = fakeSpawn({ stdout: '' }); + await runTurn({ prompt: 'p', cwd: '/tmp', mode: 'build' }, { spawn }); + const opts = spawn.calls[0].opts; + if (process.platform !== 'win32') assert.equal(opts.detached, true); + assert.equal(opts.cwd, '/tmp'); +}); + +test('注入的环境变量会传给子进程(钩子靠它判断档位与有无本地界面)', async () => { + const spawn = fakeSpawn({ stdout: '' }); + await runTurn( + { + prompt: 'p', + cwd: '/tmp', + mode: 'build', + env: { AGENTMAIL_SESSION_ID: 'sess-x', AGENTMAIL_PERMISSION_MODE: 'workspace' } + }, + { spawn } + ); + const env = spawn.calls[0].opts.env; + assert.equal(env.AGENTMAIL_SESSION_ID, 'sess-x'); + assert.equal(env.AGENTMAIL_PERMISSION_MODE, 'workspace'); +});