fix(auth): 工作区成为读权限的边界 —— Agent 侧读端点按会话工作区收窄

用户报的:「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 个文件。)
This commit is contained in:
2026-09-14 23:03:25 +08:00
parent 4c2bf26c42
commit 1b8cd43935
7 changed files with 602 additions and 34 deletions

View File

@ -1,6 +1,7 @@
package handler
import (
"log"
"net/http"
"strings"
@ -45,10 +46,73 @@ import (
// 这里没有任何写端点。归档、改别名、决策权限都仍然只有人能做 ——
// 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`。
// 本 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 == "" {
@ -56,8 +120,20 @@ func AgentListContacts(w http.ResponseWriter, r *http.Request) {
return
}
scope, ok := agentScope(w, r, agentName)
if !ok {
return
}
archived := r.URL.Query().Get("archived") == "true"
contacts, err := repo.ListContactsFor(r.Context(), agentName, archived)
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
@ -160,7 +236,23 @@ func AgentSuggestAddress(w http.ResponseWriter, r *http.Request) {
// 可见性传自己的名字:只提示自己参与过的会话。
// 传空会把别人的私下线索也列出来,那是越权。
sessions, err := repo.SuggestSessionCandidates(r.Context(), agentName, name, path)
//
// 声明了 `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
@ -192,15 +284,25 @@ func AgentSuggestAddress(w http.ResponseWriter, r *http.Request) {
// GET /api/v1/agent/mail/{id}/thread
//
// 与人类侧 `/mail/{id}/thread` 同一份实现,可见性判据换成
// 「本 Agent 参与过该会话」。抄送协作要靠它回答「谁已经回了、谁还没回」。
// 「本 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) {
return repo.AgentCanAccessSession(r.Context(), agentName, sid)
// 丢掉 reason 而不是改 serveMailThread 的签名:人类侧 `/mail/{id}/thread`
// 与这里共用同一份实现,只为一个调用点的文案去改它不划算。
// 拒绝原因(跨工作区 / 没参与过)在 403 文案上合流成同一句,
// 反而少泄一点信息。
allowed, _, err := repo.AgentMayReadSession(r.Context(), agentName, scope, sid)
return allowed, err
})
}
@ -214,6 +316,10 @@ func AgentGetMail(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusUnauthorized, "Unauthorized")
return
}
scope, ok := agentScope(w, r, agentName)
if !ok {
return
}
mailID, ok := pathUUID(w, r, "id")
if !ok {
return
@ -224,13 +330,7 @@ func AgentGetMail(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusNotFound, "Mail not found")
return
}
allowed, err := repo.AgentCanAccessSession(r.Context(), agentName, mail.SessionID)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to check permission")
return
}
if !allowed {
Error(w, http.StatusForbidden, "无权访问该邮件")
if !canReadSession(w, r, agentName, scope, mail.SessionID) {
return
}
@ -264,13 +364,11 @@ func AgentSessionParticipants(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
allowed, err := repo.AgentCanAccessSession(r.Context(), agentName, sessionID)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to check permission")
scope, ok := agentScope(w, r, agentName)
if !ok {
return
}
if !allowed {
Error(w, http.StatusForbidden, "无权访问该会话")
if !canReadSession(w, r, agentName, scope, sessionID) {
return
}

View File

@ -110,9 +110,32 @@ type SessionCandidate struct {
// 本侧优先:邮件线索是「这个别名一定送得到」的保证,而镜像只是平台的说法。
// 同名时保留本侧那条,并把镜像的标题补上去(镜像通常有更新的标题)。
func SuggestSessionCandidates(ctx context.Context, forUser, peerName, path string) ([]SessionCandidate, error) {
return suggestSessionCandidates(ctx, forUser, peerName, path, nil)
}
// SuggestSessionCandidatesInWorkspace 额外要求候选会话属于 `workspace`。
//
// Agent 侧声明了「我在哪条会话」时走这条:别人工作区的会话别名与标题
// 对调用方就是不该看的信息(与 repo.AgentMayReadSession 同一条命)。
// 只收窄会话候选:`paths`(对方用过哪些目录)与 `new` 都保留,
// 因为跨工作区**发信**是设计允许的,被挡的只是"浏览同行的线索"。
func SuggestSessionCandidatesInWorkspace(ctx context.Context, forUser, peerName, path, workspace string) ([]SessionCandidate, error) {
return suggestSessionCandidates(ctx, forUser, peerName, path, &workspace)
}
func suggestSessionCandidates(ctx context.Context, forUser, peerName, path string, onlyWorkspace *string) ([]SessionCandidate, error) {
out := []SessionCandidate{}
seen := map[string]int{} // alias -> out 下标
// 工作区收窄只在本侧线索那条查询里能加:`sessions.workspace` 才是会话的工作区,
// 镜像表(来源 2)自己那份 workspace 列另算。
wsClause := ""
wsArgs := []any{}
if onlyWorkspace != nil {
wsClause = ` AND COALESCE(s.workspace, '') = $4`
wsArgs = append(wsArgs, *onlyWorkspace)
}
// ---- 来源 1:本侧邮件线索 ----
//
// sessions.workspace 是权威来源。它是新加的列,历史会话为空串,
@ -146,9 +169,9 @@ func SuggestSessionCandidates(ctx context.Context, forUser, peerName, path strin
WHERE mm.session_id = s.session_id
AND (mm.from_name = $3 OR mm.to_name = $3
OR `+db.CCHas("mm.cc_list", 3)+`)
))
))`+wsClause+`
ORDER BY s.updated_at DESC
`, peerName, path, forUser)
`, append([]any{peerName, path, forUser}, wsArgs...)...)
if err != nil {
return out, err
}
@ -176,16 +199,26 @@ func SuggestSessionCandidates(ctx context.Context, forUser, peerName, path strin
}
// ---- 来源 2:平台会话镜像 ----
//
// 镜像表有自己的 workspace 列,所以这里另写一个参数位($3),
// 不与来源 1 复用 $4 —— 两条查询的参数个数不同,硬凑一个编号只会让
// 下一次改动踩到"占了位子却没人绑"的坑。
mirrorWS := ""
mirrorArgs := []any{peerName, path}
if onlyWorkspace != nil {
mirrorWS = ` AND COALESCE(workspace, '') = $3`
mirrorArgs = append(mirrorArgs, *onlyWorkspace)
}
prows, err := db.DB.QueryContext(ctx, `
SELECT slug, title, platform_id
FROM agent_platform_sessions
WHERE agent_name = $1
AND slug <> ''
AND ($2 = '' OR workspace = $2)
AND ($2 = '' OR workspace = $2)`+mirrorWS+`
-- 不用 NULLS LAST:它要 SQLite 3.30+,而驱动自带的版本不由我们控制。
-- COALESCE 在两个方言里都成立,语义也更直接:没有 updated_at 就用上报时间。
ORDER BY COALESCE(updated_at, reported_at) DESC
`, peerName, path)
`, mirrorArgs...)
if err != nil {
// 镜像查不到不该让整个补全失败:本侧线索已经够用了
return out, nil

View File

@ -1121,12 +1121,34 @@ type Contact struct {
// forUser 非空时只列该用户参与的会话(owner / 收发 / 抄送);空表示不限(管理员全局视图)。
// archived=false 只列活跃会话,true 只列归档会话。
func ListContactsFor(ctx context.Context, forUser string, archived bool) ([]Contact, error) {
return listContacts(ctx, forUser, nil, archived)
}
// ListContactsInWorkspace 只在**同一个工作区**里列会话 —— Agent 侧读联系人的默认口径。
//
// 为什么 Agent 侧要收窄:一个 Agent 同时服务所有工作区(见 AgentMayReadSession 的注释),
// 不收窄的话,在 TrueAgent 里干活的 worker 调 list_contacts 会拿到 agentmail 工作区的
// 会话标题、别名与未读计数 —— 而它连看都不该看到(用户 2026-09-14 报的那类越界)。
//
// 人类侧刻意**不走**这条:人的可见性由 `UserCanAccessSession` 与 owner 决定,
// 与工作区不是一回事(一个人类可能同时管好几个项目的会话)。
func ListContactsInWorkspace(ctx context.Context, agentName, workspace string, archived bool) ([]Contact, error) {
return listContacts(ctx, agentName, &workspace, archived)
}
func listContacts(ctx context.Context, forUser string, onlyWorkspace *string, archived bool) ([]Contact, error) {
op := "<>"
if archived {
op = "="
}
// $1 恒为 forUser,即使它是空串。
//
// 未读计数那个子查询里 `r.reader_name = $1` **一直在引用 $1**,而原先的写法是
// 「forUser 为空就不传参」—— 那样 $1 就悬空了:Postgres 直接报 no parameter $1,
// SQLite 则静默把 `= $1` 当 `= NULL` 比(次次不成立,未读计数退化成“全部未归档”)。
// 管理员 `?all=true` 正是走的那条悬空路径(scope="")。
scope := ""
args := []any{}
args := []any{forUser}
if forUser != "" {
scope = ` AND (s.owner_user_id = (SELECT user_id FROM users WHERE username = $1)
OR EXISTS (
@ -1135,7 +1157,10 @@ func ListContactsFor(ctx context.Context, forUser string, archived bool) ([]Cont
AND (mm.from_name = $1 OR mm.to_name = $1
OR ` + db.CCHas("mm.cc_list", 1) + `)
))`
args = append(args, forUser)
}
if onlyWorkspace != nil {
args = append(args, *onlyWorkspace)
scope += fmt.Sprintf(` AND COALESCE(s.workspace, '') = $%d`, len(args))
}
// 取会话里最早那封邮件作为联系人身份。
// PG 用 LATERAL 子查询;SQLite 无 LATERAL,改用关联子查询逐列取值

View File

@ -33,3 +33,53 @@ func SessionWorkspaceOf(ctx context.Context, id uuid.UUID) string {
}
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
}

View File

@ -0,0 +1,233 @@
package repo
import (
"context"
"testing"
"github.com/google/uuid"
)
/*
工作区维度:一个 Agent 同时服务**所有**工作区,所以"参与过"不等于"该看"。
# 用户报的缺陷
「agentmail 工作区的邮件会话被 trueagent 工作区的 agent 看到了,还需要我亲自去解释。」
根因不是某处漏了一个 WHERE,而是**隔离单位选的是 Agent**:
- `AgentCanAccessSession(agentName, sid)` 判的是「这个 Agent 名出现在这条会话的
from/to/cc 里」;
- 而 Agent 注册时 `workspaces` 是空的(B-1.2:cwd 由每封邮件的 `to_workspace`
决定),于是同一个 agent `pi` 既"参与过" agentmail 的会话、也"参与过"
TrueAgent 的会话 —— 两个工作区之间没有任何边界。
现场证据:`mail_reads` 里 2026-09-14 08:11–09:19 有 8 次"同一瞬间读了多个不同工作区
的会话"(最典型 08:23:59 一次跨 agentmail / TrueAgent / webui4frpc 三条会话),
而那正是按 Agent 整表读的特征。更要紧的是 `mail_reads` 只记 `reader_name`,
**没有"读的人当时在哪个工作区"这一列** —— 这类越界读在数据上与正常读无法区分。
# 判据两侧都验
只验"跨工作区被拒"是不够的:把函数写成永远拒绝也能过。所以同时验
- 同工作区必须放行(否则等于把所有 Agent 都锁死);
- 未声明 scope 时的旧语义(迁移期妥协,必须明确写下来,不能靠"没人测"存在);
- 拒绝的**原因**要分得清(没参与过 vs 跨工作区)—— 否则调用方无法自查;
- 列表类接口的反向对照:不带收窄时两条会话都在(证明收窄真的在起作用)。
*/
// sessionIn 在工作区 ws 里造一条会话,并让它与该 Agent 有过一次往来。
func sessionIn(t *testing.T, agent, ws, title string) uuid.UUID {
t.Helper()
id, err := CreateSession(context.Background(), nil, "human", title, ws)
if err != nil {
t.Fatalf("create session(%s): %v", title, err)
}
seedMailInSession(t, id, agent)
return id
}
func TestAgentMayReadSessionIsWorkspaceScoped(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
const agent = "pi"
const wsA = "/home/program/agentmail"
const wsB = "/home/program/TrueAgent"
scope := sessionIn(t, agent, wsA, "我在 A 干活")
sameWS := sessionIn(t, agent, wsA, "A 的另一条线索")
otherWS := sessionIn(t, agent, wsB, "B 的线索")
alien := sessionIn(t, "someone-else", wsA, "与我无关的线索")
// ① 同工作区 → 放行。少了这条,判据"永远拒绝"也能绿。
ok, reason, err := AgentMayReadSession(ctx, agent, &scope, sameWS)
if err != nil {
t.Fatal(err)
}
if !ok {
t.Fatalf("同工作区的会话必须能读,却被拒(reason=%q)", reason)
}
// ② 跨工作区 → 拒,且原因要说清是工作区不对(不是"没参与过")。
ok, reason, err = AgentMayReadSession(ctx, agent, &scope, otherWS)
if err != nil {
t.Fatal(err)
}
if ok {
t.Fatal("★ 跨工作区必须拒 —— 用户报的就是这个(TrueAgent 的 worker 读到 agentmail 的线索)")
}
if reason != "cross-workspace" {
t.Fatalf("拒绝原因应是 cross-workspace(实际 %q):分不清原因,调用方就没法自查", reason)
}
// ③ 没参与过的会话 → 拒,且原因是 not-participant(两道闸门要能分开)。
ok, reason, err = AgentMayReadSession(ctx, agent, &scope, alien)
if err != nil {
t.Fatal(err)
}
if ok {
t.Fatal("没参与过的会话必须拒")
}
if reason != "not-participant" {
t.Fatalf("拒绝原因应是 not-participant(实际 %q)", reason)
}
// ④ 未声明 scope = 旧语义放行。**这是迁移期的妥协,不是正确行为**:
// 尚未接线的桥会走这条路,所以它必须有判据守着 —— 哪天要收紧成"拒",
// 这条测试会红,逼人去看还有谁没接线(服务端同时也记警告日志)。
ok, _, err = AgentMayReadSession(ctx, agent, nil, otherWS)
if err != nil {
t.Fatal(err)
}
if !ok {
t.Fatal("未声明 scope 时是旧语义(放行)—— 要收紧请连这条判据一起改")
}
}
func TestListContactsInWorkspace(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
const agent = "pi"
const wsA = "/home/program/agentmail"
const wsB = "/home/program/TrueAgent"
sessionIn(t, agent, wsA, "A 的线索")
sessionIn(t, agent, wsB, "B 的线索")
// 反向对照:不带工作区收窄时两条都在(证明下一段的收窄真的在起作用)。
all, err := ListContactsFor(ctx, agent, false)
if err != nil {
t.Fatal(err)
}
if len(all) != 2 {
t.Fatalf("不带收窄时应当两条都在(实际 %d 条)", len(all))
}
scoped, err := ListContactsInWorkspace(ctx, agent, wsA, false)
if err != nil {
t.Fatal(err)
}
if len(scoped) != 1 {
t.Fatalf("★ 收窄到 A 之后应当只剩 1 条(实际 %d 条)", len(scoped))
}
if scoped[0].Subject != "A 的线索" {
t.Fatalf("留下来的应当是 A 的线索(实际 %q)", scoped[0].Subject)
}
// 另一侧也要验:方向反了(总是返回第一条)同样能骗过上面那两条。
scopedB, err := ListContactsInWorkspace(ctx, agent, wsB, false)
if err != nil {
t.Fatal(err)
}
if len(scopedB) != 1 || scopedB[0].Subject != "B 的线索" {
t.Fatalf("收窄到 B 应当只剩 B 的线索(实际 %d 条)", len(scopedB))
}
}
// 管理员那条路(scope 为空 = 看全部)必须能跑通。
//
// ★ 这条是顺手修掉的真 bug:未读计数子查询里一直有 `r.reader_name = $1`,
// 而原先的写法是"forUser 为空就不传参" —— $1 于是悬空。Postgres 直接报
// `no parameter $1`;SQLite 把 `= $1` 当 `= NULL` 比,次次不成立,未读计数
// 静默退化成"全部未归档"。判据只钉"不报错 + 能列出会话"这两条硬事实。
func TestListContactsWithEmptyScopeStillWorks(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sessionIn(t, "pi", "/home/program/agentmail", "随便一条线索")
all, err := ListContactsFor(ctx, "", false)
if err != nil {
t.Fatalf("scope 为空(管理员看全部)不该报错:%v", err)
}
if len(all) != 1 {
t.Fatalf("应当列出 1 条(实际 %d 条)", len(all))
}
}
func TestSuggestSessionCandidatesInWorkspace(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
const agent = "pi"
const wsA = "/home/program/agentmail"
const wsB = "/home/program/TrueAgent"
// 别名是候选列表的可见内容,所以两条会话都要有别名。
aliasA, aliasB := "线索-a", "线索-b"
mustCreateNamed := func(alias, ws, title string) {
t.Helper()
id, err := CreateSession(ctx, &alias, "human", title, ws)
if err != nil {
t.Fatal(err)
}
seedMailInSession(t, id, agent)
}
mustCreateNamed(aliasA, wsA, "A 的线索")
mustCreateNamed(aliasB, wsB, "B 的线索")
// 同工作区:路径与调用方工作区一致 → 候选照常给出。
// 少了这条,"永远返回空"也能骗过下面那条。
sameWS, err := SuggestSessionCandidatesInWorkspace(ctx, agent, agent, wsA, wsA)
if err != nil {
t.Fatal(err)
}
if !hasAlias(sameWS, aliasA) {
t.Fatalf("本工作区的候选项该列出来(实际 %v)", aliasesOf(sameWS))
}
// 反向对照:**不带**工作区收窄时,去查 B 的路径是能列出 B 的会话别名的
// —— 那就是收窄前那个状态(跨工作区可浏览)。
raw, err := SuggestSessionCandidates(ctx, agent, agent, wsB)
if err != nil {
t.Fatal(err)
}
if !hasAlias(raw, aliasB) {
t.Fatalf("不带收窄时 B 的候选本该列出来(实际 %v)—— 对照组不成立,判据就是空的", aliasesOf(raw))
}
// ★ 带上调用方的工作区(人在 A,去查 B 的路径):B 的会话别名不能再出现。
cross, err := SuggestSessionCandidatesInWorkspace(ctx, agent, agent, wsB, wsA)
if err != nil {
t.Fatal(err)
}
if hasAlias(cross, aliasB) {
t.Fatalf("★ 别的工作区的会话别名不该被列出来(实际 %v)", aliasesOf(cross))
}
}
func hasAlias(list []SessionCandidate, alias string) bool {
for _, c := range list {
if c.Alias == alias {
return true
}
}
return false
}
func aliasesOf(list []SessionCandidate) []string {
out := make([]string, 0, len(list))
for _, c := range list {
out = append(out, c.Alias)
}
return out
}