fix: relay 死循环防护 + DSH 工作区注册修复 + homeagent 工具集补齐 + 三插件 connect_to_server

## relay 死循环防护(两道防线)

### 主防线:免配额只给发往人类的 relay(handler/mail.go)

原设计:relay 走免配额通道(harness 搬运不该算模型自主发信)。
问题:收件方是另一个同样会自动转发的 Agent 时,整个回路里没有任何
一处在计数——生产上跑出过 41 封(会话 f3d824ce),间隔从 15 分钟
缩到 5 秒,且用了 37 封才烧掉 4/20 预算。

改为:repo.IsHumanUser(to.Name) 判定。Agent→Agent 的 relay 照样扣预算。

顺带修次序问题:原来是「先占幂等键再扣预算」,预算耗尽时幂等键
已被占用,加了额度也无法重发。现在预算失败会 ReleaseRelay 还回去。

### 兜底:hop_limit 列接通(repo/relayhops.go)

schema 里早有 hop_limit INT DEFAULT 5,从未有代码读它。
CountTrailingRelayHops 从最新邮件往前扫,遇到第一封非 relay
邮件即停(中间有一封自主发信或人类插话就归零)。

5 测试:空会话 / 只数 relay / 自主发信打断归零 / 达到上限 / 按会话独立

## DSH 工作区注册修复

问题:上一轮加的 workspaceRegistry.create(cwd) 用了兜底值 cwd(来自
resolveWorkspaceCwd,可能是 ~/.dsh/mail-sessions/mail-<uuid>),
而不是会话 header 里的真实 cwd。两者不一致时 attachSession 拒绝,
且 create 已先执行,每封邮件都往注册表里塞一条空的垃圾 workspace。

修复:读 handle.agent.session.header.cwd —— create 路径下是 meta.cwd,
resume 路径下是持久化 header 里那个。

## homeagent 插件:11 工具齐平 opencode

tools.go 新增:read_mail / forward_mail / suggest_address /
list_contacts / session_participants / read_thread / connect_to_server
+ handleConnectToServer(注册到 Gateway 前先用候选坐标试注册,
成功才写回 p.gwURL/p.key,失败不破坏原配置)

关键修:Plugin.name(插件名,homed 注册用)与 Plugin.agentName
(AgentMail 身份,Gateway 密钥绑定用)是两个命名空间。
它们混淆会导致 403:「该密钥已绑定到 Agent 'homeagent',不能用于
注册 'homeagent-mail-bridge'」。现已分开,并在 systemd drop-in
里显式设 AGENTMAIL_AGENT_NAME=homeagent。

## 三插件补齐 connect_to_server

之前只有 opencode 有。后果:Gateway 换地址或密钥需要重新登记时,
opencode 里的模型能自己修好,其他平台只能干等环境变量被人改。

DSH 版:从 GatewayClient 内部调 register(),成功后写回 client.baseURL
与 client.agentKey 当场生效。

pi 版:新导出 KEY_FILE / saveLocalKey(从 gateway.mjs),connect
工具直接用。

# 测试

relayhops_test.go 5 例
opencode 172 / dsh 188 / pi 214 全绿
check-shared-libs.sh 三方同源(rename-proposal 已纳入校验)
This commit is contained in:
2026-09-03 15:01:52 +08:00
parent fc33087982
commit f321380fa3
19 changed files with 1909 additions and 75 deletions

View File

