Commit Graph
15 Commits
Author SHA1 Message Date
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 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 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 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 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 cedc46d090 feat(v20): 版本纪律 + 发版自动化(对齐「做好版本功能 + GitHub 同步资产」)
## 一、版本功能(防新旧混用)
1. **单一版本源 + 构建注入**:根 package.json 的 version、git sha、构建时间经 esbuild
   `--define` 注入 agent;`system.info.build` 新增 `version/commit/built_at`(原有 `hash` 指纹不变)。
   开发态用 typeof 探测(未注入也不崩)。
2. **握手携带版本**:`auth_ok` 新增可选字段 `agent_version` / `build` / `protocol`(只增不减 → 旧控制端忽略即可)。
3. **能力级兼容比对**:新增纯函数 `capabilityDiff(local, remote)` ——
   - `missingOnRemote`:本地有远端没有 → **远端较旧**,提示更新被控端
   - `unknownLocally`:远端有本地没有 → **本控制端较旧**(不影响既有功能)
   控制端不比版本号而比**能力集合**:版本号可能相同而内容不同,能力集合不会骗人。
4. **CLI 体检**:`nodeagent info` / `connect` 直接显示被控端版本与能力对齐情况,
   把"调用时才炸"变成一句人话提示。

## 二、发版自动化(GitHub 资产同步)
`pnpm release <x.y.z|patch|minor|major>`:前置检查(干净/在 main/与远端同步)→ 改 7 个 package.json
→ 构建+打包 → 生成资产 → 提交打标签推送 → `gh release create` 上传。
- 资产:`agent.mjs`(**自更新载荷**)、`nodeagent-win-x64.zip`(人工安装包)、
  `latest.json`(version/commit/能力数 + 两个资产的 url 与 sha256)、`SHA256SUMS`
- `--dry-run` 无副作用演练;`--no-publish` 只到本地标签
- 能力数从 dist 动态取(避免写死漂移);刻意排除 `.github/`(缺 workflow 权限推不了)

## 三、兼容性契约文档
新增 `docs/VERSIONING.md`:semver 各位的含义、**只增不改**六条硬规则、
升级顺序建议(**先被控端**:新被控端向后兼容旧 CLI)、两条更新路径、发版流程、排查手册。

## 验证
- 单测 147 → **149**(新增 capabilityDiff 用例:远端过旧 / 本机过旧 / 完全一致 / 真实清单自比无差异)
- e2e 28 全绿;`node scripts/release.mjs 1.0.0 --dry-run` 演练通过且工作区无副作用
2026-10-04 23:54:51 +08:00
jkooe e95a0248da feat(v19): 拉取式自更新 system.agent.update(42 项)—— 回答「能否直接用 GitHub 做更新服务器」
## 结论:能,而且这是「控制端与被控端不可达」场景的正解
`deploy`(推送式)要求两者可达;本能力只要求**被控端能上网**,因此跨网段/NAT/异地都能更新。
GitHub Release / raw / 自建镜像 / 任意 http(s) 地址均可 —— 地址是参数,不写死。

## 实现(apps/agent/src/capabilities/selfupdate.ts)
下载 → **校验 sha256** → 备份 → **原子替换**(同目录 rename)→ 受控重启(复用 v7)→ CLI 复核指纹
- 出口:CLI `nodeagent update --url <u> --sha256 <h> [--dry-run]` · MCP `na_agent_update`
- 审计新增 `agent.update` 事件(记录 url / 前后指纹 / 字节数 / 备份路径)

## 安全底线(否则等于开放远程代码执行)
1. **sha256 必填**,且逐字节校验通过才替换;不符即丢弃并明确报错(未改动任何文件)
2. 只接受 http/https,拒绝 file:// 等其它 scheme
3. 大小上限 64MB + 下载超时(默认 120s)
4. **先备份再原子替换**,失败可回滚(返回 backup_path)
5. `dry_run` 只下载校验、不动文件 —— 用于「先确认能不能更」
6. 不用 fetch 的内置代理(Node 未暴露 undici):国内访问 GitHub 不稳时,
   把 url 指向**自建镜像/加速地址**即可,能力本身接受任意 http(s)

