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 ?? '') },