Files
MailUI4Agents/plugins/pi-mail-bridge/lib/workspace.js
JianFeeeee 141003dce4 fix(pi-bridge): 地址里的 path 必须像工作目录 —— worker 不再被起在 /home 里
网关侧已在地址受理处拒收 `/home` 这类 path(ad1f3f1)。这里是**同一规则的第二道**,
因为它落在唯一的 choke point 上(`resolveWorkspaceCwd`,所有 cwd 都从这里出):

旧实现只判"存在且是目录",而 `/home` **存在**、也是目录 ⇒ 一路放行 ✗。
后果(2026-09-15 实测):worker 被起在 `/home`(日志 `新建 pi 会话 …(cwd=/home)`),
而它的沙箱 rw 里根本没有 `/home` —— 它在自己"工作目录"里连文件都写不了;
更糟的是它发出去的信继续带 `/home`,把污染沿线索传下去。

新增 isPlausibleWorkspace(与 Go 侧 repo.IsPlausibleWorkspace 同一套规则):
绝对路径 + 深度≥2(顶层挂载点不是干活的地方)+ 不含隐藏段(`.pi`/`.cache` 是缓存与会话存储)。
**只判地址来的值**,不判兜底目录本身 —— `/root/.pi/mail-sessions/<会话>` 这类兜底
正是"没有工作目录"的表达,判它会把正常兜底也拦掉。

判据(5 条,两侧都写 + 变异验证过):实测污染过的形状必须拒;真实工作目录
(含尚未创建的 /tmp/remotebot-ws)必须放行。去掉校验后判据变红 ✓。
桥的正式套件 514 项全绿 ✓。

未部署:pi 桥要 `deploy/redeploy-plugin.sh` 才生效,而那会重启 worker 池
(硬杀在跑的回合),所以等空闲窗口再做 —— 服务端那道闸已经在线,这条是兜底。
2026-09-15 12:32:50 +08:00