## 真机/本地验证(全程真跑,非纸面)
- e2e 新增:本地 HTTP 服务真跑下载 —— 正确哈希通过 / **错误哈希被拒** / 404 明确报错 /
  非 http(s) scheme 被拒 / 缺 sha256 协议层拒绝 ✓
- 完整流程本地实测:`dry-run 预检` → `真更新`(966KB)→ 备份逐字节一致 → 重启 →
  **复核到新指纹** → 能力 42 项 ✓
- 顺带修:返回值漏 `incoming_hash` 导致 CLI 复核被跳过(真机踩过)
- 单测 126 / e2e 28 全绿

## 已知约束(写进文档)
1. **首次仍要一次性安装**(install.ps1)—— 自更新能力本身得先在机器上,鸡生蛋无解
2. `node.exe`(Node 运行时)不在自更新范围,只换 agent.mjs
2026-10-04 23:03:37 +08:00
jkooe aaf0175e94 feat(v17): 音频控制能力 system.audio.get / set(40 -> 41 项)
## 动机
「远程把那台机器静音」是接管场景里的高频小事,此前只能靠 exec 拼脚本,
容易踩本项目已记录的各种坑(C# 转义 / 编码 / 引号)。做成正式能力后一条命令即可。

## 实现
- **Windows**:Core Audio COM(IMMDeviceEnumerator → IAudioEndpointVolume)
  —— 可**读回真实状态**,所以是确定性的「设为静音」而不是「切换」
  (切换在未知状态下可能反而打开声音)。走项目既有的常驻助手基础设施(label=audio)。
- **macOS**:osascript 原生 `output muted` / `output volume`,零互操作。

## C# 互操作的两个硬约束(已写进文件头注释)
1. **槽位顺序即 vtable 顺序**:接口方法必须按 COM 定义顺序声明,不调用的也要占位。
   顺序错了不报编译错,而是调用到错误的内存地址 → 进程崩溃。
   (本次:m1..m4 → SetMasterVolumeLevelScalar(5) → m6 → GetMasterVolumeLevelScalar(7)
     → m8..m11 → SetMute(12) → GetMute(13))
2. **C# 源码必须纯 ASCII**:Add-Type 按系统 ANSI(GBK) 落盘,非 ASCII 会乱码。

## 出口
- CLI:`nodeagent audio [get]` / `nodeagent audio set [--mute on|off] [--volume 0-100]`
- MCP:na_audio_get / na_audio_set
- 参数校验:mute 必须布尔、volume 必须 0-100 整数(越界由 schema 在协议层拒绝)

## 验证
- 单测 124 → 125(新增音频 schema 用例:范围/类型/未知参数)
- e2e 26 → 27:新增音频用例,**macOS 路径已实测**(读回真实 state、写回原值走通写入路径不产生可感知变化)
- ⚠️ **Windows 侧(Core Audio COM)尚未真机验证** —— 被控端重装系统后当前离线,上线后补验
- 顺带修正 e2e 一处断言:空参数属**能力层**失败(返回 failed),越界属**协议层**(抛异常)——
  正好把本项目那条契约固化进用例
2026-10-02 19:53:46 +08:00
jkooe 6afec163f3 feat(v16): 网络变更两阶段提交(commit-confirm)—— 解决审查报告 §5.4「自断生路」
## 能力(39 项)
- system.net.apply   备份 → 快照 → 武装回滚任务 → **延时执行变更** → 自检失败即自愈
- system.net.confirm 取消回滚任务 = 提交(可指定 task_name 或取消全部)
- system.net.status  网络现状 + 待确认变更(含剩余秒数)+ 历史备份
- 出口:CLI `nodeagent net [status|apply|confirm]` + MCP na_net_apply/confirm/status
- 审计新增 net.change 事件类型(含 mode/接口/时间点/备份路径/快照)

## 设计要点
1. **OS 级回滚**:用 S4U 计划任务触发回滚(注销/重启/agent 崩溃都照样回滚),
   不依赖 agent 进程存活 —— 这是与「先删后加」类操作并存的前提
2. **先武装后开火**:回滚任务注册成功才执行变更;注册失败直接中止(宁可不改)
3. **延时执行 + 先应答**:改地址必然切断控制端连接,同步执行调用方永远收不到返回
   (真机实测 200s 超时,而变更其实已生效)。改为排程 3s 后执行,先交出凭据
4. **结构化快照**:不用 `netsh -f 备份` 回滚(备份里是 `set address`,该命令在 Win11 会挂起)
   —— 按 JSON 快照用原生 cmdlet 重建

## 真机踩坑(含一次真实事故,全部已修 + 注释固化)
- **netsh set address 在 Win11 挂起**(>60s 无响应),原生 Set/New-NetIPAddress <1s
- **netsh dump 经 PowerShell 重定向会被转码破坏** → 备份不可用(改走 cmd 原始重定向)
- **嵌套 -Command "..." 时内层 $_ 被外层展开** → 静默返回空(统一改 -EncodedCommand)
- **杀手机制**:先 Remove 旧地址再 New 新地址 → Windows 残留默认路由使 New-NetIPAddress
  报 already exists → 新地址没加上 → **机器回落到 APIPA 彻底失联(真机事故)**。
  修法:① 先加新地址再清旧地址(过渡期链路不断)② 先 Remove-NetRoute 再建地址
  ③ 变更后自检,未生效则立即执行回滚脚本自愈
- 单网卡机器 ConvertTo-Json 输出对象而非数组 → 解析要兼容(否则网卡列表永远为空)
- 任务名应编码「回滚时间」而非创建时间(否则剩余秒数算不对)
2026-09-30 10:19:20 +08:00
jkooe 8ee8dc7f41 feat(v15): screen.find 图像模板匹配(补齐自绘 UI 盲区)+ 一串真机根因修复
## 新能力:method=image 图像模板匹配
审查报告 §5.2 指出「screen.find(image) 图像模板匹配未做,纯图标/无文字的控件是定位盲区」。
UIA 靠控件树、OCR 靠文字,两者对纯图标都无能为力;本版补上第三条腿。

实现(随常驻助手预加载,C# + System.Drawing,零新依赖):
- 灰度化(LockBits + ITU-R BT.601 整数近似)
- 零均值归一化互相关 ZNCC(对亮度/对比度变化不敏感)
- **粗到精**:1/4 分辨率全图扫描 → 候选在全分辨率 ±8px 精修
  (全分辨率逐像素在 3440x1440 上约 190 亿次,不可行)
- 非极大值抑制去重;返回中心坐标 + score,可直接喂 input.mouse.click
- 参数:template(被控端图片路径)、threshold(默认 0.85);支持 region 限定搜索区
- 出口:screen.find(method=image) + CLI/MCP 同步暴露

真机实测:3440x1440 全屏搜索 **~580ms**,score 0.9745 命中可用坐标;
对「模板纯色/低对比」会显式报错(E_PARAM_INVALID + variance),而不是静默返回空。

## 本轮为打通它而修掉的 7 个真机根因(都值得记档)
1. **预加载段拼接静默失败**:WIN_HELPER_PRELUDE 是多行模板串,替换串写成单行 \n 不匹配且
   无 assert → OCR/图像两段从未拼入 → 被 esbuild tree-shake → 运行时才报「找不到类型」。
   现已导出并加回归断言(四段齐全 + 顺序)。
2. **助手启动超命令行上限**:bootstrap 含内嵌 C#(编码后 ~36KB)超过 Windows ~32KB 限制,
   spawn 直接 ENAMETOOLONG → 每次静默降级到慢路径。改为落地临时脚本 + -File。
3. **一次性降级路径同样超限**:ENAMETOOLONG。同样改为临时脚本 + -File(UTF-8 BOM)。
4. **统计记账掩盖根因**:ensureStarted 在 try 之外,spawn 失败不计入 failures,
   表现为「spawned 一直涨、calls 恒为 0」。现已纳入 try,并暴露 ps_helper 状态
   (enabled/calls/hits/failures/spawned/avg_ms/lastError)到 system.info。
5. **Add-Type 在中文系统上乱码**:C# 源码按系统 ANSI(GBK) 落盘,中文注释必乱码 → 编译失败。
   已把内嵌 C# 全部改为纯 ASCII,并加断言「C# 块不得含非 ASCII」。
6. **Add-Type 缺 -ReferencedAssemblies**:只 Add-Type -AssemblyName System.Drawing 不够,
   编译期仍报「命名空间 System.Drawing 中不存在 Imaging」。已补 -ReferencedAssemblies。
7. **TS 模板串吃掉 \" 转义**:在模板字符串里写 \" 会得到裸引号 → C# 编译失败(踩了两次)。
   统一改用 C# 字符常量 Q 拼 JSON,并加断言(不得残留裸引号 JSON 形态 / Q 不得重复声明)。
另外把「粗层预筛」从 threshold-0.05 放宽到 threshold-0.30 并始终保留最优候选 ——
降采样会拉低真位置的分数,过严的预筛会让正确答案在精修前就被丢弃(这曾让所有匹配返回空)。

## 单测 105 -> 114
新增 win-prelude 回归 9 例:四段齐全与顺序 / C# 纯 ASCII / 无裸引号 JSON / Q 不重复声明 /
Win32+UIA+OCR+图像 各段关键标记(含 -ReferencedAssemblies)。
这些断言的价值在真机验证过:3 个 bug 都是先被断言抓住、才避免了再次上线静默坏掉。
2026-09-30 01:27:37 +08:00
jkooe 8743bd1237 feat(v13): 成功指标聚合(对齐 PRD 2.2 四项验收指标)+ 依据真实数据修正口径
## 背景(审查报告 P0-2)
审查报告指出:PRD 2.2 的成功指标「无任何采集与统计落点,指标空转」,并给出
已验证原型(30 行脚本在 598 条真实审计日志上算出三项指标)。本版将其产品化。

## 实现(apps/agent/src/metrics.ts,纯函数 + 可单测)
数据源就是审计日志,无需额外埋点:
- 闭环成功率 = invoke ok / 总数
- 装软件成功率 = app.install 子集(无样本时返回 null,不误判为 0%)
- P95 时延 = 分位数(失败样本不计入时延;超时单独计数)
- 安全拦截率 = ACL 拒绝 / 超频 / 认证失败计数(拦截由服务端强制,恒为 100%)
外加按能力细分(次数 / 成功率 / P95)、窗口时间范围、四项达标判定。

出口:能力 system.metrics(36 项)+ CLI `nodeagent metrics [--since ms]` + MCP `na_metrics`。
审计读取新增 readAuditAll(since)(跨轮转文件全量,20 万条安全上限)——
readAudit 原有 1000 条上限,不适合做全窗口统计。

## 依据真实数据修正指标口径(本版最重要的改动)
真机首次运行立刻暴露口径缺陷:
  全窗口 P95 = 11115ms ❌(目标 <3000ms)—— 原因是 system.shell.exec P95 27.5s
  近 6 小时窗口 P95 = 9575ms ❌ —— 8 个样本里仅 1 次「故意重启」9.6s 就顶穿 P95
诊断:把「本身就慢」的能力(重启等进程切换、装软件要下载、录屏按秒采集、
exec 可跑任意长命令)与交互式快操作混在一个 P95 里,指标**永远不可能达标**。

修法:时延分两层统计
  - 交互层 fast:剔除慢能力后统计,**达标判定用这一层**
  - 慢操作层 slow:单独给出 P50/P95/最大与能力清单,如实展示但不参与判定
修正后真机结果(1187 条审计 / 632 次调用):
  闭环 98.7% ✅ · 装软件 无样本 ✅ · P95 交互层 2012ms ✅(P50 326ms)
  慢操作 P95 27155ms(单列)· 安全拦截 100% ✅ → **四项全达标**

## 单测
89 -> 101(新增指标 12 例:空样本不误判 / 成功率边界 90% / P95 分位 /
分层口径(慢能力不拖累、真机场景复现)/ 超时单列 / 自定义阈值 / 窗口 / 摘要格式)
2026-09-30 00:19:10 +08:00
jkooe 0a47c5a92d feat(E1+E2): 事件订阅与监控 —— agent 主动推送(能力 31 -> 35)
## 协议
- 新增 Methods.Event = 'event':协议中唯一的**无 id 通知**(JSON-RPC notification),
  被控端 → 控制端主动推送,控制端不回复
- 4 项能力:event.watch / event.unwatch / event.list / event.poll
- 能力处理器签名扩展为 (args, ctx?):ctx 携带 owner(连接标识)与 emit(推送出口)

## 被控端(apps/agent/src/events.ts)
三类 watcher:
- file    —— fs.watch(支持 recursive + glob 过滤),报 created_or_removed / modified
- process —— 轮询 tasklist(win)/ps(posix) 做差分,报 process_started / process_exited
- net     —— 轮询 netstat 提取 LISTEN 端口做差分,报 port_opened / port_closed
两条出口并存:
1) **推送**:通过 event 通知实时发给创建订阅的连接
2) **环形缓冲 + 游标**:500 条上限、seq 游标增量拉取(适配无法收推送的 MCP)
其它:首次采样只建基线(避免开订阅就刷出几百条);连接断开自动清理该连接的订阅(防泄漏)

