Commit Graph
72 Commits
Author SHA1 Message Date
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 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 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 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
jkooe 3ff8e86fe8 chore(release): v1.4.3 2026-10-05 11:35:53 +08:00
jkooe 7689a1f80c fix(v22): 构建改为**确定性** —— 去掉注入的构建时间,否则「指纹比对是否最新」永远失真
## 现象(真机执行 `deploy --check`)
明明被控端就跑着 v1.4.2、本地也是 v1.4.2,却报「版本不一致」:
    本地 : 47326da097c7  (976 KB)
    远端 : 6499b3f7ad3c  (node v22.20.0)

## 根因
打包时注入了 `__BUILD_TIME__`,于是**同一份代码每次打包的字节都不同** →
内容哈希无法充当「构建身份」,`--check` / 部署后校验只能靠"这次上传的文件"比对,
跨批次就必然误报。

## 修复
- `pack.mjs` 不再注入构建时间(版本与 commit 仍是确定性的)
- `system.info.build.built_at` 改用**入口文件的 mtime**(mtime 不在文件内容里,
  因此不破坏字节确定性,同时仍能反映构建时刻)

## 验证
- 连打两次包(间隔 2s),指纹完全相同:`b13ce7d3ba74d0e0b293649f` ✓
- 真机更新:指纹 b13ce7d3ba74、42 项能力、证书钉住一致 ✓
- **再次执行 `--check` → 「✓ 已是最新,无需更新」**(此前同版本却报不一致)✓

## 意义
产物变为**可复现**(reproducible):同一 commit 打出同一份字节,
sha256 可以放心用作分发与更新的身份标识 —— 这对一条「下载即执行代码」的更新通道很关键。
2026-10-05 11:27:28 +08:00
jkooe 6c650244bf chore(release): v1.4.2 2026-10-05 02:51:31 +08:00
jkooe a56d67c012 fix(v22): 自更新在 Windows 上必须**就地覆写**而非 rename(真机暴露的平台差异)
## 现象(真机 192.168.1.100 上拉取式更新)
`✗ E_EXECUTION_FAILED: 替换脚本失败` —— 下载与哈希校验都过了,挂在最后一步「替换」。

## 根因
Windows **不允许重命名/替换正被运行进程打开的文件**(EPERM/EBUSY),而 node.exe
正持有 agent.mjs。POSIX 可以 unlink 打开中的文件,所以本地(macOS)测不出来。
这也解释了为什么推送式 `deploy` 一直没问题:它走**就地覆写**,而覆写打开中的文件
在 Windows 上是允许的(只有删除/重命名被禁)。

## 修复
- 改为 `writeFileSync(scriptPath, buf)` 就地覆写(先备份 + 已校验的暂存内容)
- **写后校验**:重算入口文件 sha256,与刚校验过的下载比对;不一致立即从备份回滚
- 清理暂存文件;错误信息带上 backup_path / staged_path 便于人工兜底

## 顺带的真机结论(都已在 192.168.1.100 上验证)
- **被控端访问不了 github.com**(`fetch failed`)→ 国内网络现实;改用局域网/自建镜像即可
  (本轮用 `http://192.168.1.100:8081/...` 拉取成功 ✓)→ 故 URL 参数化是必需的,不是花活
- **拉取式更新在 Windows 上可用**(内容确实变化:构建哈希 4833b0b5 → 216328d5 ✓,
  重启后复核通过 ✓)
- **Windows 音频能力真机跑通**(Core Audio COM):🔇 静音 · 音量 77%,可读回真实状态
- **证书指纹钉住**:普通 agent 更新不影响钉住(证书未变 → 仍一致 ✓);只有证书重建才触发
  `E_CERT_MISMATCH` —— 符合设计意图
2026-10-05 02:50:48 +08:00
jkooe 4106ada876 chore(release): v1.4.1 2026-10-05 02:46:37 +08:00
jkooe 50ac4b9646 chore(release): v1.4.0 2026-10-05 02:42:56 +08:00
jkooe e841fbb187 feat(v22): 第二批安全加固 —— 每客户端独立 PSK + 发现广播最小化
接着 v21 的两件 P0,补齐第二批清单里的两项(对应原建议 ③/④ 的一半)。

