用户报的:「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 个文件。)
429 lines
15 KiB
Go
429 lines
15 KiB
Go
package handler
|
||
|
||
import (
|
||
"log"
|
||
"net/http"
|
||
"strings"
|
||
|
||
"github.com/agentmail/gateway/internal/middleware"
|
||
"github.com/agentmail/gateway/internal/models"
|
||
"github.com/agentmail/gateway/internal/repo"
|
||
"github.com/google/uuid"
|
||
)
|
||
|
||
// Agent 侧的寻址发现与线索读取。
|
||
//
|
||
// # 为什么需要这一组端点
|
||
//
|
||
// 在这之前,Agent 能读的只有自己的收件箱。`/agents`、`/contacts`、
|
||
// `/contacts/suggest`、`/mail/{id}/thread`、`/sessions/{id}` 全部挂在
|
||
// `middleware.UserAuth` 后面,Agent 密钥一律 401。后果是 `send_mail` 的 `to`
|
||
// 成了一个**只能靠记忆拼写的自由文本字段**:
|
||
//
|
||
// - 想回给抄送方,只能从收件箱渲染出的 `抄送: opencode@/home.new` 里抄一段,
|
||
// 而 `.new` 是一次性的,抄过去只会再建一条会话;
|
||
// - 想知道对方接受哪个工作目录,无从查询,只能猜。生产上真实发生过一次:
|
||
// dsh 猜了 `opencode@/home`,地址解析通过、投递成功,但 `/home` 不是
|
||
// opencode 的工作目录 —— **猜错比报错更糟,它会静默变成新会话的 workspace**。
|
||
//
|
||
// 人类侧从来没有这个问题:`AddressInput` 三段式逐段查 `/contacts/suggest`,
|
||
// name / path / session 每一段都从活数据里选。这一组端点就是把同一份能力
|
||
// 给 Agent。
|
||
//
|
||
// # 为什么不直接给 Agent 复用人类那几条路由
|
||
//
|
||
// 两条理由:
|
||
//
|
||
// 1. **作用域不同。** 人类侧 `ListContactsFor(scope=username)` 的 scope 是
|
||
// 「我参与过的会话」,管理员还能 `?all=true` 看全部。Agent 没有管理员概念,
|
||
// 也不该看到自己没参与过的线索。把 AgentAuth 加进人类路由组,等于让
|
||
// `middleware.GetUser` 返回 nil 的请求走进一堆假定 user 非空的 handler。
|
||
// 2. **审计与演进。** Agent 能读什么是插件契约的一部分(PLUGIN-CONTRACT 的
|
||
// 能力矩阵),独立成组才能在一处看全。
|
||
//
|
||
// # 一律只读
|
||
//
|
||
// 这里没有任何写端点。归档、改别名、决策权限都仍然只有人能做 ——
|
||
// Agent 可以「看见并寻址」,但不能替人整理邮箱。
|
||
|
||
// agentScope 取「调用方当前所在的那条邮件会话」(查询串 `session_id`)。
|
||
//
|
||
// # 这一维是干什么的
|
||
//
|
||
// 请求里原先**根本没有**「我现在在哪个工作区」这个事实 —— 而隔离判据需要它。
|
||
// 这里的立场是:不接受调用方直接声明工作区(那等于自己给自己发通行证),
|
||
// 只接受一条**会话 id**,由服务端反查它的 `workspace`。
|
||
//
|
||
// # 三种返回
|
||
//
|
||
// - 声明了且合法 → 返回该 id
|
||
// - 未声明 → 返回 nil(旧语义:放行),并记一条警告
|
||
// - 声明了但不合法 → 写 400 并返回 ok=false
|
||
// (不静默忽略:静默忽略会让调用方以为自己收窄了,而实际是全量)
|
||
func agentScope(w http.ResponseWriter, r *http.Request, agentName string) (*uuid.UUID, bool) {
|
||
raw := strings.TrimSpace(r.URL.Query().Get("session_id"))
|
||
if raw == "" {
|
||
// 还未接线的桥/脚本/浏览器会走这里。放行是迁移期的妥协,
|
||
// 警告是收尾用的抓手:按日志把没接线的调用方找全。
|
||
log.Printf("[agent-scope] %s 读了 %s 但没声明 session_id(旧语义放行:未按工作区隔离)",
|
||
agentName, r.URL.Path)
|
||
return nil, true
|
||
}
|
||
id, err := uuid.Parse(raw)
|
||
if err != nil {
|
||
Error(w, http.StatusBadRequest, "非法的 session_id")
|
||
return nil, false
|
||
}
|
||
return &id, true
|
||
}
|
||
|
||
// canReadSession 是五个读端点共用的那道闸门,失败时自己写响应。
|
||
//
|
||
// 两类拒绝的文案刻意不同:`not-participant` 说"不是你的线索",
|
||
// `cross-workspace` 说"你的工作区不对" —— 但**不报出对方的工作区**
|
||
// (那本身就是跨工作区信息)。调用方能看见的只有自己那个工作区名。
|
||
func canReadSession(w http.ResponseWriter, r *http.Request, agentName string, scope *uuid.UUID, target uuid.UUID) bool {
|
||
ok, reason, err := repo.AgentMayReadSession(r.Context(), agentName, scope, target)
|
||
if err != nil {
|
||
Error(w, http.StatusInternalServerError, "Failed to check permission")
|
||
return false
|
||
}
|
||
if ok {
|
||
return true
|
||
}
|
||
switch reason {
|
||
case "cross-workspace":
|
||
self := ""
|
||
if scope != nil {
|
||
self = repo.SessionWorkspaceOf(r.Context(), *scope)
|
||
}
|
||
Error(w, http.StatusForbidden,
|
||
"无权访问该会话:调用方当前所在的工作区是 "+self+",目标会话不在同一个工作区")
|
||
default:
|
||
Error(w, http.StatusForbidden, "无权访问该会话")
|
||
}
|
||
return false
|
||
}
|
||
|
||
// GET /api/v1/agent/contacts
|
||
//
|
||
// 本 Agent 参与过的会话,每条给出可直接投递的 `address`。
|
||
// 与人类侧 `/contacts` 同源(`repo.ListContactsFor`),scope 固定为自己。
|
||
//
|
||
// 声明了 `session_id`(= 调用方当前所在那条会话)时只列**同工作区**的会话:
|
||
// 一个 Agent 同时服务所有工作区,不收窄的话在 TrueAgent 里干活的 worker 会拿到
|
||
// agentmail 的会话标题/别名/未读计数。
|
||
func AgentListContacts(w http.ResponseWriter, r *http.Request) {
|
||
agentName := middleware.GetAgentName(r)
|
||
if agentName == "" {
|
||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||
return
|
||
}
|
||
|
||
scope, ok := agentScope(w, r, agentName)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
archived := r.URL.Query().Get("archived") == "true"
|
||
var contacts []repo.Contact
|
||
var err error
|
||
if scope == nil {
|
||
contacts, err = repo.ListContactsFor(r.Context(), agentName, archived)
|
||
} else {
|
||
contacts, err = repo.ListContactsInWorkspace(
|
||
r.Context(), agentName, repo.SessionWorkspaceOf(r.Context(), *scope), archived)
|
||
}
|
||
if err != nil {
|
||
Error(w, http.StatusInternalServerError, "Failed to list contacts")
|
||
return
|
||
}
|
||
|
||
// 联系人条目里的 agent_name 是「会话对面那个人」,但 ListContactsFor 取的是
|
||
// 会话首封邮件的 to_name(人类侧视角:对面是 Agent)。Agent 自己调用时,
|
||
// 首封邮件的 to_name 往往就是自己,对面反而是 from_name。
|
||
// 因此这里补一个 peer 字段明确「该跟谁说话」,不改原字段以免动到前端。
|
||
out := make([]map[string]any, 0, len(contacts))
|
||
for _, c := range contacts {
|
||
peer := c.AgentName
|
||
if peer == agentName {
|
||
peer = c.LastFrom
|
||
}
|
||
out = append(out, map[string]any{
|
||
"session_id": c.SessionID,
|
||
"session_alias": c.SessionAlias,
|
||
"subject": c.Subject,
|
||
"path": c.Path,
|
||
"status": c.Status,
|
||
"mail_count": c.MailCount,
|
||
"unread_count": c.UnreadCount,
|
||
"last_activity": c.LastActivity,
|
||
"last_from": c.LastFrom,
|
||
"max_rounds": c.MaxRounds,
|
||
"used_rounds": c.UsedRounds,
|
||
// peer 是这条会话里可与之通信的另一方
|
||
"peer": peer,
|
||
// address 是投回这条会话的现成地址。别名为空的老会话给不出可寻址的
|
||
// 形式,此时置空而不是拼一个 `.new` —— 那会开新线索而不是续谈。
|
||
"address": addressForSession(peer, c.Path, c.SessionAlias),
|
||
})
|
||
}
|
||
JSON(w, http.StatusOK, map[string]any{"contacts": out})
|
||
}
|
||
|
||
// addressForSession 拼「投回这条会话」的地址;无别名时返回空串。
|
||
//
|
||
// 刻意不退化成 `name@path`(默认会话):默认会话是「该 name@path 当前最活跃的
|
||
// 那条」,与调用方想回的那条不一定是同一条。给一个看着能用其实指向别处的地址,
|
||
// 比给空串危险。
|
||
func addressForSession(name, path, alias string) string {
|
||
if alias == "" {
|
||
return ""
|
||
}
|
||
return models.FormatAddress(name, path, alias)
|
||
}
|
||
|
||
// GET /api/v1/agent/contacts/suggest?name=&path=
|
||
//
|
||
// 三段式寻址补全,与人类侧 `/contacts/suggest` 同一套语义:
|
||
//
|
||
// 不带 name → 候选收件人名(在线 Agent + 活跃用户,去掉自己)
|
||
// 带 name 不带 path → 该 name 用过的工作目录
|
||
// name + path 都带 → 该 name@path 下可续谈的会话别名,`new` 永远在最后
|
||
//
|
||
// **这是「精准发信」的关键一环**:模型不再拼地址,而是逐段选。
|
||
func AgentSuggestAddress(w http.ResponseWriter, r *http.Request) {
|
||
agentName := middleware.GetAgentName(r)
|
||
if agentName == "" {
|
||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||
return
|
||
}
|
||
|
||
name := strings.TrimSpace(r.URL.Query().Get("name"))
|
||
path := strings.TrimSpace(r.URL.Query().Get("path"))
|
||
|
||
if name == "" {
|
||
agents, err := repo.ListAgents(r.Context(), "")
|
||
if err != nil {
|
||
Error(w, http.StatusInternalServerError, "Failed to list agents")
|
||
return
|
||
}
|
||
users, _ := repo.ListActiveUsernames(r.Context())
|
||
|
||
names := make([]string, 0, len(agents)+len(users))
|
||
for _, a := range agents {
|
||
if a.Name == agentName {
|
||
continue // 不建议给自己发信
|
||
}
|
||
names = append(names, a.Name)
|
||
}
|
||
names = append(names, users...)
|
||
JSON(w, http.StatusOK, map[string]any{
|
||
"kind": "name",
|
||
"suggestions": emptySlice(names),
|
||
})
|
||
return
|
||
}
|
||
|
||
if path == "" {
|
||
paths, _ := repo.SuggestPaths(r.Context(), name)
|
||
JSON(w, http.StatusOK, map[string]any{
|
||
"kind": "path",
|
||
"suggestions": emptySlice(paths),
|
||
})
|
||
return
|
||
}
|
||
|
||
// 可见性传自己的名字:只提示自己参与过的会话。
|
||
// 传空会把别人的私下线索也列出来,那是越权。
|
||
//
|
||
// 声明了 `session_id` 时额外要求这些会话与调用方**同工作区**:参与过不等于
|
||
// 该看 —— 一个 Agent 同时服务所有工作区(见 repo.AgentMayReadSession)。
|
||
// 跨工作区寻址本身仍然可行(`new` 总在最后,paths 候选也不收窄),
|
||
// 收窄的只是"浏览别的会话的标题/别名"。
|
||
scope, ok := agentScope(w, r, agentName)
|
||
if !ok {
|
||
return
|
||
}
|
||
var sessions []repo.SessionCandidate
|
||
var err error
|
||
if scope == nil {
|
||
sessions, err = repo.SuggestSessionCandidates(r.Context(), agentName, name, path)
|
||
} else {
|
||
sessions, err = repo.SuggestSessionCandidatesInWorkspace(
|
||
r.Context(), agentName, name, path, repo.SessionWorkspaceOf(r.Context(), *scope))
|
||
}
|
||
if err != nil {
|
||
Error(w, http.StatusInternalServerError, "Failed to suggest sessions")
|
||
return
|
||
}
|
||
|
||
aliases := make([]string, 0, len(sessions)+1)
|
||
addresses := make([]string, 0, len(sessions)+1)
|
||
for _, c := range sessions {
|
||
aliases = append(aliases, c.Alias)
|
||
addresses = append(addresses, models.FormatAddress(name, path, c.Alias))
|
||
}
|
||
// new 总在最后:它不是一条已存在的会话。排在前面会让模型在想续谈时
|
||
// 顺手开出一条新线索 —— 生产上已经发生过。
|
||
aliases = append(aliases, "new")
|
||
addresses = append(addresses, models.FormatAddress(name, path, "new"))
|
||
sessions = append(sessions, repo.SessionCandidate{
|
||
Alias: "new", Source: "new", Title: "新建会话",
|
||
})
|
||
|
||
JSON(w, http.StatusOK, map[string]any{
|
||
"kind": "session",
|
||
"suggestions": emptySlice(aliases),
|
||
// addresses 与 suggestions 同序,可直接塞进 send_mail 的 to
|
||
"addresses": emptySlice(addresses),
|
||
"candidates": emptySlice(sessions),
|
||
})
|
||
}
|
||
|
||
// GET /api/v1/agent/mail/{id}/thread
|
||
//
|
||
// 与人类侧 `/mail/{id}/thread` 同一份实现,可见性判据换成
|
||
// 「本 Agent 参与过该会话**且工作区相同**」(见 repo.AgentMayReadSession)。
|
||
// 抄送协作要靠它回答「谁已经回了、谁还没回」。
|
||
func AgentGetMailThread(w http.ResponseWriter, r *http.Request) {
|
||
agentName := middleware.GetAgentName(r)
|
||
if agentName == "" {
|
||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||
return
|
||
}
|
||
scope, ok := agentScope(w, r, agentName)
|
||
if !ok {
|
||
return
|
||
}
|
||
serveMailThread(w, r, func(sid uuid.UUID) (bool, error) {
|
||
// 丢掉 reason 而不是改 serveMailThread 的签名:人类侧 `/mail/{id}/thread`
|
||
// 与这里共用同一份实现,只为一个调用点的文案去改它不划算。
|
||
// 拒绝原因(跨工作区 / 没参与过)在 403 文案上合流成同一句,
|
||
// 反而少泄一点信息。
|
||
allowed, _, err := repo.AgentMayReadSession(r.Context(), agentName, scope, sid)
|
||
return allowed, err
|
||
})
|
||
}
|
||
|
||
// GET /api/v1/agent/mail/{id}
|
||
//
|
||
// 读单封邮件全文(含抄送清单与附件)。收件箱只给摘要,
|
||
// 而要回给抄送方就必须先看清这封信到底发给了谁。
|
||
func AgentGetMail(w http.ResponseWriter, r *http.Request) {
|
||
agentName := middleware.GetAgentName(r)
|
||
if agentName == "" {
|
||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||
return
|
||
}
|
||
scope, ok := agentScope(w, r, agentName)
|
||
if !ok {
|
||
return
|
||
}
|
||
mailID, ok := pathUUID(w, r, "id")
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
mail, err := repo.GetMailByID(r.Context(), mailID)
|
||
if err != nil {
|
||
Error(w, http.StatusNotFound, "Mail not found")
|
||
return
|
||
}
|
||
if !canReadSession(w, r, agentName, scope, mail.SessionID) {
|
||
return
|
||
}
|
||
|
||
fillAttachments(r, mail)
|
||
|
||
alias := repo.SessionAliasOf(r.Context(), mail.SessionID)
|
||
JSON(w, http.StatusOK, map[string]any{
|
||
"mail": mail,
|
||
"session_alias": alias,
|
||
// 回信地址与「我这个身份」都给现成的,省得插件自己拼。
|
||
// mail.ToWorkspace 是收件方那个地址的 path 位。
|
||
"reply_address": models.FormatAddress(mail.FromName, "", alias),
|
||
"self_address": models.FormatAddress(agentName, mail.ToWorkspace, alias),
|
||
"participants": participantsOf(mail, alias),
|
||
})
|
||
}
|
||
|
||
// GET /api/v1/agent/sessions/{id}/participants
|
||
//
|
||
// 列出该会话的全部参与方及各自的可投递地址。
|
||
//
|
||
// 这是「发送给抄收方 / 转发方」缺的最后一块:知道有谁、以及**用什么地址找到他**。
|
||
// 逐封邮件扫收件人与抄送,因为参与方是随往来变化的(一封转发就多一个人)。
|
||
func AgentSessionParticipants(w http.ResponseWriter, r *http.Request) {
|
||
agentName := middleware.GetAgentName(r)
|
||
if agentName == "" {
|
||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||
return
|
||
}
|
||
sessionID, ok := pathUUID(w, r, "id")
|
||
if !ok {
|
||
return
|
||
}
|
||
scope, ok := agentScope(w, r, agentName)
|
||
if !ok {
|
||
return
|
||
}
|
||
if !canReadSession(w, r, agentName, scope, sessionID) {
|
||
return
|
||
}
|
||
|
||
parts, err := repo.SessionParticipants(r.Context(), sessionID)
|
||
if err != nil {
|
||
Error(w, http.StatusInternalServerError, "Failed to list participants")
|
||
return
|
||
}
|
||
|
||
alias := repo.SessionAliasOf(r.Context(), sessionID)
|
||
out := make([]map[string]any, 0, len(parts))
|
||
for _, p := range parts {
|
||
out = append(out, map[string]any{
|
||
"name": p.Name,
|
||
"path": p.Path,
|
||
"roles": p.Roles, // from / to / cc 的并集
|
||
"is_self": p.Name == agentName,
|
||
"mail_count": p.MailCount,
|
||
// address 用**该参与方自己的 path**,不是调用方的:
|
||
// 抄送给 opencode@/a 与主发给 dsh@/b 是两个工作区,
|
||
// 用错 path 会让对方在别人的目录里开会话。
|
||
"address": addressForSession(p.Name, p.Path, alias),
|
||
})
|
||
}
|
||
|
||
JSON(w, http.StatusOK, map[string]any{
|
||
"session_id": sessionID,
|
||
"session_alias": alias,
|
||
"participants": out,
|
||
})
|
||
}
|
||
|
||
// participantsOf 从单封邮件里摘出参与方地址,供 AgentGetMail 直接返回。
|
||
// 与 SessionParticipants 的区别:这里只看这一封(发件人 + 收件人 + 抄送),
|
||
// 用于「回这封信时该带上谁」;那里看整条会话。
|
||
func participantsOf(m *models.Mail, alias string) []map[string]any {
|
||
out := []map[string]any{}
|
||
add := func(role, name, path string) {
|
||
if name == "" {
|
||
return
|
||
}
|
||
out = append(out, map[string]any{
|
||
"role": role,
|
||
"name": name,
|
||
"path": path,
|
||
"address": addressForSession(name, path, alias),
|
||
})
|
||
}
|
||
// from_workspace 对 Agent 存的是 Agent 名而非路径(历史遗留),
|
||
// 拿它当 path 会拼出错地址,所以发件人一侧留空 path 走默认。
|
||
add("from", m.FromName, "")
|
||
add("to", m.ToName, m.ToWorkspace)
|
||
for _, c := range m.CCList {
|
||
add("cc", c.Name, c.Path)
|
||
}
|
||
return out
|
||
}
|