diff --git a/.github/workflows/console-windows.yml b/.github/workflows/console-windows.yml index 7e5a476..5c930ac 100644 --- a/.github/workflows/console-windows.yml +++ b/.github/workflows/console-windows.yml @@ -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 diff --git a/.github/workflows/quality-gate.yml b/.github/workflows/quality-gate.yml new file mode 100644 index 0000000..81355c6 --- /dev/null +++ b/.github/workflows/quality-gate.yml @@ -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 }} diff --git a/apps/agent/src/capabilities/system.ts b/apps/agent/src/capabilities/system.ts index d570322..ccacdf4 100644 --- a/apps/agent/src/capabilities/system.ts +++ b/apps/agent/src/capabilities/system.ts @@ -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 { network: { allow_from: configAllowFrom(), }, + // 交付流水线 §6:最后已知可用版本(回滚锚点)。 + // matches=false 表示「当前版本还没被证明能启动」—— 刚更新完、尚未完成一次成功 + // 启动时会是这种情况,控制端应据此暂缓向其余设备放量。 + lkg: lkgStatus(), }; if (fields && fields.length > 0) { diff --git a/apps/agent/src/index.ts b/apps/agent/src/index.ts index 4819000..2645d4b 100644 --- a/apps/agent/src/index.ts +++ b/apps/agent/src/index.ts @@ -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 { }, (msg) => console.log(`[WARN] ${msg}`), ); + // 交付流水线 §6:此刻脚本已加载、配置已读、证书已签、端口已绑、心跳已起 —— + // 走到这里说明「这个版本至少能启动」,故记为最后已知可用版本(LKG)。 + // 注意**不在更新写完时就记**:那会把尚未验证的版本当成已知可用,LKG 就失去意义。 + recordLKG(); + console.log(''); console.log(' nodeagent 被控端已启动'); console.log(' ─────────────────────────────────────────────'); diff --git a/apps/agent/src/lkg.ts b/apps/agent/src/lkg.ts new file mode 100644 index 0000000..b148447 --- /dev/null +++ b/apps/agent/src/lkg.ts @@ -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 }; diff --git a/apps/console/src/types/index.ts b/apps/console/src/types/index.ts index e4daad7..7c61e0b 100644 --- a/apps/console/src/types/index.ts +++ b/apps/console/src/types/index.ts @@ -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; } /** diff --git a/docs/DELIVERY-PIPELINE.md b/docs/DELIVERY-PIPELINE.md new file mode 100644 index 0000000..653d903 --- /dev/null +++ b/docs/DELIVERY-PIPELINE.md @@ -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-`)+ `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: 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-` → `agent.mjs`。 +2. 若需回退发布通道:把 `latest.json` 指回上一版本 URL + sha256。 +3. 重启 agent(计划任务或 `run-temp.cmd`)。 +4. 复核:`verify.mjs` 全绿 → 记录时间线 → 写复盘 → **把根因变成一条会红的断言**。 + +--- + +## 变更记录 + +| 日期 | 内容 | +|---|---| +| 2026-10-17 | 初版。基于《代码审计 → 上线 完整交付方案》v1.0 适配,基线 `590f62e`,单测 341 全绿。 | diff --git a/packages/protocol/src/capabilities/manifest/system.ts b/packages/protocol/src/capabilities/manifest/system.ts index 051e19d..9ff234c 100644 --- a/packages/protocol/src/capabilities/manifest/system.ts +++ b/packages/protocol/src/capabilities/manifest/system.ts @@ -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 表示当前版本**尚未被证明能启动**(刚更新完、还没完成一次成功启动),' + + '控制端应据此暂缓向其余设备放量。仅在「本次启动真正起来了」之后才写入。', + }, }, }, }, diff --git a/scripts/ci/check-commit-msg.mjs b/scripts/ci/check-commit-msg.mjs new file mode 100644 index 0000000..612b23c --- /dev/null +++ b/scripts/ci/check-commit-msg.mjs @@ -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 ] [--stdin]\n` + + ` 类型白名单: ${TYPES.join(', ')}\n` + + ` 格式: (): `, + ); + 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要求格式: (): `); + console.error(`type 取值: ${TYPES.join(', ')}`); + console.error(`示例: fix(console): sidecar 补 CREATE_NO_WINDOW(不再弹常驻黑窗)`); + process.exit(1); +} + +console.log(`✓ 提交信息规范校验通过(${items.length} 条)`); diff --git a/scripts/ci/check-coverage.mjs b/scripts/ci/check-coverage.mjs new file mode 100644 index 0000000..ce3dc9a --- /dev/null +++ b/scripts/ci/check-coverage.mjs @@ -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 lcov 路径(默认 coverage/lcov.info)\n` + + ` --threshold 变更文件行覆盖率阈值(默认 70)\n` + + ` --base 比对基线(如 origin/main);为空则只看全量\n` + + ` --enforce-total 全量不达标也阻断(默认只观测)\n` + + ` --total-threshold 全量阈值(默认 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✓ 覆盖率门禁通过'); diff --git a/tests/unit/lkg.test.mjs b/tests/unit/lkg.test.mjs new file mode 100644 index 0000000..1b0a983 --- /dev/null +++ b/tests/unit/lkg.test.mjs @@ -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 文件应真实落盘'); +});