feat(mcp): MCP 集成进网关本体 —— POST /api/v1/mcp(Streamable HTTP)

## 为什么要集成而不是独立进程

上一版(29ad8aa)是独立进程 `plugins/zcode-mail-bridge/mcp/server.mjs`,
用 HTTP 调本网关。四条真实成本:

1. **工具语义有两份**。桥里的 read_inbox / send_mail 是**手抄**网关的,
   抄错就是行为分叉 —— 已抓到两次:`connect_to_server` 只发
   `X-Agent-Secret` 头,而 `/agent/register` 只认 Bearer 或 body 里的
   secret ⇒ secret-only 的 Agent 必然 400。
2. **鉴权与收窄要再实现一遍**。工作区收窄、会话收窄、冷静期、配额住在服务端。
3. **多一跳 + 多一个故障点**。
4. **接入端仍要装东西**(node + 桥 + 环境变量)。

现在:工具**包装现有 handler**,同一份代码、同一套鉴权与收窄;
接入端只填一个 URL。

## 传输与实现(用户裁定)

- **Streamable HTTP**(规范 2025-06-18):单端点 POST,通知回 202,
  请求回 JSON-RPC。
- **包装 handler**(不是直调 repo):`newRequest` + `invoke` 造内部请求
  交给 `handler.GetInbox` / `SendMail` / … 于是 `AgentMayReadSession`、
  冷静期、配额、附件保护目录全部是同一条代码路径,不是复述。
- 手写零依赖 JSON-RPC(协议面只有 4 个方法),与本仓取向一致。

端点挂在 `AgentAuth` **之内**:必须与 /mail/send 同一套凭证,
否则就成了绕过收窄的旁门。

## 11 个工具,名字与参数与四桥逐字一致

`connect_to_server` 在这里只做一次真实读来确认连通性 —— 能调到它本身
就证明凭证已过(它是局内端点,不再需要 register)。

## ★ 端到端撞出并修掉的两个真 bug

**① `Tool.Run` 丢掉了身份**(本来写成 `context.Background()`)。
症状:每个工具调用都 Unauthorized,模型表现为「说连上了但读不到任何信」。

**② 路径参数没到位**:被包装的 handler 用 `chi.URLParam(r,"id")` 取 id,
而 `httptest.NewRequest` 造的请求**没过 chi 的路由** ⇒ `URLParam` 恒空
⇒ 任何带路径参数的工具都报「Invalid id」。

第②个的发现过程值得记:端到端测越权时,主人和越权者**都**返回
「Invalid id」。只看越权那一次会误判成「收得太紧」,进而把**正确的收窄改松**;
做对照才看出是参数没到位。

修法两处:`withRouteParams` 注入 chi RouteContext;`invoke` 里**不能**再
`WithContext(ctx)` —— 那会覆盖掉刚注入的 RouteContext。

**③ 发现并暴露了会话越权漏洞**(同批,单独提交 095213b):
`AgentMayReadSession` 只比 `scope == target`,不问「你是不是参与方」,
而 session_id 由请求方给。对照实验 + 生产复核证实可读他人正文。

## 判据(13 格)

`internal/mcp/mcp_test.go`。真正在钉三件**只有集成才可能坏**的事:

1. MCP 不能成为越权旁门(工具参数里没有身份字段)。
2. 参数映射不许偷偷放宽/收紧(`attachment_ids` 被吞 ⇒ 附件静默不随信发出)。
3. 协议语义不许退化(工具失败必须 result+isError,不是 JSON-RPC error)。

`TestEveryErrorResponseCarriesID` 是被真 bug 逼出来的:曾用
`ID json.RawMessage` + `omitempty`,nil 时**整个 id 字段从 JSON 里消失**,
客户端会一直等这条的响应。遍历全部错误出口逐条验。

**变异验证**:

    Run 丢身份                    → 红 4
    工具失败回 JSON-RPC error     → 红 4
    read_inbox 丢 workspace 收窄  → 红 1
    id 泄露(tag+idPtr 同时失效) → 红 1 ★(真 bug 需两处同时失效,故两处防御都要留)
    去掉 withRouteParams          → 红 1
    invoke 里加回 WithContext     → 红 1

