From 258b88da2269b10a7e2c1eeccd9ed0e78dead085 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 21 Sep 2026 04:54:45 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E5=88=A4=E6=8D=AE=20?= =?UTF-8?q?=E2=91=A0=20=E7=9A=84**=E5=88=86=E6=AF=8D**=E6=B7=B7=E7=9D=80?= =?UTF-8?q?=E6=B2=A1=E6=AF=94=E8=BF=87=E7=9A=84=E6=96=87=E4=BB=B6=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20`scanned++`=20=E5=9C=A8=20`readFile`=20?= =?UTF-8?q?=E4=B9=8B=E5=89=8D=EF=BC=8C=E8=AF=BB=E5=A4=B1=E8=B4=A5=E9=9D=99?= =?UTF-8?q?=E9=BB=98=E8=B7=B3=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 与 A 条(`catch { return; }` 吞 ENOENT ⇒ 报"一致")**同一族**:分母里混着没真比过的 对象 ⇒ 报出来的 `0` 不是闭合的。这条是**我自己写的**代码里的同形错误。 ## 形状 scanned++; // ← 先加分母 try { text = String(readFile(full,'utf8')); } catch { continue; } // ← 读不到就静默跳过 if (text.includes(REPO)) offenders.push(full); ⇒ 一个「文件存在、但读不到」(EACCES / 悬空软链 / I/O 错)**被计入"比了 N 个", 却从没被 grep 过**,note 照样报"比了 N 个文件、命中 0"。 实测复现(喂一个"存在但读抛 EACCES"的文件):note 说"比了 **2** 个",真正被 grep 的只有 **1** 个。 ## 修法 1. `scanned++` 移到 `readFile` **成功之后** ⇒ 分母 = 真读到内容的条数; 2. 读失败的单列 `unreadable` 并**判红** —— "我没能检查它"与"它没问题"是两件事; 3. 失败 note 把三类分清楚(引用仓库的 / 软链指向仓库的 / **我没读到的**), 第三类点明"这几条没被检查",否则读者会把它读成"它引用了仓库"。 ## 变异验证(两边都跑) 变异①`scanned++` 挪回读之前(旧语义)⇒ 新判据**红**; 变异②读失败**静默跳过**(既不红也不计)⇒ 新判据**红**。 修后自检 66 通过 / 3 失败,那 3 条是**既有线上红**(①b 权限 3 文件 600↔644、 ⑥ 二进制含 63 处源码路径),与本次改动无关。 ## 顺带:把 §六 那条推理写进注释 `DEPLOY_ROOT` **故意硬编码**、不读 `AGENTMAIL_PREFIX` —— 因为前缀写错会以**红**暴露 (② 与 `configNeedle` 都落到 `else fail('指向别处')`),是 **loud failure** 而非假绿。 并写明:真要收应从 `current` 软链推导,而不是再加一个三方要同步的常量。 ★ 另记一条口径(pi §一 给的验收是 133,今天实测是 **135**):判据数的分母会随机器 变化(这里是 70 普通文件 + 65 软链),**所以验收标准不该钉死在某个绝对值上**, 该钉的是"分母 = 真读到内容的条数"这个**关系**。 --- deploy/check-deploy-drift.mjs | 116 ++++++++++++++++++++++++++++++++-- 1 file changed, 112 insertions(+), 4 deletions(-) diff --git a/deploy/check-deploy-drift.mjs b/deploy/check-deploy-drift.mjs index 43a1676..d87d2ab 100644 --- a/deploy/check-deploy-drift.mjs +++ b/deploy/check-deploy-drift.mjs @@ -53,6 +53,20 @@ import { execFileSync } from 'node:child_process'; const HERE = dirname(fileURLToPath(import.meta.url)); const REPO = join(HERE, '..'); +/** + * 部署根(`/opt/agentmail/plugins`)—— **故意硬编码,不认 `AGENTMAIL_PREFIX`**。 + * + * 这不是漏做,是一个**权衡后接受**的选择(pi 评审 2026-09-14 给了这个论证,我采纳): + * 前缀写错**不会造成假绿**,而是以**红**的方式暴露 —— + * 若真实前缀不是 `/opt/agentmail`,下面 ② 的 `execStart.includes('/opt/agentmail/plugins/…')` + * 立刻落到 `else fail(… '指向别处')`;`configNeedle`(宿主配置那两行)同理报红。 + * 也就是说这里的硬编码是 **loud failure**,不是 silent pass ⇒ 可以接受。 + * + * ⚠️ 所以**不要**为了让这里"能认任意前缀"而放心地去设 `AGENTMAIL_PREFIX`: + * 三个工具(`install.sh` / `redeploy-gateway.sh` / 本文件)目前已统一读那个变量, + * 而本文件**不读**。真要收,最小一步是让 `DEPLOY_ROOT` 从 `current` 软链自身推导, + * 而不是再新增一个需要三方同步的常量(那正是"三套 PREFIX 各说各话"的老路)。 + */ const DEPLOY_ROOT = '/opt/agentmail/plugins'; /** 仓库里 systemd 单元的**唯一真相**目录(`install.sh` 也从这里 `find`)。 @@ -856,6 +870,8 @@ export function checkLayout(inject = {}) { const realpathForLinks = inject.realpath ?? realpathSync; let scanned = 0; let scannedLinks = 0; + // 存在、但**读不到内容**的条目:它们既不能算"比过",也不能当成"没问题"。 + const unreadable = []; // ★ **不划圈**:每个普通文件都读一遍再 grep(pi 评审 2026-09-14,我原先按后缀取)。 // // 我原先把这条推迟了,理由是"实测零违规 ⇒ 扩口径只增噪声"。pi 用**算术**驳回了口味问题: @@ -884,6 +900,23 @@ export function checkLayout(inject = {}) { if (e.isDirectory()) walk(full); else { // 不划圈:任何普通文件都读(见上面的论证)。软链在下面单独按 realpath 判。 + // + // ★★ `scanned` **必须只在真的读到内容之后**才加(2026-09-21 修)。 + // 原先 `scanned++` 在 `readFile` **之前**,而下面的 `catch { continue; }` 是静默的 + // ⇒ 一个「文件在、但读不到」(EACCES / 悬空软链 / I/O 错)会被计入分母 + // **却从没被 grep 过**,note 照样报"比了 N 个文件、命中 0"。 + // 这与本文件里 pi 抓到的那个假绿(`catch { return; }` 吞 ENOENT ⇒ 报"一致") + // 是**同一族**:分母里混着没真比过的对象 ⇒ `0` 不再是闭合的。 + // 实测复现(喂一个"存在但读抛 EACCES"的文件):修前 note 说"比了 2 个", + // 而真正被 grep 的只有 1 个。修后读不到的单列 `unreadable`,并**判红** —— + // 因为"我没能检查它"与"它没问题"是两件事,前者不该产出绿的结论。 + let text = ''; + try { + text = String(readFile(full, 'utf8')); + } catch (err) { + unreadable.push(`${full}(${err?.code ?? err?.message ?? '读取失败'})`); + continue; + } scanned++; // 软链另算:上面只 grep 了**内容**,跟随软链的单元必须按目标位置判。 try { @@ -893,8 +926,6 @@ export function checkLayout(inject = {}) { if (real.startsWith(`${REPO}/`)) repoLinks.push(`${full} → ${real}`); } } catch { /* 悬空软链:读不到目标,交给 ② 的 walk 报 */ } - let text = ''; - try { text = String(readFile(full, 'utf8')); } catch { continue; } if (text.includes(REPO)) offenders.push(full); } } @@ -903,13 +934,20 @@ export function checkLayout(inject = {}) { // 覆盖面写进 note:`0` 只有在"它能被证伪的范围"写明之后才是结论 // (这正是这条判据当初报"0 个文件"时缺的那句话)。 // 现在范围是**闭合**的(全部文件),不再只是"对我划的那个圈成立"。 - const refOk = offenders.length === 0 && repoLinks.length === 0; + const refOk = offenders.length === 0 && repoLinks.length === 0 && unreadable.length === 0; + // 失败时的 note 要把**三类**分清楚:引用了仓库的 / 软链指向仓库的 / **我没读到**的。 + // 第三类单列且点明"这几条没被检查" —— 否则读者会把"读不到"也读成"它引用了仓库"。 + const refBad = [ + ...offenders, + ...repoLinks, + ...(unreadable.length ? [`读不到(**这几条没被检查**,不是"它们没问题"):${unreadable.join(';')}`] : []) + ].join(' '); push( '没有任何 unit/drop-in/.bak 引用源码目录(含软链指向仓库)', refOk, refOk ? `比了 ${scanned} 个文件(**全部**,不筛后缀)、命中 0;其中软链 ${scannedLinks} 个另按 realpath 判目标` - : [...offenders, ...repoLinks].join(' ') + : refBad ); // ② 已安装单元与仓库副本一致(仓库是唯一真相)—— **两个方向都判** @@ -1207,6 +1245,67 @@ export function layoutSelfCheck() { '/opt/agentmail/bin/service-failure-notify.mjs': 'x', '/opt/agentmail/agentmail-gateway': 'fake-elf' })); + // ★ ① 的**「读不到」分支**(2026-09-21):文件存在、但 `readFile` 抛错(EACCES / + // 悬空软链 / I/O 错)时,它**既没被 grep、也不能算比过**。原先 `scanned++` 在 + // `readFile` 之前、`catch` 静默 `continue` ⇒ 分母虚增、note 照样报"比了 N 个、命中 0"。 + // 这与本文件那条假绿同族:**分母里混着没真比过的对象 ⇒ `0` 不是闭合的。** + // 样本形状:两个文件,其中一个读时抛 EACCES。 + const badUnreadable = checkLayout({ + ...fake({ + '/etc/systemd/system': [ + { name: 'a.service', isDirectory: () => false }, + { name: 'locked.service', isDirectory: () => false } + ], + '/etc/systemd/system/a.service': 'ExecStart=/opt/agentmail/agentmail-gateway', + '/etc/systemd/system/locked.service': 'ExecStart=/opt/agentmail/agentmail-gateway', + '/repo/systemd': [], + '/opt/agentmail/bin/service-failure-notify.mjs': 'x', + '/opt/agentmail/agentmail-gateway': 'fake-elf' + }), + readFile: p => { + if (p.endsWith('locked.service')) { + const e = new Error('permission denied'); + e.code = 'EACCES'; + throw e; + } + if (p === '/etc/systemd/system/a.service') return 'ExecStart=/opt/agentmail/agentmail-gateway'; + const m = { + '/repo/systemd': '', + '/opt/agentmail/bin/service-failure-notify.mjs': 'x', + '/opt/agentmail/agentmail-gateway': 'fake-elf' + }; + if (p in m) return m[p]; + throw new Error('ENOENT'); + } + }); + + // ★ 分母样本:两个文件、都能读到 ⇒ note 必须说"比了 2 个文件"。 + // 配上面那条「读不到 ⇒ 1 个」,两条一起把**分母**钉死(只钉一侧会漏掉虚增)。 + const goodLinkReadable = checkLayout({ + ...fake({ + '/etc/systemd/system': [ + { name: 'a.service', isDirectory: () => false }, + { name: 'b.service', isDirectory: () => false } + ], + '/etc/systemd/system/a.service': 'ExecStart=/opt/agentmail/agentmail-gateway', + '/etc/systemd/system/b.service': 'ExecStart=/opt/agentmail/agentmail-gateway', + '/repo/systemd': [], + '/opt/agentmail/bin/service-failure-notify.mjs': 'x', + '/opt/agentmail/agentmail-gateway': 'fake-elf' + }), + readFile: p => { + const m = { + '/etc/systemd/system/a.service': 'ExecStart=/opt/agentmail/agentmail-gateway', + '/etc/systemd/system/b.service': 'ExecStart=/opt/agentmail/agentmail-gateway', + '/repo/systemd': '', + '/opt/agentmail/bin/service-failure-notify.mjs': 'x', + '/opt/agentmail/agentmail-gateway': 'fake-elf' + }; + if (p in m) return m[p]; + throw new Error('ENOENT'); + } + }); + // ★ ① 的**软链分支**:单元内容是干净的(不含仓库字面量),但软链**指向**仓库 ⇒ 必须红。 // // 这条形状三条判据原先全都看不见(pi 反例):① 只 grep 内容、② 比的内容相同 @@ -1353,6 +1452,15 @@ export function layoutSelfCheck() { ok: unitRefCheck(badLink)?.ok === false && /→/.test(unitRefCheck(badLink)?.note ?? '') }, { name: '★软链指向仓库外 ⇒ 不许红(否则这条判据恒红)', ok: unitRefCheck(goodLink)?.ok === true }, + // ★ 「读不到」必须是红,且要点名是哪一条 —— 否则它会被当成"它引用了仓库", + // 也可能被下一个人"顺手"改回"读不到就跳过"。同时钉住分母:绿样本里的 + // `比了 N 个` 必须等于**真的读到内容**的条数(这里 2 个文件里 1 个读不到 ⇒ 只 1 个)。 + { name: '★文件存在但读不到 ⇒ 必须红,且点名"这几条没被检查"', + ok: unitRefCheck(badUnreadable)?.ok === false + && /没被检查/.test(unitRefCheck(badUnreadable)?.note ?? '') + && /locked\.service/.test(unitRefCheck(badUnreadable)?.note ?? '') }, + { name: '★可读文件仍要计入分母(两文件都读得到 ⇒ "比了 2 个文件")', + ok: /比了 2 个文件/.test(unitRefCheck(goodLinkReadable)?.note ?? '') && unitRefCheck(goodLinkReadable)?.ok === true }, // 依赖树:一致必须绿、变了必须红、一侧没有必须红 —— 三面都钉。 { name: '★依赖树一致 ⇒ 绿,且说出比了几个包', ok: sameDeps?.ok === true && /2 个包/.test(sameDeps?.note ?? '') }, { name: '★依赖树版本变了 ⇒ 必须红', ok: diffDeps?.ok === false && /不一致/.test(diffDeps?.note ?? '') },