From 7eec311756c6b886cd09fb9dbd372caba16141d9 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 14 Sep 2026 20:42:54 +0800 Subject: [PATCH] =?UTF-8?q?fix(deploy):=20C=20=E6=89=A9=E5=88=B0=E5=85=A8?= =?UTF-8?q?=E6=96=87=E4=BB=B6=EF=BC=88133/0=20=E9=97=AD=E5=90=88=EF=BC=89+?= =?UTF-8?q?=20=E2=91=A0=20=E5=8A=A0=20realpath=20=E5=88=A4=E6=8D=AE=20+=20?= =?UTF-8?q?=E2=91=A1b=20=E6=98=8E=E8=AF=B4"=E6=81=92=E7=AD=89"=20+=20?= =?UTF-8?q?=E7=8E=AF=E5=A2=83=E8=87=AA=E8=B6=B3=E6=94=B6=E6=88=90=E4=B8=80?= =?UTF-8?q?=E5=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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。 --- deploy/check-deploy-drift.mjs | 148 +++++++++++++++++++++++++++++----- deploy/install.sh | 19 ++--- deploy/lib/env-defaults.sh | 66 +++++++++++++++ deploy/redeploy-gateway.sh | 4 + deploy/redeploy-plugin.sh | 5 ++ docs/DEV-TOOLING.md | 13 ++- 6 files changed, 224 insertions(+), 31 deletions(-) create mode 100644 deploy/lib/env-defaults.sh diff --git a/deploy/check-deploy-drift.mjs b/deploy/check-deploy-drift.mjs index f9ec110..56cc81e 100644 --- a/deploy/check-deploy-drift.mjs +++ b/deploy/check-deploy-drift.mjs @@ -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 ?? '') }, // ③ 从"只判在不在"改成"比内容"之后,必须证明它真能发现内容不同(否则又是一条假绿)。 // 探针:把仓库那份读成别的内容 ⇒ ③ 必须红。 { diff --git a/deploy/install.sh b/deploy/install.sh index 46223e3..443e8e9 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -9,6 +9,11 @@ set -euo pipefail 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 对出来的): # 那两处都写 `${AGENTMAIL_PREFIX:-/opt/agentmail}`,只有这里写死 —— # 谁设了那个变量,install 装到 A、redeploy 和 drift-check 看 B,三个工具各说各话。 @@ -188,18 +193,8 @@ if [[ $CHECK_ONLY -eq 1 ]]; then # 而"第一个拿到 root 的人第一次跑门禁"正是 `--check` 要解决的场景。 # 它们不写系统目录(只写 go 缓存),所以放进这个相位没有副作用。 echo "==> 校验 Gateway 源码(go vet + go test)" - # ★ 先判"go 到底跑不跑得动",把**环境问题**与"代码不过"分开。 - # 实测(2026-09-14,我在沙箱里跑这一步时):没有 HOME/GOPATH 的环境下 - # `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 + # 环境不再在这里逐个预检 —— 开头 source 的 env-defaults.sh 已经保证 + # HOME/TMPDIR/GOMODCACHE/PATH 齐备(那道预检是"第四次"的补丁,已被一处取代)。 if ! ( cd "$REPO/server" && go vet ./... && go test ./... ); then echo " [FAIL] go vet / go test 不过 —— 先修好再安装" >&2 exit 1 diff --git a/deploy/lib/env-defaults.sh b/deploy/lib/env-defaults.sh new file mode 100644 index 0000000..72ac1b3 --- /dev/null +++ b/deploy/lib/env-defaults.sh @@ -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:-}" diff --git a/deploy/redeploy-gateway.sh b/deploy/redeploy-gateway.sh index fbba8c1..d84faac 100755 --- a/deploy/redeploy-gateway.sh +++ b/deploy/redeploy-gateway.sh @@ -28,6 +28,10 @@ set -uo pipefail REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" 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" DB="$PREFIX/data/agentmail.db" SERVICE="agentmail-gateway" diff --git a/deploy/redeploy-plugin.sh b/deploy/redeploy-plugin.sh index b032599..55de760 100755 --- a/deploy/redeploy-plugin.sh +++ b/deploy/redeploy-plugin.sh @@ -45,6 +45,11 @@ set -uo pipefail 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} DEST_ROOT=${DEST_ROOT:-/opt/agentmail/plugins} STAGE_ONLY=0 diff --git a/docs/DEV-TOOLING.md b/docs/DEV-TOOLING.md index 55f4cc5..ef52c73 100644 --- a/docs/DEV-TOOLING.md +++ b/docs/DEV-TOOLING.md @@ -201,7 +201,18 @@ bash deploy/prune-deploy-artifacts.sh --self-check # 判据自检(16 项, `set -e` ⇒ **脚本当场中止**:只报第一个分叉文件,后续对象与收尾汇总都不打印。 退出码**恰好还是 1**(判定是对的),所以光量退出码看不见它 —— 这正是"判定对、证据被截断"。修法 `|| 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 处)——**三处说法三个数**。 与"不要手抄期望用例数常量"同源:**两组矛盾的数字比没有数字更糟**, 因为它让读者以为有人数过。要判覆盖完整只能靠**机制**(整段 try/catch),不靠数数。