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:
@ -728,27 +728,55 @@ export function checkLayout(inject = {}) {
|
|||||||
// 那句话只对我自己划的那个圈成立 —— **判据的边界没说出口,就等于报了个假的 0**。
|
// 那句话只对我自己划的那个圈成立 —— **判据的边界没说出口,就等于报了个假的 0**。
|
||||||
// 留着 .bak 的代价也不是零:它们是"过期的旧真相",`grep` 到它的人会以为改动没生效。
|
// 留着 .bak 的代价也不是零:它们是"过期的旧真相",`grep` 到它的人会以为改动没生效。
|
||||||
const offenders = [];
|
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 scanned = 0;
|
||||||
// ⚠️ **已知的圈**:这里按后缀取(`.conf/.service/.timer`,含 `.bak*` 变体)。
|
let scannedLinks = 0;
|
||||||
// pi 评审 2026-09-14 建议改成"不划圈"——`/etc/systemd/system` 下每个普通文件都读一遍
|
// ★ **不划圈**:每个普通文件都读一遍再 grep(pi 评审 2026-09-14,我原先按后缀取)。
|
||||||
// 再 grep,理由是**白名单永远会漏一个**(`.dpkg-old`/`.rpmsave`/`~`/`.disabled`/…)。
|
|
||||||
//
|
//
|
||||||
// 取舍(我做了、理由写在这里):**暂不改**,因为实测这一格现在是**零违规**:
|
// 我原先把这条推迟了,理由是"实测零违规 ⇒ 扩口径只增噪声"。pi 用**算术**驳回了口味问题:
|
||||||
// 该目录 133 个文件里,**没有任何一个**包含仓库路径 —— 包括现存的 4 个 `.bak`
|
// 总文件 133(递归口径 129/129?见 note 实数)/ 现行后缀口径覆盖绝大多数 ⇒ 差集极小,
|
||||||
// (`dsh-lan.service.bak-20260903-081410`、`pi-bridge.service.bak-13010-20260814`
|
// 即"读全部"的噪声成本是**几次 `readFile`**(几十 KB),而收益是那个 `0` 从
|
||||||
// 等,全干净)。`pi` 说的 `zcode.service.bak-20260912-145744` 已经不在了
|
// "**有范围的** 0"(只对我划的圈成立)变成"**闭合的** 0"(对整棵 /etc/systemd 成立)。
|
||||||
// (他自己读时也是 ENOENT),所以我引他那句时依据的文件**已消失**。
|
// 他这条论证我认:同一条"0 必须写明可证伪范围"的规矩,**闭合范围是同样成本、更强结论**。
|
||||||
// 既然零违规,扩到"全文件"只增加噪声(二进制/dpkg 数据库类文件),换不到真信号;
|
// 而且他补了一句关键的:这条判据只报**内容里含仓库路径**的文件,
|
||||||
// 因此改为**把覆盖面写进 note**(`0` 必须带上"它能被证伪的范围"),
|
// 含仓库路径的 `.dpkg-old`/`~`/无后缀文件**恰恰都是真信号**(过期的旧真相)——
|
||||||
// 并把这条决定留在注释里 —— 等真出现一个非白名单后缀的违规再改,那时我们就有实例了。
|
// 不是噪声。所以我先前"二进制会变成噪声"的担心本来就不成立(不含仓库串的不会被报)。
|
||||||
|
//
|
||||||
|
// 验收(他自己给的):扩完应当报"比了全部 N 个、命中 0 个";若命中里有文件名后缀异常者,
|
||||||
|
// 那就说明原先漏掉的正是真信号 —— 两个结果都赢。
|
||||||
|
// 实测验收:**比了 133 个文件(全部,不筛后缀)、命中 0** —— 即现在的 `0` 是**闭合的**。
|
||||||
|
//
|
||||||
|
// ★ **"零违规"这个前提必须写明**(pi 要求),也一并说明"红/WARN 拆分"为什么没做:
|
||||||
|
// 今天实测 0 违规 ⇒ 把"生效 unit 引用仓库"(红)与"遗留备份引用仓库"(WARN)拆成两档,
|
||||||
|
// 在当前是**重构、不是修 bug**,所以推迟;
|
||||||
|
// **出现第一个非白名单/备份类命中时再决定怎么分档**(那时我们才有实例,
|
||||||
|
// 而不是凭想象设计)。
|
||||||
const walk = dir => {
|
const walk = dir => {
|
||||||
let entries = [];
|
let entries = [];
|
||||||
try { entries = readdir(dir, { withFileTypes: true }); } catch { return; }
|
try { entries = readdir(dir, { withFileTypes: true }); } catch { return; }
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
const full = `${dir}/${e.name}`;
|
const full = `${dir}/${e.name}`;
|
||||||
if (e.isDirectory()) walk(full);
|
if (e.isDirectory()) walk(full);
|
||||||
else if (/\.(conf|service|timer)(\.bak.*)?$/.test(e.name)) {
|
else {
|
||||||
|
// 不划圈:任何普通文件都读(见上面的论证)。软链在下面单独按 realpath 判。
|
||||||
scanned++;
|
scanned++;
|
||||||
|
// 软链另算:上面只 grep 了**内容**,跟随软链的单元必须按目标位置判。
|
||||||
|
try {
|
||||||
|
if (lstatForLinks(full).isSymbolicLink()) {
|
||||||
|
scannedLinks++;
|
||||||
|
const real = realpathForLinks(full);
|
||||||
|
if (real.startsWith(`${REPO}/`)) repoLinks.push(`${full} → ${real}`);
|
||||||
|
}
|
||||||
|
} catch { /* 悬空软链:读不到目标,交给 ② 的 walk 报 */ }
|
||||||
let text = '';
|
let text = '';
|
||||||
try { text = String(readFile(full, 'utf8')); } catch { continue; }
|
try { text = String(readFile(full, 'utf8')); } catch { continue; }
|
||||||
if (text.includes(REPO)) offenders.push(full);
|
if (text.includes(REPO)) offenders.push(full);
|
||||||
@ -758,12 +786,14 @@ export function checkLayout(inject = {}) {
|
|||||||
walk(SYS);
|
walk(SYS);
|
||||||
// 覆盖面写进 note:`0` 只有在"它能被证伪的范围"写明之后才是结论
|
// 覆盖面写进 note:`0` 只有在"它能被证伪的范围"写明之后才是结论
|
||||||
// (这正是这条判据当初报"0 个文件"时缺的那句话)。
|
// (这正是这条判据当初报"0 个文件"时缺的那句话)。
|
||||||
|
// 现在范围是**闭合**的(全部文件),不再只是"对我划的那个圈成立"。
|
||||||
|
const refOk = offenders.length === 0 && repoLinks.length === 0;
|
||||||
push(
|
push(
|
||||||
'没有任何 unit/drop-in/.bak 引用源码目录',
|
'没有任何 unit/drop-in/.bak 引用源码目录(含软链指向仓库)',
|
||||||
offenders.length === 0,
|
refOk,
|
||||||
offenders.length === 0
|
refOk
|
||||||
? `比了 ${scanned} 个 .conf/.service/.timer/.bak* 文件(覆盖面:按后缀取,见下)`
|
? `比了 ${scanned} 个文件(**全部**,不筛后缀)、命中 0;其中软链 ${scannedLinks} 个另按 realpath 判目标`
|
||||||
: offenders.join(' ')
|
: [...offenders, ...repoLinks].join(' ')
|
||||||
);
|
);
|
||||||
|
|
||||||
// ② 已安装单元与仓库副本一致(仓库是唯一真相)—— **两个方向都判**
|
// ② 已安装单元与仓库副本一致(仓库是唯一真相)—— **两个方向都判**
|
||||||
@ -892,7 +922,28 @@ export function checkLayout(inject = {}) {
|
|||||||
else if (sa.sig !== sb.sig) {
|
else if (sa.sig !== sb.sig) {
|
||||||
depOk = false;
|
depOk = false;
|
||||||
depNote = `依赖树不一致:仓库 ${sa.n} 个包 vs 已部署 ${sb.n} 个包(重新部署即可对齐)`;
|
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) {
|
} else if (a || b) {
|
||||||
depOk = false;
|
depOk = false;
|
||||||
depNote = `一侧有依赖树、另一侧没有(仓库 ${a ? '有' : '无'} / 已部署 ${b ? '有' : '无'})`;
|
depNote = `一侧有依赖树、另一侧没有(仓库 ${a ? '有' : '无'} / 已部署 ${b ? '有' : '无'})`;
|
||||||
@ -1040,6 +1091,36 @@ export function layoutSelfCheck() {
|
|||||||
'/opt/agentmail/bin/service-failure-notify.mjs': 'x',
|
'/opt/agentmail/bin/service-failure-notify.mjs': 'x',
|
||||||
'/opt/agentmail/agentmail-gateway': 'fake-elf'
|
'/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 分支:旧备份单元里躺着仓库路径,也必须报出来(原先它被过滤掉了)。
|
// ① 的 .bak 分支:旧备份单元里躺着仓库路径,也必须报出来(原先它被过滤掉了)。
|
||||||
const badBak = checkLayout(fake({
|
const badBak = checkLayout(fake({
|
||||||
'/etc/systemd/system': [{ name: 'z.service.bak-20260101-000000', isDirectory: () => false }],
|
'/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() }])) }));
|
JSON.stringify({ packages: Object.fromEntries(packages.map(p => [p, { version: p.split('@').pop() }])) }));
|
||||||
return root;
|
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) => {
|
const depSample = (repoPkgs, livePkgs) => {
|
||||||
let a = null; let b = null;
|
let a = null; let b = null;
|
||||||
try {
|
try {
|
||||||
@ -1127,10 +1232,17 @@ export function layoutSelfCheck() {
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
{ name: '标准目录:引用源码目录的样本必须判红', ok: unitRefCheck(bad)?.ok === false },
|
{ 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: sameDeps?.ok === true && /2 个包/.test(sameDeps?.note ?? '') },
|
||||||
{ name: '★依赖树版本变了 ⇒ 必须红', ok: diffDeps?.ok === false && /不一致/.test(diffDeps?.note ?? '') },
|
{ name: '★依赖树版本变了 ⇒ 必须红', ok: diffDeps?.ok === false && /不一致/.test(diffDeps?.note ?? '') },
|
||||||
{ name: '★一侧没有依赖树 ⇒ 必须红(不许当"未比"放过)', ok: missingDeps?.ok === false },
|
{ name: '★一侧没有依赖树 ⇒ 必须红(不许当"未比"放过)', ok: missingDeps?.ok === false },
|
||||||
|
{ name: '★两侧是同一个文件 ⇒ 明说"恒等/区分力为零",不许报"一致"',
|
||||||
|
ok: depSameFile?.ok === true && /恒等/.test(depSameFile?.note ?? '') },
|
||||||
// ③ 从"只判在不在"改成"比内容"之后,必须证明它真能发现内容不同(否则又是一条假绿)。
|
// ③ 从"只判在不在"改成"比内容"之后,必须证明它真能发现内容不同(否则又是一条假绿)。
|
||||||
// 探针:把仓库那份读成别的内容 ⇒ ③ 必须红。
|
// 探针:把仓库那份读成别的内容 ⇒ ③ 必须红。
|
||||||
{
|
{
|
||||||
|
|||||||
@ -9,6 +9,11 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
|
# 环境自足:一处给全(HOME/TMPDIR/GOMODCACHE/PATH),不在每条命令旁各写预检。
|
||||||
|
# 理由与四次历史见该文件的头注释 —— pi 的原话是"否则第五次一定会来"。
|
||||||
|
# shellcheck source=./lib/env-defaults.sh
|
||||||
|
. "$REPO/deploy/lib/env-defaults.sh"
|
||||||
# 与 `redeploy-gateway.sh` / `reset-demo.sh` 同源(pi 评审 2026-09-14 对出来的):
|
# 与 `redeploy-gateway.sh` / `reset-demo.sh` 同源(pi 评审 2026-09-14 对出来的):
|
||||||
# 那两处都写 `${AGENTMAIL_PREFIX:-/opt/agentmail}`,只有这里写死 ——
|
# 那两处都写 `${AGENTMAIL_PREFIX:-/opt/agentmail}`,只有这里写死 ——
|
||||||
# 谁设了那个变量,install 装到 A、redeploy 和 drift-check 看 B,三个工具各说各话。
|
# 谁设了那个变量,install 装到 A、redeploy 和 drift-check 看 B,三个工具各说各话。
|
||||||
@ -188,18 +193,8 @@ if [[ $CHECK_ONLY -eq 1 ]]; then
|
|||||||
# 而"第一个拿到 root 的人第一次跑门禁"正是 `--check` 要解决的场景。
|
# 而"第一个拿到 root 的人第一次跑门禁"正是 `--check` 要解决的场景。
|
||||||
# 它们不写系统目录(只写 go 缓存),所以放进这个相位没有副作用。
|
# 它们不写系统目录(只写 go 缓存),所以放进这个相位没有副作用。
|
||||||
echo "==> 校验 Gateway 源码(go vet + go test)"
|
echo "==> 校验 Gateway 源码(go vet + go test)"
|
||||||
# ★ 先判"go 到底跑不跑得动",把**环境问题**与"代码不过"分开。
|
# 环境不再在这里逐个预检 —— 开头 source 的 env-defaults.sh 已经保证
|
||||||
# 实测(2026-09-14,我在沙箱里跑这一步时):没有 HOME/GOPATH 的环境下
|
# HOME/TMPDIR/GOMODCACHE/PATH 齐备(那道预检是"第四次"的补丁,已被一处取代)。
|
||||||
# `go vet` 直接报 `go: module cache not found: neither GOMODCACHE nor GOPATH is set`,
|
|
||||||
# 于是这句话会说成"go vet / go test 不过 —— 先修好再安装" —— **把环境问题报成代码问题**,
|
|
||||||
# 而那正好诱导人去 sudo 建目录(GOPATH 落到 `${GOPATH:-$HOME/go}/pkg/mod`)。
|
|
||||||
# `go env GOMODCACHE` 在没有这两者时返回**空串**,一个既便宜又精确的信号。
|
|
||||||
GOMODCACHE_DIR="$(go env GOMODCACHE 2>/dev/null || true)"
|
|
||||||
if [ -z "$GOMODCACHE_DIR" ]; then
|
|
||||||
echo " [FAIL] 环境不足:\`go env GOMODCACHE\` 是空的(既没有 GOMODCACHE 也没有 GOPATH,通常是 HOME 没设)" >&2
|
|
||||||
echo " 这是**环境问题**,不是代码问题。药方:带上有 HOME 的环境重跑(如 sudo 或 HOME=/root)" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
if ! ( cd "$REPO/server" && go vet ./... && go test ./... ); then
|
if ! ( cd "$REPO/server" && go vet ./... && go test ./... ); then
|
||||||
echo " [FAIL] go vet / go test 不过 —— 先修好再安装" >&2
|
echo " [FAIL] go vet / go test 不过 —— 先修好再安装" >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
66
deploy/lib/env-defaults.sh
Normal file
66
deploy/lib/env-defaults.sh
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# 部署脚本的**环境自足**声明:一处给全,不再每条命令旁各写一个预检。
|
||||||
|
#
|
||||||
|
# # 为什么要有这个文件
|
||||||
|
#
|
||||||
|
# 2026-09-14 这条链上一共修了**四次**同一形状的错,每次形态一模一样:
|
||||||
|
# **部署路径上假设了一个环境变量存在**。四次都是"再加一个预检",
|
||||||
|
# 而预检只挡**已知的那一个** —— 挡住 `HOME` 之后 `GOMODCACHE` 才露出来
|
||||||
|
# (两者其实是同一个根因的两面:`HOME` 决定 go 默认缓存位置)。
|
||||||
|
# pi 的原话:**"否则第五次一定会来。"**
|
||||||
|
#
|
||||||
|
# 四次记录(每次都是实测,不是推测):
|
||||||
|
# 1. `HOME` 未设 ⇒ `go` 拒绝工作(`homeagent` 的 build.sh,当次修了一处)
|
||||||
|
# 2. `HOME` 未设 ⇒ 同一族再现(`install.sh`,当次又修一处)
|
||||||
|
# 3. `TMPDIR` 未设/不可写 ⇒ 构建与测试把临时文件写到意外位置
|
||||||
|
# (`redeploy-plugin.sh:160` 曾写成 `TMPDIR=${TMPDIR:-/tmp} npx tsc …`,即就地兜了一次)
|
||||||
|
# 4. `GOMODCACHE`/`GOPATH` 都未设(`HOME` 也没设)⇒
|
||||||
|
# `go: module cache not found: neither GOMODCACHE nor GOPATH is set`
|
||||||
|
#
|
||||||
|
# # 纪律
|
||||||
|
#
|
||||||
|
# · 需要环境的脚本**开头** `source` 本文件,别在命令行里内联兜底;
|
||||||
|
# · 本文件只设**未设**的变量(`:-` 语义),绝不覆盖调用者已经给的值;
|
||||||
|
# · 失败要**说清是环境问题**并按仓库约定用退出码 2(环境),不要让它冒充代码缺陷。
|
||||||
|
#
|
||||||
|
# 注意:本文件被 `source`,**不能带 `set -e`**(那会改调用者的语义)。
|
||||||
|
# 调用者自己已经有 `set -uo pipefail`(`install.sh` 另有 `-e`),这里不重复声明。
|
||||||
|
|
||||||
|
# shellcheck shell=bash
|
||||||
|
|
||||||
|
# ① HOME:`go`/`npm`/`npx` 都会用它推默认缓存与配置位置。
|
||||||
|
# 以 root 跑部署时它通常就是 /root;显式给出以免"沙箱/最小环境下 HOME 为空"。
|
||||||
|
if [ -z "${HOME:-}" ]; then
|
||||||
|
HOME=/root
|
||||||
|
export HOME
|
||||||
|
AGENTMAIL_ENV_DEFAULTS="${AGENTMAIL_ENV_DEFAULTS:-} HOME=$HOME"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ② TMPDIR:构建暂存与测试都往这里写。缺省 /tmp 可能与真实挂载不符,
|
||||||
|
# 但"空的 TMPDIR"比"指错一个已存在的目录"更糟 —— 后者会让判据看错地方。
|
||||||
|
if [ -z "${TMPDIR:-}" ]; then
|
||||||
|
TMPDIR=/tmp
|
||||||
|
export TMPDIR
|
||||||
|
AGENTMAIL_ENV_DEFAULTS="${AGENTMAIL_ENV_DEFAULTS:-} TMPDIR=$TMPDIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ③ GOMODCACHE / GOPATH:go 的模块缓存。**只在 go 存在时**推导,
|
||||||
|
# 且只在两者都空的时候推导(有 GOPATH 时 go 自己会算 GOMODCACHE)。
|
||||||
|
if command -v go >/dev/null 2>&1; then
|
||||||
|
if [ -z "${GOMODCACHE:-}" ] && [ -z "${GOPATH:-}" ]; then
|
||||||
|
GOPATH="$HOME/go"
|
||||||
|
export GOPATH
|
||||||
|
AGENTMAIL_ENV_DEFAULTS="${AGENTMAIL_ENV_DEFAULTS:-} GOPATH=$GOPATH"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ④ PATH:只保证常见系统目录在(最小环境里 PATH 可能为空)。
|
||||||
|
case ":${PATH:-}:" in
|
||||||
|
*:/usr/bin:*) : ;;
|
||||||
|
*) PATH="${PATH:+$PATH:}/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; export PATH
|
||||||
|
AGENTMAIL_ENV_DEFAULTS="${AGENTMAIL_ENV_DEFAULTS:-} PATH=+系统目录" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# 给调用者一句可打印的话:本次**兜了哪几个**变量(空则说明环境本来就齐)。
|
||||||
|
export AGENTMAIL_ENV_DEFAULTS="${AGENTMAIL_ENV_DEFAULTS:-}"
|
||||||
@ -28,6 +28,10 @@ set -uo pipefail
|
|||||||
|
|
||||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
PREFIX="${AGENTMAIL_PREFIX:-/opt/agentmail}"
|
PREFIX="${AGENTMAIL_PREFIX:-/opt/agentmail}"
|
||||||
|
|
||||||
|
# 环境自足:一处给全(理由与四次历史见该文件头注释)
|
||||||
|
# shellcheck source=./lib/env-defaults.sh
|
||||||
|
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/env-defaults.sh"
|
||||||
TARGET="$PREFIX/agentmail-gateway"
|
TARGET="$PREFIX/agentmail-gateway"
|
||||||
DB="$PREFIX/data/agentmail.db"
|
DB="$PREFIX/data/agentmail.db"
|
||||||
SERVICE="agentmail-gateway"
|
SERVICE="agentmail-gateway"
|
||||||
|
|||||||
@ -45,6 +45,11 @@
|
|||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
REPO=${REPO:-/home/program/agentmail}
|
REPO=${REPO:-/home/program/agentmail}
|
||||||
|
|
||||||
|
# 环境自足:一处给全(HOME/TMPDIR/GOMODCACHE/PATH)。原先这里只有一行内联兜底
|
||||||
|
# (`TMPDIR=${TMPDIR:-/tmp} npx tsc …`)—— 那正是"第四次"那种分散补丁的形状。
|
||||||
|
# shellcheck source=./lib/env-defaults.sh
|
||||||
|
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/env-defaults.sh"
|
||||||
GATEWAY_DB=${GATEWAY_DB:-/opt/agentmail/data/agentmail.db}
|
GATEWAY_DB=${GATEWAY_DB:-/opt/agentmail/data/agentmail.db}
|
||||||
DEST_ROOT=${DEST_ROOT:-/opt/agentmail/plugins}
|
DEST_ROOT=${DEST_ROOT:-/opt/agentmail/plugins}
|
||||||
STAGE_ONLY=0
|
STAGE_ONLY=0
|
||||||
|
|||||||
@ -201,7 +201,18 @@ bash deploy/prune-deploy-artifacts.sh --self-check # 判据自检(16 项,
|
|||||||
`set -e` ⇒ **脚本当场中止**:只报第一个分叉文件,后续对象与收尾汇总都不打印。
|
`set -e` ⇒ **脚本当场中止**:只报第一个分叉文件,后续对象与收尾汇总都不打印。
|
||||||
退出码**恰好还是 1**(判定是对的),所以光量退出码看不见它 ——
|
退出码**恰好还是 1**(判定是对的),所以光量退出码看不见它 ——
|
||||||
这正是"判定对、证据被截断"。修法 `|| true`,并在注释里写明它不是风格而是判据。
|
这正是"判定对、证据被截断"。修法 `|| true`,并在注释里写明它不是风格而是判据。
|
||||||
13. **注释里的数字无法被判据守住。** 同一文件里曾同时写"写点五处"和"共 6 处"
|
13. **锚点必须与结论一一对应 —— 连"文件名"都会骗你。** 实例(2026-09-14):
|
||||||
|
我写了个探针判"通知脚本内容与仓库不一致 ⇒ 必须红",探针按**文件名**取文件
|
||||||
|
(`p.endsWith('service-failure-notify.mjs')`),可两个路径的 basename **相同**
|
||||||
|
(`deploy/service-failure-notify.mjs` vs `/opt/agentmail/bin/service-failure-notify.mjs`)
|
||||||
|
⇒ 两侧读到同一个串、探针**自己没分辨力**,样本"通过"得毫无意义。
|
||||||
|
与 `grep -c 用例名` 那个假数、与"名字出现在 `# Subtest:` 头"是同一族但不同形:
|
||||||
|
**锚在"名字"而没有锚在"哪一侧"**。
|
||||||
|
14. **退出码也有量纲。** `node deploy/check-deploy-drift.mjs --self-check` 的退出码
|
||||||
|
**不是**"自检的结论":自检本体 28/28 全过,但同一个进程接着跑了宿主判据、
|
||||||
|
于是整体 exit 1。报"自检失败"就是把两个量纲混成一个。
|
||||||
|
⇒ 报结论时**分开说**:"自检本体 N/M 通过;整体退出码还包含 X"。
|
||||||
|
15. **注释里的数字无法被判据守住。** 同一文件里曾同时写"写点五处"和"共 6 处"
|
||||||
(且它的式子 2+2+3 加起来是 7,实际 10 处)——**三处说法三个数**。
|
(且它的式子 2+2+3 加起来是 7,实际 10 处)——**三处说法三个数**。
|
||||||
与"不要手抄期望用例数常量"同源:**两组矛盾的数字比没有数字更糟**,
|
与"不要手抄期望用例数常量"同源:**两组矛盾的数字比没有数字更糟**,
|
||||||
因为它让读者以为有人数过。要判覆盖完整只能靠**机制**(整段 try/catch),不靠数数。
|
因为它让读者以为有人数过。要判覆盖完整只能靠**机制**(整段 try/catch),不靠数数。
|
||||||
|
|||||||
Reference in New Issue
Block a user