Files
nodeagent/docs/DELIVERY-PIPELINE.md
T
jkooe 2d5be044ee chore(privacy): 交付流水线文档去掉其他项目名
`docs/DELIVERY-PIPELINE.md:10` 原以三个真实项目名举例说明「原方案面向 Web/容器
服务」,属审计基线的树内 PII 之一(存量,非本批次引入)。公开仓库不宜暴露项目
组合,改为中性表述「同类多项目实践」,论证语义不变。

注:该处 PII 已存在于既有历史 blob(2026-10-17 起),彻底清除需重写整段历史,
属独立决策,不在本批次范围。
2026-10-26 21:11:43 +08:00

24 KiB
Raw Blame History

nodeagent 交付流水线规范

本文是《代码审计 → 上线 完整交付方案》v1.0(2026-10-14)在 nodeagent 项目上的落地适配。 适配日期:2026-10-17。基线提交:590f62e。


0. 适配原则(先说清楚为什么不能照搬)

原方案面向 Web/容器服务(同类多项目实践)——有服务端、有镜像、 有多套环境、有线上流量。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)
控制端手动回滚入口(nodeagent rollback,读 LKG → 覆盖回入口 → 重启 → 复核) ✅ v2.1.0 新增(需被控端可达)
启动期自动恢复(看门狗:启动连续失败 N 次 → 从 LKG 恢复) ✅ v2.1.0 新增(scripts/startup-guard.mjs,三端 supervisor 已接入)
运行期自动回滚(更新后健康检查失败即自动回滚到备份) ⚠️ 刻意不做,见下

LKG 的设计(v2.0.x 落地,v2.1.0 补全量指纹)

  • 写入时机:agent 成功启动后(服务器已 listen、心跳已起)才写 agent-lkg.json。 不在更新写完时就写 —— 那会把尚未验证的版本当成已知可用,LKG 就失去意义。
  • 内容:hash(与 system.info 的 build.hash 同口径,前 12 位)、 hash_full(sha256 全量 64 位,v2.1.0 起记录 —— 备份指纹匹配用全值, 消除 12 位前缀碰撞的理论风险)、bytes、script_path、 backup_glob(v2.1.0 起,回滚内容候选的文件名模式 <basename>.bak*)、 recorded_at、node_version、platform。 老记录(无 hash_full/backup_glob)不猜:消费方回落 12 位前缀比对与 <basename>.bak* 约定,向后兼容。
  • 暴露:system.info.lkg = { lkg, current_hash, matches, byte_delta }。 matches=false = 当前版本还没被证明能启动 → 控制端应暂缓向其余设备放量。

回滚通道:手动 rollback + 看门狗自动恢复 + 边界明示(v2.1.0 定稿)

场景 通道 前提
新版本能启动但需回退(行为异常 / 主动避险) 手动 nodeagent rollback [--dry-run] [--backup <远端路径>]:指纹比对定位备份 → 覆盖回入口 → 受控重启 → 复核 build.hash == LKG 被控端可达(RPC 命令;--dry-run 只读零写)
新版本进程完全起不来(无监听可连,CLI 救不了) 看门狗自动恢复 scripts/startup-guard.mjs:supervisor 每次拉起先过守卫,启动连续失败 N 次(默认 3)自动从 LKG 恢复入口 外层 supervisor 在位(Windows 计划任务 / Linux systemd / macOS launchd,三端均已接入)
agent.mjs 自身损坏到 Node 无法加载 / Node 运行时缺失 带外恢复(见附录 C):备份文件 + LKG 文件手工还原 物理或远程桌面可达

为什么恢复目标只有 LKG 一个:回滚循环(回滚 → 启动 → 又判定失败 → 再回滚)在 无人值守设备上比「停在坏版本」更难恢复。看门狗因此只做「回到上一个已知能启动的版本」 这一件事,恢复后计数归零;不做多版本链式回滚、不做版本选择。

边界(明示,任何文案不得暗示「失联也能一条命令救回」): ① nodeagent rollback 是 RPC 命令,需被控端可达 —— 它救的是「新版本能启动但需回退」; ② 看门狗自身也是 Node 脚本 —— agent.mjs 损坏到 Node 无法解析、或 Node 运行时缺失时, 守卫同样无法运行,只能带外恢复(附录 C); ③ 看门狗只守护「启动」这一时刻;运行期崩溃由外层 supervisor 的重启策略负责 (systemd Restart=always / 计划任务 RestartCount=10)。

