feat: 权限档位体系(三档 plan/workspace/full + 四桥 from_session_id)
L2 核心改动:sessions 表补 permission_mode / permission_enforcement 两列 (sqlite + pg 同步),三桥 lib/permission-mode.js 翻译档位到平台原生配置, homeagent advisory 模式提示词告知模型实际强制力。四桥全部携带 from_session_id 供 relay 去重与会话回溯。 FromHuman / ToHuman 判据已加入心跳 payload 与 notify/mail.go。
This commit is contained in:
274
plugins/pi-mail-bridge/lib/permission-mode.js
Normal file
274
plugins/pi-mail-bridge/lib/permission-mode.js
Normal file
@ -0,0 +1,274 @@
|
||||
/**
|
||||
* 权限档位 → 平台原生配置的翻译 —— 四个平台共用的判据。
|
||||
*
|
||||
* ## 分工
|
||||
*
|
||||
* **AgentMail 声明,平台执行,插件只翻译。** 这个模块是「翻译」那一步的
|
||||
* 唯一实现:把 `plan` / `workspace` / `full` 翻成各平台原生的沙箱/审批配置。
|
||||
*
|
||||
* 为什么不让插件自己按工具名猜着拦:那会同时违反 I-1(平台原生信号是唯一
|
||||
* 真相来源)与 I-4(插件只搬运不决策),而且四个插件对「workspace 到底管
|
||||
* 什么」必然各猜一套 —— 同一封 workspace 档的邮件在 A 平台被拦、在 B 平台放行。
|
||||
*
|
||||
* ## 为什么必须「向更严取整」
|
||||
*
|
||||
* 平台表达不出精确档位时,一律往更严的方向走,并如实上报自己做到了什么
|
||||
* (native / advisory)。pi 就是例子:write/edit 能查 `input.path` 判断越界,
|
||||
* 而 bash 命令要碰哪些文件是解析不出来的 —— 于是 workspace 档下 pi 只能
|
||||
* 「每条 bash 都问人」,比声明的更严。
|
||||
*
|
||||
* 不定这条规则的后果:不同插件会朝不同方向取整,而往宽松取整是静默失效
|
||||
* (人以为收紧了,实际没有)。
|
||||
*/
|
||||
|
||||
/** 只读:查资料、读代码、出方案,一个字都不许写。 */
|
||||
export const MODE_PLAN = 'plan';
|
||||
/** 本目录内可动手,越界要问人。默认档。 */
|
||||
export const MODE_WORKSPACE = 'workspace';
|
||||
/** 自动放行,不问人。 */
|
||||
export const MODE_FULL = 'full';
|
||||
|
||||
/** 全部合法档位,按宽松程度递增。顺序是 modeAtMost 的依据。 */
|
||||
export const MODES = [MODE_PLAN, MODE_WORKSPACE, MODE_FULL];
|
||||
|
||||
/** 没有显式指定时的档位。与 Gateway 的 DefaultPermissionMode 必须一致。 */
|
||||
export const DEFAULT_MODE = MODE_WORKSPACE;
|
||||
|
||||
/** 平台有原生拦截点,档位被真正执行。 */
|
||||
export const ENFORCE_NATIVE = 'native';
|
||||
/** 平台没有拦截点,档位只写进提示词。 */
|
||||
export const ENFORCE_ADVISORY = 'advisory';
|
||||
|
||||
/**
|
||||
* 把外部输入收敛成合法档位。
|
||||
*
|
||||
* 非法值 → 默认档(**不是** full)。拼错一个档位名不该换来更大的权限。
|
||||
* 与 Gateway 的 NormalizePermissionMode 同语义。
|
||||
*
|
||||
* @param {unknown} mode
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizeMode(mode) {
|
||||
return MODES.includes(mode) ? mode : DEFAULT_MODE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收敛强制力取值。空串或非法值 → advisory。
|
||||
*
|
||||
* 保守方向是 advisory 而不是 native:不能替一个没自报过的平台宣称
|
||||
* 「档位在这里是被强制的」。
|
||||
*
|
||||
* @param {unknown} e
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizeEnforcement(e) {
|
||||
return e === ENFORCE_NATIVE || e === ENFORCE_ADVISORY ? e : ENFORCE_ADVISORY;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取两个档位里更严的那一个。
|
||||
*
|
||||
* 先归一化再比较 —— 两个脏值都变成默认档,于是结果与参数顺序无关(可交换)。
|
||||
* Gateway 侧的 ModeAtMost 曾因为「modeRank 把未知值当最严、Normalize 把它
|
||||
* 归到默认档」而不可交换,单元测试当场抓到。两边保持同一套语义。
|
||||
*
|
||||
* @param {string} a
|
||||
* @param {string} b
|
||||
* @returns {string}
|
||||
*/
|
||||
export function modeAtMost(a, b) {
|
||||
const na = normalizeMode(a);
|
||||
const nb = normalizeMode(b);
|
||||
return MODES.indexOf(na) <= MODES.indexOf(nb) ? na : nb;
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一档会不会产生权限邮件(即需不需要人来点头)。
|
||||
*
|
||||
* 只有 workspace 档需要人:plan 档当场拒绝、full 档自动放行,两者都不问人。
|
||||
* 插件据此决定要不要把平台的权限钩子接到 `/permission/request`。
|
||||
*
|
||||
* @param {string} mode
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function modeNeedsHuman(mode) {
|
||||
return normalizeMode(mode) === MODE_WORKSPACE;
|
||||
}
|
||||
|
||||
/**
|
||||
* opencode 的 permission 规则数组。
|
||||
*
|
||||
* ## 六条实测结论(不实测就会做出「看起来对但管不住」的东西)
|
||||
*
|
||||
* 1. **规则是 findLast 胜出**(二进制里
|
||||
* `findLast((z)=>g.match(j,z.permission)&&g.match(J,z.pattern))`)
|
||||
* → deny 必须放前面、allow 放后面。反了的话连允许的路径也被拒。
|
||||
* 2. **pattern 匹配 worktree 相对路径**(`patterns:[relative(y.worktree,file)]`)
|
||||
* → 写 `/tmp/**` 这种绝对 pattern 永远匹配不上(`/tmp/x` 相对
|
||||
* `/home/program/agentmail` 是 `../../../tmp/x`)。所以 workspace 档用 `**`。
|
||||
* 3. **write / edit / patch 共用 `edit` 一个权限名**
|
||||
* (`if(A==="write"||A==="edit"||A==="patch"){G.edit=I}`)。
|
||||
* 4. **全 deny 让工具从模型清单里消失**(模型自述「I don't have a bash tool
|
||||
* available in this session」),部分 deny 则工具保留、越界调用才报错。
|
||||
* plan 档用前者更好:模型不会浪费轮次去试。
|
||||
* 5. **task(子代理)能绕过父会话权限** —— 实测中模型发现自己没 write,
|
||||
* 主动 task 委派给一个带 write 的子代理去写成了。plan/workspace 必须
|
||||
* `task deny *`,否则档位形同虚设。
|
||||
* 6. **bash 能绕过 edit 的路径限制** —— 模型用 shell 重定向写成了本该被
|
||||
* deny 的文件。所以 workspace 档必须同时管 bash,只管 edit 没用。
|
||||
*
|
||||
* 另注:opencode 原生有 `plan_enter` / `plan_exit` 权限项,与我们的 plan 档
|
||||
* **撞名但语义不同**(那是它自己的计划模式开关),这里不碰它们。
|
||||
*
|
||||
* @param {string} mode
|
||||
* @returns {{permission: string, action: string, pattern: string}[]}
|
||||
*/
|
||||
export function opencodePermissions(mode) {
|
||||
const m = normalizeMode(mode);
|
||||
|
||||
if (m === MODE_FULL) {
|
||||
// 全权:不下发任何规则,用平台自己的默认配置。
|
||||
// 显式全 allow 会覆盖掉用户在 opencode.jsonc 里的个人设置。
|
||||
return [];
|
||||
}
|
||||
|
||||
if (m === MODE_PLAN) {
|
||||
// 只读。四项都要 deny:
|
||||
// - edit 覆盖 write/edit/patch
|
||||
// - bash 否则 shell 重定向就能写文件(实测过)
|
||||
// - task 否则子代理能绕过(实测过)
|
||||
// - webfetch/websearch 不禁:查资料是 plan 档的本职
|
||||
return [
|
||||
{ permission: 'edit', action: 'deny', pattern: '*' },
|
||||
{ permission: 'bash', action: 'deny', pattern: '*' },
|
||||
{ permission: 'task', action: 'deny', pattern: '*' },
|
||||
];
|
||||
}
|
||||
|
||||
// workspace:目录内可写,越界问人。
|
||||
//
|
||||
// deny 在前、allow 在后(findLast 胜出)。pattern `**` 是 worktree
|
||||
// 相对路径,等价于「这个工作目录内的任何文件」。
|
||||
//
|
||||
// bash 一律 ask 而不是 allow:命令要碰哪些文件解析不出来,
|
||||
// 这就是「向更严取整」——比声明的严,不比它松。
|
||||
//
|
||||
// task 仍然 deny:子代理带着自己的权限跑,父会话的边界对它无效。
|
||||
return [
|
||||
{ permission: 'edit', action: 'deny', pattern: '*' },
|
||||
{ permission: 'edit', action: 'allow', pattern: '**' },
|
||||
{ permission: 'bash', action: 'ask', pattern: '*' },
|
||||
{ permission: 'task', action: 'deny', pattern: '*' },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* DSH 的沙箱模式。
|
||||
*
|
||||
* 三档与 DSH 原生的三档**一一对应** —— 这不是巧合,是同一个问题的同一个答案
|
||||
* (见 `@deepseek-ai/dsh-sandbox-policy` 的 SANDBOX_MODES)。
|
||||
*
|
||||
* @param {string} mode
|
||||
* @returns {'read-only'|'workspace-write'|'danger-full-access'}
|
||||
*/
|
||||
export function dshSandboxMode(mode) {
|
||||
switch (normalizeMode(mode)) {
|
||||
case MODE_PLAN: return 'read-only';
|
||||
case MODE_FULL: return 'danger-full-access';
|
||||
default: return 'workspace-write';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DSH 的审批策略。
|
||||
*
|
||||
* 关键实测:`danger-full-access` 对应 `approval: "never"`,而
|
||||
* `ApprovalService.decide()` 里 `if (effectivePolicy === "never") return "rejected"`
|
||||
* **在 waterfall 之前短路** —— 于是 `approval/request` 钩子根本不触发。
|
||||
*
|
||||
* 这解释了一个此前查不清的现象:本机 dsh 配了 `defaultPreset: danger-full-access`,
|
||||
* 所以整个权限转邮件链路从来没在 dsh 上跑起来过。
|
||||
*
|
||||
* @param {string} mode
|
||||
* @returns {'ask'|'never'}
|
||||
*/
|
||||
export function dshApprovalPolicy(mode) {
|
||||
return modeNeedsHuman(mode) ? 'ask' : 'never';
|
||||
}
|
||||
|
||||
/**
|
||||
* pi 侧应当守卫的工具名。
|
||||
*
|
||||
* pi 只有 `tool_call` 钩子能 `{block:true}`,没有沙箱 —— 所以档位靠
|
||||
* 「拦哪些工具」表达:
|
||||
*
|
||||
* - plan 拦 bash/write/edit(读类工具 read/grep/find/ls 不拦)
|
||||
* - workspace 拦同样三个,但 write/edit 可以查 `input.path` 判越界,
|
||||
* bash 无法判断 → 一律问人(向更严取整)
|
||||
* - full 不拦
|
||||
*
|
||||
* @param {string} mode
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function piGuardedTools(mode) {
|
||||
return normalizeMode(mode) === MODE_FULL ? [] : ['bash', 'write', 'edit'];
|
||||
}
|
||||
|
||||
/**
|
||||
* pi 在某档位下,某次工具调用该不该直接拒绝(不问人)。
|
||||
*
|
||||
* plan 档下所有被守卫的工具都直接拒绝 —— 该档语义就是「这轮不动手」,
|
||||
* 没什么可问人的,模型该把方案写在回信里。
|
||||
*
|
||||
* workspace 档返回 false(走问人流程)。full 档不会进到这里。
|
||||
*
|
||||
* @param {string} mode
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function piBlocksOutright(mode) {
|
||||
return normalizeMode(mode) === MODE_PLAN;
|
||||
}
|
||||
|
||||
/**
|
||||
* 给模型看的档位说明,放进提示词。
|
||||
*
|
||||
* 为什么 advisory 时措辞完全不同:那种平台(homeagent)没有任何机制阻止
|
||||
* 模型动手,所以只能把约束说成「请你遵守」而不是「你做不到」。
|
||||
* 假装它是强制的更危险 —— 模型会以为越界会被拦,于是不必自己小心。
|
||||
*
|
||||
* @param {{mode: string, enforcement: string, workspace?: string}} ctx
|
||||
* @returns {string}
|
||||
*/
|
||||
export function modeBriefing({ mode, enforcement, workspace }) {
|
||||
const m = normalizeMode(mode);
|
||||
const enforced = normalizeEnforcement(enforcement) === ENFORCE_NATIVE;
|
||||
const dir = workspace ? `\`${workspace}\`` : '本任务的工作目录';
|
||||
|
||||
if (m === MODE_FULL) {
|
||||
return '本任务权限档位:full(全权)。工具调用不需要额外授权。';
|
||||
}
|
||||
|
||||
if (m === MODE_PLAN) {
|
||||
return enforced
|
||||
? [
|
||||
'本任务权限档位:plan(只读)。',
|
||||
'写文件、改文件、执行命令都会被平台拦下 —— 这一档只用来查与想。',
|
||||
'请把结论、方案、需要人工执行的步骤写在回信里。需要动手请让发件人把档位改成 workspace。',
|
||||
].join('\n')
|
||||
: [
|
||||
'本任务权限档位:plan(只读)。',
|
||||
'**这个平台无法强制这一档**,所以约束靠你自己遵守:请不要写文件、改文件或执行命令。',
|
||||
'请把结论、方案、需要人工执行的步骤写在回信里。需要动手请让发件人把档位改成 workspace。',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
return enforced
|
||||
? [
|
||||
`本任务权限档位:workspace。可以在 ${dir} 内读写,越出该目录的写入与命令执行会先向人类请求授权。`,
|
||||
'授权可能需要等待,也可能被拒绝 —— 被拒绝时请换一条不需要越界的做法,或在回信里说明需要人工执行哪一步。',
|
||||
].join('\n')
|
||||
: [
|
||||
`本任务权限档位:workspace。请把改动限制在 ${dir} 内。`,
|
||||
'**这个平台无法强制这一档**,所以边界靠你自己遵守:需要改该目录之外的东西时,不要直接动手,先在回信里说明。',
|
||||
].join('\n');
|
||||
}
|
||||
@ -36,11 +36,13 @@
|
||||
import { mkdirSync, openSync, closeSync, unlinkSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { ModelRuntime } from '@earendil-works/pi-coding-agent';
|
||||
import { ModelRuntime, getAgentDir } from '@earendil-works/pi-coding-agent';
|
||||
|
||||
import { GatewayClient, readLocalKey, generateLocalKey, saveConfig } from './gateway.mjs';
|
||||
import { createWorkerPool } from './pool.mjs';
|
||||
import { createSessionScanner } from './session-scan.mjs';
|
||||
import { describeError } from './turn.mjs';
|
||||
import { BoundedSet, MAX_TRACKED_MAILS } from '../lib/bounded.js';
|
||||
import { snapshotPiModels } from '../lib/model-scope.js';
|
||||
import { snapshotPiSessions } from '../lib/session-snapshot.js';
|
||||
import { selectCatchup } from '../lib/catchup.js';
|
||||
@ -91,12 +93,18 @@ const log = (...args) => console.error('[pi-mail-bridge]', ...args);
|
||||
// 会话映射、权限授权、命名指纹都下沉到 pool 里按邮件会话存 —— 主进程不再持有
|
||||
// AgentSession 对象(那东西跨不了进程边界)。
|
||||
|
||||
const deliveredMails = new Set(); // 已投过的 mail_id(SSE 与补拉共用,B-7.3)
|
||||
// 已投过的 mail_id(SSE 与补拉共用,B-7.3)。
|
||||
//
|
||||
// 有界:桥是守护进程,跑几十天下来这里会攒下每一封处理过的邮件 id 而永远
|
||||
// 没有出口。淘汰是安全的 —— 它防的两种重复(心跳与 SSE 建连之间的窗口、
|
||||
// SSE 断线重放)都发生在秒到分钟级,几千封之前的 id 不可能再来。
|
||||
const deliveredMails = new BoundedSet(MAX_TRACKED_MAILS);
|
||||
|
||||
let allowedModels = [];
|
||||
let modelRuntime = null;
|
||||
let client = null;
|
||||
let pool = null;
|
||||
let sessionScanner = null;
|
||||
let heartbeatTimer = null;
|
||||
let shuttingDown = false;
|
||||
|
||||
@ -172,14 +180,15 @@ function handlePermissionDecision(data) {
|
||||
|
||||
async function reportSessions() {
|
||||
try {
|
||||
const { SessionManager } = await import('@earendil-works/pi-coding-agent');
|
||||
// 不传参数:`listAll(dir)` 把字符串当**自定义会话目录**,传 getAgentDir()
|
||||
// 会去 ~/.pi/agent 下直接找 .jsonl(那里没有),得到空列表。
|
||||
// 不传时它用默认的 ~/.pi/agent/sessions,逐个 cwd 子目录扫。
|
||||
// **不用 `SessionManager.listAll()`**:它为了拿 id/cwd/name/modified 四个
|
||||
// 字段,把 ~/.pi/agent/sessions 下每个 .jsonl 的每一行都读进来并 JSON.parse,
|
||||
// 还把所有消息正文拼成一个 allMessagesText 大字符串。本机实测(115 个文件 /
|
||||
// 145MB)单次 1431ms、堆里瞬时 240MB —— 而这 282MB 每 30 秒分配一次随即
|
||||
// 变成垃圾,且那 1.4 秒是同步解析,跑在事件循环上(SSE 读循环那期间停着)。
|
||||
//
|
||||
// 用 listAll 而不是 list(cwd):桥的进程 cwd 与会话 cwd 无关,
|
||||
// 按前者过滤会漏掉所有真正在干活的会话。
|
||||
const all = await SessionManager.listAll();
|
||||
// sessionScanner 只读 header 的首行 + 增量扫尾部找 session_info:
|
||||
// 稳态下未变化的文件一个字节都不读(实测 3ms / 0 字节)。
|
||||
const all = await sessionScanner.scan();
|
||||
const driven = pool.mailDrivenIDs();
|
||||
return snapshotPiSessions(all, (id) => driven.has(id));
|
||||
} catch (e) {
|
||||
@ -255,6 +264,16 @@ async function main() {
|
||||
const runtimeErr = modelRuntime.getError?.();
|
||||
if (runtimeErr) log(`模型运行时告警: ${runtimeErr}`);
|
||||
|
||||
// 会话目录扫描器。**必须建一次并复用** —— 它的省内存全靠跨拍存活的
|
||||
// size 缓存(稳态下未变化的文件一个字节都不读)。每拍新建一个等于
|
||||
// 每拍都冷启动,退回 listAll 那种全量读的开销。
|
||||
//
|
||||
// 路径自己拼而不是 import getSessionsDir:SDK 只导出 getAgentDir,
|
||||
// getSessionsDir 是内部函数(dist/config.js 里 `join(getAgentDir(), "sessions")`)。
|
||||
sessionScanner = createSessionScanner({
|
||||
sessionsDir: join(getAgentDir(), 'sessions'),
|
||||
});
|
||||
|
||||
// 工作进程池。config() 每次派活时取一次 —— allowedModels 随心跳变,
|
||||
// 取快照会让 worker 用上一轮的模型范围。
|
||||
pool = createWorkerPool({
|
||||
@ -339,6 +358,14 @@ function handleSSEEvent(type, data) {
|
||||
handlePermissionDecision(data);
|
||||
return;
|
||||
}
|
||||
if (type === 'session_archived') {
|
||||
// 会话归档 = 那条会话再也不会收信(别名 404),pool 里的 sessionState
|
||||
// 可以确定性地清掉,不必等上限淘汰去猜。
|
||||
if (pool?.forget(data?.session_id || '')) {
|
||||
log(`会话 ${data.session_id} 已归档,清除本地状态`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (type !== 'new_mail') return;
|
||||
if (data?.role && data.role !== 'to' && data.role !== 'cc') return;
|
||||
const id = data?.mail_id;
|
||||
|
||||
@ -47,11 +47,20 @@
|
||||
* `{type:'name_synced', signature}` 命名指纹,防下一个 worker 重复 sync
|
||||
* `{type:'reconfigure', url, agentKey}` connect_to_server 换了坐标
|
||||
* `{type:'done', ok, error}` 这封处理完了
|
||||
*
|
||||
* # 内存边界
|
||||
*
|
||||
* `sessionState` 与 `retired` 是**跨 worker 长期存活**的两张表,键来自邮件会话流
|
||||
* —— 会话数随时间单调增长。两条出口:`forget()`(会话归档,确定性)与
|
||||
* `BoundedMap`/`BoundedSet` 的上限淘汰(兜底)。缺了它们这里就是常驻进程里
|
||||
* 一处只增不减的结构。
|
||||
*/
|
||||
|
||||
import { fork } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { BoundedMap, BoundedSet, MAX_TRACKED_SESSIONS } from '../lib/bounded.js';
|
||||
|
||||
const WORKER_PATH = fileURLToPath(new URL('./worker.mjs', import.meta.url));
|
||||
|
||||
/**
|
||||
@ -81,14 +90,14 @@ export function createWorkerPool({
|
||||
* 这是 worker 一封一进程之后仍需在主进程留存的全部东西 —— 下一封邮件靠
|
||||
* sessionFile 接着谈,靠 grants 不重复问已经「一直同意」过的工具。
|
||||
*/
|
||||
const sessionState = new Map();
|
||||
const sessionState = new BoundedMap(MAX_TRACKED_SESSIONS);
|
||||
/**
|
||||
* 被模型降级换掉的旧 pi 会话 id。
|
||||
*
|
||||
* 仍要计入 mail_driven:它们已经参与过邮件往来,而磁盘上的会话文件
|
||||
* 不会因为换模型而消失 —— 心跳快照仍会上报它们。
|
||||
*/
|
||||
const retired = new Set();
|
||||
const retired = new BoundedSet(MAX_TRACKED_SESSIONS);
|
||||
let stopped = false;
|
||||
|
||||
/**
|
||||
@ -235,12 +244,38 @@ export function createWorkerPool({
|
||||
/** 这条邮件会话有 worker 在跑吗(B-4.2 判断降级路径用)。 */
|
||||
const hasSession = (mailSessionID) => sessionState.has(mailSessionID);
|
||||
|
||||
/**
|
||||
* 忘掉一条已归档会话的全部状态。
|
||||
*
|
||||
* 归档是个**确定性的终点**:归档后那条会话不可寻址(别名 404),也不会再有
|
||||
* 新邮件投进来。把它的 sessionState 留着只是占内存,而上限淘汰是「猜」——
|
||||
* 能确切知道该删的时候就不该依赖猜。
|
||||
*
|
||||
* 正在跑的 worker **不杀**:归档不是中止指令,模型可能正在写文件;它自己跑完
|
||||
* 就退,只是那一轮的回信会因为会话已归档而被服务端拦下。
|
||||
*
|
||||
* @param {string} mailSessionID
|
||||
* @returns {boolean} 是否真的删掉了东西
|
||||
*/
|
||||
function forget(mailSessionID) {
|
||||
if (!mailSessionID) return false;
|
||||
// peek 而不是 get:这是清理路径,不该把即将删掉的条目刷成「最近活跃」。
|
||||
const state = sessionState.peek(mailSessionID);
|
||||
// 已归档会话的 pi 会话 id 也不必再报 mail_driven:那个标记的用途是让人在
|
||||
// 补全里看到「这条在跑邮件」,而已归档的会话不在补全候选里。
|
||||
if (state?.piSessionId) retired.delete(state.piSessionId);
|
||||
return sessionState.delete(mailSessionID);
|
||||
}
|
||||
|
||||
/**
|
||||
* 邮件驱动过的 pi 会话 id,喂给心跳快照的 `mail_driven` 标记。
|
||||
*
|
||||
* 不随 worker 退出而清:worker 退了不代表那条会话不再参与邮件往来 ——
|
||||
* 下一封邮件还会接着谈,而人在补全里需要看到它带着这个标记。
|
||||
* 重启丢是已知取舍(契约第六节)。
|
||||
* 重启丢是已知取舍(契约第六节);确定性的清理时机是归档(见 forget)。
|
||||
*
|
||||
* 返回普通 Set 而不是 BoundedSet:调用方只拿它做一轮 has 查询就丢,
|
||||
* 没有长期持有,不需要上界。
|
||||
*/
|
||||
const mailDrivenIDs = () => {
|
||||
const out = new Set(retired);
|
||||
@ -268,10 +303,13 @@ export function createWorkerPool({
|
||||
running: running.size,
|
||||
queued: queue.length,
|
||||
sessions: sessionState.size,
|
||||
// 淘汰计数持续增长说明上限设得太小 —— 那意味着会话上下文在被白白丢掉,
|
||||
// 而症状是「这条会话怎么突然不记得前面说过什么了」。
|
||||
evictedSessions: sessionState.evicted,
|
||||
workers: [...running.values()].map((e) => ({
|
||||
pid: e.child.pid, mailID: e.mailID, ageMs: Date.now() - e.startedAt,
|
||||
})),
|
||||
});
|
||||
|
||||
return { submit, routePermission, hasSession, mailDrivenIDs, stop, stats };
|
||||
return { submit, routePermission, hasSession, forget, mailDrivenIDs, stop, stats };
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
*/
|
||||
|
||||
import { replyInstruction, inboundHeadline } from '../lib/relay-policy.js';
|
||||
import { clampRelayKey } from '../lib/relay-key.js';
|
||||
|
||||
/** 去掉已有的 Re: 前缀,避免 Re: Re: Re: 叠加。 */
|
||||
export function stripRe(subject) {
|
||||
@ -180,7 +181,10 @@ export function buildMailPrompt({ agentName, data, kind, reused }) {
|
||||
* @returns {string}
|
||||
*/
|
||||
export function relayKeyFor(piSessionId, leafId) {
|
||||
return `${piSessionId || 'unknown'}:${leafId || 'noleaf'}`;
|
||||
// clampRelayKey 收尾:会话 id 与 leafId 平常都短,但不能假定——
|
||||
// 同一个假定在权限询问那边已经坏过一次(toolCallId 被拼了思考签名,
|
||||
// 437 ~ 13601 字节)。超限时才改写,所以合规的键不受影响。
|
||||
return clampRelayKey(`${piSessionId || 'unknown'}:${leafId || 'noleaf'}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
246
plugins/pi-mail-bridge/test/permission-mode.test.mjs
Normal file
246
plugins/pi-mail-bridge/test/permission-mode.test.mjs
Normal file
@ -0,0 +1,246 @@
|
||||
/**
|
||||
* lib/permission-mode.js 的测试 —— 四个平台逐字节共用。
|
||||
*
|
||||
* 这些判据编码了六条 opencode 实测结论。不实测就写代码会做出「看起来对但
|
||||
* 管不住」的东西,所以每条结论都在这里钉死,改坏了会当场失败。
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
MODE_PLAN, MODE_WORKSPACE, MODE_FULL, MODES, DEFAULT_MODE,
|
||||
ENFORCE_NATIVE, ENFORCE_ADVISORY,
|
||||
normalizeMode, normalizeEnforcement, modeAtMost, modeNeedsHuman,
|
||||
opencodePermissions, dshSandboxMode, dshApprovalPolicy,
|
||||
piGuardedTools, piBlocksOutright, modeBriefing,
|
||||
} from '../lib/permission-mode.js';
|
||||
|
||||
// ─── 归一化 ───
|
||||
|
||||
test('合法档位原样返回', () => {
|
||||
for (const m of MODES) assert.equal(normalizeMode(m), m);
|
||||
});
|
||||
|
||||
test('非法档位 fail-closed 到默认档,不是 full', () => {
|
||||
for (const bad of ['', 'FULL', 'full-access', 'workspace-write', null, undefined, 42, {}]) {
|
||||
assert.equal(normalizeMode(bad), DEFAULT_MODE, `${String(bad)} 应当归到默认档`);
|
||||
}
|
||||
assert.notEqual(DEFAULT_MODE, MODE_FULL, '默认档不能是 full');
|
||||
});
|
||||
|
||||
test('强制力保守方向是 advisory', () => {
|
||||
assert.equal(normalizeEnforcement(ENFORCE_NATIVE), ENFORCE_NATIVE);
|
||||
assert.equal(normalizeEnforcement(ENFORCE_ADVISORY), ENFORCE_ADVISORY);
|
||||
for (const bad of ['', 'NATIVE', 'enforced', null, undefined]) {
|
||||
assert.equal(normalizeEnforcement(bad), ENFORCE_ADVISORY);
|
||||
}
|
||||
});
|
||||
|
||||
test('档位顺序必须是 plan < workspace < full(modeAtMost 的依据)', () => {
|
||||
assert.deepEqual(MODES, [MODE_PLAN, MODE_WORKSPACE, MODE_FULL]);
|
||||
});
|
||||
|
||||
// ─── modeAtMost ───
|
||||
|
||||
test('modeAtMost 取更严的一档', () => {
|
||||
assert.equal(modeAtMost(MODE_PLAN, MODE_FULL), MODE_PLAN);
|
||||
assert.equal(modeAtMost(MODE_FULL, MODE_PLAN), MODE_PLAN);
|
||||
assert.equal(modeAtMost(MODE_WORKSPACE, MODE_FULL), MODE_WORKSPACE);
|
||||
assert.equal(modeAtMost(MODE_FULL, MODE_FULL), MODE_FULL);
|
||||
});
|
||||
|
||||
// Gateway 侧曾因为「未知值当最严 vs 归到默认档」两套语义而不可交换,
|
||||
// 单元测试当场抓到。两边保持同一套语义。
|
||||
test('modeAtMost 可交换(脏值也不例外)', () => {
|
||||
const all = [...MODES, 'garbage', '', null];
|
||||
for (const a of all) {
|
||||
for (const b of all) {
|
||||
assert.equal(modeAtMost(a, b), modeAtMost(b, a),
|
||||
`不可交换:(${a},${b})`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('脏值不得把 plan 抬成更宽松的档', () => {
|
||||
assert.equal(modeAtMost('garbage', MODE_PLAN), MODE_PLAN);
|
||||
});
|
||||
|
||||
// ─── modeNeedsHuman ───
|
||||
|
||||
test('只有 workspace 档需要人点头', () => {
|
||||
assert.equal(modeNeedsHuman(MODE_PLAN), false, 'plan 档当场拒绝,不问人');
|
||||
assert.equal(modeNeedsHuman(MODE_WORKSPACE), true);
|
||||
assert.equal(modeNeedsHuman(MODE_FULL), false, 'full 档自动放行,不问人');
|
||||
});
|
||||
|
||||
test('脏档位按默认档处理,即需要人(宁可多问一次)', () => {
|
||||
assert.equal(modeNeedsHuman('garbage'), true);
|
||||
assert.equal(modeNeedsHuman(''), true);
|
||||
});
|
||||
|
||||
// ─── opencode ───
|
||||
|
||||
test('full 档不下发规则,不覆盖用户自己的 opencode.jsonc', () => {
|
||||
assert.deepEqual(opencodePermissions(MODE_FULL), []);
|
||||
});
|
||||
|
||||
// 实测结论 3、5、6:edit 覆盖 write/edit/patch;task 会绕过;bash 能重定向写文件
|
||||
test('plan 档同时 deny edit / bash / task', () => {
|
||||
const rules = opencodePermissions(MODE_PLAN);
|
||||
const denied = new Set(rules.filter(r => r.action === 'deny').map(r => r.permission));
|
||||
assert.ok(denied.has('edit'), 'edit 覆盖 write/edit/patch,必须 deny');
|
||||
assert.ok(denied.has('bash'), 'bash 能用 shell 重定向写文件(实测过),必须 deny');
|
||||
assert.ok(denied.has('task'), 'task 子代理会绕过父会话权限(实测过),必须 deny');
|
||||
});
|
||||
|
||||
test('plan 档不禁 webfetch/websearch —— 查资料是这一档的本职', () => {
|
||||
const rules = opencodePermissions(MODE_PLAN);
|
||||
for (const p of ['webfetch', 'websearch', 'read', 'grep', 'glob']) {
|
||||
assert.equal(rules.some(r => r.permission === p), false, `${p} 不该被禁`);
|
||||
}
|
||||
});
|
||||
|
||||
// 实测结论 1:findLast 胜出 → deny 必须在 allow 之前
|
||||
test('workspace 档的 edit 规则 deny 在前 allow 在后(findLast 胜出)', () => {
|
||||
const rules = opencodePermissions(MODE_WORKSPACE);
|
||||
const denyIdx = rules.findIndex(r => r.permission === 'edit' && r.action === 'deny');
|
||||
const allowIdx = rules.findIndex(r => r.permission === 'edit' && r.action === 'allow');
|
||||
assert.ok(denyIdx >= 0 && allowIdx >= 0, '两条 edit 规则都要在');
|
||||
assert.ok(denyIdx < allowIdx,
|
||||
'deny 必须在 allow 之前 —— 反了的话最后匹配到 deny,连允许的路径也被拒');
|
||||
});
|
||||
|
||||
// 实测结论 2:pattern 匹配 worktree 相对路径,绝对路径永远匹配不上
|
||||
test('workspace 档的 allow pattern 是相对路径而非绝对路径', () => {
|
||||
const rules = opencodePermissions(MODE_WORKSPACE);
|
||||
const allow = rules.find(r => r.permission === 'edit' && r.action === 'allow');
|
||||
assert.ok(allow, '要有 allow 规则');
|
||||
assert.equal(allow.pattern.startsWith('/'), false,
|
||||
'pattern 匹配的是 worktree 相对路径,绝对路径永远匹配不上(实测)');
|
||||
});
|
||||
|
||||
// 向更严取整:命令要碰哪些文件解析不出来
|
||||
test('workspace 档的 bash 是 ask 而不是 allow(向更严取整)', () => {
|
||||
const rules = opencodePermissions(MODE_WORKSPACE);
|
||||
const bash = rules.find(r => r.permission === 'bash');
|
||||
assert.equal(bash.action, 'ask',
|
||||
'bash 命令的影响范围无法解析,只能问人 —— 比声明的严,不比它松');
|
||||
});
|
||||
|
||||
test('workspace 档仍然 deny task(子代理带自己的权限跑)', () => {
|
||||
const rules = opencodePermissions(MODE_WORKSPACE);
|
||||
const task = rules.find(r => r.permission === 'task');
|
||||
assert.equal(task.action, 'deny');
|
||||
});
|
||||
|
||||
test('opencode 规则不碰 plan_enter / plan_exit(撞名但语义不同)', () => {
|
||||
for (const m of MODES) {
|
||||
for (const r of opencodePermissions(m)) {
|
||||
assert.notEqual(r.permission, 'plan_enter');
|
||||
assert.notEqual(r.permission, 'plan_exit');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('脏档位按默认档下发(与 workspace 相同)', () => {
|
||||
assert.deepEqual(opencodePermissions('garbage'), opencodePermissions(MODE_WORKSPACE));
|
||||
});
|
||||
|
||||
// ─── DSH ───
|
||||
|
||||
test('DSH 三档与原生沙箱一一对应', () => {
|
||||
assert.equal(dshSandboxMode(MODE_PLAN), 'read-only');
|
||||
assert.equal(dshSandboxMode(MODE_WORKSPACE), 'workspace-write');
|
||||
assert.equal(dshSandboxMode(MODE_FULL), 'danger-full-access');
|
||||
});
|
||||
|
||||
// 关键实测:danger-full-access → approval:"never" → decide() 在 waterfall
|
||||
// 之前短路 return "rejected",approval/request 钩子根本不触发。
|
||||
test('DSH 审批策略只在 workspace 档是 ask', () => {
|
||||
assert.equal(dshApprovalPolicy(MODE_WORKSPACE), 'ask');
|
||||
assert.equal(dshApprovalPolicy(MODE_PLAN), 'never');
|
||||
assert.equal(dshApprovalPolicy(MODE_FULL), 'never');
|
||||
});
|
||||
|
||||
test('DSH 脏档位按默认档(workspace-write + ask)', () => {
|
||||
assert.equal(dshSandboxMode('garbage'), 'workspace-write');
|
||||
assert.equal(dshApprovalPolicy('garbage'), 'ask');
|
||||
});
|
||||
|
||||
// ─── pi ───
|
||||
|
||||
test('pi 在 full 档不守卫任何工具', () => {
|
||||
assert.deepEqual(piGuardedTools(MODE_FULL), []);
|
||||
});
|
||||
|
||||
test('pi 在 plan / workspace 档守卫 bash / write / edit', () => {
|
||||
for (const m of [MODE_PLAN, MODE_WORKSPACE]) {
|
||||
const g = piGuardedTools(m);
|
||||
assert.ok(g.includes('bash'));
|
||||
assert.ok(g.includes('write'));
|
||||
assert.ok(g.includes('edit'));
|
||||
}
|
||||
});
|
||||
|
||||
test('pi 不守卫读类工具', () => {
|
||||
const g = piGuardedTools(MODE_WORKSPACE);
|
||||
for (const t of ['read', 'grep', 'find', 'ls']) {
|
||||
assert.equal(g.includes(t), false, `${t} 是读类工具,不该守卫`);
|
||||
}
|
||||
});
|
||||
|
||||
test('pi 在 plan 档直接拒绝,不走问人流程', () => {
|
||||
assert.equal(piBlocksOutright(MODE_PLAN), true);
|
||||
assert.equal(piBlocksOutright(MODE_WORKSPACE), false);
|
||||
assert.equal(piBlocksOutright(MODE_FULL), false);
|
||||
});
|
||||
|
||||
// ─── modeBriefing ───
|
||||
|
||||
test('full 档的说明不提授权', () => {
|
||||
const s = modeBriefing({ mode: MODE_FULL, enforcement: ENFORCE_NATIVE });
|
||||
assert.match(s, /full/);
|
||||
assert.equal(/授权/.test(s.replace('不需要额外授权', '')), false);
|
||||
});
|
||||
|
||||
// advisory 与 native 措辞必须不同:假装 advisory 是强制的会让模型以为
|
||||
// 越界会被拦,于是不必自己小心 —— 那比做不到本身更危险。
|
||||
test('advisory 必须明说平台无法强制这一档', () => {
|
||||
const adv = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_ADVISORY });
|
||||
const nat = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_NATIVE });
|
||||
assert.match(adv, /无法强制/);
|
||||
assert.equal(/无法强制/.test(nat), false, 'native 不该说无法强制');
|
||||
assert.notEqual(adv, nat, '两种强制力的措辞必须不同');
|
||||
});
|
||||
|
||||
test('workspace 档的 advisory 版同样明说', () => {
|
||||
const adv = modeBriefing({ mode: MODE_WORKSPACE, enforcement: ENFORCE_ADVISORY, workspace: '/tmp/x' });
|
||||
assert.match(adv, /无法强制/);
|
||||
assert.match(adv, /\/tmp\/x/, '要带上具体目录');
|
||||
});
|
||||
|
||||
test('native 的 workspace 说明要交代「授权可能被拒」', () => {
|
||||
const s = modeBriefing({ mode: MODE_WORKSPACE, enforcement: ENFORCE_NATIVE, workspace: '/srv/app' });
|
||||
assert.match(s, /\/srv\/app/);
|
||||
assert.match(s, /拒绝/, '被拒时该怎么办必须说清楚,否则模型会反复重试');
|
||||
});
|
||||
|
||||
test('plan 档的说明必须告诉模型「把方案写在回信里」', () => {
|
||||
for (const e of [ENFORCE_NATIVE, ENFORCE_ADVISORY]) {
|
||||
const s = modeBriefing({ mode: MODE_PLAN, enforcement: e });
|
||||
assert.match(s, /回信/, '不给出路的话模型只会反复撞墙');
|
||||
}
|
||||
});
|
||||
|
||||
test('缺 workspace 时用兜底措辞,不出现 undefined', () => {
|
||||
const s = modeBriefing({ mode: MODE_WORKSPACE, enforcement: ENFORCE_NATIVE });
|
||||
assert.equal(/undefined/.test(s), false);
|
||||
assert.equal(/`` /.test(s), false);
|
||||
});
|
||||
|
||||
test('脏输入不炸且按默认档', () => {
|
||||
const s = modeBriefing({ mode: 'garbage', enforcement: 'garbage' });
|
||||
assert.match(s, /workspace/);
|
||||
assert.match(s, /无法强制/, '脏强制力按 advisory 处理');
|
||||
});
|
||||
Reference in New Issue
Block a user