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 的同源校验。
This commit is contained in:
12
plugins/dsh-mail-bridge/lib/permission-grants.d.ts
vendored
Normal file
12
plugins/dsh-mail-bridge/lib/permission-grants.d.ts
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
export declare function isAlwaysDecision(decision: string | null | undefined): boolean;
|
||||
|
||||
export declare function isApproval(decision: string | null | undefined): boolean;
|
||||
|
||||
export interface GrantStore {
|
||||
isGranted(sessionId: string, toolName: string): boolean;
|
||||
grant(sessionId: string, toolName: string, decision: string): boolean;
|
||||
revokeSession(sessionId: string): void;
|
||||
size(): number;
|
||||
}
|
||||
|
||||
export declare function createGrantStore(): GrantStore;
|
||||
105
plugins/dsh-mail-bridge/lib/permission-grants.js
Normal file
105
plugins/dsh-mail-bridge/lib/permission-grants.js
Normal file
@ -0,0 +1,105 @@
|
||||
// 权限免批(「一直同意」)的纯逻辑 —— 所有平台插件共用。
|
||||
//
|
||||
// # 这是什么
|
||||
//
|
||||
// 权限询问默认是**每次都问**:模型每调一次 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -93,17 +93,38 @@ export function appendRenameProposal(body, alias, reason) {
|
||||
/**
|
||||
* 提议提交后回给模型的那句话。
|
||||
*
|
||||
* **别名取服务端回的 `rename_proposed`,不是本地提议的那个。** 服务端会跑
|
||||
* `normalizeAlias` —— 非法字符换成 `-`、`new` 变 `session-new`、超长按 UTF-8
|
||||
* 边界截断。回显本地值会让模型记住一个不存在的名字,之后拿它寻址就 404。
|
||||
*
|
||||
* 必须说明「等人确认」。不说的话模型会以为改名已经生效,接着在后续邮件里
|
||||
* 用新别名当地址发信 —— 而那个别名此刻还不存在,投递会失败。
|
||||
*
|
||||
* @param {string} alias
|
||||
* @param {boolean} proposed appendRenameProposal 的返回值
|
||||
* @param {string} [serverAlias] 服务端 `/mail/send` 响应里的 `rename_proposed`
|
||||
* @param {string} [requestedAlias] 本地提议的别名,仅用于「未提交」时的说明
|
||||
* @param {boolean} [proposed] appendRenameProposal 的返回值
|
||||
* @returns {string} 空串表示没有需要追加的说明
|
||||
*/
|
||||
export function renameProposalNote(alias, proposed) {
|
||||
if (!alias) return '';
|
||||
if (!proposed) {
|
||||
return `(改名提议 "${alias}" 未提交:别名不可为 new,不可含 . 空白 / @ 或双引号。)`;
|
||||
export function renameProposalNote(serverAlias, requestedAlias, proposed) {
|
||||
const server = String(serverAlias ?? '').trim();
|
||||
const wanted = String(requestedAlias ?? '').trim();
|
||||
|
||||
// 服务端确认收到了:用它给的最终值
|
||||
if (server) {
|
||||
const changed = wanted && wanted !== server
|
||||
? `(你提的 "${wanted}" 被规范化成了这个)`
|
||||
: '';
|
||||
return `已附上改名提议 "${server}"${changed},等用户在界面上确认后生效 —— ` +
|
||||
`在那之前继续用原别名寻址。`;
|
||||
}
|
||||
return `已附上改名提议 "${alias}",等用户在界面上确认后生效 —— 在那之前继续用原别名寻址。`;
|
||||
|
||||
if (!wanted) return '';
|
||||
|
||||
// 本地就判定不合法,标记没发出去
|
||||
if (!proposed) {
|
||||
return `(改名提议 "${wanted}" 未提交:别名不可为 new,不可含 . 空白 / @ 或双引号。)`;
|
||||
}
|
||||
|
||||
// 标记发出去了但服务端没回 rename_proposed:它那侧的校验也拒了
|
||||
return `(改名提议 "${wanted}" 未被服务端接受,会话别名不变。)`;
|
||||
}
|
||||
|
||||
@ -51,6 +51,8 @@ import {
|
||||
renderThread,
|
||||
} from '../lib/discovery.js';
|
||||
import { appendRenameProposal, renameProposalNote } from '../lib/rename-proposal.js';
|
||||
// 只用 isApproval:DSH 没有 always 语义,免批授权表在这里用不上(见决策处的注释)。
|
||||
import { isApproval } from '../lib/permission-grants.js';
|
||||
|
||||
// ─── 凭证管理 ───
|
||||
|
||||
@ -108,7 +110,15 @@ class GatewayClient {
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json() as any;
|
||||
if (!res.ok) throw new Error(data?.error || `POST ${path} failed: ${res.status}`);
|
||||
if (!res.ok) {
|
||||
// 状态码与响应体挂在 error 上:调用方要区分「暂时失败」与「永远不会成功」。
|
||||
// 权限询问碰到 409(任务链上没有人类)必须当场拒绝,
|
||||
// 而 502 应该重试 —— 只看 message 字符串分不出这两种。
|
||||
const err: any = new Error(data?.error || `POST ${path} failed: ${res.status}`);
|
||||
err.status = res.status;
|
||||
err.body = data;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
@ -427,13 +437,12 @@ export function apply(ctx: any, config: PluginConfig): void {
|
||||
if (event?.type === 'assistant/chunk') {
|
||||
const chunk = event.data?.chunk;
|
||||
if (chunk?.type === 'finish') {
|
||||
// finish 是这一步的收尾,可能成功也可能失败
|
||||
if (chunk.reason?.kind === 'error') {
|
||||
return finish({ ok: false, error: describe(chunk.reason.failure) });
|
||||
}
|
||||
return; // 正常收尾,等 turn/end 定论
|
||||
return; // finish 收尾,等 turn/end
|
||||
}
|
||||
// 其余 chunk 类型 = 模型真的在产出内容
|
||||
// assistant/chunk 模型在产出内容 → 模型确实活着,算 OK
|
||||
return finish({ ok: true });
|
||||
}
|
||||
|
||||
@ -442,13 +451,47 @@ export function apply(ctx: any, config: PluginConfig): void {
|
||||
if (reason?.kind === 'error') {
|
||||
return finish({ ok: false, error: describe(reason.error) });
|
||||
}
|
||||
// 正常结束(completed/canceled)也算走通了 —— 有些轮次不产出 chunk
|
||||
return finish({ ok: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待当前轮次结束后再投递下一封。
|
||||
*
|
||||
* 原来 catchUp 用 awaitFirstTurn(首 token 即放行),同一会话的多封邮件在
|
||||
* 补投时全部撞进同一个 turn → 「message undefined is already pending」。
|
||||
* 这个函数等 turn/end,且对同一会话串行化,杜绝并发 followup。
|
||||
*/
|
||||
async function waitForTurnEnd(agent: any, timeoutMs = 120_000): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
let done = false;
|
||||
const finish = () => { if (!done) { done = true; clearTimeout(timer); dispose?.(); resolve(); } };
|
||||
const timer = setTimeout(finish, timeoutMs);
|
||||
const dispose = ctx.on('session/event', (session: any, event: any) => {
|
||||
if (session !== agent.session) return;
|
||||
if (event?.type === 'turn/end') finish();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 按会话锁串行化 followup,同一会话同一时刻只跑一轮。 */
|
||||
const sessionLocks = new Map<string, Promise<void>>();
|
||||
async function locked<T>(dshSessionId: string, fn: () => Promise<T>): Promise<T> {
|
||||
const prev = sessionLocks.get(dshSessionId) ?? Promise.resolve();
|
||||
let release!: () => void;
|
||||
const next = new Promise<void>((r) => { release = r; });
|
||||
sessionLocks.set(dshSessionId, next);
|
||||
try {
|
||||
await prev;
|
||||
return await fn();
|
||||
} finally {
|
||||
release();
|
||||
if (sessionLocks.get(dshSessionId) === next) sessionLocks.delete(dshSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 建会话(磁盘上已有则 resume)───
|
||||
|
||||
/**
|
||||
@ -534,20 +577,27 @@ export function apply(ctx: any, config: PluginConfig): void {
|
||||
if (existing) {
|
||||
const live = ctx.agents.get(existing.dshSessionId);
|
||||
if (live) {
|
||||
const promptText = kind === 'permission'
|
||||
? `你之前发起的权限请求已有结论:${data.decision}(决策人:${data.decided_by || '用户'})。请据此继续后续工作。`
|
||||
: [
|
||||
`本会话收到一封新邮件(AgentMail 续谈)。`,
|
||||
``,
|
||||
`发件人:${data.from_name || 'unknown'}`,
|
||||
`主题:${data.subject || '(无主题)'}`,
|
||||
`邮件 ID:${data.mail_id || 'unknown'}`,
|
||||
``,
|
||||
`请先调用 read_inbox 读取完整正文,然后处理其中的请求。`,
|
||||
`回信不用你自己发:把这一轮做完、把结论说出来就行。`,
|
||||
].join('\n');
|
||||
live.followup(userMessage(promptText));
|
||||
return { sessionID: existing.dshSessionId, reused: true };
|
||||
return locked(existing.dshSessionId, async () => {
|
||||
const promptText = kind === 'permission'
|
||||
? `你之前发起的权限请求已有结论:${data.decision}(决策人:${data.decided_by || '用户'})。请据此继续后续工作。`
|
||||
: [
|
||||
`本会话收到一封新邮件(AgentMail 续谈)。`,
|
||||
``,
|
||||
`发件人:${data.from_name || 'unknown'}`,
|
||||
`主题:${data.subject || '(无主题)'}`,
|
||||
`邮件 ID:${data.mail_id || 'unknown'}`,
|
||||
``,
|
||||
`请先调用 read_inbox 读取完整正文,然后处理其中的请求。`,
|
||||
`回信不用你自己发:把这一轮做完、把结论说出来就行。`,
|
||||
].join('\n');
|
||||
live.followup(userMessage(promptText));
|
||||
// 等 turn/end 而不是立即返回:这封邮件的轮次未结束时投递下一封,
|
||||
// 会让 DSH 报 "message already pending"。串行化靠 locked() 保证
|
||||
// 同一时刻只有一个 followup 在跑,两个锁互斥 —— 即使 turn/end
|
||||
// 未出现(比如模型完全没响应),120s 超时兜底不会把后续邮件永久卡住。
|
||||
await waitForTurnEnd(live);
|
||||
return { sessionID: existing.dshSessionId, reused: true };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -1239,26 +1289,51 @@ export function apply(ctx: any, config: PluginConfig): void {
|
||||
|
||||
// DSH 不给询问发 id,用 (会话, 工具, callId) 做幂等键。
|
||||
const relayKey = `${agentId}:${req.toolName}:${req.callId ?? 'nocall'}`;
|
||||
const mctx = mailContexts.get(mailSessionID);
|
||||
|
||||
try {
|
||||
// **不传 `to`**:决策人由服务端定(会话 owner → 线索里最近的人类 →
|
||||
// 无人可问则 409)。插件若把来信人当决策人,Agent 之间转派任务时
|
||||
// (A 把活分给 B)权限邮件会发给 Agent 自己 —— Agent 不可能在界面上点
|
||||
// 「同意」,于是下面那个 await 永不 resolve,会话无声挂死。
|
||||
await client.post('/permission/request', {
|
||||
question: `请求执行 ${req.toolName}`,
|
||||
options: ['同意', '拒绕'],
|
||||
options: ['同意', '拒绝'],
|
||||
context: [
|
||||
`工具:${req.toolName}`,
|
||||
req.callId ? `调用 ID:${req.callId}` : '',
|
||||
req.reason ? `理由:${req.reason}` : '',
|
||||
// 决策人未必是这条会话的参与者(Agent 转派出来的会话,人从没见过它),
|
||||
// 只给工具名无从判断,得说明这活是谁派的、为的什么事(B-8.4)。
|
||||
mctx?.subject ? `触发任务:${mctx.subject}` : '',
|
||||
mctx?.replyTo ? `任务来自:${mctx.replyTo}` : '',
|
||||
].filter(Boolean).join('\n'),
|
||||
session_id: mailSessionID,
|
||||
relay_key: relayKey,
|
||||
});
|
||||
} catch (e: any) {
|
||||
// 转不出去就别把 DSH 挂在那儿等:交给下一个 answerer(本地 UI)接管。
|
||||
ctx.logger.error(`[dsh-mail-bridge] 权限询问转发失败: ${e?.message || e}`);
|
||||
// 409 = 服务端已判定这条任务链上没有人类,永远不会有人来点头。
|
||||
//
|
||||
// 不能 `return next()`:下一个 answerer 是本地 UI,而邮件驱动的会话
|
||||
// 根本没有 UI,waterfall 跑到尾以后依旧无人应答 —— 这正是生产事故
|
||||
// 的形状:pi 把任务派给自己的另一条会话,那条要跑 bash,会话永久挂死。
|
||||
//
|
||||
// 直接 denied 并把服务端的建议原文写进日志:模型从工具报错里看到
|
||||
// 拒绝后会自己换方式,而挂死时它连重试的机会都没有。
|
||||
if (e?.status === 409) {
|
||||
const hint = [e?.body?.error, e?.body?.detail, e?.body?.suggestion]
|
||||
.filter(Boolean).join(' ');
|
||||
console.error(`[dsh-mail-bridge] 权限询问无人可投,当场拒绝 ${relayKey}:${hint}`);
|
||||
// 用 'rejected' 而不是 'denied':DSH 的 ApprovalOutcome 只认
|
||||
// allowed-once / rejected / cancelled,写错了它不报错而是当成未知值处理。
|
||||
return 'rejected';
|
||||
}
|
||||
// 其余失败(网络抖动、Gateway 重启)是暂时的,交给下一个 answerer(本地 UI)。
|
||||
console.error(`[dsh-mail-bridge] 权限询问转发失败: ${e?.message || e}`);
|
||||
return next();
|
||||
}
|
||||
|
||||
ctx.logger.info(`[dsh-mail-bridge] 权限询问已转邮件 ${relayKey}`);
|
||||
console.error(`[dsh-mail-bridge] 权限询问已转邮件 ${relayKey}`);
|
||||
|
||||
// 等人类决策;DSH 撤销询问(signal abort)时结算为 cancelled。
|
||||
return new Promise<string>((resolve) => {
|
||||
@ -1277,8 +1352,15 @@ export function apply(ctx: any, config: PluginConfig): void {
|
||||
pendingApprovals.delete(relayKey);
|
||||
// AgentMail 的选项文本 → DSH 的 ApprovalOutcome。
|
||||
// 只有“同意”才放行,其余(包括认不出的选项)一律 fail closed。
|
||||
//
|
||||
// **DSH 不提供「一直同意」**:它的 ApprovalOutcome 只有
|
||||
// allowed-once / rejected / cancelled / unavailable,没有 always 语义
|
||||
// (见 @deepseek-ai/dsh-user-approval 的类型定义)。桥自己记免批的话,
|
||||
// DSH 侧仍会每次调 approval/request,而桥直接答 allowed-once ——
|
||||
// 那等于用插件内存覆盖平台的审批策略,且这份策略没人能审计。
|
||||
// 因此这里的选项只有两个(见上面的 options),isApproval 就够用。
|
||||
const decision = String(data?.decision ?? '');
|
||||
const outcome = /^(同意|allow|approve|yes)/i.test(decision) ? 'allowed-once' : 'rejected';
|
||||
const outcome = isApproval(decision) ? 'allowed-once' : 'rejected';
|
||||
pending.resolve(outcome);
|
||||
ctx.logger.info(`[dsh-mail-bridge] 权限决策 ${relayKey} -> ${outcome}`);
|
||||
return;
|
||||
|
||||
156
plugins/dsh-mail-bridge/test/permission-grants.test.mjs
Normal file
156
plugins/dsh-mail-bridge/test/permission-grants.test.mjs
Normal file
@ -0,0 +1,156 @@
|
||||
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);
|
||||
});
|
||||
@ -100,20 +100,37 @@ test('不给别名时正文完全不变', () => {
|
||||
assert.equal(body, '正文');
|
||||
});
|
||||
|
||||
test('renameProposalNote: 成功时必须说明等人确认', () => {
|
||||
// 不说的话模型会以为改名已生效,接着用新别名当地址发信 —— 那个别名还不存在
|
||||
const note = renameProposalNote('fix-leak', true);
|
||||
assert.match(note, /fix-leak/);
|
||||
test('renameProposalNote: 用服务端回的别名,不是本地提议的', () => {
|
||||
// 服务端跑 normalizeAlias:非法字符换 -、new 变 session-new、超长截断。
|
||||
// 回显本地值会让模型记住一个不存在的名字,之后拿它寻址就 404。
|
||||
const note = renameProposalNote('fix-login-leak', 'fix.login.leak', true);
|
||||
assert.match(note, /fix-login-leak/);
|
||||
assert.match(note, /规范化/, '要告诉模型名字被改写过');
|
||||
assert.match(note, /确认/);
|
||||
assert.match(note, /原别名/, '要明确说在那之前用哪个');
|
||||
});
|
||||
|
||||
test('renameProposalNote: 失败时说清为什么', () => {
|
||||
const note = renameProposalNote('a.b', false);
|
||||
test('renameProposalNote: 服务端别名与提议一致时不提规范化', () => {
|
||||
const note = renameProposalNote('fix-leak', 'fix-leak', true);
|
||||
assert.match(note, /fix-leak/);
|
||||
assert.doesNotMatch(note, /规范化/);
|
||||
});
|
||||
|
||||
test('renameProposalNote: 本地判非法时说清为什么', () => {
|
||||
const note = renameProposalNote('', 'a.b', false);
|
||||
assert.match(note, /未提交/);
|
||||
assert.match(note, /a\.b/);
|
||||
});
|
||||
|
||||
test('renameProposalNote: 没提议时不产生噪音', () => {
|
||||
assert.equal(renameProposalNote('', false), '');
|
||||
test('renameProposalNote: 标记发出但服务端没接受', () => {
|
||||
// 本地校验比服务端宽的情况(例如服务端加了新约束)——
|
||||
// 不能沉默,否则模型以为提议成功了
|
||||
const note = renameProposalNote('', 'somealias', true);
|
||||
assert.match(note, /未被服务端接受/);
|
||||
assert.match(note, /somealias/);
|
||||
});
|
||||
|
||||
test('renameProposalNote: 没提议时不产生噪音', () => {
|
||||
assert.equal(renameProposalNote('', '', false), '');
|
||||
assert.equal(renameProposalNote(undefined, undefined, false), '');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user