23 Commits
Author SHA1 Message Date
jkooe 4f4d8913b5 chore(release): v2.0.1 2026-10-11 08:02:19 +08:00
jkooe f4ae20969a chore(release): release.mjs 支持双架构(同时出 x64 + arm64 包)+ CHANGELOG v2.0.1
## release.mjs
- 打包阶段改为循环 `pack.mjs --arch x64|arm64`(原先只出 x64)
- 资产新增 `nodeagent-win-arm64.zip`;`latest.json` 新增 `zip_arm64` 字段
  (**只增不减**,符合 VERSIONING 的兼容铁律;旧控制端忽略即可)
- RELEASE_NOTES 提示按架构选包(x64 包在 ARM Windows 上 TLS 握手不通)
- 顺手修一处改名遗漏:`zipPath` → `zipX64` 后,产物存在性检查仍在用旧名(会 ReferenceError)

## CHANGELOG
补 v2.0.1 段:3 个修复(control.ps1 解析期错误 / 连接保活缺 pong / v26 清理阈值过粗)
+ 2 项新增(`pack --arch`、`verify-b2.mjs`)+ 新增文档 `VM-VERIFICATION.md` + 真机验证状态表
2026-10-11 08:00:52 +08:00
jkooe 7d13a48caa docs(changelog): v2.0.0 的「已知限制」更新为「验证状态已补齐」(2026-10-11 VM 真机验证) 2026-10-11 07:53:42 +08:00
jkooe 40b7db16c1 docs(roadmap): §五/§九 的 7 处历史「未做」标注实际状态(避免误导为待办)
这些行是当时的计划快照,内容其实都已实现(screen.find 属性化、条件组合、方向二/三、ed25519 默认化
以及 v1.5 真机验证)。不改写历史结论,只更新事实以免读者误判为未完成。
2026-10-11 07:53:10 +08:00
jkooe 765ad08ee6 docs(roadmap): §九 台账 —— v1.5/v26 真机验证标记完成(VM Win11 ARM) 2026-10-11 01:13:36 +08:00
jkooe fbcb8f8408 fix(v23): 连接保活补 pong 检测(治僵尸连接占名额)+ B2 三防护真机验证通过
## 真机发现(Parallels Win11 ARM,B2 验证过程中撞到)
心跳只发 `ping()` 而**不检查 pong**,且 ws 库默认不会因「ping 无 pong」断开 ——
客户端进程已死(TCP 未发 FIN)时,连接会一直挂在 `states` 里占 `max_connections` 名额。
叠加「有任务/订阅时清扫保守跳过」的既有策略 → `states.size` 虚高 → **把正常控制端挡在外面**
(本次实测就把自己锁在外面了)。

## 改法
- `attachConnection` 里加 `ws.on('pong')`:**客户端回应也算活跃**(更新 lastActiveAt)
- 心跳里:若某连接静默超过 `idle_timeout_ms * 2` → `ws.terminate()`(标准 ws 保活)
  → 僵尸连接不会再永久占位

## B2 三场景真机验证(全部通过)
- ① **握手封禁**:配 `max_attempts=3` → 3 次错误密钥被拒后,**正确密钥也被拒** ✓
- ② **连接上限**:配 `max_connections=6` → 第 1~6 个成功、**第 7 个被拒** ✓
- ③ **空闲断开**:配 `idle_timeout_ms=20000` → 静默 **40 秒**被断开 ✓
  (符合 20s 阈值 + 30s 清扫周期)
- 新增 `scripts/verify-b2.mjs`(三模式:idle / maxconn / ban),全部走**真实连接**

## 验证过程中另修的两个自测问题(都记进文档)
1. 脚本默认 URL 写成了 `ws://` —— 被控端 `tls:true`,**必须 wss**(否则 socket hang up)
2. 测 `max_connections` 时值太小会**把自己锁在外面**;且被拒连接会被计入握手失败形成恶性循环
   → 结论:测试要留余量,并给自己配 `auth_ban.whitelist`

## 收尾
测试用的 `security` 段已用**备份恢复**(`agent.json.before-b2`);pong 修复已 `deploy` 到 VM
(指纹 abca2b8168f0)。
2026-10-11 01:13:12 +08:00
jkooe ab7a2b147b fix(v26): 自重启任务清理阈值改为「本 agent 启动时刻」(真机实测 1 小时粗阈值仍会累积)
## 真机发现(Parallels Win11 ARM)
restart 两次后仍有 **2 个** selfrestart 任务(旧的 Ready 未被清)。诊断确认:
- 清理**条件**能选中它 ✓(非匹配问题)
- **手工删除**成功 ✓(非权限问题)
→ 算时间戳才明白:那是「1 小时」粗阈值的**必然结果** —— 旧任务创建于 00:44,
  第二次 restart 在 00:56(仅隔 12 分钟),清理那一刻它**仍是 Running**(cmd 还持有上一次的 agent),
  不满足「非 Running 或早于 1 小时」→ 保留。

