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:
2026-09-03 21:10:48 +08:00
parent edb4bd94fd
commit 2996f9af9c
16 changed files with 1156 additions and 81 deletions

View 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;
},
};
}

View File

@ -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}" 未被服务端接受,会话别名不变。)`;
}

View File

@ -114,6 +114,9 @@ export class GatewayClient {
if (!res.ok) {
const err = new Error(data?.error || `POST ${path} 失败: HTTP ${res.status}`);
err.status = res.status;
// 响应体也带上:服务端对 409 会给 detail/suggestion,
// 那些文字要原文转给模型(它据此决定换什么做法)。
err.body = data;
throw err;
}
return data;

View File

@ -34,6 +34,7 @@ import { modelAttemptOrder, renderFailureReport, snapshotPiModels } from '../lib
import { snapshotPiSessions } from '../lib/session-snapshot.js';
import { selectCatchup } from '../lib/catchup.js';
import { explicitSends, shouldSkipAutoRelay } from '../lib/relay-dedup.js';
import { createGrantStore, isApproval } from '../lib/permission-grants.js';
// ─── 配置 ───
@ -60,6 +61,8 @@ const mailContexts = new Map(); // agentmail session_id -> { replyTo, subject,
const relayedSummaries = new Map(); // pi session id -> 已转发过的 relay_key
const syncedNames = new Map(); // pi session id -> 上次提交给 Gateway 的名字
const pendingPermissions = new Map(); // relay_key -> { resolve, piSessionId }
// 人点过「一直同意」的 (会话, 工具)。作用域与清理语义见 lib/permission-grants.js。
const permissionGrants = createGrantStore();
const deliveredMails = new Set(); // 已投过的 mail_id(SSE 与补拉共用,B-7.3)
let allowedModels = [];
@ -140,23 +143,66 @@ function permissionExtension(getMailContext) {
// 占着钩子不放会让人在 TUI 里干活时每一步都卡住等邮件。
if (!mailSessionId) return;
// 人对这条会话的这个工具点过「一直同意」→ 直接放行,不再发邮件。
// 这一步必须在 POST 之前:否则每条命令都生成一封邮件,人点过的
// 「一直同意」形同虚设(实测同一条会话被问了 15 次 bash)。
if (permissionGrants.isGranted(piSessionId, event.toolName)) {
return;
}
// relay_key 用 pi 给的 toolCallId(B-8.1):服务端会随决策事件回传它,
// 桥重启丢了 pendingPermissions 也能对上(B-4.2)。自造随机 id 做不到。
const relayKey = `${piSessionId}:${event.toolCallId}`;
const ctxInfo = getMailContext(mailSessionId);
try {
// **不传 `to`**(这里曾经传 `ctxInfo.replyTo`,那是个死锁 bug)。
//
// replyTo 是来信人的名字,而来信人可能是另一个 Agent —— Agent 把任务
// 分派给自己的另一条会话时(pi→pi),权限邮件就发给了 `pi` 自己。
// 后果是死锁而不是报错:Agent 不可能在 Web 界面上点「同意」,
// 服务端的 SendToUser 又投进一个不存在的用户通道(没有任何人被提醒),
// 于是下面那个 await 永不 resolve —— 会话永久挂死,没有超时、没有日志。
//
// 决策人交给服务端定:它按 会话 owner → 线索里最近的人类 → 无人可问则
// 409 的顺序解析,那是唯一能看到整条线索的地方。插件只有本地那点上下文,
// 猜不出「这条 Agent 链最初是谁派的活」。
await client.post('/permission/request', {
question: `是否允许执行 ${event.toolName}?`,
options: ['同意', '一直同意', '拒绝'],
context: describeToolCall(event),
// 带上触发这次询问的来信(B-8.4)。决策人未必是这条会话的参与者 ——
// Agent 转派出来的会话,人从没见过它,只给一句「是否允许执行 bash」
// 是无从判断的:得知道这活是谁派的、为的什么事。
context: [
describeToolCall(event),
ctxInfo?.subject ? `\n触发任务:${ctxInfo.subject}` : '',
ctxInfo?.replyTo ? `任务来自:${ctxInfo.replyTo}` : '',
].filter(Boolean).join('\n'),
session_id: mailSessionId,
to: ctxInfo?.replyTo || '',
relay_key: relayKey,
});
} catch (e) {
// 转发失败 → 让位给 pi 本地 UI(B-8.2)。返回 undefined 表示
// 「这个钩子不表态」,pi 会走它自己的批准流程。
// 409 = 服务端已判定这条任务链上没有人类,永远不会有人来点头。
//
// 不能「让位给本地 UI」:邮件驱动的会话没有 TUI,让位之后 pi 按默认
// 策略处置,而默认策略在没有交互端时就是等 —— 又一次无声挂死。
//
// 直接 block 并把服务端的建议原文当作 reason:模型从工具报错里
// 看到「这条链上没人可问,换不需要权限的方式」才能自己改道,
// 而挂死时它连重试的机会都没有。
if (e?.status === 409) {
const b = e.body || {};
const reason = [
b.error || '权限询问无法送达:这条任务链上没有人类用户',
b.detail || '',
b.suggestion || '',
].filter(Boolean).join('\n');
log(`权限询问无人可投,当场拒绝 ${relayKey}:${b.error || ''}`);
return { block: true, reason };
}
// 其余失败(网络抖动、Gateway 重启)是暂时的 → 让位给 pi 本地 UI(B-8.2)。
// 返回 undefined 表示「这个钩子不表态」,pi 会走它自己的批准流程。
log(`权限转发失败,让位给本地决策: ${describeError(e)}`);
return;
}
@ -168,7 +214,13 @@ function permissionExtension(getMailContext) {
// fail closed(B-9.2 / N-9):只有明确的同意才放行。
// 关停时 shutdown() 会用 'shutdown' 唤醒所有等待者,落到这里的 else。
if (/^(同意|一直同意|allow|approve|always|yes)/i.test(decision)) {
if (isApproval(decision)) {
// 「一直同意」要真的记住,否则这个选项是在骗人:人点了它,
// 下一条命令照样来一封邮件。grant() 内部只认精确的 always 文本 ——
// 「同意」是单次授权,把它当 always 会放行人没看过的后续命令。
if (permissionGrants.grant(piSessionId, event.toolName, decision)) {
log(`本会话的 ${event.toolName} 已获「一直同意」,后续不再询问`);
}
log(`权限 ${relayKey} 获批(${decision}),放行 ${event.toolName}`);
return;
}
@ -483,6 +535,9 @@ async function deliverMail(data, kind, mailTools) {
function rebind(mailSessionID, oldPiId, opened, cwd) {
reverseMap.delete(oldPiId);
mailDriven.delete(oldPiId);
// 免批授权跟着旧的 pi 会话作废:它是人对**那次**上下文的判断,
// 换模型意味着重开一条会话、重跑一遍提示,不该继承上一条的授权。
permissionGrants.revokeSession(oldPiId);
const piSessionId = opened.session.sessionId;
// cwd 由调用方传:AgentSession 上没有 cwd getter(只有 sessionId /
// sessionFile / sessionName),从 sessionManager.getCwd() 也行,
@ -610,7 +665,18 @@ async function main() {
const runtimeErr = modelRuntime.getError?.();
if (runtimeErr) log(`模型运行时告警: ${runtimeErr}`);
const mailTools = createMailTools({ client, log, agentName: AGENT_NAME });
// onReconnect:connect_to_server 换了 Gateway 地址/密钥后,旧 SSE 长连仍连着
// 旧地址(或已被旧密钥打断),必须在这里重建,模型调完工具才真正「切过去」。
// reconfigure 已清空 lastEventID,所以 startSSE 会以「首次连接」姿态
// (不带 Last-Event-ID,N-11)连上新地址 —— 拿旧序号问新 Gateway 只会
// 命中一段无关的历史。
const mailTools = createMailTools({
client, log, agentName: AGENT_NAME,
onReconnect: () => {
client.stopSSE();
client.startSSE((type, data) => handleSSEEvent(type, data, mailTools), log);
},
});
try {
await client.register(); // B-1.2
@ -644,23 +710,31 @@ async function main() {
await beat(); // B-1.3:不等第一个 30 秒周期
heartbeatTimer = setInterval(beat, 30_000); // B-1.5
client.startSSE((type, data) => { // B-1.4:首次不带 Last-Event-ID
if (type === 'permission_decision') {
handlePermissionDecision(data, mailTools).catch((e) =>
log(`权限决策处理失败: ${describeError(e)}`));
return;
}
if (type !== 'new_mail') return;
if (data?.role && data.role !== 'to' && data.role !== 'cc') return;
const id = data?.mail_id;
if (!id || deliveredMails.has(id)) return; // B-3 第 1 步:去重
deliveredMails.add(id);
deliverMail(data, 'mail', mailTools).catch((e) => log(`投递 ${id} 失败: ${describeError(e)}`));
}, log);
client.startSSE((type, data) => handleSSEEvent(type, data, mailTools), log);
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => shutdown(sig));
}
/**
* SSE 事件分派。
*
* 提成命名函数是因为 connect_to_server 换地址后要用同一个处理器重建长连 ——
* 内联箭头函数在那里拿不到,只能复制一遍,而复制出来的两份迟早会分叉。
*/
function handleSSEEvent(type, data, mailTools) {
if (type === 'permission_decision') {
handlePermissionDecision(data, mailTools).catch((e) =>
log(`权限决策处理失败: ${describeError(e)}`));
return;
}
if (type !== 'new_mail') return;
if (data?.role && data.role !== 'to' && data.role !== 'cc') return;
const id = data?.mail_id;
if (!id || deliveredMails.has(id)) return; // B-3 第 1 步:去重
deliveredMails.add(id);
deliverMail(data, 'mail', mailTools).catch((e) => log(`投递 ${id} 失败: ${describeError(e)}`));
}
function shutdown(reason) {
if (shuttingDown) return;
shuttingDown = true;

View 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);
});

View File

@ -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), '');
});