## 背景 zcode 宿主已在本机删除(进程无、`/etc/systemd/system/zcode*.service` 无、 `/opt/agentmail/plugins/` 下无该目录),而 `plugins/zcode-mail-bridge/` (62 个文件)与 `deploy/systemd/zcode.*`(3 个单元)**故意留在仓库里**以便恢复。 `HOSTS` 是硬编码四家,于是两条判据永久红,且结论区建议的 `bash deploy/redeploy-plugin.sh zcode` 是**错的动作** —— 那会部署一个用户已决定不再运行的宿主。 ## 改法:显式豁免表 `RETIRED_HOSTS` **豁免只换表述、不压红**(这是关键,理由见下): · `checkHost` 仍逐条列出「已退场」+ 理由,只是不计入 `stale`; · `checkLayout` 的 note 里明写「已退场宿主、故意不装:<单元>」。 为什么不能静默跳过:本文件反复强调「**没检查到** ≠ **不存在**」。 静默跳过 = 让人以为"检查了、没问题",而"这台机器上跑什么" 本身就是需要有人知道的**事实**。豁免必须**可见**。 ## ★★ 我自己连踩两个坑,都让豁免**静默生效**(不是"豁免得太宽"那种噪声) ① 第一版 `isRetiredHost` 只查 `units[0]`(zcode.service)。 实测"只装 zcode-mail-bridge.service、不装快照"时 **仍被判成已退场**,且那个新装上的单元在 note 里被写成 「故意不装」—— **自相矛盾且完全静默**。 ⇒ 改为 `units.every` 逐个查。根因:只抽查一个单元就宣称"全都没装"。 ② 第二版用 `existsSync` 判软链存在,而 `current` 是**相对**软链 (`→ 20260926-085613`)。快照目录被删、软链残留成**断链**时 `existsSync` 返回 false ⇒ 「装过又删剩」与「从没装过」读数 **完全同形**。实测断链场景仍被判「已退场」。 ⇒ 改用 `lstatSync`(看得见软链本身,含断链)。 ## 验证:四态都实测过 A 全都没装 ⇒ 豁免("已退场"仍出现在输出里) B 有效软链 ⇒ 报漂移 C **断链**软链 ⇒ 报漂移 D 只装一个单元 ⇒ 报漂移 `--self-check` 47 格仍全过。结论区回到 "四个宿主都在跑当前代码"。 ## 欠账 豁免逻辑本身**没有自检样本**进 `--self-check`(现靠人测四态)。 按本仓纪律"能自证就别靠人测",应补:注入假 fs 让四态各跑一遍, 且必须有一格是**断链**样本 —— 第一/二版都恰好漏在断链上。 已登记 `DEBTS.json` 的 `retired-host-exempt-never-tested`。
1843 lines
101 KiB
JavaScript
1843 lines
101 KiB
JavaScript
#!/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,仓库里若有它会造成永久假红
|
||
|
||
/**
|
||
* 已在本机**退场**、但代码**故意留在仓库里**的宿主。
|
||
*
|
||
* # 为什么需要这张表
|
||
*
|
||
* `HOSTS` 是"仓库认为应当部署的宿主"的清单。zcode 宿主已在本机删除
|
||
* (进程无、`/etc/systemd/system/zcode*.service` 无、`/opt/agentmail/plugins/`
|
||
* 下无该目录),而 `plugins/zcode-mail-bridge/` 与 `deploy/systemd/zcode.*`
|
||
* **故意保留**(可随时恢复)。两侧于是永久不一致:
|
||
*
|
||
* - `checkHost` 报「未部署」⇒ `stale=true` ⇒ 结论区建议 `redeploy-plugin.sh zcode`
|
||
* - `checkLayout` 报「已安装单元缺 zcode*.service(3 项)」
|
||
*
|
||
* 而照建议执行是**错的动作**:那条命令会部署一个用户已经决定不再运行的宿主。
|
||
*
|
||
* ★ 所以这里不是"把红压下去",而是**换成一个可读的真陈述**:
|
||
* 豁免项仍会被逐条列在输出里(列名 + 理由),只是不再计入 stale。
|
||
* 静默跳过 = 让人以为"检查了、没问题",与本文件反复强调的那条同源;
|
||
* 而"已退场"本身是需要有人知道的**事实** —— 它是"这台机器上跑什么"的真相。
|
||
*
|
||
* # 口径(必须写明可证伪范围)
|
||
*
|
||
* 本表只豁免两件事,**不豁免任何内容比对**:
|
||
* ① `checkHost` 的「仓库目录存在 / 已部署(current 存在)」这一格;
|
||
* ② `checkLayout` 的「已安装单元缺 <该宿主的单元>」这一格。
|
||
*
|
||
* 一旦该宿主重新部署(或部分部署),豁免立即失效、判据照常报它 ——
|
||
* 因为豁免的前提是"**全都没有**",不是"**现在没有**"。
|
||
* `isRetiredHost` 要求 `!existsSync(linkPath) && !existsSync(unitPath)`,
|
||
* 两者任一存在就退化成正常检查。
|
||
*
|
||
* @type {Record<string, {plugin: string, units: string[], reason: string}>}
|
||
*/
|
||
const RETIRED_HOSTS = {
|
||
zcode: {
|
||
plugin: 'zcode-mail-bridge',
|
||
units: ['zcode.service', 'zcode-mail-bridge.service', 'zcode.service.d/10-dbus.conf'],
|
||
reason: '宿主已在本机删除(用户 2026-09-28 确认);插件与单元故意留在仓库里以便恢复'
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 该宿主是否处于"已退场"状态 —— **全都没装**才算。
|
||
*
|
||
* 刻意不接受"部分没装":装了一半(快照在、单元不在,或反之)是真的漂移,
|
||
* 必须照常报出来,否则这条豁免就成了"zcode 半坏也不报"的洞。
|
||
*
|
||
* ★★ 第一版只查了 `units[0]`(zcode.service),**实测漏掉了半装**:
|
||
* 只放 `zcode-mail-bridge.service` 而不放快照时,它仍被判成"已退场"、
|
||
* 且那个新装上的单元在 note 里被写成「故意不装」—— **自相矛盾且完全静默**。
|
||
* 根因:只抽查一个单元就宣称"全都没装"。
|
||
* ⇒ 改为**逐个单元查**(`units.every`),任何一个存在都算"装过"⇒ 豁免失效。
|
||
* `units[0]` 之所以够用只对"三个单元里恰好第一个代表全体"的假想成立。
|
||
*
|
||
* @param {string} host
|
||
* @returns {boolean}
|
||
*/
|
||
function isRetiredHost(host) {
|
||
const spec = RETIRED_HOSTS[host];
|
||
if (!spec) return false;
|
||
const linkPath = join(DEPLOY_ROOT, spec.plugin, 'current');
|
||
// 逐个查单元,而不是只看第一个
|
||
const anyUnit = spec.units.some(u => existsSync(join('/etc/systemd/system', u)));
|
||
return !entryPresent(linkPath) && !anyUnit;
|
||
}
|
||
|
||
/**
|
||
* 「这个路径存在吗」—— **不跟随符号链接**。
|
||
*
|
||
* ★ 为什么不能用 `existsSync`:`current` 是个**相对**软链(`→ 20260926-085613`)。
|
||
* 快照目录被删而软链残留时,它会变成**断链**,而 `existsSync` 对断链返回
|
||
* `false` —— 于是「装过又删剩」与「从没装过」在读数上**完全同形**。
|
||
* 实测(2026-09-28)正是这个坑:先手动造 `current → 20260101-000000`(断链)
|
||
* 来模拟"装了快照",豁免**仍然生效**并报「已退场」⇒ 判据对一个真实存在的
|
||
* 部署痕迹**完全静默**。
|
||
*
|
||
* 这与本文件反复记的是同一族:「**没检查到**」≠「**不存在**」。
|
||
* `lstatSync` 能看到软链本身(含断链),这才是"这里被部署过"的正确读数。
|
||
*
|
||
* @param {string} p
|
||
* @returns {boolean}
|
||
*/
|
||
function entryPresent(p) {
|
||
if (existsSync(p)) return true; // 正常文件/目录/有效软链
|
||
return lstatSync(p, { throwIfNoEntry: false }) !== undefined; // 断链也算
|
||
}
|
||
|
||
/**
|
||
* 这个单元文件是否属于某个**已退场**宿主(且该宿主确实完全没装)。
|
||
*
|
||
* @param {string} rel 相对 `deploy/systemd/` 的路径,如 `zcode.service.d/10-dbus.conf`
|
||
* @returns {boolean}
|
||
*/
|
||
function retiredUnit(rel) {
|
||
for (const host of Object.keys(RETIRED_HOSTS)) {
|
||
if (!isRetiredHost(host)) continue;
|
||
if (RETIRED_HOSTS[host].units.includes(rel)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** 已退场宿主的单元清单(写进判据 note,让豁免**可见**而不是静默)。 */
|
||
function retiredUnitList() {
|
||
const out = [];
|
||
for (const host of Object.keys(RETIRED_HOSTS)) {
|
||
if (!isRetiredHost(host)) continue;
|
||
for (const u of RETIRED_HOSTS[host].units) out.push(`${u}(${host} 已退场)`);
|
||
}
|
||
return out.join(';');
|
||
}
|
||
|
||
/**
|
||
* 宿主表。`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 (isRetiredHost(host)) {
|
||
// 已退场:**不**计 stale,但把事实说出来(见 RETIRED_HOSTS 的注释)。
|
||
pass('已退场(本机不部署,代码保留在仓库)', RETIRED_HOSTS[host].reason);
|
||
return 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) {
|
||
// 已退场宿主的单元:**不**算"机器上缺"。
|
||
//
|
||
// 这与"仓库里没有它"不是一回事 —— 仓库里**有** zcode 的三个单元,
|
||
// 而本机**故意**不装。判据分不出"忘了装"与"决定不装",于是默认报缺,
|
||
// 照建议执行 `redeploy-plugin.sh zcode` 就是部署一个用户已退场的宿主。
|
||
//
|
||
// 窄到**逐个单元**豁免(不是整个宿主一刀切):只跳过 `units` 里列出的那几个,
|
||
// 仓库里若有其它 zcode 相关单元仍然照报。
|
||
if (retiredUnit(rel)) continue;
|
||
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 无法区分"一致"和"没比过",而那正是这条判据
|
||
// 原先的样子。绿的时候也要留下覆盖范围的证据。
|
||
//
|
||
// ★ `retiredSkipped` 不从 `比了 N 个` 里减掉,而是**另起一句写明**:
|
||
// 减掉会让读者以为只比了 N-3 个;不减掉又会让"跳过的"这件事消失在数字里。
|
||
// 两句话都在,读者能自己算出比了哪些、没比哪些。
|
||
const retiredNote = retiredUnitList();
|
||
push(
|
||
'已安装单元与 deploy/systemd/ 一致(两个方向)',
|
||
drift.length === 0,
|
||
(drift.length === 0
|
||
? `比了 ${repoMap ? repoMap.size : 0} 个文件,全部一致(目录:${repoUnits})`
|
||
: drift.join(' '))
|
||
+ (retiredNote ? `;已退场宿主、故意不装:${retiredNote}` : '')
|
||
);
|
||
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);
|
||
|
||
// ⑤b 已安装的网关二进制**是不是当前代码** —— 比 `vcs.revision`。
|
||
//
|
||
// 为什么需要单独一条:⑤ 只判"有没有带 -trimpath",那是**构建参数**的性质,
|
||
// 与"这份二进制是哪次提交建的"无关。2026-09-25 实证:线上二进制
|
||
// vcs.revision=7b8c78f(09-19 构建),而仓库已有 6 天的新提交(含两条
|
||
// relay 幂等键/回滚修复);⑤ 当日报的是"63 处源码路径"(另一个毛病),
|
||
// **完全没有指出它落后 6 天**。当时是靠人手动跑 `go version -m` 才发现的。
|
||
//
|
||
// Go 会把 `vcs.revision=<sha>` 以**明文字节**编进二进制(默认 `-buildvcs=auto`),
|
||
// 所以这里直接按字节匹配读出,不依赖 `go` 命令、不 spawn 进程 ——
|
||
// 与 ⑤ 读同一份 buffer。
|
||
//
|
||
// ⚠️ 判据只比"相等/不等",**不判方向**(谁新谁旧):判断新旧要 git 图,
|
||
// 而二进制里的 sha 未必在本地仓库里(可能是别处构建的)。
|
||
// 这里报"不等"就够了 —— 它是"部署过之后没重启/没重构建"的**唯一**直接信号。
|
||
// 拿不到 revision(老 build、非 git 构建、读不到文件)⇒ **不判**(不算失败),
|
||
// 否则会造出一条在合法环境下总亮的判据 —— 本文件头骂过的病。
|
||
let binRevOk = true;
|
||
let binRevNote = '';
|
||
try {
|
||
const buf = readFile(BIN); // Buffer;⑤ 已读过,这里复用读法
|
||
const text = Buffer.isBuffer(buf) ? buf.toString('latin1') : String(buf);
|
||
const got = (text.match(/vcs\.revision=([0-9a-f]{40})/) || [])[1] ?? '';
|
||
// 仓库 HEAD 的 sha:`git rev-parse HEAD`
|
||
let head = '';
|
||
try {
|
||
const run = inject.git ?? ((args) => execFileSync('git', args, { cwd: REPO, encoding: 'utf8' }));
|
||
head = String(run(['rev-parse', 'HEAD'])).trim();
|
||
} catch { head = ''; }
|
||
if (!got || !head) {
|
||
binRevOk = true; // 有一侧拿不到 ⇒ 不判(见上)
|
||
binRevNote = got && !head ? `二进制 vcs.revision=${got.slice(0, 8)},但读不到仓库 HEAD ⇒ 不判`
|
||
: !got && head ? `${BIN} 里没有 vcs.revision(可能是 -buildvcs=false 或老构建)⇒ 不判`
|
||
: `两侧都拿不到 ⇒ 不判`;
|
||
} else if (got === head) {
|
||
// ★ `revision == HEAD` **不等于**"二进制就是那份提交的代码"(pi 2026-09-25 指出):
|
||
// 树脏时 Go 照样写 `vcs.revision=<HEAD>`,只在 `vcs.modified` 上留痕。
|
||
// 实测(/tmp/vm2:HEAD=2931050a,改一行**不提交**再 build):
|
||
// revision=2931050a == HEAD ⇒ 本判据绿,而二进制里是未提交的代码 —— 假绿。
|
||
// ⇒ 把 modified 也读出来说清楚;**不改 ok**(脏树在本仓是常态,
|
||
// 判红会造出总在亮的判据 —— 与本文件 ⑥ 的既定严重度一致:WARN,不参与退出码)。
|
||
const mod = (text.match(/vcs\.modified=(\w+)/) || [])[1] ?? '';
|
||
// ★ ⑬′(pi 2026-09-25):判据文案必须写出**负向清单**(R\C —— 它**不**覆盖哪几类失败)。
|
||
// 正向范围可以含糊,负向清单必须逐条列,否则读者会把"绿"读成"这份二进制没问题"。
|
||
// 本判据的 R\C 就是下面这两类 —— 它们**只有编译/别的判据能判**:
|
||
// · 从脏树构建(revision 相等但含未提交代码)—— 本判据只**披露** modified,不判红
|
||
// · 二进制内容与某次提交一致、但**部署后又被人手改过**(本判据只看内嵌 revision)
|
||
// ★ 注意本判据**读不到构建时的工作树**:它读的是二进制的内嵌位,而 `git status`
|
||
// 在这里问的是**此刻**的树 —— 两者可能不同(构建完又提交了 / 别人的改动进来了)。
|
||
// 所以"这次构建带了哪些未提交文件"由**部署脚本**在构建步打印(那里才是同一时刻),
|
||
// 本判据只回答"含不含未提交代码"。别把这里补成 `git status` 然后当成构建清单。
|
||
const negative = '本判据**不覆盖**: ①从脏树构建(含未提交代码,只披露不判红)'
|
||
+ '②部署后被手工替换/修改(只看内嵌 revision)';
|
||
binRevNote = `与仓库 HEAD 一致(${got.slice(0, 8)})`
|
||
+ (mod === 'true'
|
||
? ',但 vcs.modified=true ⇒ **含未提交代码**,不对应任何已提交版本'
|
||
: mod === 'false' ? '' : '(未记录 vcs.modified ⇒ 是否含未提交代码未知)')
|
||
+ `;${negative}`;
|
||
} else {
|
||
binRevOk = false;
|
||
binRevNote = `${BIN} 是 ${got.slice(0, 8)},仓库 HEAD 是 ${head.slice(0, 8)} —— `
|
||
+ `已部署的不是当前代码(跑一次 redeploy-gateway.sh)`;
|
||
}
|
||
} catch {
|
||
// 读不到 BIN:⑤ 已经会报"读不到",这里不重复扣分
|
||
binRevNote = `读不到 ${BIN} ⇒ 不判`;
|
||
}
|
||
push('网关二进制构建版本 = 当前 HEAD(比 vcs.revision)', binRevOk, binRevNote);
|
||
|
||
// ⑥ 工作区干净度 —— **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 });
|
||
// ⑤b 版本判据的样本构造器。`rev` 是喂给二进制的 `vcs.revision=`,
|
||
// `head` 是 `git rev-parse HEAD` 的回包 —— 两侧**分别**可喂,
|
||
// 否则测不出"拿不到其中一侧 ⇒ 不判"那个分支。
|
||
const binRevOf = ({ rev, modified }, head) => checkLayout({
|
||
...fake({
|
||
'/etc/systemd/system': [],
|
||
'/repo/systemd': [],
|
||
'/opt/agentmail/bin/service-failure-notify.mjs': 'x',
|
||
'/opt/agentmail/agentmail-gateway': rev
|
||
? `ELF…vcs.revision=${rev}…${modified ? `vcs.modified=${modified}…` : ''}github.com/agentmail/gateway…`
|
||
: 'ELF…-buildmode=exe…github.com/agentmail/gateway…'
|
||
}),
|
||
git: args => (args[0] === 'rev-parse' ? head : '')
|
||
});
|
||
const seventh = o => o.find(c => c.name.startsWith('网关二进制构建版本'));
|
||
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'); } });
|
||
// ⚠️ 用**完整名字**取,不用 `startsWith('已安装的网关二进制')` —— ⑤ 与 ⑤b
|
||
// 两条都以那个前缀开头,`find` 只返回第一个 ⇒ ⑤b 的断言会静默地测到 ⑤。
|
||
// (这正是本文件反复记的形状:按名字锚定,但锚得太短就等于按位置。)
|
||
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 },
|
||
// ⑤b 版本判据:**三面都要真**(相等绿 / 不等红 / 拿不到不判)。
|
||
// 只钉"不等红"会放过"两侧都读不到时恒绿"的退化 —— 那正是本文件最怕的假绿。
|
||
{
|
||
name: '★网关二进制:revision 与 HEAD 不同 ⇒ 必须红,且说"不是当前代码"',
|
||
ok: seventh(binRevOf({ rev: 'a'.repeat(40) }, 'b'.repeat(40)))?.ok === false
|
||
&& /不是当前代码/.test(seventh(binRevOf({ rev: 'a'.repeat(40) }, 'b'.repeat(40)))?.note ?? '')
|
||
},
|
||
{
|
||
name: '★网关二进制:revision 与 HEAD 相同 ⇒ 必须绿',
|
||
ok: seventh(binRevOf({ rev: 'c'.repeat(40) }, 'c'.repeat(40)))?.ok === true
|
||
},
|
||
{
|
||
// ★ ⑬′:绿的时候也必须写出**负向清单**(本判据不覆盖哪几类)。
|
||
// 否则"绿"会被读成"这份二进制没问题",而它其实只答了一个问题:
|
||
// "内嵌 revision 是否等于 HEAD"。写成正向范围可以含糊,负向清单必须逐条列。
|
||
name: '★网关二进制:绿时 note 必须写负向清单(不覆盖哪几类)',
|
||
ok: seventh(binRevOf({ rev: 'c'.repeat(40) }, 'c'.repeat(40)))?.ok === true
|
||
&& /不覆盖/.test(seventh(binRevOf({ rev: 'c'.repeat(40) }, 'c'.repeat(40)))?.note ?? '')
|
||
},
|
||
{
|
||
// ★★ ⑬″(pi `7ec0044a`):负向清单的**每一项**都必须有自检格守着 —— 否则那份
|
||
// 声明自己会漂(有人删掉一项、或以为它被覆盖了,都**没有东西会红**)。
|
||
// 实测指出时: 项①(脏树构建)有格,**项②(部署后手工替换)一格都没有**。
|
||
// ⑬″ 的判法: 每项判「在 R 内 C 外」(真洞 ⇒ 加格)还是「在 R 外」(⇒ 划出宣称)。
|
||
// 项② 在 R 内(⑤b 宣称回答"这份二进制是不是当前代码")而 C 外(只读内嵌 revision,
|
||
// 同 revision 换内容照样绿)⇒ **真洞 ⇒ 加格**(本格)。
|
||
// 本格**不要求改 ok** —— 内容替换确实判不了,只能"说出来";它钉的是**那句话还在**
|
||
// (删掉 note 里这半句 ⇒ 本格红)。
|
||
name: '★网关二进制:绿时 note 必须写明"①脏树构建②部署后手工替换"**两项都不覆盖**',
|
||
ok: ['①', '②'].every(m => (seventh(binRevOf({ rev: 'c'.repeat(40) }, 'c'.repeat(40)))?.note ?? '').includes(m))
|
||
&& /手工替换/.test(seventh(binRevOf({ rev: 'c'.repeat(40) }, 'c'.repeat(40)))?.note ?? '')
|
||
&& /脏树/.test(seventh(binRevOf({ rev: 'c'.repeat(40) }, 'c'.repeat(40)))?.note ?? '')
|
||
},
|
||
{
|
||
// ★ revision 相同 **但** modified=true ⇒ 仍是绿,**但 note 必须点明**"含未提交代码"。
|
||
// 这是本判据唯一的**假绿**形状(pi 2026-09-25 指出):树脏时 Go 照样写
|
||
// `vcs.revision=<HEAD>`,只有 modified 留痕 ⇒ 只比 revision 会把"未提交的代码"
|
||
// 当成"就是那份提交"。不改 ok(脏树在本仓是常态,判红=总在亮),但**不许不说**。
|
||
name: '★网关二进制:revision 相同但 modified=true ⇒ 绿,且 note 点明"含未提交代码"',
|
||
ok: seventh(binRevOf({ rev: 'c'.repeat(40), modified: 'true' }, 'c'.repeat(40)))?.ok === true
|
||
&& /含未提交代码/.test(seventh(binRevOf({ rev: 'c'.repeat(40), modified: 'true' }, 'c'.repeat(40)))?.note ?? '')
|
||
},
|
||
{
|
||
// 拿不到 revision(-buildvcs=false / 老构建 / 非 git 构建)⇒ **不判**。
|
||
// 写成红则合法环境恒红;写成绿又等于没判 ⇒ 只接受"不判",且 note 必须说出来,
|
||
// 否则读者分不清"比过且相同"与"根本没比"。
|
||
name: '★网关二进制:拿不到 revision ⇒ 不判(且 note 说出来)',
|
||
ok: seventh(binRevOf({ rev: '' }, 'd'.repeat(40)))?.ok === true
|
||
&& /不判/.test(seventh(binRevOf({ rev: '' }, 'd'.repeat(40)))?.note ?? '')
|
||
},
|
||
// 编号: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();
|