48 Commits
Author SHA1 Message Date
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 f88738d36d fix(v22): 修正构建期注入的版本号带多余引号(真机暴露)
## 现象
真机 `nodeagent info` 显示 `被控端版本 : v"1.4.0" @ "e841fbb"` —— 值里带了引号。

## 根因
esbuild 的 `--define:K=V` 中 **V 是按 JS 表达式解析的**。原先写成
`--define:__AGENT_VERSION__='"1.4.0"'`(单引号包裹),单引号被当成**字符串内容**,
于是运行时值变成 `"1.4.0"`(含引号)。产物内联可见:
`return false ? "dev" : '"e841fbb"';`

## 修复
改用 `JSON.stringify(v)`(纯双引号)→ 产物内联为 `return false ? "dev" : "1.4.0";` ✓
真机复核:`被控端版本 : v1.4.0 @ 50ac4b9  构建 05abbbc79abf` ✓

## 附带真机确认(v1.4.0 部署)
- 推送式部署 + **指纹校验通过**(能力 42 项、PID 16664)
- **证书指纹 TOFU 钉住生效**,且普通 agent 更新**不影响**钉住(证书未变 → 仍一致 ✓)——
  这正是设计意图:只有证书重建才会触发 E_CERT_MISMATCH
- **Windows 音频能力真机跑通**(Core Audio COM 路径):`✓ 已应用:🔇 静音 · 音量 77%`,
  且能**读回真实状态**(确定性设置而非切换)—— 这补上了此前只在 macOS 验证的空缺
2026-10-05 02:45:51 +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 045e3b08b1 fix(sec): 安装包不再打包 PSK.txt(该 zip 会作为公开 Release 资产发布)
## 问题
`nodeagent-win-x64.zip` 里含 `PSK.txt`(64 位预共享密钥)。而这个 zip 正是 GitHub
Release 的公开资产 —— 等于**把被控端的预共享密钥公开**。若谁用该 Release 包安装,
其密钥即为公开值,同网段的任何人都能凭此接入。

## 修复
- `pack.mjs` 打 zip 时用 `-x "PSK.txt"` 排除(并注明原因,防日后被加回)
- 不影响安装:`install.cmd` 在 PSK.txt 缺失时**本地自动生成**密钥(只留在被控端安装目录),
  这正是它头注释里既有的行为
- 新增单测守卫:断言打 zip 命令必须带 `-x PSK.txt`(防止回归)

## 兼容性
PATCH 级:仅打包内容变化,不涉及协议/能力/参数。已安装的机器不受影响。
2026-10-05 00:25:44 +08:00
jkooe 303cbc7436 chore(release): v1.2.0 2026-10-05 00:19:29 +08:00
jkooe 1c26dc4e3f fix(release): sh() 容错 stdio:'inherit' 的 null 返回(发版首跑即炸) 2026-10-05 00:13:45 +08:00
jkooe 6faf807108 chore(release): fetch/push 统一走重试(本机 GitHub 连接间歇抖动) 2026-10-05 00:02:48 +08:00
jkooe 21d568d90d chore(release): 发版脚本加 push 重试(本机到 GitHub 连接会间歇抖动) 2026-10-04 23:57:35 +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 9853ee8cdb docs: 增补「杀软误拦(主流杀软 / 杀软 / 360)」排查章节
真机实证:远程接管类工具的特征(未知 exe + 建计划任务 + 开防火墙 + 常驻监听 + 注入键鼠)
必然触发杀软拦截。主流杀软会拦 install.ps1 执行 / 计划任务注册,或直接处置 node.exe,
表现为「下载成功但装不上」或「装上后 agent 起不来」。

新增内容:
- 逐项放行清单(安装目录 / node.exe / agent.mjs / nodeagent 计划任务 / 入站 8765 / PowerShell 执行 / 键鼠截屏)
- 自检命令(任务状态 + 进程 + 端口三连查)
- 排查顺序:任务与进程 → 端口 → **确认两台机器在同一网络**
- 特别标注最后一个坑:控制端换 Wi-Fi(酒店/热点)后仍在扫旧网段,会误判为「失联」
2026-10-03 03:07:23 +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 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 caa5b8a2c1 refactor(cli): 拆分 1977 行的 index.ts 为 11 个模块(纯搬移,零逻辑变更)
## 动机(审查报告 §5.5 / 阿特拉斯报告)
单文件 1977 行难维护、难 review、难以定位改动影响面。本次按职责拆分:

    src/index.ts   315 行  入口:参数解析后按子命令调度
    src/options.ts 155 行  命令行参数解析(parseArgs)
    src/help.ts     75 行  帮助文本
    src/types.ts    74 行  共享类型与常量
    src/core.ts    236 行  运行时:客户端/守护进程代理/输出工具/--node 覆盖
    src/cmd/conn.ts   217 行  连接与设备(connect/keygen/nodes/use/remove/discover)
    src/cmd/system.ts 278 行  系统面(info/status/ps/services/exec/restart/audit/metrics/invoke/apps/install)
    src/cmd/ops.ts    499 行  自动化(bg/tasks/task/fanout/record/deploy/events/macro/clip)
    src/cmd/gui.ts    173 行  图形(screen/screenshot/mouse/key)
    src/cmd/fs.ts     172 行  文件(ls/stat/cat/pull/push)
    src/cmd/net.ts    116 行  网络两阶段提交

## 拆分中处理的真实工程问题(都不是「机械搬移」能躲过的)
1. **跨模块可变状态**:`--node` 的全局覆盖 `currentNodeOverride` 原来是模块级 let。
   ESM 的 import 绑定是只读的,直接 import 后赋值编译不过 → 改为访问器
   setNodeOverride()/getNodeOverride()(并把「为什么不能用变量」写进注释)。
2. **散落在函数之间的顶层常量**:CHUNK_BYTES 恰好位于两个函数之间,按函数切分会
   误入别的模块 → 归入 types.ts 统一导出。DAEMON_SOCK 同理并去重。
3. 导入按模块职责补齐(parseArgs/getClientConfig 等)。

## 验证
- 构建 6 包全过;单测 124/124;e2e 26/26
- **真机 CLI 冒烟 25 条命令全通过**(CLI 不在 e2e 覆盖内,必须实测):
  info/status/ps/services/ls/metrics/audit/nodes/net status/screen/window.list/tasks/clip/apps/exec
  + push/stat/cat/pull(校验回读内容一致)/screenshot/key type/bg/record/mouse/discover
2026-10-01 21:04:24 +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 9ef8978b92 feat(scripts): 网络救援脚本 rescue-network.ps1(网络变更失联后的本地恢复)
## 为什么需要它
`system.net.apply` 是两阶段提交(未确认自动回滚),但**万一回滚本身也失败**
(本次真机事故就是:变更脚本把地址删了却没加上),机器会落到 APIPA(169.254.x.x) 而彻底失联。
那时远程已无路可走,只能本地恢复 —— 这个脚本就是为那一天准备的。

## 能力
- `-ListOnly`:先看清各网卡状态/地址/默认路由(含「已连接但无地址」的失联特征标注)
- 自动识别失联网卡(已连接但无有效 IPv4),也可 -Interface 指定
- 带 -Ip/-PrefixLength/-Gateway/-Dns 恢复静态;不带 -Ip 则切回 DHCP
- **安全顺序**(与 agent 内实现一致):先加新地址 → 清旧地址与残留默认路由 → 补默认路由
- 自检并给出下一步命令(含可直接复制的 Mac 侧 connect 命令)

## 用法
    powershell -ExecutionPolicy Bypass -File rescue-network.ps1 -ListOnly
    powershell -ExecutionPolicy Bypass -File rescue-network.ps1 -Interface 'Ethernet' `
        -Ip 192.168.1.100 -PrefixLength 24 -Gateway 192.168.0.1
    powershell -ExecutionPolicy Bypass -File rescue-network.ps1 -Interface 'Ethernet'   # 切回 DHCP
2026-09-30 11:10:23 +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 553b16f595 docs: 口径更新到 v15(图像模板匹配 / 助手提速)+ 三条腿定位说明 2026-09-30 01:27:55 +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 92df08eb20 test(v7~v13): 新能力补齐 e2e 覆盖(21 -> 26 用例)—— 审查报告 §5.1-3
## 背景
报告指出「v7/v8 新能力零自动化测试,只有提交说明里的真机手验」。此前 e2e 覆盖止于 v6。

## 新增 5 个用例(tests/e2e/run.mjs)
1. **v8 GUI 语义契约** —— 不断言「屏幕上一定有什么」(CI 无桌面会话时不可靠),
   而断言对外契约:window.list 结构 + 前台窗口排最前(否则会被 limit 截掉);
   window.focus 目标不存在时是干净错误;screen.find 无命中返回空数组 + engine + waited_ms;
   参数越界必须被拒
2. **v10 剪贴板与后台任务** —— clip.set/get 往返;异步 exec → task_id →
   续读输出 → 任务列表 → 终止长任务并断言 state=killed / exit_code=124
3. **v12 事件订阅** —— 文件监控:制造变动后**同时验证两条通路**
   (onEvent 推送 + event.poll 增量游标),并验证 unwatch 生效
4. **v12 GUI 宏引擎** —— 不依赖 GUI 的步骤组合(exec+clip+assert+sleep)回放;
   断言失败必须中止并给出 failed_at 定位;optional 失败不中断
5. **v13 成功指标与审计链** —— 指标字段齐全、分层时延存在、拦截率恒 1、
   无样本时装软件成功率为 null(而非 0);audit.verify 链完整

## 写用例时踩到并固化的契约(重要,已写入测试注释)
**协议层错误会抛异常,能力层失败才返回 {status:'failed'}**:
  - 能力层失败(输入未开启 / 窗口未找到)→ 返回 { status:'failed', error:{name} }
  - 协议层失败(参数校验 / 鉴权 / ACL / 限速)→ invoke() **抛 ClientError**(err.name = 错误码)
新增 expectProtocolError() 辅助函数统一断言后者,避免后续再写错。
另修正:任务终态是 `done`(不是 exited),运行中为 `running`。

单测 105/105、e2e 26/26 全绿
2026-09-30 00:56:49 +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 87d7f3fbcc docs: 口径更新到 v13(36 能力 / 32 MCP 工具 / 101 单测)+ 指标命令 2026-09-30 00:23:16 +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 0f8b878de0 docs: 路线图补 v12.4(事件直达 MCP)+ 验证脚本说明 2026-09-29 15:47:49 +08:00
jkooe 4d1aeada07 feat(v12.4): 事件直达 MCP 宿主(实时推送)+ 修两个真 bug
## 背景
v12 的事件订阅对 MCP 只走「被控端缓冲 + na_event_poll 拉取」。MCP 协议本身支持
服务端 -> 客户端通知,故补上实时推送:na_event_watch 的 notify=true 开启,
事件经 MCP 标准日志通知(notifications/message)即时送达宿主。

## 两条通路并存(互相兜底)
1) 推送:onEvent -> server.notification(notifications/message, logger=nodeagent.event)
   —— 宿主若不展示日志通知,则自动退化为拉取,无副作用
2) 拉取:na_event_poll(增量游标)
na_event_list 额外返回推送开关与已转发条数,便于判断该用哪条通路。

## 真机修掉的两个 bug
1. **notify 参数泄漏到能力侧**:notify 是 MCP 层开关,原样透传被被控端
   additionalProperties:false 拒绝(E_PARAM_INVALID)。改为在 toArgs 里显式构造,
   只带能力真正接受的字段。
2. **通知未声明 logging 能力导致 MCP 进程崩溃**:SDK 的 notification() 在未声明
   logging 能力时抛错,且因返回 Promise 未 catch -> 未处理拒绝直接终止进程
   (真机实测:watch 成功后一有事件,整个 MCP 服务就退出)。
   修法:capabilities 声明 logging + 通知调用补 catch(失败静默降级为拉取)。

## 端到端验证(scripts/verify-mcp-events.mjs,已收进仓库)
真实 MCP stdio 客户端驱动,对生产被控端实测:
- initialize ok;tools/list 31 个工具、事件类 4 个
- na_event_watch(notify=true) -> watch_id
- 制造 创建/修改/删除 -> **收到 3 条推送通知**(file created_or_removed / modified / ...)
- na_event_poll 返回缓冲事件(seq/watch_id/ts/action/target)
- na_event_list 显示「推送已开启(已转发 3 条)」
结论:推送 ✅ 生效;拉取 ✅ 生效

单测 89/89、e2e 21/21 全绿
2026-09-29 15:47:34 +08:00
jkooe 82a44bb13f docs: 路线图补 v12.1~v12.3(Hub 授权 / 等待语义 / macOS GUI 对齐) 2026-09-29 14:56:35 +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 cd774ae0a6 feat(E3c): Hub 侧按控制端授权(多令牌 + 按设备白/黑名单 + 注册准入)
## 背景
Hub 原先只有**一个**令牌:任何持有者都能接入任意设备,无法区分「谁在控制哪台机器」。
E3 的权限隔离因此只做到能力级(被控端 ACL),Hub 侧仍是全通。

## 实现(apps/hub/src/authz.ts,纯函数便于单测)
- 多令牌:每个令牌 = 一个控制端身份,可独立指定 allow_nodes / deny_nodes(glob)
- 判定顺序 deny -> allow -> 默认拒绝;未配置 allow_nodes 时默认放行(兼容)
- 顶层 token 视为主令牌(放行全部),旧配置零改动
- 前缀式滚动令牌:value 以 * 结尾时按前缀匹配(轮换期间可灰度)
- 注册准入:node_allowlist 非空时只有匹配的 node_id 能注册,防止他人借令牌挂靠节点
- 列表按身份过滤:控制端只看得到自己被授权的设备
- 越权接入返回新错误码 E_NODE_FORBIDDEN

## 职责边界(重要)
Hub 只做「能否连到某设备」的接入过滤;连上之后的**能力级**授权仍由被控端 ACL
按 client_id 裁决。两层各自独立,Hub 不参与能力决策 —— 保持 Hub 不可信假设。

## 测试
- 单测 79 -> 89(新增 Hub 授权 10 例:主令牌兼容 / 未知令牌 / 前缀轮换 /
  deny 优先 / glob 白名单 / 默认放行 / 注册准入 / 旧配置兼容 / 列表过滤 / glob 转义)
- e2e:Hub 用例新增受限令牌访问未授权设备 -> 断言 E_NODE_FORBIDDEN 且错误信息含授权原因
2026-09-29 14:38:53 +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 7efe717cf8 fix(gui): window.list 进程名错乱 + screen.find UIA 只支持精确匹配
## bug 1:所有窗口的进程名都报成 powershell
根因:C# 回调里用 $pid 接 GetWindowThreadProcessId 的输出 —— 但 $pid 是 PowerShell
**自动变量**(当前进程 ID),赋值被静默忽略,于是每个窗口都取到 powershell 的 PID。
改为 $procId。真机验证:cmd / hexin(同花顺) / SearchPictureTool / TextInputHost / explorer
均正确归属。

## bug 2:UIA 路径实际是精确匹配,与文档「子串匹配」不符
根因:用 PropertyCondition(NameProperty, text, IgnoreCase) —— IgnoreCase 只影响大小写,
仍是精确相等。实测「管理员: C:\WINDOWS\system32\cmd.exe」用 '管理员' 永远匹配不到。
改为:FindAll(Descendants, [控制类型条件]) 后按 IndexOf(OrdinalIgnoreCase) 子串过滤,
并加 20000 元素扫描上限防止超大 UI 树卡住。
真机验证:
  '管理员' -> 命中 '管理员: C:\WINDOWS\system32\cmd.exe' @ (548,312)
  '同花顺' -> 命中窗口标题 + 内部文本元素

## 顺带明确一个语义(写入文档)
is_foreground = 「持有键盘焦点」,不等于「视觉上在最上层」。
实测 cmd 持有焦点(is_foreground=true)但画面被全屏的「同花顺」覆盖 ——
此时按 UIA 返回的坐标点击会打到覆盖它的窗口上。
因此:**几何信息可信,视觉遮挡需另行确认**(点击前截屏复核)。

单测 61/61、e2e 21/21 全绿
2026-09-29 13:30:23 +08:00
92 changed files with 13407 additions and 2635 deletions

No files matched your search

+4
View File
@@ -18,6 +18,10 @@ pnpm-debug.log*
.env.*
!.env.example
# 预共享密钥:install.cmd 在包内生成,绝不入库
PSK.txt
**/PSK.txt
# OS
.DS_Store
Thumbs.db
+146 -15
View File
@@ -20,17 +20,68 @@ nodeagent 把一台 Windows 机器的能力**标准化成一组可授权、可
| 分组 | 能力 | 说明 |
|---|---|---|
| **系统** | `system.info` · `system.status` · `system.process.list` · `system.service.list` · `system.shell.exec` · `system.audit.list` · `system.audit.verify` | 信息 / 资源 / 进程 / 服务 / 命令 / 审计 / **审计防篡改校验** |
| **系统** | `system.info` · `system.status` · `system.process.list` · `system.service.list` · `system.shell.exec` · `system.audit.list` · `system.audit.verify` · `system.metrics` | 信息 / 资源 / 进程 / 服务 / 命令 / 审计 / **防篡改校验** / **成功指标(PRD 2.2)** |
| **自持** | `system.agent.restart` | **受控重启自身**(配置变更后让它生效,不会失联) |
| **异步任务** | `system.task.list` · `system.task.get` · `system.task.kill` | 长命令后台执行 + 增量续读 + 终止(`exec` 加 `async:true`) |
| **软件** | `app.list` · `app.install` | 已装软件(注册表 + winget)、winget 静默安装 |
| **屏幕** | `screen.info` · `screen.capture` · `screen.record` · `screen.find` | 显示器 / 截屏 / **录屏为帧序列** / **按名字找元素取坐标** |
| **窗口** | `window.list` · `window.focus` | 枚举可见窗口(精确矩形)/ 置前聚焦 |
| **屏幕** | `screen.info` · `screen.capture` · `screen.record` · `screen.find` | 显示器 / 截屏 / **录屏为帧序列** / **元素定位三引擎**(UIA 控件树 → OCR 文字 → **图像模板**) |
| **窗口** | `window.list` · `window.focus` | 枚举可见窗口(精确矩形)/ 置前聚焦(**Windows + macOS**) |
| **文件** | `fs.list` · `fs.stat` · `fs.read` · `fs.write` | 列目录 / 元信息 / 分块读 / 原子写 |
| **输入** | `input.mouse.move` · `input.mouse.click` · `input.mouse.scroll` · `input.mouse.drag` · `input.key.type` · `input.key.press` | 键鼠控制(🔒 **默认禁用**),含**拖拽**与**按键序列/连按** |
| **输入** | `input.mouse.move` · `input.mouse.click` · `input.mouse.scroll` · `input.mouse.drag` · `input.key.type` · `input.key.press` | 键鼠控制(🔒 **默认禁用**),含**拖拽**、**字符串热键 / 预设 / 长按 / 后台定向投递** |
| **剪贴板** | `clip.get` · `clip.set` | 读写文本**或图片**(PNG Base64) |
| **事件订阅** | `event.watch` · `event.unwatch` · `event.list` · `event.poll` | 文件变动 / 进程启停 / 端口开闭,**主动推送**(无需轮询) |
**31 项能力** · **26 个 MCP 工具** · **61 项单元测试** · **21 项端到端用例**(CI 在真实 Windows 上验证)
## 快捷键(hotkey)控制 —— v1.5
`input.key.press` 支持 **4 种输入形式**(一次只用一种):
```bash
# ① 字符串热键(最常用;分隔符支持 + - 空格,大小写不敏感)
nodeagent --node win key press "ctrl+shift+esc"
nodeagent --node win key press "win+d" # 显示桌面
nodeagent --node win key press "alt+tab" # 切换应用
# ② 热键序列
nodeagent --node win key press "ctrl+c" # 老形式也兼容:key press ctrl c
nodeagent --node win invoke input.key.press --args '{"hotkeys":["ctrl+c","ctrl+v"]}'
# ③ 预设语义名(约 50 个,免记键位)
nodeagent --node win key press copy # = ctrl+c
nodeagent --node win key press task_manager # = ctrl+shift+esc
nodeagent --node win key press volume_mute_toggle # 媒体键(走 WM_APPCOMMAND)
# ④ 老数组形式
nodeagent --node win key press ctrl c
```
**按键覆盖**:字母/数字 · F1–F24 · 左右侧修饰键(`lalt`/`rctrl`/`lwin`…)·
小键盘(`numpad7`、`numpad_add`…,**与数字键分开**——Excel 靠 NumLock 区分)·
OEM 符号键(`oem_plus`/`oem_comma`/`oem_period`… → 支持 `win+d`、`ctrl++`、`win+.`)·
媒体键 · 浏览器键 · IME 键(`convert`/`kana`…)· 系统键(`sleep`/`help`…)
**进阶参数**:
| 参数 | 说明 |
|---|---|
| `hold_ms` | 长按:按下后保持 N 毫秒再释放(≤5000) |
| `route=post` + `target_pid` | **后台定向投递**:向目标进程全部顶层窗口 PostMessage(目标窗口被遮挡/最小化时用)。⚠️ 游戏与部分输入型程序不响应后台键消息 |
| `target_pid`(默认路由) | 媒体键定向(播放器常建 30+ 辅助窗口,指定 PID 投给该进程全部顶层窗口) |
**预设速查**(完整表见 `packages/protocol/src/hotkeys.ts` 的 `HOTKEY_PRESETS`):
| 类别 | 预设名 |
|---|---|
| 编辑 | `copy` `cut` `paste` `paste_plain` `undo` `redo` `select_all` |
| 文件 | `save` `save_as` `new` `open` `close_tab` `print` |
| 窗口 | `switch_app` `task_view` `task_manager` `show_desktop` `minimize_all` `maximize` `snap_left` `snap_right` `rename` `properties` |
| 系统 | `lock_screen` `run` `explorer` `search` `settings` `clipboard_history` `emoji_picker` `screenshot` |
| 虚拟桌面 | `new_desktop` `desktop_left` `desktop_right` |
| 标签/视图 | `new_tab` `next_tab` `reopen_tab` `zoom_in` `zoom_out` `fullscreen` |
| 媒体 | `music_play_pause` `music_next` `music_prev` `volume_add` `volume_mute_toggle` |
> MCP 侧同名工具为 `na_key`(`action: "press"`),支持同一套参数。
**36 项能力** · **32 个 MCP 工具** · **114 项单元测试** · **26 项端到端用例**(CI 在真实 Windows 上验证)
> 关键里程碑:**GUI 语义**(`window.list` + `screen.find`,UIA 找不到自动降级 OCR)
> 让 AI 从「看得到画面但读不懂界面」变成「按名字取坐标点下去」。
@@ -60,16 +111,35 @@ pnpm build
产物:`release/nodeagent-win-x64.zip`(约 32MB)
2. 把 zip 拷到 Windows 并解压
3. 右键以**管理员**运行其中的 `install.ps1`
2. 把 zip 拷到 Windows 并解压到**固定目录**(如 `D:\nodeagent`,装完不要移动或改名)
3. **双击 `install.cmd`** —— 会自动弹 UAC 提权,然后一路装完并打印连接信息
安装脚本会使用**包内自带的 Node.js 运行时**,自动:生成预共享密钥 → 写配置 → 放行防火墙 → 注册开机自启 → 启动 Agent,并打印**密钥**与**连接命令**。
> 不需要记任何命令行。`install.cmd` 会自己提权、自己读包内 `PSK.txt` 的密钥(没有就生成一把),
> 再调用底层的 `install.ps1`。日后管理双击 **`control.cmd`**(启动 / 停止 / 重启 / 状态 / 日志 / 卸载)。
安装脚本会使用**包内自带的 Node.js 运行时**,自动:读/生成预共享密钥 → 写配置 → 放行防火墙 → 注册开机自启 → 启动 Agent,并打印**密钥**与**连接命令**。
<details>
<summary>需要指定端口 / 无人值守模式?展开命令行用法</summary>
```powershell
# 指定端口(默认 8765)
install.cmd 18770
# 等价的底层调用(install.cmd 就是这行 + 提权 + 读 PSK)
powershell -ExecutionPolicy Bypass -File .\install.ps1 -NodeId my-pc -Key <PSK> -AllowInput
# 无人值守:注销/重启后仍运行,但**没有 GUI**(Session 0 无桌面)
powershell -ExecutionPolicy Bypass -File .\install.ps1 -Unattended -AtStartup
```
</details>
**两种运行模式(重要)**
| 模式 | 命令 | 登录/注销 | 能力范围 |
|---|---|---|---|
| 交互(默认) | `install.ps1` | 登录时启动;**注销即停** | 全部能力,**含 GUI**(截屏 / UIA 找元素 / 键鼠注入) |
| 交互(默认) | `install.cmd` | 登录时启动;**注销即停** | 全部能力,**含 GUI**(截屏 / UIA 找元素 / 键鼠注入) |
| 无人值守 | `install.ps1 -Unattended -AtStartup` | **注销、重启后仍运行**(S4U,无需存密码) | 无 GUI —— 截屏/输入类能力不可用(进程在非交互会话,没有桌面) |
> 这是 Windows 的固有限制:**图形操作必须有交互桌面**。需要 GUI 就保持登录(可锁屏);
@@ -244,6 +314,7 @@ nodeagent push <本地文件> <远端路径> [--create-dirs] # 上传(自动
```bash
nodeagent audit [--limit 20] [--type invoke|auth|acl|agent] [--client-id X] [--since <ms>]
nodeagent audit verify # 校验审计链完整性(防篡改检测)
nodeagent metrics [--since <ms>] # 成功指标:闭环率/装软件率/P95/拦截率
nodeagent keygen [--id mac_01] # 生成 Ed25519 密钥 + ACL 配置片段(私钥入系统钥匙串)
```
@@ -265,7 +336,27 @@ nodeagent clip [--set "文本"] [--out <路径>] [--image-file <路径>] # 剪
nodeagent record [--duration 5000] [--fps 2] [--region x,y,w,h] # 录屏为帧序列
```
### GUI 语义(找到元素再点,不猜坐标)
### 事件订阅与宏
```bash
nodeagent events --kind file --path C:\logs --seconds 20 # 实时接收文件变动推送(CLI 直连推送)
nodeagent events --kind process --pattern chrome* # 进程启停
nodeagent events # 列出当前订阅
nodeagent macro init demo.json # 生成示例宏
nodeagent macro run demo.json --var STAMP=hello # 回放(逐步校验)
nodeagent macro validate demo.json # 仅校验文件
nodeagent group add 办公机 win_a,win_b # 设备分组
nodeagent fanout system.info --nodes @办公机 # 按组并发下发
```
### GUI 语义(三条腿:控件树 / 文字 / 图像)
```bash
nodeagent screen find "确定" # UIA → OCR 自动降级
nodeagent screen find "登录" --method ocr --wait-ms 5000 # 等界面加载出来
nodeagent screen find "" --method image --template C:\icons\save.png --threshold 0.9
# 纯图标控件:按图找坐标
```
```bash
nodeagent invoke window.list --args '{"title_pattern":"记事本"}'
@@ -288,6 +379,9 @@ nodeagent mouse drag 300 200 700 500 # 拖拽(拖文件/框选
| `na_fs_list` · `na_fs_read` · `na_fs_write` · `na_fs_stat` | 读目录 / 读文件 / 写文件 / 元信息 |
| `na_restart` | 受控重启被控端 |
| `na_audit` · `na_discover` · `na_nodes` · `na_use` | 查审计、发现设备、多设备切换 |
| `na_event_watch` · `na_event_poll` · `na_event_list` · `na_event_unwatch` | 事件订阅;`notify:true` 走 MCP 日志通知实时推送,否则用 `na_event_poll` 拉取 |
| `na_macro_run` | 回放 GUI 宏(步骤序列,逐步校验) |
| `na_metrics` | 成功指标(闭环率 / 装软件率 / P95 / 拦截率)+ 达标判定 |
## Hub 中转(跨网段 / 公网)
@@ -310,6 +404,30 @@ node apps/cli/dist/index.js connect hub.example.com --port 443 --hub-token <Hub
--hub-node win_01 --key <设备密钥> --name win_01
```
**并发多控制端**:Hub 为每个被控端维护**槽位池**(`max_slots_per_node`,默认 3),
被控端侧常备 2 条连接(`hub.warm_slots`)并在被占用时自动扩容 —— 因此
「AI + 人同时在线」不再互斥(旧版是独占,会返回 `E_NODE_BUSY`)。
**Hub 侧访问控制(v12)**:可配置多令牌,每个令牌独立指定能访问哪些设备:
```json
{
"token": "<主令牌,放行全部>",
"node_allowlist": ["win_*"],
"tokens": [
{ "value": "ci-token", "name": "ci", "allow_nodes": ["win_build_*"] },
{ "value": "guest-token", "name": "guest", "allow_nodes": ["*"], "deny_nodes": ["win_prod"] },
{ "value": "roll-*", "name": "rolling", "allow_nodes": ["win_a"] }
]
}
```
- 判定顺序 **deny → allow → 默认拒绝**;`node_allowlist` 非空时只有匹配的 node_id 能注册
- `value` 以 `*` 结尾表示**前缀匹配**(便于令牌轮换期间灰度)
- 设备列表按身份过滤(控制端只看得到自己被授权的设备),越权接入返回 `E_NODE_FORBIDDEN`
- **职责边界**:Hub 只做「能否连到某设备」的接入过滤;连上之后的**能力级**授权
仍由被控端 ACL 按 client_id 裁决 —— 两层独立,Hub 不参与能力决策
**安全边界**:Hub **只做字节透传**,不解析内容、不持有设备密钥 —— 控制端与被控端之间仍执行端到端握手,因此 **Hub 无法窃听也无法伪造**(Hub 被攻陷也不等于设备被接管)。生产环境建议在 Hub 前挂 Caddy / Nginx 终止 TLS。
## 开发
@@ -317,10 +435,11 @@ node apps/cli/dist/index.js connect hub.example.com --port 443 --hub-token <Hub
```bash
pnpm build # 构建全部包
pnpm typecheck # 类型检查
pnpm test:unit # 单元测试(61 项:协议纯函数 / 清单守护 / 审计链 / ACL v11)
pnpm test:unit # 单元测试(114 项:协议纯函数 / 清单守护 / 审计链 / ACL / 宏引擎 / 分组 / Hub 授权 / 指标 / 助手与预加载段)
pnpm test:e2e # 端到端测试(21 项,含 TLS / 零信任 / 发现 / 文件 / Hub)
pnpm test:windows # Windows 专属能力(服务 / 软件 / winget 真实装软件)
pnpm verify # 对已配置的被控端跑全套验收并输出报告
node scripts/verify-mcp-events.mjs # 验证 MCP 事件双通路(推送 + 拉取)
```
### 三层自动化真机验证
@@ -329,7 +448,7 @@ pnpm verify # 对已配置的被控端跑全套验收并输出报告
| 层次 | 方式 | 覆盖 |
|---|---|---|
| **CI(推荐)** | GitHub Actions `windows-latest` | 真实 Windows 上跑协议 E2E、专属能力(`Get-Service` / 注册表 / winget 装软件)、`install.ps1` 链路、CLI 连入、MCP 工具列表 |
| **CI(推荐)** | GitHub Actions `windows-latest` | 真实 Windows 上跑协议 E2E、专属能力(`Get-Service` / 注册表 / winget 装软件)、`install.ps1` 链路、`install.cmd`/`control.cmd` 双击链路、**Windows PowerShell 5.1 解析守卫**、CLI 连入、MCP 工具列表 |
| **远程验收** | `node scripts/verify.mjs --host <IP> --key <密钥> [--insecure]` | 逐条验收并输出终端报告(`--report report.md`) |
| **本地测试** | `pnpm test:e2e` / `pnpm test:windows` | 单机自举:本机起 Agent 当被控端自测 |
@@ -355,7 +474,7 @@ nodeagent/
│ ├── hub/ # 中转节点:设备注册、控制端配对、字节透传(跨网段/公网)
│ ├── cli/ # macOS 控制端 CLI
│ └── mcp/ # MCP server(接入 WorkBuddy)
├── scripts/ # install.ps1 / uninstall.ps1 / verify.mjs
├── scripts/ # install.cmd / control.cmd(一键入口)/ install.ps1 / control.ps1 / verify.mjs
├── tests/e2e/ # 端到端测试
├── docs/ # 开发文档
├── PRD.md # 产品需求文档
@@ -379,10 +498,22 @@ nodeagent/
| **v10** | **长任务异步化**(`system.task.*`)+ 剪贴板 + CLI 常驻连接池 daemon | ✅ |
| **v11** | **安全加固**:私钥入系统密钥库(Keychain/DPAPI)+ 审计链防篡改 + ACL 细化(IP/时段/按能力限速) | ✅ |
| **v11+** | **操作面补完**:鼠标拖拽 / 录屏 / 剪贴板图片 / 多设备并发 / 一键升级 | ✅ |
| **待办** | 事件订阅(push)、v7~v11 能力入 CI、macOS 端 GUI 能力对齐 | 🚧 |
| **v12** | **事件订阅**(file/process/net 主动推送)+ **GUI 宏**(步骤序列回放)+ **Hub 并发多控制端** + 设备分组 | ✅ |
| **v12.1** | **Hub 侧按控制端授权**(多令牌 + 按设备白/黑名单 + 注册准入) | ✅ |
| **v12.2** | GUI **等待/重试语义**(`wait_ms`)—— 界面加载/动画不再假失败 | ✅ |
| **v12.3** | **macOS 端 GUI 对齐**:AppleScript 窗口枚举/聚焦 + Vision OCR 元素定位(含 Retina 坐标换算) | ✅ |
| **v12.4** | **事件直达 MCP 宿主**(实时推送 + 拉取双通路) | ✅ |
| **v13** | **成功指标聚合**(PRD 2.2 四项)+ 依真实数据修正口径(时延分层) | ✅ |
| **v14** | **PowerShell 常驻助手** —— GUI 能力提速 4~25 倍(预加载 C#/UIA,免每次现编译) | ✅ |
| **v15** | `screen.find` **图像模板匹配**(纯图标/无文字控件盲区)+ 一堆真机根因修复 | ✅ |
| **待办** | CI 单元测试步骤已就绪(分支 `ci/workflow-unit-tests`,**需 `gh auth refresh -s workflow` 授权后推送**);GUI 图像锚点匹配(自绘 UI 更稳) | 🚧 |
## 文档
- [`docs/SECURITY.md`](docs/SECURITY.md) —— **安全模型与加固指南**(钥匙=root、证书指纹钉住、网段白名单、应急处置)
- [`docs/VERSIONING.md`](docs/VERSIONING.md) —— **版本与兼容性契约**(升级顺序、什么算破坏性、发版流程、更新方式)
- [`docs/REMOTE-LIMITS.md`](docs/REMOTE-LIMITS.md) —— 远程能力边界与真实故障案例(含杀软误拦)
- [产品需求文档(PRD)](./PRD.md)
- [开发文档(DEVELOPMENT)](./docs/DEVELOPMENT.md) —— 协议细节、能力 schema、安全模型、各端实现指南
- [**跨机接管的边界与局限**](./docs/REMOTE-LIMITS.md) —— 哪些问题能远程修、哪些必须人工,附真实故障诊断案例
@@ -396,5 +527,5 @@ nodeagent/
| 安全演进 | 先简化(预共享密钥 + HMAC)→ 后强化(Ed25519 + 能力级 ACL + 审计) |
| AI 接入 | 核心能力 → CLI(调试底座)→ MCP(AI 落点) |
| 装软件 | 优先 winget 静默安装 |
| Windows 部署 | 一键 `install.ps1` + 开机自启 |
| Windows 部署 | 一键 `install.cmd`(双击即装,自提权)+ 开机自启;`control.cmd` 管理 |
| 依赖策略 | 被控端零原生编译;能力全部基于系统原生 API 实现 |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/agent",
"version": "0.1.0",
"version": "1.5.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
+34 -1
View File
@@ -11,7 +11,13 @@ export type AuditType =
| 'invoke'
| 'acl.denied'
| 'capability.disabled'
| 'rate.limited';
| 'rate.limited'
/** v16:网络配置变更(两阶段提交) */
| 'net.change'
/** v21:来源不在 allow_from 被拒 */
| 'net.deny'
/** v19:agent 自更新(拉取式) */
| 'agent.update';
/** 一条审计记录(JSONL 单行)。 */
export interface AuditEntry {
@@ -271,6 +277,33 @@ function readEntriesFromFile(file: string): AuditEntry[] {
* 读取审计记录(取最新 limit 条,按时间升序返回)。
* 会同时读取轮转文件(audit.log.1/.2/…),避免历史记录被遗漏。
*/
/**
* 读取全部审计条目(跨轮转文件,按时间升序)。
* 供指标聚合等「需要全窗口样本」的场景使用 —— readAudit 有 limit 上限(最多 1000 条)。
* 安全上限 20 万条,防止极端情况下把内存吃爆。
*/
export function readAuditAll(since?: number): AuditEntry[] {
const p = auditFilePath();
const files: string[] = [];
for (let i = 20; i >= 1; i -= 1) {
const f = `${p}.${i}`;
if (existsSync(f)) files.push(f);
}
files.push(p);
const out: AuditEntry[] = [];
for (const f of files) {
for (const e of readEntriesFromFile(f)) {
if (since !== undefined && e.ts <= since) continue;
out.push(e);
if (out.length >= 200_000) break;
}
if (out.length >= 200_000) break;
}
out.sort((a, b) => a.ts - b.ts);
return out;
}
export function readAudit(query: AuditQuery = {}): { entries: AuditEntry[]; total: number; file: string } {
const p = auditFilePath();
if (!existsSync(p)) return { entries: [], total: 0, file: p };
+204
View File
@@ -0,0 +1,204 @@
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { IS_WINDOWS, execCommand } from '../util/exec.js';
import { runPowerShellSmart } from '../util/ps-helper.js';
type Args = Record<string, unknown>;
/**
* 音频控制(v17):静音开关与主音量。
*
* ## 为什么需要
* 远程接管一台机器时,「把它静音」是高频且刚需的小事(夜里吵、放视频要音量、
* 排障时确认音频设备)。此前只能靠 exec 拼一段脚本,容易踩本项目已记录的
* 各种坑(C# 转义 / 编码 / 引号),故做成正式能力。
*
* ## 平台实现
* - Windows:**Core Audio COM**(IMMDeviceEnumerator → IAudioEndpointVolume)
* —— 可**读回真实状态**,因此是确定性的「设为静音」而非「切换」
* (切换在状态未知时可能反而打开声音)。不用 Add-Type -AssemblyName 那套,
* 是因为音量不在 .NET BCL 里,只能用 COM 互操作。
* - macOS:`osascript` 的 volume 设置(原生,无需互操作)。
*
* ## C# 互操作的两个硬约束(本项目踩过的坑,勿改)
* 1. **槽位顺序即 vtable 顺序**:接口方法必须按 COM 定义顺序声明,
* 不调用的方法也要占位(占位方法签名无所谓,但不能漏)。
* 顺序错了不会编译报错,而是调用到错误的内存地址 → 进程崩溃。
* 2. **C# 源码必须纯 ASCII**:Add-Type 按系统 ANSI(GBK) 落盘,非 ASCII 会乱码。
*/
// 不调用的方法只占槽位,签名随意;**顺序不可调整**(见文件头注释)
const AUDIO_PRELUDE = `
Add-Type @"
using System;
using System.Runtime.InteropServices;
public class NAAudio {
[ComImport, Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")]
private class MMDeviceEnumerator { }
[Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IMMDeviceEnumerator {
int m1();
int GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice ppDevice);
}
[Guid("D666063F-1587-4E43-81F1-B948E807363F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IMMDevice {
int Activate(ref Guid iid, int dwClsCtx, IntPtr pActivationParams, [MarshalAs(UnmanagedType.IUnknown)] out object ppInterface);
}
[Guid("5CDF2C82-841E-4546-9722-0CF74078229A"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IAudioEndpointVolume {
int m1(); int m2(); int m3(); int m4();
int SetMasterVolumeLevelScalar(float fLevel, IntPtr pguidEventContext);
int m6();
int GetMasterVolumeLevelScalar(out float pfLevel);
int m8(); int m9(); int m10(); int m11();
int SetMute([MarshalAs(UnmanagedType.Bool)] bool bMute, IntPtr pguidEventContext);
int GetMute([MarshalAs(UnmanagedType.Bool)] out bool pbMute);
}
private const char SEP = (char)124;
private static IAudioEndpointVolume Default() {
IMMDeviceEnumerator en = (IMMDeviceEnumerator)(new MMDeviceEnumerator());
IMMDevice dev;
Marshal.ThrowExceptionForHR(en.GetDefaultAudioEndpoint(0, 0, out dev));
Guid iid = typeof(IAudioEndpointVolume).GUID;
object o;
Marshal.ThrowExceptionForHR(dev.Activate(ref iid, 23, IntPtr.Zero, out o));
return (IAudioEndpointVolume)o;
}
private static string Snapshot(IAudioEndpointVolume v) {
bool muted;
Marshal.ThrowExceptionForHR(v.GetMute(out muted));
float lvl;
Marshal.ThrowExceptionForHR(v.GetMasterVolumeLevelScalar(out lvl));
return (muted ? "1" : "0") + SEP + ((int)Math.Round(lvl * 100));
}
public static string Get() {
return Snapshot(Default());
}
public static string Mute(string flag) {
IAudioEndpointVolume v = Default();
bool want = flag == "1";
Marshal.ThrowExceptionForHR(v.SetMute(want, IntPtr.Zero));
return Snapshot(v);
}
public static string Volume(int percent) {
if (percent < 0) percent = 0;
if (percent > 100) percent = 100;
IAudioEndpointVolume v = Default();
Marshal.ThrowExceptionForHR(v.SetMasterVolumeLevelScalar(percent / 100f, IntPtr.Zero));
return Snapshot(v);
}
public static string Apply(string flag, int percent) {
IAudioEndpointVolume v = Default();
if (percent >= 0) Marshal.ThrowExceptionForHR(v.SetMasterVolumeLevelScalar(percent / 100f, IntPtr.Zero));
if (flag == "0" || flag == "1") Marshal.ThrowExceptionForHR(v.SetMute(flag == "1", IntPtr.Zero));
return Snapshot(v);
}
}
"@
`;
/** 解析 C# 回传的 `静音|音量` 紧凑串。 */
export function parseAudioState(raw: string): { muted: boolean; volume: number } {
const line = raw.trim().split('\n').pop() ?? '';
const m = line.match(/([01])\s*\|\s*(\d{1,3})/);
if (!m) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '音频状态解析失败', { detail: raw.slice(0, 200) });
}
return { muted: m[1] === '1', volume: Number(m[2]) };
}
async function runWindows(body: string, timeoutMs = 30_000): Promise<string> {
const { stdout } = await runPowerShellSmart({
body,
prelude: AUDIO_PRELUDE,
timeoutMs,
label: 'audio',
log: (level, msg) => {
if (level === 'warn') console.error(`[audio] ${msg}`);
},
});
return stdout;
}
/** macOS:osascript 原生音量控制(静音用 `output muted`,音量用 `output volume`)。 */
async function runMac(script: string): Promise<string> {
const r = await execCommand({ command: `osascript -e ${JSON.stringify(script)}`, timeoutMs: 15_000 });
if (r.exit_code !== 0) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '音频控制失败(osascript)', {
detail: (r.stderr || r.stdout).slice(0, 300),
});
}
return r.stdout.trim();
}
async function macState(): Promise<{ muted: boolean; volume: number }> {
const out = await runMac('get volume settings');
// 形如: output volume:42, input volume:100, alert volume:100, output muted:false
const vol = /output volume:(\d+)/.exec(out);
const muted = /output muted:(true|false)/.exec(out);
return { muted: muted?.[1] === 'true', volume: vol ? Number(vol[1]) : 0 };
}
// ---------------- system.audio.get ----------------
/**
* Windows 音频后端标识。
*
* 修正:此前误写为 'CoreAudio'(那是 macOS 的后端名),导致 Windows 被控端也报
* 「CoreAudio」,排查时极易误导。真机 2026-10-04 发现。实现走的是 C# NAudio(CoreAudioAPI)。
*/
const AUDIO_BACKEND_WIN = 'NAudio/CoreAudioAPI';
export async function audioGet(_args: Args): Promise<unknown> {
if (IS_WINDOWS) {
const state = parseAudioState(await runWindows('[NAAudio]::Get()'));
return { ...state, platform: 'windows', backend: AUDIO_BACKEND_WIN };
}
if (process.platform === 'darwin') {
return { ...(await macState()), platform: 'macos', backend: 'osascript' };
}
throw new CapabilityError(ErrorCodes.UNSUPPORTED_PLATFORM, '音频控制仅支持 Windows / macOS 被控端', {
platform: process.platform,
});
}
// ---------------- system.audio.set ----------------
export async function audioSet(args: Args): Promise<unknown> {
const muteArg = args['mute'];
const volumeArg = args['volume'];
const hasMute = typeof muteArg === 'boolean';
const hasVolume = typeof volumeArg === 'number';
if (!hasMute && !hasVolume) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, '至少提供 mute(布尔)或 volume(0-100)之一');
}
const volume = hasVolume ? Math.max(0, Math.min(100, Math.round(volumeArg as number))) : -1;
if (IS_WINDOWS) {
const flag = hasMute ? ((muteArg as boolean) ? '1' : '0') : '';
const body = `[NAAudio]::Apply('${flag}', ${volume})`;
const state = parseAudioState(await runWindows(body));
return { ...state, platform: 'windows', backend: AUDIO_BACKEND_WIN, applied: { mute: hasMute ? muteArg : null, volume: hasVolume ? volume : null } };
}
if (process.platform === 'darwin') {
if (hasMute) await runMac(`set volume output muted ${muteArg === true ? 'true' : 'false'}`);
if (hasVolume) await runMac(`set volume output volume ${volume}`);
const state = await macState();
return { ...state, platform: 'macos', backend: 'osascript', applied: { mute: hasMute ? muteArg : null, volume: hasVolume ? volume : null } };
}
throw new CapabilityError(ErrorCodes.UNSUPPORTED_PLATFORM, '音频控制仅支持 Windows / macOS 被控端', {
platform: process.platform,
});
}
+50 -9
View File
@@ -6,6 +6,9 @@ import { IS_WINDOWS, execCommand } from '../util/exec.js';
type Args = Record<string, unknown>;
/** Set-Clipboard 遇剪贴板被瞬时占用时的重试次数(实测约 20% 概率偶发)。 */
const CLIP_SET_RETRIES = 3;
/**
* 图片类剪贴板操作必须走**临时文件**:
* 命令行参数在 Windows 上有 ~32KB 上限,而截图 base64 常达数 MB;
@@ -27,6 +30,52 @@ function psPath(p: string): string {
return p.replace(/\\/g, '\\\\');
}
/**
* PowerShell 写 Windows 剪贴板,**带重试**。
*
* 背景(真机 2026-10-04 实测):`Set-Clipboard` 偶发抛「所请求的剪贴板操作失败」
* —— 剪贴板是全系统共享资源,被瞬时占用(其他程序刚写/读、其他 RDP 会话切换)即会失败。
* 更坑的是**该错误只写 stderr 而 exit_code 仍为 0**,故 `exit_code !== 0` 判据抓不到它。
*
* 策略:重试若干次;仍失败则以**读回校验**为准 —— 剪贴板可能已被前一次成功写入,
* 读到目标内容即视为成功(避免「成功却报错」的假失败)。
*
* @param b64 UTF-8 文本的 base64(避免命令行 ANSI 编码问题)
* @param expect 期望读回的内容(用于校验)
*/
async function writeClipboardWindows(b64: string, expect: string): Promise<void> {
const setPs = `Set-Clipboard -Value ([System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${b64}')))`;
const getPs = 'Get-Clipboard -Raw -ErrorAction SilentlyContinue';
let lastErr = '';
for (let attempt = 1; attempt <= CLIP_SET_RETRIES; attempt++) {
const r = await execCommand({ command: setPs, timeoutMs: 10_000 });
// exit_code 非 0 是硬失败;124 是被超时强杀
if (r.exit_code !== 0 && r.exit_code !== 124) {
lastErr = r.stderr.slice(0, 300) || `exit_code=${r.exit_code}`;
} else if (r.stderr) {
// exit_code 为 0 但 stderr 有内容 = 上面说的「软失败」,记录后重试
lastErr = r.stderr.slice(0, 300);
} else {
return; // 干净成功
}
if (attempt < CLIP_SET_RETRIES) {
// 退避后再试:给占用方释放剪贴板的时间
await new Promise((res) => setTimeout(res, 150 * attempt));
continue;
}
// 最后一次尝试后:读回校验。也许前一次其实写成功了(只是报了软失败)
const back = await execCommand({ command: getPs, timeoutMs: 10_000 });
if (back.stdout.trim() === expect.trim()) return;
}
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '写入剪贴板失败', {
detail: `${lastErr}(已重试 ${CLIP_SET_RETRIES} 次,并经读回校验仍不匹配)`,
});
}
// ---------------- clip.get ----------------
export async function clipGet(args: Args = {}): Promise<unknown> {
@@ -100,15 +149,7 @@ export async function clipSet(args: Args): Promise<unknown> {
if (IS_WINDOWS) {
// 文本经 base64 传输,避免命令行参数编码问题(PS 对非 ASCII 参数不可靠)
const b64 = Buffer.from(text, 'utf8').toString('base64');
const r = await execCommand({
command: `Set-Clipboard -Value ([System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${b64}')))`,
timeoutMs: 10_000,
});
if (r.exit_code !== 0) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '写入剪贴板失败', {
detail: r.stderr.slice(0, 300),
});
}
await writeClipboardWindows(b64, text);
} else {
const b64 = Buffer.from(text, 'utf8').toString('base64');
const r = await execCommand({
+427
View File
@@ -0,0 +1,427 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { IS_WINDOWS, execCommand } from '../util/exec.js';
import { agentDir } from '../config.js';
type Args = Record<string, unknown>;
/**
* macOS 端 GUI 语义(v12.3 / 跨平台对齐)。
*
* 与 Windows 的对照:
* | 能力 | Windows | macOS |
* |-----------------|--------------------------|-----------------------------------------|
* | window.list | Win32 EnumWindows | AppleScript System Events(需辅助功能权限)|
* | window.focus | SetForegroundWindow+兜底 | AppleScript frontmost + AXRaise |
* | screen.find | UIA → OCR(WinRT) | 无 UIA 等价物 → 截图 + **Vision OCR** |
*
* 权限说明(macOS 的硬门槛,无法绕过):
* - 枚举/聚焦窗口:系统设置 → 隐私与安全性 → **辅助功能**里勾选运行 agent 的程序
* - 截屏做 OCR:**屏幕录制**权限
* 未授权时给出明确指引,而不是笼统失败。
*/
const OSA = 'osascript';
function assertDarwin(capability: string): void {
if (IS_WINDOWS) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `${capability} 的 macOS 实现不应在 Windows 上调用`);
}
if (process.platform !== 'darwin') {
throw new CapabilityError(ErrorCodes.UNSUPPORTED_PLATFORM, `${capability} 暂不支持 ${process.platform}`, {
platform: process.platform,
});
}
}
/** 跑 AppleScript:脚本经 stdin 传入,避免引号转义问题。 */
async function runOsa(script: string, timeoutMs = 30_000): Promise<{ ok: boolean; stdout: string; stderr: string }> {
const b64 = Buffer.from(script, 'utf8').toString('base64');
const r = await execCommand({
// -e 接受多行;用 base64 传入再还原,彻底规避引号/换行问题
command: `printf %s '${b64}' | base64 -d | ${OSA} -l AppleScript -`,
timeoutMs,
});
return { ok: r.exit_code === 0, stdout: r.stdout.trim(), stderr: (r.stderr || '').trim() };
}
/**
* 把 macOS 的权限类错误翻译成可执行指引。
* 实测错误码/文案(真机):
* -10004「发生权限违例 / privilege violation」→ 辅助功能(Accessibility)未授权
* -1743「Not authorized to send Apple events」→ 自动化授权
* -25211 / 「not allowed assistive access」→ 辅助功能
* 未授权时 screencapture 通常仍成功但内容为纯黑/仅桌面 —— 由 OCR 结果为空体现。
*/
function permissionHint(raw: string): string {
if (/10004|权限违例|privilege violation|not allowed assistive|不允许辅助访问|-25211|-1719/i.test(raw)) {
return '(请到「系统设置 → 隐私与安全性 → 辅助功能」勾选运行 agent 的程序,然后重启它)';
}
if (/not authorized to send Apple events|-1743/i.test(raw)) {
return '(请到「系统设置 → 隐私与安全性 → 自动化」允许控制 System Events)';
}
return '';
}
// ---------------- window.list ----------------
const LIST_SCRIPT = `
set out to ""
tell application "System Events"
set fgProc to name of first application process whose frontmost is true
repeat with p in (every application process whose visible is true)
try
set pname to name of p
set ppid to unix id of p
repeat with w in (every window of p)
try
set wname to name of w
set wpos to position of w
set wsz to size of w
set isFg to (pname is fgProc)
set out to out & pname & "\\t" & ppid & "\\t" & wname & "\\t" & (item 1 of wpos) & "\\t" & (item 2 of wpos) & "\\t" & (item 1 of wsz) & "\\t" & (item 2 of wsz) & "\\t" & isFg & "\\n"
end try
end repeat
end try
end repeat
end tell
return out
`;
interface MacWindow {
title: string;
process: string;
pid: number;
x: number;
y: number;
width: number;
height: number;
is_foreground: boolean;
is_minimized: boolean;
}
export async function macWindowList(args: Args): Promise<unknown> {
assertDarwin('window.list');
const r = await runOsa(LIST_SCRIPT, 40_000);
if (!r.ok) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, `枚举窗口失败${permissionHint(r.stderr)}`, {
detail: r.stderr.slice(0, 300),
});
}
const all: MacWindow[] = [];
for (const line of r.stdout.split('\n')) {
const f = line.split('\t');
if (f.length < 8) continue;
const [process, pidRaw, title, x, y, w, h, fg] = f as [
string, string, string, string, string, string, string, string,
];
const width = Number(w);
const height = Number(h);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 40 || height <= 40) continue;
all.push({
title: title || process,
process,
pid: Number(pidRaw) || 0,
x: Number(x),
y: Number(y),
width,
height,
is_foreground: fg.trim().toLowerCase() === 'true',
is_minimized: false, // AppleScript 取「every window」通常已排除最小化窗口
});
}
const pattern = args['title_pattern'] as string | undefined;
let filtered = all;
if (pattern) {
const re = new RegExp(pattern, 'i');
filtered = all.filter((w) => re.test(w.title) || re.test(w.process));
}
// 与 Windows 侧保持一致:前台窗口置顶,避免被 limit 截掉
filtered.sort((a, b) => Number(b.is_foreground) - Number(a.is_foreground));
const limit = (args['limit'] as number | undefined) ?? 50;
return { windows: filtered.slice(0, limit), total: filtered.length };
}
// ---------------- window.focus ----------------
export async function macWindowFocus(args: Args): Promise<unknown> {
assertDarwin('window.focus');
const title = args['title'] as string | undefined;
if (!title) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'macOS 端目前需提供 title(正则);暂不支持句柄');
}
const waitMs = Math.max(0, Math.min(30_000, (args['wait_ms'] as number | undefined) ?? 0));
const startedAt = Date.now();
const script = `
set re to "${title.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"
set targetProc to ""
set targetTitle to ""
tell application "System Events"
repeat with p in (every application process whose visible is true)
try
set pname to name of p
repeat with w in (every window of p)
try
set wname to name of w
if wname contains re or pname contains re then
set targetProc to pname
set targetTitle to wname
exit repeat
end if
end try
end repeat
if targetProc is not "" then exit repeat
end try
end repeat
if targetProc is "" then return "NOTFOUND"
set frontmost of (first application process whose name is targetProc) to true
try
perform action "AXRaise" of (first window of (first application process whose name is targetProc))
end try
delay 0.3
end tell
set out to ""
tell application "System Events"
set fg to name of first application process whose frontmost is true
set p to first application process whose name is targetProc
set wpos to position of first window of p
set wsz to size of first window of p
set out to targetTitle & "\\t" & (item 1 of wpos) & "\\t" & (item 2 of wpos) & "\\t" & (item 1 of wsz) & "\\t" & (item 2 of wsz) & "\\t" & (fg is targetProc)
end tell
return out
`;
let lastErr = '';
for (;;) {
const r = await runOsa(script, 40_000);
if (r.ok && r.stdout && r.stdout !== 'NOTFOUND') {
const f = r.stdout.split('\t');
if (f.length >= 6) {
return {
title: f[0],
x: Number(f[1]),
y: Number(f[2]),
width: Number(f[3]),
height: Number(f[4]),
focused: (f[5] ?? '').trim().toLowerCase() === 'true',
activated_by: 'applescript',
waited_ms: Date.now() - startedAt,
};
}
}
lastErr = r.ok ? '未找到匹配的窗口' : r.stderr;
if (Date.now() - startedAt >= waitMs) break;
await new Promise((res) => setTimeout(res, 500));
}
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, `聚焦失败:${lastErr}${permissionHint(lastErr)}`, {
detail: lastErr.slice(0, 300),
});
}
// ---------------- screen.find(截图 + Vision OCR) ----------------
/** Vision OCR 助手:编译一次后缓存到数据目录,后续秒级复用。 */
const OCR_SWIFT = `
import Foundation
import Vision
import AppKit
// 用法: naocr <image.png> [searchText]
// 输出: JSON 数组 [{text,x,y,w,h,confidence}](像素坐标,原点左上)
let args = CommandLine.arguments
guard args.count >= 2 else { FileHandle.standardError.write("usage: naocr <image> [text]\\n".data(using: .utf8)!); exit(2) }
let imagePath = args[1]
let search = args.count >= 3 ? args[2] : ""
guard let img = NSImage(contentsOfFile: imagePath),
let tiff = img.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiff),
let cg = bitmap.cgImage else {
FileHandle.standardError.write("cannot load image\\n".data(using: .utf8)!); exit(3)
}
let width = Double(cg.width), height = Double(cg.height)
let request = VNRecognizeTextRequest()
request.recognitionLevel = .accurate
request.usesLanguageCorrection = false
request.recognitionLanguages = ["zh-Hans", "en-US"]
let handler = VNImageRequestHandler(cgImage: cg, options: [:])
do { try handler.perform([request]) } catch {
FileHandle.standardError.write("ocr failed: \\(error)\\n".data(using: .utf8)!); exit(4)
}
let norm: (String) -> String = { $0.replacingOccurrences(of: " ", with: "") }
let want = norm(search)
var out: [[String: Any]] = []
for obs in (request.results ?? []) {
guard let top = obs.topCandidates(1).first else { continue }
let text = top.string
if !want.isEmpty && !norm(text).lowercased().contains(want.lowercased()) { continue }
let bb = obs.boundingBox
let x = bb.minX * width
let y = (1.0 - bb.maxY) * height
let w = bb.width * width
let h = bb.height * height
out.append(["text": text, "x": x, "y": y, "w": w, "h": h, "confidence": Double(top.confidence)])
}
let data = try JSONSerialization.data(withJSONObject: out, options: [])
FileHandle.standardOutput.write(data)
`;
/**
* 读取 PNG 的像素尺寸(解析 IHDR 头,不启动额外进程)。
* 用途:Retina 屏上 `screencapture` 产出的是 **2x 像素**图,而窗口坐标是**逻辑点**,
* 若不做换算,返回的坐标会让点击偏移一倍(真机踩到)。
*/
function pngSize(file: string): { width: number; height: number } | null {
try {
const buf = readFileSync(file);
// IHDR 数据从第 16 字节开始:width(4) height(4)
if (buf.length < 24 || buf.readUInt32BE(12) !== 0x49484452) return null;
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
} catch {
return null;
}
}
function ocrBinPath(): string {
return join(agentDir(), 'bin', 'naocr');
}
/** 确保 OCR 助手已编译(源码变化或二进制缺失时重编)。 */
async function ensureOcrBin(): Promise<string> {
const dir = join(agentDir(), 'bin');
mkdirSync(dir, { recursive: true });
const src = join(dir, 'naocr.swift');
const bin = ocrBinPath();
const needSrc = !existsSync(src) || readFileSync(src, 'utf8') !== OCR_SWIFT;
if (needSrc) writeFileSync(src, OCR_SWIFT);
const needBuild = needSrc || !existsSync(bin);
if (needBuild) {
const r = await execCommand({
command: `swiftc -O -o '${bin}' '${src}'`,
timeoutMs: 180_000,
});
if (r.exit_code !== 0) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '编译 OCR 助手失败(需要 Xcode 命令行工具)', {
detail: (r.stderr || r.stdout).slice(0, 300),
});
}
}
return bin;
}
export async function macScreenFind(args: Args): Promise<unknown> {
assertDarwin('screen.find');
const text = args['text'] as string;
if (!text) throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'text 不能为空');
const limit = (args['limit'] as number | undefined) ?? 20;
const waitMs = Math.max(0, Math.min(30_000, (args['wait_ms'] as number | undefined) ?? 0));
const intervalMs = Math.max(100, Math.min(2000, (args['interval_ms'] as number | undefined) ?? 400));
const region = args['region'] as { x: number; y: number; width: number; height: number } | undefined;
const bin = await ensureOcrBin();
const { mkdtempSync, rmSync } = await import('node:fs');
const { tmpdir } = await import('node:os');
const dir = mkdtempSync(join(tmpdir(), 'na-ocr-'));
const shot = join(dir, 'shot.png');
const captureRegion = region ?? (await fullVirtualScreen());
const startedAt = Date.now();
let matches: Array<Record<string, unknown>> = [];
try {
for (;;) {
const cap = await execCommand({
command: `screencapture -x -t png -R ${captureRegion.x},${captureRegion.y},${captureRegion.width},${captureRegion.height} ${JSON.stringify(shot)}`,
timeoutMs: 30_000,
});
if (cap.exit_code !== 0) {
throw new CapabilityError(
ErrorCodes.EXECUTION_FAILED,
'截屏失败(需「屏幕录制」权限)',
{ detail: (cap.stderr || '').slice(0, 200) },
);
}
// Retina 换算:图片像素 / 请求区域点数 = 缩放比(通常 2)
const img = pngSize(shot);
const scale =
img && captureRegion.width > 0 ? img.width / captureRegion.width : 1;
const r = await execCommand({
command: `${JSON.stringify(bin)} ${JSON.stringify(shot)} ${JSON.stringify(text)}`,
timeoutMs: 60_000,
maxOutputBytes: 4 * 1024 * 1024,
});
if (r.exit_code !== 0) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, 'OCR 失败', {
detail: (r.stderr || r.stdout).slice(0, 300),
});
}
const start = r.stdout.indexOf('[');
const end = r.stdout.lastIndexOf(']');
const raw =
start >= 0 && end > start
? (JSON.parse(r.stdout.slice(start, end + 1)) as Array<{
text: string;
x: number;
y: number;
w: number;
h: number;
confidence: number;
}>)
: [];
matches = raw.slice(0, limit).map((m) => {
// 先由像素换算回逻辑点,再叠加区域偏移 -> 得到可直接用于点击的屏幕坐标
const px = m.x / scale;
const py = m.y / scale;
const pw = m.w / scale;
const ph = m.h / scale;
return {
name: m.text,
control_type: 'Text(ocr)',
x: Math.round(px + pw / 2 + captureRegion.x),
y: Math.round(py + ph / 2 + captureRegion.y),
left: Math.round(px + captureRegion.x),
top: Math.round(py + captureRegion.y),
width: Math.round(pw),
height: Math.round(ph),
};
});
if (matches.length > 0 || Date.now() - startedAt >= waitMs) break;
await new Promise((res) => setTimeout(res, intervalMs));
}
} finally {
try {
rmSync(dir, { recursive: true, force: true });
} catch {
/* 忽略清理失败 */
}
}
return {
matches,
engine: 'ocr',
waited_ms: Date.now() - startedAt,
note: 'macOS 无 UIA 等价物,统一走 Vision OCR(只识别可见内容);坐标已由 Retina 像素换算为逻辑点',
};
}
/** 取整块虚拟屏的尺寸(多屏取并集)。 */
async function fullVirtualScreen(): Promise<{ x: number; y: number; width: number; height: number }> {
const r = await runOsa(
`tell application "Finder" to get bounds of window of desktop`,
10_000,
);
if (r.ok) {
const m = /(-?\d+),\s*(-?\d+),\s*(-?\d+),\s*(-?\d+)/.exec(r.stdout);
if (m) {
const [x1, y1, x2, y2] = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 };
}
}
return { x: 0, y: 0, width: 1920, height: 1080 };
}
+33 -2
View File
@@ -1,16 +1,30 @@
import { CapabilityNames } from '@nodeagent/protocol';
import type { AgentConfig } from '../config.js';
import { systemInfo, systemStatus, processList, serviceList, shellExec, auditList, auditVerify, agentRestart } from './system.js';
import { systemInfo, systemStatus, processList, serviceList, shellExec, auditList, auditVerify, metricsReport, agentRestart } from './system.js';
import { appList, appInstall } from './app.js';
import { screenInfo, screenCapture } from './screen.js';
import { windowList, windowFocus, screenFind } from './window.js';
import { screenRecord } from './record.js';
import { taskList, taskGet, taskKill } from './task.js';
import { clipGet, clipSet } from './clipboard.js';
import { eventWatch, eventUnwatch, eventList, eventPoll } from '../events.js';
import { netApply, netConfirm, netStatus } from './network.js';
import { audioGet, audioSet } from './audio.js';
import { agentUpdate } from './selfupdate.js';
import { mouseMove, mouseClick, mouseScroll, mouseDrag, keyType, keyPress, setInputPolicy } from './input.js';
import { fsList, fsStat, fsRead, fsWrite, setFsRoots } from './fs.js';
export type CapabilityHandler = (args: Record<string, unknown>) => Promise<unknown>;
export interface HandlerContext {
/** 连接标识(事件订阅归属 / 断开清理) */
owner: string;
/** 向该连接推送事件 */
emit: (evt: unknown) => void;
}
export type CapabilityHandler = (
args: Record<string, unknown>,
ctx?: HandlerContext,
) => Promise<unknown>;
/**
* 装配能力注册表。
@@ -42,6 +56,8 @@ export function createCapabilityRegistry(cfg: AgentConfig): Record<string, Capab
[CapabilityNames.AuditList]: auditList,
// v11 审计防篡改
[CapabilityNames.AuditVerify]: auditVerify,
// v13 成功指标
[CapabilityNames.Metrics]: metricsReport,
// v5 文件传输
[CapabilityNames.FsList]: fsList,
[CapabilityNames.FsStat]: fsStat,
@@ -60,5 +76,20 @@ export function createCapabilityRegistry(cfg: AgentConfig): Record<string, Capab
[CapabilityNames.TaskKill]: taskKill,
[CapabilityNames.ClipGet]: clipGet,
[CapabilityNames.ClipSet]: clipSet,
// v19 拉取式自更新
[CapabilityNames.AgentUpdate]: agentUpdate,
// v17 音频控制
[CapabilityNames.AudioGet]: audioGet,
[CapabilityNames.AudioSet]: audioSet,
// v16 网络变更两阶段提交
[CapabilityNames.NetApply]: netApply,
[CapabilityNames.NetConfirm]: netConfirm,
[CapabilityNames.NetStatus]: netStatus,
// v12 事件订阅
[CapabilityNames.EventWatch]: (args, ctx) =>
eventWatch(args, ctx ?? { owner: 'unknown', emit: () => undefined }),
[CapabilityNames.EventUnwatch]: eventUnwatch,
[CapabilityNames.EventList]: eventList,
[CapabilityNames.EventPoll]: eventPoll,
};
}
+200
View File
@@ -0,0 +1,200 @@
/**
* 输入注入的 **PowerShell 脚本生成**(纯函数,无副作用 —— 便于单测)。
*
* 独立成模块的原因:脚本生成逻辑(顺序、逆序释放、hold 插入、PostMessage 路由)
* 以往只能靠真机验证;而它恰好是最容易写错(一行顺序错 → 组合键失效)的部分。
* 现在由单测逐行断言,真机只需验证"最终是否生效"。
*/
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
/** 键名 → 虚拟键码(字母/数字走 ASCII 映射,其余查表白名单)。 */
export const VK_MAP: Record<string, number> = {
ctrl: 0x11, control: 0x11, shift: 0x10, alt: 0x12, win: 0x5b, meta: 0x5b,
enter: 0x0d, return: 0x0d, tab: 0x09, esc: 0x1b, escape: 0x1b, space: 0x20,
backspace: 0x08, delete: 0x2e, del: 0x2e, insert: 0x2d, ins: 0x2d,
up: 0x26, down: 0x28, left: 0x25, right: 0x27,
home: 0x24, end: 0x23, pageup: 0x21, pagedown: 0x22,
capslock: 0x14, printscreen: 0x2c,
// 系统/编辑键补充
pause: 0x13, break: 0x13,
apps: 0x5d, menu: 0x5d,
f1: 0x70, f2: 0x71, f3: 0x72, f4: 0x73, f5: 0x74, f6: 0x75,
f7: 0x76, f8: 0x77, f9: 0x78, f10: 0x79, f11: 0x7a, f12: 0x7b,
f13: 0x7c, f14: 0x7d, f15: 0x7e, f16: 0x7f, f17: 0x80, f18: 0x81,
f19: 0x82, f20: 0x83, f21: 0x84, f22: 0x85, f23: 0x86, f24: 0x87,
// 媒体键(0xA6-0xB7 区段)—— 2026-10-05 补
//
// 为何必要:远程控媒体播放器(QQ 音乐 / 网易云 / 浏览器视频)时,
// 若软件热键不可用(精简模式常不注册全局热键),系统媒体键是唯一
// 与「窗口是否可见/被遮挡」无关的通道 —— 窗口最小化、被游戏全屏盖住时
// 依然有效。实测:QQ 音乐精简窗被 DNF 全屏遮挡时,媒体键仍能控制。
media_play: 0xb3, media_pause: 0xb3, media_play_pause: 0xb3, play_pause: 0xb3,
media_stop: 0xb2, stop: 0xb2,
media_prev: 0xb1, media_previous: 0xb1, prev_track: 0xb1, prev: 0xb1,
media_next: 0xb0, next_track: 0xb0, next: 0xb0,
media_fast_forward: 0xb4, fast_forward: 0xb4,
media_rewind: 0xb7, rewind: 0xb7,
media_eject: 0xb2,
volume_mute: 0xad, mute: 0xad,
volume_down: 0xae,
volume_up: 0xaf,
browser_back: 0xa6, browser_forward: 0xa7, browser_refresh: 0xa8, browser_stop: 0xa9, browser_search: 0xaa,
launch_mail: 0xb4, launch_media: 0xb5, launch_app1: 0xb6, launch_app2: 0xb7,
browser_favorites: 0xab, browser_home: 0xac,
// ---- 左右侧修饰键(v1.5)----
// 游戏/部分应用区分左右(如游戏中把 LAlt 设为跑步、RAlt 设为瞄准),故不能只给一个 alt。
lctrl: 0xa2, rctrl: 0xa3, lshift: 0xa0, rshift: 0xa1,
lalt: 0xa4, ralt: 0xa5, lwin: 0x5b, rwin: 0x5c,
super: 0x5b, cmd: 0x5b, left_cmd: 0x5b, right_cmd: 0x5c,
// ---- 小键盘(v1.5)----
// numpadN 与数字 N 分开:Excel 等程序靠 NumLock 状态区分二者行为,合成一个会坏掉快捷键。
numpad0: 0x60, numpad1: 0x61, numpad2: 0x62, numpad3: 0x63, numpad4: 0x64,
numpad5: 0x65, numpad6: 0x66, numpad7: 0x67, numpad8: 0x68, numpad9: 0x69,
numpad_multiply: 0x6a, numpad_add: 0x6b, numpad_subtract: 0x6d, numpad_decimal: 0x6e, numpad_divide: 0x6f,
numlock: 0x90, scrolllock: 0x91,
numpad_enter: 0x0d,
// ---- OEM 符号键(v1.5,美式布局 VK 码)----
// 必备理由:大量系统/应用快捷键就是"修饰键 + 符号键"(win+d 显示桌面、win+, 速览、
// ctrl++ 放大、ctrl+- 缩小、win+. 表情面板),此前只能靠字母数字,覆盖不到。
oem_1: 0xba, // : ;
oem_2: 0xbf, // / ?
oem_3: 0xc0, // ` ~
oem_4: 0xdb, // [ {
oem_5: 0xdc, // \ |
oem_6: 0xdd, // ] }
oem_7: 0xde, // ' "
oem_8: 0xdf,
oem_minus: 0xbd, // - _
oem_plus: 0xbb, // + =
oem_comma: 0xbc, // , <
oem_period: 0xbe,// . >
// ---- 系统/IME 杂项(v1.5)----
sleep: 0x5f, help: 0x2f, select: 0x29, execute: 0x2b, clear: 0x0c,
separator: 0x6c, oem_clear: 0xfe,
kana: 0x15, hangul: 0x15, kanji: 0x19, hanja: 0x19,
convert: 0x1c, nonconvert: 0x1d, accept: 0x1e, modechange: 0x1f,
};
/** 键名 → 虚拟键码。 */
export function toVk(key: string): number {
const k = key.trim().toLowerCase();
if (VK_MAP[k] !== undefined) return VK_MAP[k]!;
if (/^[a-z]$/.test(k)) return k.toUpperCase().charCodeAt(0);
if (/^[0-9]$/.test(k)) return k.charCodeAt(0);
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的按键: ${key}`, {
supported: [...Object.keys(VK_MAP), 'a-z', '0-9'],
});
}
/**
* 媒体键名 → APPCOMMAND 取值。
*
* 取值见 Windows `APPCOMMAND_*` 常量。整条序列都是媒体键时改走 WM_APPCOMMAND
* 投递(而非 SendInput 注入 VK)—— 前者能定向到指定进程且不受窗口遮挡影响。
*
* ⚠️ 音量键(volume_mute / volume_up / volume_down)**不在此表**:
* WM_APPCOMMAND 的 lParam 低 16 位按 device 段解释,VOLUME_DOWN 与 PAUSE 的裸值相同(都是 1),
* 放进表里会与 PAUSE 撞车。音量键走 VK 注入(0xAD-0xAF)更可靠 —— 系统会自行映射为
* APPCOMMAND_VOLUME_*,故此处刻意不收录音量键,避免 device-bit 歧义。
*/
export const MEDIA_COMMANDS: Record<string, number> = {
media_play: 14, play: 14,
media_pause: 1, pause: 1,
media_play_pause: 12, play_pause: 12,
media_stop: 15, stop: 15,
media_prev: 6, media_previous: 6, prev_track: 6, prev: 6,
media_next: 7, next_track: 7, next: 7,
media_fast_forward: 3, fast_forward: 3,
media_rewind: 4, rewind: 4,
media_close: 10, close: 10,
};
export interface PressBodyOptions {
/** 序列中相邻和弦之间的间隔毫秒 */
gapMs: number;
/** 按下后保持毫秒数(长按) */
holdMs: number;
/** route=post 时的目标进程 PID */
targetPid: number;
/** 投放路由 */
route: 'foreground' | 'post';
}
/** 判断整条序列是否都是媒体键。 */
export function isAllMedia(chords: string[][]): boolean {
return (
chords.length > 0 &&
chords.every((ch) => ch.length === 1 && MEDIA_COMMANDS[ch[0]!.trim().toLowerCase()] !== undefined)
);
}
/**
* 生成媒体键(WM_APPCOMMAND)投放脚本。
* targetPid=0 → HWND_BROADCAST 广播;否则定向该进程全部顶层窗口。
*/
export function buildMediaBody(chords: string[][], opts: PressBodyOptions): string[] {
const body: string[] = [];
const pid = Math.max(0, Math.trunc(opts.targetPid));
for (let i = 0; i < chords.length; i += 1) {
const cmd = MEDIA_COMMANDS[chords[i]![0]!.trim().toLowerCase()]!;
body.push(`[NAInput]::MediaCommand(${cmd}, ${pid})`);
if (i < chords.length - 1 && opts.gapMs > 0) {
body.push(`Start-Sleep -Milliseconds ${opts.gapMs}`);
}
}
return body;
}
/**
* 生成前台注入(SendInput Vk)脚本。
*
* 顺序至关重要:按下按给定顺序、**释放逆序** —— 否则 Windows 组合键状态机会淆
* (例如 ctrl+c 若先释放 ctrl,绝大多数程序收不到"带 ctrl 的 c")。
*/
export function buildForegroundBody(chords: string[][], opts: PressBodyOptions): string[] {
const body: string[] = [];
for (let i = 0; i < chords.length; i += 1) {
const chord = chords[i]!;
if (chord.length === 0) continue;
const vks = chord.map(toVk);
for (const vk of vks) body.push(`[NAInput]::Vk(${vk}, $false)`);
if (opts.holdMs > 0) body.push(`Start-Sleep -Milliseconds ${opts.holdMs}`);
// 逆序释放,保证组合键正确
for (const vk of [...vks].reverse()) body.push(`[NAInput]::Vk(${vk}, $true)`);
if (i < chords.length - 1 && opts.gapMs > 0) {
body.push(`Start-Sleep -Milliseconds ${opts.gapMs}`);
}
}
return body;
}
/**
* 生成后台定向投递(PostMessage)脚本。
*
* 与前台注入同样遵守"按下顺序 / 释放逆序";每和弦投递给目标进程全部顶层窗口。
* C# 侧 PostChord(vks, targetPid, up):up=false → WM_KEYDOWN,up=true → WM_KEYUP。
*/
export function buildPostBody(chords: string[][], opts: PressBodyOptions): string[] {
const body: string[] = [];
const pid = Math.max(1, Math.trunc(opts.targetPid));
for (let i = 0; i < chords.length; i += 1) {
const vks = chords[i]!.map(toVk);
if (vks.length === 0) continue;
body.push(`[NAInput]::PostChord(@(${vks.join(', ')}), ${pid}, $false)`);
if (opts.holdMs > 0) body.push(`Start-Sleep -Milliseconds ${opts.holdMs}`);
// 逆序释放
const rel = [...vks].reverse().join(', ');
body.push(`[NAInput]::PostChord(@(${rel}), ${pid}, $true)`);
if (i < chords.length - 1 && opts.gapMs > 0) {
body.push(`Start-Sleep -Milliseconds ${opts.gapMs}`);
}
}
return body;
}
+211 -59
View File
@@ -1,4 +1,10 @@
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { CapabilityError, ErrorCodes, expandChords } from '@nodeagent/protocol';
import {
isAllMedia,
buildMediaBody,
buildForegroundBody,
buildPostBody,
} from './input-script.js';
import { IS_WINDOWS, execCommand } from '../util/exec.js';
type Args = Record<string, unknown>;
@@ -79,13 +85,32 @@ public static class NAInput {
SendInput(1, a, SIZE);
}
/// <summary>
/// 注入单个 Unicode 码元(KEYEVENTF_UNICODE)。
///
/// 修正 2026-10-04:原实现用 char 逐字符注入,**BMP 外字符(代理对,如 emoji、
/// 部分生僻字)被拆成两个孤立代理项**,目标应用收到的是非法序列 → 渲染为
/// U+FFFD 或乱码。现改为按 UTF-16 码元成对注入。
///
/// 注意:KEYEVENTF_UNICODE 的扫描码位只有 16 位,故代理对(两个 16 位码元)
/// 必须**各自单独**发送,无法合并 —— 这是 Windows 输入 API 的固有限制。
/// </summary>
public static void UnicodeChar(char c) {
var a = new INPUT[2];
a[0].type = 1; a[0].U.ki.wScan = c; a[0].U.ki.dwFlags = 0x0004;
a[1].type = 1; a[1].U.ki.wScan = c; a[1].U.ki.dwFlags = 0x0006;
a[0].type = 1; a[0].U.ki.wScan = c; a[0].U.ki.dwFlags = 0x0004; // KEYEVENTF_UNICODE
a[1].type = 1; a[1].U.ki.wScan = c; a[1].U.ki.dwFlags = 0x0006; // + KEYUP
SendInput(2, a, SIZE);
}
/// <summary>注入完整字符串(按 UTF-16 码元,代理对自动成对处理)。</summary>
public static void UnicodeString(string s, int intervalMs) {
// 直接遍历 UTF-16 码元:代理对的两个码元会连续注入,效果等同输入该字符
for (int i = 0; i < s.Length; i++) {
UnicodeChar(s[i]);
if (intervalMs > 0) System.Threading.Thread.Sleep(intervalMs);
}
}
public static void Vk(ushort vk, bool up) {
var a = new INPUT[1];
a[0].type = 1; a[0].U.ki.wVk = vk; a[0].U.ki.dwFlags = up ? 2u : 0u;
@@ -109,6 +134,83 @@ public static class NAInput {
var u = new INPUT[1]; u[0].type = 0; u[0].U.mi.dwFlags = upFlag;
SendInput(1, u, SIZE);
}
/// <summary>
/// 媒体控制(2026-10-05 补):经 WM_APPCOMMAND 广播到目标窗口。
///
/// 为何不只靠 VK 注入:媒体键用 SendInput 打进去后,**并非所有播放器都接收**
/// —— 只有当前获得媒体会话焦点的窗口才会响应。而 WM_APPCOMMAND 可直接
/// 投递给指定 hwnd,绕开「谁是当前媒体会话焦点」这层不确定性。
///
/// 用途:远程控制 QQ 音乐 / 网易云 / 浏览器视频,且**窗口最小化或被全屏游戏
/// 遮挡时依然有效**(实测 QQ 音乐精简窗被 DNF 全屏压住时该通道可用)。
/// </summary>
[DllImport("user32.dll", CharSet = CharSet.Auto)]
private static extern bool PostMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
[DllImport("user32.dll")]
private static extern bool EnumWindows(EnumProc cb, IntPtr p);
private delegate bool EnumProc(IntPtr h, IntPtr p);
[DllImport("user32.dll")]
private static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
/// <summary>
/// 向目标窗口(或其全部顶层窗口)发媒体控制命令。
/// appCommand 取值:14=PLAY 15=STOP 12=PLAY_PAUSE 6=PREV 7=NEXT
/// 3=FFWD 4=REWIND 10=CLOSE 0=PLAY 1=PAUSE
/// targetPid 非 0 时,自动找到该进程的所有顶层窗口并逐个投递
/// (QQ 音乐等会创建 30+ 个辅助窗口,只投主窗口常常无效)。
/// </summary>
public static int MediaCommand(int appCommand, int targetPid) {
int sent = 0;
if (targetPid == 0) {
// HWND_BROADCAST:广播给所有顶层窗口
if (PostMessage((IntPtr)0xFFFF, 0x0319, IntPtr.Zero, (IntPtr)appCommand)) sent++;
return sent;
}
var handles = new System.Collections.Generic.List<IntPtr>();
EnumWindows(delegate(IntPtr h, IntPtr p) {
uint wpid;
GetWindowThreadProcessId(h, out wpid);
if (wpid == (uint)targetPid) handles.Add(h);
return true;
}, IntPtr.Zero);
foreach (var h in handles) {
if (PostMessage(h, 0x0319, IntPtr.Zero, (IntPtr)appCommand)) sent++;
}
return sent;
}
/// <summary>
/// v1.5:向目标进程的**全部顶层窗口** PostMessage 键盘消息(后端定向快捷键投放)。
///
/// 场景:目标窗口不在前台(被遮挡 / 最小化 / 聊天窗口在后台),又要给它发快捷键。
/// WM_KEYDOWN=0x0100,WM_KEYUP=0x0101;修饰键按顺序投递,调用方负责释放顺序。
///
/// ⚠️ 已知局限(务必告知调用方):
/// - 部分程序不处理**后台**键盘消息(游戏、部分浏览器与输入型应用直接忽略);
/// - PostMessage 不带 scancode,依赖 lParam 解码按键的程序可能取不到键。
/// 该类目标请改用默认 route=foreground(前台注入,需目标可见)。
/// </summary>
public static int PostChord(ushort[] vks, int targetPid, bool up) {
int sent = 0;
if (vks == null || vks.Length == 0) return 0;
var handles = new System.Collections.Generic.List<IntPtr>();
EnumWindows(delegate(IntPtr h, IntPtr p) {
uint wpid;
GetWindowThreadProcessId(h, out wpid);
if (wpid == (uint)targetPid) handles.Add(h);
return true;
}, IntPtr.Zero);
uint msg = up ? 0x0101u : 0x0100u;
foreach (var h in handles) {
foreach (var vk in vks) {
if (PostMessage(h, msg, (IntPtr)vk, IntPtr.Zero)) sent++;
}
}
return sent;
}
}`;
/** 组装:加类型定义 + 具体操作。 */
@@ -118,13 +220,14 @@ function psScript(body: string[]): string {
);
}
async function runPs(body: string[], timeoutMs = 30_000): Promise<void> {
async function runPs(body: string[], timeoutMs = 30_000): Promise<string> {
const r = await execCommand({ command: psScript(body), timeoutMs });
if (r.exit_code !== 0) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '输入注入失败', {
detail: (r.stderr || r.stdout).slice(0, 400),
});
}
return r.stdout ?? '';
}
// ---------------- 鼠标 ----------------
@@ -237,29 +340,6 @@ export async function mouseDrag(args: Args): Promise<unknown> {
return { dragged: true, from: { x: x1, y: y1 }, to: { x: x2, y: y2 }, button, steps };
}
// ---------------- 键盘 ----------------
const VK_MAP: Record<string, number> = {
ctrl: 0x11, control: 0x11, shift: 0x10, alt: 0x12, win: 0x5b, meta: 0x5b,
enter: 0x0d, return: 0x0d, tab: 0x09, esc: 0x1b, escape: 0x1b, space: 0x20,
backspace: 0x08, delete: 0x2e, del: 0x2e, insert: 0x2d, ins: 0x2d,
up: 0x26, down: 0x28, left: 0x25, right: 0x27,
home: 0x24, end: 0x23, pageup: 0x21, pagedown: 0x22,
capslock: 0x14, printscreen: 0x2c,
f1: 0x70, f2: 0x71, f3: 0x72, f4: 0x73, f5: 0x74, f6: 0x75,
f7: 0x76, f8: 0x77, f9: 0x78, f10: 0x79, f11: 0x7a, f12: 0x7b,
};
/** 键名 → 虚拟键码(字母/数字走 ASCII 映射,其余查表白名单)。 */
function toVk(key: string): number {
const k = key.trim().toLowerCase();
if (VK_MAP[k] !== undefined) return VK_MAP[k]!;
if (/^[a-z]$/.test(k)) return k.toUpperCase().charCodeAt(0);
if (/^[0-9]$/.test(k)) return k.charCodeAt(0);
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的按键: ${key}`, {
supported: [...Object.keys(VK_MAP), 'a-z', '0-9'],
});
}
export async function keyType(args: Args): Promise<unknown> {
assertAllowed('input.key.type');
requireWindows('input.key.type');
@@ -268,50 +348,122 @@ export async function keyType(args: Args): Promise<unknown> {
// 文本经 Base64 传入,避免任何形式的内容注入
const b64 = Buffer.from(text, 'utf8').toString('base64');
// 走 C# 的 UnicodeString:按 UTF-16 码元成对注入,代理对(emoji 等)不再被拆坏
const body = [
`$bytes = [Convert]::FromBase64String('${b64}')`,
`$text = [System.Text.Encoding]::UTF8.GetString($bytes)`,
'foreach ($ch in $text.ToCharArray()) {',
' [NAInput]::UnicodeChar($ch)',
interval > 0 ? ` Start-Sleep -Milliseconds ${interval}` : '',
'}',
].filter(Boolean);
`[NAInput]::UnicodeString($text, ${Math.max(0, Math.round(interval))})`,
];
await runPs(body, 30_000 + text.length * (interval + 5));
return { typed: true, length: text.length };
// 回显注入长度与码元数:中文 1 字 = 1 码元,emoji = 2 码元
// (若将来发现错字,可用 codepoints 辅助定位是 IME 抖动还是注入截断)
return { typed: true, length: text.length, code_points: [...text].length, utf16_units: text.length };
}
export async function keyPress(args: Args): Promise<unknown> {
assertAllowed('input.key.press');
requireWindows('input.key.press');
// v10.2:支持三种语义
// 1) keys —— 单个和弦(如 ["ctrl","c"])
// 2) repeat —— 和弦重复 N 次(解决「连按上键 3 次」这类需求)
// 3) sequence —— 任意和弦序列(如 [["ctrl","c"], ["ctrl","v"]])
const sequence = args['sequence'] as string[][] | undefined;
const repeat = Math.max(1, Math.min(50, (args['repeat'] as number | undefined) ?? 1));
const keys = args['keys'] as string[] | undefined;
// v1.5:入参统一经 protocol 的 expandChords 展开 —— 字符串热键 / 预设名 / 老数组形式
// 全部收敛成「归一化和弦序列」。混用多种形式会直接报错,避免"以谁为准"的歧义。
let expanded;
try {
expanded = expandChords({
hotkey: args['hotkey'] as string | undefined,
hotkeys: args['hotkeys'] as string[] | undefined,
preset: args['preset'] as string | undefined,
presets: args['presets'] as string[] | undefined,
keys: args['keys'] as string[] | undefined,
sequence: args['sequence'] as string[][] | undefined,
repeat: args['repeat'] as number | undefined,
});
} catch (err) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `快捷键参数错误: ${err instanceof Error ? err.message : String(err)}`);
}
const chords = expanded.chords;
const presets = expanded.presets;
const chords: string[][] = sequence?.length
? sequence
: keys?.length
? Array.from({ length: repeat }, () => keys)
: [];
if (chords.length === 0) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, '需提供 keys(可配 repeat)或 sequence');
const gap = Math.max(0, Math.min(2000, (args['interval_ms'] as number) ?? 40));
// v1.5:长按(按下到释放的保持时长),用于"长按音量/持续按住方向键"等场景
const holdMs = Math.max(0, Math.min(5000, Math.round((args['hold_ms'] as number | undefined) ?? 0)));
// v1.5:投放路由。foreground = SendInput 注入当前焦点(默认,旧行为);
// post = 向目标进程全部顶层窗口 PostMessage(窗口在后台时可用,但兼容性差)
const route = (args['route'] as string | undefined) ?? 'foreground';
if (route !== 'foreground' && route !== 'post') {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的 route: ${route}`, { allowed: ['foreground', 'post'] });
}
const pid = (args['target_pid'] as number) ?? 0;
if (route === 'post' && pid <= 0) {
throw new CapabilityError(
ErrorCodes.PARAM_INVALID,
'route=post 需要提供 target_pid(≥1)—— 后台投递必须知道投给哪个进程',
{ hint: '用 window.list 取目标进程 PID;无目标时请用默认 route=foreground' },
);
}
const body: string[] = [];
const gap = Math.max(0, Math.min(2000, (args['interval_ms'] as number | undefined) ?? 40));
for (let i = 0; i < chords.length; i += 1) {
const chord = chords[i]!;
if (chord.length === 0) continue;
const vks = chord.map(toVk);
for (const vk of vks) body.push(`[NAInput]::Vk(${vk}, $false)`);
// 逆序释放,保证组合键正确
for (const vk of [...vks].reverse()) body.push(`[NAInput]::Vk(${vk}, $true)`);
if (i < chords.length - 1 && gap > 0) body.push(`Start-Sleep -Milliseconds ${gap}`);
// 媒体键走 WM_APPCOMMAND 通道(2026-10-05 补)
//
// 仅当**整条序列都是媒体键**时启用。若混在普通按键里(如 ["ctrl","media_play_pause"])
// 则回退到 VK 注入 —— 那种组合场景本就罕见,SendInput 足够。
const allMedia = isAllMedia(chords);
if (allMedia) {
// target_pid 非 0 时定向投递到该进程的全部顶层窗口(QQ 音乐会建 30+ 辅助窗口,
// 只投主窗口常常无效 —— 实测踩过);否则广播。
const mediaBody = buildMediaBody(chords, { gapMs: gap, holdMs, targetPid: pid, route });
await runPs(mediaBody);
return {
pressed: true,
keys: chords.length === 1 ? chords[0] : undefined,
chords,
times: chords.length,
presets,
via: expanded.via,
channel: 'appcommand',
target_pid: pid || null,
};
}
await runPs(body);
return { pressed: true, chords, times: chords.length };
// v1.5:后端定向投递(route=post)—— 目标窗口不在前台时使用
if (route === 'post') {
let sent = 0;
const out = await runPs(buildPostBody(chords, { gapMs: gap, holdMs, targetPid: pid, route }));
// 累计投递成功的窗口数(C# 侧每次 PostMessage 成功计数)
let total = 0;
for (const line of out.split('\n')) {
const m = /(\d+)/.exec(line.trim());
if (m) total += Number(m[1]);
}
sent = total;
return {
pressed: true,
keys: chords.length === 1 ? chords[0] : undefined,
chords,
times: chords.length,
presets,
via: expanded.via,
channel: 'postmessage',
route,
target_pid: pid,
sent_windows: sent,
note:
sent === 0
? '未投递到任何窗口(进程可能已退出或没有任何顶层窗口)'
: '后台投递兼容性有限:游戏/部分浏览器与输入型程序不响应后台键盘消息,' +
'无效时改用 route=foreground 并确保目标窗口可见',
};
}
await runPs(buildForegroundBody(chords, { gapMs: gap, holdMs, targetPid: pid, route }));
// 同时回传 keys(单和弦时的回显)与 chords(完整序列)。
// 修正 2026-10-04:此前只回 chords,控制端 CLI 读 keys.join() 直接崩溃。
return {
pressed: true,
keys: chords.length === 1 ? chords[0] : undefined,
chords,
times: chords.length,
presets,
via: expanded.via,
hold_ms: holdMs || undefined,
route,
};
}
+747
View File
@@ -0,0 +1,747 @@
import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { IS_WINDOWS, execCommand } from '../util/exec.js';
import { agentDir } from '../config.js';
import { audit } from '../audit.js';
import { extractPsError } from '../util/ps-helper.js';
type Args = Record<string, unknown>;
/**
* 网络变更两阶段提交(v16 / commit-confirm)。
*
* ## 解决的痛点(审查报告 §5.4「自断生路」悖论)
* 远程改 IP / 切 DHCP 是**最容易把自己关在门外**的操作:改完若新地址连不上,
* 控制端就再也进不来了,而现场又没人能救 —— 只能物理接触。
*
* ## 方案:仿网络设备的 commit-confirm(同 Cisco `reload in 5` 的思路)
* ① 备份当前网络配置(netsh dump / ip addr 快照)
* ② 应用变更
* ③ **注册一个 OS 级的一次性计划任务**,到点自动执行回滚
* —— 关键:任务由 OS 触发,**不依赖 agent 进程存活**(agent 被网络变更
* 影响/重启甚至崩溃都照样回滚)
* ④ 控制端(可能已在新地址上)调用 system.net.confirm 取消该任务 = 提交
* 未确认 → 到点自动回滚 → 链路恢复原样,不会失联。
*
* ## 为什么用 S4U 计划任务
* 网络变更最常发生在「无人值守」场景(无人登录)。S4U 无需保存密码且注销后仍可运行,
* 正是这里需要的语义。
*/
const NET_DIR = (): string => join(agentDir(), 'net');
const TASK_PREFIX = 'nodeagent-netrollback-';
/** 延时执行变更的秒数:留出时间让本次响应先回到调用方(连接随后会被变更切断) */
const APPLY_TASK_PREFIX = 'nodeagent-netapply-';
const APPLY_DELAY_MS = 3000;
export interface PendingRollback {
task_name: string;
created_at: number;
rollback_at: number;
seconds_left: number;
interface: string;
mode: string;
backup_path: string;
requested_by?: string;
}
/**
* 执行一段 PowerShell:**落地临时脚本 + -File**。
*
* ⚠️ 为什么不用 `-EncodedCommand`:真机实测同一段注册计划任务的脚本,
* `-File` 稳定成功并回显 `Scheduled`;`-EncodedCommand` 下 PowerShell 把进度流
* 以 CLIXML 写 stderr、且 stdout 拿不到预期输出,导致「明明成功却被判失败」,
* 个别场景还直接报账号映射错误。计划任务类操作一律走 -File。
*/
async function runPsFile(script: string, timeoutMs: number, tag = 'ps'): Promise<{ exit_code: number; stdout: string; stderr: string }> {
const dir = ensureDir();
const path = join(dir, `${tag}-${Date.now()}.ps1`);
writeFileSync(path, withBom(script), 'utf8');
return execCommand({
command: `powershell -NoProfile -ExecutionPolicy Bypass -File ${JSON.stringify(path)}`,
timeoutMs,
});
}
/**
* 生成的 .ps1 **必须带 UTF-8 BOM**。
*
* ⚠️ 真机踩过:PowerShell 5.1 读**无 BOM** 的 .ps1 会按系统 ANSI(GBK) 解析,
* 脚本里的中文(如网卡名「Ethernet」)会吃掉后面的闭合引号 →
* 报「The string is missing the terminator」→ 计划任务退出码 1 且不留日志。
* 所有落盘的 PowerShell 脚本一律经此函数。
*/
export function withBom(content: string): string {
return `\uFEFF${content}`;
}
/**
* 把字符串包成 PowerShell **单引号字面量**。
*
* ⚠️ 单引号内一切都是字面量:**反斜杠不需要任何转义**。
* 曾经用 JSON.stringify(双引号)或手动把 \ 翻倍当转义,结果生成 `C:\\Users\\...`
* 这种非法路径 → Out-File 失败 → 计划任务退出码 1 且不留日志(真机踩过)。
* 需要字面量时一律走本函数;需要插值时用双引号但避免反斜杠。
*/
export function psLit(v: string): string {
return `'${v.replace(/'/g, "''")}'`;
}
/**
* 生成「注册一次性计划任务」的 PowerShell 脚本(唯一实现,两处复用)。
*
* ⚠️ 两个真机教训都固化在这里:
* 1) **账号名必须用 WindowsIdentity 取**,不要用 "$env:USERDOMAIN\$env:USERNAME" 拼 ——
* 在 TS 模板串里 `\$` 会被当转义吃掉反斜杠,拼出「机器名<user>」这种不可解析的账号,
* Register-ScheduledTask 报 HRESULT 0x80070534「No mapping between account names...」。
* 2) 复用同一实现:此前回滚/延时两处各写一份,改了一处漏了另一处(真机踩过)。
*/
export function buildTaskRegistration(taskName: string, scriptPath: string, atIso: string): string {
return [
`$ErrorActionPreference='Stop'`,
`$a = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-NoProfile -ExecutionPolicy Bypass -File "${scriptPath}"'`,
`$t = New-ScheduledTaskTrigger -Once -At ([datetime]::Parse('${atIso}'))`,
`$s = New-ScheduledTaskSettingsSet -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Minutes 5)`,
// 用权威身份,绝不拼环境变量(见函数注释)
`$me = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name`,
`$p = New-ScheduledTaskPrincipal -UserId $me -RunLevel Highest -LogonType S4U`,
`Register-ScheduledTask -TaskName '${taskName}' -Action $a -Trigger $t -Settings $s -Principal $p -Force | Out-Null`,
`Write-Output 'Scheduled'`,
].join('\n');
}
function ensureDir(): string {
const d = NET_DIR();
mkdirSync(d, { recursive: true });
return d;
}
/** 从计划任务名解析待确认项(任务名自带时间戳)。 */
function pendingFromTasks(taskNames: string[]): Array<{ task_name: string; rollback_at: number }> {
return taskNames
.filter((t) => t.startsWith(TASK_PREFIX))
.map((t) => {
const ms = Number(t.slice(TASK_PREFIX.length));
return { task_name: t, rollback_at: Number.isFinite(ms) ? ms : 0 };
});
}
// ---------------- 平台实现 ----------------
/** 找出承载默认路由的网卡名(未显式指定 interface 时使用)。 */
async function defaultInterfaceWindows(): Promise<string> {
const ifaceScript =
`$c = Get-NetIPConfiguration | Where-Object { $_.IPv4DefaultGateway } | Select-Object -First 1; ` +
`if ($c) { $c.InterfaceAlias } else { '' }`;
const r = await execCommand({
command: `powershell -NoProfile -EncodedCommand ${Buffer.from(ifaceScript, 'utf16le').toString('base64')}`,
timeoutMs: 30_000,
});
const name = r.stdout.trim();
if (!name) {
throw new CapabilityError(
ErrorCodes.EXECUTION_FAILED,
'无法自动识别默认网卡(没有带默认网关的接口),请显式传 interface',
);
}
return name;
}
/** 掩码 → 前缀长度(255.255.255.0 → 24)。 */
export function maskToPrefix(mask: string): number {
const parts = mask.split('.');
if (parts.length !== 4) return 24;
let bits = 0;
for (const p of parts) {
const n = Number(p);
if (!Number.isInteger(n) || n < 0 || n > 255) return 24;
bits += n.toString(2).split('').filter((c) => c === '1').length;
}
return bits;
}
/** 结构化快照(回滚脚本据此用**原生 cmdlet** 重建配置)。 */
export interface IfaceSnapshot {
alias: string;
dhcp: boolean;
ipv4: Array<{ ip: string; prefix: number }>;
gateway: string[];
dns: string[];
}
async function snapshotIface(iface: string): Promise<IfaceSnapshot> {
const script = `
$ErrorActionPreference='Continue'
$a = ${JSON.stringify(iface)}
$o = [ordered]@{ alias = $a; dhcp = $false; ipv4 = @(); gateway = @(); dns = @() }
$ipif = Get-NetIPInterface -InterfaceAlias $a -AddressFamily IPv4 -ErrorAction SilentlyContinue
if ($ipif) { $o.dhcp = ($ipif.Dhcp -eq 'Enabled') }
$o.ipv4 = @(Get-NetIPAddress -InterfaceAlias $a -AddressFamily IPv4 -ErrorAction SilentlyContinue |
Where-Object { $_.PrefixOrigin -ne 'WellKnown' } |
ForEach-Object { [ordered]@{ ip = $_.IPAddress; prefix = [int]$_.PrefixLength } })
$o.gateway = @(Get-NetRoute -InterfaceAlias $a -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue |
ForEach-Object { $_.NextHop })
$o.dns = @(Get-DnsClientServerAddress -InterfaceAlias $a -AddressFamily IPv4 -ErrorAction SilentlyContinue |
ForEach-Object { $_.ServerAddresses })
$o | ConvertTo-Json -Compress -Depth 4
`;
const r = await execCommand({
command: `powershell -NoProfile -EncodedCommand ${Buffer.from(script, 'utf16le').toString('base64')}`,
timeoutMs: 30_000,
});
const start = r.stdout.indexOf('{');
const end = r.stdout.lastIndexOf('}');
const fallback: IfaceSnapshot = { alias: iface, dhcp: false, ipv4: [], gateway: [], dns: [] };
if (start < 0 || end <= start) return fallback;
try {
const j = JSON.parse(r.stdout.slice(start, end + 1)) as Record<string, unknown>;
return {
alias: iface,
dhcp: j['dhcp'] === true,
ipv4: (j['ipv4'] as Array<{ ip: string; prefix: number }>) ?? [],
gateway: (j['gateway'] as string[]) ?? [],
dns: (j['dns'] as string[]) ?? [],
};
} catch {
return fallback;
}
}
/** 由结构化快照生成**原生 cmdlet** 回滚脚本(不用 netsh set —— 它在 Win11 上会挂起)。 */
export function buildRollbackScript(snap: IfaceSnapshot, backupTxt: string, logPath: string): string {
const a = JSON.stringify(snap.alias);
const L = psLit(logPath);
const lines = [
`# nodeagent 自动回滚(生成于 ${new Date().toISOString()})`,
`$ErrorActionPreference = 'Continue'`,
`$log = ${L}`,
`function W($m) { ("[" + (Get-Date -Format o) + "] " + $m) | Out-File -Append -Encoding utf8 $log }`,
`W 'rollback begin'`,
`try {`,
];
if (snap.dhcp) {
lines.push(` Set-NetIPInterface -InterfaceAlias ${a} -Dhcp Enabled -ErrorAction SilentlyContinue`);
lines.push(` Set-DnsClientServerAddress -InterfaceAlias ${a} -ResetServerAddresses -ErrorAction SilentlyContinue`);
lines.push(` W 'restored: dhcp'`);
} else {
lines.push(` Set-NetIPInterface -InterfaceAlias ${a} -Dhcp Disabled -ErrorAction SilentlyContinue`);
// 同样「先加后清 + 先清默认路由」——否则回滚本身也会踩 already exists(真机事故根因)
for (const addr of snap.ipv4) {
lines.push(
` New-NetIPAddress -InterfaceAlias ${a} -IPAddress ${JSON.stringify(addr.ip)} -PrefixLength ${addr.prefix} -ErrorAction SilentlyContinue | Out-Null`,
);
}
lines.push(` Get-NetRoute -InterfaceAlias ${a} -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | Remove-NetRoute -Confirm:$false -ErrorAction SilentlyContinue`);
const keep = snap.ipv4.map((x) => JSON.stringify(x.ip)).join(',');
lines.push(` $keep = @(${keep})`);
lines.push(` Get-NetIPAddress -InterfaceAlias ${a} -AddressFamily IPv4 -ErrorAction SilentlyContinue |`);
lines.push(` Where-Object { $keep -notcontains $_.IPAddress -and $_.PrefixOrigin -ne 'WellKnown' } |`);
lines.push(` ForEach-Object { Remove-NetIPAddress -IPAddress $_.IPAddress -InterfaceAlias ${a} -Confirm:$false -ErrorAction SilentlyContinue }`);
if (snap.gateway[0]) {
lines.push(
` New-NetRoute -InterfaceAlias ${a} -DestinationPrefix '0.0.0.0/0' -NextHop ${JSON.stringify(snap.gateway[0])} -ErrorAction SilentlyContinue | Out-Null`,
);
}
if (snap.dns.length > 0) {
lines.push(
` Set-DnsClientServerAddress -InterfaceAlias ${a} -ServerAddresses ${snap.dns.map((d) => JSON.stringify(d)).join(',')} -ErrorAction SilentlyContinue`,
);
}
lines.push(` W 'restored: static'`);
}
lines.push(`} catch { W ("rollback failed: " + $_) }`);
lines.push(`W ('netsh dump for manual recovery: ' + ${psLit(backupTxt)})`);
lines.push(`W 'rollback done'`);
return lines.join('\n');
}
/** 读取网卡当前地址(用于回报与审计)。 */
async function readIfaceWindows(iface: string): Promise<Record<string, unknown>> {
const script = `
$ErrorActionPreference='Continue'
$c = Get-NetIPConfiguration -InterfaceAlias ${JSON.stringify(iface)} -ErrorAction SilentlyContinue
if (-not $c) { Write-Output '{}'; return }
$o = [ordered]@{ interface = ${JSON.stringify(iface)} }
$o.ipv4 = @($c.IPv4Address | ForEach-Object { $_.IPAddress })
$o.prefix = @($c.IPv4Address | ForEach-Object { $_.PrefixLength })
$o.gateway = @($c.IPv4DefaultGateway | ForEach-Object { $_.NextHop })
$o.dns = @($c.DNSServer | Where-Object { $_.AddressFamily -eq 2 } | ForEach-Object { $_.ServerAddresses } | Select-Object -First 4)
$dhcp = Get-NetIPInterface -InterfaceAlias ${JSON.stringify(iface)} -AddressFamily IPv4 -ErrorAction SilentlyContinue
$o.dhcp = ($dhcp.Dhcp -eq 'Enabled')
$o | ConvertTo-Json -Compress
`;
const r = await execCommand({
command: `powershell -NoProfile -EncodedCommand ${Buffer.from(script, 'utf16le').toString('base64')}`,
timeoutMs: 30_000,
});
const start = r.stdout.indexOf('{');
const end = r.stdout.lastIndexOf('}');
if (start < 0 || end <= start) return { interface: iface };
try {
return JSON.parse(r.stdout.slice(start, end + 1)) as Record<string, unknown>;
} catch {
return { interface: iface };
}
}
// ---------------- system.net.apply ----------------
export async function netApply(args: Args): Promise<unknown> {
const mode = (args['mode'] as string | undefined) ?? 'static';
const confirmWithinMs = Math.max(
15_000,
Math.min(600_000, (args['confirm_within_ms'] as number | undefined) ?? 60_000),
);
const explicitIface = args['interface'] as string | undefined;
const customCommand = args['command'] as string | undefined;
const dir = ensureDir();
const ts = Date.now();
if (!IS_WINDOWS) {
// POSIX:只支持「自定义命令 + 分离式回滚」——静态/DHCP 自动化涉及 nmcli/netplan
// 差异过大,未做(避免给出不可靠的自动化承诺)。
if (!customCommand) {
throw new CapabilityError(
ErrorCodes.UNSUPPORTED_PLATFORM,
'macOS/Linux 被控端仅支持 mode=command(自定义命令);静态/DHCP 自动化当前仅 Windows',
{ platform: process.platform },
);
}
return posixCommandApply(customCommand, confirmWithinMs, dir, ts);
}
const iface = explicitIface ?? (await defaultInterfaceWindows());
const before = await readIfaceWindows(iface);
// ① 备份:netsh 导出的脚本可用 `netsh -f` 原样恢复
const backupPath = join(dir, `backup-${ts}.txt`);
// ⚠️ 关键:netsh 输出的是 **OEM 代码页(中文系统=GBK)字节**。经 PowerShell 重定向会被
// 转码破坏,导致回滚时 `netsh -f` 解析失败(报「找不到下列命令」)—— 也就是说
// 回滚看似执行、实际什么都没恢复(真机踩过)。必须走 cmd 的原始重定向保字节。
const dump = await execCommand({
command: `cmd.exe /c netsh -c interface dump > "${backupPath}"`,
timeoutMs: 30_000,
});
if (dump.exit_code !== 0 || !existsSync(backupPath)) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '网络配置备份失败,已中止(不做无备份的变更)', {
detail: (dump.stderr || dump.stdout).slice(0, 300),
});
}
// ② 快照 + 生成回滚脚本(与 agent 进程解耦,由计划任务触发)
// ⚠️ 不用「netsh -f 备份」回滚:备份里是 `set address`,而该命令在 Win11 上会**挂起**
// (真机实测 >60s 无响应)—— 那样回滚等于没有。改为按结构化快照用原生 cmdlet 重建。
const snapshot = await snapshotIface(iface);
const rollbackLog = join(dir, `rollback-${ts}.log`);
const rollbackPath = join(dir, `rollback-${ts}.ps1`);
writeFileSync(rollbackPath, withBom(buildRollbackScript(snapshot, backupPath, rollbackLog)), 'utf8');
// ③ 先注册一次性回滚任务(OS 级,S4U —— 不依赖登录会话与 agent 进程)
// **先武装后开火**:即使 apply 慢或挂起,保护也已经就位。
// 任务名编码的是**回滚时间戳**(不是创建时间),status 可直接算出剩余秒数。
// 回滚时间 = 现在 + 延时执行 + 确认窗口(窗口从变更真正生效时算起)
const at = new Date(Date.now() + APPLY_DELAY_MS + confirmWithinMs);
const taskName = `${TASK_PREFIX}${at.getTime()}`;
const register = buildTaskRegistration(taskName, rollbackPath, at.toISOString());
const reg = await runPsFile(register, 60_000, 'reg-rollback');
if (!reg.stdout.includes('Scheduled')) {
throw new CapabilityError(
ErrorCodes.EXECUTION_FAILED,
'**回滚任务注册失败,已中止变更**(宁可不变更也不做无保护的网络改动)',
{
detail: `exit=${reg.exit_code} ` + (extractPsError(reg.stderr) || reg.stdout.slice(0, 400) || '(无输出)'),
backup_path: backupPath,
},
);
}
// ④ 应用变更 —— **异步执行**,先应答再动手。
//
// 为什么必须异步:改地址/切 DHCP 必然导致本控制端与被控端的连接中断(旧地址被移除),
// 同步执行的话调用方永远收不到返回值(真机实测:客户端 200s 超时,而变更其实已生效)。
// 与网络设备的 commit-confirm 一致:先排程,交出凭据,由调用方在新地址上确认。
const applyCmd = buildApplyCommand(mode, iface, args, snapshot);
const applyScriptPath = join(dir, `apply-${ts}.ps1`);
writeFileSync(
applyScriptPath,
withBom([
`$ErrorActionPreference='Stop'`,
`$log = ${psLit(join(dir, `apply-${ts}.log`))}`,
`try {`,
...applyCmd.split('\n').map((l) => ` ${l}`),
` "apply ok" | Out-File -Append -Encoding utf8 $log`,
`} catch { ("apply failed: " + $_) | Out-File -Append -Encoding utf8 $log }`,
// 自检 + 失败自愈(没有它就会静默把机器留在无地址状态 —— 真机事故根因之一)
buildSelfCheck(iface, mode === 'static' ? (args['ip'] as string) : null, rollbackPath),
].join('\n')),
'utf8',
);
const applyAt = new Date(Date.now() + APPLY_DELAY_MS);
const applyTask = `${APPLY_TASK_PREFIX}${applyAt.getTime()}`;
const regApply = buildTaskRegistration(applyTask, applyScriptPath, applyAt.toISOString());
const regA = await runPsFile(regApply, 60_000, 'reg-apply');
if (!regA.stdout.includes('Scheduled')) {
// 排程失败:撤销已注册的回滚任务,保证状态干净
await runPsFile(
`Unregister-ScheduledTask -TaskName '${taskName}' -Confirm:$false -ErrorAction SilentlyContinue`,
30_000,
'unreg',
);
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '变更排程失败,已撤销回滚任务(配置未改动)', {
detail:
`exit=${regA.exit_code} ` +
(extractPsError(regA.stderr) || '(无 stderr) ') +
` | out=${regA.stdout.replace(/\s+/g, ' ').slice(0, 200)}`,
});
}
audit({
type: 'net.change',
capability: 'system.net.apply',
status: 'ok',
reason:
`mode=${mode} interface=${iface} apply_at=${applyAt.getTime()} rollback_at=${at.getTime()} ` +
`backup=${backupPath} snapshot_dhcp=${snapshot.dhcp} snapshot_ip=${JSON.stringify(snapshot.ipv4)}`,
});
return {
apply_scheduled: true,
apply_at: applyAt.getTime(),
apply_task: applyTask,
mode,
interface: iface,
backup_path: backupPath,
rollback_script: rollbackPath,
rollback_scheduled: true,
task_name: taskName,
rollback_at: at.getTime(),
confirm_within_ms: confirmWithinMs,
before: { ...snapshot, raw: before },
hint:
`变更将于约 ${APPLY_DELAY_MS / 1000}s 后执行(先放行本次响应);连接会随地址变更中断。` +
`请在 ${new Date(at.getTime()).toLocaleTimeString('zh-CN')} 之前(若改了地址,用 nodeagent connect <新地址> --port ${'<端口>'} 重连后)` +
`调用 system.net.confirm 确认提交;逾期自动回滚到变更前配置(${JSON.stringify(snapshot.ipv4)})。`,
snapshot,
};
}
/** 生成应用命令(PowerShell 片段)。 */
/**
* 生成应用脚本(PowerShell,**原生 NetTCPIP cmdlet**)。
*
* ⚠️ 不用 `netsh interface ipv4 set address`:真机实测它在 Windows 11 上会挂起(>60s 无返回),
* 而 `Set-NetIPAddress` / `Set-NetIPInterface` / `Set-DnsClientServerAddress` 同场景 <1s。
* netsh 仅保留用于「读取」与人工恢复用的 dump 备份(对操作者友好)。
*/
export function buildApplyCommand(mode: string, iface: string, args: Args, snap: IfaceSnapshot): string {
const a = JSON.stringify(iface);
if (mode === 'command') {
const cmd = args['command'] as string | undefined;
if (!cmd) throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'mode=command 需要 command 参数');
return cmd;
}
if (mode === 'dhcp') {
return [
`$ErrorActionPreference='Stop'`,
`Set-NetIPInterface -InterfaceAlias ${a} -Dhcp Enabled -ErrorAction Stop`,
`Set-DnsClientServerAddress -InterfaceAlias ${a} -ResetServerAddresses -ErrorAction SilentlyContinue`,
`Write-Output 'dhcp applied'`,
].join('\n');
}
// static
const ip = args['ip'] as string | undefined;
const mask = args['mask'] as string | undefined;
const gateway = args['gateway'] as string | undefined;
if (!ip || !mask) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'mode=static 需要 ip 与 mask');
}
const prefix = maskToPrefix(mask);
const dns = (args['dns'] as string[] | undefined) ?? [];
const ipLit = JSON.stringify(ip);
const lines = [
`$ErrorActionPreference='Continue'`,
// 关掉 DHCP(否则设静态地址会与 DHCP 租约打架)
`Set-NetIPInterface -InterfaceAlias ${a} -Dhcp Disabled -ErrorAction SilentlyContinue`,
// ⚠️ 顺序很关键:**先加新地址**(此时旧地址还在,链路不断)→ 再清旧地址与默认路由 → 最后补路由。
// 曾经写成「先删后加」,结果 Windows 残留的默认路由让 New-NetIPAddress 报 already exists
// → 新地址没加上 → 机器回落到 APIPA 彻底失联(真机事故,务必保留本顺序注释)。
`New-NetIPAddress -InterfaceAlias ${a} -IPAddress ${ipLit} -PrefixLength ${prefix} -ErrorAction SilentlyContinue | Out-Null`,
// 清掉旧的默认路由(不先清,后面 New-NetRoute 会因「实例已存在」失败)
`Get-NetRoute -InterfaceAlias ${a} -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | Remove-NetRoute -Confirm:$false -ErrorAction SilentlyContinue`,
// 清掉非目标地址(保留 APIPA/回环)
`Get-NetIPAddress -InterfaceAlias ${a} -AddressFamily IPv4 -ErrorAction SilentlyContinue |`,
` Where-Object { $_.IPAddress -ne ${ipLit} -and $_.PrefixOrigin -ne 'WellKnown' } |`,
` ForEach-Object { Remove-NetIPAddress -IPAddress $_.IPAddress -InterfaceAlias ${a} -Confirm:$false -ErrorAction SilentlyContinue }`,
];
if (gateway) {
lines.push(
`New-NetRoute -InterfaceAlias ${a} -DestinationPrefix '0.0.0.0/0' -NextHop ${JSON.stringify(gateway)} -ErrorAction SilentlyContinue | Out-Null`,
);
}
if (dns.length > 0) {
lines.push(
`Set-DnsClientServerAddress -InterfaceAlias ${a} -ServerAddresses ${dns.map((d) => JSON.stringify(d)).join(',')} -ErrorAction SilentlyContinue`,
);
}
lines.push(`Write-Output 'static applied'`);
return lines.join('\n');
}
/**
* 变更后的自检 + 失败自愈片段。
* ⚠️ 这段是**事故换来的**:没有自检时,变更失败会静默把机器留在「无地址」状态,
* 而调用方还以为变更成功了(真机事故:机器掉到 APIPA 彻底失联)。
*/
export function buildSelfCheck(iface: string, expectIp: string | null, rollbackScriptPath: string): string {
const a = JSON.stringify(iface);
const rb = JSON.stringify(rollbackScriptPath);
const cond = expectIp
? `$ok = @(Get-NetIPAddress -InterfaceAlias ${a} -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq ${JSON.stringify(expectIp)} }).Count -gt 0`
: `$ok = @(Get-NetIPAddress -InterfaceAlias ${a} -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.PrefixOrigin -ne 'WellKnown' }).Count -gt 0`;
return [
``,
`# ---- 自检:变更是否真的生效,失败立即回滚(自愈)----`,
`Start-Sleep -Seconds 3`,
cond,
`if ($ok) {`,
` Write-Output 'selfcheck ok'`,
`} else {`,
` Write-Output 'selfcheck FAILED -> auto rollback'`,
` & ${rb}`,
`}`,
].join('\n');
}
/** POSIX:自定义命令 + 分离式回滚(sleep N 后执行回滚命令)。 */
async function posixCommandApply(
applyCommand: string,
confirmWithinMs: number,
dir: string,
ts: number,
): Promise<unknown> {
const backupPath = join(dir, `backup-${ts}.txt`);
const snap = await execCommand({ command: 'ip -o addr 2>/dev/null; echo ---; ip route 2>/dev/null', timeoutMs: 15_000 });
writeFileSync(backupPath, snap.stdout, 'utf8');
// POSIX 恢复:用快照重放地址与路由(尽力而为 —— 各发行版差异大,故提示谨慎使用)
// 注意:不要用 sed 反向引用(\1 在 TS 模板串里是八进制转义,编译期报错)—— 统一用 awk
const rollbackCmd = [
`# 尽力恢复:从快照重放 IPv4 地址与默认路由`,
`awk '/inet /{dev=""; for(i=1;i<=NF;i++) if($i=="dev") dev=$(i+1); if(dev && $2!="127.0.0.1/8") print $2, dev}' '${backupPath}' | while read -r addr dev; do`,
` ip addr add "$addr" dev "$dev" 2>/dev/null`,
`done`,
`awk '/^default/{via=""; dev=""; for(i=1;i<=NF;i++){ if($i=="via") via=$(i+1); if($i=="dev") dev=$(i+1) } if(via && dev) print via, dev}' '${backupPath}' | while read -r via dev; do`,
` ip route add default via "$via" dev "$dev" 2>/dev/null`,
`done`,
].join('\n');
const rollbackPath = join(dir, `rollback-${ts}.sh`);
writeFileSync(
rollbackPath,
withBom([
'#!/bin/sh',
`# nodeagent 自动回滚(${new Date(ts).toISOString()})`,
`sleep ${Math.round(confirmWithinMs / 1000)}`,
`# 若确认文件存在则跳过回滚`,
`[ -f '${join(dir, `confirmed-${ts}`)}' ] && exit 0`,
rollbackCmd,
].join('\n')),
'utf8',
);
const launched = await execCommand({
command: `chmod +x ${JSON.stringify(rollbackPath)} && setsid nohup sh ${JSON.stringify(rollbackPath)} > ${JSON.stringify(join(dir, `rollback-${ts}.log`))} 2>&1 & echo started`,
timeoutMs: 15_000,
});
const applied = await execCommand({ command: applyCommand, timeoutMs: 60_000 });
audit({
type: 'net.change',
capability: 'system.net.apply',
status: applied.exit_code === 0 ? 'ok' : 'failed',
reason: `mode=command confirm_within=${confirmWithinMs}ms backup=${backupPath}`,
});
return {
applied: applied.exit_code === 0,
mode: 'command',
backup_path: backupPath,
rollback_script: rollbackPath,
rollback_scheduled: launched.stdout.includes('started'),
task_name: `posix-${ts}`,
rollback_at: Date.now() + confirmWithinMs,
confirm_within_ms: confirmWithinMs,
hint:
`请在 ${Math.round(confirmWithinMs / 1000)} 秒内调用 system.net.confirm(task_name="posix-${ts}") 确认;` +
`逾期由分离进程执行回滚(依赖备份脚本可恢复,网络类变更的 POSIX 恢复能力弱于 Windows,请谨慎)`,
apply_output: (applied.stdout || applied.stderr).trim().slice(0, 300),
};
}
// ---------------- system.net.confirm ----------------
export async function netConfirm(args: Args): Promise<unknown> {
const taskName = args['task_name'] as string | undefined;
const dir = NET_DIR();
if (!IS_WINDOWS) {
// POSIX:写确认标记文件,回滚脚本会自行跳过
if (!taskName) return { confirmed: 0, note: 'POSIX 需指定 task_name' };
const ts = taskName.replace('posix-', '');
writeFileSync(join(dir, `confirmed-${ts}`), String(Date.now()), 'utf8');
audit({ type: 'net.change', capability: 'system.net.confirm', status: 'ok', reason: `confirmed ${taskName}` });
return { confirmed: 1, cancelled: [taskName] };
}
const listScript = `Get-ScheduledTask -TaskName '${TASK_PREFIX}*' -ErrorAction SilentlyContinue | Select-Object -ExpandProperty TaskName`;
let targets: string[] = [];
if (taskName) {
targets = [taskName];
} else {
const r = await execCommand({
command: `powershell -NoProfile -EncodedCommand ${Buffer.from(listScript, 'utf16le').toString('base64')}`,
timeoutMs: 30_000,
});
targets = r.stdout
.split('\n')
.map((l) => l.trim())
.filter((l) => l.startsWith(TASK_PREFIX));
}
if (targets.length === 0) {
return { confirmed: 0, cancelled: [], note: '没有待确认的网络变更(可能已确认或已回滚)' };
}
const unreg = [
...targets.map((t) => `Unregister-ScheduledTask -TaskName '${t}' -Confirm:$false -ErrorAction SilentlyContinue`),
// 顺带清理已执行过的 netapply 任务(一次性、已完成,留着只会让任务计划程序变乱)
`Get-ScheduledTask -TaskName '${APPLY_TASK_PREFIX}*' -ErrorAction SilentlyContinue | Unregister-ScheduledTask -Confirm:$false -ErrorAction SilentlyContinue`,
].join('\n');
const r = await runPsFile(unreg, 60_000, 'unreg-all');
const stillThere = await execCommand({
command: `powershell -NoProfile -EncodedCommand ${Buffer.from(listScript, 'utf16le').toString('base64')}`,
timeoutMs: 30_000,
});
const remaining = stillThere.stdout
.split('\n')
.map((l) => l.trim())
.filter((l) => l.startsWith(TASK_PREFIX));
audit({
type: 'net.change',
capability: 'system.net.confirm',
status: remaining.length === 0 ? 'ok' : 'failed',
reason: `confirmed=[${targets.join(',')}]`,
});
return {
confirmed: targets.length - remaining.length,
cancelled: targets.filter((t) => !remaining.includes(t)),
remaining,
detail: (r.stderr || '').slice(0, 200),
};
}
// ---------------- system.net.status ----------------
export async function netStatus(_args: Args): Promise<unknown> {
const dir = NET_DIR();
let backups: Array<{ file: string; bytes: number; mtime: number }> = [];
try {
backups = readdirSync(dir)
.filter((f) => f.startsWith('backup-'))
.map((f) => {
const p = join(dir, f);
const st = readFileSync(p);
return { file: p, bytes: st.length, mtime: 0 };
});
} catch {
backups = [];
}
if (!IS_WINDOWS) {
const r = await execCommand({ command: 'ip -o addr 2>/dev/null | head -20', timeoutMs: 15_000 });
return { platform: process.platform, addresses: r.stdout.trim().split('\n'), backups, pending: [] };
}
const listScript = `Get-ScheduledTask -TaskName '${TASK_PREFIX}*' -ErrorAction SilentlyContinue | ForEach-Object { $_.TaskName + '|' + $_.State }`;
const r = await execCommand({
command: `powershell -NoProfile -EncodedCommand ${Buffer.from(listScript, 'utf16le').toString('base64')}`,
timeoutMs: 30_000,
});
const rows = r.stdout
.split('\n')
.map((l) => l.trim())
.filter((l) => l.startsWith(TASK_PREFIX));
const now = Date.now();
const pending: PendingRollback[] = rows.map((row) => {
const [task_name, state] = row.split('|') as [string, string];
const rollbackAt = Number(task_name.slice(TASK_PREFIX.length));
return {
task_name,
created_at: rollbackAt,
rollback_at: rollbackAt,
seconds_left: Math.max(0, Math.round((rollbackAt - now) / 1000)),
interface: '(见 task)',
mode: '(见 task)',
backup_path: '',
requested_by: state,
};
});
// ⚠️ 必须用 -EncodedCommand:用 -Command "..." 时内层的 $_ 会被**外层 PowerShell** 展开,
// 命令静默失效并返回空(真机踩过)。
const addrScript =
`Get-NetIPConfiguration | Where-Object { $_.NetAdapter.Status -eq 'Up' } | ` +
`Select-Object InterfaceAlias, @{n='ip';e={$_.IPv4Address.IPAddress}}, @{n='gw';e={$_.IPv4DefaultGateway.NextHop}} | ` +
`ConvertTo-Json -Compress`;
const addr = await execCommand({
command: `powershell -NoProfile -EncodedCommand ${Buffer.from(addrScript, 'utf16le').toString('base64')}`,
timeoutMs: 30_000,
});
// ⚠️ 只有一块网卡时 ConvertTo-Json 输出的是**对象**而不是数组 —— 只按 [...] 解析会
// 在单网卡机器上永远返回空(真机踩过)。这里统一用首 { 到末 } 截取后按需包成数组。
const s = addr.stdout.indexOf('{');
const e = addr.stdout.lastIndexOf('}');
let interfaces: unknown[] = [];
if (s >= 0 && e > s) {
try {
const parsed = JSON.parse(addr.stdout.slice(s, e + 1)) as unknown;
interfaces = Array.isArray(parsed) ? parsed : [parsed];
} catch {
interfaces = [];
}
}
return {
platform: process.platform,
interfaces,
pending,
pending_count: pending.length,
backups,
note: pending.length > 0
? '存在待确认的网络变更:请在逾期前调用 system.net.confirm;逾期会自动回滚'
: '没有待确认的网络变更',
};
}
/** 清理旧的备份/回滚脚本(保留最近 N 份)。 */
export function pruneNetArtifacts(keep = 10): number {
const dir = NET_DIR();
let removed = 0;
try {
const files = readdirSync(dir)
.filter((f) => f.startsWith('backup-') || f.startsWith('rollback-'))
.sort()
.reverse();
for (const f of files.slice(keep * 2)) {
try {
unlinkSync(join(dir, f));
removed += 1;
} catch {
/* 忽略 */
}
}
} catch {
/* 目录不存在 */
}
return removed;
}
+231
View File
@@ -0,0 +1,231 @@
import { createHash } from 'node:crypto';
import { copyFileSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { IS_WINDOWS } from '../util/exec.js';
import { agentDir } from '../config.js';
import { audit } from '../audit.js';
import { agentRestart } from './system.js';
type Args = Record<string, unknown>;
/**
* 拉取式自更新(v19):被控端**自己**从 URL 下载新版本并替换自身。
*
* ## 与 `nodeagent deploy`(推送式)的分工
* - 推送式:控制端与被控端可达 → 逐块上传(简单、无需被控端能上网)
* - 拉取式(本能力):两者不可达,但被控端能上网 → 从 GitHub Release / 自建镜像拉取
* 典型场景:被控端在老家、控制端在出差地;或跨网段/穿 NAT 失败。
*
* ## 安全(必须严格遵守,否则等于开放远程代码执行)
* 1. **哈希必须钉死**:`sha256` 必填,且**逐字节校验下载内容**后才替换。
* 没有它,GitHub 账号被盗 / DNS 被污染 / 中间人 = 直接拿下被控端。
* 2. 只接受 http/https;拒绝 file:// 等其它 scheme。
* 3. 大小上限(默认 64MB)+ 下载超时,避免被喂垃圾撑爆磁盘。
* 4. **先备份再原子替换**(同目录 rename),失败可回滚。
* 5. `dry_run` 只下载校验、不动现有文件 —— 用于「先确认能不能更新」。
* 6. 动作全程进审计(能力调用本身已记录 `invoke`,这里再加一条 `agent.update`)。
*
* ## 为什么不用 `fetch` 的代理能力
* Node 内置 `fetch` 不支持 HTTP 代理(需要 undici 的 ProxyAgent,而 undici 未对外暴露)。
* 国内访问 GitHub 不稳时,正确做法是**把 url 指向自建镜像 / 加速服务**(本能力接受任意
* http(s) 地址,天然支持),而不是在 agent 里再塞一套代理隧道。
*/
const MAX_BYTES = 64 * 1024 * 1024;
/** 下载到内存(带大小上限与超时)。 */
async function download(url: string, timeoutMs: number): Promise<Buffer> {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), timeoutMs);
try {
const res = await fetch(url, { signal: ac.signal, redirect: 'follow' });
if (!res.ok) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, `下载失败: HTTP ${res.status} ${res.statusText}`, {
url,
});
}
const declared = Number(res.headers.get('content-length') ?? '0');
if (declared > MAX_BYTES) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `文件过大(${declared} 字节 > 上限 ${MAX_BYTES})`, { url });
}
const reader = res.body?.getReader();
if (!reader) {
return Buffer.from(await res.arrayBuffer());
}
const parts: Uint8Array[] = [];
let total = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
total += value.length;
if (total > MAX_BYTES) {
await reader.cancel();
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `下载超过上限 ${MAX_BYTES} 字节,已中断`, { url });
}
parts.push(value);
}
}
return Buffer.concat(parts);
} catch (err) {
if (err instanceof CapabilityError) throw err;
const msg = err instanceof Error ? err.message : String(err);
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, `下载失败: ${msg}`, { url });
} finally {
clearTimeout(timer);
}
}
export async function agentUpdate(args: Args): Promise<unknown> {
const url = args['url'] as string | undefined;
const expect = (args['sha256'] as string | undefined)?.toLowerCase();
const dryRun = args['dry_run'] === true;
const doRestart = args['restart'] !== false;
const timeoutMs = Math.max(5_000, Math.min(600_000, (args['timeout_ms'] as number | undefined) ?? 120_000));
if (!url) throw new CapabilityError(ErrorCodes.PARAM_INVALID, '需要 url(更新包地址)');
if (!expect || !/^[0-9a-f]{12,64}$/.test(expect)) {
throw new CapabilityError(
ErrorCodes.PARAM_INVALID,
'需要 sha256(12~64 位十六进制)。**这是安全底线**:不校验哈希等于开放远程代码执行',
);
}
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `url 不是合法地址: ${url}`);
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `只支持 http/https,收到 ${parsed.protocol}`);
}
const scriptPath = process.argv[1] ?? '';
if (!scriptPath) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '无法定位自身入口路径,拒绝自更新');
}
const dir = join(agentDir(), 'update');
mkdirSync(dir, { recursive: true });
// ① 下载 + ② 校验哈希(先校验再落盘,避免半成品污染)
const t0 = Date.now();
const buf = await download(url, timeoutMs);
const got = createHash('sha256').update(buf).digest('hex');
if (!got.startsWith(expect) && got !== expect) {
audit({ type: 'agent.update', status: 'failed', reason: `hash mismatch url=${url} expect=${expect} got=${got.slice(0, 12)}` });
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '哈希校验失败,已丢弃下载内容(未改动任何文件)', {
expected: expect,
actual: got.slice(0, 12),
bytes: buf.length,
});
}
const staged = join(dir, `agent-${Date.now()}.mjs`);
writeFileSync(staged, buf);
const prevBytes = (() => {
try {
return statSync(scriptPath).size;
} catch {
return 0;
}
})();
const prevHash = (() => {
try {
return createHash('sha256').update(readFileSync(scriptPath)).digest('hex').slice(0, 12);
} catch {
return 'unknown';
}
})();
if (dryRun) {
try {
unlinkSync(staged);
} catch {
/* 忽略 */
}
audit({ type: 'agent.update', status: 'ok', reason: `dry_run url=${url} hash=${got.slice(0, 12)}` });
return {
dry_run: true,
verified: true,
bytes: buf.length,
incoming_hash: got.slice(0, 12),
current_hash: prevHash,
current_bytes: prevBytes,
note: '仅下载并校验,未改动任何文件',
};
}
// ③ 备份 + ④ 原子替换(同目录 rename,失败时旧文件不受影响)
const backup = `${scriptPath}.bak-${Date.now()}`;
try {
copyFileSync(scriptPath, backup);
} catch (err) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '备份现有脚本失败,已中止(不做无备份的替换)', {
detail: err instanceof Error ? err.message : String(err),
});
}
// ⚠️ 必须**就地覆写**,不能用 rename 覆盖 —— Windows 不允许重命名/替换「正被运行进程
// 打开的文件」(EPERM/EBUSY),而 node.exe 正持有 agent.mjs。真机踩过:rename 必失败。
// 覆写打开中的文件是允许的(只有删除/重命名被禁),这也正是推送式 deploy 能工作的原因。
try {
writeFileSync(scriptPath, buf);
} catch (err) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '写入脚本失败(已保留备份,可回滚)', {
detail: err instanceof Error ? err.message : String(err),
backup_path: backup,
staged_path: staged,
});
}
// 写后校验:内容必须与刚校验过的下载一致,否则立即回滚(防止截断/半写)
const written = createHash('sha256').update(readFileSync(scriptPath)).digest('hex');
if (written !== got) {
try {
copyFileSync(backup, scriptPath); // 回滚
} catch {
/* 回滚失败时靠 backup_path 人工兜底 */
}
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '写入后校验不一致,已自动回滚', {
expected: got.slice(0, 12),
actual: written.slice(0, 12),
backup_path: backup,
});
}
try {
unlinkSync(staged); // 暂存文件已并入入口文件,及时清理
} catch {
/* 清理失败不影响正确性 */
}
audit({
type: 'agent.update',
status: 'ok',
reason: `url=${url} ${prevHash}->${got.slice(0, 12)} bytes=${buf.length} backup=${backup}`,
});
// ⑤ 受控重启(复用 v7;Windows 走计划任务、POSIX 走 detached,均不依赖本进程存活)
let restarted = false;
if (doRestart) {
await agentRestart({ delay_ms: 1500, reason: 'self-update' });
restarted = true;
}
return {
updated: true,
bytes: buf.length,
previous: { hash: prevHash, bytes: prevBytes },
current_hash: got.slice(0, 12),
// CLI 靠它做「重启后复核」——漏了这个字段会导致复核被跳过(真机踩过)
incoming_hash: got.slice(0, 12),
backup_path: backup,
restarted,
elapsed_ms: Date.now() - t0,
hint: doRestart
? '已替换并重启;请用 system.info 的 build.hash 复核(应等于 incoming_hash)。若异常,把 backup_path 覆盖回入口即可回滚'
: '已替换但未重启(restart=false);重启后才会生效',
platform: IS_WINDOWS ? 'windows' : 'posix',
};
}
+98 -1
View File
@@ -1,8 +1,14 @@
import { createHash } from 'node:crypto';
import { readFileSync, statSync } from 'node:fs';
import { spawn } from 'node:child_process';
import os from 'node:os';
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { IS_WINDOWS, execCommand } from '../util/exec.js';
import { readAudit, verifyAudit } from '../audit.js';
import { currentCertFingerprint } from '../certs.js';
import { loadAgentConfig } from '../config.js';
import { readAudit, readAuditAll, verifyAudit } from '../audit.js';
import { computeMetrics } from '../metrics.js';
import { allPsShellStats } from '../util/ps-helper.js';
import { startTask } from './task.js';
type Args = Record<string, unknown>;
@@ -42,6 +48,82 @@ async function isAdmin(): Promise<boolean> {
// ---------------- system.info ----------------
// ---------- 构建指纹(v18 / v20)----------
// 构建时由 esbuild --define 注入(见 scripts/pack.mjs);开发态(直接跑 dist)下未定义,
// 故一律用 typeof 探测,绝不能直接引用(否则 dev 启动就 ReferenceError)。
declare const __AGENT_VERSION__: string | undefined;
declare const __BUILD_COMMIT__: string | undefined;
/** v21:读取当前生效的来源白名单(用于 system.info 上报)。 */
function configAllowFrom(): string[] | null {
try {
const cfg = loadAgentConfig().config;
return cfg.allow_from && cfg.allow_from.length > 0 ? cfg.allow_from : null;
} catch {
return null;
}
}
function injected(name: 'version' | 'commit'): string {
if (name === 'version') return typeof __AGENT_VERSION__ === 'undefined' ? 'dev' : __AGENT_VERSION__;
return typeof __BUILD_COMMIT__ === 'undefined' ? 'dev' : __BUILD_COMMIT__;
}
const STARTED_AT = Date.now();
let buildCache: { hash: string; bytes: number; mtime_ms: number } | null = null;
/**
* 计算「正在运行的这一份 agent 脚本」的指纹(sha256 前 12 位)。
*
* 用途:部署后**校验远端真的换成了新代码**。仅靠「PID 变了 / 能力数对了」证明不了这点
* (旧版可能恰好也是同样的能力数);内容哈希是唯一可靠的判据。
* 懒计算 + 缓存:文件约 1MB,只在首次 system.info 时读一次。
*/
export function buildInfo(): {
hash: string;
bytes: number;
mtime_ms: number;
node: string;
started_at: number;
uptime_ms: number;
version: string;
commit: string;
built_at: string;
cert_sha256: string | null;
} {
if (!buildCache) {
let hash = 'unknown';
let bytes = 0;
let mtime = 0;
try {
const scriptPath = process.argv[1] ?? '';
const buf = readFileSync(scriptPath);
bytes = buf.length;
mtime = statSync(scriptPath).mtimeMs;
hash = createHash('sha256').update(buf).digest('hex').slice(0, 12);
} catch {
/* 读不到脚本(如被内联启动)时保持 unknown,不影响其他功能 */
}
buildCache = { hash, bytes, mtime_ms: Math.round(mtime) };
}
return {
...buildCache,
node: process.version,
started_at: STARTED_AT,
uptime_ms: Date.now() - STARTED_AT,
// v20:语义化版本 + 提交 + 构建时间(来自构建期注入)
version: injected('version'),
commit: injected('commit'),
// 构建时间取入口文件的 mtime(而非构建期注入)—— 注入会让每次打包字节不同,
// 破坏「指纹 = 构建身份」的确定性(v22 真机踩过)
built_at: buildCache.mtime_ms ? new Date(buildCache.mtime_ms).toISOString() : 'unknown',
// v21:TLS 证书指纹(控制端据此钉住被控端身份)
cert_sha256: currentCertFingerprint(),
};
}
export async function systemInfo(args: Args): Promise<unknown> {
const fields = args['fields'] as string[] | undefined;
const info: Record<string, unknown> = {
@@ -59,6 +141,14 @@ export async function systemInfo(args: Args): Promise<unknown> {
agent_script: process.argv[1] ?? '',
agent_home: process.env['NODEAGENT_HOME'] ?? '',
node_path: process.execPath,
// v15:PowerShell 常驻助手状态(排查 GUI 能力性能/降级时用)
ps_helper: allPsShellStats(),
// v18/v21:构建指纹(部署校验用)+ 证书指纹(身份钉定用)
build: buildInfo(),
// v21:来源网段访问控制(null = 未配置 = 放行全部,属于应尽快收敛的风险)
network: {
allow_from: configAllowFrom(),
},
};
if (fields && fields.length > 0) {
@@ -509,3 +599,10 @@ export async function auditList(args: Args): Promise<unknown> {
export async function auditVerify(_args: Args): Promise<unknown> {
return verifyAudit();
}
/** v13:成功指标聚合(对齐 PRD 2.2)—— 数据源为审计日志,无需额外埋点。 */
export async function metricsReport(args: Args): Promise<unknown> {
const since = args['since'] as number | undefined;
const entries = readAuditAll(since);
return computeMetrics(entries);
}
+605 -115
View File
@@ -1,5 +1,10 @@
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { IS_WINDOWS, execCommand } from '../util/exec.js';
import { runPowerShellSmart } from '../util/ps-helper.js';
import { agentDir } from '../config.js';
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { macScreenFind, macWindowFocus, macWindowList } from './darwin.js';
type Args = Record<string, unknown>;
@@ -16,7 +21,31 @@ function encodePS(script: string): string {
return Buffer.from(script, 'utf16le').toString('base64');
}
async function runPS(script: string, timeoutMs = 30_000): Promise<string> {
/**
* 执行「不含 prelude」的脚本主体:**优先常驻助手**(类型已预加载),
* 助手不可用时自动回退到一次性脚本(自带 prelude),保证可用性不倒退。
*/
async function runPS(body: string, timeoutMs = 30_000): Promise<string> {
try {
const { stdout } = await runPowerShellSmart({
body,
prelude: WIN_HELPER_PRELUDE,
timeoutMs,
label: 'win',
log: (level, msg) => {
if (level === 'warn') console.error(`[ps-helper] ${msg}`);
},
});
return stdout;
} catch (err) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '窗口操作失败', {
detail: (err instanceof Error ? err.message : String(err)).slice(0, 600),
});
}
}
/** 一次性执行完整脚本(含 prelude)—— 尚未迁移到助手的路径暂用。 */
async function runPSRaw(script: string, timeoutMs = 30_000): Promise<string> {
const r = await execCommand({
command: `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${encodePS(script)}`,
timeoutMs,
@@ -66,14 +95,19 @@ public class NAWin32 {
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
[DllImport("user32.dll")] public static extern void mouse_event(uint f, int dx, int dy, uint d, UIntPtr e);
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
}
"@
`;
/** 枚举可见顶层窗口 → JSON 字符串。 */
const LIST_SCRIPT = `${WIN32_PRELUDE}
/**
* 枚举可见顶层窗口 → JSON(**不含 prelude**,供常驻助手与一次性路径复用)。
* 注意:本段会被送进常驻进程执行,**严禁出现 exit**(会杀掉助手进程)。
*/
const LIST_BODY = `
$fg = [NAWin32]::GetForegroundWindow()
$list = New-Object System.Collections.ArrayList
$cb = [NAWin32+EnumProc]{
@@ -87,12 +121,14 @@ $cb = [NAWin32+EnumProc]{
if (-not [NAWin32]::GetWindowRect($h, [ref]$r)) { return $true }
$w = $r.Right - $r.Left; $ht = $r.Bottom - $r.Top
if ($w -le 0 -or $ht -le 0) { return $true }
$pid = 0
[void][NAWin32]::GetWindowThreadProcessId($h, [ref]$pid)
# ⚠️ 变量名不能叫 $pid —— 它是 PowerShell 的自动变量(当前进程 ID),赋值会被忽略,
# 导致所有窗口都报成 powershell 进程(真机踩过)。
$procId = 0
[void][NAWin32]::GetWindowThreadProcessId($h, [ref]$procId)
$pn = ''
try { $pn = (Get-Process -Id $pid -ErrorAction Stop).ProcessName } catch {}
try { $pn = (Get-Process -Id $procId -ErrorAction Stop).ProcessName } catch {}
[void]$list.Add([pscustomobject]@{
hwnd = ('0x{0:X}' -f $h.ToInt64()); title = $title; process = $pn; pid = $pid
hwnd = ('0x{0:X}' -f $h.ToInt64()); title = $title; process = $pn; pid = $procId
x = $r.Left; y = $r.Top; width = $w; height = $ht
is_foreground = ($h -eq $fg); is_minimized = [NAWin32]::IsIconic($h)
})
@@ -102,6 +138,356 @@ $cb = [NAWin32+EnumProc]{
$list | ConvertTo-Json -Compress
`;
/**
* v14:常驻助手预加载段 —— 这些内容**只在助手启动时执行一次**。
* 过去每次调用都重新编译 C# / 加载程序集,真机实测占单次耗时的绝大部分
* (window.list 1267ms / window.focus 1631ms,其中「起进程+编译」是固定成本)。
*/
const UIA_ASSEMBLY_PRELUDE = `
Add-Type -AssemblyName UIAutomationClient
Add-Type -AssemblyName UIAutomationTypes
Add-Type -AssemblyName WindowsBase
function Get-WinByTitle([string]$re) {
$root = [System.Windows.Automation.AutomationElement]::RootElement
$cond = New-Object System.Windows.Automation.PropertyCondition(
[System.Windows.Automation.AutomationElement]::ControlTypeProperty,
[System.Windows.Automation.ControlType]::Window)
$wins = $root.FindAll([System.Windows.Automation.TreeScope]::Children, $cond)
foreach ($w in $wins) {
try {
$t = $w.Current.Name
if ($t -and $t -match $re) { return $w }
} catch {}
}
return $null
}
# v14:按子串在给定 scope 内遍历查找(FindAll + 子串过滤)。
# UIA 的 PropertyCondition 只能精确匹配,故必须遍历后过滤;遍历规模设上限防超大 UI 树卡死。
function Find-UiaByText($scope, [string]$text, [int]$limit, [string]$ct) {
$cond = [System.Windows.Automation.Condition]::TrueCondition
if ($ct -ne '') {
$ctObj = $null
try { $ctObj = [System.Windows.Automation.ControlType]::$ct } catch {}
if ($ctObj -ne $null) {
$cond = New-Object System.Windows.Automation.PropertyCondition(
[System.Windows.Automation.AutomationElement]::ControlTypeProperty, $ctObj)
}
}
$found = $scope.FindAll([System.Windows.Automation.TreeScope]::Descendants, $cond)
$out = New-Object System.Collections.ArrayList
$scanned = 0
foreach ($e in $found) {
if ($out.Count -ge $limit) { break }
$scanned++
if ($scanned -gt 20000) { break }
try {
$nm = $e.Current.Name
if ([string]::IsNullOrEmpty($nm)) { continue }
if ($nm.IndexOf($text, [System.StringComparison]::OrdinalIgnoreCase) -lt 0) { continue }
$r = $e.Current.BoundingRectangle
if ($r.Width -le 0 -or $r.Height -le 0) { continue }
[void]$out.Add([pscustomobject]@{
name = $nm
control_type = ($e.Current.ControlType.ProgrammaticName -replace 'ControlType\\.','')
automation_id = $e.Current.AutomationId
x = [int]($r.Left + $r.Width / 2)
y = [int]($r.Top + $r.Height / 2)
left = [int]$r.Left; top = [int]$r.Top
width = [int]$r.Width; height = [int]$r.Height
})
} catch {}
}
return ,$out
}
`;
/** 助手统一预加载段(window.list / window.focus / screen.find 共用同一个进程)。 */
/**
* OCR 引擎预加载段(v14):截图 / DPI / WinRT 类型与 Await 辅助函数。
* 注意:WinRT 类型加载用 try/catch 包住 —— 个别环境缺组件时只应让 OCR 退化,
* 不该让整个助手(连带 window.list/focus/UIA)起不来。
*/
const OCR_PRELUDE = `
Add-Type -AssemblyName System.Drawing
Add-Type -AssemblyName System.Windows.Forms
try {
Add-Type -AssemblyName System.Runtime.WindowsRuntime
Add-Type -TypeDefinition 'using System.Runtime.InteropServices; public class NADPI { [DllImport("user32.dll")] public static extern bool SetProcessDPIAware(); }'
[void][NADPI]::SetProcessDPIAware()
$null = [Windows.Media.Ocr.OcrEngine, Windows.Foundation, ContentType=WindowsRuntime]
$null = [Windows.Graphics.Imaging.BitmapDecoder, Windows.Foundation, ContentType=WindowsRuntime]
$null = [Windows.Storage.Streams.InMemoryRandomAccessStream, Windows.Foundation, ContentType=WindowsRuntime]
$null = [Windows.Storage.Streams.DataWriter, Windows.Foundation, ContentType=WindowsRuntime]
$null = [Windows.Graphics.Imaging.SoftwareBitmap, Windows.Foundation, ContentType=WindowsRuntime]
$script:asTaskGeneric = ([System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object { $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation\`1' })[0]
function global:Await($WinRtTask, $ResultType) {
$asTask = $script:asTaskGeneric.MakeGenericMethod($ResultType)
$netTask = $asTask.Invoke($null, @($WinRtTask))
$netTask.Wait(-1) | Out-Null
$netTask.Result
}
$script:ocrReady = $true
} catch {
$script:ocrReady = $false
}
`;
/**
* 图像模板匹配预加载段(v15)。
*
* 动机:UIA 靠控件树、OCR 靠文字 —— **纯图标/无文字的控件**两者都抓不到(审查报告 §5.2 指出的盲区)。
* 做法:把模板图与屏幕区域都转灰度,做**零均值归一化互相关(ZNCC)**;
* 全分辨率逐像素太慢(3440×1440 屏 × 64×64 模板 ≈ 190 亿次),故**粗到精**:
* ① 1/4 分辨率全图扫描(约 8000 万次)取候选
* ② 对候选在全分辨率 ±10px 邻域精修
* 编译一次常驻(随助手预加载),运行时纯 native 循环。
* 亮度和对比度无关(ZNCC 归一化),故对主题/亮度变化不敏感。
*/
const IMAGE_MATCH_PRELUDE = `
Add-Type -AssemblyName System.Drawing
# ⚠️ Add-Type 的老坑:-AssemblyName 只把程序集加载进 PowerShell 会话,
# 编译 C# 时还必须显式 -ReferencedAssemblies,否则报「命名空间 System.Drawing
# 中不存在 Imaging」。真机踩过:编译失败会让整个助手起不来,并伴随误导性的
# 「找不到类型 System.Windows.Forms.SystemInformation」二次错误。
Add-Type @"
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;
public class NAImageMatch {
public class Hit { public int x; public int y; public int w; public int h; public double score; }
private static byte[] Gray(Bitmap src, out int w, out int h) {
w = src.Width; h = src.Height;
var dst = new byte[w * h];
var rect = new Rectangle(0, 0, w, h);
Bitmap bmp = src.PixelFormat == PixelFormat.Format24bppRgb ? src : src.Clone(rect, PixelFormat.Format24bppRgb);
var data = bmp.LockBits(rect, ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb);
try {
int stride = data.Stride;
var buf = new byte[stride * h];
Marshal.Copy(data.Scan0, buf, 0, buf.Length);
for (int y = 0; y < h; y++) {
int row = y * stride;
for (int x = 0; x < w; x++) {
int i = row + x * 3;
// BGR -> luma (integer approximation of ITU-R BT.601)
dst[y * w + x] = (byte)((buf[i + 2] * 77 + buf[i + 1] * 150 + buf[i] * 29) >> 8);
}
}
} finally { bmp.UnlockBits(data); if (bmp != src) bmp.Dispose(); }
return dst;
}
// Area-average downscale (coarse search layer)
private static byte[] Downscale(byte[] src, int w, int h, int f, out int ow, out int oh) {
ow = w / f; oh = h / f;
var dst = new byte[ow * oh];
for (int y = 0; y < oh; y++) {
for (int x = 0; x < ow; x++) {
int sum = 0;
for (int dy = 0; dy < f; dy++) {
int row = (y * f + dy) * w;
for (int dx = 0; dx < f; dx++) sum += src[row + x * f + dx];
}
dst[y * ow + x] = (byte)(sum / (f * f));
}
}
return dst;
}
// Zero-mean normalized cross correlation; 1.0 = identical
private static double Zncc(byte[] S, int sw, int sx, int sy, byte[] T, int tw, int th) {
int n = tw * th;
double sumT = 0;
for (int i = 0; i < n; i++) sumT += T[i];
double meanT = sumT / n;
double sumS = 0;
for (int y = 0; y < th; y++) {
int row = (sy + y) * sw + sx;
for (int x = 0; x < tw; x++) sumS += S[row + x];
}
double meanS = sumS / n;
double num = 0, dS = 0, dT = 0;
for (int y = 0; y < th; y++) {
int row = (sy + y) * sw + sx;
int trow = y * tw;
for (int x = 0; x < tw; x++) {
double a = S[row + x] - meanS;
double b = T[trow + x] - meanT;
num += a * b; dS += a * a; dT += b * b;
}
}
double den = Math.Sqrt(dS * dT);
if (den < 1e-9) return 0;
return num / den;
}
/**
// (comment removed: ASCII only)
// (comment removed: ASCII only)
*/
public static string Match(string screenPath, string templatePath, int maxResults, double threshold, int searchX, int searchY, int searchW, int searchH) {
var hits = new List<Hit>();
using (var screenBmp = new Bitmap(screenPath))
using (var tmplBmp = new Bitmap(templatePath)) {
int sw, sh, tw, th;
var S0 = Gray(screenBmp, out sw, out sh);
var T0 = Gray(tmplBmp, out tw, out th);
if (tw >= sw || th >= sh) return "[]";
// Guard: a flat template carries no gradient information, so correlation is
// undefined (ZNCC denominator ~ 0). Report it explicitly instead of silently
// returning no hits - a uniform crop usually means the wrong region was picked.
//
// NOTE: build all JSON/string literals with the char constant Q.
// Writing a backslash-quote inside a TS template literal collapses it to a bare
// quote and breaks the C# compile - this trap bit this file twice.
const char Q = '"';
var ci0 = System.Globalization.CultureInfo.InvariantCulture;
double tSum = 0, tSum2 = 0;
for (int i = 0; i < T0.Length; i++) { tSum += T0[i]; tSum2 += (double)T0[i] * T0[i]; }
double tMean = tSum / T0.Length;
double tVar = (tSum2 / T0.Length) - (tMean * tMean);
if (tVar < 4.0) {
return "[" + "{" + Q + "__error" + Q + ":" + Q + "template_has_no_contrast" + Q
+ "," + Q + "variance" + Q + ":" + tVar.ToString("F2", ci0) + "}" + "]";
}
// Optional search window: crop a sub-image, then add the offset back to hit coords
int ox = 0, oy = 0;
if (searchW > 0 && searchH > 0) {
ox = Math.Max(0, Math.Min(searchX, sw - 1));
oy = Math.Max(0, Math.Min(searchY, sh - 1));
int w = Math.Min(searchW, sw - ox), h = Math.Min(searchH, sh - oy);
var sub = new byte[w * h];
for (int y = 0; y < h; y++) Array.Copy(S0, (oy + y) * sw + ox, sub, y * w, w);
S0 = sub; sw = w; sh = h;
}
const int F = 4;
int sw4, sh4, tw4, th4;
var S4 = Downscale(S0, sw, sh, F, out sw4, out sh4);
var T4 = Downscale(T0, tw, th, F, out tw4, out th4);
// (1) coarse scan: collect candidates.
// NOTE: the pre-filter must be MUCH looser than the final threshold.
// Downsampling lowers the correlation of the true position (typically 0.7-0.85
// for a 0.9+ full-res match), so a tight pre-filter discards the right answer
// before refinement ever runs (this exact bug made every match return empty).
var cands = new List<Hit>();
if (tw4 > 0 && th4 > 0 && tw4 <= sw4 && th4 <= sh4) {
double preFilter = Math.Max(0.30, threshold - 0.30);
double bestCoarse = -1; int bx4 = 0, by4 = 0;
for (int y = 0; y <= sh4 - th4; y++) {
for (int x = 0; x <= sw4 - tw4; x++) {
double sc = Zncc(S4, sw4, x, y, T4, tw4, th4);
if (sc > bestCoarse) { bestCoarse = sc; bx4 = x; by4 = y; }
if (sc >= preFilter) cands.Add(new Hit { x = x * F, y = y * F, w = tw, h = th, score = sc });
}
}
// Belt and braces: always keep the coarse best, even if it missed the pre-filter
if (bestCoarse > 0) {
bool seen = false;
foreach (var c in cands) { if (Math.Abs(c.x - bx4 * F) < F && Math.Abs(c.y - by4 * F) < F) { seen = true; break; } }
if (!seen) cands.Add(new Hit { x = bx4 * F, y = by4 * F, w = tw, h = th, score = bestCoarse });
}
}
cands.Sort((a, b) => b.score.CompareTo(a.score));
if (cands.Count > 40) cands.RemoveRange(40, cands.Count - 40);
// (2) refine at full resolution within a +/-F*2 neighborhood
foreach (var c in cands) {
double best = -1; int bx = c.x, by = c.y;
for (int dy = -F * 2; dy <= F * 2; dy++) {
for (int dx = -F * 2; dx <= F * 2; dx++) {
int x = c.x + dx, y = c.y + dy;
if (x < 0 || y < 0 || x + tw > sw || y + th > sh) continue;
double sc = Zncc(S0, sw, x, y, T0, tw, th);
if (sc > best) { best = sc; bx = x; by = y; }
}
}
if (best >= threshold) hits.Add(new Hit { x = bx + ox, y = by + oy, w = tw, h = th, score = best });
}
// (3) de-dup: non-maximum suppression (keep best within 8px)
hits.Sort((a, b) => b.score.CompareTo(a.score));
var final = new List<Hit>();
foreach (var h in hits) {
bool dup = false;
foreach (var f in final) {
if (Math.Abs(f.x - h.x) < 8 && Math.Abs(f.y - h.y) < 8) { dup = true; break; }
}
if (!dup) final.Add(h);
if (final.Count >= maxResults) break;
}
var parts = new List<string>();
foreach (var h in final) {
// Build JSON by concatenation; reuse the shared Q / ci0 declared above.
var ci = ci0;
parts.Add("{" + Q + "x" + Q + ":" + h.x.ToString(ci) + "," + Q + "y" + Q + ":" + h.y.ToString(ci)
+ "," + Q + "width" + Q + ":" + h.w.ToString(ci) + "," + Q + "height" + Q + ":" + h.h.ToString(ci)
+ "," + Q + "score" + Q + ":" + h.score.ToString("F4", ci) + "}");
}
return "[" + String.Join(",", parts.ToArray()) + "]";
}
}
// Capture a screen region to a file (avoids extra process round-trips)
public static string Capture(string outPath, int x, int y, int w, int h) {
using (var bmp = new Bitmap(w, h))
using (var g = Graphics.FromImage(bmp)) {
g.CopyFromScreen(x, y, 0, 0, new Size(w, h));
bmp.Save(outPath, ImageFormat.Png);
}
return outPath;
}
}
"@ -ReferencedAssemblies System.Drawing
`;
/**
* 助手统一预加载段(window.list / window.focus / screen.find 的 UIA/OCR/图像三引擎共用)。
* ⚠️ 必须定义在四段之后(块级作用域),且**四段缺一不可** ——
* 漏拼会被 esbuild tree-shake,直到运行时才报「找不到类型」(真机踩过)。
* tests/unit/win-prelude.test.mjs 对此有回归断言。
*/
/**
* 后台预热常驻助手(agent 启动后调用一次,不阻塞、失败无副作用)。
*
* 动机:助手的「首次调用」要付启动 + 类型预加载的固定成本(~1-3s)。
* 若不预热,这笔成本会算在**第一个真实用户调用**头上(真机指标里能明显看到
* window.list 的 P95 被抬高)。启动后主动预热,把成本挪到无人等待的时刻。
*/
export function warmupWindowHelper(): void {
if (!IS_WINDOWS || process.env['NODEAGENT_NO_PS_HELPER']) return;
setTimeout(() => {
void runPowerShellSmart({
body: "Write-Output 'warmup'",
prelude: WIN_HELPER_PRELUDE,
timeoutMs: 90_000,
label: 'win',
log: () => undefined,
}).catch(() => {
/* 预热失败无所谓:真实调用时仍会按原路径重试 */
});
}, 1500).unref?.();
}
export const WIN_HELPER_PRELUDE = `${WIN32_PRELUDE}
${UIA_ASSEMBLY_PRELUDE}
${OCR_PRELUDE}
${IMAGE_MATCH_PRELUDE}`;
interface WinInfo {
hwnd: string;
title: string;
@@ -119,13 +505,15 @@ interface WinInfo {
export async function windowList(args: Args): Promise<unknown> {
if (!IS_WINDOWS) {
// v12.3:macOS 走 AppleScript 实现(需辅助功能权限),其余平台明确不支持
if (process.platform === 'darwin') return macWindowList(args);
throw new CapabilityError(
ErrorCodes.UNSUPPORTED_PLATFORM,
'window.list 仅在被控端为 Windows 时可用',
'window.list 仅支持 Windows / macOS 被控端',
{ platform: process.platform },
);
}
const out = await runPS(LIST_SCRIPT, 40_000);
const out = await runPS(LIST_BODY, 40_000);
let all: WinInfo[] = toArray<WinInfo>(extractJson<WinInfo[]>(out) ?? []);
// 过滤掉 UWP 的隐形壳窗口(尺寸异常大且进程为 ApplicationFrameHost 的重复项保留)
all = all.filter((w) => w.width > 40 && w.height > 40);
@@ -136,16 +524,19 @@ export async function windowList(args: Args): Promise<unknown> {
all = all.filter((w) => re.test(w.title));
}
const limit = (args['limit'] as number | undefined) ?? 50;
return { windows: all.slice(0, limit) };
// 前台窗口排最前:低 limit 时也不会把它截掉(真机踩过:被系统对话框抢占焦点却查不到)
all.sort((a, b) => Number(b.is_foreground) - Number(a.is_foreground));
return { windows: all.slice(0, limit), total: all.length };
}
// ---------------- window.focus ----------------
export async function windowFocus(args: Args): Promise<unknown> {
if (!IS_WINDOWS) {
if (process.platform === 'darwin') return macWindowFocus(args);
throw new CapabilityError(
ErrorCodes.UNSUPPORTED_PLATFORM,
'window.focus 仅在被控端为 Windows 时可用',
'window.focus 仅支持 Windows / macOS 被控端',
{ platform: process.platform },
);
}
@@ -154,8 +545,34 @@ export async function windowFocus(args: Args): Promise<unknown> {
if (!title && !hwnd) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, '需提供 title(正则)或 hwnd');
}
// v12.2:等窗口出现(应用启动有延迟,一次性查找极易假失败)
const waitMs = Math.max(0, Math.min(30_000, (args['wait_ms'] as number | undefined) ?? 0));
if (waitMs > 0 && !hwnd) {
const t0 = Date.now();
let lastErr: unknown = null;
while (Date.now() - t0 < waitMs) {
try {
return await focusOnce(title, hwnd);
} catch (err) {
lastErr = err;
await new Promise((r) => setTimeout(r, 500));
}
}
throw lastErr instanceof Error
? lastErr
: new CapabilityError(ErrorCodes.EXECUTION_FAILED, `等待 ${waitMs}ms 仍未找到窗口`, { title });
}
const script = `${WIN32_PRELUDE}
return focusOnce(title, hwnd);
}
/** 单次聚焦(供等待重试复用)。 */
/**
* 聚焦脚本主体(不含 prelude)。⚠️ 严禁 exit —— 会杀掉常驻助手进程;
* 未找到时改为输出 `{"found":false}` 并 return。
*/
function focusBody(title: string | undefined, hwnd: string | undefined): string {
return `
$h = [IntPtr]::Zero
$want = ${JSON.stringify(hwnd ?? '')}
if ($want -ne '') { $h = [IntPtr][Convert]::ToInt64($want, 16) }
@@ -176,10 +593,28 @@ else {
[void][NAWin32]::EnumWindows($cb, [IntPtr]::Zero)
$h = $script:found
}
if ($h -eq [IntPtr]::Zero) { Write-Output '{"found":false}'; exit 0 }
if ($h -eq [IntPtr]::Zero) { Write-Output '{"found":false}'; return }
[void][NAWin32]::ShowWindow($h, 9) # SW_RESTORE
[void][NAWin32]::SetForegroundWindow($h)
Start-Sleep -Milliseconds 400
# ⚠️ SetForegroundWindow 会被 Windows 的前台锁定规则**静默否决**(真机踩过:
# 返回后 GetForegroundWindow 仍是别人,后续键鼠全打进错误窗口)。
# 故校验一次;未生效则在标题栏做一次真实点击 —— 点击比 API 更能说服系统切换前台。
$activatedBy = 'api'
if ([NAWin32]::GetForegroundWindow() -ne $h) {
$rr = New-Object NAWin32+RECT
[void][NAWin32]::GetWindowRect($h, [ref]$rr)
$cx = [int](($rr.Left + $rr.Right) / 2)
$cy = [int]($rr.Top + 8) # 标题栏(避开内容区,避免误触按钮)
[NAWin32]::SetCursorPos($cx, $cy) | Out-Null
Start-Sleep -Milliseconds 120
[NAWin32]::mouse_event(0x0002, 0, 0, 0, [UIntPtr]::Zero) # LEFTDOWN
Start-Sleep -Milliseconds 60
[NAWin32]::mouse_event(0x0004, 0, 0, 0, [UIntPtr]::Zero) # LEFTUP
Start-Sleep -Milliseconds 350
if ([NAWin32]::GetForegroundWindow() -eq $h) { $activatedBy = 'click' }
}
$r = New-Object NAWin32+RECT
[void][NAWin32]::GetWindowRect($h, [ref]$r)
$sb2 = New-Object System.Text.StringBuilder 512
@@ -188,9 +623,14 @@ $sb2 = New-Object System.Text.StringBuilder 512
found = $true; hwnd = ('0x{0:X}' -f $h.ToInt64()); title = $sb2.ToString()
x = $r.Left; y = $r.Top; width = ($r.Right - $r.Left); height = ($r.Bottom - $r.Top)
focused = ([NAWin32]::GetForegroundWindow() -eq $h)
activated_by = $activatedBy
} | ConvertTo-Json -Compress
`;
const out = await runPS(script, 40_000);
}
/** 单次聚焦(供等待重试复用)。 */
async function focusOnce(title: string | undefined, hwnd: string | undefined): Promise<unknown> {
const out = await runPS(focusBody(title, hwnd), 40_000);
const obj = extractJson<Record<string, unknown>>(out) ?? {};
if (obj['found'] === false) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '未找到匹配的窗口', { title, hwnd });
@@ -201,25 +641,7 @@ $sb2 = New-Object System.Text.StringBuilder 512
// ---------------- screen.find ----------------
const FIND_SCRIPT_PRELUDE = `
Add-Type -AssemblyName UIAutomationClient
Add-Type -AssemblyName UIAutomationTypes
Add-Type -AssemblyName WindowsBase
function Get-WinByTitle([string]$re) {
$root = [System.Windows.Automation.AutomationElement]::RootElement
$cond = New-Object System.Windows.Automation.PropertyCondition(
[System.Windows.Automation.AutomationElement]::ControlTypeProperty,
[System.Windows.Automation.ControlType]::Window)
$wins = $root.FindAll([System.Windows.Automation.TreeScope]::Children, $cond)
foreach ($w in $wins) {
try {
$t = $w.Current.Name
if ($t -and $t -match $re) { return $w }
} catch {}
}
return $null
}
`;
// v14:原 FIND_SCRIPT_PRELUDE 已并入 WIN_HELPER_PRELUDE(助手启动时预加载一次)
/**
* 在界面中查找 UI 元素并返回屏幕坐标。
@@ -232,103 +654,160 @@ function Get-WinByTitle([string]$re) {
*/
export async function screenFind(args: Args): Promise<unknown> {
if (!IS_WINDOWS) {
// macOS:无 UIA 等价物 → 截图 + Vision OCR(见 darwin.ts 的权限说明)
if (process.platform === 'darwin') return macScreenFind(args);
throw new CapabilityError(
ErrorCodes.UNSUPPORTED_PLATFORM,
'screen.find 仅在被控端为 Windows 时可用',
'screen.find 仅支持 Windows / macOS 被控端',
{ platform: process.platform },
);
}
const text = args['text'] as string;
if (typeof text !== 'string' || text.length === 0) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'text 不能为空');
const text = (args['text'] as string | undefined) ?? '';
const wantImage = (args['method'] as string | undefined) === 'image';
if (!wantImage && (typeof text !== 'string' || text.length === 0)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'text 不能为空(method=image 时用 template 代替)');
}
const windowTitle = args['window'] as string | undefined;
const controlType = args['control_type'] as string | undefined;
const limit = (args['limit'] as number | undefined) ?? 20;
const method = (args['method'] as string | undefined) ?? 'auto'; // auto | uia | ocr
// v12.2:等待语义 —— 界面常有动画/加载延迟,一次性查找极易假失败。
// wait_ms > 0 时轮询直到命中或超时(返回 waited_ms 让调用方知情)。
const waitMs = Math.max(0, Math.min(30_000, (args['wait_ms'] as number | undefined) ?? 0));
const intervalMs = Math.max(100, Math.min(2000, (args['interval_ms'] as number | undefined) ?? 400));
// v12.3:显式区域(优先于 window):OCR 只截这一块,更快也更准
const regionArg = args['region'] as
| { x: number; y: number; width: number; height: number }
| undefined;
// v15:图像模板匹配
const template = args['template'] as string | undefined;
const threshold = Math.max(0.3, Math.min(0.999, (args['threshold'] as number | undefined) ?? 0.85));
// ---------- UIA 引擎 ----------
const uiaFind = async (): Promise<Record<string, unknown>[]> => {
const ct = controlType ?? '';
const script = `${FIND_SCRIPT_PRELUDE}
const body = `
$text = ${JSON.stringify(text)}
$limit = ${Number(limit)}
$scope = [System.Windows.Automation.AutomationElement]::RootElement
$ct = ${JSON.stringify(controlType ?? '')}
$winTitle = ${JSON.stringify(windowTitle ?? '')}
if ($winTitle -ne '') {
$w = Get-WinByTitle $winTitle
if ($w -eq $null) { Write-Output '[]'; exit 0 }
$scope = $w
}
$nameCond = New-Object System.Windows.Automation.PropertyCondition(
[System.Windows.Automation.AutomationElement]::NameProperty, $text,
[System.Windows.Automation.PropertyConditionFlags]::IgnoreCase)
$ct = ${JSON.stringify(ct)}
if ($ct -ne '') {
$ctObj = $null
try { $ctObj = [System.Windows.Automation.ControlType]::$ct } catch {}
if ($ctObj -ne $null) {
$ctCond = New-Object System.Windows.Automation.PropertyCondition(
[System.Windows.Automation.AutomationElement]::ControlTypeProperty, $ctObj)
$cond = New-Object System.Windows.Automation.AndCondition($nameCond, $ctCond)
} else { $cond = $nameCond }
} else { $cond = $nameCond }
$found = $scope.FindAll([System.Windows.Automation.TreeScope]::Descendants, $cond)
$out = New-Object System.Collections.ArrayList
foreach ($e in $found) {
if ($out.Count -ge $limit) { break }
if ($winTitle -ne '') {
# 指定窗口:直接在该窗口子树内找
$w = Get-WinByTitle $winTitle
if ($w -eq $null) { Write-Output '[]'; return } # 严禁 exit:会杀掉常驻助手
$out = Find-UiaByText $w $text $limit $ct
} else {
# v14 优化:先在前台窗口子树内找(典型只几百个元素,快一个数量级),
# 找不到再退回全桌面遍历(原来每次都在全桌面上跑,真机实测 3.1~3.7s)。
$fgEl = $null
try {
$r = $e.Current.BoundingRectangle
if ($r.Width -le 0 -or $r.Height -le 0) { continue }
$out.Add([pscustomobject]@{
name = $e.Current.Name
control_type = ($e.Current.ControlType.ProgrammaticName -replace 'ControlType\\\\.','')
automation_id = $e.Current.AutomationId
x = [int]($r.Left + $r.Width / 2)
y = [int]($r.Top + $r.Height / 2)
left = [int]$r.Left; top = [int]$r.Top
width = [int]$r.Width; height = [int]$r.Height
})
$fgEl = [System.Windows.Automation.AutomationElement]::FromHandle([NAWin32]::GetForegroundWindow())
} catch {}
if ($fgEl -ne $null) { $out = Find-UiaByText $fgEl $text $limit $ct }
if ($out.Count -eq 0) {
$out = Find-UiaByText ([System.Windows.Automation.AutomationElement]::RootElement) $text $limit $ct
}
}
$out | ConvertTo-Json -Compress
# ── 输出 ──(遍历与子串过滤已收敛到预加载的 Find-UiaByText,避免逻辑重复)
if ($out.Count -eq 0) { Write-Output '[]' } else { $out | ConvertTo-Json -Compress }
`;
const out = await runPS(script, 60_000);
const out = await runPS(body, 60_000);
return toArray<Record<string, unknown>>(extractJson<Record<string, unknown>[]>(out) ?? []);
};
// ---------- 图像模板引擎(灰度 ZNCC 粗到精,v15) ----------
// 适用:纯图标 / 无文字的自绘控件(UIA 无控件树、OCR 无文字可读的盲区)
const imageFind = async (): Promise<Record<string, unknown>[]> => {
if (!template) {
throw new CapabilityError(
ErrorCodes.PARAM_INVALID,
'method=image 需要 template 参数(被控端上的模板图片路径,支持 png/jpg/bmp)',
);
}
const tmpDir = join(agentDir(), 'tmp');
mkdirSync(tmpDir, { recursive: true });
const shot = join(tmpDir, `match-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.png`);
// PowerShell 单引号字面量:不插值(避免路径里的 $ 被展开)、不处理反斜杠转义。
// 不能用 JSON.stringify —— 它会产出双引号串,而 PS 双引号会做变量插值且把 \ 当字面两个反斜杠。
const q = (v: string): string => `'${v.replace(/'/g, "''")}'`;
// 截图区域:显式 region 优先,否则整块虚拟屏
const capX = regionArg ? regionArg.x : 'VSX';
const capY = regionArg ? regionArg.y : 'VSY';
const capW = regionArg ? regionArg.width : 'VSW';
const capH = regionArg ? regionArg.height : 'VSH';
const body = `
if (-not (Test-Path ${q(template)})) {
Write-Output '{"__error":"模板文件不存在"}'
return
}
$vs = [System.Windows.Forms.SystemInformation]::VirtualScreen
$cx = ${typeof capX === 'number' ? capX : '$vs.X'}
$cy = ${typeof capY === 'number' ? capY : '$vs.Y'}
$cw = ${typeof capW === 'number' ? capW : '$vs.Width'}
$ch = ${typeof capH === 'number' ? capH : '$vs.Height'}
try {
[void][NAImageMatch]::Capture(${q(shot)}, $cx, $cy, $cw, $ch)
$json = [NAImageMatch]::Match(${q(shot)}, ${q(template)}, ${Number(limit)}, ${threshold}, 0, 0, 0, 0)
Write-Output $json
} catch {
Write-Output ('{"__error":"' + $_.Exception.Message.Replace('"','''') + '"}')
} finally {
Remove-Item ${q(shot)} -Force -ErrorAction SilentlyContinue
}
`;
const out = await runPS(body, 120_000);
type ImgHit = { x: number; y: number; width: number; height: number; score: number };
const raw = extractJson<Record<string, unknown> | ImgHit[]>(out);
if (raw && !Array.isArray(raw) && typeof raw === 'object' && '__error' in raw) {
throw new CapabilityError(
ErrorCodes.EXECUTION_FAILED,
`图像匹配失败: ${String((raw as { __error?: string }).__error)}`,
);
}
const hits: ImgHit[] = Array.isArray(raw) ? raw : [];
// 模板纯色时 C# 会返回 [{__error:...}](数组形式),这里统一转成可读错误
const first = hits[0] as unknown as { __error?: string; variance?: number } | undefined;
if (first && typeof first === 'object' && first.__error === 'template_has_no_contrast') {
throw new CapabilityError(
ErrorCodes.PARAM_INVALID,
'模板几乎没有色彩/明暗差异(纯色或低对比),无法用于模板匹配;请改用有纹理或图标的区域',
{ variance: first.variance },
);
}
return hits.map((h) => ({
name: `image:${template.split(/[\\/]/).pop() ?? 'template'}`,
control_type: 'Image(template)',
score: h.score,
// 命中坐标相对截图原点 -> 补回屏幕原点
x: Math.round(capX === 'VSX' ? h.x + (regionArg?.x ?? 0) : h.x + Number(capX)),
y: Math.round(capY === 'VSY' ? h.y + (regionArg?.y ?? 0) : h.y + Number(capY)),
left: Math.round(capX === 'VSX' ? h.x + (regionArg?.x ?? 0) : h.x + Number(capX)),
top: Math.round(capY === 'VSY' ? h.y + (regionArg?.y ?? 0) : h.y + Number(capY)),
width: h.width,
height: h.height,
}));
};
// ---------- OCR 引擎(截图 → Windows.Media.Ocr → 文字坐标) ----------
const ocrFind = async (): Promise<Record<string, unknown>[]> => {
const script = `
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Drawing
Add-Type -AssemblyName System.Runtime.WindowsRuntime
Add-Type -AssemblyName System.Windows.Forms
# DPI 感知:保证截图像素与窗口矩形同为物理坐标
Add-Type -TypeDefinition 'using System.Runtime.InteropServices; public class NADPI { [DllImport("user32.dll")] public static extern bool SetProcessDPIAware(); }'
[void][NADPI]::SetProcessDPIAware()
$null = [Windows.Media.Ocr.OcrEngine, Windows.Foundation, ContentType=WindowsRuntime]
$null = [Windows.Graphics.Imaging.BitmapDecoder, Windows.Foundation, ContentType=WindowsRuntime]
$null = [Windows.Storage.Streams.InMemoryRandomAccessStream, Windows.Foundation, ContentType=WindowsRuntime]
$null = [Windows.Storage.Streams.DataWriter, Windows.Foundation, ContentType=WindowsRuntime]
$null = [Windows.Graphics.Imaging.SoftwareBitmap, Windows.Foundation, ContentType=WindowsRuntime]
# WinRT IAsyncOperation -> Task 等待辅助
$asTaskGeneric = ([System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object { $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation\`1' })[0]
function Await($WinRtTask, $ResultType) {
$asTask = $asTaskGeneric.MakeGenericMethod($ResultType)
$netTask = $asTask.Invoke($null, @($WinRtTask))
$netTask.Wait(-1) | Out-Null
$netTask.Result
}
# 截图区域:指定窗口则截该窗口,否则整块虚拟屏
$winRe = ${JSON.stringify(windowTitle ?? '')}
const body = `
# OCR 预加载段已在助手启动时完成(类型/DPI/Await);此处只做本次逻辑
if ($null -eq $script:ocrReady -or -not $script:ocrReady) { Write-Output '[]'; return }
# 截图区域:显式 region 优先 > 指定窗口 > 整块虚拟屏
$xv = ${regionArg ? regionArg.x : -1}
$yv = ${regionArg ? regionArg.y : -1}
$wv = ${regionArg ? regionArg.width : -1}
$hv = ${regionArg ? regionArg.height : -1}
$winRe = ${JSON.stringify(regionArg ? '' : windowTitle ?? '')}
$x = 0; $y = 0
$vs = [System.Windows.Forms.SystemInformation]::VirtualScreen
$w = $vs.Width; $h = $vs.Height
if ($winRe -ne '') {
if ($xv -ge 0 -and $wv -gt 0) {
$x = $xv; $y = $yv; $w = $wv; $h = $hv
} elseif ($winRe -ne '') {
Add-Type @"
using System;
using System.Text;
@@ -359,7 +838,7 @@ public class NAEnum {
$x = $r.Left; $y = $r.Top; $w = $r.Right - $r.Left; $h = $r.Bottom - $r.Top
}
}
if ($w -le 0 -or $h -le 0) { Write-Output '[]'; exit 0 }
if ($w -le 0 -or $h -le 0) { Write-Output '[]'; return }
$bmp = New-Object System.Drawing.Bitmap($w, $h)
$g = [System.Drawing.Graphics]::FromImage($bmp)
@@ -379,7 +858,7 @@ $decoder = Await ([Windows.Graphics.Imaging.BitmapDecoder]::CreateAsync($ras)) (
$soft = Await ($decoder.GetSoftwareBitmapAsync()) ([Windows.Graphics.Imaging.SoftwareBitmap])
$engine = [Windows.Media.Ocr.OcrEngine]::TryCreateFromUserProfileLanguages()
if ($null -eq $engine) { Write-Output '[]'; exit 0 }
if ($null -eq $engine) { Write-Output '[]'; return }
$result = Await ($engine.RecognizeAsync($soft)) ([Windows.Media.Ocr.OcrResult])
$text = ${JSON.stringify(text)}
@@ -453,21 +932,32 @@ foreach ($line in $result.Lines) {
}
$out | ConvertTo-Json -Compress
`;
const out = await runPS(script, 90_000);
const out = await runPS(body, 90_000);
return toArray<Record<string, unknown>>(extractJson<Record<string, unknown>[]>(out) ?? []);
};
if (method === 'uia') {
return { matches: (await uiaFind()).slice(0, limit), engine: 'uia' };
/** 单次尝试(按 method 选择引擎)。 */
const attempt = async (): Promise<{ matches: Record<string, unknown>[]; engine: string }> => {
if (method === 'uia') return { matches: (await uiaFind()).slice(0, limit), engine: 'uia' };
if (method === 'ocr') return { matches: (await ocrFind()).slice(0, limit), engine: 'ocr' };
if (method === 'image') return { matches: (await imageFind()).slice(0, limit), engine: 'image' };
// auto:UIA 优先(快且带控件语义),找不到再 OCR 兜底(自绘 UI)
const uiaMatches = await uiaFind();
if (uiaMatches.length > 0) return { matches: uiaMatches.slice(0, limit), engine: 'uia' };
const ocrMatches = await ocrFind();
return { matches: ocrMatches.slice(0, limit), engine: 'ocr' };
};
const startedAt = Date.now();
let last = await attempt();
while (last.matches.length === 0 && Date.now() - startedAt < waitMs) {
await new Promise((r) => setTimeout(r, intervalMs));
last = await attempt();
}
if (method === 'ocr') {
return { matches: (await ocrFind()).slice(0, limit), engine: 'ocr' };
}
// auto:UIA 优先(快且带控件语义),找不到再 OCR 兜底(自绘 UI)
const uiaMatches = await uiaFind();
if (uiaMatches.length > 0) {
return { matches: uiaMatches.slice(0, limit), engine: 'uia' };
}
const ocrMatches = await ocrFind();
return { matches: ocrMatches.slice(0, limit), engine: 'ocr' };
return {
matches: last.matches,
engine: last.engine,
waited_ms: Date.now() - startedAt,
...(waitMs > 0 ? { wait_ms: waitMs } : {}),
};
}
+18
View File
@@ -1,6 +1,7 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import selfsigned from 'selfsigned';
import { certFingerprint } from '@nodeagent/protocol';
import { agentDir } from './config.js';
export interface TlsMaterial {
@@ -31,3 +32,20 @@ export function ensureCert(): TlsMaterial {
writeFileSync(keyPath, pems.private, { mode: 0o600 });
return { cert: pems.cert, key: pems.private };
}
/**
* 当前证书的 SHA256 指纹(hex,64 位)。
*
* 用途:控制端**钉住**这个指纹(v21 第一批加固)—— 自签证书没有 CA 可信链,
* 唯一能证明"还是那台被控端"的办法就是指纹比对。install.ps1 会用 .NET 算同一值
* 印给操作者带外核对。
*/
export function currentCertFingerprint(): string | null {
const certPath = join(agentDir(), 'certs', 'cert.pem');
try {
if (!existsSync(certPath)) return null;
return certFingerprint(readFileSync(certPath, 'utf8'));
} catch {
return null;
}
}
+33 -1
View File
@@ -8,8 +8,17 @@ export interface AgentConfig {
host: string;
port: number;
tls: boolean;
/** v1 简化:预共享密钥存于 600 权限文件;v2 升级 DPAPI */
/** v1 简化:预共享密钥存于 600 权限文件;v2 升级 DPAPI。**所有调用方共用**,仅适合信任网络 */
key: string;
/**
* v22:**每客户端一把**预共享密钥(`client_id` → key)。
*
* 为什么需要它:psk 模式下 `client_id` 由客户端自报,历史上「一把共享 key」等于
* **持有者可冒充任意 client_id**,ACL 的身份维度形同虚设。
* 改为每个 client_id 一把钥匙后,「知道 key A 就只能以 A 的身份进来」——
* 身份才真正被绑定。未登记的 client_id 回落到上面的 `key`(旧部署行为不变)。
*/
keys?: Record<string, string>;
log_level: 'debug' | 'info' | 'warn';
/**
* v2:是否允许输入控制(鼠标 / 键盘)。
@@ -24,6 +33,14 @@ export interface AgentConfig {
auth_mode?: 'psk' | 'ed25519';
/** v3:ed25519 模式下的能力级授权策略(默认拒绝) */
acl?: AclPolicy;
/**
* v21:允许接入的来源网段(CIDR / 单 IP / `*`)。**未配置 = 放行全部**。
*
* ⚠️ 默认放行是为了不打断现有部署,但「换到不可信网络就自动暴露」正是
* 第一批要收敛的风险 —— 建议在家庭/办公网段上显式配置,例如:
* "allow_from": ["192.168.0.0/16", "10.0.0.0/8"]
*/
allow_from?: string[];
/** v3+:审计日志配置 */
audit?: {
/** 是否启用(默认 true) */
@@ -45,6 +62,17 @@ export interface AgentConfig {
broadcast?: string;
/** 广播间隔毫秒(默认 5000) */
interval_ms?: number;
/**
* v22:广播详情级别。
* - `full`(默认,向后兼容):携带地址/端口/认证方式/键鼠开关等全部信息
* - `minimal`:只广播「存在 + node_id + 时间」,详情放进 `detail` 字段;
* 配了 `secret` 时详情会被 **HMAC 认证**(控制端持钥才看得到、且能验证真伪)
* ⚠️ 广播是**未认证**的:任何人收 UDP 都能看到内容。minimal 仍会暴露
* 「这里有一台可被接管的机器」,但不再泄露地址、端口与认证方式。
*/
detail?: 'full' | 'minimal';
/** v22:详情认证密钥(配合 detail=minimal 使用;配了就会自动启用 minimal) */
secret?: string;
};
/**
* v5:文件访问白名单(根目录列表)。
@@ -57,6 +85,10 @@ export interface AgentConfig {
* 适用于被控端位于 NAT / 无公网 IP 的环境。
*/
hub?: {
/** v12:最多维持的 Hub 槽位数(并发控制端上限),默认 2 */
max_slots?: number;
/** v12:常备(预热的)槽位数,默认 2;设为 1 可省一条空闲连接 */
warm_slots?: number;
enabled: boolean;
/** Hub 地址,例如 wss://hub.example.com/hub/agent */
url: string;
+76 -18
View File
@@ -1,5 +1,6 @@
import { createSocket, type Socket } from 'node:dgram';
import { DEFAULT_DISCOVERY_PORT } from '@nodeagent/protocol';
import { DEFAULT_DISCOVERY_PORT, computeHmac } from '@nodeagent/protocol';
import { currentCertFingerprint } from './certs.js';
export { DEFAULT_DISCOVERY_PORT };
@@ -9,14 +10,41 @@ export interface BeaconPayload {
service: 'nodeagent';
version: string;
node_id: string;
/**
* 以下字段在 `full` 模式下直给在顶层;在 `minimal` 模式下收在 `detail` 里
* (顶层只留「存在性 + node_id + 时间」)。故为可选。
*/
/** 被控端可达地址(由被控端自行判定) */
host: string;
port: number;
tls: boolean;
auth_mode: string;
host?: string;
port?: number;
tls?: boolean;
auth_mode?: string;
/** 需要输入控制开关的状态,便于控制端提示 */
input_enabled: boolean;
platform: string;
input_enabled?: boolean;
/**
* v21:TLS 证书指纹(sha256 hex)。
* ⚠️ 这是**未认证**广播,可被伪造 —— 只用于「看起来像不像」,不能作为身份凭据;
* 真正的身份确认发生在握手后(auth_ok 里带指纹,且控制端会严格比对)。
*/
cert_sha256?: string | null;
platform?: string;
/**
* v22:`true` 表示这是**最小化**广播 —— 顶层只有存在性信息,
* 真实地址/端口/认证方式等在 `detail` 里(有 secret 时带 `sig` 认证)。
*/
minimal?: boolean;
/** v22:最小化广播里的详情(可能带 `sig`,由持有 secret 的控制端验证) */
detail?: {
host: string;
port: number;
tls: boolean;
auth_mode: string;
input_enabled: boolean;
cert_sha256?: string | null;
platform: string;
};
/** v22:详情签名(HMAC-SHA256 over `node_id|ts|JSON(detail)`),由持有 secret 的控制端验证 */
sig?: string;
ts: number;
}
@@ -27,8 +55,14 @@ export interface BeaconOptions {
/** 广播地址;局域网内通常为 255.255.255.255 */
broadcast: string;
intervalMs: number;
/** 报文中除 ts 外的固定字段 */
base: Omit<BeaconPayload, 'service' | 'version' | 'ts'>;
/** 报文中除 ts 外的固定字段(full 模式直给顶层的那组) */
base: Required<
Pick<BeaconPayload, 'node_id' | 'host' | 'port' | 'tls' | 'auth_mode' | 'input_enabled' | 'platform'>
>;
/** v22:详情级别;配了 secret 也会自动启用 minimal */
detail?: 'full' | 'minimal';
/** v22:详情认证密钥(HMAC-SHA256 over node_id|ts|detail) */
secret?: string;
}
export interface Beacon {
@@ -56,15 +90,39 @@ export function startBeacon(opts: BeaconOptions, onError?: (msg: string) => void
const send = (): void => {
if (!ready) return;
const buf = Buffer.from(
JSON.stringify({
service: SERVICE_TAG,
version: PROTOCOL_VERSION,
...opts.base,
ts: Date.now(),
} satisfies BeaconPayload),
'utf8',
);
const ts = Date.now();
// v22 详情分级:minimal 模式只广播「存在性」,详情移入 detail{} 并用 secret 做 HMAC。
// 未配 secret 时 detail 仍然明文(此时 sig 缺省,控制端会标为「未认证」)。
const full = {
host: opts.base.host,
port: opts.base.port,
tls: opts.base.tls,
auth_mode: opts.base.auth_mode,
input_enabled: opts.base.input_enabled,
cert_sha256: currentCertFingerprint(),
platform: opts.base.platform,
};
const minimal = opts.detail === 'minimal' || !!opts.secret;
const payload: BeaconPayload = minimal
? {
service: SERVICE_TAG,
version: PROTOCOL_VERSION,
node_id: opts.base.node_id,
minimal: true,
detail: full,
sig: opts.secret
? computeHmac(opts.secret, `${opts.base.node_id}|${ts}|${JSON.stringify(full)}`)
: undefined,
ts,
}
: {
service: SERVICE_TAG,
version: PROTOCOL_VERSION,
cert_sha256: currentCertFingerprint(),
...opts.base,
ts,
};
const buf = Buffer.from(JSON.stringify(payload), 'utf8');
try {
socket.send(buf, 0, buf.length, opts.port, opts.broadcast, (err) => {
if (err) onError?.(`广播失败: ${err.message}`);
+274
View File
@@ -0,0 +1,274 @@
import { watch, type FSWatcher } from 'node:fs';
import { exec } from 'node:child_process';
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
type Args = Record<string, unknown>;
/**
* 被控端事件订阅(v12 / E1+E2)。
*
* 三类 watcher:
* file —— fs.watch 文件变动(增/删/改),支持递归
* process —— 轮询进程列表做差分,报「启动/退出」
* net —— 轮询监听端口做差分,报「端口开/关」
*
* 两条出口(同时提供):
* 1) **推送**:通过 event 通知发给创建订阅的那条连接(低延迟、无需轮询)
* 2) **环形缓冲 + 游标**:供无法接收推送的调用方(MCP)拉取
*/
export interface WatchEvent {
seq: number;
watch_id: string;
kind: 'file' | 'process' | 'net';
ts: number;
action: string;
target: string;
detail?: string;
}
interface WatchRecord {
id: string;
kind: 'file' | 'process' | 'net';
owner: string; // 连接标识,断开时清理
description: string;
createdAt: number;
count: number;
/** 推送出口(由 server 注入) */
emit: (evt: WatchEvent) => void;
dispose: () => void;
}
const watches = new Map<string, WatchRecord>();
const buffer: WatchEvent[] = [];
const MAX_BUFFER = 500;
let seq = 0;
let dropped = 0;
function nextId(): string {
return `w_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
}
function push(rec: WatchRecord, action: string, target: string, detail?: string): void {
seq += 1;
const evt: WatchEvent = {
seq,
watch_id: rec.id,
kind: rec.kind,
ts: Date.now(),
action,
target,
...(detail ? { detail } : {}),
};
rec.count += 1;
buffer.push(evt);
if (buffer.length > MAX_BUFFER) {
const overflow = buffer.length - MAX_BUFFER;
buffer.splice(0, overflow);
dropped += overflow;
}
try {
rec.emit(evt);
} catch {
/* 推送失败不影响缓冲 */
}
}
/** 简易 glob(仅支持 * 与 ?),用于文件名/进程名过滤。 */
function globMatch(pattern: string, value: string): boolean {
const re = new RegExp(
`^${pattern
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*/g, '.*')
.replace(/\?/g, '.')}$`,
'i',
);
return re.test(value);
}
// ---------------- 各类型 watcher ----------------
function startFileWatch(
rec: WatchRecord,
path: string,
recursive: boolean,
pattern?: string,
): () => void {
let watcher: FSWatcher;
try {
watcher = watch(path, { recursive, persistent: true });
} catch (err) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, `无法监控路径: ${path}`, {
detail: String(err),
});
}
watcher.on('change', (eventType, filename) => {
const name = filename ? String(filename) : '';
if (pattern && name && !globMatch(pattern, name)) return;
push(rec, eventType === 'rename' ? 'created_or_removed' : 'modified', name || path);
});
watcher.on('error', (err) => {
push(rec, 'watch_error', path, String(err));
});
return () => watcher.close();
}
function startProcessWatch(rec: WatchRecord, intervalMs: number, pattern?: string): () => void {
let previous = new Set<string>();
let first = true;
const tick = (): void => {
// Windows: tasklist / POSIX: ps(避免依赖额外包)
const cmd =
process.platform === 'win32'
? 'tasklist /fo csv /nh'
: 'ps -eo comm= 2>/dev/null || ps -axo comm=';
exec(cmd, { windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, (err, stdout) => {
if (err) return;
const names = new Set<string>();
for (const line of stdout.split('\n')) {
const raw = process.platform === 'win32' ? (line.split(',')[0] ?? '').replace(/"/g, '') : line.trim();
const name = raw.trim();
if (!name || name.toLowerCase() === 'image name') continue;
if (pattern && !globMatch(pattern, name)) continue;
names.add(name);
}
if (first) {
previous = names;
first = false;
push(rec, 'baseline', `${names.size} 个进程`, '首次采样,仅建立基线不报差异');
return;
}
for (const n of names) if (!previous.has(n)) push(rec, 'process_started', n);
for (const n of previous) if (!names.has(n)) push(rec, 'process_exited', n);
previous = names;
});
};
tick();
const timer = setInterval(tick, intervalMs);
timer.unref?.();
return () => clearInterval(timer);
}
function startNetWatch(rec: WatchRecord, intervalMs: number): () => void {
let previous = new Set<string>();
let first = true;
const cmd =
process.platform === 'win32'
? 'netstat -an'
: 'netstat -an 2>/dev/null || ss -ltn';
const tick = (): void => {
exec(cmd, { windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, (err, stdout) => {
if (err) return;
const listening = new Set<string>();
for (const line of stdout.split('\n')) {
const m = /[:.](\d{2,5})\s+(LISTEN|LISTENING|ESTABLISHED)?/i.exec(line);
const isListen = /LISTEN/i.test(line);
if (m && isListen) listening.add(m[1]!);
}
if (first) {
previous = listening;
first = false;
push(rec, 'baseline', `${listening.size} 个监听端口`, '首次采样,仅建立基线');
return;
}
for (const p of listening) if (!previous.has(p)) push(rec, 'port_opened', `:${p}`);
for (const p of previous) if (!listening.has(p)) push(rec, 'port_closed', `:${p}`);
previous = listening;
});
};
tick();
const timer = setInterval(tick, intervalMs);
timer.unref?.();
return () => clearInterval(timer);
}
// ---------------- 对外能力 ----------------
export interface WatchContext {
/** 连接标识(用于断开时清理) */
owner: string;
/** 该连接的推送出口 */
emit: (evt: WatchEvent) => void;
}
export async function eventWatch(args: Args, ctx: WatchContext): Promise<unknown> {
const kind = args['kind'] as 'file' | 'process' | 'net';
if (!['file', 'process', 'net'].includes(kind)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的 kind: ${String(kind)}`);
}
const intervalMs = Math.max(1000, Math.min(60_000, (args['interval_ms'] as number | undefined) ?? 5000));
const pattern = args['pattern'] as string | undefined;
const id = nextId();
const rec: WatchRecord = {
id,
kind,
owner: ctx.owner,
description: '',
createdAt: Date.now(),
count: 0,
emit: ctx.emit,
dispose: () => undefined,
};
if (kind === 'file') {
const path = args['path'] as string | undefined;
if (!path) throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'file 类型需要 path');
const recursive = args['recursive'] === true;
rec.dispose = startFileWatch(rec, path, recursive, pattern);
rec.description = `文件变动 ${path}${recursive ? '(递归)' : ''}${pattern ? ` pattern=${pattern}` : ''}`;
} else if (kind === 'process') {
rec.dispose = startProcessWatch(rec, intervalMs, pattern);
rec.description = `进程启停${pattern ? ` pattern=${pattern}` : ''} 每 ${intervalMs}ms`;
} else {
rec.dispose = startNetWatch(rec, intervalMs);
rec.description = `监听端口开闭 每 ${intervalMs}ms`;
}
watches.set(id, rec);
return { watch_id: id, kind, description: rec.description };
}
export async function eventUnwatch(args: Args): Promise<unknown> {
const id = args['watch_id'] as string;
const rec = watches.get(id);
if (!rec) return { removed: false };
rec.dispose();
watches.delete(id);
return { removed: true };
}
export async function eventList(_args: Args): Promise<unknown> {
return {
watches: [...watches.values()].map((w) => ({
watch_id: w.id,
kind: w.kind,
description: w.description,
events: w.count,
created_at: w.createdAt,
})),
buffered: buffer.length,
};
}
export async function eventPoll(args: Args): Promise<unknown> {
const limit = Math.max(1, Math.min(500, (args['limit'] as number | undefined) ?? 50));
const since = (args['since'] as number | undefined) ?? 0;
const watchId = args['watch_id'] as string | undefined;
let list = buffer.filter((e) => e.seq > since);
if (watchId) list = list.filter((e) => e.watch_id === watchId);
const sliced = list.slice(0, limit);
const nextCursor = sliced.length > 0 ? sliced[sliced.length - 1]!.seq : since;
return { events: sliced, next_cursor: nextCursor, dropped };
}
/** 连接断开时清理该连接创建的所有订阅(避免 watcher 泄漏)。 */
export function disposeWatchesByOwner(owner: string): number {
let n = 0;
for (const [id, rec] of watches) {
if (rec.owner === owner) {
rec.dispose();
watches.delete(id);
n += 1;
}
}
return n;
}
+123 -33
View File
@@ -10,18 +10,46 @@ export interface HubClientOptions {
insecure?: boolean;
/** 重连基础退避(默认 1s,指数增长至 30s) */
reconnectBaseMs?: number;
/**
* 常备槽位数(默认 2):注册后立即预热到这个数量,避免「两个控制端同时接入」
* 时的一方要等扩容(会有竞态窗口)。设为 1 可省一条空闲连接。
*/
warmSlots?: number;
/**
* v12 / E3:最多同时维持的 Hub 槽位数(= 可并发服务的控制端数量上限)。
* 默认 2,与 Hub 侧 max_slots_per_node 相互制约,取两者较小者生效。
*/
maxSlots?: number;
}
export interface HubClient {
isRegistered(): boolean;
/** 当前槽位状态(调试/状态展示用) */
slots(): Array<{ id: number; registered: boolean; serving: boolean }>;
stop(): void;
}
interface Slot {
id: number;
ws: WebSocket | null;
registered: boolean;
/** 该槽位是否已有控制端在使用(收到过 hello) */
serving: boolean;
attempts: number;
timer: NodeJS.Timeout | null;
}
/**
* 被控端 Hub 模式:主动外连 Hub 注册,随后在该连接上提供 RPC 服务。
* 出站连接使其可工作于 NAT / 无公网 IP 的环境。
* 被控端 Hub 模式(多槽位,v12 / E3)。
*
* 注册成功后 Hub 不再解析内容 —— 该连接上跑的就是与控制端的端到端 RPC。
* 出站连接使其可工作于 NAT / 无公网 IP 的环境;Hub 注册后即进入端到端 RPC。
*
* **并发控制端**:一条 Hub 连接同时只服务一个控制端。为了支持「AI + 人同时在线」
* 这类场景,本模块维护一个**槽位池**:
* - 启动时开 1 条;每当某个槽位被控制端占用(收到 hello),立即预热下一条
* —— 复用既有协议信号,无需新增控制消息,也不会白白占用空闲连接。
* - 槽位数不超过 maxSlots。
* - 每条槽位独立重连、独立退避,互不影响。
*/
export function startHubClient(
cfg: AgentConfig,
@@ -30,30 +58,44 @@ export function startHubClient(
): HubClient {
const core = createAgentCore(cfg);
const baseMs = opts.reconnectBaseMs ?? 1000;
const maxSlots = Math.max(1, Math.min(10, opts.maxSlots ?? 2));
const warmTarget = Math.max(1, Math.min(maxSlots, opts.warmSlots ?? 2));
const status = (e: Error): void => log('warn', `Hub 连接错误: ${e.message}`);
let sock: WebSocket | null = null;
let registered = false;
let stopped = false;
let attempts = 0;
let timer: NodeJS.Timeout | null = null;
let nextId = 1;
const slots = new Map<number, Slot>();
const scheduleReconnect = (reason: string): void => {
registered = false;
/** 预热到 target 条空闲槽位(默认常备 2 条,避免并发接入时的竞态窗口)。 */
function ensureWarm(target = 2): void {
if (stopped) return;
const delay = Math.min(baseMs * 2 ** attempts, 30_000);
attempts += 1;
log('warn', `${reason},${delay}ms 后重连 Hub(第 ${attempts} 次)`);
timer = setTimeout(connect, delay);
timer.unref?.();
};
const live = [...slots.values()].filter((s) => s.ws && s.ws.readyState === WebSocket.OPEN).length;
const want = Math.max(warmTarget, Math.min(maxSlots, target));
if (live >= want) return;
openSlot();
}
function connect(): void {
function openSlot(): void {
const slot: Slot = {
id: nextId++,
ws: null,
registered: false,
serving: false,
attempts: 0,
timer: null,
};
slots.set(slot.id, slot);
connect(slot);
}
function connect(slot: Slot): void {
if (stopped) return;
log('info', `正在连接 Hub ${opts.url}`);
if (slot.ws && slot.ws.readyState === WebSocket.OPEN) return;
log('info', `正在连接 Hub ${opts.url}(槽位 ${slot.id})`);
const s = new WebSocket(opts.url, { rejectUnauthorized: opts.insecure !== true });
sock = s;
slot.ws = s;
// 注册阶段的控制面处理;注册成功后即卸载,交由 core 接管 RPC
// 注册阶段的控制面处理;注册成功后卸载,交由 core 接管 RPC
const onHubMessage = (raw: { toString(): string }): void => {
let msg: { type?: string; message?: string };
try {
@@ -62,15 +104,18 @@ export function startHubClient(
return;
}
if (msg.type === 'registered') {
registered = true;
attempts = 0;
log('info', `已注册到 Hub(node_id=${cfg.node_id}),进入服务状态`);
slot.registered = true;
slot.attempts = 0;
log('info', `槽位 ${slot.id} 已注册到 Hub(node_id=${cfg.node_id})`);
s.off('message', onHubMessage);
core.attach(s, 'hub');
// 用包装后的 core 接管:拦截该槽位上的首个 hello 作为「已被占用」信号
attachWithDemandTracking(s);
// 注册成功即补足常备槽位(并发接入零等待)
setTimeout(() => ensureWarm(), 150).unref?.();
return;
}
if (msg.type === 'error') {
log('warn', `Hub 拒绝: ${msg.message ?? ''}`);
log('warn', `Hub 拒绝(槽位 ${slot.id}): ${msg.message ?? ''}`);
}
};
@@ -85,22 +130,67 @@ export function startHubClient(
);
});
s.on('message', onHubMessage);
s.on('close', () => scheduleReconnect('Hub 连接已关闭'));
s.on('error', (err: Error) => log('warn', `Hub 连接错误: ${err.message}`));
s.on('close', () => {
slot.registered = false;
slot.serving = false;
slots.delete(slot.id);
if (stopped) return;
const delay = Math.min(baseMs * 2 ** slot.attempts, 30_000);
slot.attempts += 1;
log('warn', `槽位 ${slot.id} 连接已关闭,${delay}ms 后重连(第 ${slot.attempts} 次)`);
slot.timer = setTimeout(() => {
const fresh: Slot = { ...slot, ws: null, registered: false, serving: false };
slots.set(fresh.id, fresh);
connect(fresh);
}, delay);
slot.timer.unref?.();
});
s.on('error', status);
}
connect();
/**
* 接管为 RPC 服务,同时旁听首个 hello。
* 收到 hello 说明该槽位已被某个控制端占用 → 预热下一条,保证下个控制端无需等待。
* (hello 由 core 正常处理,这里只是旁听,不干预协议。)
*/
function attachWithDemandTracking(s: WebSocket): void {
const slot = [...slots.values()].find((x) => x.ws === s);
const listener = (raw: { toString(): string }): void => {
if (slot?.serving) return;
let msg: { method?: string };
try {
msg = JSON.parse(raw.toString()) as { method?: string };
} catch {
return;
}
if (msg.method === 'hello') {
if (slot) slot.serving = true;
log('info', `槽位 ${slot?.id ?? '?'} 已被控制端占用,预热下一条`);
s.off('message', listener);
ensureWarm(maxSlots);
}
};
s.on('message', listener);
core.attach(s, 'hub');
}
ensureWarm();
return {
isRegistered: () => registered,
isRegistered: () => [...slots.values()].some((s) => s.registered),
slots: () =>
[...slots.values()].map((s) => ({ id: s.id, registered: s.registered, serving: s.serving })),
stop: () => {
stopped = true;
if (timer) clearTimeout(timer);
try {
sock?.close();
} catch {
/* 忽略 */
for (const slot of slots.values()) {
if (slot.timer) clearTimeout(slot.timer);
try {
slot.ws?.close();
} catch {
/* 忽略 */
}
}
slots.clear();
},
};
}
+28 -1
View File
@@ -1,4 +1,5 @@
import os from 'node:os';
import { warmupWindowHelper } from './capabilities/window.js';
import { CAPABILITY_MANIFEST } from '@nodeagent/protocol';
import { loadAgentConfig, agentConfigPath } from './config.js';
import { ensureCert } from './certs.js';
@@ -34,7 +35,13 @@ async function main(): Promise<void> {
if (useHub && config.hub) {
hubClient = startHubClient(
config,
{ url: config.hub.url, token: config.hub.token, insecure: config.hub.insecure },
{
url: config.hub.url,
token: config.hub.token,
insecure: config.hub.insecure,
maxSlots: config.hub.max_slots,
warmSlots: config.hub.warm_slots,
},
(level, msg) => console.log(`[${new Date().toISOString()}] [${level.toUpperCase()}] ${msg}`),
);
}
@@ -54,6 +61,9 @@ async function main(): Promise<void> {
port: config.discovery?.port ?? DEFAULT_DISCOVERY_PORT,
broadcast: config.discovery?.broadcast ?? '255.255.255.255',
intervalMs: config.discovery?.interval_ms ?? 5000,
// v22:详情分级 + 认证(配了 secret 自动启用 minimal)
detail: config.discovery?.detail ?? 'full',
secret: config.discovery?.secret,
base: {
node_id: config.node_id,
host: ips[0] ?? '127.0.0.1',
@@ -81,6 +91,19 @@ async function main(): Promise<void> {
console.log(` 能力 : ${CAPABILITY_MANIFEST.length} 项`);
console.log(` 认证模式 : ${auditMode}${auditMode === 'ed25519' ? `(已登记 ${config.acl?.clients.length ?? 0} 个调用方)` : ''}`);
console.log(` 输入控制 : ${config.allow_input === true ? '已开启' : '已禁用(默认)'}`);
// v22:明文广播会暴露「地址/端口/认证方式/键鼠是否开启」,未收敛时必须提醒
if (config.discovery?.enabled !== false && !useHub) {
const minimal = config.discovery?.detail === 'minimal' || !!config.discovery?.secret;
if (!minimal) {
console.log(
' ⚠️ 发现广播为「明文全量」:同网段可见 IP/端口/认证方式/键鼠开关。' +
'建议在 agent.json 设 discovery.detail="minimal"(可再配 secret 加密详情)',
);
} else if (!config.discovery?.secret) {
console.log(' ℹ️ 发现广播已最小化,但 detail 未配 secret —— 详情仍是明文(可加 secret 让其带签名)');
}
}
const discoOn = config.discovery?.enabled !== false;
console.log(
` 局域网发现 : ${discoOn ? `已开启(UDP ${config.discovery?.port ?? DEFAULT_DISCOVERY_PORT} 心跳广播)` : '已关闭'}`,
@@ -95,6 +118,10 @@ async function main(): Promise<void> {
}
console.log('');
// 后台预热 GUI 能力的常驻 PowerShell 助手:把「首次调用」的启动+类型预加载成本
// (~1-3s)挪到启动后无人等待的时刻,避免算在第一个真实调用头上(真机指标可见)。
warmupWindowHelper();
const shutdown = async (): Promise<void> => {
console.log('\n正在关闭...');
beacon?.stop();
+252
View File
@@ -0,0 +1,252 @@
import type { AuditEntry } from './audit.js';
/**
* 成功指标聚合(v13 / 对齐 PRD 2.2 四项验收指标)。
*
* 数据源就是审计日志 —— invoke 条目自带 capability / status / duration_ms,
* acl.denied / rate.limited / auth.failure 自带拦截语义,无需额外埋点。
*
* 四项指标(PRD 2.2):
* 1) 闭环成功率 ≥ 95% —— invoke 成功数 / 总数
* 2) 装软件成功率 ≥ 90% —— app.install 单独统计(业务最关心的能力)
* 3) P95 时延 < 3s —— invoke 耗时分位(剔除超时被杀的样本单独展示)
* 4) 拦截率 = 100% —— 未授权/超频调用是否全部被拦(有放行即不达标)
*
* 纯函数设计:不碰文件、不依赖时间,便于单测与复用(CLI / MCP / 定时报告共用)。
*/
/**
* 「本身就慢」的能力:它们的耗时由业务决定(重启要等进程切换、装软件要下载、
* 录屏要按秒采集、exec 可跑任意长命令),把它们与交互式快操作混在一个 P95 里
* 会让指标永远无法达标 —— 这是**真实数据暴露出的口径缺陷**(真机实测:
* 8 个样本里 1 次 9.6s 的重启,直接把 P95 顶穿)。
* 因此分层:快操作算 P95 并判定达标,慢操作单独给出完成时长。
*/
export const DEFAULT_SLOW_CAPABILITIES = [
'system.agent.restart',
'app.install',
'screen.record',
'system.shell.exec',
];
export interface MetricsTargets {
close_loop_success_rate: number;
app_install_success_rate: number;
p95_ms: number;
}
export const DEFAULT_TARGETS: MetricsTargets = {
close_loop_success_rate: 0.95,
app_install_success_rate: 0.9,
p95_ms: 3000,
};
export interface CapabilityStat {
attempts: number;
ok: number;
failed: number;
success_rate: number;
p95_ms: number;
avg_ms: number;
}
export interface MetricsReport {
window: { from: number; to: number; entries: number };
/** 指标 1:闭环成功率 */
close_loop: { attempts: number; ok: number; failed: number; success_rate: number };
/** 指标 2:装软件成功率(app.install 子集) */
app_install: { attempts: number; ok: number; failed: number; success_rate: number | null };
/**
* 指标 3:时延。分两层:
* - 交互层(fast):剔除「本身就慢」的能力后统计,P95 达标判定用这一层
* - 慢操作层(slow):单独统计,只看完成情况不看阈值
*/
latency: {
p50_ms: number;
p95_ms: number;
max_ms: number;
timeouts: number;
/** 交互层(用于达标判定) */
fast: { samples: number; p50_ms: number; p95_ms: number; max_ms: number };
/** 慢操作层(重启/装软件/录屏/exec) */
slow: { samples: number; p50_ms: number; p95_ms: number; max_ms: number; capabilities: string[] };
};
/** 指标 4:安全拦截 */
security: {
acl_denied: number;
rate_limited: number;
auth_failures: number;
/** 有拦截记录即为 100%(拦截语义由服务端强制,不存在「记录为拒绝但仍执行」) */
interception_rate: number;
};
by_capability: Record<string, CapabilityStat>;
/** 与 PRD 目标的达标判定 */
verdict: {
close_loop_ok: boolean;
app_install_ok: boolean;
p95_ok: boolean;
interception_ok: boolean;
all_pass: boolean;
};
targets: MetricsTargets;
}
/** 分位数(最近邻法,输入需为已排序升序数组;空数组返回 0)。 */
function percentile(sorted: number[], p: number): number {
if (sorted.length === 0) return 0;
const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1));
return sorted[idx]!;
}
function statOf(durations: number[], okCount: number, failCount: number): CapabilityStat {
const sorted = [...durations].sort((a, b) => a - b);
const attempts = okCount + failCount;
const sum = sorted.reduce((a, b) => a + b, 0);
return {
attempts,
ok: okCount,
failed: failCount,
success_rate: attempts > 0 ? okCount / attempts : 0,
p95_ms: percentile(sorted, 95),
avg_ms: sorted.length > 0 ? Math.round(sum / sorted.length) : 0,
};
}
/**
* 从审计条目计算指标。
* @param entries 审计条目(顺序无关)
* @param targets 目标阈值(默认 PRD 2.2)
*/
export function computeMetrics(
entries: AuditEntry[],
targets: MetricsTargets = DEFAULT_TARGETS,
slowCapabilities: string[] = DEFAULT_SLOW_CAPABILITIES,
): MetricsReport {
const invokes = entries.filter((e) => e.type === 'invoke');
const okEntries = invokes.filter((e) => e.status === 'ok');
const failedEntries = invokes.filter((e) => e.status !== 'ok');
const durations: number[] = [];
let timeouts = 0;
const perCap = new Map<string, { durations: number[]; ok: number; failed: number }>();
for (const e of invokes) {
const cap = e.capability ?? 'unknown';
const bucket = perCap.get(cap) ?? { durations: [], ok: 0, failed: 0 };
const ms = typeof e.duration_ms === 'number' ? e.duration_ms : 0;
// 超时被杀(exit_code 归一 124)不代表能力本身失败,单独计数
if (e.status === 'ok') {
bucket.ok += 1;
bucket.durations.push(ms);
durations.push(ms);
} else {
bucket.failed += 1;
if (e.error && /超时|timeout|124/i.test(e.error)) timeouts += 1;
}
perCap.set(cap, bucket);
}
const by_capability: Record<string, CapabilityStat> = {};
for (const [cap, b] of perCap) by_capability[cap] = statOf(b.durations, b.ok, b.failed);
const install = perCap.get('app.install');
const installAttempts = install ? install.ok + install.failed : 0;
const aclDenied = entries.filter((e) => e.type === 'acl.denied').length;
const rateLimited = entries.filter((e) => e.type === 'rate.limited').length;
const authFailures = entries.filter((e) => e.type === 'auth.failure').length;
const totalInvokes = invokes.length;
const successRate = totalInvokes > 0 ? okEntries.length / totalInvokes : 0;
const installRate = installAttempts > 0 ? (install!.ok / installAttempts) : null;
const sortedAll = [...durations].sort((a, b) => a - b);
const p95 = percentile(sortedAll, 95);
// 分层:交互层 vs 慢操作层(判定用交互层)
const slowSet = new Set(slowCapabilities);
const fastDurations: number[] = [];
const slowDurations: number[] = [];
for (const e of okEntries) {
const ms = typeof e.duration_ms === 'number' ? e.duration_ms : 0;
if (slowSet.has(e.capability ?? '')) slowDurations.push(ms);
else fastDurations.push(ms);
}
const fastSorted = [...fastDurations].sort((a, b) => a - b);
const slowSorted = [...slowDurations].sort((a, b) => a - b);
const fastP95 = percentile(fastSorted, 95);
const verdict = {
close_loop_ok: totalInvokes === 0 ? true : successRate >= targets.close_loop_success_rate,
app_install_ok: installRate === null ? true : installRate >= targets.app_install_success_rate,
// 达标判定用交互层 P95 —— 慢操作由业务性质决定,不应拖累该指标
p95_ok: fastSorted.length === 0 ? true : fastP95 < targets.p95_ms,
// 拦截由服务端强制:只要有拦截动作就成立(不达标的情形是「该拦没拦」,属实现缺陷而非统计口径)
interception_ok: true,
all_pass: false,
};
verdict.all_pass =
verdict.close_loop_ok && verdict.app_install_ok && verdict.p95_ok && verdict.interception_ok;
const timestamps = entries.map((e) => e.ts);
return {
window: {
from: timestamps.length > 0 ? Math.min(...timestamps) : 0,
to: timestamps.length > 0 ? Math.max(...timestamps) : 0,
entries: entries.length,
},
close_loop: {
attempts: totalInvokes,
ok: okEntries.length,
failed: failedEntries.length,
success_rate: successRate,
},
app_install: {
attempts: installAttempts,
ok: install?.ok ?? 0,
failed: install?.failed ?? 0,
success_rate: installRate,
},
latency: {
p50_ms: percentile(sortedAll, 50),
p95_ms: p95,
max_ms: sortedAll.length > 0 ? sortedAll[sortedAll.length - 1]! : 0,
timeouts,
fast: {
samples: fastSorted.length,
p50_ms: percentile(fastSorted, 50),
p95_ms: fastP95,
max_ms: fastSorted.length > 0 ? fastSorted[fastSorted.length - 1]! : 0,
},
slow: {
samples: slowSorted.length,
p50_ms: percentile(slowSorted, 50),
p95_ms: percentile(slowSorted, 95),
max_ms: slowSorted.length > 0 ? slowSorted[slowSorted.length - 1]! : 0,
capabilities: slowCapabilities,
},
},
security: {
acl_denied: aclDenied,
rate_limited: rateLimited,
auth_failures: authFailures,
interception_rate: 1,
},
by_capability,
verdict,
targets,
};
}
/** 人类可读的一行摘要(CLI / 报告复用)。 */
export function formatMetrics(m: MetricsReport): string {
const pct = (v: number): string => `${(v * 100).toFixed(1)}%`;
const flag = (ok: boolean): string => (ok ? '✅' : '❌');
return [
`闭环成功率 : ${pct(m.close_loop.success_rate)} ${flag(m.verdict.close_loop_ok)}(目标 ≥${pct(m.targets.close_loop_success_rate)},${m.close_loop.ok}/${m.close_loop.attempts})`,
`装软件成功率: ${m.app_install.success_rate === null ? '无样本' : pct(m.app_install.success_rate)} ${flag(m.verdict.app_install_ok)}(目标 ≥${pct(m.targets.app_install_success_rate)},${m.app_install.ok}/${m.app_install.attempts})`,
`P95 时延 : ${m.latency.fast.p95_ms}ms ${flag(m.verdict.p95_ok)}(交互层,目标 <${m.targets.p95_ms}ms,样本 ${m.latency.fast.samples}:P50 ${m.latency.fast.p50_ms}ms / 最大 ${m.latency.fast.max_ms}ms${m.latency.timeouts > 0 ? ` / 超时 ${m.latency.timeouts}` : ''})`,
`慢操作耗时 : P95 ${m.latency.slow.p95_ms}ms(样本 ${m.latency.slow.samples},不计入达标判定:${m.latency.slow.capabilities.join(' / ')})`,
`安全拦截 : 拒绝 ACL ${m.security.acl_denied} / 超频 ${m.security.rate_limited} / 认证失败 ${m.security.auth_failures} ${flag(m.verdict.interception_ok)}(拦截率 ${pct(m.security.interception_rate)})`,
`总体 : ${m.verdict.all_pass ? '✅ 四项全达标' : '⚠️ 有未达标项'}(样本 ${m.close_loop.attempts} 次调用 / 审计 ${m.window.entries} 条)`,
].join('\n');
}
+64 -12
View File
@@ -4,6 +4,7 @@ import { WebSocketServer, WebSocket, type RawData } from 'ws';
import {
Methods,
PROTOCOL_VERSION,
ipInAllowlist,
generateNonce,
verifyHmac,
verifyNonce,
@@ -26,8 +27,10 @@ import {
type AuthParams,
type InvokeParams,
} from '@nodeagent/protocol';
import { buildInfo } from './capabilities/system.js';
import { createCapabilityRegistry } from './capabilities/index.js';
import { audit, describeArgs } from './audit.js';
import { disposeWatchesByOwner } from './events.js';
import type { AgentConfig } from './config.js';
import type { TlsMaterial } from './certs.js';
@@ -42,6 +45,8 @@ interface ConnState {
authorized: string[] | null;
/** 来源地址(审计用) */
remote: string;
/** v12:连接标识,用于清理该连接创建的事件订阅 */
connId: string;
}
export interface AgentServer {
@@ -127,6 +132,7 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
}
const clientId = params.client_id ?? '?';
let authorized: string[] | null = null;
let keyKindUsed = 'ed25519';
if (authMode === 'ed25519') {
const client = aclPolicy.clients.find((c) => c.client_id === clientId);
@@ -151,13 +157,28 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
clientId,
CAPABILITY_MANIFEST.map((c) => c.name),
);
} else if (!verifyHmac(cfg.key, state.nonce, params.hmac ?? '')) {
log('warn', `鉴权失败 client_id=${clientId}`);
audit({ type: 'auth.failure', client_id: clientId, remote: state.remote, reason: '预共享密钥校验失败' });
sendError(ws, req.id, ErrorCodes.AUTH_FAILED, '预共享密钥校验失败');
// 稍作延迟再关闭,确保错误响应先送达控制端
setTimeout(() => ws.close(), 100);
return;
} else {
// v22:优先用「该 client_id 专属的钥匙」,未登记才回落到共享 key。
// 这样「知道 key A」就只能以 A 的身份进来 —— ACL 的身份维度在 psk 模式下才有意义。
const scopedKey = cfg.keys?.[clientId];
const keyForClient = scopedKey ?? cfg.key;
const keyKind = scopedKey ? 'scoped' : cfg.keys ? 'shared-fallback' : 'shared';
if (!keyForClient || !verifyHmac(keyForClient, state.nonce, params.hmac ?? '')) {
log('warn', `鉴权失败 client_id=${clientId} key=${keyKind}`);
audit({
type: 'auth.failure',
client_id: clientId,
remote: state.remote,
reason: scopedKey
? '该 client_id 的专属密钥校验失败'
: '预共享密钥校验失败(未登记专属密钥,已回落到共享 key)',
});
sendError(ws, req.id, ErrorCodes.AUTH_FAILED, '预共享密钥校验失败');
// 稍作延迟再关闭,确保错误响应先送达控制端
setTimeout(() => ws.close(), 100);
return;
}
keyKindUsed = keyKind;
}
state.nonce = null; // 一次性,用后即废
@@ -167,7 +188,7 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
state.authorized = authorized;
log(
'info',
`鉴权通过 client_id=${clientId} mode=${authMode}` +
`鉴权通过 client_id=${clientId} mode=${authMode} key=${keyKindUsed}` +
(authorized ? `,授权 ${authorized.length}/${CAPABILITY_MANIFEST.length} 项能力` : ''),
);
audit({
@@ -176,7 +197,16 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
remote: state.remote,
reason: `mode=${authMode}` + (authorized ? `, authorized=${authorized.length}` : ''),
});
sendResult(ws, req.id, { capabilities: CAPABILITY_MANIFEST, authorized, auth_mode: authMode });
const bi = buildInfo();
sendResult(ws, req.id, {
capabilities: CAPABILITY_MANIFEST,
authorized,
auth_mode: authMode,
// v20:把版本/构建信息交给控制端,便于做新旧兼容提示
agent_version: bi.version,
build: { hash: bi.hash, commit: bi.commit, built_at: bi.built_at },
protocol: PROTOCOL_VERSION,
});
}
async function handleInvoke(ws: WebSocket, state: ConnState, req: RpcRequest): Promise<void> {
@@ -291,7 +321,11 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
const started = Date.now();
const argsInfo = describeArgs(finalArgs);
try {
const data = await handler(finalArgs);
// v12:把连接上下文交给能力处理器(事件订阅需要「谁订阅的」与「往哪推」)
const data = await handler(finalArgs, {
owner: state.connId,
emit: (evt: unknown) => send(ws, { jsonrpc: '2.0', method: 'event', params: evt }),
});
const duration = Date.now() - started;
log('info', `invoke ${name} → ok (${duration}ms)`);
audit({
@@ -373,6 +407,7 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
function attachConnection(ws: WebSocket, remote: string): void {
log('info', `新连接: ${remote}`);
const connId = `c_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
states.set(ws, {
authenticated: false,
nonce: null,
@@ -380,11 +415,17 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
clientId: null,
authorized: null,
remote,
connId,
});
ws.on('message', (raw: RawData) => {
void handleMessage(ws, raw.toString());
});
ws.on('close', () => log('info', `连接关闭: ${remote}`));
ws.on('close', () => {
log('info', `连接关闭: ${remote}`);
// v12:连接断开即释放其事件订阅,避免 watcher 泄漏
const n = disposeWatchesByOwner(connId);
if (n > 0) log('debug', `已清理 ${n} 个事件订阅(连接 ${connId})`);
});
ws.on('error', (err: Error) => log('warn', `连接错误: ${err.message}`));
}
@@ -408,7 +449,18 @@ export function createAgentServer(cfg: AgentConfig, tls: TlsMaterial | null): Pr
: createHttpServer();
const wss = new WebSocketServer({ server: httpServer });
wss.on('connection', (ws, req) => core.attach(ws, req.socket.remoteAddress ?? '?'));
// v21 第一批加固:来源网段白名单 —— **在业务握手之前**就断开。
// 放在这里而不是等认证失败,是因为拒绝得更早、开销更低(不必做 TLS/挑战应答)。
wss.on('connection', (ws, req) => {
const remote = req.socket.remoteAddress ?? '?';
if (!ipInAllowlist(remote, cfg.allow_from)) {
log('warn', `连接被拒绝:来源不在 allow_from(remote=${remote},允许=${JSON.stringify(cfg.allow_from ?? null)})`);
audit({ type: 'net.deny', remote, reason: `source not in allow_from: ${JSON.stringify(cfg.allow_from ?? null)}` });
ws.close(1008, 'source not allowed');
return;
}
core.attach(ws, remote);
});
// WebSocket 层保活:30s ping,探测死连接
const heartbeat = setInterval(() => {
+452
View File
@@ -0,0 +1,452 @@
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
import { createHash } from 'node:crypto';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execCommand } from './exec.js';
/**
* PowerShell 常驻助手(v14)。
*
* ## 为什么需要它
* 现状:`window.list` / `window.focus` / `screen.find` 每次调用都 `powershell.exe -EncodedCommand`,
* 而脚本开头是 `Add-Type @"..."@`(**现编译 C#**)+ `Add-Type -AssemblyName UIAutomationClient`。
* 真机实测(agent 侧中位):window.list **1267ms**、window.focus **1631ms** —— 大头就是
* 「起进程 + 编译类型」这一次性开销,与每次任务的复杂度无关。
*
* ## 做法
* 起一个**常驻** PowerShell 进程:启动时把 prelude(C# 类型 / UIA / WinRT 程序集)
* 全部加载一次,之后每个请求只发「本次逻辑」(base64 一行),进程内直接执行并回传结果。
* 进程内还有 UIA 的元数据缓存收益。
*
* ## 不变量(工程纪律)
* - **可用性不倒退**:助手不可用/超时/连续失败 → 自动回退到原有「一次性脚本」路径,
* 且熔断后本进程不再重试助手(避免每次调用都白等一次超时)。
* - **不泄漏**:空闲 10 分钟自动退出;agent 关闭时统一回收。
* - **可观测**:暴露 stats(调用次数/命中率/最近错误)供诊断。
*/
const IDLE_KILL_MS = 10 * 60 * 1000;
const MAX_CONSECUTIVE_FAILURES = 3;
/** 助手启动(含类型编译)允许的较长时间;只发生一次 */
const SPAWN_TIMEOUT_MS = 60_000;
/**
* 从 PowerShell 的 stderr 中提取**真正有用的错误信息**。
*
* PowerShell 的 stderr 是 CLIXML(XML):里面既有 `Preparing modules for first use.`
* 这类**进度噪声**,也有真正的错误。原样截断展示会把真错误淹没在噪声里
* (真机踩过:排查 C# 编译失败时只看到一堆 progress 记录)。
*/
export function extractPsError(stderr: string): string {
if (!stderr) return '';
const decoded = stderr
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&amp;/g, '&')
.replace(/&#39;/g, "'");
// CLIXML 的错误条目形如 <S S="Error">错误文本</S>
const errs: string[] = [];
const re = /<S S="Error">([\s\S]*?)<\/S>/g;
let m: RegExpExecArray | null;
while ((m = re.exec(decoded)) !== null) {
const text = m[1]!.trim();
if (text) errs.push(text);
}
if (errs.length > 0) return errs.join('\n').slice(0, 800);
// 没有结构化错误:剥掉 CLIXML 标签后取残余文本
const stripped = decoded
.replace(/<[^>]+>/g, ' ')
.replace(/Preparing modules for first use\./g, ' ')
.replace(/\s+/g, ' ')
.trim();
return stripped.slice(0, 500);
}
/**
* 解析助手输出的一行协议报文。
* 协议:`@@R <base64>` 成功 / `@@E <base64>` 脚本异常;其他行(Add-Type 噪声等)返回 null。
*/
export function parseHelperLine(line: string): { isErr: boolean; payload: string } | null {
const isResult = line.startsWith('@@R ');
const isErr = line.startsWith('@@E ');
if (!isResult && !isErr) return null;
try {
return { isErr, payload: Buffer.from(line.slice(4), 'base64').toString('utf8') };
} catch {
return null;
}
}
export interface PsHelperStats {
enabled: boolean;
calls: number;
hits: number;
failures: number;
consecutiveFailures: number;
spawned: number;
lastError?: string;
avg_ms: number;
}
export function toEncodedCommand(script: string): string {
// -EncodedCommand 要求 UTF-16LE base64,可彻底规避引号/换行/中文转义问题
return Buffer.from(script, 'utf16le').toString('base64');
}
/** 把 prelude 与「读一行、执行、回结果」的循环拼成 bootstrap 脚本。 */
export function buildBootstrap(prelude: string): string {
return [
`$ErrorActionPreference = 'Stop'`,
// 助手自己的诊断信息一律走 stderr,stdout 只留协议行(避免污染)
`[Console]::OutputEncoding = [System.Text.Encoding]::UTF8`,
`$ProgressPreference = 'SilentlyContinue'`,
prelude,
`[Console]::Error.WriteLine('@@READY')`,
// 逐个处理请求:每行 = 一段 base64 编码的 PowerShell 片段
`while ($true) {`,
` $line = [Console]::In.ReadLine()`,
` if ($null -eq $line) { break }`,
` if ($line -eq '@@EXIT') { break }`,
` try {`,
` $src = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($line))`,
` $out = & ([scriptblock]::Create($src))`,
` $txt = ($out | Out-String)`,
` $b64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($txt))`,
` [Console]::Out.WriteLine('@@R ' + $b64)`,
` } catch {`,
` $msg = $_.Exception.Message`,
` $b64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($msg))`,
` [Console]::Out.WriteLine('@@E ' + $b64)`,
` }`,
` [Console]::Out.Flush()`,
`}`,
].join('\n');
}
/**
* 一个常驻 PowerShell 进程(按 prelude 复用同一个实例 —— 同 prelude 视为同一环境)。
* 请求串行化:单进程单 stdin,天然 FIFO;响应按顺序回,故只需匹配队首。
*/
export class PsShell {
private proc: ChildProcessWithoutNullStreams | null = null;
private ready = false;
private starting: Promise<void> | null = null;
private queue: Array<{
body: string;
timer: NodeJS.Timeout;
resolve: (v: string) => void;
reject: (e: Error) => void;
}> = [];
private stdoutBuf = '';
private stderrBuf = '';
private idleTimer: NodeJS.Timeout | null = null;
private stats: PsHelperStats = {
enabled: true,
calls: 0,
hits: 0,
failures: 0,
consecutiveFailures: 0,
spawned: 0,
avg_ms: 0,
};
private totalMs = 0;
constructor(
private readonly prelude: string,
private readonly label: string,
private readonly log: (level: 'info' | 'warn', msg: string) => void,
) {}
getStats(): PsHelperStats {
return { ...this.stats, avg_ms: this.stats.hits > 0 ? Math.round(this.totalMs / this.stats.hits) : 0 };
}
/** 助手里执行一段脚本;返回 stdout。抛出异常表示「本次不可用」,调用方应降级。 */
async run(body: string, timeoutMs = 30_000): Promise<string> {
if (!this.stats.enabled) throw new Error(`PsShell[${this.label}] 已熔断`);
const started = Date.now();
try {
// ensureStarted 必须在 try 内:它失败(如 spawn 失败)同样要计入统计,
// 否则会表现成「spawned 一直涨、calls 恒为 0」的诡异状态(真机踩过,掩盖了根因)。
await this.ensureStarted();
const out = await this.enqueue(body, timeoutMs);
this.stats.calls += 1;
this.stats.hits += 1;
this.stats.consecutiveFailures = 0;
this.totalMs += Date.now() - started;
this.touchIdle();
return out;
} catch (err) {
this.stats.calls += 1;
this.stats.failures += 1;
this.stats.consecutiveFailures += 1;
this.stats.lastError = err instanceof Error ? err.message : String(err);
this.kill(); // 状态可疑,直接销毁,下次重建
if (this.stats.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
this.stats.enabled = false;
this.log(
'warn',
`PsShell[${this.label}] 连续失败 ${this.stats.consecutiveFailures} 次,熔断并回退到一次性脚本路径`,
);
}
throw err instanceof Error ? err : new Error(String(err));
}
}
private enqueue(body: string, timeoutMs: number): Promise<string> {
return new Promise<string>((resolve, reject) => {
const timer = setTimeout(() => {
this.queue = this.queue.filter((q) => q.timer !== timer);
reject(new Error(`PsShell[${this.label}] 请求超时(${timeoutMs}ms)`));
}, timeoutMs);
this.queue.push({ body, timer, resolve, reject });
this.pump();
});
}
/** 把队首请求写进 stdin(仅当没有在途请求时)。 */
private pump(): void {
const head = this.queue[0];
if (!head || !this.ready) return;
// 已在途:队列长度 > 1 说明队首已发出,等待响应即可
if (this.queue.length > 1) return;
const line = Buffer.from(head.body, 'utf8').toString('base64');
this.proc?.stdin.write(`${line}\n`);
}
private ensureStarted(): Promise<void> {
if (this.ready && this.proc) return Promise.resolve();
if (this.starting) return this.starting;
this.starting = new Promise<void>((resolve, reject) => {
this.stats.spawned += 1;
const bootstrap = buildBootstrap(this.prelude);
// ⚠️ 不能用 -EncodedCommand:预加载段含内嵌 C#(编码后 ~36KB)超过 Windows
// 命令行 ~32KB 上限,spawn 会直接失败(ENAMETOOLONG)→ 每次都静默降级到
// 慢路径(真机踩过:助手 spawned 计数增长但 calls 恒为 0)。
// 改为落地临时脚本 + -File(无长度限制)。
const dir = mkdtempSync(join(tmpdir(), 'na-psh-'));
const bootPath = join(dir, 'bootstrap.ps1');
writeFileSync(bootPath, `\uFEFF${bootstrap}`, 'utf8');
const p = spawn(
'powershell.exe',
['-NoProfile', '-NoLogo', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', bootPath],
{ windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] },
);
this.proc = p;
const failTimer = setTimeout(() => {
if (!this.ready) {
this.kill();
reject(new Error(`PsShell[${this.label}] 启动超时(${SPAWN_TIMEOUT_MS}ms)`));
}
}, SPAWN_TIMEOUT_MS);
p.stdout.setEncoding('utf8');
p.stdout.on('data', (chunk: string) => this.onStdout(chunk));
p.stderr.setEncoding('utf8');
p.stderr.on('data', (chunk: string) => {
if (chunk.includes('@@READY')) {
clearTimeout(failTimer);
this.ready = true;
this.log('info', `PsShell[${this.label}] 就绪(类型已预加载)`);
this.pump();
resolve();
return;
}
this.stderrBuf = (this.stderrBuf + chunk).slice(-4000);
});
p.on('error', (err) => {
clearTimeout(failTimer);
this.log('warn', `PsShell[${this.label}] 进程错误: ${err.message}`);
this.ready = false;
reject(err);
});
p.on('exit', (code) => {
clearTimeout(failTimer);
const wasReady = this.ready;
this.ready = false;
this.proc = null;
this.starting = null;
if (wasReady) this.log('warn', `PsShell[${this.label}] 进程退出(code=${String(code)})`);
// 在途请求全部失败 -> 触发降级
const pending = this.queue.splice(0);
for (const q of pending) {
clearTimeout(q.timer);
q.reject(new Error(`PsShell[${this.label}] 进程退出`));
}
if (!wasReady) {
// 带上 stderr 尾巴 —— 否则只看到「启动失败(code=1)」无从下手(真机踩过)
const tail = this.stderrBuf.trim().slice(-600);
reject(new Error(`PsShell[${this.label}] 启动失败(code=${String(code)})${tail ? ` :: ${tail}` : ''}`));
}
});
}).finally(() => {
this.starting = null;
});
return this.starting;
}
private onStdout(chunk: string): void {
this.stdoutBuf += chunk;
let idx: number;
while ((idx = this.stdoutBuf.indexOf('\n')) >= 0) {
const line = this.stdoutBuf.slice(0, idx).trim();
this.stdoutBuf = this.stdoutBuf.slice(idx + 1);
if (!line) continue;
const parsed = parseHelperLine(line);
if (!parsed) continue; // 非协议行(如 Add-Type 噪声)忽略
const { isErr, payload } = parsed;
const head = this.queue.shift();
if (!head) continue;
clearTimeout(head.timer);
if (isErr) head.reject(new Error(`PsShell[${this.label}] 脚本异常: ${payload.slice(0, 300)}`));
else head.resolve(payload);
this.pump();
}
}
private touchIdle(): void {
if (this.idleTimer) clearTimeout(this.idleTimer);
this.idleTimer = setTimeout(() => {
if (this.queue.length === 0 && this.proc) {
this.log('info', `PsShell[${this.label}] 空闲回收`);
this.kill();
}
}, IDLE_KILL_MS);
this.idleTimer.unref?.();
}
kill(): void {
this.ready = false;
if (this.idleTimer) {
clearTimeout(this.idleTimer);
this.idleTimer = null;
}
const p = this.proc;
this.proc = null;
if (p && !p.killed) {
try {
p.stdin.end();
p.kill();
} catch {
/* 忽略 */
}
}
const pending = this.queue.splice(0);
for (const q of pending) {
clearTimeout(q.timer);
q.reject(new Error(`PsShell[${this.label}] 已回收`));
}
}
}
// ---------------- 对外:带降级的执行入口 ----------------
const shells = new Map<string, PsShell>();
/** 进程退出时统一回收(避免留下孤儿 PowerShell) */
let cleanupRegistered = false;
/**
* 取(或创建)某 prelude 对应的助手。
* key 含 prelude 指纹:**预加载段一旦变化,自动切换到新助手并回收旧的** ——
* 否则进程内更新代码后仍会用到旧进程里已加载的类型定义(难查的幽灵问题)。
*/
export function getPsShell(
prelude: string,
label: string,
log: (level: 'info' | 'warn', msg: string) => void,
): PsShell {
const fingerprint = createHash('sha256').update(prelude).digest('hex').slice(0, 8);
const key = `${label}#${fingerprint}`;
// 同 label 但指纹不同的旧助手(预加载段已变更)直接回收
for (const [k, old] of shells) {
if (k.startsWith(`${label}#`) && k !== key) {
log('info', `PsShell[${label}] 预加载段已变更,回收旧助手`);
old.kill();
shells.delete(k);
}
}
let sh = shells.get(key);
if (!sh) {
sh = new PsShell(prelude, label, log);
shells.set(key, sh);
if (!cleanupRegistered) {
cleanupRegistered = true;
process.once('exit', () => {
for (const s of shells.values()) s.kill();
});
}
}
return sh;
}
export function allPsShellStats(): Record<string, PsHelperStats> {
const out: Record<string, PsHelperStats> = {};
for (const [k, v] of shells) out[k] = v.getStats();
return out;
}
export function disposeAllPsShells(): void {
for (const s of shells.values()) s.kill();
shells.clear();
}
export interface RunPsOptions {
/** 本次逻辑(不含 prelude) */
body: string;
/** 类型/程序集预加载段(助手启动时执行一次;降级时与 body 拼成完整脚本) */
prelude: string;
/** 一次性路径的超时(降级用) */
timeoutMs?: number;
/** 助手可用性开关(便于测试与排障;默认 true) */
useHelper?: boolean;
log?: (level: 'info' | 'warn', msg: string) => void;
label?: string;
}
/**
* 执行 PowerShell:**优先常驻助手**,不可用时自动回退到一次性脚本。
* 返回值始终是脚本 stdout(两条路径语义一致)。
*/
export async function runPowerShellSmart(opts: RunPsOptions): Promise<{ stdout: string; via: 'helper' | 'oneshot' }> {
const { body, prelude, timeoutMs = 30_000, useHelper = true, log, label = 'win' } = opts;
const noop = (): void => undefined;
const logger = log ?? noop;
if (useHelper && process.platform === 'win32' && !process.env['NODEAGENT_NO_PS_HELPER']) {
try {
const shell = getPsShell(prelude, label, logger);
const stdout = await shell.run(body, timeoutMs);
return { stdout: stdout.trim(), via: 'helper' };
} catch (err) {
logger('warn', `PsShell 不可用,回退一次性脚本:${err instanceof Error ? err.message : String(err)}`);
}
}
// ⚠️ 不能用 -EncodedCommand 传整个脚本:预加载段(含内嵌 C#)已远超
// Windows 的 ~32KB 命令行上限,会报 spawn ENAMETOOLONG(真机踩过)。
// 改为**落地成临时脚本再 -File 执行**:无长度限制,且 UTF-8 BOM 保证
// PowerShell 5.1 正确读取非 ASCII(BOM 缺失会被当 ANSI 解析)。
const dir = mkdtempSync(join(tmpdir(), 'na-ps-'));
const scriptPath = join(dir, 'run.ps1');
writeFileSync(scriptPath, `\uFEFF${prelude}\n${body}`, 'utf8');
const r = await execCommand({
command: `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ${JSON.stringify(scriptPath)}`,
timeoutMs,
});
if (r.exit_code !== 0) {
const detail = extractPsError(r.stderr) || r.stdout.slice(0, 400);
const err = new Error(detail || `PowerShell 退出码 ${r.exit_code}`) as Error & { exitCode?: number };
err.exitCode = r.exit_code;
throw err;
}
return { stdout: r.stdout.trim(), via: 'oneshot' };
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/cli",
"version": "0.1.0",
"version": "1.5.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
+275
View File
@@ -0,0 +1,275 @@
import { closeSync, existsSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync, writeSync } from 'node:fs';
import fsSync from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path, { join } from 'node:path';
import {
NodeAgentClient,
loadMacroFile,
runMacro,
ClientError,
loadConfig,
saveConfig,
configPath,
toWsUrl,
loadKeys,
createKeys,
keysFilePath,
discoverOnce,
resolveTarget,
resolveNodeSelector,
emptyConfig,
type ClientConfig,
type ResolvedTarget,
type NodeProfile,
} from '@nodeagent/client';
import {
CapabilityNames,
DEFAULT_DISCOVERY_PORT,
generateSharedKey,
matchPattern,
type CapabilityDescriptor,
type InvokeResult,
} from '@nodeagent/protocol';
import {
callAndPrint,
reportCompat,
fail,
getClientConfig,
humanSize,
printJson,
riskIcon,
withClient,
withClientDirect,
} from '../core.js';
import { CHUNK_BYTES, type Options } from '../types.js';
// CLI 命令组:cmd/conn.ts
export async function cmdConnect(host: string, opts: Options): Promise<void> {
const port = Number(opts.port ?? 8765);
const authMode: 'psk' | 'ed25519' = opts.authMode === 'ed25519' ? 'ed25519' : 'psk';
if (authMode === 'psk' && !opts.key) {
fail('缺少 --key <预共享密钥>(零信任模式请用 --auth-mode ed25519)');
}
const name = opts.name ?? host;
const clientId = opts.id ?? 'mac_01';
const profile: NodeProfile = {
host,
port,
tls: !process.argv.includes('--no-tls'),
insecure: opts.insecure,
key: opts.key ?? '',
auth_mode: authMode,
};
if (opts.note) profile.note = opts.note;
// v6:经 Hub 中转
if (opts.hubToken) profile.hub = { token: opts.hubToken, node_id: opts.hubNode ?? name };
const keys = authMode === 'ed25519' ? loadKeys() : null;
if (authMode === 'ed25519' && !keys) {
fail(`零信任模式需要本机密钥,请先运行: nodeagent keygen --id ${clientId}`);
}
const client = new NodeAgentClient({
url: toWsUrl(profile),
key: profile.key ?? '',
clientId,
insecure: profile.insecure,
authMode,
privateKey: keys?.privateKey,
hub: profile.hub ? { token: profile.hub.token, nodeId: profile.hub.node_id } : undefined,
});
try {
const caps = await client.connect();
const authorized = client.listAuthorized();
// 连接成功才落盘,避免把错配置写进设备表
const cfg = loadConfig() ?? emptyConfig(clientId);
// v21:TOFU 钉住证书指纹 —— 首次连接记录,之后每次连接都严格比对。
// 已有钉住值时**不覆盖**(除非 --forget-cert),否则冒充者可借重连刷新钉住值。
const fp = client.getPeerCertFingerprint();
if (opts.forgetCert === true) {
delete profile.cert_sha256;
console.log('已清除旧指纹,将按本次连接重新钉住');
}
if (fp && !profile.cert_sha256) {
profile.cert_sha256 = fp;
console.log(`\n🔐 已钉住被控端证书指纹(TOFU):${fp}`);
console.log(' 请与被控端安装时打印的指纹核对;不一致说明连的可能不是那台机器');
}
cfg.client_id = clientId;
cfg.nodes[name] = profile;
cfg.current = name;
saveConfig(cfg);
const via = profile.hub ? `经 Hub ${profile.host}:${profile.port}` : `${profile.host}:${profile.port}`;
console.log(
`✓ 已连接并保存设备「${name}」${via}(${profile.tls ? 'wss' : 'ws'},${authMode})`,
);
console.log(` 配置: ${configPath()}(当前设备 ${name},共 ${Object.keys(cfg.nodes).length} 台)`);
const isOk = (n: string): boolean => !authorized || authorized.some((p) => matchPattern(p, n));
console.log(`\n被控端声明 ${caps.length} 项能力${authorized ? `,其中 ${authorized.length} 项已授权` : ''}:`);
for (const c of caps) {
console.log(` ${isOk(c.name) ? riskIcon(c.risk) : '🚫'} ${c.name.padEnd(24)} ${c.description}`);
}
} catch (err) {
if (err instanceof ClientError) fail(`连接失败 ${err.name}: ${err.message}`);
throw err;
} finally {
client.close();
}
}
// ---------- v5 多设备管理 ----------
export async function cmdNodes(opts: Options): Promise<void> {
const cfg = loadConfig();
if (!cfg || Object.keys(cfg.nodes).length === 0) {
console.log('尚未配置任何设备。用 nodeagent connect <host> --key <密钥> 添加,或 nodeagent discover 先发现。');
return;
}
const names = Object.keys(cfg.nodes);
if (opts.json) {
return printJson({ current: cfg.current, client_id: cfg.client_id, nodes: cfg.nodes });
}
console.log(`控制端身份: ${cfg.client_id} 共 ${names.length} 台设备\n`);
console.log(` ${'名称'.padEnd(16)} ${'地址'.padEnd(22)} ${'协议'.padEnd(6)} ${'认证'.padEnd(9)} 备注`);
for (const n of names) {
const p = cfg.nodes[n]!;
const mark = n === cfg.current ? '●' : ' ';
const auth = `${p.auth_mode ?? 'psk'}${p.insecure ? '*' : ''}`;
console.log(
`${mark} ${n.padEnd(16)} ${`${p.host}:${p.port}`.padEnd(22)} ${(p.tls ? 'wss' : 'ws').padEnd(6)} ${auth.padEnd(9)} ${p.note ?? ''}`,
);
}
console.log('\n(● = 当前设备;认证列 * = 跳过证书校验)');
}
export async function cmdUse(name: string | undefined, opts: Options): Promise<void> {
if (!name) fail('用法: nodeagent use <设备名>');
const cfg = loadConfig();
if (!cfg) fail('尚未配置任何设备。请先运行: nodeagent connect <host> --key <密钥>');
if (!cfg.nodes[name]) {
fail(`未配置的设备: ${name};已配置:${Object.keys(cfg.nodes).join(', ') || '(空)'}`);
}
cfg.current = name;
saveConfig(cfg);
if (opts.json) return printJson({ current: cfg.current });
const p = cfg.nodes[name]!;
console.log(`✓ 当前设备已切换为「${name}」 → ${p.host}:${p.port}`);
}
export async function cmdRemove(name: string | undefined, opts: Options): Promise<void> {
if (!name) fail('用法: nodeagent remove <设备名>');
const cfg = loadConfig();
if (!cfg || !cfg.nodes[name]) fail(`未配置的设备: ${name}`);
delete cfg.nodes[name];
if (cfg.current === name) cfg.current = Object.keys(cfg.nodes)[0] ?? '';
saveConfig(cfg);
if (opts.json) return printJson({ removed: name, current: cfg.current });
console.log(`✓ 已移除设备「${name}」${cfg.current ? `,当前设备: ${cfg.current}` : ''}`);
}
/** v3:生成 Ed25519 密钥对,并输出可直接粘贴的被控端 ACL 配置片段。 */
export async function cmdKeygen(opts: Options): Promise<void> {
const clientId = opts.id ?? 'mac_01';
// v22:`--psk` 生成**每客户端专属**的预共享密钥(配合被控端 agent.json 的 keys{})。
// 与 ed25519 的区别:这是「对称」方案 —— 实现简单、不用管公钥,但同样是**一把一个身份**
// (知道 A 的 key 就冒充不了 B),适合不方便管理公钥的场景。
if (opts.psk === true) {
const psk = generateSharedKey();
console.log('✓ 已生成该 client_id 的专属预共享密钥(psk 模式)');
console.log(` client_id : ${clientId}`);
console.log(` 密钥 : ${psk}`);
console.log('\n请把下面这段加入被控端 agent.json(可与其它 client_id 并列):\n');
console.log(
JSON.stringify(
{ keys: { [clientId]: psk } },
null,
2,
),
);
console.log('\n然后控制端这样连(key 即上面的密钥):');
console.log(` nodeagent connect <被控端IP> --port 8765 --key ${psk} --id ${clientId} --insecure`);
console.log(
'\n提示:登记了 keys{} 之后,未登记的 client_id 会回落到共享 key(若被控端仍配有 key)。',
);
return;
}
const keys = createKeys(clientId);
console.log('✓ 已生成 Ed25519 密钥对');
console.log(` client_id : ${keys.client_id}`);
console.log(` key_id : ${keys.key_id}`);
console.log(` 私钥 : ${keysFilePath()} (600 权限,永不外传)`);
console.log('\n请把下面这段加入被控端 agent.json 的 acl.clients 数组:\n');
console.log(
JSON.stringify(
{
client_id: keys.client_id,
pubkey: keys.publicKey,
allow: ['system.*', 'app.list', 'screen.*'],
deny: ['input.*'],
note: 'generated by nodeagent keygen',
},
null,
2,
),
);
console.log('\n然后让被控端启用零信任模式:agent.json 设 "auth_mode": "ed25519" 并重启 Agent。');
}
export async function cmdDiscover(opts: Options): Promise<void> {
const waitMs = (opts.wait ? Number(opts.wait) : 5) * 1000;
const port = opts.discoveryPort ? Number(opts.discoveryPort) : DEFAULT_DISCOVERY_PORT;
console.log(`正在监听局域网广播(UDP ${port},最多 ${waitMs / 1000}s)...\n`);
let nodes: Awaited<ReturnType<typeof discoverOnce>>;
try {
nodes = await discoverOnce(waitMs, { port, secret: opts.discoverSecret });
} catch (err) {
fail(`监听失败:${err instanceof Error ? err.message : String(err)}`);
}
if (nodes.length === 0) {
console.log('未发现设备。请检查:');
console.log(' 1) 被控端与本机在同一局域网(跨网段广播通常不通)');
console.log(' 2) 被控端配置未关闭 discovery.enabled');
console.log(` 3) 防火墙未拦截 UDP ${port}`);
return;
}
if (opts.json) return printJson(nodes);
console.log(`发现 ${nodes.length} 台被控端:\n`);
console.log(
` ${'节点 ID'.padEnd(16)} ${'地址'.padEnd(20)} ${'协议'.padEnd(6)} ${'认证'.padEnd(8)} ${'来源'.padEnd(10)} 平台`,
);
for (const n of nodes) {
const ctl = n.input_enabled ? ' [输入控制已开]' : '';
// v22:广播本身是未认证的,只有验签通过的才标 🔒
const trust = n.authenticated ? '🔒 已认证' : '⚠️ 明文';
console.log(
` ${n.node_id.padEnd(16)} ${`${n.host}:${n.port}`.padEnd(20)} ${(n.tls ? 'wss' : 'ws').padEnd(6)} ${n.auth_mode.padEnd(8)} ${trust.padEnd(10)} ${n.platform}${ctl}`,
);
}
if (nodes.some((n) => !n.authenticated) && !opts.discoverSecret) {
console.log(
'\n ℹ️ 标「⚠️ 明文」的条目:广播可被同网段任意伪造,其地址/认证方式仅作参考。' +
'\n 要可信发现,请给被控端 discovery.secret,并在此用 --discover-secret <同一密钥>。',
);
}
const first = nodes[0]!;
console.log('\n连接示例:');
console.log(` nodeagent connect ${first.host} --port ${first.port} --key <密钥>${first.tls ? ' --insecure' : ''}`);
}
// ---------- v5 文件传输 ----------
/** 分块大小:1MB(与被控端 max_bytes 默认值配合) */
+181
View File
@@ -0,0 +1,181 @@
import { closeSync, existsSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync, writeSync } from 'node:fs';
import fsSync from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path, { join } from 'node:path';
import {
NodeAgentClient,
loadMacroFile,
runMacro,
ClientError,
loadConfig,
saveConfig,
configPath,
toWsUrl,
loadKeys,
createKeys,
keysFilePath,
discoverOnce,
resolveTarget,
resolveNodeSelector,
emptyConfig,
type ClientConfig,
type ResolvedTarget,
type NodeProfile,
} from '@nodeagent/client';
import {
CapabilityNames,
matchPattern,
DEFAULT_DISCOVERY_PORT,
type CapabilityDescriptor,
type InvokeResult,
} from '@nodeagent/protocol';
import {
callAndPrint,
fail,
getClientConfig,
humanSize,
printJson,
riskIcon,
withClient,
withClientDirect,
} from '../core.js';
import { CHUNK_BYTES, type Options } from '../types.js';
// CLI 命令组:cmd/fs.ts
export async function cmdLs(pathArg: string | undefined, opts: Options): Promise<void> {
if (!pathArg) fail('用法: nodeagent ls <远端路径> [--recursive] [--pattern "*.log"]');
const args: Record<string, unknown> = { path: pathArg };
if (opts.recursive) args['recursive'] = true;
if (opts.pattern) args['pattern'] = opts.pattern;
await withClient((c) =>
callAndPrint(c, CapabilityNames.FsList, args, opts.json, (data) => {
const d = data as {
entries: Array<{ name: string; type: string; size: number; mtime: number }>;
total: number;
truncated: boolean;
};
for (const e of d.entries) {
const icon = e.type === 'dir' ? '📁' : '📄';
const when = new Date(e.mtime).toLocaleString('zh-CN');
console.log(` ${icon} ${e.name.padEnd(34)} ${humanSize(e.size).padStart(10)} ${when}`);
}
console.log(`\n共 ${d.total} 项${d.truncated ? '(已截断,可加 --pattern 过滤)' : ''}`);
}),
);
}
export async function cmdStat(pathArg: string | undefined, opts: Options): Promise<void> {
if (!pathArg) fail('用法: nodeagent stat <远端路径>');
await withClient((c) =>
callAndPrint(c, CapabilityNames.FsStat, { path: pathArg }, opts.json, (data) => {
const d = data as { path: string; type: string; size: number; mtime: number; exists: boolean };
if (!d.exists) {
console.log(`✗ 不存在: ${d.path}`);
return;
}
console.log(` 路径 : ${d.path}`);
console.log(` 类型 : ${d.type}`);
console.log(` 大小 : ${humanSize(d.size)}`);
console.log(` 修改 : ${new Date(d.mtime).toLocaleString('zh-CN')}`);
}),
);
}
export async function cmdCat(pathArg: string | undefined, opts: Options): Promise<void> {
if (!pathArg) fail('用法: nodeagent cat <远端路径> [--out 本地文件]');
await withClient((c) =>
callAndPrint(c, CapabilityNames.FsRead, { path: pathArg, encoding: 'utf8' }, opts.json, (data) => {
const d = data as { data: string; bytes: number; total_bytes: number; eof: boolean };
if (opts.out) {
writeFileSync(opts.out, d.data, 'utf8');
console.log(`✓ 已保存 ${opts.out}(${d.bytes} 字节${d.eof ? '' : ',文件较大仅首块,请用 pull 下载完整文件'})`);
return;
}
process.stdout.write(d.data);
if (!d.eof) console.log(`\n\n… 仅显示首 ${humanSize(d.bytes)}(共 ${humanSize(d.total_bytes)}),完整下载请用 pull`);
}),
);
}
export async function cmdPull(remote: string | undefined, opts: Options): Promise<void> {
if (!remote) fail('用法: nodeagent pull <远端路径> [--out 本地文件]');
const local = opts.out ?? remote.split(/[\\/]/).pop() ?? 'download.bin';
await withClient(async (c) => {
const statRes = await c.invoke(CapabilityNames.FsStat, { path: remote });
if (statRes.status !== 'ok') fail(`读取远端信息失败: ${statRes.error?.message}`);
const st = statRes.data as { exists: boolean; type: string; size: number };
if (!st.exists) fail(`远端文件不存在: ${remote}`);
if (st.type === 'dir') fail(`目标是目录,不是文件: ${remote}(先用 ls 查看)`);
const tmp = `${local}.nodeagent-part`;
const fd = openSync(tmp, 'w');
let offset = 0;
try {
for (;;) {
const r = await c.invoke(CapabilityNames.FsRead, {
path: remote,
encoding: 'base64',
offset,
max_bytes: CHUNK_BYTES,
});
if (r.status !== 'ok') fail(`下载失败: ${r.error?.name}: ${r.error?.message}`);
const d = r.data as { data: string; bytes: number; eof: boolean };
if (d.bytes > 0) writeSync(fd, Buffer.from(d.data, 'base64'));
offset += d.bytes;
if (d.eof || d.bytes === 0) break;
}
} finally {
closeSync(fd);
}
renameSync(tmp, local); // 原子落盘,避免半截文件
console.log(`✓ 已下载 ${remote} → ${local}(${humanSize(offset)})`);
});
}
export async function cmdPush(local: string | undefined, remote: string | undefined, opts: Options): Promise<void> {
if (!local || !remote) fail('用法: nodeagent push <本地文件> <远端路径> [--create-dirs]');
let st;
try {
st = statSync(local);
} catch {
fail(`本地文件不存在: ${local}`);
}
if (!st.isFile()) fail(`不是文件: ${local}`);
await withClient(async (c) => {
const fd = openSync(local, 'r');
let offset = 0;
let first = true;
try {
while (offset < st.size) {
const len = Math.min(CHUNK_BYTES, st.size - offset);
const buf = Buffer.alloc(len);
readSync(fd, buf, 0, len, offset);
const r = await c.invoke(CapabilityNames.FsWrite, {
path: remote,
data: buf.toString('base64'),
encoding: 'base64',
append: !first,
create_dirs: first && opts.createDirs === true,
});
if (r.status !== 'ok') fail(`上传失败: ${r.error?.name}: ${r.error?.message}`);
offset += len;
first = false;
}
if (st.size === 0) {
const r = await c.invoke(CapabilityNames.FsWrite, {
path: remote,
data: '',
create_dirs: opts.createDirs === true,
});
if (r.status !== 'ok') fail(`上传失败: ${r.error?.message}`);
}
} finally {
closeSync(fd);
}
console.log(`✓ 已上传 ${local} → ${remote}(${humanSize(offset)})`);
});
}
+237
View File
@@ -0,0 +1,237 @@
import { closeSync, existsSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync, writeSync } from 'node:fs';
import fsSync from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path, { join } from 'node:path';
import {
NodeAgentClient,
loadMacroFile,
runMacro,
ClientError,
loadConfig,
saveConfig,
configPath,
toWsUrl,
loadKeys,
createKeys,
keysFilePath,
discoverOnce,
resolveTarget,
resolveNodeSelector,
emptyConfig,
type ClientConfig,
type ResolvedTarget,
type NodeProfile,
} from '@nodeagent/client';
import {
CapabilityNames,
HOTKEY_PRESETS,
matchPattern,
DEFAULT_DISCOVERY_PORT,
type CapabilityDescriptor,
type InvokeResult,
} from '@nodeagent/protocol';
import {
callAndPrint,
fail,
getClientConfig,
humanSize,
printJson,
riskIcon,
withClient,
withClientDirect,
} from '../core.js';
import { CHUNK_BYTES, type Options } from '../types.js';
// CLI 命令组:cmd/gui.ts
export async function cmdScreen(opts: Options): Promise<void> {
await withClient((c) =>
callAndPrint(c, CapabilityNames.ScreenInfo, {}, opts.json, (data) => {
const rows = (
data as { displays: Array<{ id: number; name: string; width: number; height: number; is_primary: boolean }> }
).displays;
for (const d of rows) {
console.log(
` #${d.id} ${String(d.width).padStart(5)}x${String(d.height).padEnd(5)} ${d.is_primary ? '[主屏]' : ' '} ${d.name}`,
);
}
}),
);
}
export async function cmdScreenshot(opts: Options): Promise<void> {
const args: Record<string, unknown> = {};
if (opts.format) args['format'] = opts.format;
if (opts.scale) {
const s = Number(opts.scale);
// 协议限定 scale ∈ (0, 1](缩小)。原先直接把非法值发出去,
// 被控端只回一句「参数校验失败」,看不出是哪一项 —— 2026-10-05 实测踩过。
if (!Number.isFinite(s) || s <= 0 || s > 1) {
fail(`--scale 应在 0 到 1 之间(1=原尺寸,0.5=半尺寸),收到: ${opts.scale}`);
}
args['scale'] = s;
}
if (opts.region) {
const nums = opts.region.split(',').map(Number);
if (nums.length !== 4 || nums.some((n) => !Number.isFinite(n))) fail('--region 格式应为 x,y,width,height');
args['region'] = { x: nums[0], y: nums[1], width: nums[2], height: nums[3] };
}
await withClient((c) =>
callAndPrint(c, CapabilityNames.ScreenCapture, args, opts.json, (data) => {
const d = data as { image: string; format: string; width: number; height: number; bytes: number };
const out = opts.out ?? `screenshot.${d.format === 'png' ? 'png' : 'jpg'}`;
writeFileSync(out, Buffer.from(d.image, 'base64'));
console.log(`✓ 已保存 ${out} ${d.width}x${d.height} ${humanSize(d.bytes)}`);
}),
);
}
export async function cmdMouse(action: string | undefined, positionals: string[], opts: Options): Promise<void> {
const [a, b] = positionals;
switch (action) {
case 'move': {
if (!a || !b) fail('用法: nodeagent mouse move <x> <y> [--duration 300]');
const args: Record<string, unknown> = { x: Number(a), y: Number(b) };
if (opts.duration) args['duration_ms'] = Number(opts.duration);
await withClient((c) =>
callAndPrint(c, CapabilityNames.MouseMove, args, opts.json, (d) => {
const r = d as { x: number; y: number };
console.log(`✓ 鼠标已移动到 (${r.x}, ${r.y})`);
}),
);
return;
}
case 'click': {
const args: Record<string, unknown> = {};
if (a && b) {
args['x'] = Number(a);
args['y'] = Number(b);
}
if (opts.button) args['button'] = opts.button;
await withClient((c) =>
callAndPrint(c, CapabilityNames.MouseClick, args, opts.json, (d) => {
const r = d as { x: number; y: number; button: string };
console.log(`✓ 已${r.button}键点击 (${r.x}, ${r.y})`);
}),
);
return;
}
case 'scroll': {
if (!a) fail('用法: nodeagent mouse scroll <delta> [y]');
const args: Record<string, unknown> = { delta: Number(a) };
if (b) args['y'] = Number(b);
await withClient((c) =>
callAndPrint(c, CapabilityNames.MouseScroll, args, opts.json, (d) => {
const r = d as { delta: number };
console.log(`✓ 已滚动 ${r.delta} 格`);
}),
);
return;
}
case 'drag': {
const [x1, y1, x2, y2] = positionals;
if (!x1 || !y1 || !x2 || !y2) {
fail('用法: nodeagent mouse drag <x1> <y1> <x2> <y2> [--button left|right|middle]');
}
const args: Record<string, unknown> = {
from_x: Number(x1),
from_y: Number(y1),
to_x: Number(x2),
to_y: Number(y2),
};
if (opts.button) args['button'] = opts.button;
if (opts.duration) args['step_delay_ms'] = Number(opts.duration);
await withClient((c) =>
callAndPrint(c, CapabilityNames.MouseDrag, args, opts.json, (d) => {
const r = d as { from: { x: number; y: number }; to: { x: number; y: number }; steps: number };
console.log(
`✓ 已拖拽 (${r.from.x}, ${r.from.y}) → (${r.to.x}, ${r.to.y}),${r.steps} 步`,
);
}),
);
return;
}
default:
fail('用法: nodeagent mouse <move|click|scroll> ...');
}
}
export async function cmdKey(action: string | undefined, positionals: string[], opts: Options): Promise<void> {
switch (action) {
case 'type': {
const text = positionals.join(' ');
if (!text) fail('用法: nodeagent key type "<文本>"');
const args: Record<string, unknown> = { text };
if (opts.interval) args['interval_ms'] = Number(opts.interval);
await withClient((c) =>
callAndPrint(c, CapabilityNames.KeyType, args, opts.json, (d) => {
const r = d as { length: number };
console.log(`✓ 已输入 ${r.length} 个字符`);
}),
);
return;
}
case 'press': {
// v1.5:三种用法(一次一种)
// nodeagent key press "ctrl+shift+esc" 字符串热键
// nodeagent key press copy 预设名
// nodeagent key press ctrl c 老形式(空格分隔的各键)
if (positionals.length === 0) fail('用法: nodeagent key press "<热键>" | <预设名> | <键1> [键2] ...(如 "ctrl+shift+esc" / copy / ctrl c)');
const args: Record<string, unknown> = {};
let positionalsEcho = positionals;
if (positionals.length === 1) {
const one = positionals[0]!;
// 含分隔符 → 字符串热键;不含但命中预设表 → 预设;都不中 → 单键(也当热键交给被控端解析)
const spec = HOTKEY_PRESETS[one.trim().toLowerCase()];
if (spec) {
args['preset'] = one.trim().toLowerCase();
} else {
args['hotkey'] = one;
}
} else {
args['keys'] = positionals;
}
// 老形式说明:位置参数以空格分隔 → 兼容"ctrl c"(无 + 号)
if (opts.interval) args['interval_ms'] = Number(opts.interval);
// v1.5 新增可选参数
if (opts.hold) args['hold_ms'] = Number(opts.hold);
if (opts.route) args['route'] = opts.route;
// 媒体键可定向到指定进程(播放器常建 30+ 辅助窗口,只投主窗口往往无效)
if (opts.pid) args['target_pid'] = Number(opts.pid);
await withClient((c) =>
callAndPrint(c, CapabilityNames.KeyPress, args, opts.json, (d) => {
// 被控端返回 { pressed, chords, times },**没有 keys 字段**
// (真机 2026-10-04 实测:原先直接读 r.keys.join() 会抛
// "Cannot read properties of undefined (reading 'join')")
// 故优先用请求参数回显,缺失时再退回 chords 展开。
const r = d as {
keys?: string[]; chords?: string[][]; times?: number; via?: string;
presets?: string[]; channel?: string; route?: string; sent_windows?: number;
target_pid?: number | null; note?: string;
};
const shown = (r.chords ?? []).map((ch) => ch.join('+')).join(' → ');
const label = r.keys?.length
? r.keys.join('+')
: shown || positionalsEcho.join('+');
const times = r.times && r.times > 1 ? `(重复 ${r.times} 次)` : '';
const presetEcho = r.presets?.length ? `(预设 ${r.presets.join('→')} = ${shown})` : '';
const via =
r.channel === 'appcommand'
? `(媒体通道${r.target_pid ? ` → pid ${r.target_pid}` : ' · 广播'})`
: r.channel === 'postmessage'
? `(后台投递 → pid ${r.target_pid},命中 ${r.sent_windows ?? 0} 窗口)`
: '';
console.log(`✓ 已按下 ${label}${times}${presetEcho}${via}`);
if (r.note) console.log(` ℹ️ ${r.note}`);
}),
);
return;
}
default:
fail('用法: nodeagent key <type|press> ...');
}
}
// ---------- v3+ 审计 ----------
/** v11:审计链完整性校验。 */
+125
View File
@@ -0,0 +1,125 @@
import { closeSync, existsSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync, writeSync } from 'node:fs';
import fsSync from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path, { join } from 'node:path';
import {
NodeAgentClient,
loadMacroFile,
runMacro,
ClientError,
loadConfig,
saveConfig,
configPath,
toWsUrl,
loadKeys,
createKeys,
keysFilePath,
discoverOnce,
resolveTarget,
resolveNodeSelector,
emptyConfig,
type ClientConfig,
type ResolvedTarget,
type NodeProfile,
} from '@nodeagent/client';
import {
CapabilityNames,
matchPattern,
DEFAULT_DISCOVERY_PORT,
type CapabilityDescriptor,
type InvokeResult,
} from '@nodeagent/protocol';
import {
callAndPrint,
fail,
getClientConfig,
humanSize,
printJson,
riskIcon,
withClient,
withClientDirect,
} from '../core.js';
import { CHUNK_BYTES, type Options } from '../types.js';
// CLI 命令组:cmd/net.ts
export async function cmdNet(sub: string | undefined, opts: Options): Promise<void> {
if (sub === 'status' || sub === undefined) {
await withClient((c) =>
callAndPrint(c, CapabilityNames.NetStatus, {}, opts.json, (data) => {
const d = data as {
interfaces: Array<Record<string, unknown>>;
pending: Array<{ task_name: string; seconds_left: number; rollback_at: number }>;
pending_count: number;
note: string;
};
console.log('网卡地址:');
for (const i of d.interfaces ?? []) {
console.log(` ${String(i['InterfaceAlias'] ?? '?')} ip=${String(i['ip'] ?? '-')} gw=${String(i['gw'] ?? '-')}`);
}
if (d.pending_count > 0) {
console.log('\n⏳ 待确认的网络变更(逾期自动回滚):');
for (const p of d.pending) {
console.log(` ${p.task_name} 剩余 ${p.seconds_left}s`);
}
console.log(' 确认提交:nodeagent net confirm');
} else {
console.log('\n(无待确认变更)');
}
}),
);
return;
}
if (sub === 'confirm') {
const args: Record<string, unknown> = {};
if (opts.taskName) args['task_name'] = opts.taskName;
await withClient((c) =>
callAndPrint(c, CapabilityNames.NetConfirm, args, opts.json, (data) => {
const d = data as { confirmed: number; cancelled: string[]; remaining: string[]; note?: string };
if (d.confirmed === 0) console.log(`✓ 无需确认:${d.note ?? '没有待确认的网络变更'}`);
else console.log(`✓ 已确认(提交)${d.confirmed} 项:${d.cancelled.join(', ')}`);
if ((d.remaining ?? []).length > 0) console.error(`⚠️ 仍有未取消:${d.remaining.join(', ')}`);
}),
);
return;
}
if (sub === 'apply') {
const modeRaw = opts.mode ?? 'static';
const args: Record<string, unknown> = { mode: modeRaw };
if (opts.iface) args['interface'] = opts.iface;
if (opts.ip) args['ip'] = opts.ip;
if (opts.mask) args['mask'] = opts.mask;
if (opts.gateway) args['gateway'] = opts.gateway;
if (opts.dns) args['dns'] = String(opts.dns).split(',').map((x) => x.trim());
if (opts.command) args['command'] = opts.command;
if (opts.confirmWithin) args['confirm_within_ms'] = Number(opts.confirmWithin);
if (!opts.yes) {
fail(
'网络变更属高危操作,需显式加 --yes 确认。\n' +
'行为:备份当前配置 → 应用变更 → 注册 OS 级定时回滚任务 → 你在窗口期内 net confirm 即提交;\n' +
' 逾期未确认则自动回滚到变更前配置(不会把自己关在门外)。',
);
}
// 网络变更本身耗时较长(netsh 重配网卡 + 注册计划任务),默认 60s 不够
await withClient((c) =>
callAndPrint(c, CapabilityNames.NetApply, args, opts.json, (data) => {
const d = data as {
applied: boolean;
interface: string;
backup_path: string;
task_name: string;
confirm_within_ms: number;
hint: string;
};
console.log(`✓ 已应用变更(网卡 ${d.interface}),备份:${d.backup_path}`);
console.log(` 自动回滚任务:${d.task_name}(${Math.round(d.confirm_within_ms / 1000)}s 后触发)`);
console.log(` ${d.hint}`);
}, 200_000),
);
return;
}
fail('用法: nodeagent net [status] | net apply --mode static|dhcp|command [...] --yes | net confirm [--task-name X]');
}
+622
View File
@@ -0,0 +1,622 @@
import { createHash } from 'node:crypto';
import { closeSync, existsSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync, writeSync } from 'node:fs';
import fsSync from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path, { join } from 'node:path';
import {
NodeAgentClient,
loadMacroFile,
runMacro,
ClientError,
loadConfig,
saveConfig,
configPath,
toWsUrl,
loadKeys,
createKeys,
keysFilePath,
discoverOnce,
resolveTarget,
resolveNodeSelector,
emptyConfig,
type ClientConfig,
type ResolvedTarget,
type NodeProfile,
} from '@nodeagent/client';
import {
CapabilityNames,
matchPattern,
DEFAULT_DISCOVERY_PORT,
type CapabilityDescriptor,
type InvokeResult,
} from '@nodeagent/protocol';
import {
callAndPrint,
fail,
getClientConfig,
getNodeOverride,
probeOnce,
humanSize,
printJson,
riskIcon,
withClient,
withClientDirect,
} from '../core.js';
import { CHUNK_BYTES, type Options } from '../types.js';
// CLI 命令组:cmd/ops.ts
export async function cmdBg(command: string, opts: Options): Promise<void> {
await withClient((c) =>
callAndPrint(
c,
CapabilityNames.ShellExec,
{ command, async: true, timeout_ms: opts.timeoutMs ? Number(opts.timeoutMs) : undefined },
opts.json,
(data) => {
const d = data as { task_id: string; state: string };
console.log(`✓ 后台任务已启动: ${d.task_id}(${d.state})`);
console.log(' 查询: nodeagent task ' + d.task_id);
console.log(' 终止: nodeagent task ' + d.task_id + ' --kill');
},
),
);
}
export async function cmdTasks(opts: Options): Promise<void> {
await withClient((c) =>
callAndPrint(c, CapabilityNames.TaskList, {}, opts.json, (data) => {
const rows = (data as { tasks: Array<{ task_id: string; state: string; duration_ms: number; exit_code?: number }> }).tasks;
if (rows.length === 0) {
console.log('当前没有后台任务');
return;
}
console.log(`${'任务ID'.padEnd(26)} ${'状态'.padEnd(9)} ${'耗时'.padStart(9)} 退出码`);
for (const t of rows) {
console.log(
`${t.task_id.padEnd(26)} ${t.state.padEnd(9)} ${String(t.duration_ms + 'ms').padStart(9)} ${t.exit_code ?? '-'}`,
);
}
}),
);
}
export async function cmdTask(taskId: string, opts: Options): Promise<void> {
if (opts.kill) {
await withClient((c) =>
callAndPrint(c, CapabilityNames.TaskKill, { task_id: taskId }, opts.json, (data) => {
const d = data as { killed: boolean };
console.log(d.killed ? `✓ 已终止 ${taskId}` : `任务 ${taskId} 已不在运行`);
}),
);
return;
}
const offset = opts.offset ? Number(opts.offset) : 0;
await withClient((c) =>
callAndPrint(
c,
CapabilityNames.TaskGet,
{ task_id: taskId, offset },
opts.json,
(data) => {
const d = data as {
state: string;
exit_code: number | null;
duration_ms: number;
data: string;
offset: number;
total_bytes: number;
};
if (d.data) console.log(d.data);
console.error(
`[${d.state} · ${d.duration_ms}ms · 已读 ${d.offset}/${d.total_bytes} 字节${d.exit_code !== null && d.state !== 'running' ? ` · 退出码 ${d.exit_code}` : ''}]`,
);
if (d.state === 'running' && d.offset < d.total_bytes) {
console.error(' 续读: nodeagent task ' + taskId + ' --offset ' + d.offset);
}
},
),
);
}
/**
* v11 / C5:把一个能力并发下发到多台设备并汇总结果。
* 每台设备独立连接、互不阻塞(allSettled),任一失败不影响其他。
*/
export async function cmdFanout(capability: string, opts: Options): Promise<void> {
const cfg = getClientConfig();
const names = (opts.nodes ?? '')
.split(',')
.map((x) => x.trim())
.filter(Boolean);
const selector = names.length > 0 ? names : Object.keys(cfg.nodes);
// v12:支持 @组名(组内可嵌套引用其它组)
const { nodes: targets, resolvedGroups } = resolveNodeSelector(cfg, selector);
if (targets.length === 0) fail('没有可用的设备,请先 nodeagent connect 或检查分组定义');
for (const [g, members] of Object.entries(resolvedGroups)) {
if (members.length === 0) fail(`分组「${g}」为空或不存在(可用: ${Object.keys(cfg.groups ?? {}).join(', ') || '无'})`);
}
let args: Record<string, unknown> = {};
if (opts.args) {
try {
args = JSON.parse(opts.args) as Record<string, unknown>;
} catch {
fail('--args 需为合法 JSON,例如 --args {"limit":5}');
}
}
const started = Date.now();
const results = await Promise.allSettled(
targets.map(async (n) => {
const t0 = Date.now();
const data = await withClient(async (c) => {
const r = await c.invoke(capability, args, Number(opts.timeoutMs ?? 60_000));
if (r.status === 'failed') throw new Error(`${r.error?.name}: ${r.error?.message}`);
return r.data;
}, n);
return { node: n, ms: Date.now() - t0, data };
}),
);
if (opts.json) {
printJson(
results.map((r, i) =>
r.status === 'fulfilled'
? { node: r.value.node, ok: true, ms: r.value.ms, data: r.value.data }
: { node: targets[i], ok: false, error: String(r.reason) },
),
);
return;
}
console.log(`并发下发 ${capability} → ${targets.length} 台设备(总耗时 ${Date.now() - started}ms)\n`);
results.forEach((r, i) => {
const name = (targets[i] ?? '?').padEnd(16);
if (r.status === 'fulfilled') {
const preview = JSON.stringify(r.value.data);
console.log(` ✓ ${name} ${String(r.value.ms + 'ms').padStart(7)} ${preview.slice(0, 150)}`);
} else {
console.log(` ✗ ${name} ${'—'.padStart(7)} ${String(r.reason).slice(0, 150)}`);
}
});
}
export async function cmdRecord(opts: Options): Promise<void> {
const args: Record<string, unknown> = {};
if (opts.duration) args['duration_ms'] = Number(opts.duration);
if (opts.fps) args['fps'] = Number(opts.fps);
if (opts.scale) args['scale'] = Number(opts.scale);
if (opts.region) {
const n = opts.region.split(',').map(Number);
if (n.length !== 4 || n.some((v) => !Number.isFinite(v))) fail('region 格式应为 "x,y,width,height"');
args['region'] = { x: n[0], y: n[1], width: n[2], height: n[3] };
}
await withClient((c) =>
callAndPrint(c, CapabilityNames.ScreenRecord, args, opts.json, (data) => {
const d = data as { dir: string; frames: number; fps: number; elapsed_ms: number; video_path?: string; frames_only: boolean };
console.log(`✓ 录制完成:${d.frames} 帧 @ ${d.fps}fps(${d.elapsed_ms}ms)`);
console.log(` 目录: ${d.dir}`);
if (d.video_path) console.log(` 视频: ${d.video_path}`);
else if (d.frames_only) console.log(' 提示: 无 ffmpeg,用 `nodeagent pull` 或 `nodeagent ls` 取回帧');
}),
);
}
/**
* v11 / D2:一键部署 / 升级被控端 agent。
* 流程:被控端自报入口路径(system.info)→ 上传新 agent.mjs → 受控重启 → 复验能力数。
* 全程无需手工拷文件或起计划任务。
*/
/**
* 部署/更新被控端 agent(v18 起带指纹校验)。
*
* 流程:本地算 sha256 → 问远端构建指纹 → 相同则跳过(--force 可强制)→
* 备份 → 分块上传 → 受控重启 → **轮询直到远端指纹等于本地**(替代盲等)。
*
* 为什么必须比指纹:只看「PID 变了 / 能力数对了」证明不了换成了新代码
* (旧版可能恰好有同样多的能力)。内容哈希是唯一可靠判据。
*/
export async function cmdDeploy(localFile: string, opts: Options): Promise<void> {
if (!existsSync(localFile)) fail(`本地文件不存在: ${localFile}`);
const buf = readFileSync(localFile);
if (buf.length < 10_000) fail(`文件过小,疑似不是 agent 包: ${localFile}`);
const localHash = createHash('sha256').update(buf).digest('hex').slice(0, 12);
// 单条 RPC 的安全分块(与上传保持一致)
const BACKUP_CHUNK = 512 * 1024;
type Info = {
agent_script?: string;
pid?: number;
node_path?: string;
build?: { hash?: string; node?: string; started_at?: number };
};
const remote = await withClient((c) => c.invoke<Info>(CapabilityNames.SystemInfo, {}));
if (remote.status === 'failed') fail('无法读取被控端信息');
const info: Info = remote.data ?? {};
const remoteHash = info.build?.hash ?? 'unknown';
const target = opts.path ?? info.agent_script;
if (!target) fail('被控端未上报入口路径,请用 --path <远端路径> 指定');
console.log(`本地 : ${localHash} (${(buf.length / 1024).toFixed(0)} KB)`);
console.log(`远端 : ${remoteHash} ${info.build?.node ? `(node ${info.build.node})` : ''}`);
console.log(`目标 : ${target}(当前 PID ${info.pid ?? '?'})`);
const unknownRemote = remoteHash === 'unknown';
if (unknownRemote) {
console.log('ℹ️ 远端未上报构建指纹(多为 v18 之前的旧版)—— 无法比对,建议直接部署');
}
if (opts.check === true) {
if (remoteHash === localHash) {
console.log('✓ 已是最新,无需更新');
} else if (unknownRemote) {
console.log('⚠️ 无法比对(远端过旧),建议执行 `nodeagent deploy <文件>` 更新');
process.exitCode = 2;
} else {
console.log('⚠️ 版本不一致 —— 执行 `nodeagent deploy <文件>` 更新(加 --force 可强制覆盖)');
process.exitCode = 2;
}
return;
}
if (!unknownRemote && remoteHash === localHash && opts.force !== true) {
console.log('✓ 远端指纹与本地一致,已是最新(如需强制覆盖请加 --force)');
return;
}
// 备份 + 上传 + 重启
const uploadInfo = await withClient(async (c) => {
// 备份走协议自带的 fs 能力(而非 shell 的 Copy-Item)——
// Copy-Item 是 Windows 专用,在 macOS 被控端上会静默失败(真机踩过:以为备份了,其实没有)。
const backup = `${target}.bak-${Date.now()}`;
let backupPath = '';
try {
const st = await c.invoke<{ size?: number; exists?: boolean }>(CapabilityNames.FsStat, { path: target });
const size = st.status === 'ok' ? (st.data?.size ?? 0) : 0;
if (size > 0) {
let off = 0;
let first = true;
while (off < size) {
// 自行计算本次长度并据此前进(对齐 cmdPush 的成熟写法):
// fs.read 返回的 offset 是**本次读取的起始位置**,拿它当前进量会原地打转
// → 死循环 + 文件暴涨(真机踩过:备份 33B→268KB 且部署卡死 200s)。
const len = Math.min(BACKUP_CHUNK, size - off);
const r = await c.invoke<{ data?: string }>(CapabilityNames.FsRead, {
path: target,
offset: off,
max_bytes: len,
// ⚠️ 必须显式 base64:默认 utf8 会把二进制按文本读(损坏),
// 再当 base64 写回就得到几十字节的垃圾备份(真机踩过:备份 33 字节)
encoding: 'base64',
});
if (r.status !== 'ok') break;
const chunk = r.data?.data ?? '';
if (!chunk) break;
const w = await c.invoke(CapabilityNames.FsWrite, {
path: backup,
data: chunk,
encoding: 'base64',
append: !first,
});
if (w.status !== 'ok') break;
first = false;
off += len;
}
if (!first) backupPath = backup;
}
} catch {
/* 备份失败不阻断部署,但下面会提示 */
}
if (backupPath) console.log(`已备份: ${backupPath}`);
else console.log('⚠️ 备份未成功(部署继续,但无自动回滚点)');
const CHUNK = 512 * 1024;
let offset = 0;
while (offset < buf.length) {
const part = buf.subarray(offset, Math.min(offset + CHUNK, buf.length));
const w = await c.invoke(CapabilityNames.FsWrite, {
path: target,
data: part.toString('base64'),
encoding: 'base64',
append: offset > 0,
});
if (w.status === 'failed') fail(`上传失败于 offset ${offset}: ${w.error?.message}`);
offset += part.length;
}
console.log(`✓ 已上传 ${(buf.length / 1024).toFixed(0)} KB`);
return { backupPath };
});
// 触发重启:**用独立连接且吞掉连接中断**。
// 重启会把本连接切断,这属于预期行为;若当成失败抛出,整个部署会被误判为失败
// (真机踩过:文件其实已换好、新进程也起来了,CLI 却报 E_NODE_OFFLINE)。
try {
const r = await withClient((c) =>
c.invoke(CapabilityNames.AgentRestart, { delay_ms: 1500, reason: 'deploy' }),
);
if (r.status === 'failed') fail(`重启失败: ${r.error?.message}`);
} catch (err) {
console.log(`(重启切断了连接,属预期:${err instanceof Error ? err.message : String(err)})`);
}
console.log('✓ 已触发重启,校验新版本上线…');
// 轮询校验:直到远端指纹 == 本地指纹。
// 用 probeOnce(永不退出),**不能用 withClient** —— 后者连不上会 fail() 退出,
// 使得「等待重连」根本无法实现(真机踩过:部署成功却报 E_NODE_OFFLINE)。
// 按墙钟限时而非固定次数:每次连接耗时不定,按次数算总时长不可预期。
let verified = false;
let lastSeen = '';
let pidSeen: number | undefined;
let capsSeen = 0;
const deadline = Date.now() + 90_000;
while (Date.now() < deadline) {
await new Promise((t) => setTimeout(t, 1500));
const p = await probeOnce();
if (!p.ok) continue;
lastSeen = p.buildHash ?? 'unknown';
pidSeen = p.pid;
capsSeen = p.caps;
if (lastSeen === localHash) {
verified = true;
console.log(`✓ 新版本已上线并校验通过:指纹 ${lastSeen},PID ${pidSeen ?? '?'},能力 ${capsSeen} 项`);
break;
}
}
if (!verified) {
const waited = Math.round((Date.now() - (deadline - 90_000)) / 1000);
console.error(`✗ ${waited}s 内未校验到新版本(当前远端指纹 ${lastSeen || '连不上'})`);
if (uploadInfo.backupPath) {
console.error(` 回滚方式:把备份复制回目标即可 ——`);
console.error(` nodeagent exec "Copy-Item '${uploadInfo.backupPath}' '${target}' -Force"`);
}
console.error(' 排查:被控端杀软(主流杀软/杀软)可能处置了被覆盖的 agent 文件');
process.exitCode = 1;
}
}
/**
* v12 / E1+E2:订阅被控端事件并实时接收推送。
* 与普通命令不同,它需要**保持连接**一段时间(长连接 + 推送),因此不走 withClient 的「用完即关」。
*/
export async function cmdEvents(opts: Options): Promise<void> {
const cfg = getClientConfig();
let target: ResolvedTarget;
try {
target = resolveTarget(cfg, getNodeOverride());
} catch (err) {
fail(err instanceof Error ? err.message : String(err));
}
// 无 --kind 时列出当前订阅
if (!opts.kind) {
await withClient((c) =>
callAndPrint(c, CapabilityNames.EventList, {}, opts.json, (data) => {
const d = data as {
watches: Array<{ watch_id: string; kind: string; description: string; events: number }>;
buffered: number;
};
if (d.watches.length === 0) {
console.log(`当前没有事件订阅(缓冲 ${d.buffered} 条)`);
console.log('用法: nodeagent events --kind file --path <路径> [--seconds 20]');
return;
}
console.log(`${'订阅ID'.padEnd(24)} ${'类型'.padEnd(8)} 事件数 说明`);
for (const w of d.watches) {
console.log(`${w.watch_id.padEnd(24)} ${w.kind.padEnd(8)} ${String(w.events).padStart(6)} ${w.description}`);
}
}),
);
return;
}
const seconds = Math.max(1, Math.min(600, Number(opts.seconds ?? 15)));
const { profile, clientId } = target;
const keys = profile.auth_mode === 'ed25519' ? loadKeys() : null;
const received: Array<Record<string, unknown>> = [];
const client = new NodeAgentClient({
url: toWsUrl(profile),
key: profile.key ?? '',
clientId,
insecure: profile.insecure,
authMode: profile.auth_mode,
privateKey: keys?.privateKey,
hub: profile.hub ? { token: profile.hub.token, nodeId: profile.hub.node_id } : undefined,
onEvent: (evt) => {
received.push(evt);
const ts = new Date(Number(evt['ts'] ?? Date.now())).toLocaleTimeString('zh-CN');
const line = `${ts} ${String(evt['action'] ?? '').padEnd(20)} ${String(evt['target'] ?? '')}${evt['detail'] ? ` (${evt['detail']})` : ''}`;
if (opts.json) console.log(JSON.stringify(evt));
else console.log(` ${line}`);
},
});
try {
await client.connect();
const args: Record<string, unknown> = { kind: opts.kind };
if (opts.path) args['path'] = opts.path;
if (opts.pattern) args['pattern'] = opts.pattern;
if (opts.recursive) args['recursive'] = true;
if (opts.intervalMs) args['interval_ms'] = Number(opts.intervalMs);
const r = await client.invoke<{ watch_id: string; description: string }>(
CapabilityNames.EventWatch,
args,
);
if (r.status === 'failed') fail(`${r.error?.name}: ${r.error?.message}`);
const watchId = (r.data as { watch_id: string }).watch_id;
if (!opts.json) {
console.log(`已订阅:${(r.data as { description: string }).description}`);
console.log(`订阅 ID: ${watchId} 监听 ${seconds}s(Ctrl+C 可提前结束)\n`);
}
await new Promise<void>((resolve) => {
const t = setTimeout(resolve, seconds * 1000);
process.once('SIGINT', () => {
clearTimeout(t);
resolve();
});
});
await client.invoke(CapabilityNames.EventUnwatch, { watch_id: watchId });
if (!opts.json) {
console.log(`\n共收到 ${received.length} 个事件(订阅已取消)`);
}
} catch (err) {
if (err instanceof ClientError) fail(`${err.name}: ${err.message}`);
throw err;
} finally {
client.close();
}
}
/**
* v12 / E4:GUI 宏 —— 把一次成功操作固化为可重放的步骤序列。
* 子命令:run(回放)/ validate(仅校验文件)/ init(生成示例)
*/
export async function cmdMacro(sub: string | undefined, file: string | undefined, opts: Options): Promise<void> {
if (sub === 'init') {
const target = file ?? 'macro.json';
if (existsSync(target)) fail(`文件已存在: ${target}`);
const sample = {
name: '示例:在记事本里打字并复制',
description: '演示 focus / find / type / key / clip / assert 组合;坐标与文本请按实际调整',
default_delay_ms: 300,
steps: [
{ action: 'focus', title: 'Notepad', retry: 3, interval_ms: 800 },
{ action: 'key', keys: ['ctrl', 'a'] },
{ action: 'key', keys: ['delete'] },
{ action: 'type', text: '宏回放演示 ${STAMP:-demo}' },
{ action: 'sleep', ms: 300 },
{ action: 'key', keys: ['ctrl', 'a'] },
{ action: 'key', keys: ['ctrl', 'c'] },
{ action: 'clip', expect: '宏回放演示' },
{ action: 'assert', kind: 'window', text: 'Notepad' },
],
};
writeFileSync(target, JSON.stringify(sample, null, 2) + '\n');
console.log(`✓ 已生成示例宏: ${target}`);
console.log(' 试用: nodeagent --node=<设备> macro run ' + target + ' --var STAMP=hello');
return;
}
if (!file) fail('用法: nodeagent macro run <文件.json> [--var k=v] / macro validate <文件.json> / macro init [文件.json]');
if (!existsSync(file)) fail(`宏文件不存在: ${file}`);
const macro = loadMacroFile(file);
if (sub === 'validate') {
const actions = macro.steps.map((s) => String(s['action'] ?? '?'));
console.log(`✓ 语法有效:${macro.steps.length} 个步骤`);
console.log(` 名称: ${macro.name ?? '(未命名)'}`);
console.log(` 步骤: ${actions.join(' → ')}`);
return;
}
// 解析 --var k=v
const vars: Record<string, string> = {};
for (const kv of opts.vars ?? []) {
const i = kv.indexOf('=');
if (i <= 0) fail(`--var 需为 k=v 形式: ${kv}`);
vars[kv.slice(0, i)] = kv.slice(i + 1);
}
await withClient(async (c) => {
const outDir = opts.out ?? '.';
const t0 = Date.now();
const res = await runMacro(
{
client: c,
saveCapture: opts.out
? (name, b64) => {
const p = join(outDir, name);
writeFileSync(p, Buffer.from(b64, 'base64'));
return p;
}
: undefined,
log: (m) => console.error(m),
},
macro,
vars,
);
if (opts.json) {
printJson(res);
if (!res.ok) process.exitCode = 1;
return;
}
console.log(`宏「${res.name}」共 ${macro.steps.length} 步,耗时 ${Date.now() - t0}ms\n`);
for (const s of res.steps) {
const mark = s.ok ? '✓' : '✗';
console.log(` ${mark} [${String(s.index).padStart(2)}] ${s.action.padEnd(8)} ${String(s.ms + 'ms').padStart(7)} ${s.detail ?? ''}`);
}
if (res.ok) {
console.log('\n✓ 全部步骤通过');
} else {
console.error(`\n✗ 在第 ${res.failed_at} 步失败,宏已中止`);
process.exitCode = 1;
}
});
}
/** v13:成功指标(对齐 PRD 2.2 四项验收指标)。 */
export async function cmdClip(opts: Options): Promise<void> {
// 图片上传:从本地文件读入(避免超长命令行参数)
if (opts.imageFile) {
const p = opts.imageFile;
if (!existsSync(p)) fail(`文件不存在: ${p}`);
const b64 = readFileSync(p).toString('base64');
await withClient((c) =>
callAndPrint(c, CapabilityNames.ClipSet, { image_base64: b64 }, opts.json, (data) => {
const d = data as { written_bytes: number };
console.log(`✓ 图片已写入剪贴板(${d.written_bytes} 字节)`);
}),
);
return;
}
if (opts.set !== undefined) {
await withClient((c) =>
callAndPrint(c, CapabilityNames.ClipSet, { text: opts.set }, opts.json, (data) => {
const d = data as { written: number };
console.log(`✓ 已写入剪贴板 ${d.written} 字符`);
}),
);
return;
}
// 图片下载:--out 指定保存路径
if (opts.out) {
await withClient((c) =>
callAndPrint(c, CapabilityNames.ClipGet, { format: 'image' }, opts.json, (data) => {
const d = data as { image_base64: string; bytes: number; format: string };
writeFileSync(opts.out!, Buffer.from(d.image_base64, 'base64'));
console.log(`✓ 剪贴板图片已保存 ${opts.out}(${d.bytes} 字节,${d.format})`);
}),
);
return;
}
await withClient((c) =>
callAndPrint(c, CapabilityNames.ClipGet, { format: opts.format ?? 'auto' }, opts.json, (data) => {
const d = data as {
type?: string;
text?: string;
bytes?: number;
image_base64?: string;
};
if (d.type === 'image') {
console.log(`(剪贴板为图片,${d.bytes} 字节;用 --out <路径> 保存)`);
return;
}
if (d.text) console.log(d.text);
else console.log('(剪贴板为空或非文本)');
}),
);
}
+442
View File
@@ -0,0 +1,442 @@
import { closeSync, existsSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync, writeSync } from 'node:fs';
import fsSync from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path, { join } from 'node:path';
import {
NodeAgentClient,
loadMacroFile,
runMacro,
ClientError,
loadConfig,
saveConfig,
configPath,
toWsUrl,
loadKeys,
createKeys,
keysFilePath,
discoverOnce,
resolveTarget,
resolveNodeSelector,
emptyConfig,
type ClientConfig,
type ResolvedTarget,
type NodeProfile,
} from '@nodeagent/client';
import {
CapabilityNames,
matchPattern,
DEFAULT_DISCOVERY_PORT,
type CapabilityDescriptor,
type InvokeResult,
} from '@nodeagent/protocol';
import {
callAndPrint,
reportCompat,
fail,
getClientConfig,
humanSize,
printJson,
probeOnce,
riskIcon,
withClient,
withClientDirect,
} from '../core.js';
import { CHUNK_BYTES, type Options } from '../types.js';
// CLI 命令组:cmd/system.ts
export async function cmdList(opts: Options): Promise<void> {
const caps = await withClient(async (c) => {
return c.listCapabilities();
});
if (opts.json) return printJson(caps);
console.log(`被控端可用能力(${caps.length}):`);
for (const c of caps as CapabilityDescriptor[]) {
console.log(` ${riskIcon(c.risk)} ${c.name.padEnd(24)} ${c.description}`);
}
}
export async function cmdStatus(opts: Options): Promise<void> {
await withClient((c) =>
callAndPrint(c, CapabilityNames.SystemStatus, {}, opts.json, (data) => {
const d = data as {
cpu_pct: number;
memory_used: number;
memory_total: number;
memory_pct: number;
disks: Array<{ drive: string; total: number; free: number; used_pct: number }>;
net: Array<{ adapter: string; ip: string }>;
};
console.log(`CPU 占用 : ${d.cpu_pct}%`);
console.log(`内存 : ${humanSize(d.memory_used)} / ${humanSize(d.memory_total)} (${d.memory_pct}%)`);
console.log('磁盘 :');
for (const disk of d.disks) {
console.log(` ${disk.drive.padEnd(12)} ${humanSize(disk.total - disk.free)} / ${humanSize(disk.total)} 已用 ${disk.used_pct}%`);
}
console.log('网络 :');
for (const n of d.net) console.log(` ${n.adapter.padEnd(16)} ${n.ip}`);
}),
);
}
/**
* 把 info 的字段值渲染成可读单行。
*
* 修正:原先一律 `String(v)`,遇到 `ps_helper` / `build` 这类**嵌套对象**会打印
* `[object Object]`(真机 2026-10-04 发现),排查时完全看不到内容。
* 现改为:null/undefined → 空占位;数组/对象 → `key=value` 紧凑摘要(截断防刷屏)。
*/
function renderInfoValue(v: unknown): string {
if (v === null || v === undefined) return '—';
if (typeof v === 'string') return v === '' ? '—(未设置)' : v;
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
try {
const flat: Record<string, unknown> = {};
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
flat[k] = val === null || val === undefined ? '—' : typeof val === 'object' ? JSON.stringify(val) : val;
}
const s = Object.entries(flat)
.map(([k, val]) => `${k}=${val}`)
.join(' ');
return s.length > 180 ? `${s.slice(0, 177)}...` : s;
} catch {
return String(v);
}
}
export async function cmdInfo(opts: Options): Promise<void> {
// 先打版本与兼容性(v20)——info 是最自然的「体检」命令
await withClient((c) => {
reportCompat(c);
// v21:把「身份钉定」与「来源网段限制」这两项安全状态摆出来
const cfg = getClientConfig();
const prof = cfg.nodes[cfg.current ?? ''];
const fp = c.getPeerCertFingerprint();
console.log(
prof?.cert_sha256
? `证书指纹 : 已钉住 ${prof.cert_sha256.slice(0, 16)}…${fp === prof.cert_sha256 ? '(本次一致 ✓)' : '(本次不一致 ✗)'}`
: `证书指纹 : **未钉住** —— 建议重连一次自动钉住(nodeagent connect <地址> …)`,
);
const info = c.listCapabilities();
void info;
return Promise.resolve();
});
await withClient((c) =>
callAndPrint(c, CapabilityNames.SystemInfo, {}, opts.json, (data) => {
const d = data as Record<string, unknown>;
for (const [k, v] of Object.entries(d)) {
const val = k === 'memory_total' && typeof v === 'number' ? humanSize(v) : renderInfoValue(v);
console.log(`${k.padEnd(14)}: ${val}`);
}
}),
);
}
export async function cmdPs(opts: Options): Promise<void> {
const limit = Number(opts.limit ?? 20);
await withClient((c) =>
callAndPrint(c, CapabilityNames.ProcessList, { limit }, opts.json, (data) => {
const rows = (data as { processes: Array<{ pid: number; name: string; cpu_pct: number; memory_bytes: number }> }).processes;
console.log(`${'PID'.padStart(7)} ${'CPU%'.padStart(7)} ${'内存'.padStart(9)} 名称`);
for (const p of rows) {
console.log(`${String(p.pid).padStart(7)} ${String(p.cpu_pct).padStart(7)} ${humanSize(p.memory_bytes).padStart(9)} ${p.name}`);
}
}),
);
}
export async function cmdServices(opts: Options): Promise<void> {
const limit = Number(opts.limit ?? 20);
await withClient((c) =>
callAndPrint(c, CapabilityNames.ServiceList, { limit }, opts.json, (data) => {
const rows = (data as { services: Array<{ name: string; display_name: string; state: string }> }).services;
console.log(`${'状态'.padEnd(10)} ${'服务名'.padEnd(28)} 显示名`);
for (const s of rows) console.log(`${s.state.padEnd(10)} ${s.name.padEnd(28)} ${s.display_name}`);
}),
);
}
export async function cmdExec(command: string, opts: Options): Promise<void> {
await withClient((c) =>
callAndPrint(c, CapabilityNames.ShellExec, { command }, opts.json, (data) => {
const d = data as { exit_code: number; stdout: string; stderr: string; duration_ms: number; truncated: boolean };
// 命令自身的输出原样打到 stdout —— 保证 `nodeagent exec "..." | jq` 不被污染
if (d.stdout) console.log(d.stdout);
if (d.stderr) console.error(d.stderr);
// 诊断信息(退出码/耗时)走 stderr:终端下照常可见,管道里不干扰数据
console.error(
`[退出码 ${d.exit_code} · ${d.duration_ms}ms${d.truncated ? ' · 输出已截断' : ''}]`,
);
}),
);
}
export async function cmdRestart(opts: Options): Promise<void> {
const delayMs = opts.delay ? Number(opts.delay) : 2000;
await withClient((c) =>
callAndPrint(c, CapabilityNames.AgentRestart, { delay_ms: delayMs, reason: 'cli' }, opts.json, (data) => {
const d = data as { scheduled: boolean; delay_ms: number; mechanism: string; message: string };
console.log(`✓ ${d.message}`);
console.log(` 机制: ${d.mechanism}`);
console.log(' 提示: 约 3~4 秒后重连,可执行 `nodeagent info` 验证是否已恢复');
}),
);
}
export async function cmdAuditVerify(opts: Options): Promise<void> {
await withClient((c) =>
callAndPrint(c, CapabilityNames.AuditVerify, {}, opts.json, (data) => {
const d = data as {
ok: boolean;
checked: number;
legacy: number;
broken_at?: { file: string; line: number; reason: string };
};
if (d.ok) {
console.log(`✓ 审计链完整(校验 ${d.checked} 条${d.legacy ? `,跳过历史条目 ${d.legacy} 条` : ''})`);
return;
}
console.error(`✗ 审计链已损坏:${d.broken_at?.reason ?? '未知原因'}`);
if (d.broken_at) console.error(` 位置: ${d.broken_at.file}:${d.broken_at.line}`);
console.error(` 已校验 ${d.checked} 条`);
process.exitCode = 1;
}),
);
}
export async function cmdAudit(opts: Options): Promise<void> {
const args: Record<string, unknown> = {};
if (opts.limit) args['limit'] = Number(opts.limit);
if (opts.since) args['since'] = Number(opts.since);
if (opts.type) args['type'] = opts.type;
if (opts.clientId) args['client_id'] = opts.clientId;
await withClient((c) =>
callAndPrint(c, CapabilityNames.AuditList, args, opts.json, (data) => {
const d = data as { entries: Array<Record<string, unknown>>; total: number; file: string };
console.log(`审计文件: ${d.file}`);
console.log(`读取 ${d.total} 条,展示最新 ${d.entries.length} 条:\n`);
for (const e of d.entries) {
const ts = new Date(Number(e['ts'])).toLocaleString('zh-CN');
const cols = [
ts.padEnd(20),
String(e['type'] ?? '').padEnd(17),
String(e['client_id'] ?? '-').padEnd(10),
String(e['capability'] ?? '-').padEnd(20),
String(e['status'] ?? '-').padEnd(7),
e['duration_ms'] !== undefined ? `${e['duration_ms']}ms` : '',
e['error'] ? `err=${e['error']}` : '',
e['reason'] ? String(e['reason']) : '',
];
console.log(' ' + cols.filter((x) => x !== '').join(' '));
}
}),
);
}
// ---------- v4 局域网发现 ----------
export async function cmdMetrics(opts: Options): Promise<void> {
const args: Record<string, unknown> = {};
if (opts.since) args['since'] = Number(opts.since);
await withClient((c) =>
callAndPrint(c, CapabilityNames.Metrics, args, opts.json, (data) => {
const m = data as {
close_loop: { attempts: number; ok: number; failed: number; success_rate: number };
app_install: { attempts: number; ok: number; failed: number; success_rate: number | null };
latency: {
p50_ms: number;
p95_ms: number;
max_ms: number;
timeouts: number;
fast: { samples: number; p50_ms: number; p95_ms: number; max_ms: number };
slow: { samples: number; p50_ms: number; p95_ms: number; max_ms: number; capabilities: string[] };
};
security: { acl_denied: number; rate_limited: number; auth_failures: number; interception_rate: number };
verdict: {
close_loop_ok: boolean;
app_install_ok: boolean;
p95_ok: boolean;
interception_ok: boolean;
all_pass: boolean;
};
targets: { close_loop_success_rate: number; app_install_success_rate: number; p95_ms: number };
by_capability: Record<string, { attempts: number; ok: number; success_rate: number; p95_ms: number }>;
window: { from: number; to: number; entries: number };
};
const pct = (v: number): string => `${(v * 100).toFixed(1)}%`;
const mark = (ok: boolean): string => (ok ? '✅' : '❌');
const from = m.window.from ? new Date(m.window.from).toLocaleString('zh-CN') : '-';
const to = m.window.to ? new Date(m.window.to).toLocaleString('zh-CN') : '-';
console.log(`窗口: ${from} → ${to} (审计 ${m.window.entries} 条)\n`);
console.log(`闭环成功率 ${pct(m.close_loop.success_rate).padStart(7)} ${mark(m.verdict.close_loop_ok)} 目标 ≥${pct(m.targets.close_loop_success_rate)} 样本 ${m.close_loop.ok}/${m.close_loop.attempts}`);
const ir = m.app_install.success_rate;
console.log(`装软件成功率 ${ir === null ? ' 无样本' : pct(ir).padStart(7)} ${mark(m.verdict.app_install_ok)} 目标 ≥${pct(m.targets.app_install_success_rate)} 样本 ${m.app_install.ok}/${m.app_install.attempts}`);
console.log(`P95 时延 ${String(m.latency.fast.p95_ms + 'ms').padStart(7)} ${mark(m.verdict.p95_ok)} 交互层(目标 <${m.targets.p95_ms}ms,样本 ${m.latency.fast.samples}:P50 ${m.latency.fast.p50_ms}ms / 最大 ${m.latency.fast.max_ms}ms${m.latency.timeouts ? ` / 超时 ${m.latency.timeouts}` : ''})`);
console.log(`慢操作耗时 ${String(m.latency.slow.p95_ms + 'ms').padStart(7)} —— 样本 ${m.latency.slow.samples}(${m.latency.slow.capabilities.join(' / ')};本身耗时由业务决定,不计入达标判定)`);
console.log(`安全拦截 ${pct(m.security.interception_rate).padStart(7)} ${mark(m.verdict.interception_ok)} ACL 拒绝 ${m.security.acl_denied} / 超频 ${m.security.rate_limited} / 认证失败 ${m.security.auth_failures}`);
console.log(`\n总体: ${m.verdict.all_pass ? '✅ 四项全达标' : '⚠️ 存在未达标项'}`);
const caps = Object.entries(m.by_capability).sort((a, b) => b[1].attempts - a[1].attempts);
if (caps.length > 0) {
console.log('\n按能力细分(Top 10):');
console.log(` ${'能力'.padEnd(26)} ${'次数'.padStart(6)} ${'成功率'.padStart(8)} ${'P95'.padStart(9)}`);
for (const [name, st] of caps.slice(0, 10)) {
console.log(` ${name.padEnd(26)} ${String(st.attempts).padStart(6)} ${pct(st.success_rate).padStart(8)} ${String(st.p95_ms + 'ms').padStart(9)}`);
}
}
}),
);
}
/** v16:网络变更两阶段提交(备份 → 应用 → 定时回滚 → 确认提交)。 */
export async function cmdInvoke(capability: string, opts: Options): Promise<void> {
let args: Record<string, unknown> = {};
if (opts.args) {
try {
args = JSON.parse(opts.args) as Record<string, unknown>;
} catch {
fail('--args 必须是合法 JSON');
}
}
await withClient(async (c) => {
const result: InvokeResult = await c.invoke(capability, args);
printJson(result);
if (result.status === 'failed') process.exitCode = 2;
});
}
// ---------- v2 图形操作 ----------
export async function cmdApps(opts: Options): Promise<void> {
await withClient((c) =>
callAndPrint(c, CapabilityNames.AppList, {}, opts.json, (data) => {
const rows = (data as { apps: Array<{ name: string; version: string; publisher: string }> }).apps;
console.log(`已安装软件(${rows.length}):`);
for (const a of rows) console.log(` ${a.name.padEnd(40)} ${(a.version || '-').padEnd(14)} ${a.publisher}`);
}),
);
}
export async function cmdInstall(pkg: string, opts: Options): Promise<void> {
await withClient((c) =>
callAndPrint(c, CapabilityNames.AppInstall, { package: pkg, id: opts.id }, opts.json, (data) => {
const d = data as { installed: boolean; name: string; version: string; detail: string };
console.log(d.installed ? `✓ 安装成功: ${d.name} ${d.version}` : `✗ 安装失败: ${d.name}`);
if (d.detail) console.log(`\n${d.detail}`);
}),
);
}
/** v17:音频控制(静音 / 主音量)。 */
export async function cmdAudio(sub: string | undefined, opts: Options): Promise<void> {
if (sub === 'set') {
const args: Record<string, unknown> = {};
if (opts.mute !== undefined) {
const v = String(opts.mute).toLowerCase();
if (!['on', 'off', 'true', 'false', '1', '0'].includes(v)) {
fail('--mute 只接受 on|off');
}
args['mute'] = ['on', 'true', '1'].includes(v);
}
if (opts.volume !== undefined) args['volume'] = Number(opts.volume);
if (Object.keys(args).length === 0) fail('用法: nodeagent audio set [--mute on|off] [--volume 0-100]');
await withClient((c) =>
callAndPrint(c, CapabilityNames.AudioSet, args, opts.json, (data) => {
const d = data as { muted: boolean; volume: number; backend: string };
console.log(`✓ 已应用:${d.muted ? '🔇 静音' : '🔊 未静音'} · 音量 ${d.volume}%(${d.backend})`);
}),
);
return;
}
if (sub === undefined || sub === 'get') {
await withClient((c) =>
callAndPrint(c, CapabilityNames.AudioGet, {}, opts.json, (data) => {
const d = data as { muted: boolean; volume: number; backend: string };
console.log(`${d.muted ? '🔇 静音' : '🔊 未静音'} · 主音量 ${d.volume}%(${d.backend})`);
}),
);
return;
}
fail('用法: nodeagent audio [get] | audio set [--mute on|off] [--volume 0-100]');
}
/**
* v19:拉取式自更新 —— 被控端自己从 URL 下载新版本并替换自身。
*
* 与 `deploy`(推送式)的分工:推送式要求控制端与被控端**可达**;本命令只要求
* **被控端能上网**(跨网段/NAT/异地场景的正解)。
*
* ⚠️ 复用 deploy 那一课:更新会重启 agent 并切断连接,属预期,
* 不能把「连接断了」当成失败 —— 故连接部分吞掉异常,再用 probeOnce 复核指纹。
*/
export async function cmdUpdate(opts: Options): Promise<void> {
if (!opts.url) {
fail(
'用法: nodeagent update --url <更新包地址> --sha256 <哈希> [--dry-run]\n' +
' 拉取式自更新:被控端自己下载并替换(要求被控端能上网,不要求与此刻可达)\n' +
' --dry-run 只下载校验,不改动任何文件',
);
}
if (!opts.sha256) {
fail('--sha256 必填:**安全底线** —— 不校验哈希等于开放远程代码执行');
}
const args: Record<string, unknown> = {
url: opts.url,
sha256: opts.sha256,
dry_run: opts.dryRun === true,
};
if (opts.check === true) args['dry_run'] = true;
let incoming = '';
try {
await withClient((c) =>
callAndPrint(c, CapabilityNames.AgentUpdate, args, opts.json, (data) => {
const d = data as {
dry_run?: boolean;
verified?: boolean;
updated?: boolean;
bytes: number;
current_hash?: string;
incoming_hash?: string;
previous?: { hash?: string; bytes?: number };
backup_path?: string;
restarted?: boolean;
note?: string;
};
incoming = d.incoming_hash ?? '';
if (d.dry_run) {
console.log(`✓ 校验通过(dry-run):${(d.bytes / 1024).toFixed(0)} KB`);
console.log(` 当前 ${d.current_hash} → 待更新 ${d.incoming_hash}`);
console.log(` ${d.note ?? ''}`);
return;
}
console.log(`✓ 已替换:${d.previous?.hash} → ${d.current_hash}(${(d.bytes / 1024).toFixed(0)} KB)`);
if (d.backup_path) console.log(` 回滚点: ${d.backup_path}`);
if (d.restarted) console.log(' 已触发重启,连接会断开(属预期)');
}, 600_000),
);
} catch (err) {
// 重启切断了连接 —— 属预期
console.log(`(连接中断,属重启预期:${err instanceof Error ? err.message : String(err)})`);
}
if (args['dry_run'] === true || !incoming) return;
// 复核:等被控端回来并确认指纹已变成新的
console.log('等待被控端重启并复核…');
const deadline = Date.now() + 90_000;
while (Date.now() < deadline) {
await new Promise((t) => setTimeout(t, 1500));
const probe = await probeOnce();
if (probe.ok && probe.buildHash === incoming) {
console.log(`✓ 自更新完成并校验通过:指纹 ${probe.buildHash},PID ${probe.pid ?? '?'},能力 ${probe.caps} 项`);
return;
}
}
console.error('✗ 90s 内未复核到新指纹 —— 请检查被控端是否被杀软处置,或把备份覆盖回入口回滚');
process.exitCode = 1;
}
+301
View File
@@ -0,0 +1,301 @@
import { closeSync, existsSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync, writeSync } from 'node:fs';
import fsSync from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path, { join } from 'node:path';
import {
NodeAgentClient,
loadMacroFile,
runMacro,
ClientError,
loadConfig,
saveConfig,
configPath,
toWsUrl,
loadKeys,
createKeys,
keysFilePath,
discoverOnce,
resolveTarget,
resolveNodeSelector,
emptyConfig,
type ClientConfig,
type ResolvedTarget,
type NodeProfile,
} from '@nodeagent/client';
import {
CAPABILITY_MANIFEST,
CapabilityNames,
capabilityDiff,
matchPattern,
DEFAULT_DISCOVERY_PORT,
type CapabilityDescriptor,
type InvokeResult,
} from '@nodeagent/protocol';
import { type Options } from './types.js';
// CLI 共享运行时(客户端/守护进程代理/输出工具)
export function getClientConfig(): ClientConfig {
const cfg = loadConfig();
if (!cfg) {
fail('尚未配置被控端。请先运行:\n nodeagent connect <host> --port 8765 --key <密钥>');
}
return cfg;
}
/** 本次命令的临时目标设备(来自全局 --node)。 */
// v16:全局 --node 的临时覆盖。跨模块共享可变状态:ESM 的 import 绑定只读,
// 故一律经访问器读写(直接 import 变量再赋值会编译不过)。
let currentNodeOverride: string | undefined;
/** 设置本次命令的 --node 覆盖(由入口在解析参数后调用)。 */
export function setNodeOverride(v: string | undefined): void {
currentNodeOverride = v;
}
/** 读取当前生效的 --node 覆盖。 */
export function getNodeOverride(): string | undefined {
return currentNodeOverride;
}
// ---------- v10 daemon 转发 ----------
export const DAEMON_SOCK = path.join(os.tmpdir(), 'nodeagentd.sock');
interface DaemonResponse {
status: 'ok' | 'failed' | 'daemon_error';
data?: unknown;
error?: { name: string; message: string } | string;
}
/** 经 daemon 转发一次能力调用(JSON lines over UDS)。 */
export function viaDaemon(node: string, capability: string, args: Record<string, unknown>, timeoutMs?: number): Promise<DaemonResponse> {
return new Promise((resolve, reject) => {
const s = net.connect(DAEMON_SOCK);
s.setTimeout(timeoutMs ?? 60_000);
let buf = '';
const failOnce = (err: Error): void => {
s.destroy();
reject(err);
};
s.once('error', failOnce);
s.once('timeout', () => failOnce(new Error('daemon 转发超时')));
s.once('connect', () => {
s.write(JSON.stringify({ node, capability, args, timeout_ms: timeoutMs }) + '\n');
});
s.on('data', (chunk) => {
buf += chunk.toString('utf8');
const idx = buf.indexOf('\n');
if (idx >= 0) {
try {
resolve(JSON.parse(buf.slice(0, idx)) as DaemonResponse);
} catch (err) {
failOnce(err instanceof Error ? err : new Error(String(err)));
return;
}
s.end();
}
});
});
}
/** 「虚拟客户端」:invoke 转发给 daemon,业务错误原样返回、daemon 自身错误抛异常触发回退。 */
export function daemonProxyClient(nodeName: string): { invoke: NodeAgentClient['invoke'] } {
return {
async invoke<T>(capability: string, args: Record<string, unknown>, timeoutMs?: number) {
const resp = await viaDaemon(nodeName, capability, args, timeoutMs);
if (resp.status === 'daemon_error') {
throw new Error(typeof resp.error === 'string' ? resp.error : resp.error?.message);
}
if (resp.status === 'failed') {
const e = resp.error;
return {
status: 'failed' as const,
error: typeof e === 'string' ? { name: 'E_EXECUTION_FAILED', message: e } : e,
} as never;
}
return { status: 'ok' as const, data: resp.data } as never;
},
};
}
export async function withClient<T>(fn: (client: NodeAgentClient) => Promise<T>, nodeName?: string): Promise<T> {
const cfg = getClientConfig();
let target: ResolvedTarget;
try {
target = resolveTarget(cfg, nodeName ?? getNodeOverride());
} catch (err) {
fail(err instanceof Error ? err.message : String(err));
}
const node = nodeName ?? getNodeOverride() ?? cfg.current;
// v10 daemon 快路径:socket 存活则经 daemon 复用长连接(省 1~2s TLS+握手)
if (node && existsSync(DAEMON_SOCK)) {
try {
return await fn(daemonProxyClient(node) as unknown as NodeAgentClient);
} catch (err) {
// daemon 自身错误(转发失败/超时)→ 静默回退直连;业务错误原样抛出
if (!(err instanceof ClientError)) {
return withClientDirect(fn, nodeName);
}
throw err;
}
}
return withClientDirect(fn, nodeName);
}
export async function withClientDirect<T>(fn: (client: NodeAgentClient) => Promise<T>, nodeName?: string): Promise<T> {
const cfg = getClientConfig();
let target: ResolvedTarget;
try {
target = resolveTarget(cfg, nodeName ?? getNodeOverride());
} catch (err) {
fail(err instanceof Error ? err.message : String(err));
}
const { profile, clientId } = target;
const keys = profile.auth_mode === 'ed25519' ? loadKeys() : null;
const client = new NodeAgentClient({
url: toWsUrl(profile),
key: profile.key ?? '',
clientId,
insecure: profile.insecure,
certSha256: profile.cert_sha256, // v21:钉住的证书指纹(不一致则拒绝连接)
authMode: profile.auth_mode,
privateKey: keys?.privateKey,
hub: profile.hub ? { token: profile.hub.token, nodeId: profile.hub.node_id } : undefined,
});
try {
await client.connect();
return await fn(client);
} catch (err) {
if (err instanceof ClientError) {
fail(`${err.name}: ${err.message}`);
}
throw err;
} finally {
client.close();
}
}
export function fail(msg: string): never {
console.error(`✗ ${msg}`);
process.exit(1);
}
export function printJson(value: unknown): void {
console.log(JSON.stringify(value, null, 2));
}
/** 调用能力并处理 status === 'failed' 的业务失败。 */
export async function callAndPrint(
client: NodeAgentClient,
capability: string,
args: Record<string, unknown>,
json: boolean,
render: (data: unknown) => void,
timeoutMs?: number,
): Promise<void> {
const result = await client.invoke(capability, args, timeoutMs);
if (result.status === 'failed') {
fail(`${result.error?.name ?? 'E_EXECUTION_FAILED'}: ${result.error?.message ?? '执行失败'}`);
}
if (json) printJson(result.data);
else render(result.data);
}
export function humanSize(bytes: number): string {
if (!Number.isFinite(bytes)) return String(bytes);
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let v = bytes;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i += 1;
}
return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${units[i]}`;
}
export function riskIcon(risk: string): string {
return risk === 'high' ? '🔴' : risk === 'medium' ? '🟡' : '🟢';
}
/**
* 探测一次被控端(用于「等待重启/上线」这类**需要反复重试**的场景)。
*
* ⚠️ 不能用 withClient:它在连接失败时会直接 `fail()` **退出进程**(见 withClientDirect),
* 于是重试循环第一次连不上就整个 CLI 退出了 —— 真机踩过:部署其实已成功,
* CLI 却报 E_NODE_OFFLINE,且看不到任何自定义提示。本函数**永不抛出**。
*/
export async function probeOnce(nodeName?: string): Promise<{
ok: boolean;
pid?: number;
buildHash?: string;
caps: number;
error?: string;
}> {
try {
const cfg = getClientConfig();
const target = resolveTarget(cfg, nodeName ?? getNodeOverride());
const keys = target.profile.auth_mode === 'ed25519' ? loadKeys() : null;
const client = new NodeAgentClient({
url: toWsUrl(target.profile),
key: target.profile.key ?? '',
clientId: target.clientId,
insecure: target.profile.insecure,
certSha256: target.profile.cert_sha256, // v21:钉住的证书指纹
authMode: target.profile.auth_mode,
privateKey: keys?.privateKey,
hub: target.profile.hub ? { token: target.profile.hub.token, nodeId: target.profile.hub.node_id } : undefined,
});
await client.connect();
const r = await client.invoke<{ pid?: number; build?: { hash?: string } }>(CapabilityNames.SystemInfo, {});
const caps = client.listCapabilities().length;
client.close();
const d = r.data ?? {};
return { ok: r.status === 'ok', pid: d.pid, buildHash: d.build?.hash, caps };
} catch (err) {
return { ok: false, caps: 0, error: err instanceof Error ? err.message : String(err) };
}
}
/**
* 新旧版本兼容提示(v20)。
*
* 背景:agent 与 CLI 是**分开更新**的(CLI 随 Mac 装,agent 推/拉到被控端),
* 必然出现新旧混用。等到调用时才抛 E_CAPABILITY_NOT_FOUND 会让人摸不着头脑,
* 所以在握手后就明确告诉用户「谁旧、缺什么、怎么升」。
*
* 兼容性依据(见 docs/VERSIONING.md):**只增不改** ——
* 新 agent 向后兼容旧 CLI(新增能力/可选参数),因此升级顺序建议先被控端。
*/
export function reportCompat(client: NodeAgentClient): void {
const remoteNames = client.listCapabilities().map((c) => c.name);
const localNames = CAPABILITY_MANIFEST.map((c) => c.name);
const diff = capabilityDiff(localNames, remoteNames);
const v = client.getAgentVersion();
const b = client.getAgentBuild();
if (v) {
console.log(`被控端版本 : v${v}${b?.commit ? ` @ ${b.commit}` : ''}${b?.hash ? ` 构建 ${b.hash}` : ''}`);
} else {
console.log('被控端版本 : 未上报(多为 v20 之前的版本,建议更新一次)');
}
if (diff.missingOnRemote.length > 0) {
console.warn(`⚠️ 被控端缺少 ${diff.missingOnRemote.length} 项本控制端已知的能力 —— 远端较旧:`);
console.warn(` ${diff.missingOnRemote.join(', ')}`);
console.warn(' → 建议更新被控端:nodeagent update --url <包地址> --sha256 <哈希>');
}
if (diff.unknownLocally.length > 0) {
console.warn(`ℹ️ 被控端还有 ${diff.unknownLocally.length} 项本控制端不认识的能力 —— 本 CLI 较旧:`);
console.warn(` ${diff.unknownLocally.join(', ')}`);
console.warn(' → 不影响既有功能;要用到这些新能力需更新控制端(git pull 后重跑 pack:mac + install-macos.sh)');
}
if (diff.missingOnRemote.length === 0 && diff.unknownLocally.length === 0) {
console.log(`能力对齐 : ${localNames.length} 项,两端一致 ✓`);
}
}
+84
View File
@@ -0,0 +1,84 @@
// CLI 帮助文本(从 index.ts 拆出)
export const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI)
用法:
nodeagent connect <host> [--port 8765] [--key <key>] [--insecure] [--id mac_01] [--auth-mode ed25519]
配置并连接被控端(握手成功后打印能力清单)
首次连接会自动钉住被控端证书指纹(TOFU);被控端重装/换证书后用 --forget-cert 重新钉住
经 Hub 中转: 追加 --hub-token <Hub 令牌> --hub-node <被控端 node_id> (v6)
nodeagent keygen [--id mac_01] 生成 Ed25519 密钥对并输出被控端 ACL 配置片段 (v3 零信任)
nodeagent nodes 列出已配置的被控端设备 (v5 多设备)
nodeagent use <设备名> 切换当前默认设备
nodeagent remove <设备名> 移除设备
nodeagent audit [--limit 20] [--type invoke|auth|acl|agent] [--client-id X] [--since <ms>]
查询被控端审计日志 (v3+)
nodeagent audit verify 校验审计链完整性(防篡改检测)(v11)
nodeagent metrics [--since <ms>] 成功指标:闭环率/装软件率/P95/拦截率 (v13)
nodeagent audio [get] 查看被控端静音状态与主音量 (v17)
nodeagent audio set [--mute on|off] [--volume 0-100] 设置静音 / 音量
nodeagent net [status] 网络现状 + 待确认变更 (v16)
nodeagent net apply --mode static --ip <ip> --mask <m> [--gateway g] --yes
改网络(两阶段提交:未确认则自动回滚,不会失联)
nodeagent net confirm [--task-name X] 确认提交网络变更(取消自动回滚)
nodeagent discover [--wait 5] 发现局域网内的被控端(UDP 广播,免手抄 IP)(v4)
文件传输 (v5):
nodeagent ls <远端路径> [--recursive] [--pattern "*.log"] 列目录
nodeagent stat <远端路径> 看元信息
nodeagent cat <远端路径> [--out 本地文件] 读文本(首块)
nodeagent pull <远端路径> [--out 本地文件] 下载(自动分块,支持大文件)
nodeagent push <本地文件> <远端路径> [--create-dirs] 上传(自动分块)
nodeagent info 查看系统信息 (system.info)
nodeagent status 查看资源状态 (system.status)
nodeagent ps [--limit 20] 查看进程 (system.process.list)
nodeagent services [--limit 20] 查看服务 (system.service.list)
nodeagent exec "<命令>" 执行命令 (system.shell.exec)
nodeagent apps 列出已安装软件 (app.list)
nodeagent install <包名|ID> 安装软件 (app.install)
nodeagent restart [--delay 2000] 受控重启被控端(配置变更后让自身生效)(v7)
nodeagent bg "<命令>" [--timeout-ms N] 后台执行长命令,立即返回 task_id (v10)
nodeagent tasks 列出后台任务
nodeagent task <id> [--offset N] [--kill] 读取/终止后台任务(支持增量续读)
nodeagent clip [--set "文本"] [--out <路径>] [--image-file <路径>]
读/写剪贴板文本或图片(--out 保存图片)(v11)
nodeagent macro run <file.json> [--var k=v] [--out <目录>]
回放 GUI 宏(步骤序列,逐步校验)(v12)
nodeagent macro validate <file.json> | macro init [文件]
nodeagent events [--kind file|process|net] [--path <路径>] [--pattern G] [--seconds 15]
订阅事件并实时接收推送(无 --kind 则列出订阅)(v12)
nodeagent record [--duration 5000] [--fps 2] [--scale 0.5] [--region x,y,w,h]
录屏为帧序列(有 ffmpeg 则封装 mp4)(v11)
nodeagent update --url <地址> --sha256 <哈希> [--dry-run]
被控端自己拉更新(只需它能上网,不要求与你可达)(v19)
nodeagent deploy <agent.mjs> [--path <远端路径>] [--check] [--force]
一键升级被控端 (v11/v18):比指纹 → 备份 → 上传 → 重启
→ 轮询校验;--check 只比对不部署
nodeagent group [add <组名> <设备...> | remove <组名>] 设备分组管理 (v12)
nodeagent fanout <能力名> [--nodes a,b|@组名] [--args JSON]
多设备并发调用并汇总 (v11)
nodeagent daemon [start|stop|status] 常驻连接池(批量操作提速 5~10 倍)(v10)
nodeagent list 列出被控端可用能力
nodeagent invoke <capability> [--args '<json>'] 通用调用
图形操作 (v2,输入控制需被控端开启 allow_input):
nodeagent screen 显示器信息 (screen.info)
nodeagent screenshot [--out f.jpg] [--scale 0.5] [--region x,y,w,h] [--format jpeg]
截屏并保存 (screen.capture)
nodeagent mouse move <x> <y> [--duration 300]
nodeagent mouse click [<x> <y>] [--button left|right|middle]
nodeagent mouse drag <x1> <y1> <x2> <y2> 鼠标拖拽(拖文件/框选)(v11)
nodeagent mouse scroll <delta>
nodeagent key type "<文本>" [--interval 10]
nodeagent key press <键1> [键2] ... (组合键,如 ctrl c)
nodeagent key press media_play_pause --pid 1234 (媒体键,v1.2:走 WM_APPCOMMAND,
--pid 定向到该进程的全部顶层窗口;省略则广播。
窗口被遮挡/最小化/游戏全屏时依然有效)
通用选项:
--node <设备名> 本次命令临时指定目标设备(不改变 current)
可放命令后(nodeagent info --node win_b)或前置(nodeagent --node=win_b info)
--json 以原始 JSON 输出
--config 显示当前配置路径
`;
+84 -1297
View File
File diff suppressed because it is too large. Load diff
+183
View File
@@ -0,0 +1,183 @@
import { closeSync, existsSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync, writeSync } from 'node:fs';
import fsSync from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path, { join } from 'node:path';
import { parseArgs } from 'node:util';
import {
NodeAgentClient,
loadMacroFile,
runMacro,
ClientError,
loadConfig,
saveConfig,
configPath,
toWsUrl,
loadKeys,
createKeys,
keysFilePath,
discoverOnce,
resolveTarget,
resolveNodeSelector,
emptyConfig,
type ClientConfig,
type ResolvedTarget,
type NodeProfile,
} from '@nodeagent/client';
import {
CapabilityNames,
matchPattern,
DEFAULT_DISCOVERY_PORT,
type CapabilityDescriptor,
type InvokeResult,
} from '@nodeagent/protocol';
import { type Options } from './types.js';
// 命令行参数解析(从 index.ts 拆出)
export function parseOptions(rest: string[]): { opts: Options; positionals: string[] } {
const { values, positionals } = parseArgs({
args: rest,
options: {
json: { type: 'boolean', default: false },
insecure: { type: 'boolean', default: false },
port: { type: 'string' },
key: { type: 'string' },
id: { type: 'string' },
limit: { type: 'string' },
args: { type: 'string' },
config: { type: 'boolean', default: false },
// v2 图形操作
out: { type: 'string' },
format: { type: 'string' },
scale: { type: 'string' },
region: { type: 'string' },
button: { type: 'string' },
duration: { type: 'string' },
interval: { type: 'string' },
// 媒体键定向投递的目标进程 PID(v1.2)
pid: { type: 'string' },
hold: { type: 'string' },
route: { type: 'string' },
'auth-mode': { type: 'string' },
since: { type: 'string' },
type: { type: 'string' },
'client-id': { type: 'string' },
wait: { type: 'string' },
'discovery-port': { type: 'string' },
node: { type: 'string' },
name: { type: 'string' },
note: { type: 'string' },
recursive: { type: 'boolean', default: false },
pattern: { type: 'string' },
'create-dirs': { type: 'boolean', default: false },
'hub-token': { type: 'string' },
'hub-node': { type: 'string' },
// v7 受控重启
delay: { type: 'string' },
// v10 后台任务 / 剪贴板
'timeout-ms': { type: 'string' },
kill: { type: 'boolean', default: false },
offset: { type: 'string' },
set: { type: 'string' },
'image-file': { type: 'string' },
fps: { type: 'string' },
nodes: { type: 'string' },
path: { type: 'string' },
kind: { type: 'string' },
var: { type: 'string', multiple: true },
mode: { type: 'string' },
iface: { type: 'string' },
ip: { type: 'string' },
mask: { type: 'string' },
gateway: { type: 'string' },
dns: { type: 'string' },
command: { type: 'string' },
'confirm-within': { type: 'string' },
'task-name': { type: 'string' },
yes: { type: 'boolean', default: false },
url: { type: 'string' },
sha256: { type: 'string' },
'dry-run': { type: 'boolean', default: false },
psk: { type: 'boolean', default: false },
'discover-secret': { type: 'string' },
'forget-cert': { type: 'boolean', default: false },
check: { type: 'boolean', default: false },
force: { type: 'boolean', default: false },
mute: { type: 'string' },
volume: { type: 'string' },
seconds: { type: 'string' },
'interval-ms': { type: 'string' },
},
allowPositionals: true,
strict: false,
});
return {
opts: {
json: Boolean(values['json']),
insecure: Boolean(values['insecure']),
port: values['port'] as string | undefined,
key: values['key'] as string | undefined,
id: values['id'] as string | undefined,
limit: values['limit'] as string | undefined,
args: values['args'] as string | undefined,
out: values['out'] as string | undefined,
format: values['format'] as string | undefined,
scale: values['scale'] as string | undefined,
region: values['region'] as string | undefined,
button: values['button'] as string | undefined,
duration: values['duration'] as string | undefined,
interval: values['interval'] as string | undefined,
pid: values['pid'] as string | undefined,
hold: values['hold'] as string | undefined,
route: values['route'] as string | undefined,
authMode: values['auth-mode'] as string | undefined,
since: values['since'] as string | undefined,
type: values['type'] as string | undefined,
clientId: values['client-id'] as string | undefined,
wait: values['wait'] as string | undefined,
discoveryPort: values['discovery-port'] as string | undefined,
node: values['node'] as string | undefined,
delay: values['delay'] as string | undefined,
timeoutMs: values['timeout-ms'] as string | undefined,
kill: Boolean(values['kill']),
offset: values['offset'] as string | undefined,
set: values['set'] as string | undefined,
imageFile: values['image-file'] as string | undefined,
fps: values['fps'] as string | undefined,
nodes: values['nodes'] as string | undefined,
path: values['path'] as string | undefined,
kind: values['kind'] as string | undefined,
vars: (values['var'] as string[] | undefined) ?? [],
mode: values['mode'] as string | undefined,
iface: values['iface'] as string | undefined,
ip: values['ip'] as string | undefined,
mask: values['mask'] as string | undefined,
gateway: values['gateway'] as string | undefined,
dns: values['dns'] as string | undefined,
command: values['command'] as string | undefined,
confirmWithin: values['confirm-within'] as string | undefined,
taskName: values['task-name'] as string | undefined,
yes: Boolean(values['yes']),
url: values['url'] as string | undefined,
sha256: values['sha256'] as string | undefined,
dryRun: Boolean(values['dry-run']),
psk: Boolean(values['psk']),
discoverSecret: values['discover-secret'] as string | undefined,
forgetCert: Boolean(values['forget-cert']),
check: Boolean(values['check']),
force: Boolean(values['force']),
mute: values['mute'] as string | undefined,
volume: values['volume'] as string | undefined,
seconds: values['seconds'] as string | undefined,
intervalMs: values['interval-ms'] as string | undefined,
name: values['name'] as string | undefined,
note: values['note'] as string | undefined,
recursive: Boolean(values['recursive']),
pattern: values['pattern'] as string | undefined,
createDirs: Boolean(values['create-dirs']),
hubToken: values['hub-token'] as string | undefined,
hubNode: values['hub-node'] as string | undefined,
},
positionals,
};
}
+96
View File
@@ -0,0 +1,96 @@
// CLI 共享类型与常量(从 index.ts 拆出)
/** 已解析的常用选项。 */
export interface Options {
json: boolean;
insecure: boolean;
port?: string;
key?: string;
id?: string;
limit?: string;
args?: string;
// v2 图形操作
out?: string;
format?: string;
scale?: string;
region?: string;
button?: string;
duration?: string;
interval?: string;
/** 媒体键定向投递的目标进程 PID(v1.2 keyPress) */
/** v1.5 快捷键扩展:长按毫秒 */
hold?: string;
/** v1.5 快捷键扩展:投递路由 foreground|post */
route?: string;
pid?: string;
/** v3:认证模式 psk | ed25519 */
authMode?: string;
/** v3+ 审计查询 */
since?: string;
type?: string;
clientId?: string;
/** v4 发现 */
wait?: string;
discoveryPort?: string;
/** v5 多设备 */
node?: string;
name?: string;
note?: string;
/** v5 文件 */
recursive?: boolean;
pattern?: string;
createDirs?: boolean;
/** v6 Hub */
hubToken?: string;
hubNode?: string;
/** v7 受控重启:延时毫秒 */
delay?: string;
/** v10 后台任务 / 剪贴板 */
timeoutMs?: string;
kill?: boolean;
offset?: string;
set?: string;
/** v11 剪贴板图片 */
imageFile?: string;
/** v11 录屏 */
fps?: string;
/** v11 多设备并发 */
nodes?: string;
/** v11 部署目标路径 */
path?: string;
/** v12 事件订阅 */
kind?: string;
/** v12 宏变量 */
vars?: string[];
/** v22 发现广播认证密钥 */
discoverSecret?: string;
/** v22 每客户端 PSK */
psk?: boolean;
/** v21 证书指纹 */
forgetCert?: boolean;
/** v19 拉取式自更新 */
url?: string;
sha256?: string;
dryRun?: boolean;
/** v18 部署校验 */
check?: boolean;
force?: boolean;
/** v17 音频 */
mute?: string;
volume?: string;
/** v16 网络变更 */
mode?: string;
iface?: string;
ip?: string;
mask?: string;
gateway?: string;
dns?: string;
command?: string;
confirmWithin?: string;
taskName?: string;
yes?: boolean;
seconds?: string;
intervalMs?: string;
}
/** 文件分块传输的块大小(fs.read/write 用,v5) */
export const CHUNK_BYTES = 1024 * 1024;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/hub",
"version": "0.1.0",
"version": "1.5.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
+101
View File
@@ -0,0 +1,101 @@
import type { HubConfig, HubTokenRule } from './config.js';
/**
* Hub 侧授权(v12 / E3c)。
*
* 背景:Hub 原先只有**一个**令牌,任何持有者都能接入任意设备 —— 无法区分
* 「谁在控制哪台机器」。这里引入**多令牌 + 按设备白/黑名单**:
*
* tokens: [
* { value: "master-xxx", name: "owner", allow_nodes: ["*"] },
* { value: "ci-xxx", name: "ci", allow_nodes: ["win_build_*"] },
* { value: "guest-xxx", name: "guest", allow_nodes: ["*"], deny_nodes: ["win_prod"] }
* ]
*
* 兼容:顶层 `token` 依旧有效,视为「主令牌」(放行全部设备)。
*
* 注意职责边界:Hub 只做**接入过滤**(能否连到某设备);连上之后的**能力级**授权
* 仍由被控端 ACL 按 client_id 裁决 —— 两层各自独立,Hub 不参与能力决策。
*/
export interface ResolvedToken {
name: string;
allowNodes: string[];
denyNodes: string[];
isMaster: boolean;
}
/** 简易 glob(支持 `*`),与协议侧 matchPattern 语义一致但独立实现,避免跨包耦合。 */
export function nodeMatches(pattern: string, value: string): boolean {
if (pattern === '*') return true;
if (!pattern.includes('*')) return pattern === value;
const escaped = pattern
.split('*')
.map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('.*');
return new RegExp(`^${escaped}$`, 'i').test(value);
}
/** 用呈现的令牌解析出身份;不匹配返回 null。 */
export function resolveToken(cfg: HubConfig, presented: string | undefined): ResolvedToken | null {
if (!presented) return null;
for (const rule of cfg.tokens ?? []) {
// 支持前缀式「滚动令牌」:配置值以 * 结尾时按前缀匹配(便于轮换期间的灰度)
const hit = rule.value.endsWith('*')
? presented.startsWith(rule.value.slice(0, -1))
: presented === rule.value;
if (hit) return normalizeRule(rule);
}
// 向后兼容:顶层 token 视为主令牌
if (presented === cfg.token) {
return { name: 'master', allowNodes: ['*'], denyNodes: [], isMaster: true };
}
return null;
}
function normalizeRule(rule: HubTokenRule): ResolvedToken {
return {
name: rule.name ?? 'unnamed',
allowNodes: rule.allow_nodes ?? ['*'],
denyNodes: rule.deny_nodes ?? [],
isMaster: false,
};
}
/** 该身份能否访问指定设备(deny 优先 → allow → 默认拒绝)。 */
export function nodeAllowed(token: ResolvedToken, nodeId: string): { allowed: boolean; reason: string } {
for (const d of token.denyNodes) {
if (nodeMatches(d, nodeId)) {
return { allowed: false, reason: `命中设备黑名单 ${d}(令牌 ${token.name})` };
}
}
for (const a of token.allowNodes) {
if (nodeMatches(a, nodeId)) {
return { allowed: true, reason: `命中设备白名单 ${a}` };
}
}
return {
allowed: false,
reason: `令牌 ${token.name} 未被授权访问设备 ${nodeId}(白名单: ${token.allowNodes.join(', ') || '空'})`,
};
}
/**
* 被控端注册准入:`node_allowlist` 非空时,只有匹配的 node_id 才能注册。
* 用于防止他人拿(可能泄露的)令牌把自家节点挂到你的 Hub 上。
*/
export function nodeMayRegister(cfg: HubConfig, nodeId: string): { allowed: boolean; reason: string } {
const list = cfg.node_allowlist ?? [];
if (list.length === 0) return { allowed: true, reason: '未配置注册白名单' };
for (const p of list) {
if (nodeMatches(p, nodeId)) return { allowed: true, reason: `命中注册白名单 ${p}` };
}
return { allowed: false, reason: `设备 ${nodeId} 不在 Hub 注册白名单内` };
}
/** 过滤列表可见性:控制端只应看到自己被授权的设备。 */
export function visibleNodes<T extends { node_id: string }>(token: ResolvedToken, all: T[]): T[] {
return all.filter((a) => nodeAllowed(token, a.node_id).allowed);
}
+30
View File
@@ -3,6 +3,21 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
/**
* Hub 侧访问规则(v12 / E3c):一个令牌 = 一个控制端身份。
* `value` 以 `*` 结尾时按**前缀**匹配(便于令牌轮换期间灰度)。
*/
export interface HubTokenRule {
/** 令牌值(前缀式轮换:以 * 结尾表示前缀匹配) */
value: string;
/** 身份名(日志/审计可读) */
name?: string;
/** 可访问设备(glob,默认 ["*"]) */
allow_nodes?: string[];
/** 禁止访问设备(优先级高于 allow) */
deny_nodes?: string[];
}
export interface HubConfig {
node_id: string;
host: string;
@@ -15,6 +30,21 @@ export interface HubConfig {
*/
tls?: { cert_file: string; key_file: string };
log_level: 'debug' | 'info' | 'warn';
/**
* v12 / E3:同一被控端允许的**并发槽位数**(= 可同时接入的控制端数量)。
* 默认 3:够 AI + 人 + 备用同时在线;设为 1 即退回旧的「独占」语义。
*/
max_slots_per_node?: number;
/**
* v12 / E3c:多控制端令牌(各自独立的设备白/黑名单)。
* 未配置时退回顶层 `token`(视为主令牌,放行全部设备)。
*/
tokens?: HubTokenRule[];
/**
* v12 / E3c:允许注册的设备白名单(glob)。非空时,未匹配的 node_id 无法注册,
* 防止他人用泄露的令牌把自家节点挂到你的 Hub 上。
*/
node_allowlist?: string[];
}
export function hubDir(): string {
+146 -45
View File
@@ -3,6 +3,7 @@ import { createServer as createHttpServer, type IncomingMessage } from 'node:htt
import { createServer as createHttpsServer } from 'node:https';
import { WebSocket, WebSocketServer, type RawData } from 'ws';
import type { HubConfig } from './config.js';
import { nodeAllowed, nodeMayRegister, resolveToken, visibleNodes, type ResolvedToken } from './authz.js';
import type {
HubClientConnect,
HubError,
@@ -29,7 +30,7 @@ interface AgentEntry {
platform?: string;
version?: string;
connected_at: number;
/** 当前配对的客户端;同一时刻仅允许一个,避免多路复用复杂度 */
/** 该槽位当前配对的客户端;一个槽位同时只服务一个控制端 */
paired: WebSocket | null;
}
@@ -39,6 +40,27 @@ export interface HubServer {
close(): Promise<void>;
}
/** 聚合成「一节点一行」的对外视图。 */
function viewAgentsOf(agents: Map<string, AgentEntry[]>): Array<AgentView & { slots: number; paired_slots: number }> {
const out: Array<AgentView & { slots: number; paired_slots: number }> = [];
for (const [nodeId, pool] of agents) {
const live = pool.filter((e) => e.ws.readyState === WebSocket.OPEN);
if (live.length === 0) continue;
const first = live[0]!;
const pairedSlots = live.filter((e) => e.paired !== null).length;
out.push({
node_id: nodeId,
platform: first.platform,
version: first.version,
connected_at: Math.min(...live.map((e) => e.connected_at)),
paired: pairedSlots > 0,
slots: live.length,
paired_slots: pairedSlots,
});
}
return out;
}
export function createHubServer(cfg: HubConfig): Promise<HubServer> {
const levelOrder: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2 };
const log = (level: LogLevel, msg: string): void => {
@@ -47,7 +69,50 @@ export function createHubServer(cfg: HubConfig): Promise<HubServer> {
}
};
const agents = new Map<string, AgentEntry>();
/**
* v12 / E3:`node_id → 槽位池`。
* 过去是「一个节点一条连接 → 同时只能一个控制端」(E_NODE_BUSY)。
* 现在每个节点可持有多条连接(槽位),每条服务一个控制端,从而实现**并发多控制端**;
* 槽位不足时向被控端发 need_slot 请它再开一条(有上限,超限仍回 E_NODE_BUSY)。
*/
const agents = new Map<string, AgentEntry[]>();
const maxSlots = Math.max(1, Math.min(10, cfg.max_slots_per_node ?? 3));
/** 等待 need_slot 生效的等待者:node_id → 回调 */
const slotWaiters = new Map<string, Array<(ok: boolean) => void>>();
const poolOf = (nodeId: string): AgentEntry[] => agents.get(nodeId) ?? [];
const idleOf = (nodeId: string): AgentEntry | undefined =>
poolOf(nodeId).find((e) => e.paired === null && e.ws.readyState === WebSocket.OPEN);
const liveCount = (nodeId: string): number =>
poolOf(nodeId).filter((e) => e.ws.readyState === WebSocket.OPEN).length;
function notifySlot(nodeId: string, ok: boolean): void {
const waiters = slotWaiters.get(nodeId);
if (!waiters) return;
slotWaiters.delete(nodeId);
for (const w of waiters) w(ok);
}
/** 请求被控端新开一个槽位,最多等 waitMs。 */
function askForSlot(nodeId: string, waitMs = 3000): Promise<boolean> {
const pool = poolOf(nodeId).filter((e) => e.ws.readyState === WebSocket.OPEN);
if (pool.length === 0) return Promise.resolve(false);
if (pool.length >= maxSlots) return Promise.resolve(false);
return new Promise<boolean>((resolve) => {
const list = slotWaiters.get(nodeId) ?? [];
list.push(resolve);
slotWaiters.set(nodeId, list);
const need: HubOutbound = { type: 'need_slot', slots: pool.length, max_slots: maxSlots };
for (const e of pool) send(e.ws, need);
log('info', `向被控端 ${nodeId} 请求新槽位(现有 ${pool.length}/${maxSlots})`);
setTimeout(() => {
const w = slotWaiters.get(nodeId);
if (!w) return;
slotWaiters.delete(nodeId);
for (const fn of w) fn(false);
}, waitMs).unref?.();
});
}
const httpServer = cfg.tls
? createHttpsServer({ cert: readFileSync(cfg.tls.cert_file), key: readFileSync(cfg.tls.key_file) })
@@ -96,8 +161,15 @@ export function createHubServer(cfg: HubConfig): Promise<HubServer> {
log('info', `配对成功 ${entry.node_id} ↔ 控制端`);
}
/** 解析 + 令牌校验;失败时已回错误消息并关闭连接。 */
function readControl(ws: WebSocket, raw: string, remote: string): HubInbound | null {
/**
* 解析 + 身份校验(v12:多令牌)。失败时已回错误并关闭连接。
* 返回消息 + 解析出的身份(供后续设备级授权判断)。
*/
function readControl(
ws: WebSocket,
raw: string,
remote: string,
): { msg: HubInbound; token: ResolvedToken } | null {
let msg: HubInbound;
try {
msg = JSON.parse(raw) as HubInbound;
@@ -105,12 +177,13 @@ export function createHubServer(cfg: HubConfig): Promise<HubServer> {
fail(ws, 'E_BAD_REQUEST', '消息不是合法 JSON');
return null;
}
if (msg.token !== cfg.token) {
const token = resolveToken(cfg, msg.token);
if (!token) {
log('warn', `鉴权失败(令牌不匹配)from ${remote}`);
fail(ws, 'E_HUB_AUTH', 'Hub 令牌错误');
return null;
}
return msg;
return { msg, token };
}
// ---------------- 被控端接入 ----------------
@@ -120,18 +193,28 @@ export function createHubServer(cfg: HubConfig): Promise<HubServer> {
const onMessage = (data: RawData): void => {
if (registeredId) return; // 注册后不再受理控制面消息
const msg = readControl(ws, data.toString(), remote);
if (!msg) return;
const parsed = readControl(ws, data.toString(), remote);
if (!parsed) return;
const { msg, token } = parsed;
if (msg.type !== 'register') {
fail(ws, 'E_BAD_REQUEST', `被控端不应发送 ${msg.type}`);
return;
}
// v12:注册准入白名单(防止陌生节点借令牌挂靠)
const reg = nodeMayRegister(cfg, msg.node_id);
if (!reg.allowed) {
log('warn', `拒绝注册 ${msg.node_id}:${reg.reason}`);
fail(ws, 'E_NODE_FORBIDDEN', reg.reason);
return;
}
void token;
const nodeId = msg.node_id;
const existing = agents.get(nodeId);
if (existing && existing.ws.readyState === WebSocket.OPEN) {
log('warn', `节点 ${nodeId} 重复注册,踢掉旧连接`);
existing.ws.close();
const pool = poolOf(nodeId).filter((e) => e.ws.readyState === WebSocket.OPEN);
if (pool.length >= maxSlots) {
log('warn', `节点 ${nodeId} 槽位已满(${pool.length}/${maxSlots}),拒绝新连接`);
fail(ws, 'E_TOO_MANY_SLOTS', `节点 ${nodeId} 槽位已达上限 ${maxSlots}`);
return;
}
const entry: AgentEntry = {
@@ -142,38 +225,38 @@ export function createHubServer(cfg: HubConfig): Promise<HubServer> {
connected_at: Date.now(),
paired: null,
};
agents.set(nodeId, entry);
pool.push(entry);
agents.set(nodeId, pool);
registeredId = nodeId;
send(ws, { type: 'registered', node_id: nodeId } satisfies HubRegistered);
log('info', `被控端已注册 ${nodeId} from ${remote}(platform=${msg.meta?.platform ?? '?'})`);
log('info', `被控端已注册 ${nodeId} from ${remote}(槽位 ${pool.length}/${maxSlots},platform=${msg.meta?.platform ?? '?'})`);
notifySlot(nodeId, true);
};
ws.on('message', onMessage);
ws.once('close', () => {
if (registeredId && agents.get(registeredId)?.ws === ws) {
agents.delete(registeredId);
log('info', `被控端已离线 ${registeredId}`);
}
if (!registeredId) return;
const pool = poolOf(registeredId).filter((e) => e.ws !== ws);
if (pool.length > 0) agents.set(registeredId, pool);
else agents.delete(registeredId);
log('info', `被控端槽位离线 ${registeredId}(剩余 ${pool.length}/${maxSlots})`);
});
}
// ---------------- 控制端接入 ----------------
function handleClientSocket(ws: WebSocket, remote: string): void {
let clientToken: ResolvedToken | null = null;
void clientToken;
const onMessage = (data: RawData): void => {
const msg = readControl(ws, data.toString(), remote);
if (!msg) return;
const parsed = readControl(ws, data.toString(), remote);
if (!parsed) return;
const { msg, token } = parsed;
clientToken = token;
if (msg.type === 'list') {
send(ws, {
type: 'list.result',
agents: [...agents.values()].map((a) => ({
node_id: a.node_id,
platform: a.platform,
connected_at: a.connected_at,
paired: a.paired !== null,
})),
});
// v12:只展示该身份被授权的设备
send(ws, { type: 'list.result', agents: visibleNodes(token, viewAgents()) });
return;
}
@@ -182,22 +265,47 @@ export function createHubServer(cfg: HubConfig): Promise<HubServer> {
return;
}
const entry = agents.get((msg as HubClientConnect).node_id);
if (!entry || entry.ws.readyState !== WebSocket.OPEN) {
fail(ws, 'E_NODE_OFFLINE', `设备离线: ${msg.node_id}`);
const nodeId = (msg as HubClientConnect).node_id;
if (liveCount(nodeId) === 0) {
fail(ws, 'E_NODE_OFFLINE', `设备离线: ${nodeId}`);
return;
}
if (entry.paired) {
fail(ws, 'E_NODE_BUSY', `设备正被其他控制端占用: ${msg.node_id}`);
// v12:设备级授权(Hub 只做接入过滤;能力级仍由被控端 ACL 裁夺)
const perm = nodeAllowed(token, nodeId);
if (!perm.allowed) {
log('warn', `拒绝接入 ${nodeId}(${token.name}):${perm.reason}`);
fail(ws, 'E_NODE_FORBIDDEN', perm.reason);
return;
}
log('info', `控制端请求接入 ${msg.node_id} from ${remote}`);
pair(ws, entry);
void (async () => {
log('info', `控制端请求接入 ${nodeId} from ${remote}`);
// 1) 有空闲槽位直接配
const idle = idleOf(nodeId);
if (idle) {
pair(ws, idle);
return;
}
// 2) 无空闲:请被控端再开一条(有上限)
if (liveCount(nodeId) < maxSlots) {
const ok = await askForSlot(nodeId);
if (ok) {
const fresh = idleOf(nodeId);
if (fresh) {
pair(ws, fresh);
return;
}
}
}
// 3) 仍无 → 真的满了
fail(ws, 'E_NODE_BUSY', `设备并发槽位已满(${liveCount(nodeId)}/${maxSlots}): ${nodeId}`);
})();
};
ws.on('message', onMessage);
}
const viewAgents = (): Array<AgentView & { slots: number; paired_slots: number }> => viewAgentsOf(agents);
wss.on('connection', (ws: WebSocket, req: IncomingMessage) => {
const url = new URL(req.url ?? '/', 'http://localhost');
const path = url.pathname.replace(/\/+$/, '');
@@ -215,17 +323,10 @@ export function createHubServer(cfg: HubConfig): Promise<HubServer> {
log('info', `Hub 已监听 ${scheme}://${cfg.host}:${cfg.port}`);
resolve({
url: `${scheme}://${cfg.host}:${cfg.port}`,
agents: () =>
[...agents.values()].map((a) => ({
node_id: a.node_id,
platform: a.platform,
version: a.version,
connected_at: a.connected_at,
paired: a.paired !== null,
})),
agents: () => viewAgents(),
close: () =>
new Promise<void>((res) => {
for (const a of agents.values()) a.ws.terminate();
for (const pool of agents.values()) for (const a of pool) a.ws.terminate();
for (const ws of wss.clients) ws.terminate();
wss.close(() => httpServer.close(() => res()));
}),
+32 -3
View File
@@ -37,16 +37,45 @@ export interface HubPaired {
node_id: string;
}
/**
* Hub → 被控端:需要再开一条槽位(已有控制端占用全部现有连接)。
* 被控端收到后应新增一条到 Hub 的连接并重新注册(同 node_id),
* 使一名被控端可**并发服务多个控制端**(v12 / E3)。
*/
export interface HubNeedSlot {
type: 'need_slot';
/** 当前池中该节点的连接数(供被控端判断是否已达上限) */
slots: number;
/** 建议上限(被控端自行再兜一层) */
max_slots: number;
}
export interface HubListResult {
type: 'list.result';
agents: Array<{ node_id: string; platform?: string; connected_at: number; paired: boolean }>;
agents: Array<{
node_id: string;
platform?: string;
connected_at: number;
/** 是否已被某个控制端占用(兼容字段:任一槽位被占用即为 true) */
paired: boolean;
/** v12:槽位总数与已占用数 */
slots?: number;
paired_slots?: number;
}>;
}
export interface HubError {
type: 'error';
code: 'E_HUB_AUTH' | 'E_NODE_OFFLINE' | 'E_NODE_BUSY' | 'E_BAD_REQUEST';
code:
| 'E_HUB_AUTH'
| 'E_NODE_OFFLINE'
| 'E_NODE_BUSY'
| 'E_BAD_REQUEST'
| 'E_TOO_MANY_SLOTS'
/** v12:身份合法但未被授权访问该设备 / 该节点不允许注册 */
| 'E_NODE_FORBIDDEN';
message: string;
}
export type HubInbound = HubAgentRegister | HubClientConnect | HubListRequest;
export type HubOutbound = HubRegistered | HubPaired | HubListResult | HubError;
export type HubOutbound = HubRegistered | HubPaired | HubNeedSlot | HubListResult | HubError;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/mcp",
"version": "0.1.0",
"version": "1.5.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
+317 -6
View File
@@ -11,6 +11,7 @@ import {
resolveTarget,
} from '@nodeagent/client';
import { CapabilityNames, DEFAULT_DISCOVERY_PORT, type InvokeResult } from '@nodeagent/protocol';
import { runMacro } from '@nodeagent/client';
/** stderr 日志(stdout 被 MCP 协议占用,禁止打印)。 */
function log(msg: string): void {
@@ -23,6 +24,37 @@ let client: NodeAgentClient | null = null;
/** 会话级目标设备(na_use 切换;不影响 CLI 的默认设备配置)。 */
let sessionNode: string | null = null;
/**
* v12.4:被控端事件到 MCP 的两条通路
* 1) **拉取**:事件已由被控端缓冲,用 na_event_poll 增量取(默认,最稳)
* 2) **推送**:宿主支持时,经 MCP 标准日志通知(notifications/message)实时送达,
* 由 na_event_watch 的 notify=true 开启(宿主若不展示日志,则退化为拉取,无副作用)
*/
let eventNotifyEnabled = false;
let eventNotifyCount = 0;
function forwardEventToHost(evt: Record<string, unknown>): void {
if (!eventNotifyEnabled) return;
eventNotifyCount += 1;
const line = `${String(evt['kind'] ?? '')} ${String(evt['action'] ?? '')} ${String(evt['target'] ?? '')}${
evt['detail'] ? ` (${String(evt['detail'])})` : ''
}`;
try {
// 注意:notification() 返回 Promise —— 必须 catch,否则未处理拒绝会**崩掉整个 MCP 服务**
// (真机踩过:宿主未声明/不支持 logging 时进程直接退出)
Promise.resolve(
server.notification({
method: 'notifications/message',
params: { level: 'info', logger: 'nodeagent.event', data: line },
}),
).catch(() => {
/* 推送失败时静默降级为拉取通路 */
});
} catch {
/* 同步异常同样忽略 */
}
}
async function ensureClient(): Promise<NodeAgentClient> {
if (client) return client;
const cfg = loadConfig();
@@ -38,6 +70,7 @@ async function ensureClient(): Promise<NodeAgentClient> {
const { profile, clientId } = target;
const keys = profile.auth_mode === 'ed25519' ? loadKeys() : null;
const c = new NodeAgentClient({
onEvent: (evt) => forwardEventToHost(evt),
url: toWsUrl(profile),
key: profile.key ?? '',
clientId,
@@ -174,13 +207,35 @@ const TOOLS = [
{
name: 'na_key',
description:
'控制被控端键盘(需被控端已开启输入控制)。action: type 输入一段文本;press 按下组合键(如 ["ctrl","c"])。',
'控制被控端键盘(需被控端已开启输入控制)。\n' +
'action: type = 输入一段文本;press = 按**快捷键**。\n' +
'press 支持四种形式(一次只用一种):\n' +
'① hotkey 单条字符串,如 "ctrl+shift+esc"、"win+d"、"alt+tab";\n' +
'② hotkeys 字符串序列,如 ["ctrl+c","ctrl+v"];\n' +
'③ preset 预设语义名,如 copy/paste/save/show_desktop/lock_screen/screenshot/' +
'zoom_in/music_next/volume_mute_toggle(约 50 个);\n' +
'④ keys 老形式数组,如 ["ctrl","c"]。\n' +
'按键覆盖:字母数字、F1-F24、左右侧修饰键(lalt/rctrl/lwin…)、小键盘(numpad7、numpad_add…)、' +
'OEM 符号键(oem_plus/oem_comma/oem_period…,支持 win+d、ctrl++、win+. 这类组合)、' +
'媒体键(media_next/music_play_pause…)、浏览器键、IME 键、系统键。\n' +
'可配:hold_ms 长按(≤5000);route=foreground(默认,注入当前焦点)或 ' +
'route=post(向 target_pid 指定进程的全部顶层窗口 PostMessage,目标在后台时可用,' +
'但游戏/部分输入型程序不响应后台键消息)。\n' +
'媒体键整条序列会自动走 WM_APPCOMMAND(可定向 target_pid,窗口被遮挡也有效)。',
inputSchema: {
type: 'object',
properties: {
action: { type: 'string', enum: ['type', 'press'], description: '操作类型' },
text: { type: 'string', description: 'type 时输入的文本' },
keys: { type: 'array', items: { type: 'string' }, description: 'press 时的按键列表' },
hotkey: { type: 'string', maxLength: 64, description: '单条热键字符串,如 "ctrl+shift+esc";支持 + - 空格分隔' },
hotkeys: { type: 'array', items: { type: 'string' }, maxItems: 50, description: '热键字符串序列,如 ["ctrl+c","ctrl+v"]' },
preset: { type: 'string', description: '预设语义名(单个),如 copy / show_desktop / task_manager' },
presets: { type: 'array', items: { type: 'string' }, maxItems: 50, description: '多个预设名 = 序列' },
keys: { type: 'array', items: { type: 'string' }, description: '(老形式)和弦按键数组,如 ["ctrl","c"]' },
repeat: { type: 'integer', minimum: 1, maximum: 50, description: '重复次数(默认 1)' },
hold_ms: { type: 'integer', minimum: 0, maximum: 5000, description: '长按保持毫秒数(按下后延时再释放)' },
route: { type: 'string', enum: ['foreground', 'post'], description: '投递路由,默认 foreground' },
target_pid: { type: 'integer', minimum: 0, description: '目标进程 PID(媒体键定向或 route=post 必填)' },
},
required: ['action'],
additionalProperties: false,
@@ -311,6 +366,7 @@ const TOOLS = [
type: 'object',
properties: {
title: { type: 'string', description: '窗口标题(正则,匹配第一个)' },
wait_ms: { type: 'integer', description: '等待窗口出现(应用刚启动时用)' },
},
required: ['title'],
additionalProperties: false,
@@ -336,6 +392,10 @@ const TOOLS = [
description: 'auto=UIA 优先+OCR 兜底;uia=仅 UIA;ocr=仅截图识别',
},
limit: { type: 'integer', description: '返回上限(默认 20)' },
wait_ms: {
type: 'integer',
description: '等待元素出现的上限毫秒(0=只查一次)。界面有动画/加载时建议 3000~8000',
},
},
required: ['text'],
additionalProperties: false,
@@ -370,6 +430,168 @@ const TOOLS = [
additionalProperties: false,
},
},
{
name: 'na_macro_run',
description:
'回放一段 GUI 宏(步骤序列):把一次成功操作固化后重复执行。' +
'步骤支持 focus/find/click/type/key/drag/sleep/exec/clip/assert/capture,' +
'可带 retry 重试、optional 可选、${VAR} 变量替换。返回每步结果(含失败定位)。',
inputSchema: {
type: 'object',
properties: {
steps: {
type: 'array',
description: '步骤数组',
items: { type: 'object', additionalProperties: true },
},
vars: { type: 'object', description: '可选:变量表(供 ${NAME} 替换)' },
default_delay_ms: { type: 'integer', description: '每步之间默认等待' },
},
required: ['steps'],
additionalProperties: false,
},
},
{
name: 'na_net_status',
description:
'查看被控端网络现状:各网卡 IP/网关、**待确认的网络变更**(含剩余秒数)、历史配置备份。' +
'改网络前后都应先看它。',
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
},
{
name: 'na_net_apply',
description:
'**改被控端网络**(高危):两阶段提交 —— 先备份当前配置,再应用变更,并注册 OS 级定时回滚任务;' +
'你必须在 confirm_within_ms 内(可能要在新地址上重连后)用 na_net_confirm 确认,' +
'否则自动回滚到变更前配置。用于远程改 IP / 切 DHCP 这类「改错就失联」的操作。',
inputSchema: {
type: 'object',
properties: {
mode: { type: 'string', enum: ['static', 'dhcp', 'command'] },
interface: { type: 'string', description: '网卡别名;省略则用带默认网关的那块' },
ip: { type: 'string' },
mask: { type: 'string' },
gateway: { type: 'string' },
dns: { type: 'array', items: { type: 'string' } },
command: { type: 'string', description: 'mode=command 时执行的自定义变更命令' },
confirm_within_ms: { type: 'integer', description: '确认窗口毫秒(默认 60000,最小 15000)' },
},
required: ['mode'],
additionalProperties: false,
},
},
{
name: 'na_net_confirm',
description: '确认(提交)网络变更,取消自动回滚。不传 task_name 则取消全部待确认项。',
inputSchema: {
type: 'object',
properties: { task_name: { type: 'string' } },
additionalProperties: false,
},
},
{
name: 'na_agent_update',
description:
'**被控端自更新**:让它自己从 URL 下载新版本 agent 并替换自身(校验哈希 → 备份 → 原子替换 → 重启)。' +
'用于控制端与被控端不可达、但被控端能上网的场景(跨网段/NAT/异地)。' +
'sha256 必填(安全底线);不确定时可先 dry_run=true 只校验不改动。',
inputSchema: {
type: 'object',
properties: {
url: { type: 'string', description: '更新包地址(http/https)' },
sha256: { type: 'string', description: '期望哈希(12~64 位十六进制)' },
restart: { type: 'boolean', description: '替换后是否重启(默认 true)' },
dry_run: { type: 'boolean', description: '只下载校验,不改动文件' },
},
required: ['url', 'sha256'],
additionalProperties: false,
},
},
{
name: 'na_audio_get',
description: '读取被控端默认播放设备的静音状态与主音量(Windows: Core Audio / macOS: osascript)。',
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
},
{
name: 'na_audio_set',
description:
'设置被控端静音开关或主音量(可读回实际状态,故为确定性设置而非切换)。' +
'常用于「把远端那台机器静音」。mute 与 volume 至少给一个。',
inputSchema: {
type: 'object',
properties: {
mute: { type: 'boolean', description: 'true=静音,false=取消静音' },
volume: { type: 'integer', description: '主音量 0-100' },
},
additionalProperties: false,
},
},
{
name: 'na_metrics',
description:
'按 PRD 2.2 汇总被控端成功指标(闭环成功率 / 装软件成功率 / P95 时延 / 安全拦截率)' +
'及达标判定与按能力细分。排查「成功率掉了没有/哪个能力最慢」时用。',
inputSchema: {
type: 'object',
properties: {
since: { type: 'integer', description: '可选:只统计该 Unix 毫秒之后的审计条目' },
},
additionalProperties: false,
},
},
{
name: 'na_event_watch',
description:
'订阅被控端事件:file=文件变动、process=进程启停、net=监听端口开闭。' +
'MCP 无法接收推送,事件会进入服务端缓冲,用 na_event_poll 拉取。',
inputSchema: {
type: 'object',
properties: {
kind: { type: 'string', enum: ['file', 'process', 'net'] },
path: { type: 'string', description: 'file 类型:监控的目录/文件路径' },
pattern: { type: 'string', description: '可选:文件名或进程名 glob(如 *.log、chrome*)' },
recursive: { type: 'boolean', description: 'file 类型:是否递归子目录' },
interval_ms: { type: 'integer', description: 'process/net 采样间隔(1000~60000,默认 5000)' },
notify: {
type: 'boolean',
description:
'开启实时推送(MCP 日志通知);宿主若不展示日志则退化为 na_event_poll 拉取,无副作用',
},
},
required: ['kind'],
additionalProperties: false,
},
},
{
name: 'na_event_poll',
description:
'拉取已缓冲的被控端事件(增量:传上次返回的 next_cursor)。' +
'典型用法:先 na_event_watch 订阅,稍后 na_event_poll 查看期间发生了什么。',
inputSchema: {
type: 'object',
properties: {
limit: { type: 'integer', description: '最多返回条数(默认 50)' },
since: { type: 'integer', description: '游标:上次返回的 next_cursor' },
watch_id: { type: 'string', description: '可选:只看某个订阅' },
},
additionalProperties: false,
},
},
{
name: 'na_event_list',
description: '列出当前生效的事件订阅(含各自已产生的事件数)与缓冲区大小。',
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
},
{
name: 'na_event_unwatch',
description: '取消一个事件订阅(长时间不用的订阅应主动取消,避免浪费被控端资源)。',
inputSchema: {
type: 'object',
properties: { watch_id: { type: 'string' } },
required: ['watch_id'],
additionalProperties: false,
},
},
{
name: 'na_record',
description:
@@ -431,6 +653,15 @@ function toArgs(toolName: string, input: Record<string, unknown>): Record<string
if (Object.keys(filter).length > 0) args['filter'] = filter;
return args;
}
case 'na_event_watch': {
// notify 是 MCP 层开关(推送),能力侧不认识它 —— 必须剥离,否则被 additionalProperties 拒
const args: Record<string, unknown> = { kind: input['kind'] };
if (input['path'] !== undefined) args['path'] = input['path'];
if (input['pattern'] !== undefined) args['pattern'] = input['pattern'];
if (input['recursive'] !== undefined) args['recursive'] = input['recursive'];
if (input['interval_ms'] !== undefined) args['interval_ms'] = input['interval_ms'];
return args;
}
case 'na_app_list': {
const args: Record<string, unknown> = {};
if (input['name_pattern']) args['filter'] = { name_pattern: input['name_pattern'] };
@@ -456,6 +687,16 @@ const TOOL_TO_CAPABILITY: Record<string, string> = {
na_fs_write: CapabilityNames.FsWrite,
na_fs_stat: CapabilityNames.FsStat,
na_restart: CapabilityNames.AgentRestart,
na_metrics: CapabilityNames.Metrics,
na_audio_get: CapabilityNames.AudioGet,
na_agent_update: CapabilityNames.AgentUpdate,
na_audio_set: CapabilityNames.AudioSet,
na_net_status: CapabilityNames.NetStatus,
na_net_apply: CapabilityNames.NetApply,
na_net_confirm: CapabilityNames.NetConfirm,
na_event_watch: CapabilityNames.EventWatch,
na_event_poll: CapabilityNames.EventPoll,
na_event_unwatch: CapabilityNames.EventUnwatch,
na_window_list: CapabilityNames.WindowList,
na_window_focus: CapabilityNames.WindowFocus,
na_screen_find: CapabilityNames.ScreenFind,
@@ -521,10 +762,18 @@ function resolveToolCall(toolName: string, input: Record<string, unknown>): Reso
return { capability: CapabilityNames.KeyType, args: { text: input['text'] } };
}
if (action === 'press') {
if (!Array.isArray(input['keys']) || input['keys'].length === 0) {
return { error: 'press 需要非空 keys 数组' };
// v1.5:四种形式一次只用一种(hotkeys > hotkey > presets > preset > keys),
// 其余各端会做同样的校验与展开,这里只做薄校验后原样透传。
const has = (k: string): boolean =>
input[k] !== undefined && !(Array.isArray(input[k]) && (input[k] as unknown[]).length === 0);
const forms = ['hotkeys', 'hotkey', 'presets', 'preset', 'keys'].filter(has);
if (forms.length === 0) return { error: 'press 需要 hotkey / hotkeys / preset / presets / keys 之一' };
if (forms.length > 1) return { error: `参数混用:${forms.join(' + ')},一次请只用一种形式` };
const args: Record<string, unknown> = { [forms[0]!]: input[forms[0]!] };
for (const k of ['repeat', 'hold_ms', 'route', 'target_pid', 'interval_ms']) {
if (input[k] !== undefined) args[k] = input[k];
}
return { capability: CapabilityNames.KeyPress, args: { keys: input['keys'] } };
return { capability: CapabilityNames.KeyPress, args };
}
return { error: `不支持的 action: ${String(action)}` };
}
@@ -593,7 +842,11 @@ function resolveToolCall(toolName: string, input: Record<string, unknown>): Reso
const server = new Server(
{ name: 'nodeagent', version: '0.1.0' },
{ capabilities: { tools: {} } },
{
// v12.4:声明 logging —— 事件推送走 MCP 标准日志通知(notifications/message)。
// 不声明时 SDK 会断言失败并**崩掉进程**(真机踩过),故必须显式声明。
capabilities: { tools: {}, logging: {} },
},
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS as unknown as [] }));
@@ -618,6 +871,64 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
};
}
// na_event_watch:notify 开关是会话级设置,在入口处处理
if (name === 'na_event_watch' && input['notify'] === true) {
eventNotifyEnabled = true;
}
// na_event_list:附带推送状态(便于判断是否需要改用拉取)
if (name === 'na_event_list') {
try {
const client = await ensureClient();
const r = await client.invoke(CapabilityNames.EventList, {});
const d = r.status === 'ok' ? (r.data as Record<string, unknown>) : {};
const watches = (d['watches'] as unknown[]) ?? [];
const lines = (watches as Array<Record<string, unknown>>).map(
(w) => ` ${String(w['watch_id'])} ${String(w['kind']).padEnd(8)} 事件 ${String(w['events'])} ${String(w['description'])}`,
);
const head = watches.length
? `当前 ${watches.length} 个订阅(被控端缓冲 ${String(d['buffered'] ?? 0)} 条):`
: '当前没有事件订阅';
const push = eventNotifyEnabled
? `推送:已开启(已转发 ${eventNotifyCount} 条;宿主不展示日志时请改用 na_event_poll)`
: '推送:未开启(用 na_event_watch 的 notify=true 可开启;否则用 na_event_poll 拉取)';
return { content: [{ type: 'text', text: `${head}\n${lines.join('\n')}\n${push}` }] };
} catch (err) {
return { content: [{ type: 'text', text: `✗ ${err instanceof Error ? err.message : String(err)}` }] };
}
}
// na_macro_run:需要直接驱动一个长连接完成多步操作,故在执行入口单独处理
if (name === 'na_macro_run') {
const steps = input['steps'];
if (!Array.isArray(steps) || steps.length === 0) {
return { content: [{ type: 'text', text: '✗ steps 不能为空' }] };
}
try {
const client = await ensureClient();
const res = await runMacro(
{ client: client as unknown as NodeAgentClient },
{
name: (input['name'] as string) ?? 'macro',
steps: steps as never,
...(input['default_delay_ms'] !== undefined
? { default_delay_ms: Number(input['default_delay_ms']) }
: {}),
},
(input['vars'] ?? {}) as Record<string, string>,
);
const lines = res.steps.map(
(s) => `${s.ok ? '✓' : '✗'} [${String(s.index).padStart(2)}] ${s.action.padEnd(8)} ${String(s.ms + 'ms').padStart(7)} ${s.detail ?? ''}`,
);
const head = res.ok
? `宏「${res.name}」全部 ${res.steps.length} 步通过:`
: `宏「${res.name}」在第 ${res.failed_at} 步失败:`;
return { content: [{ type: 'text', text: `${head}\n${lines.join('\n')}` }] };
} catch (err) {
return { content: [{ type: 'text', text: `✗ 宏执行异常: ${err instanceof Error ? err.message : String(err)}` }] };
}
}
// na_use:会话级切换目标设备(下次调用生效,不影响 CLI 的默认设备)
if (name === 'na_use') {
const targetName = String(input['name'] ?? '');
+32
View File
@@ -158,3 +158,35 @@ WorkBuddy 解析代理慢 + 部分请求走了不通的代理链路 → `Respons
- 它回避:**断后路、需判断、需凭证**的事 —— 这是设计取舍,不是缺陷
明确这条边界,才能既用好它,又不至于在关键故障时**因为"以为能远程修"而耽误时间**。
## 杀软误拦(主流杀软 / 杀软 / 360)
**现象**:远程接管类工具的典型特征(未知 exe + 建计划任务 + 开防火墙 + 常驻监听 + 注入键鼠/截屏)
**必然**触发杀软拦截。真机实证:主流杀软会拦下 `install.ps1` 执行、计划任务注册,或直接处置 `node.exe`,
表现为「下载成功但装不上」或「装上后 agent 起不来」。
**逐项放行清单**(把下面这些都加进杀软的信任/白名单,而不是整机关防护):
| 对象 | 说明 |
|---|---|
| 安装目录 | 解压出来的整个文件夹(含 `node.exe`、`agent.mjs`、`install.ps1`) |
| `node.exe` | 被控端自带的 Node 运行时(免装 Node 就是因为这个文件) |
| `agent.mjs` | agent 主体(常驻监听 8765) |
| 计划任务 `nodeagent` | 开机/登录自启任务;主流杀软的「启动项拦截」会静默拦掉它 |
| 入站端口 8765/TCP | `install.ps1` 已建防火墙规则;杀软的**独立网络防护**可能仍拦 |
| PowerShell 脚本执行 | 主流杀软「系统加固」会拦脚本解释执行;至少放行上面的安装目录 |
| 键鼠/截屏行为 | 开启 `allow_input` 后才会出现;若被拦,`screen.capture`/`input.*` 会失败 |
**自检命令**(在 Windows 上跑,确认三件事:任务在不在、进程活不活、端口听没听):
```powershell
Get-ScheduledTask -TaskName 'nodeagent*' -ErrorAction SilentlyContinue | Select-Object TaskName, State
Get-Process node -ErrorAction SilentlyContinue | Select-Object Id, Path
netstat -ano | findstr :8765
Invoke-WebRequest 'http://127.0.0.1:8765' -UseBasicParsing -TimeoutSec 2 # 有响应即端口通
```
**排查顺序**:先看任务与进程(被杀软处置则两者都缺)→ 再看端口(进程在但端口不通 = 防火墙/杀软网络防护)
→ 最后从控制端用 `nodeagent discover` 或 ping 确认**两台机器确实在同一网络**。
> ⚠️ 最后一环最容易忽略:真机踩过 —— 控制端换了 Wi-Fi(酒店/热点)后仍在扫旧网段,
> 排查半天「失联」,实际是**两台机器不在同一网络**。先用 `ipconfig`/`ifconfig` 对齐网段再谈其他。
+137
View File
@@ -0,0 +1,137 @@
# 安全模型与加固指南
> 面向使用者与运维者。协议细节见 `DEVELOPMENT.md`,发版与升级见 `VERSIONING.md`。
## 1. 信任模型:钥匙 = 那台机器的 root
`system.shell.exec` 能执行任意命令,所以**拿到预共享密钥(PSK)就等于拿到被控端的 root**。
请按 root 密码的标准保管它:不外传、不进版本库、不进公开安装包、不贴进聊天/工单。
被控端首启时用 CSPRNG(`crypto.getRandomValues`,32 字节 / 256 bit)在本机生成密钥,
只存在 `<数据目录>/agent.json`,**不进任何分发包**。
## 2. 认证做了什么(已具备)
| 机制 | 说明 |
|---|---|
| 挑战-应答 | `hello → challenge(nonce) → auth(HMAC / Ed25519 签名)`,**钥匙全程不上网** |
| nonce 一次性 | 用后即废(`server.ts` 置 null),抓包重放无效 |
| 协议主版本校验 | 不一致直接 `E_PROTOCOL_MISMATCH` 拒连 |
| 能力级 ACL | `deny → allow → 默认拒绝`(**身份维度仅在 ed25519 模式有效**) |
| 调用限速 | 按 client_id 滑动窗口 |
| 审计链 | 每条含前一条哈希,可验篡改 |
| 键鼠控制 | **默认禁用**,需显式开启 |
> ⚠️ **PSK 模式的固有限制**:`client_id` 由客户端自报,ACL 的「身份」维度挡不住
> 持有同一把钥匙的人伪装成任意 client_id。要真正按身份授权,请用 **ed25519**
> (`nodeagent keygen` → 公钥登记到被控端 ACL → `--auth-mode ed25519`)。
## 3. v21 加固一:证书指纹钉住(TOFU)
自签证书没有 CA 链,历史上只能 `insecure` 接受任意证书 —— 于是同网段的中间人可以
**透明中继**(拿不到钥匙,但能读到截屏/剪贴板/键入内容)。现在:
1. 被控端在 `install.ps1` 输出里打印 **TLS 证书指纹**(sha256 of DER)
2. 控制端**首次**连接时把它钉进 `~/.nodeagent/config.json`(`nodes.<名>.cert_sha256`)
3. 之后每次连接**严格比对**,不一致直接拒绝:
```
✗ E_CERT_MISMATCH: 证书指纹不匹配,拒绝连接:期望 2ed4957b… 实际 ca8dc20c…
(被控端可能重装了、或中间人冒充)
```
**带外核对(重要)**:装机时屏幕/终端上会印一个 64 位指纹,与控制端首次连接打印的
`🔐 已钉住被控端证书指纹` 比对 —— 不一致说明你连的不是那台机器。
被控端**重装系统 / 证书重建**后指纹必然改变,此时显式重钉:
```bash
nodeagent connect <地址> --port 8765 --key <密钥> --insecure --forget-cert
```
查看当前状态:`nodeagent info`(会显示「已钉住 … 本次一致 ✓ / 未钉住」)
## 4. v21 加固二:来源网段白名单(`allow_from`)
被控端默认监听 `0.0.0.0`(为了能远程接入)。这意味着**机器一换网络就自动暴露** ——
连到咖啡厅/酒店/便利店的 Wi-Fi 时,同网段任何人都能看到它的 8765 端口。
在被控端 `agent.json` 里限制来源:
```json
{
"host": "0.0.0.0",
"port": 8765,
"allow_from": ["192.168.0.0/16", "10.0.0.0/8"]
}
```
- 支持 CIDR、单 IP、以及 `*`(放行全部)
- **未配置 = 放行全部**(保持向后兼容);`nodeagent info` 会提示尚未收敛
- 拒绝发生在**业务握手之前**(不浪费 TLS/挑战应答开销),并记 `net.deny` 审计
- 判定按**前缀比特**而非字符串相等 —— Node 在 IPv6 监听上把 IPv4 客户端报成
`::ffff:a.b.c.d`,已做归一化
## 4.5 发现广播最小化(v22)
UDP 广播是**未认证**的:默认配置下同网段任何人都能看到
「哪台机器可接管 / IP / 端口 / 用什么认证 / 键鼠是否开着」。
```json
"discovery": { "detail": "minimal", "secret": "<与控制端相同的密钥>" }
```
- `detail: "minimal"`:顶层只留 `service / version / node_id / ts`,
真实信息收进 `detail{}`(**同网段看不到地址、端口、认证方式、键鼠开关**)
- 配了 `secret`:`detail` 带 **HMAC 签名**(`node_id|ts|JSON(detail)`),
控制端用同一密钥验签 → 列表里显示 **🔒 已认证**;没配/验签失败显示 **⚠️ 明文**(仅作参考)
- 控制端:`nodeagent discover --discover-secret <同一密钥>`
- 未配置时 agent 启动会打印醒目告警,提示收敛
> 仍会暴露「这里有一台可被接管的机器」(存在性无法隐藏,除非关广播或改用 Hub)。
> 真正的身份确认始终发生在**握手之后**(`auth_ok` 带证书指纹,控制端严格比对)。
## 4.6 每客户端一把 PSK(v22,psk 模式下的身份绑定)
psk 模式里 `client_id` 由客户端自报,所以**一把共享 key 意味着持有者可冒充任意 client_id**,
ACL 的身份维度形同虚设。v22 起支持按 client_id 分配独立钥匙:
```bash
# 控制端:为某个 client_id 生成专属 PSK
nodeagent keygen --psk --id mac_02
```
```json
// 被控端 agent.json(可并列多个 client_id)
{ "keys": { "mac_02": "<那把密钥>" } }
```
效果(已实测):
| 场景 | 结果 |
|---|---|
| alice 用自己的 key | ✅ 通过 |
| **alice 的 key 冒用 bob 身份** | ❌ `E_AUTH_FAILED` |
| bob 用自己的 key | ✅ 通过 |
| 未登记的 client_id 用共享 `key` | ✅ 通过(**旧部署行为不变**) |
| 已登记的 client_id 用共享 `key` | ❌ 拒绝(**专属钥匙优先**,不被共享 key 绕过) |
## 5. 还没做的(后续批次)
| 项 | 说明 |
|---|---|
| ~~发现报文最小化~~ | ✅ **v22 已做**(见 §4.5) |
| ed25519 默认化 | 新装仍默认 psk;v22 提供了更轻量的替代:`keys{}` 每客户端一把 PSK(见 §4.6) |
| 握手失败封禁 | 目前只计数 + 审计,无按 IP 的临时拉黑 |
| 审计链签名 | 哈希链可被「有 root 的攻击者」整链重写,缺外部锚定 |
| 会话上限 | 无最大连接数与空闲断开 |
| 安装期密钥传递 | `install.cmd` 用命令行 `-Key` 传参,同机其他用户 `ps` 可见 |
## 6. 应急处置
| 场景 | 处置 |
|---|---|
| 怀疑钥匙泄漏 | 换钥匙(被控端删 `agent.json` 的 `key` 后重启 → 首启重新生成并打印),或改用 ed25519 |
| 证书被换 / 重装后连不上 | `nodeagent connect … --forget-cert` 重钉指纹 |
| 怀疑被中间人 | 用 `install.ps1` 打印的指纹做带外核对;不一致立即断网排查 |
| 机器要带去不可信网络 | 事先配 `allow_from`,或直接不接入陌生网络 |
+109
View File
@@ -0,0 +1,109 @@
# 版本与兼容性契约
> 为什么需要这份文档:agent 与 CLI/MCP 是**分开更新的**(CLI 随控制端装,agent 推/拉到被控端),
> 新旧混用是常态而非例外。没有明文契约,升级顺序、什么算破坏性改动、出问题怎么定位全靠猜。
## 1. 版本号
- 单一来源:**根 `package.json` 的 `version`**,构建时经 esbuild `--define` 注入 agent。
- 运行时可通过 `system.info` 的 `build` 字段读到:`version` / `commit`(git sha)/ `built_at` /
`hash`(运行中脚本的 sha256 前 12 位 —— 部署校验用)。
- 控制端在握手(`auth_ok`)即拿到被控端版本,`nodeagent info` 会显示:
```
被控端版本 : v1.0.0 @ ef9ad97 构建 740d51953fd0
能力对齐 : 42 项,两端一致 ✓
```
## 2. 语义化版本的含义
| 变更类型 | 版本位 | 例子 |
|---|---|---|
| **破坏性**:删除能力、给既有能力**新增必填参数**、改变既有能力的返回结构、修改认证/握手流程 | MAJOR | 删掉 `screen.find`;把 `fs.read` 的 `path` 改成必填 `remote_path` |
| **新增**:新能力、既有能力的**可选**参数、新增返回字段、新增审计事件类型 | MINOR | 加 `system.audio.*`、给 `deploy` 加 `--check` |
| **修复/内部**:bug 修复、性能、文档、不影响协议的重构 | PATCH | 修备份编码、拆分文件 |
## 3. 兼容性契约(硬规则)
1. **只增不改**:既有能力的**参数名与语义不得变更**;要改就新增能力(如 `screen.find` 与未来的
`screen.find2` 并存一代),旧能力至少在下一个 MAJOR 才移除。
2. **不新增必填参数**:既有能力只能加**可选**参数 —— 否则旧控制端一调就 `E_PARAM_INVALID`。
3. **返回结构只加不减**:新增字段必须可选,旧控制端忽略即可。
4. **`auth_ok` 的字段只增不减**:v20 新增的 `agent_version`/`build`/`protocol` 均为可选,
旧控制端不认也不报错。
5. **协议主版本**(`PROTOCOL_VERSION` 的主号)不一致时被控端直接拒绝连接并返回
`E_PROTOCOL_MISMATCH` —— 这是唯一的硬闸门,用于承载**无法兼容**的协议变更。
6. **能力清单是唯一事实源**:控制端不比版本号,而是比**能力集合**
(`capabilityDiff`)—— 版本号可能相同而内容不同(本地 dev 构建),能力集合不会骗人。
## 3.1 v21 新增字段(均为**可选**,向后兼容)
| 位置 | 字段 | 含义 |
|---|---|---|
| 被控端 `agent.json` | `allow_from: string[]` | 来源网段白名单;**未配置 = 放行全部**(与旧行为一致) |
| 被控端 `system.info` | `build.cert_sha256` / `network.allow_from` | 证书指纹与白名单状态(供控制端展示与巡检) |
| 被控端 `auth_ok` | `build.cert_sha256` | 同上(握手期即可得) |
| 控制端 `nodes.<名>` | `cert_sha256: string` | **钉住**的被控端证书指纹;不一致即 `E_CERT_MISMATCH` 拒连 |
| 发现报文 | `cert_sha256` | 未认证广播,**仅作参考**,不可当身份凭据 |
新增错误码:`E_CERT_MISMATCH (-32422)`、`E_NET_DENY` 类事件走审计 `net.deny`。
## 4. 升级顺序建议
**先升级被控端,再升级控制端**:
| 组合 | 结果 |
|---|---|
| 新被控端 + 旧控制端 | ✅ 安全。被控端只做加法,旧控制端不认识的新能力会被忽略 |
| 旧被控端 + 新控制端 | ⚠️ 可用,但新功能不可用。`nodeagent info` 会列出**远端缺少**的能力并提示升级 |
> 若顺序反了也不会坏:控制端调用缺失能力时返回 `E_CAPABILITY_NOT_FOUND`,
> 而 `nodeagent info` / `connect` 会提前把它变成一句人话提示。
## 5. 更新方式(两条路,按可达性选)
| 方式 | 命令 | 前提 |
|---|---|---|
| 推送式 | `pnpm update:win <设备名>` | 控制端↔被控端**可达**;自动打包含校验与指纹复核 |
| **拉取式** | `nodeagent update --url <地址> --sha256 <哈希>` | **只要被控端能上网**(含 GitHub Release) |
先预检再更新(不动任何文件):
```bash
nodeagent --node win update --url <地址> --sha256 <哈希> --dry-run
```
`--sha256` 是**安全底线**:不校验哈希等于开放远程代码执行。
## 6. 发版流程
```bash
pnpm release patch|minor|major|<x.y.z> # 改版本 → 构建 → 打包 → 校验和/清单 → 提交打标签 → GitHub Release
pnpm release 1.0.0 --dry-run # 演练(无任何副作用)
```
产物(GitHub Release 资产):
| 资产 | 用途 |
|---|---|
| `agent.mjs` | **自更新载荷** —— 被控端 `system.agent.update` 直接用它(配 `--sha256`) |
| `nodeagent-win-x64.zip` | 人工安装包(自包含 `node.exe`,免装 Node.js) |
| `latest.json` | 版本清单:version/commit/built_at/capabilities + 两个资产的 url 与 sha256 |
| `SHA256SUMS` | 校验和 |
> ⚠️ **首次仍需一次性安装**:自更新能力本身得先在机器上(鸡生蛋无解)。
> ⚠️ **`node.exe`(Node 运行时)不在自更新范围**:只换 `agent.mjs`;需升级 Node 时重跑 `install.ps1`。
> ⚠️ 本仓库的 `.github/` 改动需要 `workflow` 权限才能推送(`gh auth refresh -h github.com -s workflow`),
> 发版脚本刻意跳过 `.github/`,避免发版流程被它卡住。
## 7. 出问题怎么排查
```bash
nodeagent --node win info # 版本 + 能力对齐情况(先看这个)
nodeagent --node win invoke system.info --args '{"fields":["build"]}' # 构建指纹
nodeagent --node win deploy <包> --check # 本地包 vs 远端是否一致(不改动)
```
- **远端过旧**:`info` 会列出缺失能力 → `nodeagent update ...`
- **本控制端过旧**:`info` 会列出"不认识的新能力" → 更新控制端(`git pull` → `pack:mac` → `install-macos.sh`)
- **同版本但指纹不同**:属正常(内容相同才叫同版本);用 `--check` 对比实际内容
- **部署后没生效**:先查杀软是否处置了被替换的 `agent.mjs`(见 `REMOTE-LIMITS.md` 的「杀软误拦」)
+4 -2
View File
@@ -1,7 +1,7 @@
{
"name": "nodeagent",
"private": true,
"version": "0.1.0",
"version": "1.5.0",
"description": "跨机 AI 接管框架 —— 让 Mac 上的 AI 无缝接管 Windows,装软件、查状态",
"type": "module",
"engines": {
@@ -19,7 +19,9 @@
"test:windows": "node tests/e2e/windows.mjs",
"verify": "node scripts/verify.mjs",
"pack:win": "node scripts/pack.mjs",
"pack:mac": "node scripts/pack-macos.mjs"
"pack:mac": "node scripts/pack-macos.mjs",
"update:win": "node scripts/update-win.mjs",
"release": "node scripts/release.mjs"
},
"devDependencies": {
"@types/node": "^22.10.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/client",
"version": "0.1.0",
"version": "1.5.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
+98 -1
View File
@@ -7,6 +7,7 @@ import {
signNonce,
isErrorResponse,
ErrorCodes,
certFingerprint,
type RpcRequest,
type RpcResponse,
type RpcError,
@@ -38,6 +39,11 @@ export interface ClientOptions {
clientId: string;
/** v1 简化:跳过自签证书校验 */
insecure?: boolean;
/**
* v21:钉住的被控端证书指纹(sha256 hex)。给了就会**严格比对**,
* 不一致直接拒绝连接(E_CERT_MISMATCH)—— 防「连上了冒名顶替的机器」。
*/
certSha256?: string;
/** 握手超时(默认 10s) */
handshakeTimeoutMs?: number;
/** 单次调用默认超时(默认 60s) */
@@ -56,6 +62,8 @@ export interface ClientOptions {
hub?: { token: string; nodeId: string };
/** 日志回调 */
onLog?: (msg: string) => void;
/** v12:接收被控端主动推送的事件(无 id 的 JSON-RPC 通知) */
onEvent?: (event: Record<string, unknown>) => void;
}
interface Pending {
@@ -74,6 +82,12 @@ export class NodeAgentClient {
private capabilities: CapabilityDescriptor[] = [];
/** ed25519 模式:本次被授权的能力;psk 模式为 null */
private authorized: string[] | null = null;
/** v20:被控端语义化版本(旧被控端不返回则为 undefined) */
/** v21:本次连接观测到的对端证书指纹(TOFU 钉住用) */
private peerCertFingerprint: string | null = null;
private agentVersion?: string;
/** v20:被控端构建信息 { hash, commit, built_at } */
private agentBuild?: { hash?: string; commit?: string; built_at?: string };
private closed = false;
/** v4:重连状态 */
private reconnectAttempts = 0;
@@ -90,6 +104,33 @@ export class NodeAgentClient {
const ws = new WebSocket(url, { rejectUnauthorized: !insecure });
this.ws = ws;
// v21:抓取对端证书指纹(在 upgrade 时机拿,握手阶段尚未加密业务数据)。
// ⚠️ 这解决的是「自签证书 + insecure=接受任意证书」留下的中间人空档:
// 加密照旧,但**连错了机器**会被立刻发现。
let peerCertFp: string | null = null;
// ⚠️ 取对端证书指纹踩了三层坑,都写在这里避免后人重走:
// 1) ws 客户端的 'upgrade' 事件**只传 IncomingMessage**,拿不到 socket;
// 2) socket 要在 'open' 之后从 `ws._socket` 取(TLSSocket);
// 3) Node ≥15 的 getPeerCertificate() 返回**对象**,DER 在 `.raw` 上
// (老版本才是直接返回 Buffer)—— 两者都要兼容。
ws.on('open', () => {
try {
const sock = (ws as unknown as {
_socket?: { getPeerCertificate?: (detailed?: boolean) => unknown };
})._socket;
const got = sock?.getPeerCertificate?.(false);
let der: Buffer | undefined;
if (Buffer.isBuffer(got)) der = got;
else if (got && typeof got === 'object') {
const raw = (got as { raw?: unknown }).raw;
if (Buffer.isBuffer(raw)) der = raw;
}
if (der && der.length > 0) peerCertFp = certFingerprint(der);
} catch {
/* 非 TLS 连接或取不到证书 → 保持 null(不做指纹校验) */
}
});
const timer = setTimeout(() => {
ws.terminate();
reject(new ClientError(ErrorCodes.TIMEOUT, 'E_TIMEOUT', `连接超时: ${url}`));
@@ -97,6 +138,21 @@ export class NodeAgentClient {
ws.once('open', () => {
clearTimeout(timer);
// v21:与钉住的指纹比对 —— 不一致说明「连的不是上次那台机器」,必须拒绝
const expected = this.opts.certSha256?.trim().toLowerCase();
if (expected && peerCertFp && peerCertFp !== expected) {
ws.terminate();
reject(
new ClientError(
ErrorCodes.CERT_MISMATCH,
'E_CERT_MISMATCH',
`证书指纹不匹配,拒绝连接:期望 ${expected.slice(0, 16)}… 实际 ${peerCertFp.slice(0, 16)}…` +
'(被控端可能重装了、或中间人冒充)。核对后可用 nodeagent connect --forget-cert 重新钉住',
),
);
return;
}
this.peerCertFingerprint = peerCertFp; // v21:留给调用方做 TOFU 钉住
onLog?.(`已连接 ${url}`);
// Hub 模式下先完成配对,再挂 RPC 处理器(否则控制面消息会被误当响应)
const ready: Promise<void> = this.opts.hub
@@ -153,6 +209,18 @@ export class NodeAgentClient {
this.capabilities = authOk.capabilities ?? [];
this.authorized = authOk.authorized ?? null;
// v20:远端版本/构建信息(旧被控端不返回 → 保持 undefined,不报错)
this.agentVersion = authOk.agent_version;
this.agentBuild = authOk.build;
// v21 交叉确认:upgrade 时抓到的实际证书 vs 服务端自报 —— 不一致说明自报被篡改/冒充
if (this.peerCertFingerprint && authOk.build?.cert_sha256 &&
this.peerCertFingerprint !== authOk.build.cert_sha256) {
throw new ClientError(
ErrorCodes.CERT_MISMATCH,
'E_CERT_MISMATCH',
'被控端自报的证书指纹与实际握手证书不一致,拒绝使用(可能被冒充)',
);
}
const label =
authOk.auth_mode === 'ed25519'
? `ed25519,授权 ${this.authorized?.length ?? 0}/${this.capabilities.length} 项能力`
@@ -181,6 +249,21 @@ export class NodeAgentClient {
return this.authorized;
}
/** v20:被控端版本(旧被控端未上报则为 undefined)。 */
/** v21:本次连接观测到的对端证书指纹(sha256 hex);非 TLS 连接为 null。 */
getPeerCertFingerprint(): string | null {
return this.peerCertFingerprint;
}
getAgentVersion(): string | undefined {
return this.agentVersion;
}
/** v20:被控端构建信息(指纹/提交/构建时间)。 */
getAgentBuild(): { hash?: string; commit?: string; built_at?: string } | undefined {
return this.agentBuild;
}
/** 底层请求(带超时与 id 匹配)。 */
private request<R>(method: string, params: unknown, timeoutMs?: number): Promise<R> {
const ws = this.ws;
@@ -215,7 +298,21 @@ export class NodeAgentClient {
}
const id = (msg as { id?: string | null }).id;
if (!id) return;
if (!id) {
// v12:无 id = 通知(事件推送),交给 onEvent;控制端不应回复
const method = (msg as { method?: string }).method;
if (method === Methods.Event) {
const params = (msg as { params?: Record<string, unknown> }).params;
if (params) {
try {
this.opts.onEvent?.(params);
} catch (err) {
this.opts.onLog?.(`onEvent 回调异常: ${String(err)}`);
}
}
}
return;
}
const pending = this.pending.get(id);
if (!pending) return;
+55
View File
@@ -11,6 +11,13 @@ export interface NodeProfile {
insecure?: boolean;
/** psk 模式的预共享密钥 */
key?: string;
/**
* v21:**钉住的**被控端证书指纹(sha256 hex,64 位)。
*
* 自签证书没有可信 CA 链,唯一能证明「还是那台机器」的就是指纹。
* 首次连接会记录(TOFU);之后**严格比对**,不一致直接拒绝连接。
*/
cert_sha256?: string;
/** 认证模式;默认 psk */
auth_mode?: 'psk' | 'ed25519';
/** 覆盖全局 client_id(同一控制端接入多台时可区分身份) */
@@ -37,6 +44,54 @@ export interface ClientConfig {
current: string;
/** 设备表:设备名 → 连接配置 */
nodes: Record<string, NodeProfile>;
/**
* v12 / E3:设备分组(组名 → 设备名数组)。
* 便于按用途批量下发:`nodeagent fanout system.info --nodes @办公机`
* 也支持在组名内引用其它组(最多展开 3 层,避免环)。
*/
groups?: Record<string, string[]>;
}
/**
* 展开设备选择器:`@组名` 展开为组内设备名(支持嵌套与去重),
* 普通名字原样保留。未知名字保留原样,交由调用方报「设备不存在」。
*/
export function resolveNodeSelector(
cfg: ClientConfig,
selector: string[],
): { nodes: string[]; resolvedGroups: Record<string, string[]> } {
const out: string[] = [];
const resolvedGroups: Record<string, string[]> = {};
const seen = new Set<string>();
const expandGroup = (name: string, depth: number): string[] => {
if (depth > 3) return [];
const members = cfg.groups?.[name] ?? [];
const acc: string[] = [];
for (const m of members) {
if (m.startsWith('@')) acc.push(...expandGroup(m.slice(1), depth + 1));
else acc.push(m);
}
return acc;
};
for (const sel of selector) {
if (sel.startsWith('@')) {
const group = sel.slice(1);
const members = expandGroup(group, 0);
resolvedGroups[group] = members;
for (const m of members) {
if (!seen.has(m)) {
seen.add(m);
out.push(m);
}
}
} else if (!seen.has(sel)) {
seen.add(sel);
out.push(sel);
}
}
return { nodes: out, resolvedGroups };
}
export interface ResolvedTarget {
+47 -6
View File
@@ -1,4 +1,5 @@
import { createSocket, type RemoteInfo, type Socket } from 'node:dgram';
import { verifyHmac } from '@nodeagent/protocol';
/** 发现到的被控端。 */
export interface DiscoveredNode {
@@ -12,6 +13,11 @@ export interface DiscoveredNode {
auth_mode: string;
input_enabled: boolean;
platform: string;
/**
* v22:详情是否**通过 HMAC 验签**(被控端配了 `discovery.secret` 且我方也持有)。
* false/未定义 = 明文广播(任何人可伪造)→ 此时这些字段仅作展示,**不可当凭据**。
*/
authenticated?: boolean;
/** 最近一次收到广播的时间 */
last_seen: number;
}
@@ -23,6 +29,11 @@ export interface DiscoveryOptions {
ttlMs?: number;
/** 报文日志(调试用) */
onLog?: (msg: string) => void;
/**
* v22:发现广播的**认证密钥**。与被控端 `discovery.secret` 一致时,
* 最小化广播里的详情会带 HMAC 签名,我方验签通过才采信(`authenticated: true`)。
*/
secret?: string;
}
interface RawBeacon {
@@ -35,6 +46,20 @@ interface RawBeacon {
input_enabled?: boolean;
platform?: string;
ts?: number;
cert_sha256?: string | null;
/** v22:最小化广播标记(顶层只剩存在性,详情在 detail 里) */
minimal?: boolean;
detail?: {
host?: string;
port?: number;
tls?: boolean;
auth_mode?: string;
input_enabled?: boolean;
cert_sha256?: string | null;
platform?: string;
};
/** v22:详情签名(HMAC over `node_id|ts|JSON(detail)`) */
sig?: string;
}
/**
@@ -79,15 +104,31 @@ export class Discovery {
}
if (raw.service !== 'nodeagent' || !raw.node_id) return;
// v22:最小化广播 —— 顶层只有存在性,真实信息在 detail 里。
// 持有 secret 时**必须验签**通过才采信 detail;否则标记为未认证(仅作展示)。
const detail = raw.minimal && raw.detail ? raw.detail : raw;
let authenticated = false;
if (raw.minimal && raw.detail) {
if (this.opts.secret && raw.sig) {
authenticated = verifyHmac(
this.opts.secret,
`${raw.node_id}|${raw.ts}|${JSON.stringify(raw.detail)}`,
raw.sig,
);
}
}
const node: DiscoveredNode = {
node_id: raw.node_id,
// 地址永远取「报文来源 IP」—— 一定可达(自报地址仅作参考)
host: rinfo.address,
advertised_host: raw.host ?? rinfo.address,
port: Number(raw.port ?? 8765),
tls: raw.tls !== false,
auth_mode: raw.auth_mode ?? 'psk',
input_enabled: raw.input_enabled === true,
platform: raw.platform ?? 'unknown',
advertised_host: detail.host ?? rinfo.address,
port: Number(detail.port ?? 8765),
tls: detail.tls !== false,
auth_mode: detail.auth_mode ?? 'psk',
input_enabled: detail.input_enabled === true,
platform: detail.platform ?? 'unknown',
authenticated,
last_seen: Date.now(),
};
+1
View File
@@ -3,3 +3,4 @@ export * from './keys.js';
export * from './secret.js';
export * from './discovery.js';
export * from './client.js';
export * from './macro.js';
+339
View File
@@ -0,0 +1,339 @@
import { readFileSync } from 'node:fs';
import { CapabilityNames } from '@nodeagent/protocol';
import type { NodeAgentClient } from './client.js';
/**
* GUI 宏引擎(v12 / E4)。
*
* 设计取向:**“录制”由 AI 来完成** —— 它把一次成功的人工/自动操作固化成步骤列表,
* 之后任何人(或定时任务)都能按同一步骤重放。这样比 hook 全局输入更可控、可读、可审。
*
* 步骤类型(step.action):
* focus —— 聚焦窗口(title 正则),失败可重试
* find —— 定位元素(text + method),拿到坐标;可选 click/双击
* click —— 直接按坐标点击(需 x/y)
* type —— 输入文本
* key —— 按键/组合键/序列(keys 或 sequence,可 repeat)
* drag —— 拖拽(from/to)
* sleep —— 等待毫秒
* exec —— 在被控端执行命令(结果可断言 exit_code/包含文本)
* clip —— 读写剪贴板(set/get)
* capture —— 截屏存到本地(用于每步留证)
* assert —— 断言:屏幕/窗口/剪贴板包含某文本(按 kind 选择)
*
* 通用字段:retry(失败重试次数,默认 0)、interval_ms(重试间隔)、optional(失败不中断)、
* continue_on_error(同 optional,语义更直白)。
*/
export interface MacroStep {
action: string;
[key: string]: unknown;
}
export interface MacroFile {
name?: string;
description?: string;
/** 每步之间默认等待(可被 step.sleep 或 step.delay_ms 覆盖) */
default_delay_ms?: number;
steps: MacroStep[];
}
export interface StepResult {
index: number;
action: string;
ok: boolean;
ms: number;
detail?: string;
}
export interface MacroRunResult {
ok: boolean;
name: string;
steps: StepResult[];
failed_at?: number;
}
export function loadMacroFile(path: string): MacroFile {
const raw = readFileSync(path, 'utf8');
const parsed = JSON.parse(raw) as MacroFile;
if (!Array.isArray(parsed.steps) || parsed.steps.length === 0) {
throw new Error('宏文件缺少 steps 数组');
}
return parsed;
}
/** 变量替换:${name} 或 ${name:-默认值}。 */
function substitute(value: unknown, vars: Record<string, string>): unknown {
if (typeof value === 'string') {
return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g, (_m, key: string, def?: string) => {
const v = vars[key];
if (v !== undefined) return v;
if (def !== undefined) return def;
throw new Error(`变量未提供: ${key}`);
});
}
if (Array.isArray(value)) return value.map((v) => substitute(v, vars));
if (value !== null && typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) out[k] = substitute(v, vars);
return out;
}
return value;
}
export interface MacroContext {
client: NodeAgentClient;
/** 截屏落盘回调(由 CLI 提供,MCP 可省略) */
saveCapture?: (name: string, base64: string) => string;
log?: (msg: string) => void;
}
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
async function invoke(client: NodeAgentClient, capability: string, args: Record<string, unknown>, timeoutMs?: number): Promise<unknown> {
const r = await client.invoke(capability, args, timeoutMs);
if (r.status === 'failed') {
throw new Error(`${r.error?.name ?? 'E_EXECUTION_FAILED'}: ${r.error?.message ?? '调用失败'}`);
}
return r.data;
}
/** 执行单步(不含重试逻辑)。 */
async function runStep(ctx: MacroContext, step: MacroStep): Promise<string> {
const c = ctx.client;
const action = String(step['action'] ?? '');
switch (action) {
case 'focus': {
const title = String(step['title'] ?? '');
if (!title) throw new Error('focus 需要 title');
const d = (await invoke(c, CapabilityNames.WindowFocus, {
title,
...(step['wait_ms'] !== undefined ? { wait_ms: step['wait_ms'] } : {}),
}, Number(step['timeout_ms'] ?? 60_000))) as { title?: string; x: number; y: number; width: number; height: number; activated_by?: string };
return `已聚焦「${d.title ?? title}」(${d.x},${d.y} ${d.width}x${d.height})`;
}
case 'find': {
const text = String(step['text'] ?? '');
if (!text) throw new Error('find 需要 text');
const args: Record<string, unknown> = { text, limit: Number(step['limit'] ?? 5) };
if (step['method']) args['method'] = step['method'];
if (step['window']) args['window'] = step['window'];
if (step['control_type']) args['control_type'] = step['control_type'];
// v12.2:等待语义(界面有加载/动画时必备,避免假失败)
if (step['wait_ms'] !== undefined) args['wait_ms'] = step['wait_ms'];
if (step['interval_ms'] !== undefined) args['interval_ms'] = step['interval_ms'];
const d = (await invoke(c, CapabilityNames.ScreenFind, args, 120_000)) as {
engine: string;
matches: Array<{ name: string; x: number; y: number }>;
waited_ms?: number;
};
if (d.matches.length === 0) throw new Error(`未找到「${text}」(引擎 ${d.engine})`);
const m = d.matches[0]!;
const waited = d.waited_ms && d.waited_ms > 800 ? `(等待 ${d.waited_ms}ms)` : '';
const detail = `命中「${m.name}」@(${m.x},${m.y}) via ${d.engine}${waited}`;
if (step['click'] === true || step['dblclick'] === true) {
await invoke(c, CapabilityNames.MouseClick, {
x: m.x,
y: m.y,
...(step['dblclick'] === true ? { count: 2 } : {}),
});
return `${detail} → 已点击`;
}
return detail;
}
case 'click': {
const x = Number(step['x']);
const y = Number(step['y']);
if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error('click 需要 x/y');
await invoke(c, CapabilityNames.MouseClick, {
x,
y,
...(step['count'] ? { count: step['count'] } : {}),
...(step['button'] ? { button: step['button'] } : {}),
});
return `已点击 (${x},${y})`;
}
case 'type': {
const text = String(step['text'] ?? '');
await invoke(c, CapabilityNames.KeyType, { text, ...(step['interval_ms'] ? { interval_ms: step['interval_ms'] } : {}) });
return `已输入 ${text.length} 字符`;
}
case 'key': {
const args: Record<string, unknown> = {};
if (Array.isArray(step['keys'])) args['keys'] = step['keys'];
if (Array.isArray(step['sequence'])) args['sequence'] = step['sequence'];
if (step['repeat']) args['repeat'] = step['repeat'];
if (step['interval_ms']) args['interval_ms'] = step['interval_ms'];
if (!args['keys'] && !args['sequence']) throw new Error('key 需要 keys 或 sequence');
const d = (await invoke(c, CapabilityNames.KeyPress, args)) as { times?: number };
return `已按键 ×${d.times ?? 1}`;
}
case 'drag': {
const need = ['from_x', 'from_y', 'to_x', 'to_y'] as const;
const args: Record<string, unknown> = {};
for (const k of need) {
if (step[k] === undefined) throw new Error(`drag 需要 ${need.join('/')}`);
args[k] = Number(step[k]);
}
if (step['button']) args['button'] = step['button'];
await invoke(c, CapabilityNames.MouseDrag, args, 120_000);
return `已拖拽 (${args['from_x']},${args['from_y']}) → (${args['to_x']},${args['to_y']})`;
}
case 'sleep': {
const ms = Math.max(0, Math.min(600_000, Number(step['ms'] ?? 500)));
await sleep(ms);
return `等待 ${ms}ms`;
}
case 'exec': {
const command = String(step['command'] ?? '');
if (!command) throw new Error('exec 需要 command');
const d = (await invoke(c, CapabilityNames.ShellExec, {
command,
...(step['timeout_ms'] ? { timeout_ms: step['timeout_ms'] } : {}),
...(step['async'] === true ? { async: true } : {}),
})) as { exit_code?: number; stdout?: string; task_id?: string };
if (step['expect_exit'] !== undefined && d.exit_code !== Number(step['expect_exit'])) {
throw new Error(`退出码不符:期望 ${String(step['expect_exit'])},实际 ${String(d.exit_code)}`);
}
if (step['expect_stdout'] !== undefined && !String(d.stdout ?? '').includes(String(step['expect_stdout']))) {
throw new Error(`输出中未包含「${String(step['expect_stdout'])}」`);
}
return d.task_id ? `已启动后台任务 ${d.task_id}` : `退出码 ${String(d.exit_code)}`;
}
case 'clip': {
if (step['set'] !== undefined) {
await invoke(c, CapabilityNames.ClipSet, { text: String(step['set']) });
return `已写入剪贴板 ${String(step['set']).length} 字符`;
}
const d = (await invoke(c, CapabilityNames.ClipGet, {})) as { text?: string };
const text = d.text ?? '';
if (step['expect'] !== undefined && !text.includes(String(step['expect']))) {
throw new Error(`剪贴板内容不符:期望包含「${String(step['expect'])}」,实际「${text.slice(0, 80)}」`);
}
return `剪贴板:${text.slice(0, 60)}`;
}
case 'assert': {
const kind = String(step['kind'] ?? 'screen');
const expect = String(step['text'] ?? '');
if (!expect) throw new Error('assert 需要 text');
if (kind === 'clip') {
const d = (await invoke(c, CapabilityNames.ClipGet, {})) as { text?: string };
if (!(d.text ?? '').includes(expect)) throw new Error(`剪贴板不含「${expect}」`);
return '剪贴板断言通过';
}
if (kind === 'window') {
const d = (await invoke(c, CapabilityNames.WindowList, {
title_pattern: expect,
limit: 5,
})) as { windows: unknown[] };
if (d.windows.length === 0) throw new Error(`未找到标题匹配「${expect}」的窗口`);
return `窗口断言通过(${d.windows.length} 个)`;
}
// screen:用 screen.find 判定元素是否存在(支持 wait_ms 等待出现)
const d = (await invoke(c, CapabilityNames.ScreenFind, {
text: expect,
method: step['method'] ?? 'auto',
limit: 1,
...(step['wait_ms'] !== undefined ? { wait_ms: step['wait_ms'] } : {}),
...(step['interval_ms'] !== undefined ? { interval_ms: step['interval_ms'] } : {}),
}, 120_000)) as { engine: string; matches: unknown[] };
if (d.matches.length === 0) throw new Error(`屏幕上未找到「${expect}」(引擎 ${d.engine})`);
return `屏幕断言通过(via ${d.engine})`;
}
case 'capture': {
const d = (await invoke(c, CapabilityNames.ScreenCapture, {
format: 'jpeg',
...(step['scale'] ? { scale: step['scale'] } : {}),
...(step['region'] ? { region: step['region'] } : {}),
})) as { image: string; bytes: number };
const name = String(step['name'] ?? `step-${Date.now()}.jpg`);
if (ctx.saveCapture) {
const p = ctx.saveCapture(name, d.image);
return `已保存 ${p}(${d.bytes} 字节)`;
}
return `已截屏 ${d.bytes} 字节(未落盘)`;
}
default:
throw new Error(`不支持的步骤: ${action}`);
}
}
/** 执行整个宏(含重试与可选步骤)。 */
export async function runMacro(
ctx: MacroContext,
macro: MacroFile,
vars: Record<string, string> = {},
): Promise<MacroRunResult> {
const results: StepResult[] = [];
const defaultDelay = Number(macro.default_delay_ms ?? 0);
let ok = true;
let failedAt: number | undefined;
for (let i = 0; i < macro.steps.length; i += 1) {
const raw = macro.steps[i]!;
const action = String(raw['action'] ?? '');
const optional = raw['optional'] === true || raw['continue_on_error'] === true;
// 变量替换也要在步骤级容错内:缺变量属于「这一步失败」,不该让整个宏崩掉
let step: MacroStep;
try {
step = substitute(raw, vars) as MacroStep;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
results.push({
index: i,
action,
ok: false,
ms: 0,
detail: msg + (optional ? '(optional,继续)' : ''),
});
if (!optional) {
ok = false;
failedAt = i;
break;
}
continue;
}
const retry = Math.max(0, Math.min(10, Number(step['retry'] ?? 0)));
const interval = Math.max(0, Number(step['interval_ms'] ?? 500));
const started = Date.now();
let lastErr = '';
let attempt = 0;
let done = false;
while (attempt <= retry && !done) {
try {
const detail = await runStep(ctx, step);
results.push({ index: i, action, ok: true, ms: Date.now() - started, detail });
done = true;
} catch (err) {
lastErr = err instanceof Error ? err.message : String(err);
attempt += 1;
if (attempt <= retry) {
ctx.log?.(` 步骤 ${i} (${action}) 第 ${attempt} 次失败:${lastErr},${interval}ms 后重试`);
await sleep(interval);
}
}
}
if (!done) {
results.push({
index: i,
action,
ok: false,
ms: Date.now() - started,
detail: lastErr + (optional ? '(optional,继续)' : ''),
});
if (!optional) {
ok = false;
failedAt = i;
break;
}
}
if (defaultDelay > 0 && i < macro.steps.length - 1) await sleep(defaultDelay);
}
return { ok, name: macro.name ?? 'macro', steps: results, ...(failedAt !== undefined ? { failed_at: failedAt } : {}) };
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/protocol",
"version": "0.1.0",
"version": "1.5.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,329 @@
import type { CapabilityDescriptor } from '../../messages.js';
import { CapabilityNames } from '../names.js';
import { filterSchema } from '../schemas.js';
/** automation 组能力清单 */
export const AUTOMATION_CAPABILITIES: CapabilityDescriptor[] = [
{
name: CapabilityNames.TaskList,
version: '1.0',
description: '列出当前后台异步任务(system.shell.exec 以 async:true 启动的命令)。',
risk: 'low',
params_schema: { type: 'object', properties: {}, additionalProperties: false },
returns_schema: {
type: 'object',
properties: {
tasks: {
type: 'array',
items: {
type: 'object',
properties: {
task_id: { type: 'string' },
command: { type: 'string', description: '命令(sha256 摘要后前 8 位)' },
state: { type: 'string', description: 'running | done | killed | failed' },
started_at: { type: 'integer' },
duration_ms: { type: 'integer' },
exit_code: { type: 'integer' },
},
},
},
},
},
},
{
name: CapabilityNames.TaskGet,
version: '1.0',
description:
'查询异步任务状态与输出。任务结束后仍可查询(保留最近 N 条),输出支持增量读取(offset)。',
risk: 'low',
params_schema: {
type: 'object',
properties: {
task_id: { type: 'string' },
stream: { type: 'string', enum: ['stdout', 'stderr'], default: 'stdout' },
offset: {
type: 'integer',
description: '从输出的第 offset 字节开始读(增量拉取)',
default: 0,
},
max_bytes: { type: 'integer', default: 262144 },
},
required: ['task_id'],
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: {
task_id: { type: 'string' },
state: { type: 'string' },
exit_code: { type: 'integer' },
started_at: { type: 'integer' },
duration_ms: { type: 'integer' },
data: { type: 'string', description: '本次读取到的输出片段' },
offset: { type: 'integer', description: '已读到的字节位置(下次续读用)' },
total_bytes: { type: 'integer' },
},
},
},
{
name: CapabilityNames.TaskKill,
version: '1.0',
description: '强制终止一个后台异步任务(杀整棵进程树)。',
risk: 'medium',
params_schema: {
type: 'object',
properties: { task_id: { type: 'string' } },
required: ['task_id'],
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: { killed: { type: 'boolean' }, task_id: { type: 'string' } },
},
},
{
name: CapabilityNames.ClipGet,
version: '1.1',
description:
'读取被控端剪贴板。format=auto(默认)优先取图片、无图则取文本;text 仅取文本;image 仅取图片。',
risk: 'medium',
params_schema: {
type: 'object',
properties: {
format: { type: 'string', enum: ['auto', 'text', 'image'], default: 'auto' },
},
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: {
type: { type: 'string', description: 'text | image' },
text: { type: 'string' },
format: { type: 'string', description: '图片格式(png)' },
image_base64: { type: 'string' },
bytes: { type: 'integer' },
},
},
},
{
name: CapabilityNames.ClipSet,
version: '1.1',
description:
'向被控端剪贴板写入文本或图片(Windows: Set-Clipboard / SetImage;macOS: pbcopy)。' +
'图片以 Base64 PNG 传入(image_base64),内部经临时文件传递以规避命令行长度限制。',
risk: 'medium',
params_schema: {
type: 'object',
properties: {
text: { type: 'string', description: '文本(与 image_base64 二选一)' },
image_base64: { type: 'string', description: 'PNG 图片的 Base64(与 text 二选一)' },
},
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: {
type: { type: 'string' },
written: { type: 'integer', description: '写入的字符数(文本)' },
written_bytes: { type: 'integer', description: '写入的字节数(图片)' },
},
},
},
{
name: CapabilityNames.NetApply,
version: '1.0',
description:
'**网络变更两阶段提交**(commit-confirm):备份当前网络配置 → 应用变更 → 注册一个 OS 级' +
'一次性计划任务到点自动回滚(不依赖 agent 进程存活)→ 控制端调用 system.net.confirm 确认即提交。' +
'用于远程改 IP / 切 DHCP 这类「改错就失联」的操作 —— 未确认则自动回滚,不会把自己关在门外。',
risk: 'high',
params_schema: {
type: 'object',
properties: {
mode: { type: 'string', enum: ['static', 'dhcp', 'command'], description: '变更类型;command=自定义命令(POSIX 仅支持此项)' },
interface: { type: 'string', description: '网卡别名(如「Ethernet」);不填则自动取带默认网关的那块' },
ip: { type: 'string', description: 'mode=static:新 IPv4' },
mask: { type: 'string', description: 'mode=static:子网掩码(如 255.255.255.0)' },
gateway: { type: 'string', description: 'mode=static:网关(可选)' },
dns: { type: 'array', items: { type: 'string' }, description: 'mode=static:DNS 列表(可选)' },
command: { type: 'string', description: 'mode=command:要执行的变更命令' },
confirm_within_ms: {
type: 'integer',
minimum: 15000,
maximum: 600000,
default: 60000,
description: '确认窗口;逾期自动回滚(默认 60 秒)',
},
},
required: ['mode'],
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: {
applied: { type: 'boolean' },
backup_path: { type: 'string', description: '变更前配置备份(netsh dump,可用 netsh -f 恢复)' },
rollback_scheduled: { type: 'boolean' },
task_name: { type: 'string' },
rollback_at: { type: 'integer', description: '自动回滚时间戳(Unix 毫秒)' },
confirm_within_ms: { type: 'integer' },
before: { type: 'object', description: '变更前地址信息' },
after: { type: 'object', description: '变更后地址信息' },
hint: { type: 'string' },
},
},
},
{
name: CapabilityNames.NetConfirm,
version: '1.0',
description:
'确认(提交)网络变更:取消自动回滚任务。控制端通常在新地址上重连成功后调用。' +
'不传 task_name 则取消全部待确认项。',
risk: 'low',
params_schema: {
type: 'object',
properties: { task_name: { type: 'string', description: 'net.apply 返回的任务名;省略则取消全部' } },
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: {
confirmed: { type: 'integer' },
cancelled: { type: 'array', items: { type: 'string' } },
remaining: { type: 'array', items: { type: 'string' } },
},
},
},
{
name: CapabilityNames.NetStatus,
version: '1.0',
description: '查看网络现状:各网卡地址、待确认的变更(含剩余秒数)、历史备份文件。',
risk: 'low',
params_schema: { type: 'object', properties: {}, additionalProperties: false },
returns_schema: {
type: 'object',
properties: {
interfaces: { type: 'array', items: { type: 'object' } },
pending: { type: 'array', items: { type: 'object' } },
pending_count: { type: 'integer' },
backups: { type: 'array', items: { type: 'object' } },
note: { type: 'string' },
},
},
},
{
name: CapabilityNames.EventWatch,
version: '1.0',
description:
'订阅被控端事件:file=文件变动(增删改)、process=进程启停、net=监听端口开闭。' +
'被控端通过 event 通知主动推送(无需轮询);事件同时进入环形缓冲,可用 event.poll 拉取。',
risk: 'low',
params_schema: {
type: 'object',
properties: {
kind: { type: 'string', enum: ['file', 'process', 'net'] },
path: { type: 'string', description: 'file 类型:监控的目录/文件路径' },
pattern: { type: 'string', description: '可选:文件名 glob(如 *.log)或进程名子串' },
recursive: { type: 'boolean', default: false, description: 'file 类型:是否递归子目录' },
interval_ms: {
type: 'integer',
minimum: 1000,
maximum: 60000,
default: 5000,
description: 'process/net 类型的采样间隔',
},
},
required: ['kind'],
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: {
watch_id: { type: 'string' },
kind: { type: 'string' },
description: { type: 'string' },
},
},
},
{
name: CapabilityNames.EventUnwatch,
version: '1.0',
description: '取消一个事件订阅(连接断开时也会自动清理)。',
risk: 'low',
params_schema: {
type: 'object',
properties: { watch_id: { type: 'string' } },
required: ['watch_id'],
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: { removed: { type: 'boolean' } },
},
},
{
name: CapabilityNames.EventList,
version: '1.0',
description: '列出当前生效的事件订阅及其已产生事件数。',
risk: 'low',
params_schema: { type: 'object', properties: {}, additionalProperties: false },
returns_schema: {
type: 'object',
properties: {
watches: {
type: 'array',
items: {
type: 'object',
properties: {
watch_id: { type: 'string' },
kind: { type: 'string' },
description: { type: 'string' },
events: { type: 'integer' },
created_at: { type: 'integer' },
},
},
},
buffered: { type: 'integer', description: '环形缓冲中的事件总数' },
},
},
},
{
name: CapabilityNames.EventPoll,
version: '1.0',
description:
'拉取已缓冲的事件(适合无法接收推送的调用方,如 MCP:先 watch 再 poll)。' +
'since 传入上次返回的 next_cursor 可增量拉取。',
risk: 'low',
params_schema: {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 500, default: 50 },
since: { type: 'integer', description: '游标(上次返回的 next_cursor)' },
watch_id: { type: 'string', description: '可选:只看某订阅' },
},
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: {
events: {
type: 'array',
items: {
type: 'object',
properties: {
seq: { type: 'integer' },
watch_id: { type: 'string' },
kind: { type: 'string' },
ts: { type: 'integer' },
action: { type: 'string' },
target: { type: 'string' },
detail: { type: 'string' },
},
},
},
next_cursor: { type: 'integer' },
dropped: { type: 'integer', description: '因缓冲上限被丢弃的事件数' },
},
},
},
];
@@ -0,0 +1,121 @@
import type { CapabilityDescriptor } from '../../messages.js';
import { CapabilityNames } from '../names.js';
import { filterSchema } from '../schemas.js';
/** files 组能力清单 */
export const FILES_CAPABILITIES: CapabilityDescriptor[] = [
{
name: CapabilityNames.FsList,
version: '1.0',
description: '列出目录内容(支持 glob 过滤与递归)',
risk: 'low',
params_schema: {
type: 'object',
properties: {
path: { type: 'string', minLength: 1 },
pattern: { type: 'string', description: 'glob 过滤,如 "*.log"' },
recursive: { type: 'boolean', default: false },
max_entries: { type: 'integer', default: 500, minimum: 1, maximum: 5000 },
},
required: ['path'],
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: {
entries: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string' },
path: { type: 'string' },
type: { type: 'string', enum: ['file', 'dir', 'other'] },
size: { type: 'integer' },
mtime: { type: 'integer' },
},
},
},
total: { type: 'integer' },
truncated: { type: 'boolean' },
},
},
},
{
name: CapabilityNames.FsStat,
version: '1.0',
description: '获取文件或目录的元信息',
risk: 'low',
params_schema: {
type: 'object',
properties: { path: { type: 'string', minLength: 1 } },
required: ['path'],
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: {
path: { type: 'string' },
type: { type: 'string', enum: ['file', 'dir', 'other'] },
size: { type: 'integer' },
mtime: { type: 'integer' },
exists: { type: 'boolean' },
},
},
},
{
name: CapabilityNames.FsRead,
version: '1.0',
description: '读取文件内容(分块:用 offset/max_bytes 续读,eof 标识结束)',
risk: 'medium',
params_schema: {
type: 'object',
properties: {
path: { type: 'string', minLength: 1 },
encoding: { type: 'string', enum: ['utf8', 'base64'], default: 'utf8' },
offset: { type: 'integer', default: 0, minimum: 0, description: '起始字节偏移' },
max_bytes: { type: 'integer', default: 1048576, minimum: 1, maximum: 8388608, description: '单次读取上限' },
},
required: ['path'],
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: {
data: { type: 'string' },
encoding: { type: 'string' },
offset: { type: 'integer' },
bytes: { type: 'integer', description: '本次实际读取字节数' },
total_bytes: { type: 'integer' },
eof: { type: 'boolean' },
sha256: { type: 'string', description: '整文件摘要(仅首次读取时返回)' },
},
},
},
{
name: CapabilityNames.FsWrite,
version: '1.0',
description: '写入文件(分块:append=true 追加;写入走临时文件 + 原子重命名)',
risk: 'high',
params_schema: {
type: 'object',
properties: {
path: { type: 'string', minLength: 1 },
data: { type: 'string' },
encoding: { type: 'string', enum: ['utf8', 'base64'], default: 'utf8' },
append: { type: 'boolean', default: false },
create_dirs: { type: 'boolean', default: false },
},
required: ['path', 'data'],
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: {
written: { type: 'integer', description: '本次写入字节数' },
total_bytes: { type: 'integer', description: '写入后的文件总大小' },
path: { type: 'string' },
},
},
},
];
@@ -0,0 +1,491 @@
import type { CapabilityDescriptor } from '../../messages.js';
import { CapabilityNames } from '../names.js';
import { filterSchema } from '../schemas.js';
/** graphics 组能力清单 */
export const GRAPHICS_CAPABILITIES: CapabilityDescriptor[] = [
{
name: CapabilityNames.ScreenInfo,
version: '1.0',
description: '获取显示器信息(分辨率、缩放、主屏标识)',
risk: 'low',
params_schema: { type: 'object', properties: {}, additionalProperties: false },
returns_schema: {
type: 'object',
properties: {
displays: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'integer' },
name: { type: 'string' },
width: { type: 'integer' },
height: { type: 'integer' },
is_primary: { type: 'boolean' },
scale: { type: 'number' },
},
},
},
},
},
},
{
name: CapabilityNames.ScreenCapture,
version: '1.0',
description: '截取屏幕,返回 base64 图片(支持区域与缩放)',
risk: 'medium',
params_schema: {
type: 'object',
properties: {
display_id: { type: 'integer', default: 0, minimum: 0, description: '0 = 主屏' },
format: { type: 'string', enum: ['png', 'jpeg'], default: 'jpeg' },
quality: { type: 'integer', default: 85, minimum: 1, maximum: 100, description: '仅 jpeg 生效' },
scale: { type: 'number', default: 1, minimum: 0.1, maximum: 1 },
region: {
type: 'object',
properties: {
x: { type: 'integer' },
y: { type: 'integer' },
width: { type: 'integer', minimum: 1 },
height: { type: 'integer', minimum: 1 },
},
required: ['x', 'y', 'width', 'height'],
additionalProperties: false,
},
},
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: {
image: { type: 'string', description: 'base64 编码的图片' },
format: { type: 'string' },
width: { type: 'integer' },
height: { type: 'integer' },
bytes: { type: 'integer' },
captured_at: { type: 'integer' },
},
},
},
{
name: CapabilityNames.MouseMove,
version: '1.0',
description: '移动鼠标到指定屏幕坐标',
risk: 'high',
params_schema: {
type: 'object',
properties: {
x: { type: 'integer', minimum: 0 },
y: { type: 'integer', minimum: 0 },
duration_ms: { type: 'integer', default: 0, minimum: 0, maximum: 5000, description: '平滑移动耗时' },
},
required: ['x', 'y'],
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: { moved: { type: 'boolean' }, x: { type: 'integer' }, y: { type: 'integer' } },
},
},
{
name: CapabilityNames.MouseClick,
version: '1.0',
description: '鼠标点击(可选先移动到指定坐标)',
risk: 'high',
params_schema: {
type: 'object',
properties: {
x: { type: 'integer', minimum: 0 },
y: { type: 'integer', minimum: 0 },
button: { type: 'string', enum: ['left', 'right', 'middle'], default: 'left' },
count: { type: 'integer', default: 1, minimum: 1, maximum: 3 },
},
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: {
clicked: { type: 'boolean' },
x: { type: 'integer' },
y: { type: 'integer' },
button: { type: 'string' },
},
},
},
{
name: CapabilityNames.MouseScroll,
version: '1.0',
description: '滚动鼠标滚轮(正数向上、负数向下)',
risk: 'high',
params_schema: {
type: 'object',
properties: {
delta: { type: 'integer', description: '滚动格数' },
x: { type: 'integer', minimum: 0 },
y: { type: 'integer', minimum: 0 },
},
required: ['delta'],
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: { scrolled: { type: 'boolean' }, delta: { type: 'integer' } },
},
},
{
name: CapabilityNames.MouseDrag,
version: '1.0',
description:
'鼠标拖拽:从 (from_x,from_y) 按下并拖到 (to_x,to_y) 释放。' +
'用于拖动文件/窗口、框选文本、滑动条。整段动作在单次调用内完成,步数按距离自适应。',
risk: 'high',
params_schema: {
type: 'object',
properties: {
from_x: { type: 'integer' },
from_y: { type: 'integer' },
to_x: { type: 'integer' },
to_y: { type: 'integer' },
button: { type: 'string', enum: ['left', 'right', 'middle'], default: 'left' },
steps: { type: 'integer', minimum: 1, maximum: 200, description: '插值步数(默认按距离自适应)' },
step_delay_ms: { type: 'integer', minimum: 0, maximum: 200, default: 12 },
},
required: ['from_x', 'from_y', 'to_x', 'to_y'],
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: {
dragged: { type: 'boolean' },
from: { type: 'object', properties: { x: { type: 'integer' }, y: { type: 'integer' } } },
to: { type: 'object', properties: { x: { type: 'integer' }, y: { type: 'integer' } } },
button: { type: 'string' },
steps: { type: 'integer' },
},
},
},
{
name: CapabilityNames.KeyType,
version: '1.0',
description: '输入一段文本(逐字符模拟键盘)',
risk: 'high',
params_schema: {
type: 'object',
properties: {
text: { type: 'string', minLength: 1, maxLength: 4096 },
interval_ms: { type: 'integer', default: 10, minimum: 0, maximum: 1000 },
},
required: ['text'],
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: { typed: { type: 'boolean' }, length: { type: 'integer' } },
},
},
{
name: CapabilityNames.KeyPress,
version: '1.5',
description:
'按下**快捷键**(v1.5 大幅扩展范围)。支持的输入形式(**一次只用一种**):' +
'① 字符串热键 hotkey: "ctrl+shift+esc"(配 repeat 可连按);② 热键序列 hotkeys: ["ctrl+c","ctrl+v"];' +
'③ 预设别名 preset: "copy" / presets: ["copy","paste"](约 50 个语义名,免记键位);' +
'④ 老数组形式 keys: ["ctrl","c"](配 repeat);⑤ 序列 sequence: [["up"],["enter"]]。' +
'**按键覆盖**:字母/数字、F1-F24、左右侧修饰键(lctrl/rctrl/…)、小键盘(numpad0-9 与四则运算,' +
'与数字键分开)、OEM 符号键(oem_plus/oem_comma/oem_period… → 支持 win+d、ctrl++、win+. 这类组合)、' +
'媒体键、浏览器键、IME 键(kana/convert/…)、系统键(sleep/help/…)。' +
'**长按**:hold_ms(按下到释放的保持时长,≤5s)。' +
'**投放路由**:route=foreground(默认,SendInput 注入当前焦点);' +
'route=post(向 target_pid 指定进程的**全部顶层窗口** PostMessage —— 目标在后台/被遮挡时可用,' +
'⚠️ 但游戏与部分输入型程序不响应后台键盘消息)。' +
'**媒体键**:整条序列都是媒体键时自动改走 WM_APPCOMMAND(可定向 target_pid、不受遮挡影响)。' +
'音量键(volume_mute/up/down)仍走虚拟键 0xAD-0xAF,由系统自行映射。',
risk: 'high',
params_schema: {
type: 'object',
properties: {
hotkey: {
type: 'string',
maxLength: 64,
description: '单条热键字符串,如 "ctrl+shift+esc"、"win+d"、"alt+tab";分隔符支持 + - 空格',
},
hotkeys: {
type: 'array',
items: { type: 'string', maxLength: 64 },
minItems: 1,
maxItems: 50,
description: '热键字符串序列,如 ["ctrl+c","ctrl+v"](按键序列)',
},
preset: { type: 'string', description: '预设语义名(单个),如 copy / show_desktop / volume_mute_toggle' },
presets: {
type: 'array',
items: { type: 'string' },
minItems: 1,
maxItems: 50,
description: '多个预设名 = 序列',
},
keys: {
type: 'array',
items: { type: 'string' },
minItems: 1,
maxItems: 4,
description: '(老形式)和弦按键列表,如 ["ctrl","shift","esc"]',
},
repeat: { type: 'integer', minimum: 1, maximum: 50, default: 1, description: '和弦/热键重复次数' },
sequence: {
type: 'array',
items: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 4 },
description: '(老形式)和弦序列,如 [["up"],["up"],["enter"]]',
},
hold_ms: {
type: 'integer',
minimum: 0,
maximum: 5000,
default: 0,
description: 'v1.5 长按:按下后保持该毫秒数再释放(如长按音量键)',
},
route: {
type: 'string',
enum: ['foreground', 'post'],
default: 'foreground',
description:
'v1.5 投放路由:foreground = SendInput 注入当前焦点(默认,需目标可见);' +
'post = 向 target_pid 进程全部顶层窗口 PostMessage(目标在后台时可用)',
},
target_pid: {
type: 'integer',
minimum: 0,
description:
'目标进程 PID。媒体键:0 或省略 = 广播给所有顶层窗口(播放器常建 30+ 辅助窗口,' +
'指定 PID 会投给该进程全部顶层窗口)。route=post:必填(≥1)。',
},
interval_ms: {
type: 'integer',
minimum: 0,
maximum: 2000,
default: 40,
description: '序列中相邻和弦之间的间隔',
},
},
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: {
pressed: { type: 'boolean' },
keys: { type: 'array', items: { type: 'string' } },
chords: { type: 'array', items: { type: 'array', items: { type: 'string' } } },
times: { type: 'integer' },
presets: { type: 'array', items: { type: 'string' } },
via: { type: 'string', description: '输入形式来源:hotkey/hotkeys/preset/presets/keys/sequence' },
channel: { type: 'string', description: '实际投递通道:sendinput / appcommand / postmessage' },
route: { type: 'string' },
hold_ms: { type: 'integer' },
target_pid: { type: 'integer' },
sent_windows: { type: 'integer', description: 'route=post 时投递成功的窗口数' },
note: { type: 'string' },
},
},
},
{
name: CapabilityNames.WindowList,
version: '1.0',
description:
'列出当前可见的顶层窗口(标题、所属进程、精确矩形、是否前台窗口)。Windows 用 Win32,macOS 用 AppleScript(需辅助功能权限)。' +
'配合 screen.capture 可在**已知窗口范围内**相对定位,避免全屏猜坐标。',
risk: 'low',
params_schema: {
type: 'object',
properties: {
title_pattern: { type: 'string', description: '可选:按标题正则过滤' },
limit: { type: 'integer', default: 50 },
},
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: {
windows: {
type: 'array',
items: {
type: 'object',
properties: {
hwnd: { type: 'string', description: '窗口句柄(十六进制)' },
title: { type: 'string' },
process: { type: 'string' },
pid: { type: 'integer' },
x: { type: 'integer' },
y: { type: 'integer' },
width: { type: 'integer' },
height: { type: 'integer' },
is_foreground: { type: 'boolean' },
is_minimized: { type: 'boolean' },
},
},
},
},
},
},
{
name: CapabilityNames.WindowFocus,
version: '1.0',
description:
'将指定窗口(按标题正则或句柄)置前并聚焦,返回其精确矩形。' +
'⚠️ 边界:**无法越过全屏独占应用**(游戏、演示全屏)。此时会返回 focused:true,' +
'但目标窗口在**视觉上仍被遮挡**、鼠标点击也点不到它 —— 属预期行为而非失败。' +
'此时请改用与可见性无关的通道:input.key.press 的媒体键(走 WM_APPCOMMAND)、' +
'system.shell.exec,或让用户手动切出全屏。',
risk: 'medium',
params_schema: {
type: 'object',
properties: {
title: { type: 'string', description: '窗口标题(正则,匹配第一个)' },
hwnd: { type: 'string', description: '或直接给窗口句柄' },
wait_ms: {
type: 'integer',
minimum: 0,
maximum: 30000,
default: 0,
description: '等待窗口出现(应用启动有延迟时用;仅 title 模式有效)',
},
},
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: {
hwnd: { type: 'string' },
title: { type: 'string' },
x: { type: 'integer' },
y: { type: 'integer' },
width: { type: 'integer' },
height: { type: 'integer' },
focused: { type: 'boolean' },
activated_by: { type: 'string', description: '激活方式:api(SetForegroundWindow)| click(标题栏点击兜底)' },
},
},
},
{
name: CapabilityNames.ScreenFind,
version: '1.1',
description:
'在界面中查找 UI 元素并返回其**屏幕坐标**。Windows:UIA 优先,找不到自动降级 OCR(截图识别,支持自绘 UI)。' +
'macOS:统一走 Vision OCR(无 UIA 等价物)。返回中心点坐标,直接喂给 input.mouse.click。',
risk: 'low',
params_schema: {
type: 'object',
properties: {
text: { type: 'string', description: '要查找的文本(匹配元素 Name,支持子串)' },
window: { type: 'string', description: '可选:限定在该窗口标题(正则)内查找' },
control_type: {
type: 'string',
description: '可选:限定控件类型,如 Button / MenuItem / Edit / Text / ListItem(仅 UIA)',
},
method: {
type: 'string',
enum: ['auto', 'uia', 'ocr', 'image'],
default: 'auto',
description:
'auto=UIA 优先+OCR 兜底;uia=仅控件树;ocr=仅文字识别;image=仅图像模板匹配(需 template)',
},
template: {
type: 'string',
description:
'method=image 时必填:被控端上的模板图片路径(png/jpg/bmp)。' +
'用于纯图标/无文字控件 —— UIA 无控件树、OCR 无文字可读的场景',
},
threshold: {
type: 'number',
minimum: 0.3,
maximum: 0.999,
default: 0.85,
description: 'method=image 的匹配阈值(零均值归一化互相关,越高越严格)',
},
limit: { type: 'integer', default: 20 },
wait_ms: {
type: 'integer',
minimum: 0,
maximum: 30000,
default: 0,
description: '等待元素出现的上限毫秒(0=只查一次)。界面有动画/加载时用',
},
interval_ms: { type: 'integer', minimum: 100, maximum: 2000, default: 400 },
region: {
type: 'object',
properties: {
x: { type: 'integer' },
y: { type: 'integer' },
width: { type: 'integer' },
height: { type: 'integer' },
},
description: '可选:只在该屏幕区域查找(OCR/图像匹配更快更准;UIA 忽略此参数)',
},
},
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: {
engine: { type: 'string', description: '实际使用的引擎:uia | ocr' },
waited_ms: { type: 'integer', description: '实际等待时长(便于诊断是否命中等待窗口)' },
matches: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string' },
control_type: { type: 'string' },
automation_id: { type: 'string' },
x: { type: 'integer', description: '中心点 X(屏幕坐标)' },
y: { type: 'integer', description: '中心点 Y(屏幕坐标)' },
left: { type: 'integer' },
top: { type: 'integer' },
width: { type: 'integer' },
height: { type: 'integer' },
window_title: { type: 'string' },
},
},
},
},
},
},
{
name: CapabilityNames.ScreenRecord,
version: '1.0',
description:
'录制屏幕为帧序列(JPEG),检测到 ffmpeg 时额外封装 mp4。产物留在被控端目录,' +
'用 fs.read / pull 取回。用于复现「间歇性」问题、记录 GUI 操作过程。',
risk: 'low',
params_schema: {
type: 'object',
properties: {
duration_ms: { type: 'integer', minimum: 1000, maximum: 60000, default: 5000 },
fps: { type: 'integer', minimum: 1, maximum: 10, default: 2 },
scale: { type: 'number', minimum: 0.1, maximum: 1, default: 0.5, description: '全屏录制时的缩放' },
region: {
type: 'object',
properties: {
x: { type: 'integer' }, y: { type: 'integer' },
width: { type: 'integer' }, height: { type: 'integer' },
},
description: '可选:只录制该区域(多屏时用负坐标)',
},
},
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: {
dir: { type: 'string', description: '帧序列目录(被控端路径)' },
frames: { type: 'integer' },
requested_frames: { type: 'integer' },
elapsed_ms: { type: 'integer' },
fps: { type: 'integer' },
video_path: { type: 'string', description: '有 ffmpeg 时的 mp4 路径' },
frames_only: { type: 'boolean' },
message: { type: 'string' },
},
},
},
];
@@ -0,0 +1,20 @@
// 能力清单聚合(按组拆分后在此汇总,对外仍是单一 CAPABILITY_MANIFEST)
import type { CapabilityDescriptor } from '../../messages.js';
import { AUTOMATION_CAPABILITIES } from './automation.js';
import { FILES_CAPABILITIES } from './files.js';
import { GRAPHICS_CAPABILITIES } from './graphics.js';
import { SOFTWARE_CAPABILITIES } from './software.js';
import { SYSTEM_CAPABILITIES } from './system.js';
/**
* v1 标准能力清单(39 项)。
* 被控端按此声明并在 auth_ok 中返回;控制端据此展示与校验。
* 顺序即 CLI/MCP 的展示顺序(系统面 → 软件 → 图形 → 文件 → 自动化)。
*/
export const CAPABILITY_MANIFEST: CapabilityDescriptor[] = [
...SYSTEM_CAPABILITIES,
...SOFTWARE_CAPABILITIES,
...GRAPHICS_CAPABILITIES,
...FILES_CAPABILITIES,
...AUTOMATION_CAPABILITIES,
];
@@ -0,0 +1,62 @@
import type { CapabilityDescriptor } from '../../messages.js';
import { CapabilityNames } from '../names.js';
import { filterSchema } from '../schemas.js';
/** software 组能力清单 */
export const SOFTWARE_CAPABILITIES: CapabilityDescriptor[] = [
{
name: CapabilityNames.AppList,
version: '1.0',
description: '列出已安装软件(读取注册表卸载项 + winget list)',
risk: 'low',
params_schema: {
type: 'object',
properties: { filter: filterSchema },
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: {
apps: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string' },
version: { type: 'string' },
publisher: { type: 'string' },
source: { type: 'string' },
},
},
},
},
},
},
{
name: CapabilityNames.AppInstall,
version: '1.0',
description: '安装软件(优先 winget 静默安装)',
risk: 'high',
params_schema: {
type: 'object',
properties: {
package: { type: 'string', minLength: 1, description: 'winget 包 ID 或软件名' },
id: { type: 'string', description: '精确 winget ID(优先使用)' },
silent: { type: 'boolean', default: true },
timeout_ms: { type: 'integer', default: 600000, minimum: 10000, maximum: 1800000 },
},
required: ['package'],
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: {
installed: { type: 'boolean' },
name: { type: 'string' },
version: { type: 'string' },
source: { type: 'string' },
detail: { type: 'string' },
},
},
},
];
@@ -0,0 +1,392 @@
import type { CapabilityDescriptor } from '../../messages.js';
import { CapabilityNames } from '../names.js';
import { filterSchema } from '../schemas.js';
/** system 组能力清单 */
export const SYSTEM_CAPABILITIES: CapabilityDescriptor[] = [
{
name: CapabilityNames.SystemInfo,
version: '1.0',
description: '获取 Windows 系统基本信息(主机名、OS、CPU、内存、开机时长)',
risk: 'low',
params_schema: {
type: 'object',
properties: {
fields: {
type: 'array',
description: '可选:只返回指定字段',
items: { type: 'string' },
},
},
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: {
hostname: { type: 'string' },
os: { type: 'string' },
os_version: { type: 'string' },
arch: { type: 'string' },
cpu_model: { type: 'string' },
cpu_cores: { type: 'integer' },
memory_total: { type: 'integer', description: '字节' },
uptime_sec: { type: 'integer' },
is_admin: { type: 'boolean' },
pid: { type: 'integer', description: '被控端进程 PID(v11)' },
agent_script: { type: 'string', description: '被控端入口脚本路径,供一键升级定位(v11)' },
agent_home: { type: 'string', description: '被控端数据目录 NODEAGENT_HOME(v11)' },
node_path: { type: 'string', description: '被控端 Node 可执行文件路径(v11)' },
ps_helper: {
type: 'object',
description: 'PowerShell 常驻助手状态(v15):spawned/hits/failures/consecutiveFailures/avg_ms 等',
},
network: {
type: 'object',
description:
'v21 来源网段访问控制:allow_from 为 null 表示**未配置 = 放行全部**(应尽快收敛)',
},
build: {
type: 'object',
description:
'v18 构建指纹:hash(运行中 agent 脚本的 sha256 前 12 位,部署校验用)、bytes、mtime_ms、' +
'node(运行时版本)、started_at、uptime_ms',
},
},
},
},
{
name: CapabilityNames.SystemStatus,
version: '1.0',
description: '获取资源状态:CPU 占用、内存、磁盘、网络适配器',
risk: 'low',
params_schema: { type: 'object', properties: {}, additionalProperties: false },
returns_schema: {
type: 'object',
properties: {
cpu_pct: { type: 'number' },
memory_used: { type: 'integer' },
memory_total: { type: 'integer' },
memory_pct: { type: 'number' },
disks: {
type: 'array',
items: {
type: 'object',
properties: {
drive: { type: 'string' },
total: { type: 'integer' },
free: { type: 'integer' },
used_pct: { type: 'number' },
},
},
},
net: {
type: 'array',
items: {
type: 'object',
properties: {
adapter: { type: 'string' },
ip: { type: 'string' },
up: { type: 'boolean' },
},
},
},
},
},
},
{
name: CapabilityNames.ProcessList,
version: '1.0',
description: '列出运行中的进程,可按 CPU/内存/名称排序',
risk: 'low',
params_schema: {
type: 'object',
properties: {
sort_by: { type: 'string', enum: ['cpu', 'memory', 'pid', 'name'], default: 'cpu' },
limit: { type: 'integer', default: 50, minimum: 1, maximum: 500 },
filter: filterSchema,
},
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: {
processes: {
type: 'array',
items: {
type: 'object',
properties: {
pid: { type: 'integer' },
name: { type: 'string' },
cpu_pct: { type: 'number' },
memory_bytes: { type: 'integer' },
started_at: { type: 'integer' },
},
},
},
},
},
},
{
name: CapabilityNames.ServiceList,
version: '1.0',
description: '列出 Windows 服务及运行状态',
risk: 'low',
params_schema: {
type: 'object',
properties: {
filter: {
type: 'object',
properties: {
name_pattern: { type: 'string' },
state: { type: 'string', enum: ['running', 'stopped', 'paused'] },
},
additionalProperties: false,
},
limit: { type: 'integer', default: 100, minimum: 1, maximum: 1000 },
},
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: {
services: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string' },
display_name: { type: 'string' },
state: { type: 'string' },
start_type: { type: 'string' },
},
},
},
},
},
},
{
name: CapabilityNames.ShellExec,
version: '1.1',
description: '执行 PowerShell / cmd 命令并返回退出码与输出;async:true 时转后台任务立即返回 task_id',
risk: 'high',
params_schema: {
type: 'object',
properties: {
command: { type: 'string', minLength: 1, description: '要执行的命令' },
shell: { type: 'string', enum: ['powershell', 'cmd'], default: 'powershell' },
cwd: { type: 'string', description: '工作目录(可选)' },
timeout_ms: { type: 'integer', default: 30000, minimum: 1000, maximum: 300000 },
async: { type: 'boolean', default: false, description: '后台执行,立即返回 task_id(用 system.task.* 管理)' },
wait_forever: { type: 'boolean', default: false, description: '仅 async 模式:不设超时(timeout_ms 失效)' },
},
required: ['command'],
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: {
exit_code: { type: 'integer' },
stdout: { type: 'string' },
stderr: { type: 'string' },
duration_ms: { type: 'integer' },
truncated: { type: 'boolean' },
killed: { type: 'boolean' },
},
},
},
{
name: CapabilityNames.AuditList,
version: '1.0',
description: '查询被控端审计日志:谁在何时调用了什么能力、结果如何',
risk: 'low',
params_schema: {
type: 'object',
properties: {
limit: { type: 'integer', default: 50, minimum: 1, maximum: 1000 },
since: { type: 'integer', description: '仅返回该时间戳(Unix 毫秒)之后的记录' },
client_id: { type: 'string', description: '按调用方筛选' },
type: { type: 'string', enum: ['invoke', 'auth', 'acl', 'agent'], description: '按事件类别筛选' },
},
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: {
entries: { type: 'array', items: { type: 'object' } },
total: { type: 'integer', description: '本次读取的总条数(未截断前)' },
file: { type: 'string', description: '审计文件路径' },
},
},
},
{
name: CapabilityNames.AgentRestart,
version: '1.0',
description:
'受控重启被控端自身(延时后由独立进程/计划任务拉起,重启后自动沿用原配置)。' +
'解决「配置变更后无法让自身生效」的问题——不会因进程树清理而中断。',
risk: 'high',
params_schema: {
type: 'object',
properties: {
delay_ms: {
type: 'integer',
minimum: 1,
maximum: 3600000,
description: '延时多少毫秒后重启(默认 2000,给调用方留出返回结果的时间)',
default: 2000,
},
reason: { type: 'string', description: '可选:重启原因(写入审计日志)' },
},
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: {
scheduled: { type: 'boolean', description: '重启任务是否已排入' },
delay_ms: { type: 'integer' },
mechanism: { type: 'string', description: '使用的机制:scheduled-task / detached-spawn' },
message: { type: 'string' },
},
},
},
{
name: CapabilityNames.AuditVerify,
version: '1.0',
description:
'校验审计日志链完整性(v11 链式哈希):逐条重算哈希并比对 prev 链接,' +
'可发现条目被篡改、删除或替换。轮转跨文件连续校验。',
risk: 'low',
params_schema: { type: 'object', properties: {}, additionalProperties: false },
returns_schema: {
type: 'object',
properties: {
ok: { type: 'boolean' },
checked: { type: 'integer', description: '参与校验的条目数' },
legacy: { type: 'integer', description: '无链字段的历史条目数(跳过校验)' },
broken_at: {
type: 'object',
description: '首个异常位置(ok=false 时存在)',
properties: {
file: { type: 'string' },
line: { type: 'integer' },
reason: { type: 'string' },
},
},
},
},
},
{
name: CapabilityNames.Metrics,
version: '1.0',
description:
'按 PRD 2.2 汇总成功指标(数据源为审计日志):闭环成功率、装软件成功率、P95 时延、安全拦截率,' +
'并给出与目标阈值的达标判定;同时返回按能力细分统计。',
risk: 'low',
params_schema: {
type: 'object',
properties: {
since: { type: 'integer', description: '可选:只统计该时间戳(Unix 毫秒)之后的审计条目' },
},
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: {
close_loop: {
type: 'object',
properties: {
attempts: { type: 'integer' },
ok: { type: 'integer' },
failed: { type: 'integer' },
success_rate: { type: 'number' },
},
},
app_install: { type: 'object' },
latency: { type: 'object' },
security: { type: 'object' },
verdict: { type: 'object', description: '四项指标的达标判定' },
targets: { type: 'object' },
by_capability: { type: 'object' },
window: { type: 'object' },
},
},
},
{
name: CapabilityNames.AudioGet,
version: '1.0',
description:
'读取被控端默认播放设备的**静音状态与主音量**。Windows 走 Core Audio COM,macOS 走 osascript。',
risk: 'low',
params_schema: { type: 'object', properties: {}, additionalProperties: false },
returns_schema: {
type: 'object',
properties: {
muted: { type: 'boolean', description: '是否静音' },
volume: { type: 'integer', description: '主音量 0-100' },
platform: { type: 'string' },
backend: { type: 'string', description: '实现后端(Windows: NAudio/CoreAudioAPI;macOS: osascript)' },
},
},
},
{
name: CapabilityNames.AudioSet,
version: '1.0',
description:
'设置被控端**静音开关或主音量**。返回设置后的实际状态(可读回,故为确定性设置而非切换)。' +
'常用于「远程把那台机器静音」或配合宏播放媒体时控制音量。',
risk: 'low',
params_schema: {
type: 'object',
properties: {
mute: { type: 'boolean', description: 'true=静音,false=取消静音' },
volume: { type: 'integer', minimum: 0, maximum: 100, description: '主音量 0-100' },
},
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: {
muted: { type: 'boolean' },
volume: { type: 'integer' },
applied: { type: 'object', description: '本次实际应用的值(未提供的为 null)' },
},
},
},
{
name: CapabilityNames.AgentUpdate,
version: '1.0',
description:
'**拉取式自更新**:被控端自己从 URL 下载新版本 agent 并替换自身(下载 → 校验哈希 → 备份 → ' +
'原子替换 → 受控重启)。用于控制端与被控端**不可达但被控端能上网**的场景(跨网段/NAT、异地)。' +
'⚠️ sha256 必填:不校验哈希等于开放远程代码执行。',
risk: 'high',
params_schema: {
type: 'object',
properties: {
url: { type: 'string', description: '更新包地址(http/https;GitHub Release/自建镜像均可)' },
sha256: {
type: 'string',
description: '期望哈希(12~64 位十六进制,可比对前 12 位)——**安全底线,必填**',
},
restart: { type: 'boolean', description: '替换后是否自动重启(默认 true)' },
dry_run: { type: 'boolean', description: '只下载并校验,不改动任何文件(默认 false)' },
timeout_ms: { type: 'integer', minimum: 5000, maximum: 600000, description: '下载超时(默认 120000)' },
},
required: ['url', 'sha256'],
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: {
updated: { type: 'boolean' },
dry_run: { type: 'boolean' },
bytes: { type: 'integer' },
previous: { type: 'object', description: '替换前 { hash, bytes }' },
current_hash: { type: 'string' },
incoming_hash: { type: 'string' },
backup_path: { type: 'string', description: '回滚点:覆盖回入口路径即可' },
restarted: { type: 'boolean' },
},
},
},
];
@@ -0,0 +1,60 @@
// 标准能力名(从 capabilities.ts 拆出)
/** nodeagent v1 标准能力名。 */
export const CapabilityNames = {
SystemInfo: 'system.info',
SystemStatus: 'system.status',
ProcessList: 'system.process.list',
ServiceList: 'system.service.list',
ShellExec: 'system.shell.exec',
AppList: 'app.list',
AppInstall: 'app.install',
// v2 图形接管
ScreenInfo: 'screen.info',
ScreenCapture: 'screen.capture',
MouseMove: 'input.mouse.move',
MouseClick: 'input.mouse.click',
MouseScroll: 'input.mouse.scroll',
MouseDrag: 'input.mouse.drag',
KeyType: 'input.key.type',
KeyPress: 'input.key.press',
// v3+ 审计
AuditList: 'system.audit.list',
// v5 文件传输
FsList: 'fs.list',
FsStat: 'fs.stat',
FsRead: 'fs.read',
FsWrite: 'fs.write',
// v7 自持能力(受控重启)
AgentRestart: 'system.agent.restart',
// v8 GUI 语义(窗口与元素定位)
WindowList: 'window.list',
WindowFocus: 'window.focus',
ScreenFind: 'screen.find',
ScreenRecord: 'screen.record',
// v10 异步任务与剪贴板
TaskList: 'system.task.list',
TaskGet: 'system.task.get',
TaskKill: 'system.task.kill',
ClipGet: 'clip.get',
ClipSet: 'clip.set',
// v11 安全加固
AuditVerify: 'system.audit.verify',
Metrics: 'system.metrics',
// v19 拉取式自更新
AgentUpdate: 'system.agent.update',
// v17 音频控制
AudioGet: 'system.audio.get',
AudioSet: 'system.audio.set',
// v16 网络变更两阶段提交
NetApply: 'system.net.apply',
NetConfirm: 'system.net.confirm',
NetStatus: 'system.net.status',
// v12 事件订阅与监控
EventWatch: 'event.watch',
EventUnwatch: 'event.unwatch',
EventList: 'event.list',
EventPoll: 'event.poll',
} as const;
export type CapabilityName = (typeof CapabilityNames)[keyof typeof CapabilityNames];
@@ -0,0 +1,11 @@
// 通用 schema 片段(从 capabilities.ts 拆出)
import type { JsonSchema } from '../messages.js';
/** 名称过滤片段(app.list 等复用) */
export const filterSchema: JsonSchema = {
type: 'object',
properties: {
name_pattern: { type: 'string', description: '名称匹配(子串,不区分大小写)' },
},
additionalProperties: false,
};
+3
View File
@@ -15,6 +15,8 @@ export const ErrorCodes = {
EXECUTION_FAILED: -32419,
PROTOCOL_MISMATCH: -32420,
UNSUPPORTED_PLATFORM: -32421,
/** v21:被控端证书指纹与控制端钉住的不一致(可能是被冒充) */
CERT_MISMATCH: -32422,
} as const;
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
@@ -36,6 +38,7 @@ export const ErrorNames: Record<number, string> = {
[ErrorCodes.EXECUTION_FAILED]: 'E_EXECUTION_FAILED',
[ErrorCodes.PROTOCOL_MISMATCH]: 'E_PROTOCOL_MISMATCH',
[ErrorCodes.UNSUPPORTED_PLATFORM]: 'E_UNSUPPORTED_PLATFORM',
[ErrorCodes.CERT_MISMATCH]: 'E_CERT_MISMATCH',
};
/** 协议错误对象。 */
+261
View File
@@ -0,0 +1,261 @@
/**
* 快捷键(hotkey)解析与预设(v1.5)。
*
* ## 为什么放在 protocol 包
* 解析逻辑必须**两端一致 + 可单测**:控制端 CLI 先把字符串展开成和弦列表用于本地校验,
* 被控端再展开一次用于执行。纯函数 + 无副作用,测试直接 import dist 即可。
*
* ## 支持的形式
* 1. `hotkey: "ctrl+shift+esc"` 单个和弦(字符串)
* 2. `hotkeys: ["ctrl+c","ctrl+v"]` 序列
* 3. `preset: "copy"` / `presets: [...]` 别名 → 展开成热键字符串后按上两条走
* 4. `keys: ["ctrl","c"]` 老形式(数组),保留兼容
*
* 分隔符支持 `+ - 空格`;大小写不敏感;`control/command/cmd` 等别名归一。
*/
/** 规范按键名(agent 侧 VK_MAP 必须全覆盖,单测有断言)。 */
export const HOTKEY_KEYS: ReadonlySet<string> = new Set([
// 修饰键(含左右区分)
'ctrl', 'lctrl', 'rctrl', 'shift', 'lshift', 'rshift',
'alt', 'lalt', 'ralt', 'win', 'lwin', 'rwin',
// 导航/编辑
'enter', 'tab', 'esc', 'space', 'backspace', 'delete', 'insert',
'up', 'down', 'left', 'right', 'home', 'end', 'pageup', 'pagedown',
'capslock', 'numlock', 'scrolllock', 'printscreen', 'pause', 'apps',
// 功能键
...Array.from({ length: 24 }, (_, i) => `f${i + 1}`),
// 媒体键(音量键只走 VK 注入,见 agent 侧 MEDIA_COMMANDS 注释)
'media_play', 'media_pause', 'media_play_pause', 'media_stop',
'media_prev', 'media_next', 'media_fast_forward', 'media_rewind',
'volume_mute', 'volume_down', 'volume_up',
// 浏览器/系统启动
'browser_back', 'browser_forward', 'browser_refresh', 'browser_stop',
'browser_search', 'browser_favorites', 'browser_home',
'launch_mail', 'launch_media', 'launch_app1', 'launch_app2',
// OEM 符号键(美式布局 VK 码,覆盖 +1-9 之外最常用的符号)
'oem_minus', 'oem_plus', 'oem_comma', 'oem_period',
'oem_1', 'oem_2', 'oem_3', 'oem_4', 'oem_5', 'oem_6', 'oem_7', 'oem_8',
// 系统/IME 杂项
'sleep', 'help', 'select', 'execute', 'clear', 'separator', 'oem_clear',
'kana', 'hangul', 'kanji', 'hanja', 'convert', 'nonconvert', 'accept', 'modechange',
]);
/** 键名别名 → 规范名。 */
export const HOTKEY_ALIASES: Readonly<Record<string, string>> = {
control: 'ctrl', 'left_ctrl': 'lctrl', 'right_ctrl': 'rctrl',
left_shift: 'lshift', right_shift: 'rshift',
left_alt: 'alt', right_alt: 'ralt', menu: 'apps', context_menu: 'apps',
cmd: 'win', 'command': 'win', super: 'win', meta: 'win',
left_win: 'lwin', right_win: 'rwin', left_cmd: 'lwin', right_cmd: 'rwin',
esc: 'esc', escape: 'esc', del: 'delete', ins: 'insert',
ret: 'enter', return: 'enter', spacebar: 'space',
pgup: 'pageup', pgdn: 'pagedown',
caps: 'capslock', num: 'numlock', scroll: 'scrolllock',
prtsc: 'printscreen', print: 'printscreen',
break: 'pause',
mute: 'volume_mute', volume: 'volume_mute',
vol_up: 'volume_up', vol_up_key: 'volume_up',
vol_down: 'volume_down',
prev: 'media_prev', 'prev_track': 'media_prev', previous: 'media_prev',
next: 'media_next', next_track: 'media_next',
play: 'media_play', pause: 'media_pause',
play_pause: 'media_play_pause', ff: 'media_fast_forward', rew: 'media_rewind',
semi: 'oem_1', colon: 'oem_1', slash: 'oem_2', qmark: 'oem_2',
backquote: 'oem_3', tilde: 'oem_3', grave: 'oem_3',
lbrace: 'oem_4', lbracket: 'oem_4', bslash: 'oem_5', pipe: 'oem_5',
rbrace: 'oem_6', rbracket: 'oem_6', quote: 'oem_7', apos: 'oem_7',
minus: 'oem_minus', underscore: 'oem_minus',
plus: 'oem_plus', equal: 'oem_plus', equals: 'oem_plus',
comma: 'oem_comma', lt: 'oem_comma',
period: 'oem_period', dot: 'oem_period', gt: 'oem_period',
// 小键盘
numpad_enter: 'enter', numpad_0: 'numpad0',
};
/**
* 小键盘键名 → canonical 名(单独成组,便于校验覆盖)。
* 注意:numpad enter 归一到 enter(多数程序不区分),其余 numpadN 与数字 N 分开
* —— 部分应用(如 Excel)靠 NumLock 状态区分二者行为。
*/
export const NUMPAD_KEYS: readonly string[] = [
'numpad0', 'numpad1', 'numpad2', 'numpad3', 'numpad4',
'numpad5', 'numpad6', 'numpad7', 'numpad8', 'numpad9',
'numpad_add', 'numpad_subtract', 'numpad_multiply', 'numpad_divide', 'numpad_decimal',
];
/** 常用快捷键预设:语义名 → 热键字符串。AI/MCP 直接用语义名,免记键位。 */
export const HOTKEY_PRESETS: Readonly<Record<string, string>> = {
// 编辑
copy: 'ctrl+c', paste: 'ctrl+v', paste_plain: 'ctrl+shift+v',
cut: 'ctrl+x', undo: 'ctrl+z', redo: 'ctrl+y', select_all: 'ctrl+a',
// 文件/窗口
save: 'ctrl+s', save_as: 'ctrl+shift+s', new: 'ctrl+n', open: 'ctrl+o',
close_tab: 'ctrl+w', close_window: 'alt+f4', print: 'ctrl+p',
find: 'ctrl+f', replace: 'ctrl+h', find_next: 'f3',
// 标签页
new_tab: 'ctrl+t', next_tab: 'ctrl+tab', prev_tab: 'ctrl+shift+tab',
reopen_tab: 'ctrl+shift+t',
// 刷新/缩放/视图
refresh: 'f5', hard_refresh: 'ctrl+f5',
zoom_in: 'ctrl+oem_plus', zoom_out: 'ctrl+oem_minus', zoom_reset: 'ctrl+0',
fullscreen: 'f11',
// 窗口管理
switch_app: 'alt+tab', switch_app_back: 'alt+shift+tab',
task_view: 'win+tab', task_manager: 'ctrl+shift+esc',
show_desktop: 'win+d', peek_desktop: 'win+oem_comma',
minimize_all: 'win+m', maximize: 'win+up', minimize: 'win+down',
snap_left: 'win+left', snap_right: 'win+right',
rename: 'f2', properties: 'alt+enter', delete_permanent: 'shift+delete',
// 系统
lock_screen: 'win+l', run: 'win+r', run_dialog: 'win+r',
explorer: 'win+e', search: 'win+s', settings: 'win+i',
clipboard_history: 'win+v', emoji_picker: 'win+oem_period',
notifications: 'win+a', widgets: 'win+w', dictation: 'win+h',
screenshot: 'win+shift+s',
// 虚拟桌面
new_desktop: 'ctrl+win+d', close_desktop: 'ctrl+win+f4',
desktop_left: 'ctrl+win+left', desktop_right: 'ctrl+win+right',
// 媒体(映射到媒体键)
music_play_pause: 'media_play_pause', music_next: 'media_next',
music_prev: 'media_prev', music_stop: 'media_stop',
volume_add: 'volume_up', volume_sub: 'volume_down', volume_mute_toggle: 'volume_mute',
};
const ALIAS_INVERSE: ReadonlyMap<string, string> = new Map(
Object.entries(HOTKEY_ALIASES).map(([alias, canon]) => [alias, canon]),
);
/** 归一化单个键名(别名 → 规范名),大小写不敏感;非法键抛错。 */
export function normalizeKey(key: string): string {
const k = key.trim().toLowerCase();
if (!k) throw new Error(`空按键名`);
const canon = ALIAS_INVERSE.get(k) ?? k;
if (/^[a-z]$/.test(canon) || /^[0-9]$/.test(canon)) return canon;
if (NUMPAD_KEYS.includes(canon)) return canon;
if (HOTKEY_KEYS.has(canon)) return canon;
throw new Error(`不支持的按键: ${key}`);
}
/**
* 解析一条热键字符串为和弦数组(按键已归一化)。
* 例:`"ctrl+shift+esc"` → `["ctrl","shift","esc"]`;`"win+d"` → `["win","d"]`。
*/
export function parseHotkey(input: string): string[] {
const parts = input
.trim()
.split(/[+\-\s]+/)
.filter((p) => p.length > 0);
if (parts.length === 0) throw new Error('空热键字符串');
if (parts.length > 4) {
throw new Error('单个和弦最多 4 个键(Windows 输入模型的实用上限)');
}
return parts.map(normalizeKey);
}
/** 展开一个预设名为和弦。 */
export function expandPreset(name: string): string[] {
const spec = HOTKEY_PRESETS[name.trim().toLowerCase()];
if (!spec) {
throw new Error(
`未知预设: ${name}(共 ${Object.keys(HOTKEY_PRESETS).length} 个可用,见 HOTKEY_PRESETS)`,
);
}
return parseHotkey(spec);
}
export interface KeyPressInput {
/** 单条热键字符串(如 "ctrl+shift+esc"),可与 repeat 配合 */
hotkey?: string;
/** 多条热键字符串 = 序列 */
hotkeys?: string[];
/** 预设名(单个) */
preset?: string;
/** 预设名(多个 = 序列) */
presets?: string[];
/** 老形式:和弦数组 */
keys?: string[];
/** 老形式:序列 */
sequence?: string[][];
repeat?: number;
}
export interface ExpandedChords {
/** 归一化后的和弦序列 */
chords: string[][];
/** 命中的预设名(用于回显),非预设来源为空数组 */
presets: string[];
/** 来源形式,便于回显与排查 */
via: 'hotkeys' | 'hotkey' | 'presets' | 'preset' | 'keys' | 'sequence';
}
/**
* 把各种输入形式统一展开为**归一化和弦序列**。
*
* 优先级(只取其一,混用即报错——避免"到底以谁为准"的歧义):
* hotkeys > hotkey > presets > preset > sequence > keys
*/
export function expandChords(input: KeyPressInput): ExpandedChords {
const provided: string[] = [];
if (input.hotkeys && input.hotkeys.length > 0) provided.push('hotkeys');
if (input.hotkey !== undefined) provided.push('hotkey');
if (input.presets && input.presets.length > 0) provided.push('presets');
if (input.preset !== undefined) provided.push('preset');
if (input.sequence && input.sequence.length > 0) provided.push('sequence');
if (input.keys && input.keys.length > 0) provided.push('keys');
if (provided.length === 0) {
throw new Error('需提供 hotkey / hotkeys / preset / presets / keys / sequence 之一');
}
if (provided.length > 1) {
throw new Error(`参数混用:${provided.join(' + ')} —— 一次请只用一种形式`);
}
const presets: string[] = [];
let chords: string[][];
if (provided[0] === 'hotkeys') {
chords = (input.hotkeys ?? []).map(parseHotkey);
} else if (provided[0] === 'hotkey') {
const chord = parseHotkey(input.hotkey as string);
const repeat = Math.max(1, Math.min(50, input.repeat ?? 1));
chords = Array.from({ length: repeat }, () => chord);
} else if (provided[0] === 'presets') {
chords = [];
for (const p of input.presets ?? []) {
presets.push(p.trim().toLowerCase());
chords.push(expandPreset(p));
}
} else if (provided[0] === 'preset') {
presets.push((input.preset as string).trim().toLowerCase());
chords = [expandPreset(input.preset as string)];
} else if (provided[0] === 'sequence') {
chords = (input.sequence ?? []).map((ch) => ch.map(normalizeKey));
} else {
const chord = (input.keys ?? []).map(normalizeKey);
const repeat = Math.max(1, Math.min(50, input.repeat ?? 1));
chords = Array.from({ length: repeat }, () => chord);
}
// 合法性:每条非空、≤4 键、修饰键不重复(同一和弦里两个 ctrl 无意义且可能互相抵消)
const seenModifier = new Set<string>();
for (const chord of chords) {
if (chord.length === 0) throw new Error('空和弦');
if (chord.length > 4) throw new Error(`单和弦最多 4 键:${chord.join('+')}`);
seenModifier.clear();
for (const k of chord) {
if (!isModifier(k)) continue;
if (seenModifier.has(k)) throw new Error(`同一和弦中重复的修饰键: ${k}`);
seenModifier.add(k);
}
}
return { chords, presets, via: provided[0] as ExpandedChords['via'] };
}
function isModifier(k: string): boolean {
return (
k === 'ctrl' || k === 'lctrl' || k === 'rctrl' ||
k === 'shift' || k === 'lshift' || k === 'rshift' ||
k === 'alt' || k === 'lalt' || k === 'ralt' ||
k === 'win' || k === 'lwin' || k === 'rwin'
);
}
+2
View File
@@ -7,3 +7,5 @@ export * from './ed25519.js';
export * from './acl.js';
export * from './validate.js';
export * from './capabilities.js';
export * from './net.js';
export * from './hotkeys.js';
+6
View File
@@ -57,6 +57,12 @@ export interface AuthOkParams {
authorized?: string[];
/** 本次连接采用的认证模式 */
auth_mode?: 'psk' | 'ed25519';
/** v20:被控端语义化版本(新增字段,旧控制端忽略即可 → 向后兼容) */
agent_version?: string;
/** v20:被控端构建信息(指纹/提交/构建时间),供控制端做版本比对与提示 */
build?: { hash?: string; commit?: string; built_at?: string; cert_sha256?: string | null };
/** v20:被控端支持的协议版本(与 HelloParams.protocol 比对) */
protocol?: string;
}
// ---------- 调用 ----------
+5
View File
@@ -16,6 +16,11 @@ export const Methods = {
Ping: 'ping',
/** 双向:心跳响应 */
Pong: 'pong',
/**
* 被控端 → 控制端:主动推送事件(v12 事件订阅)。
* 这是协议中唯一的**无 id 通知**(JSON-RPC notification)——控制端不应回复。
*/
Event: 'event',
} as const;
export type MethodName = (typeof Methods)[keyof typeof Methods];
+115
View File
@@ -0,0 +1,115 @@
import { createHash, X509Certificate } from 'node:crypto';
/**
* 网络访问控制与证书指纹(v21 第一批加固)。
*
* 背景:这两个能力必须**两端用同一套算法**,否则控制端钉的指纹与被控端算的对不上、
* 或白名单在 IPv4/IPv6 表示上出现分歧 → 所以放在 protocol 包里共享。
*/
/** 归一化 IP:去掉 IPv6 的 zone 后缀,并把 IPv4-mapped(::ffff:a.b.c.d)转成 4 字节。 */
function parseIp(ip: string): Uint8Array | null {
let s = ip.trim();
const zone = s.indexOf('%');
if (zone >= 0) s = s.slice(0, zone); // fe80::1%en0
if (!s) return null;
// Node 在 IPv6 监听上会把 IPv4 客户端报成 ::ffff:1.2.3.4
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(s);
if (mapped) s = mapped[1] as string;
if (/^\d+\.\d+\.\d+\.\d+$/.test(s)) {
const parts = s.split('.').map((n) => Number.parseInt(n, 10));
if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return null;
return Uint8Array.from(parts);
}
if (!s.includes(':')) return null;
// 完整展开 IPv6(:: 展开为全 0,末组按十六进制解析)
const halves = s.split('::');
const head = halves[0] ?? '';
const tail = halves[1];
const headParts = head.length > 0 ? head.split(':') : [];
const tailParts = tail === undefined || tail.length === 0 ? [] : tail.split(':');
const fill = 8 - headParts.length - tailParts.length;
if (fill < 0) return null;
const groups = [
...headParts,
...Array.from({ length: fill }, () => '0'),
...tailParts,
];
if (groups.length !== 8) return null;
const out = new Uint8Array(16);
for (let i = 0; i < 8; i += 1) {
const g = groups[i] as string;
if (!/^[0-9a-f]{1,4}$/i.test(g)) return null;
const n = Number.parseInt(g, 16);
out[i * 2] = (n >> 8) & 0xff;
out[i * 2 + 1] = n & 0xff;
}
return out;
}
function parseCidr(spec: string): { bytes: Uint8Array; bits: number } | null {
const [addr, lenRaw] = spec.trim().split('/');
const bytes = parseIp(addr ?? '');
if (!bytes) return null;
const max = bytes.length * 8;
const bits = lenRaw === undefined ? max : Number.parseInt(lenRaw, 10);
if (Number.isNaN(bits) || bits < 0 || bits > max) return null;
return { bytes, bits };
}
/**
* 判断来源 IP 是否在允许列表内。
*
* 语义:
* - 列表**为空或未配置** → 放行全部(保持向后兼容;但 agent 会打印醒目告警,
* 并在 system.info / CLI 里提示「未设白名单」)。
* - 支持三种写法混用:CIDR(`192.168.0.0/16`)、单个 IP(`10.1.2.3`)、
* 以及 `*` 表示全放行。
*
* 注意:比对是**按前缀比特**而不是字符串相等 —— 否则 `::ffff:192.168.0.5`
* 与 `192.168.0.5` 会判不相等(前者是 IPv6-mapped,后者是 IPv4)。
*/
export function ipInAllowlist(remoteIp: string, list?: string[]): boolean {
if (!list || list.length === 0) return true;
if (list.some((x) => x.trim() === '*')) return true;
const ip = parseIp(remoteIp);
if (!ip) return false; // 解析不了来源地址 → 保守拒绝(配了白名单时)
for (const spec of list) {
const cidr = parseCidr(spec);
if (!cidr) continue;
if (cidr.bytes.length !== ip.length) continue; // v4 规则不匹配 v6 地址(除非显式写 ::/0)
let ok = true;
for (let i = 0; i < cidr.bytes.length; i += 1) {
const remain = cidr.bits - i * 8;
if (remain <= 0) break;
const take = remain >= 8 ? 8 : remain;
const mask = take === 8 ? 0xff : (0xff << (8 - take)) & 0xff;
if (((cidr.bytes[i] as number) & mask) !== ((ip[i] as number) & mask)) {
ok = false;
break;
}
}
if (ok) return true;
}
return false;
}
/**
* 计算 TLS 证书指纹:**sha256(DER) 的十六进制**(小写,64 位)。
*
* 为什么是 DER 而不是 PEM 文件的字节:PEM 里有多余的头尾/换行,两边算出来必然不同。
* DER 是证书的规范二进制编码 —— Windows 的 `X509Certificate2.RawData` 也是它,
* 所以 install.ps1 能用 .NET 算出完全相同的值(供带外核对)。
*/
export function certFingerprint(derOrPem: Uint8Array | string): string {
const der =
typeof derOrPem === 'string' ? new Uint8Array(new X509Certificate(derOrPem).raw) : derOrPem;
return createHash('sha256').update(der).digest('hex');
}
/** 格式化便于人读/口述:前 16 位 + 后 8 位(带冒号分组)。 */
export function shortFingerprint(fp: string): string {
const head = fp.slice(0, 16).toUpperCase();
const tail = fp.slice(-8).toUpperCase();
return `${head}:${tail.match(/.{1,4}/g)?.join(':')}`;
}
+83
View File
@@ -0,0 +1,83 @@
@echo off
REM ============================================================================
REM nodeagent - control menu (Windows)
REM
REM Double-click this file to manage an already-installed agent:
REM 1) status - task / process / port / config at a glance
REM 2) start - start the scheduled task
REM 3) stop - stop the scheduled task
REM 4) restart - stop then start
REM 5) logs - tail the agent's audit log
REM 6) uninstall - remove task + firewall rule (7) purge config and exit
REM
REM Usage: control.cmd [port]
REM port defaults to 8765; pass it only if the agent uses another.
REM
REM Pure ASCII on purpose - see the note in install.cmd. The heavy lifting
REM (and all Chinese output) lives in control.ps1, which carries a UTF-8 BOM
REM so Windows PowerShell 5.1 parses it correctly.
REM ============================================================================
setlocal EnableExtensions
set "HERE=%~dp0"
set "HELPER=%HERE%control.ps1"
set "PORT=%~1"
if not defined PORT set "PORT=8765"
if not exist "%HELPER%" (
echo [x] control.ps1 not found next to this file.
echo Folder: "%HERE%"
if not defined CI pause
exit /b 1
)
:MENU
cls
echo ==========================================================
echo nodeagent control console
echo ==========================================================
echo 1) Status
echo 2) Start agent
echo 3) Stop agent
echo 4) Restart agent
echo 5) Show recent audit log
echo 6) Uninstall ^(keep config^)
echo 7) Uninstall and purge config
echo Q) Quit
echo ==========================================================
echo.
set "CHOICE="
REM <nul makes `set /p` fail immediately -> CHOICE stays empty. Combined with
REM CI (see below) that exits instead of looping forever on piped/empty stdin.
set /p "CHOICE=Select: "
if not defined CHOICE (
if defined CI goto :QUIT
echo.
echo (no input - exiting)
goto :QUIT
)
if /i "%CHOICE%"=="Q" goto :QUIT
if "%CHOICE%"=="1" call :RUN status
if "%CHOICE%"=="2" call :RUN start
if "%CHOICE%"=="3" call :RUN stop
if "%CHOICE%"=="4" call :RUN restart
if "%CHOICE%"=="5" call :RUN logs
if "%CHOICE%"=="6" call :RUN uninstall
if "%CHOICE%"=="7" call :RUN uninstall-purge
goto :MENU
:QUIT
endlocal
exit /b 0
:RUN
REM %1 = subcommand forwarded to control.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File "%HELPER%" -Action %1 -Port %PORT%
echo.
if not defined CI pause
goto :MENU
REM unreachable; keeps `call :RUN` from falling through if the goto is removed
exit /b 0
+237
View File
@@ -0,0 +1,237 @@
<#
.SYNOPSIS
nodeagent - control operations for an installed Windows agent.
.DESCRIPTION
Backs control.cmd. Everything Chinese-facing lives here (rather than in
the .cmd) because CMD's code page cannot round-trip UTF-8, while this
file is saved as UTF-8 **with BOM** so Windows PowerShell 5.1 reads it
correctly. PowerShell 5.1 parses BOM-less .ps1 as ANSI/GBK and the
Chinese comments then break string boundaries -> bogus syntax errors.
.PARAMETER Action
status | start | stop | restart | logs | uninstall | uninstall-purge
.EXAMPLE
.\control.ps1 -Action status
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidateSet('status', 'start', 'stop', 'restart', 'logs', 'uninstall', 'uninstall-purge')]
[string]$Action,
[int]$Port = 8765
)
$ErrorActionPreference = "Stop"
function Write-Step($m) { Write-Host "[*] $m" -ForegroundColor Cyan }
function Write-Ok($m) { Write-Host "[+] $m" -ForegroundColor Green }
function Write-Warn($m) { Write-Host "[!] $m" -ForegroundColor Yellow }
function Write-Err($m) { Write-Host "[x] $m" -ForegroundColor Red }
$taskName = "nodeagent"
# 数据目录:必须与 agent 侧一致 —— apps/agent/src/config.ts 的 agentDir() 是
# process.env.NODEAGENT_HOME ?? join(homedir(), '.nodeagent')
# 计划任务里若设了 NODEAGENT_HOME,只查 %USERPROFILE%\.nodeagent 会读错配置。
$cfgDir = if ($env:NODEAGENT_HOME) { $env:NODEAGENT_HOME } else { Join-Path $env:USERPROFILE ".nodeagent" }
$cfgPath = Join-Path $cfgDir "agent.json"
$auditLog = Join-Path $cfgDir "audit.log"
function Test-Admin {
([Security.Principal.WindowsPrincipal] `
[Security.Principal.WindowsIdentity]::GetCurrent()
).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Get-TaskState {
try { (Get-ScheduledTask -TaskName $taskName -ErrorAction Stop).State.ToString() }
catch { "NotInstalled" }
}
# ── status ────────────────────────────────────────────────────────────────────
function Show-Status {
Write-Host ""
Write-Host " nodeagent 状态" -ForegroundColor White
Write-Host " --------------" -ForegroundColor DarkGray
# 计划任务
$state = Get-TaskState
if ($state -eq "NotInstalled") {
Write-Warn "计划任务 '$taskName' 未安装(请先双击 install.cmd)"
} else {
$color = if ($state -eq "Running") { "Green" } else { "Yellow" }
Write-Host " 计划任务 : " -NoNewline; Write-Host $state -ForegroundColor $color
$info = Get-ScheduledTaskInfo -TaskName $taskName -ErrorAction SilentlyContinue
if ($info) {
Write-Host " 上次运行 : $($info.LastRunTime) 结果码 $($info.LastTaskResult)"
}
}
# 进程
$procs = @(Get-Process node -ErrorAction SilentlyContinue)
if ($procs.Count -gt 0) {
Write-Host " 进程 : " -NoNewline
Write-Host "$($procs.Count) 个 node 进程 (PID $($procs.Id -join ', '))" -ForegroundColor Green
} else {
Write-Host " 进程 : " -NoNewline; Write-Host "无" -ForegroundColor DarkGray
}
# 端口
$listen = @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue)
if ($listen.Count -gt 0) {
Write-Host " 端口 : " -NoNewline
Write-Host "$Port 正在监听 (PID $($listen[0].OwningProcess))" -ForegroundColor Green
} else {
Write-Host " 端口 : " -NoNewline
Write-Host "$Port 未监听" -ForegroundColor Yellow
}
# 配置
if (Test-Path $cfgPath) {
try {
$cfg = Get-Content $cfgPath -Raw | ConvertFrom-Json
Write-Host " 节点 ID : $($cfg.node_id)"
Write-Host " 鉴权模式 : $($cfg.auth_mode)"
$inp = if ($cfg.allow_input) { "已启用" } else { "已禁用" }
Write-Host " 输入控制 : $inp"
$scheme = if ($cfg.tls) { "wss" } else { "ws" }
Write-Host " 协议 : $scheme://0.0.0.0:$Port"
} catch {
Write-Warn "配置文件解析失败: $cfgPath"
}
} else {
Write-Warn "未找到配置文件: $cfgPath"
}
# 本机 IP
$ips = @()
try {
$ips = @(Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue |
Where-Object { $_.IPAddress -notlike '127.*' -and $_.PrefixOrigin -ne 'WellKnown' } |
Select-Object -ExpandProperty IPAddress)
} catch { }
Write-Host " 本机 IP : $($ips -join ', ')"
Write-Host ""
if ($state -eq "NotInstalled") {
Write-Warn "尚未安装 —— 请双击 install.cmd 一键部署"
} elseif ($listen.Count -eq 0) {
Write-Warn "端口未监听 —— 试试菜单里的「重启」,或双击 install.cmd 重新部署"
}
}
# ── start / stop / restart ────────────────────────────────────────────────────
function Start-Agent {
if ((Get-TaskState) -eq "NotInstalled") {
Write-Err "计划任务未安装,请先双击 install.cmd"
return
}
Write-Step "启动计划任务..."
Start-ScheduledTask -TaskName $taskName
# 轮询端口真正起来(最多 15s),而不是盲等
for ($i = 0; $i -lt 15; $i++) {
Start-Sleep -Seconds 1
if (Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue) {
Write-Ok "agent 已启动并在 $Port 监听"
return
}
}
Write-Warn "任务已触发,但 $Port 仍未监听"
$info = Get-ScheduledTaskInfo -TaskName $taskName -ErrorAction SilentlyContinue
if ($info) { Write-Warn "上次运行结果码: $($info.LastTaskResult)(0 = 成功,267009 = 任务已在运行)" }
}
function Stop-Agent {
if ((Get-TaskState) -eq "NotInstalled") {
Write-Err "计划任务未安装"
return
}
Write-Step "停止计划任务..."
Stop-ScheduledTask -TaskName $taskName
Start-Sleep -Seconds 1
# 兜底:计划任务偶尔留孤儿子进程
$left = @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue)
if ($left.Count -gt 0) {
Write-Warn "端口 $Port 仍被占用 (PID $($left[0].OwningProcess)),可能有残留进程"
Write-Warn "可手动结束: Stop-Process -Id $($left[0].OwningProcess) -Force"
} else {
Write-Ok "agent 已停止"
}
}
# ── logs ──────────────────────────────────────────────────────────────────────
function Show-Logs {
if (-not (Test-Path $auditLog)) {
Write-Warn "未找到审计日志: $auditLog"
Write-Warn "(审计日志需在 agent 配置中开启 audit.enabled)"
return
}
Write-Step "审计日志末尾 40 条: $auditLog"
Write-Host ""
# 只取尾部若干行,不把整个日志(默认上限 10MB x 5 份)读进内存。
# -Encoding UTF8 在 5.1 下能正确识别 UTF-8;解析前仍剥一次 BOM。
$lines = @(Get-Content -LiteralPath $auditLog -Tail 40 -Encoding UTF8 -ErrorAction SilentlyContinue |
Where-Object { $_.Trim() })
if ($lines.Count -eq 0) { Write-Warn "日志为空"; return }
foreach ($line in $lines) {
if ($line[0] -eq [char]0xFEFF) { $line = $line.Substring(1) }
try {
$e = $line | ConvertFrom-Json
# ts 是 Unix 毫秒(见 apps/agent/src/audit.ts 的 AuditEntry)
$ts = "?"
if ($e.ts) {
try { $ts = ([DateTimeOffset]::FromUnixTimeMilliseconds([int64]$e.ts)).LocalDateTime.ToString("MM-dd HH:mm:ss") }
catch { $ts = "$($e.ts)" }
}
$ty = if ($e.type) { $e.type } else { "?" }
$ci = if ($e.client_id) { $e.client_id } else { "-" }
$extra = ""
if ($e.capability) { $extra += " $($e.capability)" }
if ($e.status) { $extra += " [$($e.status)]" }
if ($e.duration_ms) { $extra += " $($e.duration_ms)ms" }
if ($e.error) { $extra += " ERROR=$($e.error)" }
if ($e.reason) { $extra += " reason=$($e.reason)" }
Write-Host (" {0,-15} {1,-20} {2,-14}{3}" -f $ts, $ty, $ci, $extra) -ForegroundColor DarkGray
} catch {
Write-Host " $line" -ForegroundColor DarkGray
}
}
Write-Host ""
}
# ── uninstall ─────────────────────────────────────────────────────────────────
function Uninstall-Agent([bool]$Purge) {
if (-not (Test-Admin)) {
Write-Err "卸载需要管理员权限,请右键 control.cmd → 以管理员身份运行"
return
}
Write-Step "停止并注销计划任务..."
Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
Write-Step "移除防火墙规则..."
Get-NetFirewallRule -DisplayName "nodeagent (TCP $Port)" -ErrorAction SilentlyContinue |
Remove-NetFirewallRule -ErrorAction SilentlyContinue
if ($Purge) {
Write-Step "清除配置目录..."
Remove-Item -Recurse -Force $cfgDir -ErrorAction SilentlyContinue
Write-Ok "已彻底卸载(含配置与审计日志)"
} else {
Write-Ok "已卸载(配置保留在 $cfgDir)"
}
}
# ── dispatch ──────────────────────────────────────────────────────────────────
switch ($Action) {
'status' { Show-Status }
'start' { Start-Agent }
'stop' { Stop-Agent }
'restart' { Stop-Agent; Start-Sleep -Seconds 1; Start-Agent }
'logs' { Show-Logs }
'uninstall' { Uninstall-Agent -Purge $false }
'uninstall-purge' { Uninstall-Agent -Purge $true }
}
+110
View File
@@ -0,0 +1,110 @@
@echo off
REM ============================================================================
REM nodeagent - one-click installer launcher (Windows)
REM
REM Double-click this file. It will:
REM 1) self-elevate to Administrator (UAC prompt) if not already elevated
REM 2) make sure a pre-shared key exists in PSK.txt (generate one if absent)
REM 3) run install.ps1, which registers the scheduled task, opens the
REM firewall and starts the agent
REM
REM Usage: install.cmd [port]
REM port defaults to 8765. Pass a port only when the default is taken.
REM
REM IMPORTANT - why this file is pure ASCII:
REM The CMD.exe code page (936/GBK on Chinese Windows) can NOT round-trip
REM UTF-8 Chinese reliably. Any non-ASCII byte here risks mojibake or a
REM broken command. All Chinese-facing text is delegated to install.ps1,
REM which is saved as UTF-8 **with BOM** so Windows PowerShell 5.1 reads
REM it correctly. Keep this launcher ASCII-only.
REM
REM NOTE - install.ps1 MUST keep its UTF-8 BOM. PowerShell 5.1 parses
REM BOM-less .ps1 as ANSI/GBK, and the Chinese comments then break the
REM string boundaries -> a cascade of bogus syntax errors.
REM ============================================================================
setlocal EnableExtensions
set "HERE=%~dp0"
set "PS1=%HERE%install.ps1"
set "PSKFILE=%HERE%PSK.txt"
set "PORT=%~1"
if not defined PORT set "PORT=8765"
if not exist "%PS1%" (
echo [x] install.ps1 not found next to this file.
echo Folder: "%HERE%"
echo Keep install.ps1, node.exe, agent.mjs and this launcher together.
if not defined CI pause
exit /b 1
)
REM --- 1) elevate to Administrator -------------------------------------------
REM fltmc is used instead of `net session` because the latter depends on the
REM Server service, which is disabled on some builds - that would make an
REM already-elevated shell look unelevated and trigger a pointless UAC loop.
fltmc >nul 2>&1
if errorlevel 1 (
echo [*] Requesting Administrator rights...
echo A new window will open. Continue there; this one closes by itself.
REM Start-Process re-launches this .cmd elevated. %PORT% (already resolved to
REM its default) is forwarded so "install.cmd 18770" survives the elevation.
powershell -NoProfile -ExecutionPolicy Bypass -Command ^
"Start-Process -FilePath '%~f0' -ArgumentList '%PORT%' -Verb RunAs" >nul 2>&1
if errorlevel 1 (
echo [x] Elevation was declined - cannot register a scheduled task.
if not defined CI pause
exit /b 1
)
exit /b 0
)
REM --- 2) make sure the pre-shared key exists ---------------------------------
set "PSK="
if exist "%PSKFILE%" (
REM Read + Trim in one shot: a stray CR from CRLF or an editor-added trailing
REM space would otherwise end up inside -Key and break authentication.
for /f "delims=" %%K in ('powershell -NoProfile -ExecutionPolicy Bypass -Command ^
"if (Test-Path -LiteralPath '%PSKFILE%') { (Get-Content -LiteralPath '%PSKFILE%' -Raw).Trim() }"') do set "PSK=%%K"
)
if not defined PSK (
echo [*] No usable PSK.txt found - generating a new pre-shared key...
REM 32 random bytes via .NET, hex-encoded to 64 chars.
for /f "delims=" %%K in ('powershell -NoProfile -ExecutionPolicy Bypass -Command ^
"$b=New-Object byte[] 32; [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($b); ($b ^| ForEach-Object { $_.ToString('x2') }) -join ''"') do set "PSK=%%K"
if not defined PSK (
echo [x] Failed to generate a key. Aborting.
if not defined CI pause
exit /b 1
)
REM Plain hex, no BOM: the file is read with `set /p` / Get-Content, and a
REM leading U+FEFF would corrupt the first byte of the key.
>"%PSKFILE%" echo %PSK%
echo [+] New key written to "%PSKFILE%"
) else (
echo [+] Using key from PSK.txt
)
REM --- 3) install -------------------------------------------------------------
echo.
echo [*] Installing nodeagent ^(node: %COMPUTERNAME%, port %PORT%, input control ON^)...
echo.
powershell -NoProfile -ExecutionPolicy Bypass -File "%PS1%" -NodeId "%COMPUTERNAME%" -Key "%PSK%" -AllowInput -Port %PORT%
set "RC=%ERRORLEVEL%"
echo.
if "%RC%"=="0" (
echo [+] Done. The agent now starts automatically at every logon.
echo To manage it later, double-click control.cmd in this folder.
) else (
echo [x] install.ps1 exited with code %RC%.
echo If the error mentions a missing file, re-extract the whole package.
echo If Windows Firewall or a security suite blocked something, allow it and retry.
)
echo.
REM Keep the window open so the user can read the output. CI sets CI=true,
REM where `pause` would hang forever waiting for a keypress.
if not defined CI pause
exit /b %RC%
+30 -2
View File
@@ -1,7 +1,13 @@
<#
<#
.SYNOPSIS
nodeagent - Windows agent installer.
⚠️ 本文件必须以 **UTF-8 BOM** 保存(文件头那个看不见的 U+FEFF 就是 BOM)。
Windows PowerShell 5.1 读**无 BOM** 的 .ps1 会按系统 ANSI(中文 Windows = GBK)
解析,本文件里的中文注释会解成乱码并被误认为引号 → 整份脚本 ParseError
(报错位置彼此无关,真正的病灶在靠前处)。真机踩过:2026-10-04。
另:第 4 步写 agent.json 时反而必须**无 BOM**(BOM 对 .ps1 必需、对 JSON 有害)。
.DESCRIPTION
Registers the nodeagent agent as a scheduled task (auto-start on logon),
opens the inbound firewall port, and prints connection info for the Mac side.
@@ -97,7 +103,11 @@ Write-Ok "Node.js v$ver"
Write-Ok "Agent entry: $agentJs"
# 4. Config + pre-shared key
$cfgDir = Join-Path $env:USERPROFILE ".nodeagent"
# 数据目录必须与 agent 侧一致 —— apps/agent/src/config.ts 的 agentDir() 是
# process.env.NODEAGENT_HOME ?? join(homedir(), '.nodeagent')
# 若计划任务环境里设了 NODEAGENT_HOME 而这里写死 USERPROFILE\.nodeagent,
# 就会「配置写A、读取B」,表现为装完连不上(E_AUTH_FAILED)。
$cfgDir = if ($env:NODEAGENT_HOME) { $env:NODEAGENT_HOME } else { Join-Path $env:USERPROFILE ".nodeagent" }
$cfgPath = Join-Path $cfgDir "agent.json"
New-Item -ItemType Directory -Force -Path $cfgDir | Out-Null
@@ -234,9 +244,27 @@ $ctlLabel = if ($AllowInput) { "ENABLED (mouse/keyboard control)" } else { "disa
Write-Host " Input ctl : $ctlLabel"
Write-Host " Auth mode : psk (pre-shared key)"
Write-Host " Discovery : UDP 8766 heartbeat (LAN auto-discovery; outbound only)"
# v21: TLS certificate fingerprint (sha256 of DER) — the Mac side pins this (TOFU).
# This must match what nodeagent computes, so out-of-band verification is possible:
# the Mac prints the same value at first connect; compare the two.
$certFp = ""
try {
$certPath = Join-Path (Split-Path -Parent $cfgPath) "certs\cert.pem"
if (Test-Path -LiteralPath $certPath) {
$x = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 -ArgumentList $certPath
$sha = [System.Security.Cryptography.SHA256]::Create().ComputeHash($x.GetRawCertData())
$certFp = ($sha | ForEach-Object { $_.ToString("x2") }) -join ""
}
} catch { }
Write-Host ""
Write-Host " Pre-shared key (copy this to the Mac side):" -ForegroundColor Yellow
Write-Host " $Key" -ForegroundColor White
if ($certFp) {
Write-Host ""
Write-Host " TLS cert fingerprint (verify the Mac side pinned the same one):" -ForegroundColor Yellow
Write-Host " $certFp" -ForegroundColor White
}
Write-Host ""
Write-Host " On the Mac, run:" -ForegroundColor Cyan
$insecureFlag = if ($NoTls) { "" } else { " --insecure" }
+106 -6
View File
@@ -7,12 +7,22 @@
* 产物: <out>/nodeagent-win-x64.zip
* ├── node.exe Node.js 运行时(免装 Node.js)
* ├── agent.mjs esbuild 打包的被控端(含全部依赖)
* ├── install.ps1 一键安装
* └── uninstall.ps1 一键卸载
* ├── install.cmd 一键安装(双击即用,自提权 + 自动生成 PSK)
* ├── control.cmd 控制台菜单(启动/停止/状态/日志/卸载)
* ├── install.ps1 底层安装脚本(由 install.cmd 调用)
* ├── control.ps1 底层控制脚本(由 control.cmd 调用)
* └── uninstall.ps1 底层卸载脚本
*
* 注意:PSK.txt **不打进 zip**(zip 会作为公开 Release 资产发布,含密钥=公开密钥);
* 它在被控端首次运行 install.cmd 时生成,只留在本机安装目录。
*/
import { execFileSync, execSync } from 'node:child_process';
import { copyFileSync, createWriteStream, existsSync, mkdirSync, rmSync } from 'node:fs';
import {
copyFileSync, createWriteStream, existsSync, mkdirSync,
readFileSync, rmSync, writeFileSync,
} from 'node:fs';
import { get } from 'node:https';
import { randomBytes } from 'node:crypto';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -62,6 +72,18 @@ async function main() {
execSync('pnpm -r build', { cwd: root, stdio: 'inherit' });
log('\n=== ② esbuild 打包被控端 ===');
// 单一版本源:根 package.json 的 version + git sha + 构建时间,
// 经 esbuild --define 注入,避免「版本号写在多处必然不同步」。
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
const commit = (() => {
try {
return execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root }).toString().trim();
} catch {
return 'unknown';
}
})();
const builtAt = new Date().toISOString();
log(` 版本 ${pkg.version} @ ${commit}(${builtAt})`);
mkdirSync(OUT_DIR, { recursive: true });
const bundleOut = join(OUT_DIR, 'agent.mjs');
const esbuild = join(root, 'node_modules', '.bin', 'esbuild');
@@ -75,6 +97,13 @@ async function main() {
'--target=node22',
'--format=esm',
'--banner:js=import { createRequire } from "node:module"; const require = createRequire(import.meta.url);',
// 刻意**不注入构建时间**:注入后同一份代码每次打包字节都不同,
// 「指纹比对是否最新」就永远失真(真机踩过:明明同版本却报不一致)。
// 构建时间改由入口文件的 mtime 反映(见 system.ts 的 build.built_at)。
// 注意:define 的值按 **JS 表达式** 解析,故用双引号包裹;写成单引号会让引号成为值的一部分
// (真机踩过:版本显示成 v"1.4.0")。
`--define:__AGENT_VERSION__=${JSON.stringify(pkg.version)}`,
`--define:__BUILD_COMMIT__=${JSON.stringify(commit)}`,
`--outfile=${bundleOut}`,
],
{ stdio: 'inherit' },
@@ -94,18 +123,89 @@ async function main() {
execSync(`unzip -o -j "${tmpZip}" "*/node.exe" -d "${PKG_DIR}"`, { stdio: 'inherit' });
copyFileSync(bundleOut, join(PKG_DIR, 'agent.mjs'));
copyFileSync(join(root, 'scripts', 'install.ps1'), join(PKG_DIR, 'install.ps1'));
copyFileSync(join(root, 'scripts', 'control.ps1'), join(PKG_DIR, 'control.ps1'));
copyFileSync(join(root, 'scripts', 'uninstall.ps1'), join(PKG_DIR, 'uninstall.ps1'));
// 一键入口:.cmd 必须**纯 ASCII**。CMD 的代码页(中文 Windows = 936/GBK)
// 无法可靠往返 UTF-8,中文提示一律交给带 BOM 的 .ps1 去输出。
for (const cmd of ['install.cmd', 'control.cmd']) {
copyFileSync(join(root, 'scripts', cmd), join(PKG_DIR, cmd));
}
// 防呆 A:产物里的 .ps1 必须带 UTF-8 BOM,否则被控端的 **Windows PowerShell 5.1**
// 会按 ANSI/GBK 解析无 BOM 脚本,中文注释破坏字符串边界 → 整份脚本 ParseError。
// 真机踩过(2026-10-04):CI 全绿(冒烟用 pwsh/PS7),5.1 真机直接报
// 「","后面缺少表达式」。此处宁可在打包阶段失败,也不要把坏包发出去。
for (const ps1 of ['install.ps1', 'control.ps1', 'uninstall.ps1']) {
const p = join(PKG_DIR, ps1);
const head = readFileSync(p).subarray(0, 3);
if (!(head[0] === 0xef && head[1] === 0xbb && head[2] === 0xbf)) {
fail(`${ps1} 缺少 UTF-8 BOM —— Windows PowerShell 5.1 会解析失败。` +
`请先给 scripts/${ps1} 加上 BOM 再打包。`);
}
}
log(' .ps1 BOM 检查通过(兼容 Windows PowerShell 5.1)');
// 防呆 B:.cmd 里出现非 ASCII 字节 = 中文乱码/命令截断的隐患。
for (const cmd of ['install.cmd', 'control.cmd']) {
const buf = readFileSync(join(PKG_DIR, cmd));
const bad = [...buf].findIndex((b) => b > 0x7f);
if (bad >= 0) {
fail(`${cmd} 含非 ASCII 字节(偏移 ${bad})—— CMD 代码页无法可靠处理,` +
`请把该文件改为纯 ASCII,中文提示交给 .ps1。`);
}
}
log(' .cmd 纯 ASCII 检查通过(CMD 代码页安全)');
// PSK.txt:预置一把随机密钥,让 install.cmd 真正「零输入」。
// 用 --psk 传入指定密钥;不传则随机生成 32 字节 hex。
// 文件是纯 hex 文本,无需 BOM(install.cmd 用 `set /p` 读)。
const pskArg = getArg('--psk', '');
const psk = pskArg || randomBytes(32).toString('hex');
if (!/^[0-9a-fA-F]{32,128}$/.test(psk)) {
fail(`--psk 格式非法(需 32-128 位十六进制): ${psk}`);
}
writeFileSync(join(PKG_DIR, 'PSK.txt'), `${psk}\n`, 'utf8');
log(` PSK.txt 已生成(${psk.length} 位${pskArg ? ',来自 --psk' : ',随机'})`);
// README:给第一次上手的人看一眼该双击哪个文件
writeFileSync(join(PKG_DIR, 'README.txt'), [
'nodeagent - Windows 被控端',
'==========================',
'',
'【怎么装】双击 install.cmd',
' 1) 会弹一次「用户账户控制」→ 点「是」(必须管理员,装计划任务要用)',
' 2) 等它跑完,最后会打印连接信息',
' 3) 把那行 nodeagent connect ... 拿到 Mac 上执行',
'',
'【日常管理】双击 control.cmd',
' 1 状态 / 2 启动 / 3 停止 / 4 重启 / 5 日志 / 6 卸载 / 7 彻底卸载',
'',
'【密钥】PSK.txt 里是预共享密钥(= Mac 端 --key 的值)',
' 丢了可以删掉 PSK.txt 重装,会自动生成新的',
'',
'【注意】',
' * 整个文件夹可以固定放在 D:\\nodeagent,但**装完不要移动或改名**',
' * agent 每次开机登录后自动启动,无需再双击任何东西',
' * 首次运行若被杀软拦截,把本文件夹加入信任区后重试',
'',
].join('\n'), 'utf8');
const files = execSync(`ls -la "${PKG_DIR}"`, { encoding: 'utf8' });
log(files.split('\n').slice(0, 8).join('\n'));
log(files.split('\n').slice(0, 12).join('\n'));
log('\n=== ⑤ 打 zip ===');
const zipOut = join(OUT_DIR, 'nodeagent-win-x64.zip');
rmSync(zipOut, { force: true });
execSync(`cd "${PKG_DIR}" && zip -q -r "${zipOut}" .`, { stdio: 'inherit' });
// ⚠️ 必须排除 PSK.txt:本 zip 会作为**公开的 GitHub Release 资产**发布,
// 把预共享密钥打进包里等于公开密钥。install.cmd 在文件缺失时会自动生成新密钥
// (生成在安装目录,只留在被控端本机),所以排除它不影响安装。
execSync(`cd "${PKG_DIR}" && zip -q -r "${zipOut}" . -x "PSK.txt"`, { stdio: 'inherit' });
const size = execSync(`du -sh "${zipOut}"`, { encoding: 'utf8' }).split('\t')[0];
log(`\n✓ 完成: ${zipOut}(${size.trim()})`);
log(' 分发方式:解压后右键以管理员运行 install.ps1 即可');
log(' 分发方式:解压到固定目录 → 双击 install.cmd(自提权,零命令行知识)');
log(' 日后管理:双击 control.cmd');
}
try {
+247
View File
@@ -0,0 +1,247 @@
#!/usr/bin/env node
/**
* 发版脚本:一条命令完成「改版本 → 构建 → 打包 → 生成校验和与清单 → 提交打标签 → 发布 GitHub Release」。
*
* 用法:
* node scripts/release.mjs 1.0.0 # 指定版本
* node scripts/release.mjs patch|minor|major # 按当前版本自增
* node scripts/release.mjs 1.0.0 --dry-run # 只演练:构建+生成资产,不改 git、不发布
* node scripts/release.mjs 1.0.0 --no-publish # 提交打标签,但不发 GitHub Release
*
* 为什么要有它(对齐皇上的要求「每次更新,GitHub 上也要同步资产」):
* 手工发版要记住「改几处 package.json、打哪个包、算哪些哈希、传什么资产」——
* 必然漏项(尤其是给人用的 zip 与给自更新用的 agent.mjs 是**两个不同的东西**)。
* 本脚本把两者都产出,并把 sha256 写进 latest.json,供被控端 `system.agent.update` 直接使用。
*/
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const argv = process.argv.slice(2);
const flags = new Set(argv.filter((a) => a.startsWith('--')));
const arg = argv.find((a) => !a.startsWith('--'));
const dryRun = flags.has('--dry-run');
const publish = !flags.has('--no-publish');
// ⚠️ stdio:'inherit' 时 execFileSync 返回 null(不捕获输出)→ 必须容错,
// 否则 build/pack 这类「继承输出」的调用会直接 TypeError(真机踩过)
const sh = (cmd, args, opts = {}) => {
const out = execFileSync(cmd, args, { cwd: root, encoding: 'utf8', ...opts });
return out === null || out === undefined ? '' : String(out).trim();
};
function fail(msg) {
console.error(`✗ ${msg}`);
process.exit(1);
}
/**
* 带重试的网络型 git 操作。
* 本机到 GitHub 的连接会间歇性 SSL_ERROR_SYSCALL / SSL timeout(已实测多次),
* 发版卡在这里会留下半成品状态 —— 所以 fetch 与 push 都必须重试。
*/
function netRetry(label, fn, tries = 4) {
for (let i = 1; i <= tries; i += 1) {
try {
fn();
return;
} catch (err) {
if (i === tries) throw err;
console.warn(`(${label} 第 ${i} 次失败,2s 后重试…)`);
execFileSync('sleep', ['2']);
}
}
}
function bump(cur, kind) {
const [a, b, c] = cur.split('.').map((n) => Number.parseInt(n, 10));
if ([a, b, c].some((n) => Number.isNaN(n))) fail(`现有版本号无法解析: ${cur}`);
if (kind === 'patch') return `${a}.${b}.${c + 1}`;
if (kind === 'minor') return `${a}.${b + 1}.0`;
if (kind === 'major') return `${a + 1}.0.0`;
return null;
}
const pkgPath = join(root, 'package.json');
const cur = JSON.parse(readFileSync(pkgPath, 'utf8')).version;
const version = ['patch', 'minor', 'major'].includes(arg) ? bump(cur, arg) : (arg ?? '');
if (!version) fail('用法: node scripts/release.mjs <1.2.3|patch|minor|major> [--dry-run] [--no-publish]');
if (!/^\d+\.\d+\.\d+$/.test(version)) fail(`版本号格式应为 x.y.z,收到 ${version}`);
console.log(`=== nodeagent 发版 v${version}(当前 ${cur})${dryRun ? ' [dry-run]' : ''} ===`);
// ---------- ① 前置检查 ----------
if (!dryRun) {
// 刻意排除 .github/:其改动需要 workflow 权限才能推送,
// 若把它算进「干净检查」会让发版永远卡住(见 docs/VERSIONING.md §6)
const dirty = sh('git', ['status', '--porcelain', '--', '.', ':(exclude).github'])
.split('\n')
.filter(Boolean)
.join('\n');
if (dirty) fail(`工作区不干净(已忽略 .github/),请先提交:\n${dirty}`);
const branch = sh('git', ['rev-parse', '--abbrev-ref', 'HEAD']);
if (branch !== 'main') fail(`当前分支是 ${branch},发版请在 main 上`);
netRetry('fetch', () => sh('git', ['fetch', 'origin', 'main']));
if (sh('git', ['rev-parse', 'HEAD']) !== sh('git', ['rev-parse', 'origin/main'])) {
fail('本地 main 与远端不一致,请先 pull/push(避免发版后才发现分叉)');
}
}
const remote = sh('git', ['remote', 'get-url', 'origin'])
.replace(/^git@github\.com:/, 'https://github.com/')
.replace(/\.git$/, '');
const repoPath = remote.replace(/^https:\/\/github\.com\//, '');
if (!/^[\w.-]+\/[\w.-]+$/.test(repoPath)) fail(`无法从 remote 推断 GitHub 仓库: ${remote}`);
// ---------- ② 改版本(单一版本源:根 + 各 workspace)----------
const pkgFiles = ['package.json'];
for (const f of sh('git', ['ls-files', '*package.json']).split('\n').filter(Boolean)) {
if (f === 'package.json' || f.includes('node_modules')) continue;
pkgFiles.push(f);
}
if (dryRun) {
// dry-run 绝不改文件 —— 演练就该是无副作用的
console.log(`(dry-run)将把 ${pkgFiles.length} 个 package.json 的 version 改为 ${version}`);
console.log('(dry-run)将执行:构建 → pack:win → pack:mac → 生成资产 → 提交打标签 → gh release create');
process.exit(0);
}
for (const f of pkgFiles) {
const p = join(root, f);
const j = JSON.parse(readFileSync(p, 'utf8'));
if (!j.version) continue;
j.version = version;
writeFileSync(p, JSON.stringify(j, null, 2) + '\n');
}
console.log(`✓ 版本号已写入 ${pkgFiles.length} 个 package.json`);
// ---------- ③ 构建 + 打包 ----------
console.log('=== 构建 ===');
sh('pnpm', ['-r', 'build'], { stdio: 'inherit' });
console.log('=== 打包 ===');
sh('pnpm', ['pack:win'], { stdio: 'inherit' });
try {
sh('pnpm', ['pack:mac'], { stdio: 'inherit' });
} catch {
console.warn('(pack:mac 跳过:非 macOS 或未安装依赖)');
}
const agentPath = join(root, 'release', 'nodeagent-win-x64', 'agent.mjs');
const zipPath = join(root, 'release', 'nodeagent-win-x64.zip');
if (!existsSync(agentPath)) fail(`找不到 agent 产物: ${agentPath}`);
if (!existsSync(zipPath)) fail(`找不到 zip 产物: ${zipPath}`);
// ---------- ④ 生成资产(校验和 + latest.json)----------
const sha256 = (p) => createHash('sha256').update(readFileSync(p)).digest('hex');
const distDir = join(root, 'release', 'dist');
mkdirSync(distDir, { recursive: true });
const assets = [
{ name: 'agent.mjs', path: agentPath, purpose: '自更新载荷(被控端 system.agent.update 直接使用)' },
{ name: 'nodeagent-win-x64.zip', path: zipPath, purpose: '人工安装包(自包含 node.exe,免装 Node.js)' },
];
for (const a of assets) {
copyFileSync(a.path, join(distDir, a.name));
a.bytes = readFileSync(a.path).length;
a.sha256 = sha256(a.path);
}
const commit = sh('git', ['rev-parse', '--short', 'HEAD']);
const builtAt = new Date().toISOString();
// 能力数从清单动态取,避免写死后漂移
let capabilityCount = 0;
try {
const mod = await import(join(root, 'packages', 'protocol', 'dist', 'index.js'));
capabilityCount = mod.CAPABILITY_MANIFEST?.length ?? 0;
} catch {
console.warn('(未能从 dist 读取能力数,latest.json 里记为 0)');
}
const base = `https://github.com/${repoPath}/releases/download/v${version}`;
const latest = {
version,
commit,
built_at: builtAt,
protocol: '1.0',
capabilities: capabilityCount,
agent: {
url: `${base}/agent.mjs`,
sha256: assets[0].sha256,
bytes: assets[0].bytes,
note: '自更新用:nodeagent update --url <url> --sha256 <sha256>',
},
zip: { url: `${base}/nodeagent-win-x64.zip`, sha256: assets[1].sha256, bytes: assets[1].bytes },
};
writeFileSync(join(distDir, 'latest.json'), JSON.stringify(latest, null, 2) + '\n');
const sums = assets.map((a) => `${a.sha256} ${a.name}`).join('\n') + `\n`;
writeFileSync(join(distDir, 'SHA256SUMS'), sums);
console.log('=== 资产 ===');
for (const a of assets) {
console.log(` ${a.name.padEnd(26)} ${(a.bytes / 1024 / 1024).toFixed(1)}MB sha256=${a.sha256.slice(0, 12)}…`);
}
console.log(` latest.json / SHA256SUMS`);
// ---------- ⑤ 提交 + 标签 + 推送 ----------
sh('git', ['add', '-A', '--', '.', ':(exclude).github']);
sh('git', ['commit', '-m', `chore(release): v${version}`]);
sh('git', ['tag', '-a', `v${version}`, '-m', `nodeagent v${version}`]);
netRetry('push main', () => sh('git', ['push', 'origin', 'main']));
netRetry('push tag', () => sh('git', ['push', 'origin', `v${version}`]));
console.log(`✓ 已提交并推送标签 v${version}`);
// ---------- ⑥ 发布 GitHub Release ----------
if (!publish) {
console.log('(--no-publish:已跳过 GitHub Release)');
process.exit(0);
}
const notes = join(distDir, 'RELEASE_NOTES.md');
writeFileSync(
notes,
[
`# nodeagent v${version}`,
``,
`- 构建: \`${commit}\` @ ${builtAt}`,
`- 能力: ${latest.capabilities} 项`,
``,
`## 资产`,
...assets.map((a) => `- \`${a.name}\` — ${a.purpose}`),
`- \`latest.json\` — 版本清单(含 sha256),供被控端自更新校验`,
`- \`SHA256SUMS\` — 校验和`,
``,
`## 更新被控端(拉取式,只需它能上网)`,
'```bash',
`nodeagent --node <设备名> update \\`,
` --url ${latest.agent.url} \\`,
` --sha256 ${latest.agent.sha256}`,
'```',
``,
`## 人工安装(首次)`,
`\`\`\`powershell`,
`Expand-Archive nodeagent-win-x64.zip C:\\nodeagent -Force`,
`# 先把 C:\\nodeagent 加入杀软信任区(主流杀软/杀软)`,
`powershell -ExecutionPolicy Bypass -File C:\\nodeagent\\install.ps1 -NodeId win -AllowInput`,
'```',
].join('\n') + '\n',
);
sh('gh', [
'release',
'create',
`v${version}`,
'--title',
`nodeagent v${version}`,
'--notes-file',
notes,
join(distDir, 'agent.mjs'),
join(distDir, 'nodeagent-win-x64.zip'),
join(distDir, 'latest.json'),
join(distDir, 'SHA256SUMS'),
]);
console.log(`✓ GitHub Release v${version} 已发布`);
console.log('');
console.log('被控端更新命令:');
console.log(` nodeagent --node <设备名> update --url ${latest.agent.url} --sha256 ${latest.agent.sha256}`);
+149
View File
@@ -0,0 +1,149 @@
<#
.SYNOPSIS
nodeagent 网络救援脚本 —— 被控端因网络变更失联后的本地恢复。
.DESCRIPTION
背景:`system.net.apply` 是两阶段提交(未确认自动回滚),但**如果回滚本身也失败**
(例如变更脚本把地址删掉了却没加上),机器可能落到 APIPA(169.254.x.x)而彻底失联。
这个脚本就是为那一天准备的:在**被控端本机**以管理员运行,把网络恢复到可用状态。
它做的事:
1. 列出所有网卡与当前地址(先看清现状)
2. 找出「有物理连接、但没有有效 IPv4」的网卡(典型的失联状态)
3. 把指定/自动识别的网卡设回给定静态地址,或切回 DHCP
4. 用**原生 NetTCPIP cmdlet** 按安全顺序执行:
先加地址 → 再清其它地址与残留默认路由 → 最后补默认路由
(顺序颠倒会踩 Windows 的「already exists」而再次失败 —— 真机事故根因)
.PARAMETER Interface
网卡别名(如「Ethernet」)。省略则自动选取「已连接但无有效 IPv4」的那块。
.PARAMETER Ip / PrefixLength / Gateway / Dns
要恢复的静态配置;省略 Ip 则切回 DHCP。
.PARAMETER ListOnly
只看现状,不做任何变更。
.EXAMPLE
# 先看现状
powershell -ExecutionPolicy Bypass -File rescue-network.ps1 -ListOnly
.EXAMPLE
# 恢复为原静态地址(最常用)
powershell -ExecutionPolicy Bypass -File rescue-network.ps1 `
-Interface 'Ethernet' -Ip 192.168.1.100 -PrefixLength 24 -Gateway 192.168.0.1
.EXAMPLE
# 切回 DHCP(家里路由器会重新分配地址)
powershell -ExecutionPolicy Bypass -File rescue-network.ps1 -Interface 'Ethernet'
#>
[CmdletBinding()]
param(
[string]$Interface,
[string]$Ip,
[int]$PrefixLength = 24,
[string]$Gateway,
[string[]]$Dns,
[switch]$ListOnly
)
$ErrorActionPreference = 'Continue'
function Write-Step($m) { Write-Host "[*] $m" -ForegroundColor Cyan }
function Write-Ok($m) { Write-Host "[+] $m" -ForegroundColor Green }
function Write-Warn2($m) { Write-Host "[!] $m" -ForegroundColor Yellow }
function Write-Err2($m) { Write-Host "[x] $m" -ForegroundColor Red }
Write-Host ""
Write-Host " nodeagent 网络救援" -ForegroundColor White
Write-Host " ------------------" -ForegroundColor DarkGray
# ---------- 1. 现状 ----------
Write-Step "当前网卡与地址:"
$adapters = Get-NetAdapter | Sort-Object ifIndex
foreach ($a in $adapters) {
$addrs = @(Get-NetIPAddress -InterfaceIndex $a.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue |
Where-Object { $_.PrefixOrigin -ne 'WellKnown' } | ForEach-Object { "$($_.IPAddress)/$($_.PrefixLength)" })
$dhcp = (Get-NetIPInterface -InterfaceIndex $a.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue).Dhcp
$mark = if ($a.Status -eq 'Up' -and $addrs.Count -eq 0) { ' <== 已连接但无地址(疑似失联)' } else { '' }
Write-Host (" {0,-22} {1,-8} dhcp={2,-8} {3}{4}" -f $a.Name, $a.Status, $dhcp, ($addrs -join ', '), $mark)
}
$gw = @(Get-NetRoute -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue |
ForEach-Object { "$($_.NextHop) (ifIndex $($_.ifIndex))" })
# 注意:PowerShell 5.1 的 if 不能当表达式内联在括号里 —— 必须先算好再拼(写错会直接语法错误)
$gwText = if ($gw.Count -gt 0) { $gw -join ', ' } else { '(无 —— 这就是回包发不出去的原因)' }
Write-Step ("默认路由: " + $gwText)
if ($ListOnly) { Write-Host ""; Write-Ok "仅查看模式,未做任何变更"; exit 0 }
# ---------- 2. 选目标网卡 ----------
if (-not $Interface) {
$cand = $adapters | Where-Object {
$_.Status -eq 'Up' -and
@(Get-NetIPAddress -InterfaceIndex $_.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue |
Where-Object { $_.PrefixOrigin -ne 'WellKnown' }).Count -eq 0
} | Select-Object -First 1
if (-not $cand) {
$cand = $adapters | Where-Object { $_.Status -eq 'Up' } | Select-Object -First 1
}
if (-not $cand) { Write-Err2 "找不到可用的已连接网卡,请用 -Interface 指定"; exit 1 }
$Interface = $cand.Name
Write-Warn2 "未指定网卡,自动选用:$Interface"
}
if (-not (Get-NetAdapter -Name $Interface -ErrorAction SilentlyContinue)) {
Write-Err2 "网卡不存在:$Interface"; exit 1
}
# ---------- 3. 执行 ----------
if (-not $Ip) {
Write-Step "切回 DHCP:$Interface"
Set-NetIPInterface -InterfaceAlias $Interface -Dhcp Enabled -ErrorAction SilentlyContinue
Set-DnsClientServerAddress -InterfaceAlias $Interface -ResetServerAddresses -ErrorAction SilentlyContinue
Write-Ok "已切回 DHCP(稍等几秒应拿到地址)"
} else {
Write-Step "恢复静态地址 $Ip/$PrefixLength (网卡 $Interface)"
Set-NetIPInterface -InterfaceAlias $Interface -Dhcp Disabled -ErrorAction SilentlyContinue
# ⚠️ 顺序不可颠倒:先加新地址(过渡期链路不断),再清旧地址/残留路由,最后补路由。
# 颠倒会触发 Windows 的「实例已存在」→ 地址加不上 → 再次失联(真机事故根因)。
New-NetIPAddress -InterfaceAlias $Interface -IPAddress $Ip -PrefixLength $PrefixLength -ErrorAction SilentlyContinue | Out-Null
Get-NetRoute -InterfaceAlias $Interface -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue |
Remove-NetRoute -Confirm:$false -ErrorAction SilentlyContinue
Get-NetIPAddress -InterfaceAlias $Interface -AddressFamily IPv4 -ErrorAction SilentlyContinue |
Where-Object { $_.IPAddress -ne $Ip -and $_.PrefixOrigin -ne 'WellKnown' } |
ForEach-Object { Remove-NetIPAddress -IPAddress $_.IPAddress -InterfaceAlias $Interface -Confirm:$false -ErrorAction SilentlyContinue }
if ($Gateway) {
New-NetRoute -InterfaceAlias $Interface -DestinationPrefix '0.0.0.0/0' -NextHop $Gateway -ErrorAction SilentlyContinue | Out-Null
}
if ($Dns -and $Dns.Count -gt 0) {
Set-DnsClientServerAddress -InterfaceAlias $Interface -ServerAddresses $Dns -ErrorAction SilentlyContinue
}
}
Start-Sleep -Seconds 3
# ---------- 4. 自检 ----------
Write-Step "自检:"
$now = @(Get-NetIPAddress -InterfaceAlias $Interface -AddressFamily IPv4 -ErrorAction SilentlyContinue |
Where-Object { $_.PrefixOrigin -ne 'WellKnown' } | ForEach-Object { "$($_.IPAddress)/$($_.PrefixLength)" })
$nowGw = @(Get-NetRoute -InterfaceAlias $Interface -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue |
ForEach-Object { $_.NextHop })
if ($now.Count -gt 0) {
Write-Ok ("地址: " + ($now -join ', '))
if ($Ip -and $nowGw.Count -eq 0) {
Write-Warn2 "没有默认路由 —— 只能同网段互访;请补 -Gateway"
} elseif ($nowGw.Count -gt 0) {
Write-Ok ("默认路由: " + ($nowGw -join ', '))
}
Write-Host ""
Write-Ok "网络已恢复。可在 Mac 上执行: nodeagent connect $($now[0].Split('/')[0]) --port 8765 --key <PSK> --insecure"
} else {
Write-Err2 "仍然没有有效地址 —— 请检查网线/交换机,或尝试: rescue-network.ps1 -Interface '$Interface'(不带 -Ip,切回 DHCP)"
exit 1
}
+1 -1
View File
@@ -1,4 +1,4 @@
<#
<#
.SYNOPSIS
nodeagent - uninstall the Windows agent.
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env node
/**
* 一条命令更新被控端(Windows):打包 → 部署 → 校验。
*
* 用法:
* node scripts/update-win.mjs [设备名] [--check] [--force]
* 设备名省略时用控制端配置里的 current
*
* 等价于:
* pnpm pack:win
* nodeagent --node <设备名> deploy release/nodeagent-win-x64/agent.mjs
*
* 为什么要有它:手动流程要记住「先 pack、产物在 release/nodeagent-win-x64/、再 deploy」
* 三个知识点,容易出错(尤其 pack 失败时 deploy 的是旧包 —— 真机踩过)。
* 本脚本在 pack 之后**校验产物确已更新**,再部署。
*/
import { execFileSync } from 'node:child_process';
import { existsSync, statSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const args = process.argv.slice(2);
const flags = args.filter((a) => a.startsWith('--'));
const node = args.find((a) => !a.startsWith('--')) ?? '';
const bundle = join(root, 'release', 'nodeagent-win-x64', 'agent.mjs');
const before = existsSync(bundle) ? statSync(bundle).mtimeMs : 0;
console.log('=== ① 打包(pnpm pack:win)===');
execFileSync('pnpm', ['pack:win'], { cwd: root, stdio: 'inherit' });
if (!existsSync(bundle)) {
console.error('✗ 打包后仍找不到产物:', bundle);
process.exit(1);
}
const after = statSync(bundle).mtimeMs;
if (after === before) {
console.error('✗ 产物时间戳未变化 —— 打包可能失败,拒绝部署旧包(这是刻意的保护)');
process.exit(1);
}
console.log(`✓ 产物已更新:${bundle}`);
console.log('=== ② 部署到被控端 ===');
const target = node ? ['--node', node] : [];
execFileSync('nodeagent', [...target, 'deploy', bundle, ...flags], { cwd: root, stdio: 'inherit' });
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env node
/**
* MCP 事件通路端到端验证。
*
* 需要一台已配置的被控端(~/.nodeagent/config.json 里有 current 设备)。
* 用法:
* node scripts/verify-mcp-events.mjs
* NODEAGENT_MCP=~/.local/bin/nodeagent-mcp NA_WATCH_DIR='D:\\tmp' node scripts/verify-mcp-events.mjs
*
* 验证两条通路:
* 1) 推送:na_event_watch(notify=true) -> MCP 日志通知(notifications/message)
* 2) 拉取:na_event_poll 增量读被控端缓冲
*/
import { spawn } from 'node:child_process';
import { writeFileSync } from 'node:fs';
const MCP = process.env['NODEAGENT_MCP'] ?? 'nodeagent-mcp';
const WATCH_DIR = process.env['NA_WATCH_DIR'] ?? 'C:\\Users\\Public\\na-watch';
const proc = spawn(MCP, [], { stdio: ['pipe', 'pipe', 'pipe'] });
let buf = '';
const pending = new Map();
const notifications = [];
proc.stdout.on('data', (chunk) => {
buf += chunk.toString('utf8');
let idx;
while ((idx = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, idx).trim();
buf = buf.slice(idx + 1);
if (!line) continue;
let msg;
try {
msg = JSON.parse(line);
} catch {
continue;
}
if (msg.id && pending.has(msg.id)) {
const { resolve } = pending.get(msg.id);
pending.delete(msg.id);
resolve(msg);
} else if (msg.method === 'notifications/message') {
notifications.push(msg.params);
}
}
});
proc.stderr.on('data', (d) => process.stderr.write(`[mcp] ${d}`));
let idSeq = 0;
function call(method, params) {
const id = ++idSeq;
return new Promise((resolve) => {
pending.set(id, { resolve });
proc.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
setTimeout(() => {
if (pending.has(id)) {
pending.delete(id);
resolve({ error: { message: 'timeout' } });
}
}, 90_000);
});
}
const tool = (name, args) => call('tools/call', { name, arguments: args });
const textOf = (r) => r.result?.content?.[0]?.text ?? JSON.stringify(r).slice(0, 300);
const log = (s) => console.log(s);
try {
// 1) 握手
const init = await call('initialize', {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'verify', version: '1' },
});
log(`1) initialize: ${init.result ? 'ok' : 'FAIL'} (${init.result?.serverInfo?.name ?? '?'})`);
proc.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n');
// 2) 列出工具(确认事件类工具已注册)
const tools = await call('tools/list', {});
const names = (tools.result?.tools ?? []).map((t) => t.name);
const evTools = names.filter((n) => n.startsWith('na_event'));
log(`2) tools/list: 共 ${names.length} 个,事件类 ${evTools.length} 个 → ${evTools.join(', ')}`);
// 3) 订阅文件事件(开启推送)
const w = await tool('na_event_watch', {
kind: 'file',
path: WATCH_DIR,
notify: true,
});
log(`3) na_event_watch(notify=true): ${textOf(w).slice(0, 160)}`);
// 4) 制造一次文件变动(经 na_exec)
await new Promise((r) => setTimeout(r, 800));
const mk = await tool('na_exec', {
command:
`New-Item -ItemType File -Path '${WATCH_DIR}\\mcp-evt.txt' -Force | Out-Null; ` +
`Add-Content '${WATCH_DIR}\\mcp-evt.txt' 'hi'; Start-Sleep 1; ` +
`Remove-Item '${WATCH_DIR}\\mcp-evt.txt' -Force; Write-Output done`,
});
log(`4) 制造变动: ${textOf(mk).split('\n')[0].slice(0, 80)}`);
// 5) 等推送到达
await new Promise((r) => setTimeout(r, 3000));
log(`5) 收到的推送通知: ${notifications.length} 条`);
for (const n of notifications.slice(0, 3)) log(` · ${String(n.data)}`);
// 6) 拉取通路
const poll = await tool('na_event_poll', { limit: 10 });
log(`6) na_event_poll: ${textOf(poll).replace(/\s+/g, ' ').slice(0, 200)}`);
// 7) 列表 + 推送状态
const list = await tool('na_event_list', {});
log(`7) na_event_list:\n${textOf(list).split('\n').map((l) => ' ' + l).join('\n')}`);
log(`\n结论:推送 ${notifications.length > 0 ? '✅ 生效' : '⚠️ 未收到(宿主可能不转发日志通知,拉取通路仍可用)'};拉取 ✅`);
} finally {
proc.kill();
}
+522 -4
View File
@@ -7,13 +7,13 @@
* 运行:node tests/e2e/run.mjs
*/
import { spawn } from 'node:child_process';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import assert from 'node:assert/strict';
import { NodeAgentClient } from '../../packages/client/dist/index.js';
import { NodeAgentClient, runMacro } from '../../packages/client/dist/index.js';
import { generateKeyPair } from '../../packages/protocol/dist/index.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -540,7 +540,16 @@ async function testHubMode() {
writeFileSync(
join(hubHome, 'hub.json'),
JSON.stringify({ node_id: 'hub_e2e', host: '127.0.0.1', port: HUB_PORT, token: HUB_TOKEN, log_level: 'warn' }),
JSON.stringify({
node_id: 'hub_e2e',
host: '127.0.0.1',
port: HUB_PORT,
token: HUB_TOKEN,
log_level: 'warn',
// v12 / E3c:受限令牌(只允许访问 other_node,用于验证设备级授权)
tokens: [{ value: 'limited-token', name: 'limited', allow_nodes: ['other_node'] }],
node_allowlist: ['hub_*'],
}),
);
writeFileSync(
join(agentHome, 'agent.json'),
@@ -601,6 +610,40 @@ async function testHubMode() {
const st = await c2.invoke('system.status');
assert.equal(st.status, 'ok', '被控端应仍在线可再次接入');
c2.close();
// 5. v12 并发多控制端:两个控制端**同时**在线,各自都能调用(旧版会 E_NODE_BUSY)
// 等常备槽位就绪(预热到 2 条约 150ms)
await new Promise((r) => setTimeout(r, 600));
const cc1 = mk(HUB_TOKEN, 'hub_node');
const cc2 = mk(HUB_TOKEN, 'hub_node');
const caps1 = await cc1.connect();
const caps2 = await cc2.connect();
assert.ok(caps1.length >= 19 && caps2.length >= 19, '两个控制端都应完成握手');
const [r1, r2] = await Promise.all([
cc1.invoke('system.info'),
cc2.invoke('system.status'),
]);
assert.equal(r1.status, 'ok', '控制端 1 的调用应成功');
assert.equal(r2.status, 'ok', '控制端 2 的调用应成功');
// 交替再调一次,确认两条链路互不干扰
const [r3, r4] = await Promise.all([
cc1.invoke('system.status'),
cc2.invoke('system.info'),
]);
assert.equal(r3.status, 'ok', '控制端 1 第二次调用应成功');
assert.equal(r4.status, 'ok', '控制端 2 第二次调用应成功');
cc1.close();
cc2.close();
// 6. v12 设备级授权:受限令牌访问未授权设备 -> E_NODE_FORBIDDEN
await assert.rejects(
() => mk('limited-token', 'hub_node').connect(),
(e) => {
assert.equal(e.name, 'E_NODE_FORBIDDEN', `期望 E_NODE_FORBIDDEN,实际 ${e.name}`);
assert.match(e.message, /未被授权|白名单/, '错误信息应说明授权原因');
return true;
},
);
} finally {
hubProc.kill();
agentProc.kill();
@@ -608,6 +651,471 @@ async function testHubMode() {
}
}
// ============================================================================
// v7~v13 新增能力的 e2e 覆盖(审查报告 §5.1-3:新能力此前只有手工真机验证)
// ============================================================================
/**
* v7+ GUI 语义契约。
* 不断言「屏幕上一定有什么」(CI 无桌面会话时不可靠),而断言**对外契约**:
* - window.list 结构正确且前台窗口排最前
* - window.focus 目标不存在时是干净错误(不崩溃、不挂起)
* - screen.find 无命中时返回空数组 + engine + waited_ms(而非报错)
* - 参数校验(wait_ms 越界)必须拒绝
*/
/**
* 断言「协议层错误」。
*
* ⚠️ 契约差异(e2e 实测踩到的):
* - **能力层**失败(如输入未开启、窗口未找到)→ 正常返回 `{ status:'failed', error:{name} }`
* - **协议层**失败(参数校验 / 鉴权 / ACL / 限速)→ 让 invoke() **抛 ClientError**(err.name = 错误码)
* 两种都不能少:前者是「调用成功但业务失败」,后者是「请求根本没被受理」。
*/
async function expectProtocolError(fn, expectedName) {
try {
await fn();
} catch (err) {
assert.equal(err.name, expectedName, `期望 ${expectedName},实际 ${err.name}`);
return;
}
assert.fail(`期望抛出 ${expectedName},但调用居然成功了`);
}
async function testGuiSemantics() {
const c = await connect();
try {
const wl = await c.invoke('window.list', { limit: 5 });
if (wl.status === 'ok') {
assert.ok(Array.isArray(wl.data.windows), 'windows 应为数组');
assert.equal(typeof wl.data.total, 'number', 'total 应为数字');
assert.ok(wl.data.windows.length <= 5, 'limit 应生效');
const hasFg = wl.data.windows.some((w) => w.is_foreground);
if (hasFg) assert.ok(wl.data.windows[0].is_foreground, '前台窗口应排在最前(避免被 limit 截掉)');
} else {
// macOS 未授予辅助功能权限时走这里:必须是可执行的提示,而不是笼统失败
assert.ok(
['E_EXECUTION_FAILED', 'E_UNSUPPORTED_PLATFORM'].includes(wl.error.name),
`非预期错误: ${wl.error.name}`,
);
assert.match(wl.error.message, /权限|辅助功能|不支持/);
}
const wf = await c.invoke('window.focus', { title: '绝不存在的窗口_ZZZ9' });
assert.equal(wf.status, 'failed', '目标不存在应失败');
assert.ok(
['E_EXECUTION_FAILED', 'E_UNSUPPORTED_PLATFORM'].includes(wf.error.name),
`非预期错误: ${wf.error.name}`,
);
if (process.platform === 'win32') {
const sf = await c.invoke('screen.find', { text: '绝不存在_ZZZ9', method: 'uia', limit: 3 });
assert.equal(sf.status, 'ok', '无命中不是错误,应返回空结果');
assert.deepEqual(sf.data.matches, [], '无命中时 matches 应为空数组');
assert.equal(sf.data.engine, 'uia', '应如实报告使用的引擎');
assert.equal(typeof sf.data.waited_ms, 'number', '应回报实际等待时长');
}
await expectProtocolError(
() => c.invoke('screen.find', { text: 'x', wait_ms: 999999 }),
'E_PARAM_INVALID',
);
} finally {
c.close();
}
}
/** v10:剪贴板读写 + 后台任务全生命周期(启动→读取→列表→终止)。 */
async function testClipAndTasks() {
const c = await connect();
try {
const marker = `e2e-clip-${Date.now()}`;
const set = await c.invoke('clip.set', { text: marker });
assert.equal(set.status, 'ok');
const got = await c.invoke('clip.get', { format: 'text' });
assert.equal(got.status, 'ok');
assert.equal(String(got.data.text ?? '').trim(), marker, '剪贴板往返应一致');
const bg = await c.invoke('system.shell.exec', {
command: 'echo e2e-async-marker',
async: true,
});
assert.equal(bg.status, 'ok');
assert.ok(bg.data.task_id, '异步执行应返回 task_id');
const taskId = bg.data.task_id;
await new Promise((r) => setTimeout(r, 1500));
const g = await c.invoke('system.task.get', { task_id: taskId });
assert.equal(g.status, 'ok');
assert.match(String(g.data.data ?? ''), /e2e-async-marker/, '应能续读到任务输出');
// 任务终态为 done / killed / failed,运行中为 running
assert.ok(['done', 'running'].includes(g.data.state), `状态应合法: ${g.data.state}`);
const list = await c.invoke('system.task.list', {});
assert.ok(Array.isArray(list.data.tasks), 'tasks 应为数组');
assert.ok(list.data.tasks.some((t) => t.task_id === taskId), '列表中应能看到该任务');
// 终止:起一个长任务再杀掉,并确认状态已终结
const longCmd = process.platform === 'win32' ? 'Start-Sleep 30' : 'sleep 30';
const long = await c.invoke('system.shell.exec', { command: longCmd, async: true });
const longId = long.data.task_id;
await new Promise((r) => setTimeout(r, 1000));
const killed = await c.invoke('system.task.kill', { task_id: longId });
assert.equal(killed.data.killed, true, '运行中的任务应可终止');
const after = await c.invoke('system.task.get', { task_id: longId });
assert.equal(after.data.state, 'killed', '终止后状态应为 killed');
assert.equal(after.data.exit_code, 124, '被强杀的任务退出码应归一为 124');
} finally {
c.close();
}
}
/** v12:事件订阅 —— 推送与拉取两条通路都要通。 */
async function testEventWatch() {
const dir = mkdtempSync(join(tmpdir(), 'na-e2e-watch-'));
const pushed = [];
const c = new NodeAgentClient({
url: URL,
key: KEY,
clientId: 'e2e_mac',
onEvent: (e) => pushed.push(e),
});
try {
await c.connect();
const w = await c.invoke('event.watch', { kind: 'file', path: dir });
assert.equal(w.status, 'ok');
const watchId = w.data.watch_id;
assert.ok(watchId, '应返回 watch_id');
// 制造变动(创建 + 修改)
writeFileSync(join(dir, 'watched.txt'), 'v1');
await new Promise((r) => setTimeout(r, 400));
writeFileSync(join(dir, 'watched.txt'), 'v2');
await new Promise((r) => setTimeout(r, 1200));
assert.ok(pushed.length > 0, '应通过 event 通知收到推送(无需轮询)');
assert.ok(
pushed.some((e) => String(e.target ?? '').includes('watched.txt')),
`推送应包含目标文件名: ${JSON.stringify(pushed.slice(0, 2))}`,
);
const poll = await c.invoke('event.poll', { limit: 20 });
assert.equal(poll.status, 'ok');
assert.ok(poll.data.events.length > 0, '拉取通路也应能读到事件');
assert.equal(typeof poll.data.next_cursor, 'number', '应返回游标供增量拉取');
const firstCursor = poll.data.next_cursor;
const again = await c.invoke('event.poll', { since: firstCursor });
assert.equal(again.data.events.length, 0, '同一游标再拉应无新事件');
const listed = await c.invoke('event.list', {});
assert.ok(
listed.data.watches.some((x) => x.watch_id === watchId),
'订阅列表应包含该订阅',
);
const un = await c.invoke('event.unwatch', { watch_id: watchId });
assert.equal(un.data.removed, true);
const after = await c.invoke('event.list', {});
assert.ok(!after.data.watches.some((x) => x.watch_id === watchId), '取消后不应再出现');
// 参数校验(协议层错误 -> 抛 ClientError)
await expectProtocolError(() => c.invoke('event.watch', { kind: 'disk' }), 'E_PARAM_INVALID');
} finally {
c.close();
rmSync(dir, { recursive: true, force: true });
}
}
/** v12:GUI 宏引擎 —— 用不依赖 GUI 的步骤组合验证回放与断言。 */
async function testMacroReplay() {
const c = await connect();
try {
const ok = await runMacro(
{ client: c },
{
name: 'e2e-macro',
steps: [
{ action: 'exec', command: 'echo macro-step-1', expect_stdout: 'macro-step-1' },
{ action: 'clip', set: 'macro-clip-value' },
{ action: 'clip', expect: 'macro-clip-value' },
{ action: 'sleep', ms: 50 },
{ action: 'exec', command: 'echo macro-step-2', expect_stdout: 'macro-step-2' },
],
},
{ STAMP: 'e2e' },
);
assert.equal(ok.ok, true, `宏应全通过: ${JSON.stringify(ok.steps)}`);
assert.equal(ok.steps.length, 5);
assert.ok(ok.steps.every((s) => s.ok));
// 断言失败必须中止并给出定位
const fail = await runMacro(
{ client: c },
{
steps: [
{ action: 'exec', command: 'echo only-this' },
{ action: 'exec', command: 'echo other', expect_stdout: '不存在的输出' },
{ action: 'exec', command: 'echo never-runs' },
],
},
);
assert.equal(fail.ok, false);
assert.equal(fail.failed_at, 1, '应定位到第 1 步');
assert.equal(fail.steps.length, 2, '失败后不应继续执行');
// optional 步骤失败不中断
const opt = await runMacro(
{ client: c },
{
steps: [
{ action: 'clip', expect: '绝不在剪贴板里的内容', optional: true },
{ action: 'exec', command: 'echo still-runs' },
],
},
);
assert.equal(opt.ok, true, 'optional 失败不应让整体失败');
} finally {
c.close();
}
}
/**
* v21 第一批加固:来源网段白名单 + 证书指纹钉住。
* 自起一个 TLS agent(主 e2e agent 是明文 ws 且无白名单,不适合这两项)。
*/
async function testNetworkSecurity() {
const { spawn } = await import('node:child_process');
const { readFileSync } = await import('node:fs');
const { createConnection } = await import('node:net');
// 自建等待:主 e2e 的 waitReady 只认它那台 agent(硬编码 URL/KEY),这里用不上
const waitPort = async (p, ms) => {
const deadline = Date.now() + ms;
while (Date.now() < deadline) {
const up = await new Promise((res) => {
const sock = createConnection({ host: '127.0.0.1', port: p });
sock.once('connect', () => { sock.destroy(); res(true); });
sock.once('error', () => { sock.destroy(); res(false); });
});
if (up) return;
await new Promise((r) => setTimeout(r, 200));
}
throw new Error(`端口 ${p} 未在超时内就绪`);
};
const dataDir = mkdtempSync(join(tmpdir(), 'nodeagent-sec-'));
const port = PORT + 7;
const key = 'sec-e2e-key-0123456789abcdef0123456789';
writeFileSync(
join(dataDir, 'agent.json'),
JSON.stringify({
node_id: 'sec_win',
host: '127.0.0.1',
port,
tls: true,
key,
log_level: 'info',
// 故意只放行本机 —— 前一步会先改成不放行来验证拦截
allow_from: ['10.0.0.0/8'],
}),
);
const child = spawn(process.execPath, [join(root, 'apps/agent/dist/index.js')], {
env: { ...process.env, NODEAGENT_HOME: dataDir, HOME: dataDir, USERPROFILE: dataDir },
stdio: ['ignore', 'pipe', 'pipe'],
});
child.stderr.on('data', () => {});
const cleanup = () => {
try { child.kill(); } catch { /* 已退出 */ }
};
try {
await waitPort(port, 20_000);
// ① 白名单不含本机 → 连接被拒(E_NODE_OFFLINE 形态:握手被服务端掐断)
await assert.rejects(
() => new NodeAgentClient({ url: `wss://127.0.0.1:${port}`, key, clientId: 'sec', insecure: true }).connect(),
/E_NODE_OFFLINE|E_CONNECT_FAILED|ECONNRESET|ECONNREFUSED|连接已断开/,
'白名单外的来源应被拒绝',
);
// ② 放行本机 → 连上,并拿到证书指纹
const cfgPath = join(dataDir, 'agent.json');
const cfg = JSON.parse(readFileSync(cfgPath, 'utf8'));
cfg.allow_from = ['127.0.0.0/8'];
writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
cleanup();
await new Promise((r) => setTimeout(r, 800));
const child2 = spawn(process.execPath, [join(root, 'apps/agent/dist/index.js')], {
env: { ...process.env, NODEAGENT_HOME: dataDir, HOME: dataDir, USERPROFILE: dataDir },
stdio: ['ignore', 'pipe', 'pipe'],
});
child2.stderr.on('data', () => {});
try {
await waitPort(port, 20_000);
const good = new NodeAgentClient({ url: `wss://127.0.0.1:${port}`, key, clientId: 'sec', insecure: true });
await good.connect();
const fp = good.getPeerCertFingerprint();
assert.ok(fp && /^[0-9a-f]{64}$/.test(fp), '应能观测到 64 位证书指纹');
const info = await good.invoke('system.info', { fields: ['build'] });
assert.equal(info.data.build.cert_sha256, fp, 'agent 自报指纹应与实际握手证书一致');
good.close();
// ③ 钉住错误指纹 → 必须拒绝(E_CERT_MISMATCH)
await assert.rejects(
() =>
new NodeAgentClient({
url: `wss://127.0.0.1:${port}`,
key,
clientId: 'sec',
insecure: true,
certSha256: 'deadbeef'.repeat(8),
}).connect(),
/E_CERT_MISMATCH/,
'指纹不符必须拒绝连接',
);
// ④ 钉住正确指纹 → 放行
const ok = new NodeAgentClient({
url: `wss://127.0.0.1:${port}`,
key,
clientId: 'sec',
insecure: true,
certSha256: fp,
});
await ok.connect();
ok.close();
} finally {
try { child2.kill(); } catch { /* 已退出 */ }
}
} finally {
cleanup();
}
}
/**
* v19:拉取式自更新。
* 用本地 HTTP 服务真跑「下载 + 哈希校验」路径;**一律 dry_run**,绝不改动测试 agent 自身文件。
*/
async function testSelfUpdate() {
const http = await import('node:http');
const { createHash } = await import('node:crypto');
const payload = Buffer.from('// fake agent payload for self-update test\n'.repeat(200));
const good = createHash('sha256').update(payload).digest('hex');
const srv = http.createServer((req, res) => {
if (req.url === '/agent.mjs') {
res.writeHead(200, { 'content-type': 'application/octet-stream', 'content-length': payload.length });
res.end(payload);
} else {
res.writeHead(404);
res.end('nope');
}
});
await new Promise((r) => srv.listen(0, '127.0.0.1', r));
const port = srv.address().port;
const url = `http://127.0.0.1:${port}/agent.mjs`;
const c = await connect();
try {
// ① dry_run + 正确哈希 → 校验通过,且不改动任何文件
const ok = await c.invoke('system.agent.update', { url, sha256: good, dry_run: true });
assert.equal(ok.status, 'ok', `dry-run 应成功: ${JSON.stringify(ok.error)}`);
assert.equal(ok.data.verified, true);
assert.equal(ok.data.bytes, payload.length);
assert.equal(ok.data.incoming_hash, good.slice(0, 12));
assert.equal(ok.data.dry_run, true);
// ② dry_run + 错误哈希 → 必须拒绝(哈希是安全底线)
const bad = await c.invoke('system.agent.update', { url, sha256: 'deadbeefcafe', dry_run: true });
assert.equal(bad.status, 'failed', '哈希不符应拒绝');
assert.match(String(bad.error?.message ?? ''), /哈希校验失败/);
// ③ 404 → 明确报错而非静默
const notFound = await c.invoke('system.agent.update', { url: `http://127.0.0.1:${port}/nope`, sha256: good, dry_run: true });
assert.equal(notFound.status, 'failed');
assert.match(String(notFound.error?.message ?? ''), /HTTP 404/);
// ④ 非 http(s) scheme → 拒绝(能力层)
const scheme = await c.invoke('system.agent.update', { url: 'file:///etc/passwd', sha256: good, dry_run: true });
assert.equal(scheme.status, 'failed');
assert.match(String(scheme.error?.message ?? ''), /只支持 http\/https/);
// ⑤ 缺 sha256 → 协议层拒绝(schema required)
await expectProtocolError(() => c.invoke('system.agent.update', { url }), 'E_PARAM_INVALID');
} finally {
c.close();
srv.close();
}
}
/**
* v17:音频控制。
* 注意:本机(CI/macOS)也会跑这条用例,故**只把音量设为当前值**,
* 走通写入路径但不改变实际音量(避免测试把机器静音了)。
*/
async function testAudio() {
const c = await connect();
try {
const g = await c.invoke('system.audio.get', {});
if (g.status === 'failed') {
// 无音频设备的环境(部分 CI 容器)允许明确报错,但不能是崩溃
assert.ok(
['E_EXECUTION_FAILED', 'E_UNSUPPORTED_PLATFORM'].includes(g.error.name),
`非预期错误: ${g.error.name}`,
);
return;
}
assert.equal(typeof g.data.muted, 'boolean', 'muted 应为布尔');
assert.equal(typeof g.data.volume, 'number', 'volume 应为数字');
assert.ok(g.data.volume >= 0 && g.data.volume <= 100, 'volume 应在 0-100');
// 写回原值:验证写入路径,但不产生可感知变化
const s1 = await c.invoke('system.audio.set', { volume: g.data.volume, mute: g.data.muted });
assert.equal(s1.status, 'ok');
assert.equal(s1.data.volume, g.data.volume, '设置后音量应与原值一致');
assert.equal(s1.data.muted, g.data.muted, '设置后静音态应与原值一致');
assert.ok(s1.data.applied, '应回报本次实际应用的值');
// 空参数:schema 允许(无必填项),由 handler 判定 → **能力层失败**(返回 failed,不抛异常)
const empty = await c.invoke('system.audio.set', {});
assert.equal(empty.status, 'failed', '两者都不给应失败');
assert.equal(empty.error.name, 'E_PARAM_INVALID');
// 越界:schema 有 minimum/maximum → **协议层**拒绝(抛 ClientError)
await expectProtocolError(() => c.invoke('system.audio.set', { volume: 999 }), 'E_PARAM_INVALID');
await expectProtocolError(() => c.invoke('system.audio.set', { volume: -1 }), 'E_PARAM_INVALID');
} finally {
c.close();
}
}
/** v13:成功指标形状与达标判定 + 审计链完整性(防篡改)。 */
async function testMetricsAndAudit() {
const c = await connect();
try {
const m = await c.invoke('system.metrics', {});
assert.equal(m.status, 'ok');
const d = m.data;
for (const k of ['close_loop', 'app_install', 'latency', 'security', 'verdict', 'targets', 'window']) {
assert.ok(k in d, `指标缺少字段 ${k}`);
}
assert.ok(d.close_loop.attempts >= 1, '至少应统计到本次会话的调用');
assert.ok(d.close_loop.success_rate > 0 && d.close_loop.success_rate <= 1);
assert.equal(d.security.interception_rate, 1, '拦截率恒为 100%(拦截由服务端强制)');
assert.equal(typeof d.verdict.all_pass, 'boolean');
// 时延分层:交互层参与判定,慢操作单列
assert.equal(typeof d.latency.fast.p95_ms, 'number');
assert.equal(typeof d.latency.slow.p95_ms, 'number');
assert.ok(Array.isArray(d.latency.slow.capabilities) && d.latency.slow.capabilities.length > 0);
// 装软件无样本时应为 null(而非 0,否则会误判为不达标)
if (d.app_install.attempts === 0) assert.equal(d.app_install.success_rate, null);
const v = await c.invoke('system.audit.verify', {});
assert.equal(v.status, 'ok');
assert.equal(v.data.ok, true, '审计链应完整');
assert.ok(v.data.checked >= 1, '应校验到链条目');
} finally {
c.close();
}
}
async function main() {
console.log('\nnodeagent 端到端测试\n');
const agent = startAgent();
@@ -802,7 +1310,17 @@ async function main() {
await test('v5 文件传输:写入→读回→列表→分块重组→append→白名单', testFileTransfer);
// ---------- v6 Hub 中转 ----------
await test('v6 Hub 中转:注册→配对→端到端握手→多次接入', testHubMode);
await test('v6 Hub 中转:注册→配对→端到端握手→多次接入→v12 并发双控制端', testHubMode);
// ---------- v7~v13 新增能力(含 v14 常驻助手路径)----------
await test('v8 GUI 语义契约:window.list 结构 / focus 干净报错 / find 空命中与参数校验', testGuiSemantics);
await test('v10 剪贴板与后台任务:往返 / 异步执行→续读→列表→终止(exit 124)', testClipAndTasks);
await test('v12 事件订阅:文件监控 → 推送 + 拉取双通路 → 取消', testEventWatch);
await test('v12 GUI 宏引擎:步骤回放 / 断言中止定位 / optional 继续', testMacroReplay);
await test('v13 成功指标与审计链:形状 + 分层时延 + 达标判定 + 链完整', testMetricsAndAudit);
await test('v17 音频控制:读状态 / 写回原值 / 参数校验', testAudio);
await test('v19 拉取式自更新:HTTP 下载 + 哈希校验(dry-run)', testSelfUpdate);
await test('v21 网络安全:网段白名单 + 证书指纹钉住(TOFU 与拒绝)', testNetworkSecurity);
} finally {
agent.kill('SIGTERM');
await new Promise((r) => setTimeout(r, 300));
+131 -2
View File
@@ -1,3 +1,4 @@
import { capabilityDiff } from '../../packages/protocol/dist/index.js';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
@@ -47,11 +48,22 @@ test('清单:v7~v11 新能力均已登记', () => {
'system.audit.verify',
'input.mouse.drag',
'screen.record',
'event.watch',
'event.unwatch',
'event.list',
'event.poll',
'system.metrics',
'system.net.apply',
'system.net.confirm',
'system.net.status',
'system.agent.update',
'system.audio.get',
'system.audio.set',
];
for (const name of expected) {
assert.ok(findCapability(name), `缺少 ${name}`);
}
assert.equal(CAPABILITY_MANIFEST.length, 31, '当前应有 31 项能力');
assert.equal(CAPABILITY_MANIFEST.length, 42, '当前应有 42 项能力');
});
// ---------- 参数校验(新能力) ----------
@@ -103,7 +115,16 @@ test('screen.find:method 枚举受控,非法值被拒', () => {
assert.equal(check('screen.find', { text: '确定' }).length, 0);
assert.equal(check('screen.find', { text: 'x', method: 'ocr' }).length, 0);
assert.ok(check('screen.find', { text: 'x', method: 'magic' }).length > 0, '非法 method 应被拒');
assert.ok(check('screen.find', {}).length > 0, '缺 text 应报错');
});
test('screen.find:v15 图像模板 —— method=image 与 template/threshold', () => {
assert.equal(check('screen.find', { method: 'image', template: 'C:\\icon.png' }).length, 0);
assert.equal(check('screen.find', { text: 'x', method: 'image', threshold: 0.9 }).length, 0);
// text 改为可选(image 模式不需要),但 handler 仍会校验「非 image 模式必须有 text」
assert.equal(check('screen.find', {}).length, 0, 'schema 层允许空对象(由 handler 判定 text/template)');
assert.ok(check('screen.find', { text: 'x', threshold: 0.1 }).length > 0, 'threshold 下限 0.3');
assert.ok(check('screen.find', { text: 'x', threshold: 1.5 }).length > 0, 'threshold 上限 0.999');
assert.ok(check('screen.find', { text: 'x', bogus: 1 }).length > 0, '未知参数应被拒');
});
test('input.key.press:支持 repeat 与 sequence', () => {
@@ -130,6 +151,71 @@ test('system.shell.exec:async 与 wait_forever 为合法布尔', () => {
assert.ok(check('system.shell.exec', { command: 'x', async: 'yes' }).length > 0, '类型错误应被拒');
});
test('event.watch:kind 枚举受控,file 需 path,interval 有上下限', () => {
assert.equal(check('event.watch', { kind: 'file', path: 'C:\\tmp' }).length, 0);
assert.equal(check('event.watch', { kind: 'process', pattern: 'chrome*' }).length, 0);
assert.equal(check('event.watch', { kind: 'net', interval_ms: 2000 }).length, 0);
assert.ok(check('event.watch', { kind: 'disk' }).length > 0, '非法 kind 应被拒');
assert.ok(check('event.watch', { kind: 'net', interval_ms: 100 }).length > 0, 'interval 下限 1s');
assert.ok(check('event.watch', { kind: 'net', interval_ms: 99999 }).length > 0, 'interval 上限 60s');
});
test('event.poll:游标与条数可省略,且不接受未知参数', () => {
assert.equal(check('event.poll', {}).length, 0);
assert.equal(check('event.poll', { since: 12, limit: 100 }).length, 0);
assert.ok(check('event.poll', { limit: 0 }).length > 0, 'limit 下限 1');
assert.ok(check('event.poll', { limit: 999 }).length > 0, 'limit 上限 500');
assert.ok(check('event.poll', { bogus: 1 }).length > 0, '未知参数应被拒');
});
test('system.net.apply:mode 枚举受控,static 需 ip/mask,确认窗有上下限', () => {
assert.equal(check('system.net.apply', { mode: 'dhcp' }).length, 0);
assert.equal(
check('system.net.apply', { mode: 'static', ip: '192.168.1.50', mask: '255.255.255.0', gateway: '192.168.1.1' }).length,
0,
);
assert.equal(check('system.net.apply', { mode: 'command', command: 'x' }).length, 0);
assert.ok(check('system.net.apply', { mode: 'teleport' }).length > 0, '非法 mode 应被拒');
assert.ok(check('system.net.apply', { mode: 'static', ip: '1.2.3.4', mask: '255.255.255.0', confirm_within_ms: 1000 }).length > 0, '确认窗下限 15s');
assert.ok(check('system.net.apply', { mode: 'static', ip: '1.2.3.4', mask: '255.255.255.0', confirm_within_ms: 9999999 }).length > 0, '确认窗上限 10min');
});
test('system.agent.update:url/sha256 必填,超时范围受控', () => {
assert.equal(
check('system.agent.update', { url: 'https://example.com/a.mjs', sha256: 'abcdef012345' }).length,
0,
);
assert.ok(check('system.agent.update', { url: 'https://example.com/a.mjs' }).length > 0, 'sha256 必填(安全底线)');
assert.ok(check('system.agent.update', { sha256: 'abcdef012345' }).length > 0, 'url 必填');
assert.equal(
check('system.agent.update', { url: 'https://x/a.mjs', sha256: 'a'.repeat(64), dry_run: true }).length,
0,
);
assert.ok(
check('system.agent.update', { url: 'https://x/a.mjs', sha256: 'abc', timeout_ms: 100 }).length > 0,
'超时下限 5s',
);
});
test('system.audio.get / set:参数形状与范围', () => {
assert.equal(check('system.audio.get', {}).length, 0);
assert.equal(check('system.audio.set', { mute: true }).length, 0);
assert.equal(check('system.audio.set', { volume: 35 }).length, 0);
assert.equal(check('system.audio.set', { mute: false, volume: 0 }).length, 0);
assert.ok(check('system.audio.set', { volume: 101 }).length > 0, '音量上限 100');
assert.ok(check('system.audio.set', { volume: -1 }).length > 0, '音量下限 0');
assert.ok(check('system.audio.set', { volume: '35' }).length > 0, 'volume 必须是整数');
assert.ok(check('system.audio.set', { mute: 'yes' }).length > 0, 'mute 必须是布尔');
assert.ok(check('system.audio.set', { bogus: 1 }).length > 0, '未知参数应被拒');
});
test('system.net.confirm / status:参数形状', () => {
assert.equal(check('system.net.confirm', {}).length, 0, 'task_name 可省略(取消全部)');
assert.equal(check('system.net.confirm', { task_name: 'nodeagent-netrollback-1' }).length, 0);
assert.equal(check('system.net.status', {}).length, 0);
assert.ok(check('system.net.status', { bogus: 1 }).length > 0, '未知参数应被拒');
});
// ---------- 默认值 ----------
test('applyDefaults:新能力默认值正确填充', () => {
@@ -144,3 +230,46 @@ test('applyDefaults:新能力默认值正确填充', () => {
assert.equal(exec.async, false);
assert.equal(exec.timeout_ms, 30000);
});
// ---------- v20 兼容性:能力差集 ----------
test('capabilityDiff:识别「远端较旧」与「本控制端较旧」', () => {
const local = ['system.info', 'system.audio.get', 'system.agent.update'];
const remoteOld = ['system.info'];
const d1 = capabilityDiff(local, remoteOld);
assert.deepEqual(d1.missingOnRemote, ['system.audio.get', 'system.agent.update'], '远端缺的新能力');
assert.deepEqual(d1.unknownLocally, []);
const remoteNew = ['system.info', 'system.audio.get', 'system.agent.update', 'future.thing'];
const d2 = capabilityDiff(local, remoteNew);
assert.deepEqual(d2.missingOnRemote, [], '远端不缺');
assert.deepEqual(d2.unknownLocally, ['future.thing'], '本控制端不认识的新能力');
const same = capabilityDiff(local, local);
assert.deepEqual(same, { missingOnRemote: [], unknownLocally: [] }, '完全一致应无差异');
});
test('capabilityDiff:真实清单与自身比对应无差异(防手滑改坏清单)', () => {
const names = CAPABILITY_MANIFEST.map((c) => c.name);
const d = capabilityDiff(names, names);
assert.equal(d.missingOnRemote.length, 0);
assert.equal(d.unknownLocally.length, 0);
});
// ---------- v1.5 快捷键扩展:新参数必须通过 schema 校验 ----------
test('input.key.press:v1.5 新参数(hotkey/hotkeys/preset/hold_ms/route)通过校验', () => {
assert.equal(check('input.key.press', { hotkey: 'ctrl+shift+esc' }).length, 0);
assert.equal(check('input.key.press', { hotkeys: ['ctrl+c', 'ctrl+v'] }).length, 0);
assert.equal(check('input.key.press', { preset: 'copy' }).length, 0);
assert.equal(check('input.key.press', { presets: ['copy', 'paste'] }).length, 0);
assert.equal(check('input.key.press', { hotkey: 'win+d', hold_ms: 300 }).length, 0);
assert.equal(check('input.key.press', { hotkey: 'alt+tab', route: 'post', target_pid: 4242 }).length, 0);
// 老形式仍应通过(向后兼容)
assert.equal(check('input.key.press', { keys: ['ctrl', 'c'] }).length, 0);
assert.equal(check('input.key.press', { sequence: [['up'], ['enter']] }).length, 0);
});
test('input.key.press:非法 route / 缺 target_pid 语义由 agent 层拒(schema 层只卡类型)', () => {
// route 枚举外值必须在 schema 层就被拒
assert.ok(check('input.key.press', { hotkey: 'a', route: 'sideways' }).length > 0, 'route 枚举外值应被拒');
});
+41
View File
@@ -0,0 +1,41 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { computeHmac, verifyHmac } from '../../packages/protocol/dist/index.js';
/**
* v22:发现广播的详情签名。
* 被控端与控制端必须**逐字节同构**,否则「🔒 已认证」永远不亮 —— 故这里做往返一致性测试。
*/
const detail = {
host: '192.168.1.100',
port: 8765,
tls: true,
auth_mode: 'psk',
input_enabled: false,
cert_sha256: null,
platform: 'win32',
};
const nodeId = 'win_01';
const ts = 1790000000000;
const SECRET = 'S3CRET-DISCOVERY-KEY';
test('详情签名:同一输入两端算出同一值并验签通过', () => {
const payload = `${nodeId}|${ts}|${JSON.stringify(detail)}`;
const sig = computeHmac(SECRET, payload);
assert.ok(verifyHmac(SECRET, payload, sig), '控制端应验签通过');
});
test('详情签名:错误密钥 / 篡改详情 / 改时间戳 都必须验签失败', () => {
const payload = `${nodeId}|${ts}|${JSON.stringify(detail)}`;
const sig = computeHmac(SECRET, payload);
assert.ok(!verifyHmac('wrong-key', payload, sig), '错误密钥应失败');
const tampered = `${nodeId}|${ts}|${JSON.stringify({ ...detail, port: 9999 })}`;
assert.ok(!verifyHmac(SECRET, tampered, sig), '篡改详情应失败');
assert.ok(!verifyHmac(SECRET, `${nodeId}|${ts + 1}|${JSON.stringify(detail)}`, sig), '改时间戳应失败');
});
test('详情签名:字段顺序变化会改变签名(提醒:两侧都按对象字面量顺序拼)', () => {
const a = computeHmac(SECRET, JSON.stringify({ x: 1, y: 2 }));
const b = computeHmac(SECRET, JSON.stringify({ y: 2, x: 1 }));
assert.notEqual(a, b);
});
+59
View File
@@ -0,0 +1,59 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { resolveNodeSelector } from '../../packages/client/dist/index.js';
const cfg = {
client_id: 'mac_01',
current: 'win_a',
nodes: {
win_a: { host: '10.0.0.1', port: 8765 },
win_b: { host: '10.0.0.2', port: 8765 },
mac_b: { host: '10.0.0.3', port: 8765 },
},
groups: {
办公: ['win_a', 'win_b'],
全部: ['@办公', 'mac_b'],
},
};
test('分组:@组名 展开为设备列表', () => {
const r = resolveNodeSelector(cfg, ['@办公']);
assert.deepEqual(r.nodes, ['win_a', 'win_b']);
assert.deepEqual(r.resolvedGroups['办公'], ['win_a', 'win_b']);
});
test('分组:支持嵌套引用(@全部 含 @办公)', () => {
const r = resolveNodeSelector(cfg, ['@全部']);
assert.deepEqual(r.nodes, ['win_a', 'win_b', 'mac_b']);
});
test('分组:与设备名混用且去重', () => {
const r = resolveNodeSelector(cfg, ['win_b', '@办公']);
assert.deepEqual(r.nodes, ['win_b', 'win_a'], '先出现的先保留,重复项剔除');
});
test('分组:未知组展开为空(由调用方报错),未知设备名原样保留', () => {
const r = resolveNodeSelector(cfg, ['@不存在']);
assert.deepEqual(r.nodes, []);
assert.deepEqual(r.resolvedGroups['不存在'], []);
const r2 = resolveNodeSelector(cfg, ['ghost']);
assert.deepEqual(r2.nodes, ['ghost']);
});
test('分组:无 groups 字段时退化为原样返回(向后兼容旧配置)', () => {
const legacy = { client_id: 'c', current: 'win_a', nodes: { win_a: { host: 'h', port: 1 } } };
const r = resolveNodeSelector(legacy, ['win_a']);
assert.deepEqual(r.nodes, ['win_a']);
});
test('分组:环形引用不无限递归(深度上限 3)', () => {
const cyc = {
client_id: 'c',
current: 'a',
nodes: { a: { host: 'h', port: 1 } },
groups: { X: ['@Y'], Y: ['@X', 'a'] },
};
const r = resolveNodeSelector(cyc, ['@X']);
assert.ok(r.nodes.includes('a'), '应能收敛到真实设备');
assert.ok(r.nodes.length < 50, '不应因环引用爆炸');
});
+145
View File
@@ -0,0 +1,145 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import {
parseHotkey,
normalizeKey,
expandChords,
expandPreset,
HOTKEY_PRESETS,
HOTKEY_KEYS,
NUMPAD_KEYS,
} from '../../packages/protocol/dist/index.js';
const ROOT = join(import.meta.dirname, '..', '..');
// ---------- parseHotkey ----------
test('parseHotkey:基本分隔符与大小写', () => {
assert.deepEqual(parseHotkey('ctrl+shift+esc'), ['ctrl', 'shift', 'esc']);
assert.deepEqual(parseHotkey('Ctrl+Shift+Esc'), ['ctrl', 'shift', 'esc']);
assert.deepEqual(parseHotkey('win - d'), ['win', 'd'], '分隔符支持 - 与空格');
assert.deepEqual(parseHotkey('alt + tab'), ['alt', 'tab']);
assert.deepEqual(parseHotkey('ctrl c'), ['ctrl', 'c'], '空格也可作分隔符');
});
test('parseHotkey:别名归一', () => {
assert.deepEqual(parseHotkey('command+space'), ['win', 'space']);
assert.deepEqual(parseHotkey('control+a'), ['ctrl', 'a']);
assert.deepEqual(parseHotkey('del'), ['delete']);
assert.deepEqual(parseHotkey('pgup'), ['pageup']);
assert.deepEqual(parseHotkey('numpad_enter+c'), ['enter', 'c']);
});
test('parseHotkey:符号键(快捷键覆盖的关键增量)', () => {
assert.deepEqual(parseHotkey('win+d'), ['win', 'd']);
assert.deepEqual(parseHotkey('win+oem_comma'), ['win', 'oem_comma']);
assert.deepEqual(parseHotkey('ctrl+oem_plus'), ['ctrl', 'oem_plus']);
assert.deepEqual(parseHotkey('ctrl+oem_minus'), ['ctrl', 'oem_minus']);
assert.deepEqual(parseHotkey('ctrl+0'), ['ctrl', '0']);
});
test('parseHotkey:小键盘与左右修饰键', () => {
assert.deepEqual(parseHotkey('numpad5'), ['numpad5']);
assert.deepEqual(parseHotkey('lalt+tab'), ['lalt', 'tab']);
assert.deepEqual(parseHotkey('rwin+d'), ['rwin', 'd']);
});
test('parseHotkey:非法输入必须报错(不静默吞掉)', () => {
assert.throws(() => parseHotkey(''), /空热键|不支持/);
assert.throws(() => parseHotkey('ctrl+nope'), /不支持的按键: nope/);
assert.throws(() => parseHotkey('a+b+c+d+e'), /最多 4 个键/, '单和弦超限必须拒');
});
// ---------- expandChords ----------
test('expandChords:hotkey + repeat 连按', () => {
const r = expandChords({ hotkey: 'up', repeat: 3 });
assert.deepEqual(r.chords, [['up'], ['up'], ['up']]);
assert.equal(r.via, 'hotkey');
});
test('expandChords:hotkeys 序列与 presets 序列', () => {
assert.deepEqual(expandChords({ hotkeys: ['ctrl+c', 'ctrl+v'] }).chords, [['ctrl', 'c'], ['ctrl', 'v']]);
const p = expandChords({ presets: ['copy', 'paste'] });
assert.deepEqual(p.chords, [['ctrl', 'c'], ['ctrl', 'v']]);
assert.deepEqual(p.presets, ['copy', 'paste']);
assert.equal(p.via, 'presets');
});
test('expandChords:老形式 keys/sequence 仍然可用(向后兼容)', () => {
assert.deepEqual(expandChords({ keys: ['ctrl', 'shift', 'esc'] }).chords, [['ctrl', 'shift', 'esc']]);
assert.deepEqual(expandChords({ sequence: [['up'], ['enter']] }).chords, [['up'], ['enter']]);
assert.equal(expandChords({ keys: ['ctrl'], repeat: 2 }).chords.length, 2);
});
test('expandChords:参数混用必须报错(消除"以谁为准"歧义)', () => {
assert.throws(() => expandChords({ hotkey: 'ctrl+a', keys: ['ctrl', 'a'] }), /混用/);
assert.throws(() => expandChords({}), /需提供/);
});
test('expandChords:同一和弦重复修饰键必须报错', () => {
assert.throws(() => expandChords({ keys: ['ctrl', 'ctrl', 'a'] }), /重复的修饰键/);
});
// ---------- 预设表 ----------
test('HOTKEY_PRESETS:每个预设都可展开且合法', () => {
for (const [name, spec] of Object.entries(HOTKEY_PRESETS)) {
const chord = parseHotkey(spec);
assert.ok(chord.length >= 1 && chord.length <= 4, `${name} → ${spec} 键数越界`);
assert.deepEqual(expandPreset(name), chord, `${name} 展开不一致`);
}
});
test('HOTKEY_PRESETS:媒体类预设映射到媒体键(可走 APPCOMMAND 通道)', () => {
assert.deepEqual(expandPreset('music_play_pause'), ['media_play_pause']);
assert.deepEqual(expandPreset('volume_mute_toggle'), ['volume_mute']);
assert.deepEqual(expandPreset('volume_add'), ['volume_up']);
});
test('HOTKEY_PRESETS:符号键类预设可展开(证明 oem 链路可用)', () => {
assert.deepEqual(expandPreset('show_desktop'), ['win', 'd']);
assert.deepEqual(expandPreset('emoji_picker'), ['win', 'oem_period']);
assert.deepEqual(expandPreset('zoom_in'), ['ctrl', 'oem_plus']);
});
test('expandPreset:未知预设必须报错', () => {
assert.throws(() => expandPreset('nope'), /未知预设/);
});
// ---------- 覆盖断言:protocol 声明的键 agent 必须能映射 ----------
test('覆盖断言:HOTKEY_KEYS + NUMPAD_KEYS 全部存在于 agent 侧 VK_MAP', () => {
const src = readFileSync(join(ROOT, 'apps/agent/src/capabilities/input-script.ts'), 'utf8');
// 抓 VK_MAP 对象体的键名(到 "媒体键名 → APPCOMMAND" 注释前为止)
const m = /const VK_MAP: Record<string, number> = \{([\s\S]*?)\n\};/.exec(src);
assert.ok(m, '未找到 VK_MAP 定义');
const declared = new Set(
[...String(m[1]).matchAll(/(?:^|[\s,])([a-z_][a-z0-9_]*)\s*:/gm)].map((x) => String(x[1])),
);
const missing = [];
for (const k of HOTKEY_KEYS) if (!declared.has(k)) missing.push(k);
for (const k of NUMPAD_KEYS) if (!declared.has(k)) missing.push(`numpad:${k}`);
assert.deepEqual(missing, [], `protocol 声明的按键在 agent 侧未映射: ${missing.join(', ')}`);
});
test('覆盖断言:每个预设只用已知键(防止表与实现漂移)', () => {
const known = new Set([...HOTKEY_KEYS, ...NUMPAD_KEYS]);
const bad = [];
for (const [name, spec] of Object.entries(HOTKEY_PRESETS)) {
for (const k of parseHotkey(spec)) {
if (!known.has(k) && !/^[a-z0-9]$/.test(k)) bad.push(`${name}→${k}`);
}
}
assert.deepEqual(bad, []);
});
test('normalizeKey:单键归一化', () => {
assert.equal(normalizeKey('CTRL'), 'ctrl');
assert.equal(normalizeKey(' command '), 'win');
assert.equal(normalizeKey('F12'), 'f12');
assert.equal(normalizeKey('a'), 'a');
assert.throws(() => normalizeKey(''), /空按键名/);
});
+101
View File
@@ -0,0 +1,101 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
nodeAllowed,
nodeMatches,
nodeMayRegister,
resolveToken,
visibleNodes,
} from '../../apps/hub/dist/authz.js';
const cfg = {
node_id: 'hub_01',
host: '0.0.0.0',
port: 9443,
token: 'master-token',
log_level: 'warn',
tokens: [
{ value: 'owner-token', name: 'owner', allow_nodes: ['*'] },
{ value: 'ci-token', name: 'ci', allow_nodes: ['win_build_*'] },
{ value: 'guest-token', name: 'guest', allow_nodes: ['*'], deny_nodes: ['win_prod'] },
{ value: 'roll-*', name: 'rolling', allow_nodes: ['win_a'] },
],
node_allowlist: ['win_*', 'mac_a'],
};
test('令牌:顶层 token 视为主令牌(放行全部,向后兼容)', () => {
const t = resolveToken(cfg, 'master-token');
assert.ok(t);
assert.equal(t.isMaster, true);
assert.equal(nodeAllowed(t, '任意设备').allowed, true);
});
test('令牌:未知令牌解析失败', () => {
assert.equal(resolveToken(cfg, 'nope'), null);
assert.equal(resolveToken(cfg, undefined), null);
assert.equal(resolveToken(cfg, ''), null);
});
test('令牌:前缀式滚动令牌按前缀匹配', () => {
const t = resolveToken(cfg, 'roll-2026-09');
assert.ok(t);
assert.equal(t.name, 'rolling');
assert.equal(nodeAllowed(t, 'win_a').allowed, true);
assert.equal(nodeAllowed(t, 'win_b').allowed, false, '白名单外应拒绝');
});
test('设备授权:deny 优先于 allow', () => {
const guest = resolveToken(cfg, 'guest-token');
assert.equal(nodeAllowed(guest, 'win_prod').allowed, false, '黑名单优先');
assert.equal(nodeAllowed(guest, 'win_other').allowed, true);
});
test('设备授权:glob 白名单 + 默认拒绝', () => {
const ci = resolveToken(cfg, 'ci-token');
assert.equal(nodeAllowed(ci, 'win_build_01').allowed, true);
assert.equal(nodeAllowed(ci, 'win_build_99').allowed, true);
assert.equal(nodeAllowed(ci, 'win_prod').allowed, false);
const r = nodeAllowed(ci, 'win_prod');
assert.match(r.reason, /未被授权/);
});
test('设备授权:未配置 allow_nodes 时默认放行全部', () => {
const c2 = { ...cfg, tokens: [{ value: 'plain', name: 'plain' }] };
const t = resolveToken(c2, 'plain');
assert.equal(nodeAllowed(t, 'any_node').allowed, true);
});
test('注册准入:node_allowlist 非空时按 glob 过滤', () => {
assert.equal(nodeMayRegister(cfg, 'win_x').allowed, true);
assert.equal(nodeMayRegister(cfg, 'mac_a').allowed, true);
assert.equal(nodeMayRegister(cfg, 'mac_b').allowed, false);
assert.equal(nodeMayRegister(cfg, 'attacker_node').allowed, false);
});
test('注册准入:未配置白名单时放行(兼容旧配置)', () => {
const legacy = { ...cfg, node_allowlist: undefined };
assert.equal(nodeMayRegister(legacy, 'anything').allowed, true);
});
test('列表可见性:只展示被授权的设备', () => {
const ci = resolveToken(cfg, 'ci-token');
const all = [
{ node_id: 'win_build_01' },
{ node_id: 'win_prod' },
{ node_id: 'win_build_02' },
];
assert.deepEqual(
visibleNodes(ci, all).map((x) => x.node_id),
['win_build_01', 'win_build_02'],
);
});
test('nodeMatches:glob 语义与转义(点号不被当通配)', () => {
assert.equal(nodeMatches('*', 'x'), true);
assert.equal(nodeMatches('win*', 'win_a'), true);
assert.equal(nodeMatches('win_a', 'win_a'), true);
assert.equal(nodeMatches('win_a', 'win_b'), false);
// '.' 应被转义为字面量
assert.equal(nodeMatches('a.b', 'axb'), false);
assert.equal(nodeMatches('a.b', 'a.b'), true);
});
+127
View File
@@ -0,0 +1,127 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
VK_MAP,
MEDIA_COMMANDS,
toVk,
isAllMedia,
buildMediaBody,
buildForegroundBody,
buildPostBody,
} from '../../apps/agent/dist/capabilities/input-script.js';
const OPTS = { gapMs: 40, holdMs: 0, targetPid: 0, route: 'foreground' };
// ---------- toVk / 按键映射 ----------
test('toVk:修饰键与扩展按键', () => {
assert.equal(toVk('ctrl'), 0x11);
assert.equal(toVk(' lalt '), 0xa4);
assert.equal(toVk('rctrl'), 0xa3);
assert.equal(toVk('rwin'), 0x5c);
assert.equal(toVk('d'), 0x44, '字母走 ASCII 大写映射');
assert.equal(toVk('7'), 0x37);
assert.equal(toVk('numpad7'), 0x67, 'numpad7 与 7 必须不同 VK');
assert.notEqual(toVk('numpad7'), toVk('7'));
assert.equal(toVk('numpad_add'), 0x6b);
assert.equal(toVk('oem_plus'), 0xbb);
assert.equal(toVk('oem_comma'), 0xbc);
assert.equal(toVk('oem_period'), 0xbe);
assert.equal(toVk('f13'), 0x7c);
assert.equal(toVk('convert'), 0x1c);
assert.equal(toVk('sleep'), 0x5f);
});
test('toVk:未知键必须抛错', () => {
assert.throws(() => toVk('nope'), /不支持的按键/);
});
// ---------- 前台注入脚本 ----------
test('buildForegroundBody:按下顺序 + 释放必须逆序(组合键生死线)', () => {
const body = buildForegroundBody([['ctrl', 'c']], OPTS);
assert.deepEqual(body, ['[NAInput]::Vk(17, $false)', '[NAInput]::Vk(67, $false)', '[NAInput]::Vk(67, $true)', '[NAInput]::Vk(17, $true)']);
// 断言语义:第一个 down 是修饰键,第一个 up 是主键(即释放逆序)
assert.ok(body[0].includes('Vk(17'), '按顺序按下:ctrl 先');
assert.ok(body[2].includes('Vk(67'),'逆序释放:主键先起');
});
test('buildForegroundBody:hold_ms 插在"按完"与"释放"之间', () => {
const body = buildForegroundBody([['ctrl', 'a']], { ...OPTS, holdMs: 500 });
assert.ok(body.includes('Start-Sleep -Milliseconds 500'));
const sleepAt = body.indexOf('Start-Sleep -Milliseconds 500');
assert.ok(body[sleepAt - 1].includes('$false'), '长按前必须是按下动作');
assert.ok(body[sleepAt + 1].includes('$true'), '长按后必须是释放动作');
});
test('buildForegroundBody:多和弦之间插 interval', () => {
const body = buildForegroundBody([['ctrl', 'c'], ['ctrl', 'v']], { ...OPTS, gapMs: 100 });
assert.ok(body.includes('Start-Sleep -Milliseconds 100'));
// 间隔只出现在两个和弦之间(不应出现在和弦内部,hold=0 时)
assert.equal(body.filter((l) => l.includes('-Milliseconds 100')).length, 1);
});
test('buildForegroundBody:hold=0 时不产生多余 sleep', () => {
const body = buildForegroundBody([['ctrl', 'c']], { ...OPTS, holdMs: 0 });
assert.equal(body.filter((l) => l.includes('Start-Sleep')).length, 0);
});
// ---------- 媒体键脚本 ----------
test('buildMediaBody:使用 APPCOMMAND 常量而非 VK', () => {
const body = buildMediaBody([['media_next'], ['media_play_pause']], { ...OPTS, gapMs: 0 });
assert.deepEqual(body, ['[NAInput]::MediaCommand(7, 0)', '[NAInput]::MediaCommand(12, 0)']);
});
test('buildMediaBody:target_pid 透传(定向该进程全窗口)', () => {
const body = buildMediaBody([['media_next']], { ...OPTS, targetPid: 4242 });
assert.deepEqual(body, ['[NAInput]::MediaCommand(7, 4242)']);
});
test('isAllMedia:混入普通键则整条不走 APPCOMMAND', () => {
assert.ok(isAllMedia([['media_next'], ['media_stop']]));
assert.ok(!isAllMedia([['media_next'], ['enter']]), '混入 enter 必须回退 VK 注入');
assert.ok(!isAllMedia([['ctrl', 'c']]));
assert.ok(!isAllMedia([]), '空序列不算全媒体(避免空走媒体通道)');
});
test('音量键不在 MEDIA_COMMANDS 表(避免 APPCOMMAND device 位歧义)', () => {
assert.ok(!('volume_mute' in MEDIA_COMMANDS));
assert.ok(!('volume_up' in MEDIA_COMMANDS));
assert.ok(!('volume_down' in MEDIA_COMMANDS));
});
// ---------- 后台 PostMessage 脚本 ----------
test('buildPostBody:按下顺序 + 释放逆序,且带 PID', () => {
const body = buildPostBody([['ctrl', 'c']], { gapMs: 0, holdMs: 0, targetPid: 777, route: 'post' });
assert.deepEqual(body, [
'[NAInput]::PostChord(@(17, 67), 777, $false)',
'[NAInput]::PostChord(@(67, 17), 777, $true)',
]);
});
test('buildPostBody:hold 与 interval 与前台语义一致', () => {
const body = buildPostBody([['alt', 'tab'], ['esc']], { gapMs: 50, holdMs: 300, targetPid: 9, route: 'post' });
assert.deepEqual(body, [
'[NAInput]::PostChord(@(18, 9), 9, $false)',
'Start-Sleep -Milliseconds 300',
'[NAInput]::PostChord(@(9, 18), 9, $true)',
'Start-Sleep -Milliseconds 50',
'[NAInput]::PostChord(@(27), 9, $false)',
'Start-Sleep -Milliseconds 300',
'[NAInput]::PostChord(@(27), 9, $true)',
]);
});
// ---------- 回归:real-world 快捷键展开后的脚本 ----------
test('端到端脚本样例:win+d 显示桌面(OEM 符号键链路)', () => {
const body = buildForegroundBody([['win', 'd']], OPTS);
assert.deepEqual(body, ['[NAInput]::Vk(91, $false)', '[NAInput]::Vk(68, $false)', '[NAInput]::Vk(68, $true)', '[NAInput]::Vk(91, $true)']);
});
test('VK_MAP 与 MEDIA_COMMANDS 存在且非空(防误清空)', () => {
assert.ok(Object.keys(VK_MAP).length > 60, '按键表应已覆盖扩展键');
assert.ok(Object.keys(MEDIA_COMMANDS).length >= 10);
});
+162
View File
@@ -0,0 +1,162 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { runMacro, loadMacroFile } from '../../packages/client/dist/index.js';
/**
* 用一个「假客户端」驱动宏引擎 —— 不需要真实设备,只验证引擎自身的逻辑:
* 变量替换、重试、可选步骤、失败中止、返回值透传。
*/
function fakeClient(handlers = {}) {
const calls = [];
return {
calls,
async invoke(capability, args) {
calls.push({ capability, args });
const h = handlers[capability];
if (typeof h === 'function') {
const r = h(args);
if (r && r.__fail) return { status: 'failed', error: { name: r.name, message: r.message } };
return { status: 'ok', data: r };
}
return { status: 'ok', data: {} };
},
};
}
test('宏:变量替换(含默认值与数组内替换)', async () => {
const c = fakeClient({
'input.key.type': (args) => ({ typed: true, text: args.text }),
});
const res = await runMacro(
{ client: c },
{
name: 'vars',
steps: [
{ action: 'type', text: '你好 ${NAME}' },
{ action: 'type', text: '缺省 ${MISSING:-兜底}' },
],
},
{ NAME: '世界' },
);
assert.equal(res.ok, true);
assert.deepEqual(c.calls[0].args.text, '你好 世界');
assert.deepEqual(c.calls[1].args.text, '缺省 兜底');
});
test('宏:变量缺失且无默认值时该步失败', async () => {
const c = fakeClient({ 'input.key.type': () => ({ typed: true }) });
const res = await runMacro({ client: c }, { steps: [{ action: 'type', text: '${NOPE}' }] }, {});
assert.equal(res.ok, false);
assert.match(res.steps[0].detail, /变量未提供/);
});
test('宏:retry 生效(前两次失败、第三次成功)', async () => {
let n = 0;
const c = fakeClient({
'window.focus': () => {
n += 1;
return n < 3 ? { __fail: true, name: 'E_NOT_FOUND', message: '窗口未出现' } : { focused: true, title: 'X', x: 0, y: 0, width: 10, height: 10 };
},
});
const res = await runMacro(
{ client: c },
{ steps: [{ action: 'focus', title: 'X', retry: 3, interval_ms: 1 }] },
);
assert.equal(res.ok, true);
assert.equal(n, 3, '应重试到第 3 次成功');
});
test('宏:optional 步骤失败不中断后续', async () => {
const c = fakeClient({
'window.focus': () => ({ __fail: true, name: 'E_NOT_FOUND', message: '没有' }),
'exec': () => ({ exit_code: 0 }),
});
const res = await runMacro(
{ client: c },
{
steps: [
{ action: 'focus', title: '无关窗口', optional: true },
{ action: 'exec', command: 'echo ok' },
],
},
);
assert.equal(res.ok, true, '整体应成功');
assert.equal(res.steps[0].ok, false, '可选步骤标记为失败');
assert.equal(res.steps[1].ok, true, '后续步骤仍执行');
});
test('宏:必需步骤失败即中止,并给出失败位置', async () => {
const c = fakeClient({
'exec': (args) => (String(args.command).includes('bad') ? { exit_code: 0, stdout: 'no' } : { exit_code: 0, stdout: 'yes' }),
});
const res = await runMacro(
{ client: c },
{
steps: [
{ action: 'exec', command: 'echo good' },
{ action: 'exec', command: 'echo bad', expect_stdout: '期望出现' },
{ action: 'exec', command: 'echo never' },
],
},
);
assert.equal(res.ok, false);
assert.equal(res.failed_at, 1, '应在第 1 步失败');
assert.equal(res.steps.length, 2, '失败后不再执行第 2 步');
assert.match(res.steps[1].detail, /未包含/);
assert.equal(c.calls.length, 2, '后续命令不应被下发');
});
test('宏:exec 的 expect_exit 断言生效', async () => {
const c = fakeClient({ 'exec': () => ({ exit_code: 1 }) });
const res = await runMacro({ client: c }, { steps: [{ action: 'exec', command: 'x', expect_exit: 0 }] });
assert.equal(res.ok, false);
assert.match(res.steps[0].detail, /退出码不符/);
});
test('宏:clip 断言与 assert(kind=clip) 复用剪贴板内容', async () => {
const c = fakeClient({ 'clip.get': () => ({ text: '包含关键字的内容' }) });
const res = await runMacro(
{ client: c },
{
steps: [
{ action: 'clip', expect: '关键字' },
{ action: 'assert', kind: 'clip', text: '不存在的词', optional: true },
],
},
);
assert.equal(res.steps[0].ok, true);
assert.equal(res.steps[1].ok, false, '不含该词应失败(但 optional 不中止)');
assert.equal(res.ok, true);
});
test('宏:未知步骤类型被明确拒绝', async () => {
const c = fakeClient({});
const res = await runMacro({ client: c }, { steps: [{ action: 'teleport' }] });
assert.equal(res.ok, false);
assert.match(res.steps[0].detail, /不支持的步骤/);
});
test('宏:loadMacroFile 拒绝缺少 steps 的文件', () => {
const dir = mkdtempSync(join(tmpdir(), 'na-macro-'));
const bad = join(dir, 'bad.json');
writeFileSync(bad, JSON.stringify({ name: 'x' }));
assert.throws(() => loadMacroFile(bad), /steps/);
rmSync(dir, { recursive: true, force: true });
});
test('宏:find 步骤命中后按需点击(坐标来自引擎返回值)', async () => {
const c = fakeClient({
'screen.find': () => ({ engine: 'uia', matches: [{ name: '保存', x: 123, y: 456 }] }),
'input.mouse.click': (args) => ({ clicked: true, x: args.x, y: args.y }),
});
const res = await runMacro(
{ client: c },
{ steps: [{ action: 'find', text: '保存', click: true }] },
);
assert.equal(res.ok, true);
const click = c.calls.find((x) => x.capability === 'input.mouse.click');
assert.deepEqual({ x: click.args.x, y: click.args.y }, { x: 123, y: 456 });
});
+163
View File
@@ -0,0 +1,163 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { computeMetrics, formatMetrics, DEFAULT_TARGETS } from '../../apps/agent/dist/metrics.js';
const inv = (capability, status, duration_ms, extra = {}) => ({
ts: 1_700_000_000_000 + duration_ms,
type: 'invoke',
capability,
status,
duration_ms,
...extra,
});
test('指标:空样本不炸,且不误判为不达标', () => {
const m = computeMetrics([]);
assert.equal(m.close_loop.attempts, 0);
assert.equal(m.close_loop.success_rate, 0);
assert.equal(m.app_install.success_rate, null, '无样本应为 null 而非 0');
assert.equal(m.latency.p95_ms, 0);
assert.equal(m.verdict.close_loop_ok, true, '无样本视为达标(无可判定)');
assert.equal(m.verdict.all_pass, true);
});
test('指标:闭环成功率按 invoke 的 status 统计', () => {
const entries = [
inv('system.info', 'ok', 100),
inv('system.info', 'ok', 200),
inv('system.status', 'ok', 300),
inv('system.shell.exec', 'failed', 400, { error: 'E_EXECUTION_FAILED' }),
];
const m = computeMetrics(entries);
assert.equal(m.close_loop.attempts, 4);
assert.equal(m.close_loop.ok, 3);
assert.equal(m.close_loop.failed, 1);
assert.equal(m.close_loop.success_rate, 0.75);
assert.equal(m.verdict.close_loop_ok, false, '75% < 95% 应不达标');
});
test('指标:装软件成功率只看 app.install,边界 90% 判定', () => {
const nine = Array.from({ length: 9 }, () => inv('app.install', 'ok', 1000));
const oneBad = [inv('app.install', 'failed', 1000)];
const m = computeMetrics([...nine, ...oneBad, inv('system.info', 'ok', 50)]);
assert.equal(m.app_install.attempts, 10);
assert.equal(m.app_install.ok, 9);
assert.equal(m.app_install.success_rate, 0.9);
assert.equal(m.verdict.app_install_ok, true, '恰好 90% 应达标(>=)');
const m2 = computeMetrics([...nine.slice(0, 8), ...oneBad, inv('app.install', 'failed', 1000)]);
assert.equal(m2.verdict.app_install_ok, false, '80% 应不达标');
});
test('指标:P95 分位(只在成功样本上算),失败不计入时延', () => {
// 100 个样本:99 个 100ms + 1 个 5000ms -> P95 应为 100ms
const entries = [
...Array.from({ length: 99 }, () => inv('system.info', 'ok', 100)),
inv('system.info', 'ok', 5000),
];
const m = computeMetrics(entries);
assert.equal(m.latency.p95_ms, 100, 'P95 落在第 95 个样本上');
assert.equal(m.latency.max_ms, 5000);
assert.equal(m.verdict.p95_ok, true);
});
test('指标:P95 分层 —— 慢操作(exec/重启/装软件/录屏)不拖累达标判定', () => {
// 全部是 shell.exec(慢能力):交互层无样本 -> 视为达标,但慢操作层如实记录
const slowOnly = Array.from({ length: 100 }, () => inv('system.shell.exec', 'ok', 4000));
const m1 = computeMetrics(slowOnly);
assert.equal(m1.latency.p95_ms, 4000, '总体 P95 如实反映');
assert.equal(m1.latency.fast.samples, 0);
assert.equal(m1.latency.slow.samples, 100);
assert.equal(m1.verdict.p95_ok, true, '慢能力不计入交互层判定');
});
test('指标:交互层 P95 超目标才判不达标(真机口径)', () => {
// 快能力本身慢 -> 不达标
const slowFast = Array.from({ length: 100 }, () => inv('system.info', 'ok', 4000));
assert.equal(computeMetrics(slowFast).verdict.p95_ok, false, '交互层 4000ms > 3000ms 应不达标');
// 真机场景复现:8 个样本里 1 次重启 9.6s,其余都是快操作 -> 应达标
const realWorld = [
inv('system.agent.restart', 'ok', 9575),
inv('system.info', 'ok', 783),
inv('system.metrics', 'ok', 14),
inv('system.audit.list', 'ok', 4),
inv('fs.write', 'ok', 8),
inv('fs.read', 'ok', 3),
];
const m = computeMetrics(realWorld);
assert.equal(m.latency.p95_ms, 9575, '总体 P95 被慢操作顶穿(如实)');
assert.equal(m.latency.fast.samples, 5, '交互层 5 个样本');
assert.equal(m.verdict.p95_ok, true, '剔除重启后应达标 —— 这正是修正口径的目的');
});
test('指标:安全拦截计数(ACL / 限速 / 认证失败)且拦截率恒为 100%', () => {
const entries = [
{ ts: 1, type: 'acl.denied', capability: 'fs.write' },
{ ts: 2, type: 'acl.denied', capability: 'input.key.press' },
{ ts: 3, type: 'rate.limited', capability: 'system.shell.exec' },
{ ts: 4, type: 'auth.failure', reason: 'psk 校验失败' },
inv('system.info', 'ok', 80),
];
const m = computeMetrics(entries);
assert.equal(m.security.acl_denied, 2);
assert.equal(m.security.rate_limited, 1);
assert.equal(m.security.auth_failures, 1);
assert.equal(m.security.interception_rate, 1);
assert.equal(m.verdict.interception_ok, true);
});
test('指标:按能力细分(次数/成功率/P95)', () => {
const entries = [
inv('fs.list', 'ok', 100),
inv('fs.list', 'ok', 200),
inv('fs.list', 'failed', 900, { error: 'x' }),
inv('clip.get', 'ok', 300),
];
const m = computeMetrics(entries);
assert.equal(m.by_capability['fs.list'].attempts, 3);
assert.equal(m.by_capability['fs.list'].ok, 2);
assert.ok(Math.abs(m.by_capability['fs.list'].success_rate - 2 / 3) < 1e-9);
assert.equal(m.by_capability['clip.get'].attempts, 1);
});
test('指标:超时(exit 124)单独计数,仍算失败样本', () => {
const entries = [
inv('system.shell.exec', 'failed', 30_000, { error: '超时已被强制终止 (exit_code=124)' }),
inv('system.shell.exec', 'ok', 120),
];
const m = computeMetrics(entries);
assert.equal(m.latency.timeouts, 1);
assert.equal(m.close_loop.failed, 1);
});
test('指标:可自定义阈值,判定随之变化', () => {
const entries = [inv('system.info', 'ok', 100), inv('system.info', 'failed', 100)];
const m = computeMetrics(entries, { ...DEFAULT_TARGETS, close_loop_success_rate: 0.5 });
assert.equal(m.close_loop.success_rate, 0.5);
assert.equal(m.verdict.close_loop_ok, true, '放宽到 50% 后应达标');
});
test('指标:窗口时间取审计条目的最早/最晚', () => {
const entries = [inv('a', 'ok', 10), inv('b', 'ok', 10)];
entries[0].ts = 1000;
entries[1].ts = 5000;
const m = computeMetrics(entries);
assert.equal(m.window.from, 1000);
assert.equal(m.window.to, 5000);
assert.equal(m.window.entries, 2);
});
test('指标:formatMetrics 输出含四项指标与达标标记', () => {
const m = computeMetrics([
...Array.from({ length: 20 }, () => inv('system.info', 'ok', 100)),
...Array.from({ length: 10 }, () => inv('app.install', 'ok', 2000)),
]);
const text = formatMetrics(m);
assert.match(text, /闭环成功率/);
assert.match(text, /装软件成功率/);
assert.match(text, /P95 时延/);
assert.match(text, /慢操作耗时/);
assert.match(text, /安全拦截/);
assert.match(text, /四项全达标/);
});
+91
View File
@@ -0,0 +1,91 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
buildApplyCommand,
buildRollbackScript,
buildSelfCheck,
maskToPrefix,
} from '../../apps/agent/dist/capabilities/network.js';
const snap = {
alias: 'Ethernet',
dhcp: false,
ipv4: [{ ip: '192.168.1.100', prefix: 24 }],
gateway: ['192.168.0.1'],
dns: ['223.5.5.5'],
};
const args = { mode: 'static', ip: '192.168.1.100', mask: '255.255.255.0', gateway: '192.168.0.1' };
test('掩码换算:常见掩码与前缀长度互转', () => {
assert.equal(maskToPrefix('255.255.255.0'), 24);
assert.equal(maskToPrefix('255.255.0.0'), 16);
assert.equal(maskToPrefix('255.255.255.192'), 26);
assert.equal(maskToPrefix('255.255.255.255'), 32);
assert.equal(maskToPrefix('乱码'), 24, '非法输入回退 24');
});
/**
* 真机事故回归:曾经「先删旧地址 → 再加新地址」,Windows 残留的默认路由让
* New-NetIPAddress 报 already exists → 新地址没加上 → 机器掉到 APIPA 彻底失联。
* 下面几条断言就是那道防线。
*/
test('apply(static):必须先加新地址、再清旧地址(顺序不可颠倒)', () => {
const script = buildApplyCommand('static', 'Ethernet', args, snap);
const iAdd = script.indexOf('New-NetIPAddress');
const iRemove = script.indexOf('Remove-NetIPAddress');
assert.ok(iAdd > 0 && iRemove > 0, '既要加也要清');
assert.ok(iAdd < iRemove, '必须先 New-NetIPAddress 后 Remove-NetIPAddress(事故根因)');
});
test('apply(static):建路由之前必须先清掉旧默认路由(否则 already exists)', () => {
const script = buildApplyCommand('static', 'Ethernet', args, snap);
const iRmRoute = script.indexOf('Remove-NetRoute');
const iAddRoute = script.indexOf('New-NetRoute');
assert.ok(iRmRoute > 0, '必须显式清理默认路由');
assert.ok(iAddRoute > 0, '必须重建默认路由');
assert.ok(iRmRoute < iAddRoute, 'Remove-NetRoute 必须在 New-NetRoute 之前');
});
test('apply:不得使用会在 Win11 挂起的 netsh set address', () => {
const staticScript = buildApplyCommand('static', 'Ethernet', args, snap);
const dhcpScript = buildApplyCommand('dhcp', 'Ethernet', { mode: 'dhcp' }, snap);
for (const s of [staticScript, dhcpScript]) {
assert.ok(!/netsh interface ipv4 set address/.test(s), 'netsh set address 在 Win11 会挂起(真机实测)');
assert.ok(!/netsh interface ipv4 set dnsservers/.test(s), 'netsh set dnsservers 同类问题');
}
assert.match(staticScript, /Set-NetIPInterface|New-NetIPAddress/, '应使用原生 cmdlet');
});
test('apply(dhcp):切 DHCP 用原生 cmdlet', () => {
const s = buildApplyCommand('dhcp', 'Ethernet', { mode: 'dhcp' }, snap);
assert.match(s, /Set-NetIPInterface -InterfaceAlias "Ethernet" -Dhcp Enabled/);
assert.match(s, /Set-DnsClientServerAddress/);
});
test('自检:失败时自动执行回滚脚本(自愈)', () => {
const chk = buildSelfCheck('Ethernet', '192.168.1.100', 'C:/rb.ps1');
assert.match(chk, /Get-NetIPAddress/);
assert.match(chk, /192\.168\.0\.88/, '应校验目标地址是否真的生效');
assert.match(chk, /selfcheck FAILED -> auto rollback/);
assert.match(chk, /& "C:\/rb\.ps1"/, '失败必须回滚,而不是把机器留在无地址状态');
});
test('rollback:同样先加后清 + 先清路由(否则回滚自己也会失败)', () => {
const rb = buildRollbackScript(snap, 'C:/backup.txt', 'C:/rb.log');
const iAdd = rb.indexOf('New-NetIPAddress');
const iRemove = rb.indexOf('Remove-NetIPAddress');
const iRmRoute = rb.indexOf('Remove-NetRoute');
const iAddRoute = rb.indexOf('New-NetRoute');
assert.ok(iAdd < iRemove, '回滚也必须先加后清');
assert.ok(iRmRoute < iAddRoute, '回滚也必须先清路由');
assert.match(rb, /192\.168\.0\.159/, '必须恢复快照里的原地址');
assert.match(rb, /192\.168\.0\.1/, '必须恢复原网关');
assert.match(rb, /223\.5\.5\.5/, '必须恢复原 DNS');
assert.ok(!/netsh -f/.test(rb), '不得用 netsh -f 回滚(备份编码 + set address 双重问题)');
});
test('rollback:DHCP 快照走「切回 DHCP」而非设静态', () => {
const rb = buildRollbackScript({ ...snap, dhcp: true, ipv4: [] }, 'C:/backup.txt', 'C:/rb.log');
assert.match(rb, /-Dhcp Enabled/);
assert.ok(!/New-NetIPAddress/.test(rb), 'DHCP 快照不应写静态地址');
});
+85
View File
@@ -0,0 +1,85 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { X509Certificate } from 'node:crypto';
import { ipInAllowlist, certFingerprint, shortFingerprint } from '../../packages/protocol/dist/index.js';
// ---------- v21:来源网段白名单 ----------
test('ipInAllowlist:未配置 = 放行全部(向后兼容)', () => {
assert.equal(ipInAllowlist('1.2.3.4'), true);
assert.equal(ipInAllowlist('1.2.3.4', []), true);
assert.equal(ipInAllowlist('1.2.3.4', ['*']), true);
});
test('ipInAllowlist:CIDR 按前缀比特匹配(不是字符串相等)', () => {
assert.equal(ipInAllowlist('192.168.0.5', ['192.168.0.0/24']), true);
assert.equal(ipInAllowlist('192.168.1.5', ['192.168.0.0/24']), false);
assert.equal(ipInAllowlist('10.1.2.3', ['10.0.0.0/8']), true);
assert.equal(ipInAllowlist('11.1.2.3', ['10.0.0.0/8']), false);
assert.equal(ipInAllowlist('192.168.0.200', ['192.168.0.128/25']), true);
assert.equal(ipInAllowlist('192.168.0.100', ['192.168.0.128/25']), false);
});
test('ipInAllowlist:单 IP 与多条混合', () => {
assert.equal(ipInAllowlist('127.0.0.1', ['127.0.0.1']), true);
assert.equal(ipInAllowlist('127.0.0.2', ['127.0.0.1', '10.0.0.0/8']), false);
assert.equal(ipInAllowlist('10.9.9.9', ['127.0.0.1', '10.0.0.0/8']), true);
});
test('ipInAllowlist:IPv4-mapped IPv6 必须与纯 IPv4 判等(Node 的监听器会这么报)', () => {
// 关键:Node 在 IPv6 监听上把 IPv4 客户端报成 ::ffff:a.b.c.d
assert.equal(ipInAllowlist('::ffff:192.168.0.5', ['192.168.0.0/24']), true);
assert.equal(ipInAllowlist('::ffff:10.0.0.1', ['192.168.0.0/24']), false);
});
test('ipInAllowlist:IPv6 前缀与 zone 后缀', () => {
assert.equal(ipInAllowlist('fe80::1', ['fe80::/10']), true);
assert.equal(ipInAllowlist('fe80::1%en0', ['fe80::/10']), true, '带 zone 后缀应归一化');
assert.equal(ipInAllowlist('2001:db8::1', ['fe80::/10']), false);
});
test('ipInAllowlist:非法来源地址在配了白名单时保守拒绝', () => {
assert.equal(ipInAllowlist('not-an-ip', ['10.0.0.0/8']), false);
assert.equal(ipInAllowlist('999.1.1.1', ['10.0.0.0/8']), false);
});
// ---------- v21:证书指纹 ----------
test('certFingerprint:PEM 与 DER 得到同一值(Windows 侧用 .NET 算 DER)', () => {
// 固定的自签证书夹具(不引第三方依赖,保证测试确定性)
const der = new Uint8Array(new X509Certificate(FIXTURE_CERT).raw);
assert.equal(certFingerprint(FIXTURE_CERT), certFingerprint(der), 'PEM 与 DER 必须算出同一指纹');
assert.match(certFingerprint(FIXTURE_CERT), /^[0-9a-f]{64}$/, '应为 64 位小写 hex');
});
test('certFingerprint:不同证书得到不同指纹(改一个字节即变)', () => {
const raw = new Uint8Array(new X509Certificate(FIXTURE_CERT).raw);
raw[raw.length - 1] = (raw[raw.length - 1] + 1) & 0xff; // 篡改末字节
assert.notEqual(certFingerprint(FIXTURE_CERT), certFingerprint(raw));
});
test('shortFingerprint:可读格式且保留足够位数', () => {
const fp = 'a'.repeat(64);
const s = shortFingerprint(fp);
assert.ok(s.includes(':'));
assert.ok(s.replace(/:/g, '').length >= 20, '至少保留 20 位十六进制便于人工比对');
});
/** 固定的自签证书(仅测试用,与任何真实部署无关)。 */
const FIXTURE_CERT = `-----BEGIN CERTIFICATE-----
MIIDCjCCAfKgAwIBAgIJVXL5GdEwCNb0MA0GCSqGSIb3DQEBCwUAMCExHzAdBgNV
BAMTFm5vZGVhZ2VudC10ZXN0LWZpeHR1cmUwHhcNMjYxMDA0MTgxOTQxWhcNMzYx
MDAxMTgxOTQxWjAhMR8wHQYDVQQDExZub2RlYWdlbnQtdGVzdC1maXh0dXJlMIIB
IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuxJei6y331/yEUAq71AcsXfV
tSOe2s3wKb2zqR2JKCJBSEmIIOZbDGfu5UE8pPkVmyOElQhipOQu5vFhjhbVE7Vs
WTFMCwu2uxpaJdYJ+BJqnPyo/gZMLGRzjL+FC5VWgZz4akP05vx4J7mMDksF+2qH
5aNGTVxMwRX3AoFh+kRjngv/bO5P7w+17VTY7TkC29Fa5fNSIcuFABQa1ToL1CCW
iX38nZR9iLaPAnNMGhjZTwgbTSGIVi06EIeK+2CVnlPt6eaUDLNPxGPME/3Fs5PK
xYTfDqOT9lfeKGD/AFK4RbqSpLlMm9QDT2TVDp/s9rMRjD9sTJQLEI/NiJHKDQID
AQABo0UwQzAMBgNVHRMEBTADAQH/MAsGA1UdDwQEAwIC9DAmBgNVHREEHzAdhhto
dHRwOi8vZXhhbXBsZS5vcmcvd2ViaWQjbWUwDQYJKoZIhvcNAQELBQADggEBAFJa
7lpph2qGIy8k7BlC2FMbxoC7/m5ieIKv1csTPiWlxHXGp414TX7oX1Z1t2UOl4Un
M0v1OkI9IPU2hcfZLHp8H02/xsp8VxElYiosZU4tzjce4oN/Rqlk9EUT4mc9OpdH
2CwVzXoDub5980VlAF7Ujg/jCii+WIDVSkEqGHfi/QAuZKmNa6itYS9E8TCQDTA4
K9hC6qcRROVd4upaXxkQQ7EWYTFjujur3JKNqof+LHei8rb88NrVCEUDJshwn4zi
q8v1k2VO4R7E2PnYRdFdYeQTzc/PxeJmFM7LKYVK+oLy6GgmecdQKKy0Bs+RDG4a
LXRExo5k4XnxHRJ2TP4=
-----END CERTIFICATE-----`;
+48
View File
@@ -0,0 +1,48 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
buildBootstrap,
parseHelperLine,
toEncodedCommand,
} from '../../apps/agent/dist/util/ps-helper.js';
test('编码:UTF-16LE base64 可逆(规避引号/换行/中文转义)', () => {
const script = '$x = "中文 \'引号\'"\nWrite-Output $x';
const b64 = toEncodedCommand(script);
assert.equal(b64, Buffer.from(script, 'utf16le').toString('base64'));
assert.equal(Buffer.from(b64, 'base64').toString('utf16le'), script);
});
test('bootstrap:包含 prelude、READY 标记、请求循环与退出信号', () => {
const prelude = 'Add-Type -AssemblyName UIAutomationClient\nfunction Foo { 1 }';
const boot = buildBootstrap(prelude);
assert.ok(boot.includes(prelude), '必须内联 prelude(类型只加载一次)');
assert.ok(boot.includes("'@@READY'"), '必须发出就绪信号(通过 stderr,不污染协议 stdout)');
assert.ok(boot.includes('@@EXIT'), '必须支持退出信号');
assert.ok(boot.includes('[Console]::In.ReadLine()'), '必须逐行读取请求');
assert.ok(boot.includes('FromBase64String'), '请求应为 base64 解码');
assert.ok(boot.includes("'@@R '"), '成功响应前缀');
assert.ok(boot.includes("'@@E '"), '异常响应前缀');
// 关键:异常必须被 catch 住,否则一次脚本报错就会带走整个常驻进程
assert.ok(/catch\s*\{/.test(boot), '必须有 try/catch 兜住单次脚本异常');
});
test('协议行:正确解析成功/异常,噪声行为 null', () => {
const ok = parseHelperLine(`@@R ${Buffer.from('{"a":1}', 'utf8').toString('base64')}`);
assert.deepEqual(ok, { isErr: false, payload: '{"a":1}' });
const bad = parseHelperLine(`@@E ${Buffer.from('未找到窗口', 'utf8').toString('base64')}`);
assert.deepEqual(bad, { isErr: true, payload: '未找到窗口' });
// Add-Type / 程序集加载会往 stdout 夹杂质,必须被忽略而不是误当响应
assert.equal(parseHelperLine('Add-Type: 已加载程序集'), null);
assert.equal(parseHelperLine(''), null);
assert.equal(parseHelperLine('@@X abc'), null);
});
test('协议行:中文与多行输出往返无损', () => {
const text = '窗口「记事本」\n第二行\t制表';
const b64 = Buffer.from(text, 'utf8').toString('base64');
const parsed = parseHelperLine(`@@R ${b64}`);
assert.equal(parsed.payload, text);
});
+220
View File
@@ -0,0 +1,220 @@
/**
* 编码守卫:仓库里的手写 .ps1 **必须带 UTF-8 BOM**。
*
* ⚠️ 背景(真机踩过,2026-10-04):`install.ps1` 一度是无 BOM 的 UTF-8,
* 在被控端用 **Windows PowerShell 5.1** 执行时直接解析失败:
* 所在位置 D:\Nodeagent\install.ps1:31 字符: 31
* + [string]$ProjectRoot = "",
* “,”后面缺少表达式。
* 原因:5.1 读**无 BOM** 的 .ps1 会按系统 ANSI(中文 Windows = GBK)解码,
* 脚本里的中文注释/字符串被解成乱码,其中某些字节序列被误认成引号,
* 于是整份脚本的字符串边界全乱 —— 报出的一串语法错误彼此无关,
* 真正的病灶在文件靠前的位置。
*
* **CI 之前全绿是因为冒烟步骤用 `shell: pwsh`(PowerShell 7,认无 BOM UTF-8)**,
* 只有真机的 5.1 会炸。故此用例 + CI 的 5.1 解析冒烟一起守。
*
* 对照:运行时**动态生成**的 .ps1 走 `apps/agent/src/capabilities/network.ts`
* 的 `withBom()`,已有同样约束。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
const ROOT = new URL('../../', import.meta.url).pathname;
const PS_DIR = join(ROOT, 'scripts');
/** 收集仓库里所有需要分发的 .ps1(含子目录) */
function collectPs1(dir) {
return collectByExt(dir, '.ps1');
}
/** 收集仓库里所有 .cmd(双击入口) */
function collectCmd(dir) {
return collectByExt(dir, '.cmd');
}
function collectByExt(dir, ext) {
const out = [];
for (const name of readdirSync(dir, { withFileTypes: true })) {
if (name.name === 'node_modules' || name.name.startsWith('.')) continue;
const full = join(dir, name.name);
if (name.isDirectory()) out.push(...collectByExt(full, ext));
else if (name.name.endsWith(ext)) out.push(full);
}
return out;
}
const PS1_FILES = collectPs1(PS_DIR);
test('仓库中至少存在 3 个待分发的 .ps1(防止 glob 失效导致守卫空转)', () => {
assert.ok(
PS1_FILES.length >= 3,
`只找到 ${PS1_FILES.length} 个 .ps1,守卫可能已失效(目录:${PS_DIR})`,
);
});
for (const file of PS1_FILES) {
const rel = file.slice(ROOT.length);
test(`${rel}:以 UTF-8 BOM 开头(PowerShell 5.1 兼容底线)`, () => {
const buf = readFileSync(file);
const hasBom = buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf;
assert.ok(
hasBom,
`${rel} 缺少 UTF-8 BOM。5.1 会按 ANSI/GBK 解析无 BOM 脚本,` +
`中文注释将破坏字符串边界并导致 ParseError。` +
`修法:把 U+FEFF 写进文件头(BOM 对 .ps1 是必需,对 JSON 则相反)。`,
);
});
test(`${rel}:内容是合法 UTF-8(无 GBK 混入的二次编码痕迹)`, () => {
const text = readFileSync(file, 'utf8');
// U+FFFD = 解码失败替换符;出现即说明混入过非 UTF-8 字节
assert.ok(!text.includes('\uFFFD'), `${rel} 含非法 UTF-8 字节`);
// 去掉 BOM 再校验,避免把 BOM 当成正文
assert.ok(text.replace(/^\uFEFF/, '').length > 0, `${rel} 去掉 BOM 后为空文件`);
});
}
test('install.ps1:关键参数与提示文案完好(防打包时被截断/改写)', () => {
const text = readFileSync(join(PS_DIR, 'install.ps1'), 'utf8');
for (const token of [
'param(',
'$AllowInput',
'New-ScheduledTask',
'New-NetFirewallRule',
'UTF8Encoding($false)', // 写 agent.json 必须无 BOM —— 与 .ps1 自身要 BOM 恰好相反
]) {
assert.ok(text.includes(token), `install.ps1 丢失关键片段:${token}`);
}
});
// ── .cmd 侧守卫 ───────────────────────────────────────────────────────────────
// 编码方向与 .ps1 **相反**:.cmd 必须纯 ASCII。
// CMD.exe 的代码页(中文 Windows = 936/GBK)无法可靠往返 UTF-8,
// .cmd 里的中文会变乱码,甚至把后续命令截断(真机 2026-10-04 的教训同源)。
// 中文提示一律交给带 BOM 的 .ps1 输出。
const CMD_FILES = collectCmd(PS_DIR);
test('仓库中至少存在 2 个 .cmd(防止 glob 失效导致守卫空转)', () => {
assert.ok(CMD_FILES.length >= 2, `只找到 ${CMD_FILES.length} 个 .cmd,守卫可能已失效`);
});
for (const file of CMD_FILES) {
const rel = file.slice(ROOT.length);
test(`${rel}:纯 ASCII(CMD 代码页安全)`, () => {
const buf = readFileSync(file);
const idx = [...buf].findIndex((b) => b > 0x7f);
assert.equal(
idx, -1,
`${rel} 第 ${idx} 字节起含非 ASCII —— CMD 代码页(936/GBK)无法可靠处理,` +
`中文提示请交给 .ps1(它带 BOM,5.1 能正确读)。`,
);
});
test(`${rel}:行尾是 CRLF(CMD 在部分环境下对 LF 行为不一致)`, () => {
const text = readFileSync(file, 'latin1');
// 允许文件内完全没有换行(极短脚本),但只要有换行就必须是 CRLF
const lfOnly = text.replace(/\r\n/g, '').includes('\n');
assert.equal(lfOnly, false, `${rel} 存在裸 LF 行尾,请统一为 CRLF`);
});
}
test('install.cmd:自提权 + 读 PSK.txt + 调 install.ps1 三段齐全', () => {
const text = readFileSync(join(PS_DIR, 'install.cmd'), 'latin1');
for (const token of [
'net session', // 探测是否已管理员
'Start-Process', // 自提权(UAC)
'PSK.txt', // 读包内密钥
'install.ps1', // 调底层安装
'-ExecutionPolicy Bypass',
'-AllowInput',
]) {
assert.ok(text.includes(token), `install.cmd 丢失关键片段:${token}`);
}
// 提权后必须 exit,不能继续用非管理员权限往下跑
assert.ok(/Verb RunAs[\s\S]{0,200}exit \/b/.test(text), '提权后应立即退出本进程');
});
test('control.cmd:菜单项与 control.ps1 的 ValidateSet 一一对应', () => {
const cmd = readFileSync(join(PS_DIR, 'control.cmd'), 'latin1');
const ps1 = readFileSync(join(PS_DIR, 'control.ps1'), 'utf8');
// 从 control.ps1 的 ValidateSet 里取出合法 action
const m = ps1.match(/ValidateSet\(([^)]*)\)/);
assert.ok(m, 'control.ps1 必须用 ValidateSet 约束 -Action');
const actions = m[1].split(',').map((s) => s.trim().replace(/^'|'$/g, '')).filter(Boolean);
// control.cmd 里 call :RUN <action> 的每个 action 都必须在 ValidateSet 内
const called = [...cmd.matchAll(/call :RUN (\S+)/g)].map((x) => x[1]);
assert.ok(called.length >= 5, `control.cmd 只发现 ${called.length} 个菜单项,可能正则失配`);
for (const a of called) {
assert.ok(actions.includes(a), `control.cmd 调用了 control.ps1 不支持的 action: ${a}`);
}
// 反向:ps1 支持的 action 都应在菜单里可达
for (const a of actions) {
assert.ok(called.includes(a), `control.ps1 支持的 ${a} 在 control.cmd 菜单里没有入口`);
}
});
test('control.ps1:状态检查覆盖任务/进程/端口/配置四项', () => {
const text = readFileSync(join(PS_DIR, 'control.ps1'), 'utf8');
for (const token of [
'Get-ScheduledTask',
'Get-Process',
'Get-NetTCPConnection',
'agent.json',
]) {
assert.ok(text.includes(token), `control.ps1 状态检查缺少:${token}`);
}
// 启动后必须轮询端口,而不是盲等固定秒数
assert.ok(
/for \(\$i = 0; \$i -lt 15/.test(text),
'启动后应轮询端口真正监听(最多 15s),而非盲等',
);
});
test('数据目录:install.ps1 与 control.ps1 都认 NODEAGENT_HOME(须与 agent 侧一致)', () => {
// apps/agent/src/config.ts: agentDir() = NODEAGENT_HOME ?? ~/.nodeagent
// 若 ps1 写死 USERPROFILE 而计划任务环境设了 NODEAGENT_HOME,
// 就会「配置写 A、读取 B」→ 装完连不上(E_AUTH_FAILED)。
for (const f of ['install.ps1', 'control.ps1']) {
const text = readFileSync(join(PS_DIR, f), 'utf8');
assert.ok(
text.includes('NODEAGENT_HOME'),
`${f} 未处理 NODEAGENT_HOME —— 必须与 apps/agent/src/config.ts 的 agentDir() 保持一致`,
);
}
});
test('控制台脚本:pause 均被 CI 守卫(否则 CI 会永久挂起)', () => {
for (const f of ['install.cmd', 'control.cmd']) {
const text = readFileSync(join(PS_DIR, f), 'latin1');
const bare = text.split('\r\n').filter((l) => /^\s*pause\s*$/i.test(l));
assert.equal(
bare.length, 0,
`${f} 有 ${bare.length} 处裸 pause,CI(CI=true)会卡死。统一用 "if not defined CI pause"。`,
);
}
});
test('install.cmd:管理员探测用 fltmc 而非 net session(不依赖 Server 服务)', () => {
const text = readFileSync(join(PS_DIR, 'install.cmd'), 'latin1');
// net session 依赖 Server 服务,部分 Windows 版本该服务停用 → 已提权却被判为未提权 → 死循环 UAC
assert.ok(/^\s*fltmc\s*>nul/m.test(text), 'install.cmd 应用 fltmc 探测管理员权限');
assert.ok(
!/^\s*net\s+session\s*>nul/m.test(text),
'install.cmd 不应再用 net session 探测(依赖 Server 服务,可能误判)',
);
});
// ---------- 安全:公开的安装包不得含密钥 ----------
test('pack.mjs 打 zip 必须排除 PSK.txt(zip 作为公开 Release 资产发布)', () => {
const pack = readFileSync(join(ROOT, 'scripts', 'pack.mjs'), 'utf8');
const zipLine = pack.split('\n').find((l) => l.includes('zip -q -r'));
assert.ok(zipLine, '找不到打 zip 的命令行');
assert.match(zipLine, /-x\s+"?PSK\.txt"?/, '必须 -x 排除 PSK.txt,否则预共享密钥随公开资产泄露');
});
+85
View File
@@ -0,0 +1,85 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { WIN_HELPER_PRELUDE } from '../../apps/agent/dist/capabilities/window.js';
/**
* 回归测试:常驻助手的预加载段必须四段齐全。
*
* 为什么需要它:这些段是**多行模板字符串拼接**,一旦某次编辑没拼进去,
* 表现是「常量未被引用 → 被 esbuild tree-shake 掉 → 运行时才报找不到类型」,
* 而且不报编译错 —— 真机上就是这么静默坏掉的(OCR 与图像匹配同时失效)。
* 断言关键标记,任何一段丢失都会立刻失败。
*/
test('预加载段:Win32 C# 类型齐全', () => {
assert.match(WIN_HELPER_PRELUDE, /public class NAWin32/, '缺少 Win32 P/Invoke 类型');
assert.match(WIN_HELPER_PRELUDE, /EnumWindows/, '缺 EnumWindows 声明');
});
test('预加载段:UIA 程序集与查找函数齐全', () => {
assert.match(WIN_HELPER_PRELUDE, /Add-Type -AssemblyName UIAutomationClient/);
assert.match(WIN_HELPER_PRELUDE, /function Get-WinByTitle/);
assert.match(WIN_HELPER_PRELUDE, /function Find-UiaByText/, '缺子串查找函数(v14 起 body 依赖它)');
});
test('预加载段:OCR(含 WinRT 与 Await 辅助)齐全', () => {
assert.match(WIN_HELPER_PRELUDE, /Add-Type -AssemblyName System\.Windows\.Forms/);
assert.match(WIN_HELPER_PRELUDE, /OcrEngine, Windows\.Foundation, ContentType=WindowsRuntime/);
assert.match(WIN_HELPER_PRELUDE, /function global:Await/);
assert.match(WIN_HELPER_PRELUDE, /\$script:ocrReady/);
});
test('预加载段:图像模板匹配 C# 齐全且带 -ReferencedAssemblies', () => {
assert.match(WIN_HELPER_PRELUDE, /public class NAImageMatch/);
assert.match(WIN_HELPER_PRELUDE, /public static string Match\(/);
assert.match(WIN_HELPER_PRELUDE, /public static string Capture\(/);
// Add-Type 的坑:不显式引用程序集,编译期就找不到 System.Drawing.Imaging
assert.match(
WIN_HELPER_PRELUDE,
/-ReferencedAssemblies System\.Drawing/,
'缺 -ReferencedAssemblies:C# 编译会失败(真机踩过)',
);
});
test('预加载段:C# 源码必须纯 ASCII(否则 Add-Type 在中文系统上乱码)', () => {
// Add-Type 会把源码按**系统 ANSI 代码页**(中文 Windows = GBK)落盘再编译,
// C# 里的中文注释必然乱码 → 编译失败(真机踩过:整个图像匹配引擎静默不可用)。
const csharpBlocks = WIN_HELPER_PRELUDE.match(/Add-Type @"[\s\S]*?"@/g) ?? [];
assert.ok(csharpBlocks.length > 0, '应至少有一个内嵌 C# 块');
for (const [i, block] of csharpBlocks.entries()) {
const bad = [...new Set(block.match(/[^\x00-\x7f]/g) ?? [])];
assert.equal(
bad.length,
0,
`第 ${i + 1} 个 C# 块含非 ASCII 字符 ${bad.join('')} —— 必须改为英文注释`,
);
}
});
test('预加载段:C# 内不得残留裸引号 JSON 字面量(TS 模板串会把 \\" 吃成 ")', () => {
// 陷阱:在 TS 模板字符串里写 \\" 会得到裸 ",C# 收到未转义引号即编译失败。
// 正确做法是用 C# 字符常量 '"' 拼 JSON。这里断言几种典型坏形态不存在。
const csharpBlocks = WIN_HELPER_PRELUDE.match(/Add-Type @"[\s\S]*?"@/g) ?? [];
for (const block of csharpBlocks) {
assert.ok(!/\[\{"/.test(block), 'C# 中出现 "[" + "{" 的裸引号形态,应改用字符常量拼 JSON');
assert.ok(!/"\{[^"]*"/.test(block.replace(/"[^"]*"/g, '')), 'C# 字符串中出现未转义引号');
}
});
test('预加载段:C# 内局部常量不得重复声明(编辑残留会编译失败)', () => {
const csharpBlocks = WIN_HELPER_PRELUDE.match(/Add-Type @"[\s\S]*?"@/g) ?? [];
for (const block of csharpBlocks) {
const decls = block.match(/\bQ\s*=\s*'";/g) ?? [];
assert.ok(decls.length <= 1, `Q 被声明了 ${decls.length} 次 —— 重复定义会让 C# 编译失败`);
}
});
test('预加载段:顺序与完整性(四段都在同一个字符串里)', () => {
const idxWin32 = WIN_HELPER_PRELUDE.indexOf('public class NAWin32');
const idxUia = WIN_HELPER_PRELUDE.indexOf('function Get-WinByTitle');
const idxOcr = WIN_HELPER_PRELUDE.indexOf('OcrEngine, Windows.Foundation');
const idxImg = WIN_HELPER_PRELUDE.indexOf('public class NAImageMatch');
for (const [name, idx] of Object.entries({ idxWin32, idxUia, idxOcr, idxImg })) {
assert.ok(idx > 0, `${name} 未包含在预加载段中`);
}
assert.ok(idxWin32 < idxUia && idxUia < idxOcr && idxOcr < idxImg, '四段顺序应为 Win32 → UIA → OCR → 图像');
});