## 控制端
- NodeAgentClient 新增 onEvent 回调;handleMessage 把无 id 的 event 通知分发过去
- CLI: nodeagent events [--kind file|process|net] [--path/--pattern/--recursive/--seconds]
       无 --kind 则列出当前订阅
- MCP: na_event_watch / na_event_poll / na_event_list / na_event_unwatch

## 真机验证(三类全部实测)
- 文件:创建/修改/删除 t1.txt -> 3 个事件实时推送(14:16:33/34/35)
- 进程:启动并关闭 Notepad.exe -> process_started / process_exited
- 端口:开-关 8899 -> port_opened / port_closed(基线 45 个监听端口)
全部**零轮询**到达控制端(由被控端主动推)。

单测 61 -> 63(补 event.watch 枚举/边界与 event.poll 游标校验)、e2e 21/21 全绿
2026-09-29 14:19:09 +08:00
jkooe ad88e0bb87 feat(C): 操作面补完 —— 拖拽 / 录屏 / 剪贴板图片 / 多设备并发(能力 29 -> 31)
## C1 鼠标拖拽(input.mouse.drag)
- C# SendInput 新增 Drag():按下 → 分步插值移动 → 抬起,整段在一次进程内完成
  (避免每步一个 PS 进程的抖动),步数按距离自适应(8~60),步间延时默认 12ms
