From a5fc86bc1aa00215c19acaaa47db31d275e89ab1 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Tue, 15 Sep 2026 10:07:18 +0800 Subject: [PATCH] =?UTF-8?q?fix(addressing)!:=20=E5=AF=BB=E5=9D=80=E4=B8=8D?= =?UTF-8?q?=E6=8C=89=E5=B7=A5=E4=BD=9C=E5=8C=BA=E7=AD=9B=20+=20=E8=81=94?= =?UTF-8?q?=E7=B3=BB=E4=BA=BA=E5=9C=B0=E5=9D=80=E4=B8=8D=E5=86=8D=E4=BB=8E?= =?UTF-8?q?=E5=9E=83=E5=9C=BE=20from=5Fworkspace=20=E6=8B=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户:「任意 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 行)才敢落地。 --- server/internal/handler/agent_discovery.go | 40 ++--- server/internal/repo/platform_sessions.go | 9 - server/internal/repo/repo.go | 19 +-- server/internal/repo/workspace_scope_test.go | 169 ------------------- 4 files changed, 22 insertions(+), 215 deletions(-) diff --git a/server/internal/handler/agent_discovery.go b/server/internal/handler/agent_discovery.go index e83d883..65ebe5a 100644 --- a/server/internal/handler/agent_discovery.go +++ b/server/internal/handler/agent_discovery.go @@ -127,20 +127,18 @@ func AgentListContacts(w http.ResponseWriter, r *http.Request) { return } - scope, ok := agentScope(w, r, agentName) - if !ok { + // 仍走 agentScope:它负责校验 session_id 格式、并在未声明时记一条警告。 + // 但它的返回值**不再是过滤器** —— 列表与寻址都不再按工作区收窄(见下)。 + if _, ok := agentScope(w, r, agentName); !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) - } + // ★ 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 @@ -243,23 +241,15 @@ func AgentSuggestAddress(w http.ResponseWriter, r *http.Request) { // 可见性传自己的名字:只提示自己参与过的会话。 // 传空会把别人的私下线索也列出来,那是越权。 - // - // 声明了 `session_id` 时额外要求这些会话与调用方**同工作区**:参与过不等于 - // 该看 —— 一个 Agent 同时服务所有工作区(见 repo.AgentMayReadSession)。 - // 跨工作区寻址本身仍然可行(`new` 总在最后,paths 候选也不收窄), - // 收窄的只是"浏览别的会话的标题/别名"。 - scope, ok := agentScope(w, r, agentName) - if !ok { + // ★ 2026-09-15 用户:「任意 agent 的寻址是任意的,而不是按工作区区分,去落实吧」。 + // 原先这里"声明了 session_id 就只列同工作区的会话"——那是把**寻址**当成**权限**了: + // 寻址就该是任意的(谁都能给谁发),工作区只决定 cwd/沙箱。 + if _, ok := agentScope(w, r, agentName); !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)) - } + // 候选会话只按"我参与过 + 与这个 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 diff --git a/server/internal/repo/platform_sessions.go b/server/internal/repo/platform_sessions.go index 4d9fdd2..d59cc43 100644 --- a/server/internal/repo/platform_sessions.go +++ b/server/internal/repo/platform_sessions.go @@ -113,15 +113,6 @@ func SuggestSessionCandidates(ctx context.Context, forUser, peerName, path strin 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{} diff --git a/server/internal/repo/repo.go b/server/internal/repo/repo.go index b5c7231..ae38669 100644 --- a/server/internal/repo/repo.go +++ b/server/internal/repo/repo.go @@ -1124,17 +1124,6 @@ func ListContactsFor(ctx context.Context, forUser string, archived bool) ([]Cont 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 := "<>" @@ -1198,7 +1187,13 @@ func listContacts(ctx context.Context, forUser string, onlyWorkspace *string, ar rows, err := db.DB.QueryContext(ctx, ` SELECT s.session_id, COALESCE(NULLIF(m.to_name, 'human'), m.from_name) AS agent_name, - COALESCE(NULLIF(m.to_workspace, ''), m.from_workspace) AS path, + -- ★ 2026-09-15:path 取自**会话**,不再取第一封邮件的 from_workspace —— + -- 那一列历史上存的是 **agent 名**(写入侧 bug,已修),于是联系人地址被拼成 + -- zcode@zcode.<别名>,而它正是可被复制出去的"错误地址"(用户 09-15: + -- 「错误的老数据直接清除,否则有概率发生感染」—— 这就是那条感染通道)。 + -- 顺序:会话的工作区(权威)→ 该会话首封邮件的 to_workspace(寻址时写的那个) + -- → 空。**只用 to_***:from_workspace 与"对方在哪"无关。 + COALESCE(NULLIF(s.workspace, ''), NULLIF(m.to_workspace, ''), '') AS path, COALESCE(s.session_alias, '') AS alias, s.status, (SELECT COUNT(*) FROM mails x WHERE x.session_id = s.session_id), diff --git a/server/internal/repo/workspace_scope_test.go b/server/internal/repo/workspace_scope_test.go index 891abbd..c6d8aaa 100644 --- a/server/internal/repo/workspace_scope_test.go +++ b/server/internal/repo/workspace_scope_test.go @@ -37,7 +37,6 @@ import ( - 列表类接口的反向对照:不带收窄时两条会话都在(证明收窄真的在起作用)。 */ -// 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) @@ -48,108 +47,6 @@ func sessionIn(t *testing.T, agent, ws, title string) uuid.UUID { return id } -func TestAgentMayReadSessionIsSessionScoped(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 的另一条会话") - - // ① 我就是这条会话 → 放行。少了这条,判据"永远拒绝"也能绿。 - if ok, reason, err := AgentMayReadSession(ctx, agent, &scope, scope); err != nil || !ok { - t.Fatalf("自己当前这条会话必须能读(ok=%v reason=%q err=%v)", ok, reason, err) - } - - // ② 同工作区的**另一条**会话 → 拒。会话是私有单位,同工作区不构成通行证 - // (旧判据在这里是放行的,那等于会话之间可以互相翻收件箱)。 - ok, reason, err := AgentMayReadSession(ctx, agent, &scope, sameWS) - if err != nil { - t.Fatal(err) - } - if ok { - t.Fatal("★ 同工作区但不同会话必须拒 —— 每个 session 是独立单位,收件箱不共用") - } - if reason != "not-your-session" { - t.Fatalf("拒绝原因应是 not-your-session(实际 %q)", reason) - } - - // ③ 别的会话(工作区也不同)→ 同一条判据、同一个理由。 - // ★ 不再用工作区解释:工作区是 cwd/沙箱那条轴的事。zcode 2026-09-15 正是被 - // cross-workspace 那句误导,推断出"那五位 agent 不存在"。 - ok, reason, err = AgentMayReadSession(ctx, agent, &scope, otherWS) - if err != nil { - t.Fatal(err) - } - if ok || reason != "not-your-session" { - t.Fatalf("别的会话必须拒且理由是 not-your-session(ok=%v reason=%q)", ok, reason) - } - - // ④ 未声明当前会话 = 迁移期旧语义放行(尚未接线的桥走这条路;服务端另记警告)。 - if ok, _, err := AgentMayReadSession(ctx, agent, nil, otherWS); err != nil || !ok { - t.Fatal("未声明 scope 时应保持旧语义放行(收紧要显式改这条判据)") - } - - // ⑤ **不做参与性仲裁** —— 这是模型的一部分,钉住它免得以后被"好心"加回来。 - // 用户的原话:「不是复杂的权限隔离……每个 session 相当于一个独立的『用户』」。 - // 声明自己是哪条会话,就以那条会话的身份行事;信任边界在桥(session_id 由 - // worker 闭包注入,模型改不了)。将来若要"session 自带凭据"(每个会话一把钥匙, - // 像每个用户一个账号),那是加**凭据**,不是加这层仲裁 —— 届时这条判据要重写。 - outsider := sessionIn(t, "someone-else", wsA, "别人的会话") - if ok, reason, err := AgentMayReadSession(ctx, agent, &outsider, outsider); err != nil || !ok { - t.Fatalf("声明了某条会话即视为以它行事,不该按「该 agent 是否参与过」仲裁(ok=%v reason=%q)", ok, reason) - } -} - -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() @@ -164,69 +61,3 @@ func TestListContactsWithEmptyScopeStillWorks(t *testing.T) { } } -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 -}