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

@ -66,10 +66,20 @@ CREATE TABLE IF NOT EXISTS sessions (
CREATE INDEX IF NOT EXISTS idx_sessions_alias ON sessions(session_alias);
CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
-- 会话别名负责寻址(name@path.<alias>),必须全局唯一。
-- 部分唯一索引:未命名会话(NULL)不受约束,可以有任意多个。
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_alias_uniq
ON sessions(session_alias) WHERE session_alias IS NOT NULL;
-- 会话身份 = (path, session) —— 用户 2026-09-15 订正:
-- 「agent 平台的 session 是和 path 绑定的,path+session 才能指定到准确的 agent,
-- 而授权也是对 session 授权,而不是整个 agent」。
-- 所以寻址名的唯一性也是**按 path** 的:不同工作目录下的同名会话是两条不同的会话
-- (各自的身份、各自的授权),而不是"同名冲突"。
--
-- 历史:这里原先是 ON sessions(session_alias) 的全局唯一索引。全局唯一让 path 在解析时
-- 成为冗余(于是被忽略),一条 /home 之类的错 path 就能让两条不相干的线索落进同一个
-- 会话、同一个 pi 会话文件(2026-09-15 实测:zcode 的"介绍请求"与 agentmail 的部署
-- 往来混在 --home-- 的一个 jsonl 里)。
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_path_alias_uniq
ON sessions(COALESCE(workspace,''), session_alias) WHERE session_alias IS NOT NULL;
-- 老索引必须显式丢弃:CREATE ... IF NOT EXISTS 不会删掉它,而它会继续把别名限制成全局唯一。
DROP INDEX IF EXISTS idx_sessions_alias_uniq;
-- 已存在的库补列(必须先于依赖该列的索引)
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS owner_user_id UUID REFERENCES users(user_id);

View File

@ -144,10 +144,20 @@ CREATE INDEX IF NOT EXISTS idx_sessions_alias ON sessions(session_alias);
CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
CREATE INDEX IF NOT EXISTS idx_sessions_owner ON sessions(owner_user_id);
-- 会话别名负责寻址(name@path.<alias>),必须全局唯一。
-- 部分唯一索引:未命名会话(NULL)不受约束,可以有任意多个。
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_alias_uniq
ON sessions(session_alias) WHERE session_alias IS NOT NULL;
-- 会话身份 = (path, session) —— 用户 2026-09-15 订正:
-- 「agent 平台的 session 是和 path 绑定的,path+session 才能指定到准确的 agent,
-- 而授权也是对 session 授权,而不是整个 agent」。
-- 所以寻址名的唯一性也是**按 path** 的:不同工作目录下的同名会话是两条不同的会话
-- (各自的身份、各自的授权),而不是"同名冲突"。
--
-- 历史:这里原先是 ON sessions(session_alias) 的全局唯一索引。全局唯一让 path 在解析时
-- 成为冗余(于是被忽略),一条 /home 之类的错 path 就能让两条不相干的线索落进同一个
-- 会话、同一个 pi 会话文件(2026-09-15 实测:zcode 的"介绍请求"与 agentmail 的部署
-- 往来混在 --home-- 的一个 jsonl 里)。
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_path_alias_uniq
ON sessions(COALESCE(workspace,''), session_alias) WHERE session_alias IS NOT NULL;
-- 老索引必须显式丢弃:CREATE ... IF NOT EXISTS 不会删掉它,而它会继续把别名限制成全局唯一。
DROP INDEX IF EXISTS idx_sessions_alias_uniq;
CREATE TABLE IF NOT EXISTS mails (
mail_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),

View File

@ -86,6 +86,22 @@ func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, sub
return mail.SessionID, &replyID, false, nil
}
// 地址里的 path 必须**像一个工作目录**(用户 2026-09-15 报的严重 bug)。
//
// 实情:客户端把收件人的 path 填成了 `/home`,于是链条一路跑通:
// 会话 workspace=/home → 桥按来信的 to_workspace 起 worker(实测 cwd=/home)
// → 那个 worker 发的信继续带 /home → 建议接口把 /home 学成第一条候选 → 自我强化。
// 后果不只是地址难看:两条不相干的线索会落进同一条会话、同一个 pi 会话文件。
//
// 拒收而不是"悄悄改成默认目录":path 是会话身份的一半,猜一个等于把信
// 放进另一条会话里 —— 那正是要修的毛病。文案给出可执行的下一步。
if addr.Path != "" && !repo.IsPlausibleWorkspace(addr.Path) {
return uuid.Nil, nil, false, errBadRequest(fmt.Sprintf(
"地址里的 path 不像工作目录:%q。地址形如 name@/绝对/路径.<会话别名>;"+
"新建会话可写 name@<工作目录>.new,人类回信可省略 path(name@.<别名>)。",
addr.Path))
}
switch addr.Mode() {
case models.SessionNew:
// 新建会话:若调用方给了别名,当场命名,之后即可用 name@path.<alias> 续谈。
@ -149,6 +165,13 @@ func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, sub
repo.TouchSession(r.Context(), id)
return id, nil, false, nil
}
// 同名别名在多个工作目录下都存在、而地址里又没写 path:不能猜一条,
// 要让发信方补 path(见 repo.ErrSessionAmbiguous 的注释)。
if errors.Is(err, repo.ErrSessionAmbiguous) {
return uuid.Nil, nil, false, errBadRequest(fmt.Sprintf(
"别名 %q 在多个工作目录下都存在,无法确定是哪一条:请在地址里写明 path(name@/路径.%s)。",
addr.Session, addr.Session))
}
if !errors.Is(err, repo.ErrSessionNotFound) {
return uuid.Nil, nil, false, err
}

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)
}
}

