Files
MailUI4Agents/server/internal/repo/agent_key_provision_test.go
JianFeeeee d84c198975 fix(mcp)★★: admin 签的 key 接不上 MCP —— 缺 agents 行
## 断点(实测出来的,不是推断)

    POST /admin/agent-keys {agent_name:"mcp-probe-tmp"}  → 签发成功,key 绑定正确
    GET  /mcp  用这把 key                                    → {"error":"密钥尚未绑定 Agent,请先调用 /agent/register 完成注册"}

`CreateAgentKey` 只写 `agent_keys`,**不建 `agents` 行** ⇒
`VerifyAgentKey` 返回空身份 ⇒ `AgentAuth` 拒掉。

而它自己的注释一直写着「登记密钥会**级联建 agents 行**,绕过注册检查」
—— **注释与实现不符**。

## 为什么这条断路没被报出来

报给客户端的是「请先调用 /agent/register 完成注册」,而 MCP 宿主
(Codex / Claude Desktop 等)**永远不会**去调它 —— 它只按配置发
`tools/list`。于是「admin 建身份 → 填进 MCP 配置 → 用」这条路是断的,
而断点没有任何报错指向 admin 侧。

## 修复

签 key 时顺带 `INSERT INTO agents`(ON CONFLICT DO NOTHING)。

★ **刻意不写成 online。** `CreateOrUpdateAgent` 那套是「插件刚启动、
自报家门」的语义(status='online' + last_seen=NOW());而 admin 预建的
身份**此刻还没连上**。写成 online 的症状很具体:WebUI 联系人里出现一个
「在线」却永远不回信的幽灵,而排查会先怀疑「是不是它卡住了」。

首次握手时 `TouchAgentLastSeen` 会把它改成在线 —— 那才是真的。
已有身份不会被改动任何字段(有判据钉住 secret/platform 不被覆盖)。

## 判据(3 格,2 个变异都转红)

- 签 key 后 agents 行存在,且 `VerifyAgentKey` 能解出身份
  (不建行 → 转红)
- 预建身份不是 online、没有 last_seen
  (offline → online → 转红)
- 已有 agent 的 secret/platform 不被签 key 改坏

14 包全绿。
2026-10-04 17:08:15 +08:00

