fix(session): 会话身份 = (path, 别名) —— 修用户报的错投,并堵住 /home 这类毒 path

用户 2026-09-15 的订正:「agent 平台的 session 是和 path 绑定的,path+session 才能
指定到准确的 agent,而授权也是对 session 授权,而不是整个 agent」;随后报了一个
严重错投:「工作区在 TrueAgent 的 pi 客户端,发送邮件给 dsh 被投递给了工作区在 home
的客户端」。

根因是四处叠加(都有实测证据):
1. 解析只按别名:`WHERE session_alias = $1`,path 被当可选提示 ⇒ 两条不相干的
   线索能落进同一会话;
2. 别名全局唯一 ⇒ path 在解析时冗余,进一步被忽略;
3. 桥按来信的 to_workspace 起 worker(实测日志 `新建 pi 会话 …(cwd=/home)`);
4. 投递(SSE)只按 name 广播。

本提交改 1/2 + 堵源头:
- 解析按 (path, 别名):给了 path 必须两者同时命中,对不上就是 ErrSessionNotFound
  (**绝不**退回按别名找);没给 path 则要求该别名唯一,多条时 ErrSessionAmbiguous
  (不许掷骰子);
- 唯一索引从 `(session_alias)` 改成 `(COALESCE(workspace,''), session_alias)`
  (两方言 + 显式 DROP 旧索引;EnsureSessionAlias 靠唯一索引判撞名,自动变成按 path);
- 新 IsPlausibleWorkspace(与目录建议**同源**一条规则):地址受理处拒收不像工作目录的
  path(`/home`、`/root`、`~/.pi/mail-sessions/…`、顶层挂载点),并给出可执行文案;
- 建议接口的候选过滤改用同一条规则(只留具体目录;祖先容器让位给更具体的候选)。

判据:新增/改写 4 组,全部做变异验证 —— 三条核心变异(解析退回按别名找 / 歧义时
掷骰子 / 去掉「深度≥2」)全部变红。过程中判据还抓到自己一个漏洞:
IsPlausibleWorkspace("/home") 在旧规则下是 true(/home 不是 root 的家目录)。

★ 语义变化(会让旧测试红):不同 path 下的同名别名现在是两条独立会话,不再"撞名"。
TestAdoptHandlesAliasCollision 已按新模型重写为两侧(同 path 才加后缀)。

未做:桥侧仍按来信 to_workspace 起 cwd(下一步);线上的库要等下一次启动才跑迁移。
This commit is contained in:
2026-09-15 12:20:28 +08:00
parent ee3ada2740
commit ad1f3f14c3
7 changed files with 460 additions and 37 deletions

View File

@ -198,31 +198,50 @@ func TestAdoptedSessionSurvivesMirrorReplace(t *testing.T) {
}
}
// 接管用的 slug 与本侧某条无关会话撞名时要自动加后缀(别名全局唯一)。
func TestAdoptHandlesAliasCollision(t *testing.T) {
/*
接管为 slug 自动命名时的撞名判据。
★ 语义在 2026-09-15 被用户订正过:「agent 平台的 session 是和 path 绑定的,
path+session 才能指定到准确的 agent」。所以别名唯一性是**按 path** 的:
- 不同工作目录下的同名别名 → 两条独立会话,**都保留原名**(不是撞名);
- 同一工作目录下的同名别名 → 才算撞名,追加 -2/-3… 后缀。
判据两侧都写:只验一侧的话,"永远加后缀"或"从不加后缀"都能骗过。
(后者的后果是同一目录里两条会话同号 —— 寻址会指到哪条就成了掷骰子。)
*/
func TestAdoptAliasCollisionIsScopedToPath(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
seedAgent(t, "pi", 20)
// 先占掉这个别名
taken := "撞名的别名"
if _, err := CreateSession(ctx, &taken, "pi", "已存在", "/tmp/a"); err != nil {
t.Fatalf("建占位会话: %v", err)
}
id, err := AdoptPlatformSession(ctx, "pi", "pi-sess-x", taken, "/tmp/b", "标题")
// ① 不同 path:同名允许,两个字面相同
idB, err := AdoptPlatformSession(ctx, "pi", "pi-sess-b", taken, "/tmp/b", "标题")
if err != nil {
t.Fatalf("接管: %v", err)
t.Fatalf("接管(不同 path): %v", err)
}
alias := SessionAliasOf(ctx, id)
if alias == "" {
t.Fatal("接管后没有别名 —— 这条会话将无法寻址")
if got := SessionAliasOf(ctx, idB); got != taken {
t.Errorf("不同 path 下同名别名应原样保留(path+别名才是身份),实际 %q", got)
}
if alias == taken {
t.Errorf("别名与已存在的重复了:%q", alias)
}
// 绑定仍然正确
if pid := PlatformIDOf(ctx, id); pid != "pi-sess-x" {
if pid := PlatformIDOf(ctx, idB); pid != "pi-sess-b" {
t.Errorf("platform_id = %q", pid)
}
// ② 同一 path:这才是真撞名,必须加后缀
idC, err := AdoptPlatformSession(ctx, "pi", "pi-sess-c", taken, "/tmp/a", "标题")
if err != nil {
t.Fatalf("接管(同 path): %v", err)
}
got := SessionAliasOf(ctx, idC)
if got == "" {
t.Fatal("接管后没有别名 —— 这条会话将无法寻址")
}
if got == taken {
t.Errorf("同一 path 下别名重复了:%q(同目录两条会话同号 = 寻址掷骰子)", got)
}
}