feat(pipeline): 交付流水线规范落地 —— 门禁 + LKG 回滚锚点

将《代码审计 → 上线 完整交付方案》v1.0 适配到 nodeagent,产出
docs/DELIVERY-PIPELINE.md,并落地其中可执行的部分。

## 为什么要适配而不是照搬
原方案面向 Web/容器服务(有服务端、有镜像、有多环境)。nodeagent 是
跑在用户机器上的跨机接管框架,无服务端、无容器、无多环境。照搬会产生
「跑得很好但守不住任何东西」的假门禁。故每个阶段给三档判定:
适用 / 改造后适用 / 不适用(但必须给替代,不允许只写 N/A)。

关键替换:
- 容器镜像扫描 → 交付物 PE 架构断言(arm64 包误装 x64 node.exe 的真实事故)
- DAST → 协议健壮性测试(本项目对外输入面是 WebSocket RPC,非 HTTP)
- dev/staging/prod → 真机矩阵(Win x64/arm64、macOS、Linux)
- 金丝雀 → 设备分批自更新(先一台试点,latest.json 灰度)
- Prometheus → system.metrics(闭环率/P95/拦截率)+ 发布后复核清单

## 本次落地
1. 覆盖率门禁 scripts/ci/check-coverage.mjs
   - 全量**只观测不阻断**(实测基线 66.9%,照搬 80% 全量不现实)
   - 变更文件行覆盖率 ≥70% 阻断;单测 import 的是 dist/,需 src→dist 映射
   - 无覆盖数据的变更文件不阻断,单列供人工判断(算 0% 会让重构无法合入)
2. 提交信息校验 scripts/ci/check-commit-msg.mjs
   - 项目实际已在用 Conventional Commits,但无强制 → 变成会红的门禁
   - 不引 commitlint+husky:本项目只需类型白名单,几十行即可且 CI 本地都能跑
3. .github/workflows/quality-gate.yml:gitleaks + pnpm audit + typecheck + 覆盖率 + 提交规范
4. CI artifact retention-days 30 → 7(公开可下载,留存越久暴露面越大)
5. LKG(最后已知可用版本)apps/agent/src/lkg.ts
   - 只在**启动成功后**才写,不在更新写完时写(否则等于把未验证版本当已知可用)
   - 暴露为 system.info.lkg,matches=false 表示当前版本尚未被证明能启动
   - 控制端据此暂缓向其余设备放量

## 刻意不做的事:自动回滚
远程设备上无法区分「更新坏了」与「网络抖动」;自动回滚可能形成回滚循环;
被控端自作主张改自己会让控制端失去掌控。故 LKG 只提供确定的回滚目标,
决策权留给控制端。已在文档中写明取舍理由。

## 实测证据
- 覆盖率门禁三条故障注入均 exit=1(阈值100 / 全量99 / lcov缺失),正常 exit=0
- 提交规范反例 3 类均红,Merge 与无 scope 正确豁免,历史 20 条提交一次通过
- LKG 新增 8 条单测(含篡改 hash 必须检出 matches=false)
- 既有契约测试在新增 lkg 字段后立刻报红(SystemInfo 缺声明),补接口后转绿
  —— 这本身是门禁有效的实证
- 全量单测 341 → 349 全绿
This commit is contained in:
jkooe committed 2026-10-17 03:13:18 +08:00
1 parent 590f62e968
commit 2bf45fdf98
11 files changed
+1123 -1

No files matched your search