## 端到端(真实网关进程,临时库,备用端口 8199,不动生产)

    未认证 /mcp              → 401
    错误密钥                 → 401
    initialize               → 回显 2025-06-18
    notifications/initialized→ 202 且无响应体
    tools/list               → 11 个,带 annotations 与 required
    send_mail → read_inbox   → mcp-peer 通过 MCP 读到对方发来的信
    read_mail(带 session_id)→ 主人读到自己的信

## 未做

- 未删除旧桥 `plugins/zcode-mail-bridge/mcp/server.mjs`。它是 zcode 插件
  清单里声明的入口(`.zcode-plugin/plugin.json` 的 mcpServers),删掉会破坏
  该插件的组装。两者并存无害:桥仍走 HTTP,服务端这份是接入端零安装的那条路。
- 未部署(本提交只含代码)。
This commit is contained in:
2026-10-02 13:28:07 +08:00
parent 095213b981
commit 457d1608f0
6 changed files with 1921 additions and 0 deletions

View File

@ -0,0 +1,541 @@
package mcp
// MCP 端点的判据。
//
// # 这些判据真正在钉什么
//
// MCP 工具**包装现有 handler**,所以它成立的唯一依据是「与 HTTP 端点
// 同一套语义」。那就不必重新测一遍业务规则(那些在 handler 自己的测试里),
// 而要钉三件**只有集成才可能坏**的事:
//
// 1. **MCP 不能成为越权旁门**。工具参数里没有身份字段;身份只能来自
// AgentAuth 放进的 context。若某处改成从参数取身份,任何人都能冒充
// 别的 Agent —— 这正是 2026-10-02 修掉的 `AgentMayReadSession`
// `if scope == nil { return true }` 那种失败模式(默认放行)。
// 2. **参数映射不许偷偷放宽/收紧**。例如 send_mail 的 `attachment_ids`
// 若被吞掉,附件会静默不随信发出;workspace 若被丢掉,GetInbox 会 400。
// 3. **协议语义不许退化**。工具失败必须回 result+isError 而不是
// JSON-RPC error(否则模型只看到「协议错误」,拿不到原因就没法改道)。
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/agentmail/gateway/internal/db"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/repo"
)
func setupDB(t *testing.T) {
t.Helper()
dir := t.TempDir()
if err := db.Connect(context.Background(), filepath.Join(dir, "mcp-test.db")); err != nil {
t.Fatalf("connect: %v", err)
}
if err := db.Migrate(context.Background()); err != nil {
t.Fatalf("migrate: %v", err)
}
t.Cleanup(db.Close)
}
// newTestServer 起一个挂了全部工具的端点。
func newTestServer() *Server {
s := NewServer(log.New(os.Stderr, "", 0))
NewTools().RegisterAll(s)
return s
}
// call 走完整 HTTP 路径调一个工具(这样才是端到端,不是直接调 Run)。
func call(t *testing.T, s *Server, agent, tool string, args map[string]any) (int, *rpcMessage) {
t.Helper()
if args == nil {
args = map[string]any{}
}
payload, err := json.Marshal(map[string]any{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": map[string]any{"name": tool, "arguments": args},
})
if err != nil {
t.Fatalf("marshal: %v", err)
}
r := httptest.NewRequest(http.MethodPost, "/api/v1/mcp", strings.NewReader(string(payload)))
if agent != "" {
r = r.WithContext(context.WithValue(r.Context(), middleware.AgentNameKey, agent))
}
rec := httptest.NewRecorder()
s.HandleHTTP(rec, r)
var msg rpcMessage
_ = json.Unmarshal(rec.Body.Bytes(), &msg)
return rec.Code, &msg
}
// toolText 取出 tools/call 的文本内容。
func toolText(t *testing.T, msg *rpcMessage) (string, bool) {
t.Helper()
raw, err := json.Marshal(msg.Result)
if err != nil {
t.Fatalf("marshal result: %v", err)
}
var tr toolResult
if err := json.Unmarshal(raw, &tr); err != nil {
t.Fatalf("result 不是 toolResult:%v", err)
}
if len(tr.Content) == 0 {
return "", tr.IsError
}
return tr.Content[0].Text, tr.IsError
}
// seedAgentAndMail 建一个 Agent 与一封寄给它的邮件。
func seedAgentAndMail(t *testing.T, agent, workspace, subject string) (string, string) {
t.Helper()
ctx := context.Background()
if _, err := db.DB.ExecContext(ctx,
`INSERT INTO agents (agent_name, secret, platform, default_rounds) VALUES ($1, 'x', 'test', 50)`,
agent); err != nil {
t.Fatalf("seed agent: %v", err)
}
fromName, alias := "gui-lab", "s1"
mailID := repoInsertMail(t, ctx, agent, fromName, workspace, alias, subject)
var sessionID string
if err := db.DB.QueryRowContext(ctx,
`SELECT session_id FROM mails WHERE mail_id=$1`, mailID).Scan(&sessionID); err != nil {
t.Fatalf("取 session_id: %v", err)
}
_ = fromName
return mailID, fromName
}
// sessionOf 取一封信所属的会话。
func sessionOf(t *testing.T, mailID string) string {
t.Helper()
var id string
if err := db.DB.QueryRowContext(context.Background(),
`SELECT session_id FROM mails WHERE mail_id=$1`, mailID).Scan(&id); err != nil {
t.Fatalf("sessionOf: %v", err)
}
return id
}
// ---- 协议层 ----
func TestInitializeEchoesClientVersion(t *testing.T) {
s := newTestServer()
payload := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}`
r := httptest.NewRequest(http.MethodPost, "/api/v1/mcp", strings.NewReader(payload))
rec := httptest.NewRecorder()
s.HandleHTTP(rec, r)
var msg rpcMessage
_ = json.Unmarshal(rec.Body.Bytes(), &msg)
if msg.Error != nil {
t.Fatalf("initialize 不该报错:%v", msg.Error)
}
res := msg.Result.(map[string]any)
if got := res["protocolVersion"]; got != "2025-06-18" {
t.Errorf("协议版本应回显客户端给的 2025-06-18,收到 %v", got)
}
// 反向对照:缺版本时用回退值,不崩
payload = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`
rec = httptest.NewRecorder()
s.HandleHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/mcp", strings.NewReader(payload)))
_ = json.Unmarshal(rec.Body.Bytes(), &msg)
if msg.Result.(map[string]any)["protocolVersion"] == nil {
t.Error("缺 protocolVersion 时应回退一个值,而不是省略该字段")
}
}
func TestNotificationsGetNoResponse(t *testing.T) {
s := newTestServer()
// 没有 id = 通知。回了响应,客户端会把响应与请求错配。
for _, m := range []string{
`{"jsonrpc":"2.0","method":"notifications/initialized"}`,
`{"jsonrpc":"2.0","method":"tools/list"}`,
`{"jsonrpc":"2.0","method":"ping"}`,
} {
rec := httptest.NewRecorder()
s.HandleHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/mcp", strings.NewReader(m)))
if rec.Code != http.StatusAccepted {
t.Errorf("通知 %s:期望 202,收到 %d(并带响应体 %q)", m, rec.Code, rec.Body.String())
}
}
}
func TestToolsListIsStableAndCarriesAnnotations(t *testing.T) {
s := newTestServer()
var names []string
for i := 0; i < 5; i++ {
rec := httptest.NewRecorder()
s.HandleHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/mcp",
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)))
var msg rpcMessage
_ = json.Unmarshal(rec.Body.Bytes(), &msg)
list := msg.Result.(map[string]any)["tools"].([]any)
names = names[:0]
for _, it := range list {
names = append(names, it.(map[string]any)["name"].(string))
}
if i == 0 {
// annotations 必须透传:宿主据此算风险等级并在 plan 档放行。
// 漏传的后果不是「少个提示」,而是工具在该档下全被拒。
found := false
for _, it := range list {
m := it.(map[string]any)
if m["name"] == "read_inbox" {
if _, ok := m["annotations"]; !ok {
t.Error("read_inbox 缺 annotations")
}
found = true
}
}
if !found {
t.Error("tools/list 里没有 read_inbox")
}
}
}
// 顺序必须稳定:map 迭代随机会让客户端每次刷新看到不同排列
if len(names) == 0 {
t.Fatal("tools/list 为空")
}
for j := 1; j < len(names); j++ {
if names[j] < names[j-1] {
t.Fatalf("工具顺序不稳定:%v", names)
}
}
}
func TestUnknownToolIsInvalidParamsNotExecutionFailure(t *testing.T) {
s := newTestServer()
_, msg := call(t, s, "a", "no_such_tool", nil)
if msg.Error == nil {
t.Fatal("未知工具名应回 JSON-RPC error")
}
if msg.Error.Code != rpcInvalidParams {
t.Errorf("期望 INVALID_PARAMS(%d),收到 %d", rpcInvalidParams, msg.Error.Code)
}
// 错误文案要告诉模型可用集合,否则它只能瞎试
if !strings.Contains(msg.Error.Message, "read_inbox") {
t.Errorf("未知工具的错误应列出可用工具,收到 %q", msg.Error.Message)
}
}
func TestToolFailureIsResultIsErrorNotRPCError(t *testing.T) {
setupDB(t)
s := newTestServer()
// 不存在的 Agent + 非法 workspace ⇒ handler 报错。
// 关键不是失败本身,而是失败**怎么**回。
_, msg := call(t, s, "ghost", "read_inbox", map[string]any{"workspace": "not-absolute"})
if msg.Error != nil {
t.Fatalf("工具失败不该回 JSON-RPC error(模型就看不到原因了),收到 %v", msg.Error)
}
text, isErr := toolText(t, msg)
if !isErr {
t.Error("失败时必须带 isError:true")
}
if text == "" {
t.Error("失败时必须给出原因文本")
}
}
func TestMethodNotAllowedOnNonPost(t *testing.T) {
s := newTestServer()
rec := httptest.NewRecorder()
s.HandleHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/mcp", nil))
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("GET /mcp 应 405,收到 %d", rec.Code)
}
}
func TestMalformedJSONGetsParseError(t *testing.T) {
s := newTestServer()
rec := httptest.NewRecorder()
s.HandleHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/mcp", strings.NewReader(`{oops`)))
if !strings.Contains(rec.Body.String(), `"id":null`) {
t.Errorf("解析错误响应必须带 id:null,收到 %q", rec.Body.String())
}
if !strings.Contains(rec.Body.String(), fmt.Sprintf("%d", rpcParse)) {
t.Errorf("非法 JSON 应回 PARSE_ERROR(%d),收到 %q", rpcParse, rec.Body.String())
}
}
// ---- 工具语义与 HTTP 端点同源 ----
func TestReadInboxRequiresWorkspaceSameAsHTTP(t *testing.T) {
setupDB(t)
seedAgentAndMail(t, "probe", "/tmp/ws", "一封信")
s := newTestServer()
// 不带 workspace ⇒ 400(这是 GetInbox 的既有语义,包装后必须一致)
_, msg := call(t, s, "probe", "read_inbox", map[string]any{})
if msg.Error != nil {
t.Fatalf("工具失败不该是协议错误:%v", msg.Error)
}
text, isErr := toolText(t, msg)
if !isErr {
t.Errorf("缺 workspace 时 read_inbox 必须失败,收到 %q", text)
}
if !strings.Contains(text, "workspace") {
t.Errorf("错误文案应说清是 workspace 的问题,收到 %q", text)
}
}
func TestReadInboxScopedToOwnWorkspace(t *testing.T) {
setupDB(t)
ctx := context.Background()
// 同一个 Agent 在两个工作区各有信;按工作区收窄后只能看到自己那个的
seedAgentAndMail(t, "probe", "/tmp/wsA", "A 区的信")
repoInsertMail(t, ctx, "probe", "gui-lab", "/tmp/wsB", "sB", "B 区的信")
s := newTestServer()
_, msg := call(t, s, "probe", "read_inbox", map[string]any{"workspace": "/tmp/wsA"})
text, isErr := toolText(t, msg)
if isErr {
t.Fatalf("read_inbox 失败:%s", text)
}
if !strings.Contains(text, "A 区的信") {
t.Errorf("应看到 A 区的信,收到 %q", text)
}
if strings.Contains(text, "B 区的信") {
t.Errorf("★ 收窄失效:看到了别的工作区的信(B 区的信),输出 %q", text)
}
}
func TestAgentCannotReadOthersMail(t *testing.T) {
setupDB(t)
ctx := context.Background()
// 别人的信
otherMailID, _ := seedAgentAndMail(t, "victim", "/tmp/ws", "别人的信")
s := newTestServer()
// 攻击者身份,读别人的信
_, msg := call(t, s, "attacker", "read_mail", map[string]any{"mail_id": otherMailID})
text, isErr := toolText(t, msg)
if !isErr {
t.Errorf("★ 越权:attacker 读到了 victim 的邮件,输出 %q", text)
}
if strings.Contains(text, "别人的信") {
t.Errorf("★ 越权:泄露了别人的正文 %q", text)
}
_ = ctx
}
func TestToolArgsCannotForgeIdentity(t *testing.T) {
setupDB(t)
// 传 identity 相关的参数不应影响权限判定
s := newTestServer()
_, msg := call(t, s, "attacker", "read_inbox", map[string]any{
"workspace": "/tmp/ws",
"agent_name": "victim",
"X-Agent-Name": "victim",
"from": "victim",
})
// 受害者不在这个工作区/或不存在时应当看到「空」而不是别人的信
text, _ := toolText(t, msg)
if strings.Contains(text, "别人的信") {
t.Errorf("参数里的身份字段不该影响鉴权,收到 %q", text)
}
}
// ---- 参数映射 ----
func TestAttachmentIDsSurviveMapping(t *testing.T) {
setupDB(t)
_, _ = seedAgentAndMail(t, "probe", "/tmp/ws", "占位")
s := newTestServer()
// 用受配额的发信路径发一封带附件的信(附件 id 传下去)
_, msg := call(t, s, "probe", "send_mail", map[string]any{
"to": "gui-lab@/tmp.s1",
"subject": "带附件",
"body": "见附件",
"attachment_ids": []any{"att-does-not-exist"},
})
text, isErr := toolText(t, msg)
if !isErr {
t.Fatalf("传了不存在的附件 id,send_mail 理应失败,收到 %q", text)
}
// 附件 id 真被记到那封信上
// 发信时被拒的 attachment_id 必须在错误里出现(说明映射到了、校验才起效)。
// 换一个不存在的 id 会被 SendMail 拒绝,而拒绝原因正是参数映射是否生效的直接证据。
if !strings.Contains(text, "attachment") {
t.Errorf("★ attachment_ids 似乎没被映射过去(错误里没提附件):%s", text)
}
}
func TestMissingArgumentsTreatedAsEmpty(t *testing.T) {
setupDB(t)
seedAgentAndMail(t, "probe", "/tmp/ws", "信")
s := newTestServer()
// suggest_address 无参 ⇒ 返回候选名,不该崩
_, msg := call(t, s, "probe", "suggest_address", nil)
if msg.Error != nil {
t.Fatalf("无参 suggest_address 不该是协议错误:%v", msg.Error)
}
_, isErr := toolText(t, msg)
if isErr {
t.Errorf("无参 suggest_address 不该失败")
}
}
func TestToolCountAndNoExecutionTools(t *testing.T) {
s := newTestServer()
// 11 个邮件工具,不含任何"会动机器"的工具
if got := s.ToolCount(); got != 11 {
t.Errorf("工具数应为 11(只读+收发邮件),收到 %d", got)
}
for _, name := range []string{"run_command", "write_file", "exec", "shell"} {
if _, ok := s.tools[name]; ok {
t.Errorf("★ 通用 MCP 面不得提供执行类工具 %q", name)
}
}
}
// ---- 辅助 ----
func repoInsertMail(t *testing.T, ctx context.Context, to, from, workspace, alias, subject string) string {
t.Helper()
sessionID := repoEnsureSession(t, ctx, to, from, workspace, alias)
var mailID string
err := db.DB.QueryRowContext(ctx,
`INSERT INTO mails (session_id, from_name, to_name, subject, body, status)
VALUES ($1,$2,$3,$4,'正文',$5) RETURNING mail_id`,
sessionID, from, to, subject, "unread").Scan(&mailID)
if err != nil {
t.Fatalf("insert mail: %v", err)
}
return mailID
}
func repoEnsureSession(t *testing.T, ctx context.Context, to, from, workspace, alias string) string {
t.Helper()
t.Helper()
var sessionID string
// 收窄走 s.workspace(见 repo.workspaceScope),所以工作区建会话时就得给对
err := db.DB.QueryRowContext(ctx,
`INSERT INTO sessions (session_alias, subject, status, workspace, from_agent) VALUES ($1,'t','active',$2,$3)
RETURNING session_id`, alias, workspace, to).Scan(&sessionID)
if err != nil {
t.Fatalf("insert session: %v", err)
}
_ = from
return sessionID
}
// repoInsertAttachment 造一个附件。upload_attachment 走 blob 存储,
// 这里只需要一个**已存在**的 attachment_id 来验证参数映射。
func repoInsertAttachment(t *testing.T, mailID string) string {
t.Helper()
var id string
err := db.DB.QueryRowContext(context.Background(),
`INSERT INTO attachments (mail_id, uploader, filename, content_type, size_bytes, sha256)
VALUES ($1,'probe','a.txt','text/plain',12,'deadbeef') RETURNING attachment_id`, mailID).Scan(&id)
if err != nil {
t.Fatalf("insert attachment: %v", err)
}
return id
}
// repoAttachmentIDsOfMail 取一封信挂着的附件 id(attachments.mail_id)。
func repoAttachmentIDsOfMail(t *testing.T, mailID string) string {
t.Helper()
var ids string
err := db.DB.QueryRowContext(context.Background(),
`SELECT COALESCE(group_concat(attachment_id),'') FROM attachments WHERE mail_id=$1`, mailID).Scan(&ids)
if err != nil {
return ""
}
return ids
}
var _ = repo.ListInboxScoped // 保持 import(工具链会用到 handler,repo 只在测试里用到)
// TestEveryErrorResponseCarriesID 钉住 JSON-RPC 的硬要求:**每个**响应都要带 id。
//
// 为什么单独写一格:解析错误那格只覆盖了「解析失败」一条路径。曾用
// `ID json.RawMessage` + `omitempty`,nil 时整个 id 字段会从 JSON 里消失 ——
// 客户端拿不到配对依据,会一直等这条的响应。这格遍历所有错误出口,
// 任何一条漏掉 id 都会红。
func TestEveryErrorResponseCarriesID(t *testing.T) {
s := newTestServer()
cases := []struct {
name string
body string
want string // 期望的 id(JSON 字面量)
}{
{"解析失败", `{oops`, "null"},
{"jsonrpc 版本不对", `{"jsonrpc":"1.0","id":7,"method":"ping"}`, "7"},
{"方法不存在", `{"jsonrpc":"2.0","id":7,"method":"no/such"}`, "7"},
{"工具名未知", `{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"nope"}}`, "7"},
{"工具名缺失", `{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{}}`, "7"},
}
for _, c := range cases {
rec := httptest.NewRecorder()
s.HandleHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/mcp", strings.NewReader(c.body)))
out := rec.Body.String()
// 直接查 JSON 里有没有 id 键(而不是靠字符串匹配,那样会漏掉
// `"id":null` 这种情形)
var decoded map[string]json.RawMessage
if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil {
t.Errorf("%s:响应不是合法 JSON:%v(%q)", c.name, err, out)
continue
}
rawID, ok := decoded["id"]
if !ok {
t.Errorf("★ %s:响应缺少 id 字段 ⇒ 客户端无法配对,会一直等(收到 %q)", c.name, out)
continue
}
if string(rawID) != c.want {
t.Errorf("%s:id 应为 %s,收到 %s", c.name, c.want, string(rawID))
}
}
}
// TestPathParamsReachHandlers 钉住「包装 handler 时路径参数必须到位」。
//
// ★ 这格是端到端撞出来的:主人读自己的信与越权者读别人的信**都**返回
// 「Invalid id」。只看越权那一次会误判成「收得太紧」,进而把正确的收窄改松;
// 做对照才看出是 `chi.URLParam` 拿不到值(httptest 请求没过 chi 的路由)。
//
// 判据用「主人能读自己的信」当锚:它同时证明参数到位**且**收窄没有误伤。
func TestPathParamsReachHandlers(t *testing.T) {
setupDB(t)
mailID, _ := seedAgentAndMail(t, "owner", "/tmp/ws", "主人自己的信")
sessionID := sessionOf(t, mailID)
s := newTestServer()
// agentScope 要求 session_id 与信所属会话一致(这是**正确**的收窄,
// 不是路径参数问题)—— 所以这里带上它。
args := map[string]any{"mail_id": mailID, "session_id": sessionID}
text, isErr := toolText(t, callMsg(t, s, "owner", "read_mail", args))
if isErr {
t.Fatalf("★ 主人读自己的信应该成功,却失败了(路径参数没到位?):%s", text)
}
if !strings.Contains(text, "主人自己的信") {
t.Errorf("应读到自己的信,收到 %q", text)
}
// read_thread 同样带路径参数,一并钉住
text, isErr = toolText(t, callMsg(t, s, "owner", "read_thread", args))
if isErr {
t.Errorf("★ read_thread 也应成功(同一类路径参数):%s", text)
}
}
// callMsg 是 call 的单值版本(拿消息)。
func callMsg(t *testing.T, s *Server, agent, tool string, args map[string]any) *rpcMessage {
t.Helper()
_, msg := call(t, s, agent, tool, args)
return msg
}