**但规则本身是缺陷**:频繁 restart(间隔 < 1h)时旧任务照样累积 ——
v26 想治的僵尸只是从「永不清理」变成「1 小时后才清」。

## 改法(语义更精确)
```powershell
# 旧:$cut = (Get-Date).AddHours(-1)                    ← 粗略
# 新:$cut = (Get-Date).AddMilliseconds(-<agent uptime>) ← 「本 agent 的启动时刻」
```
凡在**我启动之前**就运行的任务,必然不是我的宿主(我的宿主刚刚才创建)→ 可安全删除。

## 验证(真机闭环)
1. `nodeagent deploy release/agent.mjs` → 指纹校验 + 备份 + 上传 + 重启 + 校验 ✓
   (顺带把 deploy 机制也验了)
2. `restart` → **任务只剩 1 个**(Running,当前宿主)→ **旧任务被精确清理** ✓✓
3. 单测:断言必须是「agent 启动时刻」阈值,且**禁止回退到 AddHours(-1)**;
   守卫 9/9 + 全量 274 绿;7 包构建通过
2026-10-11 00:50:51 +08:00
jkooe 047cc9efd2 docs(vm): 补 ARM64 包的解决方案(pack.mjs --arch arm64) 2026-10-11 00:32:54 +08:00
jkooe d8f21f4e68 feat(pack): 支持 --arch(x64 / arm64)—— ARM Windows 上 x64 包不可用(真机实证)
## 背景(Parallels Win11 ARM 真机验证的结论)
x64 包(内含 x64 Node)在 ARM Windows 上:**能启动、能 listen 8765,但 TLS 握手 60s 不完成**
(Prism 模拟下协议层异常)→ 协议层完全不可用,且现象具有误导性(端口是通的)。
换同版本 ARM64 Node 后**完全正常**(握手成功、连接、51 项能力)。

## 改动
`scripts/pack.mjs` 新增 `--arch <x64|arm64>`(默认 x64,向后兼容):
- Node 下载 URL、包目录、产物名全部按架构生成
- 产出:`nodeagent-win-arm64.zip`(ARM Windows 用)/ `nodeagent-win-x64.zip`(默认)
- 加了架构校验(非法值直接报错)

## 验证
- `node --check` 语法通过
- 实跑 `node scripts/pack.mjs --arch arm64` → 产出 31.4 MB 包,
  内含 ARM64 `node.exe`(85.6 MB 解压后)+ `agent.mjs`
2026-10-11 00:30:43 +08:00
jkooe 54f7f4c7ab test(vm): Parallels Win11 ARM 真机验证 —— A 组 15/0 + v1.5 四项全过 + 锚点端到端通过
## 环境
Parallels 27 + Win11 ARM,VM 10.211.55.9(共享网络);被控端 `-NodeId win-arm -AllowInput`。

## ⭐ B5 结论(与预期不同,但更有价值)
| 组合 | 结果 |
|---|---|
| `win-x64` 包(内含 x64 Node) | ⚠️ **能启动、能 listen 8765,但 TLS 握手 60s 不完成** → 协议层不可用(Prism 下) |
| **换 ARM64 Node(同版本 v22.20.0)** | ✅ **完全正常**:握手成功、连接、51 项能力 |

× 结论:`nodeagent-win-x64.zip` 不能在 ARM Windows 上实用;ARM 上需 ARM64 Node。

## 验证结果
- **A 组(verify.mjs 17 项)**:✅ 15 通过 / 0 失败 / 1 跳过(29.5s)
- **B1 v1.5 四项**:✅ 全过 —— 字符串热键 / 预设别名(`copy→ctrl+c` 映射正确)/ 长按 /
  后台定向投递(**explorer 命中 138 窗口**、WindowsTerminal 10 窗口)
  → **v1.5.0「未经真机就发版」的债,至此还清**
- **B4 锚点端到端**:✅ collect(62 条) → 上传云盘 → verify 判定「链已增长」+ 精确明细
- B2/B3:⏸ 阻塞(需 restart,而计划任务启动失败 —— 手工跑却完全正常)

## 本轮真机抓到的 2 个真 bug(都已修)
1. `control.ps1:100` 的 `$scheme:` 被 PowerShell 当 drive 引用 → **解析期错误 → 整个脚本不可用**
   (stop/start/status 全废);已修 + 加守卫(扫全部 .ps1 的 `$ident:`)
