## 为什么要集成而不是独立进程
上一版(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,服务端这份是接入端零安装的那条路。
- 未部署(本提交只含代码)。
542 lines
19 KiB
Go
542 lines
19 KiB
Go
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
|
||
}
|