Commit Graph
105 Commits
Author SHA1 Message Date
jkooe 7d781d132e feat(audit): L2 系统级审计兜底 —— 三端设计 + Windows 落地
## 它补的是 L1 结构上记不到的那块
L1 是 agent 自己记的,只看得见经过自己 RPC 的调用。攻击者绕过 agent
直接跑系统命令、或干脆把 agent 停掉 —— L1 全部盲区。L2 采集操作系统
自己的审计事件,补的正是这块。设计见 docs/AUDIT-L2.md。

## 必须先说清的信任边界(不夸大作用)
采集器与 agent 同进程同权限 —— 攻击者能停 agent 就能停采集器。
故本地 audit-l2.log 只是一份**归一化副本**,防篡改价值有限。
真正的独立性来自**把记录转发出机**(与 SECURITY.md §4.8 锚定同一思路)。
本实现不自动转发,只提供导出接口;这一点已写入文档与能力描述。

## 三端
- Windows:**落地**。选内置 auditpol 而非 Sysmon —— Sysmon 是第三方驱动,
  需单独分发、需重启生效、版本行为有差异;内置策略零依赖即可立起来。
  检测到装了 Sysmon 则并用,未装不尝试下载。
  - status:查策略是否启用,并提示两个关键开关
    * SCENoApplyLegacyAuditPolicy —— 不设它,组策略旧式类别策略会盖掉
      subcategory,表现为「auditpol 显示已启用但事件日志里没有 4688」
    * ProcessCreationIncludeCmdLine_Enabled —— 不开则 4688 只有进程名
  - enable:开启进程创建 4688 / 登录 4624,4625 / 进程终止 4689(需管理员)
  - collect:Get-WinEvent 拉取 → 归一化 → 独立落盘(带哈希链)
  - verify:L2 链完整性校验
- Linux:仅设计(auditd,需预装),未落地
- macOS:仅设计,ESF 需 Apple entitlement 且必须是签名 System Extension,
  个人自签无法启用 —— 不写假装能用的代码

## 落盘与 schema
独立文件 audit-l2.log,不与 L1 混 —— 混了就无法区分「agent 记的」与
「OS 记的」。记录含 ts(OS 事件时间)、src、event_id(保留原始便于核对)、
kind(归一化类别)、collected_at(采集时刻,与 ts 差过大说明曾离线)。

## 实测
- 新增 8 条单测,含两条故障注入:篡改内容检出「哈希不符」、
  删中间条目检出「链断裂」
- 既有契约守卫两次报红并已被修复:manifest 新增 lkg 字段 → SystemInfo 接口缺声明;
  新增 system.audit.l2 → 能力清单断言过期(51→52)
- 全量单测 349 → 357 全绿
2026-10-17 03:24:17 +08:00
jkooe 2bf45fdf98 feat(pipeline): 交付流水线规范落地 —— 门禁 + LKG 回滚锚点
将《代码审计 → 上线 完整交付方案》v1.0 适配到 nodeagent,产出
docs/DELIVERY-PIPELINE.md,并落地其中可执行的部分。

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

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

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

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

## 实测证据
- 覆盖率门禁三条故障注入均 exit=1(阈值100 / 全量99 / lcov缺失),正常 exit=0
- 提交规范反例 3 类均红,Merge 与无 scope 正确豁免,历史 20 条提交一次通过
- LKG 新增 8 条单测(含篡改 hash 必须检出 matches=false)
- 既有契约测试在新增 lkg 字段后立刻报红(SystemInfo 缺声明),补接口后转绿
  —— 这本身是门禁有效的实证
- 全量单测 341 → 349 全绿
2026-10-17 03:13:18 +08:00
jkooe b34e8c73e0 fix(console): 让「检查更新」在境内真的能用 —— 自持出网 + 系统代理发现
真机症状:控制台点「检查更新」必报 `检查更新失败:fetch failed`。
上一轮曾把此项归因为「环境网络问题」而收工;本轮按「即使环境本身不通,
代码也不该让用户无从自救」重新定位,确认为**产品缺陷**。

三条根因(均经隔离实测)
- Node 内建 fetch(undici)默认不读任何代理环境变量。`NODE_USE_ENV_PROXY=1` 可开,
  未开时 env 被**完全忽略** —— `env -i` 对照实验:带 flag 得 `fetch failed`(证明 flag 生效),
  不带得 `timeout`(证明 env 被忽略)。
- 系统代理(WinINET / macOS 网络设置)Node 亦不读;而 `fetch(url,{dispatcher})` 走不通 ——
  dispatcher 必须是 undici 的 Dispatcher 实例,Node 未把 undici 暴露为可导入模块,
  整包 vendored 会撑胖 sidecar 且其依赖 .wasm 解析器,esbuild 单文件打包有风险。
- 内部超时 20s 对真实重定向链必然失败:`latest.json` → 版本化 URL → CDN,
  实测耗时 2.0 / 10.0 / 39.7 / 68.6 / >109 秒。

修法
- 新增 `apps/console/sidecar/proxy.mjs`(自持出网,零新依赖):https 目标走 `CONNECT host:443`
  隧道后叠 TLS(隧道返回的 socket 已是 TLS,须用 `http.request` 承载,用 `https.request` 会二次握手;
  且**不可把 URL 对象当第一参**,否则会覆盖手搭的代理字段退化成直连);http 目标走绝对形态请求行;
  含 NO_PROXY 匹配、系统代理发现(`reg query` / `scutil --proxy`,PAC 不可解析时记 note 而非静默)、
  socks5 等看不懂的形态一律记 note。
- `check_update` / `download` 全部改走 `httpGet`,整条重定向链共享一个超时预算与 AbortController。
- 内外超时成对下发:`sidecar.rs` 新增 `UPDATE_CHECK_TIMEOUT_MS`/`_RPC_TIMEOUT_SECS` 与
  `UPDATE_DOWNLOAD_TIMEOUT_MS`/`_RPC_TIMEOUT_SECS` 两组常量 + **编译期不变式**
  (`const _: () => assert!(...)`),保证「出网超时 < RPC 超时」——否则用户只会看到
  丢掉真实原因的「check_update 超时」假失败。订正 6 处因超时改动而变陈旧的注释。
- 失败信息带 `url` + `via`/`proxy`/`proxy_source`/`timeout_ms`/`hint`;界面新增「出网」行,
  并修掉重复前缀(`检查更新失败:检查更新失败:…`)。

顺带修掉两个真机暴露的缺陷
- **隧道/TLS 阶段的 abort 无人受理**:`createConnection` 回调返回前外层请求尚未建立,
  外层 `signal`/`timeout` 无人受理 → 真机实测配 75s 超时却挂了 109s。
- **`ECONNRESET` 被归入 abort 类** → 报成「请求超时」(真机:耗时 82.9s 却报「120000ms 内未完成」)。
  改为只认真 abort,并新增网络错误翻译(ENOTFOUND/EAI_AGAIN/ECONNREFUSED/ECONNRESET/EPIPE)。

守卫与验证
- 新增 `tests/unit/sidecar-proxy.test.mjs`(16 用例,四段:纯函数 / 真发请求 / 端到端
  spawn sidecar 跑 JSON-RPC / 形态守卫)。夹具用本机真 `scutil --proxy` 与真 `reg query` 输出,
  地址改为 RFC 5737 文档段,不写内网 IP、不写工具实名。
- 守卫落在「最终下发的请求」与「产物字节」上,逐条**注入故障实测会红、还原后逐字节一致**:
  ① `check_update` 退回裸 `fetch` → 代理未收到该次请求 → 红
  ② 去掉 `isBypassed` 后缀匹配 → 红
  ③ 出网/RPC 超时改回反序 → 红 / `error[E0080]`
  ④ 去掉隧道 abort 挂载 → 红(`实际 4002ms`)
  ⑤ `ECONNRESET` 塞回 abort 类 → 红
- 全量:`unit` **341/341**、`rust` **18/18**、`clippy -D warnings` 干净、`vue-tsc` 退出码 0。

未覆盖项(如实记档)
- https 经 CONNECT 隧道叠 TLS 无离线自动化用例:`selfsigned` 只在 `apps/agent` 包内,
  测试进程解析不到 → 无法离线造受信任 TLS 站点;该路径仅真机验证。
- 真机「经代理取 GitHub 成功」的那一组未取到(宿主两条代理线路当时都取不到 GitHub),
  属环境而非产品缺陷;错误信息已可操作(含 `via`/`proxy_source`/`hint`)。
- 同类缺陷**第三处未改**:`apps/agent/src/capabilities/selfupdate.ts` 仍是裸 `fetch`
  (属被控端变更,改则需升 PATCH)。

注:本项为上壳变更,不触发 release patch;`apps/console` 至今未接入 `release.mjs`。
2026-10-13 22:41:02 +08:00
jkooe b9236dfaf1 chore(privacy): 公开仓库隐私脱敏 —— 清除可定位到个人与设备的字面量
按 security-reports/ 的隐私审查台账逐项收口。纯脱敏与表述泛化,
不含任何业务逻辑变更;实测结论与排查步骤一字未动。

P1(可定位到具体设备/个人)
- scripts/verify-b2.mjs:NA_URL 去掉硬编码缺省值(原指向一台真实机器),改为必填。
  这是全仓库唯一「拿着就能连到某台真机」的常量。
- 测试账户名 pdverify → na-test(docs/VM-VERIFICATION.md 7 处、docs/console-plan.md 1 处)
- 虚拟机与宿主的真实网段 → RFC 5737 文档保留段 192.0.2.x(含 CLOSE_WAIT 记录)
- 跨项目名与私人称呼 → 中性表述(docs/PRD.md、docs/DEVELOPMENT.md、docs/console-plan.md 等)

P2(低风险设备指纹)
- 虚拟化产品及其 CLI / 访客工具名 → 中性表述(12 个文件 + 1 个工作流注释)
- 测试目录 C:\nodeagent-test → C:\na-test
- NAS 设备型号与系统名 → NAS 设备 / NAS 系统(docs/DEPLOY-LINUX.md、scripts/install-linux.sh、README.md)
- 真机 CPU 型号、真机物理分辨率、代理软件品牌与监听端口、杀软品牌 → 泛化
- 单元测试示例段去掉真机 IP 尾八位:acl-v11 / net-scripts 的 fixture 与断言同步更新
- .gitignore 补 config.json / agent.json(含真实密钥与节点地址,绝不入库)
- 顺带移除混入技术文档的「不构成投资建议」字样(与文档主题无关)

验证:pnpm test:unit 340/340 通过;独立扫描器对 235 个跟踪文件做 17 类模式
全量扫描,命中 0。
2026-10-13 22:35:40 +08:00
jkooe 00992e5c1c feat(linux): Linux 被控端适配 —— 安装器 + systemd 自启 + 平台守卫 + 部署文档
面向「服务器型被控端」(命令/状态/文件/审计;无 GUI),实测环境 Debian 12(fnOS 底座)。

