fix(相位/安全): 部署门禁只判产物自证;静默 break 改成出声;内核读数带时间坐标

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` 与**正在运行的进程启动时间** —— 二进制会在两次读数之间被换掉,
   没有时间坐标的读数不成立。
This commit is contained in:
2026-09-14 16:54:30 +08:00
parent 6702cc2f5e
commit 33488760ce
9 changed files with 395 additions and 10 deletions

View File

@ -11,6 +11,13 @@
* 插件恰好刚重启过、那条会话还没收到新邮件 → `sessionMap` 没有它 →
* 这条更新被静默忽略,DSH 运行时仍按 full 执行。人以为自己收紧了权限。
*
* ⚠ **这一格的后果是安全相关的,不是"技术限制"**(pi 2026-09-14 裁定):
* 接管会话在重启后**定位不到**,意味着一次**收紧**(full → workspace)可能被
* **静默延迟** —— 而"延迟"对只被收紧、之后再没有新邮件的会话等于**永不生效**
* (兜底是"下次投递按邮件里的档位重新 apply",它依赖将来还有邮件)。
* 方向是收紧,所以措辞必须按"权限可能没按你以为的那样收紧"来说,
* 而不是"档位热更新定位不到"这种技术性说法 —— 后者会让人以为只是界面问题。
*
* # 派生规则
*
* 建会话时(`deliverMail` 的新建分支)id 是确定性的:

View File

@ -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);

View File

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

View File

@ -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>`(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-<id>-r1 / -r2 / …
const suffix = id.startsWith(`${base}-r`) ? id.slice(base.length + 2) : '';
return suffix.length > 0 && /^\d+$/.test(suffix);
}
/**
* 从一批候选会话里挑出属于该邮件会话的那条。
*
* 优先 `mail-<id>`(首次尝试),其次序号最小的 `-r<i>` —— 与 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;
}

View File

@ -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>`(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-<id>-r1 / -r2 / …
const suffix = id.startsWith(`${base}-r`) ? id.slice(base.length + 2) : '';
return suffix.length > 0 && /^\d+$/.test(suffix);
}
/**
* 从一批候选会话里挑出属于该邮件会话的那条。
*
* 优先 `mail-<id>`(首次尝试),其次序号最小的 `-r<i>` —— 与 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;
}

View File

@ -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>`(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-<id>-r1 / -r2 / …
const suffix = id.startsWith(`${base}-r`) ? id.slice(base.length + 2) : '';
return suffix.length > 0 && /^\d+$/.test(suffix);
}
/**
* 从一批候选会话里挑出属于该邮件会话的那条。
*
* 优先 `mail-<id>`(首次尝试),其次序号最小的 `-r<i>` —— 与 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;
}