diff --git a/server/internal/db/migrations/init.sql b/server/internal/db/migrations/init.sql index 3a058eb..f81ace7 100644 --- a/server/internal/db/migrations/init.sql +++ b/server/internal/db/migrations/init.sql @@ -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.),必须全局唯一。 --- 部分唯一索引:未命名会话(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); diff --git a/server/internal/db/migrations/init_sqlite.sql b/server/internal/db/migrations/init_sqlite.sql index d6aae9a..9467e13 100644 --- a/server/internal/db/migrations/init_sqlite.sql +++ b/server/internal/db/migrations/init_sqlite.sql @@ -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.),必须全局唯一。 --- 部分唯一索引:未命名会话(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()), diff --git a/server/internal/handler/mail.go b/server/internal/handler/mail.go index 4346b6e..4757390 100644 --- a/server/internal/handler/mail.go +++ b/server/internal/handler/mail.go @@ -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. 续谈。 @@ -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 } diff --git a/server/internal/repo/adopt_test.go b/server/internal/repo/adopt_test.go index 10ed6bc..dc9b144 100644 --- a/server/internal/repo/adopt_test.go +++ b/server/internal/repo/adopt_test.go @@ -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) + } } diff --git a/server/internal/repo/repo.go b/server/internal/repo/repo.go index ae38669..c824f87 100644 --- a/server/internal/repo/repo.go +++ b/server/internal/repo/repo.go @@ -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/`。它们一旦进了候选,用户点第一条就发信给 +`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 列出某发件人发出的邮件(发件箱),排除已归档会话 diff --git a/server/internal/repo/session_identity_test.go b/server/internal/repo/session_identity_test.go new file mode 100644 index 0000000..b93cfea --- /dev/null +++ b/server/internal/repo/session_identity_test.go @@ -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) + } + } +} diff --git a/server/internal/repo/suggest_paths_test.go b/server/internal/repo/suggest_paths_test.go new file mode 100644 index 0000000..8c53280 --- /dev/null +++ b/server/internal/repo/suggest_paths_test.go @@ -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/`。用户点了第一条建议 → 发信给 `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) + } + }) + } +}