## 新增
- `scripts/install-linux.sh`:一键安装器(对齐 Windows `install.ps1` 的语义)
  · 自动获取 Node 运行时(系统无 Node 22+ 时下载 linux-<arch>,走 npmmirror)
  · **systemd 服务**(系统级 / 用户级+linger / `--no-service` 三种模式),开机自启
  · 幂等:复用既有密钥(并自动备份 agent.json);卸载保留配置(`--purge` 才删)
  · `--node-id/--port/--allow-input` 等参数;缺 `ip`/`ifconfig` 时 IP 展示多级回退
- `apps/agent/src/util/platform.ts`:**统一平台守卫**
  · `assertPlatform(cap, ...allowed)` / `assertGuiPlatform` / `assertWindows`
  · 抛 `E_UNSUPPORTED_PLATFORM` 并带上「支持哪些平台」,让"未实现"不再伪装成"参数错/环境缺依赖"
- `docs/DEPLOY-LINUX.md`:部署指南 + **实测能力清单** + 故障排查表

## 修改
- `screen.ts` / `record.ts` / `clipboard.ts` / `audio.ts` / `input.ts`:入口接入平台守卫。
  修复前的表现是**误导性错误**(`screen.capture` 在 Linux 上报 `region 宽高必须 ≥ 1`,
  像是参数没传对),现在是 `screen.capture 未在 Linux 上实现(仅支持 Windows / macOS)`。
- `scripts/verify.mjs`:v2.1 / v2.3 补 `E_UNSUPPORTED_PLATFORM → skip`
  (v2.2 早已处理;此前把"平台不支持"记成失败)
- `README.md`:文档区链入 DEPLOY-LINUX

## 实测(Docker · Debian 12)
- 安装流程端到端:装文件 → 写配置(600) → 提取密钥 → 启动监听 → **幂等复装密钥不变** ✓
- 验收:**9 通过 / 1 失败 / 6 跳过**(对比 macOS 15/0/1)
  · 1 失败 = 容器缺 `procps`(`process.list` 返回空),非代码问题;NAS 自带该包
  · 6 跳过 = GUI 类能力(平台诚实拒绝)
- 单元测试 325 全绿;7 包构建通过

## 途中修正的三个"差点误判"
1. **Alpine 误判**:`spawn /bin/bash ENOENT` 是**镜像没 bash**,不是 Linux 不支持
2. **`set -e` 静默退出**:`LAN_IP=$(ip ...)` 在无 `ip` 的环境失败会**直接终止脚本**
   (`2>/dev/null` 把原因吞了,表现为"跑到一半就没了")→ 已改多级回退
3. **slim 镜像缺 procps** → `process.list` 空列表,被误读为"Linux 特性"
2026-10-13 21:15:59 +08:00
jkooe 9384b64c42 fix(console): sidecar 补 CREATE_NO_WINDOW(不再弹常驻黑窗)
真机复验 verbatim 修复时暴露的既有缺陷:点「检查更新」后屏幕上多出一个不消失的
黑窗(WindowsTerminal 承载,标题即 node.exe 路径)。

根因:控制台自身是 GUI 子系统,spawn 控制台子系统程序(node.exe)时 Windows 会
新建控制台,必须 CREATE_NO_WINDOW 抑制。AgentManager::start 早有此标志,
sidecar_command 漏了。修复 verbatim 前 sidecar 秒退、黑窗一闪而过;
sidecar 常驻后它才留在屏幕上。

修法:sidecar_command 补 creation_flags;常量提为 sidecar::CREATE_NO_WINDOW
由两处共用,避免值漂移。

本项无自动化守卫(如实记档):creation_flags 只有 setter 没有 getter,
断言不到最终值;扫源码属源码级检查,违反铁律 1。靠真机截图对照把关。
clippy 零告警,test:rust 18/18(行为零变化的注释/常量整理 + 一处新标志)。
2026-10-13 20:41:02 +08:00
jkooe 2437b0e863 fix(console): unverbatim 用 to_str 而非 to_string_lossy(不损坏非 UTF-8 路径)
to_string_lossy 会把非法字节替换成 U+FFFD 再重建路径 —— 那是损坏路径,
比不净化更糟:原本只是 node 报 EISDIR,改完变成指向一个不存在的目录。

改为 to_str():非 UTF-8 → None → 原样返回(Windows 上是含未配对代理项的路径,
Unix 上是含非法字节的文件名),绝不悄悄改写。

