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 待单独核。
This commit is contained in:
2026-10-02 01:09:19 +08:00
parent 15e4fe9203
commit de6b91516a
6 changed files with 542 additions and 14 deletions

View File

@ -0,0 +1,201 @@
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())
}
}

View File

@ -112,6 +112,80 @@ func canReadSession(w http.ResponseWriter, r *http.Request, agentName string, sc
return false
}
// GET /api/v1/agent/session/default —— 问出「我的默认会话是哪条」
//
// ★ 2026-10-02 新增。它是 `AgentMayReadSession` 收严后的**配套出口**:
// 未声明 session_id 的读信请求现在一律 403,而桥在**非邮件驱动轮次**
// (对话/自主调用)手里确实没有任何会话可声明 —— `session_id` 是 AgentMail
// 会话的 UUID,进程 cwd 给不出它(这点与 workspace 不同,workspace 能回落到 cwd)。
//
// 所以这里把**已有的**默认会话语义开放成一个可查询入口:
// `name@path` 省略 session 位时投递到的那条会话(`repo.FindOrCreateDefaultSession`)。
// 那个函数早就在(8 个测试覆盖它),只是没有「只查询」的 HTTP 形状。
//
// # 为什么默认工作区是 /tmp
//
// 非邮件轮次没有真实工作目录可依(桥进程 cwd 是插件目录,不是用户项目)。
// /tmp 是**明确的中性落点**:它不属于任何一个真实项目,
// 所以落进去的会话不会在任何人的工作区里混进项目邮件,
// 而 `UnreadWorkspaces` 要求 workspace 形如 `/%`(要能被寻址补投),/tmp 满足。
//
// # 安全边界:这个端点**不是**绕过 session 闸的万能钥匙
//
// 它只回答「你自己 `name@/tmp` 的默认会话是哪条」,不授予读任何会话的能力。
// 拿到 UUID 后仍要过 `AgentMayReadSession`:读别的会话照样 403。
// 换句话说:它把「非法」变成「合法但窄」—— 默认会话只装发给**你自己**的信。
func AgentDefaultSession(w http.ResponseWriter, r *http.Request) {
agentName := middleware.GetAgentName(r)
if agentName == "" {
Error(w, http.StatusUnauthorized, "Unauthorized")
return
}
ws := strings.TrimSpace(r.URL.Query().Get("workspace"))
if ws == "" {
ws = DefaultFallbackWorkspace
}
if !strings.HasPrefix(ws, "/") {
Error(w, http.StatusBadRequest, "workspace 必须是绝对路径,收到: "+ws)
return
}
// 纯只读:没有就返回空,**不建会话**。
//
// 我第一版直接调 FindOrCreateDefaultSessionCreated,判据当场报它**每次都新建**
//(连着两次问拿到两个不同 UUID)。改「先查后建」还不够 —— 已建的空会话仍不满足
// 那个函数的复用条件(`EXISTS (SELECT 1 FROM mails …)`),于是下一次依然「没找到」。
//
// 而想深一层:**根本不该建**。非邮件轮次读信时,若 `name@/tmp` 一封都没通过,
// 那个收件箱本来就应该是空的 —— 不需要一条会话 id 才能表达「空」。
// 要让默认会话真的存在,只需要**往 /tmp 发一封信**,那是发信路径的事。
//
// GET 端点有副作用本身就是坏味道:它会被桥每轮调一次,
// 预建会话等于让「问一次」在会话列表里留一条垃圾。
id, err := repo.FindExistingDefaultSession(r.Context(), agentName, ws)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to resolve default session")
return
}
// 没找到时返回 session_id=null(而不是 uuid.Nil 字符串):
// 桥看到 null 就知道「默认会话还不存在,收件箱按空处理」,
// 而不是拿着一个不存在的 id 去声明、然后拿到一个莫名其妙的 403。
var sid any
self := ""
if id != uuid.Nil {
sid = id
self = models.FormatAddress(agentName, ws, repo.SessionAliasOf(r.Context(), id))
}
JSON(w, http.StatusOK, map[string]any{
"session_id": sid,
"workspace": ws,
"exists": id != uuid.Nil,
"self_address": self,
})
}
// DefaultFallbackWorkspace 是非邮件轮次的默认落点(理由见 AgentDefaultSession)。
const DefaultFallbackWorkspace = "/tmp"
// GET /api/v1/agent/contacts
//
// 本 Agent 参与过的会话,每条给出可直接投递的 `address`。

View File