## ① 每客户端一把 PSK(`agent.json` 的 `keys{}`)—— 让 ACL 的身份维度真正有效
**问题**:psk 模式下 `client_id` 由客户端自报(server.ts 原注释自认),
一把共享 key 意味着**持有者可冒充任意 client_id**,ACL 的身份维度形同虚设。

**做法**:按 `client_id` 分配独立钥匙;未登记者回落到共享 `key`(旧部署行为不变)。
比 ed25519 更轻量(不用管公钥),同样是「一把一个身份」。

**实测**(五场景全对):
| 场景 | 结果 |
|---|---|
| alice 用自己的 key | ✅ |
| **alice 的 key 冒用 bob 身份** | ❌ E_AUTH_FAILED ← 核心安全性质 |
| bob 用自己的 key | ✅ |
| 未登记 carol 用共享 key | ✅(向后兼容) |
| 已登记 alice 用共享 key | ❌ 拒绝(专属优先,不被共享 key 绕过) |

配套:`nodeagent keygen --psk --id mac_02` 生成专属 key 并打印可直接粘贴的 agent.json 片段;
鉴权日志带 `key=scoped|shared-fallback|shared`,审计区分「专属钥匙失败」与「回落共享失败」。

## ② 发现广播最小化(`discovery.detail` + `discovery.secret`)—— 收情报泄漏
**问题**:UDP 广播未认证,默认明文告知 IP/端口/认证方式/键鼠开关,等于给攻击者一份目标清单。

**做法**:
- `detail: "minimal"` → 顶层只剩 `service / version / node_id / ts`,
  真实信息收进 `detail{}`(**实测顶层已无 host/port/auth_mode/input_enabled**)
- 配 `secret` → `detail` 带 HMAC 签名(`node_id|ts|JSON(detail)`),
  控制端 `discover --discover-secret <同一密钥>` 验签 → 列表标 **🔒 已认证**;
  未配/验签失败标 **⚠️ 明文**(明示「仅作参考、不可当凭据」)
- 配了 secret 自动启用 minimal;未配置时启动横幅**醒目告警**提示收敛

**实测**三视角:正确密钥 🔒 / 无密钥 ⚠️ / 错密钥 ⚠️(验签失败正确降级)✓

## 验证
- 单测 159 → **162**(新增签名往返一致性:错密钥/篡改详情/改时间戳均须验签失败)
- e2e **29** 全绿;本轮改动为纯加法(新增可选字段),未破坏既有行为
- 文档 `SECURITY.md` 新增 §4.5 / §4.6,并把「发现最小化」从待办划掉

## 剩余待办(SECURITY.md §5)
握手失败封禁 / 审计链签名与外部锚定 / 会话上限与空闲断开 / 安装期密钥改用文件传递 /
ed25519 仍非默认(v22 的 `keys{}` 已是更轻量的等价物)
2026-10-05 02:42:12 +08:00
jkooe 159ae2bf6f chore(release): v1.3.0 2026-10-05 02:26:21 +08:00
jkooe 0d59d6032b feat(v21): 第一批安全加固 —— 证书指纹钉住 + 来源网段白名单
对应审查建议的 P0 两条:这两项是**真实可利用的攻击路径**,其余(发现最小化/失败封禁等)
留待后续批次。

## ① 证书指纹钉住(TOFU)—— 挡同网段中间人
自签证书没有 CA 链,历史上客户端只能 `insecure` 接受任意证书 → 攻击者可**透明中继**:
拿不到钥匙(挑战-应答挡得住冒名),但能读到截屏/剪贴板/键入/exec 输出。

- 被控端:`install.ps1` 打印证书指纹(sha256 of DER)供**带外核对**;
  `system.info.build.cert_sha256` 与 `auth_ok.build.cert_sha256` 上报
- 控制端:首次连接自动钉进 `nodes.<名>.cert_sha256`;之后**严格比对**,
  不一致即 `E_CERT_MISMATCH`(-32422) 拒连,并提示 `--forget-cert` 重钉