154 lines
5.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package repo
/*
admin 预建 Agent 身份的判据(2026-10-04)。
★ 这一格存在的理由是一次真实的断裂:
admin 调 `POST /admin/agent-keys` 给一个不存在的名字签 key → 签发成功,
key 也正确绑定了 agent_name。但 `agents` 表里**没有那一行** ⇒
`VerifyAgentKey` 返回空名字 ⇒ MCP 客户端连上来得到
{"error":"密钥尚未绑定 Agent,请先调用 /agent/register 完成注册"}
而它**永远不会**去调 /agent/register(MCP 宿主只按配置发 tools/list)。
于是「admin 建身份 → 填进 MCP 配置 → 用」这条路是断的,
而断点没有任何报错指向 admin 侧。
*/
import (
"context"
"testing"
"github.com/agentmail/gateway/internal/db"
"github.com/google/uuid"
)
func setupKeyProvisionDB(t *testing.T) {
t.Helper()
db.Close()
db.DB = nil
path := t.TempDir() + "/provision.db"
if err := db.Connect(context.Background(), path); err != nil {
t.Fatalf("连接测试库: %v", err)
}
if err := db.Migrate(context.Background()); err != nil {
t.Fatalf("迁移测试库: %v", err)
}
t.Cleanup(func() {
db.Close()
db.DB = nil
})
}
// 签 key 之后 agents 行必须存在,且 key 能通过 AgentAuth 的那条查询。
func TestCreateAgentKeyAlsoCreatesAgentRow(t *testing.T) {
setupKeyProvisionDB(t)
ctx := context.Background()
const name = "mcp-provision-test"
if _, err := CreateAgentKey(ctx, name, "permanent", "MCP 接入端", 0, testUserID(t), ""); err != nil {
t.Fatalf("签发 key: %v", err)
}
// agents 行必须存在
var n int
if err := db.DB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM agents WHERE agent_name = $1`, name).Scan(&n); err != nil {
t.Fatalf("查 agents: %v", err)
}
if n != 1 {
t.Fatalf("★ 签了 key 却没建 agents 行 —— VerifyAgentKey 会返回空名字," +
"接入端拿到「密钥尚未绑定 Agent」而它永远不会去注册")
}
// 关键:VerifyAgentKey 必须能解出这个身份(这正是 MCP 连不上的那一步)
agent, err := VerifyAgentKey(ctx, tokenOf(t, name))
if err != nil {
t.Fatalf("VerifyAgentKey: %v", err)
}
if agent == "" {
t.Fatal("★ VerifyAgentKey 返回空身份 —— AgentAuth 会拒掉这把 key")
}
if agent != name {
t.Errorf("身份应是 %s,实际 %s", name, agent)
}
}
// 预建身份**不得**是 online:它此刻还没连上。
//
// 写成 online 的症状很具体:WebUI 联系人里出现一个「在线」却
// 永远不回信的幽灵,而排查会先怀疑「是不是它卡住了」。
func TestProvisionedAgentIsNotMarkedOnline(t *testing.T) {
setupKeyProvisionDB(t)
ctx := context.Background()
const name = "mcp-offline-test"
if _, err := CreateAgentKey(ctx, name, "permanent", "", 0, testUserID(t), ""); err != nil {
t.Fatalf("签发 key: %v", err)
}
var status string
if err := db.DB.QueryRowContext(ctx,
`SELECT status FROM agents WHERE agent_name = $1`, name).Scan(&status); err != nil {
t.Fatalf("查 status: %v", err)
}
if status == "online" {
t.Error("★ 预建身份被写成 online —— 它还没连上,WebUI 会显示一个永远不回信的幽灵")
}
var lastSeen *string
if err := db.DB.QueryRowContext(ctx,
`SELECT CAST(last_seen AS TEXT) FROM agents WHERE agent_name = $1`, name).Scan(&lastSeen); err != nil {
t.Fatalf("查 last_seen: %v", err)
}
if lastSeen != nil && *lastSeen != "" {
t.Errorf("★ 预建身份不该有 last_seen(它还没握过手),实际 %q", *lastSeen)
}
}
// 已有身份不能被 key 签发改坏(status/workspaces 属管理员与插件)。
func TestCreateAgentKeyDoesNotClobberExistingAgent(t *testing.T) {
setupKeyProvisionDB(t)
ctx := context.Background()
const name = "existing-agent"
if err := CreateOrUpdateAgent(ctx, name, "s3cret", "pi", nil); err != nil {
t.Fatalf("建 agent: %v", err)
}
if _, err := CreateAgentKey(ctx, name, "permanent", "", 0, testUserID(t), ""); err != nil {
t.Fatalf("再签一把 key: %v", err)
}
var secret, platform string
if err := db.DB.QueryRowContext(ctx,
`SELECT secret, platform FROM agents WHERE agent_name = $1`, name).Scan(&secret, &platform); err != nil {
t.Fatalf("查 agent: %v", err)
}
if secret != "s3cret" || platform != "pi" {
t.Errorf("★ 签 key 覆盖了已有 agent 的字段:secret=%q platform=%q", secret, platform)
}
}
// testUserID 建一个 admin 用户(created_by 是外键)。
func testUserID(t *testing.T) uuid.UUID {
t.Helper()
var id uuid.UUID
if err := db.DB.QueryRowContext(context.Background(),
`INSERT INTO users (username, password_hash, role) VALUES ('provision-admin','x','admin')
RETURNING user_id`).Scan(&id); err != nil {
t.Fatalf("建用户: %v", err)
}
return id
}
// tokenOf 取某 agent 名下任一把 key 的 token。
func tokenOf(t *testing.T, agentName string) string {
t.Helper()
var tok string
if err := db.DB.QueryRowContext(context.Background(),
`SELECT key_token FROM agent_keys WHERE agent_name = $1 LIMIT 1`, agentName).Scan(&tok); err != nil {
t.Fatalf("取 key: %v", err)
}
return tok
}