fix(deploy): C 扩到全文件(133/0 闭合)+ ① 加 realpath 判据 + ②b 明说"恒等" + 环境自足收成一处

pi 这一封四个实质点,逐个实测后处理:

**C. 口径扩到全部文件**(他给的是算术,不是口味,我认):
原先按后缀取(`.conf/.service/.timer/.bak*`),我说的"零违规就不扩"是把口味当论证。
他把成本量化了:差集极小 ⇒ 多读几次文件(几十 KB),而收益是那个 `0` 从
**"有范围的 0"**(只对我划的圈成立)变成**"闭合的 0"**(对整棵 /etc/systemd 成立)。
他还补了一句我没想到的:这条判据只报**内容里含仓库路径**的文件,
所以含仓库串的 `.dpkg-old`/`~`/无后缀文件**恰恰都是真信号**(过期的旧真相),
不是噪声 —— 我先前"二进制会变成噪声"的担心本来就不成立。
**验收实测:比了 133 个文件(全部,不筛后缀)、命中 0。**
另按他要求把"零违规"这个前提写进注释,并说明"红/WARN 拆分"为什么推迟
(零违规时拆分是重构不是修 bug;出现第一个非白名单命中时再决定分档)。

**反例 1(②b 对 pi 是跑不到的分支)**:确认。pi 的依赖是全局包软链
(`-> /usr/lib/node_modules/@earendil-works/pi-coding-agent`),`cp -a` 保留软链
⇒ 两侧 realpath 到**同一个 inode**(实测 `statSync(a).ino === statSync(b).ino`)
⇒ 版本集合按构造相等 ⇒ **②b 对 pi 永远不会红**。这正是本文件自己列过的第三种形态
(断言在、区分力不在),比"没写判据"更坏因为它看起来是绿的。
已改:两侧 realpath 相同时**明说"恒等、区分力为零"**并指出它真正覆盖谁(有 vendored 树的宿主),
不再报"版本集合一致"这种让人误以为验过的措辞。自检加了这一条。

**反例 2(① 的 realpath 盲区)**:确认,形状真实且三条判据全都看不见 ——
①只 grep 内容(仓库那份 unit 文本里没有仓库字面量)、②比内容(live 就是 repo 那个 inode,
必然"一致")、④只查固定名单。已加 realpath 判据:被检文件 realpath 落在仓库里 ⇒ 红,
与内容无关。自检加**正反两面**(内容干净但指向仓库 ⇒ 红;指向仓库外 ⇒ 不许红,
否则这条判据恒红)。实测:本机 `/etc/systemd/system` 下 0 条指向仓库的软链
(即这个 0 现在才是闭合的)。

**反例 3(环境假设第四次 ⇒ 建议收成一处)**:采纳。四次的形态一模一样
(HOME ⇒ 又一次 HOME ⇒ TMPDIR ⇒ GOMODCACHE/GOPATH),每次"再加一个预检"只挡已知那一个。
新增 `deploy/lib/env-defaults.sh`:一处给全 HOME/TMPDIR/GOMODCACHE(GOPATH)/PATH,
只设**未设**的变量,注释里写明四次历史与"否则第五次一定会来";
三个部署脚本开头 source 它;**删掉**我上一轮加的那个分散 go 预检。
实测:在 `HOME`/`TMPDIR`/`GOPATH`/`GOMODCACHE` **全空**的环境里
`bash deploy/install.sh --check` **exit 0**(go vet + go test 自己站起来),
兜住的变量会在 `AGENTMAIL_ENV_DEFAULTS` 里说明。

docs 补两条纪律:13「锚点必须一一对应 —— 连'文件名'都会骗你」(E 的探针教训)、
14「退出码也有量纲」(--self-check 的退出码不是自检的结论)。

验证:npm test exit 0;check-shared-libs exit 0;drift --self-check **35/0**;
prune 干跑 exit 0;install.sh --check 空环境 exit 0。
This commit is contained in:
2026-09-14 20:42:54 +08:00
parent d47035aaff
commit 7eec311756
6 changed files with 224 additions and 31 deletions

View File

