Files
nodeagent/CHANGELOG.md
T
jkooe 0b858bb343 docs(changelog): 记录本轮四项遗留修复 + 单测数 477→480
- CHANGELOG [未发布] 补:依赖彻底清零、看门狗日志轮换、L2 参数常显、
  e2e 子进程兜底清理;
- README 单测数同步 480(docs-numbers 守卫强制耦合)。
2026-10-27 08:04:33 +08:00

88 KiB
Raw Blame History

变更日志

本文件记录 nodeagent 的显著变更。格式参考 Keep a Changelog, 版本号遵循 语义化版本。


[未发布]

修复 —— 发版脚本在版本号已钉在树中时卡死(451d44a)

scripts/release.mjs 无条件执行 git commit。当版本号已经作为 chore(release) 提交进树(v2.1.0 的实际流程)时,git add -A 后暂存区为空,git commit 以 nothing to commit 非零退出,整个发版停在最后一步,且报错来自 git 而非发布逻辑 (极易误判为工作区问题)。改为:暂存区为空则跳过 commit,标签与 Release 照常创建。

安全 —— 依赖漏洞清零(pnpm audit --audit-level=high 由 3 high → 0)

Quality Gate 的「依赖漏洞扫描(SCA)」在 451d44a 上为红,全部来自 @modelcontextprotocol/sdk 的传递依赖与本仓 devDependency:

  • zod <4.6.6(GHSA-mrhx-c7xc-m3w5)→ 根 pnpm-workspace.yaml 加 overrides ^4.6.6;
  • body-parser <2.4.0(GHSA-27r3-jxmh-p828)→ overrides ^2.4.0;
  • vite <8.3.3(GHSA-587h-7wmj-h5pm)→ 控制台 devDependency ^8.3.5。

⚠️ pnpm 12 起 package.json 的 pnpm 字段不再被读取(设置迁移到 pnpm-workspace.yaml)—— 写在那里会被静默忽略,与「一直红所以等于没有」 是同一类失败形态,故覆盖项写在 workspace 文件里并注明原因。

安全 —— 依赖漏洞彻底清零(中低危一并处理)

在上一节之后复扫仍余 6 条(2 low / 4 moderate),全部经 MCP SDK 传递:

  • hono <4.13.11(serveStatic 路径二次解码绕过中间件)→ overrides ^4.13.14;
  • ip-address <=10.7.3(zone 标识符二次方耗时 DoS)→ overrides ^10.7.5;
  • fast-uri <3.1.9(scheme 伪造 / host 混淆 / 路径等价 4 条)→ overrides ^3.1.9。

三条与 dependabot #3/#6/#7 同源,覆盖后那三个 PR 已无增量(随提交一并关闭)。 当前 pnpm audit 输出 No known vulnerabilities found。

修复 —— 看门狗日志无界增长(常驻被控端,写满磁盘会拖死 agent)

startup-guard.log 一直是 appendFileSync 无限追加。看门狗本身常驻被控端, 「坏版本 + supervisor 反复重启」时写得很快,磁盘满会连带把 agent 的审计写入拖死。 现按 1MB × 4 份(当前 + .1….3)轮换:超阈值先丢最旧、再逐个后移、当前滚为 .1。 轮换与写入双双失败静默 —— 日志问题绝不能反过来拦住被控端启动。 判断逻辑放 startup-guard-core.mjs 纯函数(shouldRotate / rotationTargets), 并补 3 条单测(阈值边界含 NaN、命名序列、真实滚存三轮)。

修复 —— 控制台 L2 采集参数必须先出结果才出现

AuditView.vue 里「采集窗口 / 条数上限」两个输入框原先在 v-if="l2Rows.length" 容器内 —— 用户想「先设 72 小时再采集」做不到,只能先按 默认值点一次再改。参数与结果无生命周期关系,已拆出容器常显。

修复 —— e2e 连跑偶发失败(固定端口被上一轮残留占用)

套件有 8 处 spawn,各自 finally 只发 kill()(SIGTERM)就 process.exit, 被控端可能来不及退出 → 下一轮固定端口仍被占,表现为「连跑第二次偶发失败,重跑又绿」。 改为包一层 spawn 统一登记子进程(调用点零改动),进程退出时对存活者 SIGKILL; 单点收口,将来新增 spawn 也不会漏。实测连跑两次 34/34、退出后无残留 agent 进程。

[2.1.0] - 2026-10-26

本段为 v2.1.0 定稿段(版本统一见 a75c5c9):selfupdate 代理出网、LKG 内容指针、 三个架构审查 P0 修复、L2 三端暴露,以及 rollback / 看门狗 / na-gui 清理 / 收口。

新增 —— 控制端 nodeagent rollback:从 LKG 回滚被控端(ARCH T03 / PRD P0-2)