2. `anchor-audit.mjs` 调用**不存在的** `audit head` 子命令 → 改为 `invoke system.audit.head`
   (这种「能力存在但 CLI 无子命令」的错配,不真跑一次发现不了)

## 文档
`docs/VM-VERIFICATION.md` 补「实际验证结果」节 + 5 条新增排查项(安装/密钥/CLI 语法/计划任务)
2026-10-11 00:18:43 +08:00
jkooe c8aba83589 fix(control): control.ps1 的 $scheme: 被当 drive 引用 → 整个脚本不可用(真机踩到)
## 现象(Parallels Win11 ARM 真机)
`control.ps1 -Action stop/start/status` 一律报:
  所在位置 control.ps1:100 字符: 36
  +  Write-Host "  协议     : $scheme://0.0.0.0:$Port"
  变量引用无效。':' 后面的变量名称字符无效。

## 根因
PowerShell 把 `$scheme:` 解析为 **drive 引用**(如 `env:`),而 `scheme` 不是 drive → 语法错误。
且这是**解析期**错误 → **整个脚本无法运行**(不是只有那一行),stop/start/status 三个子命令全废。
修法:写成 `${scheme}://`(用花括号界定变量名)。

## 改动
- `scripts/control.ps1:100`:`$scheme://` → `${scheme}://`,并加注释说明为什么必须用花括号
  (注释里刻意**不写**危险字面量 —— 否则会被新守卫命中,这点在验证时真踩到了)
- 新增守卫(`tests/unit/security-hardening.test.mjs`):扫全部 `.ps1/.psm1`,
  禁止 `$ident:` 形式(白名单 `env`/`nuget`/`psitem`/`PSScriptRoot`)

## 验证
- 守卫 **9/9 绿**;全量单测 **274 绿**;ps1-encoding 保持通过(BOM 未被破坏)
- **注入故障实测**:把 `${scheme}` 回退成 `$scheme:` → 守卫**立即红**(失败数 1)→ 恢复后绿
  (按项目纪律:没验证过会红的守卫等于没有)
- 全仓重扫同类模式:**仅此 1 处**
2026-10-11 00:05:42 +08:00
jkooe 3b05f5b373 fix(docs): VM 标注插错位置 —— 移到 §九 台账的正确条目之后
上一提交的锚点匹配过宽(匹配到版本台账表里的「ci: 补全 Windows 真机验证」行),
导致标注插在了错误位置。现改为精确锚点「清理策略**真机验证**」,并复核只出现一次。
2026-10-10 23:39:48 +08:00
jkooe 7eb773099f docs(roadmap): 补上「VM 可补验证」标注(上一提交因文本未匹配而未生效) 2026-10-10 23:38:17 +08:00
jkooe ba57d99806 docs: 新增 VM 验证清单(Parallels 27 + Win11 ARM)—— 真机不可用时的替代路径
## 背景
Windows 被控端离线导致 4 项验证卡住(v1.5 四项 / v23 三防护 / v26 清理 / 锚点端到端)。
Parallels Desktop 27 可跑 **Windows 11 ARM** 虚拟机(Apple silicon 只支持 ARM 版 Windows,
x86_64 仅 Pro/Business 的早期预览),可提前跑掉**架构无关**的验证项。

## 清单内容(docs/VM-VERIFICATION.md)
- **能补 / 不能补的矩阵**:协议层与输入层可验;**杀软交互、显示/DPI、性能指标不可替代**
- 前置准备:桥接网络(否则 UDP 广播发现测不了)、优先 ARM64 Node、run-portable/install 二选一
- **逐条命令**(均已对着源码核实参数):
  - A 基础验收:直接复用既有 `scripts/verify.mjs`(17 项 + Markdown 报告)
  - B1 v1.5 四项:`key press "ctrl+shift+esc"` / `copy` / `--hold 800` / `--route post --pid N`
    (对应 args 的 hotkey / preset / hold_ms / route+target_pid 四个分支)
  - B2 v23 三防护:`agent.json` 的 `security.auth_ban` 与 `idle_timeout_ms` + 三场景判定
  - B3 v26 清理:`restart` 后 `schtasks /query`,**重点看"当前宿主任务必须仍在"**(否则即误杀)
  - B4 锚点端到端:`anchor-audit.mjs collect` → 云盘 → `verify`
  - B5 额外结论:**win-x64 包在 ARM Windows 上能否跑**(走 Prism;跑通则写进 README)
