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:
@ -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 列出某发件人发出的邮件(发件箱),排除已归档会话
|
||||
|
||||
Reference in New Issue
Block a user