- 额外做**交叉确认**:实际握手证书 vs auth_ok 自报,不一致同样拒绝(防自报被篡改)
- 指纹基于 **DER**(不是 PEM 字节),保证与 Windows .NET `GetRawCertData()` 同值

**踩坑记录(写进代码注释,避免后人重走)**:
1. `ws` 客户端的 `upgrade` 事件**只传 IncomingMessage**,拿不到 socket
2. socket 要在 `open` 之后从 `ws._socket` 取(TLSSocket)
3. Node ≥15 的 `getPeerCertificate()` 返回**对象**,DER 在 `.raw` 上(老版本才是 Buffer)
   —— 现已双兼容

## ② 来源网段白名单 `allow_from` —— 收敛暴露面
被控端默认监听 `0.0.0.0`(远程接入所需),但**换到不可信网络就自动暴露**。

- 支持 CIDR / 单 IP / `*`;**未配置 = 放行全部**(保持向后兼容,不打断现有部署)
- 拒绝发生在**业务握手之前**(不做 TLS/挑战应答,省资源),并记 `net.deny` 审计
- 判定按**前缀比特**而非字符串相等:Node 在 IPv6 监听上把 IPv4 客户端报成
  `::ffff:a.b.c.d`,已归一化(单测覆盖)
- `system.info.network.allow_from` 上报状态;`nodeagent info` 一眼看出是否已收敛

## 验证(本地真跑,非纸面)
- 白名单不含本机 → 连接被拒(agent 侧 WARN + 审计留痕)✓
- 白名单含本机 → 连上,并**自动钉住**指纹 ✓
- **换掉被控端证书后重连 → `E_CERT_MISMATCH` 拒绝**(期望 2ed4957b… / 实际 ca8dc20c…)✓
- 新增 e2e 用例(自起 TLS agent)覆盖上述四种情形 + agent 自报与实际证书一致性 ✓
- 新增单测 9 例(CIDR 前缀匹配 / IPv4-mapped 归一 / IPv6 zone / PEM≡DER / 篡改即变)
- 全量:单测 **159**(150→159)· e2e **29**(28→29)全绿

## 文档
新增 `docs/SECURITY.md`:信任模型(钥匙=root)、认证现状与**已知限制**(ACL 身份维度仅在
ed25519 有效)、本次两项加固的用法、尚未收敛的 6 项、应急处置表。
2026-10-05 02:25:19 +08:00
jkooe 3cbac53308 chore(release): v1.2.1 2026-10-05 00:26:36 +08:00
jkooe 303cbc7436 chore(release): v1.2.0 2026-10-05 00:19:29 +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 aa72241080 fix: 真机全量测试修 5 个 bug(剪贴板假失败 / 音频后端误标 / CLI info 打印 / key press 崩溃 / Unicode 代理对)
在 192.168.1.100 真机逐项跑通 42 项能力,暴露并修复 5 个真 bug:

1. clip.set 假失败(间歇 ~20%)
   Set-Clipboard 抛「所请求的剪贴板操作失败」时 exit_code 仍为 0(只写 stderr),
   且内容其实已写入 —— 造成「成功却报错」。改为重试 3 次 + 退避 + 读回校验,
   不再凭 exit_code 下结论。验证:修前 2/5 失败 → 修后 0/8 失败。

2. Windows 音频 backend 误标 'CoreAudio'
   CoreAudio 是 macOS 的后端名;Windows 实际走 C# NAudio(CoreAudioAPI)。
   抽常量并修正 manifest 描述,避免排查时误判平台。

3. CLI info 把嵌套对象打成 [object Object]
   ps_helper / build 是嵌套对象,String(v) 丢失全部内容。新增 renderInfoValue():
   null→—、空串→—(未设置)、对象→key=value 紧凑摘要(180 字截断)。

4. nodeagent key press 必崩(100% 复现)
   字段契约不一致:被控端返回 {pressed,chords,times}(无 keys),
   CLI 读 r.keys.join() 抛 TypeError。双端对齐 —— agent 补回 keys 字段,
   CLI 改为 keys ?? chords 展开 ?? 参数回显。