新增守卫 unverbatim_对非_utf8_的_verbatim_路径必须原样返回不得损坏(#[cfg(unix)]):
构造必须同时含 \\?\ 前缀与非法字节才有区分力 —— 不带前缀时新旧实现都走
「原样返回」那一步,测不出差异。
注入故障(退回 to_string_lossy)实测该守卫如期变红;还原后 sha256 逐字节一致。
pnpm test:rust 18/18(+1),clippy 零告警。
2026-10-13 20:21:51 +08:00
jkooe 56f64e1cc1 docs(console): 把 verbatim 路径的证据精确到「脚本参数」这一维
真机二维四格实验(ProcessStartInfo,精确对应 Command::new(prog).arg(script)):
  G1 plain-prog + plain-script    -> exit 0,正常
  G2 plain-prog + VERBATIM-script -> exit 1,EISDIR lstat 'C:'
  G3 VERBATIM-prog + plain-script -> exit 0,正常
  G4 VERBATIM-prog + VERBATIM     -> exit 1,EISDIR lstat 'C:'

结论:炸的是「脚本参数」带 verbatim,不是「可执行文件」带 verbatim。
旁证:真机上被控端与 sidecar 的 program 都带 \\?\ 却长期存活。

因此改准注释口径:净化 script 是关键修复,净化 node 属防御性
(守住「凡交给外部进程的路径都干净」这条单一纪律,不靠记位置)。
行为零变化(纯注释 + CHANGELOG)。
2026-10-13 20:16:07 +08:00
jkooe eec76ed91e fix(console): 交给 node 的路径去掉 Windows verbatim 前缀(真机 sidecar 秒退根因)
真机现象(Windows 11 ARM64,控制台 v2.0.3):点「检查更新」立刻报
「sidecar 已退出(stdout 关闭),请重试」—— sidecar 在请求进行中真的退出了。

真机 A/B(同一个 node.exe、同一份 sidecar.mjs,只换脚本参数写法):
  C:\…\sidecar.mjs        → 2.5 秒返回结果,进程常驻
  \\?\C:\…\sidecar.mjs    → 0.47 秒退出,exit 1,零 JS 执行

根因:Tauri 的 resource_dir() 在 Windows 上返回 canonicalize 后的 verbatim 路径,
把它当参数交给 node,node 启动期 realpathSync 把 `\\?\C:` 拆成 `C:` →
EISDIR ... lstat 'C:' → exit 1,一行 JS 都来不及执行。
本地/CI/开发模式全测不出:开发期走 CARGO_MANIFEST_DIR 的普通路径,只有打包后才暴露。

修法:
- 新增 unverbatim():剥 \\?\UNC\ → \\、剥 \\?\ → 无前缀,纯字符串处理,全平台可调用
- 新增 sidecar_command():唯一把路径交给 node 的地方,单测断言其最终 program/args
- resolve_node / resolve_sidecar / resolve_bundled_agent 全部出口加闸
- Sidecar::spawn 与 AgentManager::start 进程边界对 node/script/data_dir 三条路径净化
- autostart::app_exe() 净化(该路径进计划任务 -Execute)

回归:新增 3 条守卫;注入故障(去掉 sidecar_command 的 unverbatim)实测第三条如期变红,
还原后 sha256 逐字节一致。pnpm test:rust 17/17(14 → +3),clippy 零告警。
2026-10-13 20:11:03 +08:00
jkooe 73ed06f5f1 fix(console): 修复 sidecar 退出后被复用(Windows 更新超时根因)
现象(Win11 ARM64 真机):点「检查更新」等满 45 秒报「check_update 超时」。
对照:同一台机器手工拉起 sidecar 跑同一请求 6.989s 成功;现场 node.exe
只有被控端一个 —— 控制台的 sidecar 进程根本不存在。

根因:ensure_sidecar 只判「缓存里有没有实例」,不判「那个实例还活着没」。
往已退出进程的管道写,在 Windows 上未必立刻报错(缓冲区吞下即返回成功),
于是请求既不报错也永远等不到响应,只能干等到 request 的超时。

修法:
- Sidecar 增 dead: Arc<AtomicBool>,三处置死(stdout EOF / 写入或 flush 失败
  / shutdown 主动关闭);stdout 关闭时唤醒所有在等的请求,让它们立刻失败。
- 新增 is_alive():dead / child.try_wait() / 已被 take 走,拿不准一律按不可用。
- request() 入口先判存活,不可用即秒失败。
- ensure_sidecar 取到缓存实例先判存活,已退出则丢弃重建。
- 新增 3 条单测(含「对已退出的 sidecar 发请求必须秒失败」——给 30s 超时但
  断言耗时 < 5s,因为修复前的表现正是干等到超时)。注入故障实测守卫变红。
- pnpm test:rust 14/14;test:unit 321/321。

属控制台上壳变更,不触发 release patch;但需重建控制台安装包才能让用户拿到修复。
2026-10-13 17:59:49 +08:00
jkooe 79969e9d5d chore(release): v2.0.3 2026-10-13 17:36:54 +08:00
jkooe 530bb4fca7 fix(console): 加单实例保护,修「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,并新增一条单测。
2026-10-13 17:16:41 +08:00
jkooe ae02252a1d fix(agent): 修复 18 条实机复现缺陷(2×P0 / 5×P1 / 10×P2 / 1×P3)
一轮「静态审查 + 真机实测」的完整普查。基线 v2.0.2 / ad90634 上 unit 291 + e2e 30
全绿 —— 也就是说 18 条**全部落在既有覆盖之外**。报告见 bug-report-2026-10-13.html。

P0 两条:
- W-01:window.focus 把 JSON.stringify(title) 插进 PowerShell **双引号**串(PS 里 `\` 不是
  转义符、双引号串还会展开 $(...))→ 命令注入。真机实测最险的是它**静默**:接口返回的是
  「正常业务错误」E_EXECUTION_FAILED: 未找到匹配的窗口,而 payload 已经把文件创建出来了。
  新增 apps/agent/src/util/quote.ts 作 psLit/shLit 单一真源,14 处注入点收敛(window.focus /
  uiaFind / ocrFind / system.ts 计划任务参数 / ps-helper.ts 的 -File 裸拼 …)。
- A-01:ACL 省略 clients 时 aclPolicy.clients.find 抛错,而这段落在 if (hasAcl) 块**之外**
  无条件执行 → 配置少写一个字段,每条 invoke 都崩掉被控端。
  ⚠️ 静态审查当时判它是「fail-open 放行」,实测是崩溃(更严重)。

崩溃链共同根因:ws.on('message') 用 void handleMessage() 且无 .catch,全仓又没有任何
process.on('unhandledRejection') —— Node ≥22 遇未处理 rejection 直接终止进程。两条畸形报文
(hello {protocol:123} / auth {hmac:1})就能在**认证之前**打死被控端(verifyNonce 有 try/catch,
psk 默认路径的 verifyHmac 没有)。index.ts 新增进程级兜底:日志每次打、审计按 10s 限流
(否则畸形报文可高频刷爆 audit.log),且明确不退出进程(可用性优先,取舍写在注释里)。

其余 15 条:
- clients: [] → fail-open 全放行(判据改为「对象是否存在」而非「数组长度」)
- macOS screen.find 的 text 拼 bash → $(...) 实测执行(走 shLit)
- 控制台审计筛选项与协议 enum 漂移,除 invoke 外全 E_PARAM_INVALID
  → 新增 packages/protocol/src/audit-types.ts 作唯一真源,manifest enum 由它派生 + 编译期双向守卫
- monitor.start 的 target 注入、monitor id 路径穿越(读/删)→ psLit + guardPath
- keys{} + client_id:"constructor" 崩 → hasOwnProperty(原型链取值)
- 事件订阅跨控制端可见/可删、event.watch 绕开 fs_roots → 有状态资源落 owner + 统一白名单
- screen.capture 的 region 无上界 → assertRegionSane() 且**提前到枚举显示器之前**(fail fast)
- ipMatchesCidr('8.8.8.8','10.0.0.0/') → true(空掩码被 Number('') === 0 当 /0)
- CLI --timeout 静默失效(strict:false 下未声明选项被当 boolean,值掉进 positionals)
- 拒绝类事件不写审计 → 补 request.rejected / agent.error

回归:unit 321/321(+21)、e2e 33/33(+3 组硬化用例)、rust 11/11。
e2e 新增用例的姿势:每场景**独占端口 + 独立临时 NODEAGENT_HOME**(首版复用端口,
连上了上一块的残留实例,把对照组误判成「实现有缺陷」)。
新增守卫均按铁律注入故障实测:确认会变红、还原后复核一致。
2026-10-13 17:16:36 +08:00
jkooe 02887e265b fix(agent): 图形能力在高 DPI(200% 缩放)下坐标错位
真机(Parallels 27 + Win11 ARM64,主机 200% 缩放、物理 3600×2020)暴露:
被控端 PowerShell 助手进程**默认 DPI 不感知**,同一进程里并存两套坐标系 ——

| 调用 | 不感知时 |
|---|---|
| Screen.Bounds / VirtualScreen | 逻辑 1800×1010 |
| CopyFromScreen | 按物理裁剪 → 缺省只截到屏幕左上 1/4 |
| SetCursorPos | 被系统 ×2 |
| UIA(screen.find) | 物理(与进程是否感知无关) |

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

- 新增 apps/agent/src/util/dpi.ts:PS_DPI_AWARE(SetProcessDPIAware 前置段)。
- 注入全部坐标相关脚本:screen.ts / input.ts / window.ts(WIN_HELPER_PRELUDE,覆盖
  window.list 与 window.focus)/ record.ts;并把它从 win-ps/prelude-ocr.ts 的 try{} 里
  移出 —— 放在 try 内一旦抛错就被 catch 吞掉,助手会静默退回不感知。
- screen.ts 显式改用 $d.Bounds.Width(已是物理),scale 仅作参考。
- 新增守卫 tests/unit/dpi-awareness.test.mjs(8 项):断言**实际下发的脚本文本**,
  而非「源码里出现过常量」—— 首版按源码扫描,注入故障实测不会变红;改为断言
  buildInputPsScript / buildListDisplaysScript / buildCaptureScript / WIN_HELPER_PRELUDE
  的脚本串后,二次注入故障精准变红。

真机终验(零换算):
- screen.info 3600×2020,与裸 GetSystemMetrics 一致;
- input.mouse.move(x,y) 读回物理光标 == (x,y)(4 处抽样全中);
- screen.find 物理矩形中心**直接** click → 主题单选 UIA selected 正确翻转且可逆。

文档:VM-VERIFICATION.md 新增 §10(坐标口径缺陷本体 / 复验配方 / 输入注入失效的逐条
排除与判决证据 / 两条 prlctl 通道的语义差异);console-plan.md 补 ARM64 真机安装冒烟与
界面实点记录(含新发现 P2:控制台无单实例保护)。

⚠️ CHANGELOG 备注:本批**属被控端变更**,与上一节「本轮不升版本号」的结论不同 ——
发版必须升 PATCH(→ 2.0.3),否则 agent.mjs 会谎报一个并不包含本修复的版本。

测试:dpi 守卫 8/8;pnpm test:unit 290/290。
2026-10-12 03:11:34 +08:00
jkooe 4e6eb32929 style(console): 配色收敛为「简约白 / 高级灰」两套中性灰阶主题
原配色是深色 + 品牌青绿(#63e2b7)+ naive-ui 默认绿主色 —— 颜色种类多。
改为两套**只用中性灰阶**的主题,侧栏底部一行切换,选择存 localStorage:

  简约白  页底 #f4f4f5 / 侧栏 #fafafa / 卡片 #ffffff
  高级灰  页底 #1d1f22 / 侧栏 #181a1d / 卡片 #24262a

几处刻意的选择:

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

验证:vue-tsc 0 错 | 契约守卫 6/6 | 单测 280/280 | .app 重打通过(92.22 MiB)|
两套主题均以真实采集数据回填渲染核对 —— 截图脚本顺带读回 --na-bg 与 body 背景,
确认主题真的切过去了,而不是「看起来像」。
2026-10-11 14:29:50 +08:00
jkooe 591e44721a style(console): 界面一律不使用图标 —— 导航改纯文字,并关闭内置插画/图标
触发原因是一次真实的渲染事故:侧栏那些 emoji 图标在非系统字体栈的渲染器里
退化成**豆腐块**(本机截图对比可见)。试过给图标指定 Apple Color Emoji、
全局字体栈加 !important、乃至 font-variant-emoji —— 分别无效或更糟
(最后一招把 ASCII 数字也拆成了 emoji 呈现)。而「哪套字体栈一定带 emoji」
跨平台并不成立,与其赌字形,不如不用。

  emoji 图标          → 路由表 meta.icon 字段整体移除,侧栏改纯文字
  n-empty 空态插画    → 16 处 :show-icon="false"(空态里的插画比图标更「图形」,
                        分布在 9 个页面,是最容易漏掉的一类)
  n-alert 类型图标    → 7 处(naive-ui 的 showIcon 默认 true,不显式关就在)
  n-popconfirm 询问图标 → 3 处
  夹在文案里的 ⚠      → TerminalView 的「高权限操作,请谨慎」保留纯文字

严重程度不靠图标也能读:n-alert 仍由 type 的底色/边框区分。

顺带清掉随包的模板残留资产:
  public/vite.svg、public/tauri.svg —— public/ 下的文件会被整体复制进产物,
  此前等于把 Vite / Tauri 的 logo 一并发出去了(且 index.html 的 favicon 正指向它);
  src/assets/vue.svg —— 无人引用;
  index.html 的 <title> 还是模板默认的「Tauri + Vue + Typescript App」。
  原生窗口标题取自 tauri.conf.json("nodeagent 控制台"),故此前不影响观感,
  但与 dev 态浏览器标签页不一致,一并改齐。

「不属界面图标、刻意未动」的三类写明在 CHANGELOG 里:
托盘图标(菜单栏没图标就点不到,关窗后应用无法寻回,属功能必需)、
被控端 CLI 自身打印的文本标记(⚠️/ℹ️/✅,是程序输出内容,非界面元素)、
控件的结构性部件(下拉箭头、折叠箭头、输入框清除钮、表格排序箭头)。

验证:vue-tsc 0 错 | 契约守卫 6/6 | 单测 280/280 | 前端产物内已无任何 svg 与 logo 字样 |
macOS .app 重打通过(92.21 MiB,前端在无 public/ 下亦正常构建)。
2026-10-11 13:50:49 +08:00
jkooe 2eb6a6f8bb feat(console): 本机被控端托管 —— 启停/重启/日志/自更新/托盘/开机自启
目标:双击应用即用,全程不碰命令行。

  要求                落点
  不再打开命令行窗口   agent.rs 的 CREATE_NO_WINDOW + 自启拉控制台而非裸 node
  启动/停止/重启       AgentManager 五态状态机 + 界面按钮 + 托盘菜单
  检查更新             check_update / download / apply_update
  日志功能             stdout/stderr 双泵 + 环形缓冲 + 实时推送 + 过滤/导出
  后台运行             托盘常驻,关窗最小化而非退出
  开机自启             macOS LaunchAgent / Windows 计划任务

新增「本机服务」页(/local,默认首页)与「服务日志」页(/logs)。
默认路由由 /nodes 改为 /local:打开应用先看到本机被控端的启停面板,
而不是先要求用户填一个连接地址。

四条实测踩出来的关键约束(均已在代码里注明原因):

1. 退出监控用轮询 try_wait,绝不把 Child 交给 wait()
   —— wait() 会取得 Child 所有权,此后 stop() 再也拿不到它去 kill。
2. 日志游标用独立单调 seq,不用位置下标
   —— 环形缓冲淘汰旧行后位置下标会漂移,前端增量拉取静默错位(不报错)。
3. start() / restart() 的接收者必须是 self: &Arc<Self>
   —— 监控任务要持自持引用。
4. macOS gui/<uid> 的 uid 运行时 id -u 现取,不硬编码 501。

播种(seeding)防降级:只有「随包版本 > 已安装版本」才覆盖数据目录里的脚本,
且版本比较按数值逐段(2.0.10 > 2.0.9)。若写成「同版本才跳过」,
用户自更新过被控端后下次开控制台会被静默降回旧版。

「实例是否在跑」不靠单一信号:管理器状态与 TCP 端口探测交叉判定,
以区分「本控制台托管的实例」与「手工安装的非托管实例」并给出不同处置。

消灭黑窗有三条措施,第 2 条最易漏:Rust 的 std::process::Command 默认不设
CREATE_NO_WINDOW,于是「界面上点一下启动」= 屏幕上闪一个控制台窗口。

控制台现在随包分发被控端脚本(资源 2 份 → 3 份:sidecar.mjs / node / agent.mjs),
用户装完控制台本机被控端即已就绪。

新增 tests/unit/console-commands.test.mjs(6 用例)钉住三类「不报错的失效」:
invoke ⇄ generate_handler! 双向、emit ⇄ listen 双向(这类失效会让界面永不刷新且零报错)、
bundle 资源名 ⇄ Rust resolve_* 字面量、sidecar 方法名、路由表 ⇄ 实际 view 文件。
已注入故障实测会红(改命令名与事件名 → 当场 2 条失败),非空转守卫。

验证:cargo check 0 错 0 警 / cargo test --lib 10·10 通过(含真起 node 子进程)/
clippy -D warnings 通过 / vue-tsc 通过 / 单测 280·280(274 → 280)/
verify-bundle 真 spawn 打包内 node 跑通打包内 sidecar。

⚠️ 托盘、关窗最小化、开机自启、界面点按钮仍需图形环境人工实点。
2026-10-11 11:41:56 +08:00
jkooe f85acad5db chore(release): v2.0.2 2026-10-11 10:18:28 +08:00
jkooe 22bc732334 feat(console): 控制台随包分发 Node 运行时(目标机器无需预装 Node)
控制台的 sidecar 是 .mjs,此前只能在装了 Node 的机器上跑(resolve_node 会回退系统 node)。
新增 scripts/prepare-console.mjs 把 Node 运行时准备为 Tauri resource,随包分发给最终用户。

- prepare-console.mjs:复制本机 node → apps/console/src-tauri/runtime/,macOS 上 strip -x
  (108MB→86MB)**并 adhoc 重签**,再生成注入片段 tauri.bundle.gen.json;带幂等(同源跳过)
  与**产物自检**(真跑一次 node,失败即删除产物并报错,避免坏产物被静默复用)。
- apps/console/scripts/verify-bundle.mjs:验证「打包产物能否自举」——真 spawn 打包内的 node
  去跑打包内的 sidecar 并走一次真实 JSON-RPC 握手。
- package.json:新增 prepare:node / verify:bundle,bundle = sidecar:build → prepare:node →
  tauri build --config → verify:bundle(一条命令产出可分发件并自证可用)。
- src-tauri/.gitignore:忽略 /runtime/(~86MB)与生成的注入片段(生成物不入库)。

三处实测踩出来的约束(均已写进脚本注释与 console-plan.md):

1. **macOS 上 strip 会使 Mach-O 代码签名失效 → 内核直接 SIGKILL(exit 137)**,且无任何有用报错。
   实测对照:原样复制 ✓ / strip 不重签 ✗(137) / strip + `codesign --force --sign -` ✓。
   故 strip 之后的 adhoc 重签是**必要配套**,不是可选优化。
2. **Node 运行时不得声明进默认 tauri.conf.json** —— bundle.resources 是编译期契约
   (tauri-build 校验声明的文件必须存在),声明「运行期才就绪」的资源会让 cargo check/test
   在 build.rs 阶段直接失败(2026-10-10 已踩)。故一律走 `tauri build --config` 注入。
3. **resource 映射必须与 resolve_node 对齐** —— `runtime/node → node` 使产物落在
   Contents/Resources/node,正是 src-tauri/src/sidecar.rs 里 resolve_node 查找的路径。

实测(macOS arm64):.app **90.8 MiB**(原 6.75 MiB,增量即运行时 86MB)/
.dmg **33.78 MiB**(分发件,仍低于 40MB 目标);应用进程实测可启动且无崩溃输出。
对照:本机实测 Electron 运行时本体(Electron Framework.framework)207MB —— 不到一半。
2026-10-11 08:55:31 +08:00
jkooe 4f4d8913b5 chore(release): v2.0.1 2026-10-11 08:02:19 +08:00
jkooe fbcb8f8408 fix(v23): 连接保活补 pong 检测(治僵尸连接占名额)+ B2 三防护真机验证通过
## 真机发现(Parallels Win11 ARM,B2 验证过程中撞到)
心跳只发 `ping()` 而**不检查 pong**,且 ws 库默认不会因「ping 无 pong」断开 ——
客户端进程已死(TCP 未发 FIN)时,连接会一直挂在 `states` 里占 `max_connections` 名额。
叠加「有任务/订阅时清扫保守跳过」的既有策略 → `states.size` 虚高 → **把正常控制端挡在外面**
(本次实测就把自己锁在外面了)。

## 改法
- `attachConnection` 里加 `ws.on('pong')`:**客户端回应也算活跃**(更新 lastActiveAt)
- 心跳里:若某连接静默超过 `idle_timeout_ms * 2` → `ws.terminate()`(标准 ws 保活)
  → 僵尸连接不会再永久占位

## B2 三场景真机验证(全部通过)
- ① **握手封禁**:配 `max_attempts=3` → 3 次错误密钥被拒后,**正确密钥也被拒** ✓
- ② **连接上限**:配 `max_connections=6` → 第 1~6 个成功、**第 7 个被拒** ✓
- ③ **空闲断开**:配 `idle_timeout_ms=20000` → 静默 **40 秒**被断开 ✓
  (符合 20s 阈值 + 30s 清扫周期)
- 新增 `scripts/verify-b2.mjs`(三模式:idle / maxconn / ban),全部走**真实连接**

## 验证过程中另修的两个自测问题(都记进文档)
1. 脚本默认 URL 写成了 `ws://` —— 被控端 `tls:true`,**必须 wss**(否则 socket hang up)
2. 测 `max_connections` 时值太小会**把自己锁在外面**;且被拒连接会被计入握手失败形成恶性循环
   → 结论:测试要留余量,并给自己配 `auth_ban.whitelist`

## 收尾
测试用的 `security` 段已用**备份恢复**(`agent.json.before-b2`);pong 修复已 `deploy` 到 VM
(指纹 abca2b8168f0)。
2026-10-11 01:13:12 +08:00
jkooe ab7a2b147b fix(v26): 自重启任务清理阈值改为「本 agent 启动时刻」(真机实测 1 小时粗阈值仍会累积)
## 真机发现(Parallels Win11 ARM)
restart 两次后仍有 **2 个** selfrestart 任务(旧的 Ready 未被清)。诊断确认:
- 清理**条件**能选中它 ✓(非匹配问题)
- **手工删除**成功 ✓(非权限问题)
→ 算时间戳才明白:那是「1 小时」粗阈值的**必然结果** —— 旧任务创建于 00:44,
  第二次 restart 在 00:56(仅隔 12 分钟),清理那一刻它**仍是 Running**(cmd 还持有上一次的 agent),
  不满足「非 Running 或早于 1 小时」→ 保留。

**但规则本身是缺陷**:频繁 restart(间隔 < 1h)时旧任务照样累积 ——
v26 想治的僵尸只是从「永不清理」变成「1 小时后才清」。

## 改法(语义更精确)
```powershell
# 旧:$cut = (Get-Date).AddHours(-1)                    ← 粗略
# 新:$cut = (Get-Date).AddMilliseconds(-<agent uptime>) ← 「本 agent 的启动时刻」
```
凡在**我启动之前**就运行的任务,必然不是我的宿主(我的宿主刚刚才创建)→ 可安全删除。

## 验证(真机闭环)
1. `nodeagent deploy release/agent.mjs` → 指纹校验 + 备份 + 上传 + 重启 + 校验 ✓
   (顺带把 deploy 机制也验了)
2. `restart` → **任务只剩 1 个**(Running,当前宿主)→ **旧任务被精确清理** ✓✓
3. 单测:断言必须是「agent 启动时刻」阈值,且**禁止回退到 AddHours(-1)**;
   守卫 9/9 + 全量 274 绿;7 包构建通过
2026-10-11 00:50:51 +08:00
jkooe 28ce95f045 feat(audit): 审计锚点外部化落地 —— 判定逻辑收敛为 protocol 纯函数 + 控制端工具 + 云盘链路
## 为什么(一句话)
锚点的安全性完全来自「它在攻击者碰不到的地方」。与被审计日志同机的锚点等于没锚:
被控端一旦被 root,重写日志后顺手改掉锚点即可。

## 本次改动

### ① 判定逻辑收敛(消除「两份真相」)
把 `compareWithAnchors` 的三条判定抽到 **`packages/protocol/src/audit-anchor.ts`** 的
**纯函数 `judgeAnchors(head, anchors)`**(无 IO,不知路径):
- agent 端 `compareWithAnchors` 改为「读文件 + 调 judgeAnchors」
- 控制端脚本同样调用它
→ 同一份日志不可能出现「被控端说一致、控制端说被改」的分歧
- 附带 `parseAnchorsFromJsonl`(坏行跳过,一行坏数据不毁整份锚点)
- agent 侧类型改为 re-export,既有导入路径不变(**9 个原有锚点单测全绿,行为零变化**)

### ② 控制端工具 `scripts/anchor-audit.mjs`
- `collect`:拉链头 → 生成一行锚点 JSONL(并打印 `ANCHOR_JSON=` 便于 automation 取用)
- `verify`:读锚点文件(可从云盘下载)+ 拉链头 → 判定,`ok:false` 时**退出码 2** + 醒目告警
- 走 CLI 而非自己实现连接(复用其多设备配置/证书钉住/daemon,避免又一份实现)
- 支持 `--head-file`:**离线也能验证判定链路**(本轮即用它做了 fixture 验证)

### ③ 云盘链路
- 建 `…/Project/NodeAgent/anchors/`(与全量备份分开目录)
- 每日 automation 更新为「拉链头 → 上传云盘(一天一个带日期的文件)→ 简报」,
  并规定每月首日额外做一次「下载历史锚点 → 链外比对」

## 验证
- **纯函数重构零行为变化**:原有 audit-anchor 单测 9/9 全绿;全仓 273 单测全绿;7 包构建
- **工具离线验证**(fixture):一致 → 「与锚点完全一致」退出码 0;
  篡改 → 「链头哈希与锚点不一致」退出码 2;三个错误分支均给出明确提示
- 云盘:`anchors/` 目录创建成功(1/1 步),目录内容已复核
- ⚠️ 端到端(真拉链头 + 真上传)**待被控端上线**;脚本的 CLI 分支已做错误处理验证
2026-10-10 19:14:09 +08:00
jkooe 2fe717cb62 fix(ci): 修 clippy 的两个错误(含一个真实并发问题)+ 本地补齐 lint 验证
## CI 失败的真实原因(上一轮我误判为 resources,实为 clippy)
`-D warnings` 下两个错误:
1. **`clippy::await_holding_lock`(sidecar.rs,真问题)**:
   `if let Some(child) = self.child.lock().unwrap().take() { child.kill().await }`
   —— MutexGuard 存活到 if 块结束,**跨 await 持有 std Mutex**:不仅触发 lint,
   真会阻塞 executor。已改为「先取出 child(锁随即释放),再 await kill」。
2. `clippy::doc_lazy_continuation`(lib.rs 文档注释):列表与后续段落之间缺空行。

## 我的验证方式错在哪(已修正)
上一轮我本地只跑 `cargo test`,**没跑 clippy** —— 而 CI 是 `clippy -D warnings` + `test`
两道闸。本地与 CI **标准不一致**,于是"本地绿、CI 红"。
本轮起:**本地按 CI 的完整命令集验证**(clippy --all-targets -- -D warnings → cargo test)。

## 验证
- `cargo clippy --all-targets -- -D warnings` → **零警告**
- `cargo test` → **3/3 通过**
2026-10-10 15:33:11 +08:00
jkooe 9bba53d5c0 fix(ci): 修 Rust Shell 工作流 —— resources 只声明构建期必产出的文件
CI 失败根因(本地已复现确认):`tauri-build`(build.rs) 会在**编译期校验**
`tauri.conf.json` 的 `bundle.resources` 声明文件是否存在。
我之前声明了两项:`sidecar.mjs`(CI 未生成)+ `resources/node`(Node 运行时未下载)
→ build.rs 直接失败("failed to run custom build command")。

修复:
- `tauri.conf.json` 的 resources **只留 sidecar.mjs**,并写明为何不能声明未就绪的文件
- `rust.yml` 在 clippy/test **之前**加「构建 sidecar」步骤(`pnpm --filter nodeagent-console sidecar:build`)
- console-plan.md:⑤ 不再把 node 记为已完成(未验证的不写成做完),
  补「已知待办」(Node 运行时随包分发 + GUI 端到端人工验证)与「构建约束」说明

本地复现验证:删掉 `sidecar/dist/` → `cargo check` 复现同一错误;恢复 → 通过 ✓
2026-10-10 15:25:12 +08:00
jkooe 53bf5a0634 feat(console)!: Rust 壳改用 Node sidecar(选项 D 完成)—— 退役 crates/nodeagent-client
## 动机(消除「两套协议实现」)
原设计让 Rust 壳自己实现 client,与 TS 版(@nodeagent/client)并存 → 改一处漏一处时
构建过、单测过,只在真机暴露。现 Rust 壳只做**进程管理 + 消息转发**,协议实现只剩 TS 一份。

## ④ Rust 壳改造
- 新增 `src-tauri/src/sidecar.rs`(~290 行):Sidecar 管理器 —— spawn node、
  行分隔 JSON-RPC 的请求/响应匹配(oneshot + 超时兜底)、通知回调(state/log/agent_event)、
  `resolve_node`/`resolve_sidecar` 路径解析(env → Tauri resource → 开发期系统 node)
- 重写 `lib.rs` 为薄转发层:`ensure_sidecar` 懒启动;**5 个 command 签名与 3 个 event 名
  (na-state/na-log/na-event)一律不变 → 前端零改动**;`get_capabilities`/`get_state`
  读本地缓存以**保持同步签名**;`RunEvent::Exit` 时优雅 shutdown(kill_on_drop 兜底不留孤儿)
- `Cargo.toml`:移除 nodeagent-client 依赖、加 tokio;**版本 0.1.0 → 2.0.0**
  (原先与 tauri.conf.json 的 2.0.0 不一致,属版本号统一范围)

## ⑤ 打包配置
- `tauri.conf.json`:`bundle.resources` 声明 sidecar.mjs 与 node;
  `beforeBuildCommand` 串上 `sidecar:build`(否则 resource 缺失)

## ⑥ 退役
- 删除整个 `crates/`(12 文件,含 nodeagent-client 与其 probe 示例)
- `rust.yml` 由「Rust client 单测」改为「**Rust 壳单测(真跑 sidecar)**」:
  装 Tauri 系统依赖 + Node + pnpm,跑 clippy `-D warnings` 与 `cargo test`
- `package.json`:`test:rust` 改指 console 壳;移除 `probe`
  (其「对真机逐项核对字段」的用途已由 CLI `nodeagent invoke` 覆盖)
- 引用清理:README(数字与协议客户端说明)、console 前端两处注释、
  console-plan(开头加「原方案已被取代」提示 + 补改造详情)、ROADMAP 历史描述加退役批注

## 验证
- **`cargo test` 3/3 通过**(真跑 Node sidecar 子进程:spawn/请求-响应/错误不崩)——
  这是关键:sidecar 管理器**不依赖 Tauri GUI**,故核心逻辑可在 CI 真实验证,不必等人工开窗口
- `cargo check` 零警告;clippy 标准与 CI 一致
- 273 单测 / 7 包构建 全绿
- ⚠️ **仍需人工验证**:Tauri GUI 端到端(开窗口 → 连被控端 → 点界面),沙箱跑不了图形应用
2026-10-10 15:16:24 +08:00
jkooe 096193a8ec feat(console): 采纳 sidecar 方案(选项 D 第一步)—— Node sidecar 实现 + 8/8 真机 e2e
## 为什么
原设计让 Rust 壳自己实现一份 client(crates/nodeagent-client),代价是**两套协议实现静默漂移**
(改一处漏一处时构建/单测都不报错,只在真机暴露)。sidecar 让 Rust 壳退化为
「进程管理 + 消息转发」,协议实现只剩 TS 一份,从根上消除漂移。

## 本次交付
1. `apps/console/sidecar/main.mjs`(190 行):把 `@nodeagent/client` 暴露成
   **JSON-RPC 2.0 over stdio**(行分隔)。方法 connect/disconnect/invoke/state;
   onStateChange/onLog/onEvent 三个回调 → `event` 通知(kind=state/log/agent_event)
2. `apps/console/sidecar/build.mjs`:esbuild **JS API** 打成单文件(避开 pnpm `.bin` 的
   ENOENT 坑),并注入 `createRequire` shim —— 否则 `ws` 的动态 require 会报
   "Dynamic require of events is not supported"
3. `apps/console/package.json`:加 `@nodeagent/client`/`protocol` 依赖 + sidecar:dev/build 脚本
4. `docs/console-plan.md`:补「实施决策」节(决策理由、协议定义、进度表、打包方向)

## 两条协议纪律(写进注释与文档)
- **stdout 只写协议消息**,日志一律 stderr(混入非 JSON 行会让 Rust 侧解析失败)
- 任何异常都转成响应,**绝不因单个请求崩进程**

## 错误形态归一(实测中修正)
被控端对未知能力/ACL 拒绝/超时是回 JSON-RPC error → `client.invoke()` 抛异常。
若原样上抛前端要处理两种形态,故归一为 `{status:'failed',error:{name,message,data?}}`;
而「未连接/缺参数」仍走协议错误(语义不同)。

## 验证(真实链路,非 mock)
本地 agent → sidecar **8/8 全绿**:connect 回传 51 项能力与 agent_version ✓ ·
invoke system.info ✓ · state 反映已连接 ✓ · 事件通知已推送 ✓ ·
非法能力返回 failed 且不崩 ✓ · disconnect ✓ · **stdout 无日志污染** ✓
打包产物拷到仓库外**独立运行通过**(227KB 单文件自足)。

## 待办(下一批)
Rust 壳改造(command 签名不变 → 前端零改动)· tauri.conf.json 的 resource 配置 +
Node 运行时打包 · 退役 crates/nodeagent-client
2026-10-10 15:11:39 +08:00
jkooe e23788d717 refactor(versioning): 统一版本号 —— semver 为唯一版本号,开发批次代号并入其归属版本
## 问题(皇上指出)
项目里两套编号并行,无法判断哪个是"当前版本":
- **发布版本**(semver):v1.1.0 ~ v1.6.0、v2.0.0
- **开发批次代号**:v1 ~ v26(README 路线表 / 代码注释 / 文档)
时间线证据:v1.1.0 首发(09-29)时批次已推进到 v12.4 —— **二者从来不是一对一**(26 批次只发过 10 版)。

## 规则(新增 docs/VERSIONING.md §0 版本号唯一性规范)
- **唯一版本号 = semver `vX.Y.Z`**,两个来源必须永远一致:git tag 与 package.json(构建期注入 agent)
- **批次代号不是版本号**,不再单独出现;需要区分同版内多批改动时用**功能名**
- 附「批次 → 归属发布版本」映射表(按 tag 时间线与功能内容推定,并注明以 CHANGELOG 为准)

## 统一动作
1. **批次 v23 ~ v26 → v2.0.0**(22 文件 / 53 处)—— 它们确实属于 v2.0.0
2. **臣的批次号 v1.7 / v1.8 → v2.0.0**(27 文件)—— 未单独发版
3. **简写补全三段式**:v1.5 → v1.5.0、v1.6 → v1.6.0
4. **README 路线表**:`**v1**` → `**批次 1**`;表头加「批次 ≠ 版本号」说明并链到 §0;
   补齐批次 16~29 与 **v2.0.0 发布行**;删掉过时的「待办」行(提到的分支早已删除)
5. `docs/VERSIONING.md` 里**故意保留** 3 处批次号 —— 那是规范说明本身

## 保留项(真实发布版本,不动)
v1.1.0 / v1.2.1 / v1.3.0 / v1.4.0~v1.4.3 / v1.5.0 / v1.6.0 / v2.0.0(10 个 tag)

## 验证
- 版本号一致性:package.json = tag = 8 个 workspace = tauri.conf.json = **2.0.0** ✓
- v1.7/v1.8/v23~v26 全仓残留 **3 处**(均在 VERSIONING.md 规范说明中,属预期)
- 7 包构建 · 单测 273 · e2e 30 · ps1-encoding 24 全绿
2026-10-10 14:27:12 +08:00
jkooe 11127b1a89 chore(privacy): 杀软实名与网卡名示例泛化(安全报告第十三轮 P2 两项)
## 甲1 杀软实名泛化(6 行)
`主流杀软 / 杀软 / 360` → 「主流杀毒软件 / 杀软」:
- docs/REMOTE-LIMITS.md:标题 + 正文 + 表格两处(保留"会拦什么"的实操信息,去掉具体厂牌)
- scripts/release.mjs:278:Release 正文模板里的信任区提示
- apps/cli/src/cmd/ops.ts:398:部署失败排查提示

## 甲2 网卡名示例泛化(13 行)
「Ethernet」→ `Ethernet`(示例)/泛化为"中文网卡名"(讲原理时):
- docs/REMOTE-LIMITS.md 网络适配器表格、ROADMAP 该项标记为已完成
- scripts/rescue-network.ps1 参数说明、automation.ts 的 interface 描述
- apps/agent/src/capabilities/network.ts 注释:改为"脚本里的非 ASCII 字符(如中文网卡名)"
  —— **保留"为什么"的教训,去掉具体实况**
- tests/unit/net-scripts.test.mjs:8 处测试数据与正则断言同步改为 Ethernet

## 验证
工作区残留「主流杀软/杀软/Ethernet」= **0 处**;单测 273 全绿;7 包构建通过。
2026-10-10 12:26:41 +08:00
jkooe a515eb2e63 chore(release): v2.0.0 2026-10-10 12:14:02 +08:00
jkooe 865862ee7c refactor: 拆 mcp/index.ts(1139 → 530 行),工具清单独立成 tools/defs.ts
## 拆法
`TOOLS` 数组(610 行,占原文件过半)抽到 `apps/mcp/src/tools/defs.ts` 并 export。

保留在 index.ts 的:
- 连接与调用辅助(ensureClient / call / forwardEventToHost)
- toArgs(入参特化)· TOOL_TO_CAPABILITY · resolveToolCall(分发 switch)
- MCP server 定义与 main

**分工**:defs.ts 只管「暴露什么」,index.ts 管「怎么转发」。

## 零漂移的证据
抽出的最大风险是**顺序漂移或漏搬** —— 两者都不会让构建失败,只会静默改变客户端
看到的工具表。故用「工具名序列 sha256」做证据:
  拆分前 41 个工具 / sha=69e8533c42c3…
  拆分后 41 个工具 / sha=69e8533c42c3…  ✅ 序列与顺序完全一致

## 新增回归守卫 tests/unit/mcp-tools.test.mjs(4 例)
- 工具数 41 且名称唯一
- **顺序未漂移**(序列 sha 比对;有意调整须同步更新基线并说明)
- 每个工具都有 name/description/inputSchema 三件套
- inputSchema 普遍声明 additionalProperties: false

## 验证
单测 269 → 273 全绿;e2e 30 全绿;7 包构建通过。
2026-10-10 07:50:13 +08:00
jkooe 690f38207c refactor: 拆 window.ts(1057 → 647 行),PS 预加载段按关注点分离
## 拆法(保守:只换文件组织,运行时逐字节不变)
抽出 5 段到 apps/agent/src/capabilities/win-ps/:
- prelude-win32.ts(30 行)Win32 窗口枚举与矩形读取
- body-list.ts(42 行)窗口枚举 JS/PS 体
- prelude-uia.ts(117 行)UIA 控件树 + Get-UiaProps/Test-UiaWhere/Find-UiaByText
- prelude-ocr.ts(37 行)OCR 引擎预加载
- prelude-image.ts(226 行)图像模板匹配(ZNCC 粗到精)

window.ts 保留:工具函数(runPS/runPSRaw/extractJson/warmup)、
WIN_HELPER_PRELUDE 拼接(顺序不变)、windowList / windowFocus / screenFind。

## 为什么这样拆
1. **不拆常驻助手**:window.list / window.focus / screen.find 共用同一个常驻 PS 进程,
   预加载段缺一段就会被 esbuild tree-shake,直到运行时才报"找不到类型"(真机踩过)。
   故拼接表达式与四段顺序**原样保留**在 window.ts。
2. **用 sha256 做零行为变化的强证据**:拆分前 dump WIN_HELPER_PRELUDE 的
   len=15232 / sha=88059028b342…,拆分后**逐字节一致** ✅
3. 保留原有回归测试 tests/unit/win-prelude.test.mjs(8 例)作为长期守卫。

## 验证
- prelude sha256 与拆分前一致(逐字节)
- win-prelude 8/8、单测 269 全绿、e2e 30 全绿、7 包构建通过
2026-10-10 07:47:31 +08:00
jkooe 2cb0ed148e chore(privacy): 源码与测试里的真机常量脱敏(机器名 / 真机内网 IP)
路线图 §七 序 7「源码真机常量注入化」的脱敏部分。

## 改了什么
- apps/agent/src/capabilities/network.ts:注释里的真机机器名 → 泛化为「机器名+纯数字」
  (保留"为什么不能拼 USERDOMAIN/USERNAME"这个教训本身,去掉具体机器名)
- scripts/rescue-network.ps1:.EXAMPLE 里的真机内网地址 → 文档示例段 192.168.1.100;
  网卡名示例由中文默认名改为 'Ethernet'(既避免像真机、又更通用)
- packages/protocol/src/acl.ts:注释里的真机 Mac 地址 → 192.168.1.100
- tests/unit×4:测试数据里的真机 IP 统一换成 192.168.1.x 示例段

## 过程中的两个教训(都记在代码注释里了)
1. **正则转义形态漏替换**:断言里写的是 `192\.168\.0\.88`(转义点),第一轮只替换了
   裸形态 `192.168.0.`,导致脚本值变了、断言没变 → 3 个测试红。
2. **机械替换会破坏「跨网段」这类语义测试**:acl 的 `/24 网段匹配` 用例里有一条
   本意是「**不同网段不匹配**」,替换后两侧变成同段 → 断言必红。已改回不同网段
   并在注释里标注原因,防止下次再被批量替换破坏。

## 验证
单测 269 全绿(复跑);e2e 30 全绿。
2026-10-10 00:39:28 +08:00
jkooe 32e0dd7bd5 fix(v26): 自重启任务清理加时间余量(治真机累积的 Running 僵尸)
## 问题
重启任务的 cmd 会**一直持有新 agent 进程**,所以任务状态长期是 Running。
原清理逻辑只清「非 Running」的(怕误杀当前宿主)—— 代价是真机累积:
2026-10-09 实测 D:\\Nodeagent 上残留 3 个 nodeagent-selfrestart-* 任务(全 Ready/Running)。

## 修法(保守优先)
清理条件改为「**非 Running** 或 **LastRunTime 早于 1 小时**」:
- 1 小时余量远大于一次重启的耗时(正常 <1 分钟),所以"当前宿主"绝不会被误判 ——
  它刚刚才运行过,LastRunTime 就是几分钟前
- 清旧 Running 僵尸是安全的:它们的 cmd/node 早已退出,只剩注册表条目

用 includes 断言锁死这三条(AddHours(-1) / LastRunTime -lt $cut / Get-ScheduledTaskInfo),
并禁止回退到"只清非 Running"的老写法。

## 验证
单测 267 → **269** 全绿(新增 2 例源码断言);e2e 30 全绿;7 包构建通过。
⚠️ **该项在 agent 内,且涉及进程清理 —— 必须真机验证后才算可交付**。
当前被控端整机离线(2026-10-10 00:15 ping 100% 丢包),故标注为待验证,未发版。
2026-10-10 00:19:43 +08:00
jkooe 68546e63e8 docs+fix(v25): 审计锚定的语义说明(锚定自身也写审计 → 紧随比对应显示「已增长」)+ SECURITY §4.8
真机验证时发现一个语义瑕疵:刚锚定就比对,永远显示链已增长 ——
原因是锚定调用自身也被 server 层记入审计(统一记录所有 invoke)。
功能正确(不误报篡改),但反直觉,故:
- compareWithAnchors 的 verdict 补上解释
- manifest 的 audit.anchor 描述补上说明
- SECURITY.md 新增 §4.8(含两个关键设计:四元组锚点防轮转误判、追加式防刷新抹痕),
  §5「还没做的」清单至此**全部完成**
2026-10-10 00:12:02 +08:00
jkooe bec07fc277 feat(v25): 审计链外部锚定 —— 链头可读 + 锚点落盘 + 锚点比对(方向三收尾)
## 解决的问题
审计链的哈希只能发现**链内**篡改(改一条要重算其后全部)。但「有 root 的攻击者」
可以**整链重写**:从零重建一份自洽的假日志,verifyAudit 照样通过。
外部锚定是唯一能戳破它的手段:把链头记到**链外**(另一台机器/网盘),
攻击者改不了已经落到别处的历史记录。

## 两个新能力(能力数 49 → 51)
- system.audit.head(low):返回 {entries, head_hash, head_ts, rotated_segments, file_bytes};
  compare=true 时附带与最近锚点的比对结论
- system.audit.anchor(medium):把链头**追加**写锚点文件(默认 <数据目录>/audit-anchors.jsonl,
  指定 path 时仍受 fs_roots 白名单约束)

## 一个关键设计点(最容易写错的地方)
锚点**不能只记 head_hash**:日志轮转会丢弃最旧段使条目数下降,只记哈希会把轮转误判成篡改。
故记四元组 {entries, head_hash, head_ts, rotated_segments},比对时先按轮转对齐再判尾部。
三条判定:① 条目数减少且无轮转 → 疑似整链重写;② 条目数与段数都没变但哈希不同 → 该段被重写;
③ 条目数增长/轮转 → 锚点仍在链上,ok(附 detail 供人工核对)。

## 追加式而非覆盖式
锚点文件只追加:若可覆盖,攻击者重写链后再"刷新"锚点即可抹掉痕迹。

## 验证
- 单测 258 → **267**(新增 audit-anchor 9 例,含「整链重写」与「哈希不一致」两个关键检测、
  追加式不被覆盖、空链返回 null、无锚点时的提示语气);e2e 30 全绿;7 包构建通过
- CLI:nodeagent audit head [--compare] / audit anchor [--note ...]
- ⚠️ 真机验证随后(需部署到被控端)
2026-10-10 00:07:58 +08:00
jkooe 7e63963e4f fix(v23): 自重启在非管理员身份下的降级 + deploy 备份不再堆积
## ① system.agent.restart / deploy 重启:加权限降级(真机坐实)
原实现硬用 `New-ScheduledTaskPrincipal -RunLevel Highest`(前提是"agent 以管理员运行,
普通权限 taskkill 杀不掉它")。但真机 2026-10-09 坐实:**agent 以普通用户运行时
(is_admin=False),非管理员注册 Highest 任务会被系统拒绝** → deploy/restart 一律报
"注册重启任务失败 exit=1",只有文件更新成功、进程不重启(部署"部分成功"的假象)。

修:两级降级 —— 先试 Highest(管理员场景保持原语义),注册后校验;
失败则不指定 -Principal(默认继承当前权限级别 Limited),同权限下 taskkill 自己完全够用。
已在被控端用 schtasks /RL LIMITED 现场验证过该路径可行。

## ② deploy 备份改为固定名,并清理历史堆积
此前每次部署写 `${target}.bak-<时间戳>`,真机 D:\Nodeagent 下堆了 15+ 个(单文件 1MB)。
部署回滚只需要「上一个可用版本」→ 改为固定名 `${target}.bak`(覆盖式)。
并在上传后 best-effort 清理老格式 `.bak-*`(平台分流:Windows 用 PS,类 Unix 用 rm;
清理失败不影响部署结果,只提示)。

## 验证
单测 250 全绿;e2e 30 全绿;7 包构建通过。
⚠️ ① 在 agent 内,需部署新包才能真机验证;② 在本地 CLI,立即可见。
2026-10-09 23:44:35 +08:00
jkooe 28d590a4e5 fix(v1.8): where 注入改为 PS 单引号字面量(真机连修两轮才通)
上一版(982eb9b)把 JSON 字面量改成「JSON.stringify 两次」,生成
  $whereJson = "{\\"enabled\\":true}"
而 **PowerShell 5.1 不认反斜杠转义**(PS 用反引号),依然 ParseError。
真机复验:部署+重启后带 where 仍一律"窗口操作失败"。

正解(已在被控端现场验证):**PS 单引号字符串字面量**,JSON 内的单引号翻倍为''
  $whereJson = '{"enabled":true}'
再 ConvertFrom-Json 摊平成哈希表(Test-UiaWhere 依赖 .Keys,PSCustomObject 没有)。

## 真机复验(部署 → 自愈重启 → 五场景)
① 带 text 的 where → 命中 1 条 where_applied=True enabled=True
② 反向对照 enabled=false → 0 条  ← 证明 where 真在过滤
③ where-only → 命中 2 条(此前报"text 不能为空")
④ where value 对 null 属性 → 0 条  ← 不猜的设计成立
⑤ text 与 where 全无 → 业务层拒绝

## 两轮踩坑记录(已写进代码注释防再犯)
✗ JS 的 JSON.stringify 生成的对象字面量 → PS 不认
✗ JS 的 JSON 转义 (\\") → PS 5.1 不认反斜杠转义
✓ PS 单引号字面量 + 内部单引号翻倍

教训:**跨语言拼代码时,别用源语言的转义规则猜目标语言** ——
本轮两次都是"在 JS 里想 PS 的转义",正确做法是到真机上把候选写法直接跑一遍。
2026-10-09 23:34:39 +08:00
jkooe 982eb9b1fa fix(v1.8): 真机验证抓到的两个 where 相关真 bug(macOS 上永远测不出)
皇上问"能不能连上 Windows 真机",连上后顺带做积压的真机验证,当场暴露两处:

## ① screen.find 的 where-only 被自己的入口校验拦下
v1.7 声称"control 条件可只用 where 不传 text",但只改了 gui.await 侧,
screen.find 入口仍要求 text 非空 → 直连与 await 的 where-only 全部报
"text 不能为空"。修:text 与 where 至少给一个;macOS 侧明确说明
"无 UIA 等价物,where 不可用,请给 text"(而非笼统的参数错误)。

## ② where 的 PowerShell 注入语法错误(更关键)
原实现直接把 JSON 字面量注入 PS:$where = {"enabled":true}
—— PowerShell 不认这种语法(哈希表要 @{enabled=$true}),ParseError 让
**整份助手脚本**失败。真机现象:带 where 的调用一律"窗口操作失败",
不带 where 的完全正常。
修:改为 JSON 字符串 + ConvertFrom-Json 摊平成哈希表(Test-UiaWhere 依赖 .Keys,
PSCustomObject 没有 Keys)。

## ③ 补上此前"只在注释里承诺"的 note
manifest 写明"OCR/image 引擎忽略 where 并带回 note",代码里没实现。
现补 note + where_applied 字段(UIA 时 true,降级时 false 并说明)。
不补的话,调用方会以为"按属性过滤过了"而误判结果。

## 真机已确认可用的部分(无需修复)
- UIA 属性读取 ✓:QQ音乐精简窗的 Text 控件读到 enabled=true,
  value/selected/toggle 均为 null —— **"拿不到就留 null、绝不猜"的设计在真实
  第三方应用上验证成立**(Qt/Electron 类控件给不出这些属性)。
- monitor/log 能力已在被控端就绪(v1.8 构建已部署):monitor.list 返回 ok。

## 验证
单测 250 全绿(新增 where-only 放行断言);e2e 30 全绿;7 包构建通过。
⚠️ 两个修复都只在 Windows UIA 路径生效,**必须部署到被控端才能真机复验**。
2026-10-09 23:24:40 +08:00
jkooe 7c6a68268b feat(v1.8): 状态采样 —— log.query 被控端侧过滤 + monitor 定时采样落盘与回看(方向二)
## 背景:复盘 §四 那条判断仍成立
代理不通、杀软拦截、端口时开时闭都是**间歇性**的,而 system.status 只能看当下快照;
event.watch 解决「有事件时通知」,没解决「**持续记录并回看**」。

> 顺带纠正路线图一处过时判断:"fs.watch 未做" 不成立 —— event.watch 的 kind=file
> 就是 fs.watch 实现(递归 + glob + 推送/拉取双出口)。本轮不重复造。

## ① log.query(新增能力,低危只读)
在被控端侧过滤,只回匹配行 —— 日志常几万行,整份拉回既慢又占带宽。
- 流式逐行读(readline),扫到 limit 即停,不全量载入
- pattern(正则)/ level(含 WARNING→WARN 归一)/ since(按行首时间戳)/ offset / limit / tail
- 单行 >4KB 截断后再匹配;单次扫描上限 200 万行防失控
- 路径复用 fs.guardPath —— **不新开安全面**(fs_roots 非空即受限)

## ② monitor.*(四项新能力,低危)
start / report / stop / list / delete。四类采样源:
- port(TCP 连通性 + 延迟)/ process(存活 + PID)/ command(退出码 + 首行)/ metric(CPU·内存)
- 样本落 <数据目录>/monitors/<id>.jsonl(一行一针),**摘要直接给结论**:
  「断了几次 + 最长连续中断样本数」「CPU/内存 min·avg·max」「成功失败数与退出码集合」
- 并发上限 8;单文件 10 万行封顶轮转;timer.unref() 不阻止 agent 退出

## 真机验证(本地,15 例全过)
port 采样造一次真实断线(关端口 1.6s 再开)→ report 给出 up=9/down=4/最长中断 4 针/延迟 avg 1.31ms;
metric 给 CPU 16.9% 内存 50.24%;落盘 13 样本可回看;stop 后 report 仍可查;
log.query 按级别/pattern+tail 过滤准确;fs_roots 白名单外的 /etc/hosts 被拒;
非法 source/target/id 字符/重复 id/不存在 id 均被拒。

## 修复一个真 bug(测试抓到)
parseLeadingTs 的正则把 ISO 的  时区标记吃掉了 —— UTC 时间被当本地时间解析,
**整体偏 8 小时**。跨时区日志判断"何时断的"会错。已保留 Z/±hh:mm 后缀。

## CLI / MCP
- nodeagent monitor start|report|stop|list|delete ...;nodeagent log --path ... --level ERROR
- MCP:na_monitor(action 分发)+ na_log

## 数据
能力数 43 → **49**;单测 231 → **247** 全绿;e2e 30 全绿;7 包构建通过。
2026-10-09 23:08:03 +08:00
jkooe 2e14f1f451 fix(v1.7): 修 any_of 路径的类型收窄(TS 跨分支无法推断 condition 已校验)
CI 构建抓出:await.ts 里 buildInnerArgs(condition, ...) 收到 Condition|undefined。
走到该行时已过入口校验、且未进 any_of 分支,condition 必合法,但 TS 跨分支推不出 —
— 显式断言为 single。本地同样报错(上次漏看了 Done 数只有 3 而非 7)。
2026-10-08 01:11:13 +08:00
jkooe 8c41414e04 feat(v1.7): 语义属性化 —— screen.find 读 UIA 属性 + where 过滤,gui.await 支持属性条件与 any_of 组合
## 背景(路线图方向一第二步:从「能找到」到「可断言」)
原链路是「点 → 截图 → 目测 → 再点」。gui.await 解决了「等得到出现/消失」,
但等不到「**等它变成某状态**」—— 按钮出现了却还是灰的,点了没反应。
根因是 screen.find 只回坐标,不回控件状态。

## ① screen.find 返回 UIA 属性(v1.7)
在原有 name/control_type/automation_id/坐标 之外补四个只读属性:
- enabled(是否可用)、value(ValuePattern 当前值)、selected(SelectionItemPattern)、
  toggle(TogglePattern 的 On/Off)
- 每项单独 try/catch:**拿不到就留 null**,绝不猜 —— Electron/游戏 UI 常给不出值,
  由上层判「未知」而非「false」

## ② where 属性谓词(仅 UIA 生效)
{"enabled":true} 只回可用控件;{"value":"*已完成*"} 按值模糊匹配。
- 白名单四键(enabled/selected/value/toggle),其余键直接报错
- OCR / 图像模板引擎忽略该参数并**带回 note**,避免调用方误以为按属性过滤过了

## ③ gui.await 的属性条件与 any_of 组合
- control 条件可只用 where 不传 text ——「等一个 enabled 的按钮」比「等一个叫某名字的按钮」
  更贴近真实意图;但两者全无仍报错
- any_of: [ {...}, {...} ] 任一命中即算,命中时回报具体是哪个子条件(hit 字段)
  例:[{window:安装完成},{control:错误}] =「装成功或报错,先出现的算」
- 设计取舍:**不做通用布尔表达式**。「弹窗出现且其中按钮 enabled」用
  any_of(弹窗) + where(按钮 enabled) 两步即可表达;通用 and/or/not 会把 schema
  与求值都复杂化,收益不成比例。确需两个都成立时上层连调两次更直白。

## ④ CLI / MCP 同步
- CLI:nodeagent await ... --where '{"enabled":true}' --any_of '[{...}]'(JSON 字符串,非法 JSON 即报错)
- MCP:na_await 透传 where/any_of,schema 同步

## 验证
- 单测 **227 → 231**(新增 4 例:where/any_of 过 schema、where 白名单、
  screen.find 的 where 类型校验、any_of 的 OR 语义);e2e **30** 全绿
- **本地真机 6 场景全过**:any_of 命中并回报子条件 / absent 等到全消失 /
  where 在报错环境正常超时 / 非法 where 键被拒 / any_of 项缺 condition 被拒 / 空数组被拒
- ⚠️ 待 Windows 真机:UIA 属性的真实读取(enabled 随播放状态变化)与 where 过滤;
  macOS 侧走 OCR,where 自动忽略并带 note(已按此设计)
2026-10-08 01:04:50 +08:00
jkooe 921ff89edf feat(v23): 连接层三项防护 + 安装期密钥改 stdin(堵住最后一条密钥暴露面)
## ① 安装期密钥不再走命令行(路线图方向三,最后一条暴露面)
此前 install.cmd 用 -Key 传参,**同机其他用户可从 Win32_Process.CommandLine
明文读到**。现改为管道喂 stdin:
- install.ps1 新增 -KeyFromStdin:先判 [Console]::IsInputRedirected 再读
  (没管道时不阻塞)、Trim 去 CRLF、hex 16-128 校验;优先级 -Key > stdin > 复用既有 > 新生成
- install.cmd:echo %PSK%| powershell ... -KeyFromStdin(管道符前不留空格,
  否则空格会被 echo 进密钥 —— 老坑)

## ② 握手失败封禁(此前只计数,攻击者可无限重试)
- 同来源连续失败达 max_attempts(默认 5)→ 拉黑 ban_ms × 2^(N-max),指数退避封顶 24h;
  封禁期过计数保留,继续翻倍
- 5 个失败路径统一记账(挑战过期/nonce 不匹配/未注册/Ed25519 失败/PSK 失败);
  **认证成功即清零**(不误伤手滑的控制端)
- 白名单来源永不封禁;归一化剥离 ::ffff: 前缀(否则同来源被当两个键)
- 在连接入口挡(TLS 之后、业务握手之前),错误响应带 retry_after_ms
- 封禁状态放 core(handleAuth 在内),AgentCore 暴露 banRemainingMs/states

## ③ 连接上限 + 空闲断开
- max_connections 默认 8(超限 close 1013);idle_timeout_ms 默认 30 分钟
- 空闲清扫**保守**:只要还有任务在跑或还有事件订阅就不断 —— 不把挂着长任务
  等结果的控制端踢下线(新增 runningTaskCount / watchCount 两个判据)
- states 由 WeakMap 改 Map(需要 size 与遍历),close 时手动 delete 防泄漏
- 新增审计类型 session.idle_close(console 的 AuditType 同步,契约测试守住)

## 验证
- 单测 **227 全绿**(新增 security-hardening 6 例:退避公式翻倍/封顶、IP 归一化、
  白名单、hex 校验、配置形状);e2e **30** 全绿
- **本地真机三场景**:正确密钥可连 → 连续错误被拒 → 封禁期连正确密钥也被拒 ✓
- ⚠️ 待 Windows 真机:install.cmd 双击首装(改了安装入口,必须验管道喂钥)
2026-10-07 21:23:37 +08:00
jkooe 277c5f1ba5 feat(v1.6.1): gui.await 补齐单测与 CLI/MCP 入口;源码注释脱敏;gitignore 补密钥规则
## ① gui.await 补齐(路线图 §1 收尾清单第 2、3 项)
此前该能力只有实现、**单测 0 覆盖、CLI/MCP 0 入口**:
- 新增 tests/unit/gui-await.test.mjs(6 例):能力登记与风险等级、四类条件 ×
  两态 schema、timeout 上限兜底、isHit 对五种返回形态的判定契约、
  CLI/MCP 入口存在性
- CLI:`nodeagent await --condition <window|control|process|file> [--text/--title/
  --process/--path] [--state absent] [--timeout ms]`,回显 satisfied/耗时/轮询次数/末次异常
- MCP:新增 na_await 工具(定义 + 分发),描述含典型编排示例

## ② 源码注释脱敏(路线图序 0.5 / §4.0)
input.ts 与 input-script.ts 里 4 处真实播放器名 / 游戏名泛化为
「主流媒体播放器」「全屏独占应用」「部分播放器会创建数十个辅助窗口」——
不再画像「哪台机器装了什么、常在什么场景下远程控它」。grep 复核零残留。

## ③ .gitignore 补密钥规则(路线图 §6.2)
此前只挡 PSK.txt,挡不住 keys/(ed25519 私钥)与 *.key / *.pem(TLS 私钥)
—— 一旦误 add 就是私钥入公开库。*.pub 显式放行。

## 验证
单测 215 → **221**(+6 全绿);e2e **30** 全绿;7 包构建通过;
CLI await 实测到连接层(被控端当前离线,超时属预期)。
2026-10-07 20:31:36 +08:00
jkooe dac8a0a2a7 feat(console): 入库 Tauri 2 桌面控制台(主控/被控一体)
apps/console —— 跨端(macOS/Windows)桌面控制台,复用 ops 侧的 Node agent 作本地被控端,
不做第二套 agent。技术栈:Tauri 2(Rust 壳 + 系统 WebView,包体小)+
Vue 3 + TS + Vite + Pinia + Naive UI(暗色)+ Vue Router(hash)。

- command 层(src-tauri/src/lib.rs):connect / disconnect / invoke_capability /
  get_capabilities / get_state;client 事件经 Emitter 桥接为 na-log / na-state / na-event 推前端。
  握手后**先 await 成功再落 state**(不持锁 await)。
- 7 页面:设备管理 / 状态总览 / 终端 / 进程与服务 / 软件 / 文件 / 审计。
- 实测:macOS .app 6.75 MiB(方案指标 <30MB;Electron 同级约 100MB+),
  直接执行二进制进程存活、无崩溃 —— `codesign --verify --deep --strict` 那条
  "code has no resources but signature indicates they must be present" 是 adhoc
  链接签名的固有噪声,实测无害(勿被误导去「修签名」)。

⚠️ 本轮用 examples/probe.rs 对真实被控端核对字段契约,抓出 9 处**静默空列**
(不报错、类型系统也抓不到,界面只显示 "-"),全部按契约修掉:
- 进程表读 r.cpu / r.memory → 真源 cpu_pct / memory_bytes(CPU、内存两列恒为 "-")
- 服务表读 r.status → 真源 state
- 磁盘读 d.mount||d.name / d.used → 真源只有 drive + total/free,
  **没有 used**(须自算 total-free)
- 网卡读 {name,rx_bytes,tx_bytes} → 真源 {adapter,ip,up}
- 软件表读 a.id → app.list 结果**没有 id**,应为 publisher
- 审计 broken_at 当数字渲染 → 真源是 {file,line,reason} 对象
另清掉幻影字段 SystemInfo.node(契约里只有 node_path)。

配套两点设计:
- **平台门控**:连接后探测 system.info.os → isWindows/platformReady;app.* 与
  system.service.list 仅 Windows 被控端可用,故软件页与服务 Tab **事前禁用并说明原因**,
  而不是发出注定失败的请求再解释错误。
- **结构化错误**:协议真源 InvokeResult.error 只有 {name,message,data?}(**没有 code**),
  故分支一律按 name;前端 CapabilityFailure 暴露 errorName,并提供
  isUnsupportedPlatform / isAclDenied / isRateLimited。未改协议(改动会扩散 4 端)。

tests/unit/console-contract.test.mjs(20 例):把「前端声明 ⊆ 契约」与「契约 ⊆ 前端声明」
双向钉死,另校验平台门控清单与 agent 侧 requireWindows 集合一致、AuditType 覆盖
apps/agent/src/audit.ts 全部取值。**已注入漂移实测会红**(把 cpu_pct 改回 cpu →
正/反向各挂 1 项),非空转守卫。
2026-10-07 10:14:12 +08:00
jkooe 5e08a2768b chore(release): v1.6.0 2026-10-07 01:44:21 +08:00
jkooe 2eb33587fe feat(v1.6): 新增 gui.await 等待条件能力(复盘短板之「语义」)
把 GUI 操作从「按一下→睡几秒→截图碰运气」变成**可断言**:四类条件
window / control / process / file,各复用 window.list / screen.find /
process.list / fs.stat(零新平台代码,平台行为与既有能力一致,只读低危)。
- present/absent 两种等待;超时正确返回 satisfied:false + note
- 轮询期单次异常**不视为失败**(启动中程序常短暂报错),超时才带回 last_error
- 参数非法分两层拒:schema 层(协议错误)与业务层(E_PARAM_INVALID)

本地真实验证(macOS,7 项全过):present 命中(1208ms/7 次轮询)、absent 命中、
超时返回、非法参数 2+1 拒绝、process 命中、底层报错不中断轮询(last_error 带回
辅助功能权限提示)、缺省参数可用。

能力数 42 → 43(数量守卫测试同步更新);README 补快捷键与 gui.await 用法章节。
2026-10-07 01:21:41 +08:00
jkooe 7037c7ff94 chore(release): v1.5.0 2026-10-07 01:00:25 +08:00
jkooe 689bb8c98d feat(v1.5): 快捷键控制范围扩展 —— 字符串热键 / 预设别名 / 长按 / 后台定向投递
## 背景
原 `input.key.press` 只接受数组和弦(keys/sequence),按键表也不全:
AI/MCP 调用要拼 ["ctrl","shift","esc"],且 **win+d、ctrl++、win+. 这类
"修饰键 + OEM 符号键"的组合根本打不出来**(表里没有符号键)。

## 四类扩展
① **字符串热键**:`hotkey: "ctrl+shift+esc"`、`hotkeys: ["ctrl+c","ctrl+v"]`
   —— 分隔符支持 `+ - 空格`,大小写不敏感,别名归一(command/cmd→win、del→delete…)
② **预设别名**:约 50 个语义名(copy / show_desktop / task_manager / screenshot /
   volume_mute_toggle…),AI 调用免记键位;`preset` 与 `presets`(序列)均支持
③ **长按** `hold_ms`(≤5s):按下→保持→释放,插在"按完"与"释放"之间
④ **后台定向投递** `route=post` + `target_pid`:向目标进程**全部顶层窗口**
   PostMessage WM_KEYDOWN/UP —— 目标窗口被遮挡/最小化时可用
   (媒体键的 WM_APPCOMMAND 机制早已验证,此为其等价物)
   文档明示局限:游戏/部分输入型程序不响应后台键消息

## 按键表补齐(单测覆盖断言防漂移)
- 左右侧修饰键 lctrl/rctrl/lshift/rshift/lalt/ralt/lwin/rwin(游戏常区分左右)
- 小键盘 numpad0-9 + 四则运算,**与数字键分开**(Excel 靠 NumLock 区分)
- OEM 符号键 oem_1..oem_8 / oem_minus/plus/comma/period(解锁 win+d、ctrl++、win+.)
- 浏览器收藏夹/主页、系统键(sleep/help/select/execute/clear/separator/oem_clear)
- IME 键(kana/hangul/kanji/hanja/convert/nonconvert/accept/modechange)
- 补齐 pause/apps(重构时一度漏掉,被覆盖断言测试立刻抓到)

## 可测性重构
脚本生成抽成**纯函数** `apps/agent/src/capabilities/input-script.ts`
(VK_MAP / MEDIA_COMMANDS / toVk / build{Media,Foreground,Post}Body),
行内生成逻辑全部改为调用 —— 于是**按下顺序/逆序释放/hold 插入位置/post 路由**
这些最容易写错的部分,现在由 14 个单测逐行断言,真机只需验证最终效果。

## 参数校验
protocol `expandChords()` 统一展开六种输入形式,**混用即报错**(消除"以谁为准"歧义);
同一和弦重复修饰键、超 4 键、空输入均拒绝。渲染返回值新增 presets/via/channel/sent_windows。

## CLI / MCP
- CLI:`nodeagent key press "ctrl+shift+esc"` / `key press copy` / 老形式 `key press ctrl c`
  新增 `--hold`、`--route`;回显预设展开与后台投递命中窗口数
- MCP `na_key`:同步支持 hotkey/hotkeys/preset/presets/hold_ms/route/target_pid

## 验证
- 单测 **179 → 195**:hotkeys 解析与别名 17 例、**脚本生成逐行断言 14 例**、
  按键覆盖断言(HOTKEY_KEYS ⊆ VK_MAP)、新参数 schema 校验 2 例
- e2e **29** 全绿
- ⚠️ **真机未验证**:Windows 被控端当前离线(连接超时)。待上线后需验:
  `key press "win+d"`(切桌面再切回)、`key press copy` 配剪贴板、
  `route=post` 定向一个最小化窗口。验证通过后再发版。
2026-10-07 00:59:40 +08:00
jkooe 9d32592ed1 feat(v1.2): input.key.press 支持媒体键(WM_APPCOMMAND 通道 + 定向投递)
远程控 QQ 音乐时发现:媒体键全部查不到,window.focus 越不过游戏全屏,
而软件热键在精简模式下未注册 —— 只能绕道 WM_APPCOMMAND 手写脚本。
现把这条通道正式纳入能力,避免每次重造轮子。

1. VK_MAP 补全(input.ts)
   media_play_pause(0xB3) / media_stop(0xB2) / media_next(0xB0) / media_prev(0xB1)
   media_rewind(0xB7) / media_fast_forward(0xB4) / volume_mute(0xAD) /
   volume_down(0xAE) / volume_up(0xAF) / 浏览器键 / F13-F24

2. 新增 WM_APPCOMMAND 通道(关键,非仅补 VK)
   媒体键不再走 SendInput,改为识别「整条序列都是媒体键」后走
   PostMessage(WM_APPCOMMAND)。原因:
   - SendInput 打进媒体键后,只有当前媒体会话焦点的窗口才响应
   - APPCOMMAND 可定向投递到指定窗口/进程,绕开该不确定性
   - 窗口最小化或被全屏游戏遮挡时依然有效(实测 QQ 音乐被 DNF 全屏压住仍可控)

3. 新增 target_pid 参数 + CLI --pid
   播放器常创建 30+ 辅助顶层窗口(QQ 音乐实测 36 个),只投主窗口常常无效
   → 指定 PID 后向该进程全部顶层窗口投递。
   返回值加 via:'appcommand' 与 target_pid,便于确认走的是哪条通道。

4. 音量键刻意不进 APPCOMMAND 表
   APPCOMMAND 的 lParam 按位传递(低 16 位 command、位 12-15 设备类型),
   裸值会撞车(APPCOMMAND_VOLUME_DOWN 与 APPCOMMAND_PAUSE 都是 1),
   纳入表中会导致静音键被误当暂停。故音量键走真·VK 注入,由系统自行翻译。

5. screenshot --scale 报错信息改善(gui.ts)
   协议限定 scale ∈ (0,1],原先非法值直发只回「参数校验失败」,看不出是哪项
   → CLI 前置校验,明确提示「--scale 应在 0 到 1 之间…收到: 3」。

6. window.focus 边界写入 manifest
   明确无法越过全屏独占应用:此时返回 focused:true 但视觉仍遮挡、点不到,
   属预期而非失败;指引改用媒体键 / shell.exec / 请用户切出全屏。

真机验证(192.168.1.100):单测 162 全绿;媒体键广播与定向投递均生效;
volume_up 20%→22%、volume_down×2 →18%;非法键名仍明确报错。
双端已部署,指纹 5b68faa20a3e,v1.4.3。
2026-10-05 15:06:24 +08:00