@ -728,27 +728,55 @@ export function checkLayout(inject = {}) {
// 那句话只对我自己划的那个圈成立 —— **判据的边界没说出口,就等于报了个假的 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;
// ⚠️ **已知的圈**:这里按后缀取(`.conf/.service/.timer`,含 `.bak*` 变体)。
// pi 评审 2026-09-14 建议改成"不划圈"——`/etc/systemd/system` 下每个普通文件都读一遍
// 再 grep,理由是**白名单永远会漏一个**(`.dpkg-old`/`.rpmsave`/`~`/`.disabled`/…)。
let scannedLinks = 0;
// ★ **不划圈**:每个普通文件都读一遍再 grep(pi 评审 2026-09-14,我原先按后缀取)。
//
// 取舍(我做了、理由写在这里):**暂不改**,因为实测这一格现在是**零违规**:
// 该目录 133 个文件里,**没有任何一个**包含仓库路径 —— 包括现存的 4 个 `.bak`
// (`dsh-lan.service.bak-20260903-081410`、`pi-bridge.service.bak-13010-20260814`
// 等,全干净)。`pi` 说的 `zcode.service.bak-20260912-145744` 已经不在了
// (他自己读时也是 ENOENT),所以我引他那句时依据的文件**已消失**。
// 既然零违规,扩到"全文件"只增加噪声(二进制/dpkg 数据库类文件),换不到真信号;
// 因此改为**把覆盖面写进 note**(`0` 必须带上"它能被证伪的范围"),
// 并把这条决定留在注释里 —— 等真出现一个非白名单后缀的违规再改,那时我们就有实例了。
// 我原先把这条推迟了,理由是"实测零违规 ⇒ 扩口径只增噪声"。pi 用**算术**驳回了口味问题:
// 总文件 133(递归口径 129/129?见 note 实数)/ 现行后缀口径覆盖绝大多数 ⇒ 差集极小,
// 即"读全部"的噪声成本是**几次 `readFile`**(几十 KB),而收益是那个 `0` 从
// "**有范围的** 0"(只对我划的圈成立)变成"**闭合的** 0"(对整棵 /etc/systemd 成立)。
// 他这条论证我认:同一条"0 必须写明可证伪范围"的规矩,**闭合范围是同样成本、更强结论**。
// 而且他补了一句关键的:这条判据只报**内容里含仓库路径**的文件,
// 含仓库路径的 `.dpkg-old`/`~`/无后缀文件**恰恰都是真信号**(过期的旧真相)——
// 不是噪声。所以我先前"二进制会变成噪声"的担心本来就不成立(不含仓库串的不会被报)。
//
// 验收(他自己给的):扩完应当报"比了全部 N 个、命中 0 个";若命中里有文件名后缀异常者,
// 那就说明原先漏掉的正是真信号 —— 两个结果都赢。
// 实测验收:**比了 133 个文件(全部,不筛后缀)、命中 0** —— 即现在的 `0` 是**闭合的**。
//
// ★ **"零违规"这个前提必须写明**(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 if (/\.(conf|service|timer)(\.bak.*)?$/.test(e.name)) {
else {
// 不划圈:任何普通文件都读(见上面的论证)。软链在下面单独按 realpath 判。
scanned++;
// 软链另算:上面只 grep 了**内容**,跟随软链的单元必须按目标位置判。
try {
if (lstatForLinks(full).isSymbolicLink()) {
scannedLinks++;
const real = realpathForLinks(full);
if (real.startsWith(`${REPO}/`)) repoLinks.push(`${full} → ${real}`);
}
} catch { /* 悬空软链:读不到目标,交给 ② 的 walk 报 */ }
let text = '';
try { text = String(readFile(full, 'utf8')); } catch { continue; }
if (text.includes(REPO)) offenders.push(full);
@ -758,12 +786,14 @@ export function checkLayout(inject = {}) {
walk(SYS);
// 覆盖面写进 note:`0` 只有在"它能被证伪的范围"写明之后才是结论
// (这正是这条判据当初报"0 个文件"时缺的那句话)。
// 现在范围是**闭合**的(全部文件),不再只是"对我划的那个圈成立"。
const refOk = offenders.length === 0 && repoLinks.length === 0;
push(
'没有任何 unit/drop-in/.bak 引用源码目录',
offenders.length === 0,
offenders.length === 0
? `比了 ${scanned} 个 .conf/.service/.timer/.bak* 文件(覆盖面:按后缀取,见下)`
: offenders.join(' ')
'没有任何 unit/drop-in/.bak 引用源码目录(含软链指向仓库)',
refOk,
refOk
? `比了 ${scanned} 个文件(**全部**,不筛后缀)、命中 0;其中软链 ${scannedLinks} 个另按 realpath 判目标`
: [...offenders, ...repoLinks].join(' ')
);
// ② 已安装单元与仓库副本一致(仓库是唯一真相)—— **两个方向都判**
@ -892,7 +922,28 @@ export function checkLayout(inject = {}) {
else if (sa.sig !== sb.sig) {
depOk = false;
depNote = `依赖树不一致:仓库 ${sa.n} 个包 vs 已部署 ${sb.n} 个包(重新部署即可对齐)`;
} else depNote = `${sa.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 ? '有' : '无'})`;
@ -1040,6 +1091,36 @@ export function layoutSelfCheck() {
'/opt/agentmail/bin/service-failure-notify.mjs': 'x',
'/opt/agentmail/agentmail-gateway': 'fake-elf'
}));
// ★ ① 的**软链分支**:单元内容是干净的(不含仓库字面量),但软链**指向**仓库 ⇒ 必须红。
//
// 这条形状三条判据原先全都看不见(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 }],
@ -1097,6 +1178,30 @@ export function layoutSelfCheck() {
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 {
@ -1127,10 +1232,17 @@ export function layoutSelfCheck() {
return [
{ name: '标准目录:引用源码目录的样本必须判红', ok: unitRefCheck(bad)?.ok === false },
// ★ 软链分支的正反两面都要真:内容干净但指向仓库 ⇒ 红;指向仓库外 ⇒ 绿。
{ name: '★单元是软链且指向仓库 ⇒ 必须红(内容判据看不见这个形状)',
ok: unitRefCheck(badLink)?.ok === false && /→/.test(unitRefCheck(badLink)?.note ?? '') },
{ name: '★软链指向仓库外 ⇒ 不许红(否则这条判据恒红)',
ok: unitRefCheck(goodLink)?.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 ?? '') },
// ③ 从"只判在不在"改成"比内容"之后,必须证明它真能发现内容不同(否则又是一条假绿)。
// 探针:把仓库那份读成别的内容 ⇒ ③ 必须红。
{