fix(相位/安全): 部署门禁只判产物自证;静默 break 改成出声;内核读数带时间坐标
pi 2026-09-14 的裁定与两条更正,逐条落地。 1. **相位裁定(选 c)**:`packaging`/`build-stamp` 属于**构建相位**,不属于安装相位。 `run-all.mjs` 现在有相位:`AGENTMAIL_CRITERIA_PHASE=install`(部署门禁用)。 每条判据登记它读的哪一侧(`ARTIFACT`/`SOURCE`),install 相位里出现 SOURCE 侧判据 → 红; 被跳过的判据**点名打印**,不静默丢。汇总打 `RESULT phase=build|install`。 规则入册 `test/CRITERIA.md` §11(含三个真实实例:check-shared-libs 恒红、 packaging 一改前端就卡死、HOME 在门禁跑完之后才炸)。 安装相位**真正能判的那一半**:`deploy/install.sh` 读**产物自证**(不重算 dist)—— `releaseCandidate !== true` → 拒绝;产物 `gitRev` ≠ HEAD → "这个包比源码旧" → 拒绝; 放行要显式 `--allow-dirty` / `--allow-stale`;`--check` 干跑只报结论不拦。 实测干跑输出:`产物:gitRev=6702cc2 树=dirty releaseCandidate=false | 当前 HEAD=6702cc2` → 报"不是发布候选 + 正式安装会被拒绝 + 要放行请显式说清"。 2. **别解析运行器文本**(pi §5):`broken`/`red` 的判定改成按 TAP 的**名字**—— 文件级失败的测试名就是路径,断言失败的名字是判据名。变异双向验证: 未定义标识符 → 「跑不起来的判据」;把某条判据条件改成假 → 「红的判据」。 不再往关键字表里加补丁(那是往文本解析里加补丁,方向是错的)。 3. **静默 break 是安全相关**(pi §3):`session_update` 找不到活动会话时不再静默 break, 改成出声日志(走 journalctl 那条通道),写清两种成因(此刻没在跑 / **接管会话**重启后无法定位)、 方向(收紧被延迟)、以及兜底的**前提**("下次投递"要求这条会话还会收到新邮件)。 `lib/mail-session-id.js` 模块头同步改成安全相关措辞("人以为自己收紧了权限、实际没有"), 四桥逐字节同源,`check-shared-libs.sh` 退出码 0。 4. 内核读数补时间坐标(pi 13ea2fdf):`BUILD_INFO.txt` 里除原始 `dep`/`=>` 行外, 现在还有 `kernelBinMtime` 与**正在运行的进程启动时间** —— 二进制会在两次读数之间被换掉, 没有时间坐标的读数不成立。
This commit is contained in:
@ -355,3 +355,23 @@ pi 2026-09-14 提的形状:**不是无条件 fail-closed** —— 缺字段可
|
||||
| 邮件的 `permission_mode` | **不写、不改档**(不是写默认档) | 窄(fail-closed) | 本仓 2026-09-14:缺字段被 `\|\| 'workspace'` 兜成窄档,等于"一个 bug 以正常形态活着";守卫见 `plugins/*/lib/permission-mode.js` 的 `modeForStateWrite` |
|
||||
| HomeAgent `plugin.json` 的 `sdk` | **内核不读**(字段只对人有效) | 无(不是语义,是文档) | 内核 `internal/plugin/manifest.go` 的结构体里没有该字段;`registry.go` 的 `loadOne` 只用 `NameZh/NameEn` |
|
||||
| HomeAgent `plugin.json` 的 `capabilities` | **不受限** | 宽(fail-open) | 内核注释明写理由:17 个存量清单都没有它,空声明当最小权限会让它们**静默降级** |
|
||||
|
||||
## 11. 相位:门不许挂在它判不了的相位上
|
||||
|
||||
**规则(可机检)**:**部署门禁只允许读"待安装的产物 + 目标机状态",不许依赖"源码树是最新构建的"这个前提。**
|
||||
一条门如果在某个相位**恒红或恒绿**,那不是门的问题,是**相位挂错了**。
|
||||
|
||||
两个相位问的是不同的问题:
|
||||
|
||||
| 相位 | 问的问题 | 判据 | 判法 |
|
||||
| --- | --- | --- | --- |
|
||||
| 构建 / 发布(`npm test`、`--release`) | 我要产出的东西,是不是从**当前源码**新鲜构建的? | `packaging`、`build-stamp`、`releaseCandidate` | **重新计算**(比对 dist/产物与源码树) |
|
||||
| 安装(`deploy/install.sh`) | **别人已经产出的**这个东西,能不能装到这台机器上? | 产物自证(`gitRev`/`releaseCandidate`) | **读它自己说的**(不重算) |
|
||||
|
||||
实现:`run-all.mjs` 用 `AGENTMAIL_CRITERIA_PHASE=install` 切相位;每条判据在 `SIDES` 里登记它读的
|
||||
哪一侧,`install` 相位里出现 `SOURCE` 侧的判据**直接红**;被跳过的判据会在汇总里**点名打印**
|
||||
(不静默丢)。
|
||||
|
||||
三个真实实例(同一天撞到,都不是巧合):`check-shared-libs`(从 `153985e` 起在部署路径恒红)、
|
||||
`packaging`(前端一改就卡死 —— 部署路径不重新打包)、`HOME: unbound variable`
|
||||
(在**所有门禁跑完之后**才炸:最贵的位置)。
|
||||
|
||||
@ -161,8 +161,43 @@ if (shapeless.length) {
|
||||
}
|
||||
|
||||
const reds = [];
|
||||
/*
|
||||
* ─── 相位(pi 2026-09-14 裁定)───
|
||||
*
|
||||
* 同一份套件会被**两个不同相位**调用:
|
||||
* · 构建/发布相位(`npm test`、`--release`):问"我要产出的东西是不是从**当前源码**新鲜构建的?"
|
||||
* → `packaging`(包内 dist == 当前 dist)、`build-stamp`(产物 == HEAD)在这一相位才有意义;
|
||||
* · 安装相位(`deploy/install.sh`,见 `AGENTMAIL_CRITERIA_PHASE=install`):
|
||||
* 问"**别人已经产出的这个东西**能不能装到这台机器上?" —— 这里"dist 与源码树的关系"
|
||||
* 根本不是该问的问题:部署路径不重新打包(也**不应该**重新打包,否则"review 过的产物"
|
||||
* 和"装上去的产物"不是同一个东西,而且会重新构建的干跑不是干跑)。
|
||||
*
|
||||
* 所以规则(可机检):**部署门禁只允许读"待安装的产物 + 目标机状态",不许依赖
|
||||
* "源码树是最新构建的"这个前提。** 一条门如果在某个相位恒红或恒绿,那不是门的问题,
|
||||
* 是**相位挂错了** —— 今天撞到三条:`check-shared-libs`(从 153985e 起恒红)、
|
||||
* `packaging`(前端一改就卡死)、`HOME: unbound variable`(在门禁跑完之后才炸)。
|
||||
*
|
||||
* 下面登记每条判据读的是哪一侧;`install` 相位里出现 `SOURCE` 侧判据 → 直接红。
|
||||
*/
|
||||
const SIDE = {
|
||||
ARTIFACT: 'artifact', // 只读产物 / 目标机状态(装得上装不上)
|
||||
SOURCE: 'source' // 依赖"源码树是最新构建的"(构建相位才成立)
|
||||
};
|
||||
/** 判据文件 → 它读的那一侧。没登记的按 ARTIFACT(读源码的必须显式登记,否则会被这条例外掉) */
|
||||
const SIDES = {
|
||||
'test/build-stamp.test.mjs': SIDE.SOURCE, // 产物 gitRev/srcHash == 当前源码树
|
||||
'test/packaging.test.mjs': SIDE.SOURCE // 包内 dist == 当前 dist
|
||||
};
|
||||
const PHASE = process.env.AGENTMAIL_CRITERIA_PHASE === 'install' ? 'install' : 'build';
|
||||
function sideOf(file) { return SIDES[file] || SIDE.ARTIFACT; }
|
||||
|
||||
const brokens = [];
|
||||
const skippedByPhase = [];
|
||||
for (const [file, flags, expected] of SUITE) {
|
||||
if (PHASE === 'install' && sideOf(file) === SIDE.SOURCE) {
|
||||
skippedByPhase.push(file);
|
||||
continue;
|
||||
}
|
||||
console.log(`\n========== ${file} ==========`);
|
||||
const shape = shapeOf(file);
|
||||
const all = shape.usesNodeTest ? [...flags, '--test'] : flags;
|
||||
@ -181,12 +216,24 @@ for (const [file, flags, expected] of SUITE) {
|
||||
* 变体验证里出现 broken = **这次变异无效,重做**,不许记成"红过了"。
|
||||
*/
|
||||
/*
|
||||
* 判据是"输出里有**语言级崩**的痕迹",不是"有没有 `not ok`"。
|
||||
* 第一版我用后者,当场误判:node:test 会把**导入期**的 ReferenceError 也报成
|
||||
* `not ok 1 - …`,于是"崩了"看起来和"断言失败"一模一样 —— 正是这条判据要治的病。
|
||||
* **别解析人类可读的文本**(pi 2026-09-14 §5):文本格式是运行器的实现细节。
|
||||
* 第一版我用"输出里有没有 `not ok`"判崩,当场误判 —— node:test 把**导入期**的
|
||||
* `ReferenceError` 也报成 `not ok 1 - …`,于是"崩了"和"断言失败"在文本上同形。
|
||||
*
|
||||
* 现在按 TAP 的**名字**判,而不是按错误正文猜:文件级失败的测试名就是**文件路径**,
|
||||
* 而真正的断言失败名字是判据名。所以:
|
||||
* 所有 `not ok` 的名字都像路径(含 .mjs / 以 / 开头)→ **broken(判据没跑起来)**;
|
||||
* 只要有一个名字像判据名 → **red(判据说不成立)**。
|
||||
* 再叠一层语言级崩痕只是兜底(没有 `not ok` 却退出非零的情形)。
|
||||
*
|
||||
* 我不再往里加"更多错误关键字"——那是往文本解析里加补丁,方向是错的。
|
||||
*/
|
||||
const notOkNames = [...out.matchAll(/^not ok \d+ - (.*)$/gm)].map(m => m[1].trim());
|
||||
const looksLikePath = n => /(^\/|\.mjs\b|\.ts\b|\.js\b)/.test(n);
|
||||
const assertionFailed = notOkNames.some(n => !looksLikePath(n));
|
||||
const onlyFileLevel = notOkNames.length > 0 && !assertionFailed;
|
||||
const CRASH_SIGNS = /(SyntaxError|ReferenceError|TypeError|Cannot find module|ERR_MODULE_NOT_FOUND|is not defined|is not a function|CompileError|build failed|Unexpected identifier|missing ',' in argument list)/;
|
||||
const crashed = r.status !== 0 && CRASH_SIGNS.test(out);
|
||||
const crashed = r.status !== 0 && (onlyFileLevel || (notOkNames.length === 0 && CRASH_SIGNS.test(out)));
|
||||
const empty = out.trim().length === 0;
|
||||
// 不 break:后面每条都要跑出来,否则"红了几条"这个信息本身是假的
|
||||
if (crashed || empty) {
|
||||
@ -451,7 +498,12 @@ if (dueStatic.length > 0) {
|
||||
* probe=ok —— 探针自己是好的(unknown 说明闸门可能锈死了,得人看一眼)。
|
||||
*/
|
||||
const probeSummary = unknownProbes.length ? 'unknown' : 'ok';
|
||||
console.log(`RESULT static=${STATIC_ONLY.length} probe=${probeSummary}` +
|
||||
if (skippedByPhase.length) {
|
||||
console.log(`相位=${PHASE}:本相位不判 ${skippedByPhase.length} 条**构建相位**判据(${skippedByPhase.join('、')})` +
|
||||
`—— 它们问的是"产物是不是从当前源码来的",安装相位问的是"已产出的产物能不能装";` +
|
||||
`安装相位判它只能读**产物自证**(见 deploy/install.sh 的前置检查)。`);
|
||||
}
|
||||
console.log(`RESULT phase=${PHASE} static=${STATIC_ONLY.length} probe=${probeSummary}` +
|
||||
(unknownProbes.length ? `(探针跑不了:${unknownProbes.join('、')} —— 已按到期处理)`
|
||||
: '(只能验形态的判据:到期前提成立就自动变红)'));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user