5. Unicode 注入拆坏代理对(emoji / 生僻字)
   UnicodeChar(char) 逐字符注入,BMP 外字符被拆成两个孤立代理项,
   目标应用收到非法序列渲染 U+FFFD。新增 C# UnicodeString() 按 UTF-16 码元注入。
   注:KEYEVENTF_UNICODE 扫描码位仅 16 位,代理对必须各自单独发(API 固有限制)。

验证:单测 147 全绿;真机验收 15 通过 / 0 失败 / 1 跳过;双端已部署上线。
另确认中文偶发错字为 IME 上屏竞争,非注入逻辑问题(30 次对照实验全对)。
2026-10-04 23:40:25 +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 e6a9a3d7ea feat(v18): 更新链路可靠化 —— 构建指纹 + 备份 + 轮询校验(附 6 个真机 bug 修复)
## 皇上的问题
「这个版本如果有更新,可以在客户端上便捷的更新吗?」
答案:本来就有 `nodeagent deploy`(比最初的一键升级还早),但**它不可信**:
部署后只看「PID 变了 / 能力数对了」,证明不了真的换成了新代码(旧版可能能力数一样),
而且备份与失败判定都存在问题。本轮把这条链路做成**可信**的。

## 新增
1. **构建指纹**:agent 上报 `build.hash`(运行中脚本的 sha256 前 12 位)+ bytes/mtime/node/uptime。
   部署后按指纹校验「远端真的是新代码」,而非靠 PID/能力数猜。
2. **`deploy --check`**:只比对指纹不动文件(退出码 2 = 需要更新),适合脚本/巡检。
3. **`deploy --force`**:指纹相同也强制覆盖。
4. **`pnpm update:win [设备名]`**:一条命令完成「打包 → 校验产物确实更新 → 部署」
   (脚本刻意防呆:pack 后产物时间戳没变就拒绝部署,避免部署旧包 —— 真机踩过)。
5. **NODEAGENT_DEBUG=1**:CLI 打印异常堆栈(异步链问题必备)。

## 修掉的 6 个真机 bug(本轮逐个实测暴露)
1. **备份用 `Copy-Item`**(Windows 专用 shell 命令)→ macOS 被控端静默不备份。
   改为协议自带 fs.read/fs.write,跨平台。
2. **备份读取漏 `encoding: 'base64'`** → 二进制被按 utf8 读(损坏),再当 base64 写回
   → 备份文件只有 33 字节垃圾(实测:`fs.read` 返回 524288 字符而非 base64)。
3. **备份循环用 fs.read 返回的 `offset` 当前进量** → 那是「本次读取的起始位置」,
   于是原地打转 → 死循环 + 文件暴涨 + 部署卡死 200s。
   改为自算 len(对齐 cmdPush 成熟写法)+ 无进展即跳出。
4. **轮询用 withClient → 直接退出进程**:`withClientDirect` 在连接失败且是 ClientError 时
   调 `fail()` 退出 —— 轮询第一次连接必失败(agent 正在重启)→ CLI 立刻退出,
   报 E_NODE_OFFLINE,而**部署其实已成功**(本轮 3 次「失败」全是这个假象)。
   新增 `probeOnce()`:永不抛出的探测函数,专用于「等待上线/重启」类重试。
5. **盲等 9 秒**判定部署结果 → 改为**按墙钟**限时轮询指纹(90s),失败时报真实耗时。
6. **重启会切断连接**:该中断属预期,此前未处理会把成功误判为失败 → 显式吞掉并说明。

## 验证(本地全程真跑,非纸面)
用「起本地 agent → 造一个改动过的 bundle → 部署」的方式把整条链路跑通:
- 同版本 `--check` → 「已是最新」退出码 0 ✓
- 不同版本 `--check` → 报不一致退出码 2 ✓
- 完整部署 → 备份(979578B,`cmp` 逐字节一致)→ 上传 → 重启 → **指纹校验通过** ✓
- 防呆:文件过小/不存在被拒 ✓
- 单测 125 / e2e 27 全绿
2026-10-04 22:54:45 +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 edc184037b perf(v16): 常驻助手启动后台预热 —— 首调用不再承担 1-3s 启动成本
## 动机(由指标发现)
真机指标显示 window.list 的 P95 被抬到 2775ms,与实测稳态 42~75ms 矛盾。
排查:助手是**懒启动**的,每个 agent 进程的「第一次调用」要付启动 + 类型预加载的固定
成本(~1-3s);今天为验证反复重启了数十次,这笔成本就落进了 P95 的统计里。
这既是测量噪声,也是真实体验问题 —— 首个用户调用不该等 3 秒。