- **结论标注规则(强制)**:VM 验过的写「VM 已验」;杀软/显示/性能**不得**因 VM 通过就标已验;
  并注明「VM 与真机架构不同(真机 x86_64),VM 通过不自动推出真机通过」
- **已知 VM 环境差异表**(排查先看):广播发现需桥接、坐标为逻辑验证、性能数字不可采信等

README 文档区与 ROADMAP §九 均已链入。
2026-10-10 23:38:02 +08:00
jkooe 28ce95f045 feat(audit): 审计锚点外部化落地 —— 判定逻辑收敛为 protocol 纯函数 + 控制端工具 + 云盘链路
## 为什么(一句话)
锚点的安全性完全来自「它在攻击者碰不到的地方」。与被审计日志同机的锚点等于没锚:
被控端一旦被 root,重写日志后顺手改掉锚点即可。

## 本次改动

### ① 判定逻辑收敛(消除「两份真相」)
把 `compareWithAnchors` 的三条判定抽到 **`packages/protocol/src/audit-anchor.ts`** 的
**纯函数 `judgeAnchors(head, anchors)`**(无 IO,不知路径):
- agent 端 `compareWithAnchors` 改为「读文件 + 调 judgeAnchors」
- 控制端脚本同样调用它
→ 同一份日志不可能出现「被控端说一致、控制端说被改」的分歧
- 附带 `parseAnchorsFromJsonl`(坏行跳过,一行坏数据不毁整份锚点)
- agent 侧类型改为 re-export,既有导入路径不变(**9 个原有锚点单测全绿,行为零变化**)

### ② 控制端工具 `scripts/anchor-audit.mjs`
- `collect`:拉链头 → 生成一行锚点 JSONL(并打印 `ANCHOR_JSON=` 便于 automation 取用)
- `verify`:读锚点文件(可从云盘下载)+ 拉链头 → 判定,`ok:false` 时**退出码 2** + 醒目告警
- 走 CLI 而非自己实现连接(复用其多设备配置/证书钉住/daemon,避免又一份实现)
- 支持 `--head-file`:**离线也能验证判定链路**(本轮即用它做了 fixture 验证)

### ③ 云盘链路
- 建 `…/Project/NodeAgent/anchors/`(与全量备份分开目录)
- 每日 automation 更新为「拉链头 → 上传云盘(一天一个带日期的文件)→ 简报」,
  并规定每月首日额外做一次「下载历史锚点 → 链外比对」

## 验证
- **纯函数重构零行为变化**:原有 audit-anchor 单测 9/9 全绿;全仓 273 单测全绿;7 包构建
- **工具离线验证**(fixture):一致 → 「与锚点完全一致」退出码 0;
  篡改 → 「链头哈希与锚点不一致」退出码 2;三个错误分支均给出明确提示
- 云盘:`anchors/` 目录创建成功(1/1 步),目录内容已复核
- ⚠️ 端到端(真拉链头 + 真上传)**待被控端上线**;脚本的 CLI 分支已做错误处理验证
2026-10-10 19:14:09 +08:00
jkooe 2fe717cb62 fix(ci): 修 clippy 的两个错误(含一个真实并发问题)+ 本地补齐 lint 验证
## CI 失败的真实原因(上一轮我误判为 resources,实为 clippy)
`-D warnings` 下两个错误:
1. **`clippy::await_holding_lock`(sidecar.rs,真问题)**:
   `if let Some(child) = self.child.lock().unwrap().take() { child.kill().await }`
   —— MutexGuard 存活到 if 块结束,**跨 await 持有 std Mutex**:不仅触发 lint,
   真会阻塞 executor。已改为「先取出 child(锁随即释放),再 await kill」。
2. `clippy::doc_lazy_continuation`(lib.rs 文档注释):列表与后续段落之间缺空行。

## 我的验证方式错在哪(已修正)
上一轮我本地只跑 `cargo test`,**没跑 clippy** —— 而 CI 是 `clippy -D warnings` + `test`
两道闸。本地与 CI **标准不一致**,于是"本地绿、CI 红"。
本轮起:**本地按 CI 的完整命令集验证**(clippy --all-targets -- -D warnings → cargo test)。

## 验证
- `cargo clippy --all-targets -- -D warnings` → **零警告**
- `cargo test` → **3/3 通过**
2026-10-10 15:33:11 +08:00
jkooe 9bba53d5c0 fix(ci): 修 Rust Shell 工作流 —— resources 只声明构建期必产出的文件
CI 失败根因(本地已复现确认):`tauri-build`(build.rs) 会在**编译期校验**
`tauri.conf.json` 的 `bundle.resources` 声明文件是否存在。
我之前声明了两项:`sidecar.mjs`(CI 未生成)+ `resources/node`(Node 运行时未下载)
→ build.rs 直接失败("failed to run custom build command")。

