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

@ -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;