## 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 已纳入校验)
137 lines
5.7 KiB
JavaScript
137 lines
5.7 KiB
JavaScript
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), '');
|
||
});
|