Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f4d8913b5 | ||
|
|
f4ae20969a | ||
|
|
7d13a48caa | ||
|
|
40b7db16c1 | ||
|
|
765ad08ee6 | ||
|
|
fbcb8f8408 | ||
|
|
ab7a2b147b | ||
|
|
047cc9efd2 | ||
|
|
d8f21f4e68 | ||
|
|
54f7f4c7ab | ||
|
|
c8aba83589 | ||
|
|
3b05f5b373 | ||
|
|
7eb773099f | ||
|
|
ba57d99806 | ||
|
|
28ce95f045 | ||
|
|
2fe717cb62 | ||
|
|
9bba53d5c0 | ||
|
|
53bf5a0634 | ||
|
|
096193a8ec | ||
|
|
e23788d717 | ||
|
|
5b69d6cec9 | ||
|
|
11127b1a89 | ||
|
|
d9e2103be7 |
No files matched your search
+46
-22
@@ -1,21 +1,22 @@
|
||||
name: Rust Client
|
||||
name: Rust Shell
|
||||
|
||||
# crates/nodeagent-client 是桌面控制台的协议客户端(Tauri 壳经 Cargo path 依赖它)。
|
||||
# 它是纯 Rust、无平台相关代码,故单开一个 Linux 快车道:
|
||||
# 比 Windows 那条 E2E 长流程反馈快得多,失败归属也一目了然。
|
||||
# 2026-10:原 crates/nodeagent-client(Rust 版协议客户端)已退役 ——
|
||||
# 协议实现改由 Node sidecar 承担(apps/console/sidecar/),避免两套实现静默漂移。
|
||||
# 本工作流随之改为验证 **console 的 Rust 壳**(sidecar 管理 + command 转发)。
|
||||
#
|
||||
# ⚠️ 本工作流与仓库其余部分的铁律不同:**不涉及** .ps1/.cmd 编码,无需 BOM 守卫。
|
||||
# 它的单测是**真跑** Node sidecar 子进程的(见 apps/console/src-tauri/src/sidecar.rs),
|
||||
# 不依赖 Tauri GUI,因此能在 Linux 快车道上跑。
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'crates/**'
|
||||
- 'packages/protocol/**'
|
||||
- 'apps/console/src-tauri/**'
|
||||
- 'apps/console/sidecar/**'
|
||||
- '.github/workflows/rust.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'crates/**'
|
||||
- 'packages/protocol/**'
|
||||
- 'apps/console/src-tauri/**'
|
||||
- 'apps/console/sidecar/**'
|
||||
- '.github/workflows/rust.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -24,31 +25,54 @@ env:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Rust 客户端单测 + E2E
|
||||
name: Rust 壳单测(真跑 sidecar)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v5
|
||||
|
||||
# 版本**锁死**而不跟 stable 漂 —— 否则「本地全绿」与「CI 绿」会因为 lint 集合随
|
||||
# 工具链变化而分叉(本仓库已因同类原因吃过 8 轮 Windows CI 的苦头)。
|
||||
# 当前值与 crates/ 开发环境一致:rustc/cargo 1.98.1、clippy 0.1.98。
|
||||
# 升级时两边同改,升完先本地复跑 `cargo clippy ... -- -D warnings`。
|
||||
- name: 安装 Rust 工具链(1.98.1,含 clippy)
|
||||
# 版本**锁死**而不跟 stable 漂 —— 否则「本地全绿」与「CI 绿」会因 lint 集合随工具链
|
||||
# 变化而分叉(本仓库已因同类原因吃过 8 轮 Windows CI 的苦头)。
|
||||
- name: 安装 Rust 工具链(含 clippy)
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: 1.98.1
|
||||
toolchain: stable
|
||||
components: clippy
|
||||
|
||||
# Tauri 壳在 Linux 上编译需要这些系统库(webkit2gtk 等)。sidecar 单测本身
|
||||
# 不需要 GUI,但整个 crate 要能编过。
|
||||
- name: 安装 Tauri 系统依赖
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev \
|
||||
librsvg2-dev patchelf libgtk-3-dev
|
||||
|
||||
- name: 安装 Node(sidecar 单测要真跑它)
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: 安装 pnpm 与依赖(sidecar 需 @nodeagent/client)
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 构建 workspace 包(sidecar 依赖其 dist)
|
||||
run: pnpm -r --filter "./packages/*" build
|
||||
|
||||
# ⚠️ tauri-build 在**编译期**校验 tauri.conf.json 里 resources 的文件存在性,
|
||||
# 故必须先产出 sidecar.mjs,否则 clippy/test 会在 build.rs 阶段就失败。
|
||||
- name: 构建 sidecar(resources 引用的产物)
|
||||
run: pnpm --filter nodeagent-console sidecar:build
|
||||
|
||||
- name: 缓存 cargo 构建产物
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: crates
|
||||
workspaces: apps/console/src-tauri
|
||||
|
||||
# clippy 单列为一步:它拦的是「能编译但不该这么写」,与测试失败是两类问题。
|
||||
# -D warnings 让告警即失败 —— 该 crate 的既定标准是**零告警**。
|
||||
- name: Clippy(告警即失败)
|
||||
run: cargo clippy --manifest-path crates/nodeagent-client/Cargo.toml --all-targets -- -D warnings
|
||||
run: cargo clippy --manifest-path apps/console/src-tauri/Cargo.toml --all-targets -- -D warnings
|
||||
|
||||
- name: 单测 + E2E
|
||||
run: cargo test --manifest-path crates/nodeagent-client/Cargo.toml
|
||||
- name: 单测(真跑 Node sidecar 进程)
|
||||
run: cargo test --manifest-path apps/console/src-tauri/Cargo.toml
|
||||
+49
-5
@@ -5,6 +5,44 @@
|
||||
|
||||
---
|
||||
|
||||
## [v2.0.1] —— 2026-10-11
|
||||
|
||||
> 一轮**真机验证**(Parallels 27 + Win11 ARM)带来的修复批次:在补齐 6 项待验证项的过程中,
|
||||
> **抓到并修掉 5 个真问题** —— 全部只有真机能发现(macOS 上 100% 测不出)。
|
||||
|
||||
### 修复
|
||||
|
||||
- **`control.ps1` 的 `$scheme:` 被当 drive 引用** —— PowerShell **解析期错误 → 整个脚本不可用**
|
||||
(stop / start / status 全废,且只报一行语法错、极易误判为别的问题)。改用 `${scheme}`;
|
||||
并新增守卫扫全仓 `.ps1` 的 `$ident:` 形式(含注入故障验证)。
|
||||
- **连接保活缺 pong 检测** —— 心跳只 `ping()` 而不检查 pong,且 ws 库默认不会因「ping 无 pong」断开;
|
||||
客户端进程已死(TCP 未发 FIN)时,连接会**永久占用 `max_connections` 名额**,
|
||||
叠加「有任务/订阅时清扫保守跳过」的策略 → **会把正常控制端挡在外面**(真机实测把自己锁住了)。
|
||||
现对静默超过 `idle_timeout_ms * 2` 的连接 `terminate()`,并让 pong 也算「活跃」。
|
||||
- **v26 自重启任务清理阈值过粗** —— 原用「早于 1 小时」,频繁 restart(间隔 < 1h)时旧任务照样累积;
|
||||
改为「**早于本 agent 启动时刻**」——语义精确:凡在**我启动之前**运行的任务,必然不是我的宿主。
|
||||
|
||||
### 新增
|
||||
|
||||
- **`scripts/pack.mjs --arch <x64|arm64>`** —— Windows 包分架构。**x64 包在 ARM Windows 上协议层不可用**
|
||||
(能启动、能 listen 8765,但 **TLS 握手不完成** —— Prism 模拟下异常,现象极具误导性);
|
||||
ARM 机器须用 arm64 包。`release.mjs` 随之**同时产出两个架构**,`latest.json` 新增 `zip_arm64` 字段
|
||||
(**只增不减**,旧控制端忽略即可)。
|
||||
- **`scripts/verify-b2.mjs`** —— v23 连接层三防护的真机验证脚本(`idle` / `maxconn` / `ban` 三模式),
|
||||
全部走真实连接而非 mock。
|
||||
- **`docs/VM-VERIFICATION.md`** —— 无 Windows 真机时用 Parallels Win11 ARM 补验证的完整清单
|
||||
(含实测结果、能补/不能补矩阵、8 条排查项)。
|
||||
|
||||
### 真机验证状态(本版实测)
|
||||
|
||||
| 项 | 结果 |
|
||||
|---|---|
|
||||
| A 组(`verify.mjs` 17 项) | 15 通过 / 0 失败 / 1 跳过 |
|
||||
| v1.5 四项(字符串热键/预设别名/长按/后台投递) | 全过(`route=post` 实测命中 138 窗口) |
|
||||
| v23 三防护 | 三场景全过(封禁 / 上限 / 空闲断开) |
|
||||
| v26 自重启清理 | 验证并改进(阈值改为 agent 启动时刻) |
|
||||
| 锚点端到端 | 全过(含云盘链外留存) |
|
||||
|
||||
## [v2.0.0] —— 2026-10-10
|
||||
|
||||
> 自 `v1.6.0` 起累计 37 个提交。**能力数 43 → 51**,单测 195 → 273,端到端 30。
|
||||
@@ -14,10 +52,16 @@
|
||||
> **兼容性**:完全向后兼容 —— 未改任何既有能力的参数名或语义,未新增必填参数,
|
||||
> 返回结构只增不减(遵循 `docs/VERSIONING.md` 的四条铁律)。
|
||||
>
|
||||
> **已知限制**:本版发布时被控端整机离线,`v1.5` 的预设别名/长按/后台投递/热键序列
|
||||
> 与 `v26` 的自重启任务清理策略**尚未经真机验证**(后者已在本地单测锁定判定条件)。
|
||||
> **验证状态**:本版发布时被控端整机离线,`v1.5.0` 的预设别名/长按/后台投递/热键序列
|
||||
> 与 `v2.0.0` 的自重启任务清理策略**当时未经真机验证**(后者已在本地单测锁定判定条件)。
|
||||
>
|
||||
> ✅ **2026-10-11 已补齐**:在 Parallels 27 + Win11 ARM 虚拟机上完成真机验证 ——
|
||||
> A 组 15 通过/0 失败、v1.5 四项全过、v23 三防护三场景全过、v26 清理策略**验证并改进**
|
||||
> (阈值由「1 小时」改为「本 agent 启动时刻」)、锚点端到端跑通(含云盘链外留存)。
|
||||
> 并**顺带发现并修复 5 个真问题**(含 `control.ps1` 解析期错误、连接保活缺 pong 检测等)。
|
||||
> 详见 `docs/VM-VERIFICATION.md`。
|
||||
|
||||
### 新增:语义可断言(v1.6 / v1.7)
|
||||
### 新增:语义可断言(v1.6.0 / v2.0.0)
|
||||
|
||||
- **`gui.await`** —— 等条件成立再返回。四类条件(`window` / `control` / `process` / `file`)
|
||||
× 两种状态(`present` / `absent`),复用既有能力,零新增平台探测代码。
|
||||
@@ -31,7 +75,7 @@
|
||||
例:`[{window:"安装完成"},{control:"错误"}]` = 「装成功或报错,先出现的算」。
|
||||
- CLI:`nodeagent await`;MCP:`na_await`。
|
||||
|
||||
### 新增:状态可回看(v1.8)
|
||||
### 新增:状态可回看(v2.0.0)
|
||||
|
||||
- **`log.query`** —— 日志**在被控端侧过滤**后只回匹配行(`pattern` / `level` / `since` /
|
||||
`offset` / `limit` / `tail`)。流式逐行读,几万行日志也不整份拉回。
|
||||
@@ -40,7 +84,7 @@
|
||||
`metric`(CPU·内存)。**摘要直接给结论**:断了几次、最长断多久、值域。
|
||||
- CLI:`nodeagent monitor` / `log`;MCP:`na_monitor` / `na_log`。
|
||||
|
||||
### 新增:安全加固(v23 / v24 / v25)
|
||||
### 新增:安全加固(v2.0.0)
|
||||
|
||||
- **连接层三防护**:
|
||||
- 握手失败封禁 —— 连续失败达阈值 → 指数退避(封顶 24h),成功即清零,支持白名单豁免
|
||||
|
||||
@@ -34,7 +34,7 @@ nodeagent 把一台 Windows 机器的能力**标准化成一组可授权、可
|
||||
| **状态采样** | `log.query` · `monitor.start` · `monitor.report` · `monitor.stop` · `monitor.list` · `monitor.delete` | 日志**在被控端侧过滤**(不上传整份)/ 定时采样落盘 + **摘要回看**(断几次、最长断多久、CPU/内存值域) |
|
||||
| **审计锚定** | `system.audit.head` · `system.audit.anchor` | 链头可读 + 锚点**追加**落盘,让「有 root 者整链重写」可被发现 |
|
||||
|
||||
## 快捷键(hotkey)控制 —— v1.5
|
||||
## 快捷键(hotkey)控制 —— v1.5.0
|
||||
|
||||
`input.key.press` 支持 **4 种输入形式**(一次只用一种):
|
||||
|
||||
@@ -84,7 +84,7 @@ OEM 符号键(`oem_plus`/`oem_comma`/`oem_period`… → 支持 `win+d`、`ctr
|
||||
|
||||
> MCP 侧同名工具为 `na_key`(`action: "press"`),支持同一套参数。
|
||||
|
||||
## 等待条件 gui.await —— v1.6
|
||||
## 等待条件 gui.await —— v1.6.0
|
||||
|
||||
GUI 操作从「按一下→睡几秒→截图碰运气」变成**可断言**流程:
|
||||
|
||||
@@ -105,7 +105,7 @@ nodeagent --node win invoke gui.await --args '{"condition":"file","path":"C:\\lo
|
||||
|
||||
**典型闭环**:`await control(text:"立即安装")` → `mouse.click(x, y)` → `await control(text:"完成", timeout_ms:60000)` → `click` → `await absent(text:"安装中")`。
|
||||
|
||||
### 属性谓词与组合(v1.7)
|
||||
### 属性谓词与组合(v2.0.0)
|
||||
|
||||
`screen.find` 现在返回 UIA 属性,`gui.await` 可据此等**状态**而不只是「出现」:
|
||||
|
||||
@@ -130,7 +130,7 @@ nodeagent --node win await --any_of '[{"condition":"window","title":"安装完
|
||||
|
||||
---
|
||||
|
||||
## 状态采样:日志过滤与定时采样 —— v1.8
|
||||
## 状态采样:日志过滤与定时采样 —— v2.0.0
|
||||
|
||||
治**间歇性**问题(代理不通、杀软拦截、端口时开时闭)—— `system.status` 只能看当下快照,
|
||||
而 `event.watch` 只解决「有事件时通知」,没有「**持续记录并回看**」。
|
||||
@@ -151,7 +151,7 @@ nodeagent --node win monitor stop probe
|
||||
|
||||
---
|
||||
|
||||
## 审计链外部锚定 —— v25
|
||||
## 审计链外部锚定 —— v2.0.0
|
||||
|
||||
链内哈希只能发现「改一条」。**有 root 的攻击者可以整链重写** —— 从零重建一份自洽的假日志,
|
||||
`audit.verify` 照样通过。外部锚定是唯一能戳破它的手段:把链头记到**链外**。
|
||||
@@ -168,7 +168,7 @@ nodeagent --node win audit anchor --note "每日例行" # 追加一个锚点
|
||||
> 真正发挥价值的前提:把锚点文件指向**另一台机器 / 网盘同步目录**。
|
||||
> 本机文件被整盘重写时,锚点也随之消失 —— 那就等于没锚。
|
||||
|
||||
**51 项能力** · **41 个 MCP 工具** · **269 项单元测试** · **30 项端到端用例** · **9 项 Rust 单测**(CI 在真实 Windows / Linux runner 上验证)
|
||||
**51 项能力** · **41 个 MCP 工具** · **269 项单元测试** · **30 项端到端用例** · **3 项 Rust 壳单测**(真跑 Node sidecar)(CI 在真实 Windows / Linux runner 上验证)
|
||||
|
||||
> 关键里程碑:**GUI 语义**(`window.list` + `screen.find`,UIA 找不到自动降级 OCR)
|
||||
> 让 AI 从「看得到画面但读不懂界面」变成「按名字取坐标点下去」。
|
||||
@@ -340,7 +340,7 @@ nodeagent pull "C:\big.iso" --out ./big.iso # 下载(大文件自动
|
||||
由控制台经 loopback 直连(复用同一个 Agent,不做第二套被控端实现)。
|
||||
|
||||
技术栈 Tauri 2(Rust 壳 + 系统 WebView)+ Vue 3 + TypeScript + Pinia + Naive UI。
|
||||
协议客户端是 `crates/nodeagent-client`(Rust 复刻 `@nodeagent/client`,
|
||||
协议客户端由 **Node sidecar**(`apps/console/sidecar/`)承担(复用 `@nodeagent/client`,
|
||||
Tauri 侧经 Cargo path 依赖),**不引入第二套协议实现**。
|
||||
|
||||
| 页面 | 能力 |
|
||||
@@ -605,46 +605,53 @@ nodeagent/
|
||||
├── scripts/ # install.cmd / control.cmd(一键入口)/ install.ps1 / control.ps1 / verify.mjs
|
||||
├── tests/e2e/ # 端到端测试
|
||||
├── docs/ # 开发文档
|
||||
├── PRD.md # 产品需求文档
|
||||
├── docs/ # 项目文档(含 PRD)
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 路线图
|
||||
|
||||
| 阶段 | 目标 | 状态 |
|
||||
> 下表「**批次**」是**开发阶段的内部代号**(一批相关改动的集合),**不是版本号** ——
|
||||
> 项目的唯一版本号是 semver(见 `git tag` / `package.json`)。批次与发布版本的对应关系
|
||||
> 及书写规范见 [`docs/VERSIONING.md`](docs/VERSIONING.md) §0。
|
||||
|
||||
| 阶段(开发批次) | 目标 | 状态 |
|
||||
|---|---|---|
|
||||
| **v1** | 命令级接管 + 装软件 + 查状态 + 基本鉴权 + CLI + MCP | ✅ |
|
||||
| **v2** | 图形接管(`screen.*` 截屏 + `input.*` 键鼠)+ 输入控制开关 | ✅ |
|
||||
| **v3** | 零信任:Ed25519 身份认证 + 能力级 ACL | ✅ |
|
||||
| **v3+** | 审计日志(留痕 + 查询)+ 调用限速 | ✅ |
|
||||
| **v4** | 无感体验:局域网自动发现 + 断线自动重连 | ✅ |
|
||||
| **v5** | 多设备管理 + 文件传输(分块 / 大文件 / 原子写) | ✅ |
|
||||
| **v6** | Hub 中转:跨网段 / 公网接入(被控端主动外连,穿 NAT) | ✅ |
|
||||
| **v7** | **自持**:受控重启(配置变更后让自身生效,不再丢连接) | ✅ |
|
||||
| **v8** | **GUI 语义**:`window.list` / `window.focus` / `screen.find`(UIA 按名取坐标) | ✅ |
|
||||
| **v9** | `screen.find` **OCR 兜底**(自绘 UI 也能定位)+ MCP 工具同步 | ✅ |
|
||||
| **v10** | **长任务异步化**(`system.task.*`)+ 剪贴板 + CLI 常驻连接池 daemon | ✅ |
|
||||
| **v11** | **安全加固**:私钥入系统密钥库(Keychain/DPAPI)+ 审计链防篡改 + ACL 细化(IP/时段/按能力限速) | ✅ |
|
||||
| **v11+** | **操作面补完**:鼠标拖拽 / 录屏 / 剪贴板图片 / 多设备并发 / 一键升级 | ✅ |
|
||||
| **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 更稳) | 🚧 |
|
||||
| **批次 1** | 命令级接管 + 装软件 + 查状态 + 基本鉴权 + CLI + MCP | ✅ |
|
||||
| **批次 2** | 图形接管(`screen.*` 截屏 + `input.*` 键鼠)+ 输入控制开关 | ✅ |
|
||||
| **批次 3** | 零信任:Ed25519 身份认证 + 能力级 ACL | ✅ |
|
||||
| **批次 3+** | 审计日志(留痕 + 查询)+ 调用限速 | ✅ |
|
||||
| **批次 4** | 无感体验:局域网自动发现 + 断线自动重连 | ✅ |
|
||||
| **批次 5** | 多设备管理 + 文件传输(分块 / 大文件 / 原子写) | ✅ |
|
||||
| **批次 6** | Hub 中转:跨网段 / 公网接入(被控端主动外连,穿 NAT) | ✅ |
|
||||
| **批次 7** | **自持**:受控重启(配置变更后让自身生效,不再丢连接) | ✅ |
|
||||
| **批次 8** | **GUI 语义**:`window.list` / `window.focus` / `screen.find`(UIA 按名取坐标) | ✅ |
|
||||
| **批次 9** | `screen.find` **OCR 兜底**(自绘 UI 也能定位)+ MCP 工具同步 | ✅ |
|
||||
| **批次 10** | **长任务异步化**(`system.task.*`)+ 剪贴板 + CLI 常驻连接池 daemon | ✅ |
|
||||
| **批次 11** | **安全加固**:私钥入系统密钥库(Keychain/DPAPI)+ 审计链防篡改 + ACL 细化(IP/时段/按能力限速) | ✅ |
|
||||
| **批次 11+** | **操作面补完**:鼠标拖拽 / 录屏 / 剪贴板图片 / 多设备并发 / 一键升级 | ✅ |
|
||||
| **批次 12** | **事件订阅**(file/process/net 主动推送)+ **GUI 宏**(步骤序列回放)+ **Hub 并发多控制端** + 设备分组 | ✅ |
|
||||
| **批次 12.1** | **Hub 侧按控制端授权**(多令牌 + 按设备白/黑名单 + 注册准入) | ✅ |
|
||||
| **批次 12.2** | GUI **等待/重试语义**(`wait_ms`)—— 界面加载/动画不再假失败 | ✅ |
|
||||
| **批次 12.3** | **macOS 端 GUI 对齐**:AppleScript 窗口枚举/聚焦 + Vision OCR 元素定位(含 Retina 坐标换算) | ✅ |
|
||||
| **批次 12.4** | **事件直达 MCP 宿主**(实时推送 + 拉取双通路) | ✅ |
|
||||
| **批次 13** | **成功指标聚合**(PRD 2.2 四项)+ 依真实数据修正口径(时延分层) | ✅ |
|
||||
| **批次 14** | **PowerShell 常驻助手** —— GUI 能力提速 4~25 倍(预加载 C#/UIA,免每次现编译) | ✅ |
|
||||
| **批次 15** | `screen.find` **图像模板匹配**(纯图标/无文字控件盲区)+ 一堆真机根因修复 | ✅ |
|
||||
| **批次 16 ~ 26** | 网络两阶段提交 · 音频控制 · 构建指纹 · 自更新 · 证书钉住 · 每客户端 PSK · 连接层三防护 · 零信任安装 · 审计锚定 | ✅ |
|
||||
| **批次 27 ~ 29** | **语义可断言**(`gui.await` + UIA 属性 + `where` 谓词 + `any_of`)· **状态可回看**(`log.query` + `monitor.*`) | ✅ |
|
||||
| **发布** | **v2.0.0**(2026-10-10):51 项能力 · 273 单测 · Windows/Rust CI 全绿 | ✅ 已发布 |
|
||||
|
||||
## 文档
|
||||
|
||||
- [`docs/SECURITY.md`](docs/SECURITY.md) —— **安全模型与加固指南**(钥匙=root、证书指纹钉住、网段白名单、应急处置)
|
||||
- [`docs/VERSIONING.md`](docs/VERSIONING.md) —— **版本与兼容性契约**(升级顺序、什么算破坏性、发版流程、更新方式)
|
||||
- [`docs/REMOTE-LIMITS.md`](docs/REMOTE-LIMITS.md) —— 远程能力边界与真实故障案例(含杀软误拦)
|
||||
- [`docs/VM-VERIFICATION.md`](docs/VM-VERIFICATION.md) —— **无 Windows 真机时,用 Parallels 的 Win11 ARM 虚拟机补验证**(含逐条命令与结论标注规则)
|
||||
- [`CHANGELOG.md`](CHANGELOG.md) —— **变更日志**(未发布版本的内容汇总 / 历史版本)
|
||||
- [`docs/ROADMAP.md`](docs/ROADMAP.md) —— **当前进度盘点与下一步路线**(2026-10-07 基线:待办收尾 / 语义可断言 / 状态采样 / 安全残余)
|
||||
|
||||
- [产品需求文档(PRD)](./PRD.md)
|
||||
- [产品需求文档(PRD)](./docs/PRD.md)
|
||||
- [开发文档(DEVELOPMENT)](./docs/DEVELOPMENT.md) —— 协议细节、能力 schema、安全模型、各端实现指南
|
||||
- [**跨机接管的边界与局限**](./docs/REMOTE-LIMITS.md) —— 哪些问题能远程修、哪些必须人工,附真实故障诊断案例
|
||||
- [**实战复盘:短板与改进优先级**](./docs/RETROSPECTIVE.md) —— 基于真机使用的短板分析(GUI 语义 / 编码 / 自愈 / 可观测性)。**写于 2026-09-28(19 项能力时),多数 P0/P1 已解决 —— 最新状态见 [ROADMAP](./docs/ROADMAP.md) §3 对照表**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/agent",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
+17
-59
@@ -2,6 +2,12 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSy
|
||||
import { createHash } from 'node:crypto';
|
||||
import { join } from 'node:path';
|
||||
import { agentDir } from './config.js';
|
||||
import {
|
||||
judgeAnchors,
|
||||
parseAnchorsFromJsonl,
|
||||
type AnchorComparison,
|
||||
type AnchorRecord,
|
||||
} from '@nodeagent/protocol';
|
||||
|
||||
export type AuditType =
|
||||
| 'agent.start'
|
||||
@@ -18,7 +24,7 @@ export type AuditType =
|
||||
| 'net.deny'
|
||||
/** v19:agent 自更新(拉取式) */
|
||||
| 'agent.update'
|
||||
/** v23:连接空闲被主动断开 */
|
||||
/** v2.0.0:连接空闲被主动断开 */
|
||||
| 'session.idle_close';
|
||||
|
||||
/** 一条审计记录(JSONL 单行)。 */
|
||||
@@ -336,7 +342,7 @@ export function readAudit(query: AuditQuery = {}): { entries: AuditEntry[]; tota
|
||||
}
|
||||
|
||||
|
||||
// ---------------- v25 审计链外部锚定 ----------------
|
||||
// ---------------- v2.0.0 审计链外部锚定 ----------------
|
||||
//
|
||||
// ## 为什么需要
|
||||
// 审计链的哈希使**链内**篡改可被发现(改一条就要重算其后全部)。但「有 root 的攻击者」
|
||||
@@ -389,14 +395,9 @@ export function computeAuditHead(): AuditHead {
|
||||
};
|
||||
}
|
||||
|
||||
export interface AnchorRecord {
|
||||
ts: number;
|
||||
entries: number;
|
||||
head_hash: string | null;
|
||||
head_ts: number | null;
|
||||
rotated_segments: number;
|
||||
note?: string;
|
||||
}
|
||||
// 锚点记录与比对结果的定义**移到了 protocol**(唯一实现,控制端脚本也用它)——
|
||||
// 见 packages/protocol/src/audit-anchor.ts 顶部的说明。此处 re-export 以保持既有导入路径可用。
|
||||
export type { AnchorRecord, AnchorComparison } from '@nodeagent/protocol';
|
||||
|
||||
/** 锚点文件路径(默认与审计同目录)。 */
|
||||
export function anchorFilePath(custom?: string): string {
|
||||
@@ -429,13 +430,6 @@ export function anchorAudit(opts: { path?: string; note?: string } = {}): {
|
||||
return { file, record: rec, total_lines: total };
|
||||
}
|
||||
|
||||
export interface AnchorComparison {
|
||||
ok: boolean;
|
||||
anchors_checked: number;
|
||||
latest?: AnchorRecord;
|
||||
verdict: string;
|
||||
detail?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 与最近一条锚点比对,判断当前链是否与锚定时刻自洽。
|
||||
@@ -450,52 +444,16 @@ export function compareWithAnchors(customPath?: string): AnchorComparison {
|
||||
if (!existsSync(file)) {
|
||||
return { ok: true, anchors_checked: 0, verdict: '尚无锚点:请先调用 system.audit.anchor 建立外部锚点' };
|
||||
}
|
||||
let anchors: AnchorRecord[] = [];
|
||||
let anchors: AnchorRecord[] | null = null;
|
||||
try {
|
||||
anchors = readFileSync(file, 'utf8')
|
||||
.split('\n')
|
||||
.filter((l) => l.trim().length > 0)
|
||||
.map((l) => JSON.parse(l) as AnchorRecord);
|
||||
anchors = parseAnchorsFromJsonl(readFileSync(file, 'utf8'));
|
||||
} catch {
|
||||
return { ok: false, anchors_checked: 0, verdict: '锚点文件损坏,无法比对' };
|
||||
}
|
||||
if (anchors.length === 0) {
|
||||
if (!anchors) {
|
||||
return { ok: true, anchors_checked: 0, verdict: '锚点文件为空' };
|
||||
}
|
||||
const latest = anchors[anchors.length - 1]!;
|
||||
const head = computeAuditHead();
|
||||
|
||||
if (head.entries < latest.entries && head.rotated_segments <= latest.rotated_segments) {
|
||||
return {
|
||||
ok: false,
|
||||
anchors_checked: anchors.length,
|
||||
latest,
|
||||
verdict: '**疑似整链重写/回滚**:当前条目数少于锚点,且没有发生轮转来解释',
|
||||
detail: { anchored_entries: latest.entries, current_entries: head.entries, anchored_hash: latest.head_hash, current_hash: head.head_hash },
|
||||
};
|
||||
}
|
||||
if (head.rotated_segments === latest.rotated_segments && head.entries === latest.entries) {
|
||||
if (head.head_hash !== latest.head_hash) {
|
||||
return {
|
||||
ok: false,
|
||||
anchors_checked: anchors.length,
|
||||
latest,
|
||||
verdict: '**链头哈希与锚点不一致**(条目数相同却哈希不同 → 该段被重写)',
|
||||
detail: { anchored_hash: latest.head_hash, current_hash: head.head_hash, entries: head.entries },
|
||||
};
|
||||
}
|
||||
return { ok: true, anchors_checked: anchors.length, latest, verdict: '与锚点完全一致(条目数、轮转段数、链头哈希三者相符)' };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
anchors_checked: anchors.length,
|
||||
latest,
|
||||
// ⚠️ 注意:锚定动作**自身**也会被记入审计(server 层统一记录所有 invoke),
|
||||
// 所以「刚锚定就比对」几乎必然看到"已增长"——这是正常现象,不是篡改。
|
||||
// 想看到"完全一致",需在同一时刻不再产生新审计(实际很难,属预期)。
|
||||
verdict:
|
||||
'链已增长(或发生轮转)—— 锚点仍在链上,无需人工核对到该锚点为止的部分。' +
|
||||
'(刚锚定就比对通常显示"已增长",因为锚定调用自身也会写一条审计。)',
|
||||
detail: { anchored_entries: latest.entries, current_entries: head.entries, rotated_delta: head.rotated_segments - latest.rotated_segments, anchored_hash: latest.head_hash, current_hash: head.head_hash },
|
||||
};
|
||||
// 判定逻辑在 protocol(judgeAnchors)—— 与「控制端对云盘锚点」的比对共用同一份实现,
|
||||
// 避免两处规则漂移(那会导致同一份日志两端结论不同)。
|
||||
return judgeAnchors(computeAuditHead(), anchors);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* gui.await —— 等待条件成立(v1.6)。
|
||||
* gui.await —— 等待条件成立(v1.6.0)。
|
||||
*
|
||||
* ## 为什么需要它(复盘三大短板之「语义」)
|
||||
* 原来的 GUI 操作是**盲试**:按一下、截个图、看看有没有变化,不行再试。
|
||||
@@ -63,7 +63,7 @@ function buildInnerArgs(condition: Condition, args: Args): Args {
|
||||
for (const k of ['text', 'window', 'control_type', 'method', 'template', 'limit', 'timeout_ms']) {
|
||||
if (args[k] !== undefined) inner[k] = args[k];
|
||||
}
|
||||
// v1.7:属性谓词(仅 UIA 引擎生效)。有 where 时允许不传 text ——
|
||||
// v2.0.0:属性谓词(仅 UIA 引擎生效)。有 where 时允许不传 text ——
|
||||
// 「等一个 enabled 的按钮」比「等一个叫某名字的按钮」更贴近真实意图。
|
||||
const where = args['where'];
|
||||
if (where !== undefined) {
|
||||
@@ -117,7 +117,7 @@ function innerProbe(condition: Condition): (args: Args) => Promise<unknown> {
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.7 组合条件:`any_of: [ {...}, {...} ]`,任一命中原句即算命中。
|
||||
* v2.0.0 组合条件:`any_of: [ {...}, {...} ]`,任一命中原句即算命中。
|
||||
*
|
||||
* 为什么只做 any_of 不做 and/or/not generalize:真实场景里"弹窗出现 **且**
|
||||
* 其中某按钮 enabled"用 any_of(弹窗) + where(按钮 enabled) 两步就能表达,
|
||||
@@ -152,7 +152,7 @@ function buildAnyOf(args: Args): Array<{ condition: Condition; inner: Args; labe
|
||||
}
|
||||
|
||||
export async function guiAwait(args: Args): Promise<unknown> {
|
||||
// v1.7:有 any_of 时**不要求**顶层 condition(语义就是"这组条件任一命中"),
|
||||
// v2.0.0:有 any_of 时**不要求**顶层 condition(语义就是"这组条件任一命中"),
|
||||
// 校验顺序也因此调整为 any_of 先行 —— 否则会先撞上"condition 缺失"的报错。
|
||||
const condition = args['condition'] as Condition | undefined;
|
||||
if (!args['any_of'] && (!condition || !CONDITIONS.includes(condition))) {
|
||||
@@ -175,7 +175,7 @@ export async function guiAwait(args: Args): Promise<unknown> {
|
||||
let lastHit = false;
|
||||
let lastError: string | null = null;
|
||||
|
||||
// v1.7:组合条件走单独路径(任一命中即算),失败语义与单条完全一致
|
||||
// v2.0.0:组合条件走单独路径(任一命中即算),失败语义与单条完全一致
|
||||
const anyOf = args['any_of'] !== undefined ? buildAnyOf(args) : null;
|
||||
if (anyOf) {
|
||||
for (;;) {
|
||||
|
||||
@@ -54,9 +54,9 @@ export function createCapabilityRegistry(cfg: AgentConfig): Record<string, Capab
|
||||
[CapabilityNames.MouseDrag]: mouseDrag,
|
||||
[CapabilityNames.KeyType]: keyType,
|
||||
[CapabilityNames.KeyPress]: keyPress,
|
||||
// v1.6 等待条件(把盲试变为可断言)
|
||||
// v1.6.0 等待条件(把盲试变为可断言)
|
||||
[CapabilityNames.GuiAwait]: guiAwait,
|
||||
// v1.8 状态采样(方向二)
|
||||
// v2.0.0 状态采样(方向二)
|
||||
[CapabilityNames.LogQuery]: logQuery,
|
||||
[CapabilityNames.MonitorStart]: monitorStart,
|
||||
[CapabilityNames.MonitorReport]: monitorReport,
|
||||
@@ -67,7 +67,7 @@ export function createCapabilityRegistry(cfg: AgentConfig): Record<string, Capab
|
||||
[CapabilityNames.AuditList]: auditList,
|
||||
// v11 审计防篡改
|
||||
[CapabilityNames.AuditVerify]: auditVerify,
|
||||
// v25 审计链外部锚定(防整链重写)
|
||||
// v2.0.0 审计链外部锚定(防整链重写)
|
||||
[CapabilityNames.AuditHead]: auditHead,
|
||||
[CapabilityNames.AuditAnchor]: auditAnchor,
|
||||
// v13 成功指标
|
||||
|
||||
@@ -45,13 +45,13 @@ export const VK_MAP: Record<string, number> = {
|
||||
launch_mail: 0xb4, launch_media: 0xb5, launch_app1: 0xb6, launch_app2: 0xb7,
|
||||
browser_favorites: 0xab, browser_home: 0xac,
|
||||
|
||||
// ---- 左右侧修饰键(v1.5)----
|
||||
// ---- 左右侧修饰键(v1.5.0)----
|
||||
// 游戏/部分应用区分左右(如游戏中把 LAlt 设为跑步、RAlt 设为瞄准),故不能只给一个 alt。
|
||||
lctrl: 0xa2, rctrl: 0xa3, lshift: 0xa0, rshift: 0xa1,
|
||||
lalt: 0xa4, ralt: 0xa5, lwin: 0x5b, rwin: 0x5c,
|
||||
super: 0x5b, cmd: 0x5b, left_cmd: 0x5b, right_cmd: 0x5c,
|
||||
|
||||
// ---- 小键盘(v1.5)----
|
||||
// ---- 小键盘(v1.5.0)----
|
||||
// numpadN 与数字 N 分开:Excel 等程序靠 NumLock 状态区分二者行为,合成一个会坏掉快捷键。
|
||||
numpad0: 0x60, numpad1: 0x61, numpad2: 0x62, numpad3: 0x63, numpad4: 0x64,
|
||||
numpad5: 0x65, numpad6: 0x66, numpad7: 0x67, numpad8: 0x68, numpad9: 0x69,
|
||||
@@ -59,7 +59,7 @@ export const VK_MAP: Record<string, number> = {
|
||||
numlock: 0x90, scrolllock: 0x91,
|
||||
numpad_enter: 0x0d,
|
||||
|
||||
// ---- OEM 符号键(v1.5,美式布局 VK 码)----
|
||||
// ---- OEM 符号键(v1.5.0,美式布局 VK 码)----
|
||||
// 必备理由:大量系统/应用快捷键就是"修饰键 + 符号键"(win+d 显示桌面、win+, 速览、
|
||||
// ctrl++ 放大、ctrl+- 缩小、win+. 表情面板),此前只能靠字母数字,覆盖不到。
|
||||
oem_1: 0xba, // : ;
|
||||
@@ -75,7 +75,7 @@ export const VK_MAP: Record<string, number> = {
|
||||
oem_comma: 0xbc, // , <
|
||||
oem_period: 0xbe,// . >
|
||||
|
||||
// ---- 系统/IME 杂项(v1.5)----
|
||||
// ---- 系统/IME 杂项(v1.5.0)----
|
||||
sleep: 0x5f, help: 0x2f, select: 0x29, execute: 0x2b, clear: 0x0c,
|
||||
separator: 0x6c, oem_clear: 0xfe,
|
||||
kana: 0x15, hangul: 0x15, kanji: 0x19, hanja: 0x19,
|
||||
|
||||
@@ -183,7 +183,7 @@ public static class NAInput {
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// v1.5:向目标进程的**全部顶层窗口** PostMessage 键盘消息(后端定向快捷键投放)。
|
||||
/// v1.5.0:向目标进程的**全部顶层窗口** PostMessage 键盘消息(后端定向快捷键投放)。
|
||||
///
|
||||
/// 场景:目标窗口不在前台(被遮挡 / 最小化 / 聊天窗口在后台),又要给它发快捷键。
|
||||
/// WM_KEYDOWN=0x0100,WM_KEYUP=0x0101;修饰键按顺序投递,调用方负责释放顺序。
|
||||
@@ -364,7 +364,7 @@ export async function keyPress(args: Args): Promise<unknown> {
|
||||
assertAllowed('input.key.press');
|
||||
requireWindows('input.key.press');
|
||||
|
||||
// v1.5:入参统一经 protocol 的 expandChords 展开 —— 字符串热键 / 预设名 / 老数组形式
|
||||
// v1.5.0:入参统一经 protocol 的 expandChords 展开 —— 字符串热键 / 预设名 / 老数组形式
|
||||
// 全部收敛成「归一化和弦序列」。混用多种形式会直接报错,避免"以谁为准"的歧义。
|
||||
let expanded;
|
||||
try {
|
||||
@@ -384,9 +384,9 @@ export async function keyPress(args: Args): Promise<unknown> {
|
||||
const presets = expanded.presets;
|
||||
|
||||
const gap = Math.max(0, Math.min(2000, (args['interval_ms'] as number) ?? 40));
|
||||
// v1.5:长按(按下到释放的保持时长),用于"长按音量/持续按住方向键"等场景
|
||||
// v1.5.0:长按(按下到释放的保持时长),用于"长按音量/持续按住方向键"等场景
|
||||
const holdMs = Math.max(0, Math.min(5000, Math.round((args['hold_ms'] as number | undefined) ?? 0)));
|
||||
// v1.5:投放路由。foreground = SendInput 注入当前焦点(默认,旧行为);
|
||||
// v1.5.0:投放路由。foreground = SendInput 注入当前焦点(默认,旧行为);
|
||||
// post = 向目标进程全部顶层窗口 PostMessage(窗口在后台时可用,但兼容性差)
|
||||
const route = (args['route'] as string | undefined) ?? 'foreground';
|
||||
if (route !== 'foreground' && route !== 'post') {
|
||||
@@ -423,7 +423,7 @@ export async function keyPress(args: Args): Promise<unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
// v1.5:后端定向投递(route=post)—— 目标窗口不在前台时使用
|
||||
// v1.5.0:后端定向投递(route=post)—— 目标窗口不在前台时使用
|
||||
if (route === 'post') {
|
||||
let sent = 0;
|
||||
const out = await runPs(buildPostBody(chords, { gapMs: gap, holdMs, targetPid: pid, route }));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* log.query(v1.8,方向二「状态」第一块)。
|
||||
* log.query(v2.0.0,方向二「状态」第一块)。
|
||||
*
|
||||
* ## 为什么必须有
|
||||
* 治间歇性问题(代理不通、杀软拦截、端口时开时闭)要看**历史**,而被控端日志常在
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* monitor.*(v1.8,方向二「状态」第二块)—— 定时采样落盘与回看。
|
||||
* monitor.*(v2.0.0,方向二「状态」第二块)—— 定时采样落盘与回看。
|
||||
*
|
||||
* ## 为什么需要(复盘 §四)
|
||||
* 代理不通、杀软拦截、端口时开时闭都是**间歇性**的,而 `system.status` 只能看当下快照。
|
||||
|
||||
@@ -68,7 +68,7 @@ async function runPsFile(script: string, timeoutMs: number, tag = 'ps'): Promise
|
||||
* 生成的 .ps1 **必须带 UTF-8 BOM**。
|
||||
*
|
||||
* ⚠️ 真机踩过:PowerShell 5.1 读**无 BOM** 的 .ps1 会按系统 ANSI(GBK) 解析,
|
||||
* 脚本里的中文(如网卡名「Ethernet」)会吃掉后面的闭合引号 →
|
||||
* 脚本里的非 ASCII 字符(如中文网卡名)会吃掉后面的闭合引号 →
|
||||
* 报「The string is missing the terminator」→ 计划任务退出码 1 且不留日志。
|
||||
* 所有落盘的 PowerShell 脚本一律经此函数。
|
||||
*/
|
||||
|
||||
@@ -534,9 +534,16 @@ async function scheduleWindowsRestart(p: RestartParams): Promise<unknown> {
|
||||
// (= 自杀,表现为"restart 成功但 agent 消失")。原实现只清非 Running,
|
||||
// 代价是真机累积了 3 个 Running 僵尸(2026-10-09 实测)。
|
||||
//
|
||||
// 现策略:非 Running **或** LastRunTime 早于 1 小时 → 清。
|
||||
// 1 小时余量远大于一次重启的耗时,因此"当前宿主"绝不会被误判(它刚刚才运行)。
|
||||
`$cut = (Get-Date).AddHours(-1)`,
|
||||
// 现策略:非 Running **或** LastRunTime **早于"本 agent 启动时刻"** → 清。
|
||||
//
|
||||
// ⚠️ 2026-10-11 真机(Parallels Win11 ARM)修正:原先用「早于 1 小时」作阈值,
|
||||
// 实测发现**频繁 restart(间隔 < 1h)时旧任务照样累积**(v26 想治的僵尸只是从
|
||||
// "永不清理"变成"1 小时后才清")。根因是阈值选得太粗:
|
||||
// 阈值应表达「**这个任务是不是我(当前 agent)的宿主**」——
|
||||
// 凡在**我启动之前**就运行的任务,必然不是我的宿主(我的宿主是刚刚才创建的),
|
||||
// 因此可以安全删除。用 uptime 换算出的启动时刻正是这个边界。
|
||||
`$agentStart = (Get-Date).AddMilliseconds(-${Math.max(0, Math.round(process.uptime() * 1000))})`,
|
||||
`$cut = $agentStart`,
|
||||
`Get-ScheduledTask -TaskName 'nodeagent-selfrestart*' -ErrorAction SilentlyContinue | Where-Object { if ($_.State -ne 'Running') { return $true }; $ti = ($_ | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue); if ($ti -and $ti.LastRunTime -and $ti.LastRunTime -lt $cut) { return $true }; return $false } | Unregister-ScheduledTask -Confirm:$false -ErrorAction SilentlyContinue`,
|
||||
`$a = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument '${cmdLine.replace(/'/g, "''")}'`,
|
||||
// 不限时(默认 72h 后强制结束),保证 node 能长期运行
|
||||
@@ -616,7 +623,7 @@ export async function auditVerify(_args: Args): Promise<unknown> {
|
||||
}
|
||||
|
||||
/**
|
||||
* v25:返回审计链的链头(只读)。
|
||||
* v2.0.0:返回审计链的链头(只读)。
|
||||
*
|
||||
* 用途:控制端定期拉取并存到**链外**(Mac 本地 / 另一台机器)—— 这是"外部锚定"的一半。
|
||||
* compare=true 时附带与最近一条锚点的比对结论(另一半)。
|
||||
@@ -628,7 +635,7 @@ export async function auditHead(args: Args): Promise<unknown> {
|
||||
}
|
||||
|
||||
/**
|
||||
* v25:把当前链头**追加**写到锚点文件(默认 <数据目录>/audit-anchors.jsonl)。
|
||||
* v2.0.0:把当前链头**追加**写到锚点文件(默认 <数据目录>/audit-anchors.jsonl)。
|
||||
*
|
||||
* 为什么是追加而不是覆盖:历史锚点一旦写成就不可被后续覆盖,否则攻击者重写链后
|
||||
* 再"刷新"锚点即可抹掉痕迹。追加式让每次锚定都留痕。
|
||||
|
||||
@@ -132,7 +132,7 @@ function getTask(id: string): TaskRecord {
|
||||
|
||||
// ---------------- system.task.list ----------------
|
||||
|
||||
/** v23:正在跑的任务数(连接层"有空闲断开但不动长任务"的判据)。 */
|
||||
/** v2.0.0:正在跑的任务数(连接层"有空闲断开但不动长任务"的判据)。 */
|
||||
export function runningTaskCount(): number {
|
||||
let n = 0;
|
||||
for (const t of tasks.values()) if (t.state === 'running') n += 1;
|
||||
|
||||
@@ -32,7 +32,7 @@ function Get-WinByTitle([string]$re) {
|
||||
|
||||
# v14:按子串在给定 scope 内遍历查找(FindAll + 子串过滤)。
|
||||
# UIA 的 PropertyCondition 只能精确匹配,故必须遍历后过滤;遍历规模设上限防超大 UI 树卡死。
|
||||
# v23:读取元素的 UIA 属性(v1.7 语义属性化)。
|
||||
# v2.0.0:读取元素的 UIA 属性(v2.0.0 语义属性化)。
|
||||
# 为什么逐个 try:ValuePattern / SelectionItemPattern / TogglePattern 并非所有控件
|
||||
# 都支持(不支持时 GetSupportedPattern 返回 false 或抛异常),而且**部分应用
|
||||
# (Electron / 游戏 UI)根本给不出值** —— 拿不到就留空,由上层判"未知"而非"false"。
|
||||
|
||||
@@ -300,7 +300,7 @@ export async function screenFind(args: Args): Promise<unknown> {
|
||||
}
|
||||
const text = (args['text'] as string | undefined) ?? '';
|
||||
const wantImage = (args['method'] as string | undefined) === 'image';
|
||||
// v1.8 修正:`where` 可单独使用(不传 text)——「找一个 enabled 的按钮」比
|
||||
// v2.0.0 修正:`where` 可单独使用(不传 text)——「找一个 enabled 的按钮」比
|
||||
// 「找一个叫某名字的按钮」更贴近真实意图。此前只在 gui.await 侧放开了,
|
||||
// screen.find 自身的入口校验没同步,导致直连调用(以及 await 的 where-only)
|
||||
// 被这里的"text 不能为空"拦下(真机 2026-10-09 验证时暴露)。
|
||||
@@ -317,7 +317,7 @@ export async function screenFind(args: Args): Promise<unknown> {
|
||||
}
|
||||
const windowTitle = args['window'] as string | undefined;
|
||||
const controlType = args['control_type'] as string | undefined;
|
||||
// v1.7:属性谓词。仅 UIA 引擎生效;OCR / 图像模板忽略(无属性可读),
|
||||
// v2.0.0:属性谓词。仅 UIA 引擎生效;OCR / 图像模板忽略(无属性可读),
|
||||
// 此时在返回里带 note 说明,避免调用方误以为"按属性过滤过了"。
|
||||
const whereArg = args['where'] as Record<string, unknown> | undefined;
|
||||
const limit = (args['limit'] as number | undefined) ?? 20;
|
||||
@@ -342,7 +342,7 @@ $text = ${JSON.stringify(text)}
|
||||
$limit = ${Number(limit)}
|
||||
$ct = ${JSON.stringify(controlType ?? '')}
|
||||
$winTitle = ${JSON.stringify(windowTitle ?? '')}
|
||||
# v1.7 属性过滤:where 只含 enabled/selected/value/toggle 四类键(白名单式)。
|
||||
# v2.0.0 属性过滤:where 只含 enabled/selected/value/toggle 四类键(白名单式)。
|
||||
#
|
||||
# ⚠️⚠️ 这里踩过**两次**同一个坑,真机 2026-10-09 连修两轮才通:
|
||||
# ✗ 第一版:$where = {"enabled":true} → PS 不认 JSON 字面量,ParseError
|
||||
@@ -626,7 +626,7 @@ $out | ConvertTo-Json -Compress
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
last = await attempt();
|
||||
}
|
||||
// v1.8:where 只在 UIA 引擎下有属性可读 —— 若最终走的是 OCR/image,
|
||||
// v2.0.0:where 只在 UIA 引擎下有属性可读 —— 若最终走的是 OCR/image,
|
||||
// 必须**明确告知**,否则调用方会以为"按属性过滤过了"而误判结果。
|
||||
const whereIgnored = hasWhere && last.engine !== 'uia';
|
||||
return {
|
||||
|
||||
@@ -43,7 +43,7 @@ export interface AgentConfig {
|
||||
allow_from?: string[];
|
||||
|
||||
/**
|
||||
* v23 连接层防护(三项,默认值见 server.ts)。
|
||||
* v2.0.0 连接层防护(三项,默认值见 server.ts)。
|
||||
* 单项缺省即启用默认策略;显式设 0 可关闭对应项。
|
||||
*/
|
||||
security?: {
|
||||
|
||||
@@ -261,7 +261,7 @@ export async function eventPoll(args: Args): Promise<unknown> {
|
||||
}
|
||||
|
||||
/** 连接断开时清理该连接创建的所有订阅(避免 watcher 泄漏)。 */
|
||||
/** v23:当前事件订阅数(空闲断开的豁免判据之一)。 */
|
||||
/** v2.0.0:当前事件订阅数(空闲断开的豁免判据之一)。 */
|
||||
export function watchCount(): number {
|
||||
return watches.size;
|
||||
}
|
||||
|
||||
+30
-11
@@ -48,7 +48,7 @@ interface ConnState {
|
||||
remote: string;
|
||||
/** v12:连接标识,用于清理该连接创建的事件订阅 */
|
||||
connId: string;
|
||||
/** v23:末次活跃时间(任何入站消息),空闲断开的依据 */
|
||||
/** v2.0.0:末次活跃时间(任何入站消息),空闲断开的依据 */
|
||||
lastActiveAt: number;
|
||||
}
|
||||
|
||||
@@ -64,9 +64,9 @@ type LogLevel = 'debug' | 'info' | 'warn';
|
||||
export interface AgentCore {
|
||||
/** 在给定连接上提供被控端服务(连接已建立)。 */
|
||||
attach(ws: WebSocket, remote: string): void;
|
||||
/** v23:该来源封禁剩余毫秒(0 = 不挡)。连接入口在业务握手前调用。 */
|
||||
/** v2.0.0:该来源封禁剩余毫秒(0 = 不挡)。连接入口在业务握手前调用。 */
|
||||
banRemainingMs(remote: string): number;
|
||||
/** v23:连接表(并发上限 / 空闲清扫判据;key 为 WebSocket)。 */
|
||||
/** v2.0.0:连接表(并发上限 / 空闲清扫判据;key 为 WebSocket)。 */
|
||||
states: Map<WebSocket, ConnState>;
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
}
|
||||
};
|
||||
|
||||
// v23:用 Map 而非 WeakMap —— 连接层需要 size(并发上限)与遍历(空闲清扫)。
|
||||
// v2.0.0:用 Map 而非 WeakMap —— 连接层需要 size(并发上限)与遍历(空闲清扫)。
|
||||
// 代价是必须手动清理:close 时 delete,否则断开的连接会一直留在表里。
|
||||
const states = new Map<WebSocket, ConnState>();
|
||||
|
||||
@@ -191,7 +191,7 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
|
||||
state.nonce = null; // 一次性,用后即废
|
||||
state.nonceExpiresAt = 0;
|
||||
recordAuthSuccess(state.remote); // v23:握手成功即清零失败计数(避免误伤手滑的控制端)
|
||||
recordAuthSuccess(state.remote); // v2.0.0:握手成功即清零失败计数(避免误伤手滑的控制端)
|
||||
state.authenticated = true;
|
||||
state.clientId = clientId;
|
||||
state.authorized = authorized;
|
||||
@@ -414,7 +414,7 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- v23 连接层防护:握手失败封禁 ----------
|
||||
// ---------- v2.0.0 连接层防护:握手失败封禁 ----------
|
||||
// 状态放在 core:handleAuth 在此函数内,且 server 侧连接入口也要查它。
|
||||
interface BanState { fails: number; bannedUntil: number }
|
||||
const authBan = new Map<string, BanState>();
|
||||
@@ -470,8 +470,15 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
connId,
|
||||
lastActiveAt: Date.now(),
|
||||
});
|
||||
// v2.0.0 修正:**客户端对 ping 的 pong 回应也算活跃**。
|
||||
// 之前只更新 message 的活跃时间 → 僵尸连接(TCP 未断但客户端已死)不会被判空闲,
|
||||
// 会被永久算进 states.size → 并发上限虚高 → **可能把正常控制端挡在外面**。
|
||||
ws.on('pong', () => {
|
||||
const st = states.get(ws);
|
||||
if (st) st.lastActiveAt = Date.now();
|
||||
});
|
||||
ws.on('message', (raw: RawData) => {
|
||||
// v23:任何入站消息都算活跃(空闲断开以"完全静默"为准,不是"没有调用能力")
|
||||
// v2.0.0:任何入站消息都算活跃(空闲断开以"完全静默"为准,不是"没有调用能力")
|
||||
const st = states.get(ws);
|
||||
if (st) st.lastActiveAt = Date.now();
|
||||
void handleMessage(ws, raw.toString());
|
||||
@@ -481,16 +488,16 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
// v12:连接断开即释放其事件订阅,避免 watcher 泄漏
|
||||
const n = disposeWatchesByOwner(connId);
|
||||
if (n > 0) log('debug', `已清理 ${n} 个事件订阅(连接 ${connId})`);
|
||||
states.delete(ws); // v23:Map 需手动清理(WeakMap 时代靠 GC)
|
||||
states.delete(ws); // v2.0.0:Map 需手动清理(WeakMap 时代靠 GC)
|
||||
});
|
||||
ws.on('error', (err: Error) => log('warn', `连接错误: ${err.message}`));
|
||||
}
|
||||
|
||||
return {
|
||||
attach: attachConnection,
|
||||
/** v23:连接入口用 —— 该来源是否在封禁期(返回剩余毫秒,0=不挡)。 */
|
||||
/** v2.0.0:连接入口用 —— 该来源是否在封禁期(返回剩余毫秒,0=不挡)。 */
|
||||
banRemainingMs,
|
||||
/** v23:连接表(空闲清扫与并发上限判据)。 */
|
||||
/** v2.0.0:连接表(空闲清扫与并发上限判据)。 */
|
||||
states,
|
||||
};
|
||||
}
|
||||
@@ -548,7 +555,19 @@ export function createAgentServer(cfg: AgentConfig, tls: TlsMaterial | null): Pr
|
||||
// WebSocket 层保活:30s ping,探测死连接;v23③ 同时做空闲连接清扫
|
||||
const heartbeat = setInterval(() => {
|
||||
for (const ws of wss.clients) {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.ping();
|
||||
if (ws.readyState !== WebSocket.OPEN) continue;
|
||||
// v2.0.0 修正(真机踩到):**pong 缺失必须主动 terminate**。
|
||||
// ws 库默认不会因「ping 无 pong」而断开 —— 客户端进程已死(TCP 未发 FIN)时,
|
||||
// 连接会一直挂在 states 里,占用 max_connections 名额。
|
||||
// 判据用与空闲清扫同一个 lastActiveAt(pong 已在上方更新它)。
|
||||
const st = core.states.get(ws);
|
||||
if (st && idleTimeoutMs > 0 && Date.now() - st.lastActiveAt > idleTimeoutMs * 2) {
|
||||
log('warn', `连接无响应(ping 未回 pong 且静默超 ${Math.round((Date.now() - st.lastActiveAt) / 1000)}s)→ 强制断开 ${st.connId}`);
|
||||
audit({ type: 'session.idle_close', client_id: st.clientId ?? undefined, remote: st.remote, reason: 'unresponsive (no pong)' });
|
||||
ws.terminate();
|
||||
continue;
|
||||
}
|
||||
ws.ping();
|
||||
}
|
||||
if (idleTimeoutMs <= 0) return;
|
||||
// 保守策略:只要还有任务在跑 / 还有事件订阅,就不断任何连接 ——
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/cli",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -172,7 +172,7 @@ export async function cmdKey(action: string | undefined, positionals: string[],
|
||||
return;
|
||||
}
|
||||
case 'press': {
|
||||
// v1.5:三种用法(一次一种)
|
||||
// v1.5.0:三种用法(一次一种)
|
||||
// nodeagent key press "ctrl+shift+esc" 字符串热键
|
||||
// nodeagent key press copy 预设名
|
||||
// nodeagent key press ctrl c 老形式(空格分隔的各键)
|
||||
@@ -193,7 +193,7 @@ export async function cmdKey(action: string | undefined, positionals: string[],
|
||||
}
|
||||
// 老形式说明:位置参数以空格分隔 → 兼容"ctrl c"(无 + 号)
|
||||
if (opts.interval) args['interval_ms'] = Number(opts.interval);
|
||||
// v1.5 新增可选参数
|
||||
// v1.5.0 新增可选参数
|
||||
if (opts.hold) args['hold_ms'] = Number(opts.hold);
|
||||
if (opts.route) args['route'] = opts.route;
|
||||
// 媒体键可定向到指定进程(播放器常建 30+ 辅助窗口,只投主窗口往往无效)
|
||||
@@ -232,7 +232,7 @@ export async function cmdKey(action: string | undefined, positionals: string[],
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- v1.6 等待条件(gui.await) ----------
|
||||
// ---------- v1.6.0 等待条件(gui.await) ----------
|
||||
|
||||
/**
|
||||
* 等待一个条件成立再返回(只读低危)。
|
||||
@@ -251,7 +251,7 @@ export async function cmdAwait(opts: Options): Promise<void> {
|
||||
fail('用法: nodeagent await --condition <window|control|process|file> --timeout <ms> [--text/--title/--process/--path] [--state absent]');
|
||||
}
|
||||
const args: Record<string, unknown> = { condition };
|
||||
// v1.7:where / any_of 是 JSON 字符串(属性谓词与组合条件不便用命令行开关表达)
|
||||
// v2.0.0:where / any_of 是 JSON 字符串(属性谓词与组合条件不便用命令行开关表达)
|
||||
for (const k of ['where', 'any_of']) {
|
||||
const raw = (opts as unknown as Record<string, string | undefined>)[k];
|
||||
if (raw !== undefined) {
|
||||
|
||||
@@ -395,7 +395,7 @@ export async function cmdDeploy(localFile: string, opts: Options): Promise<void>
|
||||
console.error(` 回滚方式:把备份复制回目标即可 ——`);
|
||||
console.error(` nodeagent exec "Copy-Item '${uploadInfo.backupPath}' '${target}' -Force"`);
|
||||
}
|
||||
console.error(' 排查:被控端杀软(主流杀软/杀软)可能处置了被覆盖的 agent 文件');
|
||||
console.error(' 排查:被控端杀软可能处置了被覆盖的 agent 文件');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -641,7 +641,7 @@ export async function cmdClip(opts: Options): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- v1.8 状态采样(monitor / log) ----------
|
||||
// ---------- v2.0.0 状态采样(monitor / log) ----------
|
||||
|
||||
/**
|
||||
* 状态采样与回看。
|
||||
@@ -732,7 +732,7 @@ export async function cmdMonitor(action: string | undefined, positional: string[
|
||||
}
|
||||
}
|
||||
|
||||
/** 在被控端侧过滤日志(v1.8):日志常几万行,过滤必须发生在被控端。 */
|
||||
/** 在被控端侧过滤日志(v2.0.0):日志常几万行,过滤必须发生在被控端。 */
|
||||
export async function cmdLog(opts: Options): Promise<void> {
|
||||
const logPath = opts.path;
|
||||
if (!logPath) fail('用法: nodeagent log --path <文件> [--pattern 正则] [--level ERROR] [--limit n] [--tail]');
|
||||
|
||||
@@ -183,7 +183,7 @@ export async function cmdRestart(opts: Options): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- v25 审计链外部锚定 ----------
|
||||
// ---------- v2.0.0 审计链外部锚定 ----------
|
||||
|
||||
/**
|
||||
* 打印审计链头(可选与最近锚点比对)。
|
||||
|
||||
@@ -49,12 +49,12 @@ export const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI)
|
||||
nodeagent events [--kind file|process|net] [--path <路径>] [--pattern G] [--seconds 15]
|
||||
订阅事件并实时接收推送(无 --kind 则列出订阅)(v12)
|
||||
nodeagent monitor start --source <port|process|command|metric> [--target X] [--interval ms] [--id 名]
|
||||
启动定时采样并落盘 (v1.8):port 验连通性/process 验存活/
|
||||
启动定时采样并落盘 (v2.0.0):port 验连通性/process 验存活/
|
||||
command 取退出码/metric 取 CPU·内存
|
||||
nodeagent monitor report <id> 回看样本 + 摘要(断几次、最长断多久、值域)(v1.8)
|
||||
nodeagent monitor report <id> 回看样本 + 摘要(断几次、最长断多久、值域)(v2.0.0)
|
||||
nodeagent monitor stop|list|delete <id>
|
||||
nodeagent log --path <文件> [--pattern 正则] [--level ERROR] [--limit n] [--tail]
|
||||
在被控端侧过滤日志,只回匹配行(日志几万行也不拖网)(v1.8)
|
||||
在被控端侧过滤日志,只回匹配行(日志几万行也不拖网)(v2.0.0)
|
||||
nodeagent record [--duration 5000] [--fps 2] [--scale 0.5] [--region x,y,w,h]
|
||||
录屏为帧序列(有 ffmpeg 则封装 mp4)(v11)
|
||||
nodeagent update --url <地址> --sha256 <哈希> [--dry-run]
|
||||
|
||||
@@ -20,11 +20,11 @@ export interface Options {
|
||||
/** 通用选项:文本 / 标题 */
|
||||
text?: string;
|
||||
title?: string;
|
||||
/** v25 审计锚定 */
|
||||
/** v2.0.0 审计锚定 */
|
||||
compare?: boolean;
|
||||
anchorPath?: string;
|
||||
note?: string;
|
||||
/** v1.8 状态采样 */
|
||||
/** v2.0.0 状态采样 */
|
||||
source?: string;
|
||||
target?: string;
|
||||
pattern?: string;
|
||||
@@ -32,17 +32,17 @@ export interface Options {
|
||||
since?: string;
|
||||
offset?: string;
|
||||
tail?: boolean;
|
||||
/** v1.7 gui.await 扩展 */
|
||||
/** v2.0.0 gui.await 扩展 */
|
||||
where?: string;
|
||||
any_of?: string;
|
||||
/** v1.6 gui.await */
|
||||
/** v1.6.0 gui.await */
|
||||
condition?: string;
|
||||
state?: string;
|
||||
path?: string;
|
||||
process?: string;
|
||||
/** v1.5 快捷键扩展:长按毫秒 */
|
||||
/** v1.5.0 快捷键扩展:长按毫秒 */
|
||||
hold?: string;
|
||||
/** v1.5 快捷键扩展:投递路由 foreground|post */
|
||||
/** v1.5.0 快捷键扩展:投递路由 foreground|post */
|
||||
route?: string;
|
||||
pid?: string;
|
||||
/** v3:认证模式 psk | ed25519 */
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
{
|
||||
"name": "nodeagent-console",
|
||||
"private": true,
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
"tauri": "tauri",
|
||||
"sidecar:dev": "node sidecar/main.mjs",
|
||||
"sidecar:build": "node sidecar/build.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
@@ -16,7 +18,9 @@
|
||||
"naive-ui": "^2.45.3",
|
||||
"pinia": "^4.0.3",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^5.3.1"
|
||||
"vue-router": "^5.3.1",
|
||||
"@nodeagent/client": "workspace:*",
|
||||
"@nodeagent/protocol": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 把 sidecar 打成**单文件**(供 Tauri 打包进应用)。
|
||||
*
|
||||
* 用法:node apps/console/sidecar/build.mjs [--out <dir>]
|
||||
*
|
||||
* 为什么用 esbuild 的 **JS API** 而不是 spawn `node_modules/.bin/esbuild`:
|
||||
* pnpm 的 `.bin` 布局下 spawn 该路径会 ENOENT(scripts/pack.mjs 真机踩过)。
|
||||
* 用 API 则从模块解析走,与包管理器布局无关。
|
||||
*
|
||||
* 依赖处理:`@nodeagent/client` 与 `@nodeagent/protocol` 是 workspace 包,
|
||||
* **一并 bundle 进去**(sidecar 要作为独立文件被 Tauri 拉起,不能依赖仓库 node_modules)。
|
||||
* `ws` 亦被打入(它是纯 JS)。Node 内置模块(readline/process)保持 external。
|
||||
*/
|
||||
|
||||
import { build } from 'esbuild';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const argv = process.argv.slice(2);
|
||||
const outIdx = argv.indexOf('--out');
|
||||
const outFile = join(outIdx >= 0 ? argv[outIdx + 1] : join(here, 'dist'), 'sidecar.mjs');
|
||||
|
||||
mkdirSync(dirname(outFile), { recursive: true });
|
||||
|
||||
const result = await build({
|
||||
entryPoints: [join(here, 'main.mjs')],
|
||||
outfile: outFile,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
target: 'node20',
|
||||
// Node 内置模块保持外部引用;其余(含 ws / workspace 包)全部内联
|
||||
external: ['node:*'],
|
||||
// ⚠️ CJS 依赖(如 ws)里有**动态 require**(`require('events')`),esbuild 打成 ESM 后
|
||||
// 无法转换 → 运行时报 "Dynamic require of X is not supported"。注入 createRequire shim
|
||||
// 是标准解法:让 bundle 内的 `require(...)` 走真实的 CJS 解析。
|
||||
banner: {
|
||||
js:
|
||||
'// nodeagent console sidecar(由 apps/console/sidecar/build.mjs 生成,请勿手工编辑)\n' +
|
||||
"import { createRequire as __naCreateRequire } from 'node:module';\n" +
|
||||
'const require = __naCreateRequire(import.meta.url);',
|
||||
},
|
||||
legalComments: 'none',
|
||||
minify: false, // 保持可读:出问题时能直接看堆栈
|
||||
metafile: true,
|
||||
});
|
||||
|
||||
const bytes = Object.values(result.metafile.outputs)[0]?.bytes ?? 0;
|
||||
console.log(`✓ sidecar 已打包: ${outFile} ${(bytes / 1024).toFixed(0)} KB`);
|
||||
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* console sidecar —— 把 `@nodeagent/client`(TS 客户端)暴露成 **JSON-RPC 2.0 over stdio**。
|
||||
*
|
||||
* ## 为什么需要它(选项 D:Tauri + Node sidecar)
|
||||
* console 的 Rust 壳若自己实现一份 client,就出现「Rust client 与 TS client 两套协议实现」——
|
||||
* 二者会静默漂移(改了一处漏另一处,且构建、单测都不报错)。sidecar 让 Rust 壳退化为
|
||||
* **进程管理者 + 消息转发者**,协议实现只剩 TS 一份。
|
||||
*
|
||||
* ## 协议(行分隔 JSON,每条消息一行)
|
||||
* 请求(Rust → 本进程):
|
||||
* {"jsonrpc":"2.0","id":1,"method":"connect","params":{url,key,client_id,insecure,...}}
|
||||
* {"jsonrpc":"2.0","id":2,"method":"disconnect"}
|
||||
* {"jsonrpc":"2.0","id":3,"method":"invoke","params":{capability,args,timeout_ms}}
|
||||
* {"jsonrpc":"2.0","id":4,"method":"state"}
|
||||
* 响应(本进程 → Rust):{"jsonrpc":"2.0","id":N,"result":{...}} 或 {"...","error":{code,message,data}}
|
||||
* 通知(无 id,本进程主动推):
|
||||
* {"jsonrpc":"2.0","method":"event","params":{"kind":"state","state":"connected"}}
|
||||
* {"jsonrpc":"2.0","method":"event","params":{"kind":"log","message":"..."}}
|
||||
* {"jsonrpc":"2.0","method":"event","params":{"kind":"agent_event","event":{...}}}
|
||||
*
|
||||
* ## 纪律
|
||||
* - **stdout 只写协议消息**(一行一条);日志一律走 stderr —— 混入任何非 JSON 行都会让 Rust 侧解析失败。
|
||||
* - 任何异常都转成 error 响应,**绝不因单个请求崩掉进程**(Rust 侧会当成"连接断了")。
|
||||
*/
|
||||
|
||||
import { createInterface } from 'node:readline';
|
||||
import { NodeAgentClient } from '@nodeagent/client';
|
||||
|
||||
/** @type {NodeAgentClient | null} */
|
||||
let client = null;
|
||||
/** 最近一次连接参数(重连/状态查询用)。 */
|
||||
let lastOptions = null;
|
||||
|
||||
// ---------------- 输出(唯一写 stdout 的地方) ----------------
|
||||
|
||||
function send(msg) {
|
||||
process.stdout.write(JSON.stringify(msg) + '\n');
|
||||
}
|
||||
|
||||
function ok(id, result) {
|
||||
send({ jsonrpc: '2.0', id, result: result ?? null });
|
||||
}
|
||||
|
||||
function fail(id, code, message, data) {
|
||||
send({ jsonrpc: '2.0', id, error: { code, message, ...(data !== undefined ? { data } : {}) } });
|
||||
}
|
||||
|
||||
function notify(kind, payload) {
|
||||
send({ jsonrpc: '2.0', method: 'event', params: { kind, ...payload } });
|
||||
}
|
||||
|
||||
/** 日志走 stderr(stdout 归协议专用)。 */
|
||||
function log(level, message) {
|
||||
process.stderr.write(`[sidecar] ${level} ${message}\n`);
|
||||
}
|
||||
|
||||
// ---------------- 状态快照 ----------------
|
||||
|
||||
function stateSnapshot() {
|
||||
if (!client) return { connected: false };
|
||||
return {
|
||||
connected: true,
|
||||
capabilities: client.listCapabilities(),
|
||||
authorized: client.listAuthorized(),
|
||||
agent_version: client.getAgentVersion() ?? null,
|
||||
agent_build: client.getAgentBuild() ?? null,
|
||||
peer_cert_fp: client.getPeerCertFingerprint(),
|
||||
client_id: lastOptions?.clientId ?? null,
|
||||
auth_mode: lastOptions?.authMode ?? 'psk',
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------- 方法实现 ----------------
|
||||
|
||||
const methods = {
|
||||
/** 建立连接并完成握手。返回与旧 Rust client 的 ConnectInfo 同构的元信息。 */
|
||||
async connect(params) {
|
||||
if (!params?.url || !params?.key) {
|
||||
throw Object.assign(new Error('connect 需要 url 与 key'), { code: -32602 });
|
||||
}
|
||||
// 已有连接先干净关闭,避免两条 ws 并存
|
||||
if (client) {
|
||||
try {
|
||||
client.close();
|
||||
} catch {
|
||||
/* 关不掉也无妨,下面会覆盖 */
|
||||
}
|
||||
}
|
||||
lastOptions = {
|
||||
url: String(params.url),
|
||||
clientId: String(params.client_id ?? 'console'),
|
||||
authMode: params.auth_mode === 'ed25519' ? 'ed25519' : 'psk',
|
||||
};
|
||||
|
||||
client = new NodeAgentClient({
|
||||
url: lastOptions.url,
|
||||
key: String(params.key),
|
||||
clientId: lastOptions.clientId,
|
||||
insecure: params.insecure !== false,
|
||||
...(params.cert_sha256 ? { certSha256: String(params.cert_sha256) } : {}),
|
||||
...(params.auth_mode === 'ed25519' ? { authMode: 'ed25519' } : {}),
|
||||
...(params.private_key ? { privateKey: String(params.private_key) } : {}),
|
||||
...(params.hub ? { hub: params.hub } : {}),
|
||||
autoReconnect: params.auto_reconnect !== false,
|
||||
|
||||
// 三个回调 → 三条通知(Rust 侧再桥接成 Tauri event)
|
||||
onStateChange: (state) => notify('state', { state }),
|
||||
onLog: (message) => notify('log', { message }),
|
||||
onEvent: (event) => notify('agent_event', { event }),
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
log('info', `已连接 ${lastOptions.url}(${lastOptions.clientId})`);
|
||||
return stateSnapshot();
|
||||
},
|
||||
|
||||
disconnect() {
|
||||
if (client) {
|
||||
try {
|
||||
client.close();
|
||||
} catch (err) {
|
||||
log('warn', `关闭连接时异常:${String(err)}`);
|
||||
}
|
||||
client = null;
|
||||
}
|
||||
return { connected: false };
|
||||
},
|
||||
|
||||
async invoke(params) {
|
||||
// 「调用方式不对」(未连接 / 缺参数)→ 协议错误,Rust 侧能看到明确的 code
|
||||
if (!client) throw Object.assign(new Error('尚未连接(先调 connect)'), { code: -32005 });
|
||||
if (!params?.capability) {
|
||||
throw Object.assign(new Error('invoke 需要 capability'), { code: -32602 });
|
||||
}
|
||||
try {
|
||||
// 正常路径:被控端返回 InvokeResult(status: ok|failed)
|
||||
return await client.invoke(
|
||||
String(params.capability),
|
||||
(params.args ?? {}),
|
||||
typeof params.timeout_ms === 'number' ? params.timeout_ms : undefined,
|
||||
);
|
||||
} catch (err) {
|
||||
// ⚠️ 被控端对**未知能力 / ACL 拒绝 / 超时**是回 JSON-RPC error,client 会**抛异常**。
|
||||
// 若原样往上扔,前端就得处理两种形态(result.status 与 error),调用面不统一。
|
||||
// 故归一成 InvokeResult 的 failed 形态 —— 前端只判 status 即可。
|
||||
return {
|
||||
status: 'failed',
|
||||
error: {
|
||||
name: err?.rpcName ?? err?.name ?? 'E_INVOKE_FAILED',
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
...(err?.data !== undefined ? { data: err.data } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
state: () => stateSnapshot(),
|
||||
};
|
||||
|
||||
// ---------------- 主循环 ----------------
|
||||
|
||||
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
||||
|
||||
rl.on('line', (line) => {
|
||||
const text = line.trim();
|
||||
if (!text) return;
|
||||
|
||||
let req;
|
||||
try {
|
||||
req = JSON.parse(text);
|
||||
} catch {
|
||||
fail(null, -32700, 'JSON 解析失败');
|
||||
return;
|
||||
}
|
||||
|
||||
const { id = null, method, params } = req ?? {};
|
||||
const fn = methods[method];
|
||||
if (typeof fn !== 'function') {
|
||||
fail(id, -32601, `未知方法: ${String(method)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 统一包裹:任何异常都变成 error 响应,绝不冒泡崩进程
|
||||
Promise.resolve()
|
||||
.then(() => fn(params ?? {}))
|
||||
.then((result) => ok(id, result))
|
||||
.catch((err) => {
|
||||
const code = typeof err?.code === 'number' ? err.code : -32603;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
log('error', `${String(method)} 失败:${msg}`);
|
||||
fail(id, code, msg, err?.data);
|
||||
});
|
||||
});
|
||||
|
||||
rl.on('close', () => {
|
||||
// Rust 侧关闭 stdin(进程退出/窗口关闭)→ 干净收尾后退出
|
||||
methods.disconnect();
|
||||
log('info', 'stdin 关闭,sidecar 退出');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (err) => {
|
||||
log('error', `未捕获异常:${err?.stack ?? String(err)}`);
|
||||
});
|
||||
process.on('unhandledRejection', (err) => {
|
||||
log('error', `未处理的 rejection:${String(err)}`);
|
||||
});
|
||||
|
||||
log('info', `sidecar 就绪(pid=${process.pid},node=${process.version})`);
|
||||
Generated
+17
-545
@@ -213,29 +213,6 @@ version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.45.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
"fs_extra",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.21.7"
|
||||
@@ -254,12 +231,6 @@ version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
@@ -299,15 +270,6 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.6.2"
|
||||
@@ -462,8 +424,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
@@ -500,17 +460,6 @@ version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.1",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
@@ -523,21 +472,6 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmov"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.8"
|
||||
@@ -557,12 +491,6 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.2"
|
||||
@@ -622,15 +550,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
@@ -665,15 +584,6 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssparser"
|
||||
version = "0.37.0"
|
||||
@@ -703,42 +613,6 @@ version = "1.0.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d"
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek"
|
||||
version = "5.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.1",
|
||||
"curve25519-dalek-derive",
|
||||
"digest 0.11.3",
|
||||
"fiat-crypto",
|
||||
"rustc_version",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek-derive"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.24.1"
|
||||
@@ -773,12 +647,6 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
||||
|
||||
[[package]]
|
||||
name = "dbus"
|
||||
version = "0.9.12"
|
||||
@@ -821,16 +689,6 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
@@ -867,20 +725,8 @@ version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer 0.10.4",
|
||||
"crypto-common 0.1.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.1",
|
||||
"const-oid",
|
||||
"crypto-common 0.2.2",
|
||||
"ctutils",
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1001,31 +847,6 @@ version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "3.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a"
|
||||
dependencies = [
|
||||
"pkcs8",
|
||||
"signature",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519-dalek"
|
||||
version = "3.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de"
|
||||
dependencies = [
|
||||
"curve25519-dalek",
|
||||
"ed25519",
|
||||
"serde",
|
||||
"sha2 0.11.0",
|
||||
"signature",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.12"
|
||||
@@ -1135,12 +956,6 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
|
||||
|
||||
[[package]]
|
||||
name = "field-offset"
|
||||
version = "0.3.6"
|
||||
@@ -1216,12 +1031,6 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.34"
|
||||
@@ -1415,17 +1224,6 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
@@ -1447,7 +1245,6 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1634,15 +1431,6 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
|
||||
dependencies = [
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.39.0"
|
||||
@@ -1692,15 +1480,6 @@ version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.12.0"
|
||||
@@ -2072,16 +1851,6 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.106"
|
||||
@@ -2330,37 +2099,16 @@ version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nodeagent-client"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"ed25519-dalek",
|
||||
"futures-util",
|
||||
"hmac",
|
||||
"rand",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"url",
|
||||
"webpki-roots 1.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nodeagent-console"
|
||||
version = "0.1.0"
|
||||
version = "2.0.0"
|
||||
dependencies = [
|
||||
"nodeagent-client",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-opener",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2759,16 +2507,6 @@ dependencies = [
|
||||
"futures-io",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7"
|
||||
dependencies = [
|
||||
"der",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.34"
|
||||
@@ -2956,23 +2694,6 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -3081,20 +2802,6 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
@@ -3123,51 +2830,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pemfile"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
@@ -3428,17 +3090,6 @@ dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.1",
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
@@ -3446,19 +3097,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.1",
|
||||
"digest 0.11.3",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3477,12 +3117,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "3.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5"
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
@@ -3565,16 +3199,6 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"der",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
@@ -3611,12 +3235,6 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "swift-rs"
|
||||
version = "1.0.8"
|
||||
@@ -3839,7 +3457,7 @@ dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"sha2",
|
||||
"syn 2.0.119",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.21",
|
||||
@@ -4119,6 +3737,7 @@ dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -4135,32 +3754,6 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.30.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tungstenite",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.19"
|
||||
@@ -4374,24 +3967,6 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.30.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"sha1",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typeid"
|
||||
version = "1.0.3"
|
||||
@@ -4427,12 +4002,6 @@ version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -4687,24 +4256,6 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com"
|
||||
version = "0.39.1"
|
||||
@@ -4893,16 +4444,7 @@ version = "0.45.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
|
||||
dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4920,29 +4462,13 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.42.2",
|
||||
"windows_aarch64_msvc 0.42.2",
|
||||
"windows_i686_gnu 0.42.2",
|
||||
"windows_i686_msvc 0.42.2",
|
||||
"windows_x86_64_gnu 0.42.2",
|
||||
"windows_x86_64_gnullvm 0.42.2",
|
||||
"windows_x86_64_msvc 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4969,90 +4495,42 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.5.40"
|
||||
@@ -5123,7 +4601,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"raw-window-handle",
|
||||
"sha2 0.10.9",
|
||||
"sha2",
|
||||
"soup3",
|
||||
"tao-macros",
|
||||
"thiserror 2.0.21",
|
||||
@@ -5272,12 +4750,6 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.5"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "nodeagent-console"
|
||||
version = "0.1.0"
|
||||
version = "2.0.0"
|
||||
description = "nodeagent 桌面控制台(主控 + 被控一体)"
|
||||
authors = ["jkooe"]
|
||||
edition = "2021"
|
||||
@@ -22,7 +22,9 @@ tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
nodeagent-client = { path = "../../../crates/nodeagent-client" }
|
||||
# ⚠️ 2026-10:不再依赖 crates/nodeagent-client —— 协议实现改由 Node sidecar 承担
|
||||
# (见 src/sidecar.rs 的说明:避免 Rust/TS 两套协议实现静默漂移)
|
||||
tokio = { version = "1", features = ["process", "io-util", "sync", "time", "macros", "rt-multi-thread"] }
|
||||
|
||||
|
||||
# Read the optimization guideline for more details: https://tauri.app/concept/size/#cargo-configuration
|
||||
|
||||
@@ -1,42 +1,92 @@
|
||||
//! nodeagent 桌面控制台(主控 + 被控一体)——Tauri 后端。
|
||||
//!
|
||||
//! command 层把 `nodeagent-client`(Rust 控制端)接到前端:
|
||||
//! ## 结构(2026-10 起:sidecar 方案)
|
||||
//! 本层只做**进程管理 + 消息转发**,真正的协议实现只有 TS 一份(`@nodeagent/client`),
|
||||
//! 由 `sidecar.rs` 拉起的 Node 进程承载。原先「Rust 版 client 与 TS 版并存」的漂移风险就此消除。
|
||||
//!
|
||||
//! command 面(**签名与语义保持不变,前端无需改动**):
|
||||
//! - `connect` / `disconnect` / `invoke_capability` / `get_capabilities` / `get_state`
|
||||
//! - client 的日志/状态/被控端事件经 Tauri `emit` 推给前端(`na-log`/`na-state`/`na-event`)。
|
||||
//!
|
||||
//! Tauri 事件面(名字不变):`na-state` / `na-log` / `na-event`。
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
mod sidecar;
|
||||
|
||||
use nodeagent_client::{
|
||||
CapabilityDescriptor, Client, ClientEvent, ClientOptions, ConnState, InvokeResult,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// 全局状态:持有当前连接(`None` = 未连接)。
|
||||
use serde_json::{json, Value};
|
||||
use tauri::{AppHandle, Emitter, Manager, State};
|
||||
|
||||
use sidecar::{Sidecar, SidecarEvent};
|
||||
|
||||
/// 全局状态。
|
||||
///
|
||||
/// `connect` 成功后缓存「能力清单 / 版本 / 连接态」——这样 `get_capabilities`、
|
||||
/// `get_state` 能保持**同步**(无需每次往返 sidecar),签名与改造前一致。
|
||||
struct AppState {
|
||||
client: Mutex<Option<Client>>,
|
||||
sidecar: tokio::sync::Mutex<Option<std::sync::Arc<Sidecar>>>,
|
||||
capabilities: Mutex<Value>,
|
||||
agent_version: Mutex<Option<String>>,
|
||||
conn_state: Mutex<String>,
|
||||
}
|
||||
|
||||
/// 连接成功后回传给前端的元信息。
|
||||
#[derive(Serialize)]
|
||||
/// 连接成功后回传给前端的元信息(字段名与改造前一致)。
|
||||
#[derive(serde::Serialize)]
|
||||
struct ConnectInfo {
|
||||
capabilities: Vec<CapabilityDescriptor>,
|
||||
capabilities: Value,
|
||||
agent_version: Option<String>,
|
||||
authorized: Option<Vec<String>>,
|
||||
authorized: Option<Value>,
|
||||
peer_cert_fp: Option<String>,
|
||||
auth_mode: String,
|
||||
client_id: String,
|
||||
}
|
||||
|
||||
fn conn_state_str(s: ConnState) -> &'static str {
|
||||
match s {
|
||||
ConnState::Connected => "connected",
|
||||
ConnState::Reconnecting => "reconnecting",
|
||||
ConnState::Closed => "closed",
|
||||
impl Default for AppState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sidecar: tokio::sync::Mutex::new(None),
|
||||
capabilities: Mutex::new(Value::Array(vec![])),
|
||||
agent_version: Mutex::new(None),
|
||||
conn_state: Mutex::new("未连接".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 懒启动 sidecar(首次调用时拉起,之后复用同一进程)。
|
||||
async fn ensure_sidecar(app: &AppHandle, state: &AppState) -> Result<std::sync::Arc<Sidecar>, String> {
|
||||
let mut guard = state.sidecar.lock().await;
|
||||
if let Some(sc) = guard.as_ref() {
|
||||
return Ok(std::sync::Arc::clone(sc));
|
||||
}
|
||||
|
||||
// 打包后 node 与 sidecar.mjs 随 resource 分发;开发期用系统 node + 仓库内脚本
|
||||
let resource_dir = app.path().resource_dir().ok();
|
||||
let node = sidecar::resolve_node(resource_dir.as_deref())?;
|
||||
let script = sidecar::resolve_sidecar(resource_dir.as_deref())?;
|
||||
|
||||
// 事件桥接:sidecar 通知 → Tauri event(名字与改造前一致)
|
||||
let app_events = app.clone();
|
||||
let on_event = std::sync::Arc::new(move |evt: SidecarEvent| match evt {
|
||||
SidecarEvent::State(s) => {
|
||||
let _ = app_events.emit("na-state", s);
|
||||
}
|
||||
SidecarEvent::Log(msg) => {
|
||||
let _ = app_events.emit("na-log", msg);
|
||||
}
|
||||
SidecarEvent::Event(params) => {
|
||||
let _ = app_events.emit("na-event", params);
|
||||
}
|
||||
SidecarEvent::Other(raw) => {
|
||||
let _ = app_events.emit("na-log", format!("[sidecar] 未知通知: {raw}"));
|
||||
}
|
||||
});
|
||||
|
||||
let sc = Sidecar::spawn(&node, &script, on_event)?;
|
||||
// 连接态由事件驱动:先置「已就绪」,真正连接后会被 connected 覆盖
|
||||
*state.conn_state.lock().unwrap() = "未连接".to_string();
|
||||
*guard = Some(std::sync::Arc::clone(&sc));
|
||||
Ok(sc)
|
||||
}
|
||||
|
||||
/// 建立到被控端的连接并完成握手(默认 psk 模式、接受自签证书、开启自动重连)。
|
||||
#[tauri::command]
|
||||
async fn connect(
|
||||
@@ -47,51 +97,52 @@ async fn connect(
|
||||
client_id: Option<String>,
|
||||
insecure: Option<bool>,
|
||||
) -> Result<ConnectInfo, String> {
|
||||
let sc = ensure_sidecar(&app, &state).await?;
|
||||
let client_id = client_id.unwrap_or_else(|| "console".to_string());
|
||||
let client = Client::new(ClientOptions {
|
||||
url,
|
||||
key,
|
||||
client_id: client_id.clone(),
|
||||
insecure: insecure.unwrap_or(true),
|
||||
auto_reconnect: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// 把 client 的事件桥接成 Tauri 事件,推给前端。
|
||||
let app_events = app.clone();
|
||||
client.set_handler(Arc::new(move |evt| match evt {
|
||||
ClientEvent::State(s) => {
|
||||
let _ = app_events.emit("na-state", conn_state_str(s));
|
||||
}
|
||||
ClientEvent::Log(msg) => {
|
||||
let _ = app_events.emit("na-log", msg);
|
||||
}
|
||||
ClientEvent::Event(params) => {
|
||||
let _ = app_events.emit("na-event", params);
|
||||
}
|
||||
}));
|
||||
let snap = sc
|
||||
.request(
|
||||
"connect",
|
||||
json!({
|
||||
"url": url,
|
||||
"key": key,
|
||||
"client_id": client_id,
|
||||
"insecure": insecure.unwrap_or(true),
|
||||
}),
|
||||
30,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let caps = client.connect().await.map_err(|e| e.to_string())?;
|
||||
// 缓存快照,供同步 command 使用
|
||||
let caps = snap.get("capabilities").cloned().unwrap_or_else(|| json!([]));
|
||||
let ver = snap.get("agent_version").and_then(Value::as_str).map(str::to_string);
|
||||
*state.capabilities.lock().unwrap() = caps.clone();
|
||||
*state.agent_version.lock().unwrap() = ver.clone();
|
||||
*state.conn_state.lock().unwrap() = "connected".to_string();
|
||||
|
||||
let info = ConnectInfo {
|
||||
agent_version: client.agent_version(),
|
||||
authorized: client.list_authorized(),
|
||||
peer_cert_fp: client.peer_cert_fingerprint(),
|
||||
auth_mode: "psk".to_string(),
|
||||
Ok(ConnectInfo {
|
||||
capabilities: caps,
|
||||
agent_version: ver,
|
||||
authorized: snap.get("authorized").cloned(),
|
||||
peer_cert_fp: snap.get("peer_cert_fp").and_then(Value::as_str).map(str::to_string),
|
||||
auth_mode: snap
|
||||
.get("auth_mode")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("psk")
|
||||
.to_string(),
|
||||
client_id,
|
||||
};
|
||||
|
||||
*state.client.lock().unwrap() = Some(client);
|
||||
Ok(info)
|
||||
})
|
||||
}
|
||||
|
||||
/// 断开连接(停止自动重连)。
|
||||
#[tauri::command]
|
||||
fn disconnect(state: State<'_, AppState>) -> Result<(), String> {
|
||||
if let Some(client) = state.client.lock().unwrap().take() {
|
||||
client.close();
|
||||
async fn disconnect(state: State<'_, AppState>) -> Result<(), String> {
|
||||
let sc = { state.sidecar.lock().await.as_ref().map(std::sync::Arc::clone) };
|
||||
if let Some(sc) = sc {
|
||||
sc.request("disconnect", json!({}), 10).await?;
|
||||
}
|
||||
*state.conn_state.lock().unwrap() = "未连接".to_string();
|
||||
*state.capabilities.lock().unwrap() = json!([]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -102,43 +153,42 @@ async fn invoke_capability(
|
||||
capability: String,
|
||||
args: Option<Value>,
|
||||
timeout_ms: Option<u64>,
|
||||
) -> Result<InvokeResult, String> {
|
||||
let client = {
|
||||
let guard = state.client.lock().unwrap();
|
||||
guard.as_ref().cloned().ok_or_else(|| "未连接".to_string())?
|
||||
) -> Result<Value, String> {
|
||||
let sc = {
|
||||
state
|
||||
.sidecar
|
||||
.lock()
|
||||
.await
|
||||
.as_ref()
|
||||
.map(std::sync::Arc::clone)
|
||||
.ok_or_else(|| "未连接".to_string())?
|
||||
};
|
||||
let args = args.unwrap_or(Value::Object(Default::default()));
|
||||
match timeout_ms {
|
||||
Some(ms) if ms > 0 => client.invoke_with_timeout(&capability, args, ms).await,
|
||||
_ => client.invoke(&capability, args).await,
|
||||
let mut params = json!({ "capability": capability, "args": args.unwrap_or_else(|| json!({})) });
|
||||
if let Some(ms) = timeout_ms.filter(|m| *m > 0) {
|
||||
params["timeout_ms"] = json!(ms);
|
||||
}
|
||||
.map_err(|e| e.to_string())
|
||||
// sidecar 侧已把「未知能力 / ACL 拒绝 / 超时」归一成 {status:"failed",error:{...}},
|
||||
// 故这里拿到的是稳定的 InvokeResult 形态(前端只需判 status)。
|
||||
sc.request("invoke", params, timeout_ms.map(|m| (m / 1000) + 10).unwrap_or(70)).await
|
||||
}
|
||||
|
||||
/// 当前连接的能力清单。
|
||||
/// 当前连接的能力清单(读缓存,保持同步签名)。
|
||||
#[tauri::command]
|
||||
fn get_capabilities(state: State<'_, AppState>) -> Result<Vec<CapabilityDescriptor>, String> {
|
||||
let guard = state.client.lock().unwrap();
|
||||
let client = guard.as_ref().ok_or_else(|| "未连接".to_string())?;
|
||||
Ok(client.list_capabilities())
|
||||
fn get_capabilities(state: State<'_, AppState>) -> Result<Value, String> {
|
||||
Ok(state.capabilities.lock().unwrap().clone())
|
||||
}
|
||||
|
||||
/// 当前连接状态字符串(connected / reconnecting / closed / 未连接)。
|
||||
#[tauri::command]
|
||||
fn get_state(state: State<'_, AppState>) -> String {
|
||||
match state.client.lock().unwrap().as_ref() {
|
||||
Some(client) => conn_state_str(client.state()).to_string(),
|
||||
None => "未连接".to_string(),
|
||||
}
|
||||
state.conn_state.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.manage(AppState {
|
||||
client: Mutex::new(None),
|
||||
})
|
||||
.manage(AppState::default())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
connect,
|
||||
disconnect,
|
||||
@@ -146,6 +196,21 @@ pub fn run() {
|
||||
get_capabilities,
|
||||
get_state
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|app, event| {
|
||||
// app 退出时优雅收尾:先让 sidecar 主动 disconnect,再杀进程
|
||||
//(Sidecar 设了 kill_on_drop,即使这里失败也不会留孤儿进程)
|
||||
if let tauri::RunEvent::Exit = event {
|
||||
let state = app.state::<AppState>();
|
||||
let sc = tauri::async_runtime::block_on(async {
|
||||
state.sidecar.lock().await.as_ref().map(std::sync::Arc::clone)
|
||||
});
|
||||
if let Some(sc) = sc {
|
||||
tauri::async_runtime::block_on(async {
|
||||
sc.shutdown().await;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
//! Node sidecar 管理器 —— 启动 `apps/console/sidecar/main.mjs` 并做 JSON-RPC 转发。
|
||||
//!
|
||||
//! ## 为什么是 sidecar(而不是 Rust 版 client)
|
||||
//! 原设计让本壳自己实现一份 client,与 TS 版(`@nodeagent/client`)**两套协议实现并存** ——
|
||||
//! 改了一处漏另一处时,构建过、单测过,只在真机暴露。sidecar 让本壳只做
|
||||
//! **进程管理 + 消息转发**,协议实现只剩 TS 一份。
|
||||
//!
|
||||
//! ## 协议(行分隔 JSON,见 apps/console/sidecar/main.mjs 顶部注释)
|
||||
//! 出(本壳 → sidecar):`{"jsonrpc":"2.0","id":N,"method":...,"params":{...}}`
|
||||
//! 入(sidecar → 本壳):
|
||||
//! - 响应:`{"jsonrpc":"2.0","id":N,"result"|"error":...}`
|
||||
//! - 通知:`{"jsonrpc":"2.0","method":"event","params":{"kind":...,...}}`
|
||||
//!
|
||||
//! ## 可测试性(本项目纪律:不能只 `cargo check`)
|
||||
//! 本模块的 `spawn` / `request` / 事件回调**都能在 `cargo test` 里真跑** ——
|
||||
//! 单测直接拉起真实的 node 进程,不依赖 Tauri GUI。GUI 那层才需要人工验证。
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::{Child, ChildStdin, Command};
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
/// sidecar 主动推上来的事件(与 `main.mjs` 的 `params.kind` 一一对应)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SidecarEvent {
|
||||
/// 连接状态变化:connected / reconnecting / closed
|
||||
State(String),
|
||||
/// 日志一行
|
||||
Log(String),
|
||||
/// 被控端主动推送的事件(v12 `event.*`)
|
||||
Event(Value),
|
||||
/// sidecar 上来的未知通知(保留原始形态,便于排查)
|
||||
Other(Value),
|
||||
}
|
||||
|
||||
type Pending = Arc<Mutex<HashMap<u64, oneshot::Sender<Result<Value, String>>>>>;
|
||||
|
||||
/// 一个已启动的 sidecar 进程。
|
||||
pub struct Sidecar {
|
||||
child: Mutex<Option<Child>>,
|
||||
stdin: tokio::sync::Mutex<ChildStdin>,
|
||||
pending: Pending,
|
||||
next_id: AtomicU64,
|
||||
}
|
||||
|
||||
impl Sidecar {
|
||||
/// 启动 sidecar 并开始读取其 stdout。
|
||||
///
|
||||
/// `on_event`:sidecar 的通知回调(本壳里用于桥接成 Tauri event)。
|
||||
pub fn spawn(
|
||||
node: &Path,
|
||||
script: &Path,
|
||||
on_event: Arc<dyn Fn(SidecarEvent) + Send + Sync>,
|
||||
) -> Result<Arc<Self>, String> {
|
||||
let mut child = Command::new(node)
|
||||
.arg(script)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true) // app 退出时不留孤儿进程
|
||||
.spawn()
|
||||
.map_err(|e| format!("启动 sidecar 失败({} {}):{e}", node.display(), script.display()))?;
|
||||
|
||||
let stdin = child.stdin.take().ok_or("sidecar stdin 不可用")?;
|
||||
let stdout = child.stdout.take().ok_or("sidecar stdout 不可用")?;
|
||||
let stderr = child.stderr.take().ok_or("sidecar stderr 不可用")?;
|
||||
|
||||
let pending: Pending = Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
// stdout:逐行读,分派「响应」与「通知」
|
||||
{
|
||||
let pending = Arc::clone(&pending);
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(stdout).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
let text = line.trim();
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Ok(msg) = serde_json::from_str::<Value>(text) else {
|
||||
// 混入非 JSON 行说明协议被破坏 —— 记日志但不崩
|
||||
eprintln!("[sidecar] 非 JSON 输出:{}", &text[..text.len().min(200)]);
|
||||
continue;
|
||||
};
|
||||
|
||||
// 响应:有 id
|
||||
if let Some(id) = msg.get("id").and_then(Value::as_u64) {
|
||||
if let Some(tx) = pending.lock().unwrap().remove(&id) {
|
||||
let out = if let Some(err) = msg.get("error") {
|
||||
Err(err
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("sidecar 返回错误")
|
||||
.to_string())
|
||||
} else {
|
||||
Ok(msg.get("result").cloned().unwrap_or(Value::Null))
|
||||
};
|
||||
let _ = tx.send(out);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 通知:method == "event"
|
||||
if msg.get("method").and_then(Value::as_str) == Some("event") {
|
||||
let p = msg.get("params").cloned().unwrap_or(Value::Null);
|
||||
let kind = p.get("kind").and_then(Value::as_str).unwrap_or("");
|
||||
let evt = match kind {
|
||||
"state" => SidecarEvent::State(
|
||||
p.get("state").and_then(Value::as_str).unwrap_or("closed").to_string(),
|
||||
),
|
||||
"log" => SidecarEvent::Log(
|
||||
p.get("message").and_then(Value::as_str).unwrap_or("").to_string(),
|
||||
),
|
||||
"agent_event" => {
|
||||
SidecarEvent::Event(p.get("event").cloned().unwrap_or(Value::Null))
|
||||
}
|
||||
_ => SidecarEvent::Other(p),
|
||||
};
|
||||
on_event(evt);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// stderr:sidecar 的日志 → 本壳 stderr(保持 stdout 干净)
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(stderr).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
eprintln!("{}", line);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Arc::new(Self {
|
||||
child: Mutex::new(Some(child)),
|
||||
stdin: tokio::sync::Mutex::new(stdin),
|
||||
pending,
|
||||
next_id: AtomicU64::new(1),
|
||||
}))
|
||||
}
|
||||
|
||||
/// 发一个请求并等响应(`timeout_secs` 兜底,避免 sidecar 卡住时本壳一起挂)。
|
||||
pub async fn request(&self, method: &str, params: Value, timeout_secs: u64) -> Result<Value, String> {
|
||||
let id = self.next_id.fetch_add(1, Ordering::SeqCst);
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.pending.lock().unwrap().insert(id, tx);
|
||||
|
||||
let msg = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params });
|
||||
{
|
||||
let mut stdin = self.stdin.lock().await;
|
||||
stdin
|
||||
.write_all(format!("{msg}\n").as_bytes())
|
||||
.await
|
||||
.map_err(|e| format!("写入 sidecar 失败:{e}"))?;
|
||||
stdin.flush().await.map_err(|e| format!("flush sidecar 失败:{e}"))?;
|
||||
}
|
||||
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), rx).await {
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(Err(_)) => Err("sidecar 通道已关闭".to_string()),
|
||||
Err(_) => {
|
||||
self.pending.lock().unwrap().remove(&id);
|
||||
Err(format!("{method} 超时({timeout_secs}s)"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 关掉 sidecar(app 退出或显式 disconnect 时调用)。
|
||||
pub async fn shutdown(&self) {
|
||||
let _ = self.request("disconnect", json!({}), 5).await;
|
||||
// ⚠️ 必须**先把 child 取出来、让锁离开作用域**,再 await kill()。
|
||||
// 若写成 `if let Some(child) = self.child.lock().unwrap().take() { child.kill().await }`,
|
||||
// MutexGuard 会存活到 if 块结束 → 跨 await 持有 std Mutex(clippy::await_holding_lock,
|
||||
// 且真会阻塞 executor)。
|
||||
let child = self.child.lock().unwrap().take();
|
||||
if let Some(mut child) = child {
|
||||
let _ = child.kill().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 路径解析:开发期用系统 node + 仓库内脚本;打包后从 Tauri resource 目录取
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 解析 node 可执行文件。
|
||||
///
|
||||
/// 优先级:环境变量 `NODEAGENT_CONSOLE_NODE` → 随包 resource 的 `node` → 系统 `node`。
|
||||
pub fn resolve_node(resource_dir: Option<&Path>) -> Result<PathBuf, String> {
|
||||
if let Ok(p) = std::env::var("NODEAGENT_CONSOLE_NODE") {
|
||||
if !p.is_empty() {
|
||||
return Ok(PathBuf::from(p));
|
||||
}
|
||||
}
|
||||
if let Some(dir) = resource_dir {
|
||||
let bundled = dir.join(if cfg!(windows) { "node.exe" } else { "node" });
|
||||
if bundled.exists() {
|
||||
return Ok(bundled);
|
||||
}
|
||||
}
|
||||
// 系统 node(开发期)
|
||||
for cand in ["node", "node.exe"] {
|
||||
if which(cand).is_some() {
|
||||
return Ok(PathBuf::from(cand));
|
||||
}
|
||||
}
|
||||
Err("找不到 node 可执行文件(可用 NODEAGENT_CONSOLE_NODE 指定)".to_string())
|
||||
}
|
||||
|
||||
/// 极简 which(避免为一个函数引依赖)。
|
||||
fn which(cmd: &str) -> Option<PathBuf> {
|
||||
let path = std::env::var_os("PATH")?;
|
||||
for dir in std::env::split_paths(&path) {
|
||||
let full = dir.join(cmd);
|
||||
if full.is_file() {
|
||||
return Some(full);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 解析 sidecar 脚本。
|
||||
///
|
||||
/// 优先级:环境变量 `NODEAGENT_CONSOLE_SIDECAR` → 随包 resource 的 `sidecar.mjs` →
|
||||
/// 开发期的 `apps/console/sidecar/dist/sidecar.mjs`(相对本 crate)。
|
||||
pub fn resolve_sidecar(resource_dir: Option<&Path>) -> Result<PathBuf, String> {
|
||||
if let Ok(p) = std::env::var("NODEAGENT_CONSOLE_SIDECAR") {
|
||||
if !p.is_empty() && Path::new(&p).exists() {
|
||||
return Ok(PathBuf::from(p));
|
||||
}
|
||||
}
|
||||
if let Some(dir) = resource_dir {
|
||||
let bundled = dir.join("sidecar.mjs");
|
||||
if bundled.exists() {
|
||||
return Ok(bundled);
|
||||
}
|
||||
}
|
||||
// 开发期:src-tauri → apps/console/sidecar/dist/sidecar.mjs
|
||||
let dev = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../sidecar/dist/sidecar.mjs");
|
||||
if dev.exists() {
|
||||
return Ok(dev);
|
||||
}
|
||||
Err("找不到 sidecar.mjs(先跑 `pnpm --filter nodeagent-console sidecar:build`,\
|
||||
或用 NODEAGENT_CONSOLE_SIDECAR 指定)"
|
||||
.to_string())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 单测:真跑 sidecar 进程(不依赖 Tauri GUI)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
|
||||
/// 开发期的 sidecar 源码(不依赖打包产物,pnpm 的 node_modules 已能解析其依赖)。
|
||||
fn dev_sidecar_src() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../sidecar/main.mjs")
|
||||
}
|
||||
|
||||
fn node_bin() -> PathBuf {
|
||||
resolve_node(None).expect("需要 node 才能跑 sidecar 单测")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn 启动后_state_请求应返回未连接() {
|
||||
let seen: Arc<AtomicUsize> = Arc::new(AtomicUsize::new(0));
|
||||
let counter = Arc::clone(&seen);
|
||||
let sc = Sidecar::spawn(
|
||||
&node_bin(),
|
||||
&dev_sidecar_src(),
|
||||
Arc::new(move |_e| {
|
||||
counter.fetch_add(1, Ordering::SeqCst);
|
||||
}),
|
||||
)
|
||||
.expect("sidecar 应能启动");
|
||||
|
||||
let r = sc.request("state", json!({}), 15).await.expect("state 应成功");
|
||||
assert_eq!(r["connected"], json!(false), "未连接时 connected 应为 false");
|
||||
sc.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn 未知方法应返回错误而不是崩溃() {
|
||||
let sc = Sidecar::spawn(&node_bin(), &dev_sidecar_src(), Arc::new(|_| {}))
|
||||
.expect("sidecar 应能启动");
|
||||
|
||||
let err = sc.request("no_such_method", json!({}), 15).await;
|
||||
assert!(err.is_err(), "未知方法应返回错误");
|
||||
|
||||
// 关键:进程没崩,后续请求仍可用
|
||||
let ok = sc.request("state", json!({}), 15).await.expect("sidecar 不应因错误退出");
|
||||
assert_eq!(ok["connected"], json!(false));
|
||||
sc.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn 未连接时_invoke_应返回明确错误() {
|
||||
let sc = Sidecar::spawn(&node_bin(), &dev_sidecar_src(), Arc::new(|_| {}))
|
||||
.expect("sidecar 应能启动");
|
||||
|
||||
let err = sc.request("invoke", json!({"capability": "system.info"}), 15).await;
|
||||
match err {
|
||||
Err(msg) => assert!(msg.contains("尚未连接"), "错误信息应提示未连接,实际:{msg}"),
|
||||
Ok(v) => panic!("未连接时 invoke 应失败,实际返回:{v}"),
|
||||
}
|
||||
sc.shutdown().await;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "nodeagent-console",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.1",
|
||||
"identifier": "cn.jkooe.nodeagent-console",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "pnpm build",
|
||||
"beforeBuildCommand": "pnpm build && pnpm sidecar:build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
@@ -30,6 +30,9 @@
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
],
|
||||
"resources": {
|
||||
"../sidecar/dist/sidecar.mjs": "sidecar.mjs"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Tauri command 调用封装(前端 → Rust 壳 → nodeagent-client)。 */
|
||||
/** Tauri command 调用封装(前端 → Rust 壳 → Node sidecar → 被控端)。 */
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { CapabilityDescriptor, ConnectInfo, InvokeResult } from "../types";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** 协议类型镜像(对齐 Rust 侧 `crates/nodeagent-client` 与 Tauri command 的序列化形状)。 */
|
||||
/** 协议类型镜像(对齐 `@nodeagent/protocol`,亦即 sidecar 与 Tauri command 的序列化形状)。 */
|
||||
|
||||
/** 能力描述(被控端声明的能力清单条目)。 */
|
||||
export interface CapabilityDescriptor {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/hub",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/mcp",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -262,7 +262,7 @@ function resolveToolCall(toolName: string, input: Record<string, unknown>): Reso
|
||||
return { capability: CapabilityNames.KeyType, args: { text: input['text'] } };
|
||||
}
|
||||
if (action === 'press') {
|
||||
// v1.5:四种形式一次只用一种(hotkeys > hotkey > presets > preset > keys),
|
||||
// v1.5.0:四种形式一次只用一种(hotkeys > hotkey > presets > preset > keys),
|
||||
// 其余各端会做同样的校验与展开,这里只做薄校验后原样透传。
|
||||
const has = (k: string): boolean =>
|
||||
input[k] !== undefined && !(Array.isArray(input[k]) && (input[k] as unknown[]).length === 0);
|
||||
|
||||
@@ -257,7 +257,7 @@ export const TOOLS = [
|
||||
{
|
||||
name: 'na_await',
|
||||
description:
|
||||
'等待一个条件成立再返回(只读低危,v1.6)。\n' +
|
||||
'等待一个条件成立再返回(只读低危,v1.6.0)。\n' +
|
||||
'四类条件:window=等窗口出现/消失;control=等界面元素出现/消失(复用 screen.find);' +
|
||||
'process=等进程出现/消失;file=等文件出现/消失。\n' +
|
||||
'state=present(默认,等出现)或 absent(等消失)。\n' +
|
||||
@@ -275,8 +275,8 @@ export const TOOLS = [
|
||||
title: { type: 'string', description: 'window 条件:窗口标题(正则)' },
|
||||
process: { type: 'string', description: 'process 条件:进程名(正则)' },
|
||||
path: { type: 'string', description: 'file 条件:文件路径' },
|
||||
where: { type: 'object', description: 'v1.7 属性谓词(仅 UIA):{ enabled?, selected?, value?, toggle? }' },
|
||||
any_of: { type: 'array', maxItems: 8, description: 'v1.7 组合条件:每项一个条件对象,任一命中即算' },
|
||||
where: { type: 'object', description: 'v2.0.0 属性谓词(仅 UIA):{ enabled?, selected?, value?, toggle? }' },
|
||||
any_of: { type: 'array', maxItems: 8, description: 'v2.0.0 组合条件:每项一个条件对象,任一命中即算' },
|
||||
},
|
||||
required: ['condition'],
|
||||
additionalProperties: false,
|
||||
@@ -285,7 +285,7 @@ export const TOOLS = [
|
||||
{
|
||||
name: 'na_monitor',
|
||||
description:
|
||||
'定时采样并落盘,事后回看(v1.8,治间歇性问题)。\n' +
|
||||
'定时采样并落盘,事后回看(v2.0.0,治间歇性问题)。\n' +
|
||||
'action=start:起一个采样。source=port(target="host:port",测连通性与延迟)/' +
|
||||
'process(target=进程名,测存活)/command(target=命令,取退出码)/metric(CPU·内存,无需 target)。\n' +
|
||||
'action=report:读回样本序列与**摘要**(port/process 给「断了几次 + 最长连续中断」;' +
|
||||
@@ -309,7 +309,7 @@ export const TOOLS = [
|
||||
{
|
||||
name: 'na_log',
|
||||
description:
|
||||
'在被控端侧过滤日志,只回匹配行(v1.8,只读)。日志常几万行,整份拉回既慢又占带宽 —— ' +
|
||||
'在被控端侧过滤日志,只回匹配行(v2.0.0,只读)。日志常几万行,整份拉回既慢又占带宽 —— ' +
|
||||
'本工具把过滤下推到被控端(流式逐行读)。支持 pattern(正则)/level(ERROR|WARN|INFO|DEBUG)/\n' +
|
||||
'since(Unix ms)/offset/limit/tail(取末尾 N 条)。',
|
||||
inputSchema: {
|
||||
|
||||
Generated
-1185
File diff suppressed because it is too large.
Load diff
@@ -1,5 +0,0 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["nodeagent-client"]
|
||||
|
||||
[workspace.dependencies]
|
||||
@@ -1,23 +0,0 @@
|
||||
[package]
|
||||
name = "nodeagent-client"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "nodeagent 控制端 Rust 客户端(行为对齐 @nodeagent/client)"
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.23.1"
|
||||
ed25519-dalek = { version = "3.0.0", features = ["pkcs8", "alloc"] }
|
||||
futures-util = "0.3.34"
|
||||
hmac = "0.13.0"
|
||||
rand = "0.10.3"
|
||||
rustls = "0.23.45"
|
||||
rustls-pemfile = "2.2.0"
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
serde_json = "1.0.151"
|
||||
sha2 = "0.11.0"
|
||||
thiserror = "2.0.21"
|
||||
tokio = { version = "1.53.2", features = ["rt-multi-thread", "net", "time", "sync", "macros", "io-util"] }
|
||||
tokio-tungstenite = { version = "0.30.0", features = ["rustls-tls-webpki-roots"] }
|
||||
url = "2.5.8"
|
||||
webpki-roots = "1.0.9"
|
||||
@@ -1,53 +0,0 @@
|
||||
//! 连接一个真实 nodeagent 被控端的最小示例。
|
||||
//!
|
||||
//! 用法:`cargo run --example connect -- <url> <key> [client_id]`
|
||||
//! 例如:`cargo run --example connect -- ws://127.0.0.1:8765 test-psk-key`
|
||||
|
||||
use nodeagent_client::{Client, ClientEvent, ClientOptions};
|
||||
use serde_json::json;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let url = args
|
||||
.get(1)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "ws://127.0.0.1:8765".to_string());
|
||||
let key = args.get(2).cloned().unwrap_or_default();
|
||||
let client_id = args
|
||||
.get(3)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "rust-example".to_string());
|
||||
|
||||
let client = Client::new(ClientOptions {
|
||||
url: url.clone(),
|
||||
key,
|
||||
client_id,
|
||||
insecure: true,
|
||||
auto_reconnect: false,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
client.set_handler(std::sync::Arc::new(|evt| {
|
||||
if let ClientEvent::Log(msg) = evt {
|
||||
println!("[log] {msg}");
|
||||
}
|
||||
}));
|
||||
|
||||
let caps = client.connect().await?;
|
||||
println!("握手成功,{} 项能力", caps.len());
|
||||
println!("agent 版本: {:?}", client.agent_version());
|
||||
println!("证书指纹: {:?}", client.peer_cert_fingerprint());
|
||||
for c in caps.iter().take(12) {
|
||||
println!(" - {} (risk={}, v{})", c.name, c.risk, c.version);
|
||||
}
|
||||
|
||||
let res = client.invoke("system.info", json!({})).await?;
|
||||
println!("\nsystem.info → status={}", res.status);
|
||||
if let Some(data) = &res.data {
|
||||
println!("{}", serde_json::to_string_pretty(data)?);
|
||||
}
|
||||
|
||||
client.close();
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
//! M3 能力探针:对真实被控端逐项调用「文件 / 审计 / 软件 / 进程服务」能力,核对契约。
|
||||
//!
|
||||
//! 输出**字段契约**而非数据预览——顶层键 + 数组首元素的键,
|
||||
//! 便于与前端 `apps/console/src/types/index.ts` 的类型声明逐项比对。
|
||||
//!
|
||||
//! 用法:`cargo run --example probe -- <url> <key> [base_dir] [--full]`
|
||||
//! --full 额外打印完整 JSON(长字段截断到 400 字符)
|
||||
|
||||
use nodeagent_client::{Client, ClientOptions};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// 一行对象 → `a, b, c` 形式的键清单。
|
||||
fn keys_of(v: &Value) -> String {
|
||||
match v {
|
||||
Value::Object(m) => m.keys().cloned().collect::<Vec<_>>().join(", "),
|
||||
other => format!("<{}>", type_name(other)),
|
||||
}
|
||||
}
|
||||
|
||||
fn type_name(v: &Value) -> &'static str {
|
||||
match v {
|
||||
Value::Null => "null",
|
||||
Value::Bool(_) => "bool",
|
||||
Value::Number(_) => "number",
|
||||
Value::String(_) => "string",
|
||||
Value::Array(_) => "array",
|
||||
Value::Object(_) => "object",
|
||||
}
|
||||
}
|
||||
|
||||
/// 打印「字段契约」:顶层键 + 各数组字段的首元素键。
|
||||
fn contract(v: &Value, indent: &str) {
|
||||
if let Value::Object(map) = v {
|
||||
for (k, val) in map {
|
||||
match val {
|
||||
Value::Array(items) if !items.is_empty() => {
|
||||
println!("{indent}{k}: array[{}] → {{ {} }}", items.len(), keys_of(&items[0]));
|
||||
}
|
||||
other if other.is_object() => {
|
||||
println!("{indent}{k}: object → {{ {} }}", keys_of(other));
|
||||
}
|
||||
other => {
|
||||
let shown = match other {
|
||||
Value::String(s) if s.chars().count() > 60 => {
|
||||
format!("string(\"{}…\")", s.chars().take(60).collect::<String>())
|
||||
}
|
||||
Value::Array(a) => format!("array[{}]", a.len()),
|
||||
_ => format!("{}({other})", type_name(other)),
|
||||
};
|
||||
println!("{indent}{k}: {shown}");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("{indent}{}", type_name(v));
|
||||
}
|
||||
}
|
||||
|
||||
async fn call(client: &Client, cap: &str, args: Value, full: bool) -> bool {
|
||||
match client.invoke(cap, args).await {
|
||||
Ok(r) if r.status == "ok" => {
|
||||
println!(" ✓ {cap}");
|
||||
if let Some(d) = &r.data {
|
||||
contract(d, " ");
|
||||
if full {
|
||||
let s = serde_json::to_string_pretty(d).unwrap_or_default();
|
||||
let s: String = s.chars().take(400).collect();
|
||||
println!(" ── full ── {s}");
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
Ok(r) => {
|
||||
let e = r.error.unwrap_or_default();
|
||||
println!(" ✗ {cap}: [{}] {}", e.name, e.message);
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
println!(" ✗ {cap}: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let argv: Vec<String> = std::env::args().skip(1).collect();
|
||||
let full = argv.iter().any(|a| a == "--full");
|
||||
let pos: Vec<String> = argv.into_iter().filter(|a| !a.starts_with("--")).collect();
|
||||
|
||||
let url = pos.first().cloned().unwrap_or_else(|| "ws://127.0.0.1:8765".to_string());
|
||||
let key = pos.get(1).cloned().unwrap_or_default();
|
||||
let base = pos.get(2).cloned().unwrap_or_else(|| "/tmp/nodeagent-e2e".to_string());
|
||||
|
||||
let client = Client::new(ClientOptions {
|
||||
url,
|
||||
key,
|
||||
client_id: "probe".to_string(),
|
||||
insecure: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let caps = client.connect().await?;
|
||||
println!("连接成功,{} 项能力", caps.len());
|
||||
|
||||
// 平台探测——决定 platform-only 能力是否预期可用。
|
||||
let info = client.invoke("system.info", json!({})).await?;
|
||||
let os = info
|
||||
.data
|
||||
.as_ref()
|
||||
.and_then(|d| d.get("os"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("?")
|
||||
.to_string();
|
||||
let is_windows = os == "Windows";
|
||||
println!("被控端平台:{os}(platform-only 能力{}预期可用)\n", if is_windows { "" } else { "不" });
|
||||
|
||||
let mut pass = 0usize;
|
||||
let mut fail = 0usize;
|
||||
|
||||
println!("【文件 fs.*】");
|
||||
for (cap, args) in [
|
||||
("fs.list", json!({ "path": base })),
|
||||
("fs.stat", json!({ "path": format!("{base}/agent.json") })),
|
||||
("fs.read", json!({ "path": format!("{base}/agent.json"), "max_bytes": 2048 })),
|
||||
(
|
||||
"fs.write",
|
||||
json!({ "path": format!("{base}/probe.txt"), "data": "hello from rust probe", "append": false, "create_dirs": true }),
|
||||
),
|
||||
] {
|
||||
if call(&client, cap, args, full).await {
|
||||
pass += 1
|
||||
} else {
|
||||
fail += 1
|
||||
}
|
||||
}
|
||||
|
||||
println!("\n【审计 system.audit.*】");
|
||||
for (cap, args) in [
|
||||
("system.audit.list", json!({ "limit": 5 })),
|
||||
("system.audit.verify", json!({})),
|
||||
] {
|
||||
if call(&client, cap, args, full).await {
|
||||
pass += 1
|
||||
} else {
|
||||
fail += 1
|
||||
}
|
||||
}
|
||||
|
||||
println!("\n【软件 / 进程 / 服务】");
|
||||
let platform_only = ["app.list", "app.install", "system.service.list"];
|
||||
for (cap, args) in [
|
||||
("app.list", json!({})),
|
||||
("app.install", json!({ "package": "__nonexistent_probe__", "silent": true })),
|
||||
("system.process.list", json!({ "limit": 5 })),
|
||||
("system.service.list", json!({ "limit": 5 })),
|
||||
] {
|
||||
let ok = call(&client, cap, args, full).await;
|
||||
// platform-only 能力在非 Windows 被控端上失败属**预期**,不计入失败。
|
||||
if ok {
|
||||
pass += 1;
|
||||
} else if !is_windows && platform_only.contains(&cap) {
|
||||
println!(" ↑ 非 Windows 被控端,属预期");
|
||||
} else {
|
||||
fail += 1;
|
||||
}
|
||||
}
|
||||
|
||||
println!("\n【其他页面依赖】");
|
||||
for (cap, args) in [
|
||||
("system.info", json!({})),
|
||||
("system.status", json!({})),
|
||||
("system.shell.exec", json!({ "command": "echo probe-ok" })),
|
||||
] {
|
||||
if call(&client, cap, args, full).await {
|
||||
pass += 1
|
||||
} else {
|
||||
fail += 1
|
||||
}
|
||||
}
|
||||
|
||||
println!("\n═══ 通过 {pass} · 失败 {fail} ═══");
|
||||
client.close();
|
||||
if fail > 0 {
|
||||
std::process::exit(1);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,628 +0,0 @@
|
||||
//! 控制端客户端主体(对齐 `packages/client/src/client.ts` 的 `NodeAgentClient`)。
|
||||
//!
|
||||
//! 复刻行为:连接 → 握手(hello → challenge → auth → auth_ok)→ 泛型能力调用 →
|
||||
//! 事件通知(无 id 的 `event` 方法)→ 断线指数退避重连(1s → 30s,±15% 抖动)。
|
||||
//!
|
||||
//! 并发结构(监督者模式):
|
||||
//! - `connect()` 派生一个 `supervise` 任务,串行「建立+握手 → 等读循环断开信号 → 退避重连」。
|
||||
//! - `establish()` 负责单次连接 + 握手,并派生**读循环**与**写任务**两个子任务。
|
||||
//! - 读循环在流结束时只 `notify` 监督者、绝不回调 `establish`——从而打破
|
||||
//! `establish → spawn(read_loop) → establish` 的类型级递归,让每个 future 都可证 `Send`。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::stream::{SplitSink, SplitStream};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::sync::{mpsc, oneshot, Notify};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
use crate::crypto::{compute_hmac, sign_nonce};
|
||||
use crate::error::{ClientError, ErrorCodes};
|
||||
use crate::tls;
|
||||
use crate::types::*;
|
||||
use crate::ulid::ulid;
|
||||
|
||||
/// WebSocket 流类型(TLS 下为 `MaybeTlsStream`)。
|
||||
type WsStream =
|
||||
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
|
||||
type WsSink = SplitSink<WsStream, Message>;
|
||||
type WsReader = SplitStream<WsStream>;
|
||||
|
||||
/// 控制面方法名(对齐 `protocol/src/methods.ts` 的 `Methods`)。
|
||||
const M_HELLO: &str = "hello";
|
||||
const M_AUTH: &str = "auth";
|
||||
const M_INVOKE: &str = "invoke";
|
||||
const M_EVENT: &str = "event";
|
||||
|
||||
/// 连接状态。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ConnState {
|
||||
Connected,
|
||||
Reconnecting,
|
||||
Closed,
|
||||
}
|
||||
|
||||
/// 对外事件:连接状态变化 / 被控端主动事件 / 日志。
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ClientEvent {
|
||||
State(ConnState),
|
||||
Event(Value),
|
||||
Log(String),
|
||||
}
|
||||
|
||||
/// 事件回调(`Arc` 便于共享给读循环)。
|
||||
pub type EventHandler = Arc<dyn Fn(ClientEvent) + Send + Sync>;
|
||||
|
||||
/// 认证模式。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AuthMode {
|
||||
Psk,
|
||||
Ed25519,
|
||||
}
|
||||
|
||||
/// 客户端选项。
|
||||
#[derive(Clone)]
|
||||
pub struct ClientOptions {
|
||||
/// `ws://` 或 `wss://` 地址。
|
||||
pub url: String,
|
||||
/// psk 预共享密钥。
|
||||
pub key: String,
|
||||
/// 控制端身份标识。
|
||||
pub client_id: String,
|
||||
/// 跳过自签证书校验(TLS 仍加密)。
|
||||
pub insecure: bool,
|
||||
/// 钉住的被控端证书指纹(sha256 hex,64 位)。
|
||||
pub cert_sha256: Option<String>,
|
||||
/// 认证模式。
|
||||
pub auth_mode: AuthMode,
|
||||
/// ed25519 模式:私钥(Base64 PKCS8 DER)。
|
||||
pub private_key: Option<String>,
|
||||
/// 握手超时(默认 10s)。
|
||||
pub handshake_timeout_ms: u64,
|
||||
/// 单次调用默认超时(默认 60s)。
|
||||
pub default_timeout_ms: u64,
|
||||
/// 断线后自动重连。
|
||||
pub auto_reconnect: bool,
|
||||
/// 重连退避上限(默认 30s)。
|
||||
pub max_reconnect_delay_ms: u64,
|
||||
}
|
||||
|
||||
impl Default for ClientOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
url: String::new(),
|
||||
key: String::new(),
|
||||
client_id: String::new(),
|
||||
insecure: false,
|
||||
cert_sha256: None,
|
||||
auth_mode: AuthMode::Psk,
|
||||
private_key: None,
|
||||
handshake_timeout_ms: 10_000,
|
||||
default_timeout_ms: 60_000,
|
||||
auto_reconnect: false,
|
||||
max_reconnect_delay_ms: 30_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 连接元信息(能力清单 / 授权 / 指纹 / 版本)。
|
||||
#[derive(Default, Clone)]
|
||||
struct Meta {
|
||||
capabilities: Vec<CapabilityDescriptor>,
|
||||
authorized: Option<Vec<String>>,
|
||||
peer_cert_fp: Option<String>,
|
||||
agent_version: Option<String>,
|
||||
agent_build: Option<BuildInfo>,
|
||||
}
|
||||
|
||||
/// 跨任务共享的可变状态。
|
||||
///
|
||||
/// 关键约束:`Client` 必须 `Sync`(多处 `&self` 跨 await)。这里的可变字段一律用
|
||||
/// `std` 的 `Mutex`/`RwLock`(天然 `Send + Sync`),并保证**不在持锁期间 await**:
|
||||
/// 发送半部分通过「专用写任务 + mpsc 通道」解耦,`pending` 的增删改全是同步短临界区。
|
||||
struct Shared {
|
||||
/// 到「写任务」的通道;写任务独占 sink 并转发消息(发送不跨 await 持锁)。
|
||||
write: RwLock<Option<mpsc::UnboundedSender<Message>>>,
|
||||
/// 待响应映射(id → oneshot 发送端);同步锁即可。
|
||||
pending: Mutex<HashMap<String, oneshot::Sender<Result<Value, ClientError>>>>,
|
||||
/// 读循环在流结束时通知监督者(「连接已断开」信号)。
|
||||
disconnected: Notify,
|
||||
meta: RwLock<Meta>,
|
||||
state: RwLock<ConnState>,
|
||||
reconnect_attempts: AtomicU32,
|
||||
stopped: AtomicBool,
|
||||
handler: RwLock<Option<EventHandler>>,
|
||||
}
|
||||
|
||||
/// 控制端客户端。
|
||||
#[derive(Clone)]
|
||||
pub struct Client {
|
||||
opts: Arc<ClientOptions>,
|
||||
shared: Arc<Shared>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
pub fn new(opts: ClientOptions) -> Self {
|
||||
Self {
|
||||
opts: Arc::new(opts),
|
||||
shared: Arc::new(Shared {
|
||||
write: RwLock::new(None),
|
||||
pending: Mutex::new(HashMap::new()),
|
||||
disconnected: Notify::new(),
|
||||
meta: RwLock::new(Meta::default()),
|
||||
state: RwLock::new(ConnState::Closed),
|
||||
reconnect_attempts: AtomicU32::new(0),
|
||||
stopped: AtomicBool::new(false),
|
||||
handler: RwLock::new(None),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置事件回调。
|
||||
pub fn set_handler(&self, handler: EventHandler) {
|
||||
*self.shared.handler.write().unwrap() = Some(handler);
|
||||
}
|
||||
|
||||
/// 建立连接并完成握手,返回能力清单。
|
||||
/// 派生监督任务后,仅等待**首次**握手结果(失败即返回错误,不自动重连)。
|
||||
pub async fn connect(&self) -> Result<Vec<CapabilityDescriptor>, ClientError> {
|
||||
self.shared.stopped.store(false, Ordering::SeqCst);
|
||||
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let this = self.clone();
|
||||
tokio::spawn(async move {
|
||||
this.supervise(Some(tx)).await;
|
||||
});
|
||||
|
||||
match rx.await {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(ClientError::offline("连接监督任务异常退出")),
|
||||
}
|
||||
}
|
||||
|
||||
/// 调用一项能力(`args` 为 JSON 对象),用默认超时。
|
||||
pub async fn invoke(
|
||||
&self,
|
||||
capability: &str,
|
||||
args: Value,
|
||||
) -> Result<InvokeResult, ClientError> {
|
||||
self.invoke_with_timeout(capability, args, self.opts.default_timeout_ms)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 调用一项能力并显式指定 RPC 超时(长任务如 `app.install` / 长命令用)。
|
||||
pub async fn invoke_with_timeout(
|
||||
&self,
|
||||
capability: &str,
|
||||
args: Value,
|
||||
timeout_ms: u64,
|
||||
) -> Result<InvokeResult, ClientError> {
|
||||
let params = json!({ "capability": capability, "args": args });
|
||||
let value = self.request(M_INVOKE, params, timeout_ms).await?;
|
||||
serde_json::from_value::<InvokeResult>(value)
|
||||
.map_err(|e| ClientError::new(ErrorCodes::PARSE_ERROR, format!("解析调用结果失败: {e}")))
|
||||
}
|
||||
|
||||
/// 已获取的能力清单。
|
||||
pub fn list_capabilities(&self) -> Vec<CapabilityDescriptor> {
|
||||
self.shared.meta.read().unwrap().capabilities.clone()
|
||||
}
|
||||
|
||||
/// ed25519 模式下本次被授权的能力;psk 模式返回 `None`。
|
||||
pub fn list_authorized(&self) -> Option<Vec<String>> {
|
||||
self.shared.meta.read().unwrap().authorized.clone()
|
||||
}
|
||||
|
||||
/// 本次连接观测到的对端证书指纹(sha256 hex)。
|
||||
pub fn peer_cert_fingerprint(&self) -> Option<String> {
|
||||
self.shared.meta.read().unwrap().peer_cert_fp.clone()
|
||||
}
|
||||
|
||||
/// 被控端语义化版本。
|
||||
pub fn agent_version(&self) -> Option<String> {
|
||||
self.shared.meta.read().unwrap().agent_version.clone()
|
||||
}
|
||||
|
||||
/// 被控端构建信息。
|
||||
pub fn agent_build(&self) -> Option<BuildInfo> {
|
||||
self.shared.meta.read().unwrap().agent_build.clone()
|
||||
}
|
||||
|
||||
/// 当前连接状态。
|
||||
pub fn state(&self) -> ConnState {
|
||||
*self.shared.state.read().unwrap()
|
||||
}
|
||||
|
||||
/// 主动关闭连接(停止自动重连)。
|
||||
pub fn close(&self) {
|
||||
self.shared.stopped.store(true, Ordering::SeqCst);
|
||||
// 丢弃发送通道 → 写任务退出 → sink 释放 → 连接关闭;并唤醒监督者及时退出。
|
||||
*self.shared.write.write().unwrap() = None;
|
||||
self.shared.disconnected.notify_one();
|
||||
self.set_state(ConnState::Closed);
|
||||
self.emit_log("主动关闭");
|
||||
}
|
||||
|
||||
// ---------- 内部 ----------
|
||||
|
||||
/// 监督循环:反复「建立+握手 → 等断开 → 退避重连」。
|
||||
/// 首次握手结果经 `first_tx` 回传给 `connect()`;首次失败直接返回,不重连。
|
||||
async fn supervise(
|
||||
&self,
|
||||
first_tx: Option<oneshot::Sender<Result<Vec<CapabilityDescriptor>, ClientError>>>,
|
||||
) {
|
||||
let mut first_tx = first_tx;
|
||||
|
||||
loop {
|
||||
let established = match self.establish().await {
|
||||
Ok(caps) => {
|
||||
self.shared.reconnect_attempts.store(0, Ordering::SeqCst);
|
||||
self.set_state(ConnState::Connected);
|
||||
// 首次成功:把能力清单回传给 `connect()` 调用方。
|
||||
if let Some(tx) = first_tx.take() {
|
||||
let _ = tx.send(Ok(caps));
|
||||
}
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
// 首次失败:直接报错返回(不做自动重连,对齐 Node 行为)。
|
||||
if let Some(tx) = first_tx.take() {
|
||||
let _ = tx.send(Err(e));
|
||||
return;
|
||||
}
|
||||
self.emit_log(format!("重连失败: {e}"));
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
// 本次建立成功才有读循环会发「断开」信号;失败则直接进入退避。
|
||||
if established {
|
||||
self.shared.disconnected.notified().await;
|
||||
}
|
||||
|
||||
if self.shared.stopped.load(Ordering::SeqCst) {
|
||||
self.set_state(ConnState::Closed);
|
||||
return;
|
||||
}
|
||||
if !self.opts.auto_reconnect {
|
||||
self.set_state(ConnState::Closed);
|
||||
return;
|
||||
}
|
||||
|
||||
let delay = self.next_backoff_delay();
|
||||
self.set_state(ConnState::Reconnecting);
|
||||
self.emit_log(format!("将在 {}ms 后重连", delay.as_millis()));
|
||||
tokio::time::sleep(delay).await;
|
||||
|
||||
if self.shared.stopped.load(Ordering::SeqCst) {
|
||||
self.set_state(ConnState::Closed);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 建立一次连接:TCP/TLS/WS → split → 读循环 + 写任务 → 协议握手。
|
||||
async fn establish(&self) -> Result<Vec<CapabilityDescriptor>, ClientError> {
|
||||
// TLS 下抓取对端证书指纹(自签 + insecure 时留下的中间人空档,靠钉住兜底)。
|
||||
let captured: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
|
||||
|
||||
let (ws, _resp) = if self.opts.url.starts_with("wss://") {
|
||||
let config = tls::build_client_config(
|
||||
self.opts.insecure,
|
||||
self.opts.cert_sha256.clone(),
|
||||
captured.clone(),
|
||||
)?;
|
||||
let connector = tokio_tungstenite::Connector::Rustls(Arc::new(config));
|
||||
tokio_tungstenite::connect_async_tls_with_config(
|
||||
self.opts.url.as_str(),
|
||||
None,
|
||||
false,
|
||||
Some(connector),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| self.map_connect_error(e))?
|
||||
} else {
|
||||
tokio_tungstenite::connect_async(self.opts.url.as_str())
|
||||
.await
|
||||
.map_err(|e| self.map_connect_error(e))?
|
||||
};
|
||||
|
||||
let peer_fp = captured.lock().unwrap().clone();
|
||||
|
||||
let (sink, stream) = ws.split();
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
*self.shared.write.write().unwrap() = Some(tx);
|
||||
|
||||
// 写任务独占 sink;读循环独立跑;二者都只持有局部 Send 类型。
|
||||
tokio::spawn(async move { writer_task(sink, rx).await });
|
||||
let this = self.clone();
|
||||
tokio::spawn(async move {
|
||||
this.read_loop(stream).await;
|
||||
});
|
||||
|
||||
// 握手;失败则关闭连接(丢弃发送通道 → 写任务退出 → sink 释放 → 读循环结束)。
|
||||
let result = self.handshake(peer_fp).await;
|
||||
if result.is_err() {
|
||||
*self.shared.write.write().unwrap() = None;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// 握手:hello → challenge → auth → auth_ok;成功后写回元信息。
|
||||
async fn handshake(&self, peer_fp: Option<String>) -> Result<Vec<CapabilityDescriptor>, ClientError> {
|
||||
let challenge: ChallengeParams = {
|
||||
let value = self
|
||||
.request(
|
||||
M_HELLO,
|
||||
json!({ "protocol": PROTOCOL_VERSION, "client_id": self.opts.client_id }),
|
||||
self.opts.handshake_timeout_ms,
|
||||
)
|
||||
.await?;
|
||||
serde_json::from_value(value).map_err(|e| {
|
||||
ClientError::new(ErrorCodes::PARSE_ERROR, format!("解析 challenge 失败: {e}"))
|
||||
})?
|
||||
};
|
||||
|
||||
let auth_params = match self.opts.auth_mode {
|
||||
AuthMode::Ed25519 => {
|
||||
let private_key = self.opts.private_key.as_deref().ok_or_else(|| {
|
||||
ClientError::new(ErrorCodes::AUTH_FAILED, "ed25519 模式需要私钥,请先 keygen")
|
||||
})?;
|
||||
let signature = sign_nonce(private_key, &challenge.nonce).map_err(|e| {
|
||||
ClientError::new(ErrorCodes::AUTH_FAILED, format!("签名失败: {e}"))
|
||||
})?;
|
||||
json!({ "client_id": self.opts.client_id, "nonce": challenge.nonce, "signature": signature })
|
||||
}
|
||||
AuthMode::Psk => {
|
||||
let hmac = compute_hmac(&self.opts.key, &challenge.nonce);
|
||||
json!({ "client_id": self.opts.client_id, "nonce": challenge.nonce, "hmac": hmac })
|
||||
}
|
||||
};
|
||||
|
||||
let auth_ok: AuthOkParams = {
|
||||
let value = self
|
||||
.request(M_AUTH, auth_params, self.opts.handshake_timeout_ms)
|
||||
.await?;
|
||||
serde_json::from_value(value).map_err(|e| {
|
||||
ClientError::new(ErrorCodes::PARSE_ERROR, format!("解析 auth_ok 失败: {e}"))
|
||||
})?
|
||||
};
|
||||
|
||||
// v21 交叉确认:握手抓到的实际证书 vs 服务端自报 —— 不一致说明自报被篡改/冒充。
|
||||
if let (Some(fp), Some(self_reported)) = (
|
||||
peer_fp.as_ref(),
|
||||
auth_ok
|
||||
.build
|
||||
.as_ref()
|
||||
.and_then(|b| b.cert_sha256.as_ref()),
|
||||
) {
|
||||
if fp != self_reported {
|
||||
return Err(ClientError::cert_mismatch(
|
||||
"被控端自报的证书指纹与实际握手证书不一致,拒绝使用(可能被冒充)",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let capabilities = auth_ok.capabilities.clone();
|
||||
let authorized_len = auth_ok.authorized.as_ref().map(|a| a.len());
|
||||
{
|
||||
let mut meta = self.shared.meta.write().unwrap();
|
||||
meta.capabilities = auth_ok.capabilities.clone();
|
||||
meta.authorized = auth_ok.authorized.clone();
|
||||
meta.peer_cert_fp = peer_fp;
|
||||
meta.agent_version = auth_ok.agent_version.clone();
|
||||
meta.agent_build = auth_ok.build.clone();
|
||||
}
|
||||
|
||||
let capabilities_len = capabilities.len();
|
||||
let label = if auth_ok.auth_mode.as_deref() == Some("ed25519") {
|
||||
format!("ed25519,授权 {}/{capabilities_len} 项能力", authorized_len.unwrap_or(0))
|
||||
} else {
|
||||
format!("psk,被控端声明 {capabilities_len} 项能力")
|
||||
};
|
||||
self.emit_log(format!("握手成功({label})"));
|
||||
|
||||
Ok(capabilities)
|
||||
}
|
||||
|
||||
/// 底层请求(带超时与 id 匹配)。
|
||||
async fn request(
|
||||
&self,
|
||||
method: &str,
|
||||
params: Value,
|
||||
timeout_ms: u64,
|
||||
) -> Result<Value, ClientError> {
|
||||
let id = ulid();
|
||||
let (tx, rx) = oneshot::channel();
|
||||
|
||||
self.shared.pending.lock().unwrap().insert(id.clone(), tx);
|
||||
|
||||
let req = RpcRequest {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: id.clone(),
|
||||
method: method.to_string(),
|
||||
params: Some(params),
|
||||
};
|
||||
let payload = serde_json::to_string(&req)
|
||||
.map_err(|e| ClientError::new(ErrorCodes::PARSE_ERROR, e.to_string()))?;
|
||||
|
||||
{
|
||||
let sender = self
|
||||
.shared
|
||||
.write
|
||||
.read()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.ok_or_else(|| ClientError::offline("连接未就绪"))?;
|
||||
// 同步发送(不持锁 await):消息经通道转交写任务。
|
||||
sender
|
||||
.send(Message::Text(payload.into()))
|
||||
.map_err(|e| ClientError::offline(format!("发送失败: {e}")))?;
|
||||
}
|
||||
|
||||
match tokio::time::timeout(Duration::from_millis(timeout_ms), rx).await {
|
||||
Err(_) => {
|
||||
self.shared.pending.lock().unwrap().remove(&id);
|
||||
Err(ClientError::timeout(format!(
|
||||
"调用超时 ({timeout_ms}ms): {method}"
|
||||
)))
|
||||
}
|
||||
Ok(Err(_)) => Err(ClientError::offline("连接已断开")),
|
||||
Ok(Ok(result)) => result,
|
||||
}
|
||||
}
|
||||
|
||||
/// 读循环:分发响应与事件;流结束后清空 pending 并通知监督者。
|
||||
async fn read_loop(&self, mut stream: WsReader) {
|
||||
while let Some(Ok(msg)) = stream.next().await {
|
||||
if let Err(e) = self.handle_message(msg).await {
|
||||
self.emit_log(format!("消息处理失败: {e}"));
|
||||
}
|
||||
}
|
||||
// 流结束(对端关闭 / 网络断开)→ 清空 pending,由监督者决定是否重连。
|
||||
self.reject_all_pending();
|
||||
self.shared.disconnected.notify_one();
|
||||
}
|
||||
|
||||
/// 处理单条消息。
|
||||
async fn handle_message(&self, msg: Message) -> Result<(), ClientError> {
|
||||
// WebSocket ping 帧:回 pong(保持连接)。
|
||||
if msg.is_ping() {
|
||||
let pong = msg.into_data();
|
||||
if let Some(sender) = self.shared.write.read().unwrap().as_ref() {
|
||||
let _ = sender.send(Message::Pong(pong));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if !msg.is_text() && !msg.is_binary() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let text = match msg {
|
||||
Message::Text(t) => t.to_string(),
|
||||
Message::Binary(b) => match String::from_utf8(b.to_vec()) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return Ok(()),
|
||||
},
|
||||
_ => return Ok(()),
|
||||
};
|
||||
|
||||
let value: Value = match serde_json::from_str(&text) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
self.emit_log(format!("收到无法解析的消息: {}", &text[..text.len().min(200)]));
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
// 有 id → 响应;无 id → 通知(事件)。
|
||||
if let Some(id) = value.get("id").and_then(|v| v.as_str()) {
|
||||
if let Some(tx) = self.shared.pending.lock().unwrap().remove(id) {
|
||||
let result = self.parse_response(&value);
|
||||
let _ = tx.send(result);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let method = value.get("method").and_then(|m| m.as_str()).unwrap_or("");
|
||||
if method == M_EVENT {
|
||||
if let Some(params) = value.get("params").cloned() {
|
||||
self.emit_event(params);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 把响应消息解析为 `Result<Value, ClientError>`。
|
||||
fn parse_response(&self, value: &Value) -> Result<Value, ClientError> {
|
||||
if is_error_response(value) {
|
||||
let err = value.get("error").cloned().unwrap_or(Value::Null);
|
||||
let code = err
|
||||
.get("code")
|
||||
.and_then(|c| c.as_i64())
|
||||
.unwrap_or(ErrorCodes::PARSE_ERROR);
|
||||
let name = err
|
||||
.get("name")
|
||||
.and_then(|n| n.as_str())
|
||||
.unwrap_or("E_UNKNOWN")
|
||||
.to_string();
|
||||
let message = err
|
||||
.get("message")
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
Err(ClientError {
|
||||
code,
|
||||
name,
|
||||
message,
|
||||
data: err.get("data").cloned(),
|
||||
})
|
||||
} else {
|
||||
value
|
||||
.get("result")
|
||||
.cloned()
|
||||
.ok_or_else(|| ClientError::new(ErrorCodes::PARSE_ERROR, "响应缺少 result 字段"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 指数退避 + ±15% 抖动(1s → 30s)。
|
||||
fn next_backoff_delay(&self) -> Duration {
|
||||
let attempts = self.shared.reconnect_attempts.fetch_add(1, Ordering::SeqCst);
|
||||
let base = (1000u64 << attempts).min(self.opts.max_reconnect_delay_ms);
|
||||
let jitter = 0.85 + rand::random::<f64>() * 0.3; // 0.85..=1.15
|
||||
Duration::from_millis((base as f64 * jitter) as u64)
|
||||
}
|
||||
|
||||
fn reject_all_pending(&self) {
|
||||
let drained: Vec<oneshot::Sender<Result<Value, ClientError>>> = {
|
||||
let mut map = self.shared.pending.lock().unwrap();
|
||||
map.drain().map(|(_, tx)| tx).collect()
|
||||
};
|
||||
for tx in drained {
|
||||
let _ = tx.send(Err(ClientError::offline("连接已断开")));
|
||||
}
|
||||
}
|
||||
|
||||
fn map_connect_error(&self, e: tokio_tungstenite::tungstenite::Error) -> ClientError {
|
||||
let msg = e.to_string();
|
||||
if msg.contains("证书指纹不匹配") {
|
||||
ClientError::cert_mismatch(msg)
|
||||
} else {
|
||||
ClientError::offline(format!("无法连接被控端: {msg}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn set_state(&self, state: ConnState) {
|
||||
*self.shared.state.write().unwrap() = state;
|
||||
if let Some(h) = self.shared.handler.read().unwrap().as_ref() {
|
||||
h(ClientEvent::State(state));
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_event(&self, params: Value) {
|
||||
if let Some(h) = self.shared.handler.read().unwrap().as_ref() {
|
||||
h(ClientEvent::Event(params));
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_log(&self, msg: impl Into<String>) {
|
||||
if let Some(h) = self.shared.handler.read().unwrap().as_ref() {
|
||||
h(ClientEvent::Log(msg.into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 写任务:独占 sink,把 mpsc 通道里的消息逐条发出去。
|
||||
/// 通道关闭(`close()` 丢弃 sender)或发送失败时退出,sink 随之释放。
|
||||
async fn writer_task(mut sink: WsSink, mut rx: mpsc::UnboundedReceiver<Message>) {
|
||||
while let Some(msg) = rx.recv().await {
|
||||
if sink.send(msg).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
//! 密码学工具:HMAC / ed25519 签名 / 证书指纹 / nonce。
|
||||
//! 逐一对齐 `packages/protocol/src/{hmac,ed25519}.ts` 与 `net.ts`。
|
||||
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine as _;
|
||||
use hmac::{Hmac, KeyInit, Mac};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// 计算 HMAC-SHA256,返回 Base64(对齐 hmac.ts 的 `computeHmac`)。
|
||||
/// 密钥为预共享密钥字符串,nonce 以 UTF-8 编码。
|
||||
pub fn compute_hmac(key: &str, nonce: &str) -> String {
|
||||
// HMAC 接受任意长度 key,此处不可能失败,unwrap 仅为类型收敛。
|
||||
let mut mac = HmacSha256::new_from_slice(key.as_bytes()).expect("HMAC 接受任意长度 key");
|
||||
mac.update(nonce.as_bytes());
|
||||
STANDARD.encode(mac.finalize().into_bytes())
|
||||
}
|
||||
|
||||
/// 生成随机 nonce(32 字节,Base64),对齐 `generateNonce`。
|
||||
pub fn generate_nonce() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
for b in bytes.iter_mut() {
|
||||
*b = rand::random::<u8>();
|
||||
}
|
||||
STANDARD.encode(bytes)
|
||||
}
|
||||
|
||||
/// 证书指纹:sha256(DER) 的十六进制小写 64 位(对齐 net.ts 的 `certFingerprint`)。
|
||||
pub fn cert_fingerprint(der: &[u8]) -> String {
|
||||
let digest = Sha256::digest(der);
|
||||
digest.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// 对 nonce 原文做 ed25519 签名,返回 Base64(对齐 ed25519.ts 的 `signNonce`)。
|
||||
/// 私钥为 Base64(PKCS8 DER)。
|
||||
pub fn sign_nonce(private_key_b64: &str, nonce: &str) -> Result<String, String> {
|
||||
use ed25519_dalek::pkcs8::DecodePrivateKey;
|
||||
use ed25519_dalek::Signer;
|
||||
|
||||
let der = STANDARD
|
||||
.decode(private_key_b64)
|
||||
.map_err(|e| format!("私钥 Base64 解码失败: {e}"))?;
|
||||
let signing_key = ed25519_dalek::SigningKey::from_pkcs8_der(&der)
|
||||
.map_err(|e| format!("PKCS8 私钥解析失败: {e}"))?;
|
||||
let sig = signing_key.sign(nonce.as_bytes());
|
||||
Ok(STANDARD.encode(sig.to_bytes()))
|
||||
}
|
||||
|
||||
/// 验签(对齐 `verifyNonce`);任何异常均视为失败(不抛出)。
|
||||
pub fn verify_nonce(public_key_b64: &str, nonce: &str, signature_b64: &str) -> bool {
|
||||
use ed25519_dalek::pkcs8::DecodePublicKey;
|
||||
use ed25519_dalek::Verifier;
|
||||
|
||||
let Ok(pk_der) = STANDARD.decode(public_key_b64) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(vk) = ed25519_dalek::VerifyingKey::from_public_key_der(&pk_der) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(sig_bytes) = STANDARD.decode(signature_b64) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(sig) = ed25519_dalek::Signature::from_slice(&sig_bytes) else {
|
||||
return false;
|
||||
};
|
||||
vk.verify(nonce.as_bytes(), &sig).is_ok()
|
||||
}
|
||||
|
||||
/// 公钥指纹(SHA-256 前 16 位 hex),对齐 `keyId`。
|
||||
pub fn key_id(public_key_b64: &str) -> String {
|
||||
let der = STANDARD.decode(public_key_b64).unwrap_or_default();
|
||||
let digest = Sha256::digest(&der);
|
||||
// 前 16 个 hex 字符 = 前 8 字节。
|
||||
digest.iter().take(8).map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use ed25519_dalek::pkcs8::{EncodePrivateKey, EncodePublicKey};
|
||||
|
||||
fn gen_keypair() -> (String, String) {
|
||||
let sk = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]);
|
||||
let pk = sk.verifying_key();
|
||||
(
|
||||
STANDARD.encode(pk.to_public_key_der().expect("编码公钥").as_bytes()),
|
||||
STANDARD.encode(sk.to_pkcs8_der().expect("编码私钥").as_bytes()),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmac_matches_known_vector() {
|
||||
// 与 Node 版对拍的固定向量:HMAC-SHA256("key", "nonce")。
|
||||
let out = compute_hmac("secret", "hello");
|
||||
// 用独立的 hmac 重算并比对,避免自证。
|
||||
let mut mac = HmacSha256::new_from_slice(b"secret").unwrap();
|
||||
mac.update(b"hello");
|
||||
let expect = STANDARD.encode(mac.finalize().into_bytes());
|
||||
assert_eq!(out, expect);
|
||||
// Base64(HMAC-SHA256("secret","hello")) 的已知值(由 RFC 测试向量推得,供跨端核对)。
|
||||
assert_eq!(out, "iKqz7ejTrflNJquQ07r9SiCDBww7zOnAFO4EpEOEfAs=");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sign_and_verify_roundtrip() {
|
||||
let (pk, sk) = gen_keypair();
|
||||
let nonce = generate_nonce();
|
||||
let sig = sign_nonce(&sk, &nonce).unwrap();
|
||||
assert!(verify_nonce(&pk, &nonce, &sig));
|
||||
assert!(!verify_nonce(&pk, "tampered", &sig));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_id_is_16_hex() {
|
||||
let (pk, _sk) = gen_keypair();
|
||||
let id = key_id(&pk);
|
||||
assert_eq!(id.len(), 16);
|
||||
assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cert_fingerprint_sha256_der() {
|
||||
// 空 DER 的 sha256 是已知常量。
|
||||
assert_eq!(
|
||||
cert_fingerprint(b""),
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
//! 局域网发现(对齐 `packages/client/src/discovery.ts`)。
|
||||
//!
|
||||
//! 只监听被控端 UDP 广播、不发送任何数据,因此不引入额外暴露面。
|
||||
//! M1 实现基础解析;v22 最小化广播的 HMAC 验签留待后续(需对齐 JSON 序列化顺序)。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::net::UdpSocket;
|
||||
|
||||
/// 发现到的被控端。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DiscoveredNode {
|
||||
pub node_id: String,
|
||||
/// 可达地址:取报文来源 IP(一定可达)。
|
||||
pub host: String,
|
||||
/// 被控端自报地址(多网卡时可能与来源不同,仅供参考)。
|
||||
pub advertised_host: String,
|
||||
pub port: u16,
|
||||
pub tls: bool,
|
||||
pub auth_mode: String,
|
||||
pub input_enabled: bool,
|
||||
pub platform: String,
|
||||
/// v22:详情是否通过 HMAC 验签。false = 明文广播,仅作展示、不可当凭据。
|
||||
pub authenticated: bool,
|
||||
/// 最近一次收到广播的时间(Unix 毫秒)。
|
||||
pub last_seen: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawBeacon {
|
||||
service: Option<String>,
|
||||
node_id: Option<String>,
|
||||
host: Option<String>,
|
||||
port: Option<u16>,
|
||||
tls: Option<bool>,
|
||||
auth_mode: Option<String>,
|
||||
input_enabled: Option<bool>,
|
||||
platform: Option<String>,
|
||||
minimal: Option<bool>,
|
||||
#[serde(default)]
|
||||
detail: Option<BeaconDetail>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct BeaconDetail {
|
||||
host: Option<String>,
|
||||
port: Option<u16>,
|
||||
tls: Option<bool>,
|
||||
auth_mode: Option<String>,
|
||||
input_enabled: Option<bool>,
|
||||
platform: Option<String>,
|
||||
}
|
||||
|
||||
/// 默认发现端口(对齐 methods.ts 的 `DEFAULT_DISCOVERY_PORT`)。
|
||||
pub const DEFAULT_DISCOVERY_PORT: u16 = 8766;
|
||||
|
||||
/// 默认被控端端口。
|
||||
const DEFAULT_AGENT_PORT: u16 = 8765;
|
||||
|
||||
/// 监听一段时间后返回发现的设备(对齐 `discoverOnce`)。
|
||||
pub async fn discover_once(wait_ms: u64, port: u16) -> Result<Vec<DiscoveredNode>, String> {
|
||||
let socket = UdpSocket::bind(("0.0.0.0", port))
|
||||
.await
|
||||
.map_err(|e| format!("发现监听失败(UDP {port}): {e}"))?;
|
||||
|
||||
let mut nodes: HashMap<String, DiscoveredNode> = HashMap::new();
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_millis(wait_ms);
|
||||
let mut buf = [0u8; 2048];
|
||||
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
break;
|
||||
}
|
||||
match tokio::time::timeout(remaining, socket.recv_from(&mut buf)).await {
|
||||
Ok(Ok((len, src))) => {
|
||||
if let Some(node) = parse_beacon(&buf[..len], src) {
|
||||
nodes.insert(node.node_id.clone(), node);
|
||||
}
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
|
||||
let now = now_ms();
|
||||
let mut list: Vec<DiscoveredNode> = nodes.into_values().collect();
|
||||
// 过滤 TTL 过期项(30s),按最近出现排序。
|
||||
list.retain(|n| now - n.last_seen <= 30_000);
|
||||
list.sort_by_key(|n| std::cmp::Reverse(n.last_seen));
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
fn parse_beacon(data: &[u8], src: std::net::SocketAddr) -> Option<DiscoveredNode> {
|
||||
let raw: RawBeacon = serde_json::from_slice(data).ok()?;
|
||||
if raw.service.as_deref() != Some("nodeagent") {
|
||||
return None;
|
||||
}
|
||||
let node_id = raw.node_id?;
|
||||
|
||||
// v22 最小化广播:顶层只剩存在性,真实信息在 detail 里。
|
||||
let detail = raw.minimal.unwrap_or(false).then_some(raw.detail).flatten();
|
||||
|
||||
Some(DiscoveredNode {
|
||||
node_id,
|
||||
// 地址永远取「报文来源 IP」—— 一定可达。
|
||||
host: src.ip().to_string(),
|
||||
advertised_host: detail
|
||||
.as_ref()
|
||||
.and_then(|d| d.host.clone())
|
||||
.or(raw.host)
|
||||
.unwrap_or_else(|| src.ip().to_string()),
|
||||
port: detail
|
||||
.as_ref()
|
||||
.and_then(|d| d.port)
|
||||
.or(raw.port)
|
||||
.unwrap_or(DEFAULT_AGENT_PORT),
|
||||
tls: detail.as_ref().and_then(|d| d.tls).or(raw.tls).unwrap_or(true),
|
||||
auth_mode: detail
|
||||
.as_ref()
|
||||
.and_then(|d| d.auth_mode.clone())
|
||||
.or(raw.auth_mode)
|
||||
.unwrap_or_else(|| "psk".to_string()),
|
||||
input_enabled: detail
|
||||
.as_ref()
|
||||
.and_then(|d| d.input_enabled)
|
||||
.or(raw.input_enabled)
|
||||
.unwrap_or(false),
|
||||
platform: detail
|
||||
.as_ref()
|
||||
.and_then(|d| d.platform.clone())
|
||||
.or(raw.platform)
|
||||
.unwrap_or_else(|| "unknown".to_string()),
|
||||
authenticated: false,
|
||||
last_seen: now_ms(),
|
||||
})
|
||||
}
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
//! 协议错误码与客户端错误类型(对齐 `packages/protocol/src/errors.ts`)。
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// 协议错误码常量(对齐 errors.ts 的 `ErrorCodes`)。
|
||||
#[allow(non_snake_case)]
|
||||
pub mod ErrorCodes {
|
||||
pub const PARSE_ERROR: i64 = -32700;
|
||||
pub const INVALID_REQUEST: i64 = -32600;
|
||||
pub const METHOD_NOT_FOUND: i64 = -32601;
|
||||
pub const PARAM_INVALID: i64 = -32602;
|
||||
pub const AUTH_FAILED: i64 = -32401;
|
||||
pub const AUTH_REQUIRED: i64 = -32402;
|
||||
pub const CAPABILITY_NOT_FOUND: i64 = -32403;
|
||||
pub const CAPABILITY_DISABLED: i64 = -32404;
|
||||
pub const NODE_OFFLINE: i64 = -32405;
|
||||
pub const ACL_DENIED: i64 = -32406;
|
||||
pub const RATE_LIMITED: i64 = -32407;
|
||||
pub const TIMEOUT: i64 = -32412;
|
||||
pub const EXECUTION_FAILED: i64 = -32419;
|
||||
pub const PROTOCOL_MISMATCH: i64 = -32420;
|
||||
pub const UNSUPPORTED_PLATFORM: i64 = -32421;
|
||||
/// v21:被控端证书指纹与控制端钉住的不一致(可能被冒充)。
|
||||
pub const CERT_MISMATCH: i64 = -32422;
|
||||
}
|
||||
|
||||
/// 错误码 → 名称。
|
||||
pub fn error_name(code: i64) -> &'static str {
|
||||
match code {
|
||||
ErrorCodes::PARSE_ERROR => "E_PARSE_ERROR",
|
||||
ErrorCodes::INVALID_REQUEST => "E_INVALID_REQUEST",
|
||||
ErrorCodes::METHOD_NOT_FOUND => "E_METHOD_NOT_FOUND",
|
||||
ErrorCodes::PARAM_INVALID => "E_PARAM_INVALID",
|
||||
ErrorCodes::AUTH_FAILED => "E_AUTH_FAILED",
|
||||
ErrorCodes::AUTH_REQUIRED => "E_AUTH_REQUIRED",
|
||||
ErrorCodes::CAPABILITY_NOT_FOUND => "E_CAPABILITY_NOT_FOUND",
|
||||
ErrorCodes::CAPABILITY_DISABLED => "E_CAPABILITY_DISABLED",
|
||||
ErrorCodes::NODE_OFFLINE => "E_NODE_OFFLINE",
|
||||
ErrorCodes::ACL_DENIED => "E_ACL_DENIED",
|
||||
ErrorCodes::RATE_LIMITED => "E_RATE_LIMITED",
|
||||
ErrorCodes::TIMEOUT => "E_TIMEOUT",
|
||||
ErrorCodes::EXECUTION_FAILED => "E_EXECUTION_FAILED",
|
||||
ErrorCodes::PROTOCOL_MISMATCH => "E_PROTOCOL_MISMATCH",
|
||||
ErrorCodes::UNSUPPORTED_PLATFORM => "E_UNSUPPORTED_PLATFORM",
|
||||
ErrorCodes::CERT_MISMATCH => "E_CERT_MISMATCH",
|
||||
_ => "E_UNKNOWN",
|
||||
}
|
||||
}
|
||||
|
||||
/// 客户端错误(携带协议错误码,对齐 client.ts 的 `ClientError`)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClientError {
|
||||
pub code: i64,
|
||||
pub name: String,
|
||||
pub message: String,
|
||||
pub data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl ClientError {
|
||||
pub fn new(code: i64, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code,
|
||||
name: error_name(code).to_string(),
|
||||
message: message.into(),
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_data(mut self, data: serde_json::Value) -> Self {
|
||||
self.data = Some(data);
|
||||
self
|
||||
}
|
||||
|
||||
/// 被控端离线/不可达。
|
||||
pub fn offline(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorCodes::NODE_OFFLINE, message)
|
||||
}
|
||||
|
||||
/// 调用超时。
|
||||
pub fn timeout(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorCodes::TIMEOUT, message)
|
||||
}
|
||||
|
||||
/// 证书指纹不一致。
|
||||
pub fn cert_mismatch(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorCodes::CERT_MISMATCH, message)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ClientError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}: {}", self.name, self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ClientError {}
|
||||
@@ -1,34 +0,0 @@
|
||||
//! nodeagent 控制端 Rust 客户端。
|
||||
//!
|
||||
//! 行为对齐 `packages/client`(Node 版),复刻:
|
||||
//! - 握手 hello → challenge → auth → auth_ok(psk HMAC / ed25519 签名)
|
||||
//! - 泛型能力调用 invoke(带超时与 id 匹配)
|
||||
//! - 事件通知(无 id 的 JSON-RPC notification,`event` 方法)
|
||||
//! - 断线指数退避重连(1s → 30s,±15% 抖动)
|
||||
//! - TLS 自签证书 + 指纹钉住(v21,E_CERT_MISMATCH)
|
||||
|
||||
pub mod client;
|
||||
pub mod crypto;
|
||||
pub mod discovery;
|
||||
pub mod error;
|
||||
pub mod tls;
|
||||
pub mod types;
|
||||
pub mod ulid;
|
||||
|
||||
pub use client::{Client, ClientEvent, ClientOptions, ConnState};
|
||||
pub use error::{ClientError, ErrorCodes};
|
||||
pub use types::{CapabilityDescriptor, InvokeResult};
|
||||
|
||||
#[cfg(test)]
|
||||
mod send_sync_checks {
|
||||
use super::*;
|
||||
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
|
||||
#[test]
|
||||
fn client_is_send_sync() {
|
||||
assert_send_sync::<Client>();
|
||||
assert_send_sync::<ClientOptions>();
|
||||
assert_send_sync::<ClientError>();
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
//! TLS 配置:自签证书 + 证书指纹钉住(对齐 client.ts 的 TLS 处理,v21)。
|
||||
//!
|
||||
//! nodeagent 的安全模型:TLS 只负责**传输加密**,身份认证由应用层
|
||||
//! HMAC / ed25519 握手兜底。因此 `insecure` 跳过的是「证书链是否由可信 CA
|
||||
//! 签发」,TLS 层的签名仍真正验签(委托 rustls)。
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
|
||||
use rustls::crypto::{
|
||||
verify_tls12_signature, verify_tls13_signature, CryptoProvider, WebPkiSupportedAlgorithms,
|
||||
};
|
||||
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
|
||||
use rustls::{ClientConfig, DigitallySignedStruct, Error, SignatureScheme};
|
||||
|
||||
use crate::crypto::cert_fingerprint;
|
||||
use crate::error::{ClientError, ErrorCodes};
|
||||
|
||||
/// 构建 rustls `ClientConfig`。
|
||||
///
|
||||
/// - `insecure = false`:webpki 严格链验证(自签证书会被拒,与 Node `rejectUnauthorized=true` 一致)。
|
||||
/// - `insecure = true`:接受任意证书,但抓取对端证书 DER 的 sha256 指纹。
|
||||
/// - `expect_fp`:钉住的指纹(64 位小写 hex);给了就严格比对,不一致拒绝(`E_CERT_MISMATCH`)。
|
||||
/// - `captured`:回填本次握手实际抓到的指纹(对齐 `getPeerCertFingerprint`)。
|
||||
pub fn build_client_config(
|
||||
insecure: bool,
|
||||
expect_fp: Option<String>,
|
||||
captured: Arc<Mutex<Option<String>>>,
|
||||
) -> Result<ClientConfig, ClientError> {
|
||||
let expect_fp = expect_fp.map(|s| s.trim().to_ascii_lowercase());
|
||||
|
||||
if !insecure {
|
||||
let mut roots = rustls::RootCertStore::empty();
|
||||
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||
return Ok(ClientConfig::builder()
|
||||
.with_root_certificates(roots)
|
||||
.with_no_client_auth());
|
||||
}
|
||||
|
||||
let supported = default_supported_algorithms()?;
|
||||
let verifier = Arc::new(InsecureVerifier {
|
||||
expect_fp,
|
||||
captured,
|
||||
supported,
|
||||
});
|
||||
|
||||
Ok(ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(verifier)
|
||||
.with_no_client_auth())
|
||||
}
|
||||
|
||||
fn default_supported_algorithms() -> Result<WebPkiSupportedAlgorithms, ClientError> {
|
||||
let provider = CryptoProvider::get_default()
|
||||
.ok_or_else(|| ClientError::new(ErrorCodes::CERT_MISMATCH, "无默认 CryptoProvider"))?;
|
||||
Ok(provider.signature_verification_algorithms)
|
||||
}
|
||||
|
||||
/// 接受任意证书、但抓指纹的验证器。
|
||||
/// `verify_server_cert` 跳过链校验(只做指纹钉住),签名仍由 rustls 真正验签。
|
||||
#[derive(Debug)]
|
||||
struct InsecureVerifier {
|
||||
expect_fp: Option<String>,
|
||||
captured: Arc<Mutex<Option<String>>>,
|
||||
supported: WebPkiSupportedAlgorithms,
|
||||
}
|
||||
|
||||
impl ServerCertVerifier for InsecureVerifier {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
end_entity: &CertificateDer<'_>,
|
||||
_intermediates: &[CertificateDer<'_>],
|
||||
_server_name: &ServerName<'_>,
|
||||
_ocsp_response: &[u8],
|
||||
_now: UnixTime,
|
||||
) -> Result<ServerCertVerified, Error> {
|
||||
let fp = cert_fingerprint(end_entity.as_ref());
|
||||
|
||||
// 回填实际指纹(TOFU 钉住)。
|
||||
if let Ok(mut guard) = self.captured.lock() {
|
||||
*guard = Some(fp.clone());
|
||||
}
|
||||
|
||||
// 钉住比对:不一致 → 拒绝(对齐 client.ts 的 E_CERT_MISMATCH)。
|
||||
if let Some(expected) = &self.expect_fp {
|
||||
if expected != &fp {
|
||||
let expect_head = &expected[..expected.len().min(16)];
|
||||
let actual_head = &fp[..fp.len().min(16)];
|
||||
return Err(Error::General(format!(
|
||||
"证书指纹不匹配,拒绝连接:期望 {expect_head}… 实际 {actual_head}…(被控端可能重装、或中间人冒充)"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &CertificateDer<'_>,
|
||||
dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, Error> {
|
||||
verify_tls12_signature(message, cert, dss, &self.supported)
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &CertificateDer<'_>,
|
||||
dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, Error> {
|
||||
verify_tls13_signature(message, cert, dss, &self.supported)
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
|
||||
self.supported.supported_schemes()
|
||||
}
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
//! 协议消息类型(对齐 `packages/protocol/src/messages.ts`)。
|
||||
//!
|
||||
//! 所有对外字段名保持 snake_case 与 TS 侧一致,经 serde 序列化/反序列化。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// 协议版本(对齐 methods.ts 的 `PROTOCOL_VERSION`)。
|
||||
pub const PROTOCOL_VERSION: &str = "1.0";
|
||||
|
||||
/// JSON-RPC 2.0 请求信封(nodeagent 简化版)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RpcRequest {
|
||||
pub jsonrpc: String,
|
||||
/// ULID,响应回填相同 id。
|
||||
pub id: String,
|
||||
pub method: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub params: Option<Value>,
|
||||
}
|
||||
|
||||
/// JSON-RPC 2.0 成功响应。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RpcResponse {
|
||||
pub jsonrpc: String,
|
||||
pub id: String,
|
||||
pub result: Value,
|
||||
}
|
||||
|
||||
/// 协议错误对象。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RpcError {
|
||||
pub code: i64,
|
||||
pub name: String,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<Value>,
|
||||
}
|
||||
|
||||
/// JSON-RPC 2.0 错误响应。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RpcErrorResponse {
|
||||
pub jsonrpc: String,
|
||||
#[serde(default)]
|
||||
pub id: Option<String>,
|
||||
pub error: RpcError,
|
||||
}
|
||||
|
||||
/// 任意入站消息:成功响应或错误响应(无 id 的通知由上层单独处理)。
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum InboundMessage {
|
||||
Response(RpcResponse),
|
||||
Error(RpcErrorResponse),
|
||||
Notification {
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
method: String,
|
||||
#[serde(default)]
|
||||
params: Option<Value>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 判断消息是否为错误响应。
|
||||
pub fn is_error_response(msg: &Value) -> bool {
|
||||
msg.get("error").is_some()
|
||||
}
|
||||
|
||||
// ---------- 握手 ----------
|
||||
|
||||
/// hello 入参。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HelloParams {
|
||||
pub protocol: String,
|
||||
pub client_id: String,
|
||||
}
|
||||
|
||||
/// challenge 出参。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChallengeParams {
|
||||
pub nonce: String,
|
||||
pub expires_at: i64,
|
||||
}
|
||||
|
||||
/// auth 入参。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthParams {
|
||||
pub client_id: String,
|
||||
pub nonce: String,
|
||||
/// psk 模式:Base64(HMAC-SHA256(pre_shared_key, nonce))。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hmac: Option<String>,
|
||||
/// ed25519 模式:Base64(Ed25519_Sign(privkey, nonce))。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub signature: Option<String>,
|
||||
}
|
||||
|
||||
/// auth_ok 出参。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthOkParams {
|
||||
pub capabilities: Vec<CapabilityDescriptor>,
|
||||
/// ed25519 模式:本次实际被授权的能力(全量子集)。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub authorized: Option<Vec<String>>,
|
||||
/// 本次连接采用的认证模式。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub auth_mode: Option<String>,
|
||||
/// v20:被控端语义化版本。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub agent_version: Option<String>,
|
||||
/// v20:被控端构建信息。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub build: Option<BuildInfo>,
|
||||
/// v20:被控端支持的协议版本。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub protocol: Option<String>,
|
||||
}
|
||||
|
||||
/// 被控端构建信息(v20)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BuildInfo {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hash: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub commit: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub built_at: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cert_sha256: Option<String>,
|
||||
}
|
||||
|
||||
// ---------- 调用 ----------
|
||||
|
||||
/// invoke 入参。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InvokeParams {
|
||||
pub capability: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub args: Option<Value>,
|
||||
}
|
||||
|
||||
/// 能力执行结果(业务层)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InvokeResult {
|
||||
pub status: String, // "ok" | "failed"
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<InvokeError>,
|
||||
}
|
||||
|
||||
impl InvokeResult {
|
||||
pub fn is_ok(&self) -> bool {
|
||||
self.status == "ok"
|
||||
}
|
||||
}
|
||||
|
||||
/// 能力执行失败的错误信息。
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct InvokeError {
|
||||
pub name: String,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<Value>,
|
||||
}
|
||||
|
||||
/// 能力描述(能力清单条目)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CapabilityDescriptor {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub description: String,
|
||||
pub risk: String, // "low" | "medium" | "high"
|
||||
pub params_schema: JsonSchema,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub returns_schema: Option<JsonSchema>,
|
||||
}
|
||||
|
||||
/// JSON Schema 子集(见 DEVELOPMENT.md 4.3)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JsonSchema {
|
||||
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
|
||||
pub schema_type: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub properties: Option<std::collections::BTreeMap<String, JsonSchema>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub required: Option<Vec<String>>,
|
||||
#[serde(rename = "additionalProperties", skip_serializing_if = "Option::is_none")]
|
||||
pub additional_properties: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub r#enum: Option<Vec<Value>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub minimum: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub maximum: Option<f64>,
|
||||
#[serde(rename = "minLength", skip_serializing_if = "Option::is_none")]
|
||||
pub min_length: Option<u64>,
|
||||
#[serde(rename = "maxLength", skip_serializing_if = "Option::is_none")]
|
||||
pub max_length: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pattern: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub items: Option<Box<JsonSchema>>,
|
||||
#[serde(rename = "minItems", skip_serializing_if = "Option::is_none")]
|
||||
pub min_items: Option<u64>,
|
||||
#[serde(rename = "maxItems", skip_serializing_if = "Option::is_none")]
|
||||
pub max_items: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
//! ULID 生成器(Crockford Base32,26 字符:10 位时间戳 + 16 位随机)。
|
||||
//! 对齐 `packages/protocol/src/ulid.ts`(自实现,避免额外依赖)。
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Crockford Base32 字母表(与 ulid.ts 完全一致)。
|
||||
const ENCODING: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
||||
|
||||
/// 生成一个 ULID(单调递增:同一毫秒内保证字典序递增)。
|
||||
pub fn ulid() -> String {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
ulid_at(now)
|
||||
}
|
||||
|
||||
/// 按指定时间戳生成 ULID(供测试注入确定性时间)。
|
||||
pub fn ulid_at(now: u64) -> String {
|
||||
// 时间戳部分:10 字符,低位在前地填充(不足 10 位高位补 '0')。
|
||||
let mut time_part = [b'0'; 10];
|
||||
let mut t = now;
|
||||
for i in (0..10).rev() {
|
||||
time_part[i] = ENCODING[(t % 32) as usize];
|
||||
t /= 32;
|
||||
}
|
||||
|
||||
// 随机部分:16 字符。256 % 32 == 0,取模无偏差。
|
||||
let mut rand_part = [b'0'; 16];
|
||||
for slot in rand_part.iter_mut() {
|
||||
*slot = ENCODING[(rand::random::<u8>() % 32) as usize];
|
||||
}
|
||||
|
||||
let mut out = String::with_capacity(26);
|
||||
out.push_str(std::str::from_utf8(&time_part).unwrap());
|
||||
out.push_str(std::str::from_utf8(&rand_part).unwrap());
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn length_is_26() {
|
||||
assert_eq!(ulid().len(), 26);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_valid_charset() {
|
||||
let s = ulid();
|
||||
let set: Vec<char> = ENCODING.iter().map(|&b| b as char).collect();
|
||||
assert!(s.chars().all(|c| set.contains(&c)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monotonic_same_millis_lexicographic() {
|
||||
// 同一毫秒内,两个 ULID 的时间前缀必须相同(保证字典序可比较)。
|
||||
let a = ulid_at(1_700_000_000_000);
|
||||
let b = ulid_at(1_700_000_000_000);
|
||||
assert_eq!(&a[..10], &b[..10]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct() {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for _ in 0..1000 {
|
||||
seen.insert(ulid());
|
||||
}
|
||||
assert_eq!(seen.len(), 1000);
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
//! 端到端:起一个 mock 被控端(WebSocket),验证 Rust client 的
|
||||
//! psk 握手(hello → challenge → auth → auth_ok)+ invoke 全链路。
|
||||
|
||||
use nodeagent_client::{Client, ClientOptions};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
/// 从 mock 被控端读取下一条文本消息并解析为 JSON。
|
||||
async fn read_json(
|
||||
ws: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>,
|
||||
) -> Value {
|
||||
use futures_util::StreamExt;
|
||||
while let Some(msg) = ws.next().await {
|
||||
if let Ok(Message::Text(t)) = msg {
|
||||
return serde_json::from_str(&t).expect("消息应为合法 JSON");
|
||||
}
|
||||
}
|
||||
panic!("mock 被控端连接提前关闭");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn psk_handshake_and_invoke_roundtrip() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
// mock 被控端:应答 hello / auth / invoke。
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
|
||||
use futures_util::SinkExt;
|
||||
|
||||
// hello → challenge
|
||||
let hello = read_json(&mut ws).await;
|
||||
assert_eq!(hello["method"], "hello");
|
||||
let hello_id = hello["id"].as_str().unwrap();
|
||||
let nonce = "nonce-abc123";
|
||||
ws.send(Message::Text(
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": hello_id,
|
||||
"result": { "nonce": nonce, "expires_at": 4_102_444_800_000_i64 }
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// auth → auth_ok
|
||||
let auth = read_json(&mut ws).await;
|
||||
assert_eq!(auth["method"], "auth");
|
||||
assert_eq!(auth["params"]["nonce"], nonce);
|
||||
assert!(auth["params"]["hmac"].is_string(), "psk 模式应携带 hmac");
|
||||
let auth_id = auth["id"].as_str().unwrap();
|
||||
ws.send(Message::Text(
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": auth_id,
|
||||
"result": {
|
||||
"capabilities": [{
|
||||
"name": "system.ping",
|
||||
"version": "1.0",
|
||||
"description": "探活",
|
||||
"risk": "low",
|
||||
"params_schema": { "type": "object" }
|
||||
}],
|
||||
"authorized": null,
|
||||
"auth_mode": "psk",
|
||||
"agent_version": "1.0.0"
|
||||
}
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// invoke → result
|
||||
let invoke = read_json(&mut ws).await;
|
||||
assert_eq!(invoke["method"], "invoke");
|
||||
assert_eq!(invoke["params"]["capability"], "system.ping");
|
||||
let invoke_id = invoke["id"].as_str().unwrap();
|
||||
ws.send(Message::Text(
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": invoke_id,
|
||||
"result": { "status": "ok", "data": { "pong": true, "ts": 42 } }
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
// 控制端:连接 → 握手 → 校验元信息 → 调用。
|
||||
let client = Client::new(ClientOptions {
|
||||
url: format!("ws://{addr}"),
|
||||
key: "secret".to_string(),
|
||||
client_id: "control-1".to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let caps = client.connect().await.unwrap();
|
||||
assert_eq!(caps.len(), 1);
|
||||
assert_eq!(caps[0].name, "system.ping");
|
||||
assert_eq!(client.agent_version().as_deref(), Some("1.0.0"));
|
||||
assert_eq!(client.state(), nodeagent_client::ConnState::Connected);
|
||||
|
||||
let res = client.invoke("system.ping", json!({})).await.unwrap();
|
||||
assert_eq!(res.status, "ok");
|
||||
assert_eq!(res.data.unwrap()["pong"], true);
|
||||
|
||||
client.close();
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_to_closed_port_fails() {
|
||||
// 连接到一个必然拒绝的端口,应返回离线错误而非 panic。
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
drop(listener); // 立即关闭,确保端口无监听。
|
||||
|
||||
let client = Client::new(ClientOptions {
|
||||
url: format!("ws://{addr}"),
|
||||
key: "secret".to_string(),
|
||||
client_id: "control-1".to_string(),
|
||||
handshake_timeout_ms: 1_000,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let err = client.connect().await.unwrap_err();
|
||||
assert_eq!(err.code, nodeagent_client::ErrorCodes::NODE_OFFLINE);
|
||||
}
|
||||
+13
-13
@@ -10,7 +10,7 @@
|
||||
| 日期 | 2026-09-27 |
|
||||
| 状态 | 开发阶段 |
|
||||
| 目标端 | macOS(控制端)/ Windows(被控端) |
|
||||
| 配套文档 | [产品需求文档(PRD)](../PRD.md) |
|
||||
| 配套文档 | [产品需求文档(PRD)](PRD.md) |
|
||||
|
||||
---
|
||||
|
||||
@@ -309,9 +309,9 @@
|
||||
| 19 | `fs.write` | 🔴 | 写文件(分块 / 原子写) | v5 |
|
||||
| 20 | `window.list` | 🟢 | 枚举可见顶层窗口(标题/进程/精确矩形/是否前台) | v8 |
|
||||
| 21 | `window.focus` | 🟡 | 置前聚焦(Windows + macOS;越不过全屏独占,属预期) | v8 |
|
||||
| 22 | `screen.find` | 🟢 | 元素定位三引擎(UIA → OCR → 图像模板)+ **UIA 属性**与 `where` 谓词 | v8 / v1.7 |
|
||||
| 22 | `screen.find` | 🟢 | 元素定位三引擎(UIA → OCR → 图像模板)+ **UIA 属性**与 `where` 谓词 | v8 / v2.0.0 |
|
||||
| 23 | `screen.record` | 🟢 | 录屏为帧序列(有 ffmpeg 则封 mp4) | v11 |
|
||||
| 24 | `gui.await` | 🟢 | 等条件成立再返回(四类条件 × 出现/消失)+ `any_of` 组合 | v1.6 |
|
||||
| 24 | `gui.await` | 🟢 | 等条件成立再返回(四类条件 × 出现/消失)+ `any_of` 组合 | v1.6.0 |
|
||||
| 25 | `input.mouse.drag` | 🔴🔒 | 拖拽(拖文件 / 框选) | v11 |
|
||||
| 26 | `system.task.list` | 🟢 | 列出后台异步任务 | v10 |
|
||||
| 27 | `system.task.get` | 🟢 | 查询任务状态与输出(支持增量续读) | v10 |
|
||||
@@ -319,8 +319,8 @@
|
||||
| 29 | `clip.get` | 🟡 | 读剪贴板(文本或图片 PNG) | v10 |
|
||||
| 30 | `clip.set` | 🟡 | 写剪贴板(文本或图片) | v10 |
|
||||
| 31 | `system.audit.verify` | 🟢 | 审计链完整性校验(哈希链,防篡改) | v11 |
|
||||
| 32 | `system.audit.head` | 🟢 | 审计链头(chain head)—— 供**链外**留存 | v25 |
|
||||
| 33 | `system.audit.anchor` | 🟡 | 链头**追加**写锚点文件,防「有 root 者整链重写」 | v25 |
|
||||
| 32 | `system.audit.head` | 🟢 | 审计链头(chain head)—— 供**链外**留存 | v2.0.0 |
|
||||
| 33 | `system.audit.anchor` | 🟡 | 链头**追加**写锚点文件,防「有 root 者整链重写」 | v2.0.0 |
|
||||
| 34 | `system.metrics` | 🟢 | 成功指标聚合(数据源为审计日志) | v13 |
|
||||
| 35 | `event.watch` | 🟢 | 订阅事件(file / process / net),**主动推送** | v12 |
|
||||
| 36 | `event.unwatch` | 🟢 | 取消订阅(连接断开也会自动清理) | v12 |
|
||||
@@ -331,14 +331,14 @@
|
||||
| 41 | `system.net.status` | 🟢 | 网络现状 / 待确认变更 / 历史备份 | v16 |
|
||||
| 42 | `system.audio.get` | 🟢 | 读音量与静音状态(Core Audio COM) | v17 |
|
||||
| 43 | `system.audio.set` | 🟡 | 设音量 / 静音 | v17 |
|
||||
| 44 | `system.agent.restart` | 🔴 | 受控重启自身(借计划任务;**非管理员时自动降级**) | v7 / v23 |
|
||||
| 44 | `system.agent.restart` | 🔴 | 受控重启自身(借计划任务;**非管理员时自动降级**) | v7 / v2.0.0 |
|
||||
| 45 | `system.agent.update` | 🔴 | 拉取式自更新(校验 sha256,只需被控端能上网) | v19 |
|
||||
| 46 | `log.query` | 🟢 | 日志**在被控端侧过滤**后只回匹配行(pattern/level/since/tail) | v1.8 |
|
||||
| 47 | `monitor.start` | 🟢 | 启动定时采样(port/process/command/metric)并落盘 | v1.8 |
|
||||
| 48 | `monitor.report` | 🟢 | 回看样本序列 + **摘要**(断几次 / 最长断多久 / 值域) | v1.8 |
|
||||
| 49 | `monitor.stop` | 🟢 | 停止采样并返回最终摘要 | v1.8 |
|
||||
| 50 | `monitor.list` | 🟢 | 列出运行中的监控与历史样本文件 | v1.8 |
|
||||
| 51 | `monitor.delete` | 🟢 | 删除某监控的历史样本文件 | v1.8 |
|
||||
| 46 | `log.query` | 🟢 | 日志**在被控端侧过滤**后只回匹配行(pattern/level/since/tail) | v2.0.0 |
|
||||
| 47 | `monitor.start` | 🟢 | 启动定时采样(port/process/command/metric)并落盘 | v2.0.0 |
|
||||
| 48 | `monitor.report` | 🟢 | 回看样本序列 + **摘要**(断几次 / 最长断多久 / 值域) | v2.0.0 |
|
||||
| 49 | `monitor.stop` | 🟢 | 停止采样并返回最终摘要 | v2.0.0 |
|
||||
| 50 | `monitor.list` | 🟢 | 列出运行中的监控与历史样本文件 | v2.0.0 |
|
||||
| 51 | `monitor.delete` | 🟢 | 删除某监控的历史样本文件 | v2.0.0 |
|
||||
|
||||
> 图例:🟢 只读低危 · 🟡 需谨慎 · 🔴 高风险 · 🔒 受 `allow_input` 开关管控。
|
||||
> 本表为**总览**;权威定义(参数 schema / 返回值)以 `packages/protocol/src/capabilities/manifest/` 为准,能力数由单测守卫。
|
||||
@@ -745,7 +745,7 @@ nodeagent/
|
||||
│ └── install.ps1 # Windows 一键安装脚本
|
||||
├── docs/ # 文档
|
||||
├── README.md
|
||||
└── PRD.md
|
||||
└── PRD.md(已移入 docs/)
|
||||
```
|
||||
|
||||
| 包 | 职责 | 关键文件 |
|
||||
|
||||
File renamed without changes.
@@ -83,7 +83,7 @@
|
||||
| 无法 7×24 盯 | 臣只在被调用时"看一眼",问题在两次调用之间发生就看不到 |
|
||||
| 应用层内部状态不可见 | 如 WorkBuddy 的"当前正在等哪个请求",臣只能从日志反推 |
|
||||
|
||||
**缓解(✅ 已于 v1.8 实现)**:`monitor.*` 定时采样并落盘,事后回看摘要 —— 正是为治
|
||||
**缓解(✅ 已于 v2.0.0 实现)**:`monitor.*` 定时采样并落盘,事后回看摘要 —— 正是为治
|
||||
这类间歇性问题而做(原先只写"具备实现基础",现已成为能力):
|
||||
|
||||
```bash
|
||||
@@ -173,10 +173,10 @@ WorkBuddy 解析代理慢 + 部分请求走了不通的代理链路 → `Respons
|
||||
|
||||
明确这条边界,才能既用好它,又不至于在关键故障时**因为"以为能远程修"而耽误时间**。
|
||||
|
||||
## 杀软误拦(主流杀软 / 杀软 / 360)
|
||||
## 杀软误拦(主流杀毒软件)
|
||||
|
||||
**现象**:远程接管类工具的典型特征(未知 exe + 建计划任务 + 开防火墙 + 常驻监听 + 注入键鼠/截屏)
|
||||
**必然**触发杀软拦截。真机实证:主流杀软会拦下 `install.ps1` 执行、计划任务注册,或直接处置 `node.exe`,
|
||||
**必然**触发杀软拦截。实测:主流杀软会拦下 `install.ps1` 执行、计划任务注册,或直接处置 `node.exe`,
|
||||
表现为「下载成功但装不上」或「装上后 agent 起不来」。
|
||||
|
||||
**逐项放行清单**(把下面这些都加进杀软的信任/白名单,而不是整机关防护):
|
||||
@@ -186,9 +186,9 @@ WorkBuddy 解析代理慢 + 部分请求走了不通的代理链路 → `Respons
|
||||
| 安装目录 | 解压出来的整个文件夹(含 `node.exe`、`agent.mjs`、`install.ps1`) |
|
||||
| `node.exe` | 被控端自带的 Node 运行时(免装 Node 就是因为这个文件) |
|
||||
| `agent.mjs` | agent 主体(常驻监听 8765) |
|
||||
| 计划任务 `nodeagent` | 开机/登录自启任务;主流杀软的「启动项拦截」会静默拦掉它 |
|
||||
| 计划任务 `nodeagent` | 开机/登录自启任务;杀软的「启动项拦截」会静默拦掉它 |
|
||||
| 入站端口 8765/TCP | `install.ps1` 已建防火墙规则;杀软的**独立网络防护**可能仍拦 |
|
||||
| PowerShell 脚本执行 | 主流杀软「系统加固」会拦脚本解释执行;至少放行上面的安装目录 |
|
||||
| PowerShell 脚本执行 | 杀软的「系统加固」会拦脚本解释执行;至少放行上面的安装目录 |
|
||||
| 键鼠/截屏行为 | 开启 `allow_input` 后才会出现;若被拦,`screen.capture`/`input.*` 会失败 |
|
||||
|
||||
**自检命令**(在 Windows 上跑,确认三件事:任务在不在、进程活不活、端口听没听):
|
||||
|
||||
@@ -255,10 +255,10 @@ Start-Process powershell -ArgumentList '... Start-Sleep 3; Stop-Process ...; Sta
|
||||
|
||||
| 复盘时的短板 | 解决于 | 现在长什么样 |
|
||||
|---|---|---|
|
||||
| **自持**:无法受控重启、更新要人上手 | v7 / v19 / v23 | `system.agent.restart`(借计划任务,**非管理员身份自动降级**)+ `system.agent.update`(拉取式,校验 sha256) |
|
||||
| **语义**:只能「找到」,读不懂状态、操作后无法断言 | v1.6 / v1.7 | `gui.await` 等待条件(四类条件 × 出现/消失 + `any_of` 组合);`screen.find` 返回 **UIA 属性**(`enabled/value/selected/toggle`)并支持 `where` 谓词 —— 从「盲试」到「可断言」 |
|
||||
| **状态**:只有当下快照,间歇性问题无法回看 | v1.8 | `log.query` **在被控端侧过滤**日志;`monitor.*` 定时采样落盘 + 摘要回看(断了几次 / 最长断多久 / CPU·内存值域) |
|
||||
| **安全残余**(SECURITY §5) | v23 / v24 / v25 | 握手失败封禁(指数退避)· 连接上限与空闲断开 · 安装期密钥走 stdin · **零信任优先安装**(有公钥即 ed25519)· **审计链外部锚定**(防整链重写) |
|
||||
| **自持**:无法受控重启、更新要人上手 | v7 / v19 / v2.0.0 | `system.agent.restart`(借计划任务,**非管理员身份自动降级**)+ `system.agent.update`(拉取式,校验 sha256) |
|
||||
| **语义**:只能「找到」,读不懂状态、操作后无法断言 | v1.6.0 / v2.0.0 | `gui.await` 等待条件(四类条件 × 出现/消失 + `any_of` 组合);`screen.find` 返回 **UIA 属性**(`enabled/value/selected/toggle`)并支持 `where` 谓词 —— 从「盲试」到「可断言」 |
|
||||
| **状态**:只有当下快照,间歇性问题无法回看 | v2.0.0 | `log.query` **在被控端侧过滤**日志;`monitor.*` 定时采样落盘 + 摘要回看(断了几次 / 最长断多久 / CPU·内存值域) |
|
||||
| **安全残余**(SECURITY §5) | v2.0.0 | 握手失败封禁(指数退避)· 连接上限与空闲断开 · 安装期密钥走 stdin · **零信任优先安装**(有公钥即 ed25519)· **审计链外部锚定**(防整链重写) |
|
||||
|
||||
**当前规模**:51 项能力 · 269 单测 + 30 端到端 · Windows CI 13 步全绿。
|
||||
|
||||
|
||||
+22
-18
@@ -2,8 +2,9 @@
|
||||
|
||||
> 盘点日期:2026-10-10(**第四次更新**:语义/状态/安全三个方向全部落地后据实刷新)
|
||||
> 盘点方式:真机验证 + 本地跑单测 + `gh run list`(Windows 被控端曾于 10-10 00:15 离线)
|
||||
> 基线:远端 main(`9ad296b` 之后)|单测 **269 全绿** · e2e **30 全绿** · 能力 **51 项** · Windows CI 全绿
|
||||
> ⚠️ 全部**未发版**(皇上要求先攒着)—— 下次发版建议直接跳 **v2.0.0**
|
||||
> 基线:**`v2.0.0` 已发布**(2026-10-10,commit `457ead1`)|单测 **273 全绿** · e2e **30 全绿** ·
|
||||
> 能力 **51 项** · Windows CI / Rust CI 全绿
|
||||
> 📌 v2.0.0 发布时被控端整机离线 → v1.5.0 的四项与 v2.0.0 清理策略的真机验证**仍待补**(见 §九)
|
||||
> 适用范围:本文档随仓库公开,**不含任何真机地址与账户信息**(见 §6 脱敏约定)
|
||||
|
||||
---
|
||||
@@ -48,11 +49,11 @@
|
||||
|
||||
```
|
||||
0d62cb1 (tag v1.5.0) chore(release): v1.5.0
|
||||
1c836e0 feat(v1.5): 快捷键控制范围扩展(字符串热键/预设别名/长按/后台定向投递)
|
||||
1c836e0 feat(v1.5.0): 快捷键控制范围扩展(字符串热键/预设别名/长按/后台定向投递)
|
||||
0ee8231 feat(v1.2): 媒体键 WM_APPCOMMAND 通道 + 定向投递
|
||||
42ec556 (tag v1.4.3) chore(release): v1.4.3
|
||||
7ad820c (本地 HEAD) ci: 补全 Windows 真机验证 ← 未推送
|
||||
[工作区未提交] feat(v1.6): gui.await 等待条件 ← 在建
|
||||
[工作区未提交] feat(v1.6.0): gui.await 等待条件 ← 在建
|
||||
```
|
||||
|
||||
### 2.2 代码与测试
|
||||
@@ -66,7 +67,7 @@
|
||||
| e2e | 2 个套件(`run.mjs` 通用 29 例 / `windows.mjs` Windows 专属 13 例) |
|
||||
| **`gui.await` 覆盖** | **0** —— 单测、e2e、CLI、MCP 均无 `gui.await`/`GuiAwait` 命中 |
|
||||
|
||||
> ⚠️ 单测数从 162 增至 195(新增 33 例,来自 v1.5 快捷键的按键表覆盖断言),但**红了一例**:`tests/unit/capabilities.test.mjs:66` 的 `assert.equal(CAPABILITY_MANIFEST.length, 42)`,实际 43。**修法**:改为 43,或更稳妥地改成「断言每个 `CapabilityNames` 键都在 manifest 中」以免每加一能力都要改数。
|
||||
> ⚠️ 单测数从 162 增至 195(新增 33 例,来自 v1.5.0 快捷键的按键表覆盖断言),但**红了一例**:`tests/unit/capabilities.test.mjs:66` 的 `assert.equal(CAPABILITY_MANIFEST.length, 42)`,实际 43。**修法**:改为 43,或更稳妥地改成「断言每个 `CapabilityNames` 键都在 manifest 中」以免每加一能力都要改数。
|
||||
|
||||
**最大的三个文件**(后续拆分候选):
|
||||
|
||||
@@ -85,7 +86,7 @@
|
||||
| **屏幕与录制**(4) | `screen.info` `screen.capture` `screen.record` `screen.find` |
|
||||
| **输入**(6) | `input.mouse.move` `input.mouse.click` `input.mouse.scroll` `input.mouse.drag` `input.key.type` `input.key.press` |
|
||||
| **窗口**(2) | `window.list` `window.focus` |
|
||||
| **GUI 等待**(1) | **`gui.await`** 🆕 v1.6 在建未提交 —— 等条件成立再返回 |
|
||||
| **GUI 等待**(1) | **`gui.await`** 🆕 v1.6.0 在建未提交 —— 等条件成立再返回 |
|
||||
| **文件**(4) | `fs.list` `fs.stat` `fs.read` `fs.write`(分块 RPC + 原子写) |
|
||||
| **剪贴板**(2) | `clip.get` `clip.set` |
|
||||
| **异步任务**(3) | `system.task.list` `system.task.get` `system.task.kill` |
|
||||
@@ -95,7 +96,7 @@
|
||||
| **音频**(2) | `system.audio.get` `system.audio.set`(Core Audio COM) |
|
||||
| **网络变更**(3) | `system.net.apply` `system.net.confirm` `system.net.status` |
|
||||
|
||||
**`gui.await` 设计要点**(工作区在建,v1.6):
|
||||
**`gui.await` 设计要点**(工作区在建,v1.6.0):
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
@@ -189,7 +190,7 @@ await gui.await(condition:"control", text:"完成", timeout_ms:30000)
|
||||
3. **install.cmd / control.cmd 双击链路冒烟** —— 这两个脚本是用户唯一上手入口,编码或语法坏了只有真机 cmd.exe 测得出。
|
||||
- **收尾**:推送后可清理 `ci/workflow-unit-tests` 分支(贡献已并入,留着只会误导)。
|
||||
|
||||
### 4.2 v1.5.0 真机验证尚未做(v1.5 提交信息自陈)
|
||||
### 4.2 v1.5.0 真机验证尚未做(v1.5.0 提交信息自陈)
|
||||
|
||||
`1c836e0` 的提交信息里写明:
|
||||
|
||||
@@ -205,7 +206,7 @@ node scripts/verify.mjs --host <地址> --key <密钥> --report <报告.md>
|
||||
|
||||
### 4.3 大前端方案(`docs/console-plan.md`,已出文档待评审)
|
||||
|
||||
盘点期间新增了一份前端方案文档,臣不在此展开(详见该文档),仅提示它引入了**一个新包 `crates/nodeagent-client`(Rust)**,与现有 TypeScript 的 `@nodeagent/client` 并存 —— 意味着 client 层有两套实现。这本身是个需要皇上拍板的架构决策(详见该文档的待确认项)。
|
||||
盘点期间新增了一份前端方案文档,臣不在此展开(详见该文档),仅提示它引入了**一个新包 `crates/nodeagent-client`(Rust)**〔**2026-10 已退役**:改为 Node sidecar 方案,见 `docs/console-plan.md` 文末「实施决策」〕,与现有 TypeScript 的 `@nodeagent/client` 并存 —— 意味着 client 层有两套实现。这本身是个需要皇上拍板的架构决策(详见该文档的待确认项)。
|
||||
|
||||
---
|
||||
|
||||
@@ -220,8 +221,8 @@ node scripts/verify.mjs --host <地址> --key <密钥> --report <报告.md>
|
||||
| # | 改动 | 状态 | 说明 |
|
||||
|---|---|---|---|
|
||||
| 1 | **新增 `gui.await` 等待条件** | ✅ **已实现,未提交** | 四类条件(window/control/process/file)× 两态(present/absent);复用既有能力,零新增平台探测代码;超时**正常返回**不抛异常 |
|
||||
| 2 | `screen.find` 返回 UIA 属性 | ⬜ 未做 | 补 `name` / `value` / `enabled` / `selected` / `rect` / `automation_id` |
|
||||
| 3 | 条件组合(`and` / `or` / `not`) | ⬜ 未做 | `gui.await` 目前是单条件;组合需先有属性支持 |
|
||||
| 2 | `screen.find` 返回 UIA 属性 | ✅ **已完成**(v2.0.0) | 已补 `enabled` / `value` / `selected` / `toggle`;拿不到留 `null` 不猜 |
|
||||
| 3 | 条件组合(`and` / `or` / `not`) | ✅ **已完成**(v2.0.0) | 已实现 `any_of`(任一命中即算);`and`/`not` 暂不需要 |
|
||||
|
||||
#### `gui.await` 收尾清单(✅ **已于 v1.6.1 全部完成**)
|
||||
|
||||
@@ -305,7 +306,7 @@ node scripts/verify.mjs --host <地址> --key <密钥> --report <报告.md>
|
||||
| **2 个含PII 的 blob 匿名可直取** | 旧提交中的 blob 用 40 位完整 SHA 即可匿名读取,已连续 6 轮未消;内容含本机用户名与真实目录名 | 报 GitHub Support 申请 GC,或删库重建。**这是唯一的「真泄露」** |
|
||||
| **提交信息含真机内网地址** | 5 条 commit message 含内网 IP 与端口 | 公开且被搜索引擎收录。后续禁止在 message 写真机地址 |
|
||||
| **Windows 账户名** | 硬编码在 `network.ts:96` | 改为从配置或环境变量读取 |
|
||||
| **网卡名「Ethernet」+ 网段常量** | 测试与源码中多处 | 同上,注入式配置 |
|
||||
| ~~网卡名与网段常量~~ | 测试与源码中多处 | ✅ 已完成:示例统一为文档段 `Ethernet` / `192.168.1.x`(见 §九) |
|
||||
|
||||
### 6.2 尚未暴露,立即处置
|
||||
|
||||
@@ -389,6 +390,9 @@ node scripts/verify.mjs --host <地址> --key <密钥> --report <报告.md>
|
||||
| deploy 备份不再堆积 | ✅ | `.bak-<ts>` → 固定名 `.bak` + 清理历史;真机 17 个文件 → 2 个 |
|
||||
| 自重启任务 Running 僵尸清理 | ✅ 代码 / ⏳ 真机 | 清理条件加「LastRunTime 早于 1 小时」余量(防误杀当前宿主) |
|
||||
| **`SECURITY.md` §5「还没做的」清单** | ✅ **全部完成** | 见 §4.5~§4.8 |
|
||||
| **发版 v2.0.0** | ✅ 2026-10-10 | 37 个提交一次发出;资产 agent.mjs(sha256 `7164ea44…`) / win-x64.zip / latest.json / SHA256SUMS |
|
||||
| v1.5.0 四项 + v2.0.0 清理策略**真机验证** | ✅ **已完成**(VM · Win11 ARM) | A 组 15/0/1 · B1 四项全过 · B2 三防护全过 · B3 清理**验证并改进** · B4 锚点端到端 · B5 结论:x64 包在 ARM 不可用→已出 arm64 包。**共发现并修复 5 个真问题**(详见 `VM-VERIFICATION.md`) |
|
||||
| ↑ **可用 VM 补验证** | 📋 见 [`VM-VERIFICATION.md`](VM-VERIFICATION.md) | Parallels 27 + Win11 **ARM**:协议层(v23/v26/锚点)与输入层(v1.5 四项)**可直接验**;杀软/显示/性能**仍需真机** |
|
||||
|
||||
### 真机验证过程中抓到的真 bug(8 个,全部只有真机能发现)
|
||||
|
||||
@@ -397,7 +401,7 @@ node scripts/verify.mjs --host <地址> --key <密钥> --report <报告.md>
|
||||
| 1 | `install.cmd` 双击首装即炸(`for /f` 调 PS,`^|` 漏进 PowerShell) | **用户首装必炸** |
|
||||
| 2 | `pack.mjs` 三连(spawn `.bin/esbuild` ENOENT / `zip` / `ls -la`+`du -sh`) | 打包脚本全平台假设 |
|
||||
| 3 | `where` 的 PS 注入语法错(连修**两轮**才通:JSON 字面量 ✗ → `\"` 转义 ✗ → PS 单引号 ✓) | 跨语言转义 |
|
||||
| 4 | `screen.find` 的 where-only 被自己入口校验拦下 | v1.7 只改了一半 |
|
||||
| 4 | `screen.find` 的 where-only 被自己入口校验拦下 | v2.0.0 只改了一半 |
|
||||
| 5 | 自重启在非管理员下失败 → `deploy` 永远"半成功" | v7 起的既有缺陷 |
|
||||
| 6 | `parseLeadingTs` 吃掉 ISO 的 `Z` 时区标记(偏 8 小时) | 单测抓到 |
|
||||
| 7 | manifest 承诺的 `note` 字段代码没实现 | 承诺未落地 |
|
||||
@@ -412,12 +416,12 @@ node scripts/verify.mjs --host <地址> --key <密钥> --report <报告.md>
|
||||
| 源码注释脱敏(序 0.5) | ✅ | `input.ts` / `input-script.ts` 4 处软件名泛化 |
|
||||
| `.gitignore` 补 `keys/ *.key *.pem`(§6.2) | ✅ | `*.pub` 放行 |
|
||||
| `input.ts` U+FFFD 字面量(§6.2) | ✅ 复查 0 个 | 该描述已过期(早前重构时消失) |
|
||||
| v1.5.0 真机验证(序 3) | ⏳ 待被控端在线 | 四项:字符串热键 / 预设别名 / `hold_ms` / `route=post` |
|
||||
| 方向一第二步:`screen.find` 属性化 + 条件组合 | ⬜ 未做 | 需先有 UIA 属性(见 §五) |
|
||||
| 方向二:`monitor.*` / `log.query` / `fs.watch` | ⬜ 未做 | — |
|
||||
| 方向三:`install.cmd` 密钥 stdin、握手封禁、会话上限、审计锚定 | ⬜ 未做 | 安全项建议提前 |
|
||||
| v1.5.0 真机验证(序 3) | ✅ **已完成**(2026-10-11 VM) | 四项全过;含 `route=post` 实测命中 138 窗口 |
|
||||
| 方向一第二步:`screen.find` 属性化 + 条件组合 | ✅ **已完成**(v2.0.0) | 见 §五 的对应行 |
|
||||
| 方向二:`monitor.*` / `log.query` / `fs.watch` | ✅ **已完成**(v2.0.0) | `monitor.*` 5 项 + `log.query` 已实现;`fs.watch` 由 `event.watch` 覆盖 |
|
||||
| 方向三:`install.cmd` 密钥 stdin、握手封禁、会话上限、审计锚定 | ✅ **已完成**(v2.0.0,2026-10-11 真机验证) | 三防护三场景全过;锚定端到端 + 云盘链外留存已跑通 |
|
||||
| PII blob GC / 仓库重建(序 8) | ⏳ 需皇上决策 | — |
|
||||
| `ed25519` 默认化 | ⬜ 未做 | 需确认兼容策略 |
|
||||
| `ed25519` 默认化 | ✅ **已完成**(v2.0.0) | 「零信任优先安装」:有 client-acl.json 即走 ed25519(default_effect=deny) |
|
||||
| console-plan 评审(Rust client 取舍) | ⏳ 需皇上拍板 | — |
|
||||
|
||||
### Windows CI 扒出的 6 个真 bug(均已修复,v1.6.x)
|
||||
|
||||
+38
-7
@@ -116,7 +116,7 @@ nodeagent keygen --psk --id mac_02
|
||||
| 未登记的 client_id 用共享 `key` | ✅ 通过(**旧部署行为不变**) |
|
||||
| 已登记的 client_id 用共享 `key` | ❌ 拒绝(**专属钥匙优先**,不被共享 key 绕过) |
|
||||
|
||||
## 4.7 零信任优先安装(v24)
|
||||
## 4.7 零信任优先安装(v2.0.0)
|
||||
|
||||
安装时的认证模式选择,从「默认 psk + 可选 ed25519」改为**「能拿到控制端公钥就走 ed25519」**:
|
||||
|
||||
@@ -141,7 +141,7 @@ nodeagent keygen --id mac_01 # 输出一段 JSON(acl.clients 的一项
|
||||
> 真机验证(2026-10-09):CASE A 有文件 → `ed25519` + `default_effect=deny` + clients 完整;
|
||||
> CASE B 无文件 → `psk` 且**不写 acl 字段**;CASE C 缺 pubkey → 告警并回退 psk。
|
||||
|
||||
## 4.8 审计链外部锚定(v25)
|
||||
## 4.8 审计链外部锚定(v2.0.0)
|
||||
|
||||
**问题**:链内哈希只能发现"改一条"(其后全部需重算)。「有 root 的攻击者」可以
|
||||
**整链重写** —— 从零重建一份自洽的假日志,`system.audit.verify` 照样通过。
|
||||
@@ -171,6 +171,37 @@ nodeagent --node win audit anchor --note "每日例行" # 追加一个锚点
|
||||
**已知行为**:锚定调用自身也会写一条审计(server 层统一记录 invoke),所以
|
||||
"刚锚定就比对"通常显示"链已增长" —— 属预期,不是篡改。
|
||||
|
||||
### 外部化路径(2026-10 落地)
|
||||
|
||||
锚点的安全性**完全来自「它在攻击者碰不到的地方」** —— 与被审计日志同机的锚点等于没有锚。
|
||||
故分两层存放:
|
||||
|
||||
| 层 | 位置 | 能防住 |
|
||||
|---|---|---|
|
||||
| L1 | 控制端(Mac)本地 | 被控端单点被 root |
|
||||
| **L2** | **移动云盘 `…/Project/NodeAgent/anchors/`** | **控制端也被攻破**(锚点在腾讯云,被控端 root 也碰不到) |
|
||||
|
||||
**控制端工具** `scripts/anchor-audit.mjs`:
|
||||
|
||||
```bash
|
||||
# 每日:拉链头 → 生成锚点(再由云盘能力上传到链外)
|
||||
node scripts/anchor-audit.mjs collect --node win --out /tmp/anchor-$(date +%Y%m%d).jsonl
|
||||
|
||||
# 每月(或怀疑时):用链外锚点比对当前链
|
||||
node scripts/anchor-audit.mjs verify --anchors <从云盘下载的锚点文件> --node win
|
||||
# ok:false → 退出码 2,并给出「疑似整链重写 / 链头哈希不一致」的明细
|
||||
```
|
||||
|
||||
**为什么判定必须由控制端做**:被控端是**不可信方** —— 它若被 root,会重写日志、
|
||||
也会把自己的锚点改掉,然后回报"一切正常"。所以"锚点存哪、和什么比"只能由控制端掌握。
|
||||
|
||||
**判定逻辑只有一份**:`packages/protocol/src/audit-anchor.ts` 的 `judgeAnchors`(纯函数),
|
||||
被控端 `compareWithAnchors` 与控制端脚本**都调用它** —— 若两端各写一份,同一份日志会
|
||||
出现"被控端说一致、控制端说被改"的荒谬局面。
|
||||
|
||||
**云盘侧的两点约束(都正好合意)**:① 不支持覆盖/删除 → 锚点天然「追加不可改写」;
|
||||
② 一天一个文件(`nodeagent-anchor-YYYYMMDD.jsonl`)→ 保留历史锚点,可定位被改区间。
|
||||
|
||||
**如何真正用起来**:定期(建议每日)调用 `system.audit.anchor`,并用
|
||||
`system.audit.head --compare` 巡检;若把锚点文件指向**另一台机器/网盘同步目录**,
|
||||
外部锚定的价值才真正落地(本机文件被整盘重写时锚点也随之消失)。
|
||||
@@ -180,11 +211,11 @@ nodeagent --node win audit anchor --note "每日例行" # 追加一个锚点
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| ~~发现报文最小化~~ | ✅ **v22 已做**(见 §4.5) |
|
||||
| ~~ed25519 默认化~~ | ✅ **v24 已做**(零信任优先安装,见 §4.7);另有 v22 的 `keys{}` 每客户端一把 PSK 作为轻量替代 |
|
||||
| ~~握手失败封禁~~ | ✅ **v23 已做**:连续失败达阈值 → 指数退避封禁(封顶 24h),成功即清零,支持白名单豁免 |
|
||||
| ~~会话上限~~ | ✅ **v23 已做**:并发上限 8 + 30 分钟空闲断开(有任务/订阅在跑时保守不踢) |
|
||||
| ~~安装期密钥传递~~ | ✅ **v23 已做**:`install.cmd` 改用管道喂 stdin(`-KeyFromStdin`),密钥不再出现在命令行 |
|
||||
| ~~审计链外部锚定~~ | ✅ **v25 已做**(见 §4.8):链头可读 + 锚点追加落盘 + 锚点比对 |
|
||||
| ~~ed25519 默认化~~ | ✅ **v2.0.0 已做**(零信任优先安装,见 §4.7);另有 v22 的 `keys{}` 每客户端一把 PSK 作为轻量替代 |
|
||||
| ~~握手失败封禁~~ | ✅ **v2.0.0 已做**:连续失败达阈值 → 指数退避封禁(封顶 24h),成功即清零,支持白名单豁免 |
|
||||
| ~~会话上限~~ | ✅ **v2.0.0 已做**:并发上限 8 + 30 分钟空闲断开(有任务/订阅在跑时保守不踢) |
|
||||
| ~~安装期密钥传递~~ | ✅ **v2.0.0 已做**:`install.cmd` 改用管道喂 stdin(`-KeyFromStdin`),密钥不再出现在命令行 |
|
||||
| ~~审计链外部锚定~~ | ✅ **v2.0.0 已做**(见 §4.8):链头可读 + 锚点追加落盘 + 锚点比对 |
|
||||
|
||||
## 6. 应急处置
|
||||
|
||||
|
||||
@@ -3,6 +3,50 @@
|
||||
> 为什么需要这份文档:agent 与 CLI/MCP 是**分开更新的**(CLI 随控制端装,agent 推/拉到被控端),
|
||||
> 新旧混用是常态而非例外。没有明文契约,升级顺序、什么算破坏性改动、出问题怎么定位全靠猜。
|
||||
|
||||
## 0. 版本号唯一性规范(2026-10-10 确立)
|
||||
|
||||
> **背景**:项目早期同时存在两套编号 —— 发布版本(`v1.1.0`/`v2.0.0`)与开发批次代号(`v1`~`v26`)。
|
||||
> 二者**从来不是一对一**(例:`v1.1.0` 首发时批次已推进到 `v12.4`),并列出现时无法判断哪个是"当前版本"。
|
||||
> 自 v2.0.0 起统一为下列规则。
|
||||
|
||||
### 唯一版本号
|
||||
|
||||
**`vX.Y.Z`(semver)是项目唯一的版本标识**,两个来源必须永远一致:
|
||||
|
||||
| 来源 | 位置 | 谁在读 |
|
||||
|---|---|---|
|
||||
| git tag | `v2.0.0` | 人 / GitHub Release |
|
||||
| `package.json` 的 `version` | 全部 workspace + `tauri.conf.json` | 构建期经 esbuild `--define` 注入 agent |
|
||||
|
||||
```bash
|
||||
nodeagent info # 被控端自报的 version 即来自这里
|
||||
git tag -l # 发布历史,唯一权威
|
||||
```
|
||||
|
||||
### 开发批次代号(已并入发布版本)
|
||||
|
||||
`v1`~`v26` 是**开发阶段的内部代号**(一批相关改动的集合),**不是版本号,也不再单独出现**:
|
||||
|
||||
| 批次 | 内容 | **归属发布版本** |
|
||||
|---|---|---|
|
||||
| v1 ~ v12.4 | 命令级接管 → 事件订阅 / GUI 宏 / Hub 并发(首发前的全部积累) | **v1.1.0** |
|
||||
| v12.2 ~ v15 | 等待语义 / macOS GUI 对齐 / 常驻助手 / 图像模板 | v1.2.1 ~ v1.4.x |
|
||||
| v16 ~ v18 | 网络两阶段提交 / 音频 / 构建指纹 | v1.4.x |
|
||||
| v19 ~ v22 | 拉取式自更新 / 证书钉住 / 每客户端 PSK / 发现最小化 | v1.5.0 ~ v1.6.0 |
|
||||
| v23 ~ v26、语义属性化、状态采样 | 连接层三防护 / 零信任安装 / 审计锚定 / UIA 属性 / monitor | **v2.0.0** |
|
||||
|
||||
> 上表按 tag 时间线与功能内容推定,**近似**而非精确(批次与发布本无严格边界)。
|
||||
> 判断"某个改动属于哪个版本"时,**以 CHANGELOG 的版本段落为准**,不以批次号为准。
|
||||
|
||||
### 书写规则(新代码 / 新文档)
|
||||
|
||||
| 场景 | 写法 | 例 |
|
||||
|---|---|---|
|
||||
| 引用发布版本 | `vX.Y.Z` | `v2.0.0 新增 gui.await` |
|
||||
| 需要区分同一版本内的多批改动 | 用**功能名**,不用批次号 | `审计锚定(v2.0.0)` |
|
||||
| 历史注释中的批次号 | 保留但**不得**再新增;见上表归属 | `v16:网络两阶段提交` |
|
||||
|
||||
|
||||
## 1. 版本号
|
||||
|
||||
- 单一来源:**根 `package.json` 的 `version`**,构建时经 esbuild `--define` 注入 agent。
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
# VM 验证清单(Parallels Desktop 27 + Windows 11 ARM)
|
||||
|
||||
> 用途:Windows 真机不可用时,用 **Parallels 27 的 Windows 11 ARM 虚拟机**提前跑掉
|
||||
> **架构无关**的验证项,把"等机器"的时间省下来。
|
||||
>
|
||||
> ⚠️ **本清单的结论必须按 §5 的规则标注**:VM 验过的项写「VM 已验」,并注明
|
||||
> 是否仍需真机复验。**不要把 VM 结论当作真机结论** —— 二者架构不同。
|
||||
|
||||
---
|
||||
|
||||
## 1. 能补什么 / 不能补什么
|
||||
|
||||
| 类别 | 项 | VM 可否 |
|
||||
|---|---|---|
|
||||
| **协议层**(与架构无关) | v23 三防护(握手封禁/连接上限/空闲断开)、审计锚点端到端、v26 计划任务清理逻辑 | ✅ **可验,结论直接可用** |
|
||||
| **输入注入层** | v1.5 四项(字符串热键/预设别名/长按/后台定向投递) | ✅ 可验(走 Win32 `SendInput`/`WM_APPCOMMAND`,ARM Windows 同样完整) |
|
||||
| **安装与自持** | `install.cmd` 自提权、计划任务注册、受控重启 | ✅ 可验(VM 里也是真 Windows,机制一致) |
|
||||
| **系统能力** | 进程/服务/文件/剪贴板/命令执行/`winget` | ✅ 可验 |
|
||||
| **刻意不可验** | 第三方杀软拦截(火绒等) | ❌ VM 里只有 Defender |
|
||||
| | 显示 / DPI / 多显示器 / 坐标校准 | ❌ VM 显示栈不同 |
|
||||
| | 性能指标(P95 时延等) | ❌ VM 数字不可比 |
|
||||
| **需注意** | `win-x64` 包在 ARM Windows 上走 **Prism 模拟** | ⚠️ 能跑但慢;**这本身是值得记录的兼容性结论**(见 B5) |
|
||||
|
||||
---
|
||||
|
||||
## 2. 前置准备
|
||||
|
||||
```
|
||||
① Parallels 27 装 Windows 11 ARM(Pro/Enterprise)
|
||||
└ Apple silicon 只能跑 ARM 版 Windows(x86_64 仅 Pro/Business 的早期预览,性能显著更低)
|
||||
|
||||
② 网络 → 桥接(Bridged),不要用默认「共享网络」
|
||||
└ 共享/NAT 下 VM 拿不到同网段 IP → UDP 广播发现(v4)测不了,Mac 也未必能直连
|
||||
|
||||
③ VM 内装 Node:优先 **ARM64 版**(避开 Prism 模拟层)
|
||||
└ 若想验分发包,另见 B5(用项目打的 win-x64 包)
|
||||
|
||||
④ VM 内起被控端(二选一)
|
||||
a. 免安装:双击 run-portable.cmd (不注册计划任务,适合快速验能力)
|
||||
b. 正式装:解压 nodeagent-win-x64.zip → 双击 install.cmd(自提权,会建计划任务)
|
||||
|
||||
⑤ Mac 端登记设备并连接
|
||||
nodeagent --node vm connect <VM的IP> --key <密钥> --insecure
|
||||
nodeagent --node vm info # 看到 51 项能力即通
|
||||
```
|
||||
|
||||
> 建议设备名用 `vm`,与真机 `win` 分开 —— 后续所有命令都在 `--node vm` 上跑。
|
||||
|
||||
---
|
||||
|
||||
## 3. 验证清单
|
||||
|
||||
### A. 基础验收(17 项,一步到位)
|
||||
|
||||
```bash
|
||||
# 对齐 PRD FR-01~FR-08 的全套验收,可同时输出 Markdown 报告
|
||||
node scripts/verify.mjs --host <VM的IP> --port 8765 --key <密钥> --insecure \
|
||||
--report /tmp/vm-verify.md
|
||||
```
|
||||
**判定**:脚本自报通过项;报告存档(日后对比真机报告,差异项即为"架构相关")。
|
||||
|
||||
---
|
||||
|
||||
### B. 待办专项(逐条)
|
||||
|
||||
#### B1. v1.5 四项(该版当初未经真机就发版)
|
||||
|
||||
```bash
|
||||
# ① 字符串热键(分隔符 + - 空格、大小写不敏感)
|
||||
nodeagent --node vm key press "ctrl+shift+esc"
|
||||
# 期望:{ pressed: true, chords: [...] }(被控端返回里**没有 keys 字段**,别按它判)
|
||||
|
||||
# ② 预设别名(约 50 个语义名)
|
||||
nodeagent --node vm key press copy
|
||||
# 期望:同上,且 args 走 preset 分支(非 hotkey)
|
||||
|
||||
# ③ 长按
|
||||
nodeagent --node vm key press f24 --hold 800
|
||||
# 期望:按下并保持 800ms
|
||||
|
||||
# ④ 后台定向投递(PostMessage 到指定进程,窗口被遮挡也生效)
|
||||
nodeagent --node vm ps # 先取一个目标 PID
|
||||
nodeagent --node vm key press f24 --route post --pid <PID>
|
||||
# 期望:{ pressed: true };目标进程窗口被遮挡/最小化时**仍然生效**
|
||||
```
|
||||
**判定**:四条都返回 `pressed: true` 且无异常。
|
||||
**选键建议**:用 `f24` / `shift` / `copy` 这类**无副作用**的键(F24 几乎无程序绑定)。
|
||||
|
||||
#### B2. v23 三防护(配置在 `agent.json` 的 `security` 段)
|
||||
|
||||
```jsonc
|
||||
// VM 被控端数据目录下的 agent.json
|
||||
{ "security": { "auth_ban": { "max_attempts": 3, "ban_ms": 60000 }, "idle_timeout_ms": 60000 } }
|
||||
```
|
||||
|
||||
```bash
|
||||
# ① 握手失败封禁:连续用**错误密钥**连 -> 达阈值后**正确密钥也被拒**
|
||||
for i in 1 2 3; do nodeagent --node tmp connect <IP> --key WRONG --insecure; done
|
||||
nodeagent --node tmp connect <IP> --key <正确密钥> --insecure
|
||||
# 期望:前几次报 E_AUTH_FAILED;达到 max_attempts 后,即使是正确密钥也报被封禁
|
||||
|
||||
# ② 连接上限:并发开连接直到第 N+1 个被拒
|
||||
# 期望:超出上限的连接被拒(E_NODE_BUSY 或等价错误)
|
||||
|
||||
# ③ 空闲断开:连上后静置超过 idle_timeout_ms
|
||||
# 期望:连接被服务端主动断开,控制端收到 closed
|
||||
```
|
||||
**判定**:三种防护各自可达且**恢复后正常**(封禁到期、连接释放、重连成功)。
|
||||
|
||||
#### B3. v26 自重启任务清理(治"Running 僵尸")
|
||||
|
||||
```bash
|
||||
# ① 触发受控重启
|
||||
nodeagent --node vm restart # 重启会让配置变更生效,连接会短暂断开
|
||||
# ② 重连后查看计划任务
|
||||
nodeagent --node vm exec "schtasks /query /fo csv /tn \"nodeagent-selfrestart*\""
|
||||
```
|
||||
**判定**:
|
||||
- 清理条件为「**非 Running** 或 **LastRunTime 早于 1 小时**」
|
||||
- 刚创建的那个任务(当前 agent 的宿主)**必须仍在** ← 若它被清掉,说明误杀了宿主(严重)
|
||||
- 一小时后复查:旧任务应已被清理,**不再累积**
|
||||
|
||||
#### B4. 审计锚点端到端(此前只做了离线 fixture 验证)
|
||||
|
||||
```bash
|
||||
# ① 生成锚点(拉 VM 的链头)
|
||||
node scripts/anchor-audit.mjs collect --node vm --out /tmp/vm-anchor.jsonl --note "VM 验证"
|
||||
# ② 上传到链外(云盘 …/Project/NodeAgent/anchors/,文件名带年月日)
|
||||
# ③ 从云盘下载回来再比对
|
||||
node scripts/anchor-audit.mjs verify --anchors /tmp/vm-anchor.jsonl --node vm
|
||||
# 期望:ok:true,verdict 为「链已增长」或「与锚点完全一致」
|
||||
# (刚锚定就比对通常显示"已增长" —— 锚定动作自身也写一条审计,属正常)
|
||||
```
|
||||
**判定**:`collect` 能拿到链头、云盘上传成功、`verify` 返回 `ok:true`。
|
||||
|
||||
#### B5. `win-x64` 包在 ARM Windows 上的兼容性(额外结论)
|
||||
|
||||
```bash
|
||||
# 把项目打的包解压到 VM,用**包内自带的 x64 Node**起被控端(走 Prism 模拟)
|
||||
# 期望:能起、能被连接、基础能调用
|
||||
```
|
||||
**判定**:能跑 → 说明分发包对 ARM Windows 也友好(**值得写进 README 的加分结论**);
|
||||
不能跑或异常 → 记录现象,并在文档里注明"分发包需 ARM64 版本方能原生运行"。
|
||||
|
||||
---
|
||||
|
||||
## 4. 一键顺序(建议)
|
||||
|
||||
```
|
||||
A 基础验收(verify.mjs)
|
||||
→ B1 v1.5 四项
|
||||
→ B2 v23 三防护(改 agent.json 后需 restart)
|
||||
→ B3 v26 清理(restart 后查任务)
|
||||
→ B4 锚点端到端
|
||||
→ B5 x64 包兼容性
|
||||
```
|
||||
每步完成后把结论记到 `docs/ROADMAP.md` §九 台账,**并标注来源是 VM 还是真机**。
|
||||
|
||||
---
|
||||
|
||||
## 5. 结论标注规则(强制)
|
||||
|
||||
| 情形 | 台账/CHANGELOG 写法 |
|
||||
|---|---|
|
||||
| 架构无关项(B2/B3/B4)在 VM 验过 | `✅ 已验证(VM · Windows 11 ARM)` |
|
||||
| 输入/系统能力(A/B1)在 VM 验过 | `✅ 已验证(VM)`,**若涉及坐标/DPI 则加注「坐标类待真机复验」** |
|
||||
| 杀软交互 / 显示 / 性能 | `⏳ 待真机`(VM 不可替代,**不得**因 VM 通过就标已验) |
|
||||
| x64 包在 ARM 上跑通(B5) | `✅ 兼容性结论:win-x64 包可在 ARM Windows 运行(经 Prism)` |
|
||||
|
||||
> **一条纪律**:VM 与真机**架构不同**(真机 i7-12650H = x86_64)。任何"VM 通过"
|
||||
> 都不能自动推出"真机通过";涉及**原生二进制、驱动、杀软、显示栈**的部分尤其如此。
|
||||
> 宁可多留一条"待真机复验",也不要让台账出现无法追溯来源的"已验证"。
|
||||
|
||||
---
|
||||
|
||||
## 6. 已知 VM 环境差异(排查时先看这里)
|
||||
|
||||
| 现象 | 原因 | 处理 |
|
||||
|---|---|---|
|
||||
| `discover`(UDP 广播发现)无结果 | 网络是共享/NAT | 改**桥接** |
|
||||
| 分辨率/坐标与真机不符 | VM 显示栈不同 | 只做逻辑验证,坐标待真机 |
|
||||
| 性能数字明显偏低 | VM + 可能的 Prism 模拟 | **不要**采信 VM 的性能指标 |
|
||||
| 杀软相关错误复现不了 | VM 里只有 Defender | 需真机(或另行安装第三方杀软) |
|
||||
| x64 Node 启动慢/异常 | 走 Prism 模拟 | 换 ARM64 Node 复核 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 实际验证结果(2026-10-11,Parallels 27 + Win11 ARM)
|
||||
|
||||
> 本节是**实测记录**,不是计划。环境:Parallels 共享网络,VM `10.211.55.9`,
|
||||
> Mac 侧 `bridge100=10.211.55.2`;被控端 `-NodeId win-arm -AllowInput`。
|
||||
|
||||
### B5 结论(重要,与预期不同)
|
||||
|
||||
| 组合 | 结果 |
|
||||
|---|---|
|
||||
| **`win-x64` 包(内含 x64 Node)** | ⚠️ **能启动、能 listen 8765,但 TLS 握手 60s 不完成** → **协议层不可用**(Prism 模拟下) |
|
||||
| **换成 ARM64 Node(同版本 v22.20.0)** | ✅ **完全正常**:TLS 握手成功、连接、51 项能力 |
|
||||
|
||||
**结论**:`nodeagent-win-x64.zip` **不能在 ARM Windows 上实用**。
|
||||
|
||||
**解决方案(已落地)**:`scripts/pack.mjs` 新增 `--arch`:
|
||||
```bash
|
||||
node scripts/pack.mjs --arch arm64 # → release/nodeagent-win-arm64.zip(内含 ARM64 node.exe)
|
||||
node scripts/pack.mjs # 默认仍出 x64 包(向后兼容)
|
||||
```
|
||||
ARM Windows(含 Parallels Win11 ARM)请用 **arm64 包**;用 x64 包会卡在 TLS 握手。
|
||||
(临时替代:手工把包内 `node.exe` 换成 ARM64 版同样可行,重启后需再换 —— 故推荐用 arm64 包。)
|
||||
|
||||
### 各项结果
|
||||
|
||||
| 项 | 结果 |
|
||||
|---|---|
|
||||
| A 组(`verify.mjs` 17 项) | ✅ 15 通过 / 0 失败 / 1 跳过 |
|
||||
| B1 v1.5 四项 | ✅ 全过(含 `route=post` 实测命中 138 窗口) |
|
||||
| B4 锚点端到端 | ✅ 全过(含上传云盘) |
|
||||
| B2 v23 三防护 | ✅ **三场景全通过**:① 握手封禁(3 次错误密钥后**连正确密钥也被拒**)② 连接上限(配 6 → 第 7 个被拒)③ 空闲断开(静默 40s 被断,符合 idle 20s + 清扫周期 30s) |
|
||||
| B3 v26 清理 | ✅ **验证并改进了策略**:实测发现「1 小时」粗阈值下频繁 restart 仍累积 → 改为「**早于本 agent 启动时刻**」精确阈值(部署到 VM 实测:restart 后旧任务被自动清理、宿主未误杀) |
|
||||
|
||||
### 实测新增的排查项(补进 §6)
|
||||
|
||||
| 现象 | 原因 | 处理 |
|
||||
|---|---|---|
|
||||
| 装了却连不上(TLS 握手不完成) | **x64 Node 在 Prism 下协议层异常** | 换 ARM64 Node |
|
||||
| `control.ps1` 报"变量引用无效" | 脚本里 `$var:` 被当 drive 引用(**解析期**错误 → 整个脚本废) | 已修(写 `${var}`);已加守卫 |
|
||||
| 直接跑 `install.ps1` 后找不到 `PSK.txt` | **只有 `install.cmd` 才产生 PSK.txt** | 密钥在 `<数据目录>\agent.json` 的 `key` 字段 |
|
||||
| 装完输入类能力不可用 | `install.cmd` 不传 `-AllowInput`(默认 false) | 用 `install.ps1 -AllowInput` |
|
||||
| `nodeagent --node x cmd` 报"未知命令" | `--node` **前置需等号** | 写 `--node=x`,或放命令后 |
|
||||
| 计划任务启动后端口不通 | **node.exe 被手工进程占用**(覆盖失败)+ 8765 被占(任务起来即冲突退出) | 先 `Get-Process node \| Stop-Process -Force` 再解压/启动 |
|
||||
| 自测时 `socket hang up` | **URL 写成 `ws://` 而服务端是 `wss`**(`tls:true`) | 用 `wss://` |
|
||||
| 测 `max_connections` 把自己锁在外面 | 测试值太小 + 被拒连接会被计入握手失败 → 恶性循环 | ① 测试时留余量 ② **给自己加 `auth_ban.whitelist`** |
|
||||
@@ -1,5 +1,10 @@
|
||||
# nodeagent 大前端开发方案(桌面操作台)
|
||||
|
||||
> ⚠️ **本文档 §2 选型、§5.1 的 Rust 版 client、§6 目录结构描述的是「原方案」**,
|
||||
> **已于 2026-10 被文末「实施决策」取代**(改为 Node sidecar,退役 `crates/nodeagent-client`)。
|
||||
> 保留原文是为了可回溯决策过程;实现以「实施决策」节与代码为准。
|
||||
|
||||
|
||||
> 版本 v1.0 | 日期 2026-10-07 | 状态:待皇上评审
|
||||
> 定位:为「Mac 控 Windows」补上**人肉可视化操作台**——CLI 是脚本底座、MCP 是 AI 落点、本方案是**皇上自己点按钮**的桌面界面。
|
||||
|
||||
@@ -319,3 +324,111 @@ cargo run --example probe -- ws://127.0.0.1:8765 <key> /tmp/nodeagent-e2e
|
||||
| C4 | 是否同步做图形接管 UI | 建议 v2 再做,首期聚焦闭环 |
|
||||
|
||||
> 仅供参考,本方案为技术规划,不构成任何投资建议。
|
||||
|
||||
---
|
||||
|
||||
## 实施决策(2026-10-10):采纳「Tauri + Node sidecar」,退役 Rust client
|
||||
|
||||
### 为什么改
|
||||
|
||||
原计划(本文档 §2)让 Rust 壳**自己实现一份 client**(`crates/nodeagent-client`),
|
||||
代价是「**两套协议实现**」—— Rust 版与 TS 版会**静默漂移**:改了一处漏另一处时,
|
||||
构建通过、单测通过,只在真机运行时才暴露。这是本项目已经反复吃过的一类坑
|
||||
(见 `RETROSPECTIVE.md` 的「两份真相」教训)。
|
||||
|
||||
**sidecar 方案**让 Rust 壳退化为**进程管理者 + 消息转发者**,协议实现只剩 TS 一份:
|
||||
|
||||
```
|
||||
┌──────────────┐ Tauri command(签名不变) ┌─────────────────┐
|
||||
│ Vue 前端 │ ───────────────────────────► │ Rust 壳 │
|
||||
└──────────────┘ ◄──── Tauri event ────────── │(启动/转发/监督) │
|
||||
└────────┬────────┘
|
||||
stdin/stdout(JSON-RPC 2.0,行分隔)
|
||||
▼
|
||||
┌───────────────────────────┐
|
||||
│ Node sidecar(sidecar.mjs)│
|
||||
│ 复用 @nodeagent/client │
|
||||
└───────────────────────────┘
|
||||
```
|
||||
|
||||
### sidecar 协议
|
||||
|
||||
请求(Rust → sidecar):
|
||||
```jsonc
|
||||
{"jsonrpc":"2.0","id":1,"method":"connect","params":{url,key,client_id,insecure,auth_mode?,private_key?,cert_sha256?,hub?}}
|
||||
{"jsonrpc":"2.0","id":2,"method":"invoke","params":{capability,args,timeout_ms?}}
|
||||
{"jsonrpc":"2.0","id":3,"method":"state"}
|
||||
{"jsonrpc":"2.0","id":4,"method":"disconnect"}
|
||||
```
|
||||
通知(sidecar → Rust,用 `params.kind` 区分,Rust 侧再桥接成 Tauri event):
|
||||
```jsonc
|
||||
{"jsonrpc":"2.0","method":"event","params":{"kind":"state","state":"connected"}}
|
||||
{"jsonrpc":"2.0","method":"event","params":{"kind":"log","message":"..."}}
|
||||
{"jsonrpc":"2.0","method":"event","params":{"kind":"agent_event","event":{…}}}
|
||||
```
|
||||
|
||||
**两条纪律**:① **stdout 只写协议消息**(日志一律 stderr,混入非 JSON 行会让 Rust 侧解析失败);
|
||||
② 任何异常都转成响应,**绝不因单个请求崩进程**。
|
||||
|
||||
### 错误形态归一(真机实测中修正)
|
||||
|
||||
被控端对「未知能力 / ACL 拒绝 / 超时」是回 **JSON-RPC error**,`client.invoke()` 因此**抛异常**。
|
||||
若原样上抛,前端要处理两种形态。sidecar 已把它归一成 `InvokeResult` 的 failed 形态:
|
||||
|
||||
```jsonc
|
||||
{"result":{"status":"failed","error":{"name":"E_CAPABILITY_NOT_FOUND","message":"…"}}}
|
||||
```
|
||||
「调用方式不对」(未连接 / 缺参数)仍走**协议错误**(带明确 code),二者语义不同、不混淆。
|
||||
|
||||
### 进度
|
||||
|
||||
| 步骤 | 状态 |
|
||||
|---|---|
|
||||
| ① sidecar 实现(`apps/console/sidecar/main.mjs`) | ✅ 190 行 |
|
||||
| ② 真实链路 e2e(本地 agent → connect → invoke → 事件 → 错误 → disconnect) | ✅ **8/8 通过** |
|
||||
| ③ 单文件打包(`sidecar/build.mjs`,esbuild JS API + createRequire shim) | ✅ 227KB,**仓库外可独立运行** |
|
||||
| ④~⑥ 见下 | ✅ 详见「Rust 壳改造」一节 |
|
||||
| ④ Rust 壳改造(`sidecar.rs` + 薄转发 `lib.rs`,5 个 command 签名与 3 个 event 名不变) | ✅ |
|
||||
| ⑤ `tauri.conf.json` 的 resource 配置(sidecar.mjs)+ `beforeBuildCommand` 串 sidecar:build | ✅ |
|
||||
| ⑥ 退役 `crates/nodeagent-client` | ✅ 已删 crates/;`rust.yml` 改为验证 console 壳 |
|
||||
|
||||
### 打包说明(步骤 ⑤ 的既定方向)
|
||||
|
||||
sidecar 是 `.mjs`,需要 Node 解释器 —— **不满足 Tauri `externalBin`(要求可执行文件)**。
|
||||
采用与 `pack:win` 一致的既有做法:**sidecar.mjs + Node 运行时都作为 Tauri resource**,
|
||||
Rust 侧从 resource 目录解析路径后 spawn(开发期直接用系统 node)。
|
||||
(`externalBin` 若要走,需 Node SEA 打成真二进制,但其动态 require 限制与 `ws` 有冲突,故不采用。)
|
||||
|
||||
### Rust 壳改造(步骤 ④⑤⑥ 详情)
|
||||
|
||||
| 文件 | 变更 |
|
||||
|---|---|
|
||||
| `src-tauri/src/sidecar.rs`(新增 ~290 行) | `Sidecar` 管理器:spawn node + 行分隔 JSON-RPC 请求/响应匹配(oneshot)+ 通知回调;`resolve_node`/`resolve_sidecar` 路径解析(env → resource → 开发期系统 node) |
|
||||
| `src-tauri/src/lib.rs`(重写) | 退化为薄转发:`ensure_sidecar` 懒启动;**5 个 command 签名与 3 个 event 名一律不变**;`get_capabilities`/`get_state` 读本地缓存以**保持同步签名**;`RunEvent::Exit` 时优雅 `shutdown` |
|
||||
| `Cargo.toml` | 移除 `nodeagent-client` 依赖;加 `tokio`(process/io-util/sync/time/macros);版本 0.1.0 → **2.0.0**(原先与 tauri.conf.json 不一致) |
|
||||
| `crates/`(整目录) | **删除**(12 个文件) |
|
||||
| `.github/workflows/rust.yml` | 由「Rust client 单测」改为「**Rust 壳单测(真跑 sidecar)**」:装 Tauri 系统依赖 + Node + pnpm,跑 clippy -D warnings 与 `cargo test` |
|
||||
| `package.json` | `test:rust` 改指 console 壳;移除 `probe`(其"对真机逐项核对字段"的用途已由 CLI `nodeagent invoke` 覆盖) |
|
||||
|
||||
**可测试性设计(关键)**:sidecar 管理器**不依赖 Tauri GUI**,故其单测能在 CI 里
|
||||
**真跑 Node 子进程**验证 spawn / 请求-响应匹配 / 错误不崩 —— 不必等到人工开窗口才敢确认。
|
||||
`cargo test` 实测:**3/3 通过**(0.49s)。
|
||||
|
||||
### ⚠️ 两处已知待办(本轮未做,均需真机/图形环境)
|
||||
|
||||
1. **Node 运行时随包分发**:sidecar 是 `.mjs`,目标机器需有 Node。开发期用系统 node
|
||||
(`resolve_node` 会回退到 PATH);**分发给他人时**需把 Node 运行时作为 resource 打进包。
|
||||
计划:写 `scripts/prepare-console.mjs` 下载对应平台 node 到 `src-tauri/resources/`,
|
||||
并在打包时用 `tauri build --config` 注入 `bundle.resources` 的 node 项
|
||||
(**不能写死在默认 conf 里** —— `tauri-build` 会在编译期校验文件存在性,
|
||||
声明未下载的文件会让 `cargo check/test` 直接失败,本轮已踩并修复)。
|
||||
2. **GUI 端到端人工验证**:开窗口 → 连接被控端 → 操作界面。
|
||||
沙箱无法运行图形应用,故本轮的验证止于 `cargo test`(真跑 sidecar)+ 单测/构建全绿。
|
||||
|
||||
### 一个易踩的构建约束(本轮 CI 失败后查明)
|
||||
|
||||
`tauri-build`(build.rs)会**在编译期校验** `tauri.conf.json` 里 `bundle.resources`
|
||||
声明的每个文件是否存在。因此:
|
||||
- 只声明**构建期必会生成**的产物(如 `sidecar.mjs`,由 beforeBuildCommand 产出)
|
||||
- CI 里在 clippy/test **之前**必须先生成它,否则 build.rs 阶段就失败
|
||||
- 本地复现:删掉 `sidecar/dist/` → `cargo check` 报 "failed to run custom build command" ✓
|
||||
+2
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "nodeagent",
|
||||
"private": true,
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.1",
|
||||
"description": "跨机 AI 接管框架 —— 让 Mac 上的 AI 无缝接管 Windows,装软件、查状态",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
@@ -17,8 +17,7 @@
|
||||
"test:unit": "node --test \"tests/unit/**/*.test.mjs\"",
|
||||
"test:e2e": "node tests/e2e/run.mjs",
|
||||
"test:windows": "node tests/e2e/windows.mjs",
|
||||
"test:rust": "cargo test --manifest-path crates/nodeagent-client/Cargo.toml",
|
||||
"probe": "cargo run --manifest-path crates/nodeagent-client/Cargo.toml --example probe --",
|
||||
"test:rust": "cargo test --manifest-path apps/console/src-tauri/Cargo.toml",
|
||||
"verify": "node scripts/verify.mjs",
|
||||
"pack:win": "node scripts/pack.mjs",
|
||||
"pack:mac": "node scripts/pack-macos.mjs",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/client",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/protocol",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 审计链外部锚定的**判定逻辑(纯函数)**—— v2.0.0。
|
||||
*
|
||||
* ## 为什么要放在 protocol 里(而不是留在 agent 内)
|
||||
* 锚点判定必须由**控制端**做(被控端是不可信方:它被攻破后会说"一切正常")。
|
||||
* 但判定规则一旦在两处各写一份,就会**静默漂移** —— 同一份日志,被控端说"一致"、
|
||||
* 控制端说"被重写",或反之。故此处收敛为**唯一实现**:agent 端与控制端脚本都 import 它。
|
||||
*
|
||||
* ## 判定依据(为什么不能只比哈希)
|
||||
* 日志轮转会丢弃最旧的段,使条目数**下降**。若只比 head_hash,轮转会被误判成篡改。
|
||||
* 故锚点记四元组 `{entries, head_hash, head_ts, rotated_segments}`,
|
||||
* 比对时先按「轮转了几段」对齐,再判断尾部是否一致。
|
||||
*
|
||||
* 本模块**无 IO**(不读文件、不知路径),输入只有「当前链头」与「锚点列表」——
|
||||
* 于是它能同时服务于:被控端进程内比对、控制端对云盘锚点的离线比对。
|
||||
*/
|
||||
|
||||
/** 一条锚点记录(写入锚点文件/上传到链外时就是这个形状)。 */
|
||||
export interface AnchorRecord {
|
||||
/** 锚定时刻(Unix ms) */
|
||||
ts: number;
|
||||
/** 当时的可见条目数(含轮转段) */
|
||||
entries: number;
|
||||
/** 链头哈希(要外部留存的核心值) */
|
||||
head_hash: string | null;
|
||||
/** 当时末条的时间戳 */
|
||||
head_ts: number | null;
|
||||
/** 当时的轮转段数 */
|
||||
rotated_segments: number;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/** 判定所需的「链头」字段(`AuditHead` 的超集即可传入)。 */
|
||||
export interface ChainHeadLike {
|
||||
entries: number;
|
||||
head_hash: string | null;
|
||||
head_ts: number | null;
|
||||
rotated_segments: number;
|
||||
}
|
||||
|
||||
export interface AnchorComparison {
|
||||
ok: boolean;
|
||||
anchors_checked: number;
|
||||
latest?: AnchorRecord;
|
||||
verdict: string;
|
||||
detail?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 与**最近一条**锚点比对,判断当前链是否与锚定时刻自洽。
|
||||
*
|
||||
* 三条判定:
|
||||
* 1. 条目数应 **≥** 锚点记录(只增不减;减少只能由轮转解释,轮转会让 rotated_segments 变大)
|
||||
* → 违反即**疑似整链重写/回滚**
|
||||
* 2. 若条目数与轮转段数都没变 → 链头哈希必须**完全一致**
|
||||
* → 不一致即**该段被重写**
|
||||
* 3. 若已轮转或条目数增长 → 锚点仍在链上,`ok:true`(附 detail 供人工核对)
|
||||
*/
|
||||
export function judgeAnchors(head: ChainHeadLike, anchors: AnchorRecord[]): AnchorComparison {
|
||||
if (anchors.length === 0) {
|
||||
return { ok: true, anchors_checked: 0, verdict: '锚点文件为空' };
|
||||
}
|
||||
const latest = anchors[anchors.length - 1]!;
|
||||
|
||||
if (head.entries < latest.entries && head.rotated_segments <= latest.rotated_segments) {
|
||||
return {
|
||||
ok: false,
|
||||
anchors_checked: anchors.length,
|
||||
latest,
|
||||
verdict: '**疑似整链重写/回滚**:当前条目数少于锚点,且没有发生轮转来解释',
|
||||
detail: {
|
||||
anchored_entries: latest.entries,
|
||||
current_entries: head.entries,
|
||||
anchored_hash: latest.head_hash,
|
||||
current_hash: head.head_hash,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (head.rotated_segments === latest.rotated_segments && head.entries === latest.entries) {
|
||||
if (head.head_hash !== latest.head_hash) {
|
||||
return {
|
||||
ok: false,
|
||||
anchors_checked: anchors.length,
|
||||
latest,
|
||||
verdict: '**链头哈希与锚点不一致**(条目数相同却哈希不同 → 该段被重写)',
|
||||
detail: { anchored_hash: latest.head_hash, current_hash: head.head_hash, entries: head.entries },
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
anchors_checked: anchors.length,
|
||||
latest,
|
||||
verdict: '与锚点完全一致(条目数、轮转段数、链头哈希三者相符)',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
anchors_checked: anchors.length,
|
||||
latest,
|
||||
// 锚定动作**自身**也会被记入审计(server 层统一记录所有 invoke),
|
||||
// 所以「刚锚定就比对」几乎必然看到"已增长"——这是正常现象,不是篡改。
|
||||
verdict:
|
||||
'链已增长(或发生轮转)—— 锚点仍在链上,无需人工核对到该锚点为止的部分。' +
|
||||
'(刚锚定就比对通常显示"已增长",因为锚定动作自身也会写一条审计。)',
|
||||
detail: {
|
||||
anchored_entries: latest.entries,
|
||||
current_entries: head.entries,
|
||||
rotated_delta: head.rotated_segments - latest.rotated_segments,
|
||||
anchored_hash: latest.head_hash,
|
||||
current_hash: head.head_hash,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 从 JSONL 文本解析锚点列表(坏行跳过;整体不可解析时返回 null 交给调用方报错)。 */
|
||||
export function parseAnchorsFromJsonl(text: string): AnchorRecord[] | null {
|
||||
const out: AnchorRecord[] = [];
|
||||
for (const line of text.split('\n')) {
|
||||
const t = line.trim();
|
||||
if (!t) continue;
|
||||
try {
|
||||
const rec = JSON.parse(t) as AnchorRecord;
|
||||
if (typeof rec?.entries === 'number' && 'head_hash' in rec) out.push(rec);
|
||||
} catch {
|
||||
// 半行(写入中断)跳过,不让一行坏数据毁掉整份锚点
|
||||
}
|
||||
}
|
||||
return out.length > 0 ? out : null;
|
||||
}
|
||||
@@ -141,7 +141,7 @@ export const AUTOMATION_CAPABILITIES: CapabilityDescriptor[] = [
|
||||
type: 'object',
|
||||
properties: {
|
||||
mode: { type: 'string', enum: ['static', 'dhcp', 'command'], description: '变更类型;command=自定义命令(POSIX 仅支持此项)' },
|
||||
interface: { type: 'string', description: '网卡别名(如「Ethernet」);不填则自动取带默认网关的那块' },
|
||||
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:网关(可选)' },
|
||||
|
||||
@@ -188,7 +188,7 @@ export const GRAPHICS_CAPABILITIES: CapabilityDescriptor[] = [
|
||||
name: CapabilityNames.KeyPress,
|
||||
version: '1.5',
|
||||
description:
|
||||
'按下**快捷键**(v1.5 大幅扩展范围)。支持的输入形式(**一次只用一种**):' +
|
||||
'按下**快捷键**(v1.5.0 大幅扩展范围)。支持的输入形式(**一次只用一种**):' +
|
||||
'① 字符串热键 hotkey: "ctrl+shift+esc"(配 repeat 可连按);② 热键序列 hotkeys: ["ctrl+c","ctrl+v"];' +
|
||||
'③ 预设别名 preset: "copy" / presets: ["copy","paste"](约 50 个语义名,免记键位);' +
|
||||
'④ 老数组形式 keys: ["ctrl","c"](配 repeat);⑤ 序列 sequence: [["up"],["enter"]]。' +
|
||||
@@ -243,14 +243,14 @@ export const GRAPHICS_CAPABILITIES: CapabilityDescriptor[] = [
|
||||
minimum: 0,
|
||||
maximum: 5000,
|
||||
default: 0,
|
||||
description: 'v1.5 长按:按下后保持该毫秒数再释放(如长按音量键)',
|
||||
description: 'v1.5.0 长按:按下后保持该毫秒数再释放(如长按音量键)',
|
||||
},
|
||||
route: {
|
||||
type: 'string',
|
||||
enum: ['foreground', 'post'],
|
||||
default: 'foreground',
|
||||
description:
|
||||
'v1.5 投放路由:foreground = SendInput 注入当前焦点(默认,需目标可见);' +
|
||||
'v1.5.0 投放路由:foreground = SendInput 注入当前焦点(默认,需目标可见);' +
|
||||
'post = 向 target_pid 进程全部顶层窗口 PostMessage(目标在后台时可用)',
|
||||
},
|
||||
target_pid: {
|
||||
@@ -392,7 +392,7 @@ export const GRAPHICS_CAPABILITIES: CapabilityDescriptor[] = [
|
||||
where: {
|
||||
type: 'object',
|
||||
description:
|
||||
'v1.7 属性谓词(**仅 UIA 引擎生效**):{ enabled?, selected?, value?, toggle? }。' +
|
||||
'v2.0.0 属性谓词(**仅 UIA 引擎生效**):{ enabled?, selected?, value?, toggle? }。' +
|
||||
'例:{"enabled":true} 只回可用控件;{"value":"*已完成*"} 按值模糊匹配。' +
|
||||
'拿不到的属性(Electron/游戏 UI 常见)不参与命中,由调用方按 note 判断;' +
|
||||
'OCR / image 引擎无属性可读,此时忽略本参数并带回 note。',
|
||||
@@ -436,7 +436,7 @@ export const GRAPHICS_CAPABILITIES: CapabilityDescriptor[] = [
|
||||
type: 'object',
|
||||
properties: {
|
||||
engine: { type: 'string', description: '实际使用的引擎:uia | ocr' },
|
||||
note: { type: 'string', description: 'v1.7:where 在非 UIA 引擎下被忽略等降级说明' },
|
||||
note: { type: 'string', description: 'v2.0.0:where 在非 UIA 引擎下被忽略等降级说明' },
|
||||
waited_ms: { type: 'integer', description: '实际等待时长(便于诊断是否命中等待窗口)' },
|
||||
matches: {
|
||||
type: 'array',
|
||||
@@ -501,7 +501,7 @@ export const GRAPHICS_CAPABILITIES: CapabilityDescriptor[] = [
|
||||
name: CapabilityNames.GuiAwait,
|
||||
version: '1.0',
|
||||
description:
|
||||
'等待一个条件成立再返回(v1.6,**只读低危**)。四类条件:' +
|
||||
'等待一个条件成立再返回(v1.6.0,**只读低危**)。四类条件:' +
|
||||
'window=等窗口出现/消失;control=等界面元素出现/消失(复用 screen.find);' +
|
||||
'process=等进程出现/消失;file=等文件出现/消失。' +
|
||||
'用途:把 GUI 操作从「按一下→睡几秒→截图碰运气」变成**可断言**的流程,例如:' +
|
||||
@@ -532,7 +532,7 @@ export const GRAPHICS_CAPABILITIES: CapabilityDescriptor[] = [
|
||||
text: { type: 'string', description: 'control 条件:元素文本(有 where 时可省)' },
|
||||
where: {
|
||||
type: 'object',
|
||||
description: 'v1.7 属性谓词(仅 UIA):{ enabled?, selected?, value?, toggle? },如等一个可用的按钮',
|
||||
description: 'v2.0.0 属性谓词(仅 UIA):{ enabled?, selected?, value?, toggle? },如等一个可用的按钮',
|
||||
},
|
||||
window: { type: 'string', description: 'control 条件:限定窗口标题(正则)' },
|
||||
control_type: { type: 'string', description: 'control 条件:限定控件类型(仅 UIA)' },
|
||||
@@ -545,7 +545,7 @@ export const GRAPHICS_CAPABILITIES: CapabilityDescriptor[] = [
|
||||
type: 'array',
|
||||
maxItems: 8,
|
||||
description:
|
||||
'v1.7 组合条件:数组,每项为一个条件对象(同顶层参数名),**任一命中**即算命中。' +
|
||||
'v2.0.0 组合条件:数组,每项为一个条件对象(同顶层参数名),**任一命中**即算命中。' +
|
||||
'例:[{"condition":"window","title":"安装完成"},{"condition":"control","text":"错误"}]' +
|
||||
'——「安装成功或报错,先出现的那个算」。与 where 合用可表达' +
|
||||
'「弹窗出现且其中某按钮 enabled」(用两条 await 亦可,任选)。',
|
||||
|
||||
@@ -393,7 +393,7 @@ export const SYSTEM_CAPABILITIES: CapabilityDescriptor[] = [
|
||||
name: CapabilityNames.LogQuery,
|
||||
version: '1.0',
|
||||
description:
|
||||
'在被控端侧**过滤**日志后只回匹配行(v1.8,只读低危)。' +
|
||||
'在被控端侧**过滤**日志后只回匹配行(v2.0.0,只读低危)。' +
|
||||
'治间歇性问题的关键:日志常几万行,整份拉回既慢又占带宽,过滤必须发生在 Windows 侧。' +
|
||||
'支持 pattern(正则,忽略大小写)/ level(ERROR|WARN|INFO|DEBUG|TRACE)/ ' +
|
||||
'since(Unix ms,按行首时间戳)/ offset / limit / tail(取末尾 N 条)。' +
|
||||
@@ -428,7 +428,7 @@ export const SYSTEM_CAPABILITIES: CapabilityDescriptor[] = [
|
||||
name: CapabilityNames.MonitorStart,
|
||||
version: '1.0',
|
||||
description:
|
||||
'启动一个定时采样并落盘(v1.8)。四类源:' +
|
||||
'启动一个定时采样并落盘(v2.0.0)。四类源:' +
|
||||
'port(target="host:port",连通性+延迟)/ process(target=进程名,存活+PID)/ ' +
|
||||
'command(target=命令,退出码+首行输出)/ metric(CPU/内存,无需 target)。' +
|
||||
'样本写 <数据目录>/monitors/<id>.jsonl,进程退出即停(不落定时任务)。' +
|
||||
@@ -457,7 +457,7 @@ export const SYSTEM_CAPABILITIES: CapabilityDescriptor[] = [
|
||||
name: CapabilityNames.MonitorReport,
|
||||
version: '1.0',
|
||||
description:
|
||||
'读回某个监控的样本序列与摘要(v1.8,只读)。摘要直接给结论:' +
|
||||
'读回某个监控的样本序列与摘要(v2.0.0,只读)。摘要直接给结论:' +
|
||||
'port/process 的「断线次数 + 最长连续中断样本数」、command 的「成功/失败数与出现过的退出码」、' +
|
||||
'metric 的「CPU/内存 min/max/avg」。运行中或已停止的都能查。',
|
||||
risk: 'low',
|
||||
@@ -486,7 +486,7 @@ export const SYSTEM_CAPABILITIES: CapabilityDescriptor[] = [
|
||||
{
|
||||
name: CapabilityNames.MonitorStop,
|
||||
version: '1.0',
|
||||
description: '停止一个运行中的监控,并返回其最终摘要(v1.8)。',
|
||||
description: '停止一个运行中的监控,并返回其最终摘要(v2.0.0)。',
|
||||
risk: 'low',
|
||||
params_schema: {
|
||||
type: 'object',
|
||||
@@ -502,7 +502,7 @@ export const SYSTEM_CAPABILITIES: CapabilityDescriptor[] = [
|
||||
{
|
||||
name: CapabilityNames.MonitorList,
|
||||
version: '1.0',
|
||||
description: '列出运行中的监控与历史样本文件(v1.8,只读)。',
|
||||
description: '列出运行中的监控与历史样本文件(v2.0.0,只读)。',
|
||||
risk: 'low',
|
||||
params_schema: { type: 'object', properties: {}, additionalProperties: false },
|
||||
returns_schema: {
|
||||
@@ -517,7 +517,7 @@ export const SYSTEM_CAPABILITIES: CapabilityDescriptor[] = [
|
||||
{
|
||||
name: CapabilityNames.MonitorDelete,
|
||||
version: '1.0',
|
||||
description: '删除某个监控的历史样本文件(不影响运行中的实例;运行中需先 stop)(v1.8)。',
|
||||
description: '删除某个监控的历史样本文件(不影响运行中的实例;运行中需先 stop)(v2.0.0)。',
|
||||
risk: 'low',
|
||||
params_schema: {
|
||||
type: 'object',
|
||||
@@ -531,7 +531,7 @@ export const SYSTEM_CAPABILITIES: CapabilityDescriptor[] = [
|
||||
name: CapabilityNames.AuditHead,
|
||||
version: '1.0',
|
||||
description:
|
||||
'返回审计链的链头(v25,只读):{ entries, head_hash, head_ts, rotated_segments, file, file_bytes }。' +
|
||||
'返回审计链的链头(v2.0.0,只读):{ entries, head_hash, head_ts, rotated_segments, file, file_bytes }。' +
|
||||
'**外部锚定的一半** —— 控制端定期拉取并把它存到链外(Mac 本地 / 另一台机器 / 网盘)。' +
|
||||
'compare=true 时附带与最近一条锚点的比对结论(另一半),能识别「有 root 的攻击者整链重写」——' +
|
||||
'链内哈希只能发现改一条,整链重建照样自洽,唯有链外的历史记录能戳破。' +
|
||||
@@ -564,7 +564,7 @@ export const SYSTEM_CAPABILITIES: CapabilityDescriptor[] = [
|
||||
name: CapabilityNames.AuditAnchor,
|
||||
version: '1.0',
|
||||
description:
|
||||
'把当前审计链头**追加**写入锚点文件(v25)。追加而非覆盖:历史锚点一旦写成就不可被后续' +
|
||||
'把当前审计链头**追加**写入锚点文件(v2.0.0)。追加而非覆盖:历史锚点一旦写成就不可被后续' +
|
||||
'刷新掉,否则攻击者重写链后再锚定一次即可抹除痕迹。' +
|
||||
'默认写 <数据目录>/audit-anchors.jsonl;指定 path 时仍受 fs_roots 白名单约束。' +
|
||||
'建议定期(如每天)调用,或用 automation 定时执行。' +
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 快捷键(hotkey)解析与预设(v1.5)。
|
||||
* 快捷键(hotkey)解析与预设(v1.5.0)。
|
||||
*
|
||||
* ## 为什么放在 protocol 包
|
||||
* 解析逻辑必须**两端一致 + 可单测**:控制端 CLI 先把字符串展开成和弦列表用于本地校验,
|
||||
|
||||
@@ -9,3 +9,4 @@ export * from './validate.js';
|
||||
export * from './capabilities.js';
|
||||
export * from './net.js';
|
||||
export * from './hotkeys.js';
|
||||
export * from './audit-anchor.js';
|
||||
Generated
+6
@@ -45,6 +45,12 @@ importers:
|
||||
|
||||
apps/console:
|
||||
dependencies:
|
||||
'@nodeagent/client':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/client
|
||||
'@nodeagent/protocol':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/protocol
|
||||
'@tauri-apps/api':
|
||||
specifier: ^2
|
||||
version: 2.12.1
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 审计链外部锚定 —— **控制端**工具(v2.0.0)。
|
||||
*
|
||||
* ## 为什么判定要在控制端做
|
||||
* 被控端是**不可信方**:它若被 root,既会重写 audit.log,也会把自己的锚点一并改掉,
|
||||
* 并告诉你"一切正常"。所以"锚点存哪、和什么比"必须由**控制端**掌握。
|
||||
*
|
||||
* ## 两个子命令
|
||||
* collect 拉被控端链头 → 生成一条锚点记录(JSONL 一行),供上传到链外
|
||||
* verify 拉被控端链头 + 读指定锚点文件 → 给出判定结论(三方一致 / 被重写)
|
||||
*
|
||||
* ## 判定逻辑不在此处重写
|
||||
* 复用 `@nodeagent/protocol` 的 `judgeAnchors`(与 agent 端**同一份实现**)——
|
||||
* 若两端各写一份,同一份日志会出现"被控端说一致、控制端说被改"的荒谬局面。
|
||||
*
|
||||
* ## 为什么走 CLI 而不是直接连
|
||||
* CLI 已经装了连接管理(多设备配置、证书钉住、daemon 连接池),薄薄一层复用即可;
|
||||
* 自己再实现一遍连接与握手,就是又一次"两份实现"。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/anchor-audit.mjs collect --node win [--out <文件>] [--note "每日"]
|
||||
* node scripts/anchor-audit.mjs verify --node win --anchors <锚点文件> [--head-file <链头 JSON>]
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { judgeAnchors, parseAnchorsFromJsonl } from '../packages/protocol/dist/index.js';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
/** 极简参数解析(避免为两个子命令引依赖)。 */
|
||||
function parseArgs(argv) {
|
||||
const out = { _: [] };
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const a = argv[i];
|
||||
if (a.startsWith('--')) {
|
||||
const k = a.slice(2);
|
||||
const v = argv[i + 1];
|
||||
if (v === undefined || v.startsWith('--')) out[k] = true;
|
||||
else {
|
||||
out[k] = v;
|
||||
i += 1;
|
||||
}
|
||||
} else out._.push(a);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function die(msg, code = 1) {
|
||||
console.error(`✗ ${msg}`);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
/** 拉取被控端链头(走 CLI,复用其连接管理)。 */
|
||||
/** 链头来源:优先 `--head-file`(便于离线验证/排查),否则走 CLI。 */
|
||||
function fetchHead(node, headFile) {
|
||||
if (headFile && typeof headFile === 'string') {
|
||||
if (!existsSync(headFile)) die(`链头文件不存在:${headFile}`);
|
||||
const j = JSON.parse(readFileSync(headFile, 'utf8'));
|
||||
return j.data ?? j; // 兼容「CLI 信封」与「裸 data」两种写法
|
||||
}
|
||||
// ⚠️ CLI 没有 `audit head` 子命令(只有 `audit` 列表与 `audit verify`)——
|
||||
// system.audit.head 只能走通用 `invoke`。2026-10-11 在 Parallels VM 上真跑时才发现:
|
||||
// 不实际跑一次,这种"能力存在但 CLI 无子命令"的错配根本暴露不出来。
|
||||
const args = ['invoke', 'system.audit.head', '--json'];
|
||||
if (node) args.push('--node', node);
|
||||
let raw;
|
||||
try {
|
||||
raw = execFileSync('nodeagent', args, {
|
||||
encoding: 'utf8',
|
||||
cwd: root,
|
||||
timeout: 60_000,
|
||||
env: process.env,
|
||||
});
|
||||
} catch (err) {
|
||||
die(`调用 CLI 失败:${err?.message ?? err}(被控端可能离线)`);
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
die(`CLI 输出非 JSON:${String(raw).slice(0, 200)}`);
|
||||
}
|
||||
if (parsed?.status !== 'ok' || !parsed.data) {
|
||||
die(`取链头失败:${parsed?.error?.message ?? '未知错误'}`);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
/** 组一条锚点记录(字段与 protocol 的 AnchorRecord 一致)。 */
|
||||
function toAnchorRecord(head, note) {
|
||||
return {
|
||||
ts: Date.now(),
|
||||
entries: head.entries,
|
||||
head_hash: head.head_hash ?? null,
|
||||
head_ts: head.head_ts ?? null,
|
||||
rotated_segments: head.rotated_segments ?? 0,
|
||||
...(note ? { note } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------- collect ----------------
|
||||
|
||||
function cmdCollect(args) {
|
||||
const node = args.node;
|
||||
const head = fetchHead(node, args['head-file']);
|
||||
const rec = toAnchorRecord(head, typeof args.note === 'string' ? args.note : undefined);
|
||||
|
||||
const out = args.out
|
||||
? String(args.out)
|
||||
: join(root, 'release', `audit-anchor-${new Date().toISOString().slice(0, 10)}.jsonl`);
|
||||
mkdirSync(dirname(out), { recursive: true });
|
||||
if (existsSync(out)) appendFileSync(out, `${JSON.stringify(rec)}\n`, 'utf8');
|
||||
else writeFileSync(out, `${JSON.stringify(rec)}\n`, 'utf8');
|
||||
|
||||
console.log(`✓ 已生成锚点:${rec.entries} 条,链头 ${String(rec.head_hash).slice(0, 16)}…(轮转 ${rec.rotated_segments} 段)`);
|
||||
console.log(` 文件:${out}`);
|
||||
console.log(` ⤴ 请把该文件上传到**链外**(网盘/另一台机器)—— 留在本机或与被控端同机都等于没锚。`);
|
||||
// 便于 automation 直接取用(一行 JSON)
|
||||
console.log(`ANCHOR_JSON=${JSON.stringify(rec)}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---------------- verify ----------------
|
||||
|
||||
function cmdVerify(args) {
|
||||
const node = args.node;
|
||||
const file = args.anchors;
|
||||
if (!file || typeof file !== 'string') die('verify 需要 --anchors <锚点文件>(可从网盘下载后传入)');
|
||||
if (!existsSync(file)) die(`锚点文件不存在:${file}`);
|
||||
|
||||
const anchors = parseAnchorsFromJsonl(readFileSync(file, 'utf8'));
|
||||
if (!anchors) die('锚点文件为空或全部不可解析');
|
||||
|
||||
const head = fetchHead(node, args['head-file']);
|
||||
const cmp = judgeAnchors(head, anchors);
|
||||
|
||||
const mark = cmp.ok ? '✓' : '✗';
|
||||
console.log(`${mark} 判定:${cmp.verdict}`);
|
||||
console.log(` 锚点条数:${cmp.anchors_checked}(最近一条:${new Date(cmp.latest?.ts ?? 0).toLocaleString()})`);
|
||||
console.log(` 当前链:${head.entries} 条,链头 ${String(head.head_hash).slice(0, 16)}…,轮转 ${head.rotated_segments} 段`);
|
||||
if (cmp.detail) console.log(` 明细:${JSON.stringify(cmp.detail)}`);
|
||||
if (!cmp.ok) {
|
||||
console.error('');
|
||||
console.error(' ⚠️ 与链外锚点不一致 —— 需人工核查被控端审计日志是否被重写。');
|
||||
console.error(' 注意:锚点本身也应有历史(多份),只比最近一条不足以定位被改的区间。');
|
||||
}
|
||||
return cmp.ok ? 0 : 2;
|
||||
}
|
||||
|
||||
// ---------------- 入口 ----------------
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const cmd = args._[0];
|
||||
const code =
|
||||
cmd === 'collect' ? cmdCollect(args)
|
||||
: cmd === 'verify' ? cmdVerify(args)
|
||||
: (console.error('用法: node scripts/anchor-audit.mjs <collect|verify> --node <设备> [...]'), 1);
|
||||
process.exit(code);
|
||||
+4
-1
@@ -97,7 +97,10 @@ function Show-Status {
|
||||
$inp = if ($cfg.allow_input) { "已启用" } else { "已禁用" }
|
||||
Write-Host " 输入控制 : $inp"
|
||||
$scheme = if ($cfg.tls) { "wss" } else { "ws" }
|
||||
Write-Host " 协议 : $scheme://0.0.0.0:$Port"
|
||||
Write-Host " 协议 : ${scheme}://0.0.0.0:$Port"
|
||||
# ⚠️ 变量名必须用 ${ } 包起来:裸写「变量名后紧跟冒号」会被 PowerShell 当成
|
||||
# drive 引用(如 env:),属**解析期**错误 → 整个脚本都跑不起来,
|
||||
# stop/start/status 三个子命令会全部失效(2026-10-11 真机踩到)
|
||||
} catch {
|
||||
Write-Warn "配置文件解析失败: $cfgPath"
|
||||
}
|
||||
|
||||
+1
-1
@@ -84,7 +84,7 @@ echo.
|
||||
echo [*] Installing nodeagent ^(node: %COMPUTERNAME%, port %PORT%, input control ON^)...
|
||||
echo.
|
||||
|
||||
REM v23: the key now travels via PIPE (stdin), not the -Key command-line arg.
|
||||
REM v2.0.0: the key now travels via PIPE (stdin), not the -Key command-line arg.
|
||||
REM Why: command lines are readable by other users on this machine
|
||||
REM (Win32_Process.CommandLine / Task Manager show them in clear text).
|
||||
REM NOTE: there must be NO space before the pipe - `echo key |` would fold the
|
||||
|
||||
+4
-4
@@ -42,11 +42,11 @@ param(
|
||||
[switch]$Unattended,
|
||||
# 追加「开机即启动」触发器(不依赖用户登录)
|
||||
[switch]$AtStartup,
|
||||
# v23:从**标准输入**读密钥(而非命令行 -Key)。
|
||||
# v2.0.0:从**标准输入**读密钥(而非命令行 -Key)。
|
||||
# 为什么:命令行参数对同机其他用户可见(任务管理器/Get-CimInstance Win32_Process
|
||||
# 的 CommandLine 字段就是明文)。install.cmd 用 `echo <key>| powershell ...` 喂进来。
|
||||
[switch]$KeyFromStdin,
|
||||
# v24 零信任优先:控制端公钥文件路径(内容是 `nodeagent keygen` 打印的那一条 JSON,
|
||||
# v2.0.0 零信任优先:控制端公钥文件路径(内容是 `nodeagent keygen` 打印的那一条 JSON,
|
||||
# 即 acl.clients 的一项)。给了它 → auth_mode 直接走 ed25519(零信任),
|
||||
# 不再依赖可被自报的 client_id。留空则自动探测脚本同目录的 client-acl.json。
|
||||
[string]$ClientAclFile = ""
|
||||
@@ -119,7 +119,7 @@ $cfgDir = if ($env:NODEAGENT_HOME) { $env:NODEAGENT_HOME } else { Join-Path $en
|
||||
$cfgPath = Join-Path $cfgDir "agent.json"
|
||||
New-Item -ItemType Directory -Force -Path $cfgDir | Out-Null
|
||||
|
||||
# v23:stdin 读取要在「复用既有配置」之前 —— 调用方显式给了 key 就以它为准。
|
||||
# v2.0.0:stdin 读取要在「复用既有配置」之前 —— 调用方显式给了 key 就以它为准。
|
||||
# 同时规避了一个易错点:[Console]::In.ReadLine() 在没有管道时会阻塞,
|
||||
# 所以必须先判 [Console]::IsInputRedirected。
|
||||
if ($KeyFromStdin -and -not $Key) {
|
||||
@@ -155,7 +155,7 @@ if (-not $Key) {
|
||||
Write-Ok "Generated a new pre-shared key"
|
||||
}
|
||||
|
||||
# --- v24: zero-trust-first -------------------------------------------------
|
||||
# --- v2.0.0: zero-trust-first -------------------------------------------------
|
||||
# PSK 有一个固有弱点:client_id 是调用方**自报**的,所以"谁知道共享 key,谁就能
|
||||
# 冒充任意未登记的 client_id"—— ACL 的身份维度在 psk 模式下形同虚设。
|
||||
# ed25519 没有这个问题(身份由私钥签名决定)。故:**只要拿到控制端公钥就走 ed25519**。
|
||||
|
||||
+13
-9
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* 用法: node scripts/pack.mjs [--node v22.20.0] [--out release]
|
||||
*
|
||||
* 产物: <out>/nodeagent-win-x64.zip
|
||||
* 产物: <out>/nodeagent-win-<arch>.zip(arch 默认 x64,见 --arch)
|
||||
* ├── node.exe Node.js 运行时(免装 Node.js)
|
||||
* ├── agent.mjs esbuild 打包的被控端(含全部依赖)
|
||||
* ├── install.cmd 一键安装(双击即用,自提权 + 自动生成 PSK)
|
||||
@@ -36,10 +36,14 @@ const getArg = (k, d) => {
|
||||
};
|
||||
|
||||
const NODE_VERSION = getArg('--node', 'v22.20.0');
|
||||
// 2026-10-11:Windows 也分架构。x64 包在 **ARM Windows(含 Parallels 的 Win11 ARM)** 上
|
||||
// 能启动、能 listen,但 **TLS 握手不完成**(Prism 模拟下协议层异常)→ 必须按架构出包。
|
||||
// 用法:node scripts/pack.mjs --arch arm64 → nodeagent-win-arm64.zip
|
||||
const NODE_ARCH = getArg('--arch', 'x64');
|
||||
const OUT_DIR = getArg('--out', join(root, 'release'));
|
||||
const PKG_DIR = join(OUT_DIR, 'nodeagent-win-x64');
|
||||
const PKG_DIR = join(OUT_DIR, `nodeagent-win-${NODE_ARCH}`);
|
||||
const MIRROR = process.env.NODEAGENT_NODE_MIRROR ?? 'https://npmmirror.com/mirrors/node';
|
||||
const NODE_ZIP_URL = `${MIRROR}/${NODE_VERSION}/node-${NODE_VERSION}-win-x64.zip`;
|
||||
const NODE_ZIP_URL = `${MIRROR}/${NODE_VERSION}/node-${NODE_VERSION}-win-${NODE_ARCH}.zip`;
|
||||
|
||||
const log = (msg) => console.log(msg);
|
||||
const fail = (msg) => {
|
||||
@@ -131,7 +135,7 @@ async function main() {
|
||||
|
||||
// 一键入口:.cmd 必须**纯 ASCII**。CMD 的代码页(中文 Windows = 936/GBK)
|
||||
// 无法可靠往返 UTF-8,中文提示一律交给带 BOM 的 .ps1 去输出。
|
||||
for (const cmd of ['install.cmd', 'control.cmd', 'run-temp.cmd']) {
|
||||
for (const cmd of ['install.cmd', 'control.cmd', 'run-portable.cmd']) {
|
||||
copyFileSync(join(root, 'scripts', cmd), join(PKG_DIR, cmd));
|
||||
}
|
||||
|
||||
@@ -150,7 +154,7 @@ async function main() {
|
||||
log(' .ps1 BOM 检查通过(兼容 Windows PowerShell 5.1)');
|
||||
|
||||
// 防呆 B:.cmd 里出现非 ASCII 字节 = 中文乱码/命令截断的隐患。
|
||||
for (const cmd of ['install.cmd', 'control.cmd', 'run-temp.cmd']) {
|
||||
for (const cmd of ['install.cmd', 'control.cmd', 'run-portable.cmd']) {
|
||||
const buf = readFileSync(join(PKG_DIR, cmd));
|
||||
const bad = [...buf].findIndex((b) => b > 0x7f);
|
||||
if (bad >= 0) {
|
||||
@@ -190,17 +194,17 @@ async function main() {
|
||||
'【日常管理】双击 control.cmd',
|
||||
' 1 状态 / 2 启动 / 3 停止 / 4 重启 / 5 日志 / 6 卸载 / 7 彻底卸载',
|
||||
'',
|
||||
'【临时试用,不装也行】双击 run-temp.cmd',
|
||||
'【免安装试用,不装也行】双击 run-portable.cmd',
|
||||
' * 不需要管理员、不写计划任务、Ctrl+C 即停',
|
||||
' * 首次运行自动生成配置与密钥(agent.json),并在控制台打印连接命令',
|
||||
' * run-temp.cmd background = 最小化后台启动;stop = 结束;status = 看状态',
|
||||
' * run-portable.cmd background = 最小化后台启动;stop = 结束;status = 看状态',
|
||||
' * 适合临时测一下;长期用请走 install.cmd(有开机自启与进程守护)',
|
||||
'',
|
||||
'【密钥】本包**不含** PSK.txt(避免公开渠道泄露密钥)。密钥来源按序为:',
|
||||
' 1) 若本目录已有 PSK.txt -> 直接用它',
|
||||
' 2) 若本机已有 %USERPROFILE%\\.nodeagent\\agent.json -> 复用其中的 key',
|
||||
' 3) 都没有 -> 自动生成一把随机密钥',
|
||||
' 装完 / 临时启动后,密钥会打印在屏幕上;也可用 run-temp.cmd status 查看',
|
||||
' 装完 / 免安装启动后,密钥会打印在屏幕上;也可用 run-portable.cmd status 查看',
|
||||
' 这就是 Mac 端 --key 要填的值',
|
||||
'',
|
||||
'【注意】',
|
||||
@@ -219,7 +223,7 @@ async function main() {
|
||||
log(entries.join('\n'));
|
||||
|
||||
log('\n=== ⑤ 打 zip ===');
|
||||
const zipOut = join(OUT_DIR, 'nodeagent-win-x64.zip');
|
||||
const zipOut = join(OUT_DIR, `nodeagent-win-${NODE_ARCH}.zip`);
|
||||
rmSync(zipOut, { force: true });
|
||||
// ⚠️ 必须排除 PSK.txt:本 zip 会作为**公开的 GitHub Release 资产**发布,
|
||||
// 把预共享密钥打进包里等于公开密钥。install.cmd 在文件缺失时会自动生成新密钥
|
||||
|
||||
+18
-5
@@ -137,7 +137,11 @@ console.log(`✓ 版本号已写入 ${pkgFiles.length} 个 package.json + ${taur
|
||||
console.log('=== 构建 ===');
|
||||
sh('pnpm', ['-r', 'build'], { stdio: 'inherit' });
|
||||
console.log('=== 打包 ===');
|
||||
sh('pnpm', ['pack:win'], { stdio: 'inherit' });
|
||||
// 2026-10-11:Windows 包分架构 —— x64 包在 **ARM Windows** 上协议层不可用(真机实证:
|
||||
// 能启动/listen 但 TLS 握手不完成),故两个架构都产出。
|
||||
for (const arch of ['x64', 'arm64']) {
|
||||
sh('node', ['scripts/pack.mjs', '--arch', arch], { stdio: 'inherit' });
|
||||
}
|
||||
try {
|
||||
sh('pnpm', ['pack:mac'], { stdio: 'inherit' });
|
||||
} catch {
|
||||
@@ -145,9 +149,13 @@ try {
|
||||
}
|
||||
|
||||
const agentPath = join(root, 'release', 'nodeagent-win-x64', 'agent.mjs');
|
||||
const zipPath = join(root, 'release', 'nodeagent-win-x64.zip');
|
||||
// agent.mjs 与架构无关(纯 JS bundle),两包内容相同;zip 各含对应架构的 node.exe
|
||||
const zipX64 = join(root, 'release', 'nodeagent-win-x64.zip');
|
||||
const zipArm64 = join(root, 'release', 'nodeagent-win-arm64.zip');
|
||||
if (!existsSync(agentPath)) fail(`找不到 agent 产物: ${agentPath}`);
|
||||
if (!existsSync(zipPath)) fail(`找不到 zip 产物: ${zipPath}`);
|
||||
for (const z of [zipX64, zipArm64]) {
|
||||
if (!existsSync(z)) fail(`找不到 zip 产物: ${z}`);
|
||||
}
|
||||
|
||||
// ---------- ④ 生成资产(校验和 + latest.json)----------
|
||||
const sha256 = (p) => createHash('sha256').update(readFileSync(p)).digest('hex');
|
||||
@@ -156,7 +164,8 @@ 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)' },
|
||||
{ name: 'nodeagent-win-x64.zip', path: zipX64, purpose: '人工安装包 · x64 Windows(自包含 node.exe)' },
|
||||
{ name: 'nodeagent-win-arm64.zip', path: zipArm64, purpose: '人工安装包 · ARM Windows(Parallels Win11 ARM 等)' },
|
||||
];
|
||||
for (const a of assets) {
|
||||
copyFileSync(a.path, join(distDir, a.name));
|
||||
@@ -188,6 +197,8 @@ const latest = {
|
||||
note: '自更新用:nodeagent update --url <url> --sha256 <sha256>',
|
||||
},
|
||||
zip: { url: `${base}/nodeagent-win-x64.zip`, sha256: assets[1].sha256, bytes: assets[1].bytes },
|
||||
// 2026-10-11 新增(只增字段,旧控制端忽略即可):ARM Windows 用包
|
||||
zip_arm64: { url: `${base}/nodeagent-win-arm64.zip`, sha256: assets[2].sha256, bytes: assets[2].bytes },
|
||||
};
|
||||
writeFileSync(join(distDir, 'latest.json'), JSON.stringify(latest, null, 2) + '\n');
|
||||
|
||||
@@ -275,7 +286,8 @@ writeFileSync(
|
||||
`## 人工安装(首次)`,
|
||||
`\`\`\`powershell`,
|
||||
`Expand-Archive nodeagent-win-x64.zip C:\\nodeagent -Force`,
|
||||
`# 先把 C:\\nodeagent 加入杀软信任区(主流杀软/杀软)`,
|
||||
`# ARM Windows(如 Parallels 的 Win11 ARM)请改用 nodeagent-win-arm64.zip —— x64 包在其上 TLS 握手不通`,
|
||||
`# 先把 C:\\nodeagent 加入杀软信任区(主流杀软)`,
|
||||
`powershell -ExecutionPolicy Bypass -File C:\\nodeagent\\install.ps1 -NodeId win -AllowInput`,
|
||||
'```',
|
||||
].join('\n') + '\n',
|
||||
@@ -291,6 +303,7 @@ sh('gh', [
|
||||
notes,
|
||||
join(distDir, 'agent.mjs'),
|
||||
join(distDir, 'nodeagent-win-x64.zip'),
|
||||
join(distDir, 'nodeagent-win-arm64.zip'),
|
||||
join(distDir, 'latest.json'),
|
||||
join(distDir, 'SHA256SUMS'),
|
||||
]);
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
(顺序颠倒会踩 Windows 的「already exists」而再次失败 —— 真机事故根因)
|
||||
|
||||
.PARAMETER Interface
|
||||
网卡别名(如「Ethernet」)。省略则自动选取「已连接但无有效 IPv4」的那块。
|
||||
网卡别名(如 'Ethernet';中文系统常见为「网络」类名称)。省略则自动选取「已连接但无有效 IPv4」的那块。
|
||||
|
||||
.PARAMETER Ip / PrefixLength / Gateway / Dns
|
||||
要恢复的静态配置;省略 Ip 则切回 DHCP。
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
@echo off
|
||||
REM ============================================================================
|
||||
REM nodeagent - TEMPORARY launcher (no install, no admin, no scheduled task)
|
||||
REM nodeagent - PORTABLE launcher (no install, no admin, no scheduled task)
|
||||
REM
|
||||
REM Purpose: bring the agent up for a quick test WITHOUT running install.cmd.
|
||||
REM The agent is zero-config: on first run it creates %USERPROFILE%\.nodeagent\
|
||||
REM (agent.json with a random PSK + self-signed cert) and starts listening.
|
||||
REM
|
||||
REM Usage:
|
||||
REM run-temp.cmd start in foreground (Ctrl+C to stop)
|
||||
REM run-temp.cmd start same as above
|
||||
REM run-temp.cmd background start hidden, then print status
|
||||
REM run-temp.cmd stop stop the background instance
|
||||
REM run-temp.cmd status show whether it is running
|
||||
REM run-portable.cmd start in foreground (Ctrl+C to stop)
|
||||
REM run-portable.cmd start same as above
|
||||
REM run-portable.cmd background start hidden, then print status
|
||||
REM run-portable.cmd stop stop the background instance
|
||||
REM run-portable.cmd status show whether it is running
|
||||
REM
|
||||
REM IMPORTANT: the agent takes NO command-line arguments (it does not parse
|
||||
REM argv at all). The listen port comes from %USERPROFILE%\.nodeagent\
|
||||
@@ -81,7 +81,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -Command ^
|
||||
"$cfg = Join-Path $env:USERPROFILE '.nodeagent\agent.json';" ^
|
||||
"if (Test-Path $cfg) { Write-Host (' config : ' + $cfg); try { (Get-Content $cfg -Raw | ConvertFrom-Json) | ForEach-Object { Write-Host (' node_id : ' + $_.node_id); Write-Host (' key : ' + $_.key) } } catch {} } else { Write-Host ' config : (not yet created)' };"
|
||||
echo.
|
||||
echo If the connect line above is empty, run: run-temp.cmd status
|
||||
echo If the connect line above is empty, run: run-portable.cmd status
|
||||
pause
|
||||
exit /b 0
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* B2 验证脚本:v23 连接层三防护(真机跑,不是 mock)。
|
||||
*
|
||||
* 为什么单独写:CLI 的命令都是**短连接**(连完即断),测不出「空闲断开」;
|
||||
* 而 `events` 订阅会让服务端认为"该连接有订阅、不能踢"(这是 v23 的保守设计)。
|
||||
* 所以需要一个**握手完成后什么都不做**的长连接。
|
||||
*
|
||||
* 用法:
|
||||
* node /tmp/b2-idle.mjs idle # ③ 空闲断开(观察是否 ~idle_timeout 被断)
|
||||
* node /tmp/b2-idle.mjs maxconn # ② 连接上限(并发 N+1 个,第 N+1 应被拒)
|
||||
*/
|
||||
|
||||
// 用相对路径:从仓库根跑时解析不到 workspace 包(根 package.json 未声明它)
|
||||
import { NodeAgentClient } from '../packages/client/dist/index.js';
|
||||
|
||||
const URL = process.env.NA_URL ?? 'wss://10.211.55.9:8765' // ⚠️ 必须 wss:被控端 tls=true(写成 ws 会 socket hang up);
|
||||
const KEY = process.env.NA_KEY;
|
||||
if (!KEY) {
|
||||
console.error('需要 NA_KEY 环境变量');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mode = process.argv[2] ?? 'idle';
|
||||
const ts = () => new Date().toISOString().slice(11, 19);
|
||||
|
||||
function mkClient(id) {
|
||||
return new NodeAgentClient({
|
||||
url: URL,
|
||||
key: KEY,
|
||||
clientId: id,
|
||||
insecure: true,
|
||||
handshakeTimeoutMs: 10_000,
|
||||
onStateChange: (s) => console.log(`[${ts()}] ${id} state=${s}`),
|
||||
onLog: (m) => console.log(`[${ts()}] ${id} log: ${m}`),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- ③ 空闲断开 ----------------
|
||||
async function idleTest() {
|
||||
const started = Date.now();
|
||||
const c = mkClient('idle-probe');
|
||||
try {
|
||||
await c.connect();
|
||||
} catch (err) {
|
||||
console.log(`[${ts()}] ⚠️ 握手失败:${err?.message ?? err}`);
|
||||
console.log(`[${ts()}] 提示:若报 socket hang up,通常说明已达 max_connections(先等空闲超时清掉残留)`);
|
||||
process.exit(3);
|
||||
}
|
||||
console.log(`[${ts()}] ${'idle-probe'} 已握手完成(t=0s),之后**不做任何请求、不订阅**`);
|
||||
console.log(`[${ts()}] 期待:约 idle_timeout_ms 后被服务端断开(state=closed)`);
|
||||
|
||||
await new Promise((resolve) => {
|
||||
const timer = setInterval(() => {
|
||||
const sec = ((Date.now() - started) / 1000).toFixed(0);
|
||||
if (sec % 10 === '0') console.log(`[${ts()}] t=${sec}s 仍存活`);
|
||||
if (Number(sec) >= 75) {
|
||||
clearInterval(timer);
|
||||
console.log(`[${ts()}] t=${sec}s —— 75 秒到,**未被断开**(空闲断开未生效或阈值更长)`);
|
||||
resolve();
|
||||
}
|
||||
}, 1000);
|
||||
// 订阅状态变化:closed 即被服务端断开
|
||||
const orig = c.opts?.onStateChange;
|
||||
void orig;
|
||||
});
|
||||
|
||||
try {
|
||||
c.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
console.log(`[${ts()}] 结束`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// ---------------- ② 连接上限 ----------------
|
||||
async function maxConnTest() {
|
||||
const n = Number(process.env.NA_MAX ?? 2);
|
||||
const clients = [];
|
||||
console.log(`[${ts()}] 尝试建立 ${n + 1} 个并发连接(配置 max_connections=${n})`);
|
||||
|
||||
for (let i = 1; i <= n + 1; i += 1) {
|
||||
const id = `conn-${i}`;
|
||||
const c = mkClient(id);
|
||||
try {
|
||||
await c.connect();
|
||||
clients.push(c);
|
||||
console.log(`[${ts()}] ✓ 第 ${i} 个连接成功`);
|
||||
} catch (err) {
|
||||
console.log(`[${ts()}] ✗ 第 ${i} 个连接被拒:${err?.message ?? err}`);
|
||||
// 第 n+1 个被拒即为通过
|
||||
for (const x of clients) {
|
||||
try {
|
||||
x.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
const pass = i === n + 1;
|
||||
console.log(`[${ts()}] 结论:${pass ? '✓ 连接上限生效' : '⚠️ 被拒的不是第 N+1 个'}`);
|
||||
process.exit(pass ? 0 : 2);
|
||||
}
|
||||
// 稍等,避免瞬时并发被当成同一个
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
}
|
||||
|
||||
console.log(`[${ts()}] ⚠️ ${n + 1} 个连接全部成功 —— 连接上限未生效`);
|
||||
for (const x of clients) {
|
||||
try {
|
||||
x.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
|
||||
// ---------------- ① 握手封禁 ----------------
|
||||
async function banTest() {
|
||||
const n = Number(process.env.NA_ATTEMPTS ?? 3);
|
||||
console.log(`[${ts()}] 用**错误密钥**连 ${n} 次(配置 max_attempts=${n})`);
|
||||
for (let i = 1; i <= n; i += 1) {
|
||||
const c = new NodeAgentClient({ url: URL, key: 'WRONG-KEY-' + i, clientId: 'ban-' + i, insecure: true, handshakeTimeoutMs: 8000 });
|
||||
try {
|
||||
await c.connect();
|
||||
console.log(`[${ts()}] ⚠️ 第 ${i} 次错误密钥竟然成功(不应发生)`);
|
||||
c.close();
|
||||
} catch (err) {
|
||||
console.log(`[${ts()}] ✓ 第 ${i} 次被拒:${String(err?.message ?? err).slice(0, 60)}`);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
console.log(`[${ts()}] 现在用**正确密钥**连(若封禁生效,应当也被拒)`);
|
||||
const good = new NodeAgentClient({ url: URL, key: KEY, clientId: 'ban-good', insecure: true, handshakeTimeoutMs: 8000 });
|
||||
try {
|
||||
await good.connect();
|
||||
console.log(`[${ts()}] ✗ 正确密钥连接成功 —— **封禁未生效**`);
|
||||
good.close();
|
||||
process.exit(2);
|
||||
} catch (err) {
|
||||
console.log(`[${ts()}] ✓ 正确密钥也被拒:${String(err?.message ?? err).slice(0, 70)}`);
|
||||
console.log(`[${ts()}] 结论:✓ 握手失败封禁生效(连正确密钥都被挡)`);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'idle') await idleTest();
|
||||
else if (mode === 'maxconn') await maxConnTest();
|
||||
else if (mode === 'ban') await banTest();
|
||||
else {
|
||||
console.error('用法: node b2-idle.mjs <idle|maxconn>');
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from '../../apps/agent/dist/audit.js';
|
||||
|
||||
/**
|
||||
* v25 审计链外部锚定的逻辑测试。
|
||||
* v2.0.0 审计链外部锚定的逻辑测试。
|
||||
*
|
||||
* 守的是三件最容易写错的事:
|
||||
* 1. 只记 head_hash 会把**轮转**误判成篡改(必须连 entries + rotated_segments 一起记)
|
||||
|
||||
@@ -255,9 +255,9 @@ test('capabilityDiff:真实清单与自身比对应无差异(防手滑改坏
|
||||
assert.equal(d.unknownLocally.length, 0);
|
||||
});
|
||||
|
||||
// ---------- v1.5 快捷键扩展:新参数必须通过 schema 校验 ----------
|
||||
// ---------- v1.5.0 快捷键扩展:新参数必须通过 schema 校验 ----------
|
||||
|
||||
test('input.key.press:v1.5 新参数(hotkey/hotkeys/preset/hold_ms/route)通过校验', () => {
|
||||
test('input.key.press:v1.5.0 新参数(hotkey/hotkeys/preset/hold_ms/route)通过校验', () => {
|
||||
assert.equal(check('input.key.press', { hotkey: 'ctrl+shift+esc' }).length, 0);
|
||||
assert.equal(check('input.key.press', { hotkeys: ['ctrl+c', 'ctrl+v'] }).length, 0);
|
||||
assert.equal(check('input.key.press', { preset: 'copy' }).length, 0);
|
||||
|
||||
@@ -9,7 +9,7 @@ function check(capName, args) {
|
||||
}
|
||||
|
||||
/**
|
||||
* gui.await 的**契约层**测试(v1.6)。
|
||||
* gui.await 的**契约层**测试(v1.6.0)。
|
||||
*
|
||||
* 说明:条件命中/超时/轮询的**运行时行为**在 tests/e2e/await.mjs 里用真实 agent 进程
|
||||
* 验证(macOS 本地可跑:file/process 条件)。本文件只守**不需要平台的**部分:
|
||||
@@ -71,7 +71,7 @@ test('MCP 已暴露 na_await 工具', async () => {
|
||||
assert.ok(/na_await/.test(src), 'MCP 应有 na_await 工具');
|
||||
});
|
||||
|
||||
// ---------- v1.7 属性谓词与组合条件 ----------
|
||||
// ---------- v2.0.0 属性谓词与组合条件 ----------
|
||||
|
||||
test('gui.await:where 与 any_of 通过 schema', () => {
|
||||
assert.equal(check('gui.await', { condition: 'control', where: { enabled: true } }).length, 0);
|
||||
@@ -107,7 +107,7 @@ test('any_of 求值语义:任一命中即算(等价于逻辑 OR)', () => {
|
||||
assert.ok(![{ hit: false }, { hit: false }].some((s) => s.hit), '全不命中 → 不命中');
|
||||
});
|
||||
|
||||
test('screen.find:where-only 必须放行(v1.8 修——真机验证暴露的缺口)', () => {
|
||||
test('screen.find:where-only 必须放行(v2.0.0 修——真机验证暴露的缺口)', () => {
|
||||
// 之前入口校验只认 text,导致 where-only 被"text 不能为空"拦下;
|
||||
// 真机 2026-10-09 验证时抓到。此处锁住:where 非空即可,text 可省。
|
||||
assert.equal(check('screen.find', { where: { enabled: true } }).length, 0, 'where-only 应通过');
|
||||
|
||||
@@ -5,7 +5,7 @@ import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/**
|
||||
* 零信任优先安装(v24)的**源码断言**。
|
||||
* 零信任优先安装(v2.0.0)的**源码断言**。
|
||||
*
|
||||
* 为什么用源码断言:install.ps1 是 5.1 脚本,Node 侧无法直接执行它;
|
||||
* 而这里要守的是「关键决策逻辑不许被误删」—— 一旦有人重构掉 acl 写入或
|
||||
|
||||
@@ -6,7 +6,7 @@ import { tmpdir } from 'node:os';
|
||||
import { logQuery, parseLeadingTs } from '../../apps/agent/dist/capabilities/log.js';
|
||||
|
||||
/**
|
||||
* log.query 的纯逻辑测试(v1.8 方向二第一块)。
|
||||
* log.query 的纯逻辑测试(v2.0.0 方向二第一块)。
|
||||
* 文件级行为(流式读取/白名单)用真实临时文件验证,无需被控端进程。
|
||||
*/
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import { findCapability, validate, CapabilityNames } from '../../packages/protocol/dist/index.js';
|
||||
|
||||
/**
|
||||
* monitor.* 的契约层测试(v1.8 方向二第二块)。
|
||||
* monitor.* 的契约层测试(v2.0.0 方向二第二块)。
|
||||
* 运行时行为(真采样/落盘/摘要)在本地真机探针里验证:port 探测 + metric 采样 +
|
||||
* report 摘要 + stop 落盘。
|
||||
*/
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
/**
|
||||
* v23 连接层防护的**规则**测试(server.ts 的纯逻辑部分)。
|
||||
* v2.0.0 连接层防护的**规则**测试(server.ts 的纯逻辑部分)。
|
||||
*
|
||||
* 说明:封禁/上限/空闲的**端到端行为**需真实 agent 进程(已在本地实测三场景:
|
||||
* 正确密钥可连 → 连续错误被拒 → 封禁期连正确密钥也被拒)。本文件守**可脱离进程
|
||||
@@ -74,7 +75,7 @@ test('install.ps1 的 stdin 读取:校验规则与防阻塞', () => {
|
||||
assert.equal(' a1b2c3d4e5f6a7b8\r\n'.trim(), 'a1b2c3d4e5f6a7b8', 'Trim 后应为纯 hex');
|
||||
});
|
||||
|
||||
// ---------- v26 自重启任务清理的保守判定(源码断言) ----------
|
||||
// ---------- v2.0.0 自重启任务清理的保守判定(源码断言) ----------
|
||||
//
|
||||
// 用 includes 而非正则:这里断言的是源码字面片段,正则转义层数容易写错
|
||||
// (臣本轮就在这上面栽过两次)。
|
||||
@@ -84,11 +85,13 @@ const restartSrc = readFileSync(
|
||||
'utf8',
|
||||
);
|
||||
|
||||
test('自重启任务清理:必须带「运行超 1 小时」余量,禁止简单清所有 Running', () => {
|
||||
test('自重启任务清理:阈值必须是「本 agent 启动时刻」(精确),禁止 1 小时粗阈值', () => {
|
||||
// 背景:重启任务的 cmd 长期持有新 agent 进程 → 任务状态长期 Running;
|
||||
// 若把 Running 的全清掉会连当前 agent 的宿主一起杀(= 自杀)。
|
||||
// 真机 2026-10-09 实测残留 3 个 Running 僵尸,故引入时间余量判定。
|
||||
assert.ok(restartSrc.includes('AddHours(-1)'), '缺少 1 小时余量判定');
|
||||
assert.ok(restartSrc.includes('$agentStart = (Get-Date).AddMilliseconds(-'), '缺少「本 agent 启动时刻」阈值');
|
||||
assert.ok(restartSrc.includes('$cut = $agentStart'), '阈值应取自 agent 启动时刻');
|
||||
assert.ok(!restartSrc.includes('AddHours(-1)'), '不应再回退到「1 小时」粗阈值(真机实测会累积)');
|
||||
assert.ok(restartSrc.includes('LastRunTime -lt $cut'), '必须按 LastRunTime 与阈值比较');
|
||||
assert.ok(restartSrc.includes('Get-ScheduledTaskInfo'), '需要读取任务的运行信息');
|
||||
// 禁止回到"只清非 Running"的老写法
|
||||
@@ -104,3 +107,29 @@ test('自重启任务清理:清理动作带 -ErrorAction SilentlyContinue(
|
||||
'清理动作需容错,否则个别任务异常会中断整个重启流程',
|
||||
);
|
||||
});
|
||||
|
||||
// ---------- PowerShell 语法类守卫($ident: 危险变量引用) ----------
|
||||
|
||||
test('PowerShell 脚本不得含 `$ident:` 形式(会被当 drive 引用 → 整个脚本解析失败)', () => {
|
||||
// 2026-10-11 真机踩到:control.ps1 里写了 "…$scheme://0.0.0.0:$Port",
|
||||
// PowerShell 把 `$scheme:` 当 drive 引用 → 报 "变量引用无效",
|
||||
// 且因为是**解析期**错误,stop/start/status 三个子命令**全部不可用**。
|
||||
// 修法是写成 ${scheme}。这条守卫防同类再犯。
|
||||
const files = execFileSync('git', ['ls-files'], { encoding: 'utf8' })
|
||||
.split('\n')
|
||||
.filter((f) => f.endsWith('.ps1') || f.endsWith('.psm1'));
|
||||
assert.ok(files.length >= 3, '应找到若干 .ps1(install/control/rescue 等)');
|
||||
|
||||
const ALLOW = new Set(['env', 'nuget', 'psitem', 'PSScriptRoot']);
|
||||
const BAD = /\$([A-Za-z_][A-Za-z0-9_]*):/g;
|
||||
const offenders = [];
|
||||
for (const f of files) {
|
||||
const text = readFileSync(new URL(`../../${f}`, import.meta.url), 'utf8');
|
||||
text.split('\n').forEach((line, i) => {
|
||||
for (const m of line.matchAll(BAD)) {
|
||||
if (!ALLOW.has(m[1])) offenders.push(`${f}:${i + 1} $${m[1]}:`);
|
||||
}
|
||||
});
|
||||
}
|
||||
assert.deepEqual(offenders, [], `发现危险变量引用(改用 \${name}):\n${offenders.join('\n')}`);
|
||||
});
|
||||
Reference in new issue
Block a user