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:
33
plugins/dsh-mail-bridge/lib/permission-mode.d.ts
vendored
Normal file
33
plugins/dsh-mail-bridge/lib/permission-mode.d.ts
vendored
Normal file
@ -0,0 +1,33 @@
|
||||
export const MODE_PLAN: 'plan';
|
||||
export const MODE_WORKSPACE: 'workspace';
|
||||
export const MODE_FULL: 'full';
|
||||
export const MODES: string[];
|
||||
export const DEFAULT_MODE: string;
|
||||
|
||||
export const ENFORCE_NATIVE: 'native';
|
||||
export const ENFORCE_ADVISORY: 'advisory';
|
||||
|
||||
export function normalizeMode(mode: unknown): string;
|
||||
export function normalizeEnforcement(e: unknown): string;
|
||||
export function modeAtMost(a: string, b: string): string;
|
||||
export function modeNeedsHuman(mode: string): boolean;
|
||||
|
||||
export interface OpencodePermissionRule {
|
||||
permission: string;
|
||||
action: string;
|
||||
pattern: string;
|
||||
}
|
||||
export function opencodePermissions(mode: string): OpencodePermissionRule[];
|
||||
|
||||
export function dshSandboxMode(mode: string): 'read-only' | 'workspace-write' | 'danger-full-access';
|
||||
export function dshApprovalPolicy(mode: string): 'ask' | 'never';
|
||||
|
||||
export function piGuardedTools(mode: string): string[];
|
||||
export function piBlocksOutright(mode: string): boolean;
|
||||
|
||||
export interface ModeBriefingCtx {
|
||||
mode: string;
|
||||
enforcement: string;
|
||||
workspace?: string;
|
||||
}
|
||||
export function modeBriefing(ctx: ModeBriefingCtx): string;
|
||||
274
plugins/dsh-mail-bridge/lib/permission-mode.js
Normal file
274
plugins/dsh-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');
|
||||
}
|
||||
@ -27,6 +27,8 @@ import {
|
||||
replyInstruction,
|
||||
inboundHeadline,
|
||||
} from '../lib/relay-policy.js';
|
||||
import { clampRelayKey, isPermanentFailure } from '../lib/relay-key.js';
|
||||
import { BoundedMap, BoundedSet, MAX_TRACKED_MAILS, MAX_TRACKED_SESSIONS } from '../lib/bounded.js';
|
||||
import {
|
||||
userMessage,
|
||||
replySubject,
|
||||
@ -146,34 +148,115 @@ class GatewayClient {
|
||||
}
|
||||
|
||||
// ─── 会话映射(与 opencode-mail-bridge 相同结构)───
|
||||
//
|
||||
// 这几张表都是**常驻进程里只增不减**的形态:键来自邮件事件流,会话数随时间
|
||||
// 单调增长。两条出口 —— `forgetSession()`(会话归档,确定性)与 Bounded* 的
|
||||
// 上限淘汰(兜底)。没有它们,插件跟着 dsh 跑几周之后表里会躺着几十万条再也
|
||||
// 不会被查到的条目,而 GC 收不掉(还被强引用着)。
|
||||
|
||||
const sessionMap = new Map<string, { dshSessionId: string; directory: string }>();
|
||||
const reverseMap = new Map<string, string>();
|
||||
const mailDrivenSessions = new Set<string>();
|
||||
const sessionMap = new BoundedMap<string, { dshSessionId: string; directory: string }>(MAX_TRACKED_SESSIONS);
|
||||
const reverseMap = new BoundedMap<string, string>(MAX_TRACKED_SESSIONS);
|
||||
const mailDrivenSessions = new BoundedSet<string>(MAX_TRACKED_SESSIONS);
|
||||
// 回信上下文。fromHuman / inReplyTo 是服务端给的两个信号:
|
||||
// 前者决定要不要自动转发(Agent 间不转,见 lib/relay-policy.js),
|
||||
// 后者决定提示词说「新任务」还是「你上封信的回复到了」。
|
||||
const mailContexts = new Map<string, {
|
||||
const mailContexts = new BoundedMap<string, {
|
||||
replyTo: string; subject: string; mailID: string;
|
||||
fromHuman: boolean; inReplyTo: string;
|
||||
}>();
|
||||
const relayedSummaries = new Map<string, string>();
|
||||
}>(MAX_TRACKED_SESSIONS);
|
||||
const relayedSummaries = new BoundedMap<string, string>(MAX_TRACKED_SESSIONS);
|
||||
|
||||
// 管理员在配置页划定的可用模型范围(按优先级)。随心跳响应更新。
|
||||
// 空数组 = 不限定,回退到环境变量或平台默认。
|
||||
let allowedModels: { provider: string; model: string }[] = [];
|
||||
const syncedTitles = new Map<string, string>();
|
||||
const syncedTitles = new BoundedMap<string, string>(MAX_TRACKED_SESSIONS);
|
||||
|
||||
/**
|
||||
* 会话归档 → 忘掉它的全部映射。
|
||||
*
|
||||
* 归档是个**确定性的终点**:归档后那条会话不可寻址(别名 404),也不会再有新邮件
|
||||
* 投进来,`agent/status` 也不该再把总结转回去(会话已经收不了信)。留着这些条目
|
||||
* 只是占内存,而上限淘汰是「猜」—— 能确切知道该删的时候就不该靠猜。
|
||||
*
|
||||
* DSH 侧那个 agent **不 dispose**:人可能还在界面上看它,而且它正在跑的那一轮
|
||||
* 不该被归档打断。这里只解除邮件绑定。
|
||||
*/
|
||||
function forgetSession(mailSessionID: string): void {
|
||||
if (!mailSessionID) return;
|
||||
// peek 而不是 get:这是清理路径,不该把即将删掉的条目刷成「最近活跃」。
|
||||
const bound = sessionMap.peek(mailSessionID);
|
||||
mailContexts.delete(mailSessionID);
|
||||
sessionMap.delete(mailSessionID);
|
||||
const dshSessionId = bound?.dshSessionId;
|
||||
if (!dshSessionId) return;
|
||||
reverseMap.delete(dshSessionId);
|
||||
mailDrivenSessions.delete(dshSessionId);
|
||||
relayedSummaries.delete(dshSessionId);
|
||||
syncedTitles.delete(dshSessionId);
|
||||
}
|
||||
|
||||
// 权限询问:DSH 的 approval/request 是 waterfall 钩子,插件把它转成邮件问人,
|
||||
// 人类决策通过 SSE 回来后再 resolve 这个 promise,让 DSH 自己恢复执行。
|
||||
// relay_key 用 `${sessionId}:${toolName}:${callId}` —— DSH 不给询问发 id,
|
||||
// 而同一个 callId 的同一个工具只会问一次。
|
||||
//
|
||||
// **这张表不设上界**(与上面几张不同):它装的是「还在等结果的东西」。静默淘汰
|
||||
// 一条会让 DSH 侧那个 `await` 永远不返回 —— 那次工具调用直接挂死。它有确定的
|
||||
// 清理路径(决策到达 / 询问被 abort / 拆插件时 fail closed),不需要靠猜。
|
||||
interface PendingApproval {
|
||||
resolve: (outcome: string) => void;
|
||||
sessionId: string;
|
||||
}
|
||||
const pendingApprovals = new Map<string, PendingApproval>();
|
||||
|
||||
// 权限被插件主动拒绝时的真正原因 —— 键是 `${agentId}:${callId}`。
|
||||
//
|
||||
// 为什么需要这张表:DSH 把 `approval/request` 的返回值翻译成模型可见文本时
|
||||
// 用的是 **dsh-tools 里写死的句子**(`node_modules/@deepseek-ai/dsh-tools/lib/index.js`):
|
||||
//
|
||||
// case "rejected": reason = `the user rejected tool "${exec.name}"`
|
||||
// case "unavailable": reason = `... no approval channel is available`
|
||||
//
|
||||
// 于是插件回 'rejected' 时模型看到的是「the user rejected tool bash」——
|
||||
// 而实际上**没有任何用户拒绝它**,是「这条链上没有人类可问」或「转发遇到
|
||||
// 4xx 永久失败」。服务端给的 suggestion(换不需要权限的方式 / 在回信里请上游
|
||||
// 转达)根本没有出口,只进了 journalctl。模型得到的信息既是错的,
|
||||
// 也不含任何可行动的提示 —— 它只会以为人在拒绝它,而不会改道。
|
||||
//
|
||||
// pi(`{block:true, reason}`)与 opencode(`output.reason`)的 reason 直达模型,
|
||||
// 只有 DSH 把它吞了。出路是 `tools/post-execute`:门禁拒绝的调用**也会**走
|
||||
// post-execute(源码里 `{kind:"post-result"}` → finalizeScheduledExecution
|
||||
// → postExecute),而 `{kind:'block', feedback}` 能换掉模型看到的内容。
|
||||
interface DeniedReason {
|
||||
text: string;
|
||||
at: number;
|
||||
}
|
||||
const deniedReasons = new Map<string, DeniedReason>();
|
||||
|
||||
/** 10 分钟前的条目不可能还有对应的 post-execute,清掉以免无限增长。 */
|
||||
const DENIED_REASON_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
function denialKey(agentId: string, callId: unknown): string {
|
||||
return `${agentId}:${String(callId ?? 'nocall')}`;
|
||||
}
|
||||
|
||||
function noteDenial(agentId: string, callId: unknown, text: string): void {
|
||||
const now = Date.now();
|
||||
for (const [k, v] of deniedReasons) {
|
||||
if (now - v.at > DENIED_REASON_TTL_MS) deniedReasons.delete(k);
|
||||
}
|
||||
deniedReasons.set(denialKey(agentId, callId), { text, at: now });
|
||||
}
|
||||
|
||||
function takeDenial(agentId: string, callId: unknown): string | undefined {
|
||||
const key = denialKey(agentId, callId);
|
||||
const hit = deniedReasons.get(key);
|
||||
if (!hit) return undefined;
|
||||
deniedReasons.delete(key); // 一次性:同一次调用只能被换一次
|
||||
if (Date.now() - hit.at > DENIED_REASON_TTL_MS) return undefined;
|
||||
return hit.text;
|
||||
}
|
||||
|
||||
// ─── 运行时导入 DSH 内部函数 ───
|
||||
|
||||
let _defineTool: any;
|
||||
@ -301,7 +384,10 @@ export function apply(ctx: any, config: PluginConfig): void {
|
||||
|
||||
// 已经投过的 mail_id。心跳与 SSE 建连之间有个窗口:那期间到的邮件
|
||||
// 既在 pending_mails 里、也会被 SSE 推一次 —— 不去重就会投两遍。
|
||||
const deliveredMails = new Set<string>();
|
||||
//
|
||||
// 有界:插件跟着 dsh 长期活着,这里会攒下每一封处理过的邮件 id 而永远没有
|
||||
// 出口。淘汰是安全的 —— 它防的两种重复都发生在秒到分钟级。
|
||||
const deliveredMails = new BoundedSet<string>(MAX_TRACKED_MAILS);
|
||||
let caughtUp = false;
|
||||
|
||||
/**
|
||||
@ -888,7 +974,7 @@ export function apply(ctx: any, config: PluginConfig): void {
|
||||
body: renderFailureReport(failures, data.subject),
|
||||
reply_to: data.mail_id || '',
|
||||
relay: 'summary',
|
||||
relay_key: `model-failure:${data.mail_id || sessionId}`,
|
||||
relay_key: clampRelayKey(`model-failure:${data.mail_id || sessionId}`),
|
||||
});
|
||||
ctx.logger.info(`[dsh-mail-bridge] 已回报模型调用失败给 ${data.from_name}`);
|
||||
} catch (e: any) {
|
||||
@ -981,6 +1067,7 @@ export function apply(ctx: any, config: PluginConfig): void {
|
||||
cc: args.cc || '', reply_to: args.reply_to || '',
|
||||
session_alias: args.session_alias || '',
|
||||
attachment_ids: args.attachment_ids || [],
|
||||
from_session_id: toolCtx?.sessionID || '',
|
||||
});
|
||||
noteExplicitSend(toolCtx?.sessionID, args.to, args.reply_to);
|
||||
const budget = typeof result.budget_remaining === 'number'
|
||||
@ -1402,7 +1489,7 @@ export function apply(ctx: any, config: PluginConfig): void {
|
||||
reply_to: mctx.mailID || '',
|
||||
// relay + relay_key:走免配额通道(harness 的搬运不该收费)
|
||||
relay: 'summary',
|
||||
relay_key: `${agent.id}:${events.length}`,
|
||||
relay_key: clampRelayKey(`${agent.id}:${events.length}`),
|
||||
});
|
||||
relayedSummaries.set(String(agent.id), lastText);
|
||||
explicitSends.delete(String(agent.id));
|
||||
@ -1447,7 +1534,9 @@ export function apply(ctx: any, config: PluginConfig): void {
|
||||
if (!mailSessionID) return next();
|
||||
|
||||
// DSH 不给询问发 id,用 (会话, 工具, callId) 做幂等键。
|
||||
const relayKey = `${agentId}:${req.toolName}:${req.callId ?? 'nocall'}`;
|
||||
// clampRelayKey 收尾:callId 的长度由上游模型决定,pi 侧实测过带思考签名的
|
||||
// 13601 字节 id,超服务端 160 字节列宽直接 400。
|
||||
const relayKey = clampRelayKey(`${agentId}:${req.toolName}:${req.callId ?? 'nocall'}`);
|
||||
const mctx = mailContexts.get(mailSessionID);
|
||||
|
||||
try {
|
||||
@ -1483,12 +1572,40 @@ export function apply(ctx: any, config: PluginConfig): void {
|
||||
const hint = [e?.body?.error, e?.body?.detail, e?.body?.suggestion]
|
||||
.filter(Boolean).join(' ');
|
||||
console.error(`[dsh-mail-bridge] 权限询问无人可投,当场拒绝 ${relayKey}:${hint}`);
|
||||
// 把真正的原因存起来,post-execute 会用它换掉 dsh-tools 写死的
|
||||
// 「the user rejected tool X」—— 没有任何用户拒绝过它。
|
||||
noteDenial(agentId, req.callId, [
|
||||
e?.body?.error || `权限询问无法送达:这条任务链上没有人类用户`,
|
||||
e?.body?.detail || '',
|
||||
e?.body?.suggestion || '',
|
||||
].filter(Boolean).join('\n'));
|
||||
// 用 'rejected' 而不是 'denied':DSH 的 ApprovalOutcome 只认
|
||||
// allowed-once / rejected / cancelled,写错了它不报错而是当成未知值处理。
|
||||
// allowed-once / rejected / cancelled / unavailable,写错了它不报错
|
||||
// 而是归一化成 'unavailable'。
|
||||
return 'rejected';
|
||||
}
|
||||
// 其余失败(网络抖动、Gateway 重启)是暂时的,交给下一个 answerer(本地 UI)。
|
||||
console.error(`[dsh-mail-bridge] 权限询问转发失败: ${e?.message || e}`);
|
||||
|
||||
// 其余 4xx(400 / 401 / 403 / 404 / 422…)同样永远不会因重试成功,
|
||||
// 不能 `return next()` —— 下一个 answerer 是本地 UI,而邮件驱动的会话
|
||||
// 没有 UI,waterfall 跑到尾仍旧无人应答。
|
||||
// pi 侧实测:relay_key 过长报 400 被当暂时失败让位,
|
||||
// 那条 bash 在无人批准的情况下执行了 —— fail closed 才安全。
|
||||
if (isPermanentFailure(e)) {
|
||||
const hint = [e?.body?.error, e?.body?.detail, e?.body?.suggestion]
|
||||
.filter(Boolean).join(' ');
|
||||
console.error(
|
||||
`[dsh-mail-bridge] 权限询问遇到永久失败(HTTP ${e?.status}),当场拒绝 ${relayKey}:${hint || e?.message || ''}`,
|
||||
);
|
||||
noteDenial(agentId, req.callId, [
|
||||
`无法把 ${req.toolName} 的授权请求送达给人类(HTTP ${e?.status}):${e?.body?.error || e?.message || '请求被服务端拒绝'}`,
|
||||
e?.body?.detail || '',
|
||||
e?.body?.suggestion || '这是一个不会因重试而改变的失败。请改用不需要授权的方式完成,或在回信里说明需要人工执行哪一步。',
|
||||
].filter(Boolean).join('\n'));
|
||||
return 'rejected';
|
||||
}
|
||||
|
||||
// 暂时失败(5xx / 408 / 429 / 网络抖动)交给下一个 answerer(本地 UI)。
|
||||
console.error(`[dsh-mail-bridge] 权限询问转发暂时失败: ${e?.message || e}`);
|
||||
return next();
|
||||
}
|
||||
|
||||
@ -1503,6 +1620,40 @@ export function apply(ctx: any, config: PluginConfig): void {
|
||||
});
|
||||
});
|
||||
|
||||
// 把插件主动拒绝的真正原因递给模型。
|
||||
//
|
||||
// DSH 将 approval/request 的 'rejected' 翻译成写死的
|
||||
// `the user rejected tool "X"`(dsh-tools/lib/index.js)—— 而当插件因为
|
||||
// 「没人可问」或「转发遇 4xx」主动拒绝时,**没有任何用户拒绝过它**。
|
||||
// 模型看到一句不存在的拒绝,只会以为人不同意,不会去换一条路;
|
||||
// 服务端给的 suggestion(换不需要权限的方式 / 在回信里请上游转达)则只进了日志。
|
||||
//
|
||||
// 门禁拒绝的调用也会进 post-execute(pre-execute 的 deny 走
|
||||
// `{kind:"post-result"}` → finalizeScheduledExecution → postExecute),
|
||||
// 而 `{kind:'block', feedback}` 能换掉模型看到的内容 —— 这是 DSH 上
|
||||
// 唯一能把真实原因送到模型眼前的口子。
|
||||
//
|
||||
// pi(`{block:true, reason}`)与 opencode(`output.reason`)的 reason 直达模型,
|
||||
// 不需要这道绕行。
|
||||
ctx.on('tools/post-execute', async (exec: any, result: any, next: () => Promise<any>) => {
|
||||
const agentId = String(exec?.agent?.id ?? '');
|
||||
if (!agentId || !mailDrivenSessions.has(agentId)) return next();
|
||||
// 只管失败的结果:成功的调用不可能是被我们拒绝的那一次。
|
||||
if (!result?.isError) return next();
|
||||
|
||||
const reason = takeDenial(agentId, exec?.callId);
|
||||
if (!reason) return next();
|
||||
|
||||
console.error(`[dsh-mail-bridge] 已把拒绝原因递给模型(${exec?.name})`);
|
||||
return {
|
||||
kind: 'block',
|
||||
feedback: [{
|
||||
type: 'text',
|
||||
text: `无法执行 ${exec?.name}:${reason}`,
|
||||
}],
|
||||
};
|
||||
});
|
||||
|
||||
/** 人类决策回来:先看是不是在等的那条 approval,否则当普通通知投给会话。 */
|
||||
function handlePermissionDecision(data: any): void {
|
||||
const relayKey = String(data?.relay_key ?? '');
|
||||
@ -1549,6 +1700,10 @@ export function apply(ctx: any, config: PluginConfig): void {
|
||||
case 'permission_decision':
|
||||
handlePermissionDecision(data);
|
||||
break;
|
||||
case 'session_archived':
|
||||
// 会话归档 = 那条会话再也不会收信,映射可以确定性地清掉(不必等上限淘汰)。
|
||||
forgetSession(String(data?.session_id || ''));
|
||||
break;
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
|
||||
246
plugins/dsh-mail-bridge/test/permission-mode.test.mjs
Normal file
246
plugins/dsh-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 处理');
|
||||
});
|
||||
125
plugins/homeagent-mail-bridge/bounded.go
Normal file
125
plugins/homeagent-mail-bridge/bounded.go
Normal file
@ -0,0 +1,125 @@
|
||||
package main
|
||||
|
||||
// 有界去重表 —— 三个 Node 插件里 `lib/bounded.js` 的 Go 对应物。
|
||||
//
|
||||
// **不能共用那个文件**(homeagent 是 Go 子进程插件),但要解决的问题完全一样:
|
||||
// `deliveredMails` 是「这封邮件我处理过吗」的记忆,键来自 SSE 事件流,
|
||||
// 而插件跟着 homed 长期活着 —— 邮件数单调增长,键却从来没有出口。
|
||||
//
|
||||
// # 为什么 Go 侧不做 LRU
|
||||
//
|
||||
// Node 的 `Map` 保证插入顺序,所以那边「删掉再插入」就等于「移到队尾」,
|
||||
// LRU 几乎免费。Go 的 map **不保证遍历顺序**,做 LRU 要额外维护一个链表。
|
||||
//
|
||||
// 这里不值得:`deliveredMails` 防的两种重复(SSE 重放、心跳与建连之间的窗口)
|
||||
// 都发生在秒到分钟级,先进先出(丢最早插入的)与丢最久未访问的在这个场景下
|
||||
// 没有可观察的差别。而跨进程、跨天的去重本来就由 `ledger`(落盘,14 天保留期)
|
||||
// 负责,这张表只是同进程内的快速路径。
|
||||
//
|
||||
// # 为什么不是「攒满就整表清空」
|
||||
//
|
||||
// 整表清空会在那一刻把**全部**记忆丢掉,于是紧接着到达的 SSE 重放会被当成
|
||||
// 新邮件全部重投一遍 —— 一次性放大成一批重复投递。FIFO 每次只丢最老的一条,
|
||||
// 而最老的那条恰好是最不可能再出现的。
|
||||
|
||||
// maxTrackedMails 是同进程内已投递邮件 id 的记忆上限。
|
||||
//
|
||||
// 与 Node 侧的 MAX_TRACKED_MAILS 取同一个数:SSE 重放最多回放服务端环形缓冲的
|
||||
// 500 条事件,一次补拉最多 5 封(catchupLimit)。2000 是三个数量级的余量,
|
||||
// 内存代价约 200KB。
|
||||
const maxTrackedMails = 2000
|
||||
|
||||
// boundedIDSet 是一个带 FIFO 上限的字符串集合。
|
||||
//
|
||||
// 非并发安全:调用方(plugin.go)已经用 sseMu 保护着它,
|
||||
// 自带一把锁只会让「到底该拿哪把锁」变得含糊。
|
||||
type boundedIDSet struct {
|
||||
limit int
|
||||
seen map[string]struct{}
|
||||
// order 记录插入顺序,用来知道该丢谁。
|
||||
//
|
||||
// 用 slice 而不是 container/list:上限只有 2000,切片头部推进的代价
|
||||
// (一次 append + 一个下标)远小于链表节点的分配开销。
|
||||
order []string
|
||||
// head 是 order 里第一个仍然有效的下标。丢弃时只推进它,不做 order[1:] ——
|
||||
// 后者每次都要搬移整个底层数组。
|
||||
head int
|
||||
// evicted 累计淘汰条数,观测用。
|
||||
evicted int
|
||||
}
|
||||
|
||||
func newBoundedIDSet(limit int) *boundedIDSet {
|
||||
// 上限非法时回落到 1 而不是 panic:这张表是优化项,配错了应当退化成
|
||||
// 「只记得最后一条」(多几次重复投递),而不是让插件起不来。
|
||||
if limit < 1 {
|
||||
limit = 1
|
||||
}
|
||||
return &boundedIDSet{
|
||||
limit: limit,
|
||||
seen: make(map[string]struct{}, limit),
|
||||
order: make([]string, 0, limit),
|
||||
}
|
||||
}
|
||||
|
||||
// has 报告这个 id 是否已经记住过。
|
||||
func (s *boundedIDSet) has(id string) bool {
|
||||
if s == nil || id == "" {
|
||||
return false
|
||||
}
|
||||
_, ok := s.seen[id]
|
||||
return ok
|
||||
}
|
||||
|
||||
// add 记住一个 id,并在超限时丢掉最早插入的那些。
|
||||
//
|
||||
// 返回值是「这次调用**新加入**了吗」—— 调用方常常想在一次操作里同时完成
|
||||
// 「查重」与「登记」,分两步做需要两次加锁或一段不必要的临界区。
|
||||
func (s *boundedIDSet) add(id string) bool {
|
||||
if s == nil || id == "" {
|
||||
return false
|
||||
}
|
||||
if _, ok := s.seen[id]; ok {
|
||||
// 已存在时**不**移到队尾:FIFO 语义下位置由首次插入决定。
|
||||
return false
|
||||
}
|
||||
s.seen[id] = struct{}{}
|
||||
s.order = append(s.order, id)
|
||||
for len(s.order)-s.head > s.limit {
|
||||
oldest := s.order[s.head]
|
||||
s.order[s.head] = "" // 断引用,让字符串可回收
|
||||
s.head++
|
||||
delete(s.seen, oldest)
|
||||
s.evicted++
|
||||
}
|
||||
// 前缀攒到一半以上时压实一次,否则 order 的底层数组会随插入次数无限增长
|
||||
// —— 那正是这张表本来要修的病,只是换了个地方。
|
||||
if s.head > 0 && s.head >= len(s.order)/2 {
|
||||
s.compact()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// compact 把 order 重排到从 0 开始,丢掉已淘汰的前缀。
|
||||
//
|
||||
// 容量固定为 `limit*2` 而不是 `cap(live)+limit`:后者里的 `cap(live)` 是
|
||||
// 原切片剩下的容量,而 append 会不断扩容 —— 于是每次压实都把上一轮扩大后的
|
||||
// 容量继承下去,底层数组仍然单调增长(实测灌 1 万条后 cap 到 1130)。
|
||||
// 那正是这张表本来要修的病,只是从 map 换到了切片上。
|
||||
//
|
||||
// limit*2 刚好是下一次触发压实的长度(head 走到 limit 时 len == 2*limit),
|
||||
// 于是 append 在两次压实之间不会扩容。
|
||||
func (s *boundedIDSet) compact() {
|
||||
live := s.order[s.head:]
|
||||
fresh := make([]string, len(live), s.limit*2)
|
||||
copy(fresh, live)
|
||||
s.order = fresh
|
||||
s.head = 0
|
||||
}
|
||||
|
||||
// size 是当前记住的条数,观测与测试用。
|
||||
func (s *boundedIDSet) size() int {
|
||||
if s == nil {
|
||||
return 0
|
||||
}
|
||||
return len(s.seen)
|
||||
}
|
||||
162
plugins/homeagent-mail-bridge/bounded_test.go
Normal file
162
plugins/homeagent-mail-bridge/bounded_test.go
Normal file
@ -0,0 +1,162 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 这些用例钉住的是 bounded.go 的三条性质:封顶、FIFO、以及
|
||||
// 「add 的返回值就是查重结果」—— 后者让调用方能在一把锁里查重 + 登记。
|
||||
|
||||
func TestBoundedSetCapsSize(t *testing.T) {
|
||||
s := newBoundedIDSet(3)
|
||||
for _, id := range []string{"a", "b", "c", "d", "e"} {
|
||||
s.add(id)
|
||||
}
|
||||
if s.size() != 3 {
|
||||
t.Fatalf("上限之后 size 必须封顶,得到 %d —— 这正是泄露的反面", s.size())
|
||||
}
|
||||
if s.has("a") || s.has("b") {
|
||||
t.Error("最早插入的两条应当被淘汰")
|
||||
}
|
||||
for _, id := range []string{"c", "d", "e"} {
|
||||
if !s.has(id) {
|
||||
t.Errorf("%s 应当还在", id)
|
||||
}
|
||||
}
|
||||
if s.evicted != 2 {
|
||||
t.Errorf("evicted 应为 2,得到 %d", s.evicted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundedSetAddReportsFreshness(t *testing.T) {
|
||||
// 调用方(plugin.go 的两处去重)依赖这个返回值在同一把锁里完成
|
||||
// 「查重 + 登记」。分两步做需要两次加锁,中间那个窗口正是原来的竞态。
|
||||
s := newBoundedIDSet(10)
|
||||
if !s.add("m1") {
|
||||
t.Error("首次 add 应当返回 true(新加入)")
|
||||
}
|
||||
if s.add("m1") {
|
||||
t.Error("重复 add 应当返回 false(已存在)")
|
||||
}
|
||||
if s.size() != 1 {
|
||||
t.Errorf("重复 add 不该占额外位置,size=%d", s.size())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundedSetFIFONotLRU(t *testing.T) {
|
||||
// Go 的 map 不保证遍历顺序,所以这里是显式的 FIFO 而不是 LRU。
|
||||
// 这条用例把那个决定钉住:反复 has 不会让条目留得更久。
|
||||
s := newBoundedIDSet(2)
|
||||
s.add("a")
|
||||
s.add("b")
|
||||
for i := 0; i < 5; i++ {
|
||||
s.has("a") // 在 LRU 语义下这会保住 a
|
||||
}
|
||||
s.add("c")
|
||||
if s.has("a") {
|
||||
t.Error("FIFO 语义下最早插入的 a 应当被淘汰,has() 不刷新活跃度")
|
||||
}
|
||||
if !s.has("b") || !s.has("c") {
|
||||
t.Error("b 与 c 应当都在")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundedSetReAddDoesNotRefreshPosition(t *testing.T) {
|
||||
// 重复 add 也不改变位置(FIFO 由首次插入决定)。写成「已存在时移到队尾」
|
||||
// 会让一条被反复推送的邮件把别的条目挤出去。
|
||||
s := newBoundedIDSet(2)
|
||||
s.add("a")
|
||||
s.add("b")
|
||||
s.add("a") // 已存在,位置不变
|
||||
s.add("c")
|
||||
if s.has("a") {
|
||||
t.Error("a 是最早插入的,重复 add 不该救回它")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundedSetIgnoresEmptyID(t *testing.T) {
|
||||
// 空 mail_id 是「事件残缺」而不是「一封 id 为空的邮件」。
|
||||
// 记住它会让第二封残缺事件被误判成重复。
|
||||
s := newBoundedIDSet(5)
|
||||
if s.add("") {
|
||||
t.Error("空 id 不该被记住")
|
||||
}
|
||||
if s.has("") {
|
||||
t.Error("空 id 永远不算已见过")
|
||||
}
|
||||
if s.size() != 0 {
|
||||
t.Errorf("空 id 不该占位置,size=%d", s.size())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundedSetNilSafe(t *testing.T) {
|
||||
// 构造函数失败或字段没初始化时不该 panic —— 去重是优化项,
|
||||
// 退化成「什么都不记得」(多几次重复投递)远好过插件崩溃。
|
||||
var s *boundedIDSet
|
||||
if s.has("x") {
|
||||
t.Error("nil 上 has 应为 false")
|
||||
}
|
||||
if s.add("x") {
|
||||
t.Error("nil 上 add 应为 false")
|
||||
}
|
||||
if s.size() != 0 {
|
||||
t.Error("nil 上 size 应为 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundedSetIllegalLimitFallsBackToOne(t *testing.T) {
|
||||
// 上限非法时回落到 1 而不是 panic 或 0。
|
||||
// 0 的后果最隐蔽:每次 add 之后立刻把自己淘汰掉 → 去重全失效且不报错。
|
||||
for _, limit := range []int{0, -1, -100} {
|
||||
s := newBoundedIDSet(limit)
|
||||
s.add("a")
|
||||
if !s.has("a") {
|
||||
t.Errorf("limit=%d:刚加入的那条必须还在(回落到 1,而不是 0)", limit)
|
||||
}
|
||||
s.add("b")
|
||||
if s.size() != 1 {
|
||||
t.Errorf("limit=%d:size 应为 1,得到 %d", limit, s.size())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundedSetCompactsOrderSlice(t *testing.T) {
|
||||
// order 切片的底层数组不能随插入次数无限增长 —— 那正是这张表要修的病,
|
||||
// 只是换了个地方(map 有界了,切片没有)。
|
||||
s := newBoundedIDSet(10)
|
||||
for i := 0; i < 10_000; i++ {
|
||||
s.add(fmt.Sprintf("mail-%d", i))
|
||||
}
|
||||
if s.size() != 10 {
|
||||
t.Fatalf("size 应当封顶在 10,得到 %d", s.size())
|
||||
}
|
||||
// 压实之后 order 的有效长度不该远大于上限
|
||||
if live := len(s.order) - s.head; live > 10 {
|
||||
t.Errorf("order 有效长度 %d 超过上限 10", live)
|
||||
}
|
||||
if len(s.order) > 10*4 {
|
||||
t.Errorf("order 底层长度 %d 相对上限 10 增长失控(压实没生效)", len(s.order))
|
||||
}
|
||||
if cap(s.order) > 10*8 {
|
||||
t.Errorf("order 容量 %d 相对上限 10 增长失控", cap(s.order))
|
||||
}
|
||||
// 最新的必须还在,最老的必须没了
|
||||
if !s.has("mail-9999") {
|
||||
t.Error("最新的那条必须还在")
|
||||
}
|
||||
if s.has("mail-0") {
|
||||
t.Error("最老的那条必须已被淘汰")
|
||||
}
|
||||
if s.evicted != 9990 {
|
||||
t.Errorf("evicted 应为 9990,得到 %d", s.evicted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundedSetLimitMatchesNodeSide(t *testing.T) {
|
||||
// 与 Node 侧 lib/bounded.js 的 MAX_TRACKED_MAILS 取同一个数。
|
||||
// 四个平台在同一套语义下运行,一侧偷偷调小会让「重复投递」只在那个平台出现。
|
||||
if maxTrackedMails != 2000 {
|
||||
t.Errorf("maxTrackedMails 应为 2000(与 Node 侧一致),得到 %d", maxTrackedMails)
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@ -87,7 +88,10 @@ type Plugin struct {
|
||||
//
|
||||
// 这只挡得住**本进程内**的重复。跨进程(homed 重启、插件子进程被换)
|
||||
// 靠 ledger —— 它落盘,且区分「投过」与「跑完」。
|
||||
deliveredMails map[string]bool
|
||||
//
|
||||
// 有界(见 bounded.go):插件跟着 homed 长期活着,普通 map 会攒下每一封
|
||||
// 处理过的邮件 id 而永远没有出口。
|
||||
deliveredMails *boundedIDSet
|
||||
|
||||
// 跨进程投递账本(见 ledger.go)。
|
||||
//
|
||||
@ -95,6 +99,14 @@ type Plugin struct {
|
||||
// 前者回答的是「上一个进程有没有已经把这封跑完」。
|
||||
ledger *deliveryLedger
|
||||
|
||||
// currentSessionID 是当前正在处理的邮件所属的 agentmail 会话 ID。
|
||||
//
|
||||
// homeagent 是单事件循环(所有邮件共享一个 turn),同一时刻只处理一封信。
|
||||
// 模型调 send_mail 时,Gateway 需要知道「这封信是从哪条会话里发出的」
|
||||
// 才能用 InheritedMode 继承档位。SDK 的工具 handler 不传 session 上下文,
|
||||
// 所以靠这个字段做桥接。
|
||||
currentSessionID string
|
||||
|
||||
// 单调递增的 last-seen-ID:被重放的旧事件不会让它回退。
|
||||
// 原来直接赋值(p.lastEventID = eid),Gateway 重放时发旧 ID,
|
||||
// 于是 lastEventID 从 123 退回 116 → 下次重连又报 116 → 又重放。
|
||||
@ -180,7 +192,7 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e
|
||||
client: &http.Client{Timeout: 60 * time.Second},
|
||||
sseClient: &http.Client{}, // 无超时:SSE 是长连接
|
||||
stopCh: make(chan struct{}),
|
||||
deliveredMails: make(map[string]bool),
|
||||
deliveredMails: newBoundedIDSet(maxTrackedMails),
|
||||
explicitSends: make(map[string]time.Time),
|
||||
}, nil
|
||||
}
|
||||
@ -246,6 +258,15 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
"body": map[string]interface{}{"type": "string", "description": "邮件正文(Markdown)"},
|
||||
"cc": map[string]interface{}{"type": "string", "description": "抄送"},
|
||||
"reply_to": map[string]interface{}{"type": "string", "description": "回复某封邮件时传其 mail_id"},
|
||||
// 字段名必须是 attachment_ids、元素必须是裸 id 字符串 —— 逐字对齐服务端
|
||||
// SendMailRequest.AttachmentIDs。服务端解请求体时没开 DisallowUnknownFields,
|
||||
// 所以字段名错了是**静默丢附件**而不是报错:实测传
|
||||
// attachments:[{"attachment_id":…}] 返回 200,那封邮件的附件数是 0。
|
||||
"attachment_ids": map[string]interface{}{
|
||||
"type": "array",
|
||||
"items": map[string]interface{}{"type": "string"},
|
||||
"description": "附件 ID 列表(先用 upload_attachment 上传取得)",
|
||||
},
|
||||
},
|
||||
"required": []string{"to", "subject", "body"},
|
||||
},
|
||||
@ -270,7 +291,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
|
||||
registerTool("upload_attachment", sdk.ToolDef{
|
||||
Name: "upload_attachment",
|
||||
Description: "上传本地文件作为邮件附件。返回 attachment_id,填入 send_mail 的 attachments 字段。",
|
||||
Description: "上传本地文件作为邮件附件。返回 attachment_id,填入 send_mail 的 attachment_ids 字段。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
@ -536,6 +557,9 @@ func (p *Plugin) catchUp(pending int) {
|
||||
// parent_mail_id 非空 = 这封是回信。收件箱返回的字段名是它,
|
||||
// 而 SSE 事件里叫 in_reply_to —— 两个名字指同一件事。
|
||||
ParentMailID string `json:"parent_mail_id"`
|
||||
// from_session_id 用于档位继承:模型调 send_mail 时,Gateway 据此
|
||||
// 从来源会话继承权限档位(InheritedMode)。
|
||||
SessionID string `json:"session_id"`
|
||||
} `json:"mails"`
|
||||
}
|
||||
url := fmt.Sprintf("%s/api/v1/mail/inbox?status=unread&limit=%d", p.gwURL, limit)
|
||||
@ -559,12 +583,10 @@ func (p *Plugin) catchUp(pending int) {
|
||||
// 必须在循环里逗封查而不是拉完一批再筛:InjectInputSync 一封要跑
|
||||
// 几十秒,那期间 SSE 完全可能已经投过后面那几封。
|
||||
p.sseMu.Lock()
|
||||
dup := p.deliveredMails[m.MailID]
|
||||
if !dup {
|
||||
p.deliveredMails[m.MailID] = true
|
||||
}
|
||||
// add 返回「本次是否新加入」,于是查重与登记在同一把锁里一步完成。
|
||||
fresh := p.deliveredMails.add(m.MailID)
|
||||
p.sseMu.Unlock()
|
||||
if dup {
|
||||
if !fresh {
|
||||
continue
|
||||
}
|
||||
|
||||
@ -606,7 +628,9 @@ func (p *Plugin) catchUp(pending int) {
|
||||
replyInstruction(m.FromHuman, ""),
|
||||
)
|
||||
|
||||
p.currentSessionID = m.SessionID
|
||||
reply := p.sdk.InjectInputSync(p.name, p.name, prompt)
|
||||
p.currentSessionID = ""
|
||||
if reply == "" {
|
||||
// B-6:模型没回,发一封告知。发出去就算处理完(理由同 handleNewMail)。
|
||||
p.sendFailureReply(m.FromName, m.Subject, m.MailID, "模型未产生回复")
|
||||
@ -614,7 +638,7 @@ func (p *Plugin) catchUp(pending int) {
|
||||
continue
|
||||
}
|
||||
// B-5.3:检查模型是否已经自己发过信
|
||||
rk := "homeagent:" + m.MailID
|
||||
rk := ClampRelayKey("homeagent:" + m.MailID)
|
||||
p.explicitSendsMu.Lock()
|
||||
_, sent := p.explicitSends[rk]
|
||||
p.explicitSendsMu.Unlock()
|
||||
@ -637,8 +661,16 @@ func (p *Plugin) catchUp(pending int) {
|
||||
|
||||
// B-5.2:自动回信带 relay:"summary" —— 搬运不算模型自主发信,不扣配额
|
||||
if err := p.sendMailRelay(m.FromName, "Re: "+m.Subject, reply, m.MailID, rk); err != nil {
|
||||
// 永久失败(4xx)重试一万次也是同一个结果 —— 标完成,否则
|
||||
// 每次重启都重跑一遍模型再碰同一堆墙(烧 token 且永不收敛)。
|
||||
// 暂时失败(5xx / 网络)不标,下次重启重试。
|
||||
if st := statusOf(err); IsPermanentFailure(st) {
|
||||
log.Printf("[homeagent-mail-bridge] 补投回信遇永久失败(HTTP %d,标完成不再重试): %v", st, err)
|
||||
p.ledger.complete(m.MailID)
|
||||
continue
|
||||
}
|
||||
// 回信没发出去 —— 不标完成,下次重启重试。
|
||||
log.Printf("[homeagent-mail-bridge] 补投回信失败(不标完成): %v", err)
|
||||
log.Printf("[homeagent-mail-bridge] 补投回信暂时失败(不标完成): %v", err)
|
||||
continue
|
||||
}
|
||||
p.ledger.complete(m.MailID)
|
||||
@ -794,12 +826,11 @@ func (p *Plugin) parseSSELine(line string) {
|
||||
// B-7.3:去重。SSE 重放时同一封邮件会再出现,没有这层
|
||||
// 每封邮件会被注入 agent 两遍(实测 21 次超时 → 21 次重放)。
|
||||
p.sseMu.Lock()
|
||||
if p.deliveredMails[evt.MailID] {
|
||||
p.sseMu.Unlock()
|
||||
fresh := p.deliveredMails.add(evt.MailID)
|
||||
p.sseMu.Unlock()
|
||||
if !fresh {
|
||||
return
|
||||
}
|
||||
p.deliveredMails[evt.MailID] = true
|
||||
p.sseMu.Unlock()
|
||||
|
||||
// 跨进程去重:上一个插件子进程可能已经把这封跑完了。
|
||||
// deliveredMails 只在本进程内有效,homed 重启会把它清空 ——
|
||||
@ -908,7 +939,7 @@ func (p *Plugin) sendFailureReply(to, subject, replyTo, reason string) {
|
||||
"请稍后重试,或通过其他方式联系。",
|
||||
subject, reason,
|
||||
)
|
||||
rk := "homeagent:failure:" + replyTo
|
||||
rk := ClampRelayKey("homeagent:failure:" + replyTo)
|
||||
if err := p.sendMailRelay(to, "Re: "+subject, body, replyTo, rk); err != nil {
|
||||
log.Printf("[homeagent-mail-bridge] 失败通知发送失败: %v", err)
|
||||
}
|
||||
@ -946,7 +977,10 @@ func (p *Plugin) handleNewMail(evt mailEvent, resumed bool) {
|
||||
)
|
||||
|
||||
// InjectInputSync 阻塞等待 agent 处理完毕,返回最终回复文本。
|
||||
// 工具 handler 没有独立的 session 上下文,因此在本轮处理期间暂存来源会话。
|
||||
p.currentSessionID = evt.SessionID
|
||||
reply := p.sdk.InjectInputSync(p.name, p.name, prompt)
|
||||
p.currentSessionID = ""
|
||||
|
||||
// B-6:模型没回(空 = turn/end 信号 kind=error,或模型没说话)
|
||||
if reply == "" {
|
||||
@ -961,7 +995,7 @@ func (p *Plugin) handleNewMail(evt mailEvent, resumed bool) {
|
||||
}
|
||||
|
||||
// B-5.3:检查模型是否已经自己发过信(通过 send_mail 或 output_send)
|
||||
rk := "homeagent:" + evt.MailID
|
||||
rk := ClampRelayKey("homeagent:" + evt.MailID)
|
||||
p.explicitSendsMu.Lock()
|
||||
_, sent := p.explicitSends[rk]
|
||||
if sent {
|
||||
@ -990,9 +1024,16 @@ func (p *Plugin) handleNewMail(evt mailEvent, resumed bool) {
|
||||
|
||||
// B-5.2:自动回信带 relay:"summary" + relay_key
|
||||
if err := p.sendMailRelay(evt.FromName, "Re: "+evt.Subject, reply, evt.MailID, rk); err != nil {
|
||||
// 回信没发出去 —— **不标完成**,让下次重启能重试。
|
||||
// 永久失败(4xx)标完成:重试不会变好,不标的话每次重启
|
||||
// 都重跑一遍模型再碰同一堆墙。
|
||||
if st := statusOf(err); IsPermanentFailure(st) {
|
||||
log.Printf("[homeagent-mail-bridge] 自动回信遇永久失败(HTTP %d,标完成不再重试): %v", st, err)
|
||||
p.ledger.complete(evt.MailID)
|
||||
return
|
||||
}
|
||||
// 暂时失败 → **不标完成**,让下次重启能重试。
|
||||
// 发件人至今一个字都没收到,这时标「已完成」就是静默丢件。
|
||||
log.Printf("[homeagent-mail-bridge] 自动回信失败(不标完成,下次会重试): %v", err)
|
||||
log.Printf("[homeagent-mail-bridge] 自动回信暂时失败(不标完成,下次会重试): %v", err)
|
||||
} else {
|
||||
log.Printf("[homeagent-mail-bridge] 已自动回信给 %s(%d 字)", evt.FromName, len(reply))
|
||||
p.ledger.complete(evt.MailID)
|
||||
@ -1079,7 +1120,7 @@ func (p *Plugin) handleReadInbox(args map[string]interface{}) (interface{}, erro
|
||||
fn, _ := att["filename"].(string)
|
||||
sz, _ := att["size_bytes"].(float64)
|
||||
aid, _ := att["attachment_id"].(string)
|
||||
fmt.Fprintf(&sb, " - %s (%.1fKB, id=%s)\n", fn, sz/1024, aid)
|
||||
fmt.Fprintf(&sb, " - %s (%s, id=%s)\n", fn, formatSize(int64(sz)), aid)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1134,12 +1175,20 @@ func (p *Plugin) handleSendMail(args map[string]interface{}) (interface{}, error
|
||||
"subject": subj,
|
||||
"body": body,
|
||||
}
|
||||
if p.currentSessionID != "" {
|
||||
payload["from_session_id"] = p.currentSessionID
|
||||
}
|
||||
if cc != "" {
|
||||
payload["cc"] = cc
|
||||
}
|
||||
if replyTo != "" {
|
||||
payload["reply_to"] = replyTo
|
||||
}
|
||||
// 附件必须由 send_mail 带上:上传只是把文件登记成「待挂载」,
|
||||
// 24 小时内没有任何邮件引用它就会被 GC 清掉。
|
||||
if ids := stringList(args["attachment_ids"]); len(ids) > 0 {
|
||||
payload["attachment_ids"] = ids
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := p.post("/mail/send", payload, &result); err != nil {
|
||||
@ -1161,7 +1210,7 @@ func (p *Plugin) handleSendMail(args map[string]interface{}) (interface{}, error
|
||||
// C-14 附件上传 —— 真 multipart,不是桩。
|
||||
//
|
||||
// 读取本地文件 → 构造 multipart/form-data → POST /api/v1/attachments。
|
||||
// 返回 attachment_id,填入 send_mail 的 attachments 字段。
|
||||
// 返回 attachment_id,填入 send_mail 的 attachment_ids 字段。
|
||||
func (p *Plugin) handleUploadAttachment(args map[string]interface{}) (interface{}, error) {
|
||||
filePath, _ := args["file_path"].(string)
|
||||
if filePath == "" {
|
||||
@ -1203,17 +1252,33 @@ func (p *Plugin) handleUploadAttachment(args map[string]interface{}) (interface{
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// 服务端返回的是 {"attachment":{…}},字段**不在**顶层。
|
||||
//
|
||||
// 这里原先按平铺解,于是三个字段全是零值。那是最坏的一种失败:上传其实
|
||||
// 成功了(HTTP 200、文件已落盘、库里已登记),没有任何一层报错,但模型
|
||||
// 看到的是 `id= filename= size=0KB` —— 拿着空 id 它没法发出这个附件,
|
||||
// 而 24 小时后 GC 会把那个没人引用的文件清掉。
|
||||
var result struct {
|
||||
AttachmentID string `json:"attachment_id"`
|
||||
Filename string `json:"filename"`
|
||||
SizeBytes int `json:"size_bytes"`
|
||||
Attachment struct {
|
||||
AttachmentID string `json:"attachment_id"`
|
||||
Filename string `json:"filename"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
} `json:"attachment"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := result.Attachment
|
||||
// 解出空 id 说明响应结构又变了,必须当场报错。回一句「已上传」配一个空 id
|
||||
// 只会让模型接着去发信,然后收到一封没有附件的邮件 —— 那正是上面那个 bug
|
||||
// 之所以能存活的原因。
|
||||
if a.AttachmentID == "" {
|
||||
return nil, fmt.Errorf("上传响应里没有 attachment_id(服务端响应结构可能已变更),附件无法发出")
|
||||
}
|
||||
|
||||
text := fmt.Sprintf("附件已上传:id=%s filename=%s size=%dKB\n在 send_mail 的 attachments 字段传 [{\"attachment_id\":\"%s\"}]",
|
||||
result.AttachmentID, result.Filename, result.SizeBytes/1024, result.AttachmentID)
|
||||
text := fmt.Sprintf("附件已上传:%s(%s)。attachment_id: %s\n"+
|
||||
"在 send_mail 的 attachment_ids 里带上这个 id 才会随邮件发出:attachment_ids=[\"%s\"]",
|
||||
a.Filename, formatSize(a.SizeBytes), a.AttachmentID, a.AttachmentID)
|
||||
return map[string]interface{}{
|
||||
"content": []map[string]interface{}{{"type": "text", "text": text}},
|
||||
}, nil
|
||||
@ -1260,7 +1325,7 @@ func (p *Plugin) handleDownloadAttachment(args map[string]interface{}) (interfac
|
||||
return nil, fmt.Errorf("写入文件失败: %v", err)
|
||||
}
|
||||
|
||||
text := fmt.Sprintf("附件已下载:%s(%dKB)", savePath, written/1024)
|
||||
text := fmt.Sprintf("附件已下载:%s(%s)", savePath, formatSize(written))
|
||||
return map[string]interface{}{
|
||||
"content": []map[string]interface{}{{"type": "text", "text": text}},
|
||||
}, nil
|
||||
@ -1288,6 +1353,30 @@ func (p *Plugin) get(url string, out interface{}) error {
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
// httpError 带状态码的 HTTP 错误。
|
||||
//
|
||||
// 为什么要结构化:调用方需要区分「永久失败」与「暂时失败」
|
||||
// (见 IsPermanentFailure)。把状态码埋在 error 文本里,调用方只能
|
||||
// strings.Contains("HTTP 400") —— 那会在报文变化时静默失效。
|
||||
type httpError struct {
|
||||
Status int
|
||||
Path string
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *httpError) Error() string {
|
||||
return fmt.Sprintf("POST %s HTTP %d: %s", e.Path, e.Status, e.Body)
|
||||
}
|
||||
|
||||
// statusOf 从 error 里取 HTTP 状态码;不是 httpError 时返回 0(按网络层错误处理)。
|
||||
func statusOf(err error) int {
|
||||
var he *httpError
|
||||
if errors.As(err, &he) {
|
||||
return he.Status
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (p *Plugin) post(path string, payload interface{}, out interface{}) error {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
@ -1313,7 +1402,7 @@ func (p *Plugin) post(path string, payload interface{}, out interface{}) error {
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("POST %s HTTP %d: %s", path, resp.StatusCode, string(body))
|
||||
return &httpError{Status: resp.StatusCode, Path: path, Body: string(body)}
|
||||
}
|
||||
if out != nil {
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
|
||||
@ -69,8 +69,9 @@ func (p *Plugin) handleReadMail(args map[string]interface{}) (interface{}, error
|
||||
if len(data.Mail.Attachments) > 0 {
|
||||
fmt.Fprintf(&sb, "附件:\n")
|
||||
for _, a := range data.Mail.Attachments {
|
||||
fmt.Fprintf(&sb, " - %s (%.1fKB, id=%s)\n", a.Filename, float64(a.SizeBytes)/1024, a.AttachmentID)
|
||||
fmt.Fprintf(&sb, " - %s (%s, id=%s)\n", a.Filename, formatSize(int64(a.SizeBytes)), a.AttachmentID)
|
||||
}
|
||||
sb.WriteString(" 用 download_attachment 取回(传 attachment_id 与 save_path)\n")
|
||||
}
|
||||
fmt.Fprintf(&sb, "\n%s\n", data.Mail.Body)
|
||||
|
||||
|
||||
@ -35,6 +35,8 @@ import {
|
||||
} from "./lib/relay-dedup.js";
|
||||
import { adoptedSessionID, adoptMissingMessage } from "./lib/adopt.js";
|
||||
import { autoRelayDecision, replyInstruction, inboundHeadline } from "./lib/relay-policy.js";
|
||||
import { clampRelayKey, isPermanentFailure } from "./lib/relay-key.js";
|
||||
import { BoundedMap, BoundedSet, MAX_TRACKED_MAILS, MAX_TRACKED_SESSIONS } from "./lib/bounded.js";
|
||||
import { appendRenameProposal, renameProposalNote } from "./lib/rename-proposal.js";
|
||||
// opencode 原生支持三态权限,免批由它自己记(response:"always"),
|
||||
// 所以这里只借用决策文本的判定,不需要 createGrantStore。
|
||||
@ -199,6 +201,7 @@ const sendMailTool = {
|
||||
reply_to: args.reply_to || "",
|
||||
session_alias: args.session_alias || "",
|
||||
attachment_ids: args.attachment_ids || [],
|
||||
from_session_id: context?.sessionID || "",
|
||||
});
|
||||
|
||||
// 发成功后才记:失败的调用不该压掉自动转发 ——
|
||||
@ -585,9 +588,13 @@ function startSSE(onEvent) {
|
||||
// AgentMail 会话 ↔ opencode 会话的绑定。
|
||||
// 网关已经根据三维地址的 session 位完成了「复用默认 / 新建 / 具名必须存在」的判定,
|
||||
// 推送过来的 session_id 就是那个判定结果;插件只负责忠实映射,不自己决定开不开新会话。
|
||||
const sessionMap = new Map(); // agentmail session_id -> opencode session id
|
||||
const reverseMap = new Map(); // opencode session id -> agentmail session_id(供 event 钩子回写命名)
|
||||
const syncedTitles = new Map(); // opencode session id -> 已回写过的标题(去重,避免 session.updated 刷屏)
|
||||
// 这几张表都是**常驻进程里只增不减**的形态:键来自邮件事件流,会话数随时间
|
||||
// 单调增长。两条出口 —— `forgetSession()`(会话归档,确定性)与 Bounded* 的
|
||||
// 上限淘汰(兜底)。没有它们,插件跑几周之后表里躺着几十万条再也不会被查到的
|
||||
// 条目,而 GC 收不掉(还被强引用着)。
|
||||
const sessionMap = new BoundedMap(MAX_TRACKED_SESSIONS); // agentmail session_id -> opencode session id
|
||||
const reverseMap = new BoundedMap(MAX_TRACKED_SESSIONS); // opencode session id -> agentmail session_id(供 event 钩子回写命名)
|
||||
const syncedTitles = new BoundedMap(MAX_TRACKED_SESSIONS); // opencode session id -> 已回写过的标题(去重,避免 session.updated 刷屏)
|
||||
|
||||
// 权限询问的双向定位。
|
||||
//
|
||||
@ -595,11 +602,15 @@ const syncedTitles = new Map(); // opencode session id -> 已回写过的标题
|
||||
// 转出去时要记住 permission 属于哪个会话(回信地址从那里来),
|
||||
// 人类决策回来时要用 permission.id 去回复 opencode。
|
||||
// 服务端会把 relay_key 随决策事件回传,所以插件重启丢了内存映射也能续上。
|
||||
//
|
||||
// **这张表不设上界**(与上面几张不同):它装的是「还在等结果的东西」。
|
||||
// 静默淘汰一条会让 opencode 侧那次工具调用永远等不到回答 —— 而它有确定的
|
||||
// 清理路径(决策到达 / 询问被取消),不需要靠猜。
|
||||
const pendingPermissions = new Map(); // permission.id -> { sessionID, callID }
|
||||
|
||||
// 每个会话「上一次转出去的最后一条 assistant 消息」,避免 session.idle 重复触发时重发。
|
||||
// 服务端另有 relay_key 幂等兜底,这里只是少打一次网关。
|
||||
const relayedSummaries = new Map(); // opencode session id -> assistant message id
|
||||
const relayedSummaries = new BoundedMap(MAX_TRACKED_SESSIONS); // opencode session id -> assistant message id
|
||||
|
||||
// 哪些会话参与邮件往来,idle 时要把总结转回去。
|
||||
//
|
||||
@ -608,7 +619,7 @@ const relayedSummaries = new Map(); // opencode session id -> assistant message
|
||||
// 却永远没有回音,发件人只看到信发出去后再无音讯。
|
||||
//
|
||||
// 没有邮件投进来的 TUI 会话不在这里,它们不该被搬进邮件系统。
|
||||
const mailDrivenSessions = new Set(); // opencode session id
|
||||
const mailDrivenSessions = new BoundedSet(MAX_TRACKED_SESSIONS); // opencode session id
|
||||
|
||||
// 管理员在配置页划定的可用模型范围(按优先级)。随心跳响应更新。
|
||||
// 空数组 = 不限定,回退到环境变量或平台默认。
|
||||
@ -771,7 +782,7 @@ async function relaySummary(client, directory, sessionID) {
|
||||
reply_to: ctx.mailID || "",
|
||||
// relay + relay_key:走免配额通道,并以 assistant message id 保证只转一次
|
||||
relay: "summary",
|
||||
relay_key: last.id,
|
||||
relay_key: clampRelayKey(last.id),
|
||||
});
|
||||
relayedSummaries.set(sessionID, last.id);
|
||||
explicitSends.delete(sessionID); // 一轮结束,窗口关闭
|
||||
@ -785,7 +796,31 @@ function stripRe(subject) {
|
||||
|
||||
// 每个 AgentMail 会话最近一封来信的上下文,用于决定总结回给谁。
|
||||
// 一个会话里可能来过多封信,回最近那封(reply_to 指向它,回信才落回同一线索)。
|
||||
const mailContexts = new Map(); // agentmail session_id -> { replyTo, subject, mailID }
|
||||
const mailContexts = new BoundedMap(MAX_TRACKED_SESSIONS); // agentmail session_id -> { replyTo, subject, mailID }
|
||||
|
||||
/**
|
||||
* 会话归档 → 忘掉它的全部映射。
|
||||
*
|
||||
* 归档是个**确定性的终点**:归档后那条会话不可寻址(别名 404),也不会再有新
|
||||
* 邮件投进来,`session.idle` 也不该再把总结转回去(会话已经收不了信)。
|
||||
* 留着这些条目只是占内存,而上限淘汰是「猜」—— 能确切知道该删的时候就不该靠猜。
|
||||
*
|
||||
* opencode 侧那条会话**不删**:人可能还在 TUI 里看它。这里只解除邮件绑定。
|
||||
*
|
||||
* @param {string} mailSessionID
|
||||
*/
|
||||
function forgetSession(mailSessionID) {
|
||||
if (!mailSessionID) return;
|
||||
// peek 而不是 get:这是清理路径,不该把即将删掉的条目刷成「最近活跃」。
|
||||
const opencodeID = sessionMap.peek(mailSessionID);
|
||||
mailContexts.delete(mailSessionID);
|
||||
sessionMap.delete(mailSessionID);
|
||||
if (!opencodeID) return;
|
||||
reverseMap.delete(opencodeID);
|
||||
mailDrivenSessions.delete(opencodeID);
|
||||
relayedSummaries.delete(opencodeID);
|
||||
syncedTitles.delete(opencodeID);
|
||||
}
|
||||
|
||||
// relaySummary 需要 client/directory,而 event 钩子拿不到它们
|
||||
// (只在插件初始化时给一次)。插件启动时把它们闭包进来。
|
||||
@ -941,7 +976,7 @@ async function deliverMail(client, directory, data, kind) {
|
||||
body: renderFailureReport(failures, data.subject),
|
||||
reply_to: data.mail_id || "",
|
||||
relay: "summary",
|
||||
relay_key: `model-failure:${data.mail_id || sessionID}`,
|
||||
relay_key: clampRelayKey(`model-failure:${data.mail_id || sessionID}`),
|
||||
});
|
||||
console.error(`[mail-bridge] 已回报模型调用失败给 ${data.from_name}`);
|
||||
} catch (e) {
|
||||
@ -1073,7 +1108,10 @@ export default async function mailBridge(input) {
|
||||
|
||||
// 已经投过的 mail_id。心跳与 SSE 建连之间有个窗口:那期间到的邮件
|
||||
// 既在 pending_mails 里、也会被 SSE 推一次 —— 不去重就会投两遍。
|
||||
const deliveredMails = new Set();
|
||||
//
|
||||
// 有界:插件跟着 opencode serve 一起长期活着,这里会攒下每一封处理过的
|
||||
// 邮件 id 而永远没有出口。淘汰是安全的 —— 它防的两种重复都发生在秒到分钟级。
|
||||
const deliveredMails = new BoundedSet(MAX_TRACKED_MAILS);
|
||||
|
||||
/**
|
||||
* 补投离线期间积压的未读邮件。
|
||||
@ -1145,6 +1183,12 @@ export default async function mailBridge(input) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "session_archived") {
|
||||
// 会话归档 = 那条会话再也不会收信,映射可以确定性地清掉(不必等上限淘汰)。
|
||||
forgetSession(data?.session_id || "");
|
||||
return;
|
||||
}
|
||||
|
||||
if (type !== "new_mail") return;
|
||||
if (data?.mail_id) deliveredMails.add(data.mail_id);
|
||||
deliverMail(client, directory, data, "mail")
|
||||
@ -1194,35 +1238,42 @@ export default async function mailBridge(input) {
|
||||
? "\n```json\n" + JSON.stringify(input.metadata, null, 2) + "\n```"
|
||||
: "",
|
||||
].filter(Boolean).join("\n"),
|
||||
relayKey: input.id,
|
||||
relayKey: clampRelayKey(input.id),
|
||||
});
|
||||
console.error(`[mail-bridge] 权限询问已转邮件 ${input.id}(${input.type})`);
|
||||
} catch (e) {
|
||||
pendingPermissions.delete(input.id);
|
||||
|
||||
// 409 = 这条任务链上没有人类,永远不会有人来点头。
|
||||
// 4xx = 请求本身被服务端拒绝,重试一万次也是同一个结果。
|
||||
//
|
||||
// 必须当场 deny:保持 "ask" 等于把会话交给本地 TUI 弹窗,
|
||||
// 而邮件驱动的会话根本没有 TUI —— 模型会永久挂在那里。
|
||||
// 这正是生产事故的形状:pi 把任务派给自己的另一条会话,
|
||||
// 那条会话要跑 bash,权限邮件无人可投,整条线索卡死。
|
||||
//
|
||||
// deny 的同时把服务端的建议原文带给模型,它才知道下一步该换什么做法。
|
||||
if (e?.status === 409 && e?.body?.suggestion) {
|
||||
console.error(`[mail-bridge] 权限询问无人可投,当场拒绝 ${input.id}:${e.body.error || ""}`);
|
||||
// 两个真实事故都是这个形状:
|
||||
// 409:pi 把任务派给自己另一条会话,那条要跑 bash,
|
||||
// 权限邮件无人可投,整条线索卡死
|
||||
// 400:relay_key 超长(extended thinking 把思考签名拼进了 toolCallId),
|
||||
// 被当暂时失败让位 → 那条命令无人批准就执行了
|
||||
//
|
||||
// deny 的同时把原因带给模型(opencode 把 reason 作为工具报错回去),
|
||||
// 它才知道下一步该换什么做法。
|
||||
if (isPermanentFailure(e)) {
|
||||
const b = e?.body || {};
|
||||
console.error(
|
||||
`[mail-bridge] 权限询问遇到永久失败(HTTP ${e?.status}),当场拒绝 ${input.id}:${b.error || e?.message || ""}`,
|
||||
);
|
||||
output.status = "deny";
|
||||
// opencode 把 reason 作为工具报错回给模型
|
||||
output.reason = [
|
||||
e.body.error || "权限询问无法送达:该任务链上没有人类用户",
|
||||
e.body.detail || "",
|
||||
e.body.suggestion || "",
|
||||
b.error || `无法把授权请求送达给人类(HTTP ${e?.status})`,
|
||||
b.detail || "",
|
||||
b.suggestion || "这是一个不会因重试而改变的失败。请改用不需要授权的方式完成,或在回信里说明需要人工执行哪一步。",
|
||||
].filter(Boolean).join("\n");
|
||||
return;
|
||||
}
|
||||
|
||||
// 其余失败(网络抖动、Gateway 重启)保持 ask:那些是暂时的,
|
||||
// 人仍可能在本地看到弹窗,不该把一次抖动当成永久拒绝。
|
||||
console.error("[mail-bridge] 权限询问转发失败:", e?.message || e);
|
||||
// 暂时失败(5xx / 408 / 429 / 网络拖动)保持 ask:那些真的可能下一次就好,
|
||||
// 人也仍可能在本地看到弹窗,不该把一次抖动当成永久拒绝。
|
||||
console.error("[mail-bridge] 权限询问转发暂时失败(保持 ask):", e?.message || e);
|
||||
return;
|
||||
}
|
||||
output.status = "ask";
|
||||
|
||||
274
plugins/opencode-mail-bridge/lib/permission-mode.js
Normal file
274
plugins/opencode-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');
|
||||
}
|
||||
246
plugins/opencode-mail-bridge/test/permission-mode.test.mjs
Normal file
246
plugins/opencode-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 处理');
|
||||
});
|
||||
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