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:
117
plugins/homeagent-mail-bridge/default_session_test.go
Normal file
117
plugins/homeagent-mail-bridge/default_session_test.go
Normal file
@ -0,0 +1,117 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
/*
|
||||
非邮件轮次必须能声明一个**合法**的 session_id(2026-10-02,B 方案配套)。
|
||||
|
||||
# 背景
|
||||
|
||||
服务端 `AgentMayReadSession` 收严后(`15e4fe9`),未声明 session_id 的读信
|
||||
一律 403。而 homeagent 的工具**全局可调** ⇒ 对话里自主调 read_mail/read_thread 时
|
||||
`currentSessionID` 为空 ⇒ 不带 session_id ⇒ 403。
|
||||
|
||||
「非邮件轮次读信」这个能力不能因此消失(它是 2026-10-01 那个
|
||||
`read_inbox` 修复的用户可见部分),所以要给它一个合法声明。
|
||||
|
||||
# 为什么回落答案必须向服务端问
|
||||
|
||||
`workspace` 能回落到进程 cwd,而 **session_id 是 AgentMail 会话的 UUID,
|
||||
cwd 给不出它**。所以只有一条路:`GET /agent/session/default?workspace=/tmp`
|
||||
(服务端纯只读;没通信过就返回 session_id=null,不建会话)。
|
||||
*/
|
||||
|
||||
// ① 取值函数 effectiveSessionID **不发请求**(纯取值)。
|
||||
//
|
||||
// 我第一版把 HTTP 塞进这里,于是 `&Plugin{}` 构造的测试直接 nil panic
|
||||
// (p.client 为 nil),而且 scopeQuery 变成「拼 URL 时顺带发一次请求」。
|
||||
// 判据钉住这个形状:取值与 IO 混在一起,测试与真实调用都会变得难以推理。
|
||||
func TestEffectiveSessionIDIsPureLookup(t *testing.T) {
|
||||
p := &Plugin{}
|
||||
// 不设置 client —— 若取值函数敢发请求,这里就 panic
|
||||
if got := p.effectiveSessionID(); got != "" {
|
||||
t.Fatalf("未探测且无当前会话时应为空,实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ② 邮件轮次内优先用信封上的 currentSessionID(不回落)。
|
||||
func TestEffectiveSessionIDPrefersEnvelope(t *testing.T) {
|
||||
p := &Plugin{currentSessionID: "env-session"}
|
||||
if got := p.effectiveSessionID(); got != "env-session" {
|
||||
t.Fatalf("轮次内必须用信封上的会话,实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ③ scopeQuery 在非邮件轮次、且已问出默认会话时,**仍要带 session_id**。
|
||||
// 这是本次改动的核心形状:旧写法是 currentSessionID 为空就整体放弃。
|
||||
func TestScopeQueryCarriesDefaultSessionOutsideTurn(t *testing.T) {
|
||||
p := &Plugin{defaultSessionID: "sess-default-123"}
|
||||
q := p.scopeQuery("&")
|
||||
if !strings.Contains(q, "session_id=sess-default-123") {
|
||||
t.Fatalf("★ 非邮件轮次也必须声明合法 session_id(否则服务端 403):%q", q)
|
||||
}
|
||||
}
|
||||
|
||||
// ④ 两样都拿不到时才放弃 —— 且是**明确放弃**(返回空串),
|
||||
// 不是「退化成不带 session_id 的裸奔」。
|
||||
func TestScopeQueryGivesUpOnlyWhenBothAbsent(t *testing.T) {
|
||||
p := &Plugin{} // 既无 currentSessionID,也无 defaultSessionID
|
||||
if q := p.scopeQuery("&"); q != "" {
|
||||
t.Fatalf("两样都没有时整体放弃(服务端会 403,这是可见的错误而非静默放行):%q", q)
|
||||
}
|
||||
}
|
||||
|
||||
// ⑤ client 没就绪时**不该**标记「已问」——那不是「答案是空」,
|
||||
// 而是「还没资格问」。标了就会把空值永久缓存,之后再也不会问。
|
||||
//
|
||||
// 这条是被判据打出来的:我第一版没这道判据,register() 里调它会在
|
||||
// 插件加载阶段 nil panic;而加了判据后若还沿用旧断言,就会把
|
||||
// 「正确地没标记」当成失败 —— 断言本身要跟着实现意图走,不能反过来。
|
||||
func TestEnsureDefaultSessionDoesNotProbeBeforeClientReady(t *testing.T) {
|
||||
p := &Plugin{} // client 未就绪
|
||||
p.ensureDefaultSession()
|
||||
if p.defaultSessionProbed {
|
||||
t.Fatal("★ client 未就绪时不该标记已问(否则永久缓存空值,之后再也不问)")
|
||||
}
|
||||
if p.defaultSessionID != "" {
|
||||
t.Fatalf("更不该有 id,实际 %q", p.defaultSessionID)
|
||||
}
|
||||
}
|
||||
|
||||
// ⑤b 一旦标记过就**不再重复**问(否则每个工具调用都给服务端添压力)。
|
||||
// 这里直接检查缓存标记的粘性(真发 HTTP 的形状由 repo/handler 侧判据覆盖)。
|
||||
func TestDefaultSessionProbeFlagIsSticky(t *testing.T) {
|
||||
p := &Plugin{defaultSessionProbed: true}
|
||||
p.ensureDefaultSession() // 不该再动任何状态
|
||||
if !p.defaultSessionProbed {
|
||||
t.Fatal("已问标记应是幂等的")
|
||||
}
|
||||
if p.defaultSessionID != "" {
|
||||
t.Fatalf("标记已置时不该凭空产生 id,实际 %q", p.defaultSessionID)
|
||||
}
|
||||
}
|
||||
|
||||
// ⑥ 邮件轮次内**不**去问默认会话(那次问是纯浪费,且当前SessionID 已有值)。
|
||||
func TestEnsureDefaultSessionSkipsInsideTurn(t *testing.T) {
|
||||
p := &Plugin{currentSessionID: "env"}
|
||||
p.ensureDefaultSession()
|
||||
if p.defaultSessionProbed {
|
||||
t.Fatal("★ 轮次内不该问默认会话(currentSessionID 已有值)")
|
||||
}
|
||||
}
|
||||
|
||||
// ⑦ 默认落点与服务端一致:/tmp。
|
||||
// 两边不一致 = 桥问的是一个 key、服务端建的是另一个 key ⇒ 永远问不到。
|
||||
func TestDefaultFallbackWorkspaceMatchesServer(t *testing.T) {
|
||||
if defaultFallbackWorkspace != "/tmp" {
|
||||
t.Fatalf("必须与 handler.DefaultFallbackWorkspace 一致,实际 %q", defaultFallbackWorkspace)
|
||||
}
|
||||
if !strings.HasPrefix(defaultFallbackWorkspace, os.Getenv("HOME")) &&
|
||||
!strings.HasPrefix(defaultFallbackWorkspace, "/") {
|
||||
t.Fatal("默认落点必须是绝对路径(UnreadWorkspaces 要求 workspace LIKE '/%')")
|
||||
}
|
||||
}
|
||||
@ -142,6 +142,16 @@ type Plugin struct {
|
||||
// 拿不准时宁可少列 —— 跨工作区误读正是 2026-09-26 那个缺陷本身。
|
||||
currentWorkspace string
|
||||
|
||||
// defaultSessionID 是「非邮件轮次的默认会话」(由 /agent/session/default 问出)。
|
||||
// 默认会话与工作区回落的差别在答案的来源:workspace 能从进程 cwd 得到,
|
||||
// 会话 UUID 不能 —— 那是服务端的 id。所以它必须问,且那个端点是纯只读的
|
||||
// (没通信过就没有 ⇒ 不编 id,见 effectiveSessionID 的注释)。
|
||||
defaultSessionID string
|
||||
|
||||
// defaultSessionProbed 保证「问过了」与「答案是空」这两种情况都只问一次。
|
||||
// 缺了它,每次工具调用都会重问一个答案恒为空的端点。
|
||||
defaultSessionProbed bool
|
||||
|
||||
// 单调递增的 last-seen-ID:被重放的旧事件不会让它回退。
|
||||
// 原来直接赋值(p.lastEventID = eid),Gateway 重放时发旧 ID,
|
||||
// 于是 lastEventID 从 123 退回 116 → 下次重连又报 116 → 又重放。
|
||||
@ -533,6 +543,17 @@ func (p *Plugin) heartbeatLoop() {
|
||||
}
|
||||
|
||||
func (p *Plugin) register() error {
|
||||
// ★ 2026-10-02:装配期先问一次默认会话。
|
||||
//
|
||||
// 非邮件轮次(对话里自主调 read_mail/read_thread)时 currentSessionID 为空,
|
||||
// 而服务端对未声明 session_id 的读信一律 403(AgentMayReadSession 收严后)。
|
||||
// 放在这里而不是取值函数里:那是**取值**,不该有 IO(我第一版塞进
|
||||
// effectiveSessionID,`&Plugin{}` 构造的测试当场 nil panic)。
|
||||
//
|
||||
// 问不到也继续:那是「没有默认会话」的正常形状(那个端点是纯只读的),
|
||||
// 后果只是这次读信拿 403,而不是被静默放行成越权。
|
||||
p.ensureDefaultSession()
|
||||
|
||||
body := map[string]interface{}{
|
||||
"name": p.agentName,
|
||||
"platform": "homeagent",
|
||||
@ -1558,17 +1579,101 @@ func (p *Plugin) markRead(ids []string) {
|
||||
//
|
||||
// 不在回合里时 currentSessionID 为空,返回空串 —— 退回旧行为(服务端会记警告),
|
||||
// 而不是猜一个会话。
|
||||
// defaultFallbackWorkspace 必须与 handler.DefaultFallbackWorkspace 一致
|
||||
// (非邮件轮次没有真实工作目录,而 /tmp 是中性落点且能被寻址补投)。
|
||||
const defaultFallbackWorkspace = "/tmp"
|
||||
|
||||
func (p *Plugin) scopeQuery(sep string) string {
|
||||
if p.currentSessionID == "" {
|
||||
// ★ 2026-10-02:非邮件轮次不再「整体放弃收窄」——
|
||||
//
|
||||
// 旧写法在 currentSessionID 为空时直接返回 "",于是 URL 里既没有 session_id
|
||||
// 也没有 workspace。而服务端 `AgentMayReadSession` 对未声明 session_id 的
|
||||
// 读信请求一律 403(2026-09-15 那个「迁移期放行」已作废,实测曾 20/20 读到别人的信)。
|
||||
//
|
||||
// 现在:session_id 走 effectiveSessionID(信封 → 默认会话),
|
||||
// workspace 走 effectiveWorkspace(信封 → cwd)。两者都拿不到时才放弃。
|
||||
sid := p.effectiveSessionID()
|
||||
if sid == "" {
|
||||
return ""
|
||||
}
|
||||
q := sep + "session_id=" + url.QueryEscape(p.currentSessionID)
|
||||
q := sep + "session_id=" + url.QueryEscape(sid)
|
||||
if ws := p.effectiveWorkspace(); ws != "" {
|
||||
q += "&workspace=" + url.QueryEscape(ws)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
// effectiveSessionID 返回本轮该声明的会话:信封优先,**其次向服务端问默认会话**。
|
||||
//
|
||||
// # 为什么要回落(与 effectiveWorkspace 同一形状,但答案在服务端)
|
||||
//
|
||||
// 工具全局可调 ⇒ 非邮件轮次(对话里自主调 read_mail/read_thread)时
|
||||
// currentSessionID 为空 ⇒ 服务端 403。而 cwd 给不出会话 UUID,
|
||||
// 所以只能问服务端:`GET /agent/session/default?workspace=/tmp`。
|
||||
//
|
||||
// 那个端点是**纯只读**的(没通信过就返回 session_id=null,不建会话)——
|
||||
// 所以这里拿不到是正常形状:默认收件箱本来就空,不该编一个 id 去声明。
|
||||
//
|
||||
// 缓存:问出来的结果按进程缓存。理由是它极少变化(默认会话只在真的往
|
||||
// /tmp 发过信之后才存在),而每个工具调用都问一次会给服务端添无谓压力。
|
||||
// 缓存 null 也要记 —— 否则「还没有默认会话」会被反复重问。
|
||||
// ⚠ 这里**不发请求**:它是取值函数,与 effectiveWorkspace 同形状。
|
||||
// 我第一版把 HTTP 塞进来,于是 `&Plugin{}` 构造的测试直接 nil panic
|
||||
// (p.client 为 nil),而且 scopeQuery 变成「拼 URL 时顺带发一次请求」——
|
||||
// 取值与 IO 混在一起,很难说清什么时候有副作用。
|
||||
//
|
||||
// 所以由调用方在**装配期**调一次 ensureDefaultSession(见下),
|
||||
// 这里只读缓存值。
|
||||
func (p *Plugin) effectiveSessionID() string {
|
||||
if sid := strings.TrimSpace(p.currentSessionID); sid != "" {
|
||||
return sid
|
||||
}
|
||||
return p.defaultSessionID
|
||||
}
|
||||
|
||||
// ensureDefaultSession 在**装配期**问一次「我的默认会话是哪条」。
|
||||
//
|
||||
// 只问一次的代价:若进程活到默认会话才第一次被建立(有人往 /tmp 发了信),
|
||||
// 它不会自动看到 —— 但那不构成正确性问题:默认值本来就是兜底,
|
||||
// 拿到旧值只是少看一封本该补投的信,而补投走的是 SSE 而不是这里。
|
||||
// 而每次工具调用都问一次会给服务端添无谓压力,两者取轻。
|
||||
func (p *Plugin) ensureDefaultSession() {
|
||||
if p.defaultSessionProbed || strings.TrimSpace(p.currentSessionID) != "" {
|
||||
return // 邮件轮次里不需要默认值;已问过也不重复问
|
||||
}
|
||||
// ⚠ client 未就绪时**不标记已问**:那不是「问过了、答案是空」,
|
||||
// 而是「还没资格问」。标了就会永久缓存空值,之后再也不会问了。
|
||||
//
|
||||
// 我第一版没这道判据,判据 `TestEnsureDefaultSessionProbesOnlyOnce` 用
|
||||
// `&Plugin{client: nil}` 直接把它打 panic 出来 —— 而 register() 里就会调它,
|
||||
// 真的会在插件加载阶段崩掉。
|
||||
if p.client == nil {
|
||||
return
|
||||
}
|
||||
p.defaultSessionProbed = true
|
||||
p.defaultSessionID = p.fetchDefaultSessionID()
|
||||
if p.defaultSessionID != "" {
|
||||
log.Printf("[homeagent-mail-bridge] 非邮件轮次默认会话: %s(%s)",
|
||||
p.defaultSessionID, defaultFallbackWorkspace)
|
||||
}
|
||||
}
|
||||
|
||||
// fetchDefaultSessionID 向服务端问「我的默认会话是哪条」,失败返回空串。
|
||||
func (p *Plugin) fetchDefaultSessionID() string {
|
||||
var out struct {
|
||||
SessionID string `json:"session_id"`
|
||||
}
|
||||
url := fmt.Sprintf("%s/api/v1/agent/session/default?workspace=%s",
|
||||
p.gwURL, url.QueryEscape(defaultFallbackWorkspace))
|
||||
if err := p.get(url, &out); err != nil {
|
||||
// 问不到就当没有:下面的调用会不带 session_id,服务端 403。
|
||||
// 那是「没收窄」被明确拒绝,而不是被静默放行 —— 与 workspace 同一原则。
|
||||
log.Printf("[homeagent-mail-bridge] 取默认会话失败(本次不带 session_id): %v", err)
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(out.SessionID)
|
||||
}
|
||||
|
||||
// effectiveWorkspace 返回本轮该用的工作区:**先信封,再进程 cwd**。
|
||||
//
|
||||
// 为什么要回落:homeagent 的工具全局可调,而 currentWorkspace 只在
|
||||
|
||||
Reference in New Issue
Block a user