修复:
- `tauri.conf.json` 的 resources **只留 sidecar.mjs**,并写明为何不能声明未就绪的文件
- `rust.yml` 在 clippy/test **之前**加「构建 sidecar」步骤(`pnpm --filter nodeagent-console sidecar:build`)
- console-plan.md:⑤ 不再把 node 记为已完成(未验证的不写成做完),
  补「已知待办」(Node 运行时随包分发 + GUI 端到端人工验证)与「构建约束」说明

本地复现验证:删掉 `sidecar/dist/` → `cargo check` 复现同一错误;恢复 → 通过 ✓
2026-10-10 15:25:12 +08:00
jkooe 53bf5a0634 feat(console)!: Rust 壳改用 Node sidecar(选项 D 完成)—— 退役 crates/nodeagent-client
## 动机(消除「两套协议实现」)
原设计让 Rust 壳自己实现 client,与 TS 版(@nodeagent/client)并存 → 改一处漏一处时
构建过、单测过,只在真机暴露。现 Rust 壳只做**进程管理 + 消息转发**,协议实现只剩 TS 一份。

## ④ Rust 壳改造
- 新增 `src-tauri/src/sidecar.rs`(~290 行):Sidecar 管理器 —— spawn node、
  行分隔 JSON-RPC 的请求/响应匹配(oneshot + 超时兜底)、通知回调(state/log/agent_event)、
  `resolve_node`/`resolve_sidecar` 路径解析(env → Tauri resource → 开发期系统 node)
- 重写 `lib.rs` 为薄转发层:`ensure_sidecar` 懒启动;**5 个 command 签名与 3 个 event 名
  (na-state/na-log/na-event)一律不变 → 前端零改动**;`get_capabilities`/`get_state`
  读本地缓存以**保持同步签名**;`RunEvent::Exit` 时优雅 shutdown(kill_on_drop 兜底不留孤儿)
- `Cargo.toml`:移除 nodeagent-client 依赖、加 tokio;**版本 0.1.0 → 2.0.0**
  (原先与 tauri.conf.json 的 2.0.0 不一致,属版本号统一范围)

## ⑤ 打包配置
- `tauri.conf.json`:`bundle.resources` 声明 sidecar.mjs 与 node;
  `beforeBuildCommand` 串上 `sidecar:build`(否则 resource 缺失)

## ⑥ 退役
- 删除整个 `crates/`(12 文件,含 nodeagent-client 与其 probe 示例)
- `rust.yml` 由「Rust client 单测」改为「**Rust 壳单测(真跑 sidecar)**」:
  装 Tauri 系统依赖 + Node + pnpm,跑 clippy `-D warnings` 与 `cargo test`
- `package.json`:`test:rust` 改指 console 壳;移除 `probe`
  (其「对真机逐项核对字段」的用途已由 CLI `nodeagent invoke` 覆盖)
- 引用清理:README(数字与协议客户端说明)、console 前端两处注释、
  console-plan(开头加「原方案已被取代」提示 + 补改造详情)、ROADMAP 历史描述加退役批注

## 验证
- **`cargo test` 3/3 通过**(真跑 Node sidecar 子进程:spawn/请求-响应/错误不崩)——
  这是关键:sidecar 管理器**不依赖 Tauri GUI**,故核心逻辑可在 CI 真实验证,不必等人工开窗口
- `cargo check` 零警告;clippy 标准与 CI 一致
- 273 单测 / 7 包构建 全绿
- ⚠️ **仍需人工验证**:Tauri GUI 端到端(开窗口 → 连被控端 → 点界面),沙箱跑不了图形应用
2026-10-10 15:16:24 +08:00
jkooe 096193a8ec feat(console): 采纳 sidecar 方案(选项 D 第一步)—— Node sidecar 实现 + 8/8 真机 e2e
## 为什么
原设计让 Rust 壳自己实现一份 client(crates/nodeagent-client),代价是**两套协议实现静默漂移**
(改一处漏一处时构建/单测都不报错,只在真机暴露)。sidecar 让 Rust 壳退化为
「进程管理 + 消息转发」,协议实现只剩 TS 一份,从根上消除漂移。

## 本次交付
1. `apps/console/sidecar/main.mjs`(190 行):把 `@nodeagent/client` 暴露成
   **JSON-RPC 2.0 over stdio**(行分隔)。方法 connect/disconnect/invoke/state;
   onStateChange/onLog/onEvent 三个回调 → `event` 通知(kind=state/log/agent_event)
