/** * 授权往返:把一次「要不要执行这个动作」的询问发给人类,等他的决定。 * * # 为什么必须是独立模块 * * 它现在有两个调用方,而且两者的失败后果完全不同: * * - `hooks/permission.mjs`:ZCode 桌面(交互)模式下的 PermissionRequest 钩子 * - `lib/action-tools.mjs`:headless 模式下我们自己的执行工具(run_command 等) * * 两份实现迟早会漂移,而漂移的地方恰恰是最不该出错的判定:「什么算同意」 * 「永久失败要不要 fail closed」「超时算不算拒绝」。所以判定复用共用库的 * `isApproval` / `isAlwaysDecision` / `isPermanentFailure`,流程只有这一份。 * * # 三条不可动摇的规矩 * * 1. **只有明确同意才放行**(共用库的 `isApproval`)。注意它实际的判据是 * **前缀匹配** `/^(同意|一直同意|allow|approve|always|yes)/i`(四个桥共用同一份, * 所以这里不能另立一套)。前缀里的东西(如「同意吧」)算同意, * 而看不懂的文本、空串、`拒绝`、`deny`、平台自己的 `shutdown` 哨兵一律当拒绝 —— * 判据是「在放行白名单里」,不是「不等于拒绝」。 * 2. **永久失败当场拒绝**(409 无人可问、4xx 参数/权限错)。它们不会因为重试 * 而改变,重试只会把「权限系统坏了」这件事藏起来。 * 3. **暂时失败看有没有本地界面**:有(桌面模式)就退回平台自己的流程; * 没有(headless 邮件驱动)必须拒绝 —— 退回等于守卫消失。 * 判据用的是调用方传进来的 `sessionId`(会话由邮件驱动 = 没有界面), * **不再另读 `AGENTMAIL_SESSION_ID`**:两个事实来源迟早会不一致, * 而它们不一致时到底算有界面还是没界面,谁都说不清。 * * # 为什么自己开 SSE * * 网关的 SSE 是**扇出**的(`clients` 按唯一 id 存,`SendToAgent` 推给该 Agent * 的所有客户端),所以一个短命的钩子进程或一次工具调用都能自己订阅、拿到 * 自己那条决定、然后退出。先建连再发请求 —— 反过来会有一个窗口:人恰好在 * 窗口内点了同意,而事件推给了当时还不存在的客户端,表现为「明明点了同意 * 却被拒」。 */ import { createSSEClient } from './sse-client.js'; import { randomUUID } from 'node:crypto'; import { clampRelayKey, isPermanentFailure, isDuplicateRelay } from './relay-key.js'; import { isApproval, isAlwaysDecision } from './permission-grants.js'; import { normalizeMode, DEFAULT_MODE, MODE_FULL, MODE_PLAN } from './permission-mode.js'; /** 等待人工决策的默认上限。调用方应保证它**明显小于**自己的杀进程上限, * 否则会在正要给出结论的瞬间被杀掉,而「不表态」与「来不及答」就分不开了。 */ export const DEFAULT_WAIT_MS = 540000; /** 当前档位(来自驱动注入的环境变量)。 */ export function tierOf(env = process.env) { return normalizeMode(env.AGENTMAIL_PERMISSION_MODE) || DEFAULT_MODE; } /** * 「有没有本地界面可以让人就地决定」。 * * 判据是驱动有没有注入会话 id:邮件驱动的会话由驱动起、没有界面; * 人自己开着 ZCode 时有界面。这个区分决定了暂时失败该 fail closed 还是让位。 */ export function hasLocalUi(env = process.env) { return !String(env.AGENTMAIL_SESSION_ID || '').trim(); } /** 等 SSE 建连完成(服务端在 AddClient 时立刻下发一个 connected 事件)。 */ function waitConnected(state, timeoutMs = 5000, log = () => {}) { return new Promise(resolve => { const timer = setTimeout(() => { log('SSE 建连等待超时,仍然继续(可能错过极早到达的决策)'); resolve(); }, timeoutMs); state.onConnected = () => { clearTimeout(timer); resolve(); }; }); } /** * 幂等键必须**每次调用都不同**。 * * 这里踩过一个真坑,而且失败方式极隐蔽:键取成 `会话 + 工具` 之后, * 同一个会话里**第二次** `run_command` 就是个「重复请求」——网关按设计 * 返回 HTTP 200 `{status:"duplicate_relay", detail:"该权限询问已转发过,本次调用未产生新邮件"}` * 并且**提前返回**:不建请求、不发邮件、永远不会有人来决策。 * * 于是工具干等(实测被 MCP 的 30 秒调用超时砍掉),模型回报 * 「30 秒内未获批准」—— 看上去像人没理它,实际是**请求根本没出去**。 * 而 HTTP 还全是 200,从状态码上看不出任何异常。 * * 所以键的语义是「**这一次调用**」(一次工具调用 = 一次询问),不是「这个会话的这个工具」。 * 重复请求的去重需求由「一直同意」表承担(那张表是按 会话+工具 生效的,那是对的语义)。 */ export function relayKeyForCall({ seed, sessionId, toolName, nonce }) { const head = seed || `${sessionId || 'zcode'}:${toolName}`; const tail = nonce || randomUUID().slice(0, 8); return clampRelayKey(`${head}:${tail}`); } // `isDuplicateRelay` 与 `DUPLICATE_RELAY_STATUS` 已挪到 **共用库** `lib/relay-key.js`: // 四个桥都要认这个回包,各写一份必然分叉(而这个判据是「静默挂死」与 // 「当场拒绝」的分界)。这里只 import。 /** * 询问人类。 * * @param {object} opts * @param {any} opts.client 网关客户端(要 authHeaders / post / baseURL) * @param {string} opts.toolName 工具名(同时用作「一直同意」的授权粒度) * @param {string} opts.question 给人看的问题 * @param {string} opts.context 给人看的上下文(命令内容/文件路径等) * @param {string} [opts.sessionId] AgentMail 会话 id * @param {string} [opts.relayKeySeed] 幂等键前缀(默认 session:tool);每次调用会**追加一个随机尾**, * 见 relayKeyForCall 的注释 * @param {string} [opts.nonce] 仅测试用:固定随机尾以便断言 * @param {object} opts.grants createFileGrantStore 的实例(可省) * @param {string} [opts.tier] 档位(默认从环境读) * @param {number} [opts.waitMs] * @param {Function} [opts.log] * @param {Function} [opts.createSSE] 供测试注入 * @returns {Promise<{allowed:boolean, reason:string, decidedBy:string, via:string}>} * via 说明结论来自哪一步:tier / grant / human / permanent-failure / * timeout / transport —— 日志与回信要能看出「当时凭什么放行」。 */ export async function requestApproval(opts) { const { client, toolName, question, context = '', sessionId = '', relayKeySeed, nonce, grants = null, tier = tierOf(), waitMs = DEFAULT_WAIT_MS, log = () => {}, createSSE = createSSEClient } = opts; // ① 档位:plan 档只允许读与查,没什么可问人的(该档语义就是「不动手」)。 if (tier === MODE_PLAN) { return { allowed: false, via: 'tier', decidedBy: '', reason: `plan 档下不允许执行 ${toolName}。本档只允许读与查,请把方案写在回信里。` + `如需动手请让发件人把档位改成 workspace。` }; } // ② full 档:发件人已声明全权。这一档的核心语义就是免掉询问。 if (tier === MODE_FULL) { return { allowed: true, via: 'tier', decidedBy: '', reason: `${tier} 档:全权,无需询问` }; } const scope = sessionId || ''; // ③ 「一直同意」:钩子是短命进程,所以这张表由文件承载(见 grants-file.mjs)。 if (grants && grants.isGranted(scope, toolName)) { return { allowed: true, via: 'grant', decidedBy: '', reason: `本会话的 ${toolName} 已获「一直同意」` }; } const relayKey = relayKeyForCall({ seed: relayKeySeed, sessionId: scope, toolName, nonce }); // 有没有本地界面:由**调用方给的会话 id** 判定(单一事实来源)。 // 邮件驱动的会话一定带 sessionId;人自己开着 ZCode 时没有。 const mailDriven = scope.trim() !== ''; // ④ 先订阅再发请求(顺序不能反,见文件头注释)。 const state = { onConnected: null }; let waiter = null; const early = []; const sse = createSSE({ authHeaders: () => client.authHeaders(), baseURL: client.baseURL, path: '/api/v1/events/stream', log, onEvent: (evt, data) => { if (evt === 'connected' && state.onConnected) state.onConnected(); if (evt !== 'permission_decision') return; // 只认自己那条:同一 Agent 可能同时有多个调用在等(模型并行发起两个动作), // 按 relay_key 配对才不会互相拿到对方的决定。 if (data?.relay_key && data.relay_key !== relayKey) return; if (waiter) { const w = waiter; waiter = null; w(data); } else { early.push(data); } } }); try { await waitConnected(state, 5000, log); try { const accepted = await client.post('/permission/request', { question, options: ['同意', '一直同意', '拒绝'], context, session_id: scope, relay_key: relayKey }); // 幂等命中 = 请求**没有**发出去,永远不会有决策事件。 // 不把它当成失败的话,调用方会一直等到被客户端杀掉,而错误信息是 // 「没有人批准」—— 归因完全错了。所以当场以可读的原因拒绝。 if (isDuplicateRelay(accepted)) { log(`授权询问被网关判为重复(relay_key=${relayKey}),本次没有产生新请求`); return { allowed: false, via: 'duplicate-relay', decidedBy: '', reason: `授权请求被网关当作重复请求丢弃了(${accepted.detail || 'duplicate_relay'})。` + `这意味着**没有人会看到这次询问**,因此不放行。` + `请重新发起(键每次调用都不同),或改用不需要授权的方式。` }; } } catch (e) { // 永久失败(409 无人可问 / 4xx)不会因重试而改变 → 当场拒绝, // 让调用方从错误里看到原因并自己改道(挂死时连重试机会都没有)。 if (isPermanentFailure(e)) { const b = e?.body && typeof e.body === 'object' ? e.body : {}; const reason = [b.error || `权限询问无法送达(HTTP ${e?.status})`, b.detail || '', b.suggestion || ''] .filter(Boolean) .join('\n'); log(`权限询问永久失败,当场拒绝 ${relayKey}:${reason.split('\n')[0]}`); return { allowed: false, via: 'permanent-failure', decidedBy: '', reason }; } const detail = e?.message || String(e); log(`权限询问暂时失败:${detail}`); if (mailDriven) { // 邮件驱动:没有本地界面兜底,退回本地决策等于守卫消失。 return { allowed: false, via: 'transport', decidedBy: '', reason: `无法把 ${toolName} 的授权请求送达给人(${detail})。` + `这条会话由邮件驱动、没有本地界面,因此不放行。` + `请改用不需要授权的方式完成,或在回信里说明需要人工执行哪一步。` }; } return { allowed: false, via: 'transport', decidedBy: '', reason: `授权询问失败:${detail}` }; } const decision = early.shift() ?? (await new Promise(resolve => { waiter = resolve; setTimeout(() => { if (waiter !== resolve) return; waiter = null; resolve(null); }, waitMs); })); if (decision === null) { return { allowed: false, via: 'timeout', decidedBy: '', reason: `等待授权超时(${Math.round(waitMs / 1000)} 秒内没有人决策),未执行 ${toolName}。` }; } const text = decision.decision ?? ''; if (isApproval(text)) { if (isAlwaysDecision(text) && grants?.grant(scope, toolName, text)) { log(`记下「一直同意」:会话 ${scope} 的 ${toolName} 后续免批`); } return { allowed: true, via: 'human', decidedBy: decision.decided_by || '', reason: `获批(${text})` }; } return { allowed: false, via: 'human', decidedBy: decision.decided_by || '', reason: [ `用户拒绝了这次 ${toolName} 调用。`, decision.note ? `说明:${decision.note}` : '', decision.decided_by ? `(由 ${decision.decided_by} 决定)` : '' ] .filter(Boolean) .join('\n') }; } finally { sse.stop(); } }