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
|
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
|
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
|
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
|
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
|
6c650244bf
|
chore(release): v1.4.2
|
2026-10-05 02:51:31 +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
|
4c6190285c
|
refactor(protocol): 拆分 1281 行的 capabilities.ts(纯搬移,公共 API 不变)
## 结果
capabilities.ts 1281 → 12 行(仅门面:re-export + findCapability)
capabilities/names.ts 55 行 标准能力名 CapabilityNames + 类型
capabilities/schemas.ts 11 行 通用 schema 片段(filterSchema)
capabilities/manifest/system.ts 303 行 系统面 9 项
capabilities/manifest/graphics.ts 413 行 图形 12 项(screen/window/input)
capabilities/manifest/files.ts 121 行 文件 4 项
capabilities/manifest/software.ts 62 行 软件 2 项
capabilities/manifest/automation.ts 329 行 自动化 12 项(task/clip/event/net/macro/group)
capabilities/manifest/index.ts 20 行 聚合为单一 CAPABILITY_MANIFEST
## 做法(可复用的纯搬移流程)
1. 先扫「清单数组区间内是否夹带顶层声明」→ 无 ✓(否则按函数切分会漏)
2. 用花括号配对切出 39 个描述符(断言数量必须一致,避免静默丢项)
3. 按**能力名字符串前缀**分组(不是枚举成员名——成员名与字符串前缀并不一致,
例如 AuditList→system.audit.list、AgentRestart→system.agent.restart)
4. 聚合文件保持对外单一 CAPABILITY_MANIFEST;facade 保持全部导出不变
## 副作用(正面)
清单顺序由「历史上陆续追加的混杂顺序」变为**按域分组**(系统→软件→图形→文件→自动化),
CLI/MCP 的能力展示更易读。已真机确认无功能影响。
## 验证
- 构建 6 包全过;单测 124/124(含清单守护:数量=39、逐项 name/schema 校验);e2e 26/26
- 真机:部署后能力 39 项 ✓、CLI 能力矩阵正常打印(含风险图标)✓
|
2026-10-01 21:11:59 +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
|
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 |
|
jkooe
|
2dccec9a5c
|
feat(D): 生产化 —— 无人值守模式 / 一键升级 / 文档口径更新
## D1 无人值守(install.ps1)
- 新增 -Unattended(S4U 登录类型):**无需保存密码,注销/重启后仍自动运行**
- 新增 -AtStartup:追加开机触发器;任务设置补 StartWhenAvailable(错过触发后补跑)
- **诚实标注能力边界**:S4U 运行在非交互会话,GUI 能力(截屏/UIA/键鼠)不可用;
-Unattended 与 -AllowInput 冲突时自动关闭输入控制并回写配置
- 说明文档给出两种模式的对照表(交互模式支持 GUI,但注销即停)
## D2 一键升级(nodeagent deploy)
- system.info 新增自描述字段:pid / agent_script / agent_home / node_path
- deploy 流程:读取被控端入口路径 → 备份旧文件 → 分块上传新 agent.mjs
→ 调 system.agent.restart 受控重启 → 等待恢复并复验能力数
- 真机验证:
* 首次(旧版 agent 无自描述):--path 指定 → 上传 853KB → PID 16492 / 31 项能力
* 二次:自动识别路径 + 自动备份 → PID 34120 / 31 项能力(免 --path)
## D3 CI(待推送)
- windows-e2e.yml 增加单元测试步骤(3 行);因 OAuth token 缺 workflow scope,
该改动暂留本地未推送,需用户授权后推送
## D4 文档口径更新(README)
- 能力总览补齐 31 项(新增:自持/异步任务/窗口/录屏/剪贴板/拖拽/审计校验)
- 统计数字更正:19 项能力 -> 31,15 个 MCP 工具 -> 26,补 61 项单元测试
- 路线图补 v7~v11+;安全章节补密钥库/Audit 链/ACL 细化
- 命令速查新增:restart / deploy / daemon / fanout / bg / tasks / clip / record /
window.* / screen.find / mouse drag
- MCP 工具表按新工具重排;安装章节新增两种运行模式对照表
单测 61/61、e2e 21/21 全绿
|
2026-09-29 11:47:15 +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 |
|
jkooe
|
e50e805383
|
feat(v10): 长任务异步化(system.task.*)+ 剪贴板(clip.*),能力 23 -> 28
## 长任务异步化(P1)
- system.shell.exec 增加 async:true -> 立即返回 task_id(wait_forever 可不设超时)
- system.task.list / get / kill:任务表(Map 内存态,完成态保留 30min / 上限 50 条),
输出 Buffer 累积(上限 10MB),get 支持 offset 增量续读
- kill 走 killTree(整棵进程树);超时自动标记 killed/exit=124(与 timeout(1) 惯例一致)
- 命令只记 sha256 前 8 位摘要(与审计脱敏策略一致)
- CLI:bg / tasks / task <id> [--offset N] [--kill]
## 剪贴板(P1)
- clip.get / clip.set(Windows: Get/Set-Clipboard;macOS: pbpaste/pbcopy)
- set 走 base64 传输规避命令行参数编码问题(中文/emoji 安全)
## MCP 同步
- na_bg / na_task(list|get|kill 多动作)/ na_clip(get|set 多动作)
真机验证(全生命周期):
- bg 20 秒长任务 -> 立即返回 task_id;tasks 列表 running;
task 增量读 tick 10~12 -> 14~15;--kill 后 killed/exit=124
- clip --set 中文+emoji -> 18 字符;读回逐字一致
- 28 项能力在线;unit 26/26、e2e 21/21 全绿;Mac 端 CLI/MCP 已重装
|
2026-09-29 01:19:10 +08:00 |
|
jkooe
|
e4cc1c20b6
|
feat(v9): screen.find OCR 兜底 + MCP 同步 4 项新工具
## screen.find OCR 引擎(自绘 UI 兜底)
- method 参数:auto(UIA 优先,空结果自动 OCR)/ uia / ocr;返回 engine 字段
- OCR 实现:CopyFromScreen 截区域(可限定窗口矩形)→ WinRT BitmapDecoder →
Windows.Media.Ocr(zh-Hans-CN)→ 行/词矩形 → 屏幕坐标
- 两个真机坑(均已实证修复):
1) Windows OCR 会在中文字符间插空格(「我的加速」识别为「我 的 加 速」),
子串匹配永远失败 → 匹配一律在去空格文本上进行,
并用「最小词窗口并集」精确定位目标词段的矩形
2) 重启任务的 cmd 用 >> nodeagent-restart.log 启动新 agent,新 agent 长期持有
该文件句柄 → 下次重启任务所有带该重定向的命令被整体跳过(包括 taskkill!),
表现为 restart 返回 ok 但什么都没发生 → 日志文件改为时间戳唯一 + 清理历史
- 真机验证(加速器自绘界面):「网络监控」@(2174,568)、「STEAM」@(1590,582)、
「我的加速」@(1365,434),截图目检坐标全部精确吻合
## MCP 同步(能力 19 -> 23 对 AI 可用)
- 新增 na_restart / na_window_list / na_window_focus / na_screen_find
- unit 26/26、e2e 21/21 全绿
|
2026-09-29 00:59:15 +08:00 |
|
jkooe
|
313420a482
|
feat(v8): GUI 语义能力 —— window.list / window.focus / screen.find(能力 20 -> 23)
P0-1 UI 元素定位(基于 Windows UI Automation + Win32):
- window.list:枚举可见顶层窗口(hwnd/标题/进程/精确矩形/前台标识/最小化)
—— 真机验证:正确返回 PowerShell / cmd / 各应用窗口矩形
- window.focus:按标题正则或句柄置前窗口,返回精确矩形(SW_RESTORE + SetForegroundWindow)
- screen.find:UIA 查找元素并返回**中心点屏幕坐标**(支持窗口范围/控件类型过滤),
直接喂给 input.mouse.click —— 把「看得到画面读不懂界面」变成「按名取坐标」
—— 真机验证:查找 'Windows PowerShell' 返回 (716,648) 与完整矩形
实现要点:
- PowerShell 脚本一律走 EncodedCommand(UTF-16LE base64),规避引号/中文/换行转义
- extractJson():Add-Type 程序集加载会向 stdout 夹杂输出污染 JSON,
按首 [/{ 到末 ]/} 截取后再 parse(真机踩过:'Unexpected non-whitespace character')
顺带修复(本轮测试发现的 bug):
1. services 状态显示数字:Get-Service 的 Status/StartType 是 .NET 枚举,
ConvertTo-Json 退化为数字(4=Running),显式 ToString() 修复
2. restart 任务名冲突:重启任务的 cmd 持有新 agent 进程 → 任务长期 Running
→ 下次 Unregister/Register 静默失败 → 'restart 返回成功但没重启'。
改为唯一任务名(时间戳)+ 注册后显式校验 + 清理历史非运行任务
—— 真机验证连续 restart:PID 2612 -> 18556,任务名带时间戳
3. cmd shell 中文:定性为 Windows 固有局限(命令行参数在进程创建时已按 ANSI
解释,chcp 救不了参数本身),代码注释说明并引导用 PowerShell
真机:23 项能力全量在线;unit 26/26、e2e 21/21 全绿
|
2026-09-29 00:38:54 +08:00 |
|
jkooe
|
111a7b5918
|
feat(P0): UTF-8 全链路统一 + exec 输出结构化 + 受控重启(v7)
## P0-2 统一 UTF-8 编码链路
- util/exec.ts:Windows 侧注入 [Console]::OutputEncoding/ 为 UTF-8,
cmd 分支加 chcp 65001;并为子进程设置 PYTHONIOENCODING/LANG/LC_ALL。
- 根治三类事故:PowerShell 管道损坏中文(U+FFFD)、控制台 GBK 乱码。
- 真机验证:桌面文件名 [115浏览器.lnk / 360安全卫士.lnk] 由乱码变为正确中文。
## P0-4 exec 输出结构化
- CLI cmdExec:退出码/耗时等诊断信息改走 stderr,stdout 只保留命令自身输出。
- 使 不再被尾部标注污染(实测 stdout 仅 'ABC')。
## P0-3 受控重启(新增 system.agent.restart,能力数 19 -> 20)
- 先经历 4 种失败方案(均有真机证据):
1) 计划任务 + start node.exe -> Session 0 无桌面,start 拉不起进程
2) detached spawn + start /B -> cmd 退出即带走子进程
3) detached spawn + 直接跑 node -> detached cmd 随 agent 一起消失
4) 计划任务 action 直接跑 node -> 成功!但暴露环境变量传参 bug
- 最终方案:计划任务(ExecutionTimeLimit=0),action = cmd /c
「延时 -> taskkill 旧 PID -> 前台执行 node.exe」;Task Scheduler 持有 cmd,
cmd 持有 node,整条链与旧 agent 无关,且不依赖桌面会话。
- 关键坑:cmd 中 `set X=Y & cmd` 的 `&` 前空格会被计入变量值('Y '),
导致 agent 读不到配置回退默认端口 → EADDRINUSE。必须写成 `set X=Y& cmd`。
- 真机验证:节点 PID 31864 -> 22656,端口/能力/调用全部正常。
- 附带:Windows 用 ping 替代 timeout(后者在非交互环境报 Input redirection 不支持)。
CLI 新增 `nodeagent restart [--delay ms]`;unit 26/26、e2e 21/21 全绿。
|
2026-09-28 23:26:59 +08:00 |
|
jkooe
|
3316f08c92
|
fix: 修复 Windows 安装后无法启动 —— PowerShell 写入的 JSON 带 UTF-8 BOM
真机报错:启动失败: Unexpected token '', "{"a"... is not valid JSON
根因:Windows PowerShell 5.1 的 Set-Content -Encoding UTF8 会写入 BOM(U+FEFF),
Node 侧 JSON.parse 拒绝解析带 BOM 的文本。
双管齐下修复:
1. 写端 install.ps1:改用 [System.IO.File]::WriteAllText + UTF8Encoding($false)
写出无 BOM 的 UTF-8
2. 读端容错(兼容已被写坏的配置):
- apps/agent/src/config.ts:读配置时剥离开头 BOM
- packages/client/src/config.ts:同上
验证:构造带 BOM 的 agent.json(ef bb bf 开头),agent 可正常启动;
单元 26/26、E2E 21/21 全绿
|
2026-09-27 19:32:53 +08:00 |
|
jkooe
|
3d03945448
|
feat(v6): Hub 中转 —— 跨网段 / 公网接入
新增 apps/hub(中转节点):
- 被控端主动外连注册(/hub/agent),控制端接入配对(/hub/client)
- 配对后双向字节透传 —— Hub 不解析内容、不持有设备密钥
- Hub 令牌鉴权;list 可查在线设备
- 连接语义:控制端断开仅解绑(被控端保持在线);被控端掉线连带关闭控制端
- 同一被控端同一时刻仅服务一个控制端(占用返回 E_NODE_BUSY)
被控端:
- 新增 hub 配置;启用后不监听本地端口,改为主动外连 Hub(穿 NAT)+ 指数退避重连
- 重构抽出 createAgentCore(),监听模式与 Hub 模式共用同一套鉴权/ACL/能力分发
控制端:
- NodeProfile 新增 hub 字段;toWsUrl 自动附 /hub/client 路径
- client 支持 Hub 配对(控制面消息与 RPC 分离)
修复(Hub 场景暴露):
- cmdConnect 漏传 hub 字段,导致跳过配对直接发 RPC
- Hub unpair 过激进:控制端断开时把被控端也踢下线
- 安全:被控端 hello 时重置认证态与授权(Hub 下同一连接被多控制端复用)
验证:本地 E2E 21/21;构建 + 类型检查全绿;冒烟确认 info/exec 穿透 Hub 且多次接入保持在线
|
2026-09-27 15:04:05 +08:00 |
|
jkooe
|
4ff1b4fd90
|
feat(v5): 多设备管理 + 文件传输
方向 1 · 多设备管理:
- 配置模型升级为 nodes{} + current,自动迁移 v1/v2 单设备扁平配置(零破坏)
- 新增 resolveTarget():按名解析生效设备与控制端身份
- CLI: nodes(列表)/ use(切换)/ remove(移除)/ connect --name(命名)
- 全局 --node <设备名> 单次指定目标,前置(--node=x cmd)与后置(cmd --node x)均可
方向 2 · 文件传输(分块 RPC,协议零改动):
- fs.list(递归+glob)/ fs.stat / fs.read(offset+max_bytes 分块)/ fs.write(原子写+append)
- 小文件(<=8MB)首次读取附带 sha256;路径白名单 fs_roots,越界 -> E_ACL_DENIED
- CLI: ls / stat / cat / pull / push(自动分块;pull 与 push 均原子落盘)
- MCP: na_fs_list / na_fs_read / na_fs_write
能力总数 15 -> 19;MCP 工具 12 -> 15
验证:本地 E2E 20/20(新增文件传输全链路用例);构建 + 类型检查全绿;
冒烟确认 3MB 文件 pull 后 sha256 一致、白名单越界被拒
|
2026-09-27 13:25:38 +08:00 |
|
jkooe
|
3d0eb1c696
|
feat(v4): 无感体验 —— 局域网自动发现 + 断线自动重连
自动发现(免手抄 IP):
- 被控端 UDP 心跳广播(默认 5s 一次,目标 255.255.255.255:8766)
- 控制端 Discovery 监听 + TTL 设备表(默认 30s 过期)
- 报文不含任何凭据;可 discovery.enabled:false 关闭
- 控制端取「报文来源 IP」作为可达地址(比被控端自报地址可靠)
- CLI: nodeagent discover [--wait 5];MCP: na_discover
断线自动重连:
- client 新增 autoReconnect(指数退避 1s→30s + ±15% 抖动)
- 重连前清空在途请求(统一 E_NODE_OFFLINE),避免悬挂
- onStateChange 回调(connected/reconnecting/closed)
- close() 会停止重连,不会关不掉
修复:UDP socket 需先 bind 再 setBroadcast(未绑定时报 EBADF)
验证:本地 E2E 19/19(新增发现 + 重连两用例);构建与类型检查全绿
|
2026-09-27 13:11:13 +08:00 |
|
jkooe
|
edc69b0a34
|
feat(v3+): 审计日志 + 调用限速
审计(安全闭环最后一环):
- 被控端将关键动作以 JSONL 写入 <数据目录>/audit.log:agent 启停 / 认证成败 /
invoke 结果 / ACL 拒绝 / 限速拦截
- 大小轮转(默认 10MB × 5 份);参数默认只记 sha256 摘要,log_args 开启后
自动脱敏(pass/token/secret/key/auth 键 → ***,字符串截断 200 字符)
- 审计写入失败被吞掉,绝不阻断业务主流程
- 新增能力 system.audit.list(支持 limit/since/client_id/type 筛选)
- CLI: nodeagent audit;MCP: na_audit
限速:
- 落地 ACL 中预留的 max_calls_per_min(按调用方滑动窗口计数)
- 超限返回 E_RATE_LIMITED(-32407)
验证:本地 E2E 全绿;零信任用例扩展为 6 项断言(授权/拒绝/审计留痕/脱敏/限速/未注册/错钥)
|
2026-09-27 11:56:37 +08:00 |
|
jkooe
|
5b7e967868
|
feat(v3): 零信任安全 —— Ed25519 身份认证 + 能力级 ACL
认证(两模式并存,默认 psk 向后兼容):
- 新增 auth_mode: psk | ed25519
- ed25519:每控制端独立密钥对,挑战-应答签名验证(替代共享密钥)
- 密钥用 Node 内置 crypto 生成(零新依赖),Base64(DER) 单行格式
- 私钥存 <数据目录>/keys/ed25519.json(600 权限,永不外传)
授权(能力级 ACL):
- acl.clients[] 按调用方登记 pubkey + allow/deny(glob 匹配)
- 判定顺序 deny → allow → default_effect(默认拒绝)
- 未注册身份 / 验签失败 → 握手拒绝;未授权能力 → E_ACL_DENIED
- auth_ok 下发 authorized 子集,CLI 以 授权/未授权 标识展示授权矩阵
接入:
- CLI: keygen(生成密钥 + 打印 ACL 配置片段)、connect --auth-mode ed25519
- MCP: 按 auth_mode 自动选择认证方式
- install.ps1: 显式写 auth_mode + 零信任指引
验证:本地 E2E 17/17(新增零信任四项断言);手动端到端复验通过
|
2026-09-27 11:01:58 +08:00 |
|