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

@ -28,13 +28,21 @@ const (
// 因此本插件不做会话映射 —— 所有邮件注入同一个事件循环,像 QQ 插件一样。
// 邮件的 context 完全靠中断消息的文本传递,不靠 platform_sessions 上报。
type Plugin struct {
name string
sdk *sdk.PluginSDK
gwURL string
key string
client *http.Client
stopCh chan struct{}
stopOnce sync.Once
// name 是**插件名**(homed 注册用,如 homeagent-mail-bridge)。
name string
// agentName 是**AgentMail 身份**(如 homeagent)。
//
// 两者必须分开:密钥绑定的是 AgentMail 身份,拿插件名去注册会被拒
// 403 该密钥已绑定到 Agent "homeagent",不能用于注册 "homeagent-mail-bridge"
// 这不是 Gateway 太严格 —— 名字与人类用户名共用命名空间,
// 让一把密钥能注册任意名字等于让它能冒充任何人。
agentName string
sdk *sdk.PluginSDK
gwURL string
key string
client *http.Client
stopCh chan struct{}
stopOnce sync.Once
}
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
@ -55,12 +63,28 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e
if key == "" {
key = os.Getenv("AGENTMAIL_AGENT_KEY")
}
// AgentMail 身份:config > 环境变量 > 从插件名去掉 -mail-bridge 后缀。
// 兜底那条让默认配置能直接跑通(homeagent-mail-bridge → homeagent),
// 但显式配置永远优先 —— 插件名是部署细节,不该决定对外身份。
agentName := ""
if v, ok := config["agent_name"].(string); ok {
agentName = strings.TrimSpace(v)
}
if agentName == "" {
agentName = strings.TrimSpace(os.Getenv("AGENTMAIL_AGENT_NAME"))
}
if agentName == "" {
agentName = strings.TrimSuffix(name, "-mail-bridge")
}
return &Plugin{
name: name,
gwURL: strings.TrimRight(gw, "/"),
key: key,
client: &http.Client{Timeout: 30 * time.Second},
stopCh: make(chan struct{}),
name: name,
agentName: agentName,
gwURL: strings.TrimRight(gw, "/"),
key: key,
client: &http.Client{Timeout: 30 * time.Second},
stopCh: make(chan struct{}),
}, nil
}
@ -106,6 +130,94 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
},
}, p.handleSendMail)
// 读一封的完整内容(收件箱只给摘要;要回给抄收方就得先看清发给了谁)
s.RegisterTool("read_mail", sdk.ToolDef{
Name: "read_mail",
Description: "读一封邮件的完整内容,含收件人、抄送清单、附件与每个参与方的可投递地址。",
Parameters: oneStringParam("mail_id", "邮件 ID", true),
}, p.handleReadMail)
// 转发 —— 引用原文与附件,按目标地址另行定位会话(它是一条新线索)
s.RegisterTool("forward_mail", sdk.ToolDef{
Name: "forward_mail",
Description: "转发一封邮件给新的收件人(自动引用原文与附件)。与回复不同:回复落回原会话,转发按目标地址另行定位会话。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"mail_id": map[string]interface{}{"type": "string", "description": "要转发的邮件 ID"},
"to": map[string]interface{}{"type": "string", "description": "新收件人的三维地址(先用 suggest_address 确认)"},
"comment": map[string]interface{}{"type": "string", "description": "转发说明,置于引用原文之前"},
"cc": map[string]interface{}{"type": "string", "description": "抄送,逗号分隔多个三维地址"},
"subject": map[string]interface{}{"type": "string", "description": "自定义主题;留空则自动加 Fwd: 前缀"},
"session_alias": map[string]interface{}{"type": "string", "description": "仅当目标地址以 .new 结尾时生效:给新会话命名"},
},
"required": []string{"mail_id", "to"},
},
}, p.handleForwardMail)
// ─── 寻址发现 ───
//
// 没有这一组时,send_mail 的 to 是个只能靠记忆拼写的自由文本字段,
// 而拼错不报错:生产上另一个平台猜了 `opencode@/home`,投递成功,
// 但那不是它的工作目录,错误路径静默变成了新会话的 workspace。
s.RegisterTool("suggest_address", sdk.ToolDef{
Name: "suggest_address",
Description: "查询可用的收件人地址。不带参数给候选收件人名;带 name 给它可用的工作目录;" +
"name+path 都带则给该目录下可续谈的会话与现成地址。**发信前应先用它确认地址**,不要凭记忆拼写。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"name": map[string]interface{}{"type": "string", "description": "收件人名;留空则列出所有候选收件人"},
"path": map[string]interface{}{"type": "string", "description": "工作目录;与 name 同时给出才列会话"},
},
},
}, p.handleSuggestAddress)
s.RegisterTool("list_contacts", sdk.ToolDef{
Name: "list_contacts",
Description: "列出自己参与过的全部会话及各自的可投递地址、未读数、剩余往返预算。用于回答「我还有什么没处理」。",
Parameters: oneStringParam("limit", "最多列出多少条,默认 20", false),
}, p.handleListContacts)
s.RegisterTool("session_participants", sdk.ToolDef{
Name: "session_participants",
Description: "列出某条会话的全部参与方(发件人/收件人/抄送方)及各自的可投递地址,并标出谁还没回应。" +
"**要回给抄收方或向第三方转达时先用它拿地址**。",
Parameters: oneStringParam("session_id", "会话 ID", true),
}, p.handleSessionParticipants)
s.RegisterTool("read_thread", sdk.ToolDef{
Name: "read_thread",
Description: "查看一封邮件所在线索的完整往来(谁回了谁、谁还没回)。多方抄送协作时用它确认" +
"别人已经说了什么,避免重复提问或重复汇报。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"mail_id": map[string]interface{}{"type": "string", "description": "线索中任一封邮件的 ID"},
"offset": map[string]interface{}{"type": "number", "description": "分页偏移,续取时传上次返回的 next_offset"},
},
"required": []string{"mail_id"},
},
}, p.handleReadThread)
// connect_to_server —— 连接自愈。
//
// Gateway 换了地址、或密钥需要重新登记时,模型能自己修好而不必等人改
// 环境变量。失败时把需要登记的密钥全文打出来,省掉一轮来回。
s.RegisterTool("connect_to_server", sdk.ToolDef{
Name: "connect_to_server",
Description: "连接到 AgentMail Gateway:登记本机密钥并完成注册。首次安装或换了 Gateway 地址时调用。" +
"密钥若未在后台登记过,此处会返回需要登记的密钥全文。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"gateway_url": map[string]interface{}{"type": "string", "description": "Gateway 地址;省略则用当前配置"},
"key_token": map[string]interface{}{"type": "string", "description": "管理员签发的 Agent 密钥;省略则用当前密钥"},
},
},
}, p.handleConnectToServer)
// 注册输出通道 —— agent 可以主动调 output_send__homeagent 发信
s.RegisterOutputChannel("homeagent", sdk.CapText|sdk.CapFile,
"发送邮件。meta JSON 格式:{to, subject, reply_to},type: text",
@ -148,7 +260,7 @@ func (p *Plugin) heartbeatLoop() {
func (p *Plugin) register() error {
body := map[string]interface{}{
"name": p.name,
"name": p.agentName,
"platform": "homeagent",
"workspaces": []interface{}{},
}
@ -337,7 +449,7 @@ func (p *Plugin) handleNewMail(evt struct {
"**回信不用你自己发**:你把本轮工作做完、把结论说出来就行,\n"+
"插件会在这一轮结束时自动把你最后那段话作为回信发回给 %s(不消耗你的发信配额)。\n"+
"只有在需要主动联系其他人、或要带附件时才调用 send_mail。",
evt.FromName, evt.Subject, evt.MailID, p.name, evt.FromName,
evt.FromName, evt.Subject, evt.MailID, p.agentName, evt.FromName,
)
// 非阻塞注入 —— TrueAgent 的事件循环会处理
@ -487,9 +599,9 @@ func (p *Plugin) handleSendMail(args map[string]interface{}) (interface{}, error
}
payload := map[string]interface{}{
"to": to,
"subject": subj,
"body": body,
"to": to,
"subject": subj,
"body": body,
}
if cc != "" {
payload["cc"] = cc