Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f4d8913b5 | ||
|
|
f4ae20969a | ||
|
|
7d13a48caa | ||
|
|
40b7db16c1 | ||
|
|
765ad08ee6 | ||
|
|
fbcb8f8408 | ||
|
|
ab7a2b147b | ||
|
|
047cc9efd2 | ||
|
|
d8f21f4e68 | ||
|
|
54f7f4c7ab | ||
|
|
c8aba83589 | ||
|
|
3b05f5b373 | ||
|
|
7eb773099f | ||
|
|
ba57d99806 | ||
|
|
28ce95f045 | ||
|
|
2fe717cb62 | ||
|
|
9bba53d5c0 | ||
|
|
53bf5a0634 | ||
|
|
096193a8ec | ||
|
|
e23788d717 | ||
|
|
5b69d6cec9 | ||
|
|
11127b1a89 | ||
|
|
d9e2103be7 | ||
|
|
a515eb2e63 | ||
|
|
3fcfc7d12d | ||
|
|
865862ee7c | ||
|
|
690f38207c | ||
|
|
2e6356c7f4 | ||
|
|
01e711be77 | ||
|
|
2cb0ed148e | ||
|
|
74d878a1c5 | ||
|
|
dfd3b2349e | ||
|
|
32e0dd7bd5 | ||
|
|
68546e63e8 | ||
|
|
bec07fc277 | ||
|
|
8b97fc6106 | ||
|
|
7e63963e4f | ||
|
|
28d590a4e5 | ||
|
|
982eb9b1fa | ||
|
|
933863529a | ||
|
|
4cb8ad566b | ||
|
|
7c6a68268b | ||
|
|
2e14f1f451 | ||
|
|
8c41414e04 | ||
|
|
921ff89edf | ||
|
|
9665edf35b | ||
|
|
277c5f1ba5 | ||
|
|
24b518c663 | ||
|
|
d065242413 | ||
|
|
e7da258d17 | ||
|
|
0a5fa715d4 | ||
|
|
dac8a0a2a7 | ||
|
|
8ab8f8ae80 | ||
|
|
9efd290061 | ||
|
|
4140b8e32c | ||
|
|
6b2b3828a5 | ||
|
|
b809aaa92e | ||
|
|
9ee75cf694 | ||
|
|
cd278ffda8 | ||
|
|
817ec3645b | ||
|
|
25f889c6d8 | ||
|
|
af1cd39fdf |
No files matched your search
@@ -0,0 +1,78 @@
|
||||
name: Rust Shell
|
||||
|
||||
# 2026-10:原 crates/nodeagent-client(Rust 版协议客户端)已退役 ——
|
||||
# 协议实现改由 Node sidecar 承担(apps/console/sidecar/),避免两套实现静默漂移。
|
||||
# 本工作流随之改为验证 **console 的 Rust 壳**(sidecar 管理 + command 转发)。
|
||||
#
|
||||
# 它的单测是**真跑** Node sidecar 子进程的(见 apps/console/src-tauri/src/sidecar.rs),
|
||||
# 不依赖 Tauri GUI,因此能在 Linux 快车道上跑。
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/console/src-tauri/**'
|
||||
- 'apps/console/sidecar/**'
|
||||
- '.github/workflows/rust.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'apps/console/src-tauri/**'
|
||||
- 'apps/console/sidecar/**'
|
||||
- '.github/workflows/rust.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Rust 壳单测(真跑 sidecar)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v5
|
||||
|
||||
# 版本**锁死**而不跟 stable 漂 —— 否则「本地全绿」与「CI 绿」会因 lint 集合随工具链
|
||||
# 变化而分叉(本仓库已因同类原因吃过 8 轮 Windows CI 的苦头)。
|
||||
- name: 安装 Rust 工具链(含 clippy)
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
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: apps/console/src-tauri
|
||||
|
||||
- name: Clippy(告警即失败)
|
||||
run: cargo clippy --manifest-path apps/console/src-tauri/Cargo.toml --all-targets -- -D warnings
|
||||
|
||||
- name: 单测(真跑 Node sidecar 进程)
|
||||
run: cargo test --manifest-path apps/console/src-tauri/Cargo.toml
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: 安装 pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
@@ -48,6 +48,10 @@ jobs:
|
||||
|
||||
- name: 类型检查
|
||||
run: pnpm typecheck
|
||||
- name: 单元测试 — 协议纯函数 / 清单守护 / 脚本生成
|
||||
# 来自 ci/workflow-unit-tests 分支(4d73d6e)的实质贡献;该分支基于老 main,
|
||||
# 直接 merge 会回退上万行,故只取其提交内容手工并入。
|
||||
run: node --test "tests/unit/**/*.test.mjs"
|
||||
|
||||
- name: E2E — 协议与跨平台能力
|
||||
run: node tests/e2e/run.mjs
|
||||
@@ -55,25 +59,88 @@ jobs:
|
||||
- name: E2E — Windows 专属能力(服务 / 软件 / winget)
|
||||
run: node tests/e2e/windows.mjs
|
||||
|
||||
# ⚠️ 这一步刻意用 **powershell(Windows PowerShell 5.1)** 而非 pwsh。
|
||||
# 5.1 读无 BOM 的 .ps1 会按 ANSI/GBK 解析,中文注释会破坏字符串边界 →
|
||||
# 整份脚本 ParseError(真机 2026-10-04 踩过:install.ps1 报「","后面缺少表达式」)。
|
||||
# 之前的冒烟全用 pwsh(7+ 认无 BOM UTF-8),所以 CI 绿、真机炸。
|
||||
# 这里只做「解析 + 首行 help」检查:5.1 能否读懂,就是真机的核心约束。
|
||||
# ⚠️ 必须用 shell: powershell(runner 上的 powershell 即 Windows PowerShell 5.1)。
|
||||
# 不要用 shell: cmd 再套 powershell -Command "..." —— 多行带引号经 cmd 转义会全乱
|
||||
# (实测:Missing closing '}' / '$errs' is not recognized,是步骤自身坏,不是脚本坏)。
|
||||
- name: 冒烟 — 脚本可被 Windows PowerShell 5.1 解析(BOM 守卫)
|
||||
shell: powershell
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
foreach ($f in @('scripts\install.ps1', 'scripts\uninstall.ps1', 'scripts\rescue-network.ps1')) {
|
||||
$errs = $null
|
||||
[void][System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path $f).Path, [ref]$null, [ref]$errs)
|
||||
if ($errs -and $errs.Count -gt 0) { throw ('5.1 parse FAILED: ' + $f + ' -> ' + $errs[0].Message) }
|
||||
Write-Host ('OK 5.1 parse: ' + $f)
|
||||
}
|
||||
|
||||
- name: 冒烟 — install.ps1 安装链路
|
||||
shell: pwsh
|
||||
shell: powershell
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
./scripts/install.ps1 -Port 18769 -NodeId ci_win
|
||||
|
||||
$task = Get-ScheduledTask -TaskName "nodeagent" -ErrorAction Stop
|
||||
Write-Host "计划任务状态: $($task.State)"
|
||||
Write-Host "Scheduled task state: $($task.State)"
|
||||
|
||||
$cfg = Get-Content "$env:USERPROFILE\.nodeagent\agent.json" -Raw | ConvertFrom-Json
|
||||
if ($cfg.port -ne 18769) { throw "端口配置错误: $($cfg.port)" }
|
||||
if (-not $cfg.key) { throw "预共享密钥缺失" }
|
||||
if (-not $cfg.tls) { throw "TLS 默认应开启" }
|
||||
if ($cfg.port -ne 18769) { throw "port mismatch: $($cfg.port)" }
|
||||
if (-not $cfg.key) { throw "PSK missing in agent.json" }
|
||||
if (-not $cfg.tls) { throw "TLS should be on by default" }
|
||||
|
||||
$rule = Get-NetFirewallRule -DisplayName "nodeagent (TCP 18769)" -ErrorAction Stop
|
||||
if (-not $rule) { throw "防火墙规则未创建" }
|
||||
if (-not $rule) { throw "Firewall rule" }
|
||||
|
||||
Write-Host "OK — 计划任务 / 配置 / 防火墙均已就位"
|
||||
Write-Host "OK scheduled task / config / firewallall in place"
|
||||
|
||||
# 一键入口冒烟:真跑 install.cmd / control.cmd。
|
||||
# 目的不是「再装一遍」,而是证明双击路径可用 —— 这些脚本是用户唯一的
|
||||
# 上手入口,语法或编码一旦坏了,只有真机 cmd.exe 才测得出来。
|
||||
# 端口用 18770 避开前一步的 18770 之外的安装,便于分辨是哪一步装的。
|
||||
- name: 冒烟 — install.cmd 双击链路(含自提权与 PSK 读取)
|
||||
shell: cmd
|
||||
run: call scripts\install.cmd 18770
|
||||
env:
|
||||
CI: "true"
|
||||
- name: 冒烟 — control.cmd 状态查询
|
||||
shell: cmd
|
||||
run: scripts\control.cmd 18770 < nul
|
||||
env:
|
||||
CI: "true"
|
||||
- name: 冒烟 — 产物包结构与编码
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
pnpm pack:win
|
||||
$zip = "release/nodeagent-win-x64.zip"
|
||||
if (-not (Test-Path $zip)) { throw "zip not produced: $zip" }
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
$z = [IO.Compression.ZipFile]::OpenRead((Resolve-Path $zip))
|
||||
try {
|
||||
$names = $z.Entries.Name
|
||||
# 必须随包分发
|
||||
foreach ($need in @('install.cmd','control.cmd','install.ps1','control.ps1','README.txt','agent.mjs','node.exe')) {
|
||||
if ($names -notcontains $need) { throw "missing in package: $need" }
|
||||
}
|
||||
# 必须**不**在包内:PSK.txt 是现生成给本机的,打进公开资产等于泄露密钥
|
||||
# (eaca9e0 起已从 zip 剔除;此处反向断言守住该性质)
|
||||
foreach ($forbidden in @('PSK.txt','*.key','*.pem','certs')) {
|
||||
if ($names -contains $forbidden) { throw "package must NOT contain: $forbidden" }
|
||||
}
|
||||
if ($names -match 'PSK\.txt') { throw "package leaks PSK.txt" }
|
||||
foreach ($e in $z.Entries) {
|
||||
$s = $e.Open(); $buf = New-Object byte[] 3
|
||||
[void]$s.Read($buf, 0, 3); $s.Close()
|
||||
$hasBom = ($buf[0] -eq 0xEF -and $buf[1] -eq 0xBB -and $buf[2] -eq 0xBF)
|
||||
if ($e.Name -like '*.ps1' -and -not $hasBom) { throw "$($e.Name) lost its BOM in package" }
|
||||
if ($e.Name -like '*.cmd' -and $hasBom) { throw "$($e.Name) must not have a BOM" }
|
||||
}
|
||||
Write-Host "OK - package complete; .ps1 has BOM / .cmd has none; no key shipped"
|
||||
} finally { $z.Dispose() }
|
||||
- name: 冒烟 — 被控端监听
|
||||
shell: pwsh
|
||||
run: |
|
||||
@@ -108,7 +175,9 @@ jobs:
|
||||
|
||||
- name: 清理
|
||||
if: always()
|
||||
shell: pwsh
|
||||
# 清理失败不掩盖真实结论(前置挂了会连带这里报错)
|
||||
continue-on-error: true
|
||||
shell: powershell
|
||||
run: |
|
||||
Get-Process node -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
./scripts/uninstall.ps1 -Port 18769 -Purge
|
||||
@@ -41,9 +41,16 @@ release/
|
||||
../win-backup/
|
||||
win-backup/
|
||||
|
||||
# ---- 在建工程(用户 WIP,暂不入库;2026-10-07 保护性添加)----
|
||||
# 背景:发版脚本的 add -A 曾把这些未跟踪文件误提交到公开仓库,已移出跟踪。
|
||||
# 这些是皇上正在开发的 Rust 客户端与控制台方案,待其评审后再决定是否入库。
|
||||
/crates/
|
||||
/docs/console-plan.md
|
||||
/docs/ROADMAP.md
|
||||
# Rust 构建产物
|
||||
# ⚠️ 2026-10-07:crates/ 此前整体被忽略,target/ 是「顺带」没进库的;crates/ 正式入库后
|
||||
# 必须显式忽略,否则 4GB 级的 cargo target 会被 add -A 卷进提交。
|
||||
/crates/target/
|
||||
|
||||
# 密钥材料(路线图 §6.2):被控端数据目录 keys/、ed25519/TLS 私钥
|
||||
keys/
|
||||
*.key
|
||||
*.pem
|
||||
!*.pub
|
||||
|
||||
# 本地 agent 工作数据(memory / automations 等,属本机状态,不入库)
|
||||
.workbuddy/
|
||||
@@ -0,0 +1,165 @@
|
||||
# 变更日志
|
||||
|
||||
本文件记录 nodeagent 的显著变更。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
|
||||
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
||||
|
||||
---
|
||||
|
||||
## [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。
|
||||
> 之所以直接跳 v2.0.0:两个新方向(语义可断言 / 状态可回看)+ 安全批次四连 + 五处
|
||||
> 只有真机能暴露的严重缺陷修复,量级远超一次 MINOR。
|
||||
>
|
||||
> **兼容性**:完全向后兼容 —— 未改任何既有能力的参数名或语义,未新增必填参数,
|
||||
> 返回结构只增不减(遵循 `docs/VERSIONING.md` 的四条铁律)。
|
||||
>
|
||||
> **验证状态**:本版发布时被控端整机离线,`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.0 / v2.0.0)
|
||||
|
||||
- **`gui.await`** —— 等条件成立再返回。四类条件(`window` / `control` / `process` / `file`)
|
||||
× 两种状态(`present` / `absent`),复用既有能力,零新增平台探测代码。
|
||||
超时是**正常返回**(`satisfied:false` + `note`),不抛异常;轮询期单次异常不视为失败。
|
||||
- **`screen.find` 返回 UIA 属性** —— `enabled` / `value` / `selected` / `toggle`。
|
||||
每项单独 try/catch,**拿不到就留 `null` 绝不猜**(Electron、游戏 UI 常给不出值)。
|
||||
- **`where` 属性谓词** —— 如 `{"enabled":true}`「等一个可用的按钮」,
|
||||
比「等一个叫某名字的按钮」更贴近真实意图。仅 UIA 引擎生效,
|
||||
OCR/图像引擎会忽略并**带回 `note`**,不让调用方误以为过滤过了。
|
||||
- **`any_of` 组合** —— 任一命中即算,命中时回报是哪个子条件。
|
||||
例:`[{window:"安装完成"},{control:"错误"}]` = 「装成功或报错,先出现的算」。
|
||||
- CLI:`nodeagent await`;MCP:`na_await`。
|
||||
|
||||
### 新增:状态可回看(v2.0.0)
|
||||
|
||||
- **`log.query`** —— 日志**在被控端侧过滤**后只回匹配行(`pattern` / `level` / `since` /
|
||||
`offset` / `limit` / `tail`)。流式逐行读,几万行日志也不整份拉回。
|
||||
- **`monitor.start` / `report` / `stop` / `list` / `delete`** —— 定时采样并落盘。
|
||||
四类源:`port`(连通性 + 延迟)/ `process`(存活 + PID)/ `command`(退出码 + 首行)/
|
||||
`metric`(CPU·内存)。**摘要直接给结论**:断了几次、最长断多久、值域。
|
||||
- CLI:`nodeagent monitor` / `log`;MCP:`na_monitor` / `na_log`。
|
||||
|
||||
### 新增:安全加固(v2.0.0)
|
||||
|
||||
- **连接层三防护**:
|
||||
- 握手失败封禁 —— 连续失败达阈值 → 指数退避(封顶 24h),成功即清零,支持白名单豁免
|
||||
- 连接上限(默认 8)+ 空闲断开(默认 30 分钟;有任务或订阅在跑时保守不踢)
|
||||
- 状态从 `WeakMap` 改 `Map`(需要 size 与遍历),关闭时手动清理
|
||||
- **安装期密钥改 stdin** —— `install.cmd` 用管道喂 `-KeyFromStdin`,密钥不再出现在命令行
|
||||
(同机其他用户无法从进程命令行读到)。
|
||||
- **零信任优先安装** —— 侦测到 `client-acl.json`(含控制端公钥)即走 `ed25519` 并写入
|
||||
`acl.default_effect="deny"`;否则保持 `psk` 但输出**醒目安全告警**(说明 `client_id`
|
||||
自报的固有风险 + 三步切换指引)。
|
||||
- **审计链外部锚定** —— `system.audit.head`(链头可读)+ `system.audit.anchor`(锚点追加落盘)。
|
||||
锚点记四元组 `{entries, head_hash, head_ts, rotated_segments}`:只记哈希会把**日志轮转
|
||||
误判成篡改**。锚点文件只追加 —— 可覆盖的话,攻击者重写链后再刷新锚点即可抹痕。
|
||||
|
||||
### 修复(五处只有真机能暴露的严重缺陷)
|
||||
|
||||
- **`install.cmd` 双击首装即炸** —— `for /f` 调 PowerShell 时 `^|` 转义漏进 PS,导致
|
||||
ParseError。**真实用户第一次安装必然踩到**(PSK.txt 已不在分发包内)。
|
||||
- **`pack.mjs` 三连** —— `spawn node_modules/.bin/esbuild` 在 pnpm 布局下 ENOENT;
|
||||
以及 `zip` / `ls -la` / `du -sh` 在 Windows 上都不存在。已改为 esbuild JS API +
|
||||
平台分流(Windows 用 `Compress-Archive`)+ Node 原生 API。
|
||||
- **`where` 的 PowerShell 注入语法错** —— 连修两轮:JSON 字面量 ✗ → `\"` 反斜杠转义 ✗
|
||||
(PS 5.1 不认)→ **PS 单引号字面量** ✓。症状是「带 where 一律窗口操作失败,不带则正常」。
|
||||
- **`screen.find` 的 where-only 被自己入口校验拦下** —— 只改了一半(`gui.await` 侧放开、
|
||||
`screen.find` 侧没动)。
|
||||
- **`parseLeadingTs` 吃掉 ISO 的 `Z` 时区标记** —— UTC 被当本地时间解析,**整体偏 8 小时**,
|
||||
跨时区日志判断「何时断的」会错(单测抓到)。
|
||||
|
||||
### 修复(其他)
|
||||
|
||||
- **自重启在非管理员身份下失败** —— `-RunLevel Highest` 被系统拒绝(v7 起的既有缺陷)。
|
||||
现改为两级降级:先试 Highest,失败则不指定 Principal(继承当前权限级别)。
|
||||
修复后 `deploy` 首次能一次走通完整闭环。
|
||||
- **`deploy` 备份无限堆积** —— `.bak-<时间戳>` → 固定名 `.bak` + 清理历史(真机 17 → 2 个文件)。
|
||||
- **自重启任务 Running 僵尸** —— 清理条件加「`LastRunTime` 早于 1 小时」余量
|
||||
(不能简单清所有 Running:那会连当前 agent 的宿主一起杀)。
|
||||
- Windows CI 四连:`URL.pathname` 跨平台路径、`shell: cmd` 套多行 PowerShell、
|
||||
5.1 执行的内联脚本含中文、产物包断言方向反了(`PSK.txt` 从「必含」改「不得含」)。
|
||||
|
||||
### 安全 / 隐私
|
||||
|
||||
- 源码与测试里的真机常量脱敏(机器名、内网地址)—— 保留「为什么」的教训,去掉具体实况。
|
||||
- 能力调用新增审计类型 `session.idle_close`。
|
||||
|
||||
### 文档
|
||||
|
||||
- `README` 能力总览补三行、数字同步至 51 项能力 / 41 个 MCP 工具 / 269 单测。
|
||||
- 新增章节:`gui.await` 属性谓词与组合、状态采样、审计链外部锚定。
|
||||
- `docs/SECURITY.md` 新增 §4.7(零信任优先安装)/ §4.8(审计锚定),
|
||||
§5「还没做的」清单**至此全部完成**。
|
||||
- `docs/DEVELOPMENT.md` 能力表补全(此前停在 19 项)。
|
||||
- `docs/RETROSPECTIVE.md` 加「短板已全部解决」对照表(保留原判断不改写)。
|
||||
- `docs/ROADMAP.md` 台账刷新,并新增「真机验证抓到的 8 个真 bug」表。
|
||||
|
||||
---
|
||||
|
||||
## [v1.6.0] —— 2026-10-07
|
||||
|
||||
### 新增
|
||||
|
||||
- `gui.await` 等待条件能力(第 43 项)。
|
||||
|
||||
### 说明
|
||||
|
||||
- 本版**未经真机验证**即发布(当时被控端离线);相关验证已于 2026-10-09 ~ 10-10 补齐。
|
||||
|
||||
## [v1.5.0] —— 2026-10-07
|
||||
|
||||
### 新增
|
||||
|
||||
- `input.key.press` 参数面扩展:字符串热键(`hotkey: "ctrl+shift+esc"`)、约 50 个预设别名、
|
||||
长按 `hold_ms`、后台定向投递 `route=post` + `target_pid`。
|
||||
- 按键表补齐:左右侧修饰键、小键盘(与数字键分开)、OEM 符号键(解锁 `win+d` / `ctrl++` /
|
||||
`win+.`)、IME 键、浏览器键、系统键。
|
||||
|
||||
### 说明
|
||||
|
||||
- 本版同样**未经真机验证**即发布;2026-10-10 已验证字符串热键与单键投递,其余项待被控端在线后补齐。
|
||||
@@ -30,8 +30,11 @@ nodeagent 把一台 Windows 机器的能力**标准化成一组可授权、可
|
||||
| **输入** | `input.mouse.move` · `input.mouse.click` · `input.mouse.scroll` · `input.mouse.drag` · `input.key.type` · `input.key.press` | 键鼠控制(🔒 **默认禁用**),含**拖拽**、**字符串热键 / 预设 / 长按 / 后台定向投递** |
|
||||
| **剪贴板** | `clip.get` · `clip.set` | 读写文本**或图片**(PNG Base64) |
|
||||
| **事件订阅** | `event.watch` · `event.unwatch` · `event.list` · `event.poll` | 文件变动 / 进程启停 / 端口开闭,**主动推送**(无需轮询) |
|
||||
| **GUI 等待** | `gui.await` | 等条件成立再返回(窗口/元素/进程/文件 × 出现/消失);支持**属性谓词 `where`** 与 **`any_of` 组合** |
|
||||
| **状态采样** | `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 种输入形式**(一次只用一种):
|
||||
|
||||
@@ -81,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 操作从「按一下→睡几秒→截图碰运气」变成**可断言**流程:
|
||||
|
||||
@@ -102,7 +105,70 @@ 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:"安装中")`。
|
||||
|
||||
**43 项能力** · **32 个 MCP 工具** · **114 项单元测试** · **26 项端到端用例**(CI 在真实 Windows 上验证)
|
||||
### 属性谓词与组合(v2.0.0)
|
||||
|
||||
`screen.find` 现在返回 UIA 属性,`gui.await` 可据此等**状态**而不只是「出现」:
|
||||
|
||||
```bash
|
||||
# 等一个「可用」的按钮(不指定文字)
|
||||
nodeagent --node win await --condition control --where '{"enabled":true}'
|
||||
# 等值里含「已」的元素
|
||||
nodeagent --node win await --condition control --where '{"value":"*已*"}' --timeout 15000
|
||||
# 组合:装成功 或 报错,先出现的算
|
||||
nodeagent --node win await --any_of '[{"condition":"window","title":"安装完成"},{"condition":"control","text":"错误"}]'
|
||||
```
|
||||
|
||||
| 属性 | 来源 | 拿不到时 |
|
||||
|---|---|---|
|
||||
| `enabled` | `IsEnabled` | — |
|
||||
| `value` | `ValuePattern` | `null`(**不猜**) |
|
||||
| `selected` | `SelectionItemPattern` | `null` |
|
||||
| `toggle` | `TogglePattern` | `null` |
|
||||
|
||||
> 属性只在 **UIA 引擎**下可读(Windows)。走 OCR/图像模板时 `where` 被忽略并**带回 `note`**,
|
||||
> 不会让调用方误以为「按属性过滤过了」。macOS 无 UIA 等价物,`where` 不可用(会明确报错提示用 `text`)。
|
||||
|
||||
---
|
||||
|
||||
## 状态采样:日志过滤与定时采样 —— v2.0.0
|
||||
|
||||
治**间歇性**问题(代理不通、杀软拦截、端口时开时闭)—— `system.status` 只能看当下快照,
|
||||
而 `event.watch` 只解决「有事件时通知」,没有「**持续记录并回看**」。
|
||||
|
||||
```bash
|
||||
# 日志在被控端侧过滤,只回匹配行(几万行日志也不整份拉回)
|
||||
nodeagent --node win log --path "C:\app\app.log" --level ERROR --limit 20
|
||||
nodeagent --node win log --path "C:\app\app.log" --pattern "timeout|refused" --tail
|
||||
|
||||
# 连续采样 5 分钟,事后回看「断过几次、最长断多久」
|
||||
nodeagent --node win invoke monitor.start --args '{"source":"port","target":"127.0.0.1:8765","interval_ms":2000,"id":"probe"}'
|
||||
nodeagent --node win monitor report probe
|
||||
nodeagent --node win monitor stop probe
|
||||
```
|
||||
|
||||
采样源四类:`port`(连通性 + 延迟)/ `process`(存活 + PID)/ `command`(退出码 + 首行输出)/
|
||||
`metric`(CPU·内存)。样本落 `<数据目录>/monitors/<id>.jsonl`,**摘要直接给结论**。
|
||||
|
||||
---
|
||||
|
||||
## 审计链外部锚定 —— v2.0.0
|
||||
|
||||
链内哈希只能发现「改一条」。**有 root 的攻击者可以整链重写** —— 从零重建一份自洽的假日志,
|
||||
`audit.verify` 照样通过。外部锚定是唯一能戳破它的手段:把链头记到**链外**。
|
||||
|
||||
```bash
|
||||
nodeagent --node win audit head --compare # 看链头 + 与最近锚点比对
|
||||
nodeagent --node win audit anchor --note "每日例行" # 追加一个锚点
|
||||
```
|
||||
|
||||
**锚点记四元组** `{entries, head_hash, head_ts, rotated_segments}`,不能只记哈希 ——
|
||||
日志轮转会丢弃最旧段使条目数下降,只比哈希会把**轮转误判成篡改**。
|
||||
锚点文件**只追加**(可覆盖的话,攻击者重写链后再刷新锚点即可抹痕)。
|
||||
|
||||
> 真正发挥价值的前提:把锚点文件指向**另一台机器 / 网盘同步目录**。
|
||||
> 本机文件被整盘重写时,锚点也随之消失 —— 那就等于没锚。
|
||||
|
||||
**51 项能力** · **41 个 MCP 工具** · **269 项单元测试** · **30 项端到端用例** · **3 项 Rust 壳单测**(真跑 Node sidecar)(CI 在真实 Windows / Linux runner 上验证)
|
||||
|
||||
> 关键里程碑:**GUI 语义**(`window.list` + `screen.find`,UIA 找不到自动降级 OCR)
|
||||
> 让 AI 从「看得到画面但读不懂界面」变成「按名字取坐标点下去」。
|
||||
@@ -113,9 +179,15 @@ nodeagent --node win invoke gui.await --args '{"condition":"file","path":"C:\\lo
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build
|
||||
pnpm build # 全部 workspace(protocol/client/agent/cli/mcp/hub + 桌面控制台前端)
|
||||
pnpm test:unit # 单元测试(协议纯函数 / 清单守护 / 编码脚本守卫 / 契约守卫)
|
||||
pnpm test:e2e # 端到端(起真实被控端跑全链路)
|
||||
pnpm test:rust # Rust 控制端客户端(需 cargo)
|
||||
```
|
||||
|
||||
> 桌面控制台是 Tauri 应用,**打包**需另进目录:`cd apps/console && pnpm tauri build`。
|
||||
> 仅前端产物则 `pnpm build` 已覆盖。
|
||||
|
||||
### 1. Windows 侧(被控端)
|
||||
|
||||
**方式 A:安装包(推荐,免装 Node.js)**
|
||||
@@ -239,11 +311,17 @@ nodeagent pull "C:\big.iso" --out ./big.iso # 下载(大文件自动
|
||||
## 架构
|
||||
|
||||
```
|
||||
┌────────────────────┐ ┌────────────────────┐
|
||||
│ 控制端 (macOS) │ ◄──── JSON-RPC 2.0 ────► │ 被控端 (Windows) │
|
||||
│ CLI · MCP Server │ over WebSocket │ Agent(开机自启) │
|
||||
└────────────────────┘ └────────────────────┘
|
||||
│ │
|
||||
┌──────────────────────────────────────────┐
|
||||
│ 控制端 (macOS / Windows) │
|
||||
│ ① 桌面控制台(Tauri 2) ② CLI ③ MCP │
|
||||
└──────────────────────────────────────────┘
|
||||
│
|
||||
JSON-RPC 2.0 over WebSocket
|
||||
│
|
||||
┌──────────────────────────────────────────┐
|
||||
│ 被控端 Agent(Windows 常驻 / 开机自启) │
|
||||
│ 同时也是「本地被控端」,供本机控制台直连 │
|
||||
└──────────────────────────────────────────┘
|
||||
└──────────── 三种链路可选 ──────────────────────┘
|
||||
① 局域网直连 ② UDP 广播自动发现 ③ Hub 中转(跨网段/公网)
|
||||
```
|
||||
@@ -256,6 +334,35 @@ nodeagent pull "C:\big.iso" --out ./big.iso # 下载(大文件自动
|
||||
| 审计 | JSONL 落盘 + 轮转 + 参数脱敏 + 查询能力 |
|
||||
| 传输 | TLS(自签证书可用 `--insecure` 跳过校验,鉴权另有保障) |
|
||||
|
||||
### 桌面控制台(Tauri 2)
|
||||
|
||||
`apps/console` —— 跨端图形控制台,**主控与被控一体**:本机也可作为被控端,
|
||||
由控制台经 loopback 直连(复用同一个 Agent,不做第二套被控端实现)。
|
||||
|
||||
技术栈 Tauri 2(Rust 壳 + 系统 WebView)+ Vue 3 + TypeScript + Pinia + Naive UI。
|
||||
协议客户端由 **Node sidecar**(`apps/console/sidecar/`)承担(复用 `@nodeagent/client`,
|
||||
Tauri 侧经 Cargo path 依赖),**不引入第二套协议实现**。
|
||||
|
||||
| 页面 | 能力 |
|
||||
|---|---|
|
||||
| 设备管理 | 连接/断开、保存多台设备、能力清单与握手元信息、实时日志 |
|
||||
| 状态总览 | CPU / 内存 / 磁盘 / 网卡,3s 轮询 |
|
||||
| 终端 | `system.shell.exec`(可设超时)、历史回放 |
|
||||
| 进程与服务 | 进程表(CPU/内存/已运行)+ Windows 服务表 |
|
||||
| 软件 | `app.list` / `app.install`(winget 静默安装,超时放大到 10 分钟) |
|
||||
| 文件 | 浏览 / 预览 / 写入(走分块能力,天然支持大文件) |
|
||||
| 审计 | `system.audit.list` 筛选 + `system.audit.verify` 链完整性校验 + 导出 JSONL |
|
||||
|
||||
```bash
|
||||
pnpm -r build # 含 console 前端
|
||||
cd apps/console && pnpm tauri dev # 开发态(起 Vite + Tauri 窗口)
|
||||
cd apps/console && pnpm tauri build # 打包(macOS 实测 .app ≈ 6.8 MiB)
|
||||
```
|
||||
|
||||
⚠️ 平台差异:`app.*` 与 `system.service.list` **仅在被控端为 Windows 时可用**,
|
||||
非 Windows 被控端会返回 `E_UNSUPPORTED_PLATFORM`。控制台连接后会探测
|
||||
`system.info.os` 并**事前禁用**这些入口,而不是发出注定失败的请求。
|
||||
|
||||
## 安全模型
|
||||
|
||||
三层防护,逐层收窄权限:
|
||||
@@ -498,45 +605,53 @@ nodeagent/
|
||||
├── scripts/ # install.cmd / control.cmd(一键入口)/ install.ps1 / control.ps1 / verify.mjs
|
||||
├── tests/e2e/ # 端到端测试
|
||||
├── docs/ # 开发文档
|
||||
├── PRD.md # 产品需求文档
|
||||
├── docs/ # 项目文档(含 PRD)
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 路线图
|
||||
|
||||
| 阶段 | 目标 | 状态 |
|
||||
> 下表「**批次**」是**开发阶段的内部代号**(一批相关改动的集合),**不是版本号** ——
|
||||
> 项目的唯一版本号是 semver(见 `git tag` / `package.json`)。批次与发布版本的对应关系
|
||||
> 及书写规范见 [`docs/VERSIONING.md`](docs/VERSIONING.md) §0。
|
||||
|
||||
| 阶段(开发批次) | 目标 | 状态 |
|
||||
|---|---|---|
|
||||
| **v1** | 命令级接管 + 装软件 + 查状态 + 基本鉴权 + CLI + MCP | ✅ |
|
||||
| **v2** | 图形接管(`screen.*` 截屏 + `input.*` 键鼠)+ 输入控制开关 | ✅ |
|
||||
| **v3** | 零信任:Ed25519 身份认证 + 能力级 ACL | ✅ |
|
||||
| **v3+** | 审计日志(留痕 + 查询)+ 调用限速 | ✅ |
|
||||
| **v4** | 无感体验:局域网自动发现 + 断线自动重连 | ✅ |
|
||||
| **v5** | 多设备管理 + 文件传输(分块 / 大文件 / 原子写) | ✅ |
|
||||
| **v6** | Hub 中转:跨网段 / 公网接入(被控端主动外连,穿 NAT) | ✅ |
|
||||
| **v7** | **自持**:受控重启(配置变更后让自身生效,不再丢连接) | ✅ |
|
||||
| **v8** | **GUI 语义**:`window.list` / `window.focus` / `screen.find`(UIA 按名取坐标) | ✅ |
|
||||
| **v9** | `screen.find` **OCR 兜底**(自绘 UI 也能定位)+ MCP 工具同步 | ✅ |
|
||||
| **v10** | **长任务异步化**(`system.task.*`)+ 剪贴板 + CLI 常驻连接池 daemon | ✅ |
|
||||
| **v11** | **安全加固**:私钥入系统密钥库(Keychain/DPAPI)+ 审计链防篡改 + ACL 细化(IP/时段/按能力限速) | ✅ |
|
||||
| **v11+** | **操作面补完**:鼠标拖拽 / 录屏 / 剪贴板图片 / 多设备并发 / 一键升级 | ✅ |
|
||||
| **v12** | **事件订阅**(file/process/net 主动推送)+ **GUI 宏**(步骤序列回放)+ **Hub 并发多控制端** + 设备分组 | ✅ |
|
||||
| **v12.1** | **Hub 侧按控制端授权**(多令牌 + 按设备白/黑名单 + 注册准入) | ✅ |
|
||||
| **v12.2** | GUI **等待/重试语义**(`wait_ms`)—— 界面加载/动画不再假失败 | ✅ |
|
||||
| **v12.3** | **macOS 端 GUI 对齐**:AppleScript 窗口枚举/聚焦 + Vision OCR 元素定位(含 Retina 坐标换算) | ✅ |
|
||||
| **v12.4** | **事件直达 MCP 宿主**(实时推送 + 拉取双通路) | ✅ |
|
||||
| **v13** | **成功指标聚合**(PRD 2.2 四项)+ 依真实数据修正口径(时延分层) | ✅ |
|
||||
| **v14** | **PowerShell 常驻助手** —— GUI 能力提速 4~25 倍(预加载 C#/UIA,免每次现编译) | ✅ |
|
||||
| **v15** | `screen.find` **图像模板匹配**(纯图标/无文字控件盲区)+ 一堆真机根因修复 | ✅ |
|
||||
| **待办** | CI 单元测试步骤已就绪(分支 `ci/workflow-unit-tests`,**需 `gh auth refresh -s workflow` 授权后推送**);GUI 图像锚点匹配(自绘 UI 更稳) | 🚧 |
|
||||
| **批次 1** | 命令级接管 + 装软件 + 查状态 + 基本鉴权 + CLI + MCP | ✅ |
|
||||
| **批次 2** | 图形接管(`screen.*` 截屏 + `input.*` 键鼠)+ 输入控制开关 | ✅ |
|
||||
| **批次 3** | 零信任:Ed25519 身份认证 + 能力级 ACL | ✅ |
|
||||
| **批次 3+** | 审计日志(留痕 + 查询)+ 调用限速 | ✅ |
|
||||
| **批次 4** | 无感体验:局域网自动发现 + 断线自动重连 | ✅ |
|
||||
| **批次 5** | 多设备管理 + 文件传输(分块 / 大文件 / 原子写) | ✅ |
|
||||
| **批次 6** | Hub 中转:跨网段 / 公网接入(被控端主动外连,穿 NAT) | ✅ |
|
||||
| **批次 7** | **自持**:受控重启(配置变更后让自身生效,不再丢连接) | ✅ |
|
||||
| **批次 8** | **GUI 语义**:`window.list` / `window.focus` / `screen.find`(UIA 按名取坐标) | ✅ |
|
||||
| **批次 9** | `screen.find` **OCR 兜底**(自绘 UI 也能定位)+ MCP 工具同步 | ✅ |
|
||||
| **批次 10** | **长任务异步化**(`system.task.*`)+ 剪贴板 + CLI 常驻连接池 daemon | ✅ |
|
||||
| **批次 11** | **安全加固**:私钥入系统密钥库(Keychain/DPAPI)+ 审计链防篡改 + ACL 细化(IP/时段/按能力限速) | ✅ |
|
||||
| **批次 11+** | **操作面补完**:鼠标拖拽 / 录屏 / 剪贴板图片 / 多设备并发 / 一键升级 | ✅ |
|
||||
| **批次 12** | **事件订阅**(file/process/net 主动推送)+ **GUI 宏**(步骤序列回放)+ **Hub 并发多控制端** + 设备分组 | ✅ |
|
||||
| **批次 12.1** | **Hub 侧按控制端授权**(多令牌 + 按设备白/黑名单 + 注册准入) | ✅ |
|
||||
| **批次 12.2** | GUI **等待/重试语义**(`wait_ms`)—— 界面加载/动画不再假失败 | ✅ |
|
||||
| **批次 12.3** | **macOS 端 GUI 对齐**:AppleScript 窗口枚举/聚焦 + Vision OCR 元素定位(含 Retina 坐标换算) | ✅ |
|
||||
| **批次 12.4** | **事件直达 MCP 宿主**(实时推送 + 拉取双通路) | ✅ |
|
||||
| **批次 13** | **成功指标聚合**(PRD 2.2 四项)+ 依真实数据修正口径(时延分层) | ✅ |
|
||||
| **批次 14** | **PowerShell 常驻助手** —— GUI 能力提速 4~25 倍(预加载 C#/UIA,免每次现编译) | ✅ |
|
||||
| **批次 15** | `screen.find` **图像模板匹配**(纯图标/无文字控件盲区)+ 一堆真机根因修复 | ✅ |
|
||||
| **批次 16 ~ 26** | 网络两阶段提交 · 音频控制 · 构建指纹 · 自更新 · 证书钉住 · 每客户端 PSK · 连接层三防护 · 零信任安装 · 审计锚定 | ✅ |
|
||||
| **批次 27 ~ 29** | **语义可断言**(`gui.await` + UIA 属性 + `where` 谓词 + `any_of`)· **状态可回看**(`log.query` + `monitor.*`) | ✅ |
|
||||
| **发布** | **v2.0.0**(2026-10-10):51 项能力 · 273 单测 · Windows/Rust CI 全绿 | ✅ 已发布 |
|
||||
|
||||
## 文档
|
||||
|
||||
- [`docs/SECURITY.md`](docs/SECURITY.md) —— **安全模型与加固指南**(钥匙=root、证书指纹钉住、网段白名单、应急处置)
|
||||
- [`docs/VERSIONING.md`](docs/VERSIONING.md) —— **版本与兼容性契约**(升级顺序、什么算破坏性、发版流程、更新方式)
|
||||
- [`docs/REMOTE-LIMITS.md`](docs/REMOTE-LIMITS.md) —— 远程能力边界与真实故障案例(含杀软误拦)
|
||||
- [`docs/VM-VERIFICATION.md`](docs/VM-VERIFICATION.md) —— **无 Windows 真机时,用 Parallels 的 Win11 ARM 虚拟机补验证**(含逐条命令与结论标注规则)
|
||||
- [`CHANGELOG.md`](CHANGELOG.md) —— **变更日志**(未发布版本的内容汇总 / 历史版本)
|
||||
- [`docs/ROADMAP.md`](docs/ROADMAP.md) —— **当前进度盘点与下一步路线**(2026-10-07 基线:待办收尾 / 语义可断言 / 状态采样 / 安全残余)
|
||||
|
||||
- [产品需求文档(PRD)](./PRD.md)
|
||||
- [产品需求文档(PRD)](./docs/PRD.md)
|
||||
- [开发文档(DEVELOPMENT)](./docs/DEVELOPMENT.md) —— 协议细节、能力 schema、安全模型、各端实现指南
|
||||
- [**跨机接管的边界与局限**](./docs/REMOTE-LIMITS.md) —— 哪些问题能远程修、哪些必须人工,附真实故障诊断案例
|
||||
- [**实战复盘:短板与改进优先级**](./docs/RETROSPECTIVE.md) —— 基于真机使用的短板分析(GUI 语义 / 编码 / 自愈 / 可观测性)。**写于 2026-09-28(19 项能力时),多数 P0/P1 已解决 —— 最新状态见 [ROADMAP](./docs/ROADMAP.md) §3 对照表**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/agent",
|
||||
"version": "1.6.0",
|
||||
"version": "2.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -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'
|
||||
@@ -17,7 +23,9 @@ export type AuditType =
|
||||
/** v21:来源不在 allow_from 被拒 */
|
||||
| 'net.deny'
|
||||
/** v19:agent 自更新(拉取式) */
|
||||
| 'agent.update';
|
||||
| 'agent.update'
|
||||
/** v2.0.0:连接空闲被主动断开 */
|
||||
| 'session.idle_close';
|
||||
|
||||
/** 一条审计记录(JSONL 单行)。 */
|
||||
export interface AuditEntry {
|
||||
@@ -332,3 +340,120 @@ export function readAudit(query: AuditQuery = {}): { entries: AuditEntry[]; tota
|
||||
collected.sort((a, b) => a.ts - b.ts);
|
||||
return { entries: collected.slice(-limit), total, file: p };
|
||||
}
|
||||
|
||||
|
||||
// ---------------- v2.0.0 审计链外部锚定 ----------------
|
||||
//
|
||||
// ## 为什么需要
|
||||
// 审计链的哈希使**链内**篡改可被发现(改一条就要重算其后全部)。但「有 root 的攻击者」
|
||||
// 可以**整链重写**:从零重建一份自洽的假日志,链校验照样通过。
|
||||
// 外部锚定就是打破这一点:定期把**链头哈希 + 条目数**记到链外的独立位置
|
||||
// (另一台机器 / 网盘 / U 盘)。事后比对时,若「当前链在某锚点之后的部分对不上」,
|
||||
// 说明这段被重写过 —— 攻击者无法回到过去改掉已经落到别处的锚点。
|
||||
//
|
||||
// ## 记什么(关键:不能只记哈希)
|
||||
// 轮转会丢弃最旧的段,所以条目数会**下降**。只记 head_hash 会导致轮转后被误判为篡改。
|
||||
// 故锚点记 {entries, head_hash, head_ts, rotated_segments} 四元组,
|
||||
// 比对时先按"轮转了几段"对齐,再判断尾部是否一致。
|
||||
|
||||
export interface AuditHead {
|
||||
/** 当前链可见条目数(含轮转段) */
|
||||
entries: number;
|
||||
/** 链头哈希(末条的 hash)—— 这是要外部留存的核心值 */
|
||||
head_hash: string | null;
|
||||
/** 末条时间戳 */
|
||||
head_ts: number | null;
|
||||
/** 存在的轮转段数(.log.1 … .log.20) */
|
||||
rotated_segments: number;
|
||||
file: string;
|
||||
file_bytes: number;
|
||||
computed_at: number;
|
||||
}
|
||||
|
||||
/** 计算当前审计链的头部信息(只读)。 */
|
||||
export function computeAuditHead(): AuditHead {
|
||||
const p = auditFilePath();
|
||||
let rotated = 0;
|
||||
for (let i = 1; i <= 20; i += 1) if (existsSync(`${p}.${i}`)) rotated += 1;
|
||||
|
||||
const entries = readAuditAll();
|
||||
const last = entries.length > 0 ? entries[entries.length - 1]! : null;
|
||||
let fileBytes = 0;
|
||||
try {
|
||||
fileBytes = statSync(p).size;
|
||||
} catch {
|
||||
/* 文件可能尚不存在(还没写过审计) */
|
||||
}
|
||||
return {
|
||||
entries: entries.length,
|
||||
head_hash: last?.hash ?? null,
|
||||
head_ts: last?.ts ?? null,
|
||||
rotated_segments: rotated,
|
||||
file: p,
|
||||
file_bytes: fileBytes,
|
||||
computed_at: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
// 锚点记录与比对结果的定义**移到了 protocol**(唯一实现,控制端脚本也用它)——
|
||||
// 见 packages/protocol/src/audit-anchor.ts 顶部的说明。此处 re-export 以保持既有导入路径可用。
|
||||
export type { AnchorRecord, AnchorComparison } from '@nodeagent/protocol';
|
||||
|
||||
/** 锚点文件路径(默认与审计同目录)。 */
|
||||
export function anchorFilePath(custom?: string): string {
|
||||
return custom && custom.trim().length > 0 ? custom.trim() : join(agentDir(), 'audit-anchors.jsonl');
|
||||
}
|
||||
|
||||
/** 把当前链头**追加**到锚点文件(追加式:历史锚点不可被后续覆盖)。 */
|
||||
export function anchorAudit(opts: { path?: string; note?: string } = {}): {
|
||||
file: string;
|
||||
record: AnchorRecord;
|
||||
total_lines: number;
|
||||
} {
|
||||
const head = computeAuditHead();
|
||||
const file = anchorFilePath(opts.path);
|
||||
const rec: AnchorRecord = {
|
||||
ts: Date.now(),
|
||||
entries: head.entries,
|
||||
head_hash: head.head_hash,
|
||||
head_ts: head.head_ts,
|
||||
rotated_segments: head.rotated_segments,
|
||||
...(opts.note ? { note: opts.note } : {}),
|
||||
};
|
||||
appendFileSync(file, `${JSON.stringify(rec)}\n`, 'utf8');
|
||||
let total = 0;
|
||||
try {
|
||||
total = readFileSync(file, 'utf8').split('\n').filter((l) => l.trim().length > 0).length;
|
||||
} catch {
|
||||
total = 1;
|
||||
}
|
||||
return { file, record: rec, total_lines: total };
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 与最近一条锚点比对,判断当前链是否与锚定时刻自洽。
|
||||
*
|
||||
* 判定逻辑(三条):
|
||||
* - 条目数应 **≥** 锚点记录(只增不减;减少只能由轮转解释,轮转会让 rotated_segments 变大)
|
||||
* - 若条目数与轮转段数都没变 → 链头哈希必须**完全一致**
|
||||
* - 若已轮转(段数变大)→ 只校验"当前 entries ≥ 锚点 entries",并提示需人工核对
|
||||
*/
|
||||
export function compareWithAnchors(customPath?: string): AnchorComparison {
|
||||
const file = anchorFilePath(customPath);
|
||||
if (!existsSync(file)) {
|
||||
return { ok: true, anchors_checked: 0, verdict: '尚无锚点:请先调用 system.audit.anchor 建立外部锚点' };
|
||||
}
|
||||
let anchors: AnchorRecord[] | null = null;
|
||||
try {
|
||||
anchors = parseAnchorsFromJsonl(readFileSync(file, 'utf8'));
|
||||
} catch {
|
||||
return { ok: false, anchors_checked: 0, verdict: '锚点文件损坏,无法比对' };
|
||||
}
|
||||
if (!anchors) {
|
||||
return { ok: true, anchors_checked: 0, verdict: '锚点文件为空' };
|
||||
}
|
||||
// 判定逻辑在 protocol(judgeAnchors)—— 与「控制端对云盘锚点」的比对共用同一份实现,
|
||||
// 避免两处规则漂移(那会导致同一份日志两端结论不同)。
|
||||
return judgeAnchors(computeAuditHead(), anchors);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* gui.await —— 等待条件成立(v1.6)。
|
||||
* gui.await —— 等待条件成立(v1.6.0)。
|
||||
*
|
||||
* ## 为什么需要它(复盘三大短板之「语义」)
|
||||
* 原来的 GUI 操作是**盲试**:按一下、截个图、看看有没有变化,不行再试。
|
||||
@@ -63,6 +63,23 @@ 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];
|
||||
}
|
||||
// v2.0.0:属性谓词(仅 UIA 引擎生效)。有 where 时允许不传 text ——
|
||||
// 「等一个 enabled 的按钮」比「等一个叫某名字的按钮」更贴近真实意图。
|
||||
const where = args['where'];
|
||||
if (where !== undefined) {
|
||||
if (typeof where !== 'object' || where === null || Array.isArray(where)) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'where 必须是对象,如 {"enabled":true}');
|
||||
}
|
||||
const allowed = ['enabled', 'selected', 'value', 'toggle'];
|
||||
const bad = Object.keys(where as Record<string, unknown>).filter((k) => !allowed.includes(k));
|
||||
if (bad.length > 0) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `where 只支持 ${allowed.join('/')}`, { got: bad });
|
||||
}
|
||||
inner['where'] = where;
|
||||
}
|
||||
if (inner['text'] === undefined && where === undefined) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'control 条件需要 text 或 where 之一');
|
||||
}
|
||||
return inner;
|
||||
}
|
||||
case 'process': {
|
||||
@@ -99,9 +116,46 @@ function innerProbe(condition: Condition): (args: Args) => Promise<unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v2.0.0 组合条件:`any_of: [ {...}, {...} ]`,任一命中原句即算命中。
|
||||
*
|
||||
* 为什么只做 any_of 不做 and/or/not generalize:真实场景里"弹窗出现 **且**
|
||||
* 其中某按钮 enabled"用 any_of(弹窗) + where(按钮 enabled) 两步就能表达,
|
||||
* 而通用布尔表达式会把 schema 与求值都复杂化,收益不成比例。
|
||||
* 若真需要"两个都必须成立",上层连调两次 await 更直白。
|
||||
*/
|
||||
function buildAnyOf(args: Args): Array<{ condition: Condition; inner: Args; label: string }> {
|
||||
const raw = args['any_of'];
|
||||
if (!Array.isArray(raw) || raw.length === 0) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'any_of 需为非空数组,每项为一个条件对象');
|
||||
}
|
||||
if (raw.length > 8) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'any_of 最多 8 个条件', { got: raw.length });
|
||||
}
|
||||
return raw.map((item, i) => {
|
||||
const obj = item as Record<string, unknown>;
|
||||
const condition = obj['condition'] as Condition | undefined;
|
||||
if (!condition || !CONDITIONS.includes(condition)) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `any_of[${i}].condition 不支持: ${String(condition)}`, {
|
||||
allowed: CONDITIONS,
|
||||
});
|
||||
}
|
||||
// 每项复用主条件的构造逻辑(同等校验),顶层键名直接透传
|
||||
const inner = buildInnerArgs(condition, obj);
|
||||
const label =
|
||||
condition === 'control' ? String(obj['text'] ?? JSON.stringify(obj['where'] ?? ''))
|
||||
: condition === 'window' ? String(obj['title'] ?? obj['pattern'] ?? '')
|
||||
: condition === 'process' ? String(obj['process'] ?? '')
|
||||
: String(obj['path'] ?? '');
|
||||
return { condition, inner, label };
|
||||
});
|
||||
}
|
||||
|
||||
export async function guiAwait(args: Args): Promise<unknown> {
|
||||
// v2.0.0:有 any_of 时**不要求**顶层 condition(语义就是"这组条件任一命中"),
|
||||
// 校验顺序也因此调整为 any_of 先行 —— 否则会先撞上"condition 缺失"的报错。
|
||||
const condition = args['condition'] as Condition | undefined;
|
||||
if (!condition || !CONDITIONS.includes(condition)) {
|
||||
if (!args['any_of'] && (!condition || !CONDITIONS.includes(condition))) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的 condition: ${String(condition)}`, {
|
||||
allowed: CONDITIONS,
|
||||
hint: 'window=等窗口出现/消失;control=等界面元素;process=等进程;file=等文件',
|
||||
@@ -116,14 +170,61 @@ export async function guiAwait(args: Args): Promise<unknown> {
|
||||
const timeoutMs = Math.max(0, Math.min(60_000, (args['timeout_ms'] as number | undefined) ?? 5000));
|
||||
const intervalMs = Math.max(50, Math.min(5000, (args['interval_ms'] as number | undefined) ?? 400));
|
||||
|
||||
const innerArgs = buildInnerArgs(condition, args);
|
||||
const probe = innerProbe(condition);
|
||||
|
||||
const startedAt = Date.now();
|
||||
let attempts = 0;
|
||||
let lastHit = false;
|
||||
let lastError: string | null = null;
|
||||
|
||||
// v2.0.0:组合条件走单独路径(任一命中即算),失败语义与单条完全一致
|
||||
const anyOf = args['any_of'] !== undefined ? buildAnyOf(args) : null;
|
||||
if (anyOf) {
|
||||
for (;;) {
|
||||
attempts += 1;
|
||||
let hitLabel: string | null = null;
|
||||
lastError = null;
|
||||
for (const c of anyOf) {
|
||||
try {
|
||||
const r = await innerProbe(c.condition)(c.inner);
|
||||
if (isHit(r)) { hitLabel = c.label; break; }
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
const satisfied = (state === 'present' ? hitLabel !== null : hitLabel === null) as boolean;
|
||||
if (satisfied) {
|
||||
return {
|
||||
satisfied: true,
|
||||
condition: 'any_of',
|
||||
state,
|
||||
elapsed_ms: Date.now() - startedAt,
|
||||
attempts,
|
||||
last_seen: state,
|
||||
hit: hitLabel ?? undefined,
|
||||
note: hitLabel ? `命中:${hitLabel}` : '所有子条件均已消失',
|
||||
};
|
||||
}
|
||||
if (Date.now() - startedAt >= timeoutMs) {
|
||||
return {
|
||||
satisfied: false,
|
||||
condition: 'any_of',
|
||||
state,
|
||||
elapsed_ms: Date.now() - startedAt,
|
||||
attempts,
|
||||
last_seen: lastHit ? 'present' : 'absent',
|
||||
last_error: lastError ?? undefined,
|
||||
note: `等待 ${timeoutMs}ms,${anyOf.length} 个条件均未${state === 'present' ? '命中' : '消失'}`,
|
||||
};
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
}
|
||||
}
|
||||
|
||||
// 能走到这里说明没走 any_of 分支,而进入函数时的校验已保证 condition 合法;
|
||||
// TS 无法跨分支收窄,这里显式断言一次(buildInnerArgs / innerProbe 内部还会各自校验)。
|
||||
const single = condition as Condition;
|
||||
const innerArgs = buildInnerArgs(single, args);
|
||||
const probe = innerProbe(single);
|
||||
|
||||
for (;;) {
|
||||
attempts += 1;
|
||||
try {
|
||||
|
||||
@@ -317,7 +317,15 @@ async function ensureOcrBin(): Promise<string> {
|
||||
export async function macScreenFind(args: Args): Promise<unknown> {
|
||||
assertDarwin('screen.find');
|
||||
const text = args['text'] as string;
|
||||
if (!text) throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'text 不能为空');
|
||||
if (!text) {
|
||||
// macOS 走 Vision OCR,**没有控件属性可读** —— where-only 无法满足,
|
||||
// 明确说清原因,别让调用方以为是参数写错。
|
||||
throw new CapabilityError(
|
||||
ErrorCodes.UNSUPPORTED_PLATFORM,
|
||||
'macOS 无 UIA 等价物:where 属性过滤不可用,请提供 text(OCR 按文字查找)',
|
||||
{ platform: 'darwin', engine: 'ocr' },
|
||||
);
|
||||
}
|
||||
const limit = (args['limit'] as number | undefined) ?? 20;
|
||||
const waitMs = Math.max(0, Math.min(30_000, (args['wait_ms'] as number | undefined) ?? 0));
|
||||
const intervalMs = Math.max(100, Math.min(2000, (args['interval_ms'] as number | undefined) ?? 400));
|
||||
|
||||
@@ -29,7 +29,7 @@ export function setFsRoots(roots: string[] | undefined): void {
|
||||
allowedRoots = (roots ?? []).map((r) => resolve(r));
|
||||
}
|
||||
|
||||
function guardPath(input: string): string {
|
||||
export function guardPath(input: string): string {
|
||||
if (typeof input !== 'string' || input.length === 0) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'path 不能为空');
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CapabilityNames } from '@nodeagent/protocol';
|
||||
import type { AgentConfig } from '../config.js';
|
||||
import { systemInfo, systemStatus, processList, serviceList, shellExec, auditList, auditVerify, metricsReport, agentRestart } from './system.js';
|
||||
import { systemInfo, systemStatus, processList, serviceList, shellExec, auditList, auditVerify, auditHead, auditAnchor, metricsReport, agentRestart } from './system.js';
|
||||
import { appList, appInstall } from './app.js';
|
||||
import { screenInfo, screenCapture } from './screen.js';
|
||||
import { windowList, windowFocus, screenFind } from './window.js';
|
||||
@@ -14,6 +14,7 @@ import { agentUpdate } from './selfupdate.js';
|
||||
import { mouseMove, mouseClick, mouseScroll, mouseDrag, keyType, keyPress, setInputPolicy } from './input.js';
|
||||
import { fsList, fsStat, fsRead, fsWrite, setFsRoots } from './fs.js';
|
||||
import { guiAwait } from './await.js';
|
||||
import { logQuery, monitorStart, monitorReport, monitorStop, monitorList, monitorDelete } from './monitor.js';
|
||||
|
||||
export interface HandlerContext {
|
||||
/** 连接标识(事件订阅归属 / 断开清理) */
|
||||
@@ -53,12 +54,22 @@ 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,
|
||||
// v2.0.0 状态采样(方向二)
|
||||
[CapabilityNames.LogQuery]: logQuery,
|
||||
[CapabilityNames.MonitorStart]: monitorStart,
|
||||
[CapabilityNames.MonitorReport]: monitorReport,
|
||||
[CapabilityNames.MonitorStop]: monitorStop,
|
||||
[CapabilityNames.MonitorList]: monitorList,
|
||||
[CapabilityNames.MonitorDelete]: monitorDelete,
|
||||
// v3+ 审计
|
||||
[CapabilityNames.AuditList]: auditList,
|
||||
// v11 审计防篡改
|
||||
[CapabilityNames.AuditVerify]: auditVerify,
|
||||
// v2.0.0 审计链外部锚定(防整链重写)
|
||||
[CapabilityNames.AuditHead]: auditHead,
|
||||
[CapabilityNames.AuditAnchor]: auditAnchor,
|
||||
// v13 成功指标
|
||||
[CapabilityNames.Metrics]: metricsReport,
|
||||
// v5 文件传输
|
||||
|
||||
@@ -26,10 +26,10 @@ export const VK_MAP: Record<string, number> = {
|
||||
|
||||
// 媒体键(0xA6-0xB7 区段)—— 2026-10-05 补
|
||||
//
|
||||
// 为何必要:远程控媒体播放器(QQ 音乐 / 网易云 / 浏览器视频)时,
|
||||
// 为何必要:远程控媒体播放器(音乐 / 视频 / 浏览器内视频)时,
|
||||
// 若软件热键不可用(精简模式常不注册全局热键),系统媒体键是唯一
|
||||
// 与「窗口是否可见/被遮挡」无关的通道 —— 窗口最小化、被游戏全屏盖住时
|
||||
// 依然有效。实测:QQ 音乐精简窗被 DNF 全屏遮挡时,媒体键仍能控制。
|
||||
// 依然有效。实测:精简模式的播放器被全屏游戏遮挡时,媒体键仍能控制。
|
||||
media_play: 0xb3, media_pause: 0xb3, media_play_pause: 0xb3, play_pause: 0xb3,
|
||||
media_stop: 0xb2, stop: 0xb2,
|
||||
media_prev: 0xb1, media_previous: 0xb1, prev_track: 0xb1, prev: 0xb1,
|
||||
@@ -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,
|
||||
|
||||
@@ -142,8 +142,8 @@ public static class NAInput {
|
||||
/// —— 只有当前获得媒体会话焦点的窗口才会响应。而 WM_APPCOMMAND 可直接
|
||||
/// 投递给指定 hwnd,绕开「谁是当前媒体会话焦点」这层不确定性。
|
||||
///
|
||||
/// 用途:远程控制 QQ 音乐 / 网易云 / 浏览器视频,且**窗口最小化或被全屏游戏
|
||||
/// 遮挡时依然有效**(实测 QQ 音乐精简窗被 DNF 全屏压住时该通道可用)。
|
||||
/// 用途:远程控制主流媒体播放器(音乐 / 视频)或浏览器视频,且**窗口最小化
|
||||
/// 或被全屏独占应用遮挡时依然有效**(实测:精简模式的播放器被全屏游戏压住时该通道可用)。
|
||||
/// </summary>
|
||||
[DllImport("user32.dll", CharSet = CharSet.Auto)]
|
||||
private static extern bool PostMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
|
||||
@@ -160,7 +160,7 @@ public static class NAInput {
|
||||
/// appCommand 取值:14=PLAY 15=STOP 12=PLAY_PAUSE 6=PREV 7=NEXT
|
||||
/// 3=FFWD 4=REWIND 10=CLOSE 0=PLAY 1=PAUSE
|
||||
/// targetPid 非 0 时,自动找到该进程的所有顶层窗口并逐个投递
|
||||
/// (QQ 音乐等会创建 30+ 个辅助窗口,只投主窗口常常无效)。
|
||||
/// (部分播放器会创建数十个辅助窗口,只投主窗口常常无效)。
|
||||
/// </summary>
|
||||
public static int MediaCommand(int appCommand, int targetPid) {
|
||||
int sent = 0;
|
||||
@@ -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') {
|
||||
@@ -407,7 +407,7 @@ export async function keyPress(args: Args): Promise<unknown> {
|
||||
// 则回退到 VK 注入 —— 那种组合场景本就罕见,SendInput 足够。
|
||||
const allMedia = isAllMedia(chords);
|
||||
if (allMedia) {
|
||||
// target_pid 非 0 时定向投递到该进程的全部顶层窗口(QQ 音乐会建 30+ 辅助窗口,
|
||||
// target_pid 非 0 时定向投递到该进程的全部顶层窗口(部分播放器会建数十个辅助窗口,
|
||||
// 只投主窗口常常无效 —— 实测踩过);否则广播。
|
||||
const mediaBody = buildMediaBody(chords, { gapMs: gap, holdMs, targetPid: pid, route });
|
||||
await runPs(mediaBody);
|
||||
@@ -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 }));
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* log.query(v2.0.0,方向二「状态」第一块)。
|
||||
*
|
||||
* ## 为什么必须有
|
||||
* 治间歇性问题(代理不通、杀软拦截、端口时开时闭)要看**历史**,而被控端日志常在
|
||||
* 几万行以上。`fs.read` 虽有 8MB 上限,但"整份拉回再筛"既慢又占带宽 ——
|
||||
* 过滤必须发生在 **Windows 侧**。
|
||||
*
|
||||
* ## 实现要点
|
||||
* - 流式逐行读(readline),**不全量载入内存**;扫到 limit 条即停
|
||||
* - 三级过滤:`pattern`(正则,大小写不敏感)/ `level`(ERROR|WARN|INFO|DEBUG,
|
||||
* 命中行内含 `[LEVEL]` 或 `LEVEL:` 即算)/ `since`(Unix ms,按行首时间戳或
|
||||
* 文件 mtime 粗筛)
|
||||
* - 路径仍走 `fs.guardPath` 的白名单(fs_roots 非空即受限)—— 不新开安全面
|
||||
* - 单行超长保护:超过 4KB 的行截断后再匹配,避免一行巨型 JSON 拖垮内存
|
||||
*/
|
||||
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { createInterface } from 'node:readline';
|
||||
import { statSync } from 'node:fs';
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { guardPath } from './fs.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
const MAX_LINE = 4096;
|
||||
const MAX_SCAN = 2_000_000; // 单次查询最多扫 200 万行,防失控文件拖死 agent
|
||||
|
||||
export interface LogLine {
|
||||
/** 行号(从 1 开始)—— 回看时便于对照原文件 */
|
||||
n: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface LogQueryResult {
|
||||
path: string;
|
||||
matched: LogLine[];
|
||||
/** 实际扫描过的行数(用于判断"是扫完了还是被 limit 截住") */
|
||||
scanned: number;
|
||||
/** 是否因达到 MAX_SCAN 提前结束 */
|
||||
truncated: boolean;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/** 从行里推断日志级别(找不到返回 null,不猜)。 */
|
||||
function detectLevel(line: string): string | null {
|
||||
const m = /\[(ERROR|WARN|WARNING|INFO|DEBUG|TRACE)\]|\b(ERROR|WARN|INFO|DEBUG|TRACE):/i.exec(line);
|
||||
if (!m) return null;
|
||||
const raw = (m[1] ?? m[2] ?? '').toUpperCase();
|
||||
return raw === 'WARNING' ? 'WARN' : raw;
|
||||
}
|
||||
|
||||
export async function logQuery(args: Args): Promise<LogQueryResult> {
|
||||
const p = guardPath(args['path'] as string);
|
||||
const limit = Math.max(1, Math.min(5000, (args['limit'] as number | undefined) ?? 100));
|
||||
const offset = Math.max(0, (args['offset'] as number | undefined) ?? 0);
|
||||
const patternStr = args['pattern'] as string | undefined;
|
||||
const level = (args['level'] as string | undefined)?.toUpperCase();
|
||||
const since = args['since'] as number | undefined;
|
||||
const tail = (args['tail'] as boolean | undefined) ?? false;
|
||||
|
||||
let re: RegExp | null = null;
|
||||
if (patternStr) {
|
||||
try {
|
||||
re = new RegExp(patternStr, 'i');
|
||||
} catch (err) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `pattern 不是合法正则: ${patternStr}`, {
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (level && !['ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE'].includes(level)) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的 level: ${level}`, {
|
||||
allowed: ['ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE'],
|
||||
});
|
||||
}
|
||||
|
||||
let st;
|
||||
try {
|
||||
st = statSync(p);
|
||||
} catch {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `文件不存在或不可读: ${p}`);
|
||||
}
|
||||
if (!st.isFile()) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不是普通文件: ${p}`);
|
||||
}
|
||||
// since 粗筛:文件在 since 之前就没改过,必然没有更新的行 —— 省一次全扫
|
||||
if (since !== undefined && st.mtimeMs < since) {
|
||||
return { path: p, matched: [], scanned: 0, truncated: false, note: '文件 mtime 早于 since,未扫描' };
|
||||
}
|
||||
|
||||
// tail 模式:保留最后 limit 条命中的环形缓冲(边扫边丢旧的)
|
||||
const ring: LogLine[] = [];
|
||||
let matched = 0;
|
||||
let scanned = 0;
|
||||
let truncated = false;
|
||||
|
||||
const rl = createInterface({
|
||||
input: createReadStream(p, { encoding: 'utf8' }),
|
||||
crlfDelay: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
|
||||
for await (const rawLine of rl) {
|
||||
scanned += 1;
|
||||
if (scanned > MAX_SCAN) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
let line = rawLine;
|
||||
if (line.length > MAX_LINE) line = `${line.slice(0, MAX_LINE)}…[截断]`;
|
||||
if (re && !re.test(line)) continue;
|
||||
if (level && detectLevel(line) !== level) continue;
|
||||
// since 精确筛:只在行首能解出时间戳时才按时间比(解不出就放行,交由调用方判断)
|
||||
if (since !== undefined) {
|
||||
const ts = parseLeadingTs(line);
|
||||
if (ts !== null && ts < since) continue;
|
||||
}
|
||||
matched += 1;
|
||||
if (tail) {
|
||||
ring.push({ n: scanned, text: line });
|
||||
if (ring.length > limit) ring.shift();
|
||||
} else {
|
||||
if (matched > offset && matched <= offset + limit) ring.push({ n: scanned, text: line });
|
||||
if (matched > offset + limit) break; // 已取够,提前收工
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
path: p,
|
||||
matched: ring,
|
||||
scanned,
|
||||
truncated,
|
||||
...(truncated ? { note: `扫描超过 ${MAX_SCAN} 行上限,结果可能不完整` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 行首时间戳 → Unix ms(支持 ISO 与常见 `[2026-10-07 12:34:56]` 形态;解不出返回 null)。
|
||||
*
|
||||
* ⚠️ 必须**连同时区后缀一起解析**:只截取到秒会丢掉 `Z` / `+08:00`,
|
||||
* 让 UTC 时间被当本地时间读 —— 实测会整体偏 8 小时(真机日志跨时区时判断"何时断的"就错了)。
|
||||
*/
|
||||
export function parseLeadingTs(line: string): number | null {
|
||||
const m = /^\[?(\d{4}[-/]\d{2}[-/]\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:?\d{2})?)/.exec(line);
|
||||
if (!m) return null;
|
||||
// 无时区信息时按本地时间解析(与 `[2026-10-07 10:00:12]` 这类本地日志的语义一致);
|
||||
// 带 Z / ±hh:mm 时 Date.parse 自行按该时区处理 —— 关键是**别把后缀截掉**。
|
||||
const iso = m[1]!.replace(/\//g, '-').replace(' ', 'T');
|
||||
const t = Date.parse(iso);
|
||||
return Number.isFinite(t) ? t : null;
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* monitor.*(v2.0.0,方向二「状态」第二块)—— 定时采样落盘与回看。
|
||||
*
|
||||
* ## 为什么需要(复盘 §四)
|
||||
* 代理不通、杀软拦截、端口时开时闭都是**间歇性**的,而 `system.status` 只能看当下快照。
|
||||
* `event.watch` 解决「有事件时通知」,没解决「**持续记录,事后能查**」。
|
||||
*
|
||||
* ## 设计
|
||||
* - 四类采样源:`port`(连通性 + 延迟)/ `process`(存活 + PID 列表)/
|
||||
* `command`(退出码 + 首行输出)/ `metric`(CPU/内存)
|
||||
* - 落盘 `<数据目录>/monitors/<id>.jsonl`(一行一样本,追加写 + 立即 flush 语义)
|
||||
* - `monitor.report` 读回并给摘要:min/max/均值、断线次数、最长中断 —— 直接回答
|
||||
* 「5 分钟里断过几次、最长断了多久」
|
||||
* - 并发上限 8 个;单文件 10 万行封顶(防写满磁盘)
|
||||
* - 采样循环 `unref()`,不阻止 agent 退出;`monitor.stop` 或进程退出即停
|
||||
*/
|
||||
|
||||
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { connect } from 'node:net';
|
||||
import { exec } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import { homedir, totalmem, freemem, cpus } from 'node:os';
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { logQuery, type LogLine } from './log.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
export type MonitorSource = 'port' | 'process' | 'command' | 'metric';
|
||||
|
||||
interface Sample {
|
||||
ts: number;
|
||||
/** 各源自定义字段 */
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
interface MonitorRecord {
|
||||
id: string;
|
||||
source: MonitorSource;
|
||||
target: string;
|
||||
intervalMs: number;
|
||||
createdAt: number;
|
||||
samples: number;
|
||||
file: string;
|
||||
timer: ReturnType<typeof setInterval>;
|
||||
/** 首次采样失败时记下,避免每次 tick 都刷错误 */
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
const monitors = new Map<string, MonitorRecord>();
|
||||
const MAX_MONITORS = 8;
|
||||
const MAX_SAMPLES_PER_FILE = 100_000;
|
||||
|
||||
function dataDir(): string {
|
||||
return process.env['NODEAGENT_HOME'] ?? join(homedir(), '.nodeagent');
|
||||
}
|
||||
|
||||
function monitorDir(): string {
|
||||
const d = join(dataDir(), 'monitors');
|
||||
if (!existsSync(d)) mkdirSync(d, { recursive: true });
|
||||
return d;
|
||||
}
|
||||
|
||||
// ---------------- 采样器 ----------------
|
||||
|
||||
/** port:TCP 连通性 + 握手耗时(跨平台,无额外依赖)。 */
|
||||
function samplePort(host: string, port: number, timeoutMs: number): Promise<Sample> {
|
||||
return new Promise((resolve) => {
|
||||
const started = Date.now();
|
||||
const sock = connect({ host, port });
|
||||
const done = (up: boolean, err?: string): void => {
|
||||
sock.destroy();
|
||||
resolve({ ts: Date.now(), up, ms: Date.now() - started, ...(err ? { error: err } : {}) });
|
||||
};
|
||||
sock.setTimeout(timeoutMs);
|
||||
sock.once('connect', () => done(true));
|
||||
sock.once('timeout', () => done(false, 'timeout'));
|
||||
sock.once('error', (e) => done(false, String(e.message ?? e)));
|
||||
});
|
||||
}
|
||||
|
||||
/** process:按名判断存活(Windows tasklist / POSIX ps,与 events.ts 同策略)。 */
|
||||
function sampleProcess(name: string): Promise<Sample> {
|
||||
return new Promise((resolve) => {
|
||||
const cmd =
|
||||
process.platform === 'win32'
|
||||
? `tasklist /FI "IMAGENAME eq ${name}" /NH`
|
||||
: `ps -axo pid=,comm= | grep -i "${name}" | grep -v grep`;
|
||||
exec(cmd, { timeout: 8000 }, (err, stdout) => {
|
||||
const lines = stdout
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0 && !/^INFO:/i.test(l));
|
||||
const pids = lines
|
||||
.map((l) => {
|
||||
const m = /^(\d+)/.exec(l);
|
||||
return m ? Number(m[1]) : null;
|
||||
})
|
||||
.filter((x): x is number => x !== null);
|
||||
resolve({ ts: Date.now(), alive: pids.length > 0, pids, ...(err && pids.length === 0 ? { error: String(err.message) } : {}) });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** command:退出码 + 输出首行(用于"跑个探针命令"这类自定义采样)。 */
|
||||
function sampleCommand(command: string, timeoutMs: number): Promise<Sample> {
|
||||
return new Promise((resolve) => {
|
||||
exec(command, { timeout: timeoutMs }, (err, stdout, stderr) => {
|
||||
const first = (stdout || stderr || '').split('\n')[0]?.slice(0, 500) ?? '';
|
||||
resolve({
|
||||
ts: Date.now(),
|
||||
exit_code: err ? (typeof err.code === 'number' ? err.code : 1) : 0,
|
||||
first_line: first,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** metric:CPU(两次采样差分)+ 内存。 */
|
||||
async function sampleMetric(): Promise<Sample> {
|
||||
const a = cpus();
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
const b = cpus();
|
||||
const idle = (c: typeof a): number =>
|
||||
c.reduce((s, x) => s + x.times.idle + x.times.irq, 0) / c.length;
|
||||
const total = (c: typeof a): number =>
|
||||
c.reduce((s, x) => s + Object.values(x.times).reduce((p, q) => p + q, 0), 0) / c.length;
|
||||
const idleDiff = idle(b) - idle(a);
|
||||
const totalDiff = total(b) - total(a);
|
||||
const cpu = totalDiff > 0 ? Math.round((1 - idleDiff / totalDiff) * 10000) / 100 : 0;
|
||||
const mem = totalmem();
|
||||
const free = freemem();
|
||||
return { ts: Date.now(), cpu_pct: cpu, mem_pct: Math.round(((mem - free) / mem) * 10000) / 100 };
|
||||
}
|
||||
|
||||
async function takeSample(rec: MonitorRecord): Promise<Sample> {
|
||||
switch (rec.source) {
|
||||
case 'port': {
|
||||
const [host, portStr] = rec.target.split(':');
|
||||
return samplePort(host || '127.0.0.1', Number(portStr), Math.min(rec.intervalMs, 5000));
|
||||
}
|
||||
case 'process':
|
||||
return sampleProcess(rec.target);
|
||||
case 'command':
|
||||
return sampleCommand(rec.target, Math.min(rec.intervalMs * 2, 20_000));
|
||||
case 'metric':
|
||||
return sampleMetric();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 能力实现 ----------------
|
||||
|
||||
export async function monitorStart(args: Args): Promise<unknown> {
|
||||
const source = args['source'] as MonitorSource | undefined;
|
||||
if (!source || !['port', 'process', 'command', 'metric'].includes(source)) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的 source: ${String(source)}`, {
|
||||
allowed: ['port', 'process', 'command', 'metric'],
|
||||
hint: 'port 需 target="host:port";process 需 target=进程名;command 需 target=命令;metric 不需要 target',
|
||||
});
|
||||
}
|
||||
const intervalMs = Math.max(500, Math.min(600_000, (args['interval_ms'] as number | undefined) ?? 2000));
|
||||
const target = (args['target'] as string | undefined) ?? '';
|
||||
if (source !== 'metric' && !target) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `source=${source} 需要 target`);
|
||||
}
|
||||
if (source === 'port' && !/^.+:\d+$/.test(target)) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'port 采样的 target 应为 "host:port"', { got: target });
|
||||
}
|
||||
if (monitors.size >= MAX_MONITORS) {
|
||||
throw new CapabilityError(ErrorCodes.RATE_LIMITED, `并发监控上限 ${MAX_MONITORS} 个`, {
|
||||
running: [...monitors.keys()],
|
||||
});
|
||||
}
|
||||
|
||||
const id = (args['id'] as string | undefined)?.trim() || `m_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
if (monitors.has(id)) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `监控 id 已存在: ${id}`, { hint: '换个 id,或先 monitor.stop' });
|
||||
}
|
||||
if (!/^[\w.-]{1,64}$/.test(id)) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'id 只允许字母数字与 . _ -(≤64 字符)', { got: id });
|
||||
}
|
||||
|
||||
const file = join(monitorDir(), `${id}.jsonl`);
|
||||
const rec: MonitorRecord = {
|
||||
id, source, target, intervalMs, createdAt: Date.now(), samples: 0, file,
|
||||
timer: setInterval(() => { void tick(rec); }, intervalMs),
|
||||
lastError: null,
|
||||
};
|
||||
monitors.set(id, rec);
|
||||
// 立即采第一针(调用方往往想马上知道"现在通不通"),不额外等一个 interval
|
||||
void tick(rec);
|
||||
return { id, source, target, interval_ms: intervalMs, file, started_at: rec.createdAt };
|
||||
}
|
||||
|
||||
async function tick(rec: MonitorRecord): Promise<void> {
|
||||
try {
|
||||
const s = await takeSample(rec);
|
||||
rec.lastError = null;
|
||||
appendFileSync(rec.file, `${JSON.stringify(s)}\n`, 'utf8');
|
||||
rec.samples += 1;
|
||||
// 行数封顶:超过就从头截断(保留后半),防止磁盘写满
|
||||
if (rec.samples > MAX_SAMPLES_PER_FILE) {
|
||||
const lines = readFileSync(rec.file, 'utf8').trim().split('\n');
|
||||
writeFileSync(rec.file, `${lines.slice(-MAX_SAMPLES_PER_FILE).join('\n')}\n`, 'utf8');
|
||||
rec.samples = MAX_SAMPLES_PER_FILE;
|
||||
}
|
||||
} catch (err) {
|
||||
rec.lastError = err instanceof Error ? err.message : String(err);
|
||||
// 采样失败也记一行("查不到"本身也是信息),但不无限刷盘
|
||||
if (rec.samples < MAX_SAMPLES_PER_FILE) {
|
||||
appendFileSync(rec.file, `${JSON.stringify({ ts: Date.now(), error: rec.lastError })}\n`, 'utf8');
|
||||
rec.samples += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function monitorReport(args: Args): Promise<unknown> {
|
||||
const id = args['id'] as string;
|
||||
if (!id) throw new CapabilityError(ErrorCodes.PARAM_INVALID, '需要 id');
|
||||
const rec = monitors.get(id);
|
||||
const file = join(monitorDir(), `${id}.jsonl`);
|
||||
if (!rec && !existsSync(file)) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `没有这个监控: ${id}`, { running: [...monitors.keys()] });
|
||||
}
|
||||
const since = args['since'] as number | undefined;
|
||||
const limit = Math.max(1, Math.min(20_000, (args['limit'] as number | undefined) ?? 5000));
|
||||
|
||||
const samples: Sample[] = [];
|
||||
let truncated = false;
|
||||
if (existsSync(file)) {
|
||||
const lines = readFileSync(file, 'utf8').split('\n');
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const s = JSON.parse(line) as Sample;
|
||||
if (since !== undefined && s.ts < since) continue;
|
||||
samples.push(s);
|
||||
} catch {
|
||||
/* 半行(写入中断)跳过 */
|
||||
}
|
||||
}
|
||||
if (samples.length > limit) {
|
||||
samples.splice(0, samples.length - limit);
|
||||
truncated = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
running: rec !== undefined,
|
||||
source: rec?.source ?? 'unknown',
|
||||
target: rec?.target ?? '',
|
||||
interval_ms: rec?.intervalMs,
|
||||
file,
|
||||
samples: samples.length,
|
||||
truncated,
|
||||
last_error: rec?.lastError ?? undefined,
|
||||
summary: summarize(samples, rec?.source ?? 'port'),
|
||||
series: samples,
|
||||
};
|
||||
}
|
||||
|
||||
/** 摘要:把样本压成"人话"—— 断几次、最长多久、值域。 */
|
||||
function summarize(samples: Sample[], source: MonitorSource): Record<string, unknown> {
|
||||
if (samples.length === 0) return { n: 0 };
|
||||
const base: Record<string, unknown> = { n: samples.length };
|
||||
const ts = samples.map((s) => s.ts);
|
||||
base.window_ms = Math.max(...ts) - Math.min(...ts);
|
||||
|
||||
if (source === 'port') {
|
||||
const ups = samples.filter((s) => s.up === true);
|
||||
base.up_count = ups.length;
|
||||
base.down_count = samples.length - ups.length;
|
||||
const lat = samples.map((s) => Number(s.ms ?? 0)).filter((x) => Number.isFinite(x));
|
||||
if (lat.length) {
|
||||
base.ms_min = Math.min(...lat);
|
||||
base.ms_max = Math.max(...lat);
|
||||
base.ms_avg = Math.round((lat.reduce((a, b) => a + b, 0) / lat.length) * 100) / 100;
|
||||
}
|
||||
// 最长连续中断
|
||||
let run = 0;
|
||||
let worst = 0;
|
||||
for (const s of samples) {
|
||||
if (s.up === true) { run = 0; } else { run += 1; worst = Math.max(worst, run); }
|
||||
}
|
||||
base.longest_outage_samples = worst;
|
||||
} else if (source === 'process') {
|
||||
base.alive_count = samples.filter((s) => s.alive === true).length;
|
||||
base.dead_count = samples.filter((s) => s.alive !== true).length;
|
||||
let run = 0;
|
||||
let worst = 0;
|
||||
for (const s of samples) {
|
||||
if (s.alive === true) { run = 0; } else { run += 1; worst = Math.max(worst, run); }
|
||||
}
|
||||
base.longest_dead_samples = worst;
|
||||
} else if (source === 'command') {
|
||||
const codes = samples.map((s) => Number(s.exit_code ?? -1));
|
||||
base.ok_count = codes.filter((c) => c === 0).length;
|
||||
base.fail_count = codes.filter((c) => c !== 0).length;
|
||||
base.exit_codes_seen = [...new Set(codes)];
|
||||
} else {
|
||||
const cpu = samples.map((s) => Number(s.cpu_pct ?? 0));
|
||||
const mem = samples.map((s) => Number(s.mem_pct ?? 0));
|
||||
if (cpu.length) {
|
||||
base.cpu_min = Math.min(...cpu); base.cpu_max = Math.max(...cpu);
|
||||
base.cpu_avg = Math.round((cpu.reduce((a, b) => a + b, 0) / cpu.length) * 100) / 100;
|
||||
}
|
||||
if (mem.length) {
|
||||
base.mem_min = Math.min(...mem); base.mem_max = Math.max(...mem);
|
||||
base.mem_avg = Math.round((mem.reduce((a, b) => a + b, 0) / mem.length) * 100) / 100;
|
||||
}
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
export async function monitorStop(args: Args): Promise<unknown> {
|
||||
const id = args['id'] as string;
|
||||
if (!id) throw new CapabilityError(ErrorCodes.PARAM_INVALID, '需要 id');
|
||||
const rec = monitors.get(id);
|
||||
if (!rec) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `没有正在运行的监控: ${id}`, { running: [...monitors.keys()] });
|
||||
}
|
||||
clearInterval(rec.timer);
|
||||
monitors.delete(id);
|
||||
const report = (await monitorReport({ id })) as { samples: number; summary: Record<string, unknown> };
|
||||
return { id, stopped: true, samples: report.samples, summary: report.summary, file: rec.file };
|
||||
}
|
||||
|
||||
export async function monitorList(_args: Args): Promise<unknown> {
|
||||
const running = [...monitors.values()].map((r) => ({
|
||||
id: r.id, source: r.source, target: r.target, interval_ms: r.intervalMs,
|
||||
samples: r.samples, started_at: r.createdAt, file: r.file, last_error: r.lastError ?? undefined,
|
||||
}));
|
||||
const dir = monitorDir();
|
||||
let files: string[] = [];
|
||||
try {
|
||||
files = (await import('node:fs')).readdirSync(dir).filter((f) => f.endsWith('.jsonl'));
|
||||
} catch {
|
||||
/* 目录不存在即无历史 */
|
||||
}
|
||||
return {
|
||||
running,
|
||||
running_count: running.length,
|
||||
history_files: files.map((f) => f.replace(/\.jsonl$/, '')),
|
||||
};
|
||||
}
|
||||
|
||||
/** 供 monitor.delete:删除历史文件(不影响运行中的实例)。 */
|
||||
export async function monitorDelete(args: Args): Promise<unknown> {
|
||||
const id = args['id'] as string;
|
||||
if (!id) throw new CapabilityError(ErrorCodes.PARAM_INVALID, '需要 id');
|
||||
if (monitors.has(id)) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, '该监控仍在运行,请先 stop', { hint: `monitor.stop {"id":"${id}"}` });
|
||||
}
|
||||
const file = join(monitorDir(), `${id}.jsonl`);
|
||||
if (!existsSync(file)) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `没有历史文件: ${id}`);
|
||||
}
|
||||
rmSync(file, { force: true });
|
||||
return { id, deleted: true, file };
|
||||
}
|
||||
|
||||
// log.query 与 monitor 同属"状态"方向,放在同一能力文件里导出
|
||||
export { logQuery };
|
||||
export type { LogLine };
|
||||
@@ -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 脚本一律经此函数。
|
||||
*/
|
||||
@@ -93,7 +93,7 @@ export function psLit(v: string): string {
|
||||
*
|
||||
* ⚠️ 两个真机教训都固化在这里:
|
||||
* 1) **账号名必须用 WindowsIdentity 取**,不要用 "$env:USERDOMAIN\$env:USERNAME" 拼 ——
|
||||
* 在 TS 模板串里 `\$` 会被当转义吃掉反斜杠,拼出「机器名<user>」这种不可解析的账号,
|
||||
* 在 TS 模板串里 `\$` 会被当转义吃掉反斜杠,拼出「机器名+纯数字」这种不可解析的账号,
|
||||
* Register-ScheduledTask 报 HRESULT 0x80070534「No mapping between account names...」。
|
||||
* 2) 复用同一实现:此前回滚/延时两处各写一份,改了一处漏了另一处(真机踩过)。
|
||||
*/
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { guardPath } from './fs.js';
|
||||
import { readFileSync, statSync } from 'node:fs';
|
||||
import { spawn } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
@@ -6,7 +7,7 @@ import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { IS_WINDOWS, execCommand } from '../util/exec.js';
|
||||
import { currentCertFingerprint } from '../certs.js';
|
||||
import { loadAgentConfig } from '../config.js';
|
||||
import { readAudit, readAuditAll, verifyAudit } from '../audit.js';
|
||||
import { readAudit, readAuditAll, verifyAudit, computeAuditHead, anchorAudit, compareWithAnchors } from '../audit.js';
|
||||
import { computeMetrics } from '../metrics.js';
|
||||
import { allPsShellStats } from '../util/ps-helper.js';
|
||||
import { startTask } from './task.js';
|
||||
@@ -526,15 +527,36 @@ async function scheduleWindowsRestart(p: RestartParams): Promise<unknown> {
|
||||
const TASK = `nodeagent-selfrestart-${Date.now()}`;
|
||||
const ps = [
|
||||
`$ErrorActionPreference='SilentlyContinue'`,
|
||||
// 清理历史 selfrestart 任务(保留 Running 的 —— 那正是当前 agent 的宿主)
|
||||
`Get-ScheduledTask -TaskName 'nodeagent-selfrestart*' | Where-Object { $_.State -ne 'Running' } | Unregister-ScheduledTask -Confirm:$false`,
|
||||
// 清理历史 selfrestart 任务。
|
||||
//
|
||||
// ⚠️ 为什么不能简单清所有非 Running 的:重启任务的 cmd 会**一直持有新 agent 进程**,
|
||||
// 所以任务长期处于 Running;若把 Running 的全清掉,会连当前 agent 的宿主一起杀
|
||||
// (= 自杀,表现为"restart 成功但 agent 消失")。原实现只清非 Running,
|
||||
// 代价是真机累积了 3 个 Running 僵尸(2026-10-09 实测)。
|
||||
//
|
||||
// 现策略:非 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 能长期运行
|
||||
`$s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Seconds 0)`,
|
||||
// ⚠️ 必须用最高权限:agent 通常以管理员运行,普通权限的 taskkill 杀不掉它,
|
||||
// ① 先试「最高权限」:agent 通常以管理员运行,普通权限的 taskkill 杀不掉它,
|
||||
// 否则会出现「新进程起来了但端口被占(EADDRINUSE)」。
|
||||
`$p = New-ScheduledTaskPrincipal -UserId $env:USERNAME -RunLevel Highest -LogonType Interactive`,
|
||||
`Register-ScheduledTask -TaskName '${TASK}' -Action $a -Settings $s -Principal $p -Force | Out-Null`,
|
||||
// ② 降级:**非管理员身份下 Highest 会被系统拒绝**(真机 2026-10-09 坐实:
|
||||
// agent 以普通用户运行时,deploy/restart 一律报"注册重启任务失败 exit=1")。
|
||||
// 此时不指定 -Principal —— 默认继承当前用户权限级别(Limited),
|
||||
// 注册能成功,且同权限下 taskkill 自己完全够用。
|
||||
`if ((Get-ScheduledTask -TaskName '${TASK}' -ErrorAction SilentlyContinue) -eq $null) { Register-ScheduledTask -TaskName '${TASK}' -Action $a -Settings $s -Force | Out-Null }`,
|
||||
// 校验注册真的成功(SilentlyContinue 会吞错,必须显式确认)
|
||||
`if ((Get-ScheduledTask -TaskName '${TASK}' -ErrorAction SilentlyContinue) -eq $null) { Write-Output 'register-failed'; exit 1 }`,
|
||||
`Start-ScheduledTask -TaskName '${TASK}'`,
|
||||
@@ -600,6 +622,31 @@ export async function auditVerify(_args: Args): Promise<unknown> {
|
||||
return verifyAudit();
|
||||
}
|
||||
|
||||
/**
|
||||
* v2.0.0:返回审计链的链头(只读)。
|
||||
*
|
||||
* 用途:控制端定期拉取并存到**链外**(Mac 本地 / 另一台机器)—— 这是"外部锚定"的一半。
|
||||
* compare=true 时附带与最近一条锚点的比对结论(另一半)。
|
||||
*/
|
||||
export async function auditHead(args: Args): Promise<unknown> {
|
||||
const head = computeAuditHead();
|
||||
if (args['compare'] !== true) return head;
|
||||
return { ...head, comparison: compareWithAnchors(args['anchor_path'] as string | undefined) };
|
||||
}
|
||||
|
||||
/**
|
||||
* v2.0.0:把当前链头**追加**写到锚点文件(默认 <数据目录>/audit-anchors.jsonl)。
|
||||
*
|
||||
* 为什么是追加而不是覆盖:历史锚点一旦写成就不可被后续覆盖,否则攻击者重写链后
|
||||
* 再"刷新"锚点即可抹掉痕迹。追加式让每次锚定都留痕。
|
||||
* path 若指定,仍受 fs_roots 白名单约束(不新开写入面)。
|
||||
*/
|
||||
export async function auditAnchor(args: Args): Promise<unknown> {
|
||||
const raw = args['path'] as string | undefined;
|
||||
const path = raw ? guardPath(raw) : undefined;
|
||||
return anchorAudit({ ...(path ? { path } : {}), ...(args['note'] ? { note: String(args['note']) } : {}) });
|
||||
}
|
||||
|
||||
/** v13:成功指标聚合(对齐 PRD 2.2)—— 数据源为审计日志,无需额外埋点。 */
|
||||
export async function metricsReport(args: Args): Promise<unknown> {
|
||||
const since = args['since'] as number | undefined;
|
||||
|
||||
@@ -132,6 +132,13 @@ function getTask(id: string): TaskRecord {
|
||||
|
||||
// ---------------- system.task.list ----------------
|
||||
|
||||
/** v2.0.0:正在跑的任务数(连接层"有空闲断开但不动长任务"的判据)。 */
|
||||
export function runningTaskCount(): number {
|
||||
let n = 0;
|
||||
for (const t of tasks.values()) if (t.state === 'running') n += 1;
|
||||
return n;
|
||||
}
|
||||
|
||||
export async function taskList(_args: Args): Promise<unknown> {
|
||||
return {
|
||||
tasks: [...tasks.values()].map((t) => ({
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 枚举可见顶层窗口 → JSON(**不含 prelude**,供常驻助手与一次性路径复用)
|
||||
*
|
||||
* 2026-10 从 window.ts 抽出(结构拆分,**内容逐字节不变**)。
|
||||
* ⚠️ WIN_HELPER_PRELUDE 是本段与另外几段的拼接 —— 改动会直接影响常驻 PS 助手。
|
||||
* 拆分以 tests/unit/win-prelude.test.mjs + prelude sha256 比对双重把关。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 枚举可见顶层窗口 → JSON(**不含 prelude**,供常驻助手与一次性路径复用)。
|
||||
* 注意:本段会被送进常驻进程执行,**严禁出现 exit**(会杀掉助手进程)。
|
||||
*/
|
||||
export const LIST_BODY = `
|
||||
$fg = [NAWin32]::GetForegroundWindow()
|
||||
$list = New-Object System.Collections.ArrayList
|
||||
$cb = [NAWin32+EnumProc]{
|
||||
param($h, $l)
|
||||
if (-not [NAWin32]::IsWindowVisible($h)) { return $true }
|
||||
$sb = New-Object System.Text.StringBuilder 512
|
||||
[void][NAWin32]::GetWindowTextW($h, $sb, 512)
|
||||
$title = $sb.ToString()
|
||||
if ([string]::IsNullOrWhiteSpace($title)) { return $true }
|
||||
$r = New-Object NAWin32+RECT
|
||||
if (-not [NAWin32]::GetWindowRect($h, [ref]$r)) { return $true }
|
||||
$w = $r.Right - $r.Left; $ht = $r.Bottom - $r.Top
|
||||
if ($w -le 0 -or $ht -le 0) { return $true }
|
||||
# ⚠️ 变量名不能叫 $pid —— 它是 PowerShell 的自动变量(当前进程 ID),赋值会被忽略,
|
||||
# 导致所有窗口都报成 powershell 进程(真机踩过)。
|
||||
$procId = 0
|
||||
[void][NAWin32]::GetWindowThreadProcessId($h, [ref]$procId)
|
||||
$pn = ''
|
||||
try { $pn = (Get-Process -Id $procId -ErrorAction Stop).ProcessName } catch {}
|
||||
[void]$list.Add([pscustomobject]@{
|
||||
hwnd = ('0x{0:X}' -f $h.ToInt64()); title = $title; process = $pn; pid = $procId
|
||||
x = $r.Left; y = $r.Top; width = $w; height = $ht
|
||||
is_foreground = ($h -eq $fg); is_minimized = [NAWin32]::IsIconic($h)
|
||||
})
|
||||
return $true
|
||||
}
|
||||
[void][NAWin32]::EnumWindows($cb, [IntPtr]::Zero)
|
||||
$list | ConvertTo-Json -Compress
|
||||
`;
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* 图像模板匹配预加载段(ZNCC 粗到精)
|
||||
*
|
||||
* 2026-10 从 window.ts 抽出(结构拆分,**内容逐字节不变**)。
|
||||
* ⚠️ WIN_HELPER_PRELUDE 是本段与另外几段的拼接 —— 改动会直接影响常驻 PS 助手。
|
||||
* 拆分以 tests/unit/win-prelude.test.mjs + prelude sha256 比对双重把关。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 图像模板匹配预加载段(v15)。
|
||||
*
|
||||
* 动机:UIA 靠控件树、OCR 靠文字 —— **纯图标/无文字的控件**两者都抓不到(审查报告 §5.2 指出的盲区)。
|
||||
* 做法:把模板图与屏幕区域都转灰度,做**零均值归一化互相关(ZNCC)**;
|
||||
* 全分辨率逐像素太慢(3440×1440 屏 × 64×64 模板 ≈ 190 亿次),故**粗到精**:
|
||||
* ① 1/4 分辨率全图扫描(约 8000 万次)取候选
|
||||
* ② 对候选在全分辨率 ±10px 邻域精修
|
||||
* 编译一次常驻(随助手预加载),运行时纯 native 循环。
|
||||
* 亮度和对比度无关(ZNCC 归一化),故对主题/亮度变化不敏感。
|
||||
*/
|
||||
export const IMAGE_MATCH_PRELUDE = `
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
# ⚠️ Add-Type 的老坑:-AssemblyName 只把程序集加载进 PowerShell 会话,
|
||||
# 编译 C# 时还必须显式 -ReferencedAssemblies,否则报「命名空间 System.Drawing
|
||||
# 中不存在 Imaging」。真机踩过:编译失败会让整个助手起不来,并伴随误导性的
|
||||
# 「找不到类型 System.Windows.Forms.SystemInformation」二次错误。
|
||||
Add-Type @"
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
public class NAImageMatch {
|
||||
public class Hit { public int x; public int y; public int w; public int h; public double score; }
|
||||
|
||||
private static byte[] Gray(Bitmap src, out int w, out int h) {
|
||||
w = src.Width; h = src.Height;
|
||||
var dst = new byte[w * h];
|
||||
var rect = new Rectangle(0, 0, w, h);
|
||||
Bitmap bmp = src.PixelFormat == PixelFormat.Format24bppRgb ? src : src.Clone(rect, PixelFormat.Format24bppRgb);
|
||||
var data = bmp.LockBits(rect, ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb);
|
||||
try {
|
||||
int stride = data.Stride;
|
||||
var buf = new byte[stride * h];
|
||||
Marshal.Copy(data.Scan0, buf, 0, buf.Length);
|
||||
for (int y = 0; y < h; y++) {
|
||||
int row = y * stride;
|
||||
for (int x = 0; x < w; x++) {
|
||||
int i = row + x * 3;
|
||||
// BGR -> luma (integer approximation of ITU-R BT.601)
|
||||
dst[y * w + x] = (byte)((buf[i + 2] * 77 + buf[i + 1] * 150 + buf[i] * 29) >> 8);
|
||||
}
|
||||
}
|
||||
} finally { bmp.UnlockBits(data); if (bmp != src) bmp.Dispose(); }
|
||||
return dst;
|
||||
}
|
||||
|
||||
// Area-average downscale (coarse search layer)
|
||||
private static byte[] Downscale(byte[] src, int w, int h, int f, out int ow, out int oh) {
|
||||
ow = w / f; oh = h / f;
|
||||
var dst = new byte[ow * oh];
|
||||
for (int y = 0; y < oh; y++) {
|
||||
for (int x = 0; x < ow; x++) {
|
||||
int sum = 0;
|
||||
for (int dy = 0; dy < f; dy++) {
|
||||
int row = (y * f + dy) * w;
|
||||
for (int dx = 0; dx < f; dx++) sum += src[row + x * f + dx];
|
||||
}
|
||||
dst[y * ow + x] = (byte)(sum / (f * f));
|
||||
}
|
||||
}
|
||||
return dst;
|
||||
}
|
||||
|
||||
// Zero-mean normalized cross correlation; 1.0 = identical
|
||||
private static double Zncc(byte[] S, int sw, int sx, int sy, byte[] T, int tw, int th) {
|
||||
int n = tw * th;
|
||||
double sumT = 0;
|
||||
for (int i = 0; i < n; i++) sumT += T[i];
|
||||
double meanT = sumT / n;
|
||||
|
||||
double sumS = 0;
|
||||
for (int y = 0; y < th; y++) {
|
||||
int row = (sy + y) * sw + sx;
|
||||
for (int x = 0; x < tw; x++) sumS += S[row + x];
|
||||
}
|
||||
double meanS = sumS / n;
|
||||
|
||||
double num = 0, dS = 0, dT = 0;
|
||||
for (int y = 0; y < th; y++) {
|
||||
int row = (sy + y) * sw + sx;
|
||||
int trow = y * tw;
|
||||
for (int x = 0; x < tw; x++) {
|
||||
double a = S[row + x] - meanS;
|
||||
double b = T[trow + x] - meanT;
|
||||
num += a * b; dS += a * a; dT += b * b;
|
||||
}
|
||||
}
|
||||
double den = Math.Sqrt(dS * dT);
|
||||
if (den < 1e-9) return 0;
|
||||
return num / den;
|
||||
}
|
||||
|
||||
/**
|
||||
// (comment removed: ASCII only)
|
||||
// (comment removed: ASCII only)
|
||||
*/
|
||||
public static string Match(string screenPath, string templatePath, int maxResults, double threshold, int searchX, int searchY, int searchW, int searchH) {
|
||||
var hits = new List<Hit>();
|
||||
using (var screenBmp = new Bitmap(screenPath))
|
||||
using (var tmplBmp = new Bitmap(templatePath)) {
|
||||
int sw, sh, tw, th;
|
||||
var S0 = Gray(screenBmp, out sw, out sh);
|
||||
var T0 = Gray(tmplBmp, out tw, out th);
|
||||
if (tw >= sw || th >= sh) return "[]";
|
||||
|
||||
// Guard: a flat template carries no gradient information, so correlation is
|
||||
// undefined (ZNCC denominator ~ 0). Report it explicitly instead of silently
|
||||
// returning no hits - a uniform crop usually means the wrong region was picked.
|
||||
//
|
||||
// NOTE: build all JSON/string literals with the char constant Q.
|
||||
// Writing a backslash-quote inside a TS template literal collapses it to a bare
|
||||
// quote and breaks the C# compile - this trap bit this file twice.
|
||||
const char Q = '"';
|
||||
var ci0 = System.Globalization.CultureInfo.InvariantCulture;
|
||||
double tSum = 0, tSum2 = 0;
|
||||
for (int i = 0; i < T0.Length; i++) { tSum += T0[i]; tSum2 += (double)T0[i] * T0[i]; }
|
||||
double tMean = tSum / T0.Length;
|
||||
double tVar = (tSum2 / T0.Length) - (tMean * tMean);
|
||||
if (tVar < 4.0) {
|
||||
return "[" + "{" + Q + "__error" + Q + ":" + Q + "template_has_no_contrast" + Q
|
||||
+ "," + Q + "variance" + Q + ":" + tVar.ToString("F2", ci0) + "}" + "]";
|
||||
}
|
||||
|
||||
// Optional search window: crop a sub-image, then add the offset back to hit coords
|
||||
int ox = 0, oy = 0;
|
||||
if (searchW > 0 && searchH > 0) {
|
||||
ox = Math.Max(0, Math.Min(searchX, sw - 1));
|
||||
oy = Math.Max(0, Math.Min(searchY, sh - 1));
|
||||
int w = Math.Min(searchW, sw - ox), h = Math.Min(searchH, sh - oy);
|
||||
var sub = new byte[w * h];
|
||||
for (int y = 0; y < h; y++) Array.Copy(S0, (oy + y) * sw + ox, sub, y * w, w);
|
||||
S0 = sub; sw = w; sh = h;
|
||||
}
|
||||
|
||||
const int F = 4;
|
||||
int sw4, sh4, tw4, th4;
|
||||
var S4 = Downscale(S0, sw, sh, F, out sw4, out sh4);
|
||||
var T4 = Downscale(T0, tw, th, F, out tw4, out th4);
|
||||
|
||||
// (1) coarse scan: collect candidates.
|
||||
// NOTE: the pre-filter must be MUCH looser than the final threshold.
|
||||
// Downsampling lowers the correlation of the true position (typically 0.7-0.85
|
||||
// for a 0.9+ full-res match), so a tight pre-filter discards the right answer
|
||||
// before refinement ever runs (this exact bug made every match return empty).
|
||||
var cands = new List<Hit>();
|
||||
if (tw4 > 0 && th4 > 0 && tw4 <= sw4 && th4 <= sh4) {
|
||||
double preFilter = Math.Max(0.30, threshold - 0.30);
|
||||
double bestCoarse = -1; int bx4 = 0, by4 = 0;
|
||||
for (int y = 0; y <= sh4 - th4; y++) {
|
||||
for (int x = 0; x <= sw4 - tw4; x++) {
|
||||
double sc = Zncc(S4, sw4, x, y, T4, tw4, th4);
|
||||
if (sc > bestCoarse) { bestCoarse = sc; bx4 = x; by4 = y; }
|
||||
if (sc >= preFilter) cands.Add(new Hit { x = x * F, y = y * F, w = tw, h = th, score = sc });
|
||||
}
|
||||
}
|
||||
// Belt and braces: always keep the coarse best, even if it missed the pre-filter
|
||||
if (bestCoarse > 0) {
|
||||
bool seen = false;
|
||||
foreach (var c in cands) { if (Math.Abs(c.x - bx4 * F) < F && Math.Abs(c.y - by4 * F) < F) { seen = true; break; } }
|
||||
if (!seen) cands.Add(new Hit { x = bx4 * F, y = by4 * F, w = tw, h = th, score = bestCoarse });
|
||||
}
|
||||
}
|
||||
cands.Sort((a, b) => b.score.CompareTo(a.score));
|
||||
if (cands.Count > 40) cands.RemoveRange(40, cands.Count - 40);
|
||||
|
||||
// (2) refine at full resolution within a +/-F*2 neighborhood
|
||||
foreach (var c in cands) {
|
||||
double best = -1; int bx = c.x, by = c.y;
|
||||
for (int dy = -F * 2; dy <= F * 2; dy++) {
|
||||
for (int dx = -F * 2; dx <= F * 2; dx++) {
|
||||
int x = c.x + dx, y = c.y + dy;
|
||||
if (x < 0 || y < 0 || x + tw > sw || y + th > sh) continue;
|
||||
double sc = Zncc(S0, sw, x, y, T0, tw, th);
|
||||
if (sc > best) { best = sc; bx = x; by = y; }
|
||||
}
|
||||
}
|
||||
if (best >= threshold) hits.Add(new Hit { x = bx + ox, y = by + oy, w = tw, h = th, score = best });
|
||||
}
|
||||
|
||||
// (3) de-dup: non-maximum suppression (keep best within 8px)
|
||||
hits.Sort((a, b) => b.score.CompareTo(a.score));
|
||||
var final = new List<Hit>();
|
||||
foreach (var h in hits) {
|
||||
bool dup = false;
|
||||
foreach (var f in final) {
|
||||
if (Math.Abs(f.x - h.x) < 8 && Math.Abs(f.y - h.y) < 8) { dup = true; break; }
|
||||
}
|
||||
if (!dup) final.Add(h);
|
||||
if (final.Count >= maxResults) break;
|
||||
}
|
||||
|
||||
var parts = new List<string>();
|
||||
foreach (var h in final) {
|
||||
// Build JSON by concatenation; reuse the shared Q / ci0 declared above.
|
||||
var ci = ci0;
|
||||
parts.Add("{" + Q + "x" + Q + ":" + h.x.ToString(ci) + "," + Q + "y" + Q + ":" + h.y.ToString(ci)
|
||||
+ "," + Q + "width" + Q + ":" + h.w.ToString(ci) + "," + Q + "height" + Q + ":" + h.h.ToString(ci)
|
||||
+ "," + Q + "score" + Q + ":" + h.score.ToString("F4", ci) + "}");
|
||||
}
|
||||
return "[" + String.Join(",", parts.ToArray()) + "]";
|
||||
}
|
||||
}
|
||||
|
||||
// Capture a screen region to a file (avoids extra process round-trips)
|
||||
public static string Capture(string outPath, int x, int y, int w, int h) {
|
||||
using (var bmp = new Bitmap(w, h))
|
||||
using (var g = Graphics.FromImage(bmp)) {
|
||||
g.CopyFromScreen(x, y, 0, 0, new Size(w, h));
|
||||
bmp.Save(outPath, ImageFormat.Png);
|
||||
}
|
||||
return outPath;
|
||||
}
|
||||
}
|
||||
"@ -ReferencedAssemblies System.Drawing
|
||||
`;
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* OCR 引擎预加载段:截图 / DPI / WinRT 类型与 Await 辅助函数
|
||||
*
|
||||
* 2026-10 从 window.ts 抽出(结构拆分,**内容逐字节不变**)。
|
||||
* ⚠️ WIN_HELPER_PRELUDE 是本段与另外几段的拼接 —— 改动会直接影响常驻 PS 助手。
|
||||
* 拆分以 tests/unit/win-prelude.test.mjs + prelude sha256 比对双重把关。
|
||||
*/
|
||||
|
||||
/**
|
||||
* OCR 引擎预加载段(v14):截图 / DPI / WinRT 类型与 Await 辅助函数。
|
||||
* 注意:WinRT 类型加载用 try/catch 包住 —— 个别环境缺组件时只应让 OCR 退化,
|
||||
* 不该让整个助手(连带 window.list/focus/UIA)起不来。
|
||||
*/
|
||||
export const OCR_PRELUDE = `
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
try {
|
||||
Add-Type -AssemblyName System.Runtime.WindowsRuntime
|
||||
Add-Type -TypeDefinition 'using System.Runtime.InteropServices; public class NADPI { [DllImport("user32.dll")] public static extern bool SetProcessDPIAware(); }'
|
||||
[void][NADPI]::SetProcessDPIAware()
|
||||
$null = [Windows.Media.Ocr.OcrEngine, Windows.Foundation, ContentType=WindowsRuntime]
|
||||
$null = [Windows.Graphics.Imaging.BitmapDecoder, Windows.Foundation, ContentType=WindowsRuntime]
|
||||
$null = [Windows.Storage.Streams.InMemoryRandomAccessStream, Windows.Foundation, ContentType=WindowsRuntime]
|
||||
$null = [Windows.Storage.Streams.DataWriter, Windows.Foundation, ContentType=WindowsRuntime]
|
||||
$null = [Windows.Graphics.Imaging.SoftwareBitmap, Windows.Foundation, ContentType=WindowsRuntime]
|
||||
$script:asTaskGeneric = ([System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object { $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation\`1' })[0]
|
||||
function global:Await($WinRtTask, $ResultType) {
|
||||
$asTask = $script:asTaskGeneric.MakeGenericMethod($ResultType)
|
||||
$netTask = $asTask.Invoke($null, @($WinRtTask))
|
||||
$netTask.Wait(-1) | Out-Null
|
||||
$netTask.Result
|
||||
}
|
||||
$script:ocrReady = $true
|
||||
} catch {
|
||||
$script:ocrReady = $false
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* UIA 控件树预加载段(含 Where 谓词与属性读取)
|
||||
*
|
||||
* 2026-10 从 window.ts 抽出(结构拆分,**内容逐字节不变**)。
|
||||
* ⚠️ WIN_HELPER_PRELUDE 是本段与另外几段的拼接 —— 改动会直接影响常驻 PS 助手。
|
||||
* 拆分以 tests/unit/win-prelude.test.mjs + prelude sha256 比对双重把关。
|
||||
*/
|
||||
|
||||
/**
|
||||
* v14:常驻助手预加载段 —— 这些内容**只在助手启动时执行一次**。
|
||||
* 过去每次调用都重新编译 C# / 加载程序集,真机实测占单次耗时的绝大部分
|
||||
* (window.list 1267ms / window.focus 1631ms,其中「起进程+编译」是固定成本)。
|
||||
*/
|
||||
export const UIA_ASSEMBLY_PRELUDE = `
|
||||
Add-Type -AssemblyName UIAutomationClient
|
||||
Add-Type -AssemblyName UIAutomationTypes
|
||||
Add-Type -AssemblyName WindowsBase
|
||||
function Get-WinByTitle([string]$re) {
|
||||
$root = [System.Windows.Automation.AutomationElement]::RootElement
|
||||
$cond = New-Object System.Windows.Automation.PropertyCondition(
|
||||
[System.Windows.Automation.AutomationElement]::ControlTypeProperty,
|
||||
[System.Windows.Automation.ControlType]::Window)
|
||||
$wins = $root.FindAll([System.Windows.Automation.TreeScope]::Children, $cond)
|
||||
foreach ($w in $wins) {
|
||||
try {
|
||||
$t = $w.Current.Name
|
||||
if ($t -and $t -match $re) { return $w }
|
||||
} catch {}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# v14:按子串在给定 scope 内遍历查找(FindAll + 子串过滤)。
|
||||
# UIA 的 PropertyCondition 只能精确匹配,故必须遍历后过滤;遍历规模设上限防超大 UI 树卡死。
|
||||
# v2.0.0:读取元素的 UIA 属性(v2.0.0 语义属性化)。
|
||||
# 为什么逐个 try:ValuePattern / SelectionItemPattern / TogglePattern 并非所有控件
|
||||
# 都支持(不支持时 GetSupportedPattern 返回 false 或抛异常),而且**部分应用
|
||||
# (Electron / 游戏 UI)根本给不出值** —— 拿不到就留空,由上层判"未知"而非"false"。
|
||||
function Get-UiaProps($e) {
|
||||
$enabled = $true
|
||||
try { $enabled = [bool]$e.Current.IsEnabled } catch {}
|
||||
$value = $null
|
||||
try {
|
||||
$vp = $e.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern)
|
||||
if ($vp -ne $null) { $value = $vp.Current.Value }
|
||||
} catch {}
|
||||
$selected = $null
|
||||
try {
|
||||
$sp = $e.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern)
|
||||
if ($sp -ne $null) { $selected = [bool]$sp.Current.IsSelected }
|
||||
} catch {}
|
||||
$toggle = $null
|
||||
try {
|
||||
$tp = $e.GetCurrentPattern([System.Windows.Automation.TogglePattern]::Pattern)
|
||||
if ($tp -ne $null) { $toggle = "$($tp.Current.ToggleState)" }
|
||||
} catch {}
|
||||
return @{ enabled = $enabled; value = $value; selected = $selected; toggle = $toggle }
|
||||
}
|
||||
|
||||
function Test-UiaWhere($props, $where) {
|
||||
if ($where -eq $null) { return $true }
|
||||
foreach ($k in $where.Keys) {
|
||||
$want = $where[$k]
|
||||
switch ($k) {
|
||||
'enabled' { if ($props.enabled -ne [bool]$want) { return $false } }
|
||||
'selected' { if ($null -eq $props.selected -or $props.selected -ne [bool]$want) { return $false } }
|
||||
'value' { if ($props.value -eq $null -or $props.value -notlike [string]$want) { return $false } }
|
||||
default { } # 未知键忽略(Node 侧已白名单校验)
|
||||
'toggle' { if ($props.toggle -eq $null -or $props.toggle -notlike [string]$want) { return $false } }
|
||||
}
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
function Find-UiaByText($scope, [string]$text, [int]$limit, [string]$ct, $where) {
|
||||
$cond = [System.Windows.Automation.Condition]::TrueCondition
|
||||
if ($ct -ne '') {
|
||||
$ctObj = $null
|
||||
try { $ctObj = [System.Windows.Automation.ControlType]::$ct } catch {}
|
||||
if ($ctObj -ne $null) {
|
||||
$cond = New-Object System.Windows.Automation.PropertyCondition(
|
||||
[System.Windows.Automation.AutomitionElement]::ControlTypeProperty, $ctObj)
|
||||
}
|
||||
}
|
||||
$found = $scope.FindAll([System.Windows.Automation.TreeScope]::Descendants, $cond)
|
||||
$out = New-Object System.Collections.ArrayList
|
||||
$scanned = 0
|
||||
foreach ($e in $found) {
|
||||
if ($out.Count -ge $limit) { break }
|
||||
$scanned++
|
||||
if ($scanned -gt 20000) { break }
|
||||
try {
|
||||
$nm = $e.Current.Name
|
||||
if ([string]::IsNullOrEmpty($nm)) { continue }
|
||||
if ($text -ne '' -and $nm.IndexOf($text, [System.StringComparison]::OrdinalIgnoreCase) -lt 0) { continue }
|
||||
$r = $e.Current.BoundingRectangle
|
||||
if ($r.Width -le 0 -or $r.Height -le 0) { continue }
|
||||
$props = Get-UiaProps $e
|
||||
if (-not (Test-UiaWhere $props $where)) { continue }
|
||||
[void]$out.Add([pscustomobject]@{
|
||||
name = $nm
|
||||
control_type = ($e.Current.ControlType.ProgrammaticName -replace 'ControlType\\.','')
|
||||
automation_id = $e.Current.AutomationId
|
||||
x = [int]($r.Left + $r.Width / 2)
|
||||
y = [int]($r.Top + $r.Height / 2)
|
||||
left = [int]$r.Left; top = [int]$r.Top
|
||||
width = [int]$r.Width; height = [int]$r.Height
|
||||
enabled = $props.enabled
|
||||
value = $props.value
|
||||
selected = $props.selected
|
||||
toggle = $props.toggle
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
return ,$out
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Win32 窗口枚举与矩形读取(供 window.list / window.focus 复用)
|
||||
*
|
||||
* 2026-10 从 window.ts 抽出(结构拆分,**内容逐字节不变**)。
|
||||
* ⚠️ WIN_HELPER_PRELUDE 是本段与另外几段的拼接 —— 改动会直接影响常驻 PS 助手。
|
||||
* 拆分以 tests/unit/win-prelude.test.mjs + prelude sha256 比对双重把关。
|
||||
*/
|
||||
|
||||
export const WIN32_PRELUDE = `
|
||||
Add-Type @"
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Runtime.InteropServices;
|
||||
public class NAWin32 {
|
||||
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; }
|
||||
public delegate bool EnumProc(IntPtr hWnd, IntPtr lParam);
|
||||
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr p);
|
||||
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);
|
||||
[DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);
|
||||
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowTextW(IntPtr h, StringBuilder s, int n);
|
||||
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);
|
||||
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
||||
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
|
||||
[DllImport("user32.dll")] public static extern void mouse_event(uint f, int dx, int dy, uint d, UIntPtr e);
|
||||
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
|
||||
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);
|
||||
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
|
||||
}
|
||||
"@
|
||||
`;
|
||||
@@ -6,6 +6,12 @@ import { mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { macScreenFind, macWindowFocus, macWindowList } from './darwin.js';
|
||||
|
||||
import { WIN32_PRELUDE } from './win-ps/prelude-win32.js';
|
||||
import { LIST_BODY } from './win-ps/body-list.js';
|
||||
import { UIA_ASSEMBLY_PRELUDE } from './win-ps/prelude-uia.js';
|
||||
import { OCR_PRELUDE } from './win-ps/prelude-ocr.js';
|
||||
import { IMAGE_MATCH_PRELUDE } from './win-ps/prelude-image.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
function toArray<T>(v: T | T[] | null | undefined): T[] {
|
||||
@@ -80,381 +86,11 @@ function extractJson<T>(raw: string): T | null {
|
||||
}
|
||||
|
||||
/** Win32 窗口枚举与矩形读取(供 window.list / window.focus 复用)。 */
|
||||
const WIN32_PRELUDE = `
|
||||
Add-Type @"
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Runtime.InteropServices;
|
||||
public class NAWin32 {
|
||||
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; }
|
||||
public delegate bool EnumProc(IntPtr hWnd, IntPtr lParam);
|
||||
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr p);
|
||||
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);
|
||||
[DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);
|
||||
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowTextW(IntPtr h, StringBuilder s, int n);
|
||||
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);
|
||||
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
||||
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
|
||||
[DllImport("user32.dll")] public static extern void mouse_event(uint f, int dx, int dy, uint d, UIntPtr e);
|
||||
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
|
||||
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);
|
||||
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
|
||||
}
|
||||
"@
|
||||
`;
|
||||
|
||||
/**
|
||||
* 枚举可见顶层窗口 → JSON(**不含 prelude**,供常驻助手与一次性路径复用)。
|
||||
* 注意:本段会被送进常驻进程执行,**严禁出现 exit**(会杀掉助手进程)。
|
||||
*/
|
||||
const LIST_BODY = `
|
||||
$fg = [NAWin32]::GetForegroundWindow()
|
||||
$list = New-Object System.Collections.ArrayList
|
||||
$cb = [NAWin32+EnumProc]{
|
||||
param($h, $l)
|
||||
if (-not [NAWin32]::IsWindowVisible($h)) { return $true }
|
||||
$sb = New-Object System.Text.StringBuilder 512
|
||||
[void][NAWin32]::GetWindowTextW($h, $sb, 512)
|
||||
$title = $sb.ToString()
|
||||
if ([string]::IsNullOrWhiteSpace($title)) { return $true }
|
||||
$r = New-Object NAWin32+RECT
|
||||
if (-not [NAWin32]::GetWindowRect($h, [ref]$r)) { return $true }
|
||||
$w = $r.Right - $r.Left; $ht = $r.Bottom - $r.Top
|
||||
if ($w -le 0 -or $ht -le 0) { return $true }
|
||||
# ⚠️ 变量名不能叫 $pid —— 它是 PowerShell 的自动变量(当前进程 ID),赋值会被忽略,
|
||||
# 导致所有窗口都报成 powershell 进程(真机踩过)。
|
||||
$procId = 0
|
||||
[void][NAWin32]::GetWindowThreadProcessId($h, [ref]$procId)
|
||||
$pn = ''
|
||||
try { $pn = (Get-Process -Id $procId -ErrorAction Stop).ProcessName } catch {}
|
||||
[void]$list.Add([pscustomobject]@{
|
||||
hwnd = ('0x{0:X}' -f $h.ToInt64()); title = $title; process = $pn; pid = $procId
|
||||
x = $r.Left; y = $r.Top; width = $w; height = $ht
|
||||
is_foreground = ($h -eq $fg); is_minimized = [NAWin32]::IsIconic($h)
|
||||
})
|
||||
return $true
|
||||
}
|
||||
[void][NAWin32]::EnumWindows($cb, [IntPtr]::Zero)
|
||||
$list | ConvertTo-Json -Compress
|
||||
`;
|
||||
|
||||
/**
|
||||
* v14:常驻助手预加载段 —— 这些内容**只在助手启动时执行一次**。
|
||||
* 过去每次调用都重新编译 C# / 加载程序集,真机实测占单次耗时的绝大部分
|
||||
* (window.list 1267ms / window.focus 1631ms,其中「起进程+编译」是固定成本)。
|
||||
*/
|
||||
const UIA_ASSEMBLY_PRELUDE = `
|
||||
Add-Type -AssemblyName UIAutomationClient
|
||||
Add-Type -AssemblyName UIAutomationTypes
|
||||
Add-Type -AssemblyName WindowsBase
|
||||
function Get-WinByTitle([string]$re) {
|
||||
$root = [System.Windows.Automation.AutomationElement]::RootElement
|
||||
$cond = New-Object System.Windows.Automation.PropertyCondition(
|
||||
[System.Windows.Automation.AutomationElement]::ControlTypeProperty,
|
||||
[System.Windows.Automation.ControlType]::Window)
|
||||
$wins = $root.FindAll([System.Windows.Automation.TreeScope]::Children, $cond)
|
||||
foreach ($w in $wins) {
|
||||
try {
|
||||
$t = $w.Current.Name
|
||||
if ($t -and $t -match $re) { return $w }
|
||||
} catch {}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# v14:按子串在给定 scope 内遍历查找(FindAll + 子串过滤)。
|
||||
# UIA 的 PropertyCondition 只能精确匹配,故必须遍历后过滤;遍历规模设上限防超大 UI 树卡死。
|
||||
function Find-UiaByText($scope, [string]$text, [int]$limit, [string]$ct) {
|
||||
$cond = [System.Windows.Automation.Condition]::TrueCondition
|
||||
if ($ct -ne '') {
|
||||
$ctObj = $null
|
||||
try { $ctObj = [System.Windows.Automation.ControlType]::$ct } catch {}
|
||||
if ($ctObj -ne $null) {
|
||||
$cond = New-Object System.Windows.Automation.PropertyCondition(
|
||||
[System.Windows.Automation.AutomationElement]::ControlTypeProperty, $ctObj)
|
||||
}
|
||||
}
|
||||
$found = $scope.FindAll([System.Windows.Automation.TreeScope]::Descendants, $cond)
|
||||
$out = New-Object System.Collections.ArrayList
|
||||
$scanned = 0
|
||||
foreach ($e in $found) {
|
||||
if ($out.Count -ge $limit) { break }
|
||||
$scanned++
|
||||
if ($scanned -gt 20000) { break }
|
||||
try {
|
||||
$nm = $e.Current.Name
|
||||
if ([string]::IsNullOrEmpty($nm)) { continue }
|
||||
if ($nm.IndexOf($text, [System.StringComparison]::OrdinalIgnoreCase) -lt 0) { continue }
|
||||
$r = $e.Current.BoundingRectangle
|
||||
if ($r.Width -le 0 -or $r.Height -le 0) { continue }
|
||||
[void]$out.Add([pscustomobject]@{
|
||||
name = $nm
|
||||
control_type = ($e.Current.ControlType.ProgrammaticName -replace 'ControlType\\.','')
|
||||
automation_id = $e.Current.AutomationId
|
||||
x = [int]($r.Left + $r.Width / 2)
|
||||
y = [int]($r.Top + $r.Height / 2)
|
||||
left = [int]$r.Left; top = [int]$r.Top
|
||||
width = [int]$r.Width; height = [int]$r.Height
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
return ,$out
|
||||
}
|
||||
`;
|
||||
|
||||
/** 助手统一预加载段(window.list / window.focus / screen.find 共用同一个进程)。 */
|
||||
|
||||
|
||||
/**
|
||||
* OCR 引擎预加载段(v14):截图 / DPI / WinRT 类型与 Await 辅助函数。
|
||||
* 注意:WinRT 类型加载用 try/catch 包住 —— 个别环境缺组件时只应让 OCR 退化,
|
||||
* 不该让整个助手(连带 window.list/focus/UIA)起不来。
|
||||
*/
|
||||
const OCR_PRELUDE = `
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
try {
|
||||
Add-Type -AssemblyName System.Runtime.WindowsRuntime
|
||||
Add-Type -TypeDefinition 'using System.Runtime.InteropServices; public class NADPI { [DllImport("user32.dll")] public static extern bool SetProcessDPIAware(); }'
|
||||
[void][NADPI]::SetProcessDPIAware()
|
||||
$null = [Windows.Media.Ocr.OcrEngine, Windows.Foundation, ContentType=WindowsRuntime]
|
||||
$null = [Windows.Graphics.Imaging.BitmapDecoder, Windows.Foundation, ContentType=WindowsRuntime]
|
||||
$null = [Windows.Storage.Streams.InMemoryRandomAccessStream, Windows.Foundation, ContentType=WindowsRuntime]
|
||||
$null = [Windows.Storage.Streams.DataWriter, Windows.Foundation, ContentType=WindowsRuntime]
|
||||
$null = [Windows.Graphics.Imaging.SoftwareBitmap, Windows.Foundation, ContentType=WindowsRuntime]
|
||||
$script:asTaskGeneric = ([System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object { $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation\`1' })[0]
|
||||
function global:Await($WinRtTask, $ResultType) {
|
||||
$asTask = $script:asTaskGeneric.MakeGenericMethod($ResultType)
|
||||
$netTask = $asTask.Invoke($null, @($WinRtTask))
|
||||
$netTask.Wait(-1) | Out-Null
|
||||
$netTask.Result
|
||||
}
|
||||
$script:ocrReady = $true
|
||||
} catch {
|
||||
$script:ocrReady = $false
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
/**
|
||||
* 图像模板匹配预加载段(v15)。
|
||||
*
|
||||
* 动机:UIA 靠控件树、OCR 靠文字 —— **纯图标/无文字的控件**两者都抓不到(审查报告 §5.2 指出的盲区)。
|
||||
* 做法:把模板图与屏幕区域都转灰度,做**零均值归一化互相关(ZNCC)**;
|
||||
* 全分辨率逐像素太慢(3440×1440 屏 × 64×64 模板 ≈ 190 亿次),故**粗到精**:
|
||||
* ① 1/4 分辨率全图扫描(约 8000 万次)取候选
|
||||
* ② 对候选在全分辨率 ±10px 邻域精修
|
||||
* 编译一次常驻(随助手预加载),运行时纯 native 循环。
|
||||
* 亮度和对比度无关(ZNCC 归一化),故对主题/亮度变化不敏感。
|
||||
*/
|
||||
const IMAGE_MATCH_PRELUDE = `
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
# ⚠️ Add-Type 的老坑:-AssemblyName 只把程序集加载进 PowerShell 会话,
|
||||
# 编译 C# 时还必须显式 -ReferencedAssemblies,否则报「命名空间 System.Drawing
|
||||
# 中不存在 Imaging」。真机踩过:编译失败会让整个助手起不来,并伴随误导性的
|
||||
# 「找不到类型 System.Windows.Forms.SystemInformation」二次错误。
|
||||
Add-Type @"
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
public class NAImageMatch {
|
||||
public class Hit { public int x; public int y; public int w; public int h; public double score; }
|
||||
|
||||
private static byte[] Gray(Bitmap src, out int w, out int h) {
|
||||
w = src.Width; h = src.Height;
|
||||
var dst = new byte[w * h];
|
||||
var rect = new Rectangle(0, 0, w, h);
|
||||
Bitmap bmp = src.PixelFormat == PixelFormat.Format24bppRgb ? src : src.Clone(rect, PixelFormat.Format24bppRgb);
|
||||
var data = bmp.LockBits(rect, ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb);
|
||||
try {
|
||||
int stride = data.Stride;
|
||||
var buf = new byte[stride * h];
|
||||
Marshal.Copy(data.Scan0, buf, 0, buf.Length);
|
||||
for (int y = 0; y < h; y++) {
|
||||
int row = y * stride;
|
||||
for (int x = 0; x < w; x++) {
|
||||
int i = row + x * 3;
|
||||
// BGR -> luma (integer approximation of ITU-R BT.601)
|
||||
dst[y * w + x] = (byte)((buf[i + 2] * 77 + buf[i + 1] * 150 + buf[i] * 29) >> 8);
|
||||
}
|
||||
}
|
||||
} finally { bmp.UnlockBits(data); if (bmp != src) bmp.Dispose(); }
|
||||
return dst;
|
||||
}
|
||||
|
||||
// Area-average downscale (coarse search layer)
|
||||
private static byte[] Downscale(byte[] src, int w, int h, int f, out int ow, out int oh) {
|
||||
ow = w / f; oh = h / f;
|
||||
var dst = new byte[ow * oh];
|
||||
for (int y = 0; y < oh; y++) {
|
||||
for (int x = 0; x < ow; x++) {
|
||||
int sum = 0;
|
||||
for (int dy = 0; dy < f; dy++) {
|
||||
int row = (y * f + dy) * w;
|
||||
for (int dx = 0; dx < f; dx++) sum += src[row + x * f + dx];
|
||||
}
|
||||
dst[y * ow + x] = (byte)(sum / (f * f));
|
||||
}
|
||||
}
|
||||
return dst;
|
||||
}
|
||||
|
||||
// Zero-mean normalized cross correlation; 1.0 = identical
|
||||
private static double Zncc(byte[] S, int sw, int sx, int sy, byte[] T, int tw, int th) {
|
||||
int n = tw * th;
|
||||
double sumT = 0;
|
||||
for (int i = 0; i < n; i++) sumT += T[i];
|
||||
double meanT = sumT / n;
|
||||
|
||||
double sumS = 0;
|
||||
for (int y = 0; y < th; y++) {
|
||||
int row = (sy + y) * sw + sx;
|
||||
for (int x = 0; x < tw; x++) sumS += S[row + x];
|
||||
}
|
||||
double meanS = sumS / n;
|
||||
|
||||
double num = 0, dS = 0, dT = 0;
|
||||
for (int y = 0; y < th; y++) {
|
||||
int row = (sy + y) * sw + sx;
|
||||
int trow = y * tw;
|
||||
for (int x = 0; x < tw; x++) {
|
||||
double a = S[row + x] - meanS;
|
||||
double b = T[trow + x] - meanT;
|
||||
num += a * b; dS += a * a; dT += b * b;
|
||||
}
|
||||
}
|
||||
double den = Math.Sqrt(dS * dT);
|
||||
if (den < 1e-9) return 0;
|
||||
return num / den;
|
||||
}
|
||||
|
||||
/**
|
||||
// (comment removed: ASCII only)
|
||||
// (comment removed: ASCII only)
|
||||
*/
|
||||
public static string Match(string screenPath, string templatePath, int maxResults, double threshold, int searchX, int searchY, int searchW, int searchH) {
|
||||
var hits = new List<Hit>();
|
||||
using (var screenBmp = new Bitmap(screenPath))
|
||||
using (var tmplBmp = new Bitmap(templatePath)) {
|
||||
int sw, sh, tw, th;
|
||||
var S0 = Gray(screenBmp, out sw, out sh);
|
||||
var T0 = Gray(tmplBmp, out tw, out th);
|
||||
if (tw >= sw || th >= sh) return "[]";
|
||||
|
||||
// Guard: a flat template carries no gradient information, so correlation is
|
||||
// undefined (ZNCC denominator ~ 0). Report it explicitly instead of silently
|
||||
// returning no hits - a uniform crop usually means the wrong region was picked.
|
||||
//
|
||||
// NOTE: build all JSON/string literals with the char constant Q.
|
||||
// Writing a backslash-quote inside a TS template literal collapses it to a bare
|
||||
// quote and breaks the C# compile - this trap bit this file twice.
|
||||
const char Q = '"';
|
||||
var ci0 = System.Globalization.CultureInfo.InvariantCulture;
|
||||
double tSum = 0, tSum2 = 0;
|
||||
for (int i = 0; i < T0.Length; i++) { tSum += T0[i]; tSum2 += (double)T0[i] * T0[i]; }
|
||||
double tMean = tSum / T0.Length;
|
||||
double tVar = (tSum2 / T0.Length) - (tMean * tMean);
|
||||
if (tVar < 4.0) {
|
||||
return "[" + "{" + Q + "__error" + Q + ":" + Q + "template_has_no_contrast" + Q
|
||||
+ "," + Q + "variance" + Q + ":" + tVar.ToString("F2", ci0) + "}" + "]";
|
||||
}
|
||||
|
||||
// Optional search window: crop a sub-image, then add the offset back to hit coords
|
||||
int ox = 0, oy = 0;
|
||||
if (searchW > 0 && searchH > 0) {
|
||||
ox = Math.Max(0, Math.Min(searchX, sw - 1));
|
||||
oy = Math.Max(0, Math.Min(searchY, sh - 1));
|
||||
int w = Math.Min(searchW, sw - ox), h = Math.Min(searchH, sh - oy);
|
||||
var sub = new byte[w * h];
|
||||
for (int y = 0; y < h; y++) Array.Copy(S0, (oy + y) * sw + ox, sub, y * w, w);
|
||||
S0 = sub; sw = w; sh = h;
|
||||
}
|
||||
|
||||
const int F = 4;
|
||||
int sw4, sh4, tw4, th4;
|
||||
var S4 = Downscale(S0, sw, sh, F, out sw4, out sh4);
|
||||
var T4 = Downscale(T0, tw, th, F, out tw4, out th4);
|
||||
|
||||
// (1) coarse scan: collect candidates.
|
||||
// NOTE: the pre-filter must be MUCH looser than the final threshold.
|
||||
// Downsampling lowers the correlation of the true position (typically 0.7-0.85
|
||||
// for a 0.9+ full-res match), so a tight pre-filter discards the right answer
|
||||
// before refinement ever runs (this exact bug made every match return empty).
|
||||
var cands = new List<Hit>();
|
||||
if (tw4 > 0 && th4 > 0 && tw4 <= sw4 && th4 <= sh4) {
|
||||
double preFilter = Math.Max(0.30, threshold - 0.30);
|
||||
double bestCoarse = -1; int bx4 = 0, by4 = 0;
|
||||
for (int y = 0; y <= sh4 - th4; y++) {
|
||||
for (int x = 0; x <= sw4 - tw4; x++) {
|
||||
double sc = Zncc(S4, sw4, x, y, T4, tw4, th4);
|
||||
if (sc > bestCoarse) { bestCoarse = sc; bx4 = x; by4 = y; }
|
||||
if (sc >= preFilter) cands.Add(new Hit { x = x * F, y = y * F, w = tw, h = th, score = sc });
|
||||
}
|
||||
}
|
||||
// Belt and braces: always keep the coarse best, even if it missed the pre-filter
|
||||
if (bestCoarse > 0) {
|
||||
bool seen = false;
|
||||
foreach (var c in cands) { if (Math.Abs(c.x - bx4 * F) < F && Math.Abs(c.y - by4 * F) < F) { seen = true; break; } }
|
||||
if (!seen) cands.Add(new Hit { x = bx4 * F, y = by4 * F, w = tw, h = th, score = bestCoarse });
|
||||
}
|
||||
}
|
||||
cands.Sort((a, b) => b.score.CompareTo(a.score));
|
||||
if (cands.Count > 40) cands.RemoveRange(40, cands.Count - 40);
|
||||
|
||||
// (2) refine at full resolution within a +/-F*2 neighborhood
|
||||
foreach (var c in cands) {
|
||||
double best = -1; int bx = c.x, by = c.y;
|
||||
for (int dy = -F * 2; dy <= F * 2; dy++) {
|
||||
for (int dx = -F * 2; dx <= F * 2; dx++) {
|
||||
int x = c.x + dx, y = c.y + dy;
|
||||
if (x < 0 || y < 0 || x + tw > sw || y + th > sh) continue;
|
||||
double sc = Zncc(S0, sw, x, y, T0, tw, th);
|
||||
if (sc > best) { best = sc; bx = x; by = y; }
|
||||
}
|
||||
}
|
||||
if (best >= threshold) hits.Add(new Hit { x = bx + ox, y = by + oy, w = tw, h = th, score = best });
|
||||
}
|
||||
|
||||
// (3) de-dup: non-maximum suppression (keep best within 8px)
|
||||
hits.Sort((a, b) => b.score.CompareTo(a.score));
|
||||
var final = new List<Hit>();
|
||||
foreach (var h in hits) {
|
||||
bool dup = false;
|
||||
foreach (var f in final) {
|
||||
if (Math.Abs(f.x - h.x) < 8 && Math.Abs(f.y - h.y) < 8) { dup = true; break; }
|
||||
}
|
||||
if (!dup) final.Add(h);
|
||||
if (final.Count >= maxResults) break;
|
||||
}
|
||||
|
||||
var parts = new List<string>();
|
||||
foreach (var h in final) {
|
||||
// Build JSON by concatenation; reuse the shared Q / ci0 declared above.
|
||||
var ci = ci0;
|
||||
parts.Add("{" + Q + "x" + Q + ":" + h.x.ToString(ci) + "," + Q + "y" + Q + ":" + h.y.ToString(ci)
|
||||
+ "," + Q + "width" + Q + ":" + h.w.ToString(ci) + "," + Q + "height" + Q + ":" + h.h.ToString(ci)
|
||||
+ "," + Q + "score" + Q + ":" + h.score.ToString("F4", ci) + "}");
|
||||
}
|
||||
return "[" + String.Join(",", parts.ToArray()) + "]";
|
||||
}
|
||||
}
|
||||
|
||||
// Capture a screen region to a file (avoids extra process round-trips)
|
||||
public static string Capture(string outPath, int x, int y, int w, int h) {
|
||||
using (var bmp = new Bitmap(w, h))
|
||||
using (var g = Graphics.FromImage(bmp)) {
|
||||
g.CopyFromScreen(x, y, 0, 0, new Size(w, h));
|
||||
bmp.Save(outPath, ImageFormat.Png);
|
||||
}
|
||||
return outPath;
|
||||
}
|
||||
}
|
||||
"@ -ReferencedAssemblies System.Drawing
|
||||
`;
|
||||
|
||||
/**
|
||||
* 助手统一预加载段(window.list / window.focus / screen.find 的 UIA/OCR/图像三引擎共用)。
|
||||
* ⚠️ 必须定义在四段之后(块级作用域),且**四段缺一不可** ——
|
||||
@@ -664,11 +300,26 @@ export async function screenFind(args: Args): Promise<unknown> {
|
||||
}
|
||||
const text = (args['text'] as string | undefined) ?? '';
|
||||
const wantImage = (args['method'] as string | undefined) === 'image';
|
||||
if (!wantImage && (typeof text !== 'string' || text.length === 0)) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'text 不能为空(method=image 时用 template 代替)');
|
||||
// v2.0.0 修正:`where` 可单独使用(不传 text)——「找一个 enabled 的按钮」比
|
||||
// 「找一个叫某名字的按钮」更贴近真实意图。此前只在 gui.await 侧放开了,
|
||||
// screen.find 自身的入口校验没同步,导致直连调用(以及 await 的 where-only)
|
||||
// 被这里的"text 不能为空"拦下(真机 2026-10-09 验证时暴露)。
|
||||
const hasWhere = (() => {
|
||||
const w = args['where'];
|
||||
return typeof w === 'object' && w !== null && !Array.isArray(w) && Object.keys(w as object).length > 0;
|
||||
})();
|
||||
if (!wantImage && text.length === 0 && !hasWhere) {
|
||||
throw new CapabilityError(
|
||||
ErrorCodes.PARAM_INVALID,
|
||||
'text 与 where 至少给一个(method=image 时用 template)',
|
||||
{ hint: 'where 仅 UIA 引擎生效,如 {"enabled":true}' },
|
||||
);
|
||||
}
|
||||
const windowTitle = args['window'] as string | undefined;
|
||||
const controlType = args['control_type'] as string | undefined;
|
||||
// v2.0.0:属性谓词。仅 UIA 引擎生效;OCR / 图像模板忽略(无属性可读),
|
||||
// 此时在返回里带 note 说明,避免调用方误以为"按属性过滤过了"。
|
||||
const whereArg = args['where'] as Record<string, unknown> | undefined;
|
||||
const limit = (args['limit'] as number | undefined) ?? 20;
|
||||
const method = (args['method'] as string | undefined) ?? 'auto'; // auto | uia | ocr
|
||||
// v12.2:等待语义 —— 界面常有动画/加载延迟,一次性查找极易假失败。
|
||||
@@ -691,12 +342,33 @@ $text = ${JSON.stringify(text)}
|
||||
$limit = ${Number(limit)}
|
||||
$ct = ${JSON.stringify(controlType ?? '')}
|
||||
$winTitle = ${JSON.stringify(windowTitle ?? '')}
|
||||
# v2.0.0 属性过滤:where 只含 enabled/selected/value/toggle 四类键(白名单式)。
|
||||
#
|
||||
# ⚠️⚠️ 这里踩过**两次**同一个坑,真机 2026-10-09 连修两轮才通:
|
||||
# ✗ 第一版:$where = {"enabled":true} → PS 不认 JSON 字面量,ParseError
|
||||
# ✗ 第二版:$whereJson = "{\"enabled\":true}" → PS 5.1 **不认反斜杠转义**
|
||||
# (PS 用反引号),依然 ParseError,整份助手脚本失败
|
||||
# (症状:带 where 一律"窗口操作失败",不带 where 完全正常)
|
||||
# ✓ 第三版:**PS 单引号字符串字面量**,JSON 内的单引号翻倍成 ''(本行)
|
||||
# 并在真机现场验证过:$j = '{"enabled":true}' → ConvertFrom-Json 摊平成哈希表 → OK。
|
||||
# 摊平是因为 Test-UiaWhere 依赖 .Keys 遍历,而 PSCustomObject 没有 Keys。
|
||||
$whereJson = '${(whereArg ? JSON.stringify(whereArg) : '').replace(/'/g, "''")}'
|
||||
$where = $null
|
||||
if ($whereJson -ne '') {
|
||||
$where = @{}
|
||||
try {
|
||||
($whereJson | ConvertFrom-Json).PSObject.Properties | ForEach-Object { $where[$_.Name] = $_.Value }
|
||||
} catch {
|
||||
$where = $null # JSON 不合法就当没传(业务层已在 Node 侧校验过键名)
|
||||
}
|
||||
if ($where -ne $null -and $where.Count -eq 0) { $where = $null }
|
||||
}
|
||||
$out = New-Object System.Collections.ArrayList
|
||||
if ($winTitle -ne '') {
|
||||
# 指定窗口:直接在该窗口子树内找
|
||||
$w = Get-WinByTitle $winTitle
|
||||
if ($w -eq $null) { Write-Output '[]'; return } # 严禁 exit:会杀掉常驻助手
|
||||
$out = Find-UiaByText $w $text $limit $ct
|
||||
$out = Find-UiaByText $w $text $limit $ct $where
|
||||
} else {
|
||||
# v14 优化:先在前台窗口子树内找(典型只几百个元素,快一个数量级),
|
||||
# 找不到再退回全桌面遍历(原来每次都在全桌面上跑,真机实测 3.1~3.7s)。
|
||||
@@ -704,9 +376,9 @@ if ($winTitle -ne '') {
|
||||
try {
|
||||
$fgEl = [System.Windows.Automation.AutomationElement]::FromHandle([NAWin32]::GetForegroundWindow())
|
||||
} catch {}
|
||||
if ($fgEl -ne $null) { $out = Find-UiaByText $fgEl $text $limit $ct }
|
||||
if ($fgEl -ne $null) { $out = Find-UiaByText $fgEl $text $limit $ct $where }
|
||||
if ($out.Count -eq 0) {
|
||||
$out = Find-UiaByText ([System.Windows.Automation.AutomationElement]::RootElement) $text $limit $ct
|
||||
$out = Find-UiaByText ([System.Windows.Automation.AutomationElement]::RootElement) $text $limit $ct $where
|
||||
}
|
||||
}
|
||||
# ── 输出 ──(遍历与子串过滤已收敛到预加载的 Find-UiaByText,避免逻辑重复)
|
||||
@@ -954,10 +626,22 @@ $out | ConvertTo-Json -Compress
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
last = await attempt();
|
||||
}
|
||||
// v2.0.0:where 只在 UIA 引擎下有属性可读 —— 若最终走的是 OCR/image,
|
||||
// 必须**明确告知**,否则调用方会以为"按属性过滤过了"而误判结果。
|
||||
const whereIgnored = hasWhere && last.engine !== 'uia';
|
||||
return {
|
||||
matches: last.matches,
|
||||
engine: last.engine,
|
||||
waited_ms: Date.now() - startedAt,
|
||||
...(waitMs > 0 ? { wait_ms: waitMs } : {}),
|
||||
...(whereIgnored
|
||||
? {
|
||||
note:
|
||||
`where 已忽略:实际引擎为 ${last.engine}(无控件属性可读)。` +
|
||||
'属性过滤仅在 UIA 生效;text 过滤仍然有效。',
|
||||
where_applied: false,
|
||||
}
|
||||
: {}),
|
||||
...(hasWhere && last.engine === 'uia' ? { where_applied: true } : {}),
|
||||
};
|
||||
}
|
||||
@@ -41,6 +41,19 @@ export interface AgentConfig {
|
||||
* "allow_from": ["192.168.0.0/16", "10.0.0.0/8"]
|
||||
*/
|
||||
allow_from?: string[];
|
||||
|
||||
/**
|
||||
* v2.0.0 连接层防护(三项,默认值见 server.ts)。
|
||||
* 单项缺省即启用默认策略;显式设 0 可关闭对应项。
|
||||
*/
|
||||
security?: {
|
||||
/** 握手失败封禁:同来源连续失败 max_attempts 次起,ban = ban_ms × 2^(N-max),封顶 24h */
|
||||
auth_ban?: { max_attempts?: number; ban_ms?: number; whitelist?: string[] };
|
||||
/** 最大并发连接数(默认 8) */
|
||||
max_connections?: number;
|
||||
/** 空闲断开(默认 30 分钟;0 = 关闭) */
|
||||
idle_timeout_ms?: number;
|
||||
};
|
||||
/** v3+:审计日志配置 */
|
||||
audit?: {
|
||||
/** 是否启用(默认 true) */
|
||||
|
||||
@@ -261,6 +261,11 @@ export async function eventPoll(args: Args): Promise<unknown> {
|
||||
}
|
||||
|
||||
/** 连接断开时清理该连接创建的所有订阅(避免 watcher 泄漏)。 */
|
||||
/** v2.0.0:当前事件订阅数(空闲断开的豁免判据之一)。 */
|
||||
export function watchCount(): number {
|
||||
return watches.size;
|
||||
}
|
||||
|
||||
export function disposeWatchesByOwner(owner: string): number {
|
||||
let n = 0;
|
||||
for (const [id, rec] of watches) {
|
||||
|
||||
@@ -30,7 +30,8 @@ import {
|
||||
import { buildInfo } from './capabilities/system.js';
|
||||
import { createCapabilityRegistry } from './capabilities/index.js';
|
||||
import { audit, describeArgs } from './audit.js';
|
||||
import { disposeWatchesByOwner } from './events.js';
|
||||
import { disposeWatchesByOwner, watchCount } from './events.js';
|
||||
import { runningTaskCount } from './capabilities/task.js';
|
||||
import type { AgentConfig } from './config.js';
|
||||
import type { TlsMaterial } from './certs.js';
|
||||
|
||||
@@ -47,6 +48,8 @@ interface ConnState {
|
||||
remote: string;
|
||||
/** v12:连接标识,用于清理该连接创建的事件订阅 */
|
||||
connId: string;
|
||||
/** v2.0.0:末次活跃时间(任何入站消息),空闲断开的依据 */
|
||||
lastActiveAt: number;
|
||||
}
|
||||
|
||||
export interface AgentServer {
|
||||
@@ -61,6 +64,10 @@ type LogLevel = 'debug' | 'info' | 'warn';
|
||||
export interface AgentCore {
|
||||
/** 在给定连接上提供被控端服务(连接已建立)。 */
|
||||
attach(ws: WebSocket, remote: string): void;
|
||||
/** v2.0.0:该来源封禁剩余毫秒(0 = 不挡)。连接入口在业务握手前调用。 */
|
||||
banRemainingMs(remote: string): number;
|
||||
/** v2.0.0:连接表(并发上限 / 空闲清扫判据;key 为 WebSocket)。 */
|
||||
states: Map<WebSocket, ConnState>;
|
||||
}
|
||||
|
||||
export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
@@ -93,7 +100,9 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
}
|
||||
};
|
||||
|
||||
const states = new WeakMap<WebSocket, ConnState>();
|
||||
// v2.0.0:用 Map 而非 WeakMap —— 连接层需要 size(并发上限)与遍历(空闲清扫)。
|
||||
// 代价是必须手动清理:close 时 delete,否则断开的连接会一直留在表里。
|
||||
const states = new Map<WebSocket, ConnState>();
|
||||
|
||||
const send = (ws: WebSocket, msg: unknown): void => {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
|
||||
@@ -122,12 +131,25 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
|
||||
function handleAuth(ws: WebSocket, state: ConnState, req: RpcRequest): void {
|
||||
const params = req.params as AuthParams | undefined;
|
||||
const failOut = (msg: string, hint?: string): void => {
|
||||
const ban = recordAuthFailure(state.remote);
|
||||
audit({ type: 'auth.failure', client_id: params?.client_id ?? '?', remote: state.remote, reason: msg });
|
||||
sendError(ws, req.id, ErrorCodes.AUTH_FAILED, msg, {
|
||||
...(hint ? { hint } : {}),
|
||||
...(ban.banned ? { retry_after_ms: ban.waitMs } : {}),
|
||||
});
|
||||
// 稍作延迟再关闭,确保错误响应先送达控制端
|
||||
setTimeout(() => ws.close(), 100);
|
||||
};
|
||||
|
||||
if (!state.nonce || Date.now() > state.nonceExpiresAt) {
|
||||
sendError(ws, req.id, ErrorCodes.AUTH_FAILED, '挑战已过期,请重新握手');
|
||||
log('warn', '握手失败:挑战已过期');
|
||||
failOut('挑战已过期,请重新握手');
|
||||
return;
|
||||
}
|
||||
if (params?.nonce !== state.nonce) {
|
||||
sendError(ws, req.id, ErrorCodes.AUTH_FAILED, 'nonce 不匹配');
|
||||
log('warn', '握手失败:nonce 不匹配');
|
||||
failOut('nonce 不匹配', '请用 hello 返回的 nonce 计算签名');
|
||||
return;
|
||||
}
|
||||
const clientId = params.client_id ?? '?';
|
||||
@@ -138,18 +160,12 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
const client = aclPolicy.clients.find((c) => c.client_id === clientId);
|
||||
if (!client?.pubkey) {
|
||||
log('warn', `鉴权失败(调用方未注册)client_id=${clientId}`);
|
||||
audit({ type: 'auth.failure', client_id: clientId, remote: state.remote, reason: '调用方未在 ACL 中注册' });
|
||||
sendError(ws, req.id, ErrorCodes.AUTH_FAILED, `调用方未在 ACL 中注册: ${clientId}`, {
|
||||
hint: '在被控端 acl.clients 中加入该 client_id 与公钥',
|
||||
});
|
||||
setTimeout(() => ws.close(), 100);
|
||||
failOut(`调用方未在 ACL 中注册: ${clientId}`, '在被控端 acl.clients 中加入该 client_id 与公钥');
|
||||
return;
|
||||
}
|
||||
if (!verifyNonce(client.pubkey, state.nonce, params.signature ?? '')) {
|
||||
log('warn', `鉴权失败(Ed25519 验签不通过)client_id=${clientId}`);
|
||||
audit({ type: 'auth.failure', client_id: clientId, remote: state.remote, reason: 'Ed25519 验签失败' });
|
||||
sendError(ws, req.id, ErrorCodes.AUTH_FAILED, 'Ed25519 签名校验失败');
|
||||
setTimeout(() => ws.close(), 100);
|
||||
failOut('Ed25519 签名校验失败', '确认用的是该 client_id 对应的私钥');
|
||||
return;
|
||||
}
|
||||
authorized = authorizedCapabilities(
|
||||
@@ -165,17 +181,9 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
const keyKind = scopedKey ? 'scoped' : cfg.keys ? 'shared-fallback' : 'shared';
|
||||
if (!keyForClient || !verifyHmac(keyForClient, state.nonce, params.hmac ?? '')) {
|
||||
log('warn', `鉴权失败 client_id=${clientId} key=${keyKind}`);
|
||||
audit({
|
||||
type: 'auth.failure',
|
||||
client_id: clientId,
|
||||
remote: state.remote,
|
||||
reason: scopedKey
|
||||
? '该 client_id 的专属密钥校验失败'
|
||||
: '预共享密钥校验失败(未登记专属密钥,已回落到共享 key)',
|
||||
});
|
||||
sendError(ws, req.id, ErrorCodes.AUTH_FAILED, '预共享密钥校验失败');
|
||||
// 稍作延迟再关闭,确保错误响应先送达控制端
|
||||
setTimeout(() => ws.close(), 100);
|
||||
failOut('预共享密钥校验失败', scopedKey
|
||||
? '该 client_id 有专属密钥,需用那把 key'
|
||||
: '用错了 key,或该 client_id 应登记到 keys{}');
|
||||
return;
|
||||
}
|
||||
keyKindUsed = keyKind;
|
||||
@@ -183,6 +191,7 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
|
||||
state.nonce = null; // 一次性,用后即废
|
||||
state.nonceExpiresAt = 0;
|
||||
recordAuthSuccess(state.remote); // v2.0.0:握手成功即清零失败计数(避免误伤手滑的控制端)
|
||||
state.authenticated = true;
|
||||
state.clientId = clientId;
|
||||
state.authorized = authorized;
|
||||
@@ -405,6 +414,49 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- v2.0.0 连接层防护:握手失败封禁 ----------
|
||||
// 状态放在 core:handleAuth 在此函数内,且 server 侧连接入口也要查它。
|
||||
interface BanState { fails: number; bannedUntil: number }
|
||||
const authBan = new Map<string, BanState>();
|
||||
const banMax = Math.max(1, cfg.security?.auth_ban?.max_attempts ?? 5);
|
||||
const banBaseMs = Math.max(1000, cfg.security?.auth_ban?.ban_ms ?? 600_000);
|
||||
const banWhitelist = (cfg.security?.auth_ban?.whitelist ?? []).map((x) => x.trim()).filter(Boolean);
|
||||
|
||||
/** 归一化来源 IP:剥离 IPv4-mapped 前缀(::ffff:1.2.3.4 → 1.2.3.4),否则同一来源会被当成两个键。 */
|
||||
const normIp = (addr: string): string => addr.replace(/^::ffff:/i, '');
|
||||
|
||||
/** 该来源还有多久解封(0 = 不在封禁期)。白名单来源永不封禁。 */
|
||||
function banRemainingMs(remote: string): number {
|
||||
const ip = normIp(remote);
|
||||
if (banWhitelist.includes(ip)) return 0;
|
||||
const st = authBan.get(ip);
|
||||
if (!st) return 0;
|
||||
const left = st.bannedUntil - Date.now();
|
||||
return left > 0 ? left : 0; // 已过期则不挡(计数保留,继续指数退避)
|
||||
}
|
||||
|
||||
/** 记录一次握手失败;返回本次是否**新触发**封禁(好让错误响应带上 retry_after_ms)。 */
|
||||
function recordAuthFailure(remote: string): { banned: boolean; waitMs: number } {
|
||||
const ip = normIp(remote);
|
||||
if (banWhitelist.includes(ip)) return { banned: false, waitMs: 0 };
|
||||
const prev = authBan.get(ip) ?? { fails: 0, bannedUntil: 0 };
|
||||
const fails = prev.fails + 1;
|
||||
if (fails < banMax) {
|
||||
authBan.set(ip, { fails, bannedUntil: 0 });
|
||||
return { banned: false, waitMs: 0 };
|
||||
}
|
||||
// 第 N 次失败起:ban = base × 2^(N - max),封顶 24h
|
||||
const power = Math.min(10, fails - banMax);
|
||||
const waitMs = Math.min(86_400_000, banBaseMs * 2 ** power);
|
||||
authBan.set(ip, { fails, bannedUntil: Date.now() + waitMs });
|
||||
return { banned: true, waitMs };
|
||||
}
|
||||
|
||||
function recordAuthSuccess(remote: string): void {
|
||||
const ip = normIp(remote);
|
||||
if (authBan.delete(ip)) log('info', `握手失败计数已清零(来源 ${ip} 本次认证成功)`);
|
||||
}
|
||||
|
||||
function attachConnection(ws: WebSocket, remote: string): void {
|
||||
log('info', `新连接: ${remote}`);
|
||||
const connId = `c_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
@@ -416,8 +468,19 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
authorized: null,
|
||||
remote,
|
||||
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) => {
|
||||
// v2.0.0:任何入站消息都算活跃(空闲断开以"完全静默"为准,不是"没有调用能力")
|
||||
const st = states.get(ws);
|
||||
if (st) st.lastActiveAt = Date.now();
|
||||
void handleMessage(ws, raw.toString());
|
||||
});
|
||||
ws.on('close', () => {
|
||||
@@ -425,11 +488,18 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
// v12:连接断开即释放其事件订阅,避免 watcher 泄漏
|
||||
const n = disposeWatchesByOwner(connId);
|
||||
if (n > 0) log('debug', `已清理 ${n} 个事件订阅(连接 ${connId})`);
|
||||
states.delete(ws); // v2.0.0:Map 需手动清理(WeakMap 时代靠 GC)
|
||||
});
|
||||
ws.on('error', (err: Error) => log('warn', `连接错误: ${err.message}`));
|
||||
}
|
||||
|
||||
return { attach: attachConnection };
|
||||
return {
|
||||
attach: attachConnection,
|
||||
/** v2.0.0:连接入口用 —— 该来源是否在封禁期(返回剩余毫秒,0=不挡)。 */
|
||||
banRemainingMs,
|
||||
/** v2.0.0:连接表(空闲清扫与并发上限判据)。 */
|
||||
states,
|
||||
};
|
||||
}
|
||||
|
||||
/** 监听模式:被控端在本地起 HTTP(S) + WebSocket 服务。 */
|
||||
@@ -449,6 +519,11 @@ export function createAgentServer(cfg: AgentConfig, tls: TlsMaterial | null): Pr
|
||||
: createHttpServer();
|
||||
const wss = new WebSocketServer({ server: httpServer });
|
||||
|
||||
// v23②:连接数上限(判定放在连接入口,见下方 wss.on('connection'))
|
||||
const maxConnections = Math.max(1, cfg.security?.max_connections ?? 8);
|
||||
// v23③:空闲断开阈值(0 = 关闭)
|
||||
const idleTimeoutMs = Math.max(0, cfg.security?.idle_timeout_ms ?? 1_800_000);
|
||||
|
||||
// v21 第一批加固:来源网段白名单 —— **在业务握手之前**就断开。
|
||||
// 放在这里而不是等认证失败,是因为拒绝得更早、开销更低(不必做 TLS/挑战应答)。
|
||||
wss.on('connection', (ws, req) => {
|
||||
@@ -459,13 +534,53 @@ export function createAgentServer(cfg: AgentConfig, tls: TlsMaterial | null): Pr
|
||||
ws.close(1008, 'source not allowed');
|
||||
return;
|
||||
}
|
||||
// v23①:封禁中的来源直接拒(在 TLS 之后、业务握手之前)
|
||||
const bannedLeft = core.banRemainingMs(remote);
|
||||
if (bannedLeft > 0) {
|
||||
log('warn', `连接被拒绝:来源处于封禁期(remote=${remote},剩余 ${Math.ceil(bannedLeft / 1000)}s)`);
|
||||
audit({ type: 'net.deny', remote, reason: `auth banned, ${bannedLeft}ms left` });
|
||||
ws.close(1008, `too many failed attempts, retry in ${Math.ceil(bannedLeft / 1000)}s`);
|
||||
return;
|
||||
}
|
||||
// v23②:连接数上限
|
||||
if (core.states.size >= maxConnections) {
|
||||
log('warn', `连接被拒绝:已达并发上限 ${maxConnections}(current=${wss.clients.size})`);
|
||||
audit({ type: 'net.deny', remote, reason: `max connections reached: ${maxConnections}` });
|
||||
ws.close(1013, 'too many connections');
|
||||
return;
|
||||
}
|
||||
core.attach(ws, remote);
|
||||
});
|
||||
|
||||
// WebSocket 层保活:30s ping,探测死连接
|
||||
// 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;
|
||||
// 保守策略:只要还有任务在跑 / 还有事件订阅,就不断任何连接 ——
|
||||
// 宁可少断,也不能把"挂着长任务等结果"的控制端踢下线。
|
||||
if (runningTaskCount() > 0 || watchCount() > 0) return;
|
||||
const now = Date.now();
|
||||
for (const [ws, st] of core.states) {
|
||||
if (ws.readyState !== WebSocket.OPEN) continue;
|
||||
const idleFor = now - st.lastActiveAt;
|
||||
if (idleFor < idleTimeoutMs) continue;
|
||||
log('info', `空闲断开:连接 ${st.connId}(${st.remote})已静默 ${Math.round(idleFor / 1000)}s`);
|
||||
audit({ type: 'session.idle_close', client_id: st.clientId ?? undefined, remote: st.remote, reason: `idle ${idleFor}ms` });
|
||||
ws.close(1000, 'idle timeout');
|
||||
}
|
||||
}, 30_000);
|
||||
heartbeat.unref();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/cli",
|
||||
"version": "1.6.0",
|
||||
"version": "2.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -172,7 +172,7 @@ export async function cmdKey(action: string | undefined, positionals: string[],
|
||||
return;
|
||||
}
|
||||
case 'press': {
|
||||
// v1.5:三种用法(一次一种)
|
||||
// v1.5.0:三种用法(一次一种)
|
||||
// nodeagent key press "ctrl+shift+esc" 字符串热键
|
||||
// nodeagent key press copy 预设名
|
||||
// nodeagent key press ctrl c 老形式(空格分隔的各键)
|
||||
@@ -193,7 +193,7 @@ export async function cmdKey(action: string | undefined, positionals: string[],
|
||||
}
|
||||
// 老形式说明:位置参数以空格分隔 → 兼容"ctrl c"(无 + 号)
|
||||
if (opts.interval) args['interval_ms'] = Number(opts.interval);
|
||||
// v1.5 新增可选参数
|
||||
// v1.5.0 新增可选参数
|
||||
if (opts.hold) args['hold_ms'] = Number(opts.hold);
|
||||
if (opts.route) args['route'] = opts.route;
|
||||
// 媒体键可定向到指定进程(播放器常建 30+ 辅助窗口,只投主窗口往往无效)
|
||||
@@ -232,6 +232,64 @@ export async function cmdKey(action: string | undefined, positionals: string[],
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- v1.6.0 等待条件(gui.await) ----------
|
||||
|
||||
/**
|
||||
* 等待一个条件成立再返回(只读低危)。
|
||||
*
|
||||
* nodeagent await --condition control --text "完成" --timeout 30000
|
||||
* nodeagent await --condition window --title "安装程序"
|
||||
* nodeagent await --condition process --process setup.exe
|
||||
* nodeagent await --condition file --path "C:\\log.txt" --state absent
|
||||
*
|
||||
* 四类条件各自复用 window.list / screen.find / process.list / fs.stat,
|
||||
* 平台行为与那些能力一致。超时是**正常返回**(satisfied:false + note),不抛异常。
|
||||
*/
|
||||
export async function cmdAwait(opts: Options): Promise<void> {
|
||||
const condition = opts.condition as string | undefined;
|
||||
if (!condition) {
|
||||
fail('用法: nodeagent await --condition <window|control|process|file> --timeout <ms> [--text/--title/--process/--path] [--state absent]');
|
||||
}
|
||||
const args: Record<string, unknown> = { condition };
|
||||
// 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) {
|
||||
try {
|
||||
args[k] = JSON.parse(raw);
|
||||
} catch {
|
||||
fail(`--${k} 不是合法 JSON: ${raw.slice(0, 60)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (opts.text !== undefined) args['text'] = opts.text;
|
||||
if (opts.title !== undefined) args['title'] = opts.title;
|
||||
if (opts.process !== undefined) args['process'] = opts.process;
|
||||
if (opts.path !== undefined) args['path'] = opts.path;
|
||||
if (opts.state !== undefined) args['state'] = opts.state;
|
||||
if (opts.timeoutMs !== undefined) args['timeout_ms'] = Number(opts.timeoutMs);
|
||||
if (opts.interval !== undefined) args['interval_ms'] = Number(opts.interval);
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.GuiAwait, args, opts.json, (d) => {
|
||||
const r = d as {
|
||||
satisfied: boolean; condition: string; state: string;
|
||||
elapsed_ms: number; attempts: number; last_seen: string; last_error?: string; note?: string;
|
||||
};
|
||||
const mark = r.satisfied ? '✓' : '✗';
|
||||
const label = r.condition === 'control' ? String(args['text'] ?? '')
|
||||
: r.condition === 'window' ? String(args['title'] ?? '')
|
||||
: r.condition === 'process' ? String(args['process'] ?? '')
|
||||
: String(args['path'] ?? '');
|
||||
console.log(
|
||||
`${mark} ${r.state === 'absent' ? '已消失' : '已出现'}: ${r.condition} ${label}` +
|
||||
`(${r.elapsed_ms}ms,${r.attempts} 次轮询,末次=${r.last_seen})`,
|
||||
);
|
||||
if (r.note) console.log(` ℹ️ ${r.note}`);
|
||||
if (r.last_error) console.log(` ⚠️ 期间末次异常:${r.last_error.slice(0, 120)}`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- v3+ 审计 ----------
|
||||
|
||||
/** v11:审计链完整性校验。 */
|
||||
@@ -273,7 +273,10 @@ export async function cmdDeploy(localFile: string, opts: Options): Promise<void>
|
||||
const uploadInfo = await withClient(async (c) => {
|
||||
// 备份走协议自带的 fs 能力(而非 shell 的 Copy-Item)——
|
||||
// Copy-Item 是 Windows 专用,在 macOS 被控端上会静默失败(真机踩过:以为备份了,其实没有)。
|
||||
const backup = `${target}.bak-${Date.now()}`;
|
||||
// 备份用**固定名**(覆盖式):此前用 `.bak-<时间戳>`,每部署一次就多一个文件 ——
|
||||
// 真机 2026-10-09 发现 D:\Nodeagent 下堆了 15+ 个 bak(单文件 1MB,纯浪费)。
|
||||
// 部署回滚只需要「上一个可用版本」,一份就够,故改为覆盖写。
|
||||
const backup = `${target}.bak`;
|
||||
let backupPath = '';
|
||||
try {
|
||||
const st = await c.invoke<{ size?: number; exists?: boolean }>(CapabilityNames.FsStat, { path: target });
|
||||
@@ -329,6 +332,23 @@ export async function cmdDeploy(localFile: string, opts: Options): Promise<void>
|
||||
offset += part.length;
|
||||
}
|
||||
console.log(`✓ 已上传 ${(buf.length / 1024).toFixed(0)} KB`);
|
||||
|
||||
// best-effort 清理历史的时间戳备份(老格式 `.bak-<ts>`)。
|
||||
// 平台分流:Windows 用 PS,类 Unix 用 rm。清不掉不影响部署,只提示。
|
||||
try {
|
||||
const isWin = String((info as { os?: string }).os ?? '').toLowerCase().startsWith('win');
|
||||
const cleanup = isWin
|
||||
? `Get-ChildItem '${target}.bak-*' -EA SilentlyContinue | Remove-Item -Force -EA SilentlyContinue; (Get-ChildItem '${target}.bak-*' -EA SilentlyContinue | Measure-Object).Count`
|
||||
: `rm -f '${target}.bak-'* 2>/dev/null; ls '${target}.bak-'* 2>/dev/null | wc -l`;
|
||||
const r = await c.invoke<{ stdout?: string }>(CapabilityNames.ShellExec, { command: cleanup, timeout_ms: 15_000 });
|
||||
const left = Number((r.data?.stdout ?? '').trim());
|
||||
if (r.status === 'ok' && Number.isFinite(left)) {
|
||||
if (left === 0) console.log('✓ 已清理历史时间戳备份(老格式)');
|
||||
else console.log(`⚠️ 仍有 ${left} 个历史备份未清理(可手动删除)`);
|
||||
}
|
||||
} catch {
|
||||
/* 清理是附加项,失败不影响部署结果 */
|
||||
}
|
||||
return { backupPath };
|
||||
});
|
||||
|
||||
@@ -375,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;
|
||||
}
|
||||
}
|
||||
@@ -620,3 +640,115 @@ export async function cmdClip(opts: Options): Promise<void> {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- v2.0.0 状态采样(monitor / log) ----------
|
||||
|
||||
/**
|
||||
* 状态采样与回看。
|
||||
*
|
||||
* nodeagent monitor start --source port --target 127.0.0.1:8765 --interval 2000
|
||||
* nodeagent monitor report <id>
|
||||
* nodeagent monitor stop <id>
|
||||
* nodeagent monitor list
|
||||
* nodeagent monitor delete <id>
|
||||
*/
|
||||
export async function cmdMonitor(action: string | undefined, positional: string[], opts: Options): Promise<void> {
|
||||
switch (action) {
|
||||
case 'start': {
|
||||
const source = opts.source;
|
||||
if (!source) fail('用法: nodeagent monitor start --source <port|process|command|metric> [--target X] [--interval ms] [--id name]');
|
||||
const args: Record<string, unknown> = { source };
|
||||
if (opts.target !== undefined) args['target'] = opts.target;
|
||||
if (opts.interval !== undefined) args['interval_ms'] = Number(opts.interval);
|
||||
if (positional[0]) args['id'] = positional[0];
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.MonitorStart, args, opts.json, (d) => {
|
||||
const r = d as { id: string; source: string; target: string; interval_ms: number; file: string };
|
||||
console.log(`✓ 已启动监控 ${r.id}(${r.source}${r.target ? ' → ' + r.target : ''},每 ${r.interval_ms}ms 一针)`);
|
||||
console.log(` 样本文件:${r.file}`);
|
||||
console.log(` 回看:nodeagent monitor report ${r.id}`);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
case 'report': {
|
||||
const id = positional[0];
|
||||
if (!id) fail('用法: nodeagent monitor report <id> [--limit n]');
|
||||
const args: Record<string, unknown> = { id };
|
||||
if (opts.limit !== undefined) args['limit'] = Number(opts.limit);
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.MonitorReport, args, opts.json, (d) => {
|
||||
const r = d as {
|
||||
running: boolean; source: string; target: string; samples: number;
|
||||
summary: Record<string, number>;
|
||||
};
|
||||
console.log(`${r.running ? '● 运行中' : '○ 已停止'} ${r.source}${r.target ? ' → ' + r.target : ''},共 ${r.samples} 个样本`);
|
||||
// 摘要直接说人话(这是本能力的主要价值)
|
||||
const sm = r.summary;
|
||||
if (sm.down_count !== undefined) {
|
||||
console.log(` 连通:${sm.up_count} 通 / ${sm.down_count} 断;最长连续中断 ${sm.longest_outage_samples} 针` +
|
||||
(sm.ms_avg !== undefined ? `;延迟 min/avg/max = ${sm.ms_min}/${sm.ms_avg}/${sm.ms_max}ms` : ''));
|
||||
}
|
||||
if (sm.dead_count !== undefined) console.log(` 存活:${sm.alive_count} 在 / ${sm.dead_count} 不在;最长连续缺失 ${sm.longest_dead_samples} 针`);
|
||||
if (sm.fail_count !== undefined) console.log(` 命令:${sm.ok_count} 成功 / ${sm.fail_count} 失败;退出码 ${JSON.stringify(sm.exit_codes_seen)}`);
|
||||
if (sm.cpu_avg !== undefined) console.log(` CPU min/avg/max = ${sm.cpu_min}/${sm.cpu_avg}/${sm.cpu_max}%;内存 avg ${sm.mem_avg}%`);
|
||||
if (sm.window_ms !== undefined) console.log(` 时间窗:${(sm.window_ms / 1000).toFixed(1)}s`);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
case 'stop': {
|
||||
const id = positional[0];
|
||||
if (!id) fail('用法: nodeagent monitor stop <id>');
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.MonitorStop, { id }, opts.json, (d) => {
|
||||
const r = d as { id: string; samples: number; file: string };
|
||||
console.log(`✓ 已停止 ${r.id}(共 ${r.samples} 个样本);样本留在 ${r.file}`);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
case 'list': {
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.MonitorList, {}, opts.json, (d) => {
|
||||
const r = d as { running: Array<{ id: string; source: string; target: string; samples: number }>; running_count: number; history_files: string[] };
|
||||
console.log(`运行中 ${r.running_count} 个:`);
|
||||
for (const m of r.running) console.log(` ● ${m.id}(${m.source}${m.target ? ' → ' + m.target : ''},${m.samples} 样本)`);
|
||||
if (r.history_files.length) console.log(`历史文件:${r.history_files.join(', ')}`);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
case 'delete': {
|
||||
const id = positional[0];
|
||||
if (!id) fail('用法: nodeagent monitor delete <id>(运行中需先 stop)');
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.MonitorDelete, { id }, opts.json, () => console.log(`✓ 已删除 ${id} 的历史样本`)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
fail('用法: nodeagent monitor <start|report|stop|list|delete> ...');
|
||||
}
|
||||
}
|
||||
|
||||
/** 在被控端侧过滤日志(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]');
|
||||
const args: Record<string, unknown> = { path: logPath };
|
||||
if (opts.pattern !== undefined) args['pattern'] = opts.pattern;
|
||||
if (opts.level !== undefined) args['level'] = opts.level;
|
||||
if (opts.since !== undefined) args['since'] = Number(opts.since);
|
||||
if (opts.limit !== undefined) args['limit'] = Number(opts.limit);
|
||||
if (opts.offset !== undefined) args['offset'] = Number(opts.offset);
|
||||
if (opts.tail === true) args['tail'] = true;
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.LogQuery, args, opts.json, (d) => {
|
||||
const r = d as { matched: Array<{ n: number; text: string }>; scanned: number; truncated: boolean; note?: string };
|
||||
console.log(`✓ 扫描 ${r.scanned} 行,命中 ${r.matched.length} 条${r.truncated ? '(结果被截断)' : ''}`);
|
||||
for (const m of r.matched) console.log(` ${String(m.n).padStart(6)} ${m.text.slice(0, 160)}`);
|
||||
if (r.note) console.log(` ℹ️ ${r.note}`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -183,6 +183,54 @@ export async function cmdRestart(opts: Options): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- v2.0.0 审计链外部锚定 ----------
|
||||
|
||||
/**
|
||||
* 打印审计链头(可选与最近锚点比对)。
|
||||
*
|
||||
* nodeagent audit head [--compare]
|
||||
*/
|
||||
export async function cmdAuditHead(opts: Options): Promise<void> {
|
||||
const args: Record<string, unknown> = {};
|
||||
if (opts.compare === true) args['compare'] = true;
|
||||
if (opts.anchorPath !== undefined) args['anchor_path'] = opts.anchorPath;
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.AuditHead, args, opts.json, (data) => {
|
||||
const d = data as {
|
||||
entries: number; head_hash: string | null; head_ts: number | null;
|
||||
rotated_segments: number; file_bytes: number;
|
||||
comparison?: { ok: boolean; anchors_checked: number; verdict: string };
|
||||
};
|
||||
console.log(`审计链头:${d.entries} 条,轮转 ${d.rotated_segments} 段,${(d.file_bytes / 1024).toFixed(0)} KB`);
|
||||
console.log(` head_hash : ${d.head_hash ?? '(空链)'}`);
|
||||
console.log(` head_ts : ${d.head_ts ? new Date(d.head_ts).toLocaleString() : '-'}`);
|
||||
console.log(' 💡 把 head_hash 连同 entries 存到链外(另一台机器/网盘)—— 这是外部锚定');
|
||||
if (d.comparison) {
|
||||
const k = d.comparison.ok ? '✓' : '✗';
|
||||
console.log(` ${k} 锚点比对(${d.comparison.anchors_checked} 条锚点):${d.comparison.verdict}`);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加一个锚点到锚点文件。
|
||||
*
|
||||
* nodeagent audit anchor [--anchor-path <文件>] [--note "每日例行"]
|
||||
*/
|
||||
export async function cmdAuditAnchor(opts: Options): Promise<void> {
|
||||
const args: Record<string, unknown> = {};
|
||||
if (opts.anchorPath !== undefined) args['path'] = opts.anchorPath;
|
||||
if (opts.note !== undefined) args['note'] = opts.note;
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.AuditAnchor, args, opts.json, (data) => {
|
||||
const d = data as { file: string; total_lines: number; record: { entries: number; head_hash: string | null } };
|
||||
console.log(`✓ 已锚定:${d.record.entries} 条,链头 ${String(d.record.head_hash).slice(0, 16)}…`);
|
||||
console.log(` 锚点文件:${d.file}(累计 ${d.total_lines} 条锚点)`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function cmdAuditVerify(opts: Options): Promise<void> {
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.AuditVerify, {}, opts.json, (data) => {
|
||||
|
||||
@@ -48,6 +48,13 @@ export const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI)
|
||||
nodeagent macro validate <file.json> | macro init [文件]
|
||||
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 名]
|
||||
启动定时采样并落盘 (v2.0.0):port 验连通性/process 验存活/
|
||||
command 取退出码/metric 取 CPU·内存
|
||||
nodeagent monitor report <id> 回看样本 + 摘要(断几次、最长断多久、值域)(v2.0.0)
|
||||
nodeagent monitor stop|list|delete <id>
|
||||
nodeagent log --path <文件> [--pattern 正则] [--level ERROR] [--limit n] [--tail]
|
||||
在被控端侧过滤日志,只回匹配行(日志几万行也不拖网)(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]
|
||||
|
||||
@@ -34,13 +34,13 @@ import { HELP } from './help.js';
|
||||
import { parseOptions } from './options.js';
|
||||
import { cmdConnect, cmdDiscover, cmdKeygen, cmdNodes, cmdRemove, cmdUse } from './cmd/conn.js';
|
||||
import {
|
||||
cmdApps, cmdAudio, cmdAudit, cmdAuditVerify, cmdExec, cmdInfo, cmdInstall, cmdInvoke, cmdList,
|
||||
cmdApps, cmdAudio, cmdAudit, cmdAuditAnchor, cmdAuditHead, cmdAuditVerify, cmdExec, cmdInfo, cmdInstall, cmdInvoke, cmdList,
|
||||
cmdMetrics, cmdPs, cmdRestart, cmdServices, cmdStatus, cmdUpdate,
|
||||
} from './cmd/system.js';
|
||||
import {
|
||||
cmdBg, cmdClip, cmdDeploy, cmdEvents, cmdFanout, cmdMacro, cmdRecord, cmdTask, cmdTasks,
|
||||
cmdBg, cmdClip, cmdDeploy, cmdEvents, cmdFanout, cmdLog, cmdMacro, cmdMonitor, cmdRecord, cmdTask, cmdTasks,
|
||||
} from './cmd/ops.js';
|
||||
import { cmdKey, cmdMouse, cmdScreen, cmdScreenshot } from './cmd/gui.js';
|
||||
import { cmdAwait, cmdKey, cmdMouse, cmdScreen, cmdScreenshot } from './cmd/gui.js';
|
||||
import { cmdCat, cmdLs, cmdPull, cmdPush, cmdStat } from './cmd/fs.js';
|
||||
import { cmdNet } from './cmd/net.js';
|
||||
import { DAEMON_SOCK, fail, getClientConfig, setNodeOverride } from './core.js';
|
||||
@@ -187,6 +187,14 @@ async function main(): Promise<void> {
|
||||
case 'events':
|
||||
await cmdEvents(opts);
|
||||
return;
|
||||
case 'monitor': {
|
||||
const [action, ...rest2] = positionals;
|
||||
await cmdMonitor(action, rest2, opts);
|
||||
return;
|
||||
}
|
||||
case 'log':
|
||||
await cmdLog(opts);
|
||||
return;
|
||||
case 'macro': {
|
||||
const sub = positionals[0];
|
||||
await cmdMacro(sub, positionals[1], opts);
|
||||
@@ -268,6 +276,8 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
case 'audit':
|
||||
if (positionals[0] === 'verify') await cmdAuditVerify(opts);
|
||||
else if (positionals[0] === 'head') await cmdAuditHead(opts);
|
||||
else if (positionals[0] === 'anchor') await cmdAuditAnchor(opts);
|
||||
else await cmdAudit(opts);
|
||||
return;
|
||||
case 'discover':
|
||||
@@ -304,6 +314,10 @@ async function main(): Promise<void> {
|
||||
await cmdKey(action, rest2, opts);
|
||||
return;
|
||||
}
|
||||
case 'await': {
|
||||
await cmdAwait(opts);
|
||||
return;
|
||||
}
|
||||
case undefined:
|
||||
case 'help':
|
||||
case '--help':
|
||||
|
||||
@@ -54,6 +54,20 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
|
||||
button: { type: 'string' },
|
||||
duration: { type: 'string' },
|
||||
interval: { type: 'string' },
|
||||
tail: { type: 'boolean' },
|
||||
compare: { type: 'boolean' },
|
||||
'anchor-path': { type: 'string' },
|
||||
note: { type: 'string' },
|
||||
level: { type: 'string' },
|
||||
target: { type: 'string' },
|
||||
source: { type: 'string' },
|
||||
where: { type: 'string' },
|
||||
any_of: { type: 'string' },
|
||||
title: { type: 'string' },
|
||||
text: { type: 'string' },
|
||||
process: { type: 'string' },
|
||||
state: { type: 'string' },
|
||||
condition: { type: 'string' },
|
||||
// 媒体键定向投递的目标进程 PID(v1.2)
|
||||
pid: { type: 'string' },
|
||||
hold: { type: 'string' },
|
||||
@@ -66,7 +80,6 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
|
||||
'discovery-port': { type: 'string' },
|
||||
node: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
note: { type: 'string' },
|
||||
recursive: { type: 'boolean', default: false },
|
||||
pattern: { type: 'string' },
|
||||
'create-dirs': { type: 'boolean', default: false },
|
||||
@@ -127,6 +140,20 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
|
||||
button: values['button'] as string | undefined,
|
||||
duration: values['duration'] as string | undefined,
|
||||
interval: values['interval'] as string | undefined,
|
||||
tail: values['tail'] as boolean | undefined,
|
||||
compare: values['compare'] as boolean | undefined,
|
||||
anchorPath: values['anchor-path'] as string | undefined,
|
||||
note: values['note'] as string | undefined,
|
||||
level: values['level'] as string | undefined,
|
||||
target: values['target'] as string | undefined,
|
||||
source: values['source'] as string | undefined,
|
||||
where: values['where'] as string | undefined,
|
||||
any_of: values['any_of'] as string | undefined,
|
||||
condition: values['condition'] as string | undefined,
|
||||
state: values['state'] as string | undefined,
|
||||
process: values['process'] as string | undefined,
|
||||
text: values['text'] as string | undefined,
|
||||
title: values['title'] as string | undefined,
|
||||
pid: values['pid'] as string | undefined,
|
||||
hold: values['hold'] as string | undefined,
|
||||
route: values['route'] as string | undefined,
|
||||
@@ -171,7 +198,6 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
|
||||
seconds: values['seconds'] as string | undefined,
|
||||
intervalMs: values['interval-ms'] as string | undefined,
|
||||
name: values['name'] as string | undefined,
|
||||
note: values['note'] as string | undefined,
|
||||
recursive: Boolean(values['recursive']),
|
||||
pattern: values['pattern'] as string | undefined,
|
||||
createDirs: Boolean(values['create-dirs']),
|
||||
|
||||
@@ -17,15 +17,37 @@ export interface Options {
|
||||
duration?: string;
|
||||
interval?: string;
|
||||
/** 媒体键定向投递的目标进程 PID(v1.2 keyPress) */
|
||||
/** v1.5 快捷键扩展:长按毫秒 */
|
||||
/** 通用选项:文本 / 标题 */
|
||||
text?: string;
|
||||
title?: string;
|
||||
/** v2.0.0 审计锚定 */
|
||||
compare?: boolean;
|
||||
anchorPath?: string;
|
||||
note?: string;
|
||||
/** v2.0.0 状态采样 */
|
||||
source?: string;
|
||||
target?: string;
|
||||
pattern?: string;
|
||||
level?: string;
|
||||
since?: string;
|
||||
offset?: string;
|
||||
tail?: boolean;
|
||||
/** v2.0.0 gui.await 扩展 */
|
||||
where?: string;
|
||||
any_of?: string;
|
||||
/** v1.6.0 gui.await */
|
||||
condition?: string;
|
||||
state?: string;
|
||||
path?: string;
|
||||
process?: string;
|
||||
/** v1.5.0 快捷键扩展:长按毫秒 */
|
||||
hold?: string;
|
||||
/** v1.5 快捷键扩展:投递路由 foreground|post */
|
||||
/** v1.5.0 快捷键扩展:投递路由 foreground|post */
|
||||
route?: string;
|
||||
pid?: string;
|
||||
/** v3:认证模式 psk | ed25519 */
|
||||
authMode?: string;
|
||||
/** v3+ 审计查询 */
|
||||
since?: string;
|
||||
type?: string;
|
||||
clientId?: string;
|
||||
/** v4 发现 */
|
||||
@@ -34,10 +56,8 @@ export interface Options {
|
||||
/** v5 多设备 */
|
||||
node?: string;
|
||||
name?: string;
|
||||
note?: string;
|
||||
/** v5 文件 */
|
||||
recursive?: boolean;
|
||||
pattern?: string;
|
||||
createDirs?: boolean;
|
||||
/** v6 Hub */
|
||||
hubToken?: string;
|
||||
@@ -47,7 +67,6 @@ export interface Options {
|
||||
/** v10 后台任务 / 剪贴板 */
|
||||
timeoutMs?: string;
|
||||
kill?: boolean;
|
||||
offset?: string;
|
||||
set?: string;
|
||||
/** v11 剪贴板图片 */
|
||||
imageFile?: string;
|
||||
@@ -56,7 +75,6 @@ export interface Options {
|
||||
/** v11 多设备并发 */
|
||||
nodes?: string;
|
||||
/** v11 部署目标路径 */
|
||||
path?: string;
|
||||
/** v12 事件订阅 */
|
||||
kind?: string;
|
||||
/** v12 宏变量 */
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,7 @@
|
||||
# Tauri + Vue + TypeScript
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
- [VS Code](https://code.visualstudio.com/) + [Vue - Official](https://marketplace.visualstudio.com/items?itemName=Vue.volar) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tauri + Vue + Typescript App</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "nodeagent-console",
|
||||
"private": true,
|
||||
"version": "2.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"sidecar:dev": "node sidecar/main.mjs",
|
||||
"sidecar:build": "node sidecar/build.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"echarts": "^6.1.0",
|
||||
"naive-ui": "^2.45.3",
|
||||
"pinia": "^4.0.3",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^5.3.1",
|
||||
"@nodeagent/client": "workspace:*",
|
||||
"@nodeagent/protocol": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"typescript": "~6.0.3",
|
||||
"vite": "^8.0.16",
|
||||
"vue-tsc": "^3.3.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
|
||||
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 把 sidecar 打成**单文件**(供 Tauri 打包进应用)。
|
||||
*
|
||||
* 用法:node apps/console/sidecar/build.mjs [--out <dir>]
|
||||
*
|
||||
* 为什么用 esbuild 的 **JS API** 而不是 spawn `node_modules/.bin/esbuild`:
|
||||
* pnpm 的 `.bin` 布局下 spawn 该路径会 ENOENT(scripts/pack.mjs 真机踩过)。
|
||||
* 用 API 则从模块解析走,与包管理器布局无关。
|
||||
*
|
||||
* 依赖处理:`@nodeagent/client` 与 `@nodeagent/protocol` 是 workspace 包,
|
||||
* **一并 bundle 进去**(sidecar 要作为独立文件被 Tauri 拉起,不能依赖仓库 node_modules)。
|
||||
* `ws` 亦被打入(它是纯 JS)。Node 内置模块(readline/process)保持 external。
|
||||
*/
|
||||
|
||||
import { build } from 'esbuild';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const argv = process.argv.slice(2);
|
||||
const outIdx = argv.indexOf('--out');
|
||||
const outFile = join(outIdx >= 0 ? argv[outIdx + 1] : join(here, 'dist'), 'sidecar.mjs');
|
||||
|
||||
mkdirSync(dirname(outFile), { recursive: true });
|
||||
|
||||
const result = await build({
|
||||
entryPoints: [join(here, 'main.mjs')],
|
||||
outfile: outFile,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
target: 'node20',
|
||||
// Node 内置模块保持外部引用;其余(含 ws / workspace 包)全部内联
|
||||
external: ['node:*'],
|
||||
// ⚠️ CJS 依赖(如 ws)里有**动态 require**(`require('events')`),esbuild 打成 ESM 后
|
||||
// 无法转换 → 运行时报 "Dynamic require of X is not supported"。注入 createRequire shim
|
||||
// 是标准解法:让 bundle 内的 `require(...)` 走真实的 CJS 解析。
|
||||
banner: {
|
||||
js:
|
||||
'// nodeagent console sidecar(由 apps/console/sidecar/build.mjs 生成,请勿手工编辑)\n' +
|
||||
"import { createRequire as __naCreateRequire } from 'node:module';\n" +
|
||||
'const require = __naCreateRequire(import.meta.url);',
|
||||
},
|
||||
legalComments: 'none',
|
||||
minify: false, // 保持可读:出问题时能直接看堆栈
|
||||
metafile: true,
|
||||
});
|
||||
|
||||
const bytes = Object.values(result.metafile.outputs)[0]?.bytes ?? 0;
|
||||
console.log(`✓ sidecar 已打包: ${outFile} ${(bytes / 1024).toFixed(0)} KB`);
|
||||
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* console sidecar —— 把 `@nodeagent/client`(TS 客户端)暴露成 **JSON-RPC 2.0 over stdio**。
|
||||
*
|
||||
* ## 为什么需要它(选项 D:Tauri + Node sidecar)
|
||||
* console 的 Rust 壳若自己实现一份 client,就出现「Rust client 与 TS client 两套协议实现」——
|
||||
* 二者会静默漂移(改了一处漏另一处,且构建、单测都不报错)。sidecar 让 Rust 壳退化为
|
||||
* **进程管理者 + 消息转发者**,协议实现只剩 TS 一份。
|
||||
*
|
||||
* ## 协议(行分隔 JSON,每条消息一行)
|
||||
* 请求(Rust → 本进程):
|
||||
* {"jsonrpc":"2.0","id":1,"method":"connect","params":{url,key,client_id,insecure,...}}
|
||||
* {"jsonrpc":"2.0","id":2,"method":"disconnect"}
|
||||
* {"jsonrpc":"2.0","id":3,"method":"invoke","params":{capability,args,timeout_ms}}
|
||||
* {"jsonrpc":"2.0","id":4,"method":"state"}
|
||||
* 响应(本进程 → Rust):{"jsonrpc":"2.0","id":N,"result":{...}} 或 {"...","error":{code,message,data}}
|
||||
* 通知(无 id,本进程主动推):
|
||||
* {"jsonrpc":"2.0","method":"event","params":{"kind":"state","state":"connected"}}
|
||||
* {"jsonrpc":"2.0","method":"event","params":{"kind":"log","message":"..."}}
|
||||
* {"jsonrpc":"2.0","method":"event","params":{"kind":"agent_event","event":{...}}}
|
||||
*
|
||||
* ## 纪律
|
||||
* - **stdout 只写协议消息**(一行一条);日志一律走 stderr —— 混入任何非 JSON 行都会让 Rust 侧解析失败。
|
||||
* - 任何异常都转成 error 响应,**绝不因单个请求崩掉进程**(Rust 侧会当成"连接断了")。
|
||||
*/
|
||||
|
||||
import { createInterface } from 'node:readline';
|
||||
import { NodeAgentClient } from '@nodeagent/client';
|
||||
|
||||
/** @type {NodeAgentClient | null} */
|
||||
let client = null;
|
||||
/** 最近一次连接参数(重连/状态查询用)。 */
|
||||
let lastOptions = null;
|
||||
|
||||
// ---------------- 输出(唯一写 stdout 的地方) ----------------
|
||||
|
||||
function send(msg) {
|
||||
process.stdout.write(JSON.stringify(msg) + '\n');
|
||||
}
|
||||
|
||||
function ok(id, result) {
|
||||
send({ jsonrpc: '2.0', id, result: result ?? null });
|
||||
}
|
||||
|
||||
function fail(id, code, message, data) {
|
||||
send({ jsonrpc: '2.0', id, error: { code, message, ...(data !== undefined ? { data } : {}) } });
|
||||
}
|
||||
|
||||
function notify(kind, payload) {
|
||||
send({ jsonrpc: '2.0', method: 'event', params: { kind, ...payload } });
|
||||
}
|
||||
|
||||
/** 日志走 stderr(stdout 归协议专用)。 */
|
||||
function log(level, message) {
|
||||
process.stderr.write(`[sidecar] ${level} ${message}\n`);
|
||||
}
|
||||
|
||||
// ---------------- 状态快照 ----------------
|
||||
|
||||
function stateSnapshot() {
|
||||
if (!client) return { connected: false };
|
||||
return {
|
||||
connected: true,
|
||||
capabilities: client.listCapabilities(),
|
||||
authorized: client.listAuthorized(),
|
||||
agent_version: client.getAgentVersion() ?? null,
|
||||
agent_build: client.getAgentBuild() ?? null,
|
||||
peer_cert_fp: client.getPeerCertFingerprint(),
|
||||
client_id: lastOptions?.clientId ?? null,
|
||||
auth_mode: lastOptions?.authMode ?? 'psk',
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------- 方法实现 ----------------
|
||||
|
||||
const methods = {
|
||||
/** 建立连接并完成握手。返回与旧 Rust client 的 ConnectInfo 同构的元信息。 */
|
||||
async connect(params) {
|
||||
if (!params?.url || !params?.key) {
|
||||
throw Object.assign(new Error('connect 需要 url 与 key'), { code: -32602 });
|
||||
}
|
||||
// 已有连接先干净关闭,避免两条 ws 并存
|
||||
if (client) {
|
||||
try {
|
||||
client.close();
|
||||
} catch {
|
||||
/* 关不掉也无妨,下面会覆盖 */
|
||||
}
|
||||
}
|
||||
lastOptions = {
|
||||
url: String(params.url),
|
||||
clientId: String(params.client_id ?? 'console'),
|
||||
authMode: params.auth_mode === 'ed25519' ? 'ed25519' : 'psk',
|
||||
};
|
||||
|
||||
client = new NodeAgentClient({
|
||||
url: lastOptions.url,
|
||||
key: String(params.key),
|
||||
clientId: lastOptions.clientId,
|
||||
insecure: params.insecure !== false,
|
||||
...(params.cert_sha256 ? { certSha256: String(params.cert_sha256) } : {}),
|
||||
...(params.auth_mode === 'ed25519' ? { authMode: 'ed25519' } : {}),
|
||||
...(params.private_key ? { privateKey: String(params.private_key) } : {}),
|
||||
...(params.hub ? { hub: params.hub } : {}),
|
||||
autoReconnect: params.auto_reconnect !== false,
|
||||
|
||||
// 三个回调 → 三条通知(Rust 侧再桥接成 Tauri event)
|
||||
onStateChange: (state) => notify('state', { state }),
|
||||
onLog: (message) => notify('log', { message }),
|
||||
onEvent: (event) => notify('agent_event', { event }),
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
log('info', `已连接 ${lastOptions.url}(${lastOptions.clientId})`);
|
||||
return stateSnapshot();
|
||||
},
|
||||
|
||||
disconnect() {
|
||||
if (client) {
|
||||
try {
|
||||
client.close();
|
||||
} catch (err) {
|
||||
log('warn', `关闭连接时异常:${String(err)}`);
|
||||
}
|
||||
client = null;
|
||||
}
|
||||
return { connected: false };
|
||||
},
|
||||
|
||||
async invoke(params) {
|
||||
// 「调用方式不对」(未连接 / 缺参数)→ 协议错误,Rust 侧能看到明确的 code
|
||||
if (!client) throw Object.assign(new Error('尚未连接(先调 connect)'), { code: -32005 });
|
||||
if (!params?.capability) {
|
||||
throw Object.assign(new Error('invoke 需要 capability'), { code: -32602 });
|
||||
}
|
||||
try {
|
||||
// 正常路径:被控端返回 InvokeResult(status: ok|failed)
|
||||
return await client.invoke(
|
||||
String(params.capability),
|
||||
(params.args ?? {}),
|
||||
typeof params.timeout_ms === 'number' ? params.timeout_ms : undefined,
|
||||
);
|
||||
} catch (err) {
|
||||
// ⚠️ 被控端对**未知能力 / ACL 拒绝 / 超时**是回 JSON-RPC error,client 会**抛异常**。
|
||||
// 若原样往上扔,前端就得处理两种形态(result.status 与 error),调用面不统一。
|
||||
// 故归一成 InvokeResult 的 failed 形态 —— 前端只判 status 即可。
|
||||
return {
|
||||
status: 'failed',
|
||||
error: {
|
||||
name: err?.rpcName ?? err?.name ?? 'E_INVOKE_FAILED',
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
...(err?.data !== undefined ? { data: err.data } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
state: () => stateSnapshot(),
|
||||
};
|
||||
|
||||
// ---------------- 主循环 ----------------
|
||||
|
||||
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
||||
|
||||
rl.on('line', (line) => {
|
||||
const text = line.trim();
|
||||
if (!text) return;
|
||||
|
||||
let req;
|
||||
try {
|
||||
req = JSON.parse(text);
|
||||
} catch {
|
||||
fail(null, -32700, 'JSON 解析失败');
|
||||
return;
|
||||
}
|
||||
|
||||
const { id = null, method, params } = req ?? {};
|
||||
const fn = methods[method];
|
||||
if (typeof fn !== 'function') {
|
||||
fail(id, -32601, `未知方法: ${String(method)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 统一包裹:任何异常都变成 error 响应,绝不冒泡崩进程
|
||||
Promise.resolve()
|
||||
.then(() => fn(params ?? {}))
|
||||
.then((result) => ok(id, result))
|
||||
.catch((err) => {
|
||||
const code = typeof err?.code === 'number' ? err.code : -32603;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
log('error', `${String(method)} 失败:${msg}`);
|
||||
fail(id, code, msg, err?.data);
|
||||
});
|
||||
});
|
||||
|
||||
rl.on('close', () => {
|
||||
// Rust 侧关闭 stdin(进程退出/窗口关闭)→ 干净收尾后退出
|
||||
methods.disconnect();
|
||||
log('info', 'stdin 关闭,sidecar 退出');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (err) => {
|
||||
log('error', `未捕获异常:${err?.stack ?? String(err)}`);
|
||||
});
|
||||
process.on('unhandledRejection', (err) => {
|
||||
log('error', `未处理的 rejection:${String(err)}`);
|
||||
});
|
||||
|
||||
log('info', `sidecar 就绪(pid=${process.pid},node=${process.version})`);
|
||||
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
@@ -0,0 +1,36 @@
|
||||
[package]
|
||||
name = "nodeagent-console"
|
||||
version = "2.0.0"
|
||||
description = "nodeagent 桌面控制台(主控 + 被控一体)"
|
||||
authors = ["jkooe"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "nodeagent_console_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# ⚠️ 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
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
opt-level = 3
|
||||
panic = "abort"
|
||||
strip = true
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 567 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 536 B |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 777 B |
|
After Width: | Height: | Size: 1020 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 848 B |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
@@ -0,0 +1,216 @@
|
||||
//! nodeagent 桌面控制台(主控 + 被控一体)——Tauri 后端。
|
||||
//!
|
||||
//! ## 结构(2026-10 起:sidecar 方案)
|
||||
//! 本层只做**进程管理 + 消息转发**,真正的协议实现只有 TS 一份(`@nodeagent/client`),
|
||||
//! 由 `sidecar.rs` 拉起的 Node 进程承载。原先「Rust 版 client 与 TS 版并存」的漂移风险就此消除。
|
||||
//!
|
||||
//! command 面(**签名与语义保持不变,前端无需改动**):
|
||||
//! - `connect` / `disconnect` / `invoke_capability` / `get_capabilities` / `get_state`
|
||||
//!
|
||||
//! Tauri 事件面(名字不变):`na-state` / `na-log` / `na-event`。
|
||||
|
||||
mod sidecar;
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use tauri::{AppHandle, Emitter, Manager, State};
|
||||
|
||||
use sidecar::{Sidecar, SidecarEvent};
|
||||
|
||||
/// 全局状态。
|
||||
///
|
||||
/// `connect` 成功后缓存「能力清单 / 版本 / 连接态」——这样 `get_capabilities`、
|
||||
/// `get_state` 能保持**同步**(无需每次往返 sidecar),签名与改造前一致。
|
||||
struct AppState {
|
||||
sidecar: tokio::sync::Mutex<Option<std::sync::Arc<Sidecar>>>,
|
||||
capabilities: Mutex<Value>,
|
||||
agent_version: Mutex<Option<String>>,
|
||||
conn_state: Mutex<String>,
|
||||
}
|
||||
|
||||
/// 连接成功后回传给前端的元信息(字段名与改造前一致)。
|
||||
#[derive(serde::Serialize)]
|
||||
struct ConnectInfo {
|
||||
capabilities: Value,
|
||||
agent_version: Option<String>,
|
||||
authorized: Option<Value>,
|
||||
peer_cert_fp: Option<String>,
|
||||
auth_mode: String,
|
||||
client_id: String,
|
||||
}
|
||||
|
||||
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(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
url: String,
|
||||
key: String,
|
||||
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 snap = sc
|
||||
.request(
|
||||
"connect",
|
||||
json!({
|
||||
"url": url,
|
||||
"key": key,
|
||||
"client_id": client_id,
|
||||
"insecure": insecure.unwrap_or(true),
|
||||
}),
|
||||
30,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 缓存快照,供同步 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();
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
/// 断开连接(停止自动重连)。
|
||||
#[tauri::command]
|
||||
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(())
|
||||
}
|
||||
|
||||
/// 调用一项被控端能力。`timeout_ms` 可选:长任务(装软件 / 长命令)需显式放大。
|
||||
#[tauri::command]
|
||||
async fn invoke_capability(
|
||||
state: State<'_, AppState>,
|
||||
capability: String,
|
||||
args: Option<Value>,
|
||||
timeout_ms: Option<u64>,
|
||||
) -> Result<Value, String> {
|
||||
let sc = {
|
||||
state
|
||||
.sidecar
|
||||
.lock()
|
||||
.await
|
||||
.as_ref()
|
||||
.map(std::sync::Arc::clone)
|
||||
.ok_or_else(|| "未连接".to_string())?
|
||||
};
|
||||
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);
|
||||
}
|
||||
// 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<Value, String> {
|
||||
Ok(state.capabilities.lock().unwrap().clone())
|
||||
}
|
||||
|
||||
/// 当前连接状态字符串(connected / reconnecting / closed / 未连接)。
|
||||
#[tauri::command]
|
||||
fn get_state(state: State<'_, AppState>) -> 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::default())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
connect,
|
||||
disconnect,
|
||||
invoke_capability,
|
||||
get_capabilities,
|
||||
get_state
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|app, event| {
|
||||
// app 退出时优雅收尾:先让 sidecar 主动 disconnect,再杀进程
|
||||
//(Sidecar 设了 kill_on_drop,即使这里失败也不会留孤儿进程)
|
||||
if let tauri::RunEvent::Exit = event {
|
||||
let state = app.state::<AppState>();
|
||||
let sc = tauri::async_runtime::block_on(async {
|
||||
state.sidecar.lock().await.as_ref().map(std::sync::Arc::clone)
|
||||
});
|
||||
if let Some(sc) = sc {
|
||||
tauri::async_runtime::block_on(async {
|
||||
sc.shutdown().await;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
nodeagent_console_lib::run()
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "nodeagent-console",
|
||||
"version": "2.0.1",
|
||||
"identifier": "cn.jkooe.nodeagent-console",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "pnpm build && pnpm sidecar:build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "nodeagent 控制台",
|
||||
"width": 1100,
|
||||
"height": 720
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"resources": {
|
||||
"../sidecar/dist/sidecar.mjs": "sidecar.mjs"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
NConfigProvider,
|
||||
NMessageProvider,
|
||||
NDialogProvider,
|
||||
darkTheme,
|
||||
zhCN,
|
||||
dateZhCN,
|
||||
} from "naive-ui";
|
||||
import AppShell from "./components/AppShell.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-config-provider :theme="darkTheme" :locale="zhCN" :date-locale="dateZhCN">
|
||||
<n-message-provider>
|
||||
<n-dialog-provider>
|
||||
<app-shell />
|
||||
</n-dialog-provider>
|
||||
</n-message-provider>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
@@ -0,0 +1,88 @@
|
||||
/** Tauri command 调用封装(前端 → Rust 壳 → Node sidecar → 被控端)。 */
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { CapabilityDescriptor, ConnectInfo, InvokeResult } from "../types";
|
||||
|
||||
/** 建立连接并握手。 */
|
||||
export function connect(url: string, key: string, clientId = "console", insecure = true) {
|
||||
return invoke<ConnectInfo>("connect", { url, key, clientId, insecure });
|
||||
}
|
||||
|
||||
/** 断开连接(停自动重连)。 */
|
||||
export function disconnect() {
|
||||
return invoke<void>("disconnect");
|
||||
}
|
||||
|
||||
/** 泛型能力调用(`timeoutMs` 用于长任务,如装软件 / 长命令)。 */
|
||||
export function call<T = unknown>(
|
||||
capability: string,
|
||||
args: Record<string, unknown> = {},
|
||||
timeoutMs?: number,
|
||||
) {
|
||||
return invoke<InvokeResult<T>>("invoke_capability", { capability, args, timeoutMs });
|
||||
}
|
||||
|
||||
/** 当前连接的能力清单。 */
|
||||
export function getCapabilities() {
|
||||
return invoke<CapabilityDescriptor[]>("get_capabilities");
|
||||
}
|
||||
|
||||
/** 当前连接状态字符串。 */
|
||||
export function getState() {
|
||||
return invoke<string>("get_state");
|
||||
}
|
||||
|
||||
/**
|
||||
* 能力调用失败(业务层失败,非传输层失败)。
|
||||
*
|
||||
* 协议真源 `InvokeResult.error` 只有 `{ name, message, data? }` —— **不含 code**,
|
||||
* 所以分支判断一律走 `name`(如 `E_UNSUPPORTED_PLATFORM`)。
|
||||
*/
|
||||
export class CapabilityFailure extends Error {
|
||||
readonly capability: string;
|
||||
readonly errorName: string;
|
||||
readonly data?: unknown;
|
||||
|
||||
constructor(capability: string, errorName: string, message: string, data?: unknown) {
|
||||
super(message);
|
||||
this.name = "CapabilityFailure";
|
||||
this.capability = capability;
|
||||
this.errorName = errorName;
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
/** 被控端平台不支持该能力(如 macOS 被控端上的 app.* / system.service.list)。 */
|
||||
export function isUnsupportedPlatform(e: unknown): boolean {
|
||||
return e instanceof CapabilityFailure && e.errorName === "E_UNSUPPORTED_PLATFORM";
|
||||
}
|
||||
|
||||
/** 能力被 ACL 拒绝。 */
|
||||
export function isAclDenied(e: unknown): boolean {
|
||||
return e instanceof CapabilityFailure && e.errorName === "E_ACL_DENIED";
|
||||
}
|
||||
|
||||
/** 能力被调用限速拦截。 */
|
||||
export function isRateLimited(e: unknown): boolean {
|
||||
return e instanceof CapabilityFailure && e.errorName === "E_RATE_LIMITED";
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用并要求成功:失败时抛出 `CapabilityFailure`(携带被控端 error.name)。
|
||||
* 便于页面里 `await ok("fs.list", { path })` 直接拿数据。
|
||||
*/
|
||||
export async function ok<T = unknown>(
|
||||
capability: string,
|
||||
args: Record<string, unknown> = {},
|
||||
timeoutMs?: number,
|
||||
): Promise<T> {
|
||||
const res = await call<T>(capability, args, timeoutMs);
|
||||
if (res.status !== "ok") {
|
||||
throw new CapabilityFailure(
|
||||
capability,
|
||||
res.error?.name ?? "E_UNKNOWN",
|
||||
res.error?.message ?? `${capability} 执行失败`,
|
||||
res.error?.data,
|
||||
);
|
||||
}
|
||||
return res.data as T;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted } from "vue";
|
||||
import { RouterView, useRoute, useRouter } from "vue-router";
|
||||
import {
|
||||
NLayout,
|
||||
NLayoutSider,
|
||||
NLayoutHeader,
|
||||
NLayoutContent,
|
||||
NMenu,
|
||||
NTag,
|
||||
NButton,
|
||||
NSpace,
|
||||
NText,
|
||||
type MenuOption,
|
||||
} from "naive-ui";
|
||||
import { navRoutes } from "../router";
|
||||
import { useConnStore } from "../stores/conn";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const conn = useConnStore();
|
||||
|
||||
onMounted(() => {
|
||||
conn.bind();
|
||||
});
|
||||
|
||||
const menuOptions: MenuOption[] = navRoutes.map((r) => ({
|
||||
key: r.name as string,
|
||||
label: () => h("span", `${(r.meta as { icon: string }).icon} ${(r.meta as { title: string }).title}`),
|
||||
}));
|
||||
|
||||
const activeKey = computed(() => (route.name as string) ?? "nodes");
|
||||
|
||||
function onMenu(key: string) {
|
||||
router.push({ name: key });
|
||||
}
|
||||
|
||||
const stateType = computed(() => {
|
||||
if (conn.state === "connected") return "success" as const;
|
||||
if (conn.state === "reconnecting") return "warning" as const;
|
||||
return "error" as const;
|
||||
});
|
||||
|
||||
const stateLabel = computed(() => {
|
||||
if (conn.state === "connected") return "已连接";
|
||||
if (conn.state === "reconnecting") return "重连中";
|
||||
return "未连接";
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-layout has-sider style="height: 100vh">
|
||||
<n-layout-sider bordered :width="200" :native-scrollbar="false">
|
||||
<div class="brand">
|
||||
<div class="brand-title">nodeagent</div>
|
||||
<div class="brand-sub">桌面控制台</div>
|
||||
</div>
|
||||
<n-menu :options="menuOptions" :value="activeKey" @update:value="onMenu" />
|
||||
</n-layout-sider>
|
||||
|
||||
<n-layout>
|
||||
<n-layout-header bordered class="topbar">
|
||||
<n-space align="center">
|
||||
<n-tag :type="stateType" size="small" round>{{ stateLabel }}</n-tag>
|
||||
<n-text depth="3" style="font-size: 12px">{{ conn.url }}</n-text>
|
||||
<n-text v-if="conn.info" depth="3" style="font-size: 12px">
|
||||
· agent {{ conn.info.agent_version ?? "?" }} · {{ conn.info.capabilities.length }} 项能力
|
||||
</n-text>
|
||||
</n-space>
|
||||
<n-button size="small" :disabled="!conn.info" @click="conn.disconnect()">断开</n-button>
|
||||
</n-layout-header>
|
||||
|
||||
<n-layout-content :native-scrollbar="false" content-style="padding: 20px;">
|
||||
<router-view />
|
||||
</n-layout-content>
|
||||
</n-layout>
|
||||
</n-layout>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
.brand {
|
||||
padding: 18px 20px 10px;
|
||||
}
|
||||
.brand-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #63e2b7;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.brand-sub {
|
||||
font-size: 12px;
|
||||
opacity: 0.6;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.topbar {
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import router from "./router";
|
||||
import App from "./App.vue";
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
app.mount("#app");
|
||||
@@ -0,0 +1,60 @@
|
||||
/** 路由表(7 页)。hash history 便于 Tauri 打包后稳定工作。 */
|
||||
import { createRouter, createWebHashHistory } from "vue-router";
|
||||
|
||||
export interface NavMeta {
|
||||
title: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const routes = [
|
||||
{ path: "/", redirect: "/nodes" },
|
||||
{
|
||||
path: "/nodes",
|
||||
name: "nodes",
|
||||
component: () => import("../views/NodesView.vue"),
|
||||
meta: { title: "设备管理", icon: "🖥" } satisfies NavMeta,
|
||||
},
|
||||
{
|
||||
path: "/overview",
|
||||
name: "overview",
|
||||
component: () => import("../views/OverviewView.vue"),
|
||||
meta: { title: "状态总览", icon: "📊" } satisfies NavMeta,
|
||||
},
|
||||
{
|
||||
path: "/terminal",
|
||||
name: "terminal",
|
||||
component: () => import("../views/TerminalView.vue"),
|
||||
meta: { title: "命令执行", icon: "⌨" } satisfies NavMeta,
|
||||
},
|
||||
{
|
||||
path: "/processes",
|
||||
name: "processes",
|
||||
component: () => import("../views/ProcessesView.vue"),
|
||||
meta: { title: "进程与服务", icon: "⚙" } satisfies NavMeta,
|
||||
},
|
||||
{
|
||||
path: "/software",
|
||||
name: "software",
|
||||
component: () => import("../views/SoftwareView.vue"),
|
||||
meta: { title: "软件管理", icon: "📦" } satisfies NavMeta,
|
||||
},
|
||||
{
|
||||
path: "/files",
|
||||
name: "files",
|
||||
component: () => import("../views/FilesView.vue"),
|
||||
meta: { title: "文件管理", icon: "📁" } satisfies NavMeta,
|
||||
},
|
||||
{
|
||||
path: "/audit",
|
||||
name: "audit",
|
||||
component: () => import("../views/AuditView.vue"),
|
||||
meta: { title: "审计日志", icon: "📜" } satisfies NavMeta,
|
||||
},
|
||||
];
|
||||
|
||||
export const navRoutes = routes.filter((r) => "name" in r);
|
||||
|
||||
export default createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
/** 连接状态 store:连接/断开、能力清单、元信息、日志与被控端事件。 */
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, ref } from "vue";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import * as api from "../api";
|
||||
import type { ConnectInfo, InvokeResult, SystemInfo } from "../types";
|
||||
|
||||
export interface AgentEvent {
|
||||
ts: number;
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅在被控端为 Windows 时可用(其余平台返回 E_UNSUPPORTED_PLATFORM)。
|
||||
* 前端据此**事前门控**,而不是发出注定失败的请求再解释错误。
|
||||
*/
|
||||
export const WINDOWS_ONLY_CAPS = ["app.list", "app.install", "system.service.list"] as const;
|
||||
|
||||
export const useConnStore = defineStore("conn", () => {
|
||||
const state = ref<string>("closed");
|
||||
const url = ref(localStorage.getItem("na.url") || "ws://127.0.0.1:8765");
|
||||
const key = ref(localStorage.getItem("na.key") || "");
|
||||
const info = ref<ConnectInfo | null>(null);
|
||||
const logs = ref<string[]>([]);
|
||||
const events = ref<AgentEvent[]>([]);
|
||||
const connecting = ref(false);
|
||||
const bound = ref(false);
|
||||
/** 被控端平台:'Windows' | 'macOS' | 'Linux' | ... (连接后自动探测) */
|
||||
const nodeOs = ref<string | null>(null);
|
||||
|
||||
const connected = computed(() => state.value === "connected" && !!info.value);
|
||||
const capabilities = computed(() => info.value?.capabilities ?? []);
|
||||
/** 被控端能力名集合,便于按名判存在性。 */
|
||||
const capabilityNames = computed(() => new Set(capabilities.value.map((c) => c.name)));
|
||||
const isWindows = computed(() => nodeOs.value === "Windows");
|
||||
/** 平台门控能力当前是否可用(未探测到平台时按「乐观可用」处理)。 */
|
||||
const platformReady = computed(() => nodeOs.value === null || isWindows.value);
|
||||
|
||||
function pushLog(msg: string) {
|
||||
logs.value.push(`[${new Date().toLocaleTimeString()}] ${msg}`);
|
||||
if (logs.value.length > 500) logs.value.shift();
|
||||
}
|
||||
|
||||
/** 绑定后端事件(应用启动时调用一次)。 */
|
||||
async function bind() {
|
||||
if (bound.value) return;
|
||||
bound.value = true;
|
||||
await listen<string>("na-log", (e) => pushLog(e.payload));
|
||||
await listen<string>("na-state", (e) => {
|
||||
state.value = e.payload;
|
||||
pushLog(`状态 → ${e.payload}`);
|
||||
});
|
||||
await listen<unknown>("na-event", (e) => {
|
||||
events.value.unshift({ ts: Date.now(), payload: e.payload });
|
||||
if (events.value.length > 200) events.value.pop();
|
||||
pushLog(`被控端事件:${JSON.stringify(e.payload)}`);
|
||||
});
|
||||
state.value = await api.getState();
|
||||
}
|
||||
|
||||
/** 探测被控端平台(`system.info.os`)——决定 Windows 专属能力是否可用。 */
|
||||
async function loadPlatform() {
|
||||
try {
|
||||
const i = await api.ok<SystemInfo>("system.info", { fields: ["os"] });
|
||||
nodeOs.value = i.os ?? null;
|
||||
pushLog(`被控端平台:${nodeOs.value ?? "未知"}`);
|
||||
return nodeOs.value;
|
||||
} catch (e) {
|
||||
pushLog(`平台探测失败:${e}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function connect(u = url.value, k = key.value) {
|
||||
connecting.value = true;
|
||||
url.value = u;
|
||||
key.value = k;
|
||||
pushLog(`正在连接 ${u} ...`);
|
||||
try {
|
||||
const i = await api.connect(u, k, "console");
|
||||
info.value = i;
|
||||
localStorage.setItem("na.url", u);
|
||||
localStorage.setItem("na.key", k);
|
||||
pushLog(`握手成功:${i.capabilities.length} 项能力,agent ${i.agent_version ?? "?"}`);
|
||||
// 握手后立刻探测平台,供各页面事前门控。
|
||||
await loadPlatform();
|
||||
return i;
|
||||
} catch (e) {
|
||||
pushLog(`连接失败:${e}`);
|
||||
throw e;
|
||||
} finally {
|
||||
connecting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnect() {
|
||||
await api.disconnect();
|
||||
info.value = null;
|
||||
nodeOs.value = null;
|
||||
pushLog("已断开");
|
||||
}
|
||||
|
||||
/** 泛型调用;连接断开时抛错。 */
|
||||
async function call<T = unknown>(
|
||||
capability: string,
|
||||
args: Record<string, unknown> = {},
|
||||
timeoutMs?: number,
|
||||
): Promise<InvokeResult<T>> {
|
||||
if (!connected.value) throw new api.CapabilityFailure(capability, "E_NODE_OFFLINE", "未连接");
|
||||
const t0 = performance.now();
|
||||
const res = await api.call<T>(capability, args, timeoutMs);
|
||||
const ms = Math.round(performance.now() - t0);
|
||||
if (res.status === "ok") {
|
||||
pushLog(`${capability} → ok (${ms}ms)`);
|
||||
} else {
|
||||
pushLog(`${capability} → 失败:[${res.error?.name}] ${res.error?.message ?? "未知错误"}`);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用并要求成功,直接返回 data。
|
||||
* 失败抛 `CapabilityFailure`(携带 `errorName`),便于页面按错误名分支
|
||||
* ——如 `isUnsupportedPlatform(e)` 判平台不支持。
|
||||
*/
|
||||
async function ok<T = unknown>(
|
||||
capability: string,
|
||||
args: Record<string, unknown> = {},
|
||||
timeoutMs?: number,
|
||||
): Promise<T> {
|
||||
const res = await call<T>(capability, args, timeoutMs);
|
||||
if (res.status !== "ok") {
|
||||
throw new api.CapabilityFailure(
|
||||
capability,
|
||||
res.error?.name ?? "E_UNKNOWN",
|
||||
res.error?.message ?? `${capability} 执行失败`,
|
||||
res.error?.data,
|
||||
);
|
||||
}
|
||||
return res.data as T;
|
||||
}
|
||||
|
||||
function clearLogs() {
|
||||
logs.value = [];
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
url,
|
||||
key,
|
||||
info,
|
||||
logs,
|
||||
events,
|
||||
connecting,
|
||||
connected,
|
||||
capabilities,
|
||||
capabilityNames,
|
||||
nodeOs,
|
||||
isWindows,
|
||||
platformReady,
|
||||
bind,
|
||||
loadPlatform,
|
||||
connect,
|
||||
disconnect,
|
||||
call,
|
||||
ok,
|
||||
pushLog,
|
||||
clearLogs,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/** 设备列表 store:本地保存的被控端配置(名称 / 地址 / PSK),CRUD + 持久化。 */
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
export interface NodeProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export const useNodeStore = defineStore("node", () => {
|
||||
const nodes = ref<NodeProfile[]>(JSON.parse(localStorage.getItem("na.nodes") || "[]"));
|
||||
const currentId = ref<string | null>(localStorage.getItem("na.current"));
|
||||
|
||||
const current = computed(() => nodes.value.find((n) => n.id === currentId.value) ?? null);
|
||||
|
||||
function persist() {
|
||||
localStorage.setItem("na.nodes", JSON.stringify(nodes.value));
|
||||
localStorage.setItem("na.current", currentId.value ?? "");
|
||||
}
|
||||
|
||||
function add(profile: Omit<NodeProfile, "id">): NodeProfile {
|
||||
const node: NodeProfile = { id: crypto.randomUUID(), ...profile };
|
||||
nodes.value.push(node);
|
||||
persist();
|
||||
return node;
|
||||
}
|
||||
|
||||
function update(id: string, patch: Partial<NodeProfile>) {
|
||||
const idx = nodes.value.findIndex((n) => n.id === id);
|
||||
if (idx >= 0) {
|
||||
nodes.value[idx] = { ...nodes.value[idx], ...patch };
|
||||
persist();
|
||||
}
|
||||
}
|
||||
|
||||
function remove(id: string) {
|
||||
nodes.value = nodes.value.filter((n) => n.id !== id);
|
||||
if (currentId.value === id) currentId.value = null;
|
||||
persist();
|
||||
}
|
||||
|
||||
function setCurrent(id: string | null) {
|
||||
currentId.value = id;
|
||||
persist();
|
||||
}
|
||||
|
||||
return { nodes, currentId, current, add, update, remove, setCurrent };
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
/** 协议类型镜像(对齐 `@nodeagent/protocol`,亦即 sidecar 与 Tauri command 的序列化形状)。 */
|
||||
|
||||
/** 能力描述(被控端声明的能力清单条目)。 */
|
||||
export interface CapabilityDescriptor {
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
risk: "low" | "medium" | "high" | string;
|
||||
params_schema: JsonSchema;
|
||||
returns_schema?: JsonSchema;
|
||||
}
|
||||
|
||||
/** JSON Schema 子集。 */
|
||||
export interface JsonSchema {
|
||||
type?: string;
|
||||
properties?: Record<string, JsonSchema>;
|
||||
required?: string[];
|
||||
additionalProperties?: boolean;
|
||||
enum?: unknown[];
|
||||
default?: unknown;
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
pattern?: string;
|
||||
items?: JsonSchema;
|
||||
minItems?: number;
|
||||
maxItems?: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** connect command 返回的连接元信息。 */
|
||||
export interface ConnectInfo {
|
||||
capabilities: CapabilityDescriptor[];
|
||||
agent_version: string | null;
|
||||
authorized: string[] | null;
|
||||
peer_cert_fp: string | null;
|
||||
auth_mode: string;
|
||||
client_id: string;
|
||||
}
|
||||
|
||||
/** 能力执行失败的错误信息。 */
|
||||
export interface InvokeError {
|
||||
name: string;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
/** 能力执行结果(业务层)。 */
|
||||
export interface InvokeResult<T = unknown> {
|
||||
status: "ok" | "failed";
|
||||
data?: T;
|
||||
error?: InvokeError;
|
||||
}
|
||||
|
||||
/** 连接状态。 */
|
||||
export type ConnState = "connected" | "reconnecting" | "closed";
|
||||
|
||||
// ---------- 各能力的返回结构 ----------
|
||||
|
||||
/** 被控端构建信息(v18 构建指纹 + 运行状态)。 */
|
||||
export interface BuildInfo {
|
||||
version?: string;
|
||||
hash?: string;
|
||||
commit?: string;
|
||||
built_at?: string;
|
||||
/** 运行中 agent 脚本的 sha256(v21 证书/脚本交叉核对) */
|
||||
cert_sha256?: string;
|
||||
bytes?: number;
|
||||
mtime_ms?: number;
|
||||
node?: string;
|
||||
started_at?: number;
|
||||
uptime_ms?: number;
|
||||
}
|
||||
|
||||
/** PowerShell 常驻助手状态(v15)。 */
|
||||
export interface PsHelperInfo {
|
||||
spawned?: boolean;
|
||||
hits?: number;
|
||||
failures?: number;
|
||||
consecutiveFailures?: number;
|
||||
avg_ms?: number;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
/** v21 来源网段访问控制。 */
|
||||
export interface NetworkInfo {
|
||||
/** null = **未配置 = 放行全部**(应尽快收敛) */
|
||||
allow_from?: string[] | null;
|
||||
}
|
||||
|
||||
export interface SystemInfo {
|
||||
hostname?: string;
|
||||
/** 被控端平台:'Windows' | 'macOS' | 'Linux' —— **平台门控能力的判断依据** */
|
||||
os?: string;
|
||||
os_version?: string;
|
||||
arch?: string;
|
||||
cpu_model?: string;
|
||||
cpu_cores?: number;
|
||||
memory_total?: number;
|
||||
uptime_sec?: number;
|
||||
is_admin?: boolean;
|
||||
pid?: number;
|
||||
agent_home?: string;
|
||||
/** 被控端入口脚本路径,供一键升级定位 */
|
||||
agent_script?: string;
|
||||
/** 被控端 Node 可执行文件路径(字段名是 `node_path`,没有独立的 `node`) */
|
||||
node_path?: string;
|
||||
ps_helper?: PsHelperInfo;
|
||||
network?: NetworkInfo;
|
||||
build?: BuildInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 磁盘(对齐 manifest system.status.disks 契约)。
|
||||
* 注意:**没有 `used` 字段**,已用量需自行算 `total - free`。
|
||||
*/
|
||||
export interface DiskInfo {
|
||||
drive?: string;
|
||||
total?: number;
|
||||
free?: number;
|
||||
used_pct?: number;
|
||||
}
|
||||
|
||||
/** 网络适配器(对齐 manifest system.status.net 契约)。 */
|
||||
export interface NetInfo {
|
||||
adapter?: string;
|
||||
ip?: string;
|
||||
up?: boolean;
|
||||
}
|
||||
|
||||
export interface SystemStatus {
|
||||
cpu_pct?: number;
|
||||
memory_used?: number;
|
||||
memory_total?: number;
|
||||
memory_pct?: number;
|
||||
disks?: DiskInfo[];
|
||||
net?: NetInfo[];
|
||||
}
|
||||
|
||||
/** 进程项(对齐 manifest system.process.list 契约)。 */
|
||||
export interface ProcessEntry {
|
||||
pid: number;
|
||||
name: string;
|
||||
/** CPU 占用百分比(非 `cpu`) */
|
||||
cpu_pct?: number;
|
||||
/** 常驻内存字节数(非 `memory`) */
|
||||
memory_bytes?: number;
|
||||
started_at?: number;
|
||||
}
|
||||
|
||||
/** 服务项(对齐 manifest system.service.list 契约)。 */
|
||||
export interface ServiceEntry {
|
||||
name: string;
|
||||
display_name?: string;
|
||||
/** 运行状态字段名是 `state`,不是 `status` */
|
||||
state?: string;
|
||||
start_type?: string;
|
||||
}
|
||||
|
||||
export interface FsEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "file" | "dir" | "other";
|
||||
size: number;
|
||||
mtime: number;
|
||||
}
|
||||
|
||||
export interface FsListResult {
|
||||
entries: FsEntry[];
|
||||
total: number;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export interface FsReadResult {
|
||||
data: string;
|
||||
encoding: string;
|
||||
offset: number;
|
||||
bytes: number;
|
||||
total_bytes: number;
|
||||
eof: boolean;
|
||||
sha256?: string;
|
||||
}
|
||||
|
||||
/** 已装软件项(对齐 manifest app.list 契约)。 */
|
||||
export interface AppEntry {
|
||||
name: string;
|
||||
version?: string;
|
||||
publisher?: string;
|
||||
/** 'registry' | 'uwp' | 'winget' 等来源标记 */
|
||||
source?: string;
|
||||
}
|
||||
|
||||
/** 审计事件类别(对齐 apps/agent/src/audit.ts 的 `AuditType`)。 */
|
||||
export type AuditType =
|
||||
| "agent.start"
|
||||
| "agent.stop"
|
||||
| "auth.success"
|
||||
| "auth.failure"
|
||||
| "invoke"
|
||||
| "acl.denied"
|
||||
| "capability.disabled"
|
||||
| "rate.limited"
|
||||
| "net.change"
|
||||
| "net.deny"
|
||||
| "agent.update"
|
||||
| "session.idle_close";
|
||||
|
||||
export interface AuditEntry {
|
||||
ts?: number;
|
||||
type?: AuditType | string;
|
||||
client_id?: string;
|
||||
remote?: string;
|
||||
capability?: string;
|
||||
status?: "ok" | "failed" | "denied";
|
||||
duration_ms?: number;
|
||||
error?: string | null;
|
||||
/** 参数摘要(sha256 前缀)——默认只记摘要,不记内容 */
|
||||
args_digest?: string;
|
||||
/** 参数预览(仅 `log_args:true` 时记录,键含敏感词自动脱敏) */
|
||||
args_preview?: string;
|
||||
reason?: string;
|
||||
/** v11 防篡改:上一条哈希(首条为 "genesis") */
|
||||
prev?: string;
|
||||
/** v11 防篡改:本条哈希 */
|
||||
hash?: string;
|
||||
}
|
||||
|
||||
export interface AuditListResult {
|
||||
entries: AuditEntry[];
|
||||
total: number;
|
||||
file?: string;
|
||||
}
|
||||
|
||||
/** 审计链首个异常位置(`ok:false` 时存在)。 */
|
||||
export interface AuditBrokenAt {
|
||||
file?: string;
|
||||
line?: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface AuditVerifyResult {
|
||||
ok: boolean;
|
||||
checked: number;
|
||||
/** 无链字段的历史条目数(跳过校验) */
|
||||
legacy?: number;
|
||||
/** 是**对象**而非数字 */
|
||||
broken_at?: AuditBrokenAt | null;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/** 通用格式化工具。 */
|
||||
|
||||
export function formatBytes(n?: number | null): string {
|
||||
if (n == null || Number.isNaN(n)) return "-";
|
||||
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||
let v = n;
|
||||
let i = 0;
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
export function formatDuration(sec?: number | null): string {
|
||||
if (sec == null || Number.isNaN(sec)) return "-";
|
||||
const d = Math.floor(sec / 86400);
|
||||
const h = Math.floor((sec % 86400) / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
if (d) return `${d} 天 ${h} 小时`;
|
||||
if (h) return `${h} 小时 ${m} 分`;
|
||||
return `${m} 分`;
|
||||
}
|
||||
|
||||
export function formatTime(ms?: number | null): string {
|
||||
if (!ms) return "-";
|
||||
return new Date(ms).toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
export function formatPct(v?: number | null): string {
|
||||
if (v == null || Number.isNaN(v)) return "-";
|
||||
return `${v.toFixed(1)}%`;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
<script setup lang="ts">
|
||||
import { h, onMounted, ref } from "vue";
|
||||
import {
|
||||
NCard,
|
||||
NDataTable,
|
||||
NButton,
|
||||
NSpace,
|
||||
NInputNumber,
|
||||
NSelect,
|
||||
NText,
|
||||
NTag,
|
||||
NEmpty,
|
||||
NAlert,
|
||||
useMessage,
|
||||
type DataTableColumns,
|
||||
} from "naive-ui";
|
||||
import { useConnStore } from "../stores/conn";
|
||||
import type { AuditEntry, AuditListResult, AuditVerifyResult } from "../types";
|
||||
import { formatTime } from "../utils/format";
|
||||
|
||||
const conn = useConnStore();
|
||||
const msg = useMessage();
|
||||
|
||||
const entries = ref<AuditEntry[]>([]);
|
||||
const total = ref(0);
|
||||
const limit = ref(100);
|
||||
const typeFilter = ref<string | null>(null);
|
||||
const loading = ref(false);
|
||||
const verify = ref<AuditVerifyResult | null>(null);
|
||||
|
||||
const typeOptions = [
|
||||
{ label: "全部", value: "" },
|
||||
{ label: "invoke", value: "invoke" },
|
||||
{ label: "auth.success", value: "auth.success" },
|
||||
{ label: "auth.failure", value: "auth.failure" },
|
||||
{ label: "acl.denied", value: "acl.denied" },
|
||||
{ label: "capability.disabled", value: "capability.disabled" },
|
||||
{ label: "rate.limited", value: "rate.limited" },
|
||||
{ label: "net.deny", value: "net.deny" },
|
||||
{ label: "net.change", value: "net.change" },
|
||||
{ label: "agent.update", value: "agent.update" },
|
||||
{ label: "agent.start", value: "agent.start" },
|
||||
{ label: "agent.stop", value: "agent.stop" },
|
||||
];
|
||||
|
||||
const cols: DataTableColumns<AuditEntry> = [
|
||||
{ title: "时间", key: "ts", width: 180, render: (r) => formatTime(r.ts) },
|
||||
{
|
||||
title: "类型",
|
||||
key: "type",
|
||||
width: 150,
|
||||
render: (r) => {
|
||||
const t = r.type ?? "-";
|
||||
const kind = t.includes("denied") || t.includes("failure") || t.includes("deny") ? "error" : "default";
|
||||
return h(NTag, { size: "small", type: kind }, { default: () => t });
|
||||
},
|
||||
},
|
||||
{ title: "调用方", key: "client_id", width: 110 },
|
||||
{ title: "来源", key: "remote", width: 120 },
|
||||
{ title: "能力", key: "capability", ellipsis: { tooltip: true } },
|
||||
{
|
||||
title: "状态",
|
||||
key: "status",
|
||||
width: 90,
|
||||
render: (r) => {
|
||||
if (!r.status) return "-";
|
||||
const kind = r.status === "ok" ? "success" : "error";
|
||||
return h(NTag, { size: "small", type: kind }, { default: () => r.status });
|
||||
},
|
||||
},
|
||||
{ title: "耗时", key: "duration_ms", width: 90, render: (r) => (r.duration_ms != null ? `${r.duration_ms}ms` : "-") },
|
||||
{
|
||||
title: "参数摘要",
|
||||
key: "args_digest",
|
||||
width: 170,
|
||||
ellipsis: { tooltip: true },
|
||||
render: (r) => r.args_digest ?? "-",
|
||||
},
|
||||
{
|
||||
title: "说明",
|
||||
key: "reason",
|
||||
ellipsis: { tooltip: true },
|
||||
render: (r) => r.reason || r.error || "-",
|
||||
},
|
||||
];
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const args: Record<string, unknown> = { limit: limit.value };
|
||||
if (typeFilter.value) args.type = typeFilter.value;
|
||||
const r = await conn.ok<AuditListResult>("system.audit.list", args);
|
||||
entries.value = r.entries ?? [];
|
||||
total.value = r.total ?? entries.value.length;
|
||||
} catch (e) {
|
||||
msg.error(`加载审计失败:${e}`);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runVerify() {
|
||||
try {
|
||||
verify.value = await conn.ok<AuditVerifyResult>("system.audit.verify", {});
|
||||
} catch (e) {
|
||||
msg.error(`校验失败:${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportJsonl() {
|
||||
const text = entries.value.map((e) => JSON.stringify(e)).join("\n");
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
msg.success(`已复制 ${entries.value.length} 条到剪贴板`);
|
||||
} catch {
|
||||
msg.warning("剪贴板不可用");
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (conn.connected) load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!conn.connected">
|
||||
<n-empty description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
|
||||
</div>
|
||||
|
||||
<div v-else class="wrap">
|
||||
<n-card size="small">
|
||||
<template #header>
|
||||
<n-space align="center">
|
||||
<span>审计日志</span>
|
||||
<n-text depth="3" style="font-size: 12px">共 {{ total }} 条</n-text>
|
||||
</n-space>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<n-space align="center">
|
||||
<n-select
|
||||
v-model:value="typeFilter"
|
||||
:options="typeOptions"
|
||||
size="small"
|
||||
style="width: 140px"
|
||||
@update:value="load"
|
||||
/>
|
||||
<n-input-number v-model:value="limit" :min="10" :max="2000" :step="50" size="small" style="width: 110px" />
|
||||
<n-button size="small" :loading="loading" @click="load">刷新</n-button>
|
||||
<n-button size="small" @click="runVerify">完整性校验</n-button>
|
||||
<n-button size="small" @click="exportJsonl">导出</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<n-alert
|
||||
v-if="verify"
|
||||
:type="verify.ok ? 'success' : 'error'"
|
||||
:show-icon="true"
|
||||
style="margin-bottom: 12px"
|
||||
>
|
||||
完整性校验:{{ verify.ok ? "通过" : "发现问题" }} · 已校验 {{ verify.checked }} 条
|
||||
<template v-if="verify.legacy"> · 跳过 {{ verify.legacy }} 条历史条目</template>
|
||||
<template v-if="verify.broken_at">
|
||||
· 首个断点:{{ verify.broken_at.file }} 第 {{ verify.broken_at.line }} 行
|
||||
<template v-if="verify.broken_at.reason">({{ verify.broken_at.reason }})</template>
|
||||
</template>
|
||||
</n-alert>
|
||||
|
||||
<n-empty v-if="!entries.length" description="暂无审计记录" size="small" />
|
||||
<n-data-table v-else :columns="cols" :data="entries" :bordered="false" size="small" :max-height="560" />
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap {
|
||||
max-width: 1200px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,240 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import {
|
||||
NCard,
|
||||
NInput,
|
||||
NButton,
|
||||
NSpace,
|
||||
NText,
|
||||
NEmpty,
|
||||
NDataTable,
|
||||
NModal,
|
||||
NTag,
|
||||
useMessage,
|
||||
useDialog,
|
||||
type DataTableColumns,
|
||||
} from "naive-ui";
|
||||
import { useConnStore } from "../stores/conn";
|
||||
import type { FsEntry, FsListResult, FsReadResult, SystemInfo } from "../types";
|
||||
import { formatBytes, formatTime } from "../utils/format";
|
||||
|
||||
const conn = useConnStore();
|
||||
const msg = useMessage();
|
||||
const dialog = useDialog();
|
||||
|
||||
const cwd = ref("");
|
||||
const entries = ref<FsEntry[]>([]);
|
||||
const loading = ref(false);
|
||||
const truncated = ref(false);
|
||||
|
||||
const previewOpen = ref(false);
|
||||
const previewName = ref("");
|
||||
const previewText = ref("");
|
||||
const previewMeta = ref("");
|
||||
|
||||
const writeOpen = ref(false);
|
||||
const wName = ref("");
|
||||
const wContent = ref("");
|
||||
const wAppend = ref(false);
|
||||
|
||||
const cols: DataTableColumns<FsEntry> = [
|
||||
{
|
||||
title: "名称",
|
||||
key: "name",
|
||||
render: (r) => r.name + (r.type === "dir" ? "/" : ""),
|
||||
},
|
||||
{ title: "类型", key: "type", width: 80 },
|
||||
{ title: "大小", key: "size", width: 110, render: (r) => (r.type === "file" ? formatBytes(r.size) : "-") },
|
||||
{ title: "修改时间", key: "mtime", width: 180, render: (r) => formatTime(r.mtime) },
|
||||
];
|
||||
|
||||
async function list(path: string) {
|
||||
if (!path) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const r = await conn.ok<FsListResult>("fs.list", { path });
|
||||
entries.value = (r.entries ?? []).slice().sort((a, b) => {
|
||||
if (a.type === "dir" && b.type !== "dir") return -1;
|
||||
if (a.type !== "dir" && b.type === "dir") return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
truncated.value = !!r.truncated;
|
||||
cwd.value = path;
|
||||
} catch (e) {
|
||||
msg.error(`列目录失败:${e}`);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function parentOf(p: string): string {
|
||||
const s = p.replace(/[\\/]+$/, "");
|
||||
const idx = Math.max(s.lastIndexOf("/"), s.lastIndexOf("\\"));
|
||||
if (idx < 0) return s;
|
||||
if (idx === 0) return s.slice(0, 1); // POSIX 根
|
||||
const r = s.slice(0, idx);
|
||||
return /^[A-Za-z]:$/.test(r) ? r + "\\" : r; // Windows 盘符
|
||||
}
|
||||
|
||||
function onRowClick(row: FsEntry) {
|
||||
if (row.type === "dir") list(row.path);
|
||||
else preview(row);
|
||||
}
|
||||
|
||||
async function preview(row: FsEntry) {
|
||||
try {
|
||||
const r = await conn.ok<FsReadResult>("fs.read", {
|
||||
path: row.path,
|
||||
encoding: "utf8",
|
||||
max_bytes: 262144,
|
||||
});
|
||||
previewName.value = row.name;
|
||||
previewText.value = (r.data ?? "").replace(/\u0000/g, "");
|
||||
previewMeta.value = `${formatBytes(r.total_bytes)}${r.eof ? "" : " · 已截断预览"}${r.sha256 ? ` · sha256 ${r.sha256.slice(0, 12)}…` : ""}`;
|
||||
previewOpen.value = true;
|
||||
} catch (e) {
|
||||
msg.error(`读取失败:${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
function openWrite() {
|
||||
wName.value = "";
|
||||
wContent.value = "";
|
||||
wAppend.value = false;
|
||||
writeOpen.value = true;
|
||||
}
|
||||
|
||||
function doWrite() {
|
||||
if (!wName.value) return;
|
||||
const sep = cwd.value.includes("\\") ? "\\" : "/";
|
||||
const path = cwd.value.replace(/[\\/]+$/, "") + sep + wName.value;
|
||||
dialog.warning({
|
||||
title: "确认写入被控端",
|
||||
content: `目标:${path}\n模式:${wAppend.value ? "追加" : "覆盖"}\n字节:${wContent.value.length}`,
|
||||
positiveText: "写入",
|
||||
negativeText: "取消",
|
||||
onPositiveClick: () => writeTo(path),
|
||||
});
|
||||
}
|
||||
|
||||
async function writeTo(path: string) {
|
||||
try {
|
||||
await conn.ok("fs.write", { path, data: wContent.value, append: wAppend.value, create_dirs: true });
|
||||
msg.success(`已写入 ${path}`);
|
||||
writeOpen.value = false;
|
||||
list(cwd.value);
|
||||
} catch (e) {
|
||||
msg.error(`写入失败:${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureStart() {
|
||||
try {
|
||||
const info = await conn.ok<SystemInfo>("system.info", { fields: ["agent_home", "os"] });
|
||||
let start = info.agent_home ?? "";
|
||||
if (!start) start = info.os === "Windows" ? "C:\\" : "/";
|
||||
list(start);
|
||||
} catch {
|
||||
list(conn.info ? "/" : "");
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (conn.connected) ensureStart();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!conn.connected">
|
||||
<n-empty description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
|
||||
</div>
|
||||
|
||||
<div v-else class="wrap">
|
||||
<n-card size="small">
|
||||
<n-space align="center" :wrap="false">
|
||||
<n-input
|
||||
v-model:value="cwd"
|
||||
size="small"
|
||||
placeholder="输入路径,回车列目录(如 C:\ 或 /Users)"
|
||||
style="flex: 1"
|
||||
@keyup.enter="list(cwd)"
|
||||
/>
|
||||
<n-button size="small" @click="list(parentOf(cwd))">上级</n-button>
|
||||
<n-button size="small" :loading="loading" @click="list(cwd)">刷新</n-button>
|
||||
<n-button size="small" type="primary" @click="openWrite">新建文件</n-button>
|
||||
</n-space>
|
||||
<div class="path">
|
||||
<n-text depth="3" style="font-size: 12px">
|
||||
当前:{{ cwd || "-" }} · {{ entries.length }} 项
|
||||
<n-tag v-if="truncated" size="tiny" type="warning" style="margin-left: 6px">结果被截断</n-tag>
|
||||
</n-text>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<n-card size="small" style="margin-top: 16px">
|
||||
<n-empty v-if="!entries.length" description="目录为空或未加载" size="small" />
|
||||
<n-data-table
|
||||
v-else
|
||||
:columns="cols"
|
||||
:data="entries"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
:max-height="520"
|
||||
:row-props="(row: FsEntry) => ({ style: 'cursor:pointer', onClick: () => onRowClick(row) })"
|
||||
/>
|
||||
</n-card>
|
||||
</div>
|
||||
|
||||
<!-- 预览 -->
|
||||
<n-modal v-model:show="previewOpen" preset="card" :title="previewName" style="width: 820px; max-width: 90vw">
|
||||
<n-text depth="3" style="font-size: 12px">{{ previewMeta }}</n-text>
|
||||
<pre class="preview">{{ previewText || "(空文件)" }}</pre>
|
||||
</n-modal>
|
||||
|
||||
<!-- 新建/写入 -->
|
||||
<n-modal v-model:show="writeOpen" preset="card" title="写入文件" style="width: 640px; max-width: 90vw">
|
||||
<n-space vertical>
|
||||
<n-input v-model:value="wName" placeholder="文件名(写入当前目录)" size="small" />
|
||||
<n-input
|
||||
v-model:value="wContent"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 6, maxRows: 16 }"
|
||||
placeholder="文件内容(utf8)"
|
||||
/>
|
||||
<n-space align="center">
|
||||
<n-tag
|
||||
size="small"
|
||||
:type="wAppend ? 'warning' : 'default'"
|
||||
checkable
|
||||
:checked="wAppend"
|
||||
@update:checked="(v: boolean) => (wAppend = v)"
|
||||
>
|
||||
追加模式
|
||||
</n-tag>
|
||||
<n-button size="small" type="primary" @click="doWrite">写入</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap {
|
||||
max-width: 1100px;
|
||||
}
|
||||
.path {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.preview {
|
||||
margin: 12px 0 0;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,235 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import {
|
||||
NCard,
|
||||
NInput,
|
||||
NButton,
|
||||
NSpace,
|
||||
NTag,
|
||||
NEmpty,
|
||||
NText,
|
||||
NInputGroup,
|
||||
NCollapse,
|
||||
NCollapseItem,
|
||||
NPopconfirm,
|
||||
useMessage,
|
||||
} from "naive-ui";
|
||||
import { useConnStore } from "../stores/conn";
|
||||
import { useNodeStore } from "../stores/node";
|
||||
|
||||
const conn = useConnStore();
|
||||
const nodeStore = useNodeStore();
|
||||
const msg = useMessage();
|
||||
|
||||
const newName = ref("");
|
||||
const newUrl = ref("ws://127.0.0.1:8765");
|
||||
const newKey = ref("");
|
||||
|
||||
async function doConnect() {
|
||||
try {
|
||||
await conn.connect();
|
||||
msg.success("连接成功");
|
||||
} catch (e) {
|
||||
msg.error(`连接失败:${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function connectTo(id: string) {
|
||||
const n = nodeStore.nodes.find((x) => x.id === id);
|
||||
if (!n) return;
|
||||
nodeStore.setCurrent(id);
|
||||
try {
|
||||
await conn.connect(n.url, n.key);
|
||||
msg.success(`已连接 ${n.name}`);
|
||||
} catch (e) {
|
||||
msg.error(`连接失败:${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
function addNode() {
|
||||
if (!newName.value || !newUrl.value) {
|
||||
msg.warning("请填写名称与地址");
|
||||
return;
|
||||
}
|
||||
nodeStore.add({ name: newName.value, url: newUrl.value, key: newKey.value });
|
||||
newName.value = "";
|
||||
newKey.value = "";
|
||||
msg.success("已保存设备");
|
||||
}
|
||||
|
||||
/** 能力按前缀分组,便于核对。 */
|
||||
const capGroups = computed(() => {
|
||||
const groups: Record<string, string[]> = {};
|
||||
for (const c of conn.capabilities) {
|
||||
const prefix = c.name.split(".")[0] ?? "other";
|
||||
(groups[prefix] ??= []).push(c.name);
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid">
|
||||
<n-card title="连接被控端" size="small">
|
||||
<n-space vertical>
|
||||
<n-input-group>
|
||||
<n-input v-model:value="conn.url" placeholder="ws://127.0.0.1:8765" style="flex: 3" />
|
||||
<n-input
|
||||
v-model:value="conn.key"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
placeholder="PSK 预共享密钥"
|
||||
style="flex: 2"
|
||||
/>
|
||||
<n-button type="primary" :loading="conn.connecting" @click="doConnect">连接</n-button>
|
||||
</n-input-group>
|
||||
<div v-if="conn.info" class="meta">
|
||||
<n-tag type="success" size="small" round>已连接</n-tag>
|
||||
<n-text depth="3">agent {{ conn.info.agent_version ?? "?" }}</n-text>
|
||||
<n-text depth="3">
|
||||
授权 {{ conn.info.authorized ? `${conn.info.authorized.length} 项` : "全部" }}
|
||||
</n-text>
|
||||
<n-text depth="3" class="mono">
|
||||
指纹 {{ (conn.info.peer_cert_fp ?? "-").slice(0, 16) }}…
|
||||
</n-text>
|
||||
</div>
|
||||
<n-empty v-else description="尚未连接" size="small" />
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<n-card title="已保存设备" size="small">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 12px">{{ nodeStore.nodes.length }} 台</n-text>
|
||||
</template>
|
||||
<div v-if="nodeStore.nodes.length" class="nodes">
|
||||
<div
|
||||
v-for="n in nodeStore.nodes"
|
||||
:key="n.id"
|
||||
class="node"
|
||||
:class="{ active: nodeStore.currentId === n.id }"
|
||||
>
|
||||
<div class="node-main">
|
||||
<div class="node-name">{{ n.name }}</div>
|
||||
<n-text depth="3" class="mono" style="font-size: 12px">{{ n.url }}</n-text>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-button size="tiny" type="primary" @click="connectTo(n.id)">连接</n-button>
|
||||
<n-popconfirm @positive-click="nodeStore.remove(n.id)">
|
||||
<template #trigger>
|
||||
<n-button size="tiny" quaternary>删除</n-button>
|
||||
</template>
|
||||
确认删除该设备?
|
||||
</n-popconfirm>
|
||||
</n-space>
|
||||
</div>
|
||||
</div>
|
||||
<n-empty v-else description="暂无保存的设备" size="small" />
|
||||
|
||||
<div class="add-row">
|
||||
<n-input v-model:value="newName" placeholder="名称" size="small" style="flex: 1" />
|
||||
<n-input v-model:value="newUrl" placeholder="地址" size="small" style="flex: 2" />
|
||||
<n-input v-model:value="newKey" placeholder="PSK" size="small" style="flex: 1" />
|
||||
<n-button size="small" @click="addNode">保存</n-button>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<n-card title="能力清单" size="small">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 12px">{{ conn.capabilities.length }} 项</n-text>
|
||||
</template>
|
||||
<n-collapse v-if="conn.capabilities.length" :default-expanded-names="Object.keys(capGroups)">
|
||||
<n-collapse-item
|
||||
v-for="(caps, prefix) in capGroups"
|
||||
:key="prefix"
|
||||
:name="prefix"
|
||||
:title="`${prefix}.* (${caps.length})`"
|
||||
>
|
||||
<div class="cap-list">
|
||||
<code v-for="c in caps" :key="c">{{ c }}</code>
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
<n-empty v-else description="连接后展示" size="small" />
|
||||
</n-card>
|
||||
|
||||
<n-card title="日志" size="small">
|
||||
<template #header-extra>
|
||||
<n-button size="tiny" quaternary @click="conn.clearLogs()">清空</n-button>
|
||||
</template>
|
||||
<div class="logs">
|
||||
<div v-for="(l, i) in conn.logs.slice().reverse()" :key="i">{{ l }}</div>
|
||||
<div v-if="conn.logs.length === 0" class="logs-empty">暂无日志</div>
|
||||
</div>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
.grid > :nth-child(3),
|
||||
.grid > :nth-child(4) {
|
||||
grid-column: span 2;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 13px;
|
||||
}
|
||||
.mono {
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
}
|
||||
.nodes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.09);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.node.active {
|
||||
border-color: #63e2b7;
|
||||
}
|
||||
.node-name {
|
||||
font-size: 14px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.add-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.cap-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.cap-list code {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.logs {
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
opacity: 0.85;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
}
|
||||
.logs-empty {
|
||||
opacity: 0.4;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,200 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from "vue";
|
||||
import {
|
||||
NCard,
|
||||
NGrid,
|
||||
NGridItem,
|
||||
NProgress,
|
||||
NStatistic,
|
||||
NButton,
|
||||
NSpace,
|
||||
NEmpty,
|
||||
NText,
|
||||
NSwitch,
|
||||
NTag,
|
||||
useMessage,
|
||||
} from "naive-ui";
|
||||
import { useConnStore } from "../stores/conn";
|
||||
import type { DiskInfo, SystemInfo, SystemStatus } from "../types";
|
||||
import { formatBytes, formatDuration } from "../utils/format";
|
||||
|
||||
const conn = useConnStore();
|
||||
const msg = useMessage();
|
||||
|
||||
const info = ref<SystemInfo | null>(null);
|
||||
const status = ref<SystemStatus | null>(null);
|
||||
const auto = ref(true);
|
||||
let timer: number | undefined;
|
||||
|
||||
/** 契约只给 total/free,已用量需自算。 */
|
||||
function usedOf(d: DiskInfo): number | undefined {
|
||||
if (d.total == null || d.free == null) return undefined;
|
||||
return Math.max(0, d.total - d.free);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (!conn.connected) return;
|
||||
try {
|
||||
const [i, s] = await Promise.all([
|
||||
conn.ok<SystemInfo>("system.info"),
|
||||
conn.ok<SystemStatus>("system.status"),
|
||||
]);
|
||||
info.value = i;
|
||||
status.value = s;
|
||||
} catch (e) {
|
||||
msg.error(`刷新失败:${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAuto(v: boolean) {
|
||||
auto.value = v;
|
||||
if (v) start(); else stop();
|
||||
}
|
||||
|
||||
function start() {
|
||||
stop();
|
||||
timer = window.setInterval(refresh, 3000);
|
||||
}
|
||||
function stop() {
|
||||
if (timer) window.clearInterval(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (conn.connected) refresh();
|
||||
if (auto.value) start();
|
||||
});
|
||||
onUnmounted(stop);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!conn.connected">
|
||||
<n-empty description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
|
||||
</div>
|
||||
|
||||
<div v-else class="wrap">
|
||||
<n-space justify="space-between" align="center" style="margin-bottom: 14px">
|
||||
<n-space align="center">
|
||||
<n-text depth="3">自动刷新(3s)</n-text>
|
||||
<n-switch :value="auto" size="small" @update:value="toggleAuto" />
|
||||
</n-space>
|
||||
<n-button size="small" @click="refresh">手动刷新</n-button>
|
||||
</n-space>
|
||||
|
||||
<n-grid :cols="4" :x-gap="16" :y-gap="16" item-responsive responsive="screen">
|
||||
<n-grid-item span="4 s:2">
|
||||
<n-card size="small" title="主机">
|
||||
<n-statistic label="主机名" :value="info?.hostname ?? '-'" />
|
||||
<div class="kv"><span>系统</span><b>{{ info?.os }} {{ info?.os_version }}</b></div>
|
||||
<div class="kv"><span>架构</span><b>{{ info?.arch }}</b></div>
|
||||
<div class="kv"><span>CPU</span><b>{{ info?.cpu_model }}</b></div>
|
||||
<div class="kv"><span>核心</span><b>{{ info?.cpu_cores }}</b></div>
|
||||
<div class="kv"><span>管理员</span><b>{{ info?.is_admin ? "是" : "否" }}</b></div>
|
||||
<div class="kv"><span>开机</span><b>{{ formatDuration(info?.uptime_sec) }}</b></div>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
|
||||
<n-grid-item span="4 s:2">
|
||||
<n-card size="small" title="资源">
|
||||
<div class="gauge">
|
||||
<div class="gauge-label">CPU 使用率</div>
|
||||
<n-progress
|
||||
type="line"
|
||||
:percentage="Math.round(status?.cpu_pct ?? 0)"
|
||||
:height="10"
|
||||
:color="(status?.cpu_pct ?? 0) > 80 ? '#e88080' : '#63e2b7'"
|
||||
/>
|
||||
</div>
|
||||
<div class="gauge">
|
||||
<div class="gauge-label">
|
||||
内存 {{ formatBytes(status?.memory_used) }} / {{ formatBytes(status?.memory_total) }}
|
||||
</div>
|
||||
<n-progress
|
||||
type="line"
|
||||
:percentage="Math.round(status?.memory_pct ?? 0)"
|
||||
:height="10"
|
||||
:color="(status?.memory_pct ?? 0) > 85 ? '#e88080' : '#63e2b7'"
|
||||
/>
|
||||
</div>
|
||||
<n-statistic
|
||||
label="内存总量"
|
||||
:value="formatBytes(info?.memory_total ?? status?.memory_total)"
|
||||
/>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
|
||||
<n-grid-item span="4">
|
||||
<n-card size="small" title="磁盘">
|
||||
<div v-if="status?.disks?.length" class="disks">
|
||||
<div v-for="(d, i) in status.disks" :key="i" class="disk">
|
||||
<div class="gauge-label">
|
||||
{{ d.drive ?? "-" }}
|
||||
<span class="dim">
|
||||
{{ formatBytes(usedOf(d)) }} / {{ formatBytes(d.total) }} · 剩余
|
||||
{{ formatBytes(d.free) }}
|
||||
</span>
|
||||
</div>
|
||||
<n-progress
|
||||
type="line"
|
||||
:percentage="Math.round(d.used_pct ?? 0)"
|
||||
:height="8"
|
||||
:color="(d.used_pct ?? 0) > 90 ? '#e88080' : '#63e2b7'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<n-empty v-else description="无磁盘数据" size="small" />
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
|
||||
<n-grid-item span="4">
|
||||
<n-card size="small" title="网络适配器">
|
||||
<div v-if="status?.net?.length" class="net">
|
||||
<div v-for="(a, i) in status.net" :key="i" class="kv">
|
||||
<span>{{ a.adapter ?? "-" }}</span>
|
||||
<b>
|
||||
{{ a.ip || "-" }}
|
||||
<n-tag :type="a.up ? 'success' : 'default'" size="tiny" style="margin-left: 6px">
|
||||
{{ a.up ? "已连接" : "未连接" }}
|
||||
</n-tag>
|
||||
</b>
|
||||
</div>
|
||||
</div>
|
||||
<n-empty v-else description="无网络数据" size="small" />
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap {
|
||||
max-width: 1100px;
|
||||
}
|
||||
.kv {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
padding: 3px 0;
|
||||
}
|
||||
.kv span {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.gauge {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.gauge-label {
|
||||
font-size: 13px;
|
||||
margin-bottom: 6px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.dim {
|
||||
opacity: 0.5;
|
||||
font-size: 12px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.disks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,189 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, ref } from "vue";
|
||||
import {
|
||||
NCard,
|
||||
NTabs,
|
||||
NTabPane,
|
||||
NDataTable,
|
||||
NButton,
|
||||
NSpace,
|
||||
NInput,
|
||||
NEmpty,
|
||||
NTag,
|
||||
NAlert,
|
||||
type DataTableColumns,
|
||||
} from "naive-ui";
|
||||
import { useConnStore } from "../stores/conn";
|
||||
import type { ProcessEntry, ServiceEntry } from "../types";
|
||||
import { formatBytes, formatDuration } from "../utils/format";
|
||||
|
||||
const conn = useConnStore();
|
||||
|
||||
const procs = ref<ProcessEntry[]>([]);
|
||||
const services = ref<ServiceEntry[]>([]);
|
||||
const procFilter = ref("");
|
||||
const svcFilter = ref("");
|
||||
const loading = ref(false);
|
||||
const listError = ref("");
|
||||
|
||||
/** `system.service.list` 仅 Windows 被控端可用——事前门控。 */
|
||||
const servicesAvailable = computed(() => conn.platformReady);
|
||||
|
||||
const procCols: DataTableColumns<ProcessEntry> = [
|
||||
{ title: "PID", key: "pid", width: 90, sorter: (a, b) => (a.pid ?? 0) - (b.pid ?? 0) },
|
||||
{ title: "名称", key: "name", ellipsis: { tooltip: true } },
|
||||
{
|
||||
title: "CPU",
|
||||
key: "cpu_pct",
|
||||
width: 100,
|
||||
sorter: (a, b) => (a.cpu_pct ?? 0) - (b.cpu_pct ?? 0),
|
||||
render: (r) => (r.cpu_pct != null ? `${r.cpu_pct.toFixed(1)}%` : "-"),
|
||||
},
|
||||
{
|
||||
title: "内存",
|
||||
key: "memory_bytes",
|
||||
width: 110,
|
||||
sorter: (a, b) => (a.memory_bytes ?? 0) - (b.memory_bytes ?? 0),
|
||||
render: (r) => (r.memory_bytes ? formatBytes(r.memory_bytes) : "-"),
|
||||
},
|
||||
{
|
||||
title: "已运行",
|
||||
key: "started_at",
|
||||
width: 120,
|
||||
render: (r) =>
|
||||
r.started_at ? formatDuration(Math.floor((Date.now() - r.started_at) / 1000)) : "-",
|
||||
},
|
||||
];
|
||||
|
||||
const svcCols: DataTableColumns<ServiceEntry> = [
|
||||
{ title: "名称", key: "name", ellipsis: { tooltip: true } },
|
||||
{ title: "显示名", key: "display_name", ellipsis: { tooltip: true } },
|
||||
{
|
||||
title: "状态",
|
||||
key: "state",
|
||||
width: 130,
|
||||
render: (r) =>
|
||||
h(
|
||||
NTag,
|
||||
{ size: "small", type: /run/i.test(r.state ?? "") ? "success" : "default" },
|
||||
{ default: () => r.state ?? "-" },
|
||||
),
|
||||
},
|
||||
{ title: "启动类型", key: "start_type", width: 130 },
|
||||
];
|
||||
|
||||
function pick<T>(raw: unknown, key: string): T[] {
|
||||
if (Array.isArray(raw)) return raw as T[];
|
||||
if (raw && typeof raw === "object" && Array.isArray((raw as Record<string, unknown>)[key])) {
|
||||
return (raw as Record<string, T[]>)[key];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async function loadProcs() {
|
||||
loading.value = true;
|
||||
listError.value = "";
|
||||
try {
|
||||
const raw = await conn.ok("system.process.list", { limit: 200, sort_by: "cpu" });
|
||||
procs.value = pick<ProcessEntry>(raw, "processes");
|
||||
} catch (e) {
|
||||
listError.value = String((e as Error).message ?? e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadServices() {
|
||||
if (!servicesAvailable.value) return;
|
||||
loading.value = true;
|
||||
listError.value = "";
|
||||
try {
|
||||
const raw = await conn.ok("system.service.list", {
|
||||
limit: 200,
|
||||
filter: svcFilter.value ? { name_pattern: svcFilter.value } : undefined,
|
||||
});
|
||||
services.value = pick<ServiceEntry>(raw, "services");
|
||||
} catch (e) {
|
||||
listError.value = String((e as Error).message ?? e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const filteredProcs = ref<ProcessEntry[]>([]);
|
||||
function applyProcFilter() {
|
||||
const f = procFilter.value.trim().toLowerCase();
|
||||
filteredProcs.value = f
|
||||
? procs.value.filter((p) => p.name?.toLowerCase().includes(f))
|
||||
: procs.value;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (conn.connected) {
|
||||
loadProcs().then(applyProcFilter);
|
||||
loadServices();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!conn.connected">
|
||||
<n-empty description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
|
||||
</div>
|
||||
|
||||
<n-card v-else size="small">
|
||||
<n-alert v-if="listError" type="error" :show-icon="true" style="margin-bottom: 12px">
|
||||
{{ listError }}
|
||||
</n-alert>
|
||||
|
||||
<n-tabs type="line" animated>
|
||||
<n-tab-pane name="proc" tab="进程">
|
||||
<n-space align="center" style="margin-bottom: 12px">
|
||||
<n-input
|
||||
v-model:value="procFilter"
|
||||
placeholder="按名称筛选"
|
||||
size="small"
|
||||
style="width: 220px"
|
||||
@input="applyProcFilter"
|
||||
/>
|
||||
<n-button size="small" :loading="loading" @click="loadProcs().then(applyProcFilter)">
|
||||
刷新
|
||||
</n-button>
|
||||
<n-text depth="3" style="font-size: 12px">{{ filteredProcs.length }} / {{ procs.length }}</n-text>
|
||||
</n-space>
|
||||
<n-empty v-if="!filteredProcs.length" description="暂无进程数据" size="small" />
|
||||
<n-data-table
|
||||
v-else
|
||||
:columns="procCols"
|
||||
:data="filteredProcs"
|
||||
:bordered="false"
|
||||
:max-height="520"
|
||||
size="small"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="svc" tab="服务">
|
||||
<n-alert v-if="!servicesAvailable" type="warning" :show-icon="true">
|
||||
`system.service.list` 仅在被控端为 <b>Windows</b> 时可用;当前被控端为
|
||||
<b>{{ conn.nodeOs ?? "未知" }}</b>。
|
||||
</n-alert>
|
||||
<template v-else>
|
||||
<n-space align="center" style="margin-bottom: 12px">
|
||||
<n-input v-model:value="svcFilter" placeholder="筛选服务名" size="small" style="width: 220px" />
|
||||
<n-button size="small" :loading="loading" @click="loadServices">刷新</n-button>
|
||||
<n-text depth="3" style="font-size: 12px">{{ services.length }} 项</n-text>
|
||||
</n-space>
|
||||
<n-empty v-if="!services.length" description="暂无服务数据" size="small" />
|
||||
<n-data-table
|
||||
v-else
|
||||
:columns="svcCols"
|
||||
:data="services"
|
||||
:bordered="false"
|
||||
:max-height="520"
|
||||
size="small"
|
||||
/>
|
||||
</template>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-card>
|
||||
</template>
|
||||
@@ -0,0 +1,183 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import {
|
||||
NCard,
|
||||
NInput,
|
||||
NButton,
|
||||
NSpace,
|
||||
NText,
|
||||
NEmpty,
|
||||
NDataTable,
|
||||
NAlert,
|
||||
useMessage,
|
||||
useDialog,
|
||||
type DataTableColumns,
|
||||
} from "naive-ui";
|
||||
import { useConnStore } from "../stores/conn";
|
||||
import type { AppEntry } from "../types";
|
||||
|
||||
const conn = useConnStore();
|
||||
const msg = useMessage();
|
||||
const dialog = useDialog();
|
||||
|
||||
const apps = ref<AppEntry[]>([]);
|
||||
const filter = ref("");
|
||||
const pkg = ref("");
|
||||
const loading = ref(false);
|
||||
const installing = ref(false);
|
||||
const installResult = ref("");
|
||||
const error = ref("");
|
||||
|
||||
/** `app.list` / `app.install` 仅 Windows 被控端可用——事前门控。 */
|
||||
const available = computed(() => conn.platformReady);
|
||||
|
||||
const cols: DataTableColumns<AppEntry> = [
|
||||
{ title: "名称", key: "name", ellipsis: { tooltip: true } },
|
||||
{ title: "版本", key: "version", width: 150 },
|
||||
{ title: "发布者", key: "publisher", ellipsis: { tooltip: true } },
|
||||
{ title: "来源", key: "source", width: 110 },
|
||||
];
|
||||
|
||||
function pickApps(raw: unknown): AppEntry[] {
|
||||
if (Array.isArray(raw)) return raw as AppEntry[];
|
||||
if (raw && typeof raw === "object") {
|
||||
const o = raw as Record<string, unknown>;
|
||||
if (Array.isArray(o.apps)) return o.apps as AppEntry[];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const filtered = computed(() => {
|
||||
const f = filter.value.trim().toLowerCase();
|
||||
if (!f) return apps.value;
|
||||
return apps.value.filter(
|
||||
(a) =>
|
||||
a.name?.toLowerCase().includes(f) ||
|
||||
a.publisher?.toLowerCase().includes(f) ||
|
||||
a.source?.toLowerCase().includes(f),
|
||||
);
|
||||
});
|
||||
|
||||
async function load() {
|
||||
if (!available.value) return;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
apps.value = pickApps(await conn.ok("app.list", {}, 120000));
|
||||
} catch (e) {
|
||||
error.value = String((e as Error).message ?? e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function doInstall() {
|
||||
const p = pkg.value.trim();
|
||||
if (!p) return;
|
||||
dialog.warning({
|
||||
title: "确认安装",
|
||||
content: `将在被控端静默安装:${p}`,
|
||||
positiveText: "安装",
|
||||
negativeText: "取消",
|
||||
onPositiveClick: () => install(p),
|
||||
});
|
||||
}
|
||||
|
||||
async function install(p: string) {
|
||||
installing.value = true;
|
||||
installResult.value = "";
|
||||
try {
|
||||
// winget 安装可能耗时数分钟,控制端 RPC 超时放大到 10 分钟。
|
||||
const r = await conn.ok<Record<string, unknown>>(
|
||||
"app.install",
|
||||
{ package: p, silent: true },
|
||||
600_000,
|
||||
);
|
||||
installResult.value = JSON.stringify(r, null, 2);
|
||||
msg.success("安装完成");
|
||||
load();
|
||||
} catch (e) {
|
||||
installResult.value = String(e);
|
||||
msg.error(`安装失败:${e}`);
|
||||
} finally {
|
||||
installing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (conn.connected) load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!conn.connected">
|
||||
<n-empty description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
|
||||
</div>
|
||||
|
||||
<div v-else class="wrap">
|
||||
<n-alert v-if="!available" type="warning" :show-icon="true" style="margin-bottom: 14px">
|
||||
软件能力(<code>app.list</code> / <code>app.install</code>)依赖被控端 <code>winget</code>,
|
||||
<b>仅在被控端为 Windows 时可用</b>;当前被控端为 <b>{{ conn.nodeOs ?? "未知" }}</b>,
|
||||
本页已禁用。
|
||||
</n-alert>
|
||||
<n-alert v-else type="info" :show-icon="true" style="margin-bottom: 14px">
|
||||
软件安装依赖被控端 <code>winget</code>。安装可能耗时数分钟,已把调用超时放大到 10 分钟。
|
||||
</n-alert>
|
||||
|
||||
<n-alert v-if="error" type="error" :show-icon="true" style="margin-bottom: 14px">
|
||||
{{ error }}
|
||||
</n-alert>
|
||||
|
||||
<n-card size="small" title="静默安装(app.install)">
|
||||
<n-space align="center">
|
||||
<n-input
|
||||
v-model:value="pkg"
|
||||
:disabled="!available"
|
||||
placeholder="winget 包名或 ID,例如 Git.Git / 7zip.7zip"
|
||||
style="width: 420px"
|
||||
/>
|
||||
<n-button type="primary" :loading="installing" :disabled="!available" @click="doInstall">
|
||||
安装
|
||||
</n-button>
|
||||
</n-space>
|
||||
<pre v-if="installResult" class="result">{{ installResult }}</pre>
|
||||
</n-card>
|
||||
|
||||
<n-card size="small" title="已装软件(app.list)" style="margin-top: 16px">
|
||||
<template #header-extra>
|
||||
<n-space align="center">
|
||||
<n-input
|
||||
v-model:value="filter"
|
||||
:disabled="!available"
|
||||
placeholder="筛选"
|
||||
size="small"
|
||||
style="width: 180px"
|
||||
/>
|
||||
<n-button size="small" :loading="loading" :disabled="!available" @click="load">刷新</n-button>
|
||||
<n-text depth="3" style="font-size: 12px">{{ filtered.length }}</n-text>
|
||||
</n-space>
|
||||
</template>
|
||||
<n-empty
|
||||
v-if="!apps.length && !loading"
|
||||
:description="available ? '暂无数据(点刷新)' : '当前被控端非 Windows,不支持'"
|
||||
size="small"
|
||||
/>
|
||||
<n-data-table v-else :columns="cols" :data="filtered" :bordered="false" size="small" :max-height="480" />
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap {
|
||||
max-width: 1100px;
|
||||
}
|
||||
.result {
|
||||
margin: 12px 0 0;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
font-size: 12px;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,166 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import {
|
||||
NCard,
|
||||
NInput,
|
||||
NButton,
|
||||
NSpace,
|
||||
NInputNumber,
|
||||
NText,
|
||||
NTag,
|
||||
NEmpty,
|
||||
NList,
|
||||
NListItem,
|
||||
useMessage,
|
||||
useDialog,
|
||||
} from "naive-ui";
|
||||
import { useConnStore } from "../stores/conn";
|
||||
|
||||
interface ExecResult {
|
||||
exit_code: number;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
duration_ms?: number;
|
||||
truncated?: boolean;
|
||||
killed?: boolean;
|
||||
}
|
||||
|
||||
const conn = useConnStore();
|
||||
const msg = useMessage();
|
||||
const dialog = useDialog();
|
||||
|
||||
const cmd = ref("");
|
||||
const timeoutMs = ref(30000);
|
||||
const running = ref(false);
|
||||
const result = ref<ExecResult | null>(null);
|
||||
const history = ref<string[]>([]);
|
||||
|
||||
const output = computed(() => {
|
||||
if (!result.value) return "";
|
||||
const r = result.value;
|
||||
let out = "";
|
||||
if (r.stdout) out += r.stdout;
|
||||
if (r.stderr) out += (out ? "\n" : "") + r.stderr;
|
||||
return out || "(无输出)";
|
||||
});
|
||||
|
||||
async function exec() {
|
||||
const command = cmd.value.trim();
|
||||
if (!command) return;
|
||||
if (!conn.connected) {
|
||||
msg.warning("请先连接被控端");
|
||||
return;
|
||||
}
|
||||
dialog.warning({
|
||||
title: "确认执行远程命令",
|
||||
content: `将在被控端执行:\n${command}`,
|
||||
positiveText: "执行",
|
||||
negativeText: "取消",
|
||||
onPositiveClick: () => doExec(command),
|
||||
});
|
||||
}
|
||||
|
||||
async function doExec(command: string) {
|
||||
running.value = true;
|
||||
result.value = null;
|
||||
try {
|
||||
const r = await conn.ok<ExecResult>("system.shell.exec", {
|
||||
command,
|
||||
timeout_ms: timeoutMs.value,
|
||||
});
|
||||
result.value = r;
|
||||
history.value = [command, ...history.value.filter((c) => c !== command)].slice(0, 20);
|
||||
if (r.exit_code !== 0) msg.warning(`退出码 ${r.exit_code}`);
|
||||
} catch (e) {
|
||||
msg.error(`执行失败:${e}`);
|
||||
} finally {
|
||||
running.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function reuse(c: string) {
|
||||
cmd.value = c;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!conn.connected">
|
||||
<n-empty description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
|
||||
</div>
|
||||
|
||||
<div v-else class="wrap">
|
||||
<n-card size="small" title="命令执行(system.shell.exec)">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 12px">⚠ 高权限操作,请谨慎</n-text>
|
||||
</template>
|
||||
<n-input
|
||||
v-model:value="cmd"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 2, maxRows: 6 }"
|
||||
placeholder="输入命令,例如:Get-Process | Select-Object -First 5"
|
||||
/>
|
||||
<n-space align="center" style="margin-top: 12px">
|
||||
<span class="label">超时(ms)</span>
|
||||
<n-input-number v-model:value="timeoutMs" :min="1000" :step="1000" size="small" style="width: 140px" />
|
||||
<n-button type="primary" :loading="running" @click="exec">执行</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<n-card v-if="result" size="small" title="输出">
|
||||
<template #header-extra>
|
||||
<n-space align="center">
|
||||
<n-tag :type="result.exit_code === 0 ? 'success' : 'error'" size="small">
|
||||
退出码 {{ result.exit_code }}
|
||||
</n-tag>
|
||||
<n-text depth="3" style="font-size: 12px">{{ result.duration_ms }}ms</n-text>
|
||||
<n-tag v-if="result.truncated" size="small" type="warning">已截断</n-tag>
|
||||
<n-tag v-if="result.killed" size="small" type="error">超时终止</n-tag>
|
||||
</n-space>
|
||||
</template>
|
||||
<pre class="out">{{ output }}</pre>
|
||||
</n-card>
|
||||
|
||||
<n-card v-if="history.length" size="small" title="历史">
|
||||
<n-list>
|
||||
<n-list-item v-for="(h, i) in history" :key="i">
|
||||
<div class="hist" @click="reuse(h)">
|
||||
<code>{{ h }}</code>
|
||||
</div>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap {
|
||||
max-width: 1000px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.label {
|
||||
font-size: 13px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.out {
|
||||
margin: 0;
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 460px;
|
||||
overflow: auto;
|
||||
}
|
||||
.hist {
|
||||
cursor: pointer;
|
||||
}
|
||||
.hist code {
|
||||
font-size: 12px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.hist:hover code {
|
||||
color: #63e2b7;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
// @ts-expect-error type error without @types/node package
|
||||
import process from "node:process";
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(() => ({
|
||||
plugins: [vue()],
|
||||
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
// 1. prevent Vite from obscuring rust errors
|
||||
clearScreen: false,
|
||||
// 2. tauri expects a fixed port, fail if that port is not available
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
// 3. tell Vite to ignore watching `src-tauri`
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/hub",
|
||||
"version": "1.6.0",
|
||||
"version": "2.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/mcp",
|
||||
"version": "1.6.0",
|
||||
"version": "2.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -96,544 +96,9 @@ async function call(capability: string, args: Record<string, unknown>): Promise<
|
||||
}
|
||||
|
||||
// ---------- 工具定义 ----------
|
||||
// 具体清单见 tools/defs.ts(自 index.ts 抽出,顺序即对外呈现顺序)
|
||||
import { TOOLS } from './tools/defs.js';
|
||||
|
||||
const TOOLS = [
|
||||
{
|
||||
name: 'na_status',
|
||||
description:
|
||||
'查询被控端 Windows 的资源状态:CPU 占用率、内存使用、各磁盘容量、网络适配器。当用户问"Windows 卡不卡""内存够不够""磁盘满了吗"时使用。',
|
||||
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
||||
},
|
||||
{
|
||||
name: 'na_system_info',
|
||||
description:
|
||||
'查询被控端 Windows 的系统基本信息:主机名、操作系统版本、CPU 型号、内存总量、开机时长、是否管理员。',
|
||||
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
||||
},
|
||||
{
|
||||
name: 'na_exec',
|
||||
description:
|
||||
'在被控端 Windows 上执行 PowerShell 命令并返回退出码与输出。用于查日志、查目录、重启服务、运行脚本等任意命令场景。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
command: { type: 'string', description: '要执行的 PowerShell 命令' },
|
||||
timeout_ms: { type: 'integer', description: '超时毫秒数,默认 30000,最大 300000' },
|
||||
},
|
||||
required: ['command'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_install',
|
||||
description:
|
||||
'在被控端 Windows 上安装软件(winget 静默安装)。当用户说"在 Windows 上装个 XX""帮我装 VSCode"时使用。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
package: { type: 'string', description: '软件名或 winget 包 ID,如 Microsoft.VisualStudioCode' },
|
||||
id: { type: 'string', description: '精确 winget ID(可选,优先使用)' },
|
||||
},
|
||||
required: ['package'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_app_list',
|
||||
description: '列出被控端 Windows 上已安装的软件及其版本。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { name_pattern: { type: 'string', description: '可选:按名称筛选(不区分大小写)' } },
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_process_list',
|
||||
description: '列出被控端 Windows 上运行的进程,可按 CPU 或内存排序。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sort_by: { type: 'string', enum: ['cpu', 'memory', 'pid', 'name'], description: '排序字段,默认 cpu' },
|
||||
limit: { type: 'integer', description: '返回条数,默认 20' },
|
||||
name_pattern: { type: 'string', description: '可选:按进程名筛选' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_service_list',
|
||||
description: '列出被控端 Windows 的系统服务及其运行状态。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name_pattern: { type: 'string', description: '可选:按服务名筛选' },
|
||||
state: { type: 'string', enum: ['running', 'stopped', 'paused'], description: '可选:按状态筛选' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_screenshot',
|
||||
description:
|
||||
'截取被控端 Windows 屏幕并直接返回图片。当用户问"Windows 现在画面是什么样""帮我看下屏幕",或需要视觉确认操作结果时使用。屏幕较大时建议 scale 设 0.5 以减小体积。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
scale: { type: 'number', description: '缩放比例 0.1~1.0,默认 1' },
|
||||
format: { type: 'string', enum: ['png', 'jpeg'], description: '默认 jpeg' },
|
||||
region: { type: 'string', description: '可选截取区域,格式 "x,y,width,height"' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_mouse',
|
||||
description:
|
||||
'控制被控端鼠标(需被控端已开启输入控制)。action: move 移动到 (x,y);click 点击(给了 x/y 则先移动,否则点当前位置);scroll 滚动 delta 格。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: { type: 'string', enum: ['move', 'click', 'scroll'], description: '操作类型' },
|
||||
x: { type: 'integer', description: 'X 坐标' },
|
||||
y: { type: 'integer', description: 'Y 坐标' },
|
||||
button: { type: 'string', enum: ['left', 'right', 'middle'], description: 'click 的按钮,默认 left' },
|
||||
delta: { type: 'integer', description: 'scroll 的滚动格数(正数向上)' },
|
||||
duration_ms: { type: 'integer', description: 'move 的平滑移动耗时(毫秒)' },
|
||||
},
|
||||
required: ['action'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_key',
|
||||
description:
|
||||
'控制被控端键盘(需被控端已开启输入控制)。\n' +
|
||||
'action: type = 输入一段文本;press = 按**快捷键**。\n' +
|
||||
'press 支持四种形式(一次只用一种):\n' +
|
||||
'① hotkey 单条字符串,如 "ctrl+shift+esc"、"win+d"、"alt+tab";\n' +
|
||||
'② hotkeys 字符串序列,如 ["ctrl+c","ctrl+v"];\n' +
|
||||
'③ preset 预设语义名,如 copy/paste/save/show_desktop/lock_screen/screenshot/' +
|
||||
'zoom_in/music_next/volume_mute_toggle(约 50 个);\n' +
|
||||
'④ keys 老形式数组,如 ["ctrl","c"]。\n' +
|
||||
'按键覆盖:字母数字、F1-F24、左右侧修饰键(lalt/rctrl/lwin…)、小键盘(numpad7、numpad_add…)、' +
|
||||
'OEM 符号键(oem_plus/oem_comma/oem_period…,支持 win+d、ctrl++、win+. 这类组合)、' +
|
||||
'媒体键(media_next/music_play_pause…)、浏览器键、IME 键、系统键。\n' +
|
||||
'可配:hold_ms 长按(≤5000);route=foreground(默认,注入当前焦点)或 ' +
|
||||
'route=post(向 target_pid 指定进程的全部顶层窗口 PostMessage,目标在后台时可用,' +
|
||||
'但游戏/部分输入型程序不响应后台键消息)。\n' +
|
||||
'媒体键整条序列会自动走 WM_APPCOMMAND(可定向 target_pid,窗口被遮挡也有效)。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: { type: 'string', enum: ['type', 'press'], description: '操作类型' },
|
||||
text: { type: 'string', description: 'type 时输入的文本' },
|
||||
hotkey: { type: 'string', maxLength: 64, description: '单条热键字符串,如 "ctrl+shift+esc";支持 + - 空格分隔' },
|
||||
hotkeys: { type: 'array', items: { type: 'string' }, maxItems: 50, description: '热键字符串序列,如 ["ctrl+c","ctrl+v"]' },
|
||||
preset: { type: 'string', description: '预设语义名(单个),如 copy / show_desktop / task_manager' },
|
||||
presets: { type: 'array', items: { type: 'string' }, maxItems: 50, description: '多个预设名 = 序列' },
|
||||
keys: { type: 'array', items: { type: 'string' }, description: '(老形式)和弦按键数组,如 ["ctrl","c"]' },
|
||||
repeat: { type: 'integer', minimum: 1, maximum: 50, description: '重复次数(默认 1)' },
|
||||
hold_ms: { type: 'integer', minimum: 0, maximum: 5000, description: '长按保持毫秒数(按下后延时再释放)' },
|
||||
route: { type: 'string', enum: ['foreground', 'post'], description: '投递路由,默认 foreground' },
|
||||
target_pid: { type: 'integer', minimum: 0, description: '目标进程 PID(媒体键定向或 route=post 必填)' },
|
||||
},
|
||||
required: ['action'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_audit',
|
||||
description:
|
||||
'查询被控端审计日志:谁在何时调用了什么能力、结果如何。用于安全审计与问题追溯,例如"最近谁在操作这台 Windows"。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: { type: 'integer', description: '返回条数,默认 20' },
|
||||
since: { type: 'integer', description: '仅返回该 Unix 毫秒时间戳之后的记录' },
|
||||
client_id: { type: 'string', description: '按调用方筛选' },
|
||||
type: { type: 'string', enum: ['invoke', 'auth', 'acl', 'agent'], description: '按事件类别筛选' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_discover',
|
||||
description:
|
||||
'发现局域网内可用的 nodeagent 被控端设备(监听 UDP 广播)。当用户问"有哪些 Windows 机器可用"、或尚未配置被控端时使用。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
wait_seconds: { type: 'number', description: '监听时长(秒),默认 3,上限 15' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_fs_list',
|
||||
description: '列出被控端某目录下的文件与子目录。用户问"Windows 上某目录有什么文件"时使用。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: '目录绝对路径,如 C:\\\\Users\\\\me\\\\Desktop' },
|
||||
pattern: { type: 'string', description: '可选 glob 过滤,如 "*.log"' },
|
||||
recursive: { type: 'boolean', description: '是否递归子目录' },
|
||||
},
|
||||
required: ['path'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_fs_read',
|
||||
description:
|
||||
'读取被控端文件内容(默认 UTF-8)。文件较大时会分块返回,用 offset 续读、看 eof 判断结束。适合读取日志、配置、脚本。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: '文件绝对路径' },
|
||||
encoding: { type: 'string', enum: ['utf8', 'base64'], description: '默认 utf8;二进制文件用 base64' },
|
||||
offset: { type: 'integer', description: '起始字节偏移,默认 0' },
|
||||
max_bytes: { type: 'integer', description: '单次读取上限,默认 1MB,最大 8MB' },
|
||||
},
|
||||
required: ['path'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_fs_write',
|
||||
description:
|
||||
'向被控端写入文件(自动原子写,不会留半截文件)。已有文件可用 append: true 追加;目录不存在时设 create_dirs: true。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: '文件绝对路径' },
|
||||
data: { type: 'string', description: '写入内容' },
|
||||
encoding: { type: 'string', enum: ['utf8', 'base64'], description: '默认 utf8' },
|
||||
append: { type: 'boolean', description: '是否追加(默认覆盖)' },
|
||||
create_dirs: { type: 'boolean', description: '目录不存在时自动创建' },
|
||||
},
|
||||
required: ['path', 'data'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_fs_stat',
|
||||
description: '查看被控端文件或目录的元信息(类型 / 大小 / 修改时间),用于读文件前先判断类型与大小。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: '文件或目录绝对路径' },
|
||||
},
|
||||
required: ['path'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_nodes',
|
||||
description: '列出已配置的 nodeagent 被控端设备(● 为当前默认设备)。多台 Windows 时使用。',
|
||||
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
||||
},
|
||||
{
|
||||
name: 'na_restart',
|
||||
description:
|
||||
'受控重启被控端(约 4 秒后生效,自动恢复原配置与端口)。配置变更后让 agent 自身生效时使用;' +
|
||||
'重启期间连接会短暂中断,等待约 5 秒后即可继续调用其他工具。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
delay_ms: { type: 'integer', description: '延时毫秒(默认 2000)' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_window_list',
|
||||
description:
|
||||
'列出被控端当前可见的顶层窗口(标题 / 进程 / 精确矩形 / 是否前台)。' +
|
||||
'GUI 操作第一步:先拿到窗口矩形,再在其范围内定位,避免全屏猜坐标。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title_pattern: { type: 'string', description: '可选:按标题正则过滤' },
|
||||
limit: { type: 'integer', description: '返回上限(默认 50)' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_window_focus',
|
||||
description: '将被控端的指定窗口(按标题正则)置前并聚焦,返回其精确矩形。点击某窗口内容之前先调用。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string', description: '窗口标题(正则,匹配第一个)' },
|
||||
wait_ms: { type: 'integer', description: '等待窗口出现(应用刚启动时用)' },
|
||||
},
|
||||
required: ['title'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_screen_find',
|
||||
description:
|
||||
'在被控端界面中查找 UI 元素并返回其屏幕坐标(Windows UI Automation)。' +
|
||||
'返回中心点 (x,y),可直接配合 na_mouse 的 click 使用。找不到时自动降级为 OCR 截图识别(支持自绘 UI)。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
text: { type: 'string', description: '要查找的文本(子串匹配,不区分大小写)' },
|
||||
window: { type: 'string', description: '可选:限定在该窗口标题(正则)内查找' },
|
||||
control_type: {
|
||||
type: 'string',
|
||||
description: '可选:控件类型,如 Button / MenuItem / Edit / ListItem(仅 UIA)',
|
||||
},
|
||||
method: {
|
||||
type: 'string',
|
||||
enum: ['auto', 'uia', 'ocr'],
|
||||
description: 'auto=UIA 优先+OCR 兜底;uia=仅 UIA;ocr=仅截图识别',
|
||||
},
|
||||
limit: { type: 'integer', description: '返回上限(默认 20)' },
|
||||
wait_ms: {
|
||||
type: 'integer',
|
||||
description: '等待元素出现的上限毫秒(0=只查一次)。界面有动画/加载时建议 3000~8000',
|
||||
},
|
||||
},
|
||||
required: ['text'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_bg',
|
||||
description:
|
||||
'在被控端后台执行长命令(如下载、安装、日志采样),立即返回 task_id 不阻塞。' +
|
||||
'之后用 na_task 查询输出或终止。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
command: { type: 'string', description: '要执行的 PowerShell 命令' },
|
||||
timeout_ms: { type: 'integer', description: '超时毫秒(默认 300000,即 5 分钟)' },
|
||||
},
|
||||
required: ['command'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_task',
|
||||
description:
|
||||
'管理后台任务:action=list 列出;action=get 读取输出(offset 支持增量续读);action=kill 终止。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: { type: 'string', enum: ['list', 'get', 'kill'], description: '默认 list' },
|
||||
task_id: { type: 'string', description: 'get/kill 时必填' },
|
||||
offset: { type: 'integer', description: 'get 时从第 N 字节续读(默认 0)' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_macro_run',
|
||||
description:
|
||||
'回放一段 GUI 宏(步骤序列):把一次成功操作固化后重复执行。' +
|
||||
'步骤支持 focus/find/click/type/key/drag/sleep/exec/clip/assert/capture,' +
|
||||
'可带 retry 重试、optional 可选、${VAR} 变量替换。返回每步结果(含失败定位)。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
steps: {
|
||||
type: 'array',
|
||||
description: '步骤数组',
|
||||
items: { type: 'object', additionalProperties: true },
|
||||
},
|
||||
vars: { type: 'object', description: '可选:变量表(供 ${NAME} 替换)' },
|
||||
default_delay_ms: { type: 'integer', description: '每步之间默认等待' },
|
||||
},
|
||||
required: ['steps'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_net_status',
|
||||
description:
|
||||
'查看被控端网络现状:各网卡 IP/网关、**待确认的网络变更**(含剩余秒数)、历史配置备份。' +
|
||||
'改网络前后都应先看它。',
|
||||
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
||||
},
|
||||
{
|
||||
name: 'na_net_apply',
|
||||
description:
|
||||
'**改被控端网络**(高危):两阶段提交 —— 先备份当前配置,再应用变更,并注册 OS 级定时回滚任务;' +
|
||||
'你必须在 confirm_within_ms 内(可能要在新地址上重连后)用 na_net_confirm 确认,' +
|
||||
'否则自动回滚到变更前配置。用于远程改 IP / 切 DHCP 这类「改错就失联」的操作。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
mode: { type: 'string', enum: ['static', 'dhcp', 'command'] },
|
||||
interface: { type: 'string', description: '网卡别名;省略则用带默认网关的那块' },
|
||||
ip: { type: 'string' },
|
||||
mask: { type: 'string' },
|
||||
gateway: { type: 'string' },
|
||||
dns: { type: 'array', items: { type: 'string' } },
|
||||
command: { type: 'string', description: 'mode=command 时执行的自定义变更命令' },
|
||||
confirm_within_ms: { type: 'integer', description: '确认窗口毫秒(默认 60000,最小 15000)' },
|
||||
},
|
||||
required: ['mode'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_net_confirm',
|
||||
description: '确认(提交)网络变更,取消自动回滚。不传 task_name 则取消全部待确认项。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { task_name: { type: 'string' } },
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_agent_update',
|
||||
description:
|
||||
'**被控端自更新**:让它自己从 URL 下载新版本 agent 并替换自身(校验哈希 → 备份 → 原子替换 → 重启)。' +
|
||||
'用于控制端与被控端不可达、但被控端能上网的场景(跨网段/NAT/异地)。' +
|
||||
'sha256 必填(安全底线);不确定时可先 dry_run=true 只校验不改动。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: { type: 'string', description: '更新包地址(http/https)' },
|
||||
sha256: { type: 'string', description: '期望哈希(12~64 位十六进制)' },
|
||||
restart: { type: 'boolean', description: '替换后是否重启(默认 true)' },
|
||||
dry_run: { type: 'boolean', description: '只下载校验,不改动文件' },
|
||||
},
|
||||
required: ['url', 'sha256'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_audio_get',
|
||||
description: '读取被控端默认播放设备的静音状态与主音量(Windows: Core Audio / macOS: osascript)。',
|
||||
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
||||
},
|
||||
{
|
||||
name: 'na_audio_set',
|
||||
description:
|
||||
'设置被控端静音开关或主音量(可读回实际状态,故为确定性设置而非切换)。' +
|
||||
'常用于「把远端那台机器静音」。mute 与 volume 至少给一个。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
mute: { type: 'boolean', description: 'true=静音,false=取消静音' },
|
||||
volume: { type: 'integer', description: '主音量 0-100' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_metrics',
|
||||
description:
|
||||
'按 PRD 2.2 汇总被控端成功指标(闭环成功率 / 装软件成功率 / P95 时延 / 安全拦截率)' +
|
||||
'及达标判定与按能力细分。排查「成功率掉了没有/哪个能力最慢」时用。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
since: { type: 'integer', description: '可选:只统计该 Unix 毫秒之后的审计条目' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_event_watch',
|
||||
description:
|
||||
'订阅被控端事件:file=文件变动、process=进程启停、net=监听端口开闭。' +
|
||||
'MCP 无法接收推送,事件会进入服务端缓冲,用 na_event_poll 拉取。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
kind: { type: 'string', enum: ['file', 'process', 'net'] },
|
||||
path: { type: 'string', description: 'file 类型:监控的目录/文件路径' },
|
||||
pattern: { type: 'string', description: '可选:文件名或进程名 glob(如 *.log、chrome*)' },
|
||||
recursive: { type: 'boolean', description: 'file 类型:是否递归子目录' },
|
||||
interval_ms: { type: 'integer', description: 'process/net 采样间隔(1000~60000,默认 5000)' },
|
||||
notify: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'开启实时推送(MCP 日志通知);宿主若不展示日志则退化为 na_event_poll 拉取,无副作用',
|
||||
},
|
||||
},
|
||||
required: ['kind'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_event_poll',
|
||||
description:
|
||||
'拉取已缓冲的被控端事件(增量:传上次返回的 next_cursor)。' +
|
||||
'典型用法:先 na_event_watch 订阅,稍后 na_event_poll 查看期间发生了什么。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: { type: 'integer', description: '最多返回条数(默认 50)' },
|
||||
since: { type: 'integer', description: '游标:上次返回的 next_cursor' },
|
||||
watch_id: { type: 'string', description: '可选:只看某个订阅' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_event_list',
|
||||
description: '列出当前生效的事件订阅(含各自已产生的事件数)与缓冲区大小。',
|
||||
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
||||
},
|
||||
{
|
||||
name: 'na_event_unwatch',
|
||||
description: '取消一个事件订阅(长时间不用的订阅应主动取消,避免浪费被控端资源)。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { watch_id: { type: 'string' } },
|
||||
required: ['watch_id'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_record',
|
||||
description:
|
||||
'录制被控端屏幕为帧序列(JPEG),检测到 ffmpeg 时额外封装 mp4。' +
|
||||
'用于复现间歇性问题或记录 GUI 操作;产物在被控端目录,用 na_fs_read 取回。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
duration_ms: { type: 'integer', description: '时长毫秒(1000~60000,默认 5000)' },
|
||||
fps: { type: 'integer', description: '帧率 1~10(默认 2)' },
|
||||
scale: { type: 'number', description: '全屏缩放 0.1~1(默认 0.5)' },
|
||||
region: { type: 'string', description: '可选 "x,y,width,height" 只录该区域' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_clip',
|
||||
description:
|
||||
'读写被控端剪贴板:action=get 读取文本;action=set 写入文本。要粘贴到 GUI 输入框时先 set 再用 na_key 按 ctrl+v。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: { type: 'string', enum: ['get', 'set'], description: '默认 get' },
|
||||
text: { type: 'string', description: 'set 时要写入的文本' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_use',
|
||||
description: '切换本次会话的目标设备(不影响 CLI 的默认设备)。用户说"切换到某台机器"时使用。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: '设备名(用 na_nodes 查看)' },
|
||||
},
|
||||
required: ['name'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
/** MCP 工具入参 → 能力 args。 */
|
||||
function toArgs(toolName: string, input: Record<string, unknown>): Record<string, unknown> {
|
||||
@@ -755,6 +220,41 @@ function resolveToolCall(toolName: string, input: Record<string, unknown>): Reso
|
||||
}
|
||||
return { error: `不支持的 action: ${String(action)}` };
|
||||
}
|
||||
case 'na_monitor': {
|
||||
const action = (input['action'] as string | undefined) ?? 'list';
|
||||
if (action === 'start') {
|
||||
if (!input['source']) return { error: 'start 需要 source' };
|
||||
const a: Record<string, unknown> = { source: input['source'] };
|
||||
for (const k of ['target', 'interval_ms', 'id']) if (input[k] !== undefined) a[k] = input[k];
|
||||
return { capability: CapabilityNames.MonitorStart, args: a };
|
||||
}
|
||||
if (action === 'report') {
|
||||
if (!input['id']) return { error: 'report 需要 id' };
|
||||
const a: Record<string, unknown> = { id: input['id'] };
|
||||
if (input['limit'] !== undefined) a['limit'] = input['limit'];
|
||||
return { capability: CapabilityNames.MonitorReport, args: a };
|
||||
}
|
||||
if (action === 'stop' || action === 'delete') {
|
||||
if (!input['id']) return { error: `${action} 需要 id` };
|
||||
return { capability: action === 'stop' ? CapabilityNames.MonitorStop : CapabilityNames.MonitorDelete, args: { id: input['id'] } };
|
||||
}
|
||||
if (action === 'list') return { capability: CapabilityNames.MonitorList, args: {} };
|
||||
return { error: `不支持的 action: ${String(action)}` };
|
||||
}
|
||||
case 'na_log': {
|
||||
if (!input['path']) return { error: 'na_log 需要 path' };
|
||||
const a: Record<string, unknown> = { path: input['path'] };
|
||||
for (const k of ['pattern', 'level', 'since', 'offset', 'limit', 'tail']) if (input[k] !== undefined) a[k] = input[k];
|
||||
return { capability: CapabilityNames.LogQuery, args: a };
|
||||
}
|
||||
case 'na_await': {
|
||||
const args: Record<string, unknown> = {};
|
||||
for (const k of ['condition', 'state', 'timeout_ms', 'interval_ms', 'text', 'title', 'process', 'path', 'where', 'any_of']) {
|
||||
if (input[k] !== undefined) args[k] = input[k];
|
||||
}
|
||||
if (!args['condition']) return { error: 'await 需要 condition' };
|
||||
return { capability: CapabilityNames.GuiAwait, args };
|
||||
}
|
||||
case 'na_key': {
|
||||
const action = input['action'];
|
||||
if (action === 'type') {
|
||||
@@ -762,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);
|
||||
|
||||
@@ -0,0 +1,618 @@
|
||||
/**
|
||||
* MCP 工具定义清单(2026-10 从 index.ts 抽出)。
|
||||
*
|
||||
* ⚠️ **顺序即对外呈现顺序**:改动会改变客户端看到的工具列表次序。
|
||||
* 拆分时用「工具名序列 sha256」做回归证据(见 tests/unit/mcp-tools.test.mjs)。
|
||||
*
|
||||
* 定义与实现的分工:本文件只管「暴露什么」,分发逻辑仍在 index.ts 的 resolveToolCall。
|
||||
*/
|
||||
export const TOOLS = [
|
||||
{
|
||||
name: 'na_status',
|
||||
description:
|
||||
'查询被控端 Windows 的资源状态:CPU 占用率、内存使用、各磁盘容量、网络适配器。当用户问"Windows 卡不卡""内存够不够""磁盘满了吗"时使用。',
|
||||
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
||||
},
|
||||
{
|
||||
name: 'na_system_info',
|
||||
description:
|
||||
'查询被控端 Windows 的系统基本信息:主机名、操作系统版本、CPU 型号、内存总量、开机时长、是否管理员。',
|
||||
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
||||
},
|
||||
{
|
||||
name: 'na_exec',
|
||||
description:
|
||||
'在被控端 Windows 上执行 PowerShell 命令并返回退出码与输出。用于查日志、查目录、重启服务、运行脚本等任意命令场景。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
command: { type: 'string', description: '要执行的 PowerShell 命令' },
|
||||
timeout_ms: { type: 'integer', description: '超时毫秒数,默认 30000,最大 300000' },
|
||||
},
|
||||
required: ['command'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_install',
|
||||
description:
|
||||
'在被控端 Windows 上安装软件(winget 静默安装)。当用户说"在 Windows 上装个 XX""帮我装 VSCode"时使用。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
package: { type: 'string', description: '软件名或 winget 包 ID,如 Microsoft.VisualStudioCode' },
|
||||
id: { type: 'string', description: '精确 winget ID(可选,优先使用)' },
|
||||
},
|
||||
required: ['package'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_app_list',
|
||||
description: '列出被控端 Windows 上已安装的软件及其版本。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { name_pattern: { type: 'string', description: '可选:按名称筛选(不区分大小写)' } },
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_process_list',
|
||||
description: '列出被控端 Windows 上运行的进程,可按 CPU 或内存排序。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sort_by: { type: 'string', enum: ['cpu', 'memory', 'pid', 'name'], description: '排序字段,默认 cpu' },
|
||||
limit: { type: 'integer', description: '返回条数,默认 20' },
|
||||
name_pattern: { type: 'string', description: '可选:按进程名筛选' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_service_list',
|
||||
description: '列出被控端 Windows 的系统服务及其运行状态。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name_pattern: { type: 'string', description: '可选:按服务名筛选' },
|
||||
state: { type: 'string', enum: ['running', 'stopped', 'paused'], description: '可选:按状态筛选' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_screenshot',
|
||||
description:
|
||||
'截取被控端 Windows 屏幕并直接返回图片。当用户问"Windows 现在画面是什么样""帮我看下屏幕",或需要视觉确认操作结果时使用。屏幕较大时建议 scale 设 0.5 以减小体积。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
scale: { type: 'number', description: '缩放比例 0.1~1.0,默认 1' },
|
||||
format: { type: 'string', enum: ['png', 'jpeg'], description: '默认 jpeg' },
|
||||
region: { type: 'string', description: '可选截取区域,格式 "x,y,width,height"' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_mouse',
|
||||
description:
|
||||
'控制被控端鼠标(需被控端已开启输入控制)。action: move 移动到 (x,y);click 点击(给了 x/y 则先移动,否则点当前位置);scroll 滚动 delta 格。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: { type: 'string', enum: ['move', 'click', 'scroll'], description: '操作类型' },
|
||||
x: { type: 'integer', description: 'X 坐标' },
|
||||
y: { type: 'integer', description: 'Y 坐标' },
|
||||
button: { type: 'string', enum: ['left', 'right', 'middle'], description: 'click 的按钮,默认 left' },
|
||||
delta: { type: 'integer', description: 'scroll 的滚动格数(正数向上)' },
|
||||
duration_ms: { type: 'integer', description: 'move 的平滑移动耗时(毫秒)' },
|
||||
},
|
||||
required: ['action'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_key',
|
||||
description:
|
||||
'控制被控端键盘(需被控端已开启输入控制)。\n' +
|
||||
'action: type = 输入一段文本;press = 按**快捷键**。\n' +
|
||||
'press 支持四种形式(一次只用一种):\n' +
|
||||
'① hotkey 单条字符串,如 "ctrl+shift+esc"、"win+d"、"alt+tab";\n' +
|
||||
'② hotkeys 字符串序列,如 ["ctrl+c","ctrl+v"];\n' +
|
||||
'③ preset 预设语义名,如 copy/paste/save/show_desktop/lock_screen/screenshot/' +
|
||||
'zoom_in/music_next/volume_mute_toggle(约 50 个);\n' +
|
||||
'④ keys 老形式数组,如 ["ctrl","c"]。\n' +
|
||||
'按键覆盖:字母数字、F1-F24、左右侧修饰键(lalt/rctrl/lwin…)、小键盘(numpad7、numpad_add…)、' +
|
||||
'OEM 符号键(oem_plus/oem_comma/oem_period…,支持 win+d、ctrl++、win+. 这类组合)、' +
|
||||
'媒体键(media_next/music_play_pause…)、浏览器键、IME 键、系统键。\n' +
|
||||
'可配:hold_ms 长按(≤5000);route=foreground(默认,注入当前焦点)或 ' +
|
||||
'route=post(向 target_pid 指定进程的全部顶层窗口 PostMessage,目标在后台时可用,' +
|
||||
'但游戏/部分输入型程序不响应后台键消息)。\n' +
|
||||
'媒体键整条序列会自动走 WM_APPCOMMAND(可定向 target_pid,窗口被遮挡也有效)。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: { type: 'string', enum: ['type', 'press'], description: '操作类型' },
|
||||
text: { type: 'string', description: 'type 时输入的文本' },
|
||||
hotkey: { type: 'string', maxLength: 64, description: '单条热键字符串,如 "ctrl+shift+esc";支持 + - 空格分隔' },
|
||||
hotkeys: { type: 'array', items: { type: 'string' }, maxItems: 50, description: '热键字符串序列,如 ["ctrl+c","ctrl+v"]' },
|
||||
preset: { type: 'string', description: '预设语义名(单个),如 copy / show_desktop / task_manager' },
|
||||
presets: { type: 'array', items: { type: 'string' }, maxItems: 50, description: '多个预设名 = 序列' },
|
||||
keys: { type: 'array', items: { type: 'string' }, description: '(老形式)和弦按键数组,如 ["ctrl","c"]' },
|
||||
repeat: { type: 'integer', minimum: 1, maximum: 50, description: '重复次数(默认 1)' },
|
||||
hold_ms: { type: 'integer', minimum: 0, maximum: 5000, description: '长按保持毫秒数(按下后延时再释放)' },
|
||||
route: { type: 'string', enum: ['foreground', 'post'], description: '投递路由,默认 foreground' },
|
||||
target_pid: { type: 'integer', minimum: 0, description: '目标进程 PID(媒体键定向或 route=post 必填)' },
|
||||
},
|
||||
required: ['action'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_audit',
|
||||
description:
|
||||
'查询被控端审计日志:谁在何时调用了什么能力、结果如何。用于安全审计与问题追溯,例如"最近谁在操作这台 Windows"。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: { type: 'integer', description: '返回条数,默认 20' },
|
||||
since: { type: 'integer', description: '仅返回该 Unix 毫秒时间戳之后的记录' },
|
||||
client_id: { type: 'string', description: '按调用方筛选' },
|
||||
type: { type: 'string', enum: ['invoke', 'auth', 'acl', 'agent'], description: '按事件类别筛选' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_discover',
|
||||
description:
|
||||
'发现局域网内可用的 nodeagent 被控端设备(监听 UDP 广播)。当用户问"有哪些 Windows 机器可用"、或尚未配置被控端时使用。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
wait_seconds: { type: 'number', description: '监听时长(秒),默认 3,上限 15' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_fs_list',
|
||||
description: '列出被控端某目录下的文件与子目录。用户问"Windows 上某目录有什么文件"时使用。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: '目录绝对路径,如 C:\\\\Users\\\\me\\\\Desktop' },
|
||||
pattern: { type: 'string', description: '可选 glob 过滤,如 "*.log"' },
|
||||
recursive: { type: 'boolean', description: '是否递归子目录' },
|
||||
},
|
||||
required: ['path'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_fs_read',
|
||||
description:
|
||||
'读取被控端文件内容(默认 UTF-8)。文件较大时会分块返回,用 offset 续读、看 eof 判断结束。适合读取日志、配置、脚本。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: '文件绝对路径' },
|
||||
encoding: { type: 'string', enum: ['utf8', 'base64'], description: '默认 utf8;二进制文件用 base64' },
|
||||
offset: { type: 'integer', description: '起始字节偏移,默认 0' },
|
||||
max_bytes: { type: 'integer', description: '单次读取上限,默认 1MB,最大 8MB' },
|
||||
},
|
||||
required: ['path'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_fs_write',
|
||||
description:
|
||||
'向被控端写入文件(自动原子写,不会留半截文件)。已有文件可用 append: true 追加;目录不存在时设 create_dirs: true。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: '文件绝对路径' },
|
||||
data: { type: 'string', description: '写入内容' },
|
||||
encoding: { type: 'string', enum: ['utf8', 'base64'], description: '默认 utf8' },
|
||||
append: { type: 'boolean', description: '是否追加(默认覆盖)' },
|
||||
create_dirs: { type: 'boolean', description: '目录不存在时自动创建' },
|
||||
},
|
||||
required: ['path', 'data'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_fs_stat',
|
||||
description: '查看被控端文件或目录的元信息(类型 / 大小 / 修改时间),用于读文件前先判断类型与大小。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: '文件或目录绝对路径' },
|
||||
},
|
||||
required: ['path'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_nodes',
|
||||
description: '列出已配置的 nodeagent 被控端设备(● 为当前默认设备)。多台 Windows 时使用。',
|
||||
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
||||
},
|
||||
{
|
||||
name: 'na_restart',
|
||||
description:
|
||||
'受控重启被控端(约 4 秒后生效,自动恢复原配置与端口)。配置变更后让 agent 自身生效时使用;' +
|
||||
'重启期间连接会短暂中断,等待约 5 秒后即可继续调用其他工具。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
delay_ms: { type: 'integer', description: '延时毫秒(默认 2000)' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_await',
|
||||
description:
|
||||
'等待一个条件成立再返回(只读低危,v1.6.0)。\n' +
|
||||
'四类条件:window=等窗口出现/消失;control=等界面元素出现/消失(复用 screen.find);' +
|
||||
'process=等进程出现/消失;file=等文件出现/消失。\n' +
|
||||
'state=present(默认,等出现)或 absent(等消失)。\n' +
|
||||
'用途:把 GUI 操作从「按一下→睡几秒→截图碰运气」变成**可断言**流程:\n' +
|
||||
' await control(text:"立即安装") → na_mouse click → await control(text:"完成", timeout:60000) → click → await absent(text:"安装中")\n' +
|
||||
'超时是**正常返回**(satisfied:false + note,不抛异常);轮询期单次异常不视为失败。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
condition: { type: 'string', enum: ['window', 'control', 'process', 'file'], description: '等待哪类条件' },
|
||||
state: { type: 'string', enum: ['present', 'absent'], default: 'present', description: '等出现还是等消失' },
|
||||
timeout_ms: { type: 'integer', minimum: 0, maximum: 60000, default: 5000, description: '总超时' },
|
||||
interval_ms: { type: 'integer', minimum: 50, maximum: 5000, default: 400, description: '轮询间隔' },
|
||||
text: { type: 'string', description: 'control 条件:元素文本' },
|
||||
title: { type: 'string', description: 'window 条件:窗口标题(正则)' },
|
||||
process: { type: 'string', description: 'process 条件:进程名(正则)' },
|
||||
path: { type: 'string', description: 'file 条件:文件路径' },
|
||||
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,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_monitor',
|
||||
description:
|
||||
'定时采样并落盘,事后回看(v2.0.0,治间歇性问题)。\n' +
|
||||
'action=start:起一个采样。source=port(target="host:port",测连通性与延迟)/' +
|
||||
'process(target=进程名,测存活)/command(target=命令,取退出码)/metric(CPU·内存,无需 target)。\n' +
|
||||
'action=report:读回样本序列与**摘要**(port/process 给「断了几次 + 最长连续中断」;' +
|
||||
'metric 给 CPU/内存 min·avg·max;command 给成功失败数与退出码)。\n' +
|
||||
'action=stop/list/delete。\n' +
|
||||
'典型:先 start port 采 5 分钟,再 report 回答「这 5 分钟断过几次、最长断了多久」。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: { type: 'string', enum: ['start', 'report', 'stop', 'list', 'delete'], description: '操作' },
|
||||
source: { type: 'string', enum: ['port', 'process', 'command', 'metric'], description: 'start 时的采样源' },
|
||||
target: { type: 'string', description: 'port=host:port;process=进程名;command=命令' },
|
||||
interval_ms: { type: 'integer', minimum: 500, maximum: 600000, description: '采样间隔(默认 2000)' },
|
||||
id: { type: 'string', description: '监控 id(report/stop/delete 必需;start 可自定义)' },
|
||||
limit: { type: 'integer', description: 'report 最多回多少样本' },
|
||||
},
|
||||
required: ['action'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_log',
|
||||
description:
|
||||
'在被控端侧过滤日志,只回匹配行(v2.0.0,只读)。日志常几万行,整份拉回既慢又占带宽 —— ' +
|
||||
'本工具把过滤下推到被控端(流式逐行读)。支持 pattern(正则)/level(ERROR|WARN|INFO|DEBUG)/\n' +
|
||||
'since(Unix ms)/offset/limit/tail(取末尾 N 条)。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: '日志文件路径(受被控端 fs_roots 限制)' },
|
||||
pattern: { type: 'string', description: '正则过滤(忽略大小写)' },
|
||||
level: { type: 'string', enum: ['ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE'], description: '按级别过滤' },
|
||||
since: { type: 'integer', description: '只回该 Unix ms 之后的行' },
|
||||
offset: { type: 'integer', description: '跳过前 N 条命中' },
|
||||
limit: { type: 'integer', description: '最多回多少条(默认 100,上限 5000)' },
|
||||
tail: { type: 'boolean', description: '取末尾 N 条' },
|
||||
},
|
||||
required: ['path'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_window_list',
|
||||
description:
|
||||
'列出被控端当前可见的顶层窗口(标题 / 进程 / 精确矩形 / 是否前台)。' +
|
||||
'GUI 操作第一步:先拿到窗口矩形,再在其范围内定位,避免全屏猜坐标。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title_pattern: { type: 'string', description: '可选:按标题正则过滤' },
|
||||
limit: { type: 'integer', description: '返回上限(默认 50)' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_window_focus',
|
||||
description: '将被控端的指定窗口(按标题正则)置前并聚焦,返回其精确矩形。点击某窗口内容之前先调用。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string', description: '窗口标题(正则,匹配第一个)' },
|
||||
wait_ms: { type: 'integer', description: '等待窗口出现(应用刚启动时用)' },
|
||||
},
|
||||
required: ['title'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_screen_find',
|
||||
description:
|
||||
'在被控端界面中查找 UI 元素并返回其屏幕坐标(Windows UI Automation)。' +
|
||||
'返回中心点 (x,y),可直接配合 na_mouse 的 click 使用。找不到时自动降级为 OCR 截图识别(支持自绘 UI)。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
text: { type: 'string', description: '要查找的文本(子串匹配,不区分大小写)' },
|
||||
window: { type: 'string', description: '可选:限定在该窗口标题(正则)内查找' },
|
||||
control_type: {
|
||||
type: 'string',
|
||||
description: '可选:控件类型,如 Button / MenuItem / Edit / ListItem(仅 UIA)',
|
||||
},
|
||||
method: {
|
||||
type: 'string',
|
||||
enum: ['auto', 'uia', 'ocr'],
|
||||
description: 'auto=UIA 优先+OCR 兜底;uia=仅 UIA;ocr=仅截图识别',
|
||||
},
|
||||
limit: { type: 'integer', description: '返回上限(默认 20)' },
|
||||
wait_ms: {
|
||||
type: 'integer',
|
||||
description: '等待元素出现的上限毫秒(0=只查一次)。界面有动画/加载时建议 3000~8000',
|
||||
},
|
||||
},
|
||||
required: ['text'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_bg',
|
||||
description:
|
||||
'在被控端后台执行长命令(如下载、安装、日志采样),立即返回 task_id 不阻塞。' +
|
||||
'之后用 na_task 查询输出或终止。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
command: { type: 'string', description: '要执行的 PowerShell 命令' },
|
||||
timeout_ms: { type: 'integer', description: '超时毫秒(默认 300000,即 5 分钟)' },
|
||||
},
|
||||
required: ['command'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_task',
|
||||
description:
|
||||
'管理后台任务:action=list 列出;action=get 读取输出(offset 支持增量续读);action=kill 终止。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: { type: 'string', enum: ['list', 'get', 'kill'], description: '默认 list' },
|
||||
task_id: { type: 'string', description: 'get/kill 时必填' },
|
||||
offset: { type: 'integer', description: 'get 时从第 N 字节续读(默认 0)' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_macro_run',
|
||||
description:
|
||||
'回放一段 GUI 宏(步骤序列):把一次成功操作固化后重复执行。' +
|
||||
'步骤支持 focus/find/click/type/key/drag/sleep/exec/clip/assert/capture,' +
|
||||
'可带 retry 重试、optional 可选、${VAR} 变量替换。返回每步结果(含失败定位)。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
steps: {
|
||||
type: 'array',
|
||||
description: '步骤数组',
|
||||
items: { type: 'object', additionalProperties: true },
|
||||
},
|
||||
vars: { type: 'object', description: '可选:变量表(供 ${NAME} 替换)' },
|
||||
default_delay_ms: { type: 'integer', description: '每步之间默认等待' },
|
||||
},
|
||||
required: ['steps'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_net_status',
|
||||
description:
|
||||
'查看被控端网络现状:各网卡 IP/网关、**待确认的网络变更**(含剩余秒数)、历史配置备份。' +
|
||||
'改网络前后都应先看它。',
|
||||
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
||||
},
|
||||
{
|
||||
name: 'na_net_apply',
|
||||
description:
|
||||
'**改被控端网络**(高危):两阶段提交 —— 先备份当前配置,再应用变更,并注册 OS 级定时回滚任务;' +
|
||||
'你必须在 confirm_within_ms 内(可能要在新地址上重连后)用 na_net_confirm 确认,' +
|
||||
'否则自动回滚到变更前配置。用于远程改 IP / 切 DHCP 这类「改错就失联」的操作。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
mode: { type: 'string', enum: ['static', 'dhcp', 'command'] },
|
||||
interface: { type: 'string', description: '网卡别名;省略则用带默认网关的那块' },
|
||||
ip: { type: 'string' },
|
||||
mask: { type: 'string' },
|
||||
gateway: { type: 'string' },
|
||||
dns: { type: 'array', items: { type: 'string' } },
|
||||
command: { type: 'string', description: 'mode=command 时执行的自定义变更命令' },
|
||||
confirm_within_ms: { type: 'integer', description: '确认窗口毫秒(默认 60000,最小 15000)' },
|
||||
},
|
||||
required: ['mode'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_net_confirm',
|
||||
description: '确认(提交)网络变更,取消自动回滚。不传 task_name 则取消全部待确认项。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { task_name: { type: 'string' } },
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_agent_update',
|
||||
description:
|
||||
'**被控端自更新**:让它自己从 URL 下载新版本 agent 并替换自身(校验哈希 → 备份 → 原子替换 → 重启)。' +
|
||||
'用于控制端与被控端不可达、但被控端能上网的场景(跨网段/NAT/异地)。' +
|
||||
'sha256 必填(安全底线);不确定时可先 dry_run=true 只校验不改动。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: { type: 'string', description: '更新包地址(http/https)' },
|
||||
sha256: { type: 'string', description: '期望哈希(12~64 位十六进制)' },
|
||||
restart: { type: 'boolean', description: '替换后是否重启(默认 true)' },
|
||||
dry_run: { type: 'boolean', description: '只下载校验,不改动文件' },
|
||||
},
|
||||
required: ['url', 'sha256'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_audio_get',
|
||||
description: '读取被控端默认播放设备的静音状态与主音量(Windows: Core Audio / macOS: osascript)。',
|
||||
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
||||
},
|
||||
{
|
||||
name: 'na_audio_set',
|
||||
description:
|
||||
'设置被控端静音开关或主音量(可读回实际状态,故为确定性设置而非切换)。' +
|
||||
'常用于「把远端那台机器静音」。mute 与 volume 至少给一个。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
mute: { type: 'boolean', description: 'true=静音,false=取消静音' },
|
||||
volume: { type: 'integer', description: '主音量 0-100' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_metrics',
|
||||
description:
|
||||
'按 PRD 2.2 汇总被控端成功指标(闭环成功率 / 装软件成功率 / P95 时延 / 安全拦截率)' +
|
||||
'及达标判定与按能力细分。排查「成功率掉了没有/哪个能力最慢」时用。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
since: { type: 'integer', description: '可选:只统计该 Unix 毫秒之后的审计条目' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_event_watch',
|
||||
description:
|
||||
'订阅被控端事件:file=文件变动、process=进程启停、net=监听端口开闭。' +
|
||||
'MCP 无法接收推送,事件会进入服务端缓冲,用 na_event_poll 拉取。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
kind: { type: 'string', enum: ['file', 'process', 'net'] },
|
||||
path: { type: 'string', description: 'file 类型:监控的目录/文件路径' },
|
||||
pattern: { type: 'string', description: '可选:文件名或进程名 glob(如 *.log、chrome*)' },
|
||||
recursive: { type: 'boolean', description: 'file 类型:是否递归子目录' },
|
||||
interval_ms: { type: 'integer', description: 'process/net 采样间隔(1000~60000,默认 5000)' },
|
||||
notify: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'开启实时推送(MCP 日志通知);宿主若不展示日志则退化为 na_event_poll 拉取,无副作用',
|
||||
},
|
||||
},
|
||||
required: ['kind'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_event_poll',
|
||||
description:
|
||||
'拉取已缓冲的被控端事件(增量:传上次返回的 next_cursor)。' +
|
||||
'典型用法:先 na_event_watch 订阅,稍后 na_event_poll 查看期间发生了什么。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: { type: 'integer', description: '最多返回条数(默认 50)' },
|
||||
since: { type: 'integer', description: '游标:上次返回的 next_cursor' },
|
||||
watch_id: { type: 'string', description: '可选:只看某个订阅' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_event_list',
|
||||
description: '列出当前生效的事件订阅(含各自已产生的事件数)与缓冲区大小。',
|
||||
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
||||
},
|
||||
{
|
||||
name: 'na_event_unwatch',
|
||||
description: '取消一个事件订阅(长时间不用的订阅应主动取消,避免浪费被控端资源)。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { watch_id: { type: 'string' } },
|
||||
required: ['watch_id'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_record',
|
||||
description:
|
||||
'录制被控端屏幕为帧序列(JPEG),检测到 ffmpeg 时额外封装 mp4。' +
|
||||
'用于复现间歇性问题或记录 GUI 操作;产物在被控端目录,用 na_fs_read 取回。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
duration_ms: { type: 'integer', description: '时长毫秒(1000~60000,默认 5000)' },
|
||||
fps: { type: 'integer', description: '帧率 1~10(默认 2)' },
|
||||
scale: { type: 'number', description: '全屏缩放 0.1~1(默认 0.5)' },
|
||||
region: { type: 'string', description: '可选 "x,y,width,height" 只录该区域' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_clip',
|
||||
description:
|
||||
'读写被控端剪贴板:action=get 读取文本;action=set 写入文本。要粘贴到 GUI 输入框时先 set 再用 na_key 按 ctrl+v。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: { type: 'string', enum: ['get', 'set'], description: '默认 get' },
|
||||
text: { type: 'string', description: 'set 时要写入的文本' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_use',
|
||||
description: '切换本次会话的目标设备(不影响 CLI 的默认设备)。用户说"切换到某台机器"时使用。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: '设备名(用 na_nodes 查看)' },
|
||||
},
|
||||
required: ['name'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
@@ -10,7 +10,7 @@
|
||||
| 日期 | 2026-09-27 |
|
||||
| 状态 | 开发阶段 |
|
||||
| 目标端 | macOS(控制端)/ Windows(被控端) |
|
||||
| 配套文档 | [产品需求文档(PRD)](../PRD.md) |
|
||||
| 配套文档 | [产品需求文档(PRD)](PRD.md) |
|
||||
|
||||
---
|
||||
|
||||
@@ -307,6 +307,41 @@
|
||||
| 17 | `fs.stat` | 🟢 | 文件元信息 | v5 |
|
||||
| 18 | `fs.read` | 🟡 | 读文件(分块续读) | v5 |
|
||||
| 19 | `fs.write` | 🔴 | 写文件(分块 / 原子写) | v5 |
|
||||
| 20 | `window.list` | 🟢 | 枚举可见顶层窗口(标题/进程/精确矩形/是否前台) | v8 |
|
||||
| 21 | `window.focus` | 🟡 | 置前聚焦(Windows + macOS;越不过全屏独占,属预期) | v8 |
|
||||
| 22 | `screen.find` | 🟢 | 元素定位三引擎(UIA → OCR → 图像模板)+ **UIA 属性**与 `where` 谓词 | v8 / v2.0.0 |
|
||||
| 23 | `screen.record` | 🟢 | 录屏为帧序列(有 ffmpeg 则封 mp4) | v11 |
|
||||
| 24 | `gui.await` | 🟢 | 等条件成立再返回(四类条件 × 出现/消失)+ `any_of` 组合 | v1.6.0 |
|
||||
| 25 | `input.mouse.drag` | 🔴🔒 | 拖拽(拖文件 / 框选) | v11 |
|
||||
| 26 | `system.task.list` | 🟢 | 列出后台异步任务 | v10 |
|
||||
| 27 | `system.task.get` | 🟢 | 查询任务状态与输出(支持增量续读) | v10 |
|
||||
| 28 | `system.task.kill` | 🟡 | 终止任务(杀整棵进程树) | v10 |
|
||||
| 29 | `clip.get` | 🟡 | 读剪贴板(文本或图片 PNG) | v10 |
|
||||
| 30 | `clip.set` | 🟡 | 写剪贴板(文本或图片) | v10 |
|
||||
| 31 | `system.audit.verify` | 🟢 | 审计链完整性校验(哈希链,防篡改) | v11 |
|
||||
| 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 |
|
||||
| 37 | `event.list` | 🟢 | 列出当前订阅与事件数 | v12 |
|
||||
| 38 | `event.poll` | 🟢 | 拉取缓冲事件(供 MCP 等无推送通道的调用方) | v12 |
|
||||
| 39 | `system.net.apply` | 🔴 | 网络变更两阶段提交(改错自动回滚,不会失联) | v16 |
|
||||
| 40 | `system.net.confirm` | 🟢 | 确认网络变更(取消自动回滚) | v16 |
|
||||
| 41 | `system.net.status` | 🟢 | 网络现状 / 待确认变更 / 历史备份 | v16 |
|
||||
| 42 | `system.audio.get` | 🟢 | 读音量与静音状态(Core Audio COM) | v17 |
|
||||
| 43 | `system.audio.set` | 🟡 | 设音量 / 静音 | v17 |
|
||||
| 44 | `system.agent.restart` | 🔴 | 受控重启自身(借计划任务;**非管理员时自动降级**) | v7 / v2.0.0 |
|
||||
| 45 | `system.agent.update` | 🔴 | 拉取式自更新(校验 sha256,只需被控端能上网) | v19 |
|
||||
| 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/` 为准,能力数由单测守卫。
|
||||
|
||||
> 🔒 = 受 `allow_input` 开关管控,默认禁用。
|
||||
>
|
||||
@@ -710,7 +745,7 @@ nodeagent/
|
||||
│ └── install.ps1 # Windows 一键安装脚本
|
||||
├── docs/ # 文档
|
||||
├── README.md
|
||||
└── PRD.md
|
||||
└── PRD.md(已移入 docs/)
|
||||
```
|
||||
|
||||
| 包 | 职责 | 关键文件 |
|
||||
|
||||
@@ -83,8 +83,22 @@
|
||||
| 无法 7×24 盯 | 臣只在被调用时"看一眼",问题在两次调用之间发生就看不到 |
|
||||
| 应用层内部状态不可见 | 如 WorkBuddy 的"当前正在等哪个请求",臣只能从日志反推 |
|
||||
|
||||
**缓解**:可让被控端**持续采样并落盘**(如每分钟记录一次网络状态),事后由臣分析。
|
||||
本项目已有 `system.shell.exec` + `fs.write`,具备实现基础。
|
||||
**缓解(✅ 已于 v2.0.0 实现)**:`monitor.*` 定时采样并落盘,事后回看摘要 —— 正是为治
|
||||
这类间歇性问题而做(原先只写"具备实现基础",现已成为能力):
|
||||
|
||||
```bash
|
||||
# 对某端口连续采样(每 2 秒一针),中途无需一直连着
|
||||
nodeagent --node win invoke monitor.start --args '{"source":"port","target":"127.0.0.1:8765","interval_ms":2000,"id":"probe"}'
|
||||
# …过一段时间再回来…
|
||||
nodeagent --node win monitor report probe # 直接给出:断了几次 / 最长断多久 / 延迟值域
|
||||
```
|
||||
|
||||
配套能力:
|
||||
- `log.query` —— 日志**在被控端侧过滤**后只回匹配行,不必把几万行整份拉回
|
||||
- `system.audit.anchor` —— 定期把审计链头锚定到链外,防「有 root 者整链重写」
|
||||
|
||||
**仍未解决的**:应用层内部状态("当前在等哪个请求")依旧只能从日志反推 ——
|
||||
`monitor` 采的是**外部可观测量**,读不到进程内部语义。
|
||||
|
||||
---
|
||||
|
||||
@@ -159,10 +173,10 @@ WorkBuddy 解析代理慢 + 部分请求走了不通的代理链路 → `Respons
|
||||
|
||||
明确这条边界,才能既用好它,又不至于在关键故障时**因为"以为能远程修"而耽误时间**。
|
||||
|
||||
## 杀软误拦(主流杀软 / 杀软 / 360)
|
||||
## 杀软误拦(主流杀毒软件)
|
||||
|
||||
**现象**:远程接管类工具的典型特征(未知 exe + 建计划任务 + 开防火墙 + 常驻监听 + 注入键鼠/截屏)
|
||||
**必然**触发杀软拦截。真机实证:主流杀软会拦下 `install.ps1` 执行、计划任务注册,或直接处置 `node.exe`,
|
||||
**必然**触发杀软拦截。实测:主流杀软会拦下 `install.ps1` 执行、计划任务注册,或直接处置 `node.exe`,
|
||||
表现为「下载成功但装不上」或「装上后 agent 起不来」。
|
||||
|
||||
**逐项放行清单**(把下面这些都加进杀软的信任/白名单,而不是整机关防护):
|
||||
@@ -172,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 上跑,确认三件事:任务在不在、进程活不活、端口听没听):
|
||||
|
||||
@@ -245,3 +245,23 @@ Start-Process powershell -ArgumentList '... Start-Sleep 3; Stop-Process ...; Sta
|
||||
- **自持**:改得动别人,改不动自己(缺自愈与受控重启)
|
||||
|
||||
把这三块补上,它就从「远程命令行工具」进化为「**真正的远程操作代理**」。
|
||||
|
||||
---
|
||||
|
||||
## 更新:2026-10-10 —— 本文档列出的短板已全部解决
|
||||
|
||||
> 保留上文原判断(复盘写于 2026-09-28,19 项能力时期),此处只补「后来怎么解决的」,
|
||||
> 不改写历史结论 —— 复盘的价值在于能看到当时的判断与后来的差距。
|
||||
|
||||
| 复盘时的短板 | 解决于 | 现在长什么样 |
|
||||
|---|---|---|
|
||||
| **自持**:无法受控重启、更新要人上手 | 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 步全绿。
|
||||
|
||||
> 一条经验(本轮反复验证):**真机验证不可替代**。10-09 ~ 10-10 的真机验证一次性抓出
|
||||
> 8 个只有真机能暴露的 bug,其中最严重的两个(`install.cmd` 首装即炸、`where` 的 PS 注入
|
||||
> 语法错)在 macOS 上 100% 测不出来。详见 `ROADMAP.md` §九 的 bug 表。
|
||||
@@ -0,0 +1,447 @@
|
||||
# nodeagent 进度盘点与下一步路线
|
||||
|
||||
> 盘点日期:2026-10-10(**第四次更新**:语义/状态/安全三个方向全部落地后据实刷新)
|
||||
> 盘点方式:真机验证 + 本地跑单测 + `gh run list`(Windows 被控端曾于 10-10 00:15 离线)
|
||||
> 基线:**`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 脱敏约定)
|
||||
|
||||
---
|
||||
|
||||
## 一、结论先行
|
||||
|
||||
功能层面已基本收口。**43 项能力**覆盖了「命令接管 → 图形接管 → 零信任 → 文件传输 → Hub 中转 → 自更新」六条主线,安全加固已发版两批。
|
||||
|
||||
**方向一(语义可断言)已开工**:`gui.await` 在工作区完成实现(170 行 + 57 行 manifest + 注册),把 GUI 操作从「盲试」推进到「可等待、可断言」。**但尚未收尾** —— 详见 §4。
|
||||
|
||||
当前瓶颈:
|
||||
|
||||
| 性质 | 问题 | 影响 |
|
||||
|---|---|---|
|
||||
| **在建未收尾** | `gui.await` 已实现但**未提交、未发版、单测未覆盖、CI 未绿** | 能力数断言失败(见下),远端拿不到 |
|
||||
| **收尾缺口** | 本地 1 个 CI 提交未推送;`ci/workflow-unit-tests` 分支贡献已并入但分支未清理 | CI 护栏未生效于远端 |
|
||||
| **语义深度** | `gui.await` 解决了「等得到」,但 `screen.find` 仍不返回 UIA **属性**(value/enabled/selected) | 能等「元素出现」,还不能等「元素变成某状态」 |
|
||||
| **状态留存** | 事件订阅已通,但无采样留存与回看 | 间歇性问题(代理不通、杀软拦截)仍只能靠巧遇 |
|
||||
|
||||
优先级:**语义属性 > 状态采样 > 安全残余**(`gui.await` 与 CI 收尾均已完成,见 §九)。
|
||||
|
||||
> **历史备注(已消解)`:本文档第二版曾记录「单测 194/195 红一例」——那是能力数硬编码 42 未随 `gui.await` 更新所致,已改为集合断言思路并修复;该教训沉淀为 §十 的经验。
|
||||
|
||||
---
|
||||
|
||||
## 二、现状快照
|
||||
|
||||
### 2.1 版本与仓库
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| `package.json` version | `1.5.0`(root 及 6 个子包一致)—— **未随 `gui.await` 上调** |
|
||||
| 本地 HEAD | `7ad820c` ci: 补全 Windows 真机验证 |
|
||||
| 远端 main | `0d62cb1`(tag **`v1.5.0`**) |
|
||||
| 本地领先远端 | **1 个提交**(仅 `.github/workflows/windows-e2e.yml`,未推送) |
|
||||
| **未提交业务改动** | `gui.await` 全套:`apps/agent/src/capabilities/await.ts`(新增 170 行)/`capabilities/index.ts`(+3 注册)/`packages/protocol/.../names.ts`(+1 能力名)/`manifest/graphics.ts`(+57 行契约) |
|
||||
| 未合并分支 | `ci/workflow-unit-tests`(其贡献**已手工并入** `7ad820c`,分支本身基于 09-29 老 main,直接 merge 会回退上万行,故**只取内容不 merge**,分支可清理) |
|
||||
| 未跟踪文件 | `docs/ROADMAP.md`(本文档)、`docs/console-plan.md`(大前端方案) |
|
||||
| Release 资产 | `nodeagent-win-x64.zip`(≈32.9MB,含 Node 运行时)/ `agent.mjs` / `latest.json` / `SHA256SUMS` |
|
||||
|
||||
**近期提交脉络**:
|
||||
|
||||
```
|
||||
0d62cb1 (tag v1.5.0) chore(release): v1.5.0
|
||||
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.0): gui.await 等待条件 ← 在建
|
||||
```
|
||||
|
||||
### 2.2 代码与测试
|
||||
|
||||
| 项 | 数值 |
|
||||
|---|---|
|
||||
| `apps/` 代码量 | 12,201 行 TS(agent / cli / hub / mcp)+ `await.ts` 170 行(在建) |
|
||||
| `packages/` 代码量 | 3,620 行 TS(client / protocol)+ manifest 57 行(在建) |
|
||||
| 合计 | **约 1.58 万行** |
|
||||
| 单测 | 18 个文件 / **195 例:194 通过、1 失败**(本地实跑) |
|
||||
| e2e | 2 个套件(`run.mjs` 通用 29 例 / `windows.mjs` Windows 专属 13 例) |
|
||||
| **`gui.await` 覆盖** | **0** —— 单测、e2e、CLI、MCP 均无 `gui.await`/`GuiAwait` 命中 |
|
||||
|
||||
> ⚠️ 单测数从 162 增至 195(新增 33 例,来自 v1.5.0 快捷键的按键表覆盖断言),但**红了一例**:`tests/unit/capabilities.test.mjs:66` 的 `assert.equal(CAPABILITY_MANIFEST.length, 42)`,实际 43。**修法**:改为 43,或更稳妥地改成「断言每个 `CapabilityNames` 键都在 manifest 中」以免每加一能力都要改数。
|
||||
|
||||
**最大的三个文件**(后续拆分候选):
|
||||
|
||||
| 文件 | 行数 | 说明 |
|
||||
|---|---|---|
|
||||
| `apps/mcp/src/index.ts` | 1000 | MCP 工具定义全集中,协议前缀 `na_` |
|
||||
| `apps/agent/src/capabilities/window.ts` | 963 | UIA 枚举 + 模板匹配 + OCR 三条路径 |
|
||||
| `apps/agent/src/capabilities/network.ts` | 747 | 两阶段提交网络变更 |
|
||||
|
||||
### 2.3 能力清单(43 项,以 `packages/protocol/src/capabilities/names.ts` 为准)
|
||||
|
||||
| 域 | 能力 |
|
||||
|---|---|
|
||||
| **系统与命令**(5) | `system.info` `system.status` `system.process.list` `system.service.list` `system.shell.exec` |
|
||||
| **软件**(2) | `app.list` `app.install`(winget 静默优先) |
|
||||
| **屏幕与录制**(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.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` |
|
||||
| **审计与指标**(3) | `system.audit.list` `system.audit.verify`(哈希链) `system.metrics` |
|
||||
| **事件订阅**(4) | `event.watch` `event.unwatch` `event.list` `event.poll` |
|
||||
| **自持与更新**(2) | `system.agent.restart`(走计划任务) `system.agent.update`(拉取式,校验 sha256) |
|
||||
| **音频**(2) | `system.audio.get` `system.audio.set`(Core Audio COM) |
|
||||
| **网络变更**(3) | `system.net.apply` `system.net.confirm` `system.net.status` |
|
||||
|
||||
**`gui.await` 设计要点**(工作区在建,v1.6.0):
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 四类条件 | `window` / `control` / `process` / `file`,各自复用既有 `window.list` / `screen.find` / `process.list` / `fs.stat` —— **零新增平台探测代码**,Windows/macOS 行为与底层能力完全一致 |
|
||||
| 两个状态 | `state=present`(默认,等出现)/`absent`(等消失) |
|
||||
| 参数 | `timeout_ms`(0~60000,默认 5000)/`interval_ms`(50~5000,默认 400) |
|
||||
| 返回 | `{satisfied, condition, state, elapsed_ms, attempts, last_seen, last_error?, note?}` |
|
||||
| **超时是正常返回** | 返 `satisfied:false` + `last_seen` + `note`,**不抛异常** —— 避免上层反复重试。`last_error` 带回末次异常,用于区分「真的没有」与「每次都在报错」 |
|
||||
| 轮询期异常 | 视为「尚未命中」并记录,不让单次抖动整句失败(启动中的程序常短暂报错) |
|
||||
| 风控 | `risk: 'low'`(纯只读),`additionalProperties: false` |
|
||||
|
||||
典型编排:
|
||||
|
||||
```
|
||||
await gui.await(condition:"control", text:"完成", timeout_ms:30000)
|
||||
→ input.mouse.click(命中坐标)
|
||||
→ gui.await(condition:"control", text:"安装中", state:"absent")
|
||||
```
|
||||
|
||||
> v1.5.0 未新增能力名,而是**扩展了 `input.key.press` 的参数面**:字符串热键 `hotkey: "ctrl+shift+esc"`、约 50 个预设别名(`copy` / `show_desktop` / `task_manager` / `volume_mute_toggle`…)、`hold_ms` 长按、`route=post` + `target_pid` 后台定向投递;按键表补齐左右侧修饰键、小键盘、OEM 符号键(解锁 `win+d` / `ctrl++` / `win+.`)、IME 键。**这些是同一能力内的行为扩展,不改协议结构**。
|
||||
|
||||
安全机制(不计入能力数):挑战-应答握手、nonce 一次性、能力级 ACL(deny → allow → 默认拒绝)、调用限速、审计链、证书指纹钉住(TOFU)、来源网段白名单 `allow_from`、每客户端独立 PSK、发现广播最小化 + HMAC 签名、键鼠默认禁用。
|
||||
|
||||
---
|
||||
|
||||
## 三、与复盘文档的对照
|
||||
|
||||
`docs/RETROSPECTIVE.md` 写于 2026-09-28(当时 19 项能力)。逐项核对,**多数 P0/P1 已解决**,文档本身已过期:
|
||||
|
||||
| 复盘当时的判断 | 现状 | 落点 |
|
||||
|---|---|---|
|
||||
| **P0** UI 元素定位缺失 | ✅ 已解决 | `screen.find`(UIA → 模板匹配 → OCR 三级降级,返回 `engine`) |
|
||||
| **P0** 受控重启缺失 | ✅ 已解决 | `system.agent.restart`(借计划任务,任务名带时间戳) |
|
||||
| **P0** 编码未统一 UTF-8 | ✅ 已解决 | v7 UTF-8 全链路;`.ps1` 带 BOM / `.cmd` 纯 ASCII+CRLF / `.json` 无 BOM,三套规则有单测守卫 |
|
||||
| **P0** exec 输出污染 | ✅ 已解决 | 结构化 `{stdout, stderr, exit_code, duration_ms}` |
|
||||
| **P1** 长任务异步化 | ✅ 已解决 | `system.task.list/get/kill` |
|
||||
| **P1** 剪贴板 | ✅ 已解决 | `clip.get` / `clip.set`(软失败需重试 + 读回校验) |
|
||||
| **P1** 窗口管理 | ✅ 已解决 | `window.list` / `window.focus`(`window.rect` 无独立能力,rect 由 list 返回) |
|
||||
| **P1** CLI 无连接复用 | ✅ 已解决 | `apps/cli/src/daemon.ts` + `nodeagent daemon start` |
|
||||
| **P1** 定时采样 / 监控 | ❌ **未做** | 无 `monitor.*` 能力(grep 命中 0) |
|
||||
| **P1** GUI 操作需可断言 | 🚧 **在建** | `gui.await` 已实现未提交(等 window/control/process/file 出现或消失);属性条件与组合未做 |
|
||||
| **P2** 文件监控 `fs.watch` | ❌ **未做** | grep 命中 0 |
|
||||
| **P2** 日志聚合 `log.query` | ❌ **未做** | grep 命中 0 |
|
||||
| **P2** 录屏 | ✅ 已解决 | `screen.record` |
|
||||
| **P2** 键盘宏 / 鼠标拖拽 | ✅ 已解决 | `packages/client/src/macro.ts` + `input.mouse.drag` |
|
||||
| **P2** 并发 RPC | ⚠️ 部分 | WS 单连接仍串行批量;daemon 缓解了重连成本,未解决单连接并发 |
|
||||
| **P2** 多设备并行 | ❌ 未做 | 仍需 `--node` 串行切换 |
|
||||
|
||||
**结论**:复盘 §十的三个短板,「自持」已彻底解决,「语义」解决了一半(能找到,读不懂),「状态」只补了事件通知,采样留存仍是空白。
|
||||
|
||||
---
|
||||
|
||||
## 四、待办一:收尾
|
||||
|
||||
### 4.0 ⚠️ 推送前必须先脱敏(优先级高于一切)
|
||||
|
||||
第九轮隐私审查发现:**`0ee8231` 与 `1c836e0` 两个已推送提交都带真机痕迹**,其中设备软件清单已在公开面:
|
||||
|
||||
| 位置 | 内容性质 | 状态 |
|
||||
|---|---|---|
|
||||
| `0ee8231` 提交信息 | 真机内网地址 + 具体软件名(详见该提交) | **已推送** |
|
||||
| `1c836e0` → `apps/agent/src/capabilities/input.ts:145,146,163,410` | 注释含具体播放器名与游戏名 | **已在 origin/main** |
|
||||
| `1c836e0` → `apps/agent/src/capabilities/input-script.ts:29,32` | 同上 | **已在 origin/main** |
|
||||
|
||||
这构成**设备软件清单 + 使用习惯**的公开暴露(哪台机器装了哪些软件、常在什么场景下远程控它)。比单纯的 IP 泄露更难辩解 —— IP 会换,软件清单是稳定画像。
|
||||
|
||||
处置建议(按代价排序):
|
||||
|
||||
| 方案 | 代价 | 效果 |
|
||||
|---|---|---|
|
||||
| **A. 只清未来**(最低) | 0 | 新增代码与提交信息一律脱敏,历史不动。仓库公开仍有痕迹 |
|
||||
| **B. 清注释 + 清未来**(臣建议) | 半小时 | 源码注释泛化为「主流播放器」「全屏游戏」,不动历史 |
|
||||
| **C. B + 改写历史** | 需 `git filter-repo` + force push | 连历史一并清,但**旧 blob 已被匿名抓取过,改写不等于收回** |
|
||||
|
||||
> ⚠️ 关键认知:**已推送到公开仓库并被匿名抓取过的内容,改写历史无法收回**。旧 blob 只能靠 GitHub Support GC,而 Support GC 本身也非保证。因此现实目标应是「**止血 + 不再恶化**」,即方案 B。
|
||||
|
||||
**具体要改的注释**(脱敏后按此句式重写,勿照抄原词):
|
||||
|
||||
| 原文式(不复述) | 改为 |
|
||||
|---|---|
|
||||
| 「远程控制某播放器 / 某云音乐」 | 「远程控制主流媒体播放器」 |
|
||||
| 「某音乐会建 30+ 辅助窗口」 | 「部分播放器会创建大量辅助窗口」 |
|
||||
| 「某游戏全屏遮挡时」 | 「全屏独占应用遮挡时」 |
|
||||
|
||||
### 4.1 推送 CI 提交
|
||||
|
||||
- **现状**:本地 `7ad820c` 领先远端 1 个提交,仅改 `.github/workflows/windows-e2e.yml`(+61 行)。
|
||||
- **内容**(三段护栏,全部来自 10-04 真机事故复盘):
|
||||
1. **单测步骤** —— 把 `pnpm test:unit` 并入 Windows E2E 工作流。该内容取自 `ci/workflow-unit-tests` 分支(`4d73d6e`);因该分支基于 09-29 老 main,直接 merge 会**回退上万行**,故只取提交内容手工并入。
|
||||
2. **5.1 BOM 解析守卫** —— 刻意用 `cmd` 调 `powershell`(5.1)而非 `pwsh`。5.1 读无 BOM 的 `.ps1` 会按 ANSI/GBK 解析,中文注释破坏字符串边界 → 整份脚本 ParseError。此前全用 pwsh(7+ 认无 BOM UTF-8),所以**CI 绿、真机炸**。这道守卫只问一件事:5.1 能否读懂 —— 而这就是真机的核心约束。
|
||||
3. **install.cmd / control.cmd 双击链路冒烟** —— 这两个脚本是用户唯一上手入口,编码或语法坏了只有真机 cmd.exe 测得出。
|
||||
- **收尾**:推送后可清理 `ci/workflow-unit-tests` 分支(贡献已并入,留着只会误导)。
|
||||
|
||||
### 4.2 v1.5.0 真机验证尚未做(v1.5.0 提交信息自陈)
|
||||
|
||||
`1c836e0` 的提交信息里写明:
|
||||
|
||||
> ⚠️ **真机未验证**:Windows 被控端当前离线(连接超时)。待上线后需验……
|
||||
|
||||
即v1.5.0 是**未经真机验证就发的版**。待办:
|
||||
|
||||
```
|
||||
node scripts/verify.mjs --host <地址> --key <密钥> --report <报告.md>
|
||||
```
|
||||
|
||||
重点验四项(提交信息已列):字符串热键 `hotkey`、预设别名、长按 `hold_ms`、后台定向投递 `route=post`。其中后台定向投递需复验「窗口被遮挡时是否真有效」—— 这条机制此前只在媒体键通道验证过,等价推断需实测坐实。
|
||||
|
||||
### 4.3 大前端方案(`docs/console-plan.md`,已出文档待评审)
|
||||
|
||||
盘点期间新增了一份前端方案文档,臣不在此展开(详见该文档),仅提示它引入了**一个新包 `crates/nodeagent-client`(Rust)**〔**2026-10 已退役**:改为 Node sidecar 方案,见 `docs/console-plan.md` 文末「实施决策」〕,与现有 TypeScript 的 `@nodeagent/client` 并存 —— 意味着 client 层有两套实现。这本身是个需要皇上拍板的架构决策(详见该文档的待确认项)。
|
||||
|
||||
---
|
||||
|
||||
## 五、待办二:三个开发方向
|
||||
|
||||
### 方向一:语义 —— 从「能找到」到「可断言」
|
||||
|
||||
**判断**:`screen.find` 的能力边界已经够用,缺的是**返回值的信息量**与**操作后的状态断言**。原来的链条是「点 → 截图 → 目测 → 再点」。
|
||||
|
||||
**当前进度:第一步已落地(在建),第二步未做。**
|
||||
|
||||
| # | 改动 | 状态 | 说明 |
|
||||
|---|---|---|---|
|
||||
| 1 | **新增 `gui.await` 等待条件** | ✅ **已实现,未提交** | 四类条件(window/control/process/file)× 两态(present/absent);复用既有能力,零新增平台探测代码;超时**正常返回**不抛异常 |
|
||||
| 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 全部完成**)
|
||||
|
||||
| # | 事项 | 状态 |
|
||||
|---|---|---|
|
||||
| 1 | 修能力数断言(42→43) | ✅ 已改 43,并新增集合化断言思路 |
|
||||
| 2 | 补单测 | ✅ `tests/unit/gui-await.test.mjs` 6 例(登记/风险、四类条件 schema、上限兜底、isHit 五形态、CLI/MCP 入口存在性) |
|
||||
| 3 | 补 CLI / MCP 入口 | ✅ CLI `nodeagent await ...`(实测到连接层);MCP `na_await`(定义 + 分发) |
|
||||
| 4 | 版本号上调 v1.6.0 | ✅ 已发 |
|
||||
| 5 | 真机验证 | ✅ 本地真机 7 例(present/absent/超时/非法参数/process/底层报错不中断/缺省参数);**Windows 上验 control 条件待被控端在线** |
|
||||
| 6 | 发版 | ✅ v1.6.0 |
|
||||
|
||||
> Windows CI 上线后又连带修了 6 个真 bug(见 §九),其中 `install.cmd` 双击首装即炸属于**必发版**的修复。
|
||||
|
||||
#### `gui.await` 收尾清单(原第二版,提交前必做)
|
||||
|
||||
| # | 事项 | 说明 |
|
||||
|---|---|---|
|
||||
| 1 | **修能力数断言** | `tests/unit/capabilities.test.mjs:66` 硬编码 42 → 实际 43,**当前单测是红的**。建议改成「断言每个 `CapabilityNames` 键都在 manifest 中」,避免每加一能力都要改数 |
|
||||
| 2 | **补单测** | 当前 `gui.await` 覆盖为 0。至少覆盖:参数校验(非法 condition/state)、`timeout_ms` 上限裁剪、`isHit` 对不同返回形态的判定(`found` / `exists` / 数组 / 布尔)、超时返 `satisfied:false`、轮询期异常不中断 |
|
||||
| 3 | **补 CLI / MCP 入口** | grep 显示 `apps/cli/` 与 `apps/mcp/` **均无 `GuiAwait` 命中**。若 CLI 走通用 invoke 通道则无需改;若逐能力映射则必须补 |
|
||||
| 4 | **版本号上调** | `package.json` 仍 `1.5.0`,root + 6 子包。新增能力名 → MINOR → **v1.6.0** |
|
||||
| 5 | 真机验证 | 至少验一次「等窗口出现 → 点击 → 等元素消失」完整编排。`gui.await` 是**阻塞调用**,需确认 `timeout_ms` 上限(60s)不会撞上 WS 心跳/超时机制 |
|
||||
| 6 | 发版 | `node scripts/release.mjs` → v1.6.0 |
|
||||
|
||||
**验收标准**:
|
||||
|
||||
- 「等某窗口出现某控件」在设定 timeout 内命中,返回 `attempts` 与 `elapsed_ms`;
|
||||
- 条件永不满足时返回 `satisfied:false` + `last_seen`,**不抛异常**;
|
||||
- 条件本就「不存在」时立即返回(`attempts:1`),不等满 timeout。
|
||||
|
||||
#### 第二步:`screen.find` 属性化(后续)
|
||||
|
||||
这是方向一的**真正深水区**。`gui.await` 解决「等得到出现/消失」,但等不到「等它变成某状态」:
|
||||
|
||||
| 改动 | 内容 | 验收标准 |
|
||||
|---|---|---|
|
||||
| `screen.find` 返回 UIA 属性 | 在现有坐标之外补 `name` / `value` / `enabled` / `selected` / `rect` / `automation_id` | 对某个真实界面能读出 enabled 状态随播放状态变化 |
|
||||
| `gui.await` 支持属性条件 | predicate 可比较 `enabled` / `selected` / `value` | 能表达「按钮出现 **且** enabled」 |
|
||||
| 条件组合 | `and` / `or` / `not` | 能表达「弹窗出现 且 其中某按钮 enabled」 |
|
||||
|
||||
**为什么仍排第一**:GUI 接管是 v2 的核心价值,也是当前唯一还「不好用」的域。`gui.await` 只是把「盲试」变成「有耐心的盲试」—— 补上属性比较后,才真正变成可断言。
|
||||
|
||||
**风险**:`gui.await` 是**阻塞式调用**,必须有 `timeout_ms` 上限(已设 60s 上限 ✓);超时属正常返回(已实现 ✓)。第二个风险:`screen.find` 的 UIA 属性读取在部分应用上可能拿到空值(Electron / 游戏 UI 常见),需能优雅降级为「仅坐标 + found」。
|
||||
|
||||
### 方向二:状态 —— 治间歇性问题
|
||||
|
||||
**判断**:复盘 §四那条判断仍成立 —— 代理不通、杀软拦截都是**间歇性**的,而臣只能看当下快照。`event.watch` 解决「有事件时通知」,没解决「持续记录并回看」。
|
||||
|
||||
| 能力 | 设计 | 验收标准 |
|
||||
|---|---|---|
|
||||
| `monitor.start` | `{ interval_ms, command \| metric, duration_ms }` → 落盘,返回 `id` | 对某连接端口跑 5 分钟 / 每 2 秒采样,事后能拿到完整时间序列 |
|
||||
| `monitor.report` | `{ id }` → 返回采样序列 + 摘要(min/max/均值/异常点) | 能直接回答「5 分钟里断过几次、最长断了多久」 |
|
||||
| `log.query` | `{ path, since, level, pattern, limit }` —— **被控端侧过滤** | 10 万行日志中按 pattern 查 20 条,不整份拉回 |
|
||||
| `fs.watch` | `{ path, recursive, filter }` → 走事件订阅通道推送 | 监听配置文件变更,变更事件 ≤2s 内到达 |
|
||||
|
||||
**关键点**:`log.query` 必须过滤在 Windows 侧。当前 `fs.read` 虽有 8MB 上限,但拉大日志既慢又占带宽,治标不治本。
|
||||
|
||||
### 方向三:安全 —— 剩余四项
|
||||
|
||||
`docs/SECURITY.md` §5 自列的未做项:
|
||||
|
||||
| 项 | 现状 | 建议 |
|
||||
|---|---|---|
|
||||
| **握手失败封禁** | 只计数 + 记审计,无按 IP 拉黑 | 加指数退避封禁(如 5 分钟内失败 10 次 → 临时拉黑 1 小时),可配置白名单豁免 |
|
||||
| **会话上限与空闲断开** | 无最大连接数、无空闲超时 | 上限防资源耗尽;空闲断开(如 30 分钟无调用)释放资源 |
|
||||
| **安装期密钥传递** | `install.cmd` 用命令行 `-Key` 传参,**同机其他用户 `ps` 可见** | 改走 stdin 或临时文件(600 权限,读后即删)。这是唯一还没堵住的密钥暴露面 |
|
||||
| **审计链外部锚定** | 哈希链可被「有 root 的攻击者」整链重写 | 定期把链头哈希同步到独立位置(如 Hub 或另一台机器),重写即暴露 |
|
||||
|
||||
**臣另建议一项**:**ed25519 默认化**。`SECURITY.md` §2 已写明 PSK 模式的固有限制(`client_id` 自报 → ACL 身份维度形同虚设)。v22 的每客户端独立 PSK 只是缓解 —— 持有共享 key 的人仍可冒充未登记的 client_id。新装默认应走 ed25519,PSK 降级为「兼容存量」并在安装时明确告警。
|
||||
|
||||
---
|
||||
|
||||
## 六、待办三:隐私收敛(已连续多轮 P1,该决断)
|
||||
|
||||
### 6.1 已暴露且无法收回
|
||||
|
||||
| 项 | 状态 | 处置 |
|
||||
|---|---|---|
|
||||
| **设备软件清单已在公开面** | `input.ts` / `input-script.ts` 注释含具体播放器与游戏名(见 §4.0) | 泛化注释(方案 B);历史不追 |
|
||||
| **2 个含PII 的 blob 匿名可直取** | 旧提交中的 blob 用 40 位完整 SHA 即可匿名读取,已连续 6 轮未消;内容含本机用户名与真实目录名 | 报 GitHub Support 申请 GC,或删库重建。**这是唯一的「真泄露」** |
|
||||
| **提交信息含真机内网地址** | 5 条 commit message 含内网 IP 与端口 | 公开且被搜索引擎收录。后续禁止在 message 写真机地址 |
|
||||
| **Windows 账户名** | 硬编码在 `network.ts:96` | 改为从配置或环境变量读取 |
|
||||
| ~~网卡名与网段常量~~ | 测试与源码中多处 | ✅ 已完成:示例统一为文档段 `Ethernet` / `192.168.1.x`(见 §九) |
|
||||
|
||||
### 6.2 尚未暴露,立即处置
|
||||
|
||||
| 项 | 处置 |
|
||||
|---|---|
|
||||
| `.gitignore` 缺 `keys/`、`*.key`、`*.pem` | 立即补(当前已有 `**/PSK.txt`、`../win-backup/`) |
|
||||
| `input.ts:324` 附近含 3 个 U+FFFD 字面量 | 编码缺陷,顺手修 |
|
||||
|
||||
### 6.3 脱敏约定(本文档已遵守,后续继续遵守)
|
||||
|
||||
本文档不出现任何真机地址、端口、账户名、密钥,一律用 `<地址>` / `<密钥>` 占位。代码与测试中的真实值应逐步改为从配置或环境变量读取。
|
||||
|
||||
**新增约定**:提交信息与源码注释**不得出现具体软件名、设备名、真实地址**。举例子:「实测某播放器被某游戏全屏压住」→「实测主流播放器被全屏游戏遮挡」;真实内网地址→「控制端内网地址」。
|
||||
|
||||
> **教训**:本轮暴露的根源不是泄露了密钥(那类已按等式法核验并修复),而是**注释里写「为什么这么做」时顺手写了真实环境**。真实环境信息对理解原理无帮助,却直接画像了使用者。以后写注释,理由归理由,实况归实况。
|
||||
|
||||
---
|
||||
|
||||
## 七、建议排期
|
||||
|
||||
| 序 | 事项 | 预估 | 依赖 |
|
||||
|---|---|---|---|
|
||||
| 0 | **修 `gui.await` 能力数断言**(42→43,当前单测红) | 5 分钟 | 无(**提交前必做,否则 CI 必红**) |
|
||||
| 0.5 | **脱敏**:泛化 `input.ts` / `input-script.ts` 注释里的软件名 | 20 分钟 | 无(**先于一切推送**) |
|
||||
| 1 | `gui.await` 收尾:补单测 + CLI/MCP 入口 + 版本号 v1.6.0 + 真机验证 + 发版 | 1 天 | 序 0 |
|
||||
| 2 | 推送 CI 提交 + 清理 `ci/workflow-unit-tests` 分支 | 10 分钟 | 序 1 |
|
||||
| 3 | **v1.5.0 真机验证**(该版未经真机就发了) | 半天 | 被控端在线 |
|
||||
| 4 | 方向一第二步:`screen.find` 属性化 + `gui.await` 支持属性条件 + 条件组合 | 2~3 天 | 序 1 |
|
||||
| 5 | 方向三:`install.cmd` 密钥改 stdin + 握手封禁 + 会话上限 | 1~2 天 | 无(安全项建议提前插队) |
|
||||
| 6 | 方向二:`monitor.*` + `log.query` + `fs.watch` | 2~3 天 | 无 |
|
||||
| 7 | `.gitignore` 补密钥规则 + 源码真机常量注入化 | 分散 | 无 |
|
||||
| 8 | PII blob 报 Support GC / 决定是否重建仓库 | — | **需皇上决策** |
|
||||
| 9 | `ed25519` 默认化 | 1~2 天 | 需确认向后兼容策略 |
|
||||
| 10 | `console-plan.md` 评审:Rust client 与 TS client 双实现取舍 | — | **需皇上拍板** |
|
||||
|
||||
**臣的建议**:
|
||||
|
||||
- **序 0 立刻做**。5 分钟的事,但不做则单测红、CI 必红。注意建议顺手把断言改成「每个 `CapabilityNames` 键都在 manifest 中」——否则每加一能力都要改数字,这类「硬编码计数」会反复咬人。
|
||||
- **序 0.5 紧接着做**。只改注释,20 分钟,零风险,但能阻止「设备软件清单」继续扩散。`gui.await` 也正好是新代码,此时立脱敏规矩最自然。
|
||||
- **序 1 是当前主线**。`gui.await` 实现已完成(170 行 + 57 行契约 + 注册),设计也站得住(超时正常返回、轮询期异常不中断、复用既有能力零新增探测代码)。差的只是收尾四件事:单测、CLI/MCP 入口、版本号、真机验证。**建议一次做完再提交,不要分批**。
|
||||
- **序 3 优先级高于新功能**。v1.5.0 是**未经真机验证就发的版**,其中「后台定向投递」只在媒体键通道验证过、等价推断未经实测。
|
||||
- **序 5 可提前**。`install.cmd` 密钥经命令行传参(同机 `ps` 可见)是当下唯一未堵住的密钥暴露路径,改动局限在一个脚本内。
|
||||
- **序 8、10 需皇上决策**:前者关系到公开历史能否部分收回,后者关系到是否长期维护两套 client 实现。
|
||||
|
||||
> **一条经验**:`gui.await` 一次加能力就让单测红,原因是能力数被硬编码成 `42`。这类「计数断言」在持续加能力的项目里必然反复失效。**改为集合断言**(每个能力名都在 manifest 中)比每次改数字更省事,也更不容易漏检。
|
||||
|
||||
---
|
||||
|
||||
## 八、附:本文档的核查方法
|
||||
|
||||
| 结论 | 核查方式 |
|
||||
|---|---|
|
||||
| 版本与提交差距 | `git fetch` 后比 `git log origin/main..HEAD`(**必须先 fetch** —— 盘点期间远端已推进到 v1.5.0,本地缓存的 origin 是过期的) |
|
||||
| 能力清单 | 读 `packages/protocol/src/capabilities/names.ts` 全量枚举 |
|
||||
| 遗留能力未实现 | 对 `fs.watch` / `log.query` / `monitor.` / `serve` / `daemon` 逐个 grep 源码,命中 0 即未实现 |
|
||||
| 单测状态 | 本地 `node --test "tests/unit/**/*.test.mjs"` → **195 例:194 pass / 1 fail**(`capabilities.test.mjs:66` 能力数断言 42≠43) |
|
||||
| 复盘对照 | 逐条读 `docs/RETROSPECTIVE.md` 的 P0/P1/P2 表,与能力清单交叉 |
|
||||
| `gui.await` 完成度 | 分别 grep `tests/` `apps/cli/` `apps/mcp/` 找 `gui.await`/`GuiAwait`/`guiAwait` —— 三处均 0 命中 → 单测与两端入口都还没接|
|
||||
| 安全残余 | 读 `docs/SECURITY.md` §5「还没做的(后续批次)」 |
|
||||
| 公开面暴露 | `git fetch` 后 `git grep <关键词> origin/main` —— **必须先 fetch**(否则查的是过期快照),且**关键词要带空格变体**(`XX音乐` / `XX 音乐`),否则会漏 |
|
||||
|
||||
> **两条教训**:
|
||||
> 1. 跨平台差异(编码、文件重命名、进程语义)**必须在真机验证** —— macOS 通过不代表 Windows 通过。文档层面的盘点只能发现「没写」,发现不了「写错了但没人跑」。
|
||||
> 2. **`git grep` 不 fetch 会查旧快照**。本次差点把「未推送」误判成「未暴露」,实际远端已含该内容。凡涉及「公开面是否暴露」的结论,必须 `git fetch` 后再查,且关键词要带空格变体。
|
||||
|
||||
---
|
||||
|
||||
## 九、进展台账(2026-10-07 第三/四次盘点后完成)
|
||||
|
||||
### 10-09 ~ 10-10 追加(语义 / 状态 / 安全三方向收尾)
|
||||
|
||||
| 事项 | 状态 | 说明 |
|
||||
|---|---|---|
|
||||
| 语义方向:`screen.find` 读 UIA 属性 + `where` 谓词 + `gui.await` 组合 | ✅ | `enabled/value/selected/toggle`;拿不到留 `null` 不猜;`any_of` 组合;能力数 43→49 |
|
||||
| 状态方向:`log.query` + `monitor.*`(5 项) | ✅ | 日志被控端侧过滤;采样落盘 + 摘要回看(断几次/最长断多久/CPU·内存值域) |
|
||||
| 安全:连接层三防护(握手封禁 / 连接上限 / 空闲断开) | ✅ | 本地真机三场景验证(正确密钥可连 → 连续错误被拒 → 封禁期正确密钥也被拒) |
|
||||
| 安全:安装期密钥改 stdin | ✅ | `install.cmd` 管道喂 `-KeyFromStdin`,不再出现在命令行 |
|
||||
| 安全:`ed25519` 默认化 → **零信任优先安装** | ✅ | 有 `client-acl.json` 即走 ed25519(`default_effect=deny`);否则 psk + 醒目告警 |
|
||||
| 安全:**审计链外部锚定** | ✅ | `system.audit.head` + `system.audit.anchor`;四元组锚点防轮转误判、追加式防刷新抹痕;能力数 49→51 |
|
||||
| 自重启在**非管理员**身份下的降级 | ✅ | 真机坐实:`-RunLevel Highest` 被拒 → 回退不指定 Principal;`deploy` 由此首次一次走通 |
|
||||
| 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 个,全部只有真机能发现)
|
||||
|
||||
| # | bug | 性质 |
|
||||
|---|---|---|
|
||||
| 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 被自己入口校验拦下 | v2.0.0 只改了一半 |
|
||||
| 5 | 自重启在非管理员下失败 → `deploy` 永远"半成功" | v7 起的既有缺陷 |
|
||||
| 6 | `parseLeadingTs` 吃掉 ISO 的 `Z` 时区标记(偏 8 小时) | 单测抓到 |
|
||||
| 7 | manifest 承诺的 `note` 字段代码没实现 | 承诺未落地 |
|
||||
| 8 | 锚定动作自身写审计导致"紧随比对恒显示已增长" | 语义瑕疵(已说明) |
|
||||
|
||||
|
||||
| 事项 | 状态 | 说明 |
|
||||
|---|---|---|
|
||||
| `gui.await` 收尾(断言/单测/入口/版本号/本地真机/发版) | ✅ | v1.6.0 |
|
||||
| CI 工作流推送(需皇上授权 `workflow` 作用域) | ✅ | 皇上授权后推送成功 |
|
||||
| **Windows CI 上线后修 6 个真 bug** | ✅ | 见下表 |
|
||||
| 源码注释脱敏(序 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) | ✅ **已完成**(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` 默认化 | ✅ **已完成**(v2.0.0) | 「零信任优先安装」:有 client-acl.json 即走 ed25519(default_effect=deny) |
|
||||
| console-plan 评审(Rust client 取舍) | ⏳ 需皇上拍板 | — |
|
||||
|
||||
### Windows CI 扒出的 6 个真 bug(均已修复,v1.6.x)
|
||||
|
||||
| # | bug | 为什么 macOS 测不出 |
|
||||
|---|---|---|
|
||||
| 1 | 单测整体 ENOENT:`new URL(..., import.meta.url).pathname` 在 Windows 得 `/D:/a/...` | macOS 是 `/Users/...`。改用 `fileURLToPath` |
|
||||
| 2 | BOM 守卫步骤自身坏:`shell: cmd` 套多行 `powershell -Command` | cmd 转义必乱;改为 `shell: powershell` + `run: |` |
|
||||
| 3 | **CI 内联脚本中文**:run 块无 BOM,5.1 按 GBK 读 → 引号边界崩 | pwsh 7+ 认无 BOM UTF-8,所以此前没暴露 |
|
||||
| 4 | **`install.cmd` 双击首装即炸**:`for /f` 调 PS 生成密钥,`^|` 漏进 PowerShell | PSK.txt 已移出 zip → 首装必走此路。改为交给 `install.ps1` |
|
||||
| 5 | `pack.mjs` 三连:spawn `.bin/esbuild` ENOENT / `zip` 不存在 / `ls -la`+`du -sh` | macOS 三者都有。改 esbuild JS API + `Compress-Archive` 分流 + Node 原生 |
|
||||
| 6 | CI 断言**方向反了**:必含清单列 PSK.txt,而它早被故意剔除 | 改为反向断言「不得含」 |
|
||||
|
||||
### 新增铁律(已写入 `.workbuddy/memory/MEMORY.md`)
|
||||
|
||||
1. `new URL(x, import.meta.url).pathname` 禁止拼路径(Windows 盘符多一根斜杠)→ 一律 `fileURLToPath`
|
||||
2. **5.1 会执行的脚本必须 BOM 或纯 ASCII** —— 包括 GitHub Actions 的 `shell: powershell` run 块(**也无 BOM**),中文只放步骤名
|
||||
3. 绝不用 `shell: cmd` 套多行 `powershell -Command`;复杂逻辑上移到 `.ps1`
|
||||
4. 不在 cmd 里用 `for /f` 调 PowerShell 做正事(`^|` 会漏进 PS)
|
||||
5. `.cmd` 纯 ASCII + CRLF;`.ps1` UTF-8 BOM;`.json` 无 BOM
|
||||
6. 发版/打包脚本不得假设 unix 命令存在(`zip`/`ls`/`du`/`node_modules/.bin/*`)
|
||||
7. CI 断言会随属性变化过期,且方向可能反了
|
||||
8. 工作区有他人未提交文件时禁用 `git add -A` / `commit -am`
|
||||
@@ -116,16 +116,106 @@ nodeagent keygen --psk --id mac_02
|
||||
| 未登记的 client_id 用共享 `key` | ✅ 通过(**旧部署行为不变**) |
|
||||
| 已登记的 client_id 用共享 `key` | ❌ 拒绝(**专属钥匙优先**,不被共享 key 绕过) |
|
||||
|
||||
## 4.7 零信任优先安装(v2.0.0)
|
||||
|
||||
安装时的认证模式选择,从「默认 psk + 可选 ed25519」改为**「能拿到控制端公钥就走 ed25519」**:
|
||||
|
||||
```bash
|
||||
# 控制端(Mac)
|
||||
nodeagent keygen --id mac_01 # 输出一段 JSON(acl.clients 的一项)
|
||||
# 把它保存为 client-acl.json,放在被控端安装包同目录,然后:
|
||||
# 双击 install.cmd(自动探测 client-acl.json)
|
||||
```
|
||||
|
||||
行为:
|
||||
|
||||
| 条件 | `auth_mode` | 写入的配置 | 输出 |
|
||||
|---|---|---|---|
|
||||
| 侦测到 `client-acl.json`(含 client_id + pubkey) | **ed25519** | `acl.default_effect="deny"` + `clients=[…]` | 绿色确认 + `--auth-mode ed25519` 连接示例 |
|
||||
| 无该文件,或缺 client_id/pubkey | psk | 不写 acl | **醒目黄色告警**(说明 `client_id` 可自报的风险 + 三步切换指引) |
|
||||
|
||||
> **为什么不是直接改默认值为 ed25519**:ed25519 的**私钥必须在控制端**,被控端安装时
|
||||
> 拿不到公钥。硬改默认值会让新装的控制端连不上(还要手工改配置回退),比"有条件就走零信任
|
||||
> + 无条件时明确告警"更糟。所以这里的"默认"是**优先顺序**,而不是死值。
|
||||
>
|
||||
> 真机验证(2026-10-09):CASE A 有文件 → `ed25519` + `default_effect=deny` + clients 完整;
|
||||
> CASE B 无文件 → `psk` 且**不写 acl 字段**;CASE C 缺 pubkey → 告警并回退 psk。
|
||||
|
||||
## 4.8 审计链外部锚定(v2.0.0)
|
||||
|
||||
**问题**:链内哈希只能发现"改一条"(其后全部需重算)。「有 root 的攻击者」可以
|
||||
**整链重写** —— 从零重建一份自洽的假日志,`system.audit.verify` 照样通过。
|
||||
|
||||
**做法**:把链头记到**链外**,攻击者改不了已经落到别处的历史记录。
|
||||
|
||||
```bash
|
||||
nodeagent --node win audit head --compare # 看链头 + 与最近锚点比对
|
||||
nodeagent --node win audit anchor --note "每日例行" # 追加一个锚点
|
||||
```
|
||||
|
||||
| 能力 | 风险 | 说明 |
|
||||
|---|---|---|
|
||||
| `system.audit.head` | low | 返回 `{entries, head_hash, head_ts, rotated_segments, file_bytes}`;`compare=true` 附比对结论 |
|
||||
| `system.audit.anchor` | medium | 把链头**追加**写入锚点文件(默认 `<数据目录>/audit-anchors.jsonl`;指定 path 时受 `fs_roots` 约束) |
|
||||
|
||||
**两个关键设计**:
|
||||
|
||||
1. **锚点不能只记 `head_hash`** —— 日志轮转会丢弃最旧段使条目数下降,只记哈希会把
|
||||
**轮转误判成篡改**。故记四元组 `{entries, head_hash, head_ts, rotated_segments}`,
|
||||
比对时先按轮转对齐再判尾部。三条判定:
|
||||
- 条目数减少且无轮转 → **疑似整链重写**(`ok:false`)
|
||||
- 条目数与段数都没变但哈希不同 → **该段被重写**(`ok:false`)
|
||||
- 条目数增长 / 发生轮转 → 锚点仍在链上(`ok:true`,附 detail 供人工核对)
|
||||
2. **追加式而非覆盖式** —— 若锚点可覆盖,攻击者重写链后再"刷新"锚点即可抹掉痕迹。
|
||||
|
||||
**已知行为**:锚定调用自身也会写一条审计(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` 巡检;若把锚点文件指向**另一台机器/网盘同步目录**,
|
||||
外部锚定的价值才真正落地(本机文件被整盘重写时锚点也随之消失)。
|
||||
|
||||
## 5. 还没做的(后续批次)
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| ~~发现报文最小化~~ | ✅ **v22 已做**(见 §4.5) |
|
||||
| ed25519 默认化 | 新装仍默认 psk;v22 提供了更轻量的替代:`keys{}` 每客户端一把 PSK(见 §4.6) |
|
||||
| 握手失败封禁 | 目前只计数 + 审计,无按 IP 的临时拉黑 |
|
||||
| 审计链签名 | 哈希链可被「有 root 的攻击者」整链重写,缺外部锚定 |
|
||||
| 会话上限 | 无最大连接数与空闲断开 |
|
||||
| 安装期密钥传递 | `install.cmd` 用命令行 `-Key` 传参,同机其他用户 `ps` 可见 |
|
||||
| ~~ed25519 默认化~~ | ✅ **v2.0.0 已做**(零信任优先安装,见 §4.7);另有 v22 的 `keys{}` 每客户端一把 PSK 作为轻量替代 |
|
||||
| ~~握手失败封禁~~ | ✅ **v2.0.0 已做**:连续失败达阈值 → 指数退避封禁(封顶 24h),成功即清零,支持白名单豁免 |
|
||||
| ~~会话上限~~ | ✅ **v2.0.0 已做**:并发上限 8 + 30 分钟空闲断开(有任务/订阅在跑时保守不踢) |
|
||||
| ~~安装期密钥传递~~ | ✅ **v2.0.0 已做**:`install.cmd` 改用管道喂 stdin(`-KeyFromStdin`),密钥不再出现在命令行 |
|
||||
| ~~审计链外部锚定~~ | ✅ **v2.0.0 已做**(见 §4.8):链头可读 + 锚点追加落盘 + 锚点比对 |
|
||||
|
||||
## 6. 应急处置
|
||||
|
||||
|
||||
@@ -3,6 +3,50 @@
|
||||
> 为什么需要这份文档:agent 与 CLI/MCP 是**分开更新的**(CLI 随控制端装,agent 推/拉到被控端),
|
||||
> 新旧混用是常态而非例外。没有明文契约,升级顺序、什么算破坏性改动、出问题怎么定位全靠猜。
|
||||
|
||||
## 0. 版本号唯一性规范(2026-10-10 确立)
|
||||
|
||||
> **背景**:项目早期同时存在两套编号 —— 发布版本(`v1.1.0`/`v2.0.0`)与开发批次代号(`v1`~`v26`)。
|
||||
> 二者**从来不是一对一**(例:`v1.1.0` 首发时批次已推进到 `v12.4`),并列出现时无法判断哪个是"当前版本"。
|
||||
> 自 v2.0.0 起统一为下列规则。
|
||||
|
||||
### 唯一版本号
|
||||
|
||||
**`vX.Y.Z`(semver)是项目唯一的版本标识**,两个来源必须永远一致:
|
||||
|
||||
| 来源 | 位置 | 谁在读 |
|
||||
|---|---|---|
|
||||
| git tag | `v2.0.0` | 人 / GitHub Release |
|
||||
| `package.json` 的 `version` | 全部 workspace + `tauri.conf.json` | 构建期经 esbuild `--define` 注入 agent |
|
||||
|
||||
```bash
|
||||
nodeagent info # 被控端自报的 version 即来自这里
|
||||
git tag -l # 发布历史,唯一权威
|
||||
```
|
||||
|
||||
### 开发批次代号(已并入发布版本)
|
||||
|
||||
`v1`~`v26` 是**开发阶段的内部代号**(一批相关改动的集合),**不是版本号,也不再单独出现**:
|
||||
|
||||
| 批次 | 内容 | **归属发布版本** |
|
||||
|---|---|---|
|
||||
| v1 ~ v12.4 | 命令级接管 → 事件订阅 / GUI 宏 / Hub 并发(首发前的全部积累) | **v1.1.0** |
|
||||
| v12.2 ~ v15 | 等待语义 / macOS GUI 对齐 / 常驻助手 / 图像模板 | v1.2.1 ~ v1.4.x |
|
||||
| v16 ~ v18 | 网络两阶段提交 / 音频 / 构建指纹 | v1.4.x |
|
||||
| v19 ~ v22 | 拉取式自更新 / 证书钉住 / 每客户端 PSK / 发现最小化 | v1.5.0 ~ v1.6.0 |
|
||||
| v23 ~ v26、语义属性化、状态采样 | 连接层三防护 / 零信任安装 / 审计锚定 / UIA 属性 / monitor | **v2.0.0** |
|
||||
|
||||
> 上表按 tag 时间线与功能内容推定,**近似**而非精确(批次与发布本无严格边界)。
|
||||
> 判断"某个改动属于哪个版本"时,**以 CHANGELOG 的版本段落为准**,不以批次号为准。
|
||||
|
||||
### 书写规则(新代码 / 新文档)
|
||||
|
||||
| 场景 | 写法 | 例 |
|
||||
|---|---|---|
|
||||
| 引用发布版本 | `vX.Y.Z` | `v2.0.0 新增 gui.await` |
|
||||
| 需要区分同一版本内的多批改动 | 用**功能名**,不用批次号 | `审计锚定(v2.0.0)` |
|
||||
| 历史注释中的批次号 | 保留但**不得**再新增;见上表归属 | `v16:网络两阶段提交` |
|
||||
|
||||
|
||||
## 1. 版本号
|
||||
|
||||
- 单一来源:**根 `package.json` 的 `version`**,构建时经 esbuild `--define` 注入 agent。
|
||||
|
||||