2. `apps/console/sidecar/build.mjs`:esbuild **JS API** 打成单文件(避开 pnpm `.bin` 的
   ENOENT 坑),并注入 `createRequire` shim —— 否则 `ws` 的动态 require 会报
   "Dynamic require of events is not supported"
3. `apps/console/package.json`:加 `@nodeagent/client`/`protocol` 依赖 + sidecar:dev/build 脚本
4. `docs/console-plan.md`:补「实施决策」节(决策理由、协议定义、进度表、打包方向)

## 两条协议纪律(写进注释与文档)
- **stdout 只写协议消息**,日志一律 stderr(混入非 JSON 行会让 Rust 侧解析失败)
- 任何异常都转成响应,**绝不因单个请求崩进程**

## 错误形态归一(实测中修正)
被控端对未知能力/ACL 拒绝/超时是回 JSON-RPC error → `client.invoke()` 抛异常。
若原样上抛前端要处理两种形态,故归一为 `{status:'failed',error:{name,message,data?}}`;
而「未连接/缺参数」仍走协议错误(语义不同)。

## 验证(真实链路,非 mock)
本地 agent → sidecar **8/8 全绿**:connect 回传 51 项能力与 agent_version ✓ ·
invoke system.info ✓ · state 反映已连接 ✓ · 事件通知已推送 ✓ ·
非法能力返回 failed 且不崩 ✓ · disconnect ✓ · **stdout 无日志污染** ✓
打包产物拷到仓库外**独立运行通过**(227KB 单文件自足)。

## 待办(下一批)
Rust 壳改造(command 签名不变 → 前端零改动)· tauri.conf.json 的 resource 配置 +
Node 运行时打包 · 退役 crates/nodeagent-client
2026-10-10 15:11:39 +08:00
jkooe e23788d717 refactor(versioning): 统一版本号 —— semver 为唯一版本号,开发批次代号并入其归属版本
## 问题(皇上指出)
项目里两套编号并行,无法判断哪个是"当前版本":
- **发布版本**(semver):v1.1.0 ~ v1.6.0、v2.0.0
- **开发批次代号**:v1 ~ v26(README 路线表 / 代码注释 / 文档)
时间线证据:v1.1.0 首发(09-29)时批次已推进到 v12.4 —— **二者从来不是一对一**(26 批次只发过 10 版)。

## 规则(新增 docs/VERSIONING.md §0 版本号唯一性规范)
- **唯一版本号 = semver `vX.Y.Z`**,两个来源必须永远一致:git tag 与 package.json(构建期注入 agent)
- **批次代号不是版本号**,不再单独出现;需要区分同版内多批改动时用**功能名**
- 附「批次 → 归属发布版本」映射表(按 tag 时间线与功能内容推定,并注明以 CHANGELOG 为准)

## 统一动作
1. **批次 v23 ~ v26 → v2.0.0**(22 文件 / 53 处)—— 它们确实属于 v2.0.0
2. **臣的批次号 v1.7 / v1.8 → v2.0.0**(27 文件)—— 未单独发版
3. **简写补全三段式**:v1.5 → v1.5.0、v1.6 → v1.6.0
4. **README 路线表**:`**v1**` → `**批次 1**`;表头加「批次 ≠ 版本号」说明并链到 §0;
   补齐批次 16~29 与 **v2.0.0 发布行**;删掉过时的「待办」行(提到的分支早已删除)
5. `docs/VERSIONING.md` 里**故意保留** 3 处批次号 —— 那是规范说明本身

## 保留项(真实发布版本,不动)
v1.1.0 / v1.2.1 / v1.3.0 / v1.4.0~v1.4.3 / v1.5.0 / v1.6.0 / v2.0.0(10 个 tag)

## 验证
- 版本号一致性:package.json = tag = 8 个 workspace = tauri.conf.json = **2.0.0** ✓
- v1.7/v1.8/v23~v26 全仓残留 **3 处**(均在 VERSIONING.md 规范说明中,属预期)
- 7 包构建 · 单测 273 · e2e 30 · ps1-encoding 24 全绿
2026-10-10 14:27:12 +08:00
jkooe 5b69d6cec9 refactor: 项目结构与命名规范化(重写前的最后一次常规提交)
## 改名
`scripts/run-temp.cmd` → `scripts/run-portable.cmd`
理由:它是**正式功能**(免安装、免管理员的便携启动方案),"temp" 易被误认成临时文件。
程序内文案同步:TEMPORARY launcher → PORTABLE launcher;
打包产物 README 的"【临时试用,不装也行】" → "【免安装试用,不装也行】"。

