Files
MailUI4Agents/server/internal/handler/agent_default_session_test.go
JianFeeeee de6b91516a feat(默认会话): 非邮件轮次用 /tmp 默认会话作合法 session_id —— 配套 15e4fe9 的收严
`15e4fe9` 让未声明 session_id 的读信一律 403,而 homeagent 的工具**全局可调** ⇒
对话里自主调 read_mail/read_thread 时 `currentSessionID` 为空 ⇒ 403。
不能因此让「非邮件轮次读信」这个能力消失(它是 10-01 那个 read_inbox 修复的
用户可见部分),所以给它一个合法声明。

## 关键约束:workspace 能回落 cwd,session_id 不能

`session_id` 是 AgentMail 会话的 UUID,进程 cwd 给不出它 ⇒ 只能问服务端。
落点选 `/tmp`:非邮件轮次没有真实工作目录,而 /tmp 是中性落点(不属于任何真实
项目,不会把项目邮件混进来),且满足 `UnreadWorkspaces` 的 `workspace LIKE '/%'`
(能被寻址补投)。

## 服务端:`GET /api/v1/agent/session/default`

**复用**已有的默认会话语义(`FindOrCreateDefaultSession`,8 个测试覆盖),
只把它开放成可查询形状 —— 不新造概念。

★ 第一版调 `FindOrCreateDefaultSessionCreated`,判据当场报**每次都新建**
(连问两次得到两个不同 UUID)。根因:那个函数的复用条件含
`EXISTS (SELECT 1 FROM mails …)`,空会话不满足 ⇒ 永远「没找到可复用」。
改「先查后建」仍不够。想深一层:**根本不该建** —— 非邮件轮次若 `name@/tmp`
一封都没通过,收件箱本来就该是空的,不需要一条 id 才能表达「空」。
⇒ 改成**纯只读**:没通信过就返回 `session_id: null`。
GET 有副作用是坏味道,它会被桥每轮调一次。

同时把匹配 SQL 抽成 `defaultSessionMatchSQL` 共享常量:`FindExisting` 与
`FindOrCreate` 必须给出**同一个**答案,否则「查到的默认会话」与「发信落进去的
会话」会静默分叉(各写一份 SQL 的话,改一边不会红)。

## 桥(homeagent):effectiveSessionID = 信封 → 默认会话

⚠ 取值函数**不发请求**。我第一版把 HTTP 塞进 `effectiveSessionID`,
`&Plugin{}` 构造的测试当场 nil panic,且 scopeQuery 变成「拼 URL 时顺带发请求」。
IO 移到装配期 `register()` 里的 `ensureDefaultSession()`。

⚠ `client == nil` 时**不标记已问** —— 那不是「答案是空」而是「还没资格问」,
标了会永久缓存空值。而 register() 里就会调它,真的会在插件加载阶段崩。

## 判据

服务端 6 格(含★「不是万能钥匙」:拿默认会话 id 去读别人的会话仍须 403 ——
少了这格,这个端点就是「声明一个合法会话然后读遍全场」的后门)。
homeagent 6 格。
三个变异各红 1 格:退回旧的整体放弃 / 未就绪也标记 / 默认落点与服务端不一致。

## 未改:pi / dsh / opencode

实测它们的裸奔已停止(pi 自 Sep 26、opencode 自 Sep 28,`[agent-scope]` 日志归零),
`getMailSessionId` 由 worker 闭包注入且只有一处装配点。dsh 待单独核。
2026-10-02 01:09:19 +08:00