+2 -1
View File
@@ -316,4 +316,5 @@ jobs:
apps/console/src-tauri/target/release/bundle/nsis/*.exe
# 一条架构没产出就报错,避免「jobs 全绿但产物不全」
if-no-files-found: error
retention-days: 30
# 交付流水线 §4:CI artifact 公开可下载,留存越久暴露面越大,30 → 7
retention-days: 7
+108
View File
@@ -0,0 +1,108 @@
# 交付流水线左移门禁(见 docs/DELIVERY-PIPELINE.md §1 §2 §8)
#
# 本文件只做「快且能阻断」的检查:密钥、依赖漏洞、类型、覆盖率、提交规范。
# 真机链路(install.cmd / control.cmd 双击、Windows 能力)留在 windows-e2e.yml
# —— 那一环跑得慢但价值最高,不可被这条更快的流水线替代。
#
# ⚠️ 阈值说明(别照搬通用 80%):详见 docs/DELIVERY-PIPELINE.md §2。
# 全量覆盖率当前基线约 66.9%,故全量只观测;阻断只针对**变更文件**(≥70%)。
name: Quality Gate
on:
pull_request:
branches: [main]
push:
branches: [main]
permissions:
contents: read
jobs:
# ① 代码审计:密钥 + 依赖漏洞
audit:
name: 代码审计(密钥 + 依赖)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
# gitleaks 扫历史提交,需要完整历史
fetch-depth: 0
# 密钥扫描:nodeagent 的密钥面比一般项目大 ——
# PSK、Ed25519 私钥、TLS 自签证书、PSK.txt、agent.json
- name: 密钥与敏感信息扫描
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: pnpm install --frozen-lockfile
# 依赖漏洞:High 及以上阻断
- name: 依赖漏洞扫描(SCA)
run: pnpm audit --audit-level=high
# ② 质量门禁:类型 + 覆盖率
gate:
name: 质量门禁(类型 + 覆盖率)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
# 覆盖率门禁要算「变更文件」,需与基线做 diff
fetch-depth: 0
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
# 单测 import 的是 dist/(编译产物),必须先构建,否则覆盖率全空
- run: pnpm -r build
- name: 采集覆盖率
run: |
mkdir -p coverage
node --test --experimental-test-coverage \
--test-reporter=lcov \
--test-reporter-destination=coverage/lcov.info \
"tests/unit/**/*.test.mjs"
- name: 覆盖率门禁
run: node scripts/ci/check-coverage.mjs --lcov coverage/lcov.info --base origin/main --threshold 70
# coverage/ 已在 .gitignore 中,此处留档便于人工查看
- uses: actions/upload-artifact@v4
if: always()
with:
name: coverage
path: coverage/
retention-days: 7
# ③ 提交信息规范
commits:
name: 提交信息规范
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
- name: 校验 Conventional Commits
run: node scripts/ci/check-commit-msg.mjs --base ${{ github.event.pull_request.base.sha }}
+5
View File
@@ -10,6 +10,7 @@ import { loadAgentConfig } from '../config.js';
import { readAudit, readAuditAll, verifyAudit, computeAuditHead, anchorAudit, compareWithAnchors } from '../audit.js';
import { computeMetrics } from '../metrics.js';
import { allPsShellStats } from '../util/ps-helper.js';
import { lkgStatus } from '../lkg.js';
import { startTask } from './task.js';
import { psLit } from '../util/quote.js';
@@ -151,6 +152,10 @@ export async function systemInfo(args: Args): Promise<unknown> {
network: {
allow_from: configAllowFrom(),
},
// 交付流水线 §6:最后已知可用版本(回滚锚点)。
// matches=false 表示「当前版本还没被证明能启动」—— 刚更新完、尚未完成一次成功
// 启动时会是这种情况,控制端应据此暂缓向其余设备放量。
lkg: lkgStatus(),
};
if (fields && fields.length > 0) {
+6
View File
@@ -7,6 +7,7 @@ import { createAgentServer } from './server.js';
import { initAudit, audit, auditFilePath } from './audit.js';
import { startBeacon, DEFAULT_DISCOVERY_PORT, type Beacon } from './discovery.js';
import { startHubClient, type HubClient } from './hub-mode.js';
import { recordLKG } from './lkg.js';
function listLocalIps(): string[] {
const out: string[] = [];
@@ -133,6 +134,11 @@ async function main(): Promise<void> {
},
(msg) => console.log(`[WARN] ${msg}`),
);
// 交付流水线 §6:此刻脚本已加载、配置已读、证书已签、端口已绑、心跳已起 ——
// 走到这里说明「这个版本至少能启动」,故记为最后已知可用版本(LKG)。
// 注意**不在更新写完时就记**:那会把尚未验证的版本当成已知可用,LKG 就失去意义。
recordLKG();
console.log('');
console.log(' nodeagent 被控端已启动');
console.log(' ─────────────────────────────────────────────');
+143
View File
@@ -0,0 +1,143 @@
import { readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { join } from 'node:path';
import { agentDir } from './config.js';
import { buildInfo } from './capabilities/system.js';
/**
* 最后已知可用版本(Last-Known-Good, LKG)—— 交付流水线 §6 的回滚锚点。
*
* ## 为什么需要(本项目特有风险,通用发布规范里没有)
* 对 Web 服务,发布失败可以回滚、可以摘流量。对 nodeagent:
* **回滚通道本身就是被更新的那个程序**。一次失败的更新会让一台物理不可达的
* 远程设备永久脱管 —— 这是本项目最严重的上线失败形态。
*
* ## 记什么、什么时候记
* 关键设计:**只在「本次启动确实起来了」之后才记**。
*
* 若在「更新刚写完」时就记 LKG,等于把还没验证的版本当成已知可用 ——
* 那 LKG 就失去了意义。故由 `index.ts` 在服务器 listen 成功、打印完启动信息后
* 调用 `recordLKG()`:此刻能走到这一行,说明脚本能被 Node 加载、配置能读、
* 证书能签、端口能绑。**能启动 ≠ 功能全对,但能启动 = 至少不是「打死」状态。**
*
* ## 明确不做的事:自动回滚
* 本文刻意**不实现**「启动失败自动回滚」。理由:
* - 远程设备上无法区分「更新本身坏了」与「网络抖动 / 端口被占 / 依赖服务没起」;
* - 自动回滚可能触发**回滚循环**(回滚→启动→又判定失败→再回滚),
* 在无人值守设备上比「停在坏版本」更难恢复;
* - 被控端一旦自作主张改自己的程序,控制端将失去对「当前跑的是什么」的掌控。
*
* 因此 LKG 的定位是**给回滚提供确定的目标**,而不是替人做回滚决策:
* 控制端 `nodeagent rollback` 读 LKG → 覆盖回入口文件 → 重启。
*/
export interface LkgRecord {
/** 入口脚本(agent.mjs)的 sha256 前 12 位 —— 与 system.info 的 build.hash 同口径 */
hash: string;
bytes: number;
script_path: string;
/** 记录时刻(Unix ms) */
recorded_at: number;
/** 当时的 node 版本,便于判断「是不是 Node 运行时升级导致的」 */
node_version: string;
platform: string;
}
export function lkgFilePath(): string {
return join(agentDir(), 'agent-lkg.json');
}
/**
* 计算当前入口脚本指纹。
* 与 `capabilities/system.ts` 的 buildInfo() 同口径(都是脚本内容的 sha256 前 12 位),
* 故可与 `nodeagent info` 里的 `build.hash` 直接比对。
*/
function currentScriptHash(): { hash: string; bytes: number; path: string } | null {
const p = process.argv[1] ?? '';
if (!p) return null;
try {
const buf = readFileSync(p);
return {
hash: createHash('sha256').update(buf).digest('hex').slice(0, 12),
bytes: buf.length,
path: p,
};
} catch {
return null;
}
}
/**
* 记录当前版本为 LKG。
*
* 任何失败都被吞掉 —— 写入失败不能影响启动。
* @returns 写入的记录,失败返回 null
*/
export function recordLKG(): LkgRecord | null {
const cur = currentScriptHash();
if (!cur) return null;
const rec: LkgRecord = {
hash: cur.hash,
bytes: cur.bytes,
script_path: cur.path,
recorded_at: Date.now(),
node_version: process.version,
platform: process.platform,
};
try {
mkdirSync(agentDir(), { recursive: true });
writeFileSync(lkgFilePath(), `${JSON.stringify(rec, null, 2)}\n`, 'utf8');
return rec;
} catch {
return null;
}
}
/** 读取 LKG 记录;不存在或损坏返回 null(不作任何猜测)。 */
export function readLKG(): LkgRecord | null {
try {
const raw = readFileSync(lkgFilePath(), 'utf8');
const rec = JSON.parse(raw) as LkgRecord;
if (!rec || typeof rec.hash !== 'string') return null;
return rec;
} catch {
return null;
}
}
/**
* 当前版本与 LKG 是否一致。
*
* 用途:更新后复核 —— 若「当前 hash ≠ LKG」且刚做过更新,
* 说明新版本**尚未被证明能启动**,控制端应暂缓放量到其余设备(交付流水线 §6 分批策略)。
*/
export function lkgStatus(): {
lkg: LkgRecord | null;
current_hash: string | null;
matches: boolean;
/** LKG 与当前版本的字节差,便于快速判断是「升级」还是「回退」 */
byte_delta: number | null;
} {
const lkg = readLKG();
const cur = currentScriptHash();
const current_hash = cur?.hash ?? null;
return {
lkg,
current_hash,
matches: !!lkg && lkg.hash === current_hash,
byte_delta: lkg && cur ? cur.bytes - lkg.bytes : null,
};
}
/** 供审计/排障:LKG 文件是否存在及其大小(不读内容,避免大文件 IO)。 */
export function lkgFileInfo(): { path: string; exists: boolean; bytes: number } {
const p = lkgFilePath();
try {
return { path: p, exists: true, bytes: statSync(p).size };
} catch {
return { path: p, exists: false, bytes: 0 };
}
}
/** 让 system.info 能顺带暴露构建指纹(与 buildInfo 同源,避免两处口径漂移)。 */
export { buildInfo };
+23
View File
@@ -109,6 +109,29 @@ export interface SystemInfo {
ps_helper?: PsHelperInfo;
network?: NetworkInfo;
build?: BuildInfo;
/** 最后已知可用版本 LKG(交付流水线 §6 回滚锚点) */
lkg?: LkgInfo;
}
/**
* 最后已知可用版本(Last-Known-Good)。
*
* 只在「本次启动真正起来了」之后才由被控端写入 ——
* 故 `matches=false` 意味着当前版本**尚未被证明能启动**(刚更新完、还没完成一次成功启动),
* 界面应据此提示「暂缓向其余设备放量」。
*/
export interface LkgInfo {
lkg?: {
hash?: string;
bytes?: number;
script_path?: string;
recorded_at?: number;
node_version?: string;
platform?: string;
};
current_hash?: string;
matches?: boolean;
byte_delta?: number;
}
/**
+388
View File
@@ -0,0 +1,388 @@
# nodeagent 交付流水线规范
> 本文是《代码审计 → 上线 完整交付方案》v1.0(2026-10-14)在 **nodeagent** 项目上的落地适配。
> 适配日期:2026-10-17。基线提交:`590f62e`。
---
## 0. 适配原则(先说清楚为什么不能照搬)
原方案面向 **Web/容器服务**(FinDataHub / reader·bookshelf / OnlyInOne)——有服务端、有镜像、
有多套环境、有线上流量。nodeagent 与之有三处根本差异,照搬会产生**假门禁**(跑得很好但守不住任何东西)。
因此本文对每一阶段给出三档判定:
| 判定 | 含义 | 处置 |
|---|---|---|
| **适用** | 原样落地即可 | 直接实施 |
| **改造后适用** | 目标一致,但形态不同 | 换等价手段,并说明为什么等价 |
| **不适用** | 本项目不存在该风险面 | **必须给出替代手段**,不允许只写「N/A」了事 |
> **为什么「不适用」也必须给替代**:原方案每项都对应一类真实风险。若某类风险在本项目不存在,
> 说明风险换了形态,替代手段才是真正守它的东西;直接跳过等于承认该风险无人看守。
### nodeagent 项目画像
| 维度 | 事实 |
|---|---|
| 形态 | monorepo:TypeScript(agent/cli/mcp/hub/protocol)+ Rust(Tauri 控制台壳) |
| 交付物 | Windows 安装包 zip(含 `node.exe` + `agent.mjs`)、macOS CLI 三件套、macOS Tauri 控制台 app |
| 运行位置 | 常驻在**用户自己的机器**上(Windows/Linux/macOS),非服务器 |
| 有无服务端 | **无**(点对点直连 + 可选 Hub 中转,Hub 不在本仓库) |
| 有无容器 | **无** |
| 更新方式 | 被控端**拉取式自更新**(`system.agent.update` 读 `latest.json` + sha256) |
| 现有测试 | 单测 **341**(全绿)/ 协议 e2e / Windows 真机 e2e / Rust `cargo test` |
---
## 1. 代码审计(四维扫描 + 漏洞分级)
原方案四维:SAST / SCA / 密钥扫描 / 容器镜像。
| 维度 | 判定 | 现状 | 落地动作 |
|---|---|---|---|
| SAST 静态安全 | **适用** | ❌ 无 | 引入 Semgrep(TS/JS 规则集)进 CI,阻断 High 及以上 |
| SCA 依赖漏洞 | **适用** | ❌ 无 | `pnpm audit --audit-level=high` 进 CI;锁文件已入库 ✅ |
| 密钥与敏感信息 | **适用** | ⚠️ **有巡检无门禁** | 每日定时任务已做隐私巡检(`security-reports/`),但**不阻断合并** → 加 gitleaks 进 CI |
| 容器镜像 | **不适用** | — | 无容器。**替代:交付物断言**(见下) |
### 替代:交付物断言(比镜像扫描更贴本项目的那一层)
nodeagent 的「制品风险」不在镜像层,而在**分发包本身**。已有两道断言,本文将其升格为正式门禁:
1. **PE 架构断言**(`scripts/lib/zip-pe-arch.mjs`):解出最终 zip 内的 `node.exe` 读 PE 头,
与期望架构比对。
- 缘起(2026-10-11 事故):arm64 包里装进了 x64 的 `node.exe`,该包在 ARM Windows 上
**能启动、能 listen,只是 TLS 握手不完成** —— 单测/CI/macOS 侧全都抓不到,
且两个公开包内 `node.exe` 的 sha256 完全相同。
2. **打包期守卫**(`scripts/pack.mjs`):`.ps1` 必须带 UTF-8 BOM、`.cmd` 必须纯 ASCII + CRLF。
这两条各自对应一次真机事故(PowerShell 5.1 解析失败 / CMD 代码页截断)。
> **为什么这两条比通用镜像扫描更要紧**:镜像扫的是「第三方引入的漏洞」,
> 而这两条守的是「我们自己把错误的东西发了出去」——后者是本项目历史上真实发生过的。
### 准入判据(硬门禁)
- Critical / High 漏洞数 **必须为 0**,一票否决。
- Medium 登记并附限期修复计划;Low 可接受但须留痕。
- **黄金法则沿用**:密钥一旦提交即视为泄露,删除提交只是止损,**立即轮换**才是根治。
nodeagent 的密钥面比一般项目大:PSK、Ed25519 私钥、TLS 自签证书 —— 轮换路径已在
`docs/SECURITY.md` §6 应急处置中定义。
---
## 2. 质量门禁(Quality Gate)
| 指标 | 原方案阈值 | 现状 | 落地动作 |
|---|---|---|---|
| 新增代码覆盖率 | ≥ 80% | ❌ **未采集** | `node --test --experimental-test-coverage` 采集,设阈值(见下) |
| 圈复杂度 ≤ 10/方法 | — | ❌ 未检 | 暂不接入(见说明) |
| 重复率 ≤ 3% | — | ❌ 未检 | 暂不接入 |
| 坏味道新增 0 | — | ❌ 未检 | 暂不接入 |
| 阻断性 Bug 0 | — | ✅ `typecheck` + `clippy -D warnings` | 保持 |
**关于覆盖率阈值的现实校准**:原方案 80% 面向新项目。nodeagent 存量 341 个单测集中在
协议/编码/安全守卫,**部分模块(GUI 注入、平台脚本)无法在 CI 上无头验证**。
故采用**增量口径**:
- 全量覆盖率**先只观测、不阻断**(出数字,建立基线);
- **新增/修改文件的行覆盖率 ≥ 70% 才阻断**;
- 无法无头验证的模块显式标注豁免,并在文档中列出理由。
> 直接照搬 80% 全量阈值会导致两种后果:要么长期挂 `--skip`(门禁形同虚设),
> 要么逼迫团队为 GUI 注入代码写无意义的假断言。**门禁的价值在于守得住,不在于数字好看。**
**关于圈复杂度/重复率**:需引入 SonarQube,但对本地优先的单人项目运维成本高于收益,
本文标记为**可选项**,不进阻断门禁。
---
## 3. 自动化测试(金字塔分层)
| 层 | 判定 | 现状 | 说明 |
|---|---|---|---|
| 单元测试 | **适用** | ✅ **341 全绿** | `pnpm test:unit` |
| 集成测试 | **适用** | ✅ 协议 e2e | `pnpm test:e2e`(TLS / ed25519 / 发现 / 重连 / 文件 / Hub) |
| E2E 关键路径 | **适用** | ✅ **Windows 真机 e2e** | `pnpm test:windows`(含 `install.cmd` 双击链路)——**强于原方案**(真机而非模拟) |
| DAST 动态扫描 | **不适用** | — | 无对外 Web 服务面。**替代:协议健壮性测试** |
### 替代:协议健壮性测试(对应「运行态注入」这一类风险)
DAST 守的是「外部输入打进来会怎样」。nodeagent 的对外输入面是 **WebSocket RPC**,
已有等价覆盖(`tests/e2e/run.mjs` 内):
- 畸形 `hello`(protocol 传数字 / 传对象)
- 畸形 `auth`(hmac 传数字 / 对象 + nonce 为 null)
- **未认证直接 invoke**(U-01:必须留审计且不崩)
- 未知能力 / 参数非法 / ACL 拒绝 / 超频
> 这比通用 DAST 更精准:DAST 是黑盒猜测,这里是针对本项目协议状态机的定向攻击面覆盖。
**缺口**:畸形报文目前是**枚举式**用例,未做随机化 fuzz。建议后续加一轮
「随机字节 + 随机 JSON 结构」的模糊测试 → 记为可选改进项。
---
## 4. CI/CD 构建与制品
| 项 | 判定 | 现状 |
|---|---|---|
| 流水线 lint→test→build→scan | **适用** | ⚠️ 有 test/build/typecheck,**缺 scan** |
| 制品仓库 | **改造后适用** | ✅ GitHub Release(非镜像仓库) |
| 版本号 = 语义化 + git SHA | **适用** | ✅ 语义化版本;`release.mjs` 统一写入 11 处 |
| 签名与追溯 | **改造后适用** | ⚠️ 有 sha256 + `latest.json`,**无 cosign/SBOM** |
### 签名与追溯:容器 cosign 不适用,改为「分发包完整性」三件套
cosign 签的是容器镜像。nodeagent 分发的是 zip + `agent.mjs`,等价保障是:
1. **sha256 入库**:`SHA256SUMS.txt` 随包发布(已有)。
2. **包内容清单**:zip 内文件列表与体积随 release 记录,用于事后核对「发出去的到底是什么」。
3. **可验签**:`system.agent.update` **强制校验 sha256**(不校验等于开放远程代码执行)。
这是本项目**最强的一道**——被控端只认哈希,不认「看起来对」的包。
> **待补(可选)**:对 `agent.mjs` 做 detached GPG 签名,供高安全场景人工验签。
> 注意这不能替代 sha256 —— 被控端目前不支持验签,加签名只服务于人工审计场景。
### 现有 CI 的已知待改项
- `retention-days: 30` → 建议 **7**(CI artifact 公开可下载,留存越久暴露面越大)。
- 三个 workflow 均未集成密钥/依赖扫描(见 §1)。
---
## 5. 分级环境发布(dev / staging / prod)
**判定:不适用**,但必须给替代。
nodeagent 无服务端、无多环境:`agent` 跑在用户自己的机器上,控制端是本机 CLI。
「dev/staging/prod」这类环境隔离在物理上不存在。
### 替代:真机矩阵验证
风险换成了形态 —— 不是「哪个环境先炸」,而是「哪类机器先炸」。nodeagent 已多次栽在
**平台差异**上(CRLF、路径分隔符、verbatim 路径、ARM Windows 的 TLS、PowerShell 5.1 的 BOM)。
故替代为**真机矩阵**:
| 平台 | 架构 / 形态 | 验证方式 |
|---|---|---|
| Windows | x64 | `tests/e2e/windows.mjs`(`windows-latest` runner) |
| Windows | **arm64** | 真机验证(CI 无 arm64 Windows runner,必须人工) |
| macOS | CLI 三件套 | 本机安装冒烟 |
| macOS | Tauri 控制台 | `console-windows.yml` 构建 + `verify-bundle.mjs` |
| Linux | systemd 被控端 | `docs/DEPLOY-LINUX.md` + 真机验证 |
> **矩阵里最弱的一环是 Windows arm64** —— CI 覆盖不到,而它恰恰是历史上出过
> 「能启动但 TLS 握手不完成」这一最难发现故障的平台。本文将其列为**发版前必查项**。
---
## 6. 生产发布策略
| 原方案策略 | 判定 | 说明 |
|---|---|---|
| 滚动发布 | 不适用 | 无多实例 |
| 蓝绿部署 | 不适用 | 无流量切换 |
| 金丝雀 | **改造后适用** | 改为**设备分批**:先一台试点 |
| Feature Flag | 适用(有限) | 能力级 ACL 可视为开关 |
### 改造:金丝雀 → 设备分批自更新
被控端是**拉取式**更新(读 `latest.json`),天然支持分批:
1. **试点**:选 1 台可达且物理可及的设备先更新,观察 ≥ 30 分钟。
2. **放量**:确认无异常后,其余设备再触发更新。
3. **回滚**:每设备保留上一版本备份(`agent.mjs.bak-<ts>`)+ `latest.json` 可回退。
### ⚠️ 本项目特有风险:发布即失联(原方案未覆盖)
这是 nodeagent **最严重**的上线失败形态,且原方案完全没有对应条款:
> 一次失败的 agent 更新,会让一台**物理不可达**的远程设备**永久脱管**。
> 对 Web 服务,发布失败可以回滚、可以摘流量;对被控端,**回滚通道本身就是被更新的那个程序**。
缓解措施(部分已有,本文整合为强制项):
| 措施 | 状态 |
|---|---|
| 更新前备份当前 `agent.mjs` | ✅ 已有 |
| **先备份再原子替换**(同目录 rename,失败可回滚) | ✅ 已有 |
| **sha256 强制校验**(不校验不落盘) | ✅ 已有 |
| 更新后**重启并轮询校验新指纹**上线 | ✅ 已有(`deploy` 闭环) |
| **保留「最后已知可用版本」标记(LKG)** | ✅ **本次落地**(`apps/agent/src/lkg.ts`) |
| **更新后健康检查失败 → 自动回滚到备份** | ⚠️ **刻意不做**,见下 |
### LKG 的设计(本次落地)
- **写入时机**:agent **成功启动后**(服务器已 listen、心跳已起)才写 `agent-lkg.json`。
**不在更新写完时就写** —— 那会把尚未验证的版本当成已知可用,LKG 就失去意义。
- **内容**:`hash`(与 `system.info` 的 `build.hash` 同口径)、`bytes`、`script_path`、
`recorded_at`、`node_version`、`platform`。
- **暴露**:`system.info.lkg = { lkg, current_hash, matches, byte_delta }`。
`matches=false` = 当前版本还没被证明能启动 → 控制端应**暂缓向其余设备放量**。
### 为什么**不做**自动回滚(明确的设计取舍)
自动回滚听起来更安全,但在远程被控端上会引入更坏的失败形态:
1. **无法归因**:远程设备上区分不出「更新本身坏了」与「网络抖动 / 端口被占 / 依赖服务未起」。
2. **回滚循环**:回滚 → 启动 → 又判定失败 → 再回滚。在无人值守设备上,这比「停在坏版本」更难恢复。
3. **失控**:被控端一旦自作主张改自己的程序,控制端就失去对「当前跑的到底是什么」的掌控。
故定位为:**LKG 提供确定的回滚目标,决策权留给控制端**(`nodeagent rollback` 读 LKG → 覆盖回入口 → 重启)。
现有 `system.agent.update` 已具备备份、哈希校验、写后校验、不一致即回滚 —— 那是**写入期**的闭环;
**运行期**的可用性判断交给人,不交给一个可能误判的自动机。
---
## 7. 上线监控与复盘
| 支柱 | 判定 | nodeagent 对应 |
|---|---|---|
| 指标 | **改造后适用** | `system.metrics`:闭环成功率 / P95 时延 / 安全拦截率 / 装软件成功率 |
| 日志 | **适用** | 审计日志 JSONL(哈希链 + 外部锚定),见 `docs/SECURITY.md` §4.8 |
| 链路追踪 | 不适用 | 点对点调用,无分布式链路 |
| 告警 | 改造后适用 | 无值班通道;改为**发版后主动复核** |
### 发布后复核清单(替代 Prometheus 盯盘)
| 项 | 命令 | 期望 |
|---|---|---|
| 能力数未减少 | `nodeagent list` | 与发版前一致 |
| 审计链完整 | `nodeagent audit verify` | `ok: true` |
| 锚点自洽 | `nodeagent audit anchor --compare` | 无「整链重写」告警 |
| 核心能力可用 | `node scripts/verify.mjs --host … --key …` | 全绿 |
| 指标未恶化 | `nodeagent metrics` | 闭环率 ≥ 95%、拦截率 100% |
### 复盘
已有载体:`bug-report-2026-10-13.html`(缺陷台账)、`docs/RETROSPECTIVE.md`(短板复盘)、
`security-reports/YYYY-MM-DD.md`(每日巡检)。
**待补**:原方案要求「任何事故/回滚必须写复盘 —— 时间线、根因、改进项、责任人」。
本项目为单人项目,「责任人」不适用,改为**改进项是否进入守卫**(即:是否变成一条会红的断言)。
> 这是本项目复盘最有价值的一问:**没有变成断言的复盘,等于没有复盘**。
> 历史上多次同类事故复发(CRLF、路径、编码)都栽在这一点上。
---
## 8. 落地清单(Action Checklist)
按优先级排序,✅ 表示本次已落地:
- [x] 建立 `.gitignore` 密钥基线(`PSK.txt` / `keys/` / `*.key` / `*.pem` / `config.json` / `agent.json`)— **已有**
- [x] 密钥扫描(gitleaks)进 CI 并阻断合并 — **本次落地**(`quality-gate.yml`)
- [x] 依赖漏洞扫描(`pnpm audit --audit-level=high`)进 CI — **本次落地**
- [ ] SAST(Semgrep TS/JS 规则集)进 CI — **未做**:需先定规则集,否则噪声会淹没真问题
- [x] 覆盖率采集接入 CI(全量观测 + 变更文件 ≥70% 阻断)— **本次落地**(`scripts/ci/check-coverage.mjs`)
- [x] 提交信息规范强制(Conventional Commits + CI 校验)— **本次落地**(`scripts/ci/check-commit-msg.mjs`)
- [x] 交付物 PE 架构断言 — **已有**,升格为门禁
- [x] 打包期 `.ps1` BOM / `.cmd` ASCII+CRLF 守卫 — **已有**
- [x] CI artifact `retention-days` 30 → 7 — **本次落地**
- [x] 保留「最后已知可用版本」标记(LKG)— **本次落地**
- [ ] 分支保护:main 需 PR + CI 全绿 — **需仓库设置**(当前 protection 返回 404,已知 P1)
- [ ] 发版前 Windows arm64 真机必查(CI 覆盖不到)— **流程项**,已写入本文档
- [ ] 协议模糊测试(随机报文 fuzz)— **可选改进项**
### 本次落地的实测证据
| 项 | 证据 |
|---|---|
| 覆盖率门禁 | 三条故障注入均 `exit=1`(阈值 100 / 全量 99 / lcov 缺失);正常路径 `exit=0` |
| 覆盖率基线 | 全量 **66.9%**(6399/9560 行,54 文件)—— 印证「照搬 80% 全量不现实」 |
| 提交规范校验 | 反例 3 类均红(无类型 / wip / 类型拼错);Merge 与无 scope 正确豁免;**历史 20 条提交一次通过** |
| LKG | 新增 8 条单测全绿;含「篡改 hash 必须检出 matches=false」故障注入 |
| 契约守卫 | 新增 `lkg` 字段后,既有契约测试**立刻报红**(`SystemInfo` 缺声明)→ 补接口后转绿。这本身是门禁有效的实证 |
| 全量单测 | **349 全绿**(落地前 341) |
| 门禁会红的验证 | 覆盖率 3 注入 + LKG 1 注入,均实测为真红,非摆设 |
---
## 附录 A:CI 骨架(GitHub Actions,适配 nodeagent)
```yaml
# .github/workflows/quality-gate.yml
name: Quality Gate
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with: { fetch-depth: 0 }
# 密钥扫描:历史 + 增量(nodeagent 密钥面:PSK / ed25519 / TLS 私钥)
- uses: gitleaks/gitleaks-action@v2
env: { GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} }
# 依赖漏洞
- uses: pnpm/action-setup@v4
- run: pnpm install --frozen-lockfile
- run: pnpm audit --audit-level=high
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
# 覆盖率:全量出数字(观测),增量阈值由 coverage-gate 判定
- run: node --test --experimental-test-coverage --test-reporter=lcov \
--test-reporter-destination=coverage/lcov.info "tests/unit/**/*.test.mjs"
- uses: actions/upload-artifact@v4
with: { name: coverage, path: coverage/, retention-days: 7 }
```
> **注**:`windows-e2e.yml` 保留原样 —— 它跑的是真机链路(`install.cmd` / `control.cmd` 双击),
> 是本项目 CI 里最有价值的一环,不可被「更快」的 Linux 流水线替代。
## 附录 B:提交信息规范
项目**实际已在用** Conventional Commits(`git log` 可验证),但**无强制**。补齐校验:
```
<type>(<scope>): <subject>
type: feat | fix | docs | style | refactor | perf | test | chore | ci
scope: agent | cli | mcp | hub | protocol | console | pack | release | linux | …
```
示例(取自实际提交):
```
feat(linux): Linux 被控端适配 —— 安装器 + systemd 自启 + 平台守卫 + 部署文档
fix(console): sidecar 补 CREATE_NO_WINDOW(不再弹常驻黑窗)
chore(privacy): 公开仓库隐私脱敏 —— 清除可定位到个人与设备的字面量
```
## 附录 C:回滚检查清单(nodeagent 版)
**触发回滚的条件(满足任一)**
- 目标设备**失联**(更新后无法连接,且已排除网络问题)—— **最高优先级**
- `nodeagent list` 能力数少于发版前
- `nodeagent audit verify` 返回 `ok: false`
- `verify.mjs` 出现失败项
**回滚动作**
1. 登录目标机(物理或带外方式),用备份恢复:`agent.mjs.bak-<ts>` → `agent.mjs`。
2. 若需回退发布通道:把 `latest.json` 指回上一版本 URL + sha256。
3. 重启 agent(计划任务或 `run-temp.cmd`)。
4. 复核:`verify.mjs` 全绿 → 记录时间线 → 写复盘 → **把根因变成一条会红的断言**。
---
## 变更记录
| 日期 | 内容 |
|---|---|
| 2026-10-17 | 初版。基于《代码审计 → 上线 完整交付方案》v1.0 适配,基线 `590f62e`,单测 341 全绿。 |
@@ -53,6 +53,14 @@ export const SYSTEM_CAPABILITIES: CapabilityDescriptor[] = [
'v18 构建指纹:hash(运行中 agent 脚本的 sha256 前 12 位,部署校验用)、bytes、mtime_ms、' +
'node(运行时版本)、started_at、uptime_ms',
},
lkg: {
type: 'object',
description:
'最后已知可用版本 LKG(交付流水线 §6 回滚锚点):lkg(记录,含 hash/bytes/script_path/' +
'recorded_at)、current_hash、matches、byte_delta。' +
'matches=false 表示当前版本**尚未被证明能启动**(刚更新完、还没完成一次成功启动),' +
'控制端应据此暂缓向其余设备放量。仅在「本次启动真正起来了」之后才写入。',
},
},
},
},
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env node
/**
* 提交信息规范校验(交付流水线 §8 / 附录 B)。
*
* ## 背景
* 项目**实际早已在用** Conventional Commits(`git log` 可验证:feat(linux): / fix(console): …),
* 但一直没有强制 —— 靠自觉。本次把它变成会红的门禁。
*
* ## 为什么不引 commitlint + husky
* - husky 依赖 git hooks,CI 上不生效,还得额外装依赖;
* - commitlint 需要配置文件与 `@commitlint/config-conventional` 一整套;
* - 本项目只需「类型白名单 + 有 subject」这两条,几十行即可,且 CI 与本地都能跑。
* 与覆盖率门禁同理:不引冗余依赖。
*
* ## 用法
* node scripts/ci/check-commit-msg.mjs # 校验 HEAD 这一条
* node scripts/ci/check-commit-msg.mjs --base origin/main # 校验该区间所有提交
* echo "feat(x): y" | node scripts/ci/check-commit-msg.mjs --stdin
*/
import { execFileSync } from 'node:child_process';
const TYPES = ['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'chore', 'ci'];
/** 允许 Revert / Merge / 版本提交等自动化形态,但类型必须合规。 */
const HEADER_RE = new RegExp(`^(${TYPES.join('|')})(\\([^)]+\\))?!?: .+`);
const ALLOWED_EXEMPT = [
/^Merge (branch|pull request|remote-tracking branch)/i,
/^Revert "/,
];
function parseArgs(argv) {
const out = { base: '', stdin: false };
for (let i = 0; i < argv.length; i += 1) {
if (argv[i] === '--base') out.base = argv[++i];
else if (argv[i] === '--stdin') out.stdin = true;
else if (argv[i] === '--help' || argv[i] === '-h') {
console.log(
`用法: node scripts/ci/check-commit-msg.mjs [--base <git-ref>] [--stdin]\n` +
` 类型白名单: ${TYPES.join(', ')}\n` +
` 格式: <type>(<scope>): <subject>`,
);
process.exit(0);
}
}
return out;
}
function readStdin() {
try {
return execFileSync('cat', [], { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'] });
} catch {
return '';
}
}
function commitsInRange(base) {
const range = base ? `${base}..HEAD` : 'HEAD~1..HEAD';
try {
return execFileSync('git', ['log', '--format=%H%x09%s', range], { encoding: 'utf8' })
.split('\n')
.filter((l) => l.trim())
.map((l) => {
const idx = l.indexOf('\t');
return { sha: l.slice(0, idx), subject: l.slice(idx + 1) };
});
} catch {
return [];
}
}
const args = parseArgs(process.argv.slice(2));
let items = [];
if (args.stdin) {
const msg = readStdin().trim();
if (!msg) {
console.error('\n✗ stdin 为空,没有可校验的提交信息');
process.exit(1);
}
items = [{ sha: '(stdin)', subject: msg.split('\n')[0] }];
} else {
items = commitsInRange(args.base);
if (items.length === 0) {
console.log(`(区间内没有提交,跳过)${args.base ? `base=${args.base}` : ''}`);
process.exit(0);
}
}
const bad = [];
for (const it of items) {
const s = it.subject.trim();
if (ALLOWED_EXEMPT.some((re) => re.test(s))) continue;
if (!HEADER_RE.test(s)) bad.push(it);
}
if (bad.length > 0) {
console.error(`\n✗ ${bad.length} 条提交信息不符合 Conventional Commits:`);
for (const b of bad) console.error(` ${b.sha.slice(0, 8)} ${b.subject}`);
console.error(`\n要求格式: <type>(<scope>): <subject>`);
console.error(`type 取值: ${TYPES.join(', ')}`);
console.error(`示例: fix(console): sidecar 补 CREATE_NO_WINDOW(不再弹常驻黑窗)`);
process.exit(1);
}
console.log(`✓ 提交信息规范校验通过(${items.length} 条)`);
+246
View File
@@ -0,0 +1,246 @@
#!/usr/bin/env node
/**
* 覆盖率门禁(交付流水线 §2 / §8)。
*
* ## 为什么自己解析 lcov 而不装 c8/nyc
* 单测用的是 Node 内置 `node --test --experimental-test-coverage`,它原生出 lcov。
* 再引一套覆盖率工具只为「读一下阈值」属于重复建设 —— 本项目铁律之一就是
* 「重复功能优先引用已有模块,不新建冗余内容」。
*
* ## 口径(重要,别照搬通用 80%)
* 原方案(面向 Web 新项目)要求全量 ≥80%。本项目存量 341 个单测集中在
* 协议/编码/安全守卫,而 GUI 注入、平台脚本等模块**无法在 CI 上无头验证**。
* 直接套 80% 全量阈值只会逼出两种结果:长期 `--skip`,或为 GUI 代码写假断言。
*
* 故采用:
* - **全量:只观测,不阻断**(出数字,建基线)
* - **变更文件:行覆盖率 ≥ THRESHOLD 才阻断**(默认 70)
*
* ## 一个必须说清的映射
* 单测 import 的是 `dist/*.js`(编译产物),不是 `src/*.ts`。
* 所以「本次改了哪个源文件」要映射成「dist 里的哪个文件」才能查到覆盖率:
* apps/agent/src/audit.ts → apps/agent/dist/audit.js
* 规则:`/src/` → `/dist/`,`.ts` → `.js`。
*
* ## 无覆盖数据怎么办
* 改动的源文件若在 lcov 里查不到对应条目(例如纯类型改动、文档、或压根没被测试碰过),
* **不阻断**,只列进「无覆盖数据」清单。理由:把它算作 0% 会让任何重构都无法合入;
* 而如实列出来,比一个会被绕过的门禁更有用。
*/
import { readFileSync, existsSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { relative, resolve } from 'node:path';
const REPO = resolve(process.cwd());
function fail(msg) {
console.error(`\n✗ ${msg}`);
process.exit(1);
}
// ---------------- 参数 ----------------
function parseArgs(argv) {
const out = {
lcov: 'coverage/lcov.info',
threshold: 70,
base: '',
// 全量不达标是否阻断(默认否:只观测)
enforceTotal: false,
totalThreshold: 80,
};
for (let i = 0; i < argv.length; i += 1) {
const a = argv[i];
if (a === '--lcov') out.lcov = argv[++i];
else if (a === '--threshold') out.threshold = Number(argv[++i]);
else if (a === '--total-threshold') out.totalThreshold = Number(argv[++i]);
else if (a === '--base') out.base = argv[++i];
else if (a === '--enforce-total') out.enforceTotal = true;
else if (a === '--help' || a === '-h') {
console.log(
`用法: node scripts/ci/check-coverage.mjs [选项]\n` +
` --lcov <path> lcov 路径(默认 coverage/lcov.info)\n` +
` --threshold <n> 变更文件行覆盖率阈值(默认 70)\n` +
` --base <git-ref> 比对基线(如 origin/main);为空则只看全量\n` +
` --enforce-total 全量不达标也阻断(默认只观测)\n` +
` --total-threshold <n> 全量阈值(默认 80,仅在 --enforce-total 时生效)`,
);
process.exit(0);
}
}
return out;
}
// ---------------- lcov 解析 ----------------
/**
* 解析 lcov,返回「项目自有文件」的覆盖率。
*
* 过滤掉三类噪声:
* 1. node_modules
* 2. 仓库外路径(运行时注入的 shim,实测会混进 WorkBuddy/Application 路径)
* 3. .test.mjs 自身(测试文件不该计入被测代码)
*/
function parseLcov(text) {
const out = new Map(); // repoRelPath -> {found, hit}
let cur = null;
let found = 0;
let hit = 0;
const flush = () => {
if (!cur) return;
const rel = toRepoRelative(cur);
if (rel && isProjectFile(rel)) {
const prev = out.get(rel);
if (prev) {
prev.found += found;
prev.hit += hit;
} else {
out.set(rel, { found, hit });
}
}
cur = null;
found = 0;
hit = 0;
};
for (const raw of text.split('\n')) {
const line = raw.trimEnd();
if (line.startsWith('SF:')) {
flush();
cur = line.slice(3).trim();
} else if (line === 'end_of_record') {
flush();
} else if (cur) {
if (line.startsWith('LF:')) found = Number(line.slice(3)) || 0;
else if (line.startsWith('LH:')) hit = Number(line.slice(3)) || 0;
}
}
flush();
return out;
}
function toRepoRelative(absPath) {
const p = resolve(absPath);
if (!p.startsWith(REPO)) return null;
const rel = relative(REPO, p);
if (rel.startsWith('..')) return null;
return rel;
}
function isProjectFile(rel) {
if (rel.includes('node_modules/')) return false;
if (rel.startsWith('node_modules/')) return false;
// 只统计本仓库的 apps / packages / scripts
if (!/^(apps|packages|scripts)\//.test(rel)) return false;
// 测试自身不计入
if (/\.test\.mjs$/.test(rel)) return false;
return true;
}
/** 源文件(src/*.ts)→ 单测实际 import 的产物路径(dist/*.js)。 */
function mapSourceToArtifact(srcRel) {
if (srcRel.includes('/src/')) return srcRel.replace('/src/', '/dist/').replace(/\.ts$/, '.js');
return srcRel;
}
// ---------------- 变更文件 ----------------
function changedFiles(base) {
if (!base) return [];
const range = `${base}...HEAD`;
let out = '';
try {
out = execFileSync('git', ['diff', '--name-only', range], { encoding: 'utf8' });
} catch {
// 基线不可达(例如浅克隆)→ 退化为与 HEAD~1 比对
try {
out = execFileSync('git', ['diff', '--name-only', 'HEAD~1', 'HEAD'], { encoding: 'utf8' });
} catch {
return [];
}
}
return out
.split('\n')
.map((s) => s.trim())
.filter(Boolean);
}
// ---------------- 主流程 ----------------
const args = parseArgs(process.argv.slice(2));
if (!existsSync(args.lcov)) {
fail(`找不到 lcov 文件: ${args.lcov}\n 请先跑:node --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=${args.lcov} "tests/unit/**/*.test.mjs"`);
}
const cov = parseLcov(readFileSync(args.lcov, 'utf8'));
if (cov.size === 0) {
fail(`lcov 里没有任何项目文件(${args.lcov})—— 覆盖率采集可能失败,请检查是否加了 --experimental-test-coverage`);
}
// --- 全量 ---
let totalFound = 0;
let totalHit = 0;
for (const v of cov.values()) {
totalFound += v.found;
totalHit += v.hit;
}
const totalPct = totalFound > 0 ? (totalHit / totalFound) * 100 : 0;
console.log(`全量覆盖率(观测) : ${totalPct.toFixed(1)}% (${totalHit}/${totalFound} 行, ${cov.size} 个文件)`);
// --- 变更文件 ---
const changed = changedFiles(args.base);
let blocked = false;
const noData = [];
if (changed.length > 0) {
const candidates = new Map(); // artifactRel -> srcRel
for (const f of changed) {
if (!/^(apps|packages|scripts)\//.test(f)) continue;
if (/\.(md|json|ya?ml|txt)$/.test(f)) continue; // 文档/配置不参与
const art = mapSourceToArtifact(f);
candidates.set(art, f);
}
const rows = [];
for (const [art, src] of candidates) {
const v = cov.get(art);
if (!v) {
noData.push(src);
continue;
}
const pct = v.found > 0 ? (v.hit / v.found) * 100 : 100;
rows.push({ src, art, pct, hit: v.hit, found: v.found });
}
rows.sort((a, b) => a.pct - b.pct);
console.log(`\n变更文件覆盖率(阈值 ${args.threshold}%,阻断):`);
if (rows.length === 0) console.log(' (无具备覆盖数据的变更文件)');
for (const r of rows) {
const mark = r.pct < args.threshold ? '✗' : '✓';
console.log(` ${mark} ${r.pct.toFixed(1).padStart(5)}% ${r.src} (${r.hit}/${r.found} 行)`);
if (r.pct < args.threshold) blocked = true;
}
if (noData.length > 0) {
console.log(`\n无覆盖数据的变更文件(不阻断,供人工判断):`);
for (const f of noData) console.log(` - ${f}`);
}
} else {
console.log('\n(未提供 --base,跳过变更文件判定)');
}
if (args.enforceTotal && totalPct < args.totalThreshold) {
console.error(`\n✗ 全量覆盖率 ${totalPct.toFixed(1)}% < 阈值 ${args.totalThreshold}%`);
blocked = true;
}
if (blocked) {
console.error('\n✗ 覆盖率门禁未通过');
process.exit(1);
}
console.log('\n✓ 覆盖率门禁通过');
+87
View File
@@ -0,0 +1,87 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
/**
* LKG(最后已知可用版本)守卫 —— 交付流水线 §6。
*
* 为什么需要守卫:LKG 是回滚的**目标**。若它记错了(记成尚未验证的版本),
* 回滚就会回到一个坏版本 —— 比没有 LKG 更糟,因为它给人虚假的安全感。
* 故这里钉死三条:
* 1. 记录内容必须与当前入口脚本真实一致;
* 2. 文件不存在/损坏时**返回 null,不做任何猜测**;
* 3. matches 的判定必须用同一套 hash 口径(与 system.info 的 build.hash 同)。
*/
// 通过 NODEAGENT_HOME 指向临时目录,避免污染真实数据目录
const dir = mkdtempSync(join(tmpdir(), 'na-lkg-'));
process.env['NODEAGENT_HOME'] = dir;
const { recordLKG, readLKG, lkgStatus, lkgFilePath } = await import('../../apps/agent/dist/lkg.js');
test('LKG 落在 NODEAGENT_HOME 指定的数据目录', () => {
assert.ok(lkgFilePath().startsWith(dir), `LKG 路径应在临时目录内,实际: ${lkgFilePath()}`);
});
test('未记录时 readLKG 返回 null(不猜测)', () => {
assert.equal(readLKG(), null);
});
test('记录后可读回,且 hash 与当前入口脚本真实一致', () => {
const rec = recordLKG();
assert.ok(rec, 'recordLKG 应返回记录');
const back = readLKG();
assert.ok(back, '应能读回');
assert.equal(back.hash, rec.hash);
assert.equal(back.bytes, rec.bytes);
// 独立重算:与直接对 argv[1] 求 sha256 前 12 位比对
const p = process.argv[1];
const expect = createHash('sha256').update(readFileSync(p)).digest('hex').slice(0, 12);
assert.equal(back.hash, expect, 'LKG hash 必须等于入口脚本真实 sha256 前 12 位');
});
test('lkgStatus.matches 在记录后为 true,byte_delta 为 0', () => {
recordLKG();
const s = lkgStatus();
assert.equal(s.matches, true);
assert.equal(s.byte_delta, 0);
assert.equal(s.current_hash, s.lkg.hash);
});
test('LKG 文件损坏时 readLKG 返回 null 而非抛错', () => {
recordLKG();
writeFileSync(lkgFilePath(), '{ this is not json', 'utf8');
assert.equal(readLKG(), null, '损坏的 LKG 必须返回 null,不得抛错或猜值');
});
test('LKG 记录缺少 hash 字段时视为无效', () => {
writeFileSync(lkgFilePath(), JSON.stringify({ bytes: 1 }), 'utf8');
assert.equal(readLKG(), null);
});
test('故障注入:篡改 LKG 的 hash 会被 matches=false 检出', () => {
recordLKG();
const good = readLKG();
// 模拟「更新后还没起来」:LKG 仍是旧值,而当前脚本已被替换成别的 hash
writeFileSync(
lkgFilePath(),
JSON.stringify({ ...good, hash: 'deadbeefcafe' }, null, 2),
'utf8',
);
const s = lkgStatus();
assert.equal(s.matches, false, 'hash 不一致必须检出 matches=false(这是「暂缓放量」的判据)');
assert.ok(s.lkg, 'LKG 记录本身仍应可读,供控制端定位回滚目标');
});
test('LKG 记录含回滚所需的全部字段', () => {
recordLKG();
const r = readLKG();
for (const k of ['hash', 'bytes', 'script_path', 'recorded_at', 'node_version', 'platform']) {
assert.ok(k in r, `LKG 缺少回滚所需字段: ${k}`);
}
assert.ok(existsSync(lkgFilePath()), 'LKG 文件应真实落盘');
});