`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 待单独核。
202 lines
7.4 KiB
Go
202 lines
7.4 KiB
Go
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())
|
||
}
|
||
}
|