View File

@ -6,6 +6,8 @@ import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
@ -878,25 +880,81 @@ var ErrSessionNotFound = errors.New("session not found")
//
// 不限定 workspace:别名全局唯一且本身就承担寻址职责,
// 再叠一层工作区校验只会让「名字对上了却送不到」变成一种难查的失败。
// ErrSessionAmbiguous:地址里没给 path,而该别名在多个工作目录下都存在。
//
// 不给"随便挑一条"的兜底:那等于把信随机投进某个工作区 —— 2026-09-15 那次错投
// (工作区在 TrueAgent 的 pi 发的信进了 workspace=/home 的会话/客户端)就是这么来的。
var ErrSessionAmbiguous = errors.New("会话别名在多个工作目录下都存在,请在地址里写明 path")
// FindNamedSessionFor 按**会话身份**找会话:地址 `name@path.<别名>`。
//
// # path 是身份的一半,不是提示(用户 2026-09-15 订正)
//
// 原话:「agent 平台的 session 是和 path 绑定的,path+session 才能指定到准确的 agent,
// 而授权也是对 session 授权,而不是整个 agent」。所以:
//
// - 地址里**给了** path → 必须 path 与别名**同时命中**。对不上就是"没有这条会话",
// 而不是"退回按别名找"—— 后者正是错投的根因(path 被忽略 ⇒ 两条不相干的线索
// 落进同一条会话,甚至同一个 pi 会话文件)。
// - 地址里**没给** path(`name@.别名`,人类回信常用)→ 只有该别名**唯一**时才认;
// 同名多条时返回 ErrSessionAmbiguous,让调用方要求补 path。
//
// name 仍要在这条线索里出现过(from/to/cc):否则任何会话都能被叫任意名字。
func FindNamedSessionFor(ctx context.Context, name, path, alias string) (uuid.UUID, error) {
var id uuid.UUID
err := db.DB.QueryRowContext(ctx, `
SELECT s.session_id
FROM sessions s
WHERE s.session_alias = $1
AND s.status <> 'archived'
path = strings.TrimSpace(path)
if path != "" {
path = filepath.Clean(path)
}
// 两个变体共用同一个"我参与过"判据。
var participation = `
AND EXISTS (
SELECT 1 FROM mails m
WHERE m.session_id = s.session_id
AND (m.to_name = $2 OR m.from_name = $2 OR `+db.CCHas("m.cc_list", 2)+`)
)
ORDER BY s.updated_at DESC
LIMIT 1
`, alias, name).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return uuid.Nil, ErrSessionNotFound
AND (m.to_name = $2 OR m.from_name = $2 OR ` + db.CCHas("m.cc_list", 2) + `)
)`
if path != "" {
var id uuid.UUID
err := db.DB.QueryRowContext(ctx, `
SELECT s.session_id FROM sessions s
WHERE s.session_alias = $1 AND s.status <> 'archived'
AND COALESCE(s.workspace, '') = $3`+participation+`
ORDER BY s.updated_at DESC LIMIT 1`, alias, name, path).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return uuid.Nil, ErrSessionNotFound
}
return id, err
}
// 没给 path:取最多两条,用于区分"唯一"与"歧义"。
rows, err := db.DB.QueryContext(ctx, `
SELECT s.session_id FROM sessions s
WHERE s.session_alias = $1 AND s.status <> 'archived'`+participation+`
ORDER BY s.updated_at DESC LIMIT 2`, alias, name)
if err != nil {
return uuid.Nil, err
}
defer rows.Close()
var ids []uuid.UUID
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err != nil {
return uuid.Nil, err
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return uuid.Nil, err
}
switch len(ids) {
case 0:
return uuid.Nil, ErrSessionNotFound
case 1:
return ids[0], nil
default:
return uuid.Nil, ErrSessionAmbiguous
}
return id, err
}
// FindOrCreateDefaultSession 实现 session 位省略时的「默认会话」语义:
@ -1124,7 +1182,6 @@ func ListContactsFor(ctx context.Context, forUser string, archived bool) ([]Cont
return listContacts(ctx, forUser, nil, archived)
}
func listContacts(ctx context.Context, forUser string, onlyWorkspace *string, archived bool) ([]Contact, error) {
op := "<>"
if archived {
@ -1378,7 +1435,111 @@ func SuggestPaths(ctx context.Context, agentName string) ([]string, error) {
}
}
return out, nil
return filterWorkspaces(out), nil
}
/*
filterWorkspaces 只留「看起来像工作目录」的候选。
为什么需要这一步(2026-09-15 用户报的「莫名其妙的 pi@/home 会话」):
建议列表是从**历史数据**学的(mails.to_workspace + 平台心跳 + 注册自报),
而历史里混进了「进程恰好所在的目录」:`/root`、`/home`、`/home/program`、
`/root/.pi/mail-sessions/<uuid>`。它们一旦进了候选,用户点第一条就发信给
`zcode@/home` → 那条会话的 workspace 就成了 `/home` → 之后这条线索里
**所有参与方**都显示 `xxx@/home`,pi 桥还会真把 worker 起在那里
(实测日志 `新建 pi 会话 …(cwd=/home)`)—— 而沙箱的 rw 只有
/home/program/agentmail,那个 worker 连文件都写不了。
★ 这是一个**自增强环**:污染的会话 → 污染的候选 → 新的污染会话。
只清理现有的 `/home` 不够,必须同时不把这类值当候选。
判据不用黑名单(那是针对已知值),而是问「它是不是一个**具体**的工作目录」:
1. 绝对路径(相对路径不可能是工作目录的地址);
2. 任何一段以 `.` 开头 → 不是(那是缓存/会话存储,如 `/root/.pi/...`);
3. 进程用户的家目录(`/root`)→ 不是(agent 的工作目录不是它的进程家目录);
4. 是**另一个候选的祖先** → 不是(`/home`、`/home/program` 都被
`/home/program/agentmail` 这条干掉:它只是容器,不是干活的地方)。
刻意**不检查目录是否存在**:注册时自报的目录可能还没建(`/tmp/remotebot-ws`
就是那种),存在性检查会把合法候选误杀。
*/
func filterWorkspaces(in []string) []string {
if len(in) == 0 {
return in
}
out := make([]string, 0, len(in))
for _, p := range in {
if !IsPlausibleWorkspace(p) {
continue
}
if isAncestorOfOther(filepath.Clean(p), in) {
continue
}
out = append(out, filepath.Clean(p))
}
return out
}
// IsPlausibleWorkspace 报告地址里的 path 位**是否可能是一个工作目录**。
//
// 规则与 filterWorkspaces **同源**(一处定义、两处使用):建议接口用它筛候选,
// 地址受理用它拒收 —— 否则"建议里没有的值"仍能被人手写进来,污染从另一条路回来。
//
// 1. 绝对路径;
// 2. 任何一段以 `.` 开头 → 不是(缓存/会话存储,如 /root/.pi/mail-sessions/x);
// 3. 进程用户的家目录 → 不是(那不是"干活的地方")。
//
// 刻意**不检查是否存在**:注册时自报的目录可能还没建(/tmp/remotebot-ws 那种)。
// "是不是另一个候选的祖先"也不在这里判:那要看候选集,属于**目录建议**的取舍
// (/home/program 单独出现时是合法路径,只是不该被优先推荐)。
func IsPlausibleWorkspace(p string) bool {
p = strings.TrimSpace(p)
if p == "" || !filepath.IsAbs(p) {
return false
}
clean := filepath.Clean(p)
// 2. **深度至少 2**:工作目录是树里的一个**具体**位置,不是顶层挂载点。
// `/home`、`/root`、`/tmp`、`/opt` 本身都不是"人/agent 干活的地方",
// 它们是容器的容器。实测的污染值 `/home` 正是靠这条被挡住
// —— 它在旧规则下是"合法绝对路径",因为 /home 并不是 root 用户的家目录。
// (判据第一次跑就抓到了这个漏洞:IsPlausibleWorkspace("/home") = true ✗)
if strings.Count(strings.Trim(clean, "/"), "/") < 1 {
return false
}
if hasHiddenSegment(clean) {
return false
}
if home, err := os.UserHomeDir(); err == nil && home != "" {
if clean == filepath.Clean(home) {
return false
}
}
return true
}
func hasHiddenSegment(p string) bool {
for _, seg := range strings.Split(strings.Trim(p, "/"), "/") {
if len(seg) > 1 && strings.HasPrefix(seg, ".") {
return true
}
}
return false
}
// isAncestorOfOther 报告 p 是不是另一个候选的**严格**祖先。
// 只比给定的候选集,不碰文件系统(不用「它下面有没有目录」这种猜测)。
func isAncestorOfOther(p string, all []string) bool {
prefix := strings.TrimSuffix(p, "/") + "/"
for _, q := range all {
if q == p {
continue
}
if strings.HasPrefix(filepath.Clean(q), prefix) {
return true
}
}
return false
}
// ListSentBy 列出某发件人发出的邮件(发件箱),排除已归档会话