## 做法
agent 启动、打印完横幅后,后台预热一次助手(延时 1.5s、不阻塞、不 throw、失败无副作用):
把固定成本挪到无人等待的时刻。

## 真机验证
部署(agent 重启)→ 等 6s → **重启后首次** window.list:**0.57s**(此前 1.5~2.5s);
system.info 的 ps_helper 显示 hits=2(预热 1 + 调用 1)、spawned=1、failures=0 ✓

## 顺带说明(指标口径)
本轮 P95 3140ms 的另一部分噪声来自本人当日的开发流量(数十次重启 + 图像匹配调试的
失败样本,screen.find 成功率 77.6% 就是调试期数据)。测稳态请用 --since 限定窗口。
2026-10-01 21:16:11 +08:00
jkooe 411a483c76 fix(v16): 修复网络两阶段提交的 4 个真机缺陷(事故复盘后全部实测通过)
真机实测暴露并修复:
1. **生成的 .ps1 缺 UTF-8 BOM** → PowerShell 5.1 按 ANSI(GBK) 解析,脚本里的中文
   (网卡名「Ethernet」)会吃掉后面的闭合引号,报 "The string is missing the terminator"
   → 计划任务退出码 1 且不留任何日志。现已统一 withBom() 落盘(对照实验:纯 ASCII 正常、
   含中文失败,定位明确)。
2. **路径字面量被多余转义**:单引号内本是字面量,之前却把反斜杠翻倍 → 生成
   `C:\\Users\\...` 非法路径 → Out-File 失败。新增 psLit() 统一处理。
3. **账号名拼法错误**:`"$env:USERDOMAIN\$env:USERNAME"` 在 TS 模板串里 `\$` 被当转义吃掉
   反斜杠 → 拼出「机器名<user>」→ Register-ScheduledTask 报 HRESULT 0x80070534
   「No mapping between account names and security IDs」。改用 WindowsIdentity 权威名称。
4. **注册脚本重复实现导致漏改**:回滚/延时两处各写一份,改了一处漏另一处(本轮回滚改好了、
   apply 没改 → 继续报错)。抽出唯一实现 buildTaskRegistration(),并把注册类调用统一改为
   **临时脚本 + -File**(真机实测 -EncodedCommand 下进度流会污染 stderr 且 stdout 拿不到
   预期输出,导致「明明成功却被判失败」)。

顺带:
- 错误详情统一剥离 CLIXML 噪声(extractPsError),并带上 exit code 与 stdout —— 本轮就是靠它
  才看到真实报错(此前只有「启动失败(code=1)」)
- net confirm 顺带清理已执行完的 netapply 任务,保持任务计划程序干净

真机验证(全部通过):
- apply(command) → 返回 apply_scheduled + 两个任务名 ✓
- apply 脚本执行 → 日志出现 "apply ok" ✓
- 不确认 → 自动回滚 → 日志 "restored: static" ✓(事故路径已修复)且网络完好 ✓
- confirm → 任务被取消(netrollback 计数 0)✓
- net status 剩余秒数正确 ✓
2026-10-01 20:58:19 +08:00
jkooe e955b6a2f7 test(v16): 网络脚本回归断言 8 条(把事故根因变成防线)
针对本次真机事故(机器掉 APIPA 失联)逐条设防:
- apply 必须先 New-NetIPAddress 再 Remove-NetIPAddress(顺序颠倒 → already exists → 无地址)
- 建默认路由前必须先 Remove-NetRoute(否则 New-NetRoute 报实例已存在)
- 断言脚本里**不得出现** netsh set address / set dnsservers(Win11 上会挂起)
- 自检片段必须校验目标地址并在失败时执行回滚(自愈)
- 回滚脚本同样受「先加后清 / 先清路由」约束,且必须恢复原地址/网关/DNS
- DHCP 快照的回滚走 -Dhcp Enabled,不得写静态地址
- 掩码→前缀换算边界(含非法输入回退)