202 lines
7.4 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 handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/agentmail/gateway/internal/db"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/repo"
"github.com/google/uuid"
)
/*
`GET /api/v1/agent/session/default` —— 非邮件轮次的合法 session_id 出口(2026-10-02)。
# 为什么需要它
`AgentMayReadSession` 收严后,未声明 session_id 的读信一律 403。而桥在
**非邮件驱动轮次**(对话里自主调 read_mail / read_thread)手里没有任何会话可声明 ——
`session_id` 是 AgentMail 会话的 UUID,进程 cwd 给不出它
(这与 workspace 不同:workspace 能回落到 cwd,那条我今天已修过一次)。
⇒ 没有这个出口,`15e4fe9` 上线后 homeagent 在对话里就再也读不到信了。
# 三条语义(本组判据钉的就是它们)
① **纯只读**:没通信过就返回 `session_id: null`,**绝不建会话**。
GET 有副作用是坏味道 —— 它会被桥每轮调一次,预建会话等于每轮留一条垃圾。
(我第一版调 `FindOrCreateDefaultSessionCreated`,判据当场抓到「每次都新建」。)
② **不是万能钥匙**:它只回答「你自己 `name@/tmp` 的默认会话是哪条」,
不授予读任何会话的能力。拿到 id 后读别的会话照样 403。
③ 默认落点是 `/tmp`:非邮件轮次没有真实工作目录可依,而 `UnreadWorkspaces`
要求 workspace 形如 `/%`(要能被寻址补投),/tmp 满足。
*/
func defaultSessionGET(t *testing.T, agent, query string) *httptest.ResponseRecorder {
t.Helper()
if err := repo.CreateOrUpdateAgent(context.Background(), agent, "k-"+agent, "test", nil); err != nil {
t.Fatalf("建 agent %s: %v", agent, err)
}
req := httptest.NewRequest("GET", "/api/v1/agent/session/default"+query, nil)
req = req.WithContext(context.WithValue(context.Background(), middleware.AgentNameKey, agent))
rr := httptest.NewRecorder()
AgentDefaultSession(rr, req)
return rr
}
// seedDefaultSession 在 ws 造一条该 agent 真实通信过的会话(满足 EXISTS mails 条件)。
func seedDefaultSession(t *testing.T, agent, ws string) uuid.UUID {
t.Helper()
ctx := context.Background()
sid, err := repo.CreateSession(ctx, nil, agent, "默认会话", ws)
if err != nil {
t.Fatal(err)
}
if _, err := repo.CreateMail(ctx, sid, nil, agent, "", agent, "", "自寄", "内容", nil); err != nil {
t.Fatal(err)
}
return sid
}
type defaultSessionResp struct {
SessionID any `json:"session_id"`
Workspace string `json:"workspace"`
Exists bool `json:"exists"`
SelfAddress string `json:"self_address"`
}
// ① 从未通信过 ⇒ exists=false / session_id=null,且**不新建**。
func TestDefaultSessionPureQueryWhenAbsent(t *testing.T) {
setupPermissionHandlerDB(t)
rr := defaultSessionGET(t, "probeagent", "")
if rr.Code != http.StatusOK {
t.Fatalf("应 200,实际 %d:%s", rr.Code, rr.Body.String())
}
var out defaultSessionResp
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if out.Exists || out.SessionID != nil {
t.Fatalf("★ 从未通信过就不该凭空造会话:exists=%v id=%v", out.Exists, out.SessionID)
}
if out.Workspace != DefaultFallbackWorkspace {
t.Fatalf("默认落点应是 %s,实际 %q", DefaultFallbackWorkspace, out.Workspace)
}
}
// ② 通信过 ⇒ 问出那条会话,且带可用的 self_address(桥自己拼容易 404)。
func TestDefaultSessionReturnsExisting(t *testing.T) {
setupPermissionHandlerDB(t)
want := seedDefaultSession(t, "probeagent", DefaultFallbackWorkspace)
rr := defaultSessionGET(t, "probeagent", "")
var out defaultSessionResp
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if !out.Exists {
t.Fatal("存在默认会话时 exists 应为 true")
}
if got, _ := out.SessionID.(string); got != want.String() {
t.Fatalf("应问出 %s,实际 %v", want, out.SessionID)
}
if out.SelfAddress == "" {
t.Fatal("应给出 self_address(桥自己拼容易错,而 404 会被误读成「那封信不存在」)")
}
}
// ★③ 不是万能钥匙:拿默认会话的 id 去读**别人的**会话仍须 403。
// 少了这一格,这个端点就是「声明一个合法会话然后读遍全场」的后门。
func TestDefaultSessionIsNotUniversalKey(t *testing.T) {
setupPermissionHandlerDB(t)
ctx := context.Background()
other, err := repo.CreateSession(ctx, nil, "someoneelse", "别人的线索", "/home/other")
if err != nil {
t.Fatal(err)
}
mailID, err := repo.CreateMail(ctx, other, nil, "someoneelse", "", "anotheragent", "",
"别人的信", "机密正文", nil)
if err != nil {
t.Fatal(err)
}
seedDefaultSession(t, "probeagent", DefaultFallbackWorkspace)
rr := defaultSessionGET(t, "probeagent", "")
var out defaultSessionResp
json.Unmarshal(rr.Body.Bytes(), &out)
scope, _ := out.SessionID.(string)
req := httptest.NewRequest("GET", "/api/v1/agent/mail/"+mailID.String(), nil)
req = req.WithContext(context.WithValue(context.Background(), middleware.AgentNameKey, "probeagent"))
rr2 := httptest.NewRecorder()
id := uuid.MustParse(scope)
canReadSession(rr2, req, "probeagent", &id, other)
if rr2.Code != http.StatusForbidden {
t.Fatalf("★ 默认会话 id 不得成为万能钥匙:读别人的信应 403,实际 %d %s",
rr2.Code, rr2.Body.String())
}
}
// ④ 连问 3 次不新增会话(我第一版每次都建一条,这条判据当场抓到)。
func TestDefaultSessionQueryHasNoSideEffect(t *testing.T) {
setupPermissionHandlerDB(t)
ctx := context.Background()
seedDefaultSession(t, "probeagent", DefaultFallbackWorkspace)
var before int
db.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM sessions`).Scan(&before)
var first string
for i := 0; i < 3; i++ {
rr := defaultSessionGET(t, "probeagent", "")
var out defaultSessionResp
json.Unmarshal(rr.Body.Bytes(), &out)
got, _ := out.SessionID.(string)
if i == 0 {
first = got
} else if got != first {
t.Fatalf("第 %d 次问拿到不同会话 %q(第一次 %q)", i+1, got, first)
}
}
var after int
db.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM sessions`).Scan(&after)
if after != before {
t.Fatalf("★ 连问 3 次会话数 %d → %d(查询必须零副作用)", before, after)
}
}
// ⑤ per-agent:不同 agent 的默认会话互不相同(共享会让各 Agent 的私信箱串一起)。
func TestDefaultSessionIsPerAgent(t *testing.T) {
setupPermissionHandlerDB(t)
seedDefaultSession(t, "agent-a", DefaultFallbackWorkspace)
seedDefaultSession(t, "agent-b", DefaultFallbackWorkspace)
var a, b defaultSessionResp
json.Unmarshal(defaultSessionGET(t, "agent-a", "").Body.Bytes(), &a)
json.Unmarshal(defaultSessionGET(t, "agent-b", "").Body.Bytes(), &b)
sa, _ := a.SessionID.(string)
sb, _ := b.SessionID.(string)
if sa == "" || sb == "" {
t.Fatalf("两侧都该问得出:a=%q b=%q", sa, sb)
}
if sa == sb {
t.Fatal("★ 默认会话必须 per-agent(共享会让各 Agent 的非邮件轮次邮件互相可见)")
}
}
// ⑥ workspace 必须绝对路径(与 checkWorkspace 同一规矩,别开第二个口子)。
func TestDefaultSessionRejectsRelativeWorkspace(t *testing.T) {
setupPermissionHandlerDB(t)
rr := defaultSessionGET(t, "probeagent", "?workspace=../../etc")
if rr.Code != http.StatusBadRequest {
t.Fatalf("相对路径应 400,实际 %d:%s", rr.Code, rr.Body.String())
}
}