Files
MailUI4Agents/plugins/dsh-mail-bridge/test/permission-grants.test.mjs
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

157 lines
5.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.

import assert from 'node:assert/strict';
import test from 'node:test';
import {
isAlwaysDecision,
isApproval,
createGrantStore,
} from '../lib/permission-grants.js';
// ─── isAlwaysDecision ───
//
// 这个函数是整个模块里最危险的一处:判宽了就把单次授权静默升级成永久授权。
test('「一直同意」判为永久', () => {
assert.equal(isAlwaysDecision('一直同意'), true);
});
test('「同意」不是永久 —— 前缀匹配会把单次授权升级成永久', () => {
// /^同意/ 之类的正则会让这条过,那意味着人点一次「同意」,
// 后面所有命令都不再问 —— 静默越权。
assert.equal(isAlwaysDecision('同意'), false);
});
test('always / allow-always 判为永久(英文界面)', () => {
for (const d of ['always', 'Always', 'ALWAYS', 'allow-always', 'allow_always']) {
assert.equal(isAlwaysDecision(d), true, d);
}
});
test('allow / approve / yes 不是永久', () => {
for (const d of ['allow', 'approve', 'yes']) {
assert.equal(isAlwaysDecision(d), false, d);
}
});
test('「拒绝」不是永久', () => {
assert.equal(isAlwaysDecision('拒绝'), false);
});
test('两侧空白不影响判定(界面传过来的值可能带空格)', () => {
assert.equal(isAlwaysDecision(' 一直同意 '), true);
});
test('空值与 null 不是永久', () => {
for (const d of ['', ' ', null, undefined]) {
assert.equal(isAlwaysDecision(d), false, String(d));
}
});
test('「一直同意吧」这类多余后缀不判为永久(精确匹配)', () => {
// 精确匹配的取舍:宁可漏判(多问一次)也不误判(静默永久放行)
assert.equal(isAlwaysDecision('一直同意吧'), false);
});
// ─── isApproval ───
test('同意与一直同意都是放行', () => {
assert.equal(isApproval('同意'), true);
assert.equal(isApproval('一直同意'), true);
});
test('英文放行选项', () => {
for (const d of ['allow', 'approve', 'always', 'yes', 'Allow']) {
assert.equal(isApproval(d), true, d);
}
});
test('拒绝不是放行', () => {
assert.equal(isApproval('拒绝'), false);
});
test('fail closed认不出的文本一律当拒绝', () => {
// 关停哨兵、空值、乱码都必须落到拒绝一侧N-9
for (const d of ['shutdown', '', null, undefined, '也许吧', 'maybe']) {
assert.equal(isApproval(d), false, String(d));
}
});
// ─── createGrantStore ───
test('未授权时不放行', () => {
const s = createGrantStore();
assert.equal(s.isGranted('sess-1', 'bash'), false);
});
test('点「一直同意」后同会话同工具免批', () => {
const s = createGrantStore();
assert.equal(s.grant('sess-1', 'bash', '一直同意'), true);
assert.equal(s.isGranted('sess-1', 'bash'), true);
});
test('点「同意」不产生免批 —— 这正是修复前的 bug', () => {
const s = createGrantStore();
assert.equal(s.grant('sess-1', 'bash', '同意'), false);
assert.equal(s.isGranted('sess-1', 'bash'), false);
});
test('授权不跨工具:批了 bash 不等于批了 write', () => {
const s = createGrantStore();
s.grant('sess-1', 'bash', '一直同意');
assert.equal(s.isGranted('sess-1', 'write'), false);
});
test('授权不跨会话:这是防越权的关键', () => {
// 人为「审查 llmsproxy」这条会话批准的 bash不该授权
// 另一个发件人派来的另一条任务
const s = createGrantStore();
s.grant('sess-1', 'bash', '一直同意');
assert.equal(s.isGranted('sess-2', 'bash'), false);
});
test('revokeSession 清掉整条会话的全部授权', () => {
const s = createGrantStore();
s.grant('sess-1', 'bash', '一直同意');
s.grant('sess-1', 'write', '一直同意');
s.grant('sess-2', 'bash', '一直同意');
assert.equal(s.size(), 3);
s.revokeSession('sess-1');
assert.equal(s.isGranted('sess-1', 'bash'), false);
assert.equal(s.isGranted('sess-1', 'write'), false);
// 别的会话不受影响
assert.equal(s.isGranted('sess-2', 'bash'), true);
assert.equal(s.size(), 1);
});
test('工具名里含 : 不会导致误删(这是不用拼接键的原因)', () => {
const s = createGrantStore();
s.grant('sess-1', 'mcp:bash', '一直同意');
s.grant('sess-1:extra', 'bash', '一直同意');
s.revokeSession('sess-1');
// 拼接键实现(`${session}:${tool}` 按前缀删)会把下面这条一起删掉
assert.equal(s.isGranted('sess-1:extra', 'bash'), true);
});
test('空会话 id / 空工具名不产生授权(防止一个空键放行一切)', () => {
const s = createGrantStore();
assert.equal(s.grant('', 'bash', '一直同意'), false);
assert.equal(s.grant('sess-1', '', '一直同意'), false);
assert.equal(s.isGranted('', 'bash'), false);
assert.equal(s.isGranted('sess-1', ''), false);
assert.equal(s.size(), 0);
});
test('重复授权同一对不重复计数', () => {
const s = createGrantStore();
s.grant('sess-1', 'bash', '一直同意');
s.grant('sess-1', 'bash', '一直同意');
assert.equal(s.size(), 1);
});
test('revokeSession 对没授权过的会话是安全的空操作', () => {
const s = createGrantStore();
s.revokeSession('never-seen');
assert.equal(s.size(), 0);
});