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:
@ -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 {
|
||||
// 真实 cwd:create 路径下就是上面传的 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 {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user