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:
@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user