Files
MailUI4Agents/plugins/opencode-mail-bridge/lib/permission-grants.js
JianFeeeee 2996f9af9c fix(plugins): 409 时当场表态 + DSH 补投按会话串行
**409 = 永远不会成功**(没有人类可路由)。原来三个插件都在失败时让位给
平台本地 UI —— 但邮件驱动的会话**没有 TUI**,让位之后 waterfall 跑到尾
依旧无人应答,仍是无声挂死。

HTTP 客户端必须把 err.status 与 err.body 挂到 error 上:只看 message
字符串分不出「暂时失败(502,该重试)」与「永远不会成功(409)」,
两种都会被当成前者,而前者会永久挂住会话。

三平台表态方式不同但语义统一:
- opencode: output.status = "deny" + output.reason 带服务端原文
- dsh: return 'rejected'(ApprovalOutcome 只认 allowed-once/rejected/
  cancelled,写 'denied' 不报错而是被当未知值静默失效)
- pi: return { block: true, reason }

其余失败(502 等)保持原行为,让位本地 UI。

---

**DSH 补投并发**(同一文件,故并入本次提交)

生产日志:`补投 5 封(共 16 封未读)`,9 秒后三封失败
`message "undefined" is already pending`。串行 for...of 并未真正串行 ——
awaitFirstTurn 在**首个 token** 就放行,turn 尚未结束下一封已 followup。

新增 waitForTurnEnd(等 turn/end 而非首 chunk)与 sessionLocks/locked()
按会话串行化。live-agent 路径原来直接 followup 就返回,现在也进锁。
120s 超时兜底,模型完全无响应时不会把后续邮件永久卡住。

权限场景下锁会持有到人类决策完 —— 这是正确行为:两封都需要授权时
第二封排队,比同时弹两个授权请求更合理。

顺带把 rename-proposal 纳入 check-shared-libs.sh 的同源校验。
2026-09-03 21:10:48 +08:00

106 lines
4.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 权限免批(「一直同意」)的纯逻辑 —— 所有平台插件共用。
//
// # 这是什么
//
// 权限询问默认是**每次都问**:模型每调一次 bash 就发一封邮件等人点头。
// 这在「跑一条命令看看」的场景下是对的,在「审查这个工程」的场景下是灾难 ——
// 实测同一条会话被问了 15 次 bash人点了 15 次「同意」,全是同一类操作。
//
// 「一直同意」就是人对此的回答:这条会话里这个工具,别再问了。
//
// # 为什么需要一个独立模块
//
// 因为它的**作用域**是唯一容易搞错的地方,而搞错的后果是静默的越权:
//
// - 作用域太宽(全局 / 只按工具名)→ 人为「审查 llmsproxy」批准的 bash
// 会静默授权另一个发件人派来的另一条任务。那不是他批准的东西。
// - 作用域太窄(按 toolCallId→ 等于没有免批,每条命令还是一封邮件。
//
// 正确的粒度是 **(会话, 工具名)**:人看到的那句「是否允许执行 bash
// 就是在这个粒度上提的问,授权范围不该超出提问范围。
//
// # 为什么只在内存里
//
// 会话结束(进程重启)即失效,这是有意的。长期免批该由平台自己的 settings
// 管pi 的 settings.json、opencode 的 permission 配置),不该让一个守护进程
// 的内存变成事实上的安全策略 —— 那种策略没人能审计,重启后又悄悄消失。
/**
* 判定一个决策文本是不是「永久同意」。
*
* **必须精确匹配**,不能用前缀匹配。`/^同意/` 会把「同意」也算成 always
* 于是人点一次单次授权,后面所有命令都不再问了 —— 那是把单次授权
* 静默升级成永久授权,比不实现这个功能危险得多。
*
* @param {string} decision 人点的选项原文
* @returns {boolean}
*/
export function isAlwaysDecision(decision) {
return /^(一直同意|always|allow-always|allow_always)$/i.test(String(decision ?? '').trim());
}
/**
* 判定一个决策文本是不是「同意」(含永久同意)。
*
* fail closed认不出的文本一律当拒绝。空串、`shutdown`(关停时唤醒等待者
* 用的哨兵值)、以及任何没见过的选项都走这一支 —— 放行一个没人批准的
* 危险操作,比让它失败严重得多。
*
* @param {string} decision
* @returns {boolean}
*/
export function isApproval(decision) {
return /^(同意|一直同意|allow|approve|always|yes)/i.test(String(decision ?? '').trim());
}
/**
* 免批授权表:`会话 id -> Set<工具名>`。
*
* 用 Map<string, Set<string>> 而不是 Set<`${session}:${tool}`>
* 会话结束时要能一次清掉它的全部授权(`revokeSession`
* 拼接键的话得遍历整张表按前缀删,而工具名里出现 `:` 就会误删。
*/
export function createGrantStore() {
/** @type {Map<string, Set<string>>} */
const grants = new Map();
return {
/** 这条会话的这个工具是否已获免批。 */
isGranted(sessionId, toolName) {
if (!sessionId || !toolName) return false;
return grants.get(sessionId)?.has(toolName) ?? false;
},
/**
* 记下一条免批授权。只在决策文本确实是「一直同意」时才记 ——
* 判定交给 isAlwaysDecision调用方不要自己写正则。
* @returns {boolean} 是否真的记下了(便于调用方决定要不要打日志)
*/
grant(sessionId, toolName, decision) {
if (!sessionId || !toolName) return false;
if (!isAlwaysDecision(decision)) return false;
let set = grants.get(sessionId);
if (!set) grants.set(sessionId, (set = new Set()));
set.add(toolName);
return true;
},
/**
* 撤销整条会话的免批。
*
* 换模型重开会话时必须调:授权是人对**那次**上下文的判断,
* 新会话重跑一遍提示,不该继承上一条的授权。
*/
revokeSession(sessionId) {
grants.delete(sessionId);
},
/** 仅用于测试与诊断:当前授权总数。 */
size() {
let n = 0;
for (const set of grants.values()) n += set.size;
return n;
},
};
}