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 里构建。)
This commit is contained in:
2026-09-14 23:18:12 +08:00
parent 1b8cd43935
commit 2a5e3d7d15
12 changed files with 435 additions and 36 deletions

View File

@ -1481,6 +1481,23 @@ func (p *Plugin) markRead(ids []string) {
p.client.Do(req)
}
// scopeQuery 拼读类端点的**会话收窄参数**(`?session_id=…` / `&session_id=…`,
// 由调用方给分隔符)。
//
// 与 inboxURL 同一个理由(不收窄会把别会话的未读标掉 ⇒ 静默丢信),但服务端现在
// 拿它多干一件事:**由这条会话反查工作区**,只有同工作区的会话才放行。为什么必须
// 有一维:一个 Agent 同时服务所有工作区(注册时 workspaces 为空),不收窄时在
// TrueAgent 里干活的 worker 能读到 agentmail 的整条线索(用户 2026-09-14 报的越界)。
//
// 不在回合里时 currentSessionID 为空,返回空串 —— 退回旧行为(服务端会记警告),
// 而不是猜一个会话。
func (p *Plugin) scopeQuery(sep string) string {
if p.currentSessionID == "" {
return ""
}
return sep + "session_id=" + url.QueryEscape(p.currentSessionID)
}
// inboxURL 拼收件箱地址。单独抽出来是为了能被单测直接断言 ——
// 会话收窄这种"少了个参数就静默丢信"的改动,必须有判据盯着 URL 本身。
//

View File

@ -0,0 +1,103 @@
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)
}
}

View File

@ -45,7 +45,7 @@ func (p *Plugin) handleReadMail(args map[string]interface{}) (interface{}, error
IsSelf bool `json:"is_self"`
} `json:"participants"`
}
if err := p.get(p.gwURL+"/api/v1/agent/mail/"+mid, &data); err != nil {
if err := p.get(p.gwURL+"/api/v1/agent/mail/"+mid+p.scopeQuery("?"), &data); err != nil {
return nil, err
}
@ -112,7 +112,7 @@ func (p *Plugin) handleForwardMail(args map[string]interface{}) (interface{}, er
"session_alias": sa,
}
var result map[string]interface{}
if err := p.post("/mail/"+mid+"/forward", payload, &result); err != nil {
if err := p.post("/mail/"+mid+"/forward"+p.scopeQuery("?"), payload, &result); err != nil {
return nil, err
}
@ -150,7 +150,7 @@ func (p *Plugin) handleSuggestAddress(args map[string]interface{}) (interface{},
Source string `json:"source"`
} `json:"candidates"`
}
if err := p.get(p.gwURL+"/api/v1/agent/contacts/suggest?"+qs, &data); err != nil {
if err := p.get(p.gwURL+"/api/v1/agent/contacts/suggest?"+qs+p.scopeQuery("&"), &data); err != nil {
return nil, err
}
@ -218,7 +218,7 @@ func (p *Plugin) handleListContacts(args map[string]interface{}) (interface{}, e
Alias string `json:"session_alias"`
} `json:"contacts"`
}
if err := p.get(p.gwURL+"/api/v1/agent/contacts", &data); err != nil {
if err := p.get(p.gwURL+"/api/v1/agent/contacts"+p.scopeQuery("?"), &data); err != nil {
return nil, err
}
@ -287,7 +287,7 @@ func (p *Plugin) handleSessionParticipants(args map[string]interface{}) (interfa
Address string `json:"address"`
} `json:"participants"`
}
if err := p.get(p.gwURL+"/api/v1/agent/sessions/"+sid+"/participants", &data); err != nil {
if err := p.get(p.gwURL+"/api/v1/agent/sessions/"+sid+"/participants"+p.scopeQuery("?"), &data); err != nil {
return nil, err
}
@ -349,7 +349,7 @@ func (p *Plugin) handleReadThread(args map[string]interface{}) (interface{}, err
ParentHid bool `json:"parent_hidden"`
} `json:"nodes"`
}
if err := p.get(p.gwURL+"/api/v1/agent/mail/"+mid+"/thread"+offset, &data); err != nil {
if err := p.get(p.gwURL+"/api/v1/agent/mail/"+mid+"/thread"+offset+p.scopeQuery("&"), &data); err != nil {
return nil, err
}