## 起因 DSH 侧 Agent 报了一份寻址缺口(2026-10-02,全部结论有 API 实测复现)。 三段式寻址 `name@path.session` 里 session 段是**人的寻址入口**,而枚举它 必须先知道 path —— 但 path 恰恰是调用方无从得知的: 给 name → 只给 path(要再调一次才知道有哪些会话) 给 name+path → 给会话别名(但 path 得先猜对) 于是一个闭合的环。报告实测的踩坑:投 `pi@root` 返回 **200**,落进一条标题 为「拓展坞实测硬件正常…」的无关会话 —— 投递成功,所以调用方不知道自己投错了。 ## 修法 **① A 项:`flatten=1` 一次给出全部可投递地址** `SuggestAddressesForPeer` + `suggest?name=&flatten=1`。每个候选自带 `path` 与可直接塞进 send_mail 的 `address` —— 调用方不必自己拼, 拼错就是那个「猜错比报错更糟」。 可见性口径**不放宽**,与原 name+path 那一支逐条一致(「我参与过 + 与该 name 匹配」)。报告本身也确认问题不在权限:同一批数据给了 path 就能列出 17 条。 按 path 分组平铺而非嵌套:嵌套时调用方要发一封「不知道在哪个 path」的信 仍得遍历全部组;平铺一次给全,模型不必做「先猜 path 再枚举」两步。 **② B/C 项:标注而非隐藏** `paths[]` 每项带 `kind`(workspace / bridge-internal)与 `is_absolute`。 选标注不选过滤的理由:桥内部目录(`/root/.pi/mail-sessions/<uuid>`) 确实**是某些会话的真实 cwd**(实测那条 workspace='root' 的会话 uuid 正是 其中之一)—— 滤掉等于让那些会话彻底不可见;而留着不标,64 条候选里 33 条 是噪声,模型选中即静默投错(实测 64 条中 33 条是它)。 `suggestions` 保持原样与原顺序 —— SuggestPaths 按最近使用倒序 (刚用过的那个几乎总是下一封想用的),排序被打乱等于让模型取最老的那个。 ## ★★ 顺带修掉一个生产级缺陷(实测撞出来的) 给 `SessionCandidate` 加 `LastActivity` 时用了: COALESCE(s.updated_at, '0001-01-01 00:00:00+00') COALESCE 让驱动返回 **string**,扫进 time.Time 报 `unsupported Scan` ⇒ 命中 `return out, err` ⇒ **整个候选列表变空**(实测一条都列不出)。 生产影响:`updated_at` 为 NULL 的历史会话会全部静默消失。 而那个错误信息里**没有任何线索**指向「是你加的 COALESCE 害的」—— 本次是我自己加的列触发的,排查花了几步。 改为扫进 `sql.NullTime`(NULL 即零值),平台镜像那条同理。 注释里写明为什么不能 COALESCE 兜底,免得下次有人再加回去。 ## MCP 侧同步 `suggest_address` 加 `flatten` 参数,且**渲染必须单独写**: flatten 的响应没有 `suggestions` 字段,走原来的分支只会回一句 「(没有 session_flat 建议)」—— 模型拿不到任何地址,等于白问一次。 path 形状的渲染把两类坑直接顶到眼前:桥内部目录、相对路径 (`root` 与 `/root` 在数据里是两个不同工作区,实测 1 条 vs 17 条)。 ## 判据(8 格) 含「address 必须与候选自身 path/alias 一致」(那正是静默投错的解药)、 「两个工作区都要出现」(原形状缺的就是这一维)、 「不带 flatten 时行为一字未变」(各桥与 WebUI 都走那一支)、 「flatten 不得把 new 混在候选里」(没有真实会话时它看起来像出路)。 **变异验证**: COALESCE 兜底(那个真 bug) → 红 1 ✓ flatten 段放回 path=="" 之后(顺序 bug)→ 红 1 ✓(kind 变回 "path") ## 实测校准了一处报告里的数字 报告写「近似写法返回 0 条」,实测返回 **1 条,内容是 `new`** —— 服务端在任何 path 下都追加的新建占位。所以选错 path 时调用方看到的不是 「空」,而是「只有 new 可选」:**看起来像一条出路**,于是顺着它新建, 恰好落进猜错的那个工作区。比报 0 更危险(0 会让人停下,new 会让人继续)。 §E 无需修:`validateSessionAlias` 已拒绝别名含 `.`。 全量 14 包绿。
612 lines
25 KiB
Go
612 lines
25 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 "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/session/default —— 问出「我的默认会话是哪条」
|
||
//
|
||
// ★ 2026-10-02 新增。它是 `AgentMayReadSession` 收严后的**配套出口**:
|
||
// 未声明 session_id 的读信请求现在一律 403,而桥在**非邮件驱动轮次**
|
||
// (对话/自主调用)手里确实没有任何会话可声明 —— `session_id` 是 AgentMail
|
||
// 会话的 UUID,进程 cwd 给不出它(这点与 workspace 不同,workspace 能回落到 cwd)。
|
||
//
|
||
// 所以这里把**已有的**默认会话语义开放成一个可查询入口:
|
||
// `name@path` 省略 session 位时投递到的那条会话(`repo.FindOrCreateDefaultSession`)。
|
||
// 那个函数早就在(8 个测试覆盖它),只是没有「只查询」的 HTTP 形状。
|
||
//
|
||
// # 为什么默认工作区是 /tmp
|
||
//
|
||
// 非邮件轮次没有真实工作目录可依(桥进程 cwd 是插件目录,不是用户项目)。
|
||
// /tmp 是**明确的中性落点**:它不属于任何一个真实项目,
|
||
// 所以落进去的会话不会在任何人的工作区里混进项目邮件,
|
||
// 而 `UnreadWorkspaces` 要求 workspace 形如 `/%`(要能被寻址补投),/tmp 满足。
|
||
//
|
||
// # 安全边界:这个端点**不是**绕过 session 闸的万能钥匙
|
||
//
|
||
// 它只回答「你自己 `name@/tmp` 的默认会话是哪条」,不授予读任何会话的能力。
|
||
// 拿到 UUID 后仍要过 `AgentMayReadSession`:读别的会话照样 403。
|
||
// 换句话说:它把「非法」变成「合法但窄」—— 默认会话只装发给**你自己**的信。
|
||
func AgentDefaultSession(w http.ResponseWriter, r *http.Request) {
|
||
agentName := middleware.GetAgentName(r)
|
||
if agentName == "" {
|
||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||
return
|
||
}
|
||
ws := strings.TrimSpace(r.URL.Query().Get("workspace"))
|
||
if ws == "" {
|
||
ws = DefaultFallbackWorkspace
|
||
}
|
||
if !strings.HasPrefix(ws, "/") {
|
||
Error(w, http.StatusBadRequest, "workspace 必须是绝对路径,收到: "+ws)
|
||
return
|
||
}
|
||
// 纯只读:没有就返回空,**不建会话**。
|
||
//
|
||
// 我第一版直接调 FindOrCreateDefaultSessionCreated,判据当场报它**每次都新建**
|
||
//(连着两次问拿到两个不同 UUID)。改「先查后建」还不够 —— 已建的空会话仍不满足
|
||
// 那个函数的复用条件(`EXISTS (SELECT 1 FROM mails …)`),于是下一次依然「没找到」。
|
||
//
|
||
// 而想深一层:**根本不该建**。非邮件轮次读信时,若 `name@/tmp` 一封都没通过,
|
||
// 那个收件箱本来就应该是空的 —— 不需要一条会话 id 才能表达「空」。
|
||
// 要让默认会话真的存在,只需要**往 /tmp 发一封信**,那是发信路径的事。
|
||
//
|
||
// GET 端点有副作用本身就是坏味道:它会被桥每轮调一次,
|
||
// 预建会话等于让「问一次」在会话列表里留一条垃圾。
|
||
id, err := repo.FindExistingDefaultSession(r.Context(), agentName, ws)
|
||
if err != nil {
|
||
Error(w, http.StatusInternalServerError, "Failed to resolve default session")
|
||
return
|
||
}
|
||
// 没找到时返回 session_id=null(而不是 uuid.Nil 字符串):
|
||
// 桥看到 null 就知道「默认会话还不存在,收件箱按空处理」,
|
||
// 而不是拿着一个不存在的 id 去声明、然后拿到一个莫名其妙的 403。
|
||
var sid any
|
||
self := ""
|
||
if id != uuid.Nil {
|
||
sid = id
|
||
self = models.FormatAddress(agentName, ws, repo.SessionAliasOf(r.Context(), id))
|
||
}
|
||
JSON(w, http.StatusOK, map[string]any{
|
||
"session_id": sid,
|
||
"workspace": ws,
|
||
"exists": id != uuid.Nil,
|
||
"self_address": self,
|
||
})
|
||
}
|
||
|
||
// DefaultFallbackWorkspace 是非邮件轮次的默认落点(理由见 AgentDefaultSession)。
|
||
const DefaultFallbackWorkspace = "/tmp"
|
||
|
||
// 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"))
|
||
// flatten=1:一次给出该 name 的**全部**可投递地址(各 path 下的会话都带上
|
||
// 自己的 path 与完整地址)。见下方那一段的说明。
|
||
flatten := r.URL.Query().Get("flatten") == "1" || r.URL.Query().Get("flatten") == "true"
|
||
|
||
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
|
||
}
|
||
|
||
// flatten:把三段补全压成一次调用。
|
||
//
|
||
// ★ 2026-10-02(DSH 侧报告 A):原来的形状构成一个闭合的环 ——
|
||
// 给 name 只给 path,要再调一次才知道有哪些会话;而要枚举会话又必须先
|
||
// 知道 path,path 却只能猜(且 `root` 与 `/root` 会静默落到不同工作区)。
|
||
// 报告实测:投 `pi@root` 返回 200,落进一条标题为「拓展坞实测硬件正常…」
|
||
// 的无关会话 —— 投递成功,所以调用方不知道自己投错了。
|
||
//
|
||
// 可见性口径**不放宽**:与下面 path+name 那一支完全一致
|
||
//(「我参与过 + 与该 name 匹配」)。报告本身也确认问题不在权限。
|
||
if flatten {
|
||
if _, ok := agentScope(w, r, agentName); !ok {
|
||
return
|
||
}
|
||
cands, err := repo.SuggestAddressesForPeer(r.Context(), agentName, name)
|
||
if err != nil {
|
||
Error(w, http.StatusInternalServerError, "Failed to list addresses")
|
||
return
|
||
}
|
||
addresses := make([]string, 0, len(cands))
|
||
for _, c := range cands {
|
||
addresses = append(addresses, c.Address)
|
||
}
|
||
JSON(w, http.StatusOK, map[string]any{
|
||
"kind": "session_flat",
|
||
"addresses": emptySlice(addresses),
|
||
"candidates": emptySlice(cands),
|
||
// paths 一并给回:调用方若要按 path 逐个展开,不必再调一次。
|
||
"paths": pathCandidates(mustPaths(r, name)),
|
||
})
|
||
return
|
||
}
|
||
|
||
if path == "" {
|
||
paths, _ := repo.SuggestPaths(r.Context(), name)
|
||
JSON(w, http.StatusOK, map[string]any{
|
||
"kind": "path",
|
||
"suggestions": emptySlice(paths),
|
||
// ★ 2026-10-02(DSH 侧报告 B/C):path 候选里混着**桥的内部会话目录**
|
||
// (如 /root/.pi/mail-sessions/<uuid>),实测 64 条候选里 33 条是它。
|
||
// 而 `root` 与 `/root` 在数据里真的是两个不同工作区(实测 1 条 vs 17 条),
|
||
// 外观只差一个斜杠。调用方无从区分,选中即静默投进错误线索。
|
||
//
|
||
// 所以这里**标注**而不是隐藏:隐藏会让人以为那些工作区不存在,
|
||
// 而它们确实是某些会话的真实 cwd(只是不该出现在「工作区」候选里)。
|
||
"paths": pathCandidates(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
|
||
}
|
||
|
||
// pathCandidate 是一个 path 候选,外加**它是不是真工作区**的标注。
|
||
//
|
||
// ★ 2026-10-02(DSH 侧报告 B/C)。为什么必须标注而不是直接过滤:
|
||
//
|
||
// /root/.pi/mail-sessions/<uuid> 这类**桥内部目录**不是工作区,
|
||
// 但它确实是某些会话的真实 cwd
|
||
// (报告实测:投 pi@root 落进的那条会话
|
||
// workspace='root',uuid 正是其中一个)
|
||
// 滤掉它 ⇒ 那些会话在这个 name 下彻底不可见,调用方连"存在这样的线索"都不知道
|
||
// 留下不标 ⇒ 64 条候选里 33 条是噪声,模型选中即静默投错
|
||
//
|
||
// 所以两条信息都给:suggestions 保持原样(向后兼容,各桥按它取 path),
|
||
// paths[] 里带 kind 标注,让调用方能降权或跳过。
|
||
type pathCandidate struct {
|
||
Path string `json:"path"`
|
||
// Kind 是 "workspace" 或 "bridge-internal"。
|
||
Kind string `json:"kind"`
|
||
// Note 只在 kind != "workspace" 时给,一句话说清它是什么。
|
||
Note string `json:"note,omitempty"`
|
||
// IsAbsolute 标出**相对路径**。`root` 与 `/root` 在数据里是两个不同工作区,
|
||
// 而外观只差一个开头的斜杠 —— 三维地址的 path 位会被原样当作 cwd。
|
||
IsAbsolute bool `json:"is_absolute"`
|
||
}
|
||
|
||
// bridgeInternalMarkers 是各桥把「会话存储」放在哪的痕迹。
|
||
//
|
||
// 这些目录不是工作区,而是桥为每条会话建的落地点。它们出现在 path 候选里
|
||
// 是因为 mails.to_workspace 记的就是**投递时的 path 位**,而模型发信时如果
|
||
// 猜了这类目录,信真的会落在那里(于是那条会话的 cwd 就成了它)。
|
||
var bridgeInternalMarkers = []string{
|
||
"/.pi/mail-sessions/",
|
||
"/mail-sessions/",
|
||
"/.agentmail/sessions/",
|
||
"/.zcode/mail-sessions/",
|
||
"/.dsh/",
|
||
}
|
||
|
||
func classifyPath(p string) pathCandidate {
|
||
c := pathCandidate{Path: p, Kind: "workspace", IsAbsolute: strings.HasPrefix(p, "/")}
|
||
for _, m := range bridgeInternalMarkers {
|
||
if strings.Contains(p, m) {
|
||
c.Kind = "bridge-internal"
|
||
c.Note = "这是 Agent 桥的内部会话存储目录,不是项目工作区;投到这里的信会把会话 cwd 变成它"
|
||
break
|
||
}
|
||
}
|
||
if !c.IsAbsolute {
|
||
c.Note = strings.TrimSpace(c.Note + " 另:这是相对路径,与 /" + strings.TrimPrefix(p, "/") + " 是两个不同工作区")
|
||
}
|
||
return c
|
||
}
|
||
|
||
func pathCandidates(paths []string) []pathCandidate {
|
||
out := make([]pathCandidate, 0, len(paths))
|
||
for _, p := range paths {
|
||
out = append(out, classifyPath(p))
|
||
}
|
||
return out
|
||
}
|
||
|
||
func mustPaths(r *http.Request, name string) []string {
|
||
paths, err := repo.SuggestPaths(r.Context(), name)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
return paths
|
||
}
|