/** * 「一直同意」的跨进程持久化。 * * # 为什么需要文件 * * ZCode 的钩子是**一个事件一个进程** —— 批准完就退出。pi 桥那边的授权集合活在 * 常驻 worker 里(靠会话快照跨 worker),而这里没有可依附的常驻内存: * 不落盘的话「一直同意」只在本次调用有效,而下一次调用是个新进程, * 会再问一遍 —— 那个选项就成了骗人的(pi 桥的注释里原话是 * 「否则这个选项在骗人」)。 * * # 判定语义不在这里 * * 「什么是同意」「什么是一直同意」全部来自共用的 `lib/permission-grants.js` * (逐字节同源)。本模块只管把结果存下来,不自己写判定正则 —— * 那正是各平台会悄悄分叉的地方。 * * # 并发 * * 读-改-写。同一会话的两次授权请求几乎不会同时发生(ZCode 串行执行工具), * 且写入是原子替换(临时文件 + rename),所以最坏情况是「后写覆盖先写」, * 不会读到半截 JSON。真要并发也只会多问一次,不会漏判。 */ import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { homedir } from 'node:os'; import { isAlwaysDecision } from './permission-grants.js'; /** * 授权表文件的位置。 * * 优先用显式配置,其次插件数据目录(ZCode 会注入 `ZCODE_PLUGIN_DATA`), * 最后退回家目录下的固定名。三者都不存在的情况极罕见, * 但必须有确定答案 —— 退回家目录至少让功能可用。 */ export function grantsFilePath(env = process.env) { if (env.AGENTMAIL_ZCODE_GRANTS_FILE) return env.AGENTMAIL_ZCODE_GRANTS_FILE; if (env.AGENTMAIL_CONFIG_DIR) return join(env.AGENTMAIL_CONFIG_DIR, 'permission-grants.json'); if (env.ZCODE_PLUGIN_DATA) return join(env.ZCODE_PLUGIN_DATA, 'permission-grants.json'); return join(homedir(), '.agentmail-zcode', 'permission-grants.json'); } /** 读盘。文件不存在或内容坏掉都当空表 —— 授权表读不出来不该让钩子崩。 */ export function loadGrants(filePath) { try { const raw = JSON.parse(readFileSync(filePath, 'utf8')); const out = new Map(); for (const [session, tools] of Object.entries(raw?.sessions ?? {})) { if (Array.isArray(tools)) out.set(session, new Set(tools.filter(t => typeof t === 'string'))); } return out; } catch { return new Map(); } } function saveGrants(filePath, grants) { const sessions = {}; for (const [session, tools] of grants) sessions[session] = [...tools]; const payload = { version: 1, sessions }; mkdirSync(dirname(filePath), { recursive: true }); // 原子替换:直接覆盖写会让并发读者看到半截 JSON(而上面的 load 会把它 // 当成空表,于是刚给的授权静默消失)。 const tmp = `${filePath}.tmp-${process.pid}`; writeFileSync(tmp, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); renameSync(tmp, filePath); } /** * 基于文件的授权表。 * * @param {string} filePath */ export function createFileGrantStore(filePath) { const grants = loadGrants(filePath); return { isGranted(sessionId, toolName) { if (!sessionId || !toolName) return false; return grants.get(sessionId)?.has(toolName) ?? false; }, /** 只在决策文本确实是「一直同意」时落盘(判定交给共用库)。 */ grant(sessionId, toolName, decision) { if (!sessionId || !toolName) return false; if (!isAlwaysDecision(decision)) return false; let set = grants.get(sessionId); if (!set) grants.set(sessionId, (set = new Set())); set.add(toolName); saveGrants(filePath, grants); return true; }, /** 撤销整条会话的免批(换模型重开会话时用)。 */ revokeSession(sessionId) { if (!grants.delete(sessionId)) return false; saveGrants(filePath, grants); return true; } }; }