单测 116 -> 124
2026-09-30 10:20:02 +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 0dcf01ad4e perf(v14): PowerShell 常驻助手 —— GUI 能力提速 4~25 倍(审查报告 §5.5 P1)
## 问题(审查报告 5.5 指出,真机实测确认)
window.list / window.focus / screen.find 每次调用都起一个 powershell.exe 现编译 C#
(Add-Type @"..."@)+ 重新加载 UIA 程序集。实测 agent 侧中位:
  window.list 1267ms · window.focus 1631ms · screen.find 3140~3736ms
固定成本(起进程 + 编译类型)与「本次要做的活」无关,纯浪费。

## 方案(apps/agent/src/util/ps-helper.ts)
常驻 PowerShell 进程:启动时把 prelude(Win32 C# 类型 + UIA 程序集 + WinRT/OCR 段)
**只加载一次**,之后每请求只发「本次逻辑」(base64 一行),进程内直接执行回传。
- 协议:stdin 收 base64 脚本行;stdout 回 `@@R <b64>` / `@@E <b64>`;stderr 只走就绪信号
- 请求 FIFO 串行(单进程单 stdin),逐请求超时
- **可用性不倒退**:助手不可用/超时/进程退出 → 自动回退到原一次性脚本路径;
  连续失败 3 次熔断,本进程不再重试(避免每次白等一次超时)
- 自愈:进程退出后在下次请求重建;空闲 10 分钟自动回收;agent 退出统一回收
- 预加载段带 sha256 指纹:**prelude 变化自动换新助手**(否则会用到旧进程里已加载的定义)
- 逃生开关:环境变量 NODEAGENT_NO_PS_HELPER=1 可强制走一次性路径

## 关键工程细节:exit
脚本体会被送进常驻进程执行,**其中的 exit 会杀掉助手进程**。
已将 focusOnce 的 `exit 0`、UIA 的 `exit 0`、OCR 的 2 处 `exit 0` 全部改为 return。

## 顺带优化 UIA 扫描策略(本轮第二个瓶颈)
UIA 只能「FindAll(Descendants) + 子串过滤」(PropertyCondition 是精确匹配)。
原来**每次都遍历整棵桌面 UI 树**。改为:
  指定 window → 该窗口子树;否则**先在前台窗口子树内找**,未命中再退回全桌面兜底。
遍历逻辑收敛为预加载函数 Find-UiaByText,两处复用不再重复。

## 真机实测(agent 侧耗时,同一台机器同一会话)
| 能力 | 改造前 | 改造后 |
|---|---|---|
| window.list  | 1267 / 1216 / 1260 / 980 ms | **52 / 52 / 52 / 73 / 42 ms**(约 25x) |
| window.focus | 1576 / 1568 / 1600 ms        | **411~443 ms**(约 4x;余量为刻意的等待与激活校验) |
| screen.find  | 3140 / 3736 / 1302 / 1393 ms | **19 / 26 / 72 / 103 / 176 ms**(约 20x,前台命中路径) |
端到端(CLI 视角)window.list 由 1.34s → **0.16s**。
另注:全桌面无命中路径的耗时随 UI 树规模变化,非固定值。

## 单测
101 -> 105(新增助手协议 4 例:UTF-16LE 编码可逆 / bootstrap 含 prelude+READY+循环+exit 协议
且必须 try-catch 兜异常 / 协议行解析(含 Add-Type 噪声必须忽略)/ 中文多行往返无损)
2026-09-30 00:51:56 +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 d56556d3c3 feat(v12.3): macOS 端 GUI 能力对齐 —— window.list / window.focus / screen.find
## 动机
此前 window.* 与 screen.find 只在 Windows 可用(UNSUPPORTED_PLATFORM)。本版让
macOS 也能作为被控端被 GUI 操作,兑现「Mac 与 Windows 双向接管」。

## 实现(apps/agent/src/capabilities/darwin.ts)
| 能力        | Windows                 | macOS                                     |
|-------------|-------------------------|-------------------------------------------|
| window.list | Win32 EnumWindows       | AppleScript System Events(需辅助功能权限) |
| window.focus| SetForegroundWindow+兜底 | AppleScript frontmost + AXRaise           |
| screen.find | UIA → OCR(WinRT)        | 无 UIA 等价物 → 截图 + **Vision OCR**      |

要点:
- AppleScript 经 stdin 传入(base64 解码),彻底规避引号/换行转义
- Vision OCR 用 Swift 源码 + `swiftc -O` 按需编译,二进制缓存在数据目录 bin/naocr;
  源码变化时自动重编(首编约 15s,之后毫秒级)
- 权限错误翻译成可执行指引:实测 -10004「发生权限违例」→ 提示去「辅助功能」授权;
  -1743 → 「自动化」授权

## 真机踩坑(本轮最重要的一条)
**Retina 屏上 screencapture 产出 2x 像素图**,而窗口坐标为逻辑点 —— 直接返回会让
点击偏移一倍。修法:解析 PNG 的 IHDR 取像素尺寸,除以请求区域点数得到缩放比,
换算后再叠加区域偏移(返回体注明已换算)。

## 顺带修掉一个参数缺口
screen.find 的实现读 region 但 schema 未声明 → 带 region 的调用被参数校验拒绝
(E_PARAM_INVALID)。已补 region 到 manifest,并让 Windows 的 OCR 路径同样支持
「显式区域优先 > 指定窗口 > 整屏」(区域越小 OCR 越快越准)。

## 真机验证(本机 macOS 27.2,M3 Max)
- window.list:AppleScript 生效,但本机未授予辅助功能权限 -> 返回可执行提示(预期路径)
- screen.capture:984KB 有效 JPEG(3024x1964)
- Vision OCR 独立验证:对含中文/英文的真实截图识别出「WorkBuddy 编辑 查看 窗口 帮助」等
- screen.find(全屏):命中 3 处,坐标稳定
- screen.find(区域 0,0,1600,120):命中且坐标全部落在区域内 —— 证明 Retina 换算正确
- 等待语义(wait_ms)在 macOS 路径同样生效

单测 89/89、e2e 21/21 全绿
2026-09-29 14:55:13 +08:00
jkooe a892b113ca feat(v12.2): GUI 等待/重试语义 —— 结束「点下去就不管」的假失败
## 动机
GUI 自动化最常见的失败不是「找不到」,而是「还没出来」:应用启动、页面切换、动画
都有延迟,一次性查找必然假失败。之前调用方只能自己在外面 sleep+重试(浪费往返)。

## 实现(agent 侧内联轮询,避免多次往返)
- screen.find 新增 wait_ms(0~30000,默认 0)与 interval_ms(100~2000,默认 400):
  轮询到命中或超时;无论成败都返回 waited_ms,让调用方知道是否命中等待窗口
- window.focus 新增 wait_ms:窗口未出现时每 500ms 重试,直到超时(仅 title 模式)
- 宏步骤 find/assert 透传 wait_ms/interval_ms(写宏时可直接声明等待)
- MCP na_screen_find 暴露 wait_ms

## 真机验证(决定性)
调用 window.focus(title=Notepad, wait_ms=15000) 时**记事本尚未运行**;
4 秒后才启动 -> 等待轮询捕获并成功聚焦:
  { title: "*install.ps1 - Notepad", focused: true, activated_by: "click" }
注意 activated_by=click:本次 SetForegroundWindow 又被系统静默否决,
点击兜底自动接管 —— 两个加固协同生效(等待 + 激活兜底)。

单测 89/89、e2e 21/21 全绿
2026-09-29 14:43:27 +08:00
jkooe 66c44f6822 feat(E3): Hub 并发多控制端 + 设备分组(阶段 E 全部完成)
## E3a 并发多控制端(Hub 槽位池)
过去:一个被控端只有一条 Hub 连接 -> 同时只能一个控制端(否则 E_NODE_BUSY)。
现在:
- Hub 侧 agents 表改为「node_id -> 槽位池」,每个槽位服务一个控制端:
  有空闲槽位直接配对;不足且未达上限则向被控端请求扩容(need_slot,3s 超时);
  仍不满足才返回 E_NODE_BUSY。新增配置 max_slots_per_node(默认 3,设 1 即退回独占语义)
- 被控端侧 hub-mode 重写为槽位池:常备 2 条连接(warm_slots 可调),
  并在某槽位被占用(收到 hello)时预热下一条直到 max_slots
  —— 复用既有协议信号,不新增控制消息;每条槽位独立重连与退避
- Hub 列表视图聚合为「一节点一行」并带 slots / paired_slots
- e2e 新增并发用例:两个控制端同时握手 + 交替并发调用,均成功(旧版会失败)

## E3b 设备分组
- 客户端配置新增 groups(组名 -> 设备名数组;成员可写 @其它组名,最多展开 3 层防环)
- CLI:nodeagent group add|remove|list;fanout --nodes @组名 按组并发下发
- 真机验证:group add 本机 win -> fanout system.info --nodes @本机 正常

## E3c 权限隔离(部分完成)
- 能力级隔离沿用被控端 ACL(按 client_id,已含 allow/deny/glob/生效时段/IP 白黑名单/按能力限速)
- Hub 侧「按设备/按控制端授权」尚未实现(当前 Hub 视角为单一令牌),已记入路线图待办

## 测试
- 单测 73 -> 79(新增设备分组 6 例:展开 / 嵌套 / 去重 / 未知 / 兼容旧配置 / 环引用收敛)
- e2e 21/21(含扩展后的 Hub 并发双控制端用例)
2026-09-29 14:33:01 +08:00
jkooe 3ed117770b feat(E4): GUI 宏(步骤序列回放)+ 窗口聚焦加固(能力 35 稳定)
## E4 宏引擎(packages/client/src/macro.ts,CLI 与 MCP 共用)
- 步骤类型:focus / find / click / type / key / drag / sleep / exec / clip / assert / capture
- 通用能力:retry(失败重试)、interval_ms、optional|continue_on_error、${VAR} 与 ${VAR:-默认值} 替换
- 断言内置在步骤里:exec 的 expect_exit/expect_stdout、clip 的 expect、
  assert 支持 kind=screen|window|clip(屏幕断言自动走 UIA→OCR)
- 返回逐步结果(含 ms 与失败定位 failed_at),失败即中止并给出可读原因
- CLI: macro run <file> [--var k=v] [--out 目录] / macro validate / macro init(生成示例)
- MCP: na_macro_run(inline steps + vars)

## 真机验证(记事本全流程)
宏 init 生成示例 -> 启动记事本 -> 回放 9 步:
focus ✓ / key ctrl+a ✓ / delete ✓ / type ✓ / sleep ✓ / key ctrl+a ✓ / key ctrl+c ✓ /
clip 断言「宏回放演示 回放测试」✓ / window 断言 ✓ -> 全部通过(10.1s)

## 顺带修复两个真机踩出的问题
1. **window.focus 可能被静默否决**:SetForegroundWindow 受 Windows 前台锁定限制,
   调用后前台窗口可能仍是别人(实测被系统防火墙对话框抢焦点,导致宏的输入全进了对话框)。
   现改为:调用后**校验当前前台窗口**;未生效则在该窗口标题栏做一次真实点击兜底,
   并返回 activated_by=api|click 让调用方知情。
2. **window.list 会被 limit 截掉真正的前台窗口**:现按 is_foreground 排序置顶并返回 total。
3. 宏引擎:变量缺失原先会在 try 之外抛出、导致整个宏崩掉;现改为「记为该步失败」,
   optional 时继续执行(单测覆盖)。

单测 63 -> 73(新增宏引擎 10 个用例:变量/默认值/缺失、retry、optional、失败中止、
expect_exit/stdout、clip 断言、未知步骤、find→click 坐标透传、坏文件拒绝);e2e 21/21
2026-09-29 14:25:35 +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