diff --git a/plugins/pi-mail-bridge/src/tools.mjs b/plugins/pi-mail-bridge/src/tools.mjs index 3fb40c4..8cdf42c 100644 --- a/plugins/pi-mail-bridge/src/tools.mjs +++ b/plugins/pi-mail-bridge/src/tools.mjs @@ -62,14 +62,33 @@ const text = (s) => ({ content: [{ type: 'text', text: s }] }); * 「更严格更好」,而是与 pi 的参数传递机制直接冲突。 */ export function createMailTools({ client, log, agentName = '', onReconnect, getMailSessionId = () => ''}) { - // 收件箱列表的会话收窄参数。 + // ─── 会话收窄参数(读类工具的公共前缀)─── // - // ★ 缺了它,这条列表会把**别的会话**的来信一起列出来并按契约标成已读 —— - // 别的会话的 worker 之后按 ?status=unread 补投时就再也看不到那封信。 - // 用户原话:「不同 session 的 agent 都可以看到全部邮件」。 - const inboxScope = () => { + // 服务端拿它干两件事: + // ① 收件箱只列/只标本会话的邮件(缺了它,A 会话的 worker 会把 B 会话的未读标掉 + // ⇒ 补投再也看不到那封信 = 静默丢信。用户原话「不同 session 的 agent + // 都可以看到全部邮件」); + // ② **工作区隔离**:服务端由这条 session 反查 workspace,只有同工作区的会话才放行。 + // 一个 Agent 同时服务所有工作区,不带这一维时在 TrueAgent 里干活的 worker + // 能读到 agentmail 的整条线索(2026-09-14 用户报的那类越界)。 + // + // 取值只能是**邮件会话 id**,且必须由 worker 闭包递进来(模型改不了它)—— + // 服务端不接受调用方直接声明工作区,那等于自己给自己发通行证。 + const scopeQS = () => { const sid = typeof getMailSessionId === 'function' ? getMailSessionId() : ''; - return sid ? `&session_id=${encodeURIComponent(sid)}` : ''; + return sid ? `session_id=${encodeURIComponent(sid)}` : ''; + }; + // 已经带了查询串的 URL 用这个(收件箱那条要 append 到 status/limit 后面) + const inboxScope = () => { + const q = scopeQS(); + return q ? `&${q}` : ''; + }; + // 任意路径用这个:自己判断该用 ? 还是 &。缺了 scope 就原样返回, + // 让服务端走"旧语义 + 记警告"那条路,而不是拼出一个半截 URL。 + const withScope = (path) => { + const q = scopeQS(); + if (!q) return path; + return path.includes('?') ? `${path}&${q}` : `${path}?${q}`; }; const sendMail = { @@ -293,6 +312,10 @@ export function createMailTools({ client, log, agentName = '', onReconnect, getM const qs = new URLSearchParams(); if (name) qs.set('name', name); if (path) qs.set('path', path); + // 会话候选按调用方的工作区收窄(name/path 两段不收窄:跨工作区**发信** + // 是设计允许的,被挡的只是"浏览别的会话的标题/别名")。 + const sid = typeof getMailSessionId === 'function' ? getMailSessionId() : ''; + if (sid) qs.set('session_id', sid); const data = await client.get(`/agent/contacts/suggest?${qs.toString()}`); // 按服务端回的 kind 分派而不是按本地参数:省略与传空串在服务端 // 是同一个意思,但「哪一段该渲染成什么」只有服务端知道。 @@ -308,7 +331,7 @@ export function createMailTools({ client, log, agentName = '', onReconnect, getM name: 'list_contacts', label: 'ListContacts', description: - '列出自己参与过的全部会话及各自的可投递地址、未读数、剩余往返预算。' + + '列出自己参与过的会话(**只含本工作区**)及各自的可投递地址、未读数、剩余往返预算。' + '用于回答「我还有什么没处理」与「上次跟某人聊的那条线索地址是什么」。', parameters: { type: 'object', @@ -317,7 +340,7 @@ export function createMailTools({ client, log, agentName = '', onReconnect, getM }, }, async execute(_id, params) { - const data = await client.get('/agent/contacts'); + const data = await client.get(withScope('/agent/contacts')); return text(renderContacts(data, params.limit || 20)); }, }; @@ -336,7 +359,7 @@ export function createMailTools({ client, log, agentName = '', onReconnect, getM required: ['session_id'], }, async execute(_id, params) { - const data = await client.get(`/agent/sessions/${params.session_id}/participants`); + const data = await client.get(withScope(`/agent/sessions/${params.session_id}/participants`)); return text(renderParticipants(data)); }, }; @@ -357,7 +380,7 @@ export function createMailTools({ client, log, agentName = '', onReconnect, getM }, async execute(_id, params) { const qs = params.offset ? `?offset=${params.offset}` : ''; - const data = await client.get(`/agent/mail/${params.mail_id}/thread${qs}`); + const data = await client.get(withScope(`/agent/mail/${params.mail_id}/thread${qs}`)); return text(renderThread(data, agentName)); }, }; @@ -376,7 +399,7 @@ export function createMailTools({ client, log, agentName = '', onReconnect, getM required: ['mail_id'], }, async execute(_id, params) { - const data = await client.get(`/agent/mail/${params.mail_id}`); + const data = await client.get(withScope(`/agent/mail/${params.mail_id}`)); const m = data?.mail || {}; const lines = [ `发件人: ${m.from_name || '?'}`, diff --git a/plugins/pi-mail-bridge/test/read-scope-wiring.test.mjs b/plugins/pi-mail-bridge/test/read-scope-wiring.test.mjs new file mode 100644 index 0000000..f59a2db --- /dev/null +++ b/plugins/pi-mail-bridge/test/read-scope-wiring.test.mjs @@ -0,0 +1,106 @@ +/** + * 读类工具必须把自己的**邮件会话 id** 递给服务端 —— 这是工作区隔离的前提。 + * + * # 两条缺陷,同一个根因 + * + * 1. 2026-09-14 上午:`read_inbox` 按 **Agent** 列未读并标已读 ⇒ A 会话的 worker + * 标掉 B 会话的未读(见 `inbox-session-scope.test.mjs`)。 + * 2. 同一天用户报的越界:「agentmail 工作区的邮件会话被 trueagent 工作区的 agent + * 看到了」。根因更深一层:**隔离单位选的是 Agent**。一个 Agent 同时服务所有 + * 工作区(注册时 workspaces 为空,cwd 由每封邮件的 to_workspace 决定), + * 所以 `AgentCanAccessSession`("这个 Agent 参与过这条会话")在 TrueAgent 的 + * worker 眼里,对 agentmail 的会话也成立。 + * + * 服务端现在的判据是「参与过 **且** 两条会话的 workspace 相同」,而 workspace + * **不接受调用方直接声明** —— 只认一条会话 id,由服务端反查。所以插件的义务只有 + * 一条:把 worker 当前那条邮件会话的 id 带上。 + * + * # 判据是行为,不是正则 + * + * 直接调工具、用假 client 收集请求 URL —— 断言"这次调用到底请求了什么"。 + * 静态正则只能证明"文件里有那句话",而这里要钉的是每个工具各自的 URL。 + * 反向对照:`getMailSessionId` 返回空串时 URL 里**不许**出现 session_id + * (证明上面那条断言不是恒真)。 + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { createMailTools } from '../src/tools.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const worker = readFileSync(join(HERE, '..', 'src', 'worker.mjs'), 'utf8'); + +const SID = '11111111-2222-3333-4444-555555555555'; + +/** 按路径给一份"形状对"的空回包:断言的是 URL,不是渲染结果。 */ +function fakeResponse(url) { + if (url.startsWith('/agent/contacts/suggest')) return { kind: 'name', suggestions: [] }; + if (url.startsWith('/agent/contacts')) return { contacts: [] }; + if (url.includes('/participants')) return { participants: [] }; + if (url.includes('/thread')) return { mails: [], session_alias: '' }; + if (url.startsWith('/agent/mail/')) return { mail: {}, participants: [] }; + return {}; +} + +function toolsWith(urls, sessionId) { + const client = { + get: async (u) => { urls.push(u); return fakeResponse(u); }, + post: async () => ({}), + downloadFile: async () => Buffer.from(''), + }; + const list = createMailTools({ + client, log: () => {}, agentName: 'pi', + getMailSessionId: () => sessionId, + }); + return (name) => { + const t = list.find(x => x.name === name); + assert.ok(t, `找不到工具 ${name}`); + return t; + }; +} + +// 每个"会读服务端"的工具 + 一组能走到发请求那一步的参数。 +const READ_CALLS = [ + { tool: 'read_inbox', args: {} }, + { tool: 'list_contacts', args: {} }, + { tool: 'read_mail', args: { mail_id: 'm-1' } }, + { tool: 'read_thread', args: { mail_id: 'm-1' } }, + { tool: 'session_participants', args: { session_id: 's-1' } }, + { tool: 'suggest_address', args: { name: 'dsh', path: '/home/program/x' } }, +]; + +for (const c of READ_CALLS) { + test(`★ ${c.tool} 的请求带上自己的邮件会话 id`, async () => { + const urls = []; + await toolsWith(urls, SID)(c.tool).execute('call-1', c.args); + assert.equal(urls.length, 1, `${c.tool} 应当只发一次请求(实际 ${urls.length} 次)`); + assert.ok( + urls[0].includes(`session_id=${SID}`), + `${c.tool} 的请求没带会话收窄:${urls[0]}` + ); + }); + + test(`${c.tool}:没有会话 id 时不硬拼空参数(旧语义那条路)`, async () => { + const urls = []; + await toolsWith(urls, '')(c.tool).execute('call-1', c.args); + assert.equal(urls.length, 1); + assert.ok( + !urls[0].includes('session_id='), + `${c.tool} 在拿不到会话 id 时不该拼出空的 session_id:${urls[0]}` + ); + }); +} + +test('worker 把邮件会话 id 递给工具(闭包,不是快照)', () => { + assert.match(worker, /getMailSessionId: \(\) => mailContext\.sessionID/, '要传闭包而不是快照'); + assert.match(worker, /sessionID: msg\.data\?\.session_id/, '会话 id 来自 SSE 事件'); +}); + +test('★ 判据自检:不带收窄的旧写法必须判红', () => { + const oldCall = "await client.get('/agent/contacts')"; + assert.equal(/withScope\('\/agent\/contacts'\)/.test(oldCall), false, + '旧写法既没 withScope 也没 session_id —— 正则若匹配上,说明判据恒真'); +}); diff --git a/server/internal/handler/agent_discovery.go b/server/internal/handler/agent_discovery.go index 6317f73..0357acf 100644 --- a/server/internal/handler/agent_discovery.go +++ b/server/internal/handler/agent_discovery.go @@ -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 } diff --git a/server/internal/repo/platform_sessions.go b/server/internal/repo/platform_sessions.go index a9edbcc..4d9fdd2 100644 --- a/server/internal/repo/platform_sessions.go +++ b/server/internal/repo/platform_sessions.go @@ -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 diff --git a/server/internal/repo/repo.go b/server/internal/repo/repo.go index e23b5dd..b5c7231 100644 --- a/server/internal/repo/repo.go +++ b/server/internal/repo/repo.go @@ -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,改用关联子查询逐列取值 diff --git a/server/internal/repo/session_workspace.go b/server/internal/repo/session_workspace.go index 9851cf0..d4dbd30 100644 --- a/server/internal/repo/session_workspace.go +++ b/server/internal/repo/session_workspace.go @@ -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 +} diff --git a/server/internal/repo/workspace_scope_test.go b/server/internal/repo/workspace_scope_test.go new file mode 100644 index 0000000..cba71f9 --- /dev/null +++ b/server/internal/repo/workspace_scope_test.go @@ -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 +}