Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cbac53308 | ||
|
|
045e3b08b1 | ||
|
|
303cbc7436 | ||
|
|
1c26dc4e3f | ||
|
|
6faf807108 | ||
|
|
21d568d90d | ||
|
|
cedc46d090 | ||
|
|
aa72241080 | ||
|
|
e95a0248da | ||
|
|
e6a9a3d7ea | ||
|
|
9853ee8cdb | ||
|
|
aaf0175e94 | ||
|
|
edc184037b | ||
|
|
4c6190285c | ||
|
|
caa5b8a2c1 | ||
|
|
411a483c76 | ||
|
|
9ef8978b92 | ||
|
|
e955b6a2f7 | ||
|
|
6afec163f3 | ||
|
|
553b16f595 | ||
|
|
8ee8dc7f41 | ||
|
|
92df08eb20 | ||
|
|
0dcf01ad4e | ||
|
|
87d7f3fbcc | ||
|
|
8743bd1237 | ||
|
|
0f8b878de0 | ||
|
|
4d1aeada07 | ||
|
|
82a44bb13f | ||
|
|
d56556d3c3 | ||
|
|
a892b113ca | ||
|
|
cd774ae0a6 | ||
|
|
66c44f6822 | ||
|
|
3ed117770b | ||
|
|
0a47c5a92d | ||
|
|
7efe717cf8 |
No files matched your search
@@ -18,6 +18,10 @@ pnpm-debug.log*
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# 预共享密钥:install.cmd 在包内生成,绝不入库
|
||||
PSK.txt
|
||||
**/PSK.txt
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
@@ -20,17 +20,18 @@ 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` | 键鼠控制(🔒 **默认禁用**),含**拖拽**与**按键序列/连按** |
|
||||
| **剪贴板** | `clip.get` · `clip.set` | 读写文本**或图片**(PNG Base64) |
|
||||
| **事件订阅** | `event.watch` · `event.unwatch` · `event.list` · `event.poll` | 文件变动 / 进程启停 / 端口开闭,**主动推送**(无需轮询) |
|
||||
|
||||
**31 项能力** · **26 个 MCP 工具** · **61 项单元测试** · **21 项端到端用例**(CI 在真实 Windows 上验证)
|
||||
**36 项能力** · **32 个 MCP 工具** · **114 项单元测试** · **26 项端到端用例**(CI 在真实 Windows 上验证)
|
||||
|
||||
> 关键里程碑:**GUI 语义**(`window.list` + `screen.find`,UIA 找不到自动降级 OCR)
|
||||
> 让 AI 从「看得到画面但读不懂界面」变成「按名字取坐标点下去」。
|
||||
@@ -60,16 +61,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 +264,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 +286,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 +329,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 +354,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 +385,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 +398,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 +424,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 +448,21 @@ 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/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 +476,5 @@ nodeagent/
|
||||
| 安全演进 | 先简化(预共享密钥 + HMAC)→ 后强化(Ed25519 + 能力级 ACL + 审计) |
|
||||
| AI 接入 | 核心能力 → CLI(调试底座)→ MCP(AI 落点) |
|
||||
| 装软件 | 优先 winget 静默安装 |
|
||||
| Windows 部署 | 一键 `install.ps1` + 开机自启 |
|
||||
| Windows 部署 | 一键 `install.cmd`(双击即装,自提权)+ 开机自启;`control.cmd` 管理 |
|
||||
| 依赖策略 | 被控端零原生编译;能力全部基于系统原生 API 实现 |
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/agent",
|
||||
"version": "0.1.0",
|
||||
"version": "1.2.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
+32
-1
@@ -11,7 +11,11 @@ export type AuditType =
|
||||
| 'invoke'
|
||||
| 'acl.denied'
|
||||
| 'capability.disabled'
|
||||
| 'rate.limited';
|
||||
| 'rate.limited'
|
||||
/** v16:网络配置变更(两阶段提交) */
|
||||
| 'net.change'
|
||||
/** v19:agent 自更新(拉取式) */
|
||||
| 'agent.update';
|
||||
|
||||
/** 一条审计记录(JSONL 单行)。 */
|
||||
export interface AuditEntry {
|
||||
@@ -271,6 +275,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 };
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -79,13 +79,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;
|
||||
@@ -268,16 +287,16 @@ 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> {
|
||||
@@ -313,5 +332,12 @@ export async function keyPress(args: Args): Promise<unknown> {
|
||||
if (i < chords.length - 1 && gap > 0) body.push(`Start-Sleep -Milliseconds ${gap}`);
|
||||
}
|
||||
await runPs(body);
|
||||
return { pressed: true, chords, times: chords.length };
|
||||
// 同时回传 keys(单和弦时的回显)与 chords(完整序列)。
|
||||
// 修正 2026-10-04:此前只回 chords,控制端 CLI 读 keys.join() 直接崩溃。
|
||||
return {
|
||||
pressed: true,
|
||||
keys: chords.length === 1 ? chords[0] : undefined,
|
||||
chords,
|
||||
times: chords.length,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
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),
|
||||
});
|
||||
}
|
||||
try {
|
||||
renameSync(staged, scriptPath);
|
||||
} catch (err) {
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '替换脚本失败', {
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
backup_path: backup,
|
||||
staged_path: staged,
|
||||
});
|
||||
}
|
||||
|
||||
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',
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
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 { 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 +46,69 @@ 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;
|
||||
declare const __BUILD_TIME__: string | undefined;
|
||||
|
||||
function injected(name: 'version' | 'commit' | 'time'): string {
|
||||
if (name === 'version') return typeof __AGENT_VERSION__ === 'undefined' ? 'dev' : __AGENT_VERSION__;
|
||||
if (name === 'commit') return typeof __BUILD_COMMIT__ === 'undefined' ? 'dev' : __BUILD_COMMIT__;
|
||||
return typeof __BUILD_TIME__ === 'undefined' ? 'dev' : __BUILD_TIME__;
|
||||
}
|
||||
|
||||
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;
|
||||
} {
|
||||
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'),
|
||||
built_at: injected('time'),
|
||||
};
|
||||
}
|
||||
|
||||
export async function systemInfo(args: Args): Promise<unknown> {
|
||||
const fields = args['fields'] as string[] | undefined;
|
||||
const info: Record<string, unknown> = {
|
||||
@@ -59,6 +126,10 @@ 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:构建指纹(部署校验用)
|
||||
build: buildInfo(),
|
||||
};
|
||||
|
||||
if (fields && fields.length > 0) {
|
||||
@@ -509,3 +580,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);
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -57,6 +57,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;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
+12
-1
@@ -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}`),
|
||||
);
|
||||
}
|
||||
@@ -95,6 +102,10 @@ async function main(): Promise<void> {
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// 后台预热 GUI 能力的常驻 PowerShell 助手:把「首次调用」的启动+类型预加载成本
|
||||
// (~1-3s)挪到启动后无人等待的时刻,避免算在第一个真实调用头上(真机指标可见)。
|
||||
warmupWindowHelper();
|
||||
|
||||
const shutdown = async (): Promise<void> => {
|
||||
console.log('\n正在关闭...');
|
||||
beacon?.stop();
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -26,8 +26,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 +44,8 @@ interface ConnState {
|
||||
authorized: string[] | null;
|
||||
/** 来源地址(审计用) */
|
||||
remote: string;
|
||||
/** v12:连接标识,用于清理该连接创建的事件订阅 */
|
||||
connId: string;
|
||||
}
|
||||
|
||||
export interface AgentServer {
|
||||
@@ -176,7 +180,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 +304,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 +390,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 +398,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}`));
|
||||
}
|
||||
|
||||
|
||||
@@ -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(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/'/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,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/cli",
|
||||
"version": "0.1.0",
|
||||
"version": "1.2.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
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,
|
||||
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);
|
||||
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';
|
||||
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 });
|
||||
} 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)} 平台`);
|
||||
for (const n of nodes) {
|
||||
const ctl = n.input_enabled ? ' [输入控制已开]' : '';
|
||||
console.log(
|
||||
` ${n.node_id.padEnd(16)} ${`${n.host}:${n.port}`.padEnd(20)} ${(n.tls ? 'wss' : 'ws').padEnd(6)} ${n.auth_mode.padEnd(8)} ${n.platform}${ctl}`,
|
||||
);
|
||||
}
|
||||
const first = nodes[0]!;
|
||||
console.log('\n连接示例:');
|
||||
console.log(` nodeagent connect ${first.host} --port ${first.port} --key <密钥>${first.tls ? ' --insecure' : ''}`);
|
||||
}
|
||||
|
||||
// ---------- v5 文件传输 ----------
|
||||
|
||||
/** 分块大小:1MB(与被控端 max_bytes 默认值配合) */
|
||||
@@ -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)})`);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
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/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) args['scale'] = Number(opts.scale);
|
||||
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': {
|
||||
if (positionals.length === 0) fail('用法: nodeagent key press <键1> [键2] ...(如 ctrl c)');
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.KeyPress, { keys: positionals }, 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 };
|
||||
const label = r.keys?.length
|
||||
? r.keys.join('+')
|
||||
: (r.chords ?? []).map((ch) => ch.join('+')).join(' → ') || positionals.join('+');
|
||||
const times = r.times && r.times > 1 ? `(重复 ${r.times} 次)` : '';
|
||||
console.log(`✓ 已按下 ${label}${times}`);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
fail('用法: nodeagent key <type|press> ...');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- v3+ 审计 ----------
|
||||
|
||||
/** v11:审计链完整性校验。 */
|
||||
@@ -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]');
|
||||
}
|
||||
@@ -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('(剪贴板为空或非文本)');
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
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);
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
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,
|
||||
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,
|
||||
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} 项,两端一致 ✓`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// CLI 帮助文本(从 index.ts 拆出)
|
||||
export const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI)
|
||||
|
||||
用法:
|
||||
nodeagent connect <host> [--port 8765] [--key <key>] [--insecure] [--id mac_01] [--auth-mode ed25519]
|
||||
配置并连接被控端(握手成功后打印能力清单)
|
||||
经 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)
|
||||
|
||||
通用选项:
|
||||
--node <设备名> 本次命令临时指定目标设备(不改变 current)
|
||||
可放命令后(nodeagent info --node win_b)或前置(nodeagent --node=win_b info)
|
||||
--json 以原始 JSON 输出
|
||||
--config 显示当前配置路径
|
||||
`;
|
||||
+84
-1297
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,170 @@
|
||||
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' },
|
||||
'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 },
|
||||
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,
|
||||
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']),
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// 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;
|
||||
/** 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[];
|
||||
/** 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,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/hub",
|
||||
"version": "0.1.0",
|
||||
"version": "1.2.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/mcp",
|
||||
"version": "0.1.0",
|
||||
"version": "1.2.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
+282
-1
@@ -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,
|
||||
@@ -311,6 +344,7 @@ const TOOLS = [
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string', description: '窗口标题(正则,匹配第一个)' },
|
||||
wait_ms: { type: 'integer', description: '等待窗口出现(应用刚启动时用)' },
|
||||
},
|
||||
required: ['title'],
|
||||
additionalProperties: false,
|
||||
@@ -336,6 +370,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 +408,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 +631,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 +665,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,
|
||||
@@ -593,7 +812,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 +841,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'] ?? '');
|
||||
|
||||
@@ -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` 对齐网段再谈其他。
|
||||
@@ -0,0 +1,97 @@
|
||||
# 版本与兼容性契约
|
||||
|
||||
> 为什么需要这份文档: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 构建),能力集合不会骗人。
|
||||
|
||||
## 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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "nodeagent",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "1.2.1",
|
||||
"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,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/client",
|
||||
"version": "0.1.0",
|
||||
"version": "1.2.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -56,6 +56,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 +76,10 @@ export class NodeAgentClient {
|
||||
private capabilities: CapabilityDescriptor[] = [];
|
||||
/** ed25519 模式:本次被授权的能力;psk 模式为 null */
|
||||
private authorized: string[] | null = null;
|
||||
/** v20:被控端语义化版本(旧被控端不返回则为 undefined) */
|
||||
private agentVersion?: string;
|
||||
/** v20:被控端构建信息 { hash, commit, built_at } */
|
||||
private agentBuild?: { hash?: string; commit?: string; built_at?: string };
|
||||
private closed = false;
|
||||
/** v4:重连状态 */
|
||||
private reconnectAttempts = 0;
|
||||
@@ -153,6 +159,9 @@ export class NodeAgentClient {
|
||||
|
||||
this.capabilities = authOk.capabilities ?? [];
|
||||
this.authorized = authOk.authorized ?? null;
|
||||
// v20:远端版本/构建信息(旧被控端不返回 → 保持 undefined,不报错)
|
||||
this.agentVersion = authOk.agent_version;
|
||||
this.agentBuild = authOk.build;
|
||||
const label =
|
||||
authOk.auth_mode === 'ed25519'
|
||||
? `ed25519,授权 ${this.authorized?.length ?? 0}/${this.capabilities.length} 项能力`
|
||||
@@ -181,6 +190,16 @@ export class NodeAgentClient {
|
||||
return this.authorized;
|
||||
}
|
||||
|
||||
/** v20:被控端版本(旧被控端未上报则为 undefined)。 */
|
||||
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 +234,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;
|
||||
|
||||
|
||||
@@ -37,6 +37,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 {
|
||||
|
||||
@@ -3,3 +3,4 @@ export * from './keys.js';
|
||||
export * from './secret.js';
|
||||
export * from './discovery.js';
|
||||
export * from './client.js';
|
||||
export * from './macro.js';
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/protocol",
|
||||
"version": "0.1.0",
|
||||
"version": "1.2.1",
|
||||
"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,413 @@
|
||||
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.1',
|
||||
description:
|
||||
'按下按键。keys=["ctrl","c"] 表示和弦;配 repeat=3 可连按 3 次;' +
|
||||
'sequence=[["ctrl","c"],["ctrl","v"]] 表示按键序列',
|
||||
risk: 'high',
|
||||
params_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
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"]];提供时忽略 keys/repeat',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
returns_schema: {
|
||||
type: 'object',
|
||||
properties: { pressed: { type: 'boolean' }, keys: { type: 'array', items: { 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: '将指定窗口(按标题正则或句柄)置前并聚焦,返回其精确矩形。',
|
||||
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,387 @@
|
||||
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 等',
|
||||
},
|
||||
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,
|
||||
};
|
||||
@@ -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 };
|
||||
/** v20:被控端支持的协议版本(与 HelloParams.protocol 比对) */
|
||||
protocol?: string;
|
||||
}
|
||||
|
||||
// ---------- 调用 ----------
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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%
|
||||
+12
-2
@@ -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
|
||||
|
||||
|
||||
+102
-6
@@ -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,9 @@ async function main() {
|
||||
'--target=node22',
|
||||
'--format=esm',
|
||||
'--banner:js=import { createRequire } from "node:module"; const require = createRequire(import.meta.url);',
|
||||
`--define:__AGENT_VERSION__='"${pkg.version}"'`,
|
||||
`--define:__BUILD_COMMIT__='"${commit}"'`,
|
||||
`--define:__BUILD_TIME__='"${builtAt}"'`,
|
||||
`--outfile=${bundleOut}`,
|
||||
],
|
||||
{ stdio: 'inherit' },
|
||||
@@ -94,18 +119,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 {
|
||||
|
||||
@@ -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}`);
|
||||
@@ -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,4 +1,4 @@
|
||||
<#
|
||||
<#
|
||||
.SYNOPSIS
|
||||
nodeagent - uninstall the Windows agent.
|
||||
|
||||
|
||||
@@ -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' });
|
||||
@@ -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();
|
||||
}
|
||||
+410
-4
@@ -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,360 @@ 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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +1199,16 @@ 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);
|
||||
} finally {
|
||||
agent.kill('SIGTERM');
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
|
||||
@@ -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,27 @@ 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);
|
||||
});
|
||||
@@ -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, '不应因环引用爆炸');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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, /四项全达标/);
|
||||
});
|
||||
@@ -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 快照不应写静态地址');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,否则预共享密钥随公开资产泄露');
|
||||
});
|
||||
@@ -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 → 图像');
|
||||
});
|
||||
Reference in new issue
Block a user