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:
2026-09-06 15:16:49 +08:00
parent 13fcb00acc
commit a44fd6949b
32 changed files with 3462 additions and 177 deletions

View File

@ -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}`);
// 其余 4xx400 / 401 / 403 / 404 / 422…同样永远不会因重试成功
// 不能 `return next()` —— 下一个 answerer 是本地 UI而邮件驱动的会话
// 没有 UIwaterfall 跑到尾仍旧无人应答。
// 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-executepre-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 () => {