@ -0,0 +1,109 @@
/**
* 会话改名提议 —— 所有平台插件共用。
*
* # 这是什么
*
* 模型干完活后可能觉得当前别名不贴切:会话建立时叫 `witty-planet`(平台随机 slug
* 或 `排查登录问题`(人写的邮件主题),摸清问题后它知道这其实是
* `fix-session-cookie-leak`。改名提议就是让它把这个判断说出来。
*
* # 为什么是「提议」而不是直接改
*
* 别名是**人**的寻址入口 —— `name@path.<别名>` 里那一段。Agent 干到一半自己改掉,
* 人上一秒记住的地址下一秒就 404`session` 位三态语义要求指向不存在的会话直接报
* 「无法送达」,不会静默新建)。所以提议入库、由人在界面上点「接受」才真正生效。
*
* 这与平台命名自动同步(`POST /sessions/{id}/sync`)互补,两者不冲突:
*
* | | 谁发起 | 何时 | 是否打扰人 |
* |---|---|---|---|
* | 自动同步 | 平台的命名机制 | 每轮结束 | 不,后台静默生效 |
* | 改名提议 | 模型的主动判断 | 它认为有必要时 | 是,界面上出提示条 |
*
* # 为什么载体是 HTML 注释
*
* `/mail/send` 没有 `propose_alias` 字段 —— 提议**搭在正文里**发出去,
* 服务端用正则摘出来再把标记从入库正文中剥掉。选 HTML 注释的三个理由:
*
* - react-markdown 默认不解析 raw HTML万一服务端没剥掉它在页面上也只是
* 一行不显眼的转义文本,不会破版
* - 纯文本邮件客户端里是一行不碍事的注释,不像自造标记那样显眼
* - 不与 Markdown 语法冲突,格式化工具不会改写它
*
* # 为什么必须共用
*
* 标记格式是**服务端正则的镜像**`gateway/internal/handler/rename_proposal.go`)。
* 各平台各写一遍拼接,某一处少个空格或把双引号写成单引号,服务端匹配不上 ——
* 而失败是静默的:邮件照常发出,提议凭空消失,模型以为自己提过了。
*/
/**
* 服务端能识别的别名字符集。
*
* 与 `validateSessionAlias` 一致:`. 空白 / @` 会与三维地址解析冲突,
* `new` 是寻址保留字。这里**不做规范化**(不把非法字符替换成 `-`)——
* 规范化是服务端 `normalizeAlias` 的职责,插件擅自改写会让模型看到的
* 「我提议的名字」与实际入库的不一致。
*
* @param {string} alias
* @returns {boolean}
*/
export function isProposableAlias(alias) {
const a = String(alias ?? '').trim();
if (!a) return false;
if (a === 'new') return false;
// 双引号是标记本身的定界符,含它会截断标记
if (/[.\s/@"]/.test(a)) return false;
// 服务端 VARCHAR(128),按字节算
if (Buffer.byteLength(a, 'utf8') > 128) return false;
return true;
}
/**
* 把改名提议标记追加到正文末尾。
*
* 格式必须与服务端正则逐字符对应:
* `<!-- agentmail:rename-session alias="x" reason="y" -->`
* reason 可选,为空时**整个属性都不写**(写成 `reason=""` 服务端会存一个空理由,
* 界面上的提示条就少了那句解释)。
*
* 别名不合法时**原样返回正文**,不追加标记:与其发一个服务端匹配得上却
* 被 `validateSessionAlias` 拒掉的标记,不如当它没提 —— 调用方据此告诉模型。
*
* @param {string} body 原始正文
* @param {string} [alias] 提议的别名
* @param {string} [reason] 提议理由,一句话
* @returns {{body: string, proposed: boolean}} proposed=false 表示别名不合法,未追加
*/
export function appendRenameProposal(body, alias, reason) {
const text = String(body ?? '');
if (!isProposableAlias(alias)) return { body: text, proposed: false };
const a = String(alias).trim();
// 理由里的双引号会截断标记去掉而不是转义HTML 注释里没有转义机制
const r = String(reason ?? '').replace(/"/g, '').trim();
const reasonAttr = r ? ` reason="${r}"` : '';
return {
body: `${text}\n\n<!-- agentmail:rename-session alias="${a}"${reasonAttr} -->`,
proposed: true,
};
}
/**
* 提议提交后回给模型的那句话。
*
* 必须说明「等人确认」。不说的话模型会以为改名已经生效,接着在后续邮件里
* 用新别名当地址发信 —— 而那个别名此刻还不存在,投递会失败。
*
* @param {string} alias
* @param {boolean} proposed appendRenameProposal 的返回值
* @returns {string} 空串表示没有需要追加的说明
*/
export function renameProposalNote(alias, proposed) {
if (!alias) return '';
if (!proposed) {
return `(改名提议 "${alias}" 未提交:别名不可为 new不可含 . 空白 / @ 或双引号。)`;
}
return `已附上改名提议 "${alias}",等用户在界面上确认后生效 —— 在那之前继续用原别名寻址。`;
}

View File

@ -11,9 +11,24 @@ import { homedir } from 'node:os';
import { join } from 'node:path';
const CONFIG_DIR = process.env.AGENTMAIL_CONFIG_DIR || join(homedir(), '.agentmail');
const KEY_FILE = join(CONFIG_DIR, 'agent.key');
export const KEY_FILE = join(CONFIG_DIR, 'agent.key');
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
/**
* 把管理员给的密钥落盘0600
*
* connect_to_server 工具靠它:模型拿到一把新密钥后必须落盘,
* 否则重启后又回到无法连接的状态 —— 而那正是这个工具要解决的问题。
*/
export function saveLocalKey(token) {
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
writeFileSync(
KEY_FILE,
JSON.stringify({ key_token: token, created_at: new Date().toISOString() }, null, 2),
{ mode: 0o600 },
);
}
/** 读取本地密钥文件;不存在或损坏时返回 null。 */
export function readLocalKey() {
try {
@ -228,6 +243,28 @@ export class GatewayClient {
});
}
/**
* 换 Gateway 地址或换密钥。
*
* 守护进程不能靠重启来应用新配置 —— connect_to_server 是模型在**运行中**
* 调的,它期望调完就能收信。所以这里除了改字段还要重置断点:
* `lastEventID` 是**旧** Gateway 环形缓冲里的序号,拿去问新 Gateway 会
* 命中一段完全无关的历史(或直接被拒),得到的事件属于别人的会话。
*
* @returns {boolean} 是否真的变了(没变就不必重连 SSE省一次断流
*/
reconfigure({ url, agentKey }) {
const nextURL = url ? String(url).replace(/\/+$/, '') : this.baseURL;
const nextKey = agentKey || this.agentKey;
const changed = nextURL !== this.baseURL || nextKey !== this.agentKey;
if (!changed) return false;
if (nextURL !== this.baseURL) this.lastEventID = '';
this.baseURL = nextURL;
this.agentKey = nextKey;
return true;
}
stopSSE() {
this.sseAbort?.abort();
this.sseAbort = null;

View File

@ -10,8 +10,8 @@
* 「工具能拿到当前会话 id」自动转发去重B-5.3)靠它把发信记到正确的会话上。
*/
import { readFile, writeFile } from 'node:fs/promises';
import { basename } from 'node:path';
import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
import { basename, dirname } from 'node:path';
import {
renderInbox,
idsToMarkRead,
@ -28,6 +28,8 @@ import {
renderThread,
} from '../lib/discovery.js';
import { noteExplicitSend } from '../lib/relay-dedup.js';
import { appendRenameProposal, renameProposalNote } from '../lib/rename-proposal.js';
import { saveLocalKey, generateLocalKey, saveConfig, KEY_FILE } from './gateway.mjs';
const text = (s) => ({ content: [{ type: 'text', text: s }] });
@ -37,8 +39,10 @@ const text = (s) => ({ content: [{ type: 'text', text: s }] });
* @param {(msg: string) => void} deps.log
* @param {string} [deps.agentName] 自己的 Agent 名。收件箱渲染靠它判定
* 「我是收件人还是抄送方」并给出可投递地址。
* @param {() => void} [deps.onReconnect] connect_to_server 换了坐标后调用,
* 由入口重连 SSE。不给则只改客户端字段下次重连时生效
*/
export function createMailTools({ client, log, agentName = '' }) {
export function createMailTools({ client, log, agentName = '', onReconnect }) {
const sendMail = {
name: 'send_mail',
label: 'SendMail',
@ -59,15 +63,29 @@ export function createMailTools({ client, log, agentName = '' }) {
items: { type: 'string' },
description: '附件 ID 列表(先用 upload_attachment 取得)',
},
propose_alias: {
type: 'string',
description:
'建议把当前会话改名成这个别名(例如摸清问题后从「排查登录问题」改成 ' +
'fix-session-cookie-leak。这只是建议别名是人的寻址入口实际改名由用户' +
'在界面上确认。不可含 . / @ 空白,不可为 new',
},
propose_reason: { type: 'string', description: '改名理由,一句话,展示给用户看' },
},
required: ['to', 'subject', 'body'],
additionalProperties: false,
},
async execute(_id, params, _signal, _onUpdate, ctx) {
// 改名提议以 HTML 注释形式附在正文末尾,由网关解析后剥离。
// 拼接收在 lib/rename-proposal.js三平台共用标记格式是服务端正则的
// 镜像,各写一遍的话少个空格就静默失效 —— 邮件照常发出,提议凭空消失。
const { body, proposed } = appendRenameProposal(
params.body, params.propose_alias, params.propose_reason);
const result = await client.post('/mail/send', {
to: params.to,
subject: params.subject,
body: params.body,
body,
cc: params.cc || '',
reply_to: params.reply_to || '',
session_alias: params.session_alias || '',
@ -81,7 +99,11 @@ export function createMailTools({ client, log, agentName = '' }) {
const budget = typeof result.budget_remaining === 'number'
? ` 本任务剩余 ${result.budget_remaining}/${result.budget_max} 个来回。`
: '';
return text(`邮件已发送ID: ${result.mail_id}${budget}`);
// 别名取服务端回的 rename_proposed它跑过 normalizeAlias
// 回显本地值会让模型记住一个不存在的名字,之后拿它寻址就 404
const note = renameProposalNote(result.rename_proposed, params.propose_alias, proposed);
return text(
`邮件已发送ID: ${result.mail_id}${budget}` + (note ? `\n${note}` : ''));
},
};
@ -159,20 +181,38 @@ export function createMailTools({ client, log, agentName = '' }) {
const uploadAttachment = {
name: 'upload_attachment',
label: 'UploadAttachment',
description: '上传本地文件作为邮件附件,返回 attachment_id。',
description:
'上传本地文件作为邮件附件,返回 attachment_id。' +
'拿到 id 后必须在 send_mail 的 attachment_ids 里带上,附件才会随邮件发出。' +
'未随邮件发出的附件 24 小时后自动清理。',
parameters: {
type: 'object',
properties: {
file_path: { type: 'string', description: '本地文件绝对路径' },
file_path: { type: 'string', description: '要上传的本地文件绝对路径' },
filename: { type: 'string', description: '自定义展示文件名,默认取路径的最后一段' },
},
required: ['file_path'],
additionalProperties: false,
},
async execute(_id, params) {
// 先 stat 再读:目录和不存在的路径都要给出能行动的错误。
// 直接 readFile 的话,目录会抛 EISDIR —— 模型看到那个 errno
// 只会重试同一个路径,而不是去改参数。
let st;
try {
st = await stat(params.file_path);
} catch {
return text(`文件不存在或不可读: ${params.file_path}`);
}
if (!st.isFile()) return text(`不是普通文件: ${params.file_path}`);
// 附件上限 25MB一次性读入内存可接受。上限放宽的话这里要改成流式 multipart。
const buf = await readFile(params.file_path);
const a = await client.uploadFile(buf, basename(params.file_path) || 'file');
const name = params.filename || basename(params.file_path) || 'file';
const a = await client.uploadFile(buf, name);
return text(
`已上传 ${a.filename}${formatSize(a.size_bytes)}。attachment_id: ${a.attachment_id}`,
`已上传 ${a.filename}${formatSize(a.size_bytes)}。attachment_id: ${a.attachment_id}\n` +
`在 send_mail 的 attachment_ids 里带上这个 id 才会随邮件发出。`,
);
},
};
@ -180,18 +220,21 @@ export function createMailTools({ client, log, agentName = '' }) {
const downloadAttachment = {
name: 'download_attachment',
label: 'DownloadAttachment',
description: '下载邮件附件到本地文件。',
description: '下载邮件附件到本地文件。attachment_id 从 read_inbox 的附件清单里取。',
parameters: {
type: 'object',
properties: {
attachment_id: { type: 'string', description: '附件 IDread_inbox 的清单里给出)' },
save_path: { type: 'string', description: '保存路径' },
save_path: { type: 'string', description: '保存到的本地绝对路径' },
},
required: ['attachment_id', 'save_path'],
additionalProperties: false,
},
async execute(_id, params) {
const buf = await client.downloadFile(params.attachment_id);
// 父目录不存在时先建:模型经常写 ./downloads/x.pdf 这类还不存在的路径,
// 不建的话 writeFile 抛 ENOENT而那个错误看起来像「附件不存在」。
await mkdir(dirname(params.save_path), { recursive: true });
await writeFile(params.save_path, buf);
return text(`已保存到 ${params.save_path}${formatSize(buf.length)}`);
},
@ -343,6 +386,73 @@ export function createMailTools({ client, log, agentName = '' }) {
},
};
// connect_to_server —— 连接自愈。
//
// 之前只有 opencode 侧有。后果是Gateway 换了地址、或密钥需要重新登记时,
// opencode 里的模型能自己修好,其他平台只能干等环境变量被人改 ——
// 同一类能力在不同平台上时有时无,等于让人记住哪个平台能自己修。
//
// 失败时**把需要登记的密钥全文打出来**:密钥未登记是最常见的失败,
// 不给值的话要多走一轮「密钥无效 → 去哪拿 → 让管理员登记」。
const connectToServer = {
name: 'connect_to_server',
label: 'ConnectToServer',
description:
'连接到 AgentMail Gateway登记本机密钥并完成注册。首次安装或换了 Gateway 地址时调用。' +
'密钥若未在后台登记过,此处会返回需要登记的密钥全文。',
parameters: {
type: 'object',
properties: {
gateway_url: { type: 'string', description: 'Gateway 地址;省略则用当前配置' },
key_token: { type: 'string', description: '管理员签发的 Agent 密钥;省略则用本地密钥(不存在时自动生成)' },
},
additionalProperties: false,
},
async execute(_id, params) {
let key = client.agentKey;
if (params.key_token) {
key = String(params.key_token).trim();
// 管理员给的密钥落盘,重启后仍然可用
saveLocalKey(key);
} else if (!key) {
key = generateLocalKey(log);
}
const url = String(params.gateway_url || client.baseURL).replace(/\/+$/, '');
const res = await fetch(`${url}/api/v1/agent/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
body: JSON.stringify({ name: client.agentName, workspaces: [], platform: 'pi' }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
return text([
`连接失败HTTP ${res.status}${data?.error || '未知错误'}`,
``,
`若提示密钥无效,请让管理员在 AgentMail 后台「Agent 密钥」中登记:`,
key,
``,
`密钥文件:${KEY_FILE}`,
].join('\n'));
}
// 成功。桥是守护进程,不能靠重启来应用新坐标 —— 模型调这个工具时
// 期望调完就能收信,所以要当场改客户端并重连 SSE。
// reconfigure 顺带清掉 lastEventID那是旧 Gateway 缓冲里的序号。
const changed = client.reconfigure({ url, agentKey: key });
saveConfig({ gateway_url: url, agent_name: client.agentName, registered_at: new Date().toISOString() });
if (changed && onReconnect) {
onReconnect();
log(`connect_to_server 换了坐标SSE 已重连到 ${url}`);
}
return text(
`已连接 ${url},注册为 ${data?.agent_name || client.agentName}` +
(changed ? '事件流已切到新地址。' : ''));
},
};
// 故意**没有** request_permissionN-1 / T-7
// 权限询问由 tool_call 钩子接管 —— 模型可能忘了调,也可能在不需要时乱调,
// 而真正被 pi 拦下的那一次才是事实。
@ -351,5 +461,7 @@ export function createMailTools({ client, log, agentName = '' }) {
uploadAttachment, downloadAttachment,
// 寻址发现:让模型选地址而不是拼地址
suggestAddress, listContacts, sessionParticipants, readThread,
// 连接自愈
connectToServer,
];
}

View File

@ -0,0 +1,119 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
isProposableAlias,
appendRenameProposal,
renameProposalNote,
} from '../lib/rename-proposal.js';
// 这一组测试钉住的是「插件拼的标记与服务端正则逐字符对应」。
// 服务端那条正则在 gateway/internal/handler/rename_proposal.go
// <!--\s*agentmail:rename-session\s+alias="([^"]*)"(?:\s+reason="([^"]*)")?\s*-->
// 拼错不会报错 —— 邮件照常发出,提议凭空消失。
/** 服务端正则的等价实现,用来验证我们拼出来的标记真的能被摘出来。 */
const SERVER_RE =
/<!--\s*agentmail:rename-session\s+alias="([^"]*)"(?:\s+reason="([^"]*)")?\s*-->/s;
test('isProposableAlias: 合法别名', () => {
assert.equal(isProposableAlias('fix-login-leak'), true);
assert.equal(isProposableAlias('修复登录态泄漏'), true, '中文别名合法');
assert.equal(isProposableAlias('v2_migration'), true);
});
test('isProposableAlias: new 是寻址保留字', () => {
// `.new` 是「强制新建会话」的动作,别名叫 new 会让地址无从解释
assert.equal(isProposableAlias('new'), false);
});
test('isProposableAlias: 拒绝与三维地址冲突的字符', () => {
// 这四个字符都会让 name@path.session 的切分产生歧义
assert.equal(isProposableAlias('a.b'), false, '. 是 session 位分隔符');
assert.equal(isProposableAlias('a/b'), false, '/ 出现在 path 位');
assert.equal(isProposableAlias('a@b'), false, '@ 是 name/path 分隔符');
assert.equal(isProposableAlias('a b'), false, '空白');
assert.equal(isProposableAlias('a\tb'), false, '制表符也算空白');
});
test('isProposableAlias: 拒绝双引号', () => {
// 双引号是标记自身的定界符,含它会把标记截断成非法形式
assert.equal(isProposableAlias('say"hi'), false);
});
test('isProposableAlias: 空与空白视为没提', () => {
assert.equal(isProposableAlias(''), false);
assert.equal(isProposableAlias(' '), false);
assert.equal(isProposableAlias(undefined), false);
assert.equal(isProposableAlias(null), false);
});
test('isProposableAlias: 超过 128 字节按字节算', () => {
// 服务端是 VARCHAR(128)。中文一个字 3 字节43 字 = 129 字节
assert.equal(isProposableAlias('a'.repeat(128)), true);
assert.equal(isProposableAlias('a'.repeat(129)), false);
assert.equal(isProposableAlias('汉'.repeat(42)), true, '126 字节');
assert.equal(isProposableAlias('汉'.repeat(43)), false, '129 字节');
});
test('标记能被服务端正则摘出来', () => {
const { body, proposed } = appendRenameProposal('已定位到问题。', 'fix-login-leak', '登录态泄漏');
assert.equal(proposed, true);
const m = SERVER_RE.exec(body);
assert.ok(m, '服务端正则必须匹配得上');
assert.equal(m[1], 'fix-login-leak');
assert.equal(m[2], '登录态泄漏');
});
test('没有理由时整个 reason 属性都不写', () => {
// 写成 reason="" 会让服务端存一个空理由,界面提示条就少了那句解释
const { body } = appendRenameProposal('正文', 'fix-leak');
assert.doesNotMatch(body, /reason=/);
const m = SERVER_RE.exec(body);
assert.equal(m[1], 'fix-leak');
assert.equal(m[2], undefined);
});
test('理由里的双引号被去掉而不是转义', () => {
// HTML 注释里没有转义机制,留着会截断标记
const { body } = appendRenameProposal('正文', 'fix-leak', '他说"这是泄漏"');
const m = SERVER_RE.exec(body);
assert.ok(m);
assert.equal(m[2], '他说这是泄漏');
});
test('原正文完整保留在标记之前', () => {
const original = '第一行\n\n第二行';
const { body } = appendRenameProposal(original, 'fix-leak');
assert.ok(body.startsWith(original), '正文不得被改写');
});
test('别名不合法时原样返回,不追加标记', () => {
const { body, proposed } = appendRenameProposal('正文', 'a.b');
assert.equal(proposed, false);
assert.equal(body, '正文');
assert.doesNotMatch(body, /agentmail:rename-session/);
});
test('不给别名时正文完全不变', () => {
const { body, proposed } = appendRenameProposal('正文', '');
assert.equal(proposed, false);
assert.equal(body, '正文');
});
test('renameProposalNote: 成功时必须说明等人确认', () => {
// 不说的话模型会以为改名已生效,接着用新别名当地址发信 —— 那个别名还不存在
const note = renameProposalNote('fix-leak', true);
assert.match(note, /fix-leak/);
assert.match(note, /确认/);
assert.match(note, /原别名/, '要明确说在那之前用哪个');
});
test('renameProposalNote: 失败时说清为什么', () => {
const note = renameProposalNote('a.b', false);
assert.match(note, /未提交/);
assert.match(note, /a\.b/);
});
test('renameProposalNote: 没提议时不产生噪音', () => {
assert.equal(renameProposalNote('', false), '');
});