## 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 已纳入校验)
127 lines
3.9 KiB
Go
127 lines
3.9 KiB
Go
package repo
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
|
||
"github.com/google/uuid"
|
||
)
|
||
|
||
// 连续 relay 跳数上限守的是一个真实事故:会话 f3d824ce(dsh 与 opencode 联调
|
||
// llmsproxy)共 41 封,最后一封人类意图的邮件之后每一封都是 relay:summary,
|
||
// 间隔从 15 分钟一路缩到 5 秒。双方都没有「决定继续」,因为双方都不在做决定 ——
|
||
// 发信是插件代劳的,而免配额通道让整个回路里没有任何一处在计数。
|
||
|
||
func TestTrailingRelayHopsEmptySession(t *testing.T) {
|
||
setupTestDB(t)
|
||
ctx := context.Background()
|
||
|
||
sid, _ := CreateSession(ctx, nil, "admin", "空会话", "/tmp/ws")
|
||
n, err := CountTrailingRelayHops(ctx, sid)
|
||
if err != nil {
|
||
t.Fatalf("空会话应正常返回: %v", err)
|
||
}
|
||
if n != 0 {
|
||
t.Fatalf("空会话跳数应为 0,实为 %d", n)
|
||
}
|
||
}
|
||
|
||
func TestTrailingRelayHopsCountsOnlyRelay(t *testing.T) {
|
||
setupTestDB(t)
|
||
ctx := context.Background()
|
||
|
||
sid, _ := CreateSession(ctx, nil, "admin", "混合", "/tmp/ws")
|
||
// 人类发一封(非 relay)
|
||
mustMail(t, sid, "admin", "", "dsh", "/tmp/ws", nil)
|
||
// 插件连续转发三封
|
||
for i := 0; i < 3; i++ {
|
||
seedRelayMail(t, ctx, sid, "dsh", "opencode")
|
||
}
|
||
|
||
n, err := CountTrailingRelayHops(ctx, sid)
|
||
if err != nil {
|
||
t.Fatalf("数跳数: %v", err)
|
||
}
|
||
if n != 3 {
|
||
t.Fatalf("尾部连续 relay 应为 3,实为 %d", n)
|
||
}
|
||
}
|
||
|
||
func TestTrailingRelayHopsResetsOnAutonomousSend(t *testing.T) {
|
||
setupTestDB(t)
|
||
ctx := context.Background()
|
||
|
||
// 「连续」是这条规则的关键:中间只要有一封自主发信(模型真的决定说什么)
|
||
// 或人类插话,计数就归零。否则正常的「模型回一封、插件补一封总结」
|
||
// 会被误判成回路。
|
||
sid, _ := CreateSession(ctx, nil, "admin", "打断", "/tmp/ws")
|
||
for i := 0; i < 4; i++ {
|
||
seedRelayMail(t, ctx, sid, "dsh", "opencode")
|
||
}
|
||
// 模型亲手发了一封 —— 链条到此为止
|
||
mustMail(t, sid, "dsh", "", "opencode", "/tmp/ws", nil)
|
||
seedRelayMail(t, ctx, sid, "opencode", "dsh")
|
||
|
||
n, err := CountTrailingRelayHops(ctx, sid)
|
||
if err != nil {
|
||
t.Fatalf("数跳数: %v", err)
|
||
}
|
||
if n != 1 {
|
||
t.Fatalf("自主发信之后只剩 1 跳,实为 %d", n)
|
||
}
|
||
}
|
||
|
||
func TestTrailingRelayHopsReachesLimit(t *testing.T) {
|
||
setupTestDB(t)
|
||
ctx := context.Background()
|
||
|
||
// 攒到上限:此时 handler 应当拒绝下一封 relay。
|
||
sid, _ := CreateSession(ctx, nil, "admin", "到顶", "/tmp/ws")
|
||
mustMail(t, sid, "admin", "", "dsh", "/tmp/ws", nil)
|
||
for i := 0; i < MaxRelayHops(); i++ {
|
||
seedRelayMail(t, ctx, sid, "dsh", "opencode")
|
||
}
|
||
|
||
n, _ := CountTrailingRelayHops(ctx, sid)
|
||
if n < MaxRelayHops() {
|
||
t.Fatalf("应达到上限 %d,实为 %d", MaxRelayHops(), n)
|
||
}
|
||
}
|
||
|
||
func TestTrailingRelayHopsIsPerSession(t *testing.T) {
|
||
setupTestDB(t)
|
||
ctx := context.Background()
|
||
|
||
// 一条会话的回路不该影响另一条:两个 Agent 在 A 会话里刷爆了,
|
||
// B 会话的正常自动转发仍应放行。
|
||
a, _ := CreateSession(ctx, nil, "admin", "A", "/tmp/a")
|
||
b, _ := CreateSession(ctx, nil, "admin", "B", "/tmp/b")
|
||
for i := 0; i < 5; i++ {
|
||
seedRelayMail(t, ctx, a, "dsh", "opencode")
|
||
}
|
||
seedRelayMail(t, ctx, b, "dsh", "admin")
|
||
|
||
na, _ := CountTrailingRelayHops(ctx, a)
|
||
nb, _ := CountTrailingRelayHops(ctx, b)
|
||
if na != 5 || nb != 1 {
|
||
t.Fatalf("跳数应按会话独立计:A=%d(期望 5)B=%d(期望 1)", na, nb)
|
||
}
|
||
}
|
||
|
||
// seedRelayMail 建一封走 relay 通道的邮件(同时占幂等键并关联 mail_id),
|
||
// 复刻 handler.SendMail 的真实写入顺序。
|
||
func seedRelayMail(t *testing.T, ctx context.Context, sid uuid.UUID, from, to string) {
|
||
t.Helper()
|
||
key := "relay-" + uuid.NewString()
|
||
if err := ClaimRelay(ctx, from, key, "summary"); err != nil {
|
||
t.Fatalf("占幂等键: %v", err)
|
||
}
|
||
mid, err := CreateMail(ctx, sid, nil, from, "", to, "", "Re: 主题", "正文", nil)
|
||
if err != nil {
|
||
t.Fatalf("建邮件: %v", err)
|
||
}
|
||
if err := BindRelayMail(ctx, from, key, mid); err != nil {
|
||
t.Fatalf("关联 relay: %v", err)
|
||
}
|
||
}
|