## 移动
`PRD.md` → `docs/PRD.md`
理由:文档统一放 docs/(README/CHANGELOG 按惯例留在根)。

## 同步的引用(共 11 处)
- scripts/pack.mjs:产物文件名校验 + 生成 README 的 3 处指引文案
- scripts/run-portable.cmd:自身用法注释 6 处
- README.md:目录树 + 文档链接
- docs/DEVELOPMENT.md:配套文档链接(`../PRD.md` → `PRD.md`,现已同目录)+ 目录树

## 验证
- 全仓 `run-temp` 残留 **0**;`./PRD.md`/`../PRD.md` 旧路径残留 **0**
- 7 包构建通过;单测 273 全绿;ps1-encoding 24 全绿
- `pack.mjs` 实跑通过,`run-portable.cmd` 已正确进入 Windows 产物
- `.cmd` 保持 ASCII + CRLF(94 行);PRD.md 无内部相对链接需调整
2026-10-10 13:39:42 +08:00
jkooe 11127b1a89 chore(privacy): 杀软实名与网卡名示例泛化(安全报告第十三轮 P2 两项)
## 甲1 杀软实名泛化(6 行)
`主流杀软 / 杀软 / 360` → 「主流杀毒软件 / 杀软」:
- docs/REMOTE-LIMITS.md:标题 + 正文 + 表格两处(保留"会拦什么"的实操信息,去掉具体厂牌)
- scripts/release.mjs:278:Release 正文模板里的信任区提示
- apps/cli/src/cmd/ops.ts:398:部署失败排查提示

## 甲2 网卡名示例泛化(13 行)
「Ethernet」→ `Ethernet`(示例)/泛化为"中文网卡名"(讲原理时):
- docs/REMOTE-LIMITS.md 网络适配器表格、ROADMAP 该项标记为已完成
- scripts/rescue-network.ps1 参数说明、automation.ts 的 interface 描述
- apps/agent/src/capabilities/network.ts 注释:改为"脚本里的非 ASCII 字符(如中文网卡名)"
  —— **保留"为什么"的教训,去掉具体实况**
- tests/unit/net-scripts.test.mjs:8 处测试数据与正则断言同步改为 Ethernet

## 验证
工作区残留「主流杀软/杀软/Ethernet」= **0 处**;单测 273 全绿;7 包构建通过。
2026-10-10 12:26:41 +08:00
jkooe d9e2103be7 docs(roadmap): 同步 v2.0.0 已发布状态
- 头部基线:v2.0.0(2026-10-10 / a515eb2),单测 273,能力 51,CI 双工作流全绿
- §九 台账补「发版 v2.0.0」行 + 「v1.5 四项与 v26 清理策略真机验证仍待补」的显式标记
2026-10-10 12:18:28 +08:00
88 changed files with 2041 additions and 3959 deletions

No files matched your search

