package main import ( "net/http" "net/http/httptest" "net/url" "strings" "testing" ) /* 读类端点必须带上**自己那条邮件会话** —— 这次多出来的作用不是"少看几封",而是 **工作区隔离**。 # 缺陷(用户报的) 「agentmail 工作区的邮件会话被 trueagent 工作区的 agent 看到了,还需要我亲自去解释。」 根因:隔离单位是 Agent,而一个 Agent 同时服务所有工作区(注册时 workspaces 为空、 cwd 由每封邮件的 to_workspace 决定)。服务端原先的判据只是「这个 Agent 参与过这条 会话」—— 于是同一个 agent `pi` 在 TrueAgent 里干活的 worker 眼里,对 agentmail 的 会话也成立。服务端现在补上了第二道闸门(两条会话的 workspace 相同),而闸门的输入 就是这里要拼的 `session_id`。 # 判据是行为,不是正则 起一个 httptest 服务当网关,把**每个读工具真调一遍**,断言它请求的 URL 里带了 `session_id`。对照侧:不在回合里(currentSessionID 为空)时**不许**带 —— 否则"永远拼一个空 session_id"也能骗过上半段。 */ func TestReadToolsCarrySessionScope(t *testing.T) { var seen []string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { seen = append(seen, r.URL.RequestURI()) w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{}`)) })) defer srv.Close() const sid = "593988da-0000-0000-0000-0000000000aa" cases := []struct { tool string args map[string]interface{} call func(*Plugin, map[string]interface{}) (interface{}, error) }{ {"read_mail", map[string]interface{}{"mail_id": "m-1"}, (*Plugin).handleReadMail}, {"read_thread", map[string]interface{}{"mail_id": "m-1"}, (*Plugin).handleReadThread}, {"suggest_address", map[string]interface{}{"name": "dsh", "path": "/home/program/x"}, (*Plugin).handleSuggestAddress}, {"list_contacts", map[string]interface{}{}, (*Plugin).handleListContacts}, {"session_participants", map[string]interface{}{"session_id": "s-1"}, (*Plugin).handleSessionParticipants}, // 转发也读原文(把原文引出到别的会话)⇒ 与 read_mail 同一条命。 {"forward", map[string]interface{}{"mail_id": "m-1", "to": "x@/home/program/y"}, (*Plugin).handleForwardMail}, } for _, c := range cases { // ── 回合中:必须带上当前会话 ── p := &Plugin{gwURL: srv.URL, client: srv.Client(), currentSessionID: sid} seen = nil if _, err := c.call(p, c.args); err != nil { t.Fatalf("%s:调用失败 %v", c.tool, err) } if len(seen) != 1 { t.Fatalf("%s:应当只请求一次(实际 %d 次:%v)", c.tool, len(seen), seen) } if !strings.Contains(seen[0], "session_id="+url.QueryEscape(sid)) { t.Fatalf("★ %s 的请求没带会话收窄:%s", c.tool, seen[0]) } // ── 不在回合里(桥启动自检那种):不许拼出空的 session_id ── p2 := &Plugin{gwURL: srv.URL, client: srv.Client()} seen = nil if _, err := c.call(p2, c.args); err != nil { t.Fatalf("%s(无会话):调用失败 %v", c.tool, err) } if len(seen) == 1 && strings.Contains(seen[0], "session_id=") { t.Fatalf("%s:拿不到会话时不该拼 session_id:%s", c.tool, seen[0]) } } } // scopeQuery 的转义与分隔符:会话 id 里出现 `&` 时不能把后面的参数吃掉。 func TestScopeQueryEscapesAndSeparates(t *testing.T) { p := &Plugin{} if got := p.scopeQuery("?"); got != "" { t.Fatalf("不在回合里应当返回空串(实际 %q)", got) } p.currentSessionID = "a b&c" q := p.scopeQuery("?") if !strings.HasPrefix(q, "?session_id=") { t.Fatalf("分隔符要是调用方给的那个:%q", q) } // ★ 2026-10-01:原断言是「`q[1:]` 里不能有 `&`」,现改为**只查 session_id 那一段**。 // // 它写于「scopeQuery 只产出一个参数」的时代。补了 cwd 回落之后, // scopeQuery 会产出**两个**参数(session_id + workspace), // 而 `&workspace=` 里那个 `&` 是**合法分隔符** —— 断言把正常输出当成了未转义。 // // 要防的真问题没变:**参数值里的 `&` 不能裸奔**(否则会把后面的参数吃掉)。 // 所以这里改成只看 session_id 的值那一段。 seg := strings.TrimPrefix(q, "?session_id=") if i := strings.Index(seg, "&"); i >= 0 { seg = seg[:i] // 截到下一个参数分隔符为止 } if strings.ContainsAny(seg, "& ") { t.Fatalf("session_id 的值未转义(会把后面的参数吃掉):%q", seg) } if _, err := url.Parse("http://x/y" + q); err != nil { t.Fatalf("拼出的 URL 不合法:%v", err) } if got := p.scopeQuery("&"); !strings.HasPrefix(got, "&session_id=") { t.Fatalf("已有查询串时要用 & 分隔:%q", got) } }