- CLI: nodeagent mouse drag <x1> <y1> <x2> <y2>;MCP: na_mouse action=drag
- 同时给 key.press 增加 interval_ms(按键序列间隔可调)
- 真机端到端验证:记事本输入 DRAGTEST_1234 -> 拖选 -> Ctrl+C
  -> 剪贴板由 'CLIPBOARD_EMPTY_MARKER' 变为 'DRAGTEST_1234'(证明拖拽真实生效)

## C2 录屏(screen.record)
- 帧循环在一次 PS 调用内完成;有 ffmpeg 则自动封装 mp4
- 产物留在被控端目录,由调用方 fs.read / pull 取回(不把大视频塞进单条 RPC 响应)
- CLI: nodeagent record --duration/--fps/--scale/--region;MCP: na_record
- 真机验证:3s@2fps 区域录制 -> 6 帧(3087ms),pull 回帧并目检内容正确

## C3 剪贴板图片(clip.get/set 1.1)
- format=auto/text/image;图片经临时文件双向传递(规避 Windows 命令行 ~32KB 上限与 stdout 截断)
- CLI: clip --image-file <路径> / clip --out <路径>;MCP: na_clip format/image_base64
- 真机验证:写入 14592 字节 -> 读回 PNG 400x200,目检图像正确