View File

@ -0,0 +1,113 @@
package repo
import (
"context"
"errors"
"testing"
)
/*
会话身份 = **path + 别名**(用户 2026-09-15 订正)。
原话:「agent 平台的 session 是和 path 绑定的,path+session 才能指定到准确的 agent,
而授权也是对 session 授权,而不是整个 agent」。
这条判据直接对着当天那个**严重错投**:工作区在 TrueAgent 的 pi 给 dsh 发信,信却进了
workspace=/home 的会话/客户端。根因是解析只按别名(`WHERE session_alias = $1`),
path 被当成了可选提示 —— 于是两条不相干的线索能落进同一条会话。
判据两侧都写(只验一侧会挡住"退回按别名找"这种假修复):
- 给了 **正确** path → 命中;
- 给了 **另一个** path(而别名确实存在于别处)→ 必须 ErrSessionNotFound,
**绝不能**命中共名的那一条(这一条是本次 bug 的回归判据);
- 不给 path 且别名唯一 → 命中(兼容 `name@.别名` 的旧地址);
- 不给 path 且同名两条 → ErrSessionAmbiguous(不许掷骰子)。
*/
func TestFindNamedSessionForRequiresPath(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
seedAgent(t, "dsh", 20)
alias := "鸿蒙客户端与-WebUI-界面对齐"
a := alias
idA, err := CreateSession(ctx, &a, "pi", "在 repo 里干活", "/home/program/agentmail")
if err != nil {
t.Fatalf("建会话 A: %v", err)
}
seedMailForSession(t, idA, "pi", "dsh", "/home/program/agentmail")
b := alias
idB, err := CreateSession(ctx, &b, "pi", "在 /home 里那一条(污染值)", "/home")
if err != nil {
t.Fatalf("建会话 B(同名不同 path): %v", err)
}
seedMailForSession(t, idB, "pi", "dsh", "/home")
// ① 正确 path → 命中 A
got, err := FindNamedSessionFor(ctx, "dsh", "/home/program/agentmail", alias)
if err != nil || got != idA {
t.Fatalf("正确 path 应命中 A:got=%v err=%v", got, err)
}
// ② ★ 错 path(/home)但别名相同 → 必须命中 B,**不是** A;
// 而如果那个 path 根本没有会话 → NotFound,绝不退回命中 A。
got, err = FindNamedSessionFor(ctx, "dsh", "/tmp/别的地方", alias)
if !errors.Is(err, ErrSessionNotFound) {
t.Fatalf("错 path 必须 ErrSessionNotFound(不许退回按别名找),实际 got=%v err=%v", got, err)
}
got, err = FindNamedSessionFor(ctx, "dsh", "/home", alias)
if err != nil || got != idB {
t.Fatalf("path=/home 应命中 B(同名但不同身份):got=%v err=%v", got, err)
}
// ③ 不给 path:别名不唯一 → 歧义,不许猜
if _, err := FindNamedSessionFor(ctx, "dsh", "", alias); !errors.Is(err, ErrSessionAmbiguous) {
t.Fatalf("同名两条 + 无 path 必须 ErrSessionAmbiguous,实际 %v", err)
}
// ④ 不给 path 且唯一 → 命中(兼容旧地址 `name@.别名`)
uniq := "只有一条的别名"
u := uniq
idU, err := CreateSession(ctx, &u, "pi", "独一份", "/home/program/agentmail")
if err != nil {
t.Fatal(err)
}
seedMailForSession(t, idU, "pi", "dsh", "/home/program/agentmail")
if got, err := FindNamedSessionFor(ctx, "dsh", "", uniq); err != nil || got != idU {
t.Fatalf("唯一别名 + 无 path 应命中:got=%v err=%v", got, err)
}
// ⑤ 没参与过的名字不该命中(地址解析的另一半判据)
if _, err := FindNamedSessionFor(ctx, "zcode", "/home/program/agentmail", alias); !errors.Is(err, ErrSessionNotFound) {
t.Fatalf("没参与过这条线索的名字不该命中,实际 %v", err)
}
}
// IsPlausibleWorkspace:地址受理与"目录建议"共用的一条规则。
//
// 它存在的意义是**堵住源头**:一个 `/home` 这样的值进了地址,会话的 workspace 就错了,
// 桥会把 worker 起在那里(实测 cwd=/home),那个 worker 发的信继续带 /home,
// 建议接口再把它学成第一条候选 —— 自我强化。所以这里两侧都钉:
// 实测污染过的形状必须是 false,真实工作目录必须是 true。
func TestIsPlausibleWorkspace(t *testing.T) {
cases := []struct {
in string
want bool
why string
}{
{"/home", false, "一切的家目录(实测污染值)"},
{"/root", false, "进程家目录(实测污染值)"},
{"/root/.pi/mail-sessions/8f056b73", false, "会话存储目录(实测污染值)"},
{"/home/program/agentmail/../llmsproxy", true, "含 .. 但归一化后是具体目录"},
{"rel/path", false, "相对路径不是地址里的 path"},
{"", false, "空"},
{"/home/program/agentmail", true, "真实工作目录"},
{"/home/newqqagent", true, "homeagent 的真实工作目录"},
{"/tmp/remotebot-ws", true, "尚未创建的注册目录也要放行(不做存在性检查)"},
}
for _, c := range cases {
if got := IsPlausibleWorkspace(c.in); got != c.want {
t.Errorf("IsPlausibleWorkspace(%q) = %v, want %v(%s)", c.in, got, c.want, c.why)
}
}
}