+46 -22
View File
@@ -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
View File
@@ -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),成功即清零,支持白名单豁免
+39 -32
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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);
}
+5 -5
View File
@@ -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 (;;) {
+3 -3
View File
@@ -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 成功指标
+4 -4
View File
@@ -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,
+5 -5
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
/**
* log.query(v1.8,方向二「状态」第一块)。
* log.query(v2.0.0,方向二「状态」第一块)。
*
* ## 为什么必须有
* 治间歇性问题(代理不通、杀软拦截、端口时开时闭)要看**历史**,而被控端日志常在
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* monitor.*(v1.8,方向二「状态」第二块)—— 定时采样落盘与回看。
* monitor.*(v2.0.0,方向二「状态」第二块)—— 定时采样落盘与回看。
*
* ## 为什么需要(复盘 §四)
* 代理不通、杀软拦截、端口时开时闭都是**间歇性**的,而 `system.status` 只能看当下快照。
+1 -1
View File
@@ -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 脚本一律经此函数。
*/
+12 -5
View File
@@ -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)。
*
* 为什么是追加而不是覆盖:历史锚点一旦写成就不可被后续覆盖,否则攻击者重写链后
* 再"刷新"锚点即可抹掉痕迹。追加式让每次锚定都留痕。
+1 -1
View File
@@ -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"。
+4 -4
View File
@@ -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 {
+1 -1
View File
@@ -43,7 +43,7 @@ export interface AgentConfig {
allow_from?: string[];
/**
* v23 连接层防护(三项,默认值见 server.ts)。
* v2.0.0 连接层防护(三项,默认值见 server.ts)。
* 单项缺省即启用默认策略;显式设 0 可关闭对应项。
*/
security?: {
+1 -1
View File
@@ -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
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/cli",
"version": "2.0.0",
"version": "2.0.1",
"private": true,
"type": "module",
"main": "./dist/index.js",
+4 -4
View File
@@ -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) {
+3 -3
View File
@@ -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]');
+1 -1
View File
@@ -183,7 +183,7 @@ export async function cmdRestart(opts: Options): Promise<void> {
);
}
// ---------- v25 审计链外部锚定 ----------
// ---------- v2.0.0 审计链外部锚定 ----------
/**
* 打印审计链头(可选与最近锚点比对)。
+3 -3
View File
@@ -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]
+6 -6
View File
@@ -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 */
+7 -3
View File
@@ -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",
+52
View File
@@ -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`);
+210
View File
@@ -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})`);
+17 -545
View File
@@ -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"
+4 -2
View File
@@ -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
+142 -77
View File
@@ -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;
});
}
}
});
}
+315
View File
@@ -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;
}
}
+6 -3
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
/** 协议类型镜像(对齐 Rust 侧 `crates/nodeagent-client` 与 Tauri command 的序列化形状)。 */
/** 协议类型镜像(对齐 `@nodeagent/protocol`,亦即 sidecar 与 Tauri command 的序列化形状)。 */
/** 能力描述(被控端声明的能力清单条目)。 */
export interface CapabilityDescriptor {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/hub",
"version": "2.0.0",
"version": "2.0.1",
"private": true,
"type": "module",
"main": "./dist/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/mcp",
"version": "2.0.0",
"version": "2.0.1",
"private": true,
"type": "module",
"main": "./dist/index.js",
+1 -1
View File
@@ -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);
+5 -5
View File
@@ -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: {
-1185
View File
File diff suppressed because it is too large. Load diff
-5
View File
@@ -1,5 +0,0 @@
[workspace]
resolver = "2"
members = ["nodeagent-client"]
[workspace.dependencies]
-23
View File
@@ -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(())
}
-188
View File
@@ -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(())
}
-628
View File
@@ -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;
}
}
}
-131
View File
@@ -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"
);
}
}
-145
View File
@@ -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)
}
-96
View File
@@ -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 {}
-34
View File
@@ -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>();
}
}
-119
View File
@@ -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()
}
}
-212
View File
@@ -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>,
}
-72
View File
@@ -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);
}
}
-136
View File
@@ -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
View File
@@ -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/)
```
| 包 | 职责 | 关键文件 |
View File
File renamed without changes.
+5 -5
View File
@@ -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 上跑,确认三件事:任务在不在、进程活不活、端口听没听):
+4 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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. 应急处置
+44
View File
@@ -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。
+231
View File
@@ -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`** |
+113
View File
@@ -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
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/client",
"version": "2.0.0",
"version": "2.0.1",
"private": true,
"type": "module",
"main": "./dist/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/protocol",
"version": "2.0.0",
"version": "2.0.1",
"private": true,
"type": "module",
"main": "./dist/index.js",
+131
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
/**
* 快捷键(hotkey)解析与预设(v1.5)。
* 快捷键(hotkey)解析与预设(v1.5.0)。
*
* ## 为什么放在 protocol 包
* 解析逻辑必须**两端一致 + 可单测**:控制端 CLI 先把字符串展开成和弦列表用于本地校验,
+1
View File
@@ -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';
+6
View File
@@ -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
+163
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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'),
]);
+1 -1
View File
@@ -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
+155
View File
@@ -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);
}
+1 -1
View File
@@ -13,7 +13,7 @@ import {
} from '../../apps/agent/dist/audit.js';
/**
* v25 审计链外部锚定的逻辑测试。
* v2.0.0 审计链外部锚定的逻辑测试。
*
* 守的是三件最容易写错的事:
* 1. 只记 head_hash 会把**轮转**误判成篡改(必须连 entries + rotated_segments 一起记)
+2 -2
View File
@@ -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);
+3 -3
View File
@@ -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 应通过');
+1 -1
View File
@@ -5,7 +5,7 @@ import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
/**
* 零信任优先安装(v24)的**源码断言**。
* 零信任优先安装(v2.0.0)的**源码断言**。
*
* 为什么用源码断言:install.ps1 是 5.1 脚本,Node 侧无法直接执行它;
* 而这里要守的是「关键决策逻辑不许被误删」—— 一旦有人重构掉 acl 写入或
+1 -1
View File
@@ -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 方向二第一块)。
* 文件级行为(流式读取/白名单)用真实临时文件验证,无需被控端进程。
*/
+1 -1
View File
@@ -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 落盘。
*/
+33 -4
View File
@@ -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')}`);
});