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

@ -12,7 +12,7 @@
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { readFile, writeFile } from 'node:fs/promises';
import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
import { homedir } from 'node:os';
import { join, dirname } from 'node:path';
@ -50,6 +50,7 @@ import {
renderContacts,
renderThread,
} from '../lib/discovery.js';
import { appendRenameProposal, renameProposalNote } from '../lib/rename-proposal.js';
// ─── 凭证管理 ───
@ -628,14 +629,27 @@ export function apply(ctx: any, config: PluginConfig): void {
// 的唯一路径。不挂的后果:会话永远落在 Ungrouped人类在侧栏看不到它
// 与项目的从属关系,也无法用 DSH 的工作区级操作(批量归档、重命名等)。
//
// 失败不可怕:会话本身已经能用了,只是 GUI 分组不对。最常见失败是
// 邮件里的 cwd 在 DSH 主机上不存在(跨主机场景),此时 mkdirSync
// 建不出目录workspaceRegistry.create 也报错。
if (cwd && handle) {
// **cwd 必须取自会话 header不能用上面那个 `cwd` 变量。**
//
// 上一版就错在这里:那个变量是 `resolveWorkspaceCwd` 的结果,可能是**兜底值**
// `~/.dsh/mail-sessions/mail-<uuid>`);而 resume 路径下会话的真实 cwd 取自
// 持久化的 header两者不一致时 `attachSession` 的校验直接拒绝:
//
// cannot attach session 'mail-f3d824ce…' to workspace
// '/root/.dsh/mail-sessions/mail-f3d824ce…': its cwd resolves to '/home/program/llmsproxy'
//
// 更糟的是 `wr.create()` 已经先执行了 —— 于是每封邮件都往注册表里
// 塞一条永远为空的垃圾 workspace。先读 header 再注册就不会有这个问题。
//
// 失败不可怕:会话本身已经能用了,只是 GUI 分组不对。
if (handle) {
try {
// 真实 cwdcreate 路径下就是上面传的 meta.cwd
// resume 路径下是持久化 header 里那个。两种情形都从 session 读。
const actualCwd = String(handle.agent?.session?.header?.cwd ?? '');
const wr: any = (ctx as any).get?.('workspaceRegistry');
if (wr?.create) {
const ws = await wr.create(cwd, cwd.split('/').pop() || cwd);
if (actualCwd && wr?.create) {
const ws = await wr.create(actualCwd, actualCwd.split('/').pop() || actualCwd);
if (ws?.attachSession) {
await ws.attachSession(attemptSessionId as any);
}
@ -754,14 +768,24 @@ export function apply(ctx: any, config: PluginConfig): void {
reply_to: { type: 'string', description: '回复某封邮件时传其 mail_id' },
session_alias: { type: 'string', description: '给新会话命名' },
attachment_ids: { type: 'array', items: { type: 'string' }, description: '附件 ID 列表' },
propose_alias: {
type: 'string',
description: '建议把当前会话改名成这个别名(例如摸清问题后从「排查登录问题」改成 fix-session-cookie-leak。这只是建议别名是人的寻址入口实际改名由用户在界面上确认。不可含 . / @ 空白,不可为 new',
},
propose_reason: { type: 'string', description: '改名理由,一句话,展示给用户看' },
},
output: {
schema: { type: 'string' },
render: (_args: any, value: string) => [{ type: 'text', text: value }],
},
async execute(args: any, toolCtx: any): Promise<string> {
// 改名提议以 HTML 注释形式附在正文末尾,由网关解析后剥离。
// 拼接收在 lib/rename-proposal.js三平台共用标记格式是服务端正则的
// 镜像,各写一遍的话少个空格就静默失效 —— 邮件照常发出,提议凭空消失。
const { body, proposed } = appendRenameProposal(
args.body, args.propose_alias, args.propose_reason);
const result = await client.post('/mail/send', {
to: args.to, subject: args.subject, body: args.body,
to: args.to, subject: args.subject, body,
cc: args.cc || '', reply_to: args.reply_to || '',
session_alias: args.session_alias || '',
attachment_ids: args.attachment_ids || [],
@ -769,7 +793,10 @@ export function apply(ctx: any, config: PluginConfig): void {
noteExplicitSend(toolCtx?.sessionID, args.to, args.reply_to);
const budget = typeof result.budget_remaining === 'number'
? ` 本任务剩余 ${result.budget_remaining}/${result.budget_max} 个来回。` : '';
return `邮件已发送ID: ${result.mail_id}${budget}`;
// 别名取服务端回的 rename_proposed它跑过 normalizeAlias
// 回显本地值会让模型记住一个不存在的名字,之后拿它寻址就 404
const note = renameProposalNote(result.rename_proposed, args.propose_alias, proposed);
return `邮件已发送ID: ${result.mail_id}${budget}` + (note ? `\n${note}` : '');
},
}));
@ -813,17 +840,31 @@ export function apply(ctx: any, config: PluginConfig): void {
// upload_attachment
ctx.tools.register(defineTool({
name: 'upload_attachment',
description: '上传本地文件作为邮件附件,返回 attachment_id。',
description:
'上传本地文件作为邮件附件,返回 attachment_id。' +
'拿到 id 后必须在 send_mail 的 attachment_ids 里带上,附件才会随邮件发出。' +
'未随邮件发出的附件 24 小时后自动清理。',
parameters: {
file_path: { type: 'string', required: true, description: '本地文件路径' },
file_path: { type: 'string', required: true, description: '要上传的本地文件绝对路径' },
filename: { type: 'string', description: '自定义展示文件名,默认取路径的最后一段' },
},
output: {
schema: { type: 'string' },
render: (_args: any, value: string) => [{ type: 'text', text: value }],
},
async execute(args: any): Promise<string> {
// 先 stat 再读:目录和不存在的路径都要给出能行动的错误。
// 直接 readFile 的话,目录抛的 EISDIR 只会让模型重试同一个路径。
let st;
try {
st = await stat(args.file_path);
} catch {
return `文件不存在或不可读: ${args.file_path}`;
}
if (!st.isFile()) return `不是普通文件: ${args.file_path}`;
const data = await readFile(args.file_path);
const filename = args.file_path.split('/').pop() || 'file';
const filename = args.filename || args.file_path.split('/').pop() || 'file';
// **必须发真正的 multipart。**
//
// 早先这里发的是 `Content-Type: application/octet-stream` 加一个
@ -843,17 +884,18 @@ export function apply(ctx: any, config: PluginConfig): void {
const json = await res.json() as any;
if (!res.ok) throw new Error(json?.error || `HTTP ${res.status}`);
const a = json.attachment;
return `已上传 ${a.filename}${formatSize(a.size_bytes)}。attachment_id: ${a.attachment_id}`;
return `已上传 ${a.filename}${formatSize(a.size_bytes)}。attachment_id: ${a.attachment_id}\n` +
`在 send_mail 的 attachment_ids 里带上这个 id 才会随邮件发出。`;
},
}));
// download_attachment
ctx.tools.register(defineTool({
name: 'download_attachment',
description: '下载邮件附件到本地文件。',
description: '下载邮件附件到本地文件。attachment_id 从 read_inbox 的附件清单里取。',
parameters: {
attachment_id: { type: 'string', required: true, description: '附件 ID' },
save_path: { type: 'string', required: true, description: '保存路径' },
save_path: { type: 'string', required: true, description: '保存到的本地绝对路径' },
},
output: {
schema: { type: 'string' },
@ -865,6 +907,9 @@ export function apply(ctx: any, config: PluginConfig): void {
});
if (!res.ok) throw new Error(`下载失败: HTTP ${res.status}`);
const buf = Buffer.from(await res.arrayBuffer());
// 父目录不存在时先建:模型经常写 ./downloads/x.pdf 这类还不存在的路径,
// 不建的话 writeFile 抛 ENOENT而那个错误看起来像「附件不存在」。
await mkdir(dirname(args.save_path), { recursive: true });
await writeFile(args.save_path, buf);
return `已保存到 ${args.save_path}${formatSize(buf.length)}`;
},
@ -1043,11 +1088,71 @@ export function apply(ctx: any, config: PluginConfig): void {
},
}));
// connect_to_server —— 连接自愈。
//
// 之前只有 opencode 侧有这个工具。后果是Gateway 换了地址、或密钥需要
// 重新登记时opencode 里的模型能自己修好,而 DSH 里的模型只能干等
// systemd 环境变量被人改 —— 同一类能力在不同平台上时有时无,
// 等于让人记住哪个平台能自己修。
//
// 失败时**把需要登记的密钥全文打出来**:密钥未登记是最常见的失败,
// 不给值的话要多走一轮「密钥无效 → 去哪拿 → 让管理员登记」。
ctx.tools.register(defineTool({
name: 'connect_to_server',
description:
'连接到 AgentMail Gateway登记本机密钥并完成注册。首次安装或换了 Gateway 地址时调用。' +
'密钥若未在后台登记过,此处会返回需要登记的密钥全文。',
parameters: {
gateway_url: { type: 'string', description: 'Gateway 地址,如 https://mail.example.com省略则用当前配置' },
key_token: { type: 'string', description: '管理员签发的 Agent 密钥;省略则用本地密钥(不存在时自动生成)' },
},
output: {
schema: { type: 'string' },
render: (_args: any, value: string) => [{ type: 'text', text: value }],
},
async execute(args: any): Promise<string> {
let key = client.agentKey;
if (args.key_token) {
key = String(args.key_token).trim();
// 管理员给的密钥落盘,重启后仍然可用
saveLocalKey(key);
} else if (!key) {
key = generateLocalKey();
}
const url = String(args.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: AGENT_NAME, workspaces: [], platform: 'dsh' }),
});
const data = await res.json().catch(() => ({})) as any;
if (!res.ok) {
return [
`连接失败HTTP ${res.status}${data?.error || '未知错误'}`,
``,
`若提示密钥无效,请让管理员在 AgentMail 后台「Agent 密钥」中登记:`,
key,
``,
`密钥文件:${KEY_FILE}`,
].join('\n');
}
// 成功后把新坐标写回客户端,当场生效(不用等重启)
client.baseURL = url;
client.agentKey = key;
return `已连接 ${url},注册为 ${data?.agent_name || AGENT_NAME}`;
},
}));
return () => {
for (const n of [
'send_mail', 'read_inbox', 'read_mail', 'forward_mail',
'upload_attachment', 'download_attachment',
'suggest_address', 'list_contacts', 'session_participants', 'read_thread',
'connect_to_server',
]) {
try { ctx.tools.unregister(n); } catch {}
}