用户报的:「agentmail 工作区的邮件会话被 trueagent 工作区的 agent 看到了, 还需要我亲自去解释。」 ## 根因不是漏了一个 WHERE,是隔离单位选错了 Agent 注册时 `workspaces` 是空的(B-1.2:cwd 由每封邮件的 `to_workspace` 决定), 所以**一个 Agent 同时服务所有工作区**。而可见性判据一直是 `AgentCanAccessSession(agentName, sid)` = "这个 Agent 名出现在这条会话的 from/to/cc 里" —— 于是同一个 agent `pi`,在 TrueAgent 里干活的 worker 眼里,对 agentmail 的会话 也成立。 现场证据:`mail_reads` 里 08:11–09:19 有 8 次「同一瞬间读了多个不同工作区的会话」 (08:23:59 一次跨 agentmail / TrueAgent / webui4frpc 三条会话),最后一次是 09:19:11 —— 正好停在 `read_inbox` 按会话收窄那个提交(552fbc7,09:19:25)之前。 更要紧的是 `mail_reads` 只记 `reader_name`、**没有「读的人当时在哪个工作区」这一列**, 所以这类越界读在数据上与正常读**无法区分** —— 这也是为什么只能由用户自己去解释。 ## 改法:补一维,而不是逐个端点打补丁 - 新增 `repo.AgentMayReadSession(agentName, scope, target)`:① 参与过(原有判据) ② 两条会话的 `workspace` 相同(新增)。`scope` = 调用方当前所在的那条会话。 - 服务端只认一条**会话 id**(`?session_id=`),由它反查 workspace —— **不接受调用方直接声明工作区**,否则等于让它自己给自己发通行证。 - 应用到四个读端点:`read_mail` / `read_thread` / `session_participants` / `list_contacts`,以及 `contacts/suggest` 的**会话候选**(name/path 两段不收窄: 跨工作区**发信**是设计允许的,被挡的只是"浏览同行的线索")。 - 未声明 `session_id` 时保留旧语义(放行)并**记警告日志**:迁移要能分步走, 但"还有谁没接线"必须可观测(另四家桥仍走这条路)。 - pi 桥:五个读工具全部带上自己那条邮件会话 id(由 worker 闭包注入,模型改不了)。 ## 顺手修掉一个真 bug 联系人查询的未读计数子查询里一直有 `r.reader_name = $1`,而原写法是 "forUser 为空就不传参" ⇒ $1 悬空:Postgres 直接报 `no parameter $1`, SQLite 把 `= $1` 当 `= NULL` 比、次次不成立(未读计数静默退化成"全部未归档")。 管理员 `?all=true` 走的正是这条路。现在 $1 恒传。 ## 判据(两侧都验 + 变异) - repo:同工作区放行 / 跨工作区拒且 reason 分得清 / 没参与过拒 / 未声明 scope 的旧语义;列表类有反向对照(不带收窄两条都在); 建议补全同工作区照常给候选、跨工作区查路径不给、不带收窄会给(对照组)。 - ★ 这条判据我第一版**写错了对照组**:拿 path=wsA 去比 —— 而 path 本来就收窄, 于是"不带收窄"也只剩一条,判据等于空的。改成拿 path=wsB 比才有区分力。 - 变异 3 处(拿掉工作区判据 / ListContactsInWorkspace 不收窄 / SuggestSessionCandidatesInWorkspace 不收窄)⇒ 各自恰好红在对应那条断言。 - pi 桥 14 条:6 个读工具 × 带上/不带 scope 两侧 + worker 闭包 + 自检; 变异 read_mail 去掉收窄 ⇒ 恰好那一条红。 (工作区是多会话共用的,本次只 add 了 server/ 与 plugins/pi-mail-bridge/ 的 7 个文件。)
86 lines
4.0 KiB
Go
86 lines
4.0 KiB
Go
package repo
|
||
|
||
import (
|
||
"context"
|
||
|
||
"github.com/agentmail/gateway/internal/db"
|
||
"github.com/google/uuid"
|
||
)
|
||
|
||
// SessionWorkspaceOf 返回**这条会话**的工作目录(`sessions.workspace`)。
|
||
//
|
||
// # 为什么需要它
|
||
//
|
||
// 邮件的 `to_workspace` 是插件唯一能知道「这个任务该在哪个目录干活」的入口,
|
||
// 但它取的是**地址里的 path 位**。而 Agent 之间的回信、以及人在对话页点回复时,
|
||
// 地址里通常没有 path 位 —— 平台自己下发的 `reply_address` 就是这个形状。
|
||
//
|
||
// 空着传下去的后果是可观测的:插件只能自己拼一个临时目录,于是**每封邮件落在
|
||
// 一个不同的空目录里**;DSH / opencode 按 cwd 给会话分组,界面上就成了「每处理
|
||
// 一封邮件就多出一条未分组会话」,而模型在空目录里什么项目文件也看不到。
|
||
//
|
||
// 会话的 workspace 才是权威来源(见 `models.SessionWorkspace` 的注释):
|
||
// 回信本来就是回给**那条会话**的,而那条会话知道自己属于哪个项目。
|
||
//
|
||
// 读不到时返回空串而不是报错:投递路径不能因为一次查询失败就丢掉工作目录信息,
|
||
// 但也不能凭空编一个 —— 空串的语义就是「不知道」,由调用方决定怎么退化。
|
||
func SessionWorkspaceOf(ctx context.Context, id uuid.UUID) string {
|
||
var ws string
|
||
err := db.DB.QueryRowContext(ctx,
|
||
`SELECT COALESCE(workspace, '') FROM sessions WHERE session_id = $1`, id).Scan(&ws)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
return ws
|
||
}
|
||
|
||
// AgentMayReadSession 判「以 `scope` 为当前会话的 Agent 能不能读 `target`」。
|
||
//
|
||
// # 两道独立闸门,缺一不可
|
||
//
|
||
// ① 该 Agent 参与过目标会话(AgentCanAccessSession:出现在 from/to/cc 里)
|
||
// ② 两条会话的 workspace 相同(本函数新增的那道)
|
||
//
|
||
// # 为什么必须补 ②:一个 Agent 同时服务所有工作区
|
||
//
|
||
// Agent 注册时 `workspaces` 是空的(B-1.2:cwd 由每封邮件的 `to_workspace` 决定),
|
||
// 于是 **agent `pi` 既"参与过" agentmail 的会话、也"参与过" TrueAgent 的会话** ——
|
||
// 只靠 ① 时,一个在 TrueAgent 里干活的 worker 能读到 agentmail 的整条线索。
|
||
// 2026-09-14 用户报的正是这个:`mail_reads` 里能看到同一瞬间读了跨三个工作区的
|
||
// 会话(agentmail / TrueAgent / webui4frpc)。当时的读者身份**只有 reader_name,
|
||
// 没有"读的人在哪"** —— 也就是说这类越界读在数据上与正常读无法区分。
|
||
//
|
||
// # 判据取严,且只有一个定义
|
||
//
|
||
// workspace 按字面比较(空字符串 = 空字符串),不相等即拒。刻意**不做**
|
||
// 「workspace 为空时回头从 mails 反推」那套兜底 —— 兜底会让"这条会话到底属于哪个
|
||
// 工作区"有两个来源,而隔离判据最不需要的就是第二个来源。(2026-09-14 实测:
|
||
// 线上 workspace 为空的 6 条会话里,没有一条的邮件带 to_workspace,所以这条
|
||
// 兜底在当前数据上不生效;真出现时应当**拒读并留下一条日志**,而不是悄悄放行。)
|
||
//
|
||
// `scope == nil` 表示调用方**没有声明**自己在哪条会话(尚未接线的桥、脚本、浏览器)。
|
||
// 那是旧语义:放行。它存在的唯一理由是让迁移可以分步走,调用点会记一条警告,
|
||
// 收尾时按日志把还没接线的调用方找全。
|
||
//
|
||
// 返回的 reason 只用于日志与报错文案,不参与判定:
|
||
//
|
||
// "" 允许
|
||
// "not-participant" 该 Agent 没参与过这条会话
|
||
// "cross-workspace" 参与了,但两条会话不在同一工作区
|
||
func AgentMayReadSession(ctx context.Context, agentName string, scope *uuid.UUID, target uuid.UUID) (bool, string, error) {
|
||
participates, err := AgentCanAccessSession(ctx, agentName, target)
|
||
if err != nil {
|
||
return false, "", err
|
||
}
|
||
if !participates {
|
||
return false, "not-participant", nil
|
||
}
|
||
if scope == nil {
|
||
return true, "", nil
|
||
}
|
||
if SessionWorkspaceOf(ctx, *scope) != SessionWorkspaceOf(ctx, target) {
|
||
return false, "cross-workspace", nil
|
||
}
|
||
return true, "", nil
|
||
}
|