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:
@ -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;
|
||||
|
||||
@ -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: '附件 ID(read_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_permission(N-1 / T-7):
|
||||
// 权限询问由 tool_call 钩子接管 —— 模型可能忘了调,也可能在不需要时乱调,
|
||||
// 而真正被 pi 拦下的那一次才是事实。
|
||||
@ -351,5 +461,7 @@ export function createMailTools({ client, log, agentName = '' }) {
|
||||
uploadAttachment, downloadAttachment,
|
||||
// 寻址发现:让模型选地址而不是拼地址
|
||||
suggestAddress, listContacts, sessionParticipants, readThread,
|
||||
// 连接自愈
|
||||
connectToServer,
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user