rollback 补上「新版本能启动但需要回退」的通道:读 LKG → 枚举备份(*.bak/*.bak-*, mtime 新→早)→ 分块流式 sha256 指纹匹配(hash_full 全量优先,老记录回落 12 位前缀) → 覆盖写入口文件(同 deploy 姿势:就地覆写不用 rename,Windows 真机教训)→ 写后校验 (失败用回滚源还原)→ 独立连接重启 → 90s 墙钟复核 build.hash == LKG。

  • --dry-run 只读零写(列出候选与匹配结果);--backup <路径> 逃生门:显式指定 回滚源、跳过指纹匹配(用于 LKG 记录丢失/全部备份被污染的极端场景)。
  • 无 LKG / 无指纹匹配:列候选后零写退出(code 2),不碰被控端一个字节。
  • 纯函数(planRollback / pickBackupByHash / hashRemoteFile)与 IO 分离, 假 client 注入单测 19 例(含写后校验失败自动还原、多候选时序、Windows 路径)。
  • 能力圈边界写进 HELP 与 README:RPC 命令需被控端可达;「完全起不来」的场景 由看门狗接管(下节)。

新增 —— 被控端启动看门狗:连续失败 N 次自动从 LKG 恢复(决策 R-1)

rollback 覆盖不了「进程完全起不来」—— 没有监听可连,RPC 无能为力。看门狗跑在 supervisor 链上(Windows 计划任务 / Linux systemd / macOS launchd 三端已接入), 启动连续失败 N 次(默认 3)自动从 LKG 指纹匹配的备份恢复入口文件。

  • scripts/startup-guard.mjs(裸环境可跑:零项目依赖,只依赖 Node ≥22 内置模块)
    • scripts/lib/startup-guard-core.mjs(纯函数零 import:状态机判断可单测)。
  • 30s 启动宽限期 + 端口探活,两类误计数被消除:① agent 自身重启机制 (kill -9 / taskkill 打死长命进程)是计划内重启;② supervisor 重启造成的端口撞车 (新实例 EADDRINUSE 秒崩)—— 不区分的话连续 3 次即误回滚好版本。
  • 安全边界:无 LKG / 无指纹匹配只记日志放行,绝不擦除 install-dir;恢复目标 恒为 LKG(单一、已验证,无回滚循环);恢复动作全量记 JSONL 日志。
  • 被控端启动成功后归零失败计数(resetStartupGuardState,index.ts 唯一改动点); 守卫 spawn 时强制注入 NODEAGENT_HOME = --data-dir(Windows 计划任务动作不带 环境变量,不注入则 agent 数据目录与守卫漂移,恢复链路静默失效)。
  • Windows RestartCount 3 → 10:看门狗需要「连续失败 3 次 + 恢复」才生效,3 次不够用 (良性撞车退 0 不消耗重启次数,故 10 不会造成重启风暴)。

新增 —— na-gui 陈旧计划任务白名单清理(ARCH B-P1-6)

历史版本在被控端上注册过 na-gui 等旧名计划任务,漏删会留下「端口被占、 新版本起不来」的幽灵任务。

  • scripts/lib/scheduled-tasks.mjs:TASK_WHITELIST(nodeagent / na-gui / nodeagent-console)+ planTaskCleanup 纯函数。白名单外一律不删 —— 误删用户自己的任务是比漏删严重得多的生产事故;精确匹配(大小写敏感), 宁可漏删也不模糊匹配。
  • scripts/uninstall.ps1:卸载末尾按白名单逐个注销(存在性检查 + 动作进日志)。
  • scripts/control.ps1:新增 cleanup 子命令(control.cmd 菜单第 8 项), 供「拿到端口占用提示后自救」。
  • 单测 10 例(白名单命中才删 / 白名单外零动作 / 空列表 / 重复名 / 脏输入)+ ps1 源码守卫与跨端一致性守卫(白名单不许 mjs 与 ps1 两边漂移,注入故障实测会红)。

收口 —— L2 e2e 补齐 + 文档数字刷新与数字守卫(ARCH T05)

  • e2e:run.mjs 补 L2 平台门控用例(非 Windows 明确报 E_UNSUPPORTED_PLATFORM,按错误名断言不按 code —— 协议级错误才 throw, 能力执行失败走 result.status=failed);windows.mjs 补 status/collect/verify 三条(字段与 manifest returns_schema 逐字对齐,collect 对非管理员环境 skip 容错)。 e2e 总数 33 → 50(34 通用 + 16 Windows 专属)。
  • README 数字刷新:52 项能力 · 42 个 MCP 工具 · 467 项单元测试 · 50 项端到端用例 · 23 项 Rust 壳单测;补 nodeagent rollback 与看门狗段、 na_audit_l2 工具表项;删「控制台安装包只作为 CI artifact」过期行 (9f95560 起已随 Release 发布)。
  • 数字守卫 tests/unit/docs-numbers.test.mjs:README 五个数字与可复现实测口径 (manifest 条目 / defs.ts 工具 / node --test 实测 / e2e 源码 / Rust 属性)钉死, 不一致即红 —— 「增能力不改 README」从此会红(注入故障实测验证)。

修复 —— selfupdate 在境内必然失败(P0-1:裸 fetch 不读代理)

真机症状:被控端自更新下载 fetch failed。根因不是网络差 —— Node 的 fetch(undici) 不读 HTTPS_PROXY 等环境变量,而境内访问 GitHub 必经代理。

  • 新增 apps/agent/src/net/proxy.ts + net/index.ts:sidecar 已验证出网方案的 TS 移植。 代理来源优先级 NA_PROXY → 目标协议环境变量 → 系统代理(Windows reg query WinINET / macOS scutil --proxy);PAC/socks5 明确报 note 不静默降级;重定向链共享超时预算 (防 5 跳×30s 穿过 RPC 超时);CONNECT 隧道 abort 自管(外层 request 建立前 signal 无人 受理 —— 真机 75s 配置挂 109s 的教训);TLS 默认校验证书。只依赖 node 内置模块, 不 import 项目内任何模块。
  • httpGet 增 maxBytes 双闸(content-length 预检 + 流式中断),保住「64MB 上限」。
  • 错误分类同款移植 sidecar main.mjs:146-165:ECONNRESET 不得报成「请求超时」 (真机教训 —— 报错类型错会把人带去排查完全不相干的方向)。
  • 回环目标恒直连(有意的设计偏离):代理另一侧的 localhost 不是本机 —— CI/dev 机 设了 HTTP_PROXY 时,发往 127.0.0.1 自建镜像的请求会被送进远程代理而 404。
  • 备份保留最近 3 个(planBackupCleanup 纯函数,最旧先删)。
  • 安全属性逐条不变:sha256 强制校验 / 先校验后落盘 / 写后校验失败回滚 / 就地覆写不用 rename(Windows)/ 只接受 http(s)。

变更 —— LKG 补内容指针(A-P0-3),rollback 有确定目标

原 LKG 只有 12 位指纹,没有「备份文件长什么样」的记述,rollback 时只能猜。

  • LkgRecord 只增两字段:hash_full(sha256 全量 64 位)与 backup_glob(<basename>.bak*); 老记录无字段时原样通过(向后兼容)。
  • 新增 lkgHashMatches:有 hash_full 按全量比,老记录回落 12 位前缀,两侧都无值 不猜(返回 false)。
  • system.audit.l2 的 returns_schema 从空壳补全为四 action 属性并集; system.agent.update returns 增 via/proxy/proxy_source/outbound_notes 四个只增字段 (失败时能看出「走了代理还是直连、用的哪个代理源」)。

修复 —— 架构审查三个 P0(静态审查发现,均补「注入即红」守卫)

  • A-P0-1 validate 原型链绕过:key in schema.properties 沿原型链,JSON 里写 "constructor"/"__proto__" 会被判「schema 已声明」从而跳过拒绝(fail-open, 与已修的 ACL B-03 同源复发)。改 Object.prototype.hasOwnProperty.call。
  • A-P0-2 限速桶 key 用错值:原实现把限速值当桶名(clientId|5)—— 两条同值 规则的能力共用一个桶,互相挤占/漏过;且桶只增不删 + psk 模式 client_id 自报 → 可枚举造键 → 内存无界增长。改为 ${认证身份 ?? 远端地址}:${能力名} + 过期键清扫
    • 容量上限淘汰最旧(滑动窗口边界与旧实现逐字节一致)。
  • A-P0-4 describeArgs 大参数阻塞事件循环:原实现每次 invoke 全量 JSON.stringify + sha256,fs.write 的 data 可达数 MB —— 同步序列化阻塞期间 GUI 注入、事件推送、保活 ping 全部延迟。改为超过 64KB 只哈希前 64KB(按字节切), 总长记 args_bytes、截断记 args_truncated。口径变化只影响审计摘要, 不影响任何安全属性(摘要用途是比对与防泄密,不是完整性证明)。

新增 —— L2 三端暴露(MCP na_audit_l2 + 控制台 L2 分区 + agent.l2 审计类型)

决策「L2 同时暴露 MCP+控制台」。此前 system.audit.l2 只存在于被控端实现里: MCP 无工具(模型看不到)、控制台无入口无类型(用户点不到)、审计事件写成 agent.start(与进程生命周期混为一类,筛不出来)。

  • MCP(41 → 42 个工具):na_audit_l2 紧随 na_audit,四 action (status/enable/collect/verify)+ since/limit,映射 system.audit.l2。
  • 控制台:AuditView 新增 L2 卡片(查状态/拉取采集/校验 L2 链/启用策略 —— enable 有二次确认并写明需管理员权限);非 Windows 被控端按钮禁用并明示原因; 信任边界如实说明(采集器与 agent 同权限,防篡改依赖转发出机,不夸大本地副本)。
  • agent.l2 审计类型四端同源:protocol AUDIT_TYPES(真源)→ agent 联合 (AssertExact 编译期守卫)→ 写入点 → 控制台联合。
  • 顺手修了两个守卫盲区:MCP 工具名正则 na_[a-z_]+ 匹配不了带数字的名字 (na_audit_l2 会整个漏扫);平台门控守卫原只扫 app.ts/system.ts 两种写法, audit-l2.ts 的裸 throw 扫不到 —— 已改用统一 assertWindows 并纳入扫描范围。

新增 —— L2 系统级审计兜底(7d781d1,docs/AUDIT-L2.md)

L1 是 agent 自己记的,只看得见经过自己 RPC 的调用;攻击者绕过 agent 直接操作机器、 或把 agent 停掉时 L1 全盲。L2 采集操作系统自己的审计事件,补的正是这块。

  • 信任边界(写进能力描述与文档,不夸大):采集器与 agent 同进程同权限 —— 能停 agent 就能停采集器。故本地 audit-l2.log 只是归一化副本,防篡改价值有限; 真正的独立性来自把记录转发出机(与 SECURITY.md §4.8 锚定同一思路)。本实现不自动转发。
  • Windows 落地(选内置 auditpol 而非 Sysmon —— 第三方驱动需单独分发、重启生效、 版本行为有差异;内置策略零依赖即可立起来;检测到 Sysmon 则并用,未装不尝试下载):
    • status:查策略是否启用,并提示两个关键开关 —— SCENoApplyLegacyAuditPolicy (不设它,组策略旧式类别策略会盖掉 subcategory,表现为「auditpol 显示已启用但 事件日志里没有 4688」)与 ProcessCreationIncludeCmdLine_Enabled(不开则 4688 只有进程名,看不到参数);
    • enable(需管理员)/ collect(拉取并归一化落盘,独立于 L1 的 audit-l2.log)/ verify(校验 L2 链,与 L1 同构的「对含 prev 的记录求哈希」)。
  • 三端现状如实记录:Linux(auditd)需预装、macOS(ESF)受 Apple entitlement 限制,均未实现 —— 非 Windows 被控端明确报 E_UNSUPPORTED_PLATFORM, 不伪装成空结果。CLI 已有 nodeagent audit l2 入口;v2.1.0 起 MCP 与 控制台亦暴露(见上方「L2 三端暴露」)。

新增 —— 交付流水线规范落地(2bf45fd,docs/DELIVERY-PIPELINE.md)

将《代码审计 → 上线 完整交付方案》适配到 nodeagent。原方案面向 Web/容器服务, nodeagent 无服务端、无容器、无多环境 —— 照搬会产生「跑得很好但守不住任何东西」的 假门禁,故每个阶段给三档判定:适用 / 改造后适用 / 不适用(但必须给替代)。

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

  • 覆盖率门禁 scripts/ci/check-coverage.mjs:全量只观测不阻断(实测基线 66.9%), 变更文件行覆盖 ≥70% 阻断。
  • LKG 回滚锚点:被控端「能启动」后记最后已知可用版本(agent-lkg.json), 给回滚提供确定目标。回滚策略分两档,边界写死: 启动期自动恢复已做(看门狗:启动连续失败 N 次 → 从 LKG 恢复, scripts/startup-guard.mjs,Windows 计划任务 / Linux systemd / macOS launchd 三端 supervisor 已接入);运行期自动回滚仍刻意不做(更新后健康检查失败即回滚 无法归因、有回滚循环风险、失控 —— 决策权留给控制端,nodeagent rollback)。

新增 —— Linux 被控端适配(00992e5,docs/DEPLOY-LINUX.md)

面向「服务器型被控端」(命令/状态/文件/审计;无 GUI),实测环境 Debian 12。

  • scripts/install-linux.sh:一键安装器(对齐 Windows install.ps1 语义)—— 自动获取 Node 运行时(走 npmmirror)、systemd 服务(系统级 / 用户级+linger / --no-service 三种模式)开机自启、幂等(复用既有密钥并自动备份 agent.json)、 卸载保留配置(--purge 才删)。
  • apps/agent/src/util/platform.ts:统一平台守卫(assertPlatform / assertGuiPlatform / assertWindows),抛 E_UNSUPPORTED_PLATFORM 并带上 「支持哪些平台」。修复前的表现是误导性错误(screen.capture 在 Linux 上报 「region 宽高必须 ≥ 1」,像是参数没传对)。
  • scripts/verify.mjs:补 E_UNSUPPORTED_PLATFORM → skip(此前把「平台不支持」 记成失败)。

变更 —— 质量门禁与 CI 修复(141f479 / c7d596e / 630ea7d / 3680218)

质量门禁曾连续 8 次运行全部失败(从引入起就没绿过)—— 一道长期红的门禁等于 没有门禁。两处独立原因:

  • SCA:.npmrc 指向 npmmirror(加速用),但镜像不实现 audit 接口, pnpm audit 必报 ERR_PNPM_AUDIT_ENDPOINT_NOT_EXISTS → 显式加 --registry=https://registry.npmjs.org。
  • typecheck 缺 build 顺序:各包 import 的是 @nodeagent/protocol 的类型产物 (dist/*.d.ts)。CI 全新检出没有 dist → 集体 TS2307: Cannot find module, 而本地因为 dist 还在看不出来(典型「本地绿 / CI 红」)→ typecheck 前先建 protocol。 注入验证:移走 packages/protocol/dist 复现 TS2307 → 执行 build 步骤 → 零错误。
  • 精确忽略 4 处测试占位密钥误报(630ea7d);typecheck 前改为全量构建 (只建 protocol 不够,3680218)。

新增 —— 控制台安装包接入 Release(9f95560)

scripts/release.mjs 此前只产出 agent / cli / mcp,apps/console 从来没接进去。 后果很具体:控制台修复只存在于 CI artifact,而 artifact retention 是 7 天 —— 用户从 Release 页面下载到的永远是旧控制台(「修复发了但没人拿得到」)。

  • console-windows.yml 加 workflow_call: + publish job:只有 tag 才发布; 下载两架构产物 → 断言安装包数 ≥ 4(exe+msi × x64/arm64),少了直接报红, 绝不静默少发一个架构;gh release upload --clobber 挂到 Release。
  • 新增 console-release.yml:只认 push: tags: ['v*']。
  • 为什么不把 tags 直接写进 console-windows.yml:它带 paths 过滤, paths 与 tags 并存时 tag 推送会被路径判定挡掉而静默不触发 —— 失败形态是「什么都没发生」,最难察觉。拆成「日常验证走分支 + 发布只认 tag」最稳。

主题:控制台「去命令行化」 —— 双击应用即用,启停/重启/更新/日志/托盘常驻/开机自启全部在界面里完成。 目标原文:「不再打开命令行窗口,运行应用之后,有启动、停止、重启、检查更新等功能, 有日志功能,可以后台运行和开机自启等等功能」。

末段另含两轮纯界面调整:一律不使用图标(emoji 在部分渲染器里退化成豆腐块)、 配色收敛为「简约白 / 高级灰」两套中性灰阶主题。

新增 —— 本机被控端托管(apps/console/src-tauri/src/agent.rs,约 560 行)

  • 新增「本机服务」页(/local,默认首页)与「服务日志」页(/logs)。 默认路由由 /nodes 改为 /local:打开应用先看到「本机被控端」的启停面板, 而不是先要求用户填一个连接地址 —— 这是「运行应用之后就有启动/停止/重启」的落点。
  • 进程生命周期管理:AgentManager(STOPPED / STARTING / RUNNING / STOPPING / CRASHED 五态)。 启动、停止、重启均走界面按钮;stop() 在 Windows 走 taskkill /PID <pid> /T /F(连子进程树一起收), POSIX 走 child.kill(),随后轮询确认端口已释放才算停干净。 重复启动被拒绝(不会起出两个进程),异常退出自动重启(上限 5 次,超限停在 CRASHED 并保留原因)。
  • 日志功能:同时捕获被控端 stdout/stderr 两路,环形缓冲 4000 行。 每行带一个单调递增的 seq —— 这是刻意设计:环形缓冲淘汰旧行后,若用「位置下标」做游标, 前端的增量拉取会静默错位;独立 AtomicU64 发号可跨淘汰保持连续。 界面侧:实时事件推送、关键字过滤、三流(stdout/stderr/system)复选框、自动滚动、 暂停接收、重新拉取、滚到底部、导出文本、清空。
  • 界面化自更新:check_update / download / apply_update 三个能力。
    • check_update 读 GitHub Release 的稳定别名 latest.json(而非 API),不占用 API 配额、不受限流影响。
    • download 的 --sha256 是必填(不校验哈希等于开放远程代码执行),校验不通过不落盘,写后再复核一次。
    • apply_update 的替换顺序:下载到 runtime/agent.mjs.staged-<pid> → 复用已有被控端停服 → 备份现有脚本 → 原子替换 → 写 agent.seed.json 版本 → 按需回启。 任一步失败都停在替换之前,不会留下半截产物。
  • 开机自启(autostart.rs):macOS 写 LaunchAgent ~/Library/LaunchAgents/cn.jkooe.nodeagent-console.plist (RunAtLoad;刻意不用 KeepAlive,否则会与控制台的崩溃自愈形成两层守护互相打架); Windows 用计划任务(登录用户 + Interactive + Highest)。 一个易错点:macOS 的 bootstrap 目标 gui/<uid> 里的 uid 不能硬编码 501(那只是首个本地用户的常见值), 改为运行时 id -u 现取。
  • 后台常驻(托盘):托盘图标 + 右键菜单(显示主窗口 / 启动 / 停止 / 重启 / 退出); 左键单击图标唤出窗口。窗口 CloseRequested → prevent_close() + hide(), 关窗不退出,与「后台运行」的要求一致;真正退出走托盘菜单。
  • --agent-only 仅服务模式:开机自启拉起的是控制台自身(GUI 子系统程序,不带控制台窗口), 进入该模式后不显示窗口、只把被控端托起来;而不是去直接拉起裸 node。

新增 —— 消灭命令行窗口(三条措施,缺一不可)

# 措施 位置 不加会怎样
1 控制台是 GUI 子系统程序,自身不弹控制台 Tauri 默认 打开应用先弹黑窗
2 spawn node.exe 时加 CREATE_NO_WINDOW(0x0800_0000) agent.rs 的 creation_flags 每次启动被控端都闪一个黑窗 —— 正是用户要求消灭的现象
3 开机自启拉起控制台而非裸 node autostart.rs + --agent-only 开机时黑窗一闪而过

新增 —— 契约守卫(tests/unit/console-commands.test.mjs,8 用例)

把「改了名字两边不同步、但不报错」的三类静默失效钉死,避免「界面点了没反应、控制台无异常」:

# 守卫 抓什么
1 前端每个 invoke("x") 都已在 Rust generate_handler! 注册 前端调了个不存在的命令 → 运行时才 not found
2 Rust 注册的命令都被前端使用 死命令堆积(改名后残留的旧命令)
3 emit("na-*") ⇄ listen("na-*") 双向一致 事件名不匹配 → 界面永远不刷新,且无任何报错
4 tauri.bundle.gen.json 资源目标名 ⇄ Rust resolve_* 字面量 映射改名 → 生产包内找不到 sidecar/node/agent
5 sidecar 方法名 ⇄ Rust .request("x") 跨语言调用错名
6 新页面已注册路由 + 默认路由指向 /local + 懒加载的 view 文件真实存在 「写了页面但进不去」/循环依赖导致的路由表推导失败
7 自定义样式用到的 var(--na-*) 都由 cssVars() 定义 拼错变量名不报错,颜色只是静默落回继承值 —— 类型检查、构建、运行全都不吭声
8 index.html 的首帧兜底色 ⇄ 调色板的 bg 首帧底色是全应用唯一允许的色值重复处;允许重复就必须有守卫

已注入故障实测会红:① 把 agent_start 改成 agent_startX、na-agent-state 改成 na-agent-statee → 当场 2 条失败;② 把某处 var(--na-sunken) 拼成 var(--na-sunkenTypo)(含大写)与 var(--na-sunkenx) → 第 7 条均失败;③ 把 index.html 的 #f4f4f5 改回 #ffffff → 第 8 条失败。 全部复原后 #pass = 8 / #fail = 0,文件逐字节还原。未验证过会红的守卫等于没有守卫。

第 7 条的正则刻意放宽到 [^)\s,;]+ 而非 [a-z0-9-]+ —— 后者遇到含大写的拼错会在中途停下导致 整个模式不匹配,于是「写错的变量名」反而逃过检查。这是注入故障时才发现的守卫自身漏报。

变更 —— 打包与单一实现

  • 被控端 esbuild 配置抽为 scripts/lib/bundle-agent.mjs(repoRoot / repoVersion / gitShortCommit / bundleAgent), scripts/pack.mjs(被控端发布包)与 scripts/prepare-console.mjs(控制台资源)共用同一份。 此前 pack.mjs 里内联了一份配置,控制台若再内联一份就是两套真相(改一处漏一处,只在产物里暴露)。 该模块刻意不注入构建时间 —— 否则每次打包字节都不同,产物指纹比对会失真。
  • 控制台随包分发被控端脚本:资源由 2 份(sidecar.mjs / node)增至 3 份(+ agent.mjs)。 带来的效果:用户装完控制台,本机被控端已经就绪,不需要另外手工安装一次才能点「启动」。
  • 播种防降级:<数据目录>/runtime/agent.seed.json 记录版本, 只有「随包版本 > 已安装版本」才覆盖数据目录里的脚本。 若写成「同版本才跳过」,则用户被控端自更新到更新版本后,下次开控制台会被静默降回旧版。 版本比较按数值逐段(2.0.10 > 2.0.9),字符串比较会误判。
  • verify-bundle.mjs 增加 agent.mjs 存在性检查 + 用打包内的 node 跑 --check agent.mjs。

变更 —— CI

  • .github/workflows/rust.yml:资源数断言 2 → 3;paths 增加 scripts/lib/bundle-agent.mjs 与 apps/agent/**; 构建步骤补 @nodeagent/agent(prepare:node 现在依赖它的 dist, 新步骤必须逐条核对前置产物由哪一步生成、顺序是否在其后 —— 本地验证时产物早就在,会把这层依赖完全掩盖)。
  • .github/workflows/windows-e2e.yml:步骤名与注释同步为含 agent.mjs 的说明。

新增 —— Windows 端控制台安装包(.github/workflows/console-windows.yml)

控制台此前只在 macOS 上出过包(.app / .dmg),而它的主战场恰恰是 「在 Windows 上管控其他机器」。本轮补上 Windows 构建:双架构矩阵 —— windows-latest 出 x64 .msi + NSIS -setup.exe,windows-11-arm 出 arm64 NSIS -setup.exe。

  • 为什么必须双架构:与 v2.0.2 那次事故同源 —— 包里装着错架构的 node.exe 时, ARM Windows 上「能启动、能 listen,但 TLS 握手不完成」,而 macOS 侧 100% 复现不出。 控制台更甚:它内含 node 运行时,还要用这个 node 托起本机被控端,即自己就是 TLS 服务端。
  • 为什么用原生 ARM runner 而非交叉编译:prepare-console.mjs 的自检要真执行 node.exe, x64 机器跑不了 arm64 二进制;交叉编译会逼着引入「跳过自检」这类削弱验证的开关 —— 而产出错架构运行时的时候,自检正是唯一能拦住它的那道闸。
  • cfg!(windows) 分支第一次被编译器看到:macOS 上的 cargo check 不会编译 cfg(windows) 代码, 故 taskkill 收进程树、PowerShell 计划任务、node.exe 路径等分支此前从未产出过 Windows 产物。
  • 实测产物:x64 .msi 34.2 MiB / NSIS 23.2 MiB;arm64 NSIS 19.5 MiB(均低于 40MB 目标)。 if-no-files-found: error + fail-fast: false:一条架构没产出就报错,同时一条挂了不掩盖另一条的结果。
  • 接入 Release:安装包起初只作为 CI artifact 产出(retention 7 天),已由本版 「控制台安装包接入 Release」一节补上 —— tag 发布时四包(exe+msi × x64/arm64) 挂到 GitHub Release,修复才真正到得了用户手里。

首个 run 两个 job 全红,但错的不是构建,是断言。(run 38151366534) 两个 job 的失败点完全一致,而日志显示构建链路一路全绿:构建机架构(arm64 runner 上 process.arch = arm64,原生属实)、随包 node.exe 的 PE Machine(0x8664 / 0xaa64)、 tauri build、产物自举(打包内 node v22.23.3 真跑起打包内 sidecar)四步全 success。 死掉的是本步自己写的守卫 —— 原断言「安装包体积 > 50MB」,理由是「含 Node 运行时约 80MB」, 这把未压缩的目录形态(.app ≈ 92 MiB)当成了压缩安装包的体积: LZMA 下 node.exe 自身能压到 1/3~1/4,实测 .msi 34.2 MiB、NSIS 19.5~23.2 MiB,阈值必然误杀。

更根本的问题是:体积阈值属于「中间逻辑自洽」式断言。包大不等于 node.exe 真在里面 (可能打进的是别的大文件),包小也不等于没在里面(压缩率会随 Node 版本漂移)。 判据必须落在产物内容上,不能落在间接指标上。

改为解开安装包查内容(.msi 走 msiexec /a 管理安装、NSIS 走 /S /D= 静默安装, 两者都是各自格式的核心能力,不引任何外部依赖),一次钉死三件事: ① node.exe / sidecar.mjs / agent.mjs 三样随包资源必须都在(缺任一,真机上就是 「应用能开、但托不起被控端」);② node.exe 的 PE Machine = 目标架构(最终交付物级架构断言); ③ node.exe 解压后 > 40MB(体积在这是正确用法:内容已验明后的自明性检查, 而不是「运行时在不在」的替代判据)。附带收益:真装一遍本身就是安装器冒烟。 另给该步加 timeout-minutes: 8,避免静默安装万一卡住把 job 拖满 60 分钟。

第二个 run(38152850819)两 job 仍同点红,但已是另一处原因,且同样是静默失效: 日志里 msiexec /a 失败(退出码 ) —— $LASTEXITCODE 是空的,而整步从开始到抛错 只过了 0.8 秒。根因是 msiexec.exe 与 NSIS 安装包都是 GUI 子系统程序, PowerShell 用 & 调用时既不等待其结束、也不设置 $LASTEXITCODE ($null -ne 0 判真 → 安装器还没跑完就抛错,报错读起来像「安装失败」,实为「压根没等」); arm64 那条没有 MSI、直奔 NSIS,失败形态一模一样,正是同一根因的第二个样本。 修法:改 Start-Process -Wait -PassThru 读 .ExitCode;参数拼成单个字符串传, 避免数组被自动加引号而破坏 NSIS /D= 的「原样、不加引号、且居末」硬要求; 加 Wait-PayloadRoot(30 秒收敛)防安装器「自己先退出、子进程继续装」的竞态误报。

第三个 run(38154282653)双绿,断言给出产物实证 —— x64 .msi/-setup.exe 内均解出 node.exe 82.9 MB / PE 0x8664、arm64 -setup.exe 内解出 node.exe 73.5 MB / PE 0xaa64, 三样随包资源齐全(sidecar 232 KB、agent 1060 KB)。 这一步证明的恰是体积断言永远无法区分的那件事:19.5MB 的压缩包里真的装着 73.5MB 的 arm64 运行时。

变更 —— 界面一律不使用图标

  • 去掉全部 emoji 图标:路由表 meta.icon 字段整体移除,侧栏导航由「图标 + 标题」改为纯文字。 触发原因是一次真实的渲染事故:emoji 图标在非系统字体栈的渲染器里退化成豆腐块(截图对比可见), 而「哪套字体栈一定带 emoji」跨平台并不成立 —— 与其赌字形,不如不用。
  • n-alert / n-empty / n-popconfirm 的内置图形一并关闭:这三个组件的 showIcon 默认均为 true, 故 16 处 n-empty 的空态插画、7 处 n-alert 的类型图标、3 处 n-popconfirm 的询问图标全部消失, 只剩文字与配色(严重程度仍由 type 的底色/边框区分,信息不丢)。 n-empty 是空态里的插画(比图标更「图形」),在 9 个页面都有,是最容易漏掉的一类。
  • 夹在文案里的符号也去掉:TerminalView 的「⚠ 高权限操作,请谨慎」保留纯文字。
  • 清理随包的模板残留资产:删掉 public/vite.svg、public/tauri.svg、src/assets/vue.svg 与 index.html 的 favicon 声明。public/ 下的文件会被整体复制进产物, 此前等于把 Vite / Tauri 的 logo 一起发了出去;index.html 的标题也还是模板默认值 (原生窗口标题取自 tauri.conf.json,不受影响),本次一并改齐。

边界(以下不算「界面图标」,未动):① 托盘图标 —— 菜单栏没有图标就点不到, 关窗后应用将无法寻回,属功能必需;② 被控端 CLI 自身打印的文本标记(⚠️/ℹ️/✅, 见 apps/agent/src/index.ts、packages/client/src/keys.ts、apps/agent/src/metrics.ts)—— 它们是程序输出内容,会原样出现在「服务日志」页里,但不是界面元素; ③ 控件的结构性部件(下拉箭头、折叠箭头、输入框清除钮、表格排序箭头)。

变更 —— 配色收敛为两套中性灰阶主题

  • 两套可切换主题:简约白(浅)与 高级灰(深),侧栏底部一行切换,选择存 localStorage。 两套都只用中性灰阶:去掉原先的青色品牌字(#63e2b7)与 naive-ui 默认的绿色主色, 主按钮 / 开关激活态 / 进度条 / 勾选框统一改吃「主色位 = 中性灰」这一个值。
    • 白主题的页底刻意不是 #ffffff(#f4f4f5):卡片本身是白的,两者同色就只能靠 1px 分线撑层次, 整页发「糊」;压一档浅灰后层次为「页底 < 侧栏 < 卡片」,且仍是白为主。
  • 语义色不是去掉,而是降饱和(灰绿 / 灰琥珀 / 灰红 / 灰蓝)。 这是运维工具,运行中 / 出错 必须一眼可辨,全部灰化会导致误读;故它们只出现在徽章与告警这类小面积上, 底色观感仍是「白 + 灰」。严重程度在关闭图标后本就更依赖 type 的底色/边框。
  • 色值只有一份定义(src/theme/index.ts 的 Palette):既生成 naive-ui 的 themeOverrides, 又由 App.vue 在运行期注入成 --na-* CSS 变量供自定义样式使用。 原因是若「TS 调色板」与「CSS 写死的色值」各存一份,换主题时会一半新一半旧且不报错、类型也抓不到 —— 这正是本轮要消灭的失效模式。
  • 清掉 17 处写死的色值(散在 8 个文件:日志三流着色、代码块/预览底色、节点选中边框、历史悬停等), 全部改为 var(--na-*)。复扫后自定义样式里的硬编码色为 0。
  • 主题偏好只存 localStorage,不落 Rust 侧 settings:那是给「仅服务模式」(无窗口、常驻托盘) 也要生效的偏好用的,而主题是纯图形偏好 —— 没有窗口时毫无意义。为一件纯前端的事去动 Rust 命令面与契约守卫不划算。
  • 首帧防闪:index.html 内联一段极小脚本,在 Vue 接管前先把上次主题的底色刷上, 否则「高级灰」用户每次启动都会看到一帧刺眼的白。此处刻意重复了 2 个底色值(真值在 theme/index.ts), 因为在此之前没有任何模块可依赖;已在两处都注明「改底色时同步」。

说明 —— 本轮刻意不升版本号

本轮新增的全部是控制台功能,被控端能力集合未变(未增删任何 system.* / fs.* / app.* 能力)。 而 scripts/lib/bundle-agent.mjs 会把根 package.json 的 version 注入 agent.mjs(__AGENT_VERSION__): 一旦为控制台功能去升版本号,被控端就会谎报一个它并未包含的版本 —— 属版本纪律所禁止。 故版本号维持 2.0.2,本批改动记入本节,待真正含被控端变更时由 pnpm release minor 一并升号。

修复 —— 图形能力在 200% 缩放下坐标错位(真机实证,属被控端变更)

⚠️ 与上一节结论不同:本节改的是被控端(apps/agent/src/**)。 按上一节自己立的规矩,发版时必须升版本号(PATCH,→ 2.0.3), 否则 agent.mjs 会谎报一个并不包含本修复的版本。

真机(虚拟化平台 + Win11 ARM64,200% 缩放)暴露的缺陷:

被控端的 PowerShell 助手进程默认 DPI 不感知,于是同一个进程里并存两套坐标系:

调用 不感知时的结果 实际危害
Screen.Bounds / SystemInformation.VirtualScreen 逻辑尺寸 1280×720 screen.info 报错尺寸;screen.capture 缺省 region 一并错
CopyFromScreen 按物理像素裁剪 screen.capture 缺省只截到左上 1/4
SetCursorPos 被系统 ×2 input.mouse.* 喂 100,100 → 光标落到 200,200
UIA(screen.find) 物理坐标(与进程是否感知无关) 与上面三项口径不一致

净效果:screen.find 给出的物理矩形不能直接喂给 input.mouse.*,必须人工除以 scale (上一轮就是靠这个临时绕法才点中的主题按钮 —— 能绕,但属用户侧的隐式约定,必须消灭)。

  • 新增 apps/agent/src/util/dpi.ts:PS_DPI_AWARE 常量,把 SetProcessDPIAware() 提到脚本首行, 并附完整真机实证表与「不要删」警告。
  • 注入到全部坐标相关脚本:capabilities/screen.ts(listDisplays / captureWindows)、 capabilities/input.ts(buildInputPsScript + 点击后读回光标那一次)、 capabilities/window.ts(WIN_HELPER_PRELUDE —— window.list / window.focus 都走它)、 capabilities/record.ts(全屏探测)。
  • 从 OCR 前奏的 try{} 里移除 SetProcessDPIAware(win-ps/prelude-ocr.ts): 放在 try 里一旦抛错就被 catch 吞掉 → 助手静默退回不感知。
  • 新增 8 条守卫(tests/unit/dpi-awareness.test.mjs),关键设计是断言「实际下发的脚本文本」 而非「源码里出现过常量」 —— 首版用源码扫描,注入故障实测不会变红(同文件别处也引用了该常量); 改为断言 buildInputPsScript() / buildListDisplaysScript() / buildCaptureScript() / WIN_HELPER_PRELUDE 的脚本串后,二次注入故障精准变红。另含一条「::SetProcessDPIAware() 恰好出现 1 次」防重复注入 (正则用 :: 区分调用与 P/Invoke 声明,否则把声明也算一次 → 误报 2 次)。
  • 修复后真机复验(三项全过,零换算):
    断言 结果
    screen.info 报 2560×1440,与裸 GetSystemMetrics(感知后)一致 ✅
    input.mouse.move(x,y) → 读回光标(物理) 四次抽样精确等于 (x,y) ✅
    screen.find 物理矩形 → input.mouse.click 直接点击 移动后光标 = 物理中心;主题单选框 selected 翻转且可逆 ✅

修复 —— 安全加固批次:18 条实机复现缺陷(属被控端变更)

来源:一轮「静态审查 + 真机实测」的完整缺陷普查(报告 bug-report-2026-10-13.html)。 基线 v2.0.2 / ad90634 上 test:unit 291/291 + test:e2e 30/30 全绿 —— 也就是说 18 条全部落在既有覆盖之外。分级 2×P0 / 5×P1 / 10×P2 / 1×P3。

P0-1 命令注入(window.focus 的 title)—— 而且它是静默的

原实现把 JSON.stringify(title) 直接插进 PowerShell 的双引号串。PS 里 \ 不是转义符, 双引号串还会做变量插值与 $(...) 展开 —— 于是 title 里的 "; <命令>; " 被当代码执行。

真机实测里最险的一点:接口返回的是「正常业务错误」(E_EXECUTION_FAILED: 未找到匹配的窗口), 而 payload 已经把文件创建出来了 —— 调用方从返回值里完全看不出异常。

  • 新增 apps/agent/src/util/quote.ts:psLit / shLit 作单一真源 (PS 用 '' 转义、sh 用 '\''),另加 assertSingleQuotedLiteral() 与裸参前提校验 isSafeBareWindowsPathArg()(用于 -File <path> 这种不能用引号的位置)。
  • 全仓注入点收敛到 psLit/shLit:window.focus / uiaFind / ocrFind / system.ts 的计划任务参数 / ps-helper.ts 的 -File 裸拼 等 14 处; 原有的「双引号」断言同步翻转为单引号 —— 旧断言的方向本就是错的(CI 断言会过期且方向可能反)。
  • 新增 tests/unit/quote.test.mjs:既有行为断言,也有源码形态守卫 (禁止 ${JSON.stringify( 拼进 PS 模板、禁止再手写 replace(/'/g,"''"))。 形态守卫带 stripComments() —— 否则注释里讲解漏洞的文字会被当成犯案。

P0-2 ACL 省略 clients → 每条 invoke 崩掉被控端

{"acl":{"default_effect":"deny"}}(没写 clients)会让 aclPolicy.clients.find(...) 抛错, 而这段落在 if (hasAcl) 块之外、无条件执行 —— 配置少写一个字段,整个被控端即不可用。

⚠️ 值得记一笔:静态审查当时判它是「fail-open 放行」,实测是崩溃(后果更严重)。 静态审查必须配实测 —— 这是本轮最直接的一条教训。

崩溃链的共同根因:没有 unhandledRejection 兜底

ws.on('message') 用 void handleMessage() 起 Promise 且没有 .catch, 而全仓没有任何 process.on('unhandledRejection') —— Node ≥22 遇到未处理的 rejection 直接终止进程。 verifyNonce 有 try/catch,而 psk 默认路径走的 verifyHmac 没有。于是两条畸形报文就够了: hello {protocol:123} 与 auth {hmac:1} 都能在认证之前把被控端打死。

  • apps/agent/src/index.ts 新增进程级兜底:日志每次都打、审计按 10s 限流 (否则畸形报文可高频刷爆 audit.log)。
  • 明确不退出进程:源头已有校验与 .catch,可用性优先(取舍写在注释里)。

其余 15 条

级别 缺陷 修法
P1 clients: [] → fail-open 全放行 判据改为「对象是否存在」而非「数组长度」
P1 畸形 hello / 畸形 auth → 崩且免认证 控制面报文类型校验 + 上述进程级兜底
P1 macOS screen.find 的 text 拼 bash → $(...) 实测执行 走 shLit
P1 控制台审计筛选项与协议 enum 漂移(除 invoke 外全 E_PARAM_INVALID) 新增 packages/protocol/src/audit-types.ts 作唯一真源,manifest enum 由它派生,并加编译期双向守卫
P2 monitor.start 的 target 注入;monitor id 路径穿越(读/删) 走 psLit + 统一过 guardPath
P2 keys{} + client_id:"constructor" 崩 改 Object.prototype.hasOwnProperty.call(原型链取值)
P2 事件订阅跨控制端可见/可删;event.watch 绕开 fs_roots 有状态资源落 owner + 统一路径白名单
P2 screen.capture 的 region 无上界 assertRegionSane(),并提前到枚举显示器之前(fail fast)
P2 ipMatchesCidr('8.8.8.8','10.0.0.0/') → true(空掩码被 Number('') === 0 当成 /0) 掩码解析加严,补 /0 与 /32 对照组用例
P3 CLI --timeout 静默失效 parseArgs 补声明(strict:false 下未声明选项被当 boolean,值掉进 positionals,双重危害)
P3 拒绝类事件不写审计 补 request.rejected / agent.error 两类事件

回归

test:unit 312/312(+21)、test:e2e 33/33(+3 组硬化用例)、test:rust 11/11。 e2e 新增的 ACL / 畸形报文用例有个必须记住的姿势:每个场景独占端口 + 独立临时 NODEAGENT_HOME —— 首版复用了同一端口,结果连上了上一块的残留实例,把对照组误判成「实现有缺陷」。

新增 —— 控制台单实例保护(症状:Windows 端起了两个被控端)

真机观察到两桩现象:① Windows 上同时跑着两个被控端;② 旧的服务端还在运行时,控制台起不来。 根因是同一个:控制台没有单实例锁,再启动一次就是第二个进程 → 第二个 AgentManager → 抢同一个 8765 端口 → 后起的那个起不来。另有一条陈旧的 na-gui 计划任务指向 测试部署目录,也在抢同一个端口。

  • 引入 tauri-plugin-single-instance(必须注册为第一个插件)。
  • 第二次启动不是静默退出,而是「聚焦既有窗口 + 发 na-second-instance 事件」让前端刷新状态。 这一点是刻意为之:自启动的实例是无窗口的,若第二次启动静默退出,用户双击图标只会看到 「什么都不发生」—— 那比缺陷本身更难排查。带自启参数(AGENT_ONLY_FLAG)时跳过显示。
  • 端口占用的报错补上「怎么找出占用者」:netstat -ano | findstr :<port> / lsof -nP -iTCP:<port> -sTCP:LISTEN,并新增一条单测。

修复 —— 发版脚本漏改 Cargo 清单(版本号会分叉)

release.mjs patch --dry-run 实测:只改 8 个 package.json + 1 个 tauri.conf.json, 而 apps/console/src-tauri/{Cargo.toml,Cargo.lock} 里同样存着 2.0.2 —— 发版后必然滞留, 与 tauri.conf.json 悄悄分叉,且不报错(Tauri 打包以 tauri.conf.json 为准)。

  • 版本写入抽成 scripts/lib/version-write.mjs(纯函数、可测),release.mjs 调用它。
  • Cargo.toml 只在 [package] 段改(非 package 段里也可能有独占一行的 version = "…"); Cargo.lock 按 [[package]] 块 + crate 名定位(全文盲替换会改坏 thiserror 2.0.21 这类依赖版本)。
  • release.mjs 的「脏文件白名单」改为取自实际改写结果(changed),不再手写文件名列表 —— 手写的那份一旦与写入逻辑漂移,要么拦下本该放行的(发版卡死),要么放过不该放行的(静默推走改动)。
  • 新增 tests/unit/version-write.test.mjs(9 条):在临时目录铺真实文件并 git init / git add (versionTargets 走 git ls-files,不真进索引就会假绿),跑完读回文件断言; 另含一条仓库版本号一致性守卫,发版漏项会直接变红。
  • 注入 4 处故障实测:编排漏 Cargo / Cargo.toml 全文盲替换 / Cargo.lock 按位置盲改 / 仓库版本分叉 —— 全部变红,还原后全绿。

⚠️ 本批含被控端变更(apps/agent/src/** 十余个文件),按版本纪律,发版时 pnpm release patch(→ v2.0.3)必须执行,否则 agent.mjs 会谎报一个并不包含这些修复的版本。

修复 —— 控制台复用已退出的 sidecar(真机症状:更新等满 45 秒报超时)

现象(Windows 11 ARM64 真机):应用启动被控端后,界面点「检查更新」等满 45 秒, 报「检查更新失败:check_update 超时」。

排查对照(决定性的一条):同一台机器、同一份 sidecar、同一个请求 ——

  • 界面内发起:45s 超时(check_update 的 request 超时上限);
  • 在 VM 上手工拉起 sidecar 跑同一请求:6.989s 成功返回 has_update: true, latest 2.0.3。

现场另证:任务管理器里 node.exe 只有被控端一个 —— 控制台的 sidecar 进程根本不存在。 逐条排除网络(curl 取 release 资产 HTTP 200 / 1096096 字节)、哈希(探针 sha256 一致)、 版本判定(控制台 2.0.2 < 发布 2.0.3)、进程归属(被控端 pid 的父进程正是控制台)之后, 根因锁定在控制台上壳:ensure_sidecar 只判「缓存里有没有实例」,不判「那个实例还活着没」。

为什么以前没暴露:往已退出进程的管道里写,在 Windows 上未必立刻报错 —— 管道缓冲区把数据吞下就返回成功。于是请求既没报错、也永远等不到响应,只能一路干等到 request 的超时。

修法(apps/console/src-tauri/src/sidecar.rs + lib.rs):

  • Sidecar 增 dead: Arc<AtomicBool>。三处置死:stdout 任务读到 EOF、stdin 写入/flush 失败、 shutdown() 主动关闭;stdout 关闭时顺手唤醒所有在等的请求,让它们立刻失败而不是各等各的超时。
  • 新增 is_alive():dead 为真即假;否则 child.try_wait() 为 Ok(None) 才为真; 拿不准一律按不可用(宁可付一次冷启动,也不要让用户每个操作都干等到 timeout)。
  • request() 入口先判存活,不可用则立刻返回 —— 这是「秒失败」的那一道。
  • ensure_sidecar 取到缓存实例后先 is_alive(),已退出则丢弃并重建。
  • 新增 3 条单测:shutdown 后必须判死、子进程被杀后必须判死、 对已退出的 sidecar 发请求必须秒失败(故意给 30s 超时,断言耗时 < 5s —— 修复前的表现恰恰是「干等到超时」,只断言「有没有报错」会漏掉这个缺陷)。 注入故障(把 is_alive() 换成恒 true)实测两条守卫如期变红。
  • pnpm test:rust 14/14(基线 11 + 新增 3)。

⚠️ 本项属控制台上壳变更,不是被控端变更,因此不触发 pnpm release patch (被控端 agent.mjs 无需重新发布)。但要让用户真正拿到修复,必须重新构建并分发控制台安装包 (.msi / NSIS);直接重启已装的旧版控制台只是临时绕开(进程内缓存被清空 → 重新拉起 sidecar), 症状会消失,但下次 sidecar 再退出仍会复发。

修复 —— 两条守卫在 Windows 上必红(平台差异:CRLF 与路径分隔符)

CI 实证(run 38380306200,提交 73ed06f):Windows 真机验证 → 单元测试 319/321, macOS 上同样两条却是绿的 —— 典型的「只在 Windows 暴露」。两条各有明确根因:

① 仓库内全部版本号互一致:CRLF 把版本号读成 undefined

报错原文:undefined apps/console/src-tauri/Cargo.lock(其余 10 处均为 2.0.3,2 !== 1)。 根因:Windows 上 actions/checkout 默认 core.autocrlf=true,工作区文本是 CRLF, 而 Cargo.lock 的版本提取把 \n 写死了。更严重的是 —— 同一处隐患也在生产代码里: version-write.mjs 的写入路径另有一条 name…\nversion… 正则读旧值,CRLF 下同样返回 undefined, 于是它把 Cargo.lock 当成「没找到该 crate」记入 skipped 并跳过 → 发版后该文件滞留旧版本, 正是本模块要消灭的那类分叉,且不报错。

  • 收敛为单一真源:读旧值改走 readCargoTomlVersion() / setCargoLockVersion().before, 删掉那条会漂移的重复正则。
  • 新增 splitLineEol():写值前先把行尾拆下来、改完正文再拼回去 —— 不能偷懒写 (".*)$ 兜行尾,因为 JS 的 . 不匹配 \r(与 \n 同属行终止符),照样失配。
  • 读值一律 split('\n') 后对 line.trim() 匹配 → LF / CRLF 结果一致,写回逐字节保真。
  • 新增 3 条守卫:Cargo.lock / Cargo.toml 纯函数级 CRLF 用例 + CRLF 工作区上真跑一遍 writeVersionEverywhere 并读回文件断言(注入故障后 3 条如期变红)。

② W-01 根因守卫:PowerShell 单引号字面量必须来自 util/quote.ts:把唯一真源自己判成违规

报错原文:以下文件手写了 PS 单引号转义:util\quote.ts。 根因:守卫用 file.slice(dir.length + 1) 得相对路径,Windows 上是 util\quote.ts, 与白名单里的 util/quote.ts 比不中 → 守卫自己错(铁律 3 的第 ② 类),macOS 上复现不出。

  • 新增 relPosix() 把相对路径统一归一成 /;并补一条用 path.win32 形态字符串在 macOS 上 直接测掉 Windows 分支的用例 —— 跨平台差异不必真上 Windows 才能验(铁律 4)。

回归:test:unit 325/325(321 + 新增 4),注入故障实测 4 条守卫如期变红、还原逐字节一致。

附带发现(不构成缺陷,但值得记档):控制台随包的 agent.mjs 与发布版 agent.mjs 逐行一致、同报 2.0.3,唯二差异是 esbuild 的模块相对路径串与内嵌 commit 常量 (随包 73ed06f / 发布版 a1c51d6)—— 源于两次构建的基准目录不同。 因此二者 sha256 必然不同,不能用发布清单的哈希去校验控制台播种出来的那份。

修复 —— 打包后 sidecar 因 Windows verbatim 路径秒退(真机症状:「sidecar 已退出(stdout 关闭),请重试」)

上一节修好「复用已退出的 sidecar」之后,真机复验症状变了但没解决:不再干等 45 秒, 改为立刻报「检查更新失败:sidecar 已退出(stdout 关闭),请重试」—— 说明被拉起的 sidecar 在请求进行中真的退出了,只是这次被立刻发现。本节是它的真根因。

真机 A/B(决定性的一条):Windows 11 ARM64;同一个 node.exe、同一份脚本, 用 ProcessStartInfo 下发(精确对应 Rust Command::new(prog).arg(script) 的形态), 二维四格各跑一遍 —— 要回答的是「炸的是哪一个维度」:

# program 脚本参数 结果
G1 C:\…\node.exe C:\…\mini.mjs exit 0,正常输出 ← = 修复后的形态
G2 C:\…\node.exe \\?\C:\…\mini.mjs exit 1,EISDIR … lstat 'C:'
G3 \\?\C:\…\node.exe C:\…\mini.mjs exit 0,正常输出
G4 \\?\C:\…\node.exe \\?\C:\…\mini.mjs exit 1,EISDIR … lstat 'C:'

炸的是「脚本参数」带 verbatim,不是「可执行文件」带 verbatim —— G3 证明 program 位置带前缀 node 照常启动;真机上也有旁证:被控端与 sidecar 进程的 program 都带 \\?\ 却长期存活(Get-CimInstance Win32_Process 的 CommandLine 可直接看到)。 修复方向由此确定:净化脚本参数是关键,净化 node 属防御性。

另有一次更贴近原症状的观测(同一台机器、直接用真 sidecar.mjs): 普通路径 2.5 秒返回 check_update 结果、进程常驻且心跳持续; \\?\ 路径 0.47 秒即退出、exit 1、零 JS 执行 —— 连 sidecar 自带的启动日志一行都没写。

「零 JS 执行」是实测出来的:sidecar 自己的启动日志一行没写、审计日志在 agent.start 之后 一片空白、判死探针文件 0 字节 —— 连第一行 JS 都没跑到。

根因:Tauri 的 resource_dir() 在 Windows 上返回的是 std::fs::canonicalize 之后的 verbatim 路径(\\?\C:\Users\…\nodeagent-console)。把它当参数交给 node 时, node 启动流程在入口模块的 realpathSync 里把 \\?\C: 拆成盘符 C: 与剩余部分:

Error: EISDIR: illegal operation on a directory, lstat 'C:'
    at Object.realpathSync (node:fs:27xx)

→ exit 1,一行 JS 都来不及执行。两种界面表现都由这一条派生: 修存活检测之前是「写进已死进程的管道 → 干等 45 秒超时」, 修之后是「stdout 立即 EOF → 立刻报 sidecar 已退出」。

为什么单测 / CI / 开发模式全都测不出来:开发期 resolve_sidecar 走的是 CARGO_MANIFEST_DIR 拼出来的普通路径;只有打包之后才会从 resource_dir() 拿到 verbatim 路径。CI 里 cargo test 又只在 ubuntu-latest 跑(.github/workflows/rust.yml), Windows 侧只有打包(console-windows.yml)。于是「本地全绿 + CI 全绿 + 真机必挂」。

修法(apps/console/src-tauri/src/sidecar.rs + agent.rs + autostart.rs):

  • 新增 unverbatim():剥 \\?\UNC\ → \\、剥 \\?\ → 无前缀,其余原样返回; 纯字符串处理,任何平台都可安全调用 —— 因此可以用 Windows 形态的字符串在 macOS / Linux 上直接守住 Windows 分支(铁律 4:跨平台差异不必真上 Windows 才能验)。 实现用 to_str() 而不是 to_string_lossy():后者会把非法字节替换成 U+FFFD 再重建路径,那是损坏路径(把「node 报 EISDIR」换成「指向不存在的路径」, 更难排查)。非 UTF-8 一律原样返回。
  • 新增 sidecar_command():唯一把路径交给 node 的地方,内部统一净化。 纪律只在这里守一次,单测直接断言它产出的 program / args(守「最终下发文本」, 不是「源码里出现过某个常量」)。
  • 四个来源统一加闸:resolve_node / resolve_sidecar / resolve_bundled_agent 的 全部出口(环境变量、resource_dir、系统 PATH / 开发路径),以及进程边界的 Sidecar::spawn 与 AgentManager::start(对 node / script / data_dir 三条路径都净化)。
  • autostart::app_exe() 一并净化:该路径会被写进计划任务的 New-ScheduledTaskAction -Execute, 与上述同源风险。

回归:

  • 新增 4 条守卫 —— unverbatim_去掉各类_windows_前缀(盘符 / UNC / 普通 / POSIX / 幂等)、 unverbatim_对_resource_dir_拼接后的路径同样生效(前缀在目录上、拼完仍在最前)、 交给_node_的路径必须去掉_verbatim_前缀(断言最终 program 与 args 文本)、 unverbatim_对非_utf8_的_verbatim_路径必须原样返回不得损坏(#[cfg(unix)],见上)。
  • 注入故障实测(两轮): ① 把 sidecar_command 里的 unverbatim 去掉 → 第三条如期变红, 报错正文即 program 仍带 verbatim 前缀(示例:\\?\C:\…\node.exe); ② 把 unverbatim 的实现退回 to_string_lossy() → 第四条如期变红。 两轮还原后均 sha256 逐字节一致,复跑全绿。
  • pnpm test:rust 18/18(14 → +4)。

⚠️ 本项属控制台上壳变更,不是被控端变更,因此不触发 pnpm release patch。 但要让用户真正拿到修复,必须重新构建并分发控制台安装包。

真机复验(Windows 11 ARM64 VM,装 CI 产出的 arm64 NSIS 包):

项 修复前(2.0.3) 修复后
sidecar 进程命令行 "\\?\C:\…\node.exe" C:\…\sidecar.mjs(program 带 verbatim) "C:\…\node.exe" C:\…\sidecar.mjs(两侧均干净)
点「检查更新」后 sidecar 0.5 秒即退,界面报「sidecar 已退出(stdout 关闭)」 sidecar 常驻(pid 恰在点击那一刻出现),界面显示最新版本 v2.0.3 / 已是最新 / 发布时间 2026-10-13 17:36:54
安装包 e7636120… 27e41ef8…(exe)/ b2b78546…(随包 sidecar)

结论:缺陷已消除,完整链路真机跑通。复验同时暴露了一个既有缺陷,见下节。

修复 —— sidecar 拉起时弹出常驻黑窗(上项修复后暴露的既有缺陷)

现象:真机复验时「检查更新」能返回结果了,但屏幕上多出一个不消失的黑窗 —— 由 WindowsTerminal.exe 承载,标题就是 C:\Users\…\nodeagent-console\node.exe。

为什么以前没人发现:修复 verbatim 之前 sidecar 0.5 秒即退,这个黑窗一闪而过; sidecar 常驻之后它才留在屏幕上。所以它是被上一项修复暴露出来的既有缺陷,不是本次引入。

根因:控制台自身是 GUI 子系统程序(不占控制台)。GUI 父进程 spawn 一个 控制台子系统程序(node.exe)时,Windows 会新建一个控制台 —— 必须靠 CREATE_NO_WINDOW 抑制。AgentManager::start 早已加了这个标志(所以被控端从不弹窗), sidecar_command 漏了。换句话说:CHANGELOG 里「消灭命令行窗口」那节写的三条措施, sidecar 这条路径一直没被覆盖。

修法:sidecar_command 补 creation_flags(CREATE_NO_WINDOW); 常量提为 sidecar::CREATE_NO_WINDOW,由 sidecar 与 AgentManager::start 共用,避免值漂移。

⚠️ 本项没有自动化守卫,如实记档原因:creation_flags 只有 setter、没有 getter (std::process::Command 与 tokio::process::Command 都如此),断言不到「到底带没带」; 而「扫源码看有没有写过这个常量」属源码级检查,违反铁律 1(守卫要落在最终下发值上)。 故本条靠真机截图对照把关(修复前有黑窗 / 修复后无黑窗),并确保 Windows CI 能真编译到这段代码。

真机复验(同一 VM,装 CI 产出的下一版 arm64 包,exe 5800b972…):

  • 点「检查更新」→ sidecar 在点击那一刻被拉起(pid 4056 @ 20:52:58), 命令行两侧均无 \\?\(verbatim=False);再次点击时复用存活实例、不重建,符合设计。
  • 黑窗消失:Get-Process | Where MainWindowTitle -like '*nodeagent-console*' 返回空。 修复前该查询命中一个 WindowsTerminal 窗口,标题正是 …\nodeagent-console\node.exe。
  • 界面未再出现「sidecar 已退出」。

附带(与本次修复无关的环境问题):该次点击时 VM 恰好连不上 GitHub —— 界面报「检查更新失败:fetch failed」,同机直接 Invoke-WebRequest 该 URL 21.5 秒后「无法连接到远程服务器」。更早一次(网络通时)同一按钮已成功返回 「最新版本 v2.0.3 / 已是最新 / 发布时间 2026-10-13 17:36:54」,故功能本身已验证。

修复 —— 「检查更新」在境内必然失败(真机症状:检查更新失败:fetch failed)

订正上文:上一项末尾把 fetch failed 归为「与本次修复无关的环境问题」。那是误判 —— 环境确实不通,但产品缺陷让它无解:即使把代理配好,这条链路也永远连不上。本项即修复。

现象:控制台点「检查更新」,境内(无透明代理的机器)必报 fetch failed。

真机取证(Win11 ARM64 VM;同日多组对照):

观测 结果
VM 上 DNS 解析 github.com 正常(20.205.243.166)
VM 上 TCP 443 直连 握手成功
VM 上 fetch 取该 URL fetch failed
VM 经代理取同一 URL HTTP 302(代理本身是好的)
Mac 上给 fetch 设 HTTPS_PROXY 后重试 仍 fetch failed(env -i 隔离后仍有区分力)

三条根因(缺一条都修不好):

  1. Node 内建 fetch(undici)默认不读任何代理环境变量。NODE_USE_ENV_PROXY=1 可开 (Node 22.x 实验性、24 正式),但它只认环境变量。
  2. 系统代理读不到:Windows 用户用第三方代理客户端时配的是 WinINET 系统代理, macOS 用户配的是系统设置里的代理——Node 两者都不读。所以「用户明明配了代理」也白搭。 而 fetch(url,{dispatcher}) 也走不通:dispatcher 必须是 undici 的 Dispatcher 实例, 而 Node 没把 undici 暴露成可导入模块(整包 vendored 会把 sidecar 撑胖,且它依赖 .wasm, esbuild 单文件打包有风险)。
  3. 超时过短:原 check_update 内部 20s。而该 URL 要跟随两次重定向到资产 CDN, 真机实测整链耗时 2.0s / 10.0s / 39.7s / 68.6s / >109s(同机同代理)—— 20s 属「假定能直连」时代的数值,对真实链路必然超时。

修法 —— 新增 apps/console/sidecar/proxy.mjs(自持出网,零新依赖):

  • 不用 fetch,改用 node:http/node:https:https 目标经代理走 CONNECT 隧道再叠 TLS (隧道 socket 交 http.request,刻意不用 https.request —— 那样会二次握手); http 目标经代理发绝对形态请求行(GET http://… HTTP/1.1)。
  • 代理来源:NA_PROXY → 目标协议对应的环境变量(HTTPS_PROXY/ALL_PROXY…)→ 系统代理(Windows 读注册表 WinINET;macOS 读 scutil --proxy)。NO_PROXY 命中则直连。
  • 只支持 HTTP 代理;遇到 socks5://、或系统代理是PAC 脚本(本机 AdGuard 正是这种) 明确记一条 note 并把提示带给用户 —— 静默降级正是本次缺陷最恶劣的地方。
  • 超时成对下发:UPDATE_CHECK_TIMEOUT_MS 120s(内层)/ UPDATE_CHECK_RPC_TIMEOUT_SECS 150s(外层), 由 Rust 通过 timeout_ms 参数传给 sidecar。内层必须小于外层,这条关系写成编译期不变式 (const _: () => assert!(…))—— 破坏它只会在用户侧表现为「check_update 超时」这种丢掉真实原因的假失败。
  • 失败信息可操作:带上是直连还是经哪个代理、耗时多少、以及「为什么不走代理」(PAC / socks)。 check_update 成功时也回报 via_proxy / via,界面上新增一行「出网」—— 这正是排查这类问题最需要看到的一条。
  • 顺带修掉界面上的重复前缀(检查更新失败:检查更新失败:…)。

同一类缺陷的第三处(本次未改,如实记录): apps/agent/src/capabilities/selfupdate.ts(被控端拉取式自更新)仍是裸 fetch。 该文件顶部有明确设计立场「改用镜像地址,而不是在 agent 里塞代理隧道」, 且属被控端变更(改动即需升 PATCH、走发版流程),故不在本次控制台修复范围内。

期间一并修掉的第二个缺陷(复验时才暴露):tunnelConnection 里 隧道与 TLS 握手阶段没有接 abort 信号 —— 那个阶段外层 http.request 还没建立, signal/timeout 都无人受理。真机形态:配置 75s 超时,实际挂了 109s 才失败。

第三个缺陷(同一轮真机暴露):ECONNRESET 被误归入 abort 类,于是连接被中途重置时 报的是「请求超时」——真机形态是耗时 82.9s(预算 120s)却报「请求超时(120000ms 内未完成)」, 把「对端把连接掐断了」这条最有价值的信息抹掉。改为只认真正的 abort(AbortError/ABORT_ERR), 其余网络错误单列翻译(ENOTFOUND/EAI_AGAIN/ECONNREFUSED/ECONNRESET/EPIPE)。

守卫(tests/unit/sidecar-proxy.test.mjs,16 用例)+ 注入验证:

# 守什么 注入故障实测
1–8 纯函数:URL 归一化、NO_PROXY(含「条目比目标窄时不得反向豁免父域」)、来源优先级、scutil/reg query 解析(夹具取自真机,含 PAC 模式与「值不存在时 reg 走 stderr」)、探测失败一律视为无代理、决议优先级与 note 累积 去掉后缀匹配 → 用例 2 红
9–12 真发请求(离线本地服务器):直连跟随 302、经代理走绝对形态、代理不通必须报错(防静默降级)、CONNECT 可用 / 被拒报错、隧道+TLS 阶段 abort 必须立即生效(带看门狗,故障时失败而非挂死) 去掉隧道阶段 abort 挂载 → 用例 12 红(实际 4002ms)
13 端到端:真 spawn sidecar 进程走 JSON-RPC 跑 check_update,断言返回正确且 via_proxy 正确、代理真收到了请求;反面对照(代理指死端口)必须失败 把 check_update 退回裸 fetch → 用例 13 红(代理必须真收到这次请求)+ 用例 14 红
14 形态守卫:sidecar 源码不得再出现裸 fetch((防止修复被无声改回去) 同上
15 错误映射:连接被中途重置时不得报成「请求超时」(真机踩过) 把 ECONNRESET 塞回 abort 类 → 用例 15 红

真机复验(重打 sidecar 产物 249KB,用打包后的 dist/sidecar.mjs 直跑):

组 平台 / 配置 结果
C macOS / 无代理 成功 2169ms → 最新版本 v2.0.3、已是最新;日志正确报出「系统代理使用 PAC 自动配置脚本…已按无代理处理」(scutil 探测真实生效)
A macOS / NA_PROXY=http://127.0.0.1:1(死端口) 51ms 明确失败:connect ECONNREFUSED 127.0.0.1:1(经代理 http://127.0.0.1:1/,耗时 5ms);data 里带全 via/proxy/proxy_source/timeout_ms/hint
B macOS / NA_PROXY=http://127.0.0.1:<代理端口> 代理路径被正确选中(日志 经代理 …(来源 NA_PROXY));该次宿主代理线路拥塞未在预算内完成 —— 线路本身的问题,错误信息如实说明「代理已生效但仍失败」
D Win11 ARM64 VM / 未配系统代理、未给任何环境变量(直连) 30877ms 失败,但报的是真实原因:socket hang up(连接被对端中途断开…),data 明确给出 via=直连、proxy=null、timeout_ms=120000 与一句可操作提示「请在系统里开启『系统代理』,或设置 HTTPS_PROXY=… 后重启控制台」
E Win11 ARM64 VM / 只设 WinINET 系统代理指向转发端点(不给任何环境变量) 成功 28883ms → v2.0.3、has_update=false;日志 检查更新:经代理 http://…(来源 system)、结果 via_proxy=system,退出码 0

组 D/E 用的是随包 node.exe(%LOCALAPPDATA%\nodeagent-console\node.exe)直跑 dist/sidecar.mjs,两侧 sha256 一致(07dd592b…);E 组没有任何环境变量参与, 走通的正是本次新增的系统代理发现(reg query → 来源 system)。 E 组耗时 28.9s 同时反证根因 3:旧实现固定 20s 的内部超时对这条链路必然失败。 复验后 VM 的 ProxyEnable/ProxyServer 已原样还原(复原为 0x0 / 值不存在)。

未覆盖(如实记档):https 目标经 CONNECT 隧道的 TLS 叠层没有离线自动化用例 —— 需要一个受信任证书的本地 TLS 源站,而本仓库没有证书夹具(selfsigned 只装在被控端包内, 测试进程解析不到,为它新增夹具又会污染 ~/.nodeagent/certs/)。 在线路径已由「组 C 直连 + 原型实测经代理取 GitHub API 得 200」覆盖,但它不构成回归守卫。


安全 —— 公开仓库隐私脱敏(文档 / 注释 / 测试夹具)

按 security-reports/ 的隐私审查台账收口,逐项清除可反向定位到具体个人与设备的字面量。 纯脱敏与表述泛化,不含任何业务逻辑变更。

  • 去掉可执行的真机默认值 —— scripts/verify-b2.mjs 的 NA_URL 原先带缺省值且指向一台真实机器, 现改为必填(缺失即报错退出)。这是全仓库唯一「拿着就能连到某台真机」的常量,优先级最高。
  • 主体标识泛化 —— 测试账户名、虚拟机与宿主的真实网段、测试用绝对路径, 分别改为 na-test、RFC 5737 文档保留段地址(192.0.2.0/24)、C:\na-test。
  • 设备指纹泛化 —— 虚拟化产品及其 CLI / 访客工具名、NAS 设备型号与系统名、真机 CPU 型号、 真机物理分辨率、代理软件品牌与监听端口、杀软品牌 → 一律中性表述。 只换称呼,结论与排查步骤一字未动,避免把实测经验一起丢掉。
  • 去除跨项目与私人称呼线索 —— 文档中指向作者其他项目的名称、以及私人称谓 → 中性表述。
  • 测试夹具 —— acl-v11 / net-scripts 的示例 IP 去掉与作者真实环境一致的尾八位; fixture 与断言同步更新(只改 fixture 不改断言会让守卫变红 —— 本轮就这么红过一次)。
  • .gitignore 补 config.json / agent.json —— 二者含真实密钥与节点地址, 此前只靠「没人会提交」兜着。

验证:pnpm test:unit 340/340 通过;另用独立扫描器对 235 个跟踪文件做 17 类模式全量扫描,命中 0。 说明:改写历史不等于收回 —— 旧内容只要被匿名抓取过就已外流,本节只做「止血 + 不再恶化」。


[v2.0.2] —— 2026-10-11

一轮真机验证(虚拟化平台 + Win11 ARM)抓出的发布事故修复批次。 已公开发布的 nodeagent-win-arm64.zip 内装的其实是 x64 的 node.exe —— 该包在 ARM Windows 上能启动、能 listen,却 TLS 握手不完成(控制端只见 E_TIMEOUT), 实际不可用。本版重出资产,并把「交付物架构」直接钉进发版脚本与 CI。

新增

  • 控制台自带 Node 运行时,目标机器无需预装 Node(pnpm --filter nodeagent-console bundle) —— 控制台的 sidecar 是 .mjs,此前只能在装了 Node 的机器上运行(resolve_node 回退系统 node)。 现由 scripts/prepare-console.mjs 把 Node 运行时准备为 Tauri resource 随包分发。 实测:.app 90.8 MiB(含运行时 86MB)/.dmg 33.78 MiB(分发件,仍低于 40MB 目标)。 作为对照,本机实测 Electron 运行时本体(Electron Framework.framework)为 207MB —— 即 Tauri + Node 的运行时开销不到 Electron 的一半。
  • apps/console/scripts/verify-bundle.mjs —— 验证「打包产物能否自举」:真 spawn 打包内的 node 去跑打包内的 sidecar,并走一次真实 JSON-RPC 握手。源码与单测都正常、只在产物中暴露的问题靠它拦下。

修复 / 规避(两处实测踩出来的约束)

  • macOS 上 strip 会使 Mach-O 代码签名失效 → 内核直接 SIGKILL(exit 137), 且没有任何有用报错(不是警告、不是异常,就是静默无法执行)。 实测对照:原样复制 ✓ / strip 不重签 ✗(137) / strip + codesign --force --sign - ✓。 故准备脚本在 strip 之后必须 adhoc 重签(省 20% 体积值得,但这步不能少)。
  • Node 运行时不得声明进默认 tauri.conf.json —— bundle.resources 是编译期契约 (tauri-build 校验声明的文件必须存在),声明「运行期才就绪」的资源会让 cargo check/test 在 build.rs 阶段直接失败。一律走 tauri build --config 注入。

修复 —— 被控端打包(ARM64)

  • nodeagent-win-arm64.zip 里装的其实是 x64 的 node.exe。 pack.mjs 下载 Node 运行时的缓存文件名是固定的 node-runtime.zip(不含架构): release.mjs 先打 x64 时把它缓存下来,随后打 arm64 时误判「已缓存」直接复用 → 两份公开包内的 node.exe 字节完全相同(sha256 同为 fdddbf45…,PE Machine = 0x8664)。 后果极隐蔽:该包在 ARM Windows 上能启动、能 listen,但 TLS 握手不完成 (控制端只见 E_TIMEOUT)—— 被控端实际不可用,而 macOS 侧 100% 复现不出来。 修法:① 缓存文件名带上架构(node-runtime-<arch>.zip); ② 解包后断言 node.exe 的 PE Machine 与 --arch 一致(缓存串包 / 手工塞错文件当场红)。
  • CI 补「双架构包 PE 架构校验」(.github/workflows/windows-e2e.yml)—— 此前 CI 只跑 pnpm pack:win(默认 x64)且只校验 x64 包,arm64 打包零覆盖, 故该 bug 必然逃过 CI。现两条架构都打,并直接读最终 zip 内 node.exe 的 PE 头比对。
  • 发版脚本补「交付物架构断言」(scripts/lib/zip-pe-arch.mjs)—— release.mjs 生成资产前,直接解出最终 zip 内的 node.exe、读 PE 头比对架构, 不符即中止发版。与 pack.mjs 的断言刻意分两层:后者是打包流程内部的自洽, 这里钉的是交付物本身的性质。纯内置 zlib 实现,不依赖 unzip / Expand-Archive (Windows runner 上没有 unzip),另留 CLI 自检入口: node scripts/lib/zip-pe-arch.mjs <zip路径> <x64|arm64>(已注入故障实测会红)。

真机实测(ARM64 Windows 虚拟机)

  • arm64 包:TLS 握手成功 → PSK 认证 → 声明 51 项能力(两端一致)→ system.info / status / process.list / service.list / shell.exec / audit verify 全部正常,arch 报告为 arm64(原生,非模拟层)。
  • x64 包(对照,同机同参):connect 报 E_TIMEOUT —— 复现了「能 listen 但握手不完成」。 → 证实「按架构出包」不是理论洁癖,而是 ARM Windows 上的硬需求。
  • 已知边界:被控端若以 SYSTEM(会话 0) 手动启动,screen.capture 等图形能力会失败 (会话 0 无交互桌面)。正式安装路径不受影响 —— install.ps1 的计划任务 principal 用的是登录用户 + LogonType Interactive,注释已明写「图形能力可用」。

[v2.0.1] —— 2026-10-11

一轮真机验证(虚拟化平台 + Win11 ARM)带来的修复批次:在补齐 6 项待验证项的过程中, 抓到并修掉 5 个真问题 —— 全部只有真机能发现(macOS 上 100% 测不出)。

修复

  • control.ps1 的 $scheme: 被当 drive 引用 —— PowerShell 解析期错误 → 整个脚本不可用 (stop / start / status 全废,且只报一行语法错、极易误判为别的问题)。改用 ${scheme}; 并新增守卫扫全仓 .ps1 的 $ident: 形式(含注入故障验证)。
  • 连接保活缺 pong 检测 —— 心跳只 ping() 而不检查 pong,且 ws 库默认不会因「ping 无 pong」断开; 客户端进程已死(TCP 未发 FIN)时,连接会永久占用 max_connections 名额, 叠加「有任务/订阅时清扫保守跳过」的策略 → 会把正常控制端挡在外面(真机实测把自己锁住了)。 现对静默超过 idle_timeout_ms * 2 的连接 terminate(),并让 pong 也算「活跃」。
  • v26 自重启任务清理阈值过粗 —— 原用「早于 1 小时」,频繁 restart(间隔 < 1h)时旧任务照样累积; 改为「早于本 agent 启动时刻」——语义精确:凡在我启动之前运行的任务,必然不是我的宿主。

新增

  • scripts/pack.mjs --arch <x64|arm64> —— Windows 包分架构。x64 包在 ARM Windows 上协议层不可用 (能启动、能 listen 8765,但 TLS 握手不完成 —— Prism 模拟下异常,现象极具误导性); ARM 机器须用 arm64 包。release.mjs 随之同时产出两个架构,latest.json 新增 zip_arm64 字段 (只增不减,旧控制端忽略即可)。
  • scripts/verify-b2.mjs —— v23 连接层三防护的真机验证脚本(idle / maxconn / ban 三模式), 全部走真实连接而非 mock。
  • docs/VM-VERIFICATION.md —— 无 Windows 真机时用虚拟化平台上的 Win11 ARM 补验证的完整清单 (含实测结果、能补/不能补矩阵、8 条排查项)。

真机验证状态(本版实测)

项 结果
A 组(verify.mjs 17 项) 15 通过 / 0 失败 / 1 跳过
v1.5 四项(字符串热键/预设别名/长按/后台投递) 全过(route=post 实测命中 138 窗口)
v23 三防护 三场景全过(封禁 / 上限 / 空闲断开)
v26 自重启清理 验证并改进(阈值改为 agent 启动时刻)
锚点端到端 全过(含云盘链外留存)

[v2.0.0] —— 2026-10-10

自 v1.6.0 起累计 37 个提交。能力数 43 → 51,单测 195 → 273,端到端 30。 之所以直接跳 v2.0.0:两个新方向(语义可断言 / 状态可回看)+ 安全批次四连 + 五处 只有真机能暴露的严重缺陷修复,量级远超一次 MINOR。

兼容性:完全向后兼容 —— 未改任何既有能力的参数名或语义,未新增必填参数, 返回结构只增不减(遵循 docs/VERSIONING.md 的四条铁律)。

验证状态:本版发布时被控端整机离线,v1.5.0 的预设别名/长按/后台投递/热键序列 与 v2.0.0 的自重启任务清理策略当时未经真机验证(后者已在本地单测锁定判定条件)。

✅ 2026-10-11 已补齐:在虚拟化平台 + Win11 ARM 虚拟机上完成真机验证 —— A 组 15 通过/0 失败、v1.5 四项全过、v23 三防护三场景全过、v26 清理策略验证并改进 (阈值由「1 小时」改为「本 agent 启动时刻」)、锚点端到端跑通(含云盘链外留存)。 并顺带发现并修复 5 个真问题(含 control.ps1 解析期错误、连接保活缺 pong 检测等)。 详见 docs/VM-VERIFICATION.md。

新增:语义可断言(v1.6.0 / v2.0.0)

  • gui.await —— 等条件成立再返回。四类条件(window / control / process / file) × 两种状态(present / absent),复用既有能力,零新增平台探测代码。 超时是正常返回(satisfied:false + note),不抛异常;轮询期单次异常不视为失败。
  • screen.find 返回 UIA 属性 —— enabled / value / selected / toggle。 每项单独 try/catch,拿不到就留 null 绝不猜(Electron、游戏 UI 常给不出值)。
  • where 属性谓词 —— 如 {"enabled":true}「等一个可用的按钮」, 比「等一个叫某名字的按钮」更贴近真实意图。仅 UIA 引擎生效, OCR/图像引擎会忽略并带回 note,不让调用方误以为过滤过了。
  • any_of 组合 —— 任一命中即算,命中时回报是哪个子条件。 例:[{window:"安装完成"},{control:"错误"}] = 「装成功或报错,先出现的算」。
  • CLI:nodeagent await;MCP:na_await。

新增:状态可回看(v2.0.0)

  • log.query —— 日志在被控端侧过滤后只回匹配行(pattern / level / since / offset / limit / tail)。流式逐行读,几万行日志也不整份拉回。
  • monitor.start / report / stop / list / delete —— 定时采样并落盘。 四类源:port(连通性 + 延迟)/ process(存活 + PID)/ command(退出码 + 首行)/ metric(CPU·内存)。摘要直接给结论:断了几次、最长断多久、值域。
  • CLI:nodeagent monitor / log;MCP:na_monitor / na_log。

新增:安全加固(v2.0.0)

  • 连接层三防护:
    • 握手失败封禁 —— 连续失败达阈值 → 指数退避(封顶 24h),成功即清零,支持白名单豁免
    • 连接上限(默认 8)+ 空闲断开(默认 30 分钟;有任务或订阅在跑时保守不踢)
    • 状态从 WeakMap 改 Map(需要 size 与遍历),关闭时手动清理
  • 安装期密钥改 stdin —— install.cmd 用管道喂 -KeyFromStdin,密钥不再出现在命令行 (同机其他用户无法从进程命令行读到)。
  • 零信任优先安装 —— 侦测到 client-acl.json(含控制端公钥)即走 ed25519 并写入 acl.default_effect="deny";否则保持 psk 但输出醒目安全告警(说明 client_id 自报的固有风险 + 三步切换指引)。
  • 审计链外部锚定 —— system.audit.head(链头可读)+ system.audit.anchor(锚点追加落盘)。 锚点记四元组 {entries, head_hash, head_ts, rotated_segments}:只记哈希会把日志轮转 误判成篡改。锚点文件只追加 —— 可覆盖的话,攻击者重写链后再刷新锚点即可抹痕。

修复(五处只有真机能暴露的严重缺陷)

  • install.cmd 双击首装即炸 —— for /f 调 PowerShell 时 ^| 转义漏进 PS,导致 ParseError。真实用户第一次安装必然踩到(PSK.txt 已不在分发包内)。
  • pack.mjs 三连 —— spawn node_modules/.bin/esbuild 在 pnpm 布局下 ENOENT; 以及 zip / ls -la / du -sh 在 Windows 上都不存在。已改为 esbuild JS API + 平台分流(Windows 用 Compress-Archive)+ Node 原生 API。
  • where 的 PowerShell 注入语法错 —— 连修两轮:JSON 字面量 ✗ → \" 反斜杠转义 ✗ (PS 5.1 不认)→ PS 单引号字面量 ✓。症状是「带 where 一律窗口操作失败,不带则正常」。
  • screen.find 的 where-only 被自己入口校验拦下 —— 只改了一半(gui.await 侧放开、 screen.find 侧没动)。
  • parseLeadingTs 吃掉 ISO 的 Z 时区标记 —— UTC 被当本地时间解析,整体偏 8 小时, 跨时区日志判断「何时断的」会错(单测抓到)。

修复(其他)

  • 自重启在非管理员身份下失败 —— -RunLevel Highest 被系统拒绝(v7 起的既有缺陷)。 现改为两级降级:先试 Highest,失败则不指定 Principal(继承当前权限级别)。 修复后 deploy 首次能一次走通完整闭环。
  • deploy 备份无限堆积 —— .bak-<时间戳> → 固定名 .bak + 清理历史(真机 17 → 2 个文件)。
  • 自重启任务 Running 僵尸 —— 清理条件加「LastRunTime 早于 1 小时」余量 (不能简单清所有 Running:那会连当前 agent 的宿主一起杀)。
  • Windows CI 四连:URL.pathname 跨平台路径、shell: cmd 套多行 PowerShell、 5.1 执行的内联脚本含中文、产物包断言方向反了(PSK.txt 从「必含」改「不得含」)。

安全 / 隐私

  • 源码与测试里的真机常量脱敏(机器名、内网地址)—— 保留「为什么」的教训,去掉具体实况。
  • 能力调用新增审计类型 session.idle_close。

文档

  • README 能力总览补三行、数字同步至 51 项能力 / 41 个 MCP 工具 / 269 单测。
  • 新增章节:gui.await 属性谓词与组合、状态采样、审计链外部锚定。
  • docs/SECURITY.md 新增 §4.7(零信任优先安装)/ §4.8(审计锚定), §5「还没做的」清单至此全部完成。
  • docs/DEVELOPMENT.md 能力表补全(此前停在 19 项)。
  • docs/RETROSPECTIVE.md 加「短板已全部解决」对照表(保留原判断不改写)。
  • docs/ROADMAP.md 台账刷新,并新增「真机验证抓到的 8 个真 bug」表。

[v1.6.0] —— 2026-10-07

新增

  • gui.await 等待条件能力(第 43 项)。

说明

  • 本版未经真机验证即发布(当时被控端离线);相关验证已于 2026-10-09 ~ 10-10 补齐。

[v1.5.0] —— 2026-10-07

新增

  • input.key.press 参数面扩展:字符串热键(hotkey: "ctrl+shift+esc")、约 50 个预设别名、 长按 hold_ms、后台定向投递 route=post + target_pid。
  • 按键表补齐:左右侧修饰键、小键盘(与数字键分开)、OEM 符号键(解锁 win+d / ctrl++ / win+.)、IME 键、浏览器键、系统键。

说明

  • 本版同样未经真机验证即发布;2026-10-10 已验证字符串热键与单键投递,其余项待被控端在线后补齐。