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:
@ -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
|
||||
|
||||
Reference in New Issue
Block a user