From 33488760ce3922fcd63b4e03a564114dce722526 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 14 Sep 2026 16:54:30 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E7=9B=B8=E4=BD=8D/=E5=AE=89=E5=85=A8):=20?= =?UTF-8?q?=E9=83=A8=E7=BD=B2=E9=97=A8=E7=A6=81=E5=8F=AA=E5=88=A4=E4=BA=A7?= =?UTF-8?q?=E7=89=A9=E8=87=AA=E8=AF=81=EF=BC=9B=E9=9D=99=E9=BB=98=20break?= =?UTF-8?q?=20=E6=94=B9=E6=88=90=E5=87=BA=E5=A3=B0=EF=BC=9B=E5=86=85?= =?UTF-8?q?=E6=A0=B8=E8=AF=BB=E6=95=B0=E5=B8=A6=E6=97=B6=E9=97=B4=E5=9D=90?= =?UTF-8?q?=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pi 2026-09-14 的裁定与两条更正,逐条落地。 1. **相位裁定(选 c)**:`packaging`/`build-stamp` 属于**构建相位**,不属于安装相位。 `run-all.mjs` 现在有相位:`AGENTMAIL_CRITERIA_PHASE=install`(部署门禁用)。 每条判据登记它读的哪一侧(`ARTIFACT`/`SOURCE`),install 相位里出现 SOURCE 侧判据 → 红; 被跳过的判据**点名打印**,不静默丢。汇总打 `RESULT phase=build|install`。 规则入册 `test/CRITERIA.md` §11(含三个真实实例:check-shared-libs 恒红、 packaging 一改前端就卡死、HOME 在门禁跑完之后才炸)。 安装相位**真正能判的那一半**:`deploy/install.sh` 读**产物自证**(不重算 dist)—— `releaseCandidate !== true` → 拒绝;产物 `gitRev` ≠ HEAD → "这个包比源码旧" → 拒绝; 放行要显式 `--allow-dirty` / `--allow-stale`;`--check` 干跑只报结论不拦。 实测干跑输出:`产物:gitRev=6702cc2 树=dirty releaseCandidate=false | 当前 HEAD=6702cc2` → 报"不是发布候选 + 正式安装会被拒绝 + 要放行请显式说清"。 2. **别解析运行器文本**(pi §5):`broken`/`red` 的判定改成按 TAP 的**名字**—— 文件级失败的测试名就是路径,断言失败的名字是判据名。变异双向验证: 未定义标识符 → 「跑不起来的判据」;把某条判据条件改成假 → 「红的判据」。 不再往关键字表里加补丁(那是往文本解析里加补丁,方向是错的)。 3. **静默 break 是安全相关**(pi §3):`session_update` 找不到活动会话时不再静默 break, 改成出声日志(走 journalctl 那条通道),写清两种成因(此刻没在跑 / **接管会话**重启后无法定位)、 方向(收紧被延迟)、以及兜底的**前提**("下次投递"要求这条会话还会收到新邮件)。 `lib/mail-session-id.js` 模块头同步改成安全相关措辞("人以为自己收紧了权限、实际没有"), 四桥逐字节同源,`check-shared-libs.sh` 退出码 0。 4. 内核读数补时间坐标(pi 13ea2fdf):`BUILD_INFO.txt` 里除原始 `dep`/`=>` 行外, 现在还有 `kernelBinMtime` 与**正在运行的进程启动时间** —— 二进制会在两次读数之间被换掉, 没有时间坐标的读数不成立。 --- client/electron/test/CRITERIA.md | 20 +++++ client/electron/test/run-all.mjs | 62 +++++++++++++-- deploy/install.sh | 45 ++++++++++- .../dsh-mail-bridge/lib/mail-session-id.js | 7 ++ plugins/dsh-mail-bridge/src/index.ts | 23 +++++- plugins/homeagent-mail-bridge/build.sh | 11 +++ .../lib/mail-session-id.js | 79 +++++++++++++++++++ plugins/pi-mail-bridge/lib/mail-session-id.js | 79 +++++++++++++++++++ .../zcode-mail-bridge/lib/mail-session-id.js | 79 +++++++++++++++++++ 9 files changed, 395 insertions(+), 10 deletions(-) create mode 100644 plugins/opencode-mail-bridge/lib/mail-session-id.js create mode 100644 plugins/pi-mail-bridge/lib/mail-session-id.js create mode 100644 plugins/zcode-mail-bridge/lib/mail-session-id.js diff --git a/client/electron/test/CRITERIA.md b/client/electron/test/CRITERIA.md index 45989e1..d07c073 100644 --- a/client/electron/test/CRITERIA.md +++ b/client/electron/test/CRITERIA.md @@ -355,3 +355,23 @@ pi 2026-09-14 提的形状:**不是无条件 fail-closed** —— 缺字段可 | 邮件的 `permission_mode` | **不写、不改档**(不是写默认档) | 窄(fail-closed) | 本仓 2026-09-14:缺字段被 `\|\| 'workspace'` 兜成窄档,等于"一个 bug 以正常形态活着";守卫见 `plugins/*/lib/permission-mode.js` 的 `modeForStateWrite` | | HomeAgent `plugin.json` 的 `sdk` | **内核不读**(字段只对人有效) | 无(不是语义,是文档) | 内核 `internal/plugin/manifest.go` 的结构体里没有该字段;`registry.go` 的 `loadOne` 只用 `NameZh/NameEn` | | HomeAgent `plugin.json` 的 `capabilities` | **不受限** | 宽(fail-open) | 内核注释明写理由:17 个存量清单都没有它,空声明当最小权限会让它们**静默降级** | + +## 11. 相位:门不许挂在它判不了的相位上 + +**规则(可机检)**:**部署门禁只允许读"待安装的产物 + 目标机状态",不许依赖"源码树是最新构建的"这个前提。** +一条门如果在某个相位**恒红或恒绿**,那不是门的问题,是**相位挂错了**。 + +两个相位问的是不同的问题: + +| 相位 | 问的问题 | 判据 | 判法 | +| --- | --- | --- | --- | +| 构建 / 发布(`npm test`、`--release`) | 我要产出的东西,是不是从**当前源码**新鲜构建的? | `packaging`、`build-stamp`、`releaseCandidate` | **重新计算**(比对 dist/产物与源码树) | +| 安装(`deploy/install.sh`) | **别人已经产出的**这个东西,能不能装到这台机器上? | 产物自证(`gitRev`/`releaseCandidate`) | **读它自己说的**(不重算) | + +实现:`run-all.mjs` 用 `AGENTMAIL_CRITERIA_PHASE=install` 切相位;每条判据在 `SIDES` 里登记它读的 +哪一侧,`install` 相位里出现 `SOURCE` 侧的判据**直接红**;被跳过的判据会在汇总里**点名打印** +(不静默丢)。 + +三个真实实例(同一天撞到,都不是巧合):`check-shared-libs`(从 `153985e` 起在部署路径恒红)、 +`packaging`(前端一改就卡死 —— 部署路径不重新打包)、`HOME: unbound variable` +(在**所有门禁跑完之后**才炸:最贵的位置)。 diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index 38a1325..03957eb 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -161,8 +161,43 @@ if (shapeless.length) { } const reds = []; +/* + * ─── 相位(pi 2026-09-14 裁定)─── + * + * 同一份套件会被**两个不同相位**调用: + * · 构建/发布相位(`npm test`、`--release`):问"我要产出的东西是不是从**当前源码**新鲜构建的?" + * → `packaging`(包内 dist == 当前 dist)、`build-stamp`(产物 == HEAD)在这一相位才有意义; + * · 安装相位(`deploy/install.sh`,见 `AGENTMAIL_CRITERIA_PHASE=install`): + * 问"**别人已经产出的这个东西**能不能装到这台机器上?" —— 这里"dist 与源码树的关系" + * 根本不是该问的问题:部署路径不重新打包(也**不应该**重新打包,否则"review 过的产物" + * 和"装上去的产物"不是同一个东西,而且会重新构建的干跑不是干跑)。 + * + * 所以规则(可机检):**部署门禁只允许读"待安装的产物 + 目标机状态",不许依赖 + * "源码树是最新构建的"这个前提。** 一条门如果在某个相位恒红或恒绿,那不是门的问题, + * 是**相位挂错了** —— 今天撞到三条:`check-shared-libs`(从 153985e 起恒红)、 + * `packaging`(前端一改就卡死)、`HOME: unbound variable`(在门禁跑完之后才炸)。 + * + * 下面登记每条判据读的是哪一侧;`install` 相位里出现 `SOURCE` 侧判据 → 直接红。 + */ +const SIDE = { + ARTIFACT: 'artifact', // 只读产物 / 目标机状态(装得上装不上) + SOURCE: 'source' // 依赖"源码树是最新构建的"(构建相位才成立) +}; +/** 判据文件 → 它读的那一侧。没登记的按 ARTIFACT(读源码的必须显式登记,否则会被这条例外掉) */ +const SIDES = { + 'test/build-stamp.test.mjs': SIDE.SOURCE, // 产物 gitRev/srcHash == 当前源码树 + 'test/packaging.test.mjs': SIDE.SOURCE // 包内 dist == 当前 dist +}; +const PHASE = process.env.AGENTMAIL_CRITERIA_PHASE === 'install' ? 'install' : 'build'; +function sideOf(file) { return SIDES[file] || SIDE.ARTIFACT; } + const brokens = []; +const skippedByPhase = []; for (const [file, flags, expected] of SUITE) { + if (PHASE === 'install' && sideOf(file) === SIDE.SOURCE) { + skippedByPhase.push(file); + continue; + } console.log(`\n========== ${file} ==========`); const shape = shapeOf(file); const all = shape.usesNodeTest ? [...flags, '--test'] : flags; @@ -181,12 +216,24 @@ for (const [file, flags, expected] of SUITE) { * 变体验证里出现 broken = **这次变异无效,重做**,不许记成"红过了"。 */ /* - * 判据是"输出里有**语言级崩**的痕迹",不是"有没有 `not ok`"。 - * 第一版我用后者,当场误判:node:test 会把**导入期**的 ReferenceError 也报成 - * `not ok 1 - …`,于是"崩了"看起来和"断言失败"一模一样 —— 正是这条判据要治的病。 + * **别解析人类可读的文本**(pi 2026-09-14 §5):文本格式是运行器的实现细节。 + * 第一版我用"输出里有没有 `not ok`"判崩,当场误判 —— node:test 把**导入期**的 + * `ReferenceError` 也报成 `not ok 1 - …`,于是"崩了"和"断言失败"在文本上同形。 + * + * 现在按 TAP 的**名字**判,而不是按错误正文猜:文件级失败的测试名就是**文件路径**, + * 而真正的断言失败名字是判据名。所以: + * 所有 `not ok` 的名字都像路径(含 .mjs / 以 / 开头)→ **broken(判据没跑起来)**; + * 只要有一个名字像判据名 → **red(判据说不成立)**。 + * 再叠一层语言级崩痕只是兜底(没有 `not ok` 却退出非零的情形)。 + * + * 我不再往里加"更多错误关键字"——那是往文本解析里加补丁,方向是错的。 */ + const notOkNames = [...out.matchAll(/^not ok \d+ - (.*)$/gm)].map(m => m[1].trim()); + const looksLikePath = n => /(^\/|\.mjs\b|\.ts\b|\.js\b)/.test(n); + const assertionFailed = notOkNames.some(n => !looksLikePath(n)); + const onlyFileLevel = notOkNames.length > 0 && !assertionFailed; const CRASH_SIGNS = /(SyntaxError|ReferenceError|TypeError|Cannot find module|ERR_MODULE_NOT_FOUND|is not defined|is not a function|CompileError|build failed|Unexpected identifier|missing ',' in argument list)/; - const crashed = r.status !== 0 && CRASH_SIGNS.test(out); + const crashed = r.status !== 0 && (onlyFileLevel || (notOkNames.length === 0 && CRASH_SIGNS.test(out))); const empty = out.trim().length === 0; // 不 break:后面每条都要跑出来,否则"红了几条"这个信息本身是假的 if (crashed || empty) { @@ -451,7 +498,12 @@ if (dueStatic.length > 0) { * probe=ok —— 探针自己是好的(unknown 说明闸门可能锈死了,得人看一眼)。 */ const probeSummary = unknownProbes.length ? 'unknown' : 'ok'; -console.log(`RESULT static=${STATIC_ONLY.length} probe=${probeSummary}` + +if (skippedByPhase.length) { + console.log(`相位=${PHASE}:本相位不判 ${skippedByPhase.length} 条**构建相位**判据(${skippedByPhase.join('、')})` + + `—— 它们问的是"产物是不是从当前源码来的",安装相位问的是"已产出的产物能不能装";` + + `安装相位判它只能读**产物自证**(见 deploy/install.sh 的前置检查)。`); +} +console.log(`RESULT phase=${PHASE} static=${STATIC_ONLY.length} probe=${probeSummary}` + (unknownProbes.length ? `(探针跑不了:${unknownProbes.join('、')} —— 已按到期处理)` : '(只能验形态的判据:到期前提成立就自动变红)')); diff --git a/deploy/install.sh b/deploy/install.sh index 0b273da..ff06bf4 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -20,11 +20,15 @@ ETC=/etc/agentmail # 而"最后一个能跑这一步的人"(有 root 的那个人)每次都在**改完一堆东西之后**才第一次跑它, # 于是第一次失败就发生在最贵的时候。干跑把"门自己跑得动吗"提前暴露出来。 CHECK_ONLY=0 +ALLOW_DIRTY=0 +ALLOW_STALE=0 for a in "$@"; do case "$a" in --check) CHECK_ONLY=1 ;; + --allow-dirty) ALLOW_DIRTY=1 ;; + --allow-stale) ALLOW_STALE=1 ;; -h|--help) sed -n '2,20p' "$0"; exit 0 ;; - *) echo "未知参数:$a(支持 --check)" >&2; exit 2 ;; + *) echo "未知参数:$a(支持 --check / --allow-dirty / --allow-stale)" >&2; exit 2 ;; esac done @@ -49,7 +53,44 @@ if [[ $CHECK_ONLY -eq 1 ]]; then else ( cd "$REPO/client/electron" && npm ci --no-audit --no-fund 2>/dev/null || npm install --no-audit --no-fund ) fi -( cd "$REPO/client/electron" && npm run typecheck && npm test && npm run build ) +# 相位(pi 2026-09-14 裁定):部署门禁问的是"**别人已经产出的产物**能不能装到这台机器上", +# 不是"产物是不是从当前源码新鲜构建的"。后者属于构建相位(`packaging`/`build-stamp`), +# 挂在这里会**恒红**(部署路径不重新打包)—— 门挂错了相位。所以这里显式声明相位。 +( cd "$REPO/client/electron" && npm run typecheck && AGENTMAIL_CRITERIA_PHASE=install npm test && npm run build ) + +# ── 安装相位真正能判的那一半:**读产物自证**(不是重算 dist)── +# 同一个不变量("这份产物是谁")在两个相位要两种判据:构建相位重新计算,安装相位读它自己说的。 +if [[ -f "$REPO/client/electron/dist/BUILD_INFO.json" ]]; then + echo "==> 产物自证(安装相位)" + read -r A_REV A_DIRTY A_RC < <(node -e ' + const d = require("'"$REPO"'/client/electron/dist/BUILD_INFO.json"); + console.log([d.gitRev ?? "?", d.gitDirty ? "dirty" : "clean", d.releaseCandidate === undefined ? "?" : String(d.releaseCandidate)].join(" ")); + ') + HEAD_REV="$(git -C "$REPO" rev-parse --short HEAD)" + echo " 产物:gitRev=$A_REV 树=$A_DIRTY releaseCandidate=$A_RC | 当前 HEAD=$HEAD_REV" + refuse=0 + if [[ "$A_RC" == "false" ]]; then + echo " ✗ 这个包**不是发布候选**(构建时工作树是脏的)—— 它可能含着别人未提交的半成品。" >&2 + refuse=1 + fi + if [[ "$A_REV" != "$HEAD_REV" ]]; then + echo " ✗ 这个包比源码旧:产物 gitRev=$A_REV ≠ HEAD=$HEAD_REV。" >&2 + refuse=1 + fi + if [[ $refuse -eq 1 ]]; then + [[ $A_RC == "false" && $ALLOW_DIRTY -eq 1 ]] || [[ $A_REV != "$HEAD_REV" && $ALLOW_STALE -eq 1 ]] || true + if [[ $CHECK_ONLY -eq 1 ]]; then + echo " (干跑:正式安装会被拒绝。要放行就显式说清:--allow-dirty / --allow-stale)" >&2 + elif { [[ "$A_RC" != "false" || $ALLOW_DIRTY -eq 1 ]] && [[ "$A_REV" == "$HEAD_REV" || $ALLOW_STALE -eq 1 ]]; }; then + echo " (你显式放行了:--allow-dirty/--allow-stale —— 风险由你承担)" >&2 + else + echo " 拒绝安装。放行要显式:sudo $0 --allow-dirty --allow-stale" >&2 + exit 1 + fi + fi +else + echo "==> 产物自证:dist/BUILD_INFO.json 不存在(还没构建过)—— 本相位只能报"不知道",不能报"没问题"" >&2 +fi echo "==> 校验插件共用模块同源" # lib/ 下的纯函数模块在三个插件里逐字节相同(见 docs/PLUGIN-CONTRACT.md 第六节)。 diff --git a/plugins/dsh-mail-bridge/lib/mail-session-id.js b/plugins/dsh-mail-bridge/lib/mail-session-id.js index fe9778a..1509211 100644 --- a/plugins/dsh-mail-bridge/lib/mail-session-id.js +++ b/plugins/dsh-mail-bridge/lib/mail-session-id.js @@ -11,6 +11,13 @@ * 插件恰好刚重启过、那条会话还没收到新邮件 → `sessionMap` 没有它 → * 这条更新被静默忽略,DSH 运行时仍按 full 执行。人以为自己收紧了权限。 * + * ⚠ **这一格的后果是安全相关的,不是"技术限制"**(pi 2026-09-14 裁定): + * 接管会话在重启后**定位不到**,意味着一次**收紧**(full → workspace)可能被 + * **静默延迟** —— 而"延迟"对只被收紧、之后再没有新邮件的会话等于**永不生效** + * (兜底是"下次投递按邮件里的档位重新 apply",它依赖将来还有邮件)。 + * 方向是收紧,所以措辞必须按"权限可能没按你以为的那样收紧"来说, + * 而不是"档位热更新定位不到"这种技术性说法 —— 后者会让人以为只是界面问题。 + * * # 派生规则 * * 建会话时(`deliverMail` 的新建分支)id 是确定性的: diff --git a/plugins/dsh-mail-bridge/src/index.ts b/plugins/dsh-mail-bridge/src/index.ts index 8769780..7425a20 100644 --- a/plugins/dsh-mail-bridge/src/index.ts +++ b/plugins/dsh-mail-bridge/src/index.ts @@ -2172,9 +2172,26 @@ function permissionPrompt(data: any): string { if (!sid || !pm) break; const found = findLiveDshSession(sid); if (!found) { - // 会话不在运行(插件重启后尚未收到新邮件、或从未投过)。 - // 无需处理:下次投递时 deliverMail 会按邮件里带的 permission_mode - // 重新 applyPermissionMode,档位不会丢。 + /* + * ⚠ **安全相关**(pi 2026-09-14 裁定):这不是"外观恢复不了",而是 + * **"人以为自己收紧了权限、实际没有"** —— 方向是收紧,正是危险的那一侧。 + * + * 两种成因,都靠这一行日志才看得见: + * ① 这条会话此刻确实不在运行(插件刚重启、或从未投过信); + * ② 这是**接管会话(adopted)**:它的 DSH 会话 id 由平台生成, + * 从邮件会话 id **推不出来**,只能靠内存映射 —— 重启后那张表是空的, + * 于是这条更新永远找不到目标(见 lib/mail-session-id.js 的模块头)。 + * 两种情形在这里**无法区分**(能区分就需要一张落盘的映射),所以日志把两种都写出来。 + * + * 兜底是"下次投递时按邮件里带的 permission_mode 重新 apply",但**它依赖将来还有邮件**: + * 一条只被收紧、之后再没有新邮件的会话,等于**没有兜底**。 + * 所以这里**不许静默 break**:静默 break 是这个坏情形的唯一成因。 + */ + console.error( + `[dsh-mail-bridge] ⚠ session_update ${sid} 的档位 ${pm} **未能应用**:` + + `找不到正在运行的 DSH 会话(可能是"此刻没在跑",也可能是**接管会话**重启后无法定位)。` + + `若这是一次**收紧**,在下次投递之前 DSH 运行时仍按旧档执行 —— 而"下次投递"要求这条会话还会收到新邮件。` + ); break; } applyPermissionMode(found.agent?.session, pm); diff --git a/plugins/homeagent-mail-bridge/build.sh b/plugins/homeagent-mail-bridge/build.sh index df8377d..a0a13c8 100755 --- a/plugins/homeagent-mail-bridge/build.sh +++ b/plugins/homeagent-mail-bridge/build.sh @@ -59,6 +59,14 @@ if [[ -x "$KERNEL_BIN" ]]; then KERNEL_SDK_LINE="$(awk -v m="$SDK_MODULE" '$1=="dep" && $2==m {print NR": "$0}' <<<"$KERNEL_INFO" | head -1)" if [[ -n "$KERNEL_SDK_LINE" ]]; then KERNEL_SDK_DEP="$(cut -d' ' -f3 <<<"${KERNEL_SDK_LINE#*: }")" + # 二进制本身会变(实测:两次读数之间它被重编过),所以"现在链的是什么"必须带上 + # (路径, mtime, 正在运行的进程启动时间) —— 否则那份读数没有时间坐标(pi 2026-09-14)。 + KERNEL_MTIME="$(stat -c '%y' "$KERNEL_BIN" 2>/dev/null || echo '?')" + KERNEL_PID_START="$(for d in /proc/[0-9]*; do + c="$(tr '\0' ' ' < "$d/cmdline" 2>/dev/null || true)" + case "$c" in *homed*) stat -c 'pid=%n 启动=%y' "$d" 2>/dev/null; break;; esac + done)" + echo "[build] 内核二进制:$KERNEL_BIN mtime=$KERNEL_MTIME;运行中的进程:${KERNEL_PID_START:-(没在跑)}" # 只接受**紧跟在 SDK 那一行之后**的 => 行(按位置配对的唯一正确写法) KERNEL_SDK_REPL="$(awk -v m="$SDK_MODULE" ' $1=="dep" && $2==m {want=NR+1} @@ -108,6 +116,9 @@ sdkDir=$SDK_DIR sdkHash=$SDK_HASH sdkMetaVersion=${SDK_VER:-unknown} builtAt=$(date -Iseconds) +kernelBin=$KERNEL_BIN +kernelBinMtime=${KERNEL_MTIME:-?} +kernelRunningProcess=${KERNEL_PID_START:-none} # --- 以下是 \`go version -m $KERNEL_BIN\` 的原文(原样,不改写)--- ${KERNEL_INFO:-(取不到:$KERNEL_BIN 不可读或 go 不在 PATH)} diff --git a/plugins/opencode-mail-bridge/lib/mail-session-id.js b/plugins/opencode-mail-bridge/lib/mail-session-id.js new file mode 100644 index 0000000..1509211 --- /dev/null +++ b/plugins/opencode-mail-bridge/lib/mail-session-id.js @@ -0,0 +1,79 @@ +/** + * 邮件会话 → DSH 会话 id 的确定性派生。 + * + * # 为什么需要它 + * + * `session_update`(人在 WebUI 里改权限档位)必须找到**正在运行**的那条 DSH + * 会话才能立刻生效。查找原来只走 `sessionMap`,而那张表是纯内存的 —— + * 插件重启后为空。 + * + * 于是一个具体的坏情形:人把一条 full 会话在界面上改回 workspace, + * 插件恰好刚重启过、那条会话还没收到新邮件 → `sessionMap` 没有它 → + * 这条更新被静默忽略,DSH 运行时仍按 full 执行。人以为自己收紧了权限。 + * + * ⚠ **这一格的后果是安全相关的,不是"技术限制"**(pi 2026-09-14 裁定): + * 接管会话在重启后**定位不到**,意味着一次**收紧**(full → workspace)可能被 + * **静默延迟** —— 而"延迟"对只被收紧、之后再没有新邮件的会话等于**永不生效** + * (兜底是"下次投递按邮件里的档位重新 apply",它依赖将来还有邮件)。 + * 方向是收紧,所以措辞必须按"权限可能没按你以为的那样收紧"来说, + * 而不是"档位热更新定位不到"这种技术性说法 —— 后者会让人以为只是界面问题。 + * + * # 派生规则 + * + * 建会话时(`deliverMail` 的新建分支)id 是确定性的: + * - 首次尝试:`mail-<邮件会话 id>` + * - 模型降级重试:`mail-<邮件会话 id>-r`(i 从 1 开始) + * + * 接管会话(adopted)是唯一的例外:那条 DSH 会话 id 是平台自己生成的, + * 从邮件会话 id **推不出来**,只能靠内存映射。重启后接管会话的档位热更新 + * 确实无法定位 —— 这是已知取舍,不是这里能修的。 + */ + +/** 首次尝试使用的 DSH 会话 id。 */ +export function dshSessionIdForMail(mailSessionID) { + return `mail-${String(mailSessionID ?? '')}`; +} + +/** + * 判断一个 DSH 会话 id 是否属于某条邮件会话。 + * + * @param {string} dshSessionId DSH 侧会话 id + * @param {string} mailSessionID AgentMail 侧会话 id + * @returns {boolean} + */ +export function matchesMailSession(dshSessionId, mailSessionID) { + const id = String(dshSessionId ?? ''); + const base = dshSessionIdForMail(mailSessionID); + if (id === base) return true; + // 模型降级重试:mail--r1 / -r2 / … + const suffix = id.startsWith(`${base}-r`) ? id.slice(base.length + 2) : ''; + return suffix.length > 0 && /^\d+$/.test(suffix); +} + +/** + * 从一批候选会话里挑出属于该邮件会话的那条。 + * + * 优先 `mail-`(首次尝试),其次序号最小的 `-r` —— 与 deliverMail + * 的尝试顺序一致,而不是数组顺序。 + * + * @param {string[]} dshSessionIds + * @param {string} mailSessionID + * @returns {string|undefined} + */ +export function pickMailSession(dshSessionIds, mailSessionID) { + const base = dshSessionIdForMail(mailSessionID); + const list = Array.isArray(dshSessionIds) ? dshSessionIds.map(String) : []; + if (list.includes(base)) return base; + + let best; + let bestIndex = Infinity; + for (const id of list) { + if (!matchesMailSession(id, mailSessionID)) continue; + const idx = Number(id.slice(base.length + 2)); + if (idx < bestIndex) { + bestIndex = idx; + best = id; + } + } + return best; +} diff --git a/plugins/pi-mail-bridge/lib/mail-session-id.js b/plugins/pi-mail-bridge/lib/mail-session-id.js new file mode 100644 index 0000000..1509211 --- /dev/null +++ b/plugins/pi-mail-bridge/lib/mail-session-id.js @@ -0,0 +1,79 @@ +/** + * 邮件会话 → DSH 会话 id 的确定性派生。 + * + * # 为什么需要它 + * + * `session_update`(人在 WebUI 里改权限档位)必须找到**正在运行**的那条 DSH + * 会话才能立刻生效。查找原来只走 `sessionMap`,而那张表是纯内存的 —— + * 插件重启后为空。 + * + * 于是一个具体的坏情形:人把一条 full 会话在界面上改回 workspace, + * 插件恰好刚重启过、那条会话还没收到新邮件 → `sessionMap` 没有它 → + * 这条更新被静默忽略,DSH 运行时仍按 full 执行。人以为自己收紧了权限。 + * + * ⚠ **这一格的后果是安全相关的,不是"技术限制"**(pi 2026-09-14 裁定): + * 接管会话在重启后**定位不到**,意味着一次**收紧**(full → workspace)可能被 + * **静默延迟** —— 而"延迟"对只被收紧、之后再没有新邮件的会话等于**永不生效** + * (兜底是"下次投递按邮件里的档位重新 apply",它依赖将来还有邮件)。 + * 方向是收紧,所以措辞必须按"权限可能没按你以为的那样收紧"来说, + * 而不是"档位热更新定位不到"这种技术性说法 —— 后者会让人以为只是界面问题。 + * + * # 派生规则 + * + * 建会话时(`deliverMail` 的新建分支)id 是确定性的: + * - 首次尝试:`mail-<邮件会话 id>` + * - 模型降级重试:`mail-<邮件会话 id>-r`(i 从 1 开始) + * + * 接管会话(adopted)是唯一的例外:那条 DSH 会话 id 是平台自己生成的, + * 从邮件会话 id **推不出来**,只能靠内存映射。重启后接管会话的档位热更新 + * 确实无法定位 —— 这是已知取舍,不是这里能修的。 + */ + +/** 首次尝试使用的 DSH 会话 id。 */ +export function dshSessionIdForMail(mailSessionID) { + return `mail-${String(mailSessionID ?? '')}`; +} + +/** + * 判断一个 DSH 会话 id 是否属于某条邮件会话。 + * + * @param {string} dshSessionId DSH 侧会话 id + * @param {string} mailSessionID AgentMail 侧会话 id + * @returns {boolean} + */ +export function matchesMailSession(dshSessionId, mailSessionID) { + const id = String(dshSessionId ?? ''); + const base = dshSessionIdForMail(mailSessionID); + if (id === base) return true; + // 模型降级重试:mail--r1 / -r2 / … + const suffix = id.startsWith(`${base}-r`) ? id.slice(base.length + 2) : ''; + return suffix.length > 0 && /^\d+$/.test(suffix); +} + +/** + * 从一批候选会话里挑出属于该邮件会话的那条。 + * + * 优先 `mail-`(首次尝试),其次序号最小的 `-r` —— 与 deliverMail + * 的尝试顺序一致,而不是数组顺序。 + * + * @param {string[]} dshSessionIds + * @param {string} mailSessionID + * @returns {string|undefined} + */ +export function pickMailSession(dshSessionIds, mailSessionID) { + const base = dshSessionIdForMail(mailSessionID); + const list = Array.isArray(dshSessionIds) ? dshSessionIds.map(String) : []; + if (list.includes(base)) return base; + + let best; + let bestIndex = Infinity; + for (const id of list) { + if (!matchesMailSession(id, mailSessionID)) continue; + const idx = Number(id.slice(base.length + 2)); + if (idx < bestIndex) { + bestIndex = idx; + best = id; + } + } + return best; +} diff --git a/plugins/zcode-mail-bridge/lib/mail-session-id.js b/plugins/zcode-mail-bridge/lib/mail-session-id.js new file mode 100644 index 0000000..1509211 --- /dev/null +++ b/plugins/zcode-mail-bridge/lib/mail-session-id.js @@ -0,0 +1,79 @@ +/** + * 邮件会话 → DSH 会话 id 的确定性派生。 + * + * # 为什么需要它 + * + * `session_update`(人在 WebUI 里改权限档位)必须找到**正在运行**的那条 DSH + * 会话才能立刻生效。查找原来只走 `sessionMap`,而那张表是纯内存的 —— + * 插件重启后为空。 + * + * 于是一个具体的坏情形:人把一条 full 会话在界面上改回 workspace, + * 插件恰好刚重启过、那条会话还没收到新邮件 → `sessionMap` 没有它 → + * 这条更新被静默忽略,DSH 运行时仍按 full 执行。人以为自己收紧了权限。 + * + * ⚠ **这一格的后果是安全相关的,不是"技术限制"**(pi 2026-09-14 裁定): + * 接管会话在重启后**定位不到**,意味着一次**收紧**(full → workspace)可能被 + * **静默延迟** —— 而"延迟"对只被收紧、之后再没有新邮件的会话等于**永不生效** + * (兜底是"下次投递按邮件里的档位重新 apply",它依赖将来还有邮件)。 + * 方向是收紧,所以措辞必须按"权限可能没按你以为的那样收紧"来说, + * 而不是"档位热更新定位不到"这种技术性说法 —— 后者会让人以为只是界面问题。 + * + * # 派生规则 + * + * 建会话时(`deliverMail` 的新建分支)id 是确定性的: + * - 首次尝试:`mail-<邮件会话 id>` + * - 模型降级重试:`mail-<邮件会话 id>-r`(i 从 1 开始) + * + * 接管会话(adopted)是唯一的例外:那条 DSH 会话 id 是平台自己生成的, + * 从邮件会话 id **推不出来**,只能靠内存映射。重启后接管会话的档位热更新 + * 确实无法定位 —— 这是已知取舍,不是这里能修的。 + */ + +/** 首次尝试使用的 DSH 会话 id。 */ +export function dshSessionIdForMail(mailSessionID) { + return `mail-${String(mailSessionID ?? '')}`; +} + +/** + * 判断一个 DSH 会话 id 是否属于某条邮件会话。 + * + * @param {string} dshSessionId DSH 侧会话 id + * @param {string} mailSessionID AgentMail 侧会话 id + * @returns {boolean} + */ +export function matchesMailSession(dshSessionId, mailSessionID) { + const id = String(dshSessionId ?? ''); + const base = dshSessionIdForMail(mailSessionID); + if (id === base) return true; + // 模型降级重试:mail--r1 / -r2 / … + const suffix = id.startsWith(`${base}-r`) ? id.slice(base.length + 2) : ''; + return suffix.length > 0 && /^\d+$/.test(suffix); +} + +/** + * 从一批候选会话里挑出属于该邮件会话的那条。 + * + * 优先 `mail-`(首次尝试),其次序号最小的 `-r` —— 与 deliverMail + * 的尝试顺序一致,而不是数组顺序。 + * + * @param {string[]} dshSessionIds + * @param {string} mailSessionID + * @returns {string|undefined} + */ +export function pickMailSession(dshSessionIds, mailSessionID) { + const base = dshSessionIdForMail(mailSessionID); + const list = Array.isArray(dshSessionIds) ? dshSessionIds.map(String) : []; + if (list.includes(base)) return base; + + let best; + let bestIndex = Infinity; + for (const id of list) { + if (!matchesMailSession(id, mailSessionID)) continue; + const idx = Number(id.slice(base.length + 2)); + if (idx < bestIndex) { + bestIndex = idx; + best = id; + } + } + return best; +}