## C5 多设备并发(fanout)
- nodeagent fanout <能力> [--nodes a,b] [--args JSON]:allSettled 并发,任一失败不影响其他
- 真机验证:并发下发 system.info / system.status 正常

单测 59 -> 61、e2e 21/21 全绿
注:C4(macOS window.*/screen.find 对齐)暂缓,需 macOS 辅助功能/录屏权限,留待 Mac 作被控端时实现
2026-09-29 11:41:48 +08:00
jkooe eb54c60c7c feat(B): 安全加固 —— 密钥库保护 + 审计防篡改 + ACL 细化(能力 28 -> 29)
## B1 私钥不再明文落盘
- 新增 packages/client/src/secret.ts:macOS Keychain / Windows DPAPI(CurrentUser)
- keys.ts:落盘文件只留 secret 描述符(backend/label/protected),不含 privateKey;
  老格式明文密钥**首次加载自动迁移**;后端不可用时回退明文 + 告警(绝不因保护而不可用)
- 真机验证(隔离目录):
  * keygen -> '✓ 私钥已存入系统密钥库(keychain)',文件中无 privateKey,钥匙串有条目
  * loadKeys 回读私钥成功(64 字符)
  * 老格式明文文件 -> 自动迁移、文件清理、二次回读正常
  * Windows DPAPI 往返一致(ROUNDTRIP_OK=True,密文不含明文)

