- README:数字刷新为实测(52 能力 · 42 MCP · 467 单测 · 50 e2e · 23 Rust); 补 na_audit_l2 工具表项、看门狗三端接入段;删「控制台安装包只作为 CI artifact」过期行(9f95560 起已随 Release 发布)。 - CHANGELOG:[未发布] 定稿 v2.1.0 全部主题(rollback / 看门狗 / na-gui 清理 / e2e 与文档收口),与 A 线已回填段共存无重复。 - DEVELOPMENT:新增 §7.5 selfupdate 代理出网机制(NA_PROXY→环境变量→ 系统代理、PAC/socks5 明确报错、回环恒直连、计划任务环境变量告诫); §7.4 补 rollback/看门狗双通道分工表与「为什么运行期不做自动回滚」 三问引述。 - ROADMAP:新增第十节 —— v2.1.0 已落地项划掉、P2 登记(控制台自更新 v2.2.0 / Linux auditd / macOS ESF / macOS 安装器 / guard 日志轮换)。 - tests/unit/docs-numbers.test.mjs(新,6 例):README 五个数字与可 复现实测口径钉死(manifest 条目 / defs.ts 工具 / node --test 实测 / e2e 源码 / Rust 属性),「增能力不改 README」即红。单测数实测经 NODE_TEST_CONTEXT 剥离 + NA_DOCS_NUMBERS_CHILD 递归防护;注入故障 (52→53)实测 2 处守卫变红、恢复全绿。
85 KiB
变更日志
本文件记录 nodeagent 的显著变更。格式参考 Keep a Changelog, 版本号遵循 语义化版本。
[未发布]
本段为 v2.1.0 定稿段(版本统一见
efe4744):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
RestartCount3 → 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 三条(字段与 manifestreturns_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 / macOSscutil --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.updatereturns 增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审计类型四端同源:protocolAUDIT_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:一键安装器(对齐 Windowsinstall.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:+publishjob:只有 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
.msi34.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,实测.msi34.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.exe82.9 MB / PE0x8664、arm64-setup.exe内解出node.exe73.5 MB / PE0xaa64, 三样随包资源齐全(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:unit291/291 +test:e2e30/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:rust14/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:rust18/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 隔离后仍有区分力) |
三条根因(缺一条都修不好):
- Node 内建
fetch(undici)默认不读任何代理环境变量。NODE_USE_ENV_PROXY=1可开 (Node 22.x 实验性、24 正式),但它只认环境变量。 - 系统代理读不到:Windows 用户用第三方代理客户端时配的是 WinINET 系统代理,
macOS 用户配的是系统设置里的代理——Node 两者都不读。所以「用户明明配了代理」也白搭。
而
fetch(url,{dispatcher})也走不通:dispatcher必须是 undici 的Dispatcher实例, 而 Node 没把 undici 暴露成可导入模块(整包 vendored 会把 sidecar 撑胖,且它依赖.wasm, esbuild 单文件打包有风险)。 - 超时过短:原
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_MS120s(内层)/UPDATE_CHECK_RPC_TIMEOUT_SECS150s(外层), 由 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:unit340/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 随包分发。 实测:.app90.8 MiB(含运行时 86MB)/.dmg33.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 已验证字符串热键与单键投递,其余项待被控端在线后补齐。