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 }