View File

@ -0,0 +1,87 @@
package repo
import (
"reflect"
"testing"
)
/*
SuggestPaths 的候选过滤(2026-09-15 用户报的「莫名其妙的 pi@/home 会话」)。
背景是一整条**自增强环**:建议列表是从历史数据学的(mails.to_workspace + 平台心跳 +
注册自报),而历史里混进了「进程恰好所在的目录」——`/root`、`/home`、`/home/program`、
`/root/.pi/mail-sessions/<uuid>`。用户点了第一条建议 → 发信给 `zcode@/home` →
那条会话的 workspace 成了 `/home` → 之后这条线索里所有参与方都显示 `xxx@/home`,
pi 桥还会**真把 worker 起在 /home**(实测日志 `新建 pi 会话 …(cwd=/home)`),
而沙箱 rw 只有 /home/program/agentmail ⇒ 那个 worker 连文件都写不了。
判据两侧都写:
- 坏值必须被剔掉(否则环继续跑);
- 好值必须保留,包括**还没创建的目录**(注册时自报的 /tmp/remotebot-ws 那种)
—— 如果实现顺手加上存在性检查,这一条会红。
*/
func TestFilterWorkspaces(t *testing.T) {
cases := []struct {
name string
in []string
want []string
}{
{
name: "剔掉进程家目录、家目录本身、父目录、隐藏段(实测到的污染形状)",
in: []string{
"/root", // 进程家目录
"/home", // 一切的家目录
"/home/program", // 只是容器
"/root/.pi/mail-sessions/8f056b73", // pi 的会话存储(隐藏段)
"/home/program/agentmail", // ← 唯一像工作目录的
},
want: []string{"/home/program/agentmail"},
},
{
name: "两个互不为祖先的项目目录都要保留",
in: []string{"/home/program/agentmail", "/home/program/llmsproxy"},
want: []string{"/home/program/agentmail", "/home/program/llmsproxy"},
},
{
name: "还没创建的目录不因为'不存在'被剔(存在性检查是过度过滤)",
in: []string{"/tmp/remotebot-ws", "/home/program/agentmail"},
want: []string{"/tmp/remotebot-ws", "/home/program/agentmail"},
},
{
name: "相对路径不是工作目录地址",
in: []string{"rel/path", "/home/program/agentmail"},
want: []string{"/home/program/agentmail"},
},
{
name: "尾斜杠 / 中间 . 段都做 Clean(不是当成新候选)",
in: []string{"/home/program/agentmail/"},
want: []string{"/home/program/agentmail"},
},
{
// 取舍:rule 4 让**更具体**的目录胜出。父子都是工作目录时父目录会被剔掉 ——
// 这是为治本(`/home`、`/home/program` 那种容器)付出的代价:宁可少给
// 一条候选(用户仍可手写),也不能把容器当工作目录(那会让 worker 起在
// 一个连文件都写不了的目录里)。这条判据把代价钉住,避免以后有人
// "顺手放宽"却没人知道为什么。
name: "父子都是候选时只留更具体的那个(已知取舍)",
in: []string{"/home/program/agentmail/./", "/home/program/agentmail/x"},
want: []string{"/home/program/agentmail/x"},
},
{
name: "空输入原样返回(不放大 nil/空切片差异)",
in: nil,
want: nil,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := filterWorkspaces(c.in)
if len(got) == 0 && len(c.want) == 0 {
return
}
if !reflect.DeepEqual(got, c.want) {
t.Errorf("filterWorkspaces(%v)\n got %v\nwant %v", c.in, got, c.want)
}
})
}
}