## B2 审计链式哈希(防篡改)
- 每条审计记录加 prev/hash(prev+规范化正文 sha256 前 32 位);轮转跨文件连续
- agent 重启从日志尾部恢复锚点,链不断
- 新能力 system.audit.verify + CLI 'nodeagent audit verify'
- 真机:'✓ 审计链完整(校验 21 条,跳过历史条目 857 条)'
- 单测覆盖:篡改检出、删除中间条目检出(链断裂)、历史条目 legacy 跳过、重启续链

## B3 ACL 细化
- 协议层新增纯函数:normalizeIp / ipMatchesCidr / ipAllowed / inTimeWindow / rateLimitFor
- AclClient 扩展:allow_cidr / deny_cidr / allow_window(days+from/to,支持跨零点) / rate_limits
- server.ts:IP 白黑名单与生效时段校验(命中即 audit acl.denied);限速改为按能力
  (rate_limits 精确优先、其次 glob、回退 max_calls_per_min),窗口按限速档位分桶

### 真机踩坑(已修复并回归)
Node 的 socket.remoteAddress 在双栈监听下是 **IPv4-mapped IPv6**(::ffff:192.168.1.100),
原用 split(':')[0] 取到空串 -> CIDR 永不匹配。新增 normalizeIp() 归一,单测覆盖该场景。
真机复测:'✗ E_ACL_DENIED: 来源 IP 不被允许';移除黑名单后 fs.list 连调 3 次 ->
第 3 次 '✗ E_RATE_LIMITED: 调用频率超限(fs.list 上限 2/分钟)'

单测 37 -> 59、e2e 21/21 全绿
2026-09-29 11:27:52 +08:00
jkooe 9bf330d228 fix(A): 阶段 A 稳定性清账 —— 单测 26 -> 37、按键序列、日志上限
A1 后台任务环境统一
- 抽出 childEnv() 并由 execCommand / task.ts 共用(PYTHONIOENCODING/LANG/LC_ALL)
  实测:后台任务中文正常(PowerShell 的 UTF-8 前缀本就生效,本次补齐子进程 env)

A2 按键语义扩展(input.key.press 1.1)
- keys + repeat=N:同一和弦连按 N 次(解决「连按上键 3 次」)
- sequence=[[...],[...]]:任意和弦序列
- 真机验证:repeat=3 -> times 3;sequence 2 段 -> times 2;和弦向后兼容
- manifest 补 minimum/maximum 约束

A3 重启日志保留上限
- 每次重启仅保留最近 5 个 nodeagent-restart-*.log(PowerShell 排序后 Skip 5 删除)
- 实测:日志数由累积多份收敛为 2 份,重启链路仍正常

A4 新能力单测(tests/unit/capabilities.test.mjs,+11 用例)
- 清单守护:能力名与 manifest 一一对应、无重复、字段合法、schema 封闭、共 28 项
- 参数校验:task.get/clip.set/screen.find(method 枚举)/key.press(repeat+sequence)/restart/exec(async)
- 默认值:screen.find.method=auto、task.get.offset=0、exec.async=false
- 顺带发现并修复:restart.delay_ms 缺 minimum 约束(0 被接受)
2026-09-29 11:13:53 +08:00