Files
MailUI4Agents/deploy/check-deploy-drift.mjs
JianFeeeee 33b6033bdd 文档: 去掉判据 ① 里写死的验收数字「133」—— 它第二天就过期了
我在注释里把验收写成「比了 **133** 个文件(全部,不筛后缀)、命中 0」。
那是 pi 给的算式(`/etc/systemd/system` 133 个文件 − 现行口径 129 = 差集 4)当天的快照。
**今天实测是 135** —— 系统装/卸一个 unit 就会变(我这次是 multi-user.target.wants 下多了 3 个)。

⇒ 把「以某个绝对值为验收」改成「以**关系**为验收」:
   要钉的是 `比了 N 个` 的 N **必须等于真的读到内容的条数**
   (读不到的单列 `unreadable` 并判红 —— 那正是我上一笔修的分母问题)。
   验收时看 note 里的**实际 N**,别看历史值。

★ 同族:这与我这几轮反复记的「判据的严格度必须与它真正想守的那件事对齐」是一回事 ——
  这里想守的是「分母没有混进没比过的对象」,那是个**关系**,不是一个数。
  把关系写成常数,等于把判据焊死在「当天的机器状态」上。
2026-09-21 05:19:49 +08:00

1575 lines
84 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.

#!/usr/bin/env node
/**
* 部署漂移检查:**已部署的快照 vs 仓库 HEAD**,以及进程到底跑的是哪份代码。
*
* # 为什么需要它
*
* 「部署脚本跑过了」不等于「线上跑的是当前代码」。有几种各自独立的漂移方式,
* 而且都不会有人在意 —— 直到出问题时才发现线上是几天前的行为:
*
* 1. **仓库改了、快照没重新部署**。快照是独立副本,改仓库不会影响它。
* 2. **软链切换了、进程没重启**。`current` 只是个符号链接,切换它**不会**
* 重载已经在跑的进程 —— 进程持有的是启动那一刻加载进内存的代码。
* 3. **单元/配置改了、没 daemon-reload 或没重装**,于是进程仍按旧路径加载。
*
* # 判据必须按宿主真实的加载方式分开写(这一条是踩出来的)
*
* 第一版对四个宿主都用了同一套判据:「单元 ExecStart 指向 current」+
* 「进程 argv 里有快照路径」。结果 **opencode 与 dsh 双双假红** —— 它们根本
* 不是「自己起一个进程」那种宿主:
*
* | 宿主 | 谁加载插件 | 从哪里读 | 切换后需要重启吗 |
* |---|---|---|---|
* | pi | 自己的进程 | systemd unit 的 ExecStart | 需要(启动时加载) |
* | zcode | 自己的驱动进程 | systemd unit 的 ExecStart | 需要(启动时加载) |
* | dsh | dsh 宿主进程 | profile 的 `link:` → `node_modules` 软链 | 需要(服务启动时加载) |
* | opencode | opencode 宿主进程 | `opencode.jsonc` 的 `plugin:` | 不需要(会话创建时惰加载) |
*
* 用「进程 argv 里有快照路径」去量 opencode/dsh,永远为假 —— 它们的 argv 里
* 只有自己的可执行文件。**判据必须与它量的对象同一维度**,否则就是一条永假条件,
* 而永假条件在检查器里表现为「稳定的红灯」,人会学会忽略它。
*
* # 为什么不用 diff
*
* 本机 PATH 上的 `diff` 是鸿蒙 SDK 工具链里的那个:不认 `-q`,而且对
* **内容不同的文件仍然返回 0**(`deploy/check-shared-libs.sh` 的头注释记了这件事)。
* 所以这里一律自己算 sha256 比对。
*
* # 用法
*
* node deploy/check-deploy-drift.mjs # 人类可读
* node deploy/check-deploy-drift.mjs --json # 机器可读
* node deploy/check-deploy-drift.mjs --self-check # 先证明判据本身能发现差异
*
* 退出码:0 无运行文件漂移;1 有漂移(或判据自检失败)。
*/
import { createHash } from 'node:crypto';
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { join, relative, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { tmpdir } from 'node:os';
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`)。
*
* ★ 导出是为了让自检能**直接断言这个默认值本身**。原先的写法
* `new URL('../systemd', import.meta.url).pathname` 解析成
* `/home/program/agentmail/systemd`(**ENOENT**,真身在 `deploy/systemd/`),
* 而所有自检样本都注入 `repoUnits` ⇒ 默认值从没被走过、写错了也没人知道。 */
export const DEFAULT_REPO_UNITS = join(HERE, 'systemd');
/** 与 `deploy/redeploy-plugin.sh` 的排除清单对齐 —— **但口径必须写明,
* 因为对不齐就会出「脚本说一致、部署脚本却拷了别的」这种假绿**
* (pi 评审 2026-09-14 逐条对过,下表是核对后的实况):
*
* | 项 | 部署脚本 | 本文件 | 处置 |
* |---|---|---|---|
* | `test` | `rm -rf "$STAGING/test"` | 排除 | 对齐 |
* | `.git` | `rm -rf` | 排除 | 对齐 |
* | `node_modules` | **拷进快照** | 原为整份排除 ⇒ **假绿** | **改判**:见下方 ②b 依赖树判据 |
* | `dist.old` | `rm -rf "$STAGING/dist.old"` | 原不排除 ⇒ 仓库里若有就**永久假红** | 加进 `EXCLUDE_SUFFIX` |
* | `coverage` | 不排除 | 排除 | 仓库里当前不存在,**保留排除**(无实际影响,写明以免被当成对齐) |
* | `.cache`/`*.log`/`.DS_Store` | 只排 `node_modules/.cache`、顶层 `*.log`/`.DS_Store` | 任何深度 | **方向相反**:本文件比脚本更宽 ⇒ 快照里的深层残留看不见。危害小,保留;写在这里免得"以为对齐了" |
*
* ⚠️ `node_modules` 那格是最要紧的:脚本把依赖拷进快照,本文件原先整份跳过它,
* 于是"仓库换过依赖、快照还是旧的"会被报成**逐字节一致**。现在由 ②b 单独判。 */
const EXCLUDE_DIRS = new Set(['test', '.git', 'node_modules', 'coverage']);
const EXCLUDE_FILES = new Set(['.DS_Store']);
const EXCLUDE_SUFFIX = ['.log', '.old']; // `.old`:部署脚本会 rm -rf staging 里的 dist.old,仓库里若有它会造成永久假红
/**
* 宿主表。`load` 决定用哪套判据(见文件头那张表)。
*
* - `own-process`:插件自己是个进程,argv 里应当有快照路径
* - `host-package`:宿主经 node_modules 解析插件;服务启动时加载 ⇒ 切换后必须重启
* - `host-config`:宿主从配置文件读插件路径;会话创建时惰加载 ⇒ 不强制重启
*/
const HOSTS = [
{ host: 'pi', plugin: 'pi-mail-bridge', unit: 'pi-mail-bridge.service', load: 'own-process' },
{ host: 'zcode', plugin: 'zcode-mail-bridge', unit: 'zcode-mail-bridge.service', load: 'own-process' },
{
host: 'dsh',
plugin: 'dsh-mail-bridge',
unit: 'dsh.service',
load: 'host-package',
configFile: '/root/.dsh/profiles/web/package.json',
configNeedle: `link:/opt/agentmail/plugins/dsh-mail-bridge/current`,
// 真正被 Node 解析的那条:profile 的 node_modules 软链
resolvePath: '/root/.dsh/profiles/web/node_modules/dsh-mail-bridge'
},
{
host: 'opencode',
plugin: 'opencode-mail-bridge',
unit: 'opencode-serve.service',
load: 'host-config',
configFile: '/root/.config/opencode/opencode.jsonc',
configNeedle: 'file:///opt/agentmail/plugins/opencode-mail-bridge/current'
}
];
/** 文档类扩展名:它们的差异是「快照里的说明比仓库旧」,不影响运行行为。 */
const DOC_EXT = /\.(md|txt)$/i;
export function collectFiles(root) {
const out = new Map();
const walk = dir => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (EXCLUDE_DIRS.has(entry.name)) continue;
walk(full);
} else if (entry.isFile()) {
if (EXCLUDE_FILES.has(entry.name)) continue;
if (EXCLUDE_SUFFIX.some(s => entry.name.endsWith(s))) continue;
const rel = relative(root, full);
if (rel.split('/').includes('.cache')) continue;
out.set(rel, createHash('sha256').update(readFileSync(full)).digest('hex'));
}
}
};
walk(root);
return out;
}
/**
* 收集一棵树里每个文件的**权限位**(低 9 位)。
*
* ★ 为什么必须单独收(pi 评审 2026-09-15 §三 的实测实例):
* `collectFiles` 只把**内容**做 sha256,所以"内容一致、权限不同"在它眼里完全同形 ——
* 实测就是这么发生的:快照里 `src/paths.mjs`/`src/turn-cwd.mjs` 是 **0600**、仓库是 **0644**,
* 而 ① 报的是"逐字节一致"(`cmp` 也是这个结论,它同样只看内容)。
*
* 与 `deploy/check-file-modes.sh` 的**分工**(两者别合成一条):
* · 本函数 = **一致性**:部署副本的权限 = 仓库那一份。它**抓不到"两边都错"**。
* · `check-file-modes.sh` = **政策**:源文件不得比 0644 更严。它不看快照。
* 合成的结果会是"看起来覆盖了、其实只覆盖一半" —— 正是这一路在消的形状。
*/
export function collectModes(root) {
const out = new Map();
const walk = dir => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (EXCLUDE_DIRS.has(entry.name)) continue;
walk(full);
} else if (entry.isFile()) {
if (EXCLUDE_FILES.has(entry.name)) continue;
if (EXCLUDE_SUFFIX.some(s => entry.name.endsWith(s))) continue;
const rel = relative(root, full);
if (rel.split('/').includes('.cache')) continue;
// 只取低 9 位(权限),丢掉文件类型位与 setuid/setgid/sticky
out.set(rel, statSync(full).mode & 0o777);
}
}
};
walk(root);
return out;
}
/** 两棵树里"两边都在、但权限位不同"的文件(相对路径 + 两侧权限)。 */
export function diffModes(repoModes, snapModes) {
const out = [];
for (const [rel, repoMode] of repoModes) {
if (!snapModes.has(rel)) continue;
const snapMode = snapModes.get(rel);
if (repoMode !== snapMode) out.push({ path: rel, repoMode, snapMode });
}
return out.sort((a, b) => a.path.localeCompare(b.path));
}
/** 比较两棵树,返回分类后的差异。导出是为了让自检能直接调它。 */
export function diffTrees(repoFiles, snapFiles) {
const onlyRepo = [...repoFiles.keys()].filter(k => !snapFiles.has(k)).sort();
const onlySnap = [...snapFiles.keys()].filter(k => !repoFiles.has(k)).sort();
const changed = [...repoFiles.keys()]
.filter(k => snapFiles.has(k) && repoFiles.get(k) !== snapFiles.get(k))
.sort();
const classify = list =>
list.reduce((acc, k) => (acc[DOC_EXT.test(k) ? 'doc' : 'runtime'].push(k), acc), { doc: [], runtime: [] });
const a = classify(onlyRepo);
const b = classify(onlySnap);
const c = classify(changed);
return {
onlyRepo: { doc: a.doc, runtime: a.runtime },
onlySnap: { doc: b.doc, runtime: b.runtime },
changed: { doc: c.doc, runtime: c.runtime }
};
}
/**
* `package.json` 里哪些改动是**非运行时**的:只有 `scripts.test` 这类"只有测试会跑"的
* 条目变了 ⇒ 这份快照跑起来的行为与仓库完全一致。
*
* ★ 为什么要分开(pi 评审 2026-09-14 提的):一条**永远黄、且没人打算为它动手**的判据,
* 唯一的下场是被学会忽略 —— 而那时真正的运行时漂移会被一起忽略。
* 实测当时就是这种局面:`package.json` 只差 `scripts.test`(`node --preflight …` →
* `node test/lib/…`),生产根本不跑它,却让 pi 组恒显"⚠️ 漂移"。
*
* ⚠️ 只认**明确属于测试**的键,别的(`main`/`start`/`dependencies`…)一律算运行时。
* 判据必须窄:把运行时差异误判成"非运行时"比恒黄更坏 —— 那是**假绿**。
*/
export function jsonTestOnlyChange(repoText, snapText) {
let a;
let b;
try {
a = JSON.parse(repoText);
b = JSON.parse(snapText);
} catch {
return { testOnly: false, keys: [] }; // 解析不了就不敢下结论,按运行时算
}
if (typeof a !== 'object' || typeof b !== 'object' || !a || !b) {
return { testOnly: false, keys: [] };
}
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
const diffs = [];
for (const k of keys) {
if (JSON.stringify(a[k]) === JSON.stringify(b[k])) continue;
// 只豁免 scripts.test(以及打包工具惯用的 test:xxx 变体)
if (k === 'scripts' && a[k] && b[k] && typeof a[k] === 'object' && typeof b[k] === 'object') {
const sub = new Set([...Object.keys(a[k]), ...Object.keys(b[k])]);
const subDiff = [...sub].filter((s) => JSON.stringify(a[k][s]) !== JSON.stringify(b[k][s]));
const allTestOnly = subDiff.length > 0 && subDiff.every((s) => s === 'test' || s.startsWith('test:'));
if (allTestOnly) {
for (const s of subDiff) diffs.push(`scripts.${s}`);
continue;
}
}
diffs.push(k);
}
return { testOnly: diffs.length > 0 && diffs.every((k) => k.startsWith('scripts.test')), keys: diffs };
}
/**
* ⚠️ **带 `ctx` 时会读文件;不带是纯内存比较**(pi 提醒 2026-09-14)。
*
* 判 `scripts.test` 这类字段必须读两侧**原文**,所以 `diffSummary(d, {repoDir, snapDir})`
* 不是纯函数 —— 它会对 `d.changed.runtime` 里的每个文件各读一次。
* 拿它当纯函数用(例如在大树上反复调、或放进"只算不读"的路径)会意外吃到 I/O。
* 不带 ctx 时 `testOnly` 恒为 `[]`、不读任何文件,行为与旧版逐字相同
* (既有那批自检样本正是走这条路,必须保持住)。
*/
export function diffSummary(d, ctx = {}) {
// `testOnly` 只在给出两棵树根目录时才有意义(要读原文比对字段)。
const testOnly = (ctx.repoDir && ctx.snapDir) ? testOnlyDrift(d, ctx.repoDir, ctx.snapDir) : [];
// ★ `runtimeDrift` **就是结论**:已经减掉被豁免的那些。
//
// 原先它不减 —— 于是同一个概念有两个数:本字段("检测到多少个运行文件不同")
// 与 `checkHost` 自己算的 `runtimeOnly`("其中真正算漂移的")。
// 我给豁免写自检样本时就被这对数绊了一下:断言 `runtimeDrift === 0` 得到 1,
// 一度以为豁免失效,其实是**两个字段名同义不同数**。
// 判据自己产出两个互相矛盾的口径,与"注释里两组矛盾的写点计数"是同一族毛病,
// 所以在这里一次性统一:`runtimeDrift` = 真正的运行时漂移;
// 被豁免的部分**只出现在 `testOnly` 里** —— 看得见,但不再计入漂移。
//
// ⚠️ 不下传 ctx 时 `testOnly` 为空 ⇒ `runtimeDrift` 与旧行为完全一致
// (上面那些既有样本因此不受影响)。
return {
runtimeDrift:
d.onlyRepo.runtime.length + d.onlySnap.runtime.length + d.changed.runtime.length - testOnly.length,
docDrift: d.onlyRepo.doc.length + d.onlySnap.doc.length + d.changed.doc.length,
testOnly
};
}
/** 逐字节不同、但差别**只落在"只有测试会跑"的字段**上的那些文件。
*
* 只看 `changed`(两边都在、内容不同)—— 一侧独有/缺失都是真的文件集变化,
* 不能靠读字段豁免。 */
function testOnlyDrift(d, repoDir, snapDir) {
const out = [];
for (const rel of d.changed.runtime) {
try {
const a = readFileSync(join(repoDir, rel), 'utf8');
const b = readFileSync(join(snapDir, rel), 'utf8');
const r = jsonTestOnlyChange(a, b);
if (r.testOnly) out.push({ path: rel, keys: r.keys });
} catch {
/* 读不到就按运行时算(宁可报出来) */
}
}
return out;
}
function processArgv(pid) {
try {
return readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0').filter(Boolean).join(' ');
} catch {
return '';
}
}
function unitMainPid(unit) {
try {
return execFileSync('systemctl', ['show', '-p', 'MainPID', '--value', unit], { encoding: 'utf8' }).trim();
} catch {
return '';
}
}
function unitExecStart(unit) {
try {
const v = execFileSync('systemctl', ['show', '-p', 'ExecStart', '--value', unit], { encoding: 'utf8' });
const m = v.match(/argv\[\]=([^;]+)/);
return m ? m[1] : v.trim();
} catch {
return '';
}
}
/** 进程启动时刻(墙钟,ms)。
*
* 用 `/proc/<pid>/stat` 的第 22 字段(自开机起的时钟滴答)+ `/proc/stat` 的 `btime`
* 换算,而不是 `/proc/<pid>` 目录的 mtime —— 后者只是个碰巧相近的代理,
* 它的语义是「这个目录最后一次变动」,不是「进程何时启动」。
*/
export function procStartMs(pid) {
try {
const fields = readFileSync(`/proc/${pid}/stat`, 'utf8').split(' ');
const startTicks = Number(fields[21]);
const hz = Number(execFileSync('getconf', ['CLK_TCK'], { encoding: 'utf8' }).trim()) || 100;
const btime = Number(
readFileSync('/proc/stat', 'utf8')
.split('\n')
.find(l => l.startsWith('btime '))
.split(' ')[1]
);
if (!Number.isFinite(startTicks) || !Number.isFinite(btime)) throw new Error('字段解析失败');
return (btime + startTicks / hz) * 1000;
} catch {
return 0;
}
}
/**
* 判据 ④:进程是不是比快照还旧(= 线上跑的还是旧代码)。
*
* 抽成纯函数是因为**第一版写错过**,而且自检没接住:`restartOnSwitch` 只有
* dsh 显式设了 `true`,pi/zcode 是 `undefined` ⇒ 落进「惰加载」分支 ⇒ **永真**。
* 当时自检用的是 `h.restartOnSwitch !== false`,`undefined` 也被放过。
* 下面改成行为用例:自带一个「旧进程 + 自身进程宿主」必须判红。
*
* @param {object} o
* @param {string} o.load 宿主加载方式(见文件头的表)
* @param {number} o.startedAt 进程启动时刻(ms,0 = 读不到)
* @param {number} o.switchAt 软链切换时刻(ms)
* @param {number} [o.toleranceMs] 容差:同一次部署里先切后重启,两者相差一两秒
*/
export function judgeRestart({ load, startedAt, switchAt, toleranceMs = 2000 }) {
if (!startedAt) {
// 读不到就不要据此判红:不知道不能当成「不对」
return { ok: true, note: '读不到进程启动时刻(不据此判定)' };
}
if (startedAt >= switchAt - toleranceMs) return { ok: true, note: '切换之后才启动' };
// 惰加载的宿主(opencode:会话创建时才读配置)不强制重启;
// 其余宿主在启动时就加载了插件,软链切换不会重载它 ⇒ 必须重启。
if (load === 'host-config') {
return { ok: true, note: '惰加载:会话创建时才读配置,不强制重启' };
}
return {
ok: false,
note:
`进程启动于 ${new Date(startedAt).toISOString()},切换发生在 ${new Date(switchAt).toISOString()} —— ` +
'软链切换不会重载已在跑的进程,需要重启该服务'
};
}
function checkHost(spec) {
const { host, plugin, unit, load } = spec;
const repoDir = join(REPO, 'plugins', plugin);
const linkPath = join(DEPLOY_ROOT, plugin, 'current');
const result = { host, plugin, unit, load, checks: [], stale: false };
const fail = (name, note) => {
result.checks.push({ name, ok: false, note });
result.stale = true;
};
const pass = (name, note = '') => result.checks.push({ name, ok: true, note });
if (!existsSync(repoDir)) return fail('仓库插件目录存在', repoDir), result;
if (!existsSync(linkPath)) return fail('已部署(current 存在)', `未部署:${linkPath}`), result;
// ① 内容:快照 vs **仓库工作区当前内容**(四个宿主同一套)
//
// ★ 比的是工作区**当前**内容,不是 git HEAD —— 仓库脏(未提交的改动)时
// 这条判据照样会绿。实证(2026-09-14):`pool.mjs` 里有一行未提交的
// `let missingSessionCount = 0;`(模块顶层、无人读),被 17:20 那次
// 「从脏工作区做的」快照原样带进了生产,而这条判据当时报的是「逐字节一致」
// —— 工作区与快照确实一致,只是两者都不等于 HEAD。判据没错,是它的**量纲**
// 只覆盖「仓库→快照」这一跳,覆盖不到「HEAD→工作区」那一跳。
// 想连那一跳一起判,得单独比 `git diff --stat`,别指望这一条。
const d = diffTrees(collectFiles(repoDir), collectFiles(linkPath));
const { runtimeDrift, docDrift, testOnly } = diffSummary(d, { repoDir, snapDir: linkPath });
// 把"只差测试脚本"这类差异从运行时漂移里摘出来单独说(pi 评审 2026-09-14):
// 一条**永远黄、且没人打算为它动手**的判据,唯一的下场是被学会忽略 ——
// 而那时真正的运行时漂移会被一起忽略。摘出来之后:真正的运行时漂移**照旧判红**,
// 非运行时差异则让这一条**通过并注明**(结论区也据此把该宿主判绿)。
//
// ⚠️ 摘的条件很窄(`jsonTestOnlyChange` 只豁免 `scripts.test` 一类字段),
// 而且只对"两边都在、仅内容不同"的文件生效;一侧独有的文件集变化照旧算运行时 ——
// **把运行时差异误判成非运行时比恒黄更坏,那是假绿。**
// `runtimeDrift` 已经是"减掉豁免之后"的结论(见 diffSummary 的注释:
// 以前这里自己再算一遍 `runtimeOnly`,于是同一个概念有两个数)。
const testOnlyPaths = new Set(testOnly.map(t => t.path));
if (runtimeDrift === 0) {
const notes = [];
if (testOnly.length) {
notes.push(`${testOnly.length} 处非运行时差异(只差 ${testOnly.map(t => t.keys.join('/')).join('、')},生产不跑):${testOnly.map(t => t.path).join('、')}`);
}
if (docDrift) notes.push(`另有 ${docDrift} 个文档差异,不影响运行`);
pass('① 运行文件与仓库一致', notes.length ? notes.join(';') : '逐字节一致');
} else {
fail(
'① 运行文件与仓库一致',
`漂移 ${runtimeDrift} 处:${[
...d.onlyRepo.runtime.map(f => `仓库独有 ${f}`),
...d.onlySnap.runtime.map(f => `快照独有 ${f}`),
...d.changed.runtime.filter(f => !testOnlyPaths.has(f)).map(f => `内容不同 ${f}`)
]
.slice(0, 5)
.join(';')}`
);
}
// ①b 权限位一致性:部署副本的权限 = 仓库那一份(pi 评审 2026-09-15 §三)
//
// ★ 为什么单开一条而不是塞进 ①:① 的判据是"**内容**逐字节一致"(`cmp`/sha256),
// 它**原理上**量不到权限位 —— 实测就是"内容一致、权限 0600 vs 0644"被报成"逐字节一致"。
// 塞进 ① 会让它的名字继续替它作证(说"一致",其实只测了一半)。
// ★ 也不能拿它替代 `check-file-modes.sh`:这条在"两边都 0600"时恒绿。
const modeDiffs = diffModes(collectModes(repoDir), collectModes(linkPath));
if (modeDiffs.length === 0) {
pass('①b 部署副本权限与仓库一致', '逐文件权限位相同');
} else {
fail(
'①b 部署副本权限与仓库一致',
`${modeDiffs.length} 处权限不同:${modeDiffs
.slice(0, 5)
.map(m => `${m.path}(仓库 ${m.repoMode.toString(8)} / 快照 ${m.snapMode.toString(8)})`)
.join(';')}${modeDiffs.length > 5 ? ` …等 ${modeDiffs.length} 处` : ''}`
);
}
// ② 加载路径指向快照 —— **按宿主真实的加载方式**
if (load === 'own-process') {
const execStart = unitExecStart(unit);
const atSnapshot = execStart.includes(`/opt/agentmail/plugins/${plugin}/current`);
const atRepo = execStart.includes(`/home/program/agentmail/plugins/${plugin}`);
if (!execStart) fail('② 单元入口指向快照', `读不到 ${unit} 的 ExecStart`);
else if (atRepo) fail('② 单元入口指向快照', `仍指向仓库工作区:${execStart}`);
else if (atSnapshot) pass('② 单元入口指向快照', '指向 current');
else fail('② 单元入口指向快照', `指向别处:${execStart}`);
} else {
// host-package / host-config:查配置文件里那条引用,并尽量查它**实际解析到哪**
const cfg = spec.configFile;
let cfgText = '';
try {
cfgText = readFileSync(cfg, 'utf8');
} catch (e) {
fail('② 宿主配置指向快照', `读不到 ${cfg}:${e?.message || e}`);
}
if (cfgText) {
if (cfgText.includes(spec.configNeedle)) pass('② 宿主配置指向快照', `${cfg} 含 current`);
else fail('② 宿主配置指向快照', `${cfg} 里没有 ${spec.configNeedle}`);
}
if (spec.resolvePath) {
// 真正决定加载哪份代码的是这条软链(Node 按 node_modules 解析)
try {
const real = realpathSync(spec.resolvePath);
if (real.startsWith(`/opt/agentmail/plugins/${plugin}/`)) {
pass('② 实际解析到快照', `${real.split('/').slice(-2).join('/')}`);
} else {
fail('② 实际解析到快照', `${spec.resolvePath} → ${real}`);
}
} catch (e) {
fail('② 实际解析到快照', `${spec.resolvePath} 不可用:${e?.message || e}`);
}
}
}
// ③ 进程身份(own-process 才看 argv;别的宿主 argv 里本来就没有插件路径)
const pid = unitMainPid(unit);
if (!pid || pid === '0') {
fail('③ 宿主进程在跑', `${unit} 没有主进程(未运行?)`);
return result;
}
if (load === 'own-process') {
const argv = processArgv(pid);
if (argv.includes(`/opt/agentmail/plugins/${plugin}/current`)) pass('③ 进程在跑快照里的代码', `pid=${pid}`);
else fail('③ 进程在跑快照里的代码', `pid=${pid} 的 argv 里没有快照路径:${argv.slice(0, 90)}`);
} else {
pass('③ 宿主进程在跑', `pid=${pid}(插件由宿主加载,argv 里本就没有插件路径)`);
}
// ④ 进程启动时刻 vs 软链切换时刻。
// 软链切换**不会**重载已在跑的进程 ⇒ 对「启动时加载」的宿主,这一条是硬判据。
// 判定逻辑在纯函数 judgeRestart 里(它被自检的行为用例盖住)。
const switchAt = lstatSync(linkPath).mtimeMs;
const startedAt = procStartMs(pid);
const j = judgeRestart({ load, startedAt, switchAt });
result.checks.push({ name: '④ 进程启动不早于快照切换', ok: j.ok, note: j.note });
if (!j.ok) result.stale = true;
return result;
}
/**
* 把「临时目录写不进去」翻译成人看得懂的话。
*
* ★ 为什么要有这个函数(不是为了好看,是为了**覆盖全部写点**):
*
* `selfCheck()` 里要在临时目录造两棵小树,**写点不止一处**(`mk()` 里、
* 后面裸写的 `README.md`/`extra.mjs`/`test/t.mjs`,还有建目录那两处)。第一版只在 `mk()` 里包了
* try/catch —— 于是后面那几处撞上 ENOSPC 时,异常冒到 `main()` 的 catch,
* 退出码是对的(2),但打印的是**原始英文 `ENOSPC: no space left on device, write`
* 加一段指向本文件的堆栈** —— 也就是"看起来像检查器坏了"这个信号,**恰恰在
* 最需要它的那些路径上还在**。集中在这里换一次,以后再加写点也不用管。
*/
function describeEnvError(e, what) {
const isEnospc = e && (e.code === 'ENOSPC' || /no space left on device/i.test(String(e.message)));
if (!isEnospc) return e;
const err = new Error(
`环境不足:临时目录 ${tmpdir()} 写不进去(ENOSPC)—— ${what}。` +
'这是环境问题,不是检查器的问题。药方:TMPDIR=<有空间的目录> 后再跑。'
);
err.code = 'ENOSPC';
err.cause = e;
return err;
}
/**
* 判据自检:**先证明这个检查器能发现差异**,再用它下结论。
* 一个永远说「一致」的比较器看起来同样令人放心。
*
* ★ **本函数自己保证"环境不足说人话",与从哪个入口调它无关。**
*
* 上一版把"覆盖全部写点"那一层放在 `main()` 的 catch 里 —— 但本函数是**导出的**
* (导出的用途就是被别人直接调,例如 `--self-check` 之外的自检脚本)。任何绕过
* `main()` 的调用者,在那几处裸写撞上 ENOSPC 时拿到的仍是原始英文 + 本文件堆栈。
* pi 评审时指出:**"一处覆盖全部写点"成立的前提是"从 main() 进来"** ——
* 而"覆盖范围取决于我以为的入口"正是这一串 bug 的共同病根。
*
* 所以这里把整段包一层(`body()` + 统一 catch),主进程那个 catch 退化为
* 冗余的第二道 —— 冗余是故意的。
*
* 为什么不去用 `plugins/pi-mail-bridge/lib/env-error.mjs` 的 `translateEnvError`
* (判据相同、措辞不同,看起来该合并):**`deploy/` 的独立性比去重值钱**。
* 本文件头整段就在讲"服务不该依赖仓库是否存在",`deploy/` 下的工具同理 ——
* 让它 import 插件目录里的模块,等于把部署工具绑死在插件的目录结构上。
* 判据(`code === 'ENOSPC'` / 消息里含 no space left)是稳定的,措辞各自合适即可。
* **若哪天出现第三份拷贝,再考虑共用** —— 两份还撑得住。
*
* ★ **这里原先写了两组互相矛盾的"写点计数"**(pi 评审 2026-09-14 逐处数的):
* 上面 `describeEnvError` 的头注释写"五处",这里写"共 6 处",
* 而这条自己列的式子 `2 + 2 + 3` **加起来是 7** —— 三处说法三个数,
* 且**没有一个等于实际站点数**(实际 `mkdtempSync`×2 + `mkdirSync`×2 +
* `writeFileSync`×6 = **10 处**;函数体后来又长了,数字只会更旧)。
*
* **所以现在一个数字都不写。** 理由与本仓库那条既有纪律同源
* ("不要为此引入手抄的期望用例数常量 —— 手抄常量会过期"):
* 注释里的数字**无法被判据守住**,改代码时没人会回来改它们,
* 而"两组数字互相矛盾"比"没有数字"更糟 —— 它让读者以为有人数过。
* 实例:第 1009 行那个依赖树夹具又加了 3 处写,谁也没回头改这里。
*
* 要判"覆盖是否完整",只能靠**机制**而不是靠数数:本函数的整段 try/catch
* 保证不论哪一处抛 ENOSPC 都被翻译成人话(覆盖范围不取决于入口、不取决于处数)。
*/
export function selfCheck() {
try {
return body();
} catch (e) {
throw describeEnvError(e, '判据自检要在临时目录里造样本树');
}
function body() {
// `mkdtempSync` 本身就是一个写点:临时目录满的时候(2026-09-14 实测 `bavail`
// 一度真是 0)它会抛 ENOSPC。上一版它在 try 之外,于是那条路径连退出码 2 都拿不到。
const a = mkdtempSync(join(tmpdir(), 'drift-a-'));
const b = mkdtempSync(join(tmpdir(), 'drift-b-'));
const mk = (root, content) => {
mkdirSync(join(root, 'lib'), { recursive: true });
// ★ 自检要在临时目录里造两棵小树。临时目录满了时这里会抛 ENOSPC ——
// 而它是一个**未捕获的异常**,堆栈指向本文件的 `mk()`,看起来像检查器自己坏了。
// 真相是环境不足。翻译成说得清的错,交给 main() 报 2(环境问题)而不是崩栈。
try {
writeFileSync(join(root, 'lib', 'x.mjs'), content);
writeFileSync(join(root, 'README.md'), 'doc');
} catch (e) {
// 交给最外层那个统一 catch(见函数头注释)——**不要**在这里单独翻译,
// 否则"覆盖范围"又变成"取决于哪一处写",就是这套 bug 的病根。
throw e;
}
};
const out = [];
try {
mk(a, 'same');
mk(b, 'same');
const same = diffSummary(diffTrees(collectFiles(a), collectFiles(b)));
out.push({ name: '相同的树判为一致', ok: same.runtimeDrift === 0 && same.docDrift === 0 });
mk(b, 'DIFFERENT');
const diff = diffSummary(diffTrees(collectFiles(a), collectFiles(b)));
out.push({ name: '内容不同必须被发现', ok: diff.runtimeDrift === 1 });
mk(b, 'same');
writeFileSync(join(b, 'README.md'), 'doc-changed');
const docOnly = diffSummary(diffTrees(collectFiles(a), collectFiles(b)));
out.push({ name: '文档差异不算运行漂移', ok: docOnly.runtimeDrift === 0 && docOnly.docDrift === 1 });
writeFileSync(join(b, 'lib', 'extra.mjs'), 'x');
const extra = diffSummary(diffTrees(collectFiles(a), collectFiles(b)));
out.push({ name: '快照多出运行文件必须被发现', ok: extra.runtimeDrift === 1 });
// ①b 权限位:**内容相同、权限不同**必须被发现 —— 这正是 ① 原理上量不到的那一角。
// ★ 三条一起写,因为"能发现差异"单独一条会放过一个坏实现:
// 一个恒判"所有文件权限都不同"的实现也能让第一条变红,但它会对**相同的树**误报。
// ★ 用**一对内容相同**的新文件,不能用 `lib/x.mjs` —— 上面 `mk(b,'DIFFERENT')`
// 已经把它改成内容不同了,于是"① 报一致"那条会红在**内容**上,
// 而它想验的是"权限差异不污染内容判据"。我第一版就是这么写错的。
const mA = join(a, 'lib', 'modeonly.mjs');
const mB = join(b, 'lib', 'modeonly.mjs');
writeFileSync(mA, 'identical');
writeFileSync(mB, 'identical');
chmodSync(mA, 0o644);
chmodSync(mB, 0o644);
out.push({
name: '①b 权限相同不得误报',
ok: diffModes(collectModes(a), collectModes(b)).length === 0
});
chmodSync(mB, 0o600);
const modeDiff = diffModes(collectModes(a), collectModes(b));
out.push({
name: '①b 内容一致但权限不同(0600 vs 0644)必须被发现',
ok: modeDiff.length === 1 && modeDiff[0].path === 'lib/modeonly.mjs' &&
modeDiff[0].repoMode === 0o644 && modeDiff[0].snapMode === 0o600
});
// ① 与 ①b 必须**各报各的**:这一对文件内容相同 ⇒ ① 对它们无话可说。
out.push({
name: '①b 权限差异不污染 ① 的内容判据(同一对文件内容仍判一致)',
ok: collectFiles(a).get('lib/modeonly.mjs') === collectFiles(b).get('lib/modeonly.mjs')
});
chmodSync(mB, 0o644);
rmSync(mA);
rmSync(mB);
// 仅一侧存在的文件不产生"权限差异"(那是文件集差异,归 ① 管,别在这里重复报)
writeFileSync(join(b, 'lib', 'only-snap.mjs'), 'x');
chmodSync(join(b, 'lib', 'only-snap.mjs'), 0o600);
out.push({
name: '①b 仅一侧存在的文件不算权限漂移(归 ① 的文件集判据)',
ok: diffModes(collectModes(a), collectModes(b)).length === 0
});
rmSync(join(b, 'lib', 'only-snap.mjs'));
mkdirSync(join(a, 'test'), { recursive: true });
writeFileSync(join(a, 'test', 't.mjs'), 'only-in-repo');
const withTest = diffSummary(diffTrees(collectFiles(a), collectFiles(b)));
out.push({ name: 'test/ 不参与比较(与部署脚本一致)', ok: withTest.runtimeDrift === 1 });
// ★ `jsonTestOnlyChange` / `testOnlyDrift` 的覆盖(pi 评审 2026-09-14 指出):
// **这一段逻辑是文件里唯一一处"把一个红变成绿"的代码,也是唯一没有判据的代码** ——
// 而它失效的方向恰好是"比恒黄更坏"的那个(假绿)。
// 原先我只在开发时用内联脚本喂过六个字符串样本,**没进文件**(pi 通读全文找不到,
// 他的质疑成立)。现在补成**走真路径**的样本:真临时树 + `diffSummary(d, {repoDir, snapDir})`,
// 与上面那两棵树同形、成本一样低。
//
// 关键:`testOnly` 只在**给出两棵树根目录**时才非空,所以样本必须传 ctx ——
// 否则它恒为 `[]`,"豁免"这件事永远没被走到(这正是原先没覆盖的原因)。
// ⚠️ 先**把两棵树恢复成同形**:上面几条样本把 `b` 改过(`extra.mjs`、`README.md`
// 内容、还有只存在于 `a` 的 `test/`)—— 我第一版没复位,于是四条样本全红、
// 红的原因还都不是我要测的那件事。**样本之间的相互污染**和"位置选择器"是同一族:
// 断言的语义被前面步骤悄悄改变了。所以这里显式复位到"两棵树只差 package.json"。
rmSync(join(b, 'lib', 'extra.mjs'), { force: true });
rmSync(join(a, 'test'), { recursive: true, force: true });
writeFileSync(join(b, 'README.md'), 'doc');
writeFileSync(join(a, 'package.json'), JSON.stringify({ name: 'x', scripts: { test: 'OLD', start: 'S' } }));
writeFileSync(join(b, 'package.json'), JSON.stringify({ name: 'x', scripts: { test: 'NEW', start: 'S' } }));
const testOnlyDrift1 = diffSummary(diffTrees(collectFiles(a), collectFiles(b)), { repoDir: a, snapDir: b });
out.push({
name: '★只差 scripts.test ⇒ 不算运行时漂移(且必须说出豁免了哪条键)',
ok:
testOnlyDrift1.runtimeDrift === 0 &&
testOnlyDrift1.testOnly.length === 1 &&
testOnlyDrift1.testOnly[0].keys.includes('scripts.test')
});
// 反面:`scripts.start` 变了必须仍算运行时 —— 豁免过宽就是假绿。
writeFileSync(join(b, 'package.json'), JSON.stringify({ name: 'x', scripts: { test: 'NEW', start: 'CHANGED' } }));
const startChanged = diffSummary(diffTrees(collectFiles(a), collectFiles(b)), { repoDir: a, snapDir: b });
out.push({
name: '★scripts.start 变了 ⇒ 必须算运行时(豁免不许过宽)',
ok: startChanged.runtimeDrift === 1 && startChanged.testOnly.length === 0
});
// 反面:解析不了就不敢下结论(按运行时算)。
writeFileSync(join(b, 'package.json'), '{ 不是 json');
const unparsable = diffSummary(diffTrees(collectFiles(a), collectFiles(b)), { repoDir: a, snapDir: b });
out.push({
name: '★package.json 解析不了 ⇒ 不许豁免(按运行时算)',
ok: unparsable.runtimeDrift === 1 && unparsable.testOnly.length === 0
});
// 反面:**不传 ctx** 时 `testOnly` 必须为空 —— 否则"给出根目录才有豁免"这个前提
// 会在别处悄悄变成"任何 diffSummary 都自动豁免"。
writeFileSync(join(b, 'package.json'), JSON.stringify({ name: 'x', scripts: { test: 'NEW', start: 'S' } }));
const noCtx = diffSummary(diffTrees(collectFiles(a), collectFiles(b)));
out.push({
name: '★不传 repoDir/snapDir ⇒ 不豁免(testOnly 为空)',
ok: noCtx.runtimeDrift === 1 && noCtx.testOnly.length === 0
});
rmSync(join(b, 'package.json'), { force: true });
// 判据 ④ 的行为用例。
//
// 这一组来自一次**自检没接住的真错**:`restartOnSwitch` 只有 dsh 显式设了 true,
// pi/zcode 是 undefined ⇒ 落进「惰加载」分支 ⇒ 判据永真;
// 而当时自检写的是 `h.restartOnSwitch !== false`,undefined 也被放过了。
// 所以现在不查字段,直接查**判定结果**。
const SWITCH = 1_000_000_000_000;
const older = SWITCH - 60_000; // 进程比切换早一分钟
const newer = SWITCH + 60_000; // 进程比切换晚一分钟
out.push({
name: '④ 启动时加载的宿主:旧进程必须判红',
ok:
judgeRestart({ load: 'own-process', startedAt: older, switchAt: SWITCH }).ok === false &&
judgeRestart({ load: 'host-package', startedAt: older, switchAt: SWITCH }).ok === false
});
out.push({
name: '④ 惰加载的宿主:旧进程不判红(但也不是「已验过」)',
ok: judgeRestart({ load: 'host-config', startedAt: older, switchAt: SWITCH }).ok === true
});
out.push({
name: '④ 切换之后才启动的一律放行',
ok: ['own-process', 'host-package', 'host-config'].every(
l => judgeRestart({ load: l, startedAt: newer, switchAt: SWITCH }).ok === true
)
});
out.push({
name: '④ 读不到启动时刻时不据此判红',
ok: judgeRestart({ load: 'own-process', startedAt: 0, switchAt: SWITCH }).ok === true
});
out.push({
name: '④ 容差内(同一次部署先切后重启)不判红',
ok: judgeRestart({ load: 'own-process', startedAt: SWITCH - 1000, switchAt: SWITCH }).ok === true
});
// 宿主表本身的自检:每个宿主的判据必须落在它能观测到的地方。
const badSpec = HOSTS.filter(h => h.load !== 'own-process' && !h.configNeedle);
out.push({ name: '宿主表:非自有进程的宿主必须给出配置判据', ok: badSpec.length === 0 });
const badOwn = HOSTS.filter(h => h.load === 'own-process' && h.configNeedle);
out.push({ name: '宿主表:自有进程的宿主不该用配置判据', ok: badOwn.length === 0 });
} finally {
rmSync(a, { recursive: true, force: true });
rmSync(b, { recursive: true, force: true });
}
return out;
}
}
/**
* 标准目录部署检查(2026-09-14)。
*
* 用户注意到:「当前 agentmail 是在源码目录部署的,应当改为标准目录部署」。
* 当时有三处实证:① 失败通知钩子执行的是**仓库里**的脚本(仓库一挪,故障通知静默
* 失效);② opencode 服务的 cwd 就是源码目录;③ 仓库里的 `deploy/*.service` 是旧的
* 源码目录版本,而机器上的已被改过 —— 谁跑一次 install.sh 就把部署退回源码目录。
*
* 现在:单元与 drop-in 的唯一真相是 `deploy/systemd/`(镜像 systemd 目录结构),
* 运行时脚本装在 `/opt/agentmail/bin/`,服务不依赖仓库是否存在。
*
* @param {object} [inject] 注入点(判据自检时喂假文件系统)
*/
/** 在 `node_modules/<vendor>/<pkg>/` 这类位置里找锁文件(浅层即可,不递归整棵树)。
*
* ⚠️ 只走**注入面**(`readdir`/`exists` 参数),不直接碰 `readdirSync`/`existsSync`
* —— 否则自检样本喂不进去(第一版就是这样:样本造了真目录,判据却用注入的
* `readFile` 去读,于是恒报"依赖锁读不到",自检红)。
* 一条判据如果只能靠真文件系统喂,就等于**没法被自检**。 */
function findDepLock(pluginRoot, lockName, deps) {
const { readdir, exists } = deps;
const roots = [join(pluginRoot, 'node_modules')];
for (const r of roots) {
let vendors = [];
// ⚠️ 依赖很可能是**符号链接**(实测 pi 的 `@earendil-works/pi-coding-agent` 是指向
// `/usr/lib/node_modules/…` 的软链)⇒ 只挑 `isDirectory()` 会**空手而归**,
// 于是这条判据恒报"两边都没有依赖树,未比" —— 又是"看起来在比、其实没比"。
// 软链也要跟进去(`readdirSync` 跟软链,如实测)。
try { vendors = readdir(r, { withFileTypes: true }).filter(e => e.isDirectory() || e.isSymbolicLink()); } catch { continue; }
for (const v of vendors) {
let pkgs = [];
const vd = join(r, v.name);
try { pkgs = readdir(vd, { withFileTypes: true }).filter(e => e.isDirectory() || e.isSymbolicLink()); } catch { continue; }
for (const pk of pkgs) {
const f = join(vd, pk.name, lockName);
if (exists(f)) return f;
}
const direct = join(vd, lockName);
if (exists(direct)) return direct;
}
}
return null;
}
export function checkLayout(inject = {}) {
const readdir = inject.readdir ?? readdirSync;
const readFile = inject.readFile ?? readFileSync;
const exists = inject.exists ?? existsSync;
const stat = inject.stat ?? statSync;
const out = [];
// 每条检查带一个**显式 id**。原先只有第 ⑥ 条自带编号前缀,于是读者会去找
// 一个不存在的第 ⑤ 条 —— 编号要么全有、要么全无,没有第三条路。
// 现在编号只出现在 id 里,`name` 是纯人名的展示串。
let checkSeq = 0;
const push = (name, ok, note = '') => out.push({ id: String(++checkSeq), name, ok, note });
const SYS = '/etc/systemd/system';
const REPO = '/home/program/agentmail';
// 自有进程必须住在安装根下;其余宿主有它们自己的标准位置(不是本项目的源码目录)
const HOST_ALLOW = {
'homeagent.service': '/home/newqqagent',
'dsh.service': '/usr/bin/dsh',
'zcode.service': '/opt/ZCode'
};
// ① 任何 unit/drop-in 都不得引用源码目录
//
// ★ `.bak` 也算在内。原先这里把它排除了,理由是"systemd 不加载 .bak"——
// 运行时确实不加载,但后果是:2026-09-14 我向用户报"`/etc/systemd` 引用仓库 = 0 个文件",
// 而 `/etc/systemd/system/zcode.service.bak-20260912-145744` 里就躺着两行
// `/home/program/agentmail/deploy/service-failure-notify.mjs`。
// 那句话只对我自己划的那个圈成立 —— **判据的边界没说出口,就等于报了个假的 0**。
// 留着 .bak 的代价也不是零:它们是"过期的旧真相",`grep` 到它的人会以为改动没生效。
const offenders = [];
// ★ **realpath 判据**(pi 反例 2026-09-14):软链指向仓库时,内容判据与 ② 都看不见 ——
// ① 只 grep 内容里有没有仓库字面量,而仓库那份 unit 的文本里**没有**那个字面量;
// ② 比 repo↔live 内容,而 live **就是** repo 那个文件(同一 inode)⇒ 必然"一致";
// ④ 只查名单里那几个固定 unit 名。
// 形状:`/etc/systemd/system/x.service -> /home/program/agentmail/deploy/systemd/x.service`
// —— 而"单元指向仓库"正是 ② 存在的理由(谁跑一次 install.sh 就把部署退回源码目录)。
// 所以这里按**真实路径**判,与内容无关。
const repoLinks = [];
const lstatForLinks = inject.lstat ?? lstatSync;
const realpathForLinks = inject.realpath ?? realpathSync;
let scanned = 0;
let scannedLinks = 0;
// 存在、但**读不到内容**的条目:它们既不能算"比过",也不能当成"没问题"。
const unreadable = [];
// ★ **不划圈**:每个普通文件都读一遍再 grep(pi 评审 2026-09-14,我原先按后缀取)。
//
// 我原先把这条推迟了,理由是"实测零违规 ⇒ 扩口径只增噪声"。pi 用**算术**驳回了口味问题:
// 总文件 133(递归口径 129/129?见 note 实数)/ 现行后缀口径覆盖绝大多数 ⇒ 差集极小,
// 即"读全部"的噪声成本是**几次 `readFile`**(几十 KB),而收益是那个 `0` 从
// "**有范围的** 0"(只对我划的圈成立)变成"**闭合的** 0"(对整棵 /etc/systemd 成立)。
// 他这条论证我认:同一条"0 必须写明可证伪范围"的规矩,**闭合范围是同样成本、更强结论**。
// 而且他补了一句关键的:这条判据只报**内容里含仓库路径**的文件,
// 含仓库路径的 `.dpkg-old`/`~`/无后缀文件**恰恰都是真信号**(过期的旧真相)——
// 不是噪声。所以我先前"二进制会变成噪声"的担心本来就不成立(不含仓库串的不会被报)。
//
// 验收(他自己给的):扩完应当报"比了全部 N 个、命中 0 个";若命中里有文件名后缀异常者,
// 那就说明原先漏掉的正是真信号 —— 两个结果都赢。
// 实测验收:**命中 0** —— 即现在的 `0` 是**闭合的**。
//
// ⚠️ 分母**不要写死成某个数字**(我第一版在这里写了"133",第二天就变成 135 了 ——
// 系统装/卸一个 unit 就会变)。要钉的是**关系**:`比了 N 个` 的 N 必须等于
// **真的读到内容**的条数(读不到的单列 `unreadable` 并判红,见上面的 walk)。
// 验收时看 note 里的实际 N,别看这里的历史值。
//
// ★ **"零违规"这个前提必须写明**(pi 要求),也一并说明"红/WARN 拆分"为什么没做:
// 今天实测 0 违规 ⇒ 把"生效 unit 引用仓库"(红)与"遗留备份引用仓库"(WARN)拆成两档,
// 在当前是**重构、不是修 bug**,所以推迟;
// **出现第一个非白名单/备份类命中时再决定怎么分档**(那时我们才有实例,
// 而不是凭想象设计)。
const walk = dir => {
let entries = [];
try { entries = readdir(dir, { withFileTypes: true }); } catch { return; }
for (const e of entries) {
const full = `${dir}/${e.name}`;
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 {
if (lstatForLinks(full).isSymbolicLink()) {
scannedLinks++;
const real = realpathForLinks(full);
if (real.startsWith(`${REPO}/`)) repoLinks.push(`${full} → ${real}`);
}
} catch { /* 悬空软链:读不到目标,交给 ② 的 walk 报 */ }
if (text.includes(REPO)) offenders.push(full);
}
}
};
walk(SYS);
// 覆盖面写进 note:`0` 只有在"它能被证伪的范围"写明之后才是结论
// (这正是这条判据当初报"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 判目标`
: refBad
);
// ② 已安装单元与仓库副本一致(仓库是唯一真相)—— **两个方向都判**
//
// ★ 这条判据曾经是**假绿**(pi 评审 2026-09-14 抓到,实测确认):默认路径写成
// `new URL('../systemd', import.meta.url).pathname` —— `import.meta.url` 在
// `deploy/` 下,于是它解析成 **`/home/program/agentmail/systemd`(ENOENT)**,
// 真身在 `deploy/systemd/`。`readdir` 抛的 ENOENT 被 `catch { return; }` 静默吞掉
// ⇒ `drift` 恒空 ⇒ **一个文件都没比过,却报"已安装单元与 deploy/systemd/ 一致"**,
// 而且它参与退出码。同族里这是最难看的一种:**边界没说出口 ⇒ 报了个自己都不知道
// 是假的 0**。而 `layoutSelfCheck()` 每个样本都显式注入 `repoUnits`,默认值从没被
// 走过 ⇒ 写错了自检也 100% 绿(**注入点把该抓的 bug 藏起来了**)。
// 三处一起修:路径用 `join(HERE,'systemd')`、比不了**判红**、反向也判。
const drift = [];
const repoUnits = inject.repoUnits ?? DEFAULT_REPO_UNITS;
const liveUnits = inject.liveUnits ?? SYS;
// 依赖树比的是**插件目录**,不是 systemd 目录 —— 我第一版把 repoUnits/liveUnits
// 传给了 findDepLock,于是它永远找不到锁文件、恒报"两边都没有依赖树,未比"。
// 又一次同一个形状:一条**看起来在比、其实没比**的判据(这次是我自己写的)。
// 依赖树拿哪个插件比?pi(本文件历史上就是为它写的,且它是唯一带 node_modules 的)。
const DEP_PLUGIN = inject.depPlugin ?? 'pi-mail-bridge';
const repoPluginDir = inject.repoPluginDir ?? join(REPO, 'plugins', DEP_PLUGIN);
const livePluginDir = inject.livePluginDir ?? join(DEPLOY_ROOT, DEP_PLUGIN, 'current');
const extra = []; // 机器上多出来的(仓库里没有)—— 原先**永远不报**
const lstat = inject.lstat ?? lstatSync;
const liveLink = rel => {
try { return lstat(`${liveUnits}/${rel}`).isSymbolicLink(); } catch { return false; }
};
const walkUnits = (dir, base) => {
const out = new Map(); // rel → 内容(null = 读不到)
const go = cur => {
for (const e of readdir(cur, { withFileTypes: true })) {
const full = `${cur}/${e.name}`;
if (e.isDirectory()) { go(full); continue; }
const rel = full.slice(base.length + 1);
try { out.set(rel, String(readFile(full, 'utf8'))); } catch { out.set(rel, null); }
}
};
go(dir);
return out;
};
let repoMap = null;
try {
repoMap = walkUnits(repoUnits, repoUnits);
} catch (e) {
// **不再静默**:比不了就说"比不了",而不是报"一致"。
drift.push(`仓库单元目录读不到:${repoUnits}(${e.code ?? e.message})`);
}
// ★ 空目录也算"没比过",必须判红。这条是被自检逼出来的:注入的 `readdir` 对未知
// 目录返回 `[]`(不抛),于是"目录不存在"能伪装成"目录是空的",`drift` 恒空
// ⇒ **又变成恒绿**(我第一版修法就栽在这里)。真实世界里 `deploy/systemd/` 有
// 22 个文件,**它不可能是空的** —— 空只意味着路径写错或被清空。
if (repoMap && repoMap.size === 0) {
drift.push(`仓库单元目录里一个文件都没有:${repoUnits} —— 路径写错或被清空的信号,不是"一致"`);
}
if (repoMap) {
const liveMap = exists(liveUnits) ? walkUnits(liveUnits, liveUnits) : new Map();
for (const [rel, content] of repoMap) {
if (!liveMap.has(rel)) { drift.push(`${rel}(缺)`); continue; }
const b = liveMap.get(rel);
if (content === null || b === null) { drift.push(`${rel}(读不到)`); continue; }
if (content !== b) drift.push(rel);
}
// 反向:机器上有、仓库里没有。
//
// ⚠️ 这一格**必须收窄**,否则就是噪声:`/etc/systemd/system` 下绝大多数是系统自带
// unit 与 enable 出来的软链(实测 111 个:dbus-*、NetworkManager…),与"仓库副本
// 一致"无关。**一条每次都在报 111 件事的提示等于没有提示。**
// 口径:只报**本仓库自己那套**(同名前缀 / 同 `.d/` 目录),并跳过符号链接。
for (const rel of liveMap.keys()) {
if (repoMap.has(rel)) continue;
if (liveLink(rel)) continue;
const top = rel.split('/')[0];
const related = [...repoMap.keys()].some(
r => r === top || r.startsWith(`${top}.d/`) || top.startsWith(`${r}.d/`) || r.startsWith(`${top}/`) || top.startsWith(`${r}/`)
);
if (related && !extra.includes(rel)) extra.push(rel);
}
}
// 把"比过几个"写进 note —— 空 note 无法区分"一致"和"没比过",而那正是这条判据
// 原先的样子。绿的时候也要留下覆盖范围的证据。
push(
'已安装单元与 deploy/systemd/ 一致(两个方向)',
drift.length === 0,
drift.length === 0
? `比了 ${repoMap ? repoMap.size : 0} 个文件,全部一致(目录:${repoUnits})`
: drift.join(' ')
);
if (extra.length) {
// 不判红(机器上有仓库里没有的 unit 不等于生产配置错了),但必须**说出来** ——
// 否则"仓库里的是旧的、机器上的是新的"那一半永远看不见。
// 用既有的 `push(…, true, note)` 形状(同 ⑥):一条没人打算为它动手的红灯,下场是被学会忽略。
push('机器上另有仓库里没有的 unit(仅提示,不影响结论)', true, `${extra.length} 个:${extra.slice(0, 5).join(';')}`);
}
// ②b 依赖树一致 —— **补上 `EXCLUDE_DIRS` 挖掉的那个洞**
//
// pi 评审 2026-09-14 指出的那格:部署脚本把 `node_modules` **拷进快照**
// (注释写着"依赖必须进快照:仓库外没有 node_modules 可借"),而本文件
// `EXCLUDE_DIRS` 整份跳过它 ⇒ **仓库换过依赖、快照还是旧的,判据报"逐字节一致"**。
// 这正是 `EXCLUDE_DIRS` 上面那句注释自己预言的假绿("对不齐就会出现
// 『脚本说一致、部署脚本却拷了别的』")。
//
// 做法:读锁文件里的**版本集合**做签名,一个文件、24ms、165 个包。
// 只比版本集合(不比内容):它抓的是"依赖树漂移"这个真实风险,
// 又不会因为 `node_modules` 里的缓存时间戳之类的噪声乱报。
const depSig = file => {
try {
const d = JSON.parse(String(readFile(file, 'utf8')));
const pkgs = d.packages ?? {};
const v = Object.entries(pkgs).map(([k, x]) => `${k}@${x.version ?? ''}`).sort();
return { n: v.length, sig: createHash('sha256').update(v.join('\n')).digest('hex') };
} catch { return null; }
};
const lockRel = 'node_modules';
const lockName = 'npm-shrinkwrap.json';
let depNote = '两边都没有依赖树,未比';
let depOk = true;
{
const a = findDepLock(repoPluginDir, lockName, { readdir, exists });
const b = findDepLock(livePluginDir, lockName, { readdir, exists });
if (a && b) {
const sa = depSig(a);
const sb = depSig(b);
if (!sa || !sb) { depOk = false; depNote = '依赖锁读不到/解析不了,比不了'; }
else if (sa.sig !== sb.sig) {
depOk = false;
depNote = `依赖树不一致:仓库 ${sa.n} 个包 vs 已部署 ${sb.n} 个包(重新部署即可对齐)`;
} else {
// ★ 两侧**指向同一个文件**时,这条判据是**恒等**的 —— 必须说出来,不许报"一致"。
//
// pi 反例 2026-09-14:pi 的依赖是**全局包的符号链接**
// (`node_modules/@earendil-works/pi-coding-agent -> /usr/lib/node_modules/…`),
// 而 `redeploy-plugin.sh` 用 `cp -a`(保留软链)⇒ 快照里那个还是同一个软链
// ⇒ `findDepLock` 两侧 realpath 到**同一个 inode**,版本集合**按构造**就相等。
// 实测确认:两侧 realpath 都是 `/usr/lib/node_modules/…`、inode 相同。
// 也就是说它**永远不会因为"pi 的依赖变了"而红** —— 这是本文件自己列过的第三种形态
// (**跑不到的分支**:断言在、区分力不在),比"没写判据"更坏,
// 因为它看起来是绿的。真实风险(全局 SDK 被换掉)它同样看不见:那时两侧一起变。
// 所以:绿,但 note 明说"恒等",并指出它真正覆盖的是谁。
let same = false;
try {
const ra = (inject.realpath ?? realpathSync)(a);
const rb = (inject.realpath ?? realpathSync)(b);
same = ra === rb;
} catch { /* 读不到就不敢断言恒等 */ }
depNote = same
? `${sa.n} 个包,**两侧是同一个文件(${'(全局包的符号链接)'})⇒ 本判据对它恒等、区分力为零**;真正覆盖的是有 vendored 依赖树的宿主(opencode/dsh)`
: `${sa.n} 个包,版本集合一致`;
}
} else if (a || b) {
depOk = false;
depNote = `一侧有依赖树、另一侧没有(仓库 ${a ? '有' : '无'} / 已部署 ${b ? '有' : '无'})`;
}
// ②b 与 `lockRel` 的关系写在名字里:它比的是被 EXCLUDE_DIRS 排除的那部分。
void lockRel;
}
push('已部署依赖树与仓库一致(EXCLUDE_DIRS 排除的那部分也要判)', depOk, depNote);
// ③ 通知脚本在标准位置且可执行
const script = '/opt/agentmail/bin/service-failure-notify.mjs';
let scriptOk = false;
let note = script;
try {
const st = stat(script);
scriptOk = st.isFile() && (st.mode & 0o111) !== 0;
if (!scriptOk) note = `${script} 缺执行位`;
} catch { note = `${script} 不存在`; }
// ★ 只判"在不在、有没有执行位"是不够的(pi 评审 2026-09-14):
// **"仓库里改过、装的那份还是旧的"会是绿灯**,而判据 ② 对 unit 就是比内容的
// ⇒ ③ 该同形。动作要紧:这个脚本是故障通知的落点,单元里引用的是 /opt 那份。
if (scriptOk) {
try {
const repoCopy = join(REPO, 'deploy', 'service-failure-notify.mjs');
const a = String(readFile(repoCopy, 'utf8'));
const b = String(readFile(script, 'utf8'));
if (a !== b) {
scriptOk = false;
note = `${script} 内容与仓库 deploy/service-failure-notify.mjs 不一致(重新部署即可对齐)`;
} else {
note = `${script}(内容与仓库一致)`;
}
} catch (e) {
// 读不到仓库那份 ⇒ **比不了就说比不了**,不许当成"一致"
scriptOk = false;
note = `比不了内容:读不到 ${join(REPO, 'deploy', 'service-failure-notify.mjs')}(${e.code ?? e.message})`;
}
}
push('故障通知脚本装在 /opt/agentmail/bin/ 且可执行', scriptOk, note);
// ④ 自有服务的 cwd / ExecStart 不得落在源码目录
const badHosts = [];
for (const unit of ['agentmail-gateway.service', 'pi-mail-bridge.service', 'opencode-serve.service',
'zcode-mail-bridge.service', 'homeagent.service', 'dsh.service', 'zcode.service']) {
let text = '';
try { text = String(readFile(`${SYS}/${unit}`, 'utf8')); } catch { continue; }
const cwd = (text.match(/WorkingDirectory=(.+)/) || [])[1]?.trim() ?? '';
const exec = (text.match(/ExecStart=(.+)/) || [])[1]?.trim() ?? '';
const allow = HOST_ALLOW[unit];
if (allow) {
// 用 includes 而不是 startsWith:zcode 的 ExecStart 是
// `dbus-run-session -- xvfb-run … /opt/ZCode/zcode …`,宿主路径在中间。
if (!text.includes(allow)) badHosts.push(`${unit}(不在 ${allow})`);
continue;
}
if (cwd.startsWith(REPO) || exec.startsWith(REPO)) badHosts.push(`${unit}(${cwd || exec})`);
}
push('各服务的工作目录/可执行文件不在源码目录', badHosts.length === 0, badHosts.join(' '));
// ⑤ 生产二进制里不得嵌源码路径(-trimpath)。
//
// Go 默认把源文件的**绝对路径**编进二进制。2026-09-14 实测:换到标准目录部署之后,
// `/opt/agentmail/agentmail-gateway` 里仍有 57 处 `/home/program/agentmail/…` ——
// 构建脚本漏了 `-trimpath`(对照实验:同一份源码、同一个 go,带标志 0 处、不带 57 处)。
// 这条是"运行时不再依赖源码目录"的**后半句**:依赖确实没了,但源仓库位置还印在产物上,
// 而且它会把"这个二进制是从哪份源码建的"变成只能靠推断的事。
const BIN = '/opt/agentmail/agentmail-gateway';
let binHits = -1;
let binNote = `读不到 ${BIN}(标准位置没有网关二进制)`;
try {
const text = String(readFile(BIN));
binHits = text.split(REPO).length - 1;
binNote = binHits === 0
? `${BIN} 里一处都没有`
: `${BIN} 里有 ${binHits} 处 ${REPO}/…(重新构建即可清零:跑一次 redeploy-gateway.sh)`;
} catch { /* binHits 保持 -1 = 读不到 */ }
push('已安装的网关二进制不含源码路径(构建带 -trimpath)', binHits === 0, binNote);
// ⑥ 工作区干净度 —— **WARN,不参与退出码**。
//
// 判据 ① 比的是「仓库工作区 → 快照」这一跳。它覆盖不到「HEAD → 工作区」
// 那一跳:工作区脏(有未提交改动)时,① 照样会绿 —— 工作区与快照一致,
// 只是两者都不等于 HEAD。2026-09-14 实证:`pool.mjs` 里一行未提交的
// `let missingSessionCount = 0;` 被 17:20 那次「从脏工作区做的」快照原样
// 带进了生产,而 ① 报的是「逐字节一致」。
//
// 为什么只 WARN:开发中间态脏是正常的。把它做成红灯就造出一条**总在亮**的
// 判据 —— 正是本文件头注释骂过的病("人会学会忽略它")。所以:说出来,
// 但不改变结论、不影响 exit code。
const gitOut = (() => {
try {
const run = inject.git ?? ((args) => execFileSync('git', args, { cwd: REPO, encoding: 'utf8' }));
return run(['status', '--porcelain']);
} catch { return null; } // 不是 git 仓库/没有 git:不判
})();
if (gitOut === null) {
push('工作区干净(仅提示,不影响结论)', true, '读不到 git 状态,不据此判定');
} else {
const dirty = gitOut.split('\n').map(l => l.trim()).filter(Boolean);
push(
'工作区干净(仅提示,不影响结论)',
true,
dirty.length === 0
? '干净 —— 快照就是 HEAD 的内容'
: `脏 ${dirty.length} 处(快照会是"工作区 + HEAD 都不是"的第三种东西):${dirty.slice(0, 5).join(';')}`
);
}
return out;
}
/** 标准目录那组自检:坏样本必须红、干净样本必须绿(证明它不是恒真)。 */
export function layoutSelfCheck() {
const fake = map => ({
readdir: dir => map[dir] ?? [],
readFile: p => {
if (!(p in map)) throw new Error('ENOENT');
return map[p];
},
exists: p => p in map,
stat: () => ({ isFile: () => true, mode: 0o755 }),
lstat: () => ({ isSymbolicLink: () => false }),
repoUnits: '/repo/systemd',
// 默认把依赖树指向假目录,免得自检真去读仓库/生产(样本要能覆盖它)
repoPluginDir: '/repo/plugins/pi-mail-bridge',
livePluginDir: '/repo/plugins/pi-mail-bridge'
});
// ★ 自检必须能走**默认值**,不能只走注入值。原先每个样本都注入 `repoUnits`,
// 于是判据 ② 的真实默认路径从没被任何样本走过 —— 而它当时恰好是错的,照样 100% 绿。
// **注入点把该抓的 bug 藏起来了**(pi 的原话)。这两条直接断言默认路径本身。
const defaultProbe = [
{ name: '★默认单元目录存在(不注入 repoUnits 也走得通)', ok: existsSync(DEFAULT_REPO_UNITS), note: DEFAULT_REPO_UNITS },
{
name: '★默认单元目录不是仓库根下那个不存在的 systemd/',
ok: DEFAULT_REPO_UNITS !== join(REPO, 'systemd'),
note: DEFAULT_REPO_UNITS === join(REPO, 'systemd') ? '又写回 ../systemd 了 —— 那正是 2026-09-14 的假绿' : ''
}
];
// ⑥ 的 git 读取也要能被喂样本,否则它是一条测不到的判据(读不到就放行 ⇒ 恒绿)。
const fakeGit = porcelain => ({ git: () => porcelain });
const bad = checkLayout(fake({
'/etc/systemd/system': [{ name: 'x.service', isDirectory: () => false }],
'/etc/systemd/system/x.service': 'ExecStart=/home/program/agentmail/bin/x',
'/repo/systemd': [],
'/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 内容、② 比的内容相同
// (live 就是 repo 那个 inode)、④ 只查固定名单。所以样本要故意让内容**干净**,
// 逼判据只能靠 realpath 抓到它 —— 否则这条样本会因为内容命中而"绿得毫无意义"。
const badLink = checkLayout({
...fake({
'/etc/systemd/system': [{ name: 'linked.service', isDirectory: () => false }],
// 内容里**没有**仓库路径:这正是真实软链的形状(仓库那份 unit 的文本)
'/etc/systemd/system/linked.service': 'ExecStart=/opt/agentmail/agentmail-gateway',
'/repo/systemd': [],
'/opt/agentmail/bin/service-failure-notify.mjs': 'x',
'/opt/agentmail/agentmail-gateway': 'fake-elf'
}),
lstat: p => ({ isSymbolicLink: () => p === '/etc/systemd/system/linked.service' }),
realpath: () => '/home/program/agentmail/deploy/systemd/linked.service'
});
// 反面对照:同一个形状但目标**不在**仓库里 ⇒ 不许报(否则这条判据会变成恒红)。
const goodLink = checkLayout({
...fake({
'/etc/systemd/system': [{ name: 'linked.service', isDirectory: () => false }],
'/etc/systemd/system/linked.service': 'ExecStart=/opt/agentmail/agentmail-gateway',
'/repo/systemd': [],
'/opt/agentmail/bin/service-failure-notify.mjs': 'x',
'/opt/agentmail/agentmail-gateway': 'fake-elf'
}),
lstat: p => ({ isSymbolicLink: () => p === '/etc/systemd/system/linked.service' }),
realpath: () => '/usr/lib/systemd/system/somewhere-else.service'
});
// ① 的 .bak 分支:旧备份单元里躺着仓库路径,也必须报出来(原先它被过滤掉了)。
const badBak = checkLayout(fake({
'/etc/systemd/system': [{ name: 'z.service.bak-20260101-000000', isDirectory: () => false }],
'/etc/systemd/system/z.service.bak-20260101-000000': 'ExecStopPost=-/usr/bin/node /home/program/agentmail/deploy/x.mjs',
'/repo/systemd': [],
'/opt/agentmail/bin/service-failure-notify.mjs': 'x',
'/opt/agentmail/agentmail-gateway': 'fake-elf'
}));
// ⑤ 的坏样本:二进制里嵌着源码路径。
const badBin = checkLayout(fake({
'/etc/systemd/system': [],
'/repo/systemd': [],
'/opt/agentmail/bin/service-failure-notify.mjs': 'x',
'/opt/agentmail/agentmail-gateway': 'ELF…/home/program/agentmail/server/cmd/server/main.go…'
}));
const good = checkLayout(fake({
'/etc/systemd/system': [{ name: 'y.service', isDirectory: () => false }],
'/etc/systemd/system/y.service': 'ExecStart=/opt/agentmail/agentmail-gateway',
'/repo/systemd': [],
'/opt/agentmail/bin/service-failure-notify.mjs': 'x',
'/opt/agentmail/agentmail-gateway': 'ELF…github.com/agentmail/gateway/cmd/server…'
}));
const dirty = checkLayout({ ...fake({
'/etc/systemd/system': [],
'/repo/systemd': [],
'/opt/agentmail/bin/service-failure-notify.mjs': 'x',
'/opt/agentmail/agentmail-gateway': 'fake-elf'
}), ...fakeGit(' M src/pool.mjs\n') });
const clean = checkLayout({ ...fake({
'/etc/systemd/system': [],
'/repo/systemd': [],
'/opt/agentmail/bin/service-failure-notify.mjs': 'x',
'/opt/agentmail/agentmail-gateway': 'fake-elf'
}), ...fakeGit('') });
const unreadable = checkLayout({ ...fake({
'/etc/systemd/system': [],
'/repo/systemd': [],
'/opt/agentmail/bin/service-failure-notify.mjs': 'x',
'/opt/agentmail/agentmail-gateway': 'fake-elf'
}), git: () => { throw new Error('not a git repo'); } });
const fifth = o => o.find(c => c.name.startsWith('已安装的网关二进制'));
const sixth = o => o.find(c => c.name.startsWith('工作区干净'));
// ★ 按**名字**取,不按位置取。原先是 `bad[0]` / `badBak[0]` —— 位置选择器是另一种
// "注入点把 bug 藏起来":在函数前面插一条新检查之后,`bad[0]` 指的就不再是
// "引用源码目录"那条。判据断言的东西必须按名字锚定。
const byName = (o, prefix) => o.find(c => c.name.startsWith(prefix));
const unitRefCheck = o => byName(o, '没有任何 unit');
// ②b 依赖树:正反两面都要有样本,且**必须真的走读盘路径**(不能只喂内存 map)——
// 这条判据的价值全在"它真的读了锁文件",所以样本用真临时目录。
const depFixture = (packages) => {
const root = mkdtempSync(join(tmpdir(), 'drift-dep-'));
const dir = join(root, 'node_modules', '@vendor', 'pkg');
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'npm-shrinkwrap.json'),
JSON.stringify({ packages: Object.fromEntries(packages.map(p => [p, { version: p.split('@').pop() }])) }));
return root;
};
// ★ 两侧指向**同一个文件**时必须明说"恒等",不许报"一致"(pi 反例:pi 的依赖是全局包软链)。
const depSameFile = (() => {
let a = null;
try {
a = depFixture(['a@1.0.0']);
// 两侧都指向同一个真文件(经由同一路径),realpath 相等
const out = byName(
checkLayout({
...fake({ '/etc/systemd/system': [], '/repo/systemd': [] }),
repoPluginDir: a,
livePluginDir: a,
realpath: p => p,
readdir: (dir, opts) => (dir.startsWith(a) ? readdirSync(dir, opts) : []),
readFile: (p, enc) => (p.startsWith(a) ? readFileSync(p, enc) : (() => { throw new Error('ENOENT'); })()),
exists: p => p.startsWith(a)
}),
'已部署依赖树'
);
return out;
} finally {
if (a) rmSync(a, { recursive: true, force: true });
}
})();
const depSample = (repoPkgs, livePkgs) => {
let a = null; let b = null;
try {
a = depFixture(repoPkgs);
b = livePkgs ? depFixture(livePkgs) : mkdtempSync(join(tmpdir(), 'drift-empty-'));
// 注入面把**夹具目录**路由到真实 fs,其余路径仍走内存假件 ——
// 这样"找锁文件"那段真实逻辑被走了一遍,而 systemd 那几条判据仍可控。
const roots = [a, b].filter(Boolean);
const onDisk = p => roots.some(r => p.startsWith(r));
return byName(
checkLayout({
...fake({ '/etc/systemd/system': [], '/repo/systemd': [] }),
repoPluginDir: a,
livePluginDir: b,
readdir: (dir, opts) => (onDisk(dir) ? readdirSync(dir, opts) : []),
readFile: (p, enc) => (onDisk(p) ? readFileSync(p, enc) : (() => { throw new Error('ENOENT'); })()),
exists: p => (onDisk(p) ? existsSync(p) : false)
}),
'已部署依赖树'
);
} finally {
for (const d of [a, b]) if (d) rmSync(d, { recursive: true, force: true });
}
};
const sameDeps = depSample(['a@1.0.0', 'b@2.0.0'], ['a@1.0.0', 'b@2.0.0']);
const diffDeps = depSample(['a@1.0.0', 'b@2.0.0'], ['a@1.0.0', 'b@9.9.9']);
const missingDeps = depSample(['a@1.0.0'], null);
return [
{ name: '标准目录:引用源码目录的样本必须判红', ok: unitRefCheck(bad)?.ok === false },
// ★ 软链分支的正反两面都要真:内容干净但指向仓库 ⇒ 红;指向仓库外 ⇒ 绿。
{ name: '★单元是软链且指向仓库 ⇒ 必须红(内容判据看不见这个形状)',
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 ?? '') },
{ name: '★一侧没有依赖树 ⇒ 必须红(不许当"未比"放过)', ok: missingDeps?.ok === false },
{ name: '★两侧是同一个文件 ⇒ 明说"恒等/区分力为零",不许报"一致"',
ok: depSameFile?.ok === true && /恒等/.test(depSameFile?.note ?? '') },
// ③ 从"只判在不在"改成"比内容"之后,必须证明它真能发现内容不同(否则又是一条假绿)。
// 探针:把仓库那份读成别的内容 ⇒ ③ 必须红。
{
name: '★通知脚本内容与仓库不一致 ⇒ 必须红',
ok: byName(
checkLayout({
...fake({ '/etc/systemd/system': [], '/repo/systemd': [], '/opt/agentmail/bin/service-failure-notify.mjs': 'installed' }),
// ⚠️ 两条路径的文件名**相同**(仓库 `deploy/service-failure-notify.mjs`
// vs 装机 `/opt/agentmail/bin/service-failure-notify.mjs`),
// 第一版探针按文件名判、两边返回同一个串 ⇒ 探针自己没分辨力、自检红。
// 必须按**哪一侧**区分。
readFile: p => (p.includes('/deploy/service-failure-notify.mjs')
? 'REPO-COPY'
: (p.endsWith('service-failure-notify.mjs') ? 'INSTALLED-COPY' : (() => { throw new Error('ENOENT'); })()))
}),
'故障通知脚本'
)?.ok === false
},
{ name: '标准目录:.bak 里引用源码目录也必须判红', ok: unitRefCheck(badBak)?.ok === false },
{ name: '标准目录:干净样本必须判绿', ok: unitRefCheck(good)?.ok === true },
// 二进制那条两侧都要真:嵌了源码路径必须红,trimpath 的必须绿。
{ name: '网关二进制:嵌了源码路径必须判红', ok: fifth(badBin)?.ok === false },
{ name: '网关二进制:trimpath 过的必须判绿', ok: fifth(good)?.ok === true },
// 编号:id 从 1 连续排到 N,不许跳号(原先只有 ⑥ 带编号,读者会去找不存在的 ⑤)。
{ name: '标准目录:id 连续编号,不跳号', ok: good.every((c, i) => c.id === String(i + 1)) },
// 「工作区干净」是"说出来但不改结论"的提示:脏 / 干净 / 读不到三种都被报出来,
// 且**都不判红**(做成红灯就是一条总在亮的判据,本文件头骂过这个病)。
{ name: '工作区干净:脏 → 说出来(且不判红)', ok: sixth(dirty)?.note.includes('脏 1 处') === true && sixth(dirty)?.ok === true },
{ name: '工作区干净:干净 → 明说干净', ok: sixth(clean)?.note.includes('干净') === true },
{ name: '工作区干净:读不到 git → 不据此判定', ok: sixth(unreadable)?.note.includes('不据此判定') === true },
...defaultProbe,
{
name: '★单元目录读不到 ⇒ 必须判红(不许静默报一致)',
ok: byName(
checkLayout({
...fake({ '/etc/systemd/system': [], '/opt/agentmail/bin/service-failure-notify.mjs': 'x', '/opt/agentmail/agentmail-gateway': 'fake-elf' }),
repoUnits: '/nonexistent/systemd'
}),
'已安装单元'
)?.ok === false
}
];
}
function main() {
const json = process.argv.includes('--json');
const wantSelfCheck = process.argv.includes('--self-check');
if (wantSelfCheck) {
// 环境不足(临时目录写不进去 ⇒ 自检造不出样本树)必须报 2,不是崩栈、也不是 1。
// 1 会让人去查"是不是判据坏了",2 才说得清是机器的问题。
let checks;
try {
checks = selfCheck().concat(layoutSelfCheck());
} catch (e) {
// ★ 覆盖 **全部** 写点:上面 `mk()` 自己会翻译,但后面还有三处裸写
// (README.md / extra.mjs / test/t.mjs)。第一版只包了 `mk()`,那三处
// 撞上 ENOSPC 会打出原始英文 + 本文件堆栈 —— 退出码对、信号错。
const translated = describeEnvError(e, '判据自检要在临时目录里造样本树');
if (translated.code === 'ENOSPC') {
console.error(`\n${translated.message}\n`);
process.exit(2);
}
throw e;
}
if (json) console.log(JSON.stringify({ selfCheck: checks }, null, 2));
else {
console.log('判据自检(先证明检查器能发现差异):');
for (const c of checks) console.log(` ${c.ok ? '通过' : '失败'} ${c.name}`);
}
const bad = checks.filter(c => !c.ok);
if (bad.length) {
console.error(`判据自检失败 ${bad.length} 项 —— 检查器本身不可信,不能用它的结论`);
process.exit(1);
}
}
const layout = checkLayout();
const layoutBad = layout.filter(c => !c.ok);
const results = HOSTS.map(checkHost);
if (json) {
console.log(JSON.stringify({ hosts: results, layout }, null, 2));
} else {
console.log('\n部署漂移检查(快照 vs 仓库 HEAD,以及进程到底在跑哪份代码):');
for (const r of results) {
console.log(`\n ${r.host}(${r.plugin},加载方式 ${r.load})${r.stale ? ' ⚠️ 漂移' : ''}`);
for (const c of r.checks) console.log(` ${c.ok ? '通过' : '失败'} ${c.name}${c.note ? ' — ' + c.note : ''}`);
}
console.log('\n 标准目录部署:');
for (const c of layout) console.log(` ${c.ok ? '通过' : '失败'} ${c.id} ${c.name}${c.note ? ' — ' + c.note : ''}`);
const stale = results.filter(r => r.stale);
console.log(`\n 结论:${stale.length === 0 ? '四个宿主都在跑当前代码' : `${stale.length} 个宿主需要重新部署/重启`}`);
for (const r of stale) console.log(` ✗ ${r.host}:bash deploy/redeploy-plugin.sh ${r.host}`);
}
process.exit(results.some(r => r.stale) || layoutBad.length ? 1 : 0);
}
// 仅在被直接执行时跑 main(被 import 时只导出,供测试调用)
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) main();