Files
MailUI4Agents/plugins/opencode-mail-bridge/test/rename-proposal.test.mjs
JianFeeeee f321380fa3 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 已纳入校验)
2026-09-03 15:01:52 +08:00

137 lines
5.7 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 { 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: 用服务端回的别名,不是本地提议的', () => {
// 服务端跑 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('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: 标记发出但服务端没接受', () => {
// 本地校验比服务端宽的情况(例如服务端加了新约束)——
// 不能沉默,否则模型以为提议成功了
const note = renameProposalNote('', 'somealias', true);
assert.match(note, /未被服务端接受/);
assert.match(note, /somealias/);
});
test('renameProposalNote: 没提议时不产生噪音', () => {
assert.equal(renameProposalNote('', '', false), '');
assert.equal(renameProposalNote(undefined, undefined, false), '');
});