Files
MailUI4Agents/server/internal/handler/agent_discovery.go
JianFeeeee a5fc86bc1a fix(addressing)!: 寻址不按工作区筛 + 联系人地址不再从垃圾 from_workspace 拼
用户:「任意 agent 的寻址是任意的,而不是按工作区区分,去落实吧」。

① 拆掉两处"按工作区收窄"(那是我把**寻址**当成了**权限**):
   · AgentListContacts 不再走 ListContactsInWorkspace ⇒ 列表 = 我参与过的会话(事实,不是授权);
   · AgentSuggestAddress 的会话候选不再走 SuggestSessionCandidatesInWorkspace。
   两个 InWorkspace 变体(连同钉旧口径的用例)一并删除,避免死代码。
   生产实测:pi 的联系人从"只剩同工作区"变成 5 条,横跨 TrueAgent/agentmail/其它工作区。
   agentScope 仍调用(校验 session_id 格式 + 未声明时告警),只是它的工作区不再当过滤器。

② 联系人地址的 path 取自**会话**,不再取第一封邮件的 from_workspace:
   `COALESCE(NULLIF(s.workspace,''), NULLIF(m.to_workspace,''), '')`。
   那条老路把地址拼成 `zcode@zcode.<别名>` —— 正是用户预言的"感染":一个可被复制出去的
   错误地址。from_workspace 与"对方在哪"无关,只用 to_*。

③ **清除感染源(数据)**:658 行 from_workspace = from_name(pi 406 / dsh 137 / zcode 89 /
   homeagent 17 / opencode 9)已清空。带去重前备份(/root/gotmp/agentmail-pre-fromws-purge-*.db)
   与回滚脚本,回滚**在副本库上真跑过**(恢复 658 行)才敢落地。
2026-09-15 10:07:18 +08:00

426 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 "not-your-session":
// ★ 2026-09-15 用户订正:读信这条路只认会话 ——
// 「是应该发到对应 session 的信,因为 agent 多 session 架构,不同 session 的
// 记忆是隔离的」。所以**不再拿工作区当理由**(旧文案就是 cross-workspace,
// 它让 zcode 推断出"那五位 agent 不存在",把错误结论写进了给人看的信里)。
// 报出"我当前在哪条会话"是安全的(那是调用方自己的);**不报**目标会话在哪。
self := ""
if scope != nil {
self = (*scope).String()
}
Error(w, http.StatusForbidden,
"这封信不在你当前所在的那条会话里:每个会话各有各的收件箱(会话之间的记忆是隔离的)。"+
"你当前在会话 "+self+"。要读它,就在它那条会话里读 —— 每封来信都会把 worker 唤醒到"+
"它自己那条会话上,那时你看到的就是那条会话的收件箱。")
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
}
// 仍走 agentScope:它负责校验 session_id 格式、并在未声明时记一条警告。
// 但它的返回值**不再是过滤器** —— 列表与寻址都不再按工作区收窄(见下)。
if _, ok := agentScope(w, r, agentName); !ok {
return
}
archived := r.URL.Query().Get("archived") == "true"
// ★ 2026-09-15 用户:「任意 agent 的寻址是任意的,而不是按工作区区分,去落实吧」。
// 列表 = **我参与过的会话**(这是事实,不是授权),不再按工作区收窄。
// 旧口径让一个 agent 只看得见"同工作区的会话标题",zcode 2026-09-15 正是据此
// 认定"那五位 agent 不存在"。工作区那条轴只留给 cwd/沙箱。
contacts, err := repo.ListContactsFor(r.Context(), agentName, 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
}
// 可见性传自己的名字:只提示自己参与过的会话。
// 传空会把别人的私下线索也列出来,那是越权。
// ★ 2026-09-15 用户:「任意 agent 的寻址是任意的,而不是按工作区区分,去落实吧」。
// 原先这里"声明了 session_id 就只列同工作区的会话"——那是把**寻址**当成**权限**了:
// 寻址就该是任意的(谁都能给谁发),工作区只决定 cwd/沙箱。
if _, ok := agentScope(w, r, agentName); !ok {
return
}
// 候选会话只按"我参与过 + 与这个 name/path 匹配"来列。 —— 候选会话只按"我参与过 + 与这个名字/路径匹配"来列,
// 这样 zcode 那种"想给别的 agent 发信"的场景才能拿到真实的候选。
sessions, err := repo.SuggestSessionCandidates(r.Context(), agentName, name, path)
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
}