121 lines
5.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 邮件寻址里的工作目录(三维地址 name@path.session 的 path 位)。
*
* 这个模块存在的理由是一次真实故障:插件建会话时用的 cwd 是自己拼的
* `~/.dsh/mail-sessions/mail-<uuid>` —— 每封邮件一个全新的空目录。
* DSH 与 opencode 都按 cwd 给会话分组,于是所有邮件会话既不属于任何项目、
* 彼此也不同组,界面上全落进「未分组」。
*
* path 位本来就是「希望它在哪儿干活」,插件只需照用。
*/
import { existsSync, mkdirSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
import { isAbsolute, join, resolve } from 'node:path';
/**
* 校验寻址里的工作目录,不可用时返回调用方给的兜底。
*
* 决策顺序:
* 1. path 位是一个已存在的目录 → 直接用它(同 path 的多封邮件天然同组)
* 2. path 位非空但目录不存在 → **不创建**,返回兜底
* 3. path 位为空(地址写成 `dsh` 而不带 `@/path`)→ 兜底
*
* 为什么不给不存在的 path 建目录:那等于让一个笔误(`/home/porgram/x`)
* 在磁盘上落下一个真目录,而 Agent 会在里面一无所获地干活 ——
* 用户看到会话建起来了却什么都做不了,比明确落到兜底目录更难排查。
*
* 为什么拒绝相对路径:cwd 的相对基准是 harness 进程的启动目录,
* 那是个与邮件语义无关的量(systemd 下通常是 `/`)。
*
* 兜底由调用方给,因为各平台的兜底不同:opencode 有插件启动时的 directory
* 可用,DSH 没有、只能落到 `~/.dsh/mail-sessions/<会话>`(见 mailSessionFallback)。
*
* @param {string} workspace 事件里的 to_workspace
* @param {string} fallback 不可用时的兜底目录(可为空串 = 交给平台自己决定)
* @returns {{cwd: string, grouped: boolean}} grouped 为真表示落在了寻址指定的目录里
*/
/**
* 这个路径**像不像**一个工作目录。
*
* 与网关侧 `repo.IsPlausibleWorkspace` 同一套规则(两层各有一份实现,
* 但因为一层在 Go、一层在 Node,无法共享代码;两边都有判据钉住同样的形状):
*
* 1. 绝对路径;
* 2. **深度 ≥ 2** —— 工作目录是树里的具体位置,不是顶层挂载点。
* `/home`、`/tmp`、`/root`、`/opt` 本身都不是"干活的地方";
* 3. 不含隐藏段(`.pi`/`.cache` 这类是缓存与会话存储,不是工作目录)。
*
* # 为什么必须有这一条(2026-09-15 实测的严重错投)
*
* 旧实现只判"存在且是目录",而 `/home` **存在**、也是目录 ⇒ 一路放行。
* 于是:地址里的 path 被填成 `/home` → 会话 workspace=/home → 桥把 worker
* 起在 `/home`(日志实测 `新建 pi 会话 …(cwd=/home)`)→ 那个 worker 发的信
* 继续带 `/home` → 自我强化。而且那个 worker 的 rw 清单里**没有** `/home`,
* 它在自己"工作目录"里连文件都写不了。
*
* 网关侧现在已在地址受理处拒收这类 path(400),这里是**同一规则的第二道**:
* 万一还有别的路径把毒值送进来(老数据、别的客户端、以后新增的入口),
* worker 也不会被起在一个它写不了的目录里。
*/
export function isPlausibleWorkspace(p) {
const raw = typeof p === 'string' ? p.trim() : '';
if (!raw || !isAbsolute(raw)) return false;
const abs = resolve(raw);
// 深度 ≥ 2:去掉两端斜杠后至少还要有一个 '/'。/home → 0 段 → 不通过。
if (abs.replace(/^\/+|\/+$/g, '').split('/').length < 2) return false;
// 隐藏段:/root/.pi/mail-sessions/x 这类不是工作目录。
for (const seg of abs.split('/')) {
if (seg.length > 1 && seg.startsWith('.')) return false;
}
return true;
}
export function resolveWorkspaceCwd(workspace, fallback) {
const raw = typeof workspace === 'string' ? workspace.trim() : '';
const fb = typeof fallback === 'string' ? fallback : '';
if (!raw || !isAbsolute(raw)) return { cwd: fb, grouped: false };
const abs = resolve(raw);
// 地址里的 path 必须"像工作目录":/home、/tmp、/root/.pi/... 一律退回兜底目录。
// 只对**地址来的**值判,不判兜底目录本身(兜底目录是平台自己的选择,
// 例如 /root/.pi/mail-sessions/<会话>,那正是"没有工作目录"的表达)。
if (!isPlausibleWorkspace(abs)) {
console.warn(`[pi-mail-bridge] 地址里的 path 不像工作目录,已退回兜底目录: ${abs} → ${fb}`);
return { cwd: fb, grouped: false };
}
try {
if (existsSync(abs) && statSync(abs).isDirectory()) {
return { cwd: abs, grouped: true };
}
} catch {
// 权限不足等:当作不可用
}
return { cwd: fb, grouped: false };
}
/**
* 没有天然兜底的平台(DSH)用这个:`~/.dsh/mail-sessions/<会话 id>`。
* @param {string} sessionKey 会话标识
* @returns {string}
*/
export function mailSessionFallback(sessionKey) {
return join(homedir(), '.dsh', 'mail-sessions', String(sessionKey || 'default'));
}
/**
* 确保兜底目录存在。寻址指定的目录本来就存在(否则不会被选中),
* 只有兜底目录需要现建。
* @param {string} cwd resolveWorkspaceCwd 的结果
* @param {boolean} grouped 是否落在寻址指定的目录里
*/
export function ensureCwd(cwd, grouped) {
if (grouped || !cwd) return;
try {
mkdirSync(cwd, { recursive: true });
} catch {
// 建不出来就让 harness 自己报错,这里不该吞掉真实原因
}
}