Files
MailUI4Agents/plugins/homeagent-mail-bridge/plugin_read_scope_test.go
JianFeeeee 2a5e3d7d15 fix(auth): 四家桥的读端点也带上会话收窄 + 转发同一条命(工作区隔离第 2 步)
第 1 步(1b8cd43)把工作区判据放在服务端、pi 桥接上了线。这一步补齐另外四家,
并把**转发**纳入:转发是"把原文引出去",能转发就等于能读到那条线索的全部内容,
与 read_mail 同一条命(服务端 ForwardMail 也加了同一道校验)。

四家各自的会话来源,与各自的 read_inbox 同一处(不引入第二个来源):
- dsh:`mailSessionOf(exec)`(工具第二个参数)—— 五个读工具原本没接 exec,这次补上
- opencode:`reverseMap.get(context.sessionID)`
- zcode:`process.env.AGENTMAIL_SESSION_ID`(一轮一个进程)
- homeagent:`p.currentSessionID`(新增 `scopeQuery(sep)`,与 inboxURL 同构)

判据(每条两侧都钉:包住了 / 没包住的不存在):
- dsh:静态对照,且额外钉 **dist** —— 那是真被 dsh 加载的那份(main: dist/index.js),
  src 改了忘了 build 就是"源码对、线上旧代码"
- opencode / zcode:同上(opencode 还钉"会话来自 context 而不是模块级变量")
- homeagent:起 httptest 当网关,**五个读工具 + 转发真调一遍**,断言请求 URL 带
  session_id;对照侧:不在回合里(currentSessionID 为空)时不许带
- pi:把 post 的 URL 也纳入记录,forward 进用例表

★ zcode 那条判据我第一版**对照组写错**了:对照组只写裸 URL,而它本来就是
`withScope(\`裸URL\`)` 的子串 ⇒ `!includes(bare)` 恒假。夹具形状不对时判据会以
"恒红/恒绿"的方式骗人(这次是恒红,一眼可见;恒绿就麻烦了)。

变异:homeagent 去掉 read_mail 的收窄 ⇒ 恰好那条断言红。

(工作区共享,只 add 了上面这 12 个文件;dsh 的 dist 是 gitignore 的,由
redeploy-plugin.sh 在 staging 里构建。)
2026-09-14 23:18:12 +08:00

104 lines
4.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
if strings.Contains(q[1:], "&") {
t.Fatalf("session_id 未转义(会把后面的参数吃掉):%q", q)
}
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)
}
}