看门狗的判据(为什么不会误回滚):

  • 只计启动期死亡(子进程存活 < 30s 宽限期);运行期死亡(计划内重启杀的是长命进程)不计;
  • 端口已被别的实例服务 → 判定「使命达成」,退 0 不计数(防 supervisor 重启造成的撞车被误记成启动失败);
  • 无 LKG / 无指纹匹配的备份 → 记日志后放行,绝不擦除 install-dir;
  • 计数在 agent listen 成功后由 agent 自身归零(与 LKG 同一铁律:不把「启动中」当成功);
  • 恢复动作全量记 <数据目录>/logs/startup-guard.log(JSONL:时间、动作、命中备份、结果)。

为什么运行期不做自动回滚(明确的设计取舍,v2.1.0 收窄)

原三问(无法归因 / 回滚循环 / 失控)在启动期已被看门狗以受控方式回答 (判据见上:只看启动期死亡、单一 LKG 目标、恢复后归零、全程日志、无匹配不动盘)。 但运行期仍维持原取舍、刻意不做:

  1. 无法归因:远程设备上区分不出「更新本身坏了」与「网络抖动 / 端口被占 / 依赖服务未起」。
  2. 回滚循环:见上 —— 运行期自动回滚会把「停一次服务」放大成「来回换版本」。
  3. 失控:被控端一旦在运行期自作主张改自己的程序,控制端就失去对「当前跑的到底是什么」的掌控。

故定位为:LKG 提供确定的回滚目标;启动期决策交给看门狗(有界、可审计), 运行期决策交给人(nodeagent rollback)。写入期的闭环(备份、哈希校验、写后校验、 不一致即回滚)由 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)

按优先级排序,✅ 表示本次已落地:

  • 建立 .gitignore 密钥基线(PSK.txt / keys/ / *.key / *.pem / config.json / agent.json)— 已有
  • 密钥扫描(gitleaks)进 CI 并阻断合并 — 本次落地(quality-gate.yml)
  • 依赖漏洞扫描(pnpm audit --audit-level=high)进 CI — 本次落地
  • SAST(Semgrep TS/JS 规则集)进 CI — 未做:需先定规则集,否则噪声会淹没真问题
  • 覆盖率采集接入 CI(全量观测 + 变更文件 ≥70% 阻断)— 本次落地(scripts/ci/check-coverage.mjs)
  • 提交信息规范强制(Conventional Commits + CI 校验)— 本次落地(scripts/ci/check-commit-msg.mjs)
  • 交付物 PE 架构断言 — 已有,升格为门禁
  • 打包期 .ps1 BOM / .cmd ASCII+CRLF 守卫 — 已有
  • CI artifact retention-days 30 → 7 — 本次落地
  • 保留「最后已知可用版本」标记(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)

# .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. 被控端可达(能连上)→ nodeagent rollback(指纹比对定位 LKG 备份 → 覆盖回入口 → 受控重启 → 复核 build.hash == LKG;--dry-run 可先只读比对)。
  2. 被控端起不来(连不上)→ 看门狗应已介入:启动连续失败 3 次后自动从 LKG 恢复,过程见 <数据目录>/logs/startup-guard.log。若看门狗未恢复(无 LKG / 无指纹匹配的备份)或状态异常,登录目标机(物理或带外方式),用备份恢复:agent.mjs.bak(或 agent.mjs.bak-<ts>)→ agent.mjs。
  3. 若需回退发布通道:把 latest.json 指回上一版本 URL + sha256。
  4. 重启 agent(计划任务或 run-temp.cmd)。
  5. 复核:verify.mjs 全绿 → 记录时间线 → 写复盘 → 把根因变成一条会红的断言。

手动回滚与看门狗的分工:rollback 是「人决策、被控端可达」;看门狗是「机器决策、只限启动期」。 两者都只认 LKG 一个目标;边界(agent.mjs/Node 损坏时两者都无能为力)见 §6 的明示块。


变更记录

日期 内容
2026-10-17 初版。基于《代码审计 → 上线 完整交付方案》v1.0 适配,基线 590f62e,单测 341 全绿。
2026-10-25 v2.1.0:§6 定稿回滚通道 —— 手动 nodeagent rollback(需被控端可达)+ 看门狗自动恢复(scripts/startup-guard.mjs,启动连续失败 N 次从 LKG 恢复,三端 supervisor 接入)+ 边界明示(agent.mjs/Node 损坏时守卫也无法运行,只能带外恢复);「运行期自动回滚」维持刻意不做(三问论证收窄到运行期)。LKG 记录补 hash_full/backup_glob 只增字段(老记录回落 12 位前缀)。附录 C 同步。