diff --git a/server/internal/handler/agent_discovery.go b/server/internal/handler/agent_discovery.go index 0357acf..e83d883 100644 --- a/server/internal/handler/agent_discovery.go +++ b/server/internal/handler/agent_discovery.go @@ -92,13 +92,20 @@ func canReadSession(w http.ResponseWriter, r *http.Request, agentName string, sc return true } switch reason { - case "cross-workspace": + case "not-your-session": + // ★ 2026-09-15 用户订正:读信这条路只认会话 —— + // 「是应该发到对应 session 的信,因为 agent 多 session 架构,不同 session 的 + // 记忆是隔离的」。所以**不再拿工作区当理由**(旧文案就是 cross-workspace, + // 它让 zcode 推断出"那五位 agent 不存在",把错误结论写进了给人看的信里)。 + // 报出"我当前在哪条会话"是安全的(那是调用方自己的);**不报**目标会话在哪。 self := "" if scope != nil { - self = repo.SessionWorkspaceOf(r.Context(), *scope) + self = (*scope).String() } Error(w, http.StatusForbidden, - "无权访问该会话:调用方当前所在的工作区是 "+self+",目标会话不在同一个工作区") + "这封信不在你当前所在的那条会话里:每个会话各有各的收件箱(会话之间的记忆是隔离的)。"+ + "你当前在会话 "+self+"。要读它,就在它那条会话里读 —— 每封来信都会把 worker 唤醒到"+ + "它自己那条会话上,那时你看到的就是那条会话的收件箱。") default: Error(w, http.StatusForbidden, "无权访问该会话") } diff --git a/server/internal/repo/session_workspace.go b/server/internal/repo/session_workspace.go index d4dbd30..2144e10 100644 --- a/server/internal/repo/session_workspace.go +++ b/server/internal/repo/session_workspace.go @@ -36,38 +36,39 @@ func SessionWorkspaceOf(ctx context.Context, id uuid.UUID) string { // AgentMayReadSession 判「以 `scope` 为当前会话的 Agent 能不能读 `target`」。 // -// # 两道独立闸门,缺一不可 +// ★ 2026-09-15 用户订正(原话:「是应该发到对应 session 的信,因为 agent 多 session +// 架构,不同 session 的记忆是隔离的。你之前那个修法简直荒谬」): // -// ① 该 Agent 参与过目标会话(AgentCanAccessSession:出现在 from/to/cc 里) -// ② 两条会话的 workspace 相同(本函数新增的那道) +// **读信这条路只认会话,不认工作区。** // -// # 为什么必须补 ②:一个 Agent 同时服务所有工作区 +// ① target 必须就是 `scope`(我当前所在的那条会话)—— 「不同 session 不同收件箱」 +// ② 我确实参与过 target(防伪造 session_id:声明别人的会话也不行) // -// 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, -// 没有"读的人在哪"** —— 也就是说这类越界读在数据上与正常读无法区分。 +// # 为什么删掉原先的「工作区相同」那道闸门 // -// # 判据取严,且只有一个定义 +// 我把两条轴混在了一起:**工作区决定 cwd / 沙箱 / 谁能看到哪些线索**, +// 而**会话决定记忆与收件箱** —— 一个 agent 同时服务多个工作区,正是"多会话" +// 架构存在的理由。于是原先那条判据两头都错: // -// workspace 按字面比较(空字符串 = 空字符串),不相等即拒。刻意**不做** -// 「workspace 为空时回头从 mails 反推」那套兜底 —— 兜底会让"这条会话到底属于哪个 -// 工作区"有两个来源,而隔离判据最不需要的就是第二个来源。(2026-09-14 实测: -// 线上 workspace 为空的 6 条会话里,没有一条的邮件带 to_workspace,所以这条 -// 兜底在当前数据上不生效;真出现时应当**拒读并留下一条日志**,而不是悄悄放行。) +// · 太松:同一个工作区里,我参与过的**别的会话**也放行了 —— 而会话之间记忆隔离, +// 读别的会话等于绕过隔离(用户的原始要求就是"不同 session 不同收件箱"); +// · 太严:我在 A 工作区的上下文里,读不到**自己刚发起**的、B 工作区那条会话 —— +// 发起者读不到自己的会话(zcode 2026-09-15 撞上这条,403 之后它推断出 +// "那五位 agent 不存在",把错误结论写进了给人的信里)。 // -// `scope == nil` 表示调用方**没有声明**自己在哪条会话(尚未接线的桥、脚本、浏览器)。 -// 那是旧语义:放行。它存在的唯一理由是让迁移可以分步走,调用点会记一条警告, -// 收尾时按日志把还没接线的调用方找全。 -// -// 返回的 reason 只用于日志与报错文案,不参与判定: -// -// "" 允许 -// "not-participant" 该 Agent 没参与过这条会话 -// "cross-workspace" 参与了,但两条会话不在同一工作区 +// 会话边界一处定死之后,两侧都对了:worker 在哪条会话里,就只看得见那条的收件箱; +// 要读另一条,就得在那条会话里(每封来信都会把 worker 唤醒到它自己那条会话上)。 func AgentMayReadSession(ctx context.Context, agentName string, scope *uuid.UUID, target uuid.UUID) (bool, string, error) { + // 未声明当前会话:无法判断"你人在哪条会话里",按迁移期旧语义放行(服务端另记警告)。 + // 五家桥实测都会带 session_id(agent_discovery 的 agentScope)。 + if scope == nil { + return true, "", nil + } + // ① 只读自己当前那条会话 —— 不同 session 就是不同收件箱 + if *scope != target { + return false, "not-your-session", nil + } + // ② 防伪造 session_id:声明一条自己没参与的会话也不行 participates, err := AgentCanAccessSession(ctx, agentName, target) if err != nil { return false, "", err @@ -75,11 +76,5 @@ func AgentMayReadSession(ctx context.Context, agentName string, scope *uuid.UUID 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 index cba71f9..f4451ad 100644 --- a/server/internal/repo/workspace_scope_test.go +++ b/server/internal/repo/workspace_scope_test.go @@ -48,60 +48,76 @@ func sessionIn(t *testing.T, agent, ws, title string) uuid.UUID { return id } -func TestAgentMayReadSessionIsWorkspaceScoped(t *testing.T) { +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 的另一条线索") + 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) + // ① 就是我当前这条会话 → 放行。少了这条,判据"永远拒绝"也能绿。 + ok, reason, err := AgentMayReadSession(ctx, agent, &scope, scope) if err != nil { t.Fatal(err) } if !ok { - t.Fatalf("同工作区的会话必须能读,却被拒(reason=%q)", reason) + t.Fatalf("自己当前这条会话必须能读,却被拒(reason=%q)", reason) } - // ② 跨工作区 → 拒,且原因要说清是工作区不对(不是"没参与过")。 + // ② 同工作区的**另一条**会话 → 拒。★ 2026-09-15 用户订正: + // 会话是记忆隔离的单位,"不同 session 不同收件箱" —— 旧判据在这里是放行的 + // (它只看工作区),那等于绕过会话隔离。 + ok, reason, err = AgentMayReadSession(ctx, agent, &scope, sameWS) + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatal("★ 同工作区但不同会话必须拒 —— 会话之间记忆隔离,读别的会话就是绕过它") + } + 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 { - t.Fatal("★ 跨工作区必须拒 —— 用户报的就是这个(TrueAgent 的 worker 读到 agentmail 的线索)") + t.Fatal("别的会话必须拒") } - if reason != "cross-workspace" { - t.Fatalf("拒绝原因应是 cross-workspace(实际 %q):分不清原因,调用方就没法自查", reason) + if reason != "not-your-session" { + t.Fatalf("拒绝原因应是 not-your-session(实际 %q)—— 别拿工作区当理由", reason) } - // ③ 没参与过的会话 → 拒,且原因是 not-participant(两道闸门要能分开)。 - ok, reason, err = AgentMayReadSession(ctx, agent, &scope, alien) + // ④ 伪造 session_id:声明 target 就是自己所在会话,但并没参与过 → 拒。 + ok, reason, err = AgentMayReadSession(ctx, "pi-not-in-it", &alien, alien) if err != nil { t.Fatal(err) } if ok { - t.Fatal("没参与过的会话必须拒") + t.Fatal("没参与过的会话必须拒(防伪造 session_id)") } 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 时是旧语义(放行)—— 要收紧请连这条判据一起改") + t.Fatal("未声明 scope 时应保持旧语义放行(迁移期妥协,收紧要显式改这条判据)") } }