@ -1367,16 +1367,14 @@ func FindOrCreateDefaultSession(ctx context.Context, name, path, fromAgent, subj
return id, err
}
// FindOrCreateDefaultSessionCreated 与 FindOrCreateDefaultSession 相同,但额外返回
// **这次调用是否真的新建了会话**。
// defaultSessionMatchSQL 是「默认会话」的**唯一**匹配条件。
//
// 为什么需要这个返回值:调用方此前用 `parentMailID == nil` 判断「是不是新建会话」,
// 而复用已有默认会话时 parentMailID 也是 nil —— 于是「仅在新建时生效」的字段
// (往返预算、权限档位)在每一封省略 session 位的信上都被重写了。
// 实测:第一封 max_rounds=7 → 第二封省略该字段 → 预算被静默改成默认的 20。
func FindOrCreateDefaultSessionCreated(ctx context.Context, name, path, fromAgent, subject string) (uuid.UUID, bool, error) {
var id uuid.UUID
err := db.DB.QueryRowContext(ctx, `
// ★ 抽出来的理由(不是洁癖):`FindOrCreateDefaultSessionCreated` 与
// `FindExistingDefaultSession` 必须给出**同一个**答案,否则会出现
// 「我查到的默认会话不是我发信会落进去的那条」—— 而调用方会把前者
// 当成后者去声明,于是每一次发信/读信都指向不同会话。
// 两处各写一份 SQL 的话,改一边就会静默分叉(无任何东西会红)。
var defaultSessionMatchSQL = `
SELECT s.session_id
FROM sessions s
WHERE s.status <> 'archived'
@ -1387,7 +1385,7 @@ func FindOrCreateDefaultSessionCreated(ctx context.Context, name, path, fromAgen
AND EXISTS (
SELECT 1 FROM mails m
WHERE m.session_id = s.session_id
AND (m.to_name = $1 OR m.from_name = $1 OR `+db.CCHas("m.cc_list", 1)+`)
AND (m.to_name = $1 OR m.from_name = $1 OR ` + db.CCHas("m.cc_list", 1) + `)
)
AND (s.workspace = $2
OR (s.workspace = '' AND EXISTS (
@ -1396,8 +1394,39 @@ func FindOrCreateDefaultSessionCreated(ctx context.Context, name, path, fromAgen
AND COALESCE(w.to_workspace,'') = $2
)))
ORDER BY s.updated_at DESC
LIMIT 1
`, name, path).Scan(&id)
LIMIT 1`
// FindExistingDefaultSession 只**查**不建:命中就返回那个 id,没命中返回 uuid.Nil。
//
// 为什么要与 FindOrCreate 并存(2026-10-02):后者在没命中时会**建一条**,
// 而它的复用条件里有 `EXISTS (… FROM mails …)` —— 空会话不满足。
// 于是「先查 id 再用」的调用方(先于任何投递发生)每轮都会新建一条。
// 这就是 `GET /agent/session/default` 需要它的地方:那里只该**读**,
// 建会话是发信时才发生的事。
//
// 不调 TouchSession:查询不是活动,刷新 updated_at 会把陈旧会话排到前面
// (那条 ORDER BY s.updated_at DESC 的语义是「最近活跃的优先」)。
func FindExistingDefaultSession(ctx context.Context, name, path string) (uuid.UUID, error) {
var id uuid.UUID
err := db.DB.QueryRowContext(ctx, defaultSessionMatchSQL, name, path).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return uuid.Nil, nil
}
return id, err
}
// FindOrCreateDefaultSessionCreated 与 FindOrCreateDefaultSession 相同,但额外返回
// **这次调用是否真的新建了会话**。
//
// 为什么需要这个返回值:调用方此前用 `parentMailID == nil` 判断「是不是新建会话」,
// 而复用已有默认会话时 parentMailID 也是 nil —— 于是「仅在新建时生效」的字段
// (往返预算、权限档位)在每一封省略 session 位的信上都被重写了。
// 实测:第一封 max_rounds=7 → 第二封省略该字段 → 预算被静默改成默认的 20。
func FindOrCreateDefaultSessionCreated(ctx context.Context, name, path, fromAgent, subject string) (uuid.UUID, bool, error) {
var id uuid.UUID
// 匹配条件用共享常量(见 defaultSessionMatchSQL 的注释:两处必须同源,
// 否则「查到的默认会话」会与「发信落进去的会话」分叉)。
err := db.DB.QueryRowContext(ctx, defaultSessionMatchSQL, name, path).Scan(&id)
if err == nil {
TouchSession(ctx, id)
return id, false, nil