Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f85acad5db | ||
|
|
f558e3e7df | ||
|
|
87b74fd015 | ||
|
|
b2404291b0 | ||
|
|
e816c1fd39 | ||
|
|
a6be917637 | ||
|
|
8664ec2926 | ||
|
|
22bc732334 | ||
|
|
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 | ||
|
|
5e08a2768b | ||
|
|
79a0c15214 | ||
|
|
5571cf1aea | ||
|
|
2eb33587fe | ||
|
|
7037c7ff94 | ||
|
|
689bb8c98d | ||
|
|
9d32592ed1 | ||
|
|
3ff8e86fe8 | ||
|
|
7689a1f80c | ||
|
|
6c650244bf | ||
|
|
a56d67c012 | ||
|
|
4106ada876 | ||
|
|
f88738d36d | ||
|
|
50ac4b9646 | ||
|
|
e841fbb187 | ||
|
|
159ae2bf6f | ||
|
|
0d59d6032b | ||
|
|
3cbac53308 | ||
|
|
045e3b08b1 | ||
|
|
303cbc7436 | ||
|
|
1c26dc4e3f | ||
|
|
6faf807108 | ||
|
|
21d568d90d | ||
|
|
cedc46d090 | ||
|
|
aa72241080 | ||
|
|
e95a0248da | ||
|
|
e6a9a3d7ea | ||
|
|
9853ee8cdb | ||
|
|
aaf0175e94 | ||
|
|
edc184037b | ||
|
|
4c6190285c | ||
|
|
caa5b8a2c1 | ||
|
|
411a483c76 | ||
|
|
9ef8978b92 | ||
|
|
e955b6a2f7 | ||
|
|
6afec163f3 | ||
|
|
553b16f595 | ||
|
|
8ee8dc7f41 | ||
|
|
92df08eb20 | ||
|
|
0dcf01ad4e | ||
|
|
87d7f3fbcc | ||
|
|
8743bd1237 | ||
|
|
0f8b878de0 | ||
|
|
4d1aeada07 | ||
|
|
82a44bb13f | ||
|
|
d56556d3c3 | ||
|
|
a892b113ca | ||
|
|
cd774ae0a6 | ||
|
|
66c44f6822 | ||
|
|
3ed117770b | ||
|
|
0a47c5a92d | ||
|
|
7efe717cf8 |
No files matched your search
@@ -0,0 +1,104 @@
|
||||
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/**'
|
||||
- 'scripts/prepare-console.mjs'
|
||||
- '.github/workflows/rust.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'apps/console/src-tauri/**'
|
||||
- 'apps/console/sidecar/**'
|
||||
- 'scripts/prepare-console.mjs'
|
||||
- '.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
|
||||
|
||||
# 随包分发的 Node 运行时由该脚本准备;它同时产出「构建期注入片段」。
|
||||
# 在此真跑一遍:既验证脚本本身可用(含产物自检:node 必须真能执行),
|
||||
# 也验证注入片段声明的两份 resource 确实就绪。
|
||||
# 注意 Linux 上不会走 strip 分支(那是 macOS 专属,见脚本内注释),
|
||||
# 故「strip 后必须 adhoc 重签」这条由 macOS 本机实测覆盖,CI 不重复验证。
|
||||
- name: 准备 Node 运行时(验证 prepare-console.mjs)
|
||||
run: pnpm --filter nodeagent-console prepare:node
|
||||
|
||||
- name: 注入片段应声明两份资源
|
||||
run: |
|
||||
node -e '
|
||||
const g = require("./apps/console/src-tauri/tauri.bundle.gen.json");
|
||||
const r = g.bundle.resources;
|
||||
const keys = Object.keys(r);
|
||||
if (keys.length !== 2) { console.error("资源项应为 2,实际", keys); process.exit(1); }
|
||||
console.log("✓", JSON.stringify(r));
|
||||
'
|
||||
|
||||
# 更强的一道:不是「文件在不在」,而是「产物能不能自举」——
|
||||
# 真 spawn 打包内的 node 跑 sidecar 并走一次 JSON-RPC 握手。
|
||||
# 这类问题只在产物中暴露(源码与单测都正常)。
|
||||
- name: 验证运行时 + sidecar 可自举
|
||||
run: node apps/console/scripts/verify-bundle.mjs --runtime
|
||||
|
||||
- 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
|
||||
@@ -49,31 +49,127 @@ jobs:
|
||||
- name: 类型检查
|
||||
run: pnpm typecheck
|
||||
|
||||
# 控制台「随包分发的 Node 运行时」在 Windows 上的分支此前零覆盖
|
||||
# (准备脚本本机只在 macOS 上跑过)。此处真跑:复制 node.exe(无 strip 分支)→
|
||||
# 自检可执行 → 再验证它与 sidecar 能否走通一次 JSON-RPC 自举。
|
||||
# ⚠️ 必须先 sidecar:build —— prepare-console.mjs 的前置检查要求 sidecar.mjs 存在
|
||||
# (首次推送时漏了这步,CI 在 Windows 上当场报「未找到 sidecar/dist/sidecar.mjs」)。
|
||||
- name: 控制台 Node 运行时(Windows 分支)与自举验证
|
||||
run: |
|
||||
pnpm --filter nodeagent-console sidecar:build
|
||||
pnpm --filter nodeagent-console prepare:node
|
||||
node apps/console/scripts/verify-bundle.mjs --runtime
|
||||
|
||||
- name: 单元测试 — 协议纯函数 / 清单守护 / 脚本生成
|
||||
# 来自 ci/workflow-unit-tests 分支(4d73d6e)的实质贡献;该分支基于老 main,
|
||||
# 直接 merge 会回退上万行,故只取其提交内容手工并入。
|
||||
run: node --test "tests/unit/**/*.test.mjs"
|
||||
|
||||
- name: E2E — 协议与跨平台能力
|
||||
run: node tests/e2e/run.mjs
|
||||
|
||||
- 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: 冒烟 — 双架构包(node.exe PE 架构校验)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
# 真机教训(Parallels 27 + Win11 ARM):曾出现 arm64 包里装的其实是 x64 的 node.exe
|
||||
# —— 两份包的 node.exe 字节完全相同。x64 node 在 ARM Windows 上**能 listen,
|
||||
# 但 TLS 握手不完成**(客户端只见超时)→ 被控端实际不可用,且 macOS 侧 100% 测不出。
|
||||
# 根因是 pack.mjs 的运行时缓存文件名不含架构。这里直接验**最终产物**的 PE 架构,
|
||||
# 比只验脚本内部逻辑更靠近交付物(缓存串包 / 手工塞错文件都会被这一步拦下)。
|
||||
# 校验实现与发版脚本共用(scripts/lib/zip-pe-arch.mjs)—— 避免两套实现各自漂移。
|
||||
foreach ($arch in @('x64','arm64')) {
|
||||
node scripts/pack.mjs --arch $arch
|
||||
}
|
||||
foreach ($arch in @('x64','arm64')) {
|
||||
node scripts/lib/zip-pe-arch.mjs "release/nodeagent-win-$arch.zip" $arch
|
||||
if ($LASTEXITCODE -ne 0) { throw "node.exe 架构校验失败($arch)—— 严禁发布该包" }
|
||||
}
|
||||
- name: 冒烟 — 被控端监听
|
||||
shell: pwsh
|
||||
run: |
|
||||
@@ -108,7 +204,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
|
||||
@@ -18,6 +18,10 @@ pnpm-debug.log*
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# 预共享密钥:install.cmd 在包内生成,绝不入库
|
||||
PSK.txt
|
||||
**/PSK.txt
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -36,3 +40,17 @@ release/
|
||||
# 重装备份数据(含设备软件清单,不入库)
|
||||
../win-backup/
|
||||
win-backup/
|
||||
|
||||
# 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,232 @@
|
||||
# 变更日志
|
||||
|
||||
本文件记录 nodeagent 的显著变更。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
|
||||
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
||||
|
||||
---
|
||||
|
||||
## [未发布]
|
||||
|
||||
(暂无)
|
||||
|
||||
---
|
||||
|
||||
## [v2.0.2] —— 2026-10-11
|
||||
|
||||
> 一轮真机验证(Parallels 27 + Win11 ARM)抓出的**发布事故**修复批次。
|
||||
> 已公开发布的 `nodeagent-win-arm64.zip` 内装的其实是 **x64 的 `node.exe`** ——
|
||||
> 该包在 ARM Windows 上能启动、能 listen,却 **TLS 握手不完成**(控制端只见 `E_TIMEOUT`),
|
||||
> 实际不可用。本版重出资产,并把「交付物架构」直接钉进发版脚本与 CI。
|
||||
|
||||
### 新增
|
||||
|
||||
- **控制台自带 Node 运行时,目标机器无需预装 Node**(`pnpm --filter nodeagent-console bundle`)
|
||||
—— 控制台的 sidecar 是 `.mjs`,此前只能在装了 Node 的机器上运行(`resolve_node` 回退系统 node)。
|
||||
现由 `scripts/prepare-console.mjs` 把 Node 运行时准备为 Tauri resource 随包分发。
|
||||
实测:`.app` **90.8 MiB**(含运行时 86MB)/`.dmg` **33.78 MiB**(分发件,仍低于 40MB 目标)。
|
||||
作为对照,本机实测 Electron 运行时本体(`Electron Framework.framework`)为 207MB ——
|
||||
即 Tauri + Node 的运行时开销不到 Electron 的一半。
|
||||
- **`apps/console/scripts/verify-bundle.mjs`** —— 验证「打包产物能否自举」:真 spawn 打包内的 node
|
||||
去跑打包内的 sidecar,并走一次真实 JSON-RPC 握手。源码与单测都正常、只在产物中暴露的问题靠它拦下。
|
||||
|
||||
### 修复 / 规避(两处实测踩出来的约束)
|
||||
|
||||
- **macOS 上 `strip` 会使 Mach-O 代码签名失效 → 内核直接 `SIGKILL`(exit 137)**,
|
||||
且没有任何有用报错(不是警告、不是异常,就是静默无法执行)。
|
||||
实测对照:原样复制 ✓ / strip 不重签 ✗(137) / strip + `codesign --force --sign -` ✓。
|
||||
故准备脚本在 strip 之后**必须** adhoc 重签(省 20% 体积值得,但这步不能少)。
|
||||
- **Node 运行时不得声明进默认 `tauri.conf.json`** —— `bundle.resources` 是**编译期契约**
|
||||
(`tauri-build` 校验声明的文件必须存在),声明「运行期才就绪」的资源会让 `cargo check/test`
|
||||
在 build.rs 阶段直接失败。一律走 `tauri build --config` 注入。
|
||||
|
||||
### 修复 —— 被控端打包(ARM64)
|
||||
|
||||
- **`nodeagent-win-arm64.zip` 里装的其实是 x64 的 `node.exe`**。
|
||||
`pack.mjs` 下载 Node 运行时的缓存文件名是固定的 `node-runtime.zip`(**不含架构**):
|
||||
`release.mjs` 先打 x64 时把它缓存下来,随后打 arm64 时误判「已缓存」直接复用
|
||||
→ **两份公开包内的 `node.exe` 字节完全相同**(sha256 同为 `fdddbf45…`,PE Machine = `0x8664`)。
|
||||
后果极隐蔽:该包在 ARM Windows 上**能启动、能 listen**,但 **TLS 握手不完成**
|
||||
(控制端只见 `E_TIMEOUT`)—— 被控端实际不可用,而 macOS 侧 100% 复现不出来。
|
||||
修法:① 缓存文件名带上架构(`node-runtime-<arch>.zip`);
|
||||
② 解包后**断言 `node.exe` 的 PE Machine 与 `--arch` 一致**(缓存串包 / 手工塞错文件当场红)。
|
||||
- **CI 补「双架构包 PE 架构校验」**(`.github/workflows/windows-e2e.yml`)——
|
||||
此前 CI 只跑 `pnpm pack:win`(默认 x64)且只校验 x64 包,**arm64 打包零覆盖**,
|
||||
故该 bug 必然逃过 CI。现两条架构都打,并直接读最终 zip 内 `node.exe` 的 PE 头比对。
|
||||
- **发版脚本补「交付物架构断言」**(`scripts/lib/zip-pe-arch.mjs`)——
|
||||
`release.mjs` 生成资产前,直接解出**最终 zip 内**的 `node.exe`、读 PE 头比对架构,
|
||||
不符即**中止发版**。与 `pack.mjs` 的断言刻意分两层:后者是打包**流程内部**的自洽,
|
||||
这里钉的是**交付物本身**的性质。纯内置 `zlib` 实现,不依赖 `unzip` / `Expand-Archive`
|
||||
(Windows runner 上没有 `unzip`),另留 CLI 自检入口:
|
||||
`node scripts/lib/zip-pe-arch.mjs <zip路径> <x64|arm64>`(已注入故障实测会红)。
|
||||
|
||||
### 真机实测(ARM64 Windows 虚拟机)
|
||||
|
||||
- **arm64 包**:TLS 握手成功 → PSK 认证 → 声明 51 项能力(两端一致)→
|
||||
`system.info` / `status` / `process.list` / `service.list` / `shell.exec` /
|
||||
`audit verify` 全部正常,`arch` 报告为 `arm64`(原生,非模拟层)。
|
||||
- **x64 包(对照,同机同参)**:`connect` 报 `E_TIMEOUT` —— **复现**了「能 listen 但握手不完成」。
|
||||
→ 证实「按架构出包」不是理论洁癖,而是 ARM Windows 上的硬需求。
|
||||
- 已知边界:被控端若以 **SYSTEM(会话 0)** 手动启动,`screen.capture` 等图形能力会失败
|
||||
(会话 0 无交互桌面)。正式安装路径不受影响 —— `install.ps1` 的计划任务 principal
|
||||
用的是登录用户 + `LogonType Interactive`,注释已明写「图形能力可用」。
|
||||
|
||||
---
|
||||
|
||||
## [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 已验证字符串热键与单键投递,其余项待被控端在线后补齐。
|
||||
@@ -20,17 +20,155 @@ nodeagent 把一台 Windows 机器的能力**标准化成一组可授权、可
|
||||
|
||||
| 分组 | 能力 | 说明 |
|
||||
|---|---|---|
|
||||
| **系统** | `system.info` · `system.status` · `system.process.list` · `system.service.list` · `system.shell.exec` · `system.audit.list` · `system.audit.verify` | 信息 / 资源 / 进程 / 服务 / 命令 / 审计 / **审计防篡改校验** |
|
||||
| **系统** | `system.info` · `system.status` · `system.process.list` · `system.service.list` · `system.shell.exec` · `system.audit.list` · `system.audit.verify` · `system.metrics` | 信息 / 资源 / 进程 / 服务 / 命令 / 审计 / **防篡改校验** / **成功指标(PRD 2.2)** |
|
||||
| **自持** | `system.agent.restart` | **受控重启自身**(配置变更后让它生效,不会失联) |
|
||||
| **异步任务** | `system.task.list` · `system.task.get` · `system.task.kill` | 长命令后台执行 + 增量续读 + 终止(`exec` 加 `async:true`) |
|
||||
| **软件** | `app.list` · `app.install` | 已装软件(注册表 + winget)、winget 静默安装 |
|
||||
| **屏幕** | `screen.info` · `screen.capture` · `screen.record` · `screen.find` | 显示器 / 截屏 / **录屏为帧序列** / **按名字找元素取坐标** |
|
||||
| **窗口** | `window.list` · `window.focus` | 枚举可见窗口(精确矩形)/ 置前聚焦 |
|
||||
| **屏幕** | `screen.info` · `screen.capture` · `screen.record` · `screen.find` | 显示器 / 截屏 / **录屏为帧序列** / **元素定位三引擎**(UIA 控件树 → OCR 文字 → **图像模板**) |
|
||||
| **窗口** | `window.list` · `window.focus` | 枚举可见窗口(精确矩形)/ 置前聚焦(**Windows + macOS**) |
|
||||
| **文件** | `fs.list` · `fs.stat` · `fs.read` · `fs.write` | 列目录 / 元信息 / 分块读 / 原子写 |
|
||||
| **输入** | `input.mouse.move` · `input.mouse.click` · `input.mouse.scroll` · `input.mouse.drag` · `input.key.type` · `input.key.press` | 键鼠控制(🔒 **默认禁用**),含**拖拽**与**按键序列/连按** |
|
||||
| **输入** | `input.mouse.move` · `input.mouse.click` · `input.mouse.scroll` · `input.mouse.drag` · `input.key.type` · `input.key.press` | 键鼠控制(🔒 **默认禁用**),含**拖拽**、**字符串热键 / 预设 / 长按 / 后台定向投递** |
|
||||
| **剪贴板** | `clip.get` · `clip.set` | 读写文本**或图片**(PNG Base64) |
|
||||
| **事件订阅** | `event.watch` · `event.unwatch` · `event.list` · `event.poll` | 文件变动 / 进程启停 / 端口开闭,**主动推送**(无需轮询) |
|
||||
| **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 者整链重写」可被发现 |
|
||||
|
||||
**31 项能力** · **26 个 MCP 工具** · **61 项单元测试** · **21 项端到端用例**(CI 在真实 Windows 上验证)
|
||||
## 快捷键(hotkey)控制 —— v1.5.0
|
||||
|
||||
`input.key.press` 支持 **4 种输入形式**(一次只用一种):
|
||||
|
||||
```bash
|
||||
# ① 字符串热键(最常用;分隔符支持 + - 空格,大小写不敏感)
|
||||
nodeagent --node win key press "ctrl+shift+esc"
|
||||
nodeagent --node win key press "win+d" # 显示桌面
|
||||
nodeagent --node win key press "alt+tab" # 切换应用
|
||||
|
||||
# ② 热键序列
|
||||
nodeagent --node win key press "ctrl+c" # 老形式也兼容:key press ctrl c
|
||||
nodeagent --node win invoke input.key.press --args '{"hotkeys":["ctrl+c","ctrl+v"]}'
|
||||
|
||||
# ③ 预设语义名(约 50 个,免记键位)
|
||||
nodeagent --node win key press copy # = ctrl+c
|
||||
nodeagent --node win key press task_manager # = ctrl+shift+esc
|
||||
nodeagent --node win key press volume_mute_toggle # 媒体键(走 WM_APPCOMMAND)
|
||||
|
||||
# ④ 老数组形式
|
||||
nodeagent --node win key press ctrl c
|
||||
```
|
||||
|
||||
**按键覆盖**:字母/数字 · F1–F24 · 左右侧修饰键(`lalt`/`rctrl`/`lwin`…)·
|
||||
小键盘(`numpad7`、`numpad_add`…,**与数字键分开**——Excel 靠 NumLock 区分)·
|
||||
OEM 符号键(`oem_plus`/`oem_comma`/`oem_period`… → 支持 `win+d`、`ctrl++`、`win+.`)·
|
||||
媒体键 · 浏览器键 · IME 键(`convert`/`kana`…)· 系统键(`sleep`/`help`…)
|
||||
|
||||
**进阶参数**:
|
||||
|
||||
| 参数 | 说明 |
|
||||
|---|---|
|
||||
| `hold_ms` | 长按:按下后保持 N 毫秒再释放(≤5000) |
|
||||
| `route=post` + `target_pid` | **后台定向投递**:向目标进程全部顶层窗口 PostMessage(目标窗口被遮挡/最小化时用)。⚠️ 游戏与部分输入型程序不响应后台键消息 |
|
||||
| `target_pid`(默认路由) | 媒体键定向(播放器常建 30+ 辅助窗口,指定 PID 投给该进程全部顶层窗口) |
|
||||
|
||||
**预设速查**(完整表见 `packages/protocol/src/hotkeys.ts` 的 `HOTKEY_PRESETS`):
|
||||
|
||||
| 类别 | 预设名 |
|
||||
|---|---|
|
||||
| 编辑 | `copy` `cut` `paste` `paste_plain` `undo` `redo` `select_all` |
|
||||
| 文件 | `save` `save_as` `new` `open` `close_tab` `print` |
|
||||
| 窗口 | `switch_app` `task_view` `task_manager` `show_desktop` `minimize_all` `maximize` `snap_left` `snap_right` `rename` `properties` |
|
||||
| 系统 | `lock_screen` `run` `explorer` `search` `settings` `clipboard_history` `emoji_picker` `screenshot` |
|
||||
| 虚拟桌面 | `new_desktop` `desktop_left` `desktop_right` |
|
||||
| 标签/视图 | `new_tab` `next_tab` `reopen_tab` `zoom_in` `zoom_out` `fullscreen` |
|
||||
| 媒体 | `music_play_pause` `music_next` `music_prev` `volume_add` `volume_mute_toggle` |
|
||||
|
||||
> MCP 侧同名工具为 `na_key`(`action: "press"`),支持同一套参数。
|
||||
|
||||
## 等待条件 gui.await —— v1.6.0
|
||||
|
||||
GUI 操作从「按一下→睡几秒→截图碰运气」变成**可断言**流程:
|
||||
|
||||
```bash
|
||||
# 等界面元素出现(最多 30s)
|
||||
nodeagent --node win invoke gui.await --args '{"condition":"control","text":"完成","timeout_ms":30000}'
|
||||
# 等它消失
|
||||
nodeagent --node win invoke gui.await --args '{"condition":"control","text":"安装中","state":"absent"}'
|
||||
# 等窗口 / 进程 / 文件
|
||||
nodeagent --node win invoke gui.await --args '{"condition":"window","title":"安装程序","timeout_ms":10000}'
|
||||
nodeagent --node win invoke gui.await --args '{"condition":"process","process":"setup.exe"}'
|
||||
nodeagent --node win invoke gui.await --args '{"condition":"file","path":"C:\\log.txt"}'
|
||||
```
|
||||
|
||||
四类条件各自复用 `window.list` / `screen.find` / `process.list` / `fs.stat`,
|
||||
平台行为(Windows UIA/OCR、macOS Vision OCR)与那些能力完全一致,**只读低危**。
|
||||
轮询期单次异常不视为失败(启动中的程序常短暂报错),超时未命中才带回 `last_error`。
|
||||
|
||||
**典型闭环**:`await control(text:"立即安装")` → `mouse.click(x, y)` → `await control(text:"完成", timeout_ms:60000)` → `click` → `await absent(text:"安装中")`。
|
||||
|
||||
### 属性谓词与组合(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 从「看得到画面但读不懂界面」变成「按名字取坐标点下去」。
|
||||
@@ -41,9 +179,15 @@ nodeagent 把一台 Windows 机器的能力**标准化成一组可授权、可
|
||||
|
||||
```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)**
|
||||
@@ -60,16 +204,35 @@ pnpm build
|
||||
|
||||
产物:`release/nodeagent-win-x64.zip`(约 32MB)
|
||||
|
||||
2. 把 zip 拷到 Windows 并解压
|
||||
3. 右键以**管理员**运行其中的 `install.ps1`
|
||||
2. 把 zip 拷到 Windows 并解压到**固定目录**(如 `D:\nodeagent`,装完不要移动或改名)
|
||||
3. **双击 `install.cmd`** —— 会自动弹 UAC 提权,然后一路装完并打印连接信息
|
||||
|
||||
安装脚本会使用**包内自带的 Node.js 运行时**,自动:生成预共享密钥 → 写配置 → 放行防火墙 → 注册开机自启 → 启动 Agent,并打印**密钥**与**连接命令**。
|
||||
> 不需要记任何命令行。`install.cmd` 会自己提权、自己读包内 `PSK.txt` 的密钥(没有就生成一把),
|
||||
> 再调用底层的 `install.ps1`。日后管理双击 **`control.cmd`**(启动 / 停止 / 重启 / 状态 / 日志 / 卸载)。
|
||||
|
||||
安装脚本会使用**包内自带的 Node.js 运行时**,自动:读/生成预共享密钥 → 写配置 → 放行防火墙 → 注册开机自启 → 启动 Agent,并打印**密钥**与**连接命令**。
|
||||
|
||||
<details>
|
||||
<summary>需要指定端口 / 无人值守模式?展开命令行用法</summary>
|
||||
|
||||
```powershell
|
||||
# 指定端口(默认 8765)
|
||||
install.cmd 18770
|
||||
|
||||
# 等价的底层调用(install.cmd 就是这行 + 提权 + 读 PSK)
|
||||
powershell -ExecutionPolicy Bypass -File .\install.ps1 -NodeId my-pc -Key <PSK> -AllowInput
|
||||
|
||||
# 无人值守:注销/重启后仍运行,但**没有 GUI**(Session 0 无桌面)
|
||||
powershell -ExecutionPolicy Bypass -File .\install.ps1 -Unattended -AtStartup
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**两种运行模式(重要)**
|
||||
|
||||
| 模式 | 命令 | 登录/注销 | 能力范围 |
|
||||
|---|---|---|---|
|
||||
| 交互(默认) | `install.ps1` | 登录时启动;**注销即停** | 全部能力,**含 GUI**(截屏 / UIA 找元素 / 键鼠注入) |
|
||||
| 交互(默认) | `install.cmd` | 登录时启动;**注销即停** | 全部能力,**含 GUI**(截屏 / UIA 找元素 / 键鼠注入) |
|
||||
| 无人值守 | `install.ps1 -Unattended -AtStartup` | **注销、重启后仍运行**(S4U,无需存密码) | 无 GUI —— 截屏/输入类能力不可用(进程在非交互会话,没有桌面) |
|
||||
|
||||
> 这是 Windows 的固有限制:**图形操作必须有交互桌面**。需要 GUI 就保持登录(可锁屏);
|
||||
@@ -148,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 中转(跨网段/公网)
|
||||
```
|
||||
@@ -165,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` 并**事前禁用**这些入口,而不是发出注定失败的请求。
|
||||
|
||||
## 安全模型
|
||||
|
||||
三层防护,逐层收窄权限:
|
||||
@@ -244,6 +442,7 @@ nodeagent push <本地文件> <远端路径> [--create-dirs] # 上传(自动
|
||||
```bash
|
||||
nodeagent audit [--limit 20] [--type invoke|auth|acl|agent] [--client-id X] [--since <ms>]
|
||||
nodeagent audit verify # 校验审计链完整性(防篡改检测)
|
||||
nodeagent metrics [--since <ms>] # 成功指标:闭环率/装软件率/P95/拦截率
|
||||
nodeagent keygen [--id mac_01] # 生成 Ed25519 密钥 + ACL 配置片段(私钥入系统钥匙串)
|
||||
```
|
||||
|
||||
@@ -265,7 +464,27 @@ nodeagent clip [--set "文本"] [--out <路径>] [--image-file <路径>] # 剪
|
||||
nodeagent record [--duration 5000] [--fps 2] [--region x,y,w,h] # 录屏为帧序列
|
||||
```
|
||||
|
||||
### GUI 语义(找到元素再点,不猜坐标)
|
||||
### 事件订阅与宏
|
||||
|
||||
```bash
|
||||
nodeagent events --kind file --path C:\logs --seconds 20 # 实时接收文件变动推送(CLI 直连推送)
|
||||
nodeagent events --kind process --pattern chrome* # 进程启停
|
||||
nodeagent events # 列出当前订阅
|
||||
nodeagent macro init demo.json # 生成示例宏
|
||||
nodeagent macro run demo.json --var STAMP=hello # 回放(逐步校验)
|
||||
nodeagent macro validate demo.json # 仅校验文件
|
||||
nodeagent group add 办公机 win_a,win_b # 设备分组
|
||||
nodeagent fanout system.info --nodes @办公机 # 按组并发下发
|
||||
```
|
||||
|
||||
### GUI 语义(三条腿:控件树 / 文字 / 图像)
|
||||
|
||||
```bash
|
||||
nodeagent screen find "确定" # UIA → OCR 自动降级
|
||||
nodeagent screen find "登录" --method ocr --wait-ms 5000 # 等界面加载出来
|
||||
nodeagent screen find "" --method image --template C:\icons\save.png --threshold 0.9
|
||||
# 纯图标控件:按图找坐标
|
||||
```
|
||||
|
||||
```bash
|
||||
nodeagent invoke window.list --args '{"title_pattern":"记事本"}'
|
||||
@@ -288,6 +507,9 @@ nodeagent mouse drag 300 200 700 500 # 拖拽(拖文件/框选
|
||||
| `na_fs_list` · `na_fs_read` · `na_fs_write` · `na_fs_stat` | 读目录 / 读文件 / 写文件 / 元信息 |
|
||||
| `na_restart` | 受控重启被控端 |
|
||||
| `na_audit` · `na_discover` · `na_nodes` · `na_use` | 查审计、发现设备、多设备切换 |
|
||||
| `na_event_watch` · `na_event_poll` · `na_event_list` · `na_event_unwatch` | 事件订阅;`notify:true` 走 MCP 日志通知实时推送,否则用 `na_event_poll` 拉取 |
|
||||
| `na_macro_run` | 回放 GUI 宏(步骤序列,逐步校验) |
|
||||
| `na_metrics` | 成功指标(闭环率 / 装软件率 / P95 / 拦截率)+ 达标判定 |
|
||||
|
||||
## Hub 中转(跨网段 / 公网)
|
||||
|
||||
@@ -310,6 +532,30 @@ node apps/cli/dist/index.js connect hub.example.com --port 443 --hub-token <Hub
|
||||
--hub-node win_01 --key <设备密钥> --name win_01
|
||||
```
|
||||
|
||||
**并发多控制端**:Hub 为每个被控端维护**槽位池**(`max_slots_per_node`,默认 3),
|
||||
被控端侧常备 2 条连接(`hub.warm_slots`)并在被占用时自动扩容 —— 因此
|
||||
「AI + 人同时在线」不再互斥(旧版是独占,会返回 `E_NODE_BUSY`)。
|
||||
|
||||
**Hub 侧访问控制(v12)**:可配置多令牌,每个令牌独立指定能访问哪些设备:
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "<主令牌,放行全部>",
|
||||
"node_allowlist": ["win_*"],
|
||||
"tokens": [
|
||||
{ "value": "ci-token", "name": "ci", "allow_nodes": ["win_build_*"] },
|
||||
{ "value": "guest-token", "name": "guest", "allow_nodes": ["*"], "deny_nodes": ["win_prod"] },
|
||||
{ "value": "roll-*", "name": "rolling", "allow_nodes": ["win_a"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- 判定顺序 **deny → allow → 默认拒绝**;`node_allowlist` 非空时只有匹配的 node_id 能注册
|
||||
- `value` 以 `*` 结尾表示**前缀匹配**(便于令牌轮换期间灰度)
|
||||
- 设备列表按身份过滤(控制端只看得到自己被授权的设备),越权接入返回 `E_NODE_FORBIDDEN`
|
||||
- **职责边界**:Hub 只做「能否连到某设备」的接入过滤;连上之后的**能力级**授权
|
||||
仍由被控端 ACL 按 client_id 裁决 —— 两层独立,Hub 不参与能力决策
|
||||
|
||||
**安全边界**:Hub **只做字节透传**,不解析内容、不持有设备密钥 —— 控制端与被控端之间仍执行端到端握手,因此 **Hub 无法窃听也无法伪造**(Hub 被攻陷也不等于设备被接管)。生产环境建议在 Hub 前挂 Caddy / Nginx 终止 TLS。
|
||||
|
||||
## 开发
|
||||
@@ -317,10 +563,11 @@ node apps/cli/dist/index.js connect hub.example.com --port 443 --hub-token <Hub
|
||||
```bash
|
||||
pnpm build # 构建全部包
|
||||
pnpm typecheck # 类型检查
|
||||
pnpm test:unit # 单元测试(61 项:协议纯函数 / 清单守护 / 审计链 / ACL v11)
|
||||
pnpm test:unit # 单元测试(114 项:协议纯函数 / 清单守护 / 审计链 / ACL / 宏引擎 / 分组 / Hub 授权 / 指标 / 助手与预加载段)
|
||||
pnpm test:e2e # 端到端测试(21 项,含 TLS / 零信任 / 发现 / 文件 / Hub)
|
||||
pnpm test:windows # Windows 专属能力(服务 / 软件 / winget 真实装软件)
|
||||
pnpm verify # 对已配置的被控端跑全套验收并输出报告
|
||||
node scripts/verify-mcp-events.mjs # 验证 MCP 事件双通路(推送 + 拉取)
|
||||
```
|
||||
|
||||
### 三层自动化真机验证
|
||||
@@ -329,7 +576,7 @@ pnpm verify # 对已配置的被控端跑全套验收并输出报告
|
||||
|
||||
| 层次 | 方式 | 覆盖 |
|
||||
|---|---|---|
|
||||
| **CI(推荐)** | GitHub Actions `windows-latest` | 真实 Windows 上跑协议 E2E、专属能力(`Get-Service` / 注册表 / winget 装软件)、`install.ps1` 链路、CLI 连入、MCP 工具列表 |
|
||||
| **CI(推荐)** | GitHub Actions `windows-latest` | 真实 Windows 上跑协议 E2E、专属能力(`Get-Service` / 注册表 / winget 装软件)、`install.ps1` 链路、`install.cmd`/`control.cmd` 双击链路、**Windows PowerShell 5.1 解析守卫**、CLI 连入、MCP 工具列表 |
|
||||
| **远程验收** | `node scripts/verify.mjs --host <IP> --key <密钥> [--insecure]` | 逐条验收并输出终端报告(`--report report.md`) |
|
||||
| **本地测试** | `pnpm test:e2e` / `pnpm test:windows` | 单机自举:本机起 Agent 当被控端自测 |
|
||||
|
||||
@@ -355,38 +602,59 @@ nodeagent/
|
||||
│ ├── hub/ # 中转节点:设备注册、控制端配对、字节透传(跨网段/公网)
|
||||
│ ├── cli/ # macOS 控制端 CLI
|
||||
│ └── mcp/ # MCP server(接入 WorkBuddy)
|
||||
├── scripts/ # install.ps1 / uninstall.ps1 / verify.mjs
|
||||
├── scripts/ # install.cmd / control.cmd(一键入口)/ install.ps1 / control.ps1 / verify.mjs
|
||||
├── tests/e2e/ # 端到端测试
|
||||
├── docs/ # 开发文档
|
||||
├── PRD.md # 产品需求文档
|
||||
├── 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+** | **操作面补完**:鼠标拖拽 / 录屏 / 剪贴板图片 / 多设备并发 / 一键升级 | ✅ |
|
||||
| **待办** | 事件订阅(push)、v7~v11 能力入 CI、macOS 端 GUI 能力对齐 | 🚧 |
|
||||
| **批次 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 全绿 | ✅ 已发布 |
|
||||
|
||||
## 文档
|
||||
|
||||
- [产品需求文档(PRD)](./PRD.md)
|
||||
- [`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)](./docs/PRD.md)
|
||||
- [开发文档(DEVELOPMENT)](./docs/DEVELOPMENT.md) —— 协议细节、能力 schema、安全模型、各端实现指南
|
||||
- [**跨机接管的边界与局限**](./docs/REMOTE-LIMITS.md) —— 哪些问题能远程修、哪些必须人工,附真实故障诊断案例
|
||||
- [**实战复盘:短板与改进优先级**](./docs/RETROSPECTIVE.md) —— 基于真机使用的短板分析(GUI 语义 / 编码 / 自愈 / 可观测性)
|
||||
- [**实战复盘:短板与改进优先级**](./docs/RETROSPECTIVE.md) —— 基于真机使用的短板分析(GUI 语义 / 编码 / 自愈 / 可观测性)。**写于 2026-09-28(19 项能力时),多数 P0/P1 已解决 —— 最新状态见 [ROADMAP](./docs/ROADMAP.md) §3 对照表**
|
||||
|
||||
## 关键决策
|
||||
|
||||
@@ -396,5 +664,5 @@ nodeagent/
|
||||
| 安全演进 | 先简化(预共享密钥 + HMAC)→ 后强化(Ed25519 + 能力级 ACL + 审计) |
|
||||
| AI 接入 | 核心能力 → CLI(调试底座)→ MCP(AI 落点) |
|
||||
| 装软件 | 优先 winget 静默安装 |
|
||||
| Windows 部署 | 一键 `install.ps1` + 开机自启 |
|
||||
| Windows 部署 | 一键 `install.cmd`(双击即装,自提权)+ 开机自启;`control.cmd` 管理 |
|
||||
| 依赖策略 | 被控端零原生编译;能力全部基于系统原生 API 实现 |
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/agent",
|
||||
"version": "0.1.0",
|
||||
"version": "2.0.2",
|
||||
"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'
|
||||
@@ -11,7 +17,15 @@ export type AuditType =
|
||||
| 'invoke'
|
||||
| 'acl.denied'
|
||||
| 'capability.disabled'
|
||||
| 'rate.limited';
|
||||
| 'rate.limited'
|
||||
/** v16:网络配置变更(两阶段提交) */
|
||||
| 'net.change'
|
||||
/** v21:来源不在 allow_from 被拒 */
|
||||
| 'net.deny'
|
||||
/** v19:agent 自更新(拉取式) */
|
||||
| 'agent.update'
|
||||
/** v2.0.0:连接空闲被主动断开 */
|
||||
| 'session.idle_close';
|
||||
|
||||
/** 一条审计记录(JSONL 单行)。 */
|
||||
export interface AuditEntry {
|
||||
@@ -271,6 +285,33 @@ function readEntriesFromFile(file: string): AuditEntry[] {
|
||||
* 读取审计记录(取最新 limit 条,按时间升序返回)。
|
||||
* 会同时读取轮转文件(audit.log.1/.2/…),避免历史记录被遗漏。
|
||||
*/
|
||||
/**
|
||||
* 读取全部审计条目(跨轮转文件,按时间升序)。
|
||||
* 供指标聚合等「需要全窗口样本」的场景使用 —— readAudit 有 limit 上限(最多 1000 条)。
|
||||
* 安全上限 20 万条,防止极端情况下把内存吃爆。
|
||||
*/
|
||||
export function readAuditAll(since?: number): AuditEntry[] {
|
||||
const p = auditFilePath();
|
||||
const files: string[] = [];
|
||||
for (let i = 20; i >= 1; i -= 1) {
|
||||
const f = `${p}.${i}`;
|
||||
if (existsSync(f)) files.push(f);
|
||||
}
|
||||
files.push(p);
|
||||
|
||||
const out: AuditEntry[] = [];
|
||||
for (const f of files) {
|
||||
for (const e of readEntriesFromFile(f)) {
|
||||
if (since !== undefined && e.ts <= since) continue;
|
||||
out.push(e);
|
||||
if (out.length >= 200_000) break;
|
||||
}
|
||||
if (out.length >= 200_000) break;
|
||||
}
|
||||
out.sort((a, b) => a.ts - b.ts);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function readAudit(query: AuditQuery = {}): { entries: AuditEntry[]; total: number; file: string } {
|
||||
const p = auditFilePath();
|
||||
if (!existsSync(p)) return { entries: [], total: 0, file: p };
|
||||
@@ -299,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);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { IS_WINDOWS, execCommand } from '../util/exec.js';
|
||||
import { runPowerShellSmart } from '../util/ps-helper.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* 音频控制(v17):静音开关与主音量。
|
||||
*
|
||||
* ## 为什么需要
|
||||
* 远程接管一台机器时,「把它静音」是高频且刚需的小事(夜里吵、放视频要音量、
|
||||
* 排障时确认音频设备)。此前只能靠 exec 拼一段脚本,容易踩本项目已记录的
|
||||
* 各种坑(C# 转义 / 编码 / 引号),故做成正式能力。
|
||||
*
|
||||
* ## 平台实现
|
||||
* - Windows:**Core Audio COM**(IMMDeviceEnumerator → IAudioEndpointVolume)
|
||||
* —— 可**读回真实状态**,因此是确定性的「设为静音」而非「切换」
|
||||
* (切换在状态未知时可能反而打开声音)。不用 Add-Type -AssemblyName 那套,
|
||||
* 是因为音量不在 .NET BCL 里,只能用 COM 互操作。
|
||||
* - macOS:`osascript` 的 volume 设置(原生,无需互操作)。
|
||||
*
|
||||
* ## C# 互操作的两个硬约束(本项目踩过的坑,勿改)
|
||||
* 1. **槽位顺序即 vtable 顺序**:接口方法必须按 COM 定义顺序声明,
|
||||
* 不调用的方法也要占位(占位方法签名无所谓,但不能漏)。
|
||||
* 顺序错了不会编译报错,而是调用到错误的内存地址 → 进程崩溃。
|
||||
* 2. **C# 源码必须纯 ASCII**:Add-Type 按系统 ANSI(GBK) 落盘,非 ASCII 会乱码。
|
||||
*/
|
||||
|
||||
// 不调用的方法只占槽位,签名随意;**顺序不可调整**(见文件头注释)
|
||||
const AUDIO_PRELUDE = `
|
||||
Add-Type @"
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
public class NAAudio {
|
||||
[ComImport, Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")]
|
||||
private class MMDeviceEnumerator { }
|
||||
|
||||
[Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
private interface IMMDeviceEnumerator {
|
||||
int m1();
|
||||
int GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice ppDevice);
|
||||
}
|
||||
|
||||
[Guid("D666063F-1587-4E43-81F1-B948E807363F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
private interface IMMDevice {
|
||||
int Activate(ref Guid iid, int dwClsCtx, IntPtr pActivationParams, [MarshalAs(UnmanagedType.IUnknown)] out object ppInterface);
|
||||
}
|
||||
|
||||
[Guid("5CDF2C82-841E-4546-9722-0CF74078229A"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
private interface IAudioEndpointVolume {
|
||||
int m1(); int m2(); int m3(); int m4();
|
||||
int SetMasterVolumeLevelScalar(float fLevel, IntPtr pguidEventContext);
|
||||
int m6();
|
||||
int GetMasterVolumeLevelScalar(out float pfLevel);
|
||||
int m8(); int m9(); int m10(); int m11();
|
||||
int SetMute([MarshalAs(UnmanagedType.Bool)] bool bMute, IntPtr pguidEventContext);
|
||||
int GetMute([MarshalAs(UnmanagedType.Bool)] out bool pbMute);
|
||||
}
|
||||
|
||||
private const char SEP = (char)124;
|
||||
|
||||
private static IAudioEndpointVolume Default() {
|
||||
IMMDeviceEnumerator en = (IMMDeviceEnumerator)(new MMDeviceEnumerator());
|
||||
IMMDevice dev;
|
||||
Marshal.ThrowExceptionForHR(en.GetDefaultAudioEndpoint(0, 0, out dev));
|
||||
Guid iid = typeof(IAudioEndpointVolume).GUID;
|
||||
object o;
|
||||
Marshal.ThrowExceptionForHR(dev.Activate(ref iid, 23, IntPtr.Zero, out o));
|
||||
return (IAudioEndpointVolume)o;
|
||||
}
|
||||
|
||||
private static string Snapshot(IAudioEndpointVolume v) {
|
||||
bool muted;
|
||||
Marshal.ThrowExceptionForHR(v.GetMute(out muted));
|
||||
float lvl;
|
||||
Marshal.ThrowExceptionForHR(v.GetMasterVolumeLevelScalar(out lvl));
|
||||
return (muted ? "1" : "0") + SEP + ((int)Math.Round(lvl * 100));
|
||||
}
|
||||
|
||||
public static string Get() {
|
||||
return Snapshot(Default());
|
||||
}
|
||||
|
||||
public static string Mute(string flag) {
|
||||
IAudioEndpointVolume v = Default();
|
||||
bool want = flag == "1";
|
||||
Marshal.ThrowExceptionForHR(v.SetMute(want, IntPtr.Zero));
|
||||
return Snapshot(v);
|
||||
}
|
||||
|
||||
public static string Volume(int percent) {
|
||||
if (percent < 0) percent = 0;
|
||||
if (percent > 100) percent = 100;
|
||||
IAudioEndpointVolume v = Default();
|
||||
Marshal.ThrowExceptionForHR(v.SetMasterVolumeLevelScalar(percent / 100f, IntPtr.Zero));
|
||||
return Snapshot(v);
|
||||
}
|
||||
|
||||
public static string Apply(string flag, int percent) {
|
||||
IAudioEndpointVolume v = Default();
|
||||
if (percent >= 0) Marshal.ThrowExceptionForHR(v.SetMasterVolumeLevelScalar(percent / 100f, IntPtr.Zero));
|
||||
if (flag == "0" || flag == "1") Marshal.ThrowExceptionForHR(v.SetMute(flag == "1", IntPtr.Zero));
|
||||
return Snapshot(v);
|
||||
}
|
||||
}
|
||||
"@
|
||||
`;
|
||||
|
||||
/** 解析 C# 回传的 `静音|音量` 紧凑串。 */
|
||||
export function parseAudioState(raw: string): { muted: boolean; volume: number } {
|
||||
const line = raw.trim().split('\n').pop() ?? '';
|
||||
const m = line.match(/([01])\s*\|\s*(\d{1,3})/);
|
||||
if (!m) {
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '音频状态解析失败', { detail: raw.slice(0, 200) });
|
||||
}
|
||||
return { muted: m[1] === '1', volume: Number(m[2]) };
|
||||
}
|
||||
|
||||
async function runWindows(body: string, timeoutMs = 30_000): Promise<string> {
|
||||
const { stdout } = await runPowerShellSmart({
|
||||
body,
|
||||
prelude: AUDIO_PRELUDE,
|
||||
timeoutMs,
|
||||
label: 'audio',
|
||||
log: (level, msg) => {
|
||||
if (level === 'warn') console.error(`[audio] ${msg}`);
|
||||
},
|
||||
});
|
||||
return stdout;
|
||||
}
|
||||
|
||||
/** macOS:osascript 原生音量控制(静音用 `output muted`,音量用 `output volume`)。 */
|
||||
async function runMac(script: string): Promise<string> {
|
||||
const r = await execCommand({ command: `osascript -e ${JSON.stringify(script)}`, timeoutMs: 15_000 });
|
||||
if (r.exit_code !== 0) {
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '音频控制失败(osascript)', {
|
||||
detail: (r.stderr || r.stdout).slice(0, 300),
|
||||
});
|
||||
}
|
||||
return r.stdout.trim();
|
||||
}
|
||||
|
||||
async function macState(): Promise<{ muted: boolean; volume: number }> {
|
||||
const out = await runMac('get volume settings');
|
||||
// 形如: output volume:42, input volume:100, alert volume:100, output muted:false
|
||||
const vol = /output volume:(\d+)/.exec(out);
|
||||
const muted = /output muted:(true|false)/.exec(out);
|
||||
return { muted: muted?.[1] === 'true', volume: vol ? Number(vol[1]) : 0 };
|
||||
}
|
||||
|
||||
// ---------------- system.audio.get ----------------
|
||||
|
||||
/**
|
||||
* Windows 音频后端标识。
|
||||
*
|
||||
* 修正:此前误写为 'CoreAudio'(那是 macOS 的后端名),导致 Windows 被控端也报
|
||||
* 「CoreAudio」,排查时极易误导。真机 2026-10-04 发现。实现走的是 C# NAudio(CoreAudioAPI)。
|
||||
*/
|
||||
const AUDIO_BACKEND_WIN = 'NAudio/CoreAudioAPI';
|
||||
|
||||
export async function audioGet(_args: Args): Promise<unknown> {
|
||||
if (IS_WINDOWS) {
|
||||
const state = parseAudioState(await runWindows('[NAAudio]::Get()'));
|
||||
return { ...state, platform: 'windows', backend: AUDIO_BACKEND_WIN };
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
return { ...(await macState()), platform: 'macos', backend: 'osascript' };
|
||||
}
|
||||
throw new CapabilityError(ErrorCodes.UNSUPPORTED_PLATFORM, '音频控制仅支持 Windows / macOS 被控端', {
|
||||
platform: process.platform,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- system.audio.set ----------------
|
||||
|
||||
export async function audioSet(args: Args): Promise<unknown> {
|
||||
const muteArg = args['mute'];
|
||||
const volumeArg = args['volume'];
|
||||
const hasMute = typeof muteArg === 'boolean';
|
||||
const hasVolume = typeof volumeArg === 'number';
|
||||
if (!hasMute && !hasVolume) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, '至少提供 mute(布尔)或 volume(0-100)之一');
|
||||
}
|
||||
const volume = hasVolume ? Math.max(0, Math.min(100, Math.round(volumeArg as number))) : -1;
|
||||
|
||||
if (IS_WINDOWS) {
|
||||
const flag = hasMute ? ((muteArg as boolean) ? '1' : '0') : '';
|
||||
const body = `[NAAudio]::Apply('${flag}', ${volume})`;
|
||||
const state = parseAudioState(await runWindows(body));
|
||||
return { ...state, platform: 'windows', backend: AUDIO_BACKEND_WIN, applied: { mute: hasMute ? muteArg : null, volume: hasVolume ? volume : null } };
|
||||
}
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
if (hasMute) await runMac(`set volume output muted ${muteArg === true ? 'true' : 'false'}`);
|
||||
if (hasVolume) await runMac(`set volume output volume ${volume}`);
|
||||
const state = await macState();
|
||||
return { ...state, platform: 'macos', backend: 'osascript', applied: { mute: hasMute ? muteArg : null, volume: hasVolume ? volume : null } };
|
||||
}
|
||||
|
||||
throw new CapabilityError(ErrorCodes.UNSUPPORTED_PLATFORM, '音频控制仅支持 Windows / macOS 被控端', {
|
||||
platform: process.platform,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* gui.await —— 等待条件成立(v1.6.0)。
|
||||
*
|
||||
* ## 为什么需要它(复盘三大短板之「语义」)
|
||||
* 原来的 GUI 操作是**盲试**:按一下、截个图、看看有没有变化,不行再试。
|
||||
* 问题不是"找不到元素"(screen.find 已解决),而是**操作之后无法断言界面到了预期状态**。
|
||||
* 有了本能力,"点了安装 → 等待『完成』按钮出现" 从"睡 3 秒碰运气"变成**可验证的等待**:
|
||||
*
|
||||
* await control(text:"完成", timeout:30s) → click(found) → await absent(text:"安装中")
|
||||
*
|
||||
* ## 实现要点
|
||||
* 不写任何新的平台探测代码 —— 四个条件全部**复用既有能力**(window.list /
|
||||
* screen.find / process.list / fs.stat),因此 Windows/macOS 行为与它们完全一致。
|
||||
* 轮询期间异常一律视为"尚未命中"并记录 last_error,避免单次抖动整句失败;
|
||||
* 但超时未命中时会把它带回,方便判断是"真的没有"还是"每次都在报错"。
|
||||
*/
|
||||
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { windowList } from './window.js';
|
||||
import { screenFind } from './window.js';
|
||||
import { processList } from './system.js';
|
||||
import { fsStat } from './fs.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
type Condition = 'window' | 'control' | 'process' | 'file';
|
||||
|
||||
const CONDITIONS: readonly Condition[] = ['window', 'control', 'process', 'file'];
|
||||
|
||||
/** 判断一次底层调用是否"命中"(兼容各能力不同的返回形态)。 */
|
||||
function isHit(result: unknown): boolean {
|
||||
if (result === null || result === undefined) return false;
|
||||
if (typeof result === 'boolean') return result;
|
||||
const r = result as Record<string, unknown>;
|
||||
if (r['found'] === true) return true;
|
||||
if (r['exists'] === true) return true;
|
||||
for (const key of ['windows', 'processes', 'matches', 'elements']) {
|
||||
const arr = r[key];
|
||||
if (Array.isArray(arr) && arr.length > 0) return true;
|
||||
}
|
||||
if (Array.isArray(result) && result.length > 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 按条件组装底层调用的参数(只透传该条件支持的字段)。 */
|
||||
function buildInnerArgs(condition: Condition, args: Args): Args {
|
||||
const num = (k: string): number | undefined => {
|
||||
const v = args[k];
|
||||
return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
|
||||
};
|
||||
switch (condition) {
|
||||
case 'window': {
|
||||
const inner: Args = {};
|
||||
const title = (args['title'] as string | undefined) ?? (args['pattern'] as string | undefined);
|
||||
if (title) inner['title_pattern'] = title;
|
||||
const limit = num('limit');
|
||||
if (limit !== undefined) inner['limit'] = limit;
|
||||
return inner;
|
||||
}
|
||||
case 'control': {
|
||||
// 透传 screen.find 的全部相关参数(method=image 时也一样走轮询)
|
||||
const inner: 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': {
|
||||
const inner: Args = {};
|
||||
const name = args['process'] as string | undefined;
|
||||
if (name) inner['filter'] = { name_pattern: name };
|
||||
const limit = num('limit');
|
||||
if (limit !== undefined) inner['limit'] = limit;
|
||||
return inner;
|
||||
}
|
||||
case 'file': {
|
||||
const p = args['path'] as string | undefined;
|
||||
if (!p) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'condition=file 需要 path', {
|
||||
hint: '如 {"condition":"file","path":"C:\\\\Windows\\\\Temp\\\\setup.log"}',
|
||||
});
|
||||
}
|
||||
return { path: p };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 条件 → 底层能力(本进程内直接调用,省去 RPC 往返开销)。 */
|
||||
function innerProbe(condition: Condition): (args: Args) => Promise<unknown> {
|
||||
switch (condition) {
|
||||
case 'window':
|
||||
return (a) => windowList(a);
|
||||
case 'control':
|
||||
return (a) => screenFind(a);
|
||||
case 'process':
|
||||
return (a) => processList(a);
|
||||
case 'file':
|
||||
return (a) => fsStat(a);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (!args['any_of'] && (!condition || !CONDITIONS.includes(condition))) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的 condition: ${String(condition)}`, {
|
||||
allowed: CONDITIONS,
|
||||
hint: 'window=等窗口出现/消失;control=等界面元素;process=等进程;file=等文件',
|
||||
});
|
||||
}
|
||||
const state = (args['state'] as string | undefined) ?? 'present';
|
||||
if (state !== 'present' && state !== 'absent') {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的 state: ${state}`, {
|
||||
allowed: ['present', 'absent'],
|
||||
});
|
||||
}
|
||||
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 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 {
|
||||
const result = await probe(innerArgs);
|
||||
lastHit = isHit(result);
|
||||
lastError = null;
|
||||
} catch (err) {
|
||||
// 轮询期异常不代表条件不成立(可能正忙于启动)——记下再试
|
||||
lastError = err instanceof Error ? err.message : String(err);
|
||||
lastHit = false;
|
||||
}
|
||||
const satisfied = state === 'present' ? lastHit : !lastHit;
|
||||
if (satisfied) {
|
||||
return {
|
||||
satisfied: true,
|
||||
condition,
|
||||
state,
|
||||
elapsed_ms: Date.now() - startedAt,
|
||||
attempts,
|
||||
last_seen: state,
|
||||
note:
|
||||
state === 'absent'
|
||||
? '条件已消失(连续一次未命中即视为消失;如需更稳可加大 interval_ms)'
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
if (Date.now() - startedAt >= timeoutMs) {
|
||||
return {
|
||||
satisfied: false,
|
||||
condition,
|
||||
state,
|
||||
elapsed_ms: Date.now() - startedAt,
|
||||
attempts,
|
||||
last_seen: lastHit ? 'present' : 'absent',
|
||||
last_error: lastError ?? undefined,
|
||||
note:
|
||||
state === 'present'
|
||||
? `等待 ${timeoutMs}ms 仍未出现${lastError ? `,期间持续报错:${lastError.slice(0, 160)}` : ''}`
|
||||
: `等待 ${timeoutMs}ms 仍未消失${lastError ? `,期间持续报错:${lastError.slice(0, 160)}` : ''}`,
|
||||
};
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@ import { IS_WINDOWS, execCommand } from '../util/exec.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
/** Set-Clipboard 遇剪贴板被瞬时占用时的重试次数(实测约 20% 概率偶发)。 */
|
||||
const CLIP_SET_RETRIES = 3;
|
||||
|
||||
/**
|
||||
* 图片类剪贴板操作必须走**临时文件**:
|
||||
* 命令行参数在 Windows 上有 ~32KB 上限,而截图 base64 常达数 MB;
|
||||
@@ -27,6 +30,52 @@ function psPath(p: string): string {
|
||||
return p.replace(/\\/g, '\\\\');
|
||||
}
|
||||
|
||||
/**
|
||||
* PowerShell 写 Windows 剪贴板,**带重试**。
|
||||
*
|
||||
* 背景(真机 2026-10-04 实测):`Set-Clipboard` 偶发抛「所请求的剪贴板操作失败」
|
||||
* —— 剪贴板是全系统共享资源,被瞬时占用(其他程序刚写/读、其他 RDP 会话切换)即会失败。
|
||||
* 更坑的是**该错误只写 stderr 而 exit_code 仍为 0**,故 `exit_code !== 0` 判据抓不到它。
|
||||
*
|
||||
* 策略:重试若干次;仍失败则以**读回校验**为准 —— 剪贴板可能已被前一次成功写入,
|
||||
* 读到目标内容即视为成功(避免「成功却报错」的假失败)。
|
||||
*
|
||||
* @param b64 UTF-8 文本的 base64(避免命令行 ANSI 编码问题)
|
||||
* @param expect 期望读回的内容(用于校验)
|
||||
*/
|
||||
async function writeClipboardWindows(b64: string, expect: string): Promise<void> {
|
||||
const setPs = `Set-Clipboard -Value ([System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${b64}')))`;
|
||||
const getPs = 'Get-Clipboard -Raw -ErrorAction SilentlyContinue';
|
||||
|
||||
let lastErr = '';
|
||||
for (let attempt = 1; attempt <= CLIP_SET_RETRIES; attempt++) {
|
||||
const r = await execCommand({ command: setPs, timeoutMs: 10_000 });
|
||||
// exit_code 非 0 是硬失败;124 是被超时强杀
|
||||
if (r.exit_code !== 0 && r.exit_code !== 124) {
|
||||
lastErr = r.stderr.slice(0, 300) || `exit_code=${r.exit_code}`;
|
||||
} else if (r.stderr) {
|
||||
// exit_code 为 0 但 stderr 有内容 = 上面说的「软失败」,记录后重试
|
||||
lastErr = r.stderr.slice(0, 300);
|
||||
} else {
|
||||
return; // 干净成功
|
||||
}
|
||||
|
||||
if (attempt < CLIP_SET_RETRIES) {
|
||||
// 退避后再试:给占用方释放剪贴板的时间
|
||||
await new Promise((res) => setTimeout(res, 150 * attempt));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 最后一次尝试后:读回校验。也许前一次其实写成功了(只是报了软失败)
|
||||
const back = await execCommand({ command: getPs, timeoutMs: 10_000 });
|
||||
if (back.stdout.trim() === expect.trim()) return;
|
||||
}
|
||||
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '写入剪贴板失败', {
|
||||
detail: `${lastErr}(已重试 ${CLIP_SET_RETRIES} 次,并经读回校验仍不匹配)`,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- clip.get ----------------
|
||||
|
||||
export async function clipGet(args: Args = {}): Promise<unknown> {
|
||||
@@ -100,15 +149,7 @@ export async function clipSet(args: Args): Promise<unknown> {
|
||||
if (IS_WINDOWS) {
|
||||
// 文本经 base64 传输,避免命令行参数编码问题(PS 对非 ASCII 参数不可靠)
|
||||
const b64 = Buffer.from(text, 'utf8').toString('base64');
|
||||
const r = await execCommand({
|
||||
command: `Set-Clipboard -Value ([System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${b64}')))`,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
if (r.exit_code !== 0) {
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '写入剪贴板失败', {
|
||||
detail: r.stderr.slice(0, 300),
|
||||
});
|
||||
}
|
||||
await writeClipboardWindows(b64, text);
|
||||
} else {
|
||||
const b64 = Buffer.from(text, 'utf8').toString('base64');
|
||||
const r = await execCommand({
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { IS_WINDOWS, execCommand } from '../util/exec.js';
|
||||
import { agentDir } from '../config.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* macOS 端 GUI 语义(v12.3 / 跨平台对齐)。
|
||||
*
|
||||
* 与 Windows 的对照:
|
||||
* | 能力 | Windows | macOS |
|
||||
* |-----------------|--------------------------|-----------------------------------------|
|
||||
* | window.list | Win32 EnumWindows | AppleScript System Events(需辅助功能权限)|
|
||||
* | window.focus | SetForegroundWindow+兜底 | AppleScript frontmost + AXRaise |
|
||||
* | screen.find | UIA → OCR(WinRT) | 无 UIA 等价物 → 截图 + **Vision OCR** |
|
||||
*
|
||||
* 权限说明(macOS 的硬门槛,无法绕过):
|
||||
* - 枚举/聚焦窗口:系统设置 → 隐私与安全性 → **辅助功能**里勾选运行 agent 的程序
|
||||
* - 截屏做 OCR:**屏幕录制**权限
|
||||
* 未授权时给出明确指引,而不是笼统失败。
|
||||
*/
|
||||
|
||||
const OSA = 'osascript';
|
||||
|
||||
function assertDarwin(capability: string): void {
|
||||
if (IS_WINDOWS) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `${capability} 的 macOS 实现不应在 Windows 上调用`);
|
||||
}
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new CapabilityError(ErrorCodes.UNSUPPORTED_PLATFORM, `${capability} 暂不支持 ${process.platform}`, {
|
||||
platform: process.platform,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 跑 AppleScript:脚本经 stdin 传入,避免引号转义问题。 */
|
||||
async function runOsa(script: string, timeoutMs = 30_000): Promise<{ ok: boolean; stdout: string; stderr: string }> {
|
||||
const b64 = Buffer.from(script, 'utf8').toString('base64');
|
||||
const r = await execCommand({
|
||||
// -e 接受多行;用 base64 传入再还原,彻底规避引号/换行问题
|
||||
command: `printf %s '${b64}' | base64 -d | ${OSA} -l AppleScript -`,
|
||||
timeoutMs,
|
||||
});
|
||||
return { ok: r.exit_code === 0, stdout: r.stdout.trim(), stderr: (r.stderr || '').trim() };
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 macOS 的权限类错误翻译成可执行指引。
|
||||
* 实测错误码/文案(真机):
|
||||
* -10004「发生权限违例 / privilege violation」→ 辅助功能(Accessibility)未授权
|
||||
* -1743「Not authorized to send Apple events」→ 自动化授权
|
||||
* -25211 / 「not allowed assistive access」→ 辅助功能
|
||||
* 未授权时 screencapture 通常仍成功但内容为纯黑/仅桌面 —— 由 OCR 结果为空体现。
|
||||
*/
|
||||
function permissionHint(raw: string): string {
|
||||
if (/10004|权限违例|privilege violation|not allowed assistive|不允许辅助访问|-25211|-1719/i.test(raw)) {
|
||||
return '(请到「系统设置 → 隐私与安全性 → 辅助功能」勾选运行 agent 的程序,然后重启它)';
|
||||
}
|
||||
if (/not authorized to send Apple events|-1743/i.test(raw)) {
|
||||
return '(请到「系统设置 → 隐私与安全性 → 自动化」允许控制 System Events)';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// ---------------- window.list ----------------
|
||||
|
||||
const LIST_SCRIPT = `
|
||||
set out to ""
|
||||
tell application "System Events"
|
||||
set fgProc to name of first application process whose frontmost is true
|
||||
repeat with p in (every application process whose visible is true)
|
||||
try
|
||||
set pname to name of p
|
||||
set ppid to unix id of p
|
||||
repeat with w in (every window of p)
|
||||
try
|
||||
set wname to name of w
|
||||
set wpos to position of w
|
||||
set wsz to size of w
|
||||
set isFg to (pname is fgProc)
|
||||
set out to out & pname & "\\t" & ppid & "\\t" & wname & "\\t" & (item 1 of wpos) & "\\t" & (item 2 of wpos) & "\\t" & (item 1 of wsz) & "\\t" & (item 2 of wsz) & "\\t" & isFg & "\\n"
|
||||
end try
|
||||
end repeat
|
||||
end try
|
||||
end repeat
|
||||
end tell
|
||||
return out
|
||||
`;
|
||||
|
||||
interface MacWindow {
|
||||
title: string;
|
||||
process: string;
|
||||
pid: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
is_foreground: boolean;
|
||||
is_minimized: boolean;
|
||||
}
|
||||
|
||||
export async function macWindowList(args: Args): Promise<unknown> {
|
||||
assertDarwin('window.list');
|
||||
const r = await runOsa(LIST_SCRIPT, 40_000);
|
||||
if (!r.ok) {
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, `枚举窗口失败${permissionHint(r.stderr)}`, {
|
||||
detail: r.stderr.slice(0, 300),
|
||||
});
|
||||
}
|
||||
const all: MacWindow[] = [];
|
||||
for (const line of r.stdout.split('\n')) {
|
||||
const f = line.split('\t');
|
||||
if (f.length < 8) continue;
|
||||
const [process, pidRaw, title, x, y, w, h, fg] = f as [
|
||||
string, string, string, string, string, string, string, string,
|
||||
];
|
||||
const width = Number(w);
|
||||
const height = Number(h);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 40 || height <= 40) continue;
|
||||
all.push({
|
||||
title: title || process,
|
||||
process,
|
||||
pid: Number(pidRaw) || 0,
|
||||
x: Number(x),
|
||||
y: Number(y),
|
||||
width,
|
||||
height,
|
||||
is_foreground: fg.trim().toLowerCase() === 'true',
|
||||
is_minimized: false, // AppleScript 取「every window」通常已排除最小化窗口
|
||||
});
|
||||
}
|
||||
|
||||
const pattern = args['title_pattern'] as string | undefined;
|
||||
let filtered = all;
|
||||
if (pattern) {
|
||||
const re = new RegExp(pattern, 'i');
|
||||
filtered = all.filter((w) => re.test(w.title) || re.test(w.process));
|
||||
}
|
||||
// 与 Windows 侧保持一致:前台窗口置顶,避免被 limit 截掉
|
||||
filtered.sort((a, b) => Number(b.is_foreground) - Number(a.is_foreground));
|
||||
const limit = (args['limit'] as number | undefined) ?? 50;
|
||||
return { windows: filtered.slice(0, limit), total: filtered.length };
|
||||
}
|
||||
|
||||
// ---------------- window.focus ----------------
|
||||
|
||||
export async function macWindowFocus(args: Args): Promise<unknown> {
|
||||
assertDarwin('window.focus');
|
||||
const title = args['title'] as string | undefined;
|
||||
if (!title) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'macOS 端目前需提供 title(正则);暂不支持句柄');
|
||||
}
|
||||
const waitMs = Math.max(0, Math.min(30_000, (args['wait_ms'] as number | undefined) ?? 0));
|
||||
const startedAt = Date.now();
|
||||
|
||||
const script = `
|
||||
set re to "${title.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"
|
||||
set targetProc to ""
|
||||
set targetTitle to ""
|
||||
tell application "System Events"
|
||||
repeat with p in (every application process whose visible is true)
|
||||
try
|
||||
set pname to name of p
|
||||
repeat with w in (every window of p)
|
||||
try
|
||||
set wname to name of w
|
||||
if wname contains re or pname contains re then
|
||||
set targetProc to pname
|
||||
set targetTitle to wname
|
||||
exit repeat
|
||||
end if
|
||||
end try
|
||||
end repeat
|
||||
if targetProc is not "" then exit repeat
|
||||
end try
|
||||
end repeat
|
||||
if targetProc is "" then return "NOTFOUND"
|
||||
set frontmost of (first application process whose name is targetProc) to true
|
||||
try
|
||||
perform action "AXRaise" of (first window of (first application process whose name is targetProc))
|
||||
end try
|
||||
delay 0.3
|
||||
end tell
|
||||
set out to ""
|
||||
tell application "System Events"
|
||||
set fg to name of first application process whose frontmost is true
|
||||
set p to first application process whose name is targetProc
|
||||
set wpos to position of first window of p
|
||||
set wsz to size of first window of p
|
||||
set out to targetTitle & "\\t" & (item 1 of wpos) & "\\t" & (item 2 of wpos) & "\\t" & (item 1 of wsz) & "\\t" & (item 2 of wsz) & "\\t" & (fg is targetProc)
|
||||
end tell
|
||||
return out
|
||||
`;
|
||||
|
||||
let lastErr = '';
|
||||
for (;;) {
|
||||
const r = await runOsa(script, 40_000);
|
||||
if (r.ok && r.stdout && r.stdout !== 'NOTFOUND') {
|
||||
const f = r.stdout.split('\t');
|
||||
if (f.length >= 6) {
|
||||
return {
|
||||
title: f[0],
|
||||
x: Number(f[1]),
|
||||
y: Number(f[2]),
|
||||
width: Number(f[3]),
|
||||
height: Number(f[4]),
|
||||
focused: (f[5] ?? '').trim().toLowerCase() === 'true',
|
||||
activated_by: 'applescript',
|
||||
waited_ms: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
lastErr = r.ok ? '未找到匹配的窗口' : r.stderr;
|
||||
if (Date.now() - startedAt >= waitMs) break;
|
||||
await new Promise((res) => setTimeout(res, 500));
|
||||
}
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, `聚焦失败:${lastErr}${permissionHint(lastErr)}`, {
|
||||
detail: lastErr.slice(0, 300),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- screen.find(截图 + Vision OCR) ----------------
|
||||
|
||||
/** Vision OCR 助手:编译一次后缓存到数据目录,后续秒级复用。 */
|
||||
const OCR_SWIFT = `
|
||||
import Foundation
|
||||
import Vision
|
||||
import AppKit
|
||||
|
||||
// 用法: naocr <image.png> [searchText]
|
||||
// 输出: JSON 数组 [{text,x,y,w,h,confidence}](像素坐标,原点左上)
|
||||
let args = CommandLine.arguments
|
||||
guard args.count >= 2 else { FileHandle.standardError.write("usage: naocr <image> [text]\\n".data(using: .utf8)!); exit(2) }
|
||||
let imagePath = args[1]
|
||||
let search = args.count >= 3 ? args[2] : ""
|
||||
|
||||
guard let img = NSImage(contentsOfFile: imagePath),
|
||||
let tiff = img.tiffRepresentation,
|
||||
let bitmap = NSBitmapImageRep(data: tiff),
|
||||
let cg = bitmap.cgImage else {
|
||||
FileHandle.standardError.write("cannot load image\\n".data(using: .utf8)!); exit(3)
|
||||
}
|
||||
let width = Double(cg.width), height = Double(cg.height)
|
||||
|
||||
let request = VNRecognizeTextRequest()
|
||||
request.recognitionLevel = .accurate
|
||||
request.usesLanguageCorrection = false
|
||||
request.recognitionLanguages = ["zh-Hans", "en-US"]
|
||||
|
||||
let handler = VNImageRequestHandler(cgImage: cg, options: [:])
|
||||
do { try handler.perform([request]) } catch {
|
||||
FileHandle.standardError.write("ocr failed: \\(error)\\n".data(using: .utf8)!); exit(4)
|
||||
}
|
||||
let norm: (String) -> String = { $0.replacingOccurrences(of: " ", with: "") }
|
||||
let want = norm(search)
|
||||
var out: [[String: Any]] = []
|
||||
for obs in (request.results ?? []) {
|
||||
guard let top = obs.topCandidates(1).first else { continue }
|
||||
let text = top.string
|
||||
if !want.isEmpty && !norm(text).lowercased().contains(want.lowercased()) { continue }
|
||||
let bb = obs.boundingBox
|
||||
let x = bb.minX * width
|
||||
let y = (1.0 - bb.maxY) * height
|
||||
let w = bb.width * width
|
||||
let h = bb.height * height
|
||||
out.append(["text": text, "x": x, "y": y, "w": w, "h": h, "confidence": Double(top.confidence)])
|
||||
}
|
||||
let data = try JSONSerialization.data(withJSONObject: out, options: [])
|
||||
FileHandle.standardOutput.write(data)
|
||||
`;
|
||||
|
||||
/**
|
||||
* 读取 PNG 的像素尺寸(解析 IHDR 头,不启动额外进程)。
|
||||
* 用途:Retina 屏上 `screencapture` 产出的是 **2x 像素**图,而窗口坐标是**逻辑点**,
|
||||
* 若不做换算,返回的坐标会让点击偏移一倍(真机踩到)。
|
||||
*/
|
||||
function pngSize(file: string): { width: number; height: number } | null {
|
||||
try {
|
||||
const buf = readFileSync(file);
|
||||
// IHDR 数据从第 16 字节开始:width(4) height(4)
|
||||
if (buf.length < 24 || buf.readUInt32BE(12) !== 0x49484452) return null;
|
||||
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function ocrBinPath(): string {
|
||||
return join(agentDir(), 'bin', 'naocr');
|
||||
}
|
||||
|
||||
/** 确保 OCR 助手已编译(源码变化或二进制缺失时重编)。 */
|
||||
async function ensureOcrBin(): Promise<string> {
|
||||
const dir = join(agentDir(), 'bin');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const src = join(dir, 'naocr.swift');
|
||||
const bin = ocrBinPath();
|
||||
const needSrc = !existsSync(src) || readFileSync(src, 'utf8') !== OCR_SWIFT;
|
||||
if (needSrc) writeFileSync(src, OCR_SWIFT);
|
||||
const needBuild = needSrc || !existsSync(bin);
|
||||
if (needBuild) {
|
||||
const r = await execCommand({
|
||||
command: `swiftc -O -o '${bin}' '${src}'`,
|
||||
timeoutMs: 180_000,
|
||||
});
|
||||
if (r.exit_code !== 0) {
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '编译 OCR 助手失败(需要 Xcode 命令行工具)', {
|
||||
detail: (r.stderr || r.stdout).slice(0, 300),
|
||||
});
|
||||
}
|
||||
}
|
||||
return bin;
|
||||
}
|
||||
|
||||
export async function macScreenFind(args: Args): Promise<unknown> {
|
||||
assertDarwin('screen.find');
|
||||
const text = args['text'] as string;
|
||||
if (!text) {
|
||||
// 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));
|
||||
const region = args['region'] as { x: number; y: number; width: number; height: number } | undefined;
|
||||
|
||||
const bin = await ensureOcrBin();
|
||||
const { mkdtempSync, rmSync } = await import('node:fs');
|
||||
const { tmpdir } = await import('node:os');
|
||||
const dir = mkdtempSync(join(tmpdir(), 'na-ocr-'));
|
||||
const shot = join(dir, 'shot.png');
|
||||
|
||||
const captureRegion = region ?? (await fullVirtualScreen());
|
||||
const startedAt = Date.now();
|
||||
let matches: Array<Record<string, unknown>> = [];
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
const cap = await execCommand({
|
||||
command: `screencapture -x -t png -R ${captureRegion.x},${captureRegion.y},${captureRegion.width},${captureRegion.height} ${JSON.stringify(shot)}`,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
if (cap.exit_code !== 0) {
|
||||
throw new CapabilityError(
|
||||
ErrorCodes.EXECUTION_FAILED,
|
||||
'截屏失败(需「屏幕录制」权限)',
|
||||
{ detail: (cap.stderr || '').slice(0, 200) },
|
||||
);
|
||||
}
|
||||
// Retina 换算:图片像素 / 请求区域点数 = 缩放比(通常 2)
|
||||
const img = pngSize(shot);
|
||||
const scale =
|
||||
img && captureRegion.width > 0 ? img.width / captureRegion.width : 1;
|
||||
|
||||
const r = await execCommand({
|
||||
command: `${JSON.stringify(bin)} ${JSON.stringify(shot)} ${JSON.stringify(text)}`,
|
||||
timeoutMs: 60_000,
|
||||
maxOutputBytes: 4 * 1024 * 1024,
|
||||
});
|
||||
if (r.exit_code !== 0) {
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, 'OCR 失败', {
|
||||
detail: (r.stderr || r.stdout).slice(0, 300),
|
||||
});
|
||||
}
|
||||
const start = r.stdout.indexOf('[');
|
||||
const end = r.stdout.lastIndexOf(']');
|
||||
const raw =
|
||||
start >= 0 && end > start
|
||||
? (JSON.parse(r.stdout.slice(start, end + 1)) as Array<{
|
||||
text: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
confidence: number;
|
||||
}>)
|
||||
: [];
|
||||
matches = raw.slice(0, limit).map((m) => {
|
||||
// 先由像素换算回逻辑点,再叠加区域偏移 -> 得到可直接用于点击的屏幕坐标
|
||||
const px = m.x / scale;
|
||||
const py = m.y / scale;
|
||||
const pw = m.w / scale;
|
||||
const ph = m.h / scale;
|
||||
return {
|
||||
name: m.text,
|
||||
control_type: 'Text(ocr)',
|
||||
x: Math.round(px + pw / 2 + captureRegion.x),
|
||||
y: Math.round(py + ph / 2 + captureRegion.y),
|
||||
left: Math.round(px + captureRegion.x),
|
||||
top: Math.round(py + captureRegion.y),
|
||||
width: Math.round(pw),
|
||||
height: Math.round(ph),
|
||||
};
|
||||
});
|
||||
if (matches.length > 0 || Date.now() - startedAt >= waitMs) break;
|
||||
await new Promise((res) => setTimeout(res, intervalMs));
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* 忽略清理失败 */
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
matches,
|
||||
engine: 'ocr',
|
||||
waited_ms: Date.now() - startedAt,
|
||||
note: 'macOS 无 UIA 等价物,统一走 Vision OCR(只识别可见内容);坐标已由 Retina 像素换算为逻辑点',
|
||||
};
|
||||
}
|
||||
|
||||
/** 取整块虚拟屏的尺寸(多屏取并集)。 */
|
||||
async function fullVirtualScreen(): Promise<{ x: number; y: number; width: number; height: number }> {
|
||||
const r = await runOsa(
|
||||
`tell application "Finder" to get bounds of window of desktop`,
|
||||
10_000,
|
||||
);
|
||||
if (r.ok) {
|
||||
const m = /(-?\d+),\s*(-?\d+),\s*(-?\d+),\s*(-?\d+)/.exec(r.stdout);
|
||||
if (m) {
|
||||
const [x1, y1, x2, y2] = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
|
||||
return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 };
|
||||
}
|
||||
}
|
||||
return { x: 0, y: 0, width: 1920, height: 1080 };
|
||||
}
|
||||
@@ -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,16 +1,32 @@
|
||||
import { CapabilityNames } from '@nodeagent/protocol';
|
||||
import type { AgentConfig } from '../config.js';
|
||||
import { systemInfo, systemStatus, processList, serviceList, shellExec, auditList, auditVerify, agentRestart } from './system.js';
|
||||
import { systemInfo, systemStatus, processList, serviceList, shellExec, auditList, auditVerify, 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';
|
||||
import { screenRecord } from './record.js';
|
||||
import { taskList, taskGet, taskKill } from './task.js';
|
||||
import { clipGet, clipSet } from './clipboard.js';
|
||||
import { eventWatch, eventUnwatch, eventList, eventPoll } from '../events.js';
|
||||
import { netApply, netConfirm, netStatus } from './network.js';
|
||||
import { audioGet, audioSet } from './audio.js';
|
||||
import { agentUpdate } from './selfupdate.js';
|
||||
import { mouseMove, mouseClick, mouseScroll, mouseDrag, keyType, keyPress, setInputPolicy } from './input.js';
|
||||
import { fsList, fsStat, fsRead, fsWrite, setFsRoots } from './fs.js';
|
||||
import { guiAwait } from './await.js';
|
||||
import { logQuery, monitorStart, monitorReport, monitorStop, monitorList, monitorDelete } from './monitor.js';
|
||||
|
||||
export type CapabilityHandler = (args: Record<string, unknown>) => Promise<unknown>;
|
||||
export interface HandlerContext {
|
||||
/** 连接标识(事件订阅归属 / 断开清理) */
|
||||
owner: string;
|
||||
/** 向该连接推送事件 */
|
||||
emit: (evt: unknown) => void;
|
||||
}
|
||||
|
||||
export type CapabilityHandler = (
|
||||
args: Record<string, unknown>,
|
||||
ctx?: HandlerContext,
|
||||
) => Promise<unknown>;
|
||||
|
||||
/**
|
||||
* 装配能力注册表。
|
||||
@@ -38,10 +54,24 @@ export function createCapabilityRegistry(cfg: AgentConfig): Record<string, Capab
|
||||
[CapabilityNames.MouseDrag]: mouseDrag,
|
||||
[CapabilityNames.KeyType]: keyType,
|
||||
[CapabilityNames.KeyPress]: keyPress,
|
||||
// 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 文件传输
|
||||
[CapabilityNames.FsList]: fsList,
|
||||
[CapabilityNames.FsStat]: fsStat,
|
||||
@@ -60,5 +90,20 @@ export function createCapabilityRegistry(cfg: AgentConfig): Record<string, Capab
|
||||
[CapabilityNames.TaskKill]: taskKill,
|
||||
[CapabilityNames.ClipGet]: clipGet,
|
||||
[CapabilityNames.ClipSet]: clipSet,
|
||||
// v19 拉取式自更新
|
||||
[CapabilityNames.AgentUpdate]: agentUpdate,
|
||||
// v17 音频控制
|
||||
[CapabilityNames.AudioGet]: audioGet,
|
||||
[CapabilityNames.AudioSet]: audioSet,
|
||||
// v16 网络变更两阶段提交
|
||||
[CapabilityNames.NetApply]: netApply,
|
||||
[CapabilityNames.NetConfirm]: netConfirm,
|
||||
[CapabilityNames.NetStatus]: netStatus,
|
||||
// v12 事件订阅
|
||||
[CapabilityNames.EventWatch]: (args, ctx) =>
|
||||
eventWatch(args, ctx ?? { owner: 'unknown', emit: () => undefined }),
|
||||
[CapabilityNames.EventUnwatch]: eventUnwatch,
|
||||
[CapabilityNames.EventList]: eventList,
|
||||
[CapabilityNames.EventPoll]: eventPoll,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* 输入注入的 **PowerShell 脚本生成**(纯函数,无副作用 —— 便于单测)。
|
||||
*
|
||||
* 独立成模块的原因:脚本生成逻辑(顺序、逆序释放、hold 插入、PostMessage 路由)
|
||||
* 以往只能靠真机验证;而它恰好是最容易写错(一行顺序错 → 组合键失效)的部分。
|
||||
* 现在由单测逐行断言,真机只需验证"最终是否生效"。
|
||||
*/
|
||||
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
|
||||
/** 键名 → 虚拟键码(字母/数字走 ASCII 映射,其余查表白名单)。 */
|
||||
export const VK_MAP: Record<string, number> = {
|
||||
ctrl: 0x11, control: 0x11, shift: 0x10, alt: 0x12, win: 0x5b, meta: 0x5b,
|
||||
enter: 0x0d, return: 0x0d, tab: 0x09, esc: 0x1b, escape: 0x1b, space: 0x20,
|
||||
backspace: 0x08, delete: 0x2e, del: 0x2e, insert: 0x2d, ins: 0x2d,
|
||||
up: 0x26, down: 0x28, left: 0x25, right: 0x27,
|
||||
home: 0x24, end: 0x23, pageup: 0x21, pagedown: 0x22,
|
||||
capslock: 0x14, printscreen: 0x2c,
|
||||
// 系统/编辑键补充
|
||||
pause: 0x13, break: 0x13,
|
||||
apps: 0x5d, menu: 0x5d,
|
||||
f1: 0x70, f2: 0x71, f3: 0x72, f4: 0x73, f5: 0x74, f6: 0x75,
|
||||
f7: 0x76, f8: 0x77, f9: 0x78, f10: 0x79, f11: 0x7a, f12: 0x7b,
|
||||
f13: 0x7c, f14: 0x7d, f15: 0x7e, f16: 0x7f, f17: 0x80, f18: 0x81,
|
||||
f19: 0x82, f20: 0x83, f21: 0x84, f22: 0x85, f23: 0x86, f24: 0x87,
|
||||
|
||||
// 媒体键(0xA6-0xB7 区段)—— 2026-10-05 补
|
||||
//
|
||||
// 为何必要:远程控媒体播放器(音乐 / 视频 / 浏览器内视频)时,
|
||||
// 若软件热键不可用(精简模式常不注册全局热键),系统媒体键是唯一
|
||||
// 与「窗口是否可见/被遮挡」无关的通道 —— 窗口最小化、被游戏全屏盖住时
|
||||
// 依然有效。实测:精简模式的播放器被全屏游戏遮挡时,媒体键仍能控制。
|
||||
media_play: 0xb3, media_pause: 0xb3, media_play_pause: 0xb3, play_pause: 0xb3,
|
||||
media_stop: 0xb2, stop: 0xb2,
|
||||
media_prev: 0xb1, media_previous: 0xb1, prev_track: 0xb1, prev: 0xb1,
|
||||
media_next: 0xb0, next_track: 0xb0, next: 0xb0,
|
||||
media_fast_forward: 0xb4, fast_forward: 0xb4,
|
||||
media_rewind: 0xb7, rewind: 0xb7,
|
||||
media_eject: 0xb2,
|
||||
volume_mute: 0xad, mute: 0xad,
|
||||
volume_down: 0xae,
|
||||
volume_up: 0xaf,
|
||||
|
||||
browser_back: 0xa6, browser_forward: 0xa7, browser_refresh: 0xa8, browser_stop: 0xa9, browser_search: 0xaa,
|
||||
launch_mail: 0xb4, launch_media: 0xb5, launch_app1: 0xb6, launch_app2: 0xb7,
|
||||
browser_favorites: 0xab, browser_home: 0xac,
|
||||
|
||||
// ---- 左右侧修饰键(v1.5.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.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,
|
||||
numpad_multiply: 0x6a, numpad_add: 0x6b, numpad_subtract: 0x6d, numpad_decimal: 0x6e, numpad_divide: 0x6f,
|
||||
numlock: 0x90, scrolllock: 0x91,
|
||||
numpad_enter: 0x0d,
|
||||
|
||||
// ---- OEM 符号键(v1.5.0,美式布局 VK 码)----
|
||||
// 必备理由:大量系统/应用快捷键就是"修饰键 + 符号键"(win+d 显示桌面、win+, 速览、
|
||||
// ctrl++ 放大、ctrl+- 缩小、win+. 表情面板),此前只能靠字母数字,覆盖不到。
|
||||
oem_1: 0xba, // : ;
|
||||
oem_2: 0xbf, // / ?
|
||||
oem_3: 0xc0, // ` ~
|
||||
oem_4: 0xdb, // [ {
|
||||
oem_5: 0xdc, // \ |
|
||||
oem_6: 0xdd, // ] }
|
||||
oem_7: 0xde, // ' "
|
||||
oem_8: 0xdf,
|
||||
oem_minus: 0xbd, // - _
|
||||
oem_plus: 0xbb, // + =
|
||||
oem_comma: 0xbc, // , <
|
||||
oem_period: 0xbe,// . >
|
||||
|
||||
// ---- 系统/IME 杂项(v1.5.0)----
|
||||
sleep: 0x5f, help: 0x2f, select: 0x29, execute: 0x2b, clear: 0x0c,
|
||||
separator: 0x6c, oem_clear: 0xfe,
|
||||
kana: 0x15, hangul: 0x15, kanji: 0x19, hanja: 0x19,
|
||||
convert: 0x1c, nonconvert: 0x1d, accept: 0x1e, modechange: 0x1f,
|
||||
};
|
||||
|
||||
/** 键名 → 虚拟键码。 */
|
||||
export function toVk(key: string): number {
|
||||
const k = key.trim().toLowerCase();
|
||||
if (VK_MAP[k] !== undefined) return VK_MAP[k]!;
|
||||
if (/^[a-z]$/.test(k)) return k.toUpperCase().charCodeAt(0);
|
||||
if (/^[0-9]$/.test(k)) return k.charCodeAt(0);
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的按键: ${key}`, {
|
||||
supported: [...Object.keys(VK_MAP), 'a-z', '0-9'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 媒体键名 → APPCOMMAND 取值。
|
||||
*
|
||||
* 取值见 Windows `APPCOMMAND_*` 常量。整条序列都是媒体键时改走 WM_APPCOMMAND
|
||||
* 投递(而非 SendInput 注入 VK)—— 前者能定向到指定进程且不受窗口遮挡影响。
|
||||
*
|
||||
* ⚠️ 音量键(volume_mute / volume_up / volume_down)**不在此表**:
|
||||
* WM_APPCOMMAND 的 lParam 低 16 位按 device 段解释,VOLUME_DOWN 与 PAUSE 的裸值相同(都是 1),
|
||||
* 放进表里会与 PAUSE 撞车。音量键走 VK 注入(0xAD-0xAF)更可靠 —— 系统会自行映射为
|
||||
* APPCOMMAND_VOLUME_*,故此处刻意不收录音量键,避免 device-bit 歧义。
|
||||
*/
|
||||
export const MEDIA_COMMANDS: Record<string, number> = {
|
||||
media_play: 14, play: 14,
|
||||
media_pause: 1, pause: 1,
|
||||
media_play_pause: 12, play_pause: 12,
|
||||
media_stop: 15, stop: 15,
|
||||
media_prev: 6, media_previous: 6, prev_track: 6, prev: 6,
|
||||
media_next: 7, next_track: 7, next: 7,
|
||||
media_fast_forward: 3, fast_forward: 3,
|
||||
media_rewind: 4, rewind: 4,
|
||||
media_close: 10, close: 10,
|
||||
};
|
||||
|
||||
export interface PressBodyOptions {
|
||||
/** 序列中相邻和弦之间的间隔毫秒 */
|
||||
gapMs: number;
|
||||
/** 按下后保持毫秒数(长按) */
|
||||
holdMs: number;
|
||||
/** route=post 时的目标进程 PID */
|
||||
targetPid: number;
|
||||
/** 投放路由 */
|
||||
route: 'foreground' | 'post';
|
||||
}
|
||||
|
||||
/** 判断整条序列是否都是媒体键。 */
|
||||
export function isAllMedia(chords: string[][]): boolean {
|
||||
return (
|
||||
chords.length > 0 &&
|
||||
chords.every((ch) => ch.length === 1 && MEDIA_COMMANDS[ch[0]!.trim().toLowerCase()] !== undefined)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成媒体键(WM_APPCOMMAND)投放脚本。
|
||||
* targetPid=0 → HWND_BROADCAST 广播;否则定向该进程全部顶层窗口。
|
||||
*/
|
||||
export function buildMediaBody(chords: string[][], opts: PressBodyOptions): string[] {
|
||||
const body: string[] = [];
|
||||
const pid = Math.max(0, Math.trunc(opts.targetPid));
|
||||
for (let i = 0; i < chords.length; i += 1) {
|
||||
const cmd = MEDIA_COMMANDS[chords[i]![0]!.trim().toLowerCase()]!;
|
||||
body.push(`[NAInput]::MediaCommand(${cmd}, ${pid})`);
|
||||
if (i < chords.length - 1 && opts.gapMs > 0) {
|
||||
body.push(`Start-Sleep -Milliseconds ${opts.gapMs}`);
|
||||
}
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成前台注入(SendInput Vk)脚本。
|
||||
*
|
||||
* 顺序至关重要:按下按给定顺序、**释放逆序** —— 否则 Windows 组合键状态机会淆
|
||||
* (例如 ctrl+c 若先释放 ctrl,绝大多数程序收不到"带 ctrl 的 c")。
|
||||
*/
|
||||
export function buildForegroundBody(chords: string[][], opts: PressBodyOptions): string[] {
|
||||
const body: string[] = [];
|
||||
for (let i = 0; i < chords.length; i += 1) {
|
||||
const chord = chords[i]!;
|
||||
if (chord.length === 0) continue;
|
||||
const vks = chord.map(toVk);
|
||||
for (const vk of vks) body.push(`[NAInput]::Vk(${vk}, $false)`);
|
||||
if (opts.holdMs > 0) body.push(`Start-Sleep -Milliseconds ${opts.holdMs}`);
|
||||
// 逆序释放,保证组合键正确
|
||||
for (const vk of [...vks].reverse()) body.push(`[NAInput]::Vk(${vk}, $true)`);
|
||||
if (i < chords.length - 1 && opts.gapMs > 0) {
|
||||
body.push(`Start-Sleep -Milliseconds ${opts.gapMs}`);
|
||||
}
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成后台定向投递(PostMessage)脚本。
|
||||
*
|
||||
* 与前台注入同样遵守"按下顺序 / 释放逆序";每和弦投递给目标进程全部顶层窗口。
|
||||
* C# 侧 PostChord(vks, targetPid, up):up=false → WM_KEYDOWN,up=true → WM_KEYUP。
|
||||
*/
|
||||
export function buildPostBody(chords: string[][], opts: PressBodyOptions): string[] {
|
||||
const body: string[] = [];
|
||||
const pid = Math.max(1, Math.trunc(opts.targetPid));
|
||||
for (let i = 0; i < chords.length; i += 1) {
|
||||
const vks = chords[i]!.map(toVk);
|
||||
if (vks.length === 0) continue;
|
||||
body.push(`[NAInput]::PostChord(@(${vks.join(', ')}), ${pid}, $false)`);
|
||||
if (opts.holdMs > 0) body.push(`Start-Sleep -Milliseconds ${opts.holdMs}`);
|
||||
// 逆序释放
|
||||
const rel = [...vks].reverse().join(', ');
|
||||
body.push(`[NAInput]::PostChord(@(${rel}), ${pid}, $true)`);
|
||||
if (i < chords.length - 1 && opts.gapMs > 0) {
|
||||
body.push(`Start-Sleep -Milliseconds ${opts.gapMs}`);
|
||||
}
|
||||
}
|
||||
return body;
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { CapabilityError, ErrorCodes, expandChords } from '@nodeagent/protocol';
|
||||
import {
|
||||
isAllMedia,
|
||||
buildMediaBody,
|
||||
buildForegroundBody,
|
||||
buildPostBody,
|
||||
} from './input-script.js';
|
||||
import { IS_WINDOWS, execCommand } from '../util/exec.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
@@ -79,13 +85,32 @@ public static class NAInput {
|
||||
SendInput(1, a, SIZE);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 注入单个 Unicode 码元(KEYEVENTF_UNICODE)。
|
||||
///
|
||||
/// 修正 2026-10-04:原实现用 char 逐字符注入,**BMP 外字符(代理对,如 emoji、
|
||||
/// 部分生僻字)被拆成两个孤立代理项**,目标应用收到的是非法序列 → 渲染为
|
||||
/// U+FFFD 或乱码。现改为按 UTF-16 码元成对注入。
|
||||
///
|
||||
/// 注意:KEYEVENTF_UNICODE 的扫描码位只有 16 位,故代理对(两个 16 位码元)
|
||||
/// 必须**各自单独**发送,无法合并 —— 这是 Windows 输入 API 的固有限制。
|
||||
/// </summary>
|
||||
public static void UnicodeChar(char c) {
|
||||
var a = new INPUT[2];
|
||||
a[0].type = 1; a[0].U.ki.wScan = c; a[0].U.ki.dwFlags = 0x0004;
|
||||
a[1].type = 1; a[1].U.ki.wScan = c; a[1].U.ki.dwFlags = 0x0006;
|
||||
a[0].type = 1; a[0].U.ki.wScan = c; a[0].U.ki.dwFlags = 0x0004; // KEYEVENTF_UNICODE
|
||||
a[1].type = 1; a[1].U.ki.wScan = c; a[1].U.ki.dwFlags = 0x0006; // + KEYUP
|
||||
SendInput(2, a, SIZE);
|
||||
}
|
||||
|
||||
/// <summary>注入完整字符串(按 UTF-16 码元,代理对自动成对处理)。</summary>
|
||||
public static void UnicodeString(string s, int intervalMs) {
|
||||
// 直接遍历 UTF-16 码元:代理对的两个码元会连续注入,效果等同输入该字符
|
||||
for (int i = 0; i < s.Length; i++) {
|
||||
UnicodeChar(s[i]);
|
||||
if (intervalMs > 0) System.Threading.Thread.Sleep(intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
public static void Vk(ushort vk, bool up) {
|
||||
var a = new INPUT[1];
|
||||
a[0].type = 1; a[0].U.ki.wVk = vk; a[0].U.ki.dwFlags = up ? 2u : 0u;
|
||||
@@ -109,6 +134,83 @@ public static class NAInput {
|
||||
var u = new INPUT[1]; u[0].type = 0; u[0].U.mi.dwFlags = upFlag;
|
||||
SendInput(1, u, SIZE);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 媒体控制(2026-10-05 补):经 WM_APPCOMMAND 广播到目标窗口。
|
||||
///
|
||||
/// 为何不只靠 VK 注入:媒体键用 SendInput 打进去后,**并非所有播放器都接收**
|
||||
/// —— 只有当前获得媒体会话焦点的窗口才会响应。而 WM_APPCOMMAND 可直接
|
||||
/// 投递给指定 hwnd,绕开「谁是当前媒体会话焦点」这层不确定性。
|
||||
///
|
||||
/// 用途:远程控制主流媒体播放器(音乐 / 视频)或浏览器视频,且**窗口最小化
|
||||
/// 或被全屏独占应用遮挡时依然有效**(实测:精简模式的播放器被全屏游戏压住时该通道可用)。
|
||||
/// </summary>
|
||||
[DllImport("user32.dll", CharSet = CharSet.Auto)]
|
||||
private static extern bool PostMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool EnumWindows(EnumProc cb, IntPtr p);
|
||||
private delegate bool EnumProc(IntPtr h, IntPtr p);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
|
||||
|
||||
/// <summary>
|
||||
/// 向目标窗口(或其全部顶层窗口)发媒体控制命令。
|
||||
/// appCommand 取值:14=PLAY 15=STOP 12=PLAY_PAUSE 6=PREV 7=NEXT
|
||||
/// 3=FFWD 4=REWIND 10=CLOSE 0=PLAY 1=PAUSE
|
||||
/// targetPid 非 0 时,自动找到该进程的所有顶层窗口并逐个投递
|
||||
/// (部分播放器会创建数十个辅助窗口,只投主窗口常常无效)。
|
||||
/// </summary>
|
||||
public static int MediaCommand(int appCommand, int targetPid) {
|
||||
int sent = 0;
|
||||
if (targetPid == 0) {
|
||||
// HWND_BROADCAST:广播给所有顶层窗口
|
||||
if (PostMessage((IntPtr)0xFFFF, 0x0319, IntPtr.Zero, (IntPtr)appCommand)) sent++;
|
||||
return sent;
|
||||
}
|
||||
var handles = new System.Collections.Generic.List<IntPtr>();
|
||||
EnumWindows(delegate(IntPtr h, IntPtr p) {
|
||||
uint wpid;
|
||||
GetWindowThreadProcessId(h, out wpid);
|
||||
if (wpid == (uint)targetPid) handles.Add(h);
|
||||
return true;
|
||||
}, IntPtr.Zero);
|
||||
foreach (var h in handles) {
|
||||
if (PostMessage(h, 0x0319, IntPtr.Zero, (IntPtr)appCommand)) sent++;
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// v1.5.0:向目标进程的**全部顶层窗口** PostMessage 键盘消息(后端定向快捷键投放)。
|
||||
///
|
||||
/// 场景:目标窗口不在前台(被遮挡 / 最小化 / 聊天窗口在后台),又要给它发快捷键。
|
||||
/// WM_KEYDOWN=0x0100,WM_KEYUP=0x0101;修饰键按顺序投递,调用方负责释放顺序。
|
||||
///
|
||||
/// ⚠️ 已知局限(务必告知调用方):
|
||||
/// - 部分程序不处理**后台**键盘消息(游戏、部分浏览器与输入型应用直接忽略);
|
||||
/// - PostMessage 不带 scancode,依赖 lParam 解码按键的程序可能取不到键。
|
||||
/// 该类目标请改用默认 route=foreground(前台注入,需目标可见)。
|
||||
/// </summary>
|
||||
public static int PostChord(ushort[] vks, int targetPid, bool up) {
|
||||
int sent = 0;
|
||||
if (vks == null || vks.Length == 0) return 0;
|
||||
var handles = new System.Collections.Generic.List<IntPtr>();
|
||||
EnumWindows(delegate(IntPtr h, IntPtr p) {
|
||||
uint wpid;
|
||||
GetWindowThreadProcessId(h, out wpid);
|
||||
if (wpid == (uint)targetPid) handles.Add(h);
|
||||
return true;
|
||||
}, IntPtr.Zero);
|
||||
uint msg = up ? 0x0101u : 0x0100u;
|
||||
foreach (var h in handles) {
|
||||
foreach (var vk in vks) {
|
||||
if (PostMessage(h, msg, (IntPtr)vk, IntPtr.Zero)) sent++;
|
||||
}
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
}`;
|
||||
|
||||
/** 组装:加类型定义 + 具体操作。 */
|
||||
@@ -118,13 +220,14 @@ function psScript(body: string[]): string {
|
||||
);
|
||||
}
|
||||
|
||||
async function runPs(body: string[], timeoutMs = 30_000): Promise<void> {
|
||||
async function runPs(body: string[], timeoutMs = 30_000): Promise<string> {
|
||||
const r = await execCommand({ command: psScript(body), timeoutMs });
|
||||
if (r.exit_code !== 0) {
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '输入注入失败', {
|
||||
detail: (r.stderr || r.stdout).slice(0, 400),
|
||||
});
|
||||
}
|
||||
return r.stdout ?? '';
|
||||
}
|
||||
|
||||
// ---------------- 鼠标 ----------------
|
||||
@@ -237,29 +340,6 @@ export async function mouseDrag(args: Args): Promise<unknown> {
|
||||
return { dragged: true, from: { x: x1, y: y1 }, to: { x: x2, y: y2 }, button, steps };
|
||||
}
|
||||
|
||||
// ---------------- 键盘 ----------------
|
||||
const VK_MAP: Record<string, number> = {
|
||||
ctrl: 0x11, control: 0x11, shift: 0x10, alt: 0x12, win: 0x5b, meta: 0x5b,
|
||||
enter: 0x0d, return: 0x0d, tab: 0x09, esc: 0x1b, escape: 0x1b, space: 0x20,
|
||||
backspace: 0x08, delete: 0x2e, del: 0x2e, insert: 0x2d, ins: 0x2d,
|
||||
up: 0x26, down: 0x28, left: 0x25, right: 0x27,
|
||||
home: 0x24, end: 0x23, pageup: 0x21, pagedown: 0x22,
|
||||
capslock: 0x14, printscreen: 0x2c,
|
||||
f1: 0x70, f2: 0x71, f3: 0x72, f4: 0x73, f5: 0x74, f6: 0x75,
|
||||
f7: 0x76, f8: 0x77, f9: 0x78, f10: 0x79, f11: 0x7a, f12: 0x7b,
|
||||
};
|
||||
|
||||
/** 键名 → 虚拟键码(字母/数字走 ASCII 映射,其余查表白名单)。 */
|
||||
function toVk(key: string): number {
|
||||
const k = key.trim().toLowerCase();
|
||||
if (VK_MAP[k] !== undefined) return VK_MAP[k]!;
|
||||
if (/^[a-z]$/.test(k)) return k.toUpperCase().charCodeAt(0);
|
||||
if (/^[0-9]$/.test(k)) return k.charCodeAt(0);
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的按键: ${key}`, {
|
||||
supported: [...Object.keys(VK_MAP), 'a-z', '0-9'],
|
||||
});
|
||||
}
|
||||
|
||||
export async function keyType(args: Args): Promise<unknown> {
|
||||
assertAllowed('input.key.type');
|
||||
requireWindows('input.key.type');
|
||||
@@ -268,50 +348,122 @@ export async function keyType(args: Args): Promise<unknown> {
|
||||
|
||||
// 文本经 Base64 传入,避免任何形式的内容注入
|
||||
const b64 = Buffer.from(text, 'utf8').toString('base64');
|
||||
// 走 C# 的 UnicodeString:按 UTF-16 码元成对注入,代理对(emoji 等)不再被拆坏
|
||||
const body = [
|
||||
`$bytes = [Convert]::FromBase64String('${b64}')`,
|
||||
`$text = [System.Text.Encoding]::UTF8.GetString($bytes)`,
|
||||
'foreach ($ch in $text.ToCharArray()) {',
|
||||
' [NAInput]::UnicodeChar($ch)',
|
||||
interval > 0 ? ` Start-Sleep -Milliseconds ${interval}` : '',
|
||||
'}',
|
||||
].filter(Boolean);
|
||||
`[NAInput]::UnicodeString($text, ${Math.max(0, Math.round(interval))})`,
|
||||
];
|
||||
await runPs(body, 30_000 + text.length * (interval + 5));
|
||||
return { typed: true, length: text.length };
|
||||
// 回显注入长度与码元数:中文 1 字 = 1 码元,emoji = 2 码元
|
||||
// (若将来发现错字,可用 codepoints 辅助定位是 IME 抖动还是注入截断)
|
||||
return { typed: true, length: text.length, code_points: [...text].length, utf16_units: text.length };
|
||||
}
|
||||
|
||||
export async function keyPress(args: Args): Promise<unknown> {
|
||||
assertAllowed('input.key.press');
|
||||
requireWindows('input.key.press');
|
||||
|
||||
// v10.2:支持三种语义
|
||||
// 1) keys —— 单个和弦(如 ["ctrl","c"])
|
||||
// 2) repeat —— 和弦重复 N 次(解决「连按上键 3 次」这类需求)
|
||||
// 3) sequence —— 任意和弦序列(如 [["ctrl","c"], ["ctrl","v"]])
|
||||
const sequence = args['sequence'] as string[][] | undefined;
|
||||
const repeat = Math.max(1, Math.min(50, (args['repeat'] as number | undefined) ?? 1));
|
||||
const keys = args['keys'] as string[] | undefined;
|
||||
// v1.5.0:入参统一经 protocol 的 expandChords 展开 —— 字符串热键 / 预设名 / 老数组形式
|
||||
// 全部收敛成「归一化和弦序列」。混用多种形式会直接报错,避免"以谁为准"的歧义。
|
||||
let expanded;
|
||||
try {
|
||||
expanded = expandChords({
|
||||
hotkey: args['hotkey'] as string | undefined,
|
||||
hotkeys: args['hotkeys'] as string[] | undefined,
|
||||
preset: args['preset'] as string | undefined,
|
||||
presets: args['presets'] as string[] | undefined,
|
||||
keys: args['keys'] as string[] | undefined,
|
||||
sequence: args['sequence'] as string[][] | undefined,
|
||||
repeat: args['repeat'] as number | undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `快捷键参数错误: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
const chords = expanded.chords;
|
||||
const presets = expanded.presets;
|
||||
|
||||
const chords: string[][] = sequence?.length
|
||||
? sequence
|
||||
: keys?.length
|
||||
? Array.from({ length: repeat }, () => keys)
|
||||
: [];
|
||||
if (chords.length === 0) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, '需提供 keys(可配 repeat)或 sequence');
|
||||
const gap = Math.max(0, Math.min(2000, (args['interval_ms'] as number) ?? 40));
|
||||
// v1.5.0:长按(按下到释放的保持时长),用于"长按音量/持续按住方向键"等场景
|
||||
const holdMs = Math.max(0, Math.min(5000, Math.round((args['hold_ms'] as number | undefined) ?? 0)));
|
||||
// v1.5.0:投放路由。foreground = SendInput 注入当前焦点(默认,旧行为);
|
||||
// post = 向目标进程全部顶层窗口 PostMessage(窗口在后台时可用,但兼容性差)
|
||||
const route = (args['route'] as string | undefined) ?? 'foreground';
|
||||
if (route !== 'foreground' && route !== 'post') {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的 route: ${route}`, { allowed: ['foreground', 'post'] });
|
||||
}
|
||||
const pid = (args['target_pid'] as number) ?? 0;
|
||||
if (route === 'post' && pid <= 0) {
|
||||
throw new CapabilityError(
|
||||
ErrorCodes.PARAM_INVALID,
|
||||
'route=post 需要提供 target_pid(≥1)—— 后台投递必须知道投给哪个进程',
|
||||
{ hint: '用 window.list 取目标进程 PID;无目标时请用默认 route=foreground' },
|
||||
);
|
||||
}
|
||||
|
||||
const body: string[] = [];
|
||||
const gap = Math.max(0, Math.min(2000, (args['interval_ms'] as number | undefined) ?? 40));
|
||||
for (let i = 0; i < chords.length; i += 1) {
|
||||
const chord = chords[i]!;
|
||||
if (chord.length === 0) continue;
|
||||
const vks = chord.map(toVk);
|
||||
for (const vk of vks) body.push(`[NAInput]::Vk(${vk}, $false)`);
|
||||
// 逆序释放,保证组合键正确
|
||||
for (const vk of [...vks].reverse()) body.push(`[NAInput]::Vk(${vk}, $true)`);
|
||||
if (i < chords.length - 1 && gap > 0) body.push(`Start-Sleep -Milliseconds ${gap}`);
|
||||
// 媒体键走 WM_APPCOMMAND 通道(2026-10-05 补)
|
||||
//
|
||||
// 仅当**整条序列都是媒体键**时启用。若混在普通按键里(如 ["ctrl","media_play_pause"])
|
||||
// 则回退到 VK 注入 —— 那种组合场景本就罕见,SendInput 足够。
|
||||
const allMedia = isAllMedia(chords);
|
||||
if (allMedia) {
|
||||
// target_pid 非 0 时定向投递到该进程的全部顶层窗口(部分播放器会建数十个辅助窗口,
|
||||
// 只投主窗口常常无效 —— 实测踩过);否则广播。
|
||||
const mediaBody = buildMediaBody(chords, { gapMs: gap, holdMs, targetPid: pid, route });
|
||||
await runPs(mediaBody);
|
||||
return {
|
||||
pressed: true,
|
||||
keys: chords.length === 1 ? chords[0] : undefined,
|
||||
chords,
|
||||
times: chords.length,
|
||||
presets,
|
||||
via: expanded.via,
|
||||
channel: 'appcommand',
|
||||
target_pid: pid || null,
|
||||
};
|
||||
}
|
||||
await runPs(body);
|
||||
return { pressed: true, chords, times: chords.length };
|
||||
|
||||
// v1.5.0:后端定向投递(route=post)—— 目标窗口不在前台时使用
|
||||
if (route === 'post') {
|
||||
let sent = 0;
|
||||
const out = await runPs(buildPostBody(chords, { gapMs: gap, holdMs, targetPid: pid, route }));
|
||||
// 累计投递成功的窗口数(C# 侧每次 PostMessage 成功计数)
|
||||
let total = 0;
|
||||
for (const line of out.split('\n')) {
|
||||
const m = /(\d+)/.exec(line.trim());
|
||||
if (m) total += Number(m[1]);
|
||||
}
|
||||
sent = total;
|
||||
return {
|
||||
pressed: true,
|
||||
keys: chords.length === 1 ? chords[0] : undefined,
|
||||
chords,
|
||||
times: chords.length,
|
||||
presets,
|
||||
via: expanded.via,
|
||||
channel: 'postmessage',
|
||||
route,
|
||||
target_pid: pid,
|
||||
sent_windows: sent,
|
||||
note:
|
||||
sent === 0
|
||||
? '未投递到任何窗口(进程可能已退出或没有任何顶层窗口)'
|
||||
: '后台投递兼容性有限:游戏/部分浏览器与输入型程序不响应后台键盘消息,' +
|
||||
'无效时改用 route=foreground 并确保目标窗口可见',
|
||||
};
|
||||
}
|
||||
|
||||
await runPs(buildForegroundBody(chords, { gapMs: gap, holdMs, targetPid: pid, route }));
|
||||
// 同时回传 keys(单和弦时的回显)与 chords(完整序列)。
|
||||
// 修正 2026-10-04:此前只回 chords,控制端 CLI 读 keys.join() 直接崩溃。
|
||||
return {
|
||||
pressed: true,
|
||||
keys: chords.length === 1 ? chords[0] : undefined,
|
||||
chords,
|
||||
times: chords.length,
|
||||
presets,
|
||||
via: expanded.via,
|
||||
hold_ms: holdMs || undefined,
|
||||
route,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,747 @@
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { IS_WINDOWS, execCommand } from '../util/exec.js';
|
||||
import { agentDir } from '../config.js';
|
||||
import { audit } from '../audit.js';
|
||||
import { extractPsError } from '../util/ps-helper.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* 网络变更两阶段提交(v16 / commit-confirm)。
|
||||
*
|
||||
* ## 解决的痛点(审查报告 §5.4「自断生路」悖论)
|
||||
* 远程改 IP / 切 DHCP 是**最容易把自己关在门外**的操作:改完若新地址连不上,
|
||||
* 控制端就再也进不来了,而现场又没人能救 —— 只能物理接触。
|
||||
*
|
||||
* ## 方案:仿网络设备的 commit-confirm(同 Cisco `reload in 5` 的思路)
|
||||
* ① 备份当前网络配置(netsh dump / ip addr 快照)
|
||||
* ② 应用变更
|
||||
* ③ **注册一个 OS 级的一次性计划任务**,到点自动执行回滚
|
||||
* —— 关键:任务由 OS 触发,**不依赖 agent 进程存活**(agent 被网络变更
|
||||
* 影响/重启甚至崩溃都照样回滚)
|
||||
* ④ 控制端(可能已在新地址上)调用 system.net.confirm 取消该任务 = 提交
|
||||
* 未确认 → 到点自动回滚 → 链路恢复原样,不会失联。
|
||||
*
|
||||
* ## 为什么用 S4U 计划任务
|
||||
* 网络变更最常发生在「无人值守」场景(无人登录)。S4U 无需保存密码且注销后仍可运行,
|
||||
* 正是这里需要的语义。
|
||||
*/
|
||||
|
||||
const NET_DIR = (): string => join(agentDir(), 'net');
|
||||
const TASK_PREFIX = 'nodeagent-netrollback-';
|
||||
/** 延时执行变更的秒数:留出时间让本次响应先回到调用方(连接随后会被变更切断) */
|
||||
const APPLY_TASK_PREFIX = 'nodeagent-netapply-';
|
||||
const APPLY_DELAY_MS = 3000;
|
||||
|
||||
export interface PendingRollback {
|
||||
task_name: string;
|
||||
created_at: number;
|
||||
rollback_at: number;
|
||||
seconds_left: number;
|
||||
interface: string;
|
||||
mode: string;
|
||||
backup_path: string;
|
||||
requested_by?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一段 PowerShell:**落地临时脚本 + -File**。
|
||||
*
|
||||
* ⚠️ 为什么不用 `-EncodedCommand`:真机实测同一段注册计划任务的脚本,
|
||||
* `-File` 稳定成功并回显 `Scheduled`;`-EncodedCommand` 下 PowerShell 把进度流
|
||||
* 以 CLIXML 写 stderr、且 stdout 拿不到预期输出,导致「明明成功却被判失败」,
|
||||
* 个别场景还直接报账号映射错误。计划任务类操作一律走 -File。
|
||||
*/
|
||||
async function runPsFile(script: string, timeoutMs: number, tag = 'ps'): Promise<{ exit_code: number; stdout: string; stderr: string }> {
|
||||
const dir = ensureDir();
|
||||
const path = join(dir, `${tag}-${Date.now()}.ps1`);
|
||||
writeFileSync(path, withBom(script), 'utf8');
|
||||
return execCommand({
|
||||
command: `powershell -NoProfile -ExecutionPolicy Bypass -File ${JSON.stringify(path)}`,
|
||||
timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成的 .ps1 **必须带 UTF-8 BOM**。
|
||||
*
|
||||
* ⚠️ 真机踩过:PowerShell 5.1 读**无 BOM** 的 .ps1 会按系统 ANSI(GBK) 解析,
|
||||
* 脚本里的非 ASCII 字符(如中文网卡名)会吃掉后面的闭合引号 →
|
||||
* 报「The string is missing the terminator」→ 计划任务退出码 1 且不留日志。
|
||||
* 所有落盘的 PowerShell 脚本一律经此函数。
|
||||
*/
|
||||
export function withBom(content: string): string {
|
||||
return `\uFEFF${content}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把字符串包成 PowerShell **单引号字面量**。
|
||||
*
|
||||
* ⚠️ 单引号内一切都是字面量:**反斜杠不需要任何转义**。
|
||||
* 曾经用 JSON.stringify(双引号)或手动把 \ 翻倍当转义,结果生成 `C:\\Users\\...`
|
||||
* 这种非法路径 → Out-File 失败 → 计划任务退出码 1 且不留日志(真机踩过)。
|
||||
* 需要字面量时一律走本函数;需要插值时用双引号但避免反斜杠。
|
||||
*/
|
||||
export function psLit(v: string): string {
|
||||
return `'${v.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成「注册一次性计划任务」的 PowerShell 脚本(唯一实现,两处复用)。
|
||||
*
|
||||
* ⚠️ 两个真机教训都固化在这里:
|
||||
* 1) **账号名必须用 WindowsIdentity 取**,不要用 "$env:USERDOMAIN\$env:USERNAME" 拼 ——
|
||||
* 在 TS 模板串里 `\$` 会被当转义吃掉反斜杠,拼出「机器名+纯数字」这种不可解析的账号,
|
||||
* Register-ScheduledTask 报 HRESULT 0x80070534「No mapping between account names...」。
|
||||
* 2) 复用同一实现:此前回滚/延时两处各写一份,改了一处漏了另一处(真机踩过)。
|
||||
*/
|
||||
export function buildTaskRegistration(taskName: string, scriptPath: string, atIso: string): string {
|
||||
return [
|
||||
`$ErrorActionPreference='Stop'`,
|
||||
`$a = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-NoProfile -ExecutionPolicy Bypass -File "${scriptPath}"'`,
|
||||
`$t = New-ScheduledTaskTrigger -Once -At ([datetime]::Parse('${atIso}'))`,
|
||||
`$s = New-ScheduledTaskSettingsSet -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Minutes 5)`,
|
||||
// 用权威身份,绝不拼环境变量(见函数注释)
|
||||
`$me = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name`,
|
||||
`$p = New-ScheduledTaskPrincipal -UserId $me -RunLevel Highest -LogonType S4U`,
|
||||
`Register-ScheduledTask -TaskName '${taskName}' -Action $a -Trigger $t -Settings $s -Principal $p -Force | Out-Null`,
|
||||
`Write-Output 'Scheduled'`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function ensureDir(): string {
|
||||
const d = NET_DIR();
|
||||
mkdirSync(d, { recursive: true });
|
||||
return d;
|
||||
}
|
||||
|
||||
/** 从计划任务名解析待确认项(任务名自带时间戳)。 */
|
||||
function pendingFromTasks(taskNames: string[]): Array<{ task_name: string; rollback_at: number }> {
|
||||
return taskNames
|
||||
.filter((t) => t.startsWith(TASK_PREFIX))
|
||||
.map((t) => {
|
||||
const ms = Number(t.slice(TASK_PREFIX.length));
|
||||
return { task_name: t, rollback_at: Number.isFinite(ms) ? ms : 0 };
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- 平台实现 ----------------
|
||||
|
||||
/** 找出承载默认路由的网卡名(未显式指定 interface 时使用)。 */
|
||||
async function defaultInterfaceWindows(): Promise<string> {
|
||||
const ifaceScript =
|
||||
`$c = Get-NetIPConfiguration | Where-Object { $_.IPv4DefaultGateway } | Select-Object -First 1; ` +
|
||||
`if ($c) { $c.InterfaceAlias } else { '' }`;
|
||||
const r = await execCommand({
|
||||
command: `powershell -NoProfile -EncodedCommand ${Buffer.from(ifaceScript, 'utf16le').toString('base64')}`,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
const name = r.stdout.trim();
|
||||
if (!name) {
|
||||
throw new CapabilityError(
|
||||
ErrorCodes.EXECUTION_FAILED,
|
||||
'无法自动识别默认网卡(没有带默认网关的接口),请显式传 interface',
|
||||
);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/** 掩码 → 前缀长度(255.255.255.0 → 24)。 */
|
||||
export function maskToPrefix(mask: string): number {
|
||||
const parts = mask.split('.');
|
||||
if (parts.length !== 4) return 24;
|
||||
let bits = 0;
|
||||
for (const p of parts) {
|
||||
const n = Number(p);
|
||||
if (!Number.isInteger(n) || n < 0 || n > 255) return 24;
|
||||
bits += n.toString(2).split('').filter((c) => c === '1').length;
|
||||
}
|
||||
return bits;
|
||||
}
|
||||
|
||||
/** 结构化快照(回滚脚本据此用**原生 cmdlet** 重建配置)。 */
|
||||
export interface IfaceSnapshot {
|
||||
alias: string;
|
||||
dhcp: boolean;
|
||||
ipv4: Array<{ ip: string; prefix: number }>;
|
||||
gateway: string[];
|
||||
dns: string[];
|
||||
}
|
||||
|
||||
async function snapshotIface(iface: string): Promise<IfaceSnapshot> {
|
||||
const script = `
|
||||
$ErrorActionPreference='Continue'
|
||||
$a = ${JSON.stringify(iface)}
|
||||
$o = [ordered]@{ alias = $a; dhcp = $false; ipv4 = @(); gateway = @(); dns = @() }
|
||||
$ipif = Get-NetIPInterface -InterfaceAlias $a -AddressFamily IPv4 -ErrorAction SilentlyContinue
|
||||
if ($ipif) { $o.dhcp = ($ipif.Dhcp -eq 'Enabled') }
|
||||
$o.ipv4 = @(Get-NetIPAddress -InterfaceAlias $a -AddressFamily IPv4 -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.PrefixOrigin -ne 'WellKnown' } |
|
||||
ForEach-Object { [ordered]@{ ip = $_.IPAddress; prefix = [int]$_.PrefixLength } })
|
||||
$o.gateway = @(Get-NetRoute -InterfaceAlias $a -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue |
|
||||
ForEach-Object { $_.NextHop })
|
||||
$o.dns = @(Get-DnsClientServerAddress -InterfaceAlias $a -AddressFamily IPv4 -ErrorAction SilentlyContinue |
|
||||
ForEach-Object { $_.ServerAddresses })
|
||||
$o | ConvertTo-Json -Compress -Depth 4
|
||||
`;
|
||||
const r = await execCommand({
|
||||
command: `powershell -NoProfile -EncodedCommand ${Buffer.from(script, 'utf16le').toString('base64')}`,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
const start = r.stdout.indexOf('{');
|
||||
const end = r.stdout.lastIndexOf('}');
|
||||
const fallback: IfaceSnapshot = { alias: iface, dhcp: false, ipv4: [], gateway: [], dns: [] };
|
||||
if (start < 0 || end <= start) return fallback;
|
||||
try {
|
||||
const j = JSON.parse(r.stdout.slice(start, end + 1)) as Record<string, unknown>;
|
||||
return {
|
||||
alias: iface,
|
||||
dhcp: j['dhcp'] === true,
|
||||
ipv4: (j['ipv4'] as Array<{ ip: string; prefix: number }>) ?? [],
|
||||
gateway: (j['gateway'] as string[]) ?? [],
|
||||
dns: (j['dns'] as string[]) ?? [],
|
||||
};
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/** 由结构化快照生成**原生 cmdlet** 回滚脚本(不用 netsh set —— 它在 Win11 上会挂起)。 */
|
||||
export function buildRollbackScript(snap: IfaceSnapshot, backupTxt: string, logPath: string): string {
|
||||
const a = JSON.stringify(snap.alias);
|
||||
const L = psLit(logPath);
|
||||
const lines = [
|
||||
`# nodeagent 自动回滚(生成于 ${new Date().toISOString()})`,
|
||||
`$ErrorActionPreference = 'Continue'`,
|
||||
`$log = ${L}`,
|
||||
`function W($m) { ("[" + (Get-Date -Format o) + "] " + $m) | Out-File -Append -Encoding utf8 $log }`,
|
||||
`W 'rollback begin'`,
|
||||
`try {`,
|
||||
];
|
||||
if (snap.dhcp) {
|
||||
lines.push(` Set-NetIPInterface -InterfaceAlias ${a} -Dhcp Enabled -ErrorAction SilentlyContinue`);
|
||||
lines.push(` Set-DnsClientServerAddress -InterfaceAlias ${a} -ResetServerAddresses -ErrorAction SilentlyContinue`);
|
||||
lines.push(` W 'restored: dhcp'`);
|
||||
} else {
|
||||
lines.push(` Set-NetIPInterface -InterfaceAlias ${a} -Dhcp Disabled -ErrorAction SilentlyContinue`);
|
||||
// 同样「先加后清 + 先清默认路由」——否则回滚本身也会踩 already exists(真机事故根因)
|
||||
for (const addr of snap.ipv4) {
|
||||
lines.push(
|
||||
` New-NetIPAddress -InterfaceAlias ${a} -IPAddress ${JSON.stringify(addr.ip)} -PrefixLength ${addr.prefix} -ErrorAction SilentlyContinue | Out-Null`,
|
||||
);
|
||||
}
|
||||
lines.push(` Get-NetRoute -InterfaceAlias ${a} -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | Remove-NetRoute -Confirm:$false -ErrorAction SilentlyContinue`);
|
||||
const keep = snap.ipv4.map((x) => JSON.stringify(x.ip)).join(',');
|
||||
lines.push(` $keep = @(${keep})`);
|
||||
lines.push(` Get-NetIPAddress -InterfaceAlias ${a} -AddressFamily IPv4 -ErrorAction SilentlyContinue |`);
|
||||
lines.push(` Where-Object { $keep -notcontains $_.IPAddress -and $_.PrefixOrigin -ne 'WellKnown' } |`);
|
||||
lines.push(` ForEach-Object { Remove-NetIPAddress -IPAddress $_.IPAddress -InterfaceAlias ${a} -Confirm:$false -ErrorAction SilentlyContinue }`);
|
||||
if (snap.gateway[0]) {
|
||||
lines.push(
|
||||
` New-NetRoute -InterfaceAlias ${a} -DestinationPrefix '0.0.0.0/0' -NextHop ${JSON.stringify(snap.gateway[0])} -ErrorAction SilentlyContinue | Out-Null`,
|
||||
);
|
||||
}
|
||||
if (snap.dns.length > 0) {
|
||||
lines.push(
|
||||
` Set-DnsClientServerAddress -InterfaceAlias ${a} -ServerAddresses ${snap.dns.map((d) => JSON.stringify(d)).join(',')} -ErrorAction SilentlyContinue`,
|
||||
);
|
||||
}
|
||||
lines.push(` W 'restored: static'`);
|
||||
}
|
||||
lines.push(`} catch { W ("rollback failed: " + $_) }`);
|
||||
lines.push(`W ('netsh dump for manual recovery: ' + ${psLit(backupTxt)})`);
|
||||
lines.push(`W 'rollback done'`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** 读取网卡当前地址(用于回报与审计)。 */
|
||||
async function readIfaceWindows(iface: string): Promise<Record<string, unknown>> {
|
||||
const script = `
|
||||
$ErrorActionPreference='Continue'
|
||||
$c = Get-NetIPConfiguration -InterfaceAlias ${JSON.stringify(iface)} -ErrorAction SilentlyContinue
|
||||
if (-not $c) { Write-Output '{}'; return }
|
||||
$o = [ordered]@{ interface = ${JSON.stringify(iface)} }
|
||||
$o.ipv4 = @($c.IPv4Address | ForEach-Object { $_.IPAddress })
|
||||
$o.prefix = @($c.IPv4Address | ForEach-Object { $_.PrefixLength })
|
||||
$o.gateway = @($c.IPv4DefaultGateway | ForEach-Object { $_.NextHop })
|
||||
$o.dns = @($c.DNSServer | Where-Object { $_.AddressFamily -eq 2 } | ForEach-Object { $_.ServerAddresses } | Select-Object -First 4)
|
||||
$dhcp = Get-NetIPInterface -InterfaceAlias ${JSON.stringify(iface)} -AddressFamily IPv4 -ErrorAction SilentlyContinue
|
||||
$o.dhcp = ($dhcp.Dhcp -eq 'Enabled')
|
||||
$o | ConvertTo-Json -Compress
|
||||
`;
|
||||
const r = await execCommand({
|
||||
command: `powershell -NoProfile -EncodedCommand ${Buffer.from(script, 'utf16le').toString('base64')}`,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
const start = r.stdout.indexOf('{');
|
||||
const end = r.stdout.lastIndexOf('}');
|
||||
if (start < 0 || end <= start) return { interface: iface };
|
||||
try {
|
||||
return JSON.parse(r.stdout.slice(start, end + 1)) as Record<string, unknown>;
|
||||
} catch {
|
||||
return { interface: iface };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- system.net.apply ----------------
|
||||
|
||||
export async function netApply(args: Args): Promise<unknown> {
|
||||
const mode = (args['mode'] as string | undefined) ?? 'static';
|
||||
const confirmWithinMs = Math.max(
|
||||
15_000,
|
||||
Math.min(600_000, (args['confirm_within_ms'] as number | undefined) ?? 60_000),
|
||||
);
|
||||
const explicitIface = args['interface'] as string | undefined;
|
||||
const customCommand = args['command'] as string | undefined;
|
||||
|
||||
const dir = ensureDir();
|
||||
const ts = Date.now();
|
||||
|
||||
if (!IS_WINDOWS) {
|
||||
// POSIX:只支持「自定义命令 + 分离式回滚」——静态/DHCP 自动化涉及 nmcli/netplan
|
||||
// 差异过大,未做(避免给出不可靠的自动化承诺)。
|
||||
if (!customCommand) {
|
||||
throw new CapabilityError(
|
||||
ErrorCodes.UNSUPPORTED_PLATFORM,
|
||||
'macOS/Linux 被控端仅支持 mode=command(自定义命令);静态/DHCP 自动化当前仅 Windows',
|
||||
{ platform: process.platform },
|
||||
);
|
||||
}
|
||||
return posixCommandApply(customCommand, confirmWithinMs, dir, ts);
|
||||
}
|
||||
|
||||
const iface = explicitIface ?? (await defaultInterfaceWindows());
|
||||
const before = await readIfaceWindows(iface);
|
||||
|
||||
// ① 备份:netsh 导出的脚本可用 `netsh -f` 原样恢复
|
||||
const backupPath = join(dir, `backup-${ts}.txt`);
|
||||
// ⚠️ 关键:netsh 输出的是 **OEM 代码页(中文系统=GBK)字节**。经 PowerShell 重定向会被
|
||||
// 转码破坏,导致回滚时 `netsh -f` 解析失败(报「找不到下列命令」)—— 也就是说
|
||||
// 回滚看似执行、实际什么都没恢复(真机踩过)。必须走 cmd 的原始重定向保字节。
|
||||
const dump = await execCommand({
|
||||
command: `cmd.exe /c netsh -c interface dump > "${backupPath}"`,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
if (dump.exit_code !== 0 || !existsSync(backupPath)) {
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '网络配置备份失败,已中止(不做无备份的变更)', {
|
||||
detail: (dump.stderr || dump.stdout).slice(0, 300),
|
||||
});
|
||||
}
|
||||
|
||||
// ② 快照 + 生成回滚脚本(与 agent 进程解耦,由计划任务触发)
|
||||
// ⚠️ 不用「netsh -f 备份」回滚:备份里是 `set address`,而该命令在 Win11 上会**挂起**
|
||||
// (真机实测 >60s 无响应)—— 那样回滚等于没有。改为按结构化快照用原生 cmdlet 重建。
|
||||
const snapshot = await snapshotIface(iface);
|
||||
const rollbackLog = join(dir, `rollback-${ts}.log`);
|
||||
const rollbackPath = join(dir, `rollback-${ts}.ps1`);
|
||||
writeFileSync(rollbackPath, withBom(buildRollbackScript(snapshot, backupPath, rollbackLog)), 'utf8');
|
||||
|
||||
// ③ 先注册一次性回滚任务(OS 级,S4U —— 不依赖登录会话与 agent 进程)
|
||||
// **先武装后开火**:即使 apply 慢或挂起,保护也已经就位。
|
||||
// 任务名编码的是**回滚时间戳**(不是创建时间),status 可直接算出剩余秒数。
|
||||
// 回滚时间 = 现在 + 延时执行 + 确认窗口(窗口从变更真正生效时算起)
|
||||
const at = new Date(Date.now() + APPLY_DELAY_MS + confirmWithinMs);
|
||||
const taskName = `${TASK_PREFIX}${at.getTime()}`;
|
||||
const register = buildTaskRegistration(taskName, rollbackPath, at.toISOString());
|
||||
const reg = await runPsFile(register, 60_000, 'reg-rollback');
|
||||
if (!reg.stdout.includes('Scheduled')) {
|
||||
throw new CapabilityError(
|
||||
ErrorCodes.EXECUTION_FAILED,
|
||||
'**回滚任务注册失败,已中止变更**(宁可不变更也不做无保护的网络改动)',
|
||||
{
|
||||
detail: `exit=${reg.exit_code} ` + (extractPsError(reg.stderr) || reg.stdout.slice(0, 400) || '(无输出)'),
|
||||
backup_path: backupPath,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ④ 应用变更 —— **异步执行**,先应答再动手。
|
||||
//
|
||||
// 为什么必须异步:改地址/切 DHCP 必然导致本控制端与被控端的连接中断(旧地址被移除),
|
||||
// 同步执行的话调用方永远收不到返回值(真机实测:客户端 200s 超时,而变更其实已生效)。
|
||||
// 与网络设备的 commit-confirm 一致:先排程,交出凭据,由调用方在新地址上确认。
|
||||
const applyCmd = buildApplyCommand(mode, iface, args, snapshot);
|
||||
const applyScriptPath = join(dir, `apply-${ts}.ps1`);
|
||||
writeFileSync(
|
||||
applyScriptPath,
|
||||
withBom([
|
||||
`$ErrorActionPreference='Stop'`,
|
||||
`$log = ${psLit(join(dir, `apply-${ts}.log`))}`,
|
||||
`try {`,
|
||||
...applyCmd.split('\n').map((l) => ` ${l}`),
|
||||
` "apply ok" | Out-File -Append -Encoding utf8 $log`,
|
||||
`} catch { ("apply failed: " + $_) | Out-File -Append -Encoding utf8 $log }`,
|
||||
// 自检 + 失败自愈(没有它就会静默把机器留在无地址状态 —— 真机事故根因之一)
|
||||
buildSelfCheck(iface, mode === 'static' ? (args['ip'] as string) : null, rollbackPath),
|
||||
].join('\n')),
|
||||
'utf8',
|
||||
);
|
||||
const applyAt = new Date(Date.now() + APPLY_DELAY_MS);
|
||||
const applyTask = `${APPLY_TASK_PREFIX}${applyAt.getTime()}`;
|
||||
const regApply = buildTaskRegistration(applyTask, applyScriptPath, applyAt.toISOString());
|
||||
const regA = await runPsFile(regApply, 60_000, 'reg-apply');
|
||||
if (!regA.stdout.includes('Scheduled')) {
|
||||
// 排程失败:撤销已注册的回滚任务,保证状态干净
|
||||
await runPsFile(
|
||||
`Unregister-ScheduledTask -TaskName '${taskName}' -Confirm:$false -ErrorAction SilentlyContinue`,
|
||||
30_000,
|
||||
'unreg',
|
||||
);
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '变更排程失败,已撤销回滚任务(配置未改动)', {
|
||||
detail:
|
||||
`exit=${regA.exit_code} ` +
|
||||
(extractPsError(regA.stderr) || '(无 stderr) ') +
|
||||
` | out=${regA.stdout.replace(/\s+/g, ' ').slice(0, 200)}`,
|
||||
});
|
||||
}
|
||||
|
||||
audit({
|
||||
type: 'net.change',
|
||||
capability: 'system.net.apply',
|
||||
status: 'ok',
|
||||
reason:
|
||||
`mode=${mode} interface=${iface} apply_at=${applyAt.getTime()} rollback_at=${at.getTime()} ` +
|
||||
`backup=${backupPath} snapshot_dhcp=${snapshot.dhcp} snapshot_ip=${JSON.stringify(snapshot.ipv4)}`,
|
||||
});
|
||||
|
||||
return {
|
||||
apply_scheduled: true,
|
||||
apply_at: applyAt.getTime(),
|
||||
apply_task: applyTask,
|
||||
mode,
|
||||
interface: iface,
|
||||
backup_path: backupPath,
|
||||
rollback_script: rollbackPath,
|
||||
rollback_scheduled: true,
|
||||
task_name: taskName,
|
||||
rollback_at: at.getTime(),
|
||||
confirm_within_ms: confirmWithinMs,
|
||||
before: { ...snapshot, raw: before },
|
||||
hint:
|
||||
`变更将于约 ${APPLY_DELAY_MS / 1000}s 后执行(先放行本次响应);连接会随地址变更中断。` +
|
||||
`请在 ${new Date(at.getTime()).toLocaleTimeString('zh-CN')} 之前(若改了地址,用 nodeagent connect <新地址> --port ${'<端口>'} 重连后)` +
|
||||
`调用 system.net.confirm 确认提交;逾期自动回滚到变更前配置(${JSON.stringify(snapshot.ipv4)})。`,
|
||||
snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
/** 生成应用命令(PowerShell 片段)。 */
|
||||
/**
|
||||
* 生成应用脚本(PowerShell,**原生 NetTCPIP cmdlet**)。
|
||||
*
|
||||
* ⚠️ 不用 `netsh interface ipv4 set address`:真机实测它在 Windows 11 上会挂起(>60s 无返回),
|
||||
* 而 `Set-NetIPAddress` / `Set-NetIPInterface` / `Set-DnsClientServerAddress` 同场景 <1s。
|
||||
* netsh 仅保留用于「读取」与人工恢复用的 dump 备份(对操作者友好)。
|
||||
*/
|
||||
export function buildApplyCommand(mode: string, iface: string, args: Args, snap: IfaceSnapshot): string {
|
||||
const a = JSON.stringify(iface);
|
||||
if (mode === 'command') {
|
||||
const cmd = args['command'] as string | undefined;
|
||||
if (!cmd) throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'mode=command 需要 command 参数');
|
||||
return cmd;
|
||||
}
|
||||
if (mode === 'dhcp') {
|
||||
return [
|
||||
`$ErrorActionPreference='Stop'`,
|
||||
`Set-NetIPInterface -InterfaceAlias ${a} -Dhcp Enabled -ErrorAction Stop`,
|
||||
`Set-DnsClientServerAddress -InterfaceAlias ${a} -ResetServerAddresses -ErrorAction SilentlyContinue`,
|
||||
`Write-Output 'dhcp applied'`,
|
||||
].join('\n');
|
||||
}
|
||||
// static
|
||||
const ip = args['ip'] as string | undefined;
|
||||
const mask = args['mask'] as string | undefined;
|
||||
const gateway = args['gateway'] as string | undefined;
|
||||
if (!ip || !mask) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'mode=static 需要 ip 与 mask');
|
||||
}
|
||||
const prefix = maskToPrefix(mask);
|
||||
const dns = (args['dns'] as string[] | undefined) ?? [];
|
||||
const ipLit = JSON.stringify(ip);
|
||||
const lines = [
|
||||
`$ErrorActionPreference='Continue'`,
|
||||
// 关掉 DHCP(否则设静态地址会与 DHCP 租约打架)
|
||||
`Set-NetIPInterface -InterfaceAlias ${a} -Dhcp Disabled -ErrorAction SilentlyContinue`,
|
||||
// ⚠️ 顺序很关键:**先加新地址**(此时旧地址还在,链路不断)→ 再清旧地址与默认路由 → 最后补路由。
|
||||
// 曾经写成「先删后加」,结果 Windows 残留的默认路由让 New-NetIPAddress 报 already exists
|
||||
// → 新地址没加上 → 机器回落到 APIPA 彻底失联(真机事故,务必保留本顺序注释)。
|
||||
`New-NetIPAddress -InterfaceAlias ${a} -IPAddress ${ipLit} -PrefixLength ${prefix} -ErrorAction SilentlyContinue | Out-Null`,
|
||||
// 清掉旧的默认路由(不先清,后面 New-NetRoute 会因「实例已存在」失败)
|
||||
`Get-NetRoute -InterfaceAlias ${a} -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | Remove-NetRoute -Confirm:$false -ErrorAction SilentlyContinue`,
|
||||
// 清掉非目标地址(保留 APIPA/回环)
|
||||
`Get-NetIPAddress -InterfaceAlias ${a} -AddressFamily IPv4 -ErrorAction SilentlyContinue |`,
|
||||
` Where-Object { $_.IPAddress -ne ${ipLit} -and $_.PrefixOrigin -ne 'WellKnown' } |`,
|
||||
` ForEach-Object { Remove-NetIPAddress -IPAddress $_.IPAddress -InterfaceAlias ${a} -Confirm:$false -ErrorAction SilentlyContinue }`,
|
||||
];
|
||||
if (gateway) {
|
||||
lines.push(
|
||||
`New-NetRoute -InterfaceAlias ${a} -DestinationPrefix '0.0.0.0/0' -NextHop ${JSON.stringify(gateway)} -ErrorAction SilentlyContinue | Out-Null`,
|
||||
);
|
||||
}
|
||||
if (dns.length > 0) {
|
||||
lines.push(
|
||||
`Set-DnsClientServerAddress -InterfaceAlias ${a} -ServerAddresses ${dns.map((d) => JSON.stringify(d)).join(',')} -ErrorAction SilentlyContinue`,
|
||||
);
|
||||
}
|
||||
lines.push(`Write-Output 'static applied'`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 变更后的自检 + 失败自愈片段。
|
||||
* ⚠️ 这段是**事故换来的**:没有自检时,变更失败会静默把机器留在「无地址」状态,
|
||||
* 而调用方还以为变更成功了(真机事故:机器掉到 APIPA 彻底失联)。
|
||||
*/
|
||||
export function buildSelfCheck(iface: string, expectIp: string | null, rollbackScriptPath: string): string {
|
||||
const a = JSON.stringify(iface);
|
||||
const rb = JSON.stringify(rollbackScriptPath);
|
||||
const cond = expectIp
|
||||
? `$ok = @(Get-NetIPAddress -InterfaceAlias ${a} -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq ${JSON.stringify(expectIp)} }).Count -gt 0`
|
||||
: `$ok = @(Get-NetIPAddress -InterfaceAlias ${a} -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.PrefixOrigin -ne 'WellKnown' }).Count -gt 0`;
|
||||
return [
|
||||
``,
|
||||
`# ---- 自检:变更是否真的生效,失败立即回滚(自愈)----`,
|
||||
`Start-Sleep -Seconds 3`,
|
||||
cond,
|
||||
`if ($ok) {`,
|
||||
` Write-Output 'selfcheck ok'`,
|
||||
`} else {`,
|
||||
` Write-Output 'selfcheck FAILED -> auto rollback'`,
|
||||
` & ${rb}`,
|
||||
`}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
|
||||
/** POSIX:自定义命令 + 分离式回滚(sleep N 后执行回滚命令)。 */
|
||||
async function posixCommandApply(
|
||||
applyCommand: string,
|
||||
confirmWithinMs: number,
|
||||
dir: string,
|
||||
ts: number,
|
||||
): Promise<unknown> {
|
||||
const backupPath = join(dir, `backup-${ts}.txt`);
|
||||
const snap = await execCommand({ command: 'ip -o addr 2>/dev/null; echo ---; ip route 2>/dev/null', timeoutMs: 15_000 });
|
||||
writeFileSync(backupPath, snap.stdout, 'utf8');
|
||||
|
||||
// POSIX 恢复:用快照重放地址与路由(尽力而为 —— 各发行版差异大,故提示谨慎使用)
|
||||
// 注意:不要用 sed 反向引用(\1 在 TS 模板串里是八进制转义,编译期报错)—— 统一用 awk
|
||||
const rollbackCmd = [
|
||||
`# 尽力恢复:从快照重放 IPv4 地址与默认路由`,
|
||||
`awk '/inet /{dev=""; for(i=1;i<=NF;i++) if($i=="dev") dev=$(i+1); if(dev && $2!="127.0.0.1/8") print $2, dev}' '${backupPath}' | while read -r addr dev; do`,
|
||||
` ip addr add "$addr" dev "$dev" 2>/dev/null`,
|
||||
`done`,
|
||||
`awk '/^default/{via=""; dev=""; for(i=1;i<=NF;i++){ if($i=="via") via=$(i+1); if($i=="dev") dev=$(i+1) } if(via && dev) print via, dev}' '${backupPath}' | while read -r via dev; do`,
|
||||
` ip route add default via "$via" dev "$dev" 2>/dev/null`,
|
||||
`done`,
|
||||
].join('\n');
|
||||
const rollbackPath = join(dir, `rollback-${ts}.sh`);
|
||||
writeFileSync(
|
||||
rollbackPath,
|
||||
withBom([
|
||||
'#!/bin/sh',
|
||||
`# nodeagent 自动回滚(${new Date(ts).toISOString()})`,
|
||||
`sleep ${Math.round(confirmWithinMs / 1000)}`,
|
||||
`# 若确认文件存在则跳过回滚`,
|
||||
`[ -f '${join(dir, `confirmed-${ts}`)}' ] && exit 0`,
|
||||
rollbackCmd,
|
||||
].join('\n')),
|
||||
'utf8',
|
||||
);
|
||||
const launched = await execCommand({
|
||||
command: `chmod +x ${JSON.stringify(rollbackPath)} && setsid nohup sh ${JSON.stringify(rollbackPath)} > ${JSON.stringify(join(dir, `rollback-${ts}.log`))} 2>&1 & echo started`,
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
|
||||
const applied = await execCommand({ command: applyCommand, timeoutMs: 60_000 });
|
||||
audit({
|
||||
type: 'net.change',
|
||||
capability: 'system.net.apply',
|
||||
status: applied.exit_code === 0 ? 'ok' : 'failed',
|
||||
reason: `mode=command confirm_within=${confirmWithinMs}ms backup=${backupPath}`,
|
||||
});
|
||||
return {
|
||||
applied: applied.exit_code === 0,
|
||||
mode: 'command',
|
||||
backup_path: backupPath,
|
||||
rollback_script: rollbackPath,
|
||||
rollback_scheduled: launched.stdout.includes('started'),
|
||||
task_name: `posix-${ts}`,
|
||||
rollback_at: Date.now() + confirmWithinMs,
|
||||
confirm_within_ms: confirmWithinMs,
|
||||
hint:
|
||||
`请在 ${Math.round(confirmWithinMs / 1000)} 秒内调用 system.net.confirm(task_name="posix-${ts}") 确认;` +
|
||||
`逾期由分离进程执行回滚(依赖备份脚本可恢复,网络类变更的 POSIX 恢复能力弱于 Windows,请谨慎)`,
|
||||
apply_output: (applied.stdout || applied.stderr).trim().slice(0, 300),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------- system.net.confirm ----------------
|
||||
|
||||
export async function netConfirm(args: Args): Promise<unknown> {
|
||||
const taskName = args['task_name'] as string | undefined;
|
||||
const dir = NET_DIR();
|
||||
|
||||
if (!IS_WINDOWS) {
|
||||
// POSIX:写确认标记文件,回滚脚本会自行跳过
|
||||
if (!taskName) return { confirmed: 0, note: 'POSIX 需指定 task_name' };
|
||||
const ts = taskName.replace('posix-', '');
|
||||
writeFileSync(join(dir, `confirmed-${ts}`), String(Date.now()), 'utf8');
|
||||
audit({ type: 'net.change', capability: 'system.net.confirm', status: 'ok', reason: `confirmed ${taskName}` });
|
||||
return { confirmed: 1, cancelled: [taskName] };
|
||||
}
|
||||
|
||||
const listScript = `Get-ScheduledTask -TaskName '${TASK_PREFIX}*' -ErrorAction SilentlyContinue | Select-Object -ExpandProperty TaskName`;
|
||||
let targets: string[] = [];
|
||||
if (taskName) {
|
||||
targets = [taskName];
|
||||
} else {
|
||||
const r = await execCommand({
|
||||
command: `powershell -NoProfile -EncodedCommand ${Buffer.from(listScript, 'utf16le').toString('base64')}`,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
targets = r.stdout
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.startsWith(TASK_PREFIX));
|
||||
}
|
||||
if (targets.length === 0) {
|
||||
return { confirmed: 0, cancelled: [], note: '没有待确认的网络变更(可能已确认或已回滚)' };
|
||||
}
|
||||
|
||||
const unreg = [
|
||||
...targets.map((t) => `Unregister-ScheduledTask -TaskName '${t}' -Confirm:$false -ErrorAction SilentlyContinue`),
|
||||
// 顺带清理已执行过的 netapply 任务(一次性、已完成,留着只会让任务计划程序变乱)
|
||||
`Get-ScheduledTask -TaskName '${APPLY_TASK_PREFIX}*' -ErrorAction SilentlyContinue | Unregister-ScheduledTask -Confirm:$false -ErrorAction SilentlyContinue`,
|
||||
].join('\n');
|
||||
const r = await runPsFile(unreg, 60_000, 'unreg-all');
|
||||
const stillThere = await execCommand({
|
||||
command: `powershell -NoProfile -EncodedCommand ${Buffer.from(listScript, 'utf16le').toString('base64')}`,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
const remaining = stillThere.stdout
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.startsWith(TASK_PREFIX));
|
||||
|
||||
audit({
|
||||
type: 'net.change',
|
||||
capability: 'system.net.confirm',
|
||||
status: remaining.length === 0 ? 'ok' : 'failed',
|
||||
reason: `confirmed=[${targets.join(',')}]`,
|
||||
});
|
||||
return {
|
||||
confirmed: targets.length - remaining.length,
|
||||
cancelled: targets.filter((t) => !remaining.includes(t)),
|
||||
remaining,
|
||||
detail: (r.stderr || '').slice(0, 200),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------- system.net.status ----------------
|
||||
|
||||
export async function netStatus(_args: Args): Promise<unknown> {
|
||||
const dir = NET_DIR();
|
||||
let backups: Array<{ file: string; bytes: number; mtime: number }> = [];
|
||||
try {
|
||||
backups = readdirSync(dir)
|
||||
.filter((f) => f.startsWith('backup-'))
|
||||
.map((f) => {
|
||||
const p = join(dir, f);
|
||||
const st = readFileSync(p);
|
||||
return { file: p, bytes: st.length, mtime: 0 };
|
||||
});
|
||||
} catch {
|
||||
backups = [];
|
||||
}
|
||||
|
||||
if (!IS_WINDOWS) {
|
||||
const r = await execCommand({ command: 'ip -o addr 2>/dev/null | head -20', timeoutMs: 15_000 });
|
||||
return { platform: process.platform, addresses: r.stdout.trim().split('\n'), backups, pending: [] };
|
||||
}
|
||||
|
||||
const listScript = `Get-ScheduledTask -TaskName '${TASK_PREFIX}*' -ErrorAction SilentlyContinue | ForEach-Object { $_.TaskName + '|' + $_.State }`;
|
||||
const r = await execCommand({
|
||||
command: `powershell -NoProfile -EncodedCommand ${Buffer.from(listScript, 'utf16le').toString('base64')}`,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
const rows = r.stdout
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.startsWith(TASK_PREFIX));
|
||||
const now = Date.now();
|
||||
const pending: PendingRollback[] = rows.map((row) => {
|
||||
const [task_name, state] = row.split('|') as [string, string];
|
||||
const rollbackAt = Number(task_name.slice(TASK_PREFIX.length));
|
||||
return {
|
||||
task_name,
|
||||
created_at: rollbackAt,
|
||||
rollback_at: rollbackAt,
|
||||
seconds_left: Math.max(0, Math.round((rollbackAt - now) / 1000)),
|
||||
interface: '(见 task)',
|
||||
mode: '(见 task)',
|
||||
backup_path: '',
|
||||
requested_by: state,
|
||||
};
|
||||
});
|
||||
|
||||
// ⚠️ 必须用 -EncodedCommand:用 -Command "..." 时内层的 $_ 会被**外层 PowerShell** 展开,
|
||||
// 命令静默失效并返回空(真机踩过)。
|
||||
const addrScript =
|
||||
`Get-NetIPConfiguration | Where-Object { $_.NetAdapter.Status -eq 'Up' } | ` +
|
||||
`Select-Object InterfaceAlias, @{n='ip';e={$_.IPv4Address.IPAddress}}, @{n='gw';e={$_.IPv4DefaultGateway.NextHop}} | ` +
|
||||
`ConvertTo-Json -Compress`;
|
||||
const addr = await execCommand({
|
||||
command: `powershell -NoProfile -EncodedCommand ${Buffer.from(addrScript, 'utf16le').toString('base64')}`,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
// ⚠️ 只有一块网卡时 ConvertTo-Json 输出的是**对象**而不是数组 —— 只按 [...] 解析会
|
||||
// 在单网卡机器上永远返回空(真机踩过)。这里统一用首 { 到末 } 截取后按需包成数组。
|
||||
const s = addr.stdout.indexOf('{');
|
||||
const e = addr.stdout.lastIndexOf('}');
|
||||
let interfaces: unknown[] = [];
|
||||
if (s >= 0 && e > s) {
|
||||
try {
|
||||
const parsed = JSON.parse(addr.stdout.slice(s, e + 1)) as unknown;
|
||||
interfaces = Array.isArray(parsed) ? parsed : [parsed];
|
||||
} catch {
|
||||
interfaces = [];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
platform: process.platform,
|
||||
interfaces,
|
||||
pending,
|
||||
pending_count: pending.length,
|
||||
backups,
|
||||
note: pending.length > 0
|
||||
? '存在待确认的网络变更:请在逾期前调用 system.net.confirm;逾期会自动回滚'
|
||||
: '没有待确认的网络变更',
|
||||
};
|
||||
}
|
||||
|
||||
/** 清理旧的备份/回滚脚本(保留最近 N 份)。 */
|
||||
export function pruneNetArtifacts(keep = 10): number {
|
||||
const dir = NET_DIR();
|
||||
let removed = 0;
|
||||
try {
|
||||
const files = readdirSync(dir)
|
||||
.filter((f) => f.startsWith('backup-') || f.startsWith('rollback-'))
|
||||
.sort()
|
||||
.reverse();
|
||||
for (const f of files.slice(keep * 2)) {
|
||||
try {
|
||||
unlinkSync(join(dir, f));
|
||||
removed += 1;
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* 目录不存在 */
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { copyFileSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { IS_WINDOWS } from '../util/exec.js';
|
||||
import { agentDir } from '../config.js';
|
||||
import { audit } from '../audit.js';
|
||||
import { agentRestart } from './system.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* 拉取式自更新(v19):被控端**自己**从 URL 下载新版本并替换自身。
|
||||
*
|
||||
* ## 与 `nodeagent deploy`(推送式)的分工
|
||||
* - 推送式:控制端与被控端可达 → 逐块上传(简单、无需被控端能上网)
|
||||
* - 拉取式(本能力):两者不可达,但被控端能上网 → 从 GitHub Release / 自建镜像拉取
|
||||
* 典型场景:被控端在老家、控制端在出差地;或跨网段/穿 NAT 失败。
|
||||
*
|
||||
* ## 安全(必须严格遵守,否则等于开放远程代码执行)
|
||||
* 1. **哈希必须钉死**:`sha256` 必填,且**逐字节校验下载内容**后才替换。
|
||||
* 没有它,GitHub 账号被盗 / DNS 被污染 / 中间人 = 直接拿下被控端。
|
||||
* 2. 只接受 http/https;拒绝 file:// 等其它 scheme。
|
||||
* 3. 大小上限(默认 64MB)+ 下载超时,避免被喂垃圾撑爆磁盘。
|
||||
* 4. **先备份再原子替换**(同目录 rename),失败可回滚。
|
||||
* 5. `dry_run` 只下载校验、不动现有文件 —— 用于「先确认能不能更新」。
|
||||
* 6. 动作全程进审计(能力调用本身已记录 `invoke`,这里再加一条 `agent.update`)。
|
||||
*
|
||||
* ## 为什么不用 `fetch` 的代理能力
|
||||
* Node 内置 `fetch` 不支持 HTTP 代理(需要 undici 的 ProxyAgent,而 undici 未对外暴露)。
|
||||
* 国内访问 GitHub 不稳时,正确做法是**把 url 指向自建镜像 / 加速服务**(本能力接受任意
|
||||
* http(s) 地址,天然支持),而不是在 agent 里再塞一套代理隧道。
|
||||
*/
|
||||
|
||||
const MAX_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
/** 下载到内存(带大小上限与超时)。 */
|
||||
async function download(url: string, timeoutMs: number): Promise<Buffer> {
|
||||
const ac = new AbortController();
|
||||
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, { signal: ac.signal, redirect: 'follow' });
|
||||
if (!res.ok) {
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, `下载失败: HTTP ${res.status} ${res.statusText}`, {
|
||||
url,
|
||||
});
|
||||
}
|
||||
const declared = Number(res.headers.get('content-length') ?? '0');
|
||||
if (declared > MAX_BYTES) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `文件过大(${declared} 字节 > 上限 ${MAX_BYTES})`, { url });
|
||||
}
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) {
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
const parts: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) {
|
||||
total += value.length;
|
||||
if (total > MAX_BYTES) {
|
||||
await reader.cancel();
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `下载超过上限 ${MAX_BYTES} 字节,已中断`, { url });
|
||||
}
|
||||
parts.push(value);
|
||||
}
|
||||
}
|
||||
return Buffer.concat(parts);
|
||||
} catch (err) {
|
||||
if (err instanceof CapabilityError) throw err;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, `下载失败: ${msg}`, { url });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export async function agentUpdate(args: Args): Promise<unknown> {
|
||||
const url = args['url'] as string | undefined;
|
||||
const expect = (args['sha256'] as string | undefined)?.toLowerCase();
|
||||
const dryRun = args['dry_run'] === true;
|
||||
const doRestart = args['restart'] !== false;
|
||||
const timeoutMs = Math.max(5_000, Math.min(600_000, (args['timeout_ms'] as number | undefined) ?? 120_000));
|
||||
|
||||
if (!url) throw new CapabilityError(ErrorCodes.PARAM_INVALID, '需要 url(更新包地址)');
|
||||
if (!expect || !/^[0-9a-f]{12,64}$/.test(expect)) {
|
||||
throw new CapabilityError(
|
||||
ErrorCodes.PARAM_INVALID,
|
||||
'需要 sha256(12~64 位十六进制)。**这是安全底线**:不校验哈希等于开放远程代码执行',
|
||||
);
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `url 不是合法地址: ${url}`);
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `只支持 http/https,收到 ${parsed.protocol}`);
|
||||
}
|
||||
|
||||
const scriptPath = process.argv[1] ?? '';
|
||||
if (!scriptPath) {
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '无法定位自身入口路径,拒绝自更新');
|
||||
}
|
||||
|
||||
const dir = join(agentDir(), 'update');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
// ① 下载 + ② 校验哈希(先校验再落盘,避免半成品污染)
|
||||
const t0 = Date.now();
|
||||
const buf = await download(url, timeoutMs);
|
||||
const got = createHash('sha256').update(buf).digest('hex');
|
||||
if (!got.startsWith(expect) && got !== expect) {
|
||||
audit({ type: 'agent.update', status: 'failed', reason: `hash mismatch url=${url} expect=${expect} got=${got.slice(0, 12)}` });
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '哈希校验失败,已丢弃下载内容(未改动任何文件)', {
|
||||
expected: expect,
|
||||
actual: got.slice(0, 12),
|
||||
bytes: buf.length,
|
||||
});
|
||||
}
|
||||
|
||||
const staged = join(dir, `agent-${Date.now()}.mjs`);
|
||||
writeFileSync(staged, buf);
|
||||
|
||||
const prevBytes = (() => {
|
||||
try {
|
||||
return statSync(scriptPath).size;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
})();
|
||||
const prevHash = (() => {
|
||||
try {
|
||||
return createHash('sha256').update(readFileSync(scriptPath)).digest('hex').slice(0, 12);
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
})();
|
||||
|
||||
if (dryRun) {
|
||||
try {
|
||||
unlinkSync(staged);
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
audit({ type: 'agent.update', status: 'ok', reason: `dry_run url=${url} hash=${got.slice(0, 12)}` });
|
||||
return {
|
||||
dry_run: true,
|
||||
verified: true,
|
||||
bytes: buf.length,
|
||||
incoming_hash: got.slice(0, 12),
|
||||
current_hash: prevHash,
|
||||
current_bytes: prevBytes,
|
||||
note: '仅下载并校验,未改动任何文件',
|
||||
};
|
||||
}
|
||||
|
||||
// ③ 备份 + ④ 原子替换(同目录 rename,失败时旧文件不受影响)
|
||||
const backup = `${scriptPath}.bak-${Date.now()}`;
|
||||
try {
|
||||
copyFileSync(scriptPath, backup);
|
||||
} catch (err) {
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '备份现有脚本失败,已中止(不做无备份的替换)', {
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
// ⚠️ 必须**就地覆写**,不能用 rename 覆盖 —— Windows 不允许重命名/替换「正被运行进程
|
||||
// 打开的文件」(EPERM/EBUSY),而 node.exe 正持有 agent.mjs。真机踩过:rename 必失败。
|
||||
// 覆写打开中的文件是允许的(只有删除/重命名被禁),这也正是推送式 deploy 能工作的原因。
|
||||
try {
|
||||
writeFileSync(scriptPath, buf);
|
||||
} catch (err) {
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '写入脚本失败(已保留备份,可回滚)', {
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
backup_path: backup,
|
||||
staged_path: staged,
|
||||
});
|
||||
}
|
||||
// 写后校验:内容必须与刚校验过的下载一致,否则立即回滚(防止截断/半写)
|
||||
const written = createHash('sha256').update(readFileSync(scriptPath)).digest('hex');
|
||||
if (written !== got) {
|
||||
try {
|
||||
copyFileSync(backup, scriptPath); // 回滚
|
||||
} catch {
|
||||
/* 回滚失败时靠 backup_path 人工兜底 */
|
||||
}
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '写入后校验不一致,已自动回滚', {
|
||||
expected: got.slice(0, 12),
|
||||
actual: written.slice(0, 12),
|
||||
backup_path: backup,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
unlinkSync(staged); // 暂存文件已并入入口文件,及时清理
|
||||
} catch {
|
||||
/* 清理失败不影响正确性 */
|
||||
}
|
||||
|
||||
audit({
|
||||
type: 'agent.update',
|
||||
status: 'ok',
|
||||
reason: `url=${url} ${prevHash}->${got.slice(0, 12)} bytes=${buf.length} backup=${backup}`,
|
||||
});
|
||||
|
||||
// ⑤ 受控重启(复用 v7;Windows 走计划任务、POSIX 走 detached,均不依赖本进程存活)
|
||||
let restarted = false;
|
||||
if (doRestart) {
|
||||
await agentRestart({ delay_ms: 1500, reason: 'self-update' });
|
||||
restarted = true;
|
||||
}
|
||||
|
||||
return {
|
||||
updated: true,
|
||||
bytes: buf.length,
|
||||
previous: { hash: prevHash, bytes: prevBytes },
|
||||
current_hash: got.slice(0, 12),
|
||||
// CLI 靠它做「重启后复核」——漏了这个字段会导致复核被跳过(真机踩过)
|
||||
incoming_hash: got.slice(0, 12),
|
||||
backup_path: backup,
|
||||
restarted,
|
||||
elapsed_ms: Date.now() - t0,
|
||||
hint: doRestart
|
||||
? '已替换并重启;请用 system.info 的 build.hash 复核(应等于 incoming_hash)。若异常,把 backup_path 覆盖回入口即可回滚'
|
||||
: '已替换但未重启(restart=false);重启后才会生效',
|
||||
platform: IS_WINDOWS ? 'windows' : 'posix',
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
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';
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { IS_WINDOWS, execCommand } from '../util/exec.js';
|
||||
import { readAudit, verifyAudit } from '../audit.js';
|
||||
import { currentCertFingerprint } from '../certs.js';
|
||||
import { loadAgentConfig } from '../config.js';
|
||||
import { readAudit, readAuditAll, verifyAudit, computeAuditHead, anchorAudit, compareWithAnchors } from '../audit.js';
|
||||
import { computeMetrics } from '../metrics.js';
|
||||
import { allPsShellStats } from '../util/ps-helper.js';
|
||||
import { startTask } from './task.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
@@ -42,6 +49,82 @@ async function isAdmin(): Promise<boolean> {
|
||||
|
||||
// ---------------- system.info ----------------
|
||||
|
||||
|
||||
// ---------- 构建指纹(v18 / v20)----------
|
||||
|
||||
// 构建时由 esbuild --define 注入(见 scripts/pack.mjs);开发态(直接跑 dist)下未定义,
|
||||
// 故一律用 typeof 探测,绝不能直接引用(否则 dev 启动就 ReferenceError)。
|
||||
declare const __AGENT_VERSION__: string | undefined;
|
||||
declare const __BUILD_COMMIT__: string | undefined;
|
||||
|
||||
/** v21:读取当前生效的来源白名单(用于 system.info 上报)。 */
|
||||
function configAllowFrom(): string[] | null {
|
||||
try {
|
||||
const cfg = loadAgentConfig().config;
|
||||
return cfg.allow_from && cfg.allow_from.length > 0 ? cfg.allow_from : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function injected(name: 'version' | 'commit'): string {
|
||||
if (name === 'version') return typeof __AGENT_VERSION__ === 'undefined' ? 'dev' : __AGENT_VERSION__;
|
||||
return typeof __BUILD_COMMIT__ === 'undefined' ? 'dev' : __BUILD_COMMIT__;
|
||||
}
|
||||
|
||||
const STARTED_AT = Date.now();
|
||||
let buildCache: { hash: string; bytes: number; mtime_ms: number } | null = null;
|
||||
|
||||
/**
|
||||
* 计算「正在运行的这一份 agent 脚本」的指纹(sha256 前 12 位)。
|
||||
*
|
||||
* 用途:部署后**校验远端真的换成了新代码**。仅靠「PID 变了 / 能力数对了」证明不了这点
|
||||
* (旧版可能恰好也是同样的能力数);内容哈希是唯一可靠的判据。
|
||||
* 懒计算 + 缓存:文件约 1MB,只在首次 system.info 时读一次。
|
||||
*/
|
||||
export function buildInfo(): {
|
||||
hash: string;
|
||||
bytes: number;
|
||||
mtime_ms: number;
|
||||
node: string;
|
||||
started_at: number;
|
||||
uptime_ms: number;
|
||||
version: string;
|
||||
commit: string;
|
||||
built_at: string;
|
||||
cert_sha256: string | null;
|
||||
} {
|
||||
if (!buildCache) {
|
||||
let hash = 'unknown';
|
||||
let bytes = 0;
|
||||
let mtime = 0;
|
||||
try {
|
||||
const scriptPath = process.argv[1] ?? '';
|
||||
const buf = readFileSync(scriptPath);
|
||||
bytes = buf.length;
|
||||
mtime = statSync(scriptPath).mtimeMs;
|
||||
hash = createHash('sha256').update(buf).digest('hex').slice(0, 12);
|
||||
} catch {
|
||||
/* 读不到脚本(如被内联启动)时保持 unknown,不影响其他功能 */
|
||||
}
|
||||
buildCache = { hash, bytes, mtime_ms: Math.round(mtime) };
|
||||
}
|
||||
return {
|
||||
...buildCache,
|
||||
node: process.version,
|
||||
started_at: STARTED_AT,
|
||||
uptime_ms: Date.now() - STARTED_AT,
|
||||
// v20:语义化版本 + 提交 + 构建时间(来自构建期注入)
|
||||
version: injected('version'),
|
||||
commit: injected('commit'),
|
||||
// 构建时间取入口文件的 mtime(而非构建期注入)—— 注入会让每次打包字节不同,
|
||||
// 破坏「指纹 = 构建身份」的确定性(v22 真机踩过)
|
||||
built_at: buildCache.mtime_ms ? new Date(buildCache.mtime_ms).toISOString() : 'unknown',
|
||||
// v21:TLS 证书指纹(控制端据此钉住被控端身份)
|
||||
cert_sha256: currentCertFingerprint(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function systemInfo(args: Args): Promise<unknown> {
|
||||
const fields = args['fields'] as string[] | undefined;
|
||||
const info: Record<string, unknown> = {
|
||||
@@ -59,6 +142,14 @@ export async function systemInfo(args: Args): Promise<unknown> {
|
||||
agent_script: process.argv[1] ?? '',
|
||||
agent_home: process.env['NODEAGENT_HOME'] ?? '',
|
||||
node_path: process.execPath,
|
||||
// v15:PowerShell 常驻助手状态(排查 GUI 能力性能/降级时用)
|
||||
ps_helper: allPsShellStats(),
|
||||
// v18/v21:构建指纹(部署校验用)+ 证书指纹(身份钉定用)
|
||||
build: buildInfo(),
|
||||
// v21:来源网段访问控制(null = 未配置 = 放行全部,属于应尽快收敛的风险)
|
||||
network: {
|
||||
allow_from: configAllowFrom(),
|
||||
},
|
||||
};
|
||||
|
||||
if (fields && fields.length > 0) {
|
||||
@@ -436,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}'`,
|
||||
@@ -509,3 +621,35 @@ export async function auditList(args: Args): Promise<unknown> {
|
||||
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;
|
||||
const entries = readAuditAll(since);
|
||||
return computeMetrics(entries);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
"@
|
||||
`;
|
||||
@@ -1,5 +1,16 @@
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { IS_WINDOWS, execCommand } from '../util/exec.js';
|
||||
import { runPowerShellSmart } from '../util/ps-helper.js';
|
||||
import { agentDir } from '../config.js';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { macScreenFind, macWindowFocus, macWindowList } from './darwin.js';
|
||||
|
||||
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>;
|
||||
|
||||
@@ -16,7 +27,31 @@ function encodePS(script: string): string {
|
||||
return Buffer.from(script, 'utf16le').toString('base64');
|
||||
}
|
||||
|
||||
async function runPS(script: string, timeoutMs = 30_000): Promise<string> {
|
||||
/**
|
||||
* 执行「不含 prelude」的脚本主体:**优先常驻助手**(类型已预加载),
|
||||
* 助手不可用时自动回退到一次性脚本(自带 prelude),保证可用性不倒退。
|
||||
*/
|
||||
async function runPS(body: string, timeoutMs = 30_000): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await runPowerShellSmart({
|
||||
body,
|
||||
prelude: WIN_HELPER_PRELUDE,
|
||||
timeoutMs,
|
||||
label: 'win',
|
||||
log: (level, msg) => {
|
||||
if (level === 'warn') console.error(`[ps-helper] ${msg}`);
|
||||
},
|
||||
});
|
||||
return stdout;
|
||||
} catch (err) {
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '窗口操作失败', {
|
||||
detail: (err instanceof Error ? err.message : String(err)).slice(0, 600),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 一次性执行完整脚本(含 prelude)—— 尚未迁移到助手的路径暂用。 */
|
||||
async function runPSRaw(script: string, timeoutMs = 30_000): Promise<string> {
|
||||
const r = await execCommand({
|
||||
command: `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${encodePS(script)}`,
|
||||
timeoutMs,
|
||||
@@ -51,56 +86,43 @@ 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 bool ShowWindow(IntPtr h, int cmd);
|
||||
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
|
||||
}
|
||||
"@
|
||||
`;
|
||||
|
||||
/** 枚举可见顶层窗口 → JSON 字符串。 */
|
||||
const LIST_SCRIPT = `${WIN32_PRELUDE}
|
||||
$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 = 0
|
||||
[void][NAWin32]::GetWindowThreadProcessId($h, [ref]$pid)
|
||||
$pn = ''
|
||||
try { $pn = (Get-Process -Id $pid -ErrorAction Stop).ProcessName } catch {}
|
||||
[void]$list.Add([pscustomobject]@{
|
||||
hwnd = ('0x{0:X}' -f $h.ToInt64()); title = $title; process = $pn; pid = $pid
|
||||
x = $r.Left; y = $r.Top; width = $w; height = $ht
|
||||
is_foreground = ($h -eq $fg); is_minimized = [NAWin32]::IsIconic($h)
|
||||
})
|
||||
return $true
|
||||
|
||||
/** 助手统一预加载段(window.list / window.focus / screen.find 共用同一个进程)。 */
|
||||
|
||||
|
||||
/**
|
||||
* 助手统一预加载段(window.list / window.focus / screen.find 的 UIA/OCR/图像三引擎共用)。
|
||||
* ⚠️ 必须定义在四段之后(块级作用域),且**四段缺一不可** ——
|
||||
* 漏拼会被 esbuild tree-shake,直到运行时才报「找不到类型」(真机踩过)。
|
||||
* tests/unit/win-prelude.test.mjs 对此有回归断言。
|
||||
*/
|
||||
/**
|
||||
* 后台预热常驻助手(agent 启动后调用一次,不阻塞、失败无副作用)。
|
||||
*
|
||||
* 动机:助手的「首次调用」要付启动 + 类型预加载的固定成本(~1-3s)。
|
||||
* 若不预热,这笔成本会算在**第一个真实用户调用**头上(真机指标里能明显看到
|
||||
* window.list 的 P95 被抬高)。启动后主动预热,把成本挪到无人等待的时刻。
|
||||
*/
|
||||
export function warmupWindowHelper(): void {
|
||||
if (!IS_WINDOWS || process.env['NODEAGENT_NO_PS_HELPER']) return;
|
||||
setTimeout(() => {
|
||||
void runPowerShellSmart({
|
||||
body: "Write-Output 'warmup'",
|
||||
prelude: WIN_HELPER_PRELUDE,
|
||||
timeoutMs: 90_000,
|
||||
label: 'win',
|
||||
log: () => undefined,
|
||||
}).catch(() => {
|
||||
/* 预热失败无所谓:真实调用时仍会按原路径重试 */
|
||||
});
|
||||
}, 1500).unref?.();
|
||||
}
|
||||
[void][NAWin32]::EnumWindows($cb, [IntPtr]::Zero)
|
||||
$list | ConvertTo-Json -Compress
|
||||
`;
|
||||
|
||||
export const WIN_HELPER_PRELUDE = `${WIN32_PRELUDE}
|
||||
${UIA_ASSEMBLY_PRELUDE}
|
||||
${OCR_PRELUDE}
|
||||
${IMAGE_MATCH_PRELUDE}`;
|
||||
|
||||
interface WinInfo {
|
||||
hwnd: string;
|
||||
@@ -119,13 +141,15 @@ interface WinInfo {
|
||||
|
||||
export async function windowList(args: Args): Promise<unknown> {
|
||||
if (!IS_WINDOWS) {
|
||||
// v12.3:macOS 走 AppleScript 实现(需辅助功能权限),其余平台明确不支持
|
||||
if (process.platform === 'darwin') return macWindowList(args);
|
||||
throw new CapabilityError(
|
||||
ErrorCodes.UNSUPPORTED_PLATFORM,
|
||||
'window.list 仅在被控端为 Windows 时可用',
|
||||
'window.list 仅支持 Windows / macOS 被控端',
|
||||
{ platform: process.platform },
|
||||
);
|
||||
}
|
||||
const out = await runPS(LIST_SCRIPT, 40_000);
|
||||
const out = await runPS(LIST_BODY, 40_000);
|
||||
let all: WinInfo[] = toArray<WinInfo>(extractJson<WinInfo[]>(out) ?? []);
|
||||
// 过滤掉 UWP 的隐形壳窗口(尺寸异常大且进程为 ApplicationFrameHost 的重复项保留)
|
||||
all = all.filter((w) => w.width > 40 && w.height > 40);
|
||||
@@ -136,16 +160,19 @@ export async function windowList(args: Args): Promise<unknown> {
|
||||
all = all.filter((w) => re.test(w.title));
|
||||
}
|
||||
const limit = (args['limit'] as number | undefined) ?? 50;
|
||||
return { windows: all.slice(0, limit) };
|
||||
// 前台窗口排最前:低 limit 时也不会把它截掉(真机踩过:被系统对话框抢占焦点却查不到)
|
||||
all.sort((a, b) => Number(b.is_foreground) - Number(a.is_foreground));
|
||||
return { windows: all.slice(0, limit), total: all.length };
|
||||
}
|
||||
|
||||
// ---------------- window.focus ----------------
|
||||
|
||||
export async function windowFocus(args: Args): Promise<unknown> {
|
||||
if (!IS_WINDOWS) {
|
||||
if (process.platform === 'darwin') return macWindowFocus(args);
|
||||
throw new CapabilityError(
|
||||
ErrorCodes.UNSUPPORTED_PLATFORM,
|
||||
'window.focus 仅在被控端为 Windows 时可用',
|
||||
'window.focus 仅支持 Windows / macOS 被控端',
|
||||
{ platform: process.platform },
|
||||
);
|
||||
}
|
||||
@@ -154,8 +181,34 @@ export async function windowFocus(args: Args): Promise<unknown> {
|
||||
if (!title && !hwnd) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, '需提供 title(正则)或 hwnd');
|
||||
}
|
||||
// v12.2:等窗口出现(应用启动有延迟,一次性查找极易假失败)
|
||||
const waitMs = Math.max(0, Math.min(30_000, (args['wait_ms'] as number | undefined) ?? 0));
|
||||
if (waitMs > 0 && !hwnd) {
|
||||
const t0 = Date.now();
|
||||
let lastErr: unknown = null;
|
||||
while (Date.now() - t0 < waitMs) {
|
||||
try {
|
||||
return await focusOnce(title, hwnd);
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
}
|
||||
throw lastErr instanceof Error
|
||||
? lastErr
|
||||
: new CapabilityError(ErrorCodes.EXECUTION_FAILED, `等待 ${waitMs}ms 仍未找到窗口`, { title });
|
||||
}
|
||||
|
||||
const script = `${WIN32_PRELUDE}
|
||||
return focusOnce(title, hwnd);
|
||||
}
|
||||
|
||||
/** 单次聚焦(供等待重试复用)。 */
|
||||
/**
|
||||
* 聚焦脚本主体(不含 prelude)。⚠️ 严禁 exit —— 会杀掉常驻助手进程;
|
||||
* 未找到时改为输出 `{"found":false}` 并 return。
|
||||
*/
|
||||
function focusBody(title: string | undefined, hwnd: string | undefined): string {
|
||||
return `
|
||||
$h = [IntPtr]::Zero
|
||||
$want = ${JSON.stringify(hwnd ?? '')}
|
||||
if ($want -ne '') { $h = [IntPtr][Convert]::ToInt64($want, 16) }
|
||||
@@ -176,10 +229,28 @@ else {
|
||||
[void][NAWin32]::EnumWindows($cb, [IntPtr]::Zero)
|
||||
$h = $script:found
|
||||
}
|
||||
if ($h -eq [IntPtr]::Zero) { Write-Output '{"found":false}'; exit 0 }
|
||||
if ($h -eq [IntPtr]::Zero) { Write-Output '{"found":false}'; return }
|
||||
[void][NAWin32]::ShowWindow($h, 9) # SW_RESTORE
|
||||
[void][NAWin32]::SetForegroundWindow($h)
|
||||
Start-Sleep -Milliseconds 400
|
||||
|
||||
# ⚠️ SetForegroundWindow 会被 Windows 的前台锁定规则**静默否决**(真机踩过:
|
||||
# 返回后 GetForegroundWindow 仍是别人,后续键鼠全打进错误窗口)。
|
||||
# 故校验一次;未生效则在标题栏做一次真实点击 —— 点击比 API 更能说服系统切换前台。
|
||||
$activatedBy = 'api'
|
||||
if ([NAWin32]::GetForegroundWindow() -ne $h) {
|
||||
$rr = New-Object NAWin32+RECT
|
||||
[void][NAWin32]::GetWindowRect($h, [ref]$rr)
|
||||
$cx = [int](($rr.Left + $rr.Right) / 2)
|
||||
$cy = [int]($rr.Top + 8) # 标题栏(避开内容区,避免误触按钮)
|
||||
[NAWin32]::SetCursorPos($cx, $cy) | Out-Null
|
||||
Start-Sleep -Milliseconds 120
|
||||
[NAWin32]::mouse_event(0x0002, 0, 0, 0, [UIntPtr]::Zero) # LEFTDOWN
|
||||
Start-Sleep -Milliseconds 60
|
||||
[NAWin32]::mouse_event(0x0004, 0, 0, 0, [UIntPtr]::Zero) # LEFTUP
|
||||
Start-Sleep -Milliseconds 350
|
||||
if ([NAWin32]::GetForegroundWindow() -eq $h) { $activatedBy = 'click' }
|
||||
}
|
||||
$r = New-Object NAWin32+RECT
|
||||
[void][NAWin32]::GetWindowRect($h, [ref]$r)
|
||||
$sb2 = New-Object System.Text.StringBuilder 512
|
||||
@@ -188,9 +259,14 @@ $sb2 = New-Object System.Text.StringBuilder 512
|
||||
found = $true; hwnd = ('0x{0:X}' -f $h.ToInt64()); title = $sb2.ToString()
|
||||
x = $r.Left; y = $r.Top; width = ($r.Right - $r.Left); height = ($r.Bottom - $r.Top)
|
||||
focused = ([NAWin32]::GetForegroundWindow() -eq $h)
|
||||
activated_by = $activatedBy
|
||||
} | ConvertTo-Json -Compress
|
||||
`;
|
||||
const out = await runPS(script, 40_000);
|
||||
}
|
||||
|
||||
/** 单次聚焦(供等待重试复用)。 */
|
||||
async function focusOnce(title: string | undefined, hwnd: string | undefined): Promise<unknown> {
|
||||
const out = await runPS(focusBody(title, hwnd), 40_000);
|
||||
const obj = extractJson<Record<string, unknown>>(out) ?? {};
|
||||
if (obj['found'] === false) {
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '未找到匹配的窗口', { title, hwnd });
|
||||
@@ -201,25 +277,7 @@ $sb2 = New-Object System.Text.StringBuilder 512
|
||||
|
||||
// ---------------- screen.find ----------------
|
||||
|
||||
const FIND_SCRIPT_PRELUDE = `
|
||||
Add-Type -AssemblyName UIAutomationClient
|
||||
Add-Type -AssemblyName UIAutomationTypes
|
||||
Add-Type -AssemblyName WindowsBase
|
||||
function Get-WinByTitle([string]$re) {
|
||||
$root = [System.Windows.Automation.AutomationElement]::RootElement
|
||||
$cond = New-Object System.Windows.Automation.PropertyCondition(
|
||||
[System.Windows.Automation.AutomationElement]::ControlTypeProperty,
|
||||
[System.Windows.Automation.ControlType]::Window)
|
||||
$wins = $root.FindAll([System.Windows.Automation.TreeScope]::Children, $cond)
|
||||
foreach ($w in $wins) {
|
||||
try {
|
||||
$t = $w.Current.Name
|
||||
if ($t -and $t -match $re) { return $w }
|
||||
} catch {}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
`;
|
||||
// v14:原 FIND_SCRIPT_PRELUDE 已并入 WIN_HELPER_PRELUDE(助手启动时预加载一次)
|
||||
|
||||
/**
|
||||
* 在界面中查找 UI 元素并返回屏幕坐标。
|
||||
@@ -232,103 +290,196 @@ function Get-WinByTitle([string]$re) {
|
||||
*/
|
||||
export async function screenFind(args: Args): Promise<unknown> {
|
||||
if (!IS_WINDOWS) {
|
||||
// macOS:无 UIA 等价物 → 截图 + Vision OCR(见 darwin.ts 的权限说明)
|
||||
if (process.platform === 'darwin') return macScreenFind(args);
|
||||
throw new CapabilityError(
|
||||
ErrorCodes.UNSUPPORTED_PLATFORM,
|
||||
'screen.find 仅在被控端为 Windows 时可用',
|
||||
'screen.find 仅支持 Windows / macOS 被控端',
|
||||
{ platform: process.platform },
|
||||
);
|
||||
}
|
||||
const text = args['text'] as string;
|
||||
if (typeof text !== 'string' || text.length === 0) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'text 不能为空');
|
||||
const text = (args['text'] as string | undefined) ?? '';
|
||||
const wantImage = (args['method'] as string | undefined) === 'image';
|
||||
// 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:等待语义 —— 界面常有动画/加载延迟,一次性查找极易假失败。
|
||||
// wait_ms > 0 时轮询直到命中或超时(返回 waited_ms 让调用方知情)。
|
||||
const waitMs = Math.max(0, Math.min(30_000, (args['wait_ms'] as number | undefined) ?? 0));
|
||||
const intervalMs = Math.max(100, Math.min(2000, (args['interval_ms'] as number | undefined) ?? 400));
|
||||
// v12.3:显式区域(优先于 window):OCR 只截这一块,更快也更准
|
||||
const regionArg = args['region'] as
|
||||
| { x: number; y: number; width: number; height: number }
|
||||
| undefined;
|
||||
// v15:图像模板匹配
|
||||
const template = args['template'] as string | undefined;
|
||||
const threshold = Math.max(0.3, Math.min(0.999, (args['threshold'] as number | undefined) ?? 0.85));
|
||||
|
||||
// ---------- UIA 引擎 ----------
|
||||
const uiaFind = async (): Promise<Record<string, unknown>[]> => {
|
||||
const ct = controlType ?? '';
|
||||
const script = `${FIND_SCRIPT_PRELUDE}
|
||||
const body = `
|
||||
$text = ${JSON.stringify(text)}
|
||||
$limit = ${Number(limit)}
|
||||
$scope = [System.Windows.Automation.AutomationElement]::RootElement
|
||||
$ct = ${JSON.stringify(controlType ?? '')}
|
||||
$winTitle = ${JSON.stringify(windowTitle ?? '')}
|
||||
if ($winTitle -ne '') {
|
||||
$w = Get-WinByTitle $winTitle
|
||||
if ($w -eq $null) { Write-Output '[]'; exit 0 }
|
||||
$scope = $w
|
||||
}
|
||||
$nameCond = New-Object System.Windows.Automation.PropertyCondition(
|
||||
[System.Windows.Automation.AutomationElement]::NameProperty, $text,
|
||||
[System.Windows.Automation.PropertyConditionFlags]::IgnoreCase)
|
||||
$ct = ${JSON.stringify(ct)}
|
||||
if ($ct -ne '') {
|
||||
$ctObj = $null
|
||||
try { $ctObj = [System.Windows.Automation.ControlType]::$ct } catch {}
|
||||
if ($ctObj -ne $null) {
|
||||
$ctCond = New-Object System.Windows.Automation.PropertyCondition(
|
||||
[System.Windows.Automation.AutomationElement]::ControlTypeProperty, $ctObj)
|
||||
$cond = New-Object System.Windows.Automation.AndCondition($nameCond, $ctCond)
|
||||
} else { $cond = $nameCond }
|
||||
} else { $cond = $nameCond }
|
||||
$found = $scope.FindAll([System.Windows.Automation.TreeScope]::Descendants, $cond)
|
||||
$out = New-Object System.Collections.ArrayList
|
||||
foreach ($e in $found) {
|
||||
if ($out.Count -ge $limit) { break }
|
||||
# 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 {
|
||||
$r = $e.Current.BoundingRectangle
|
||||
if ($r.Width -le 0 -or $r.Height -le 0) { continue }
|
||||
$out.Add([pscustomobject]@{
|
||||
name = $e.Current.Name
|
||||
control_type = ($e.Current.ControlType.ProgrammaticName -replace 'ControlType\\\\.','')
|
||||
automation_id = $e.Current.AutomationId
|
||||
x = [int]($r.Left + $r.Width / 2)
|
||||
y = [int]($r.Top + $r.Height / 2)
|
||||
left = [int]$r.Left; top = [int]$r.Top
|
||||
width = [int]$r.Width; height = [int]$r.Height
|
||||
})
|
||||
} catch {}
|
||||
($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 | ConvertTo-Json -Compress
|
||||
$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 $where
|
||||
} else {
|
||||
# v14 优化:先在前台窗口子树内找(典型只几百个元素,快一个数量级),
|
||||
# 找不到再退回全桌面遍历(原来每次都在全桌面上跑,真机实测 3.1~3.7s)。
|
||||
$fgEl = $null
|
||||
try {
|
||||
$fgEl = [System.Windows.Automation.AutomationElement]::FromHandle([NAWin32]::GetForegroundWindow())
|
||||
} catch {}
|
||||
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 $where
|
||||
}
|
||||
}
|
||||
# ── 输出 ──(遍历与子串过滤已收敛到预加载的 Find-UiaByText,避免逻辑重复)
|
||||
if ($out.Count -eq 0) { Write-Output '[]' } else { $out | ConvertTo-Json -Compress }
|
||||
`;
|
||||
const out = await runPS(script, 60_000);
|
||||
const out = await runPS(body, 60_000);
|
||||
return toArray<Record<string, unknown>>(extractJson<Record<string, unknown>[]>(out) ?? []);
|
||||
};
|
||||
|
||||
// ---------- 图像模板引擎(灰度 ZNCC 粗到精,v15) ----------
|
||||
// 适用:纯图标 / 无文字的自绘控件(UIA 无控件树、OCR 无文字可读的盲区)
|
||||
const imageFind = async (): Promise<Record<string, unknown>[]> => {
|
||||
if (!template) {
|
||||
throw new CapabilityError(
|
||||
ErrorCodes.PARAM_INVALID,
|
||||
'method=image 需要 template 参数(被控端上的模板图片路径,支持 png/jpg/bmp)',
|
||||
);
|
||||
}
|
||||
const tmpDir = join(agentDir(), 'tmp');
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
const shot = join(tmpDir, `match-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.png`);
|
||||
// PowerShell 单引号字面量:不插值(避免路径里的 $ 被展开)、不处理反斜杠转义。
|
||||
// 不能用 JSON.stringify —— 它会产出双引号串,而 PS 双引号会做变量插值且把 \ 当字面两个反斜杠。
|
||||
const q = (v: string): string => `'${v.replace(/'/g, "''")}'`;
|
||||
|
||||
// 截图区域:显式 region 优先,否则整块虚拟屏
|
||||
const capX = regionArg ? regionArg.x : 'VSX';
|
||||
const capY = regionArg ? regionArg.y : 'VSY';
|
||||
const capW = regionArg ? regionArg.width : 'VSW';
|
||||
const capH = regionArg ? regionArg.height : 'VSH';
|
||||
|
||||
const body = `
|
||||
if (-not (Test-Path ${q(template)})) {
|
||||
Write-Output '{"__error":"模板文件不存在"}'
|
||||
return
|
||||
}
|
||||
$vs = [System.Windows.Forms.SystemInformation]::VirtualScreen
|
||||
$cx = ${typeof capX === 'number' ? capX : '$vs.X'}
|
||||
$cy = ${typeof capY === 'number' ? capY : '$vs.Y'}
|
||||
$cw = ${typeof capW === 'number' ? capW : '$vs.Width'}
|
||||
$ch = ${typeof capH === 'number' ? capH : '$vs.Height'}
|
||||
try {
|
||||
[void][NAImageMatch]::Capture(${q(shot)}, $cx, $cy, $cw, $ch)
|
||||
$json = [NAImageMatch]::Match(${q(shot)}, ${q(template)}, ${Number(limit)}, ${threshold}, 0, 0, 0, 0)
|
||||
Write-Output $json
|
||||
} catch {
|
||||
Write-Output ('{"__error":"' + $_.Exception.Message.Replace('"','''') + '"}')
|
||||
} finally {
|
||||
Remove-Item ${q(shot)} -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
`;
|
||||
const out = await runPS(body, 120_000);
|
||||
type ImgHit = { x: number; y: number; width: number; height: number; score: number };
|
||||
const raw = extractJson<Record<string, unknown> | ImgHit[]>(out);
|
||||
if (raw && !Array.isArray(raw) && typeof raw === 'object' && '__error' in raw) {
|
||||
throw new CapabilityError(
|
||||
ErrorCodes.EXECUTION_FAILED,
|
||||
`图像匹配失败: ${String((raw as { __error?: string }).__error)}`,
|
||||
);
|
||||
}
|
||||
const hits: ImgHit[] = Array.isArray(raw) ? raw : [];
|
||||
// 模板纯色时 C# 会返回 [{__error:...}](数组形式),这里统一转成可读错误
|
||||
const first = hits[0] as unknown as { __error?: string; variance?: number } | undefined;
|
||||
if (first && typeof first === 'object' && first.__error === 'template_has_no_contrast') {
|
||||
throw new CapabilityError(
|
||||
ErrorCodes.PARAM_INVALID,
|
||||
'模板几乎没有色彩/明暗差异(纯色或低对比),无法用于模板匹配;请改用有纹理或图标的区域',
|
||||
{ variance: first.variance },
|
||||
);
|
||||
}
|
||||
return hits.map((h) => ({
|
||||
name: `image:${template.split(/[\\/]/).pop() ?? 'template'}`,
|
||||
control_type: 'Image(template)',
|
||||
score: h.score,
|
||||
// 命中坐标相对截图原点 -> 补回屏幕原点
|
||||
x: Math.round(capX === 'VSX' ? h.x + (regionArg?.x ?? 0) : h.x + Number(capX)),
|
||||
y: Math.round(capY === 'VSY' ? h.y + (regionArg?.y ?? 0) : h.y + Number(capY)),
|
||||
left: Math.round(capX === 'VSX' ? h.x + (regionArg?.x ?? 0) : h.x + Number(capX)),
|
||||
top: Math.round(capY === 'VSY' ? h.y + (regionArg?.y ?? 0) : h.y + Number(capY)),
|
||||
width: h.width,
|
||||
height: h.height,
|
||||
}));
|
||||
};
|
||||
|
||||
// ---------- OCR 引擎(截图 → Windows.Media.Ocr → 文字坐标) ----------
|
||||
const ocrFind = async (): Promise<Record<string, unknown>[]> => {
|
||||
const script = `
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
Add-Type -AssemblyName System.Runtime.WindowsRuntime
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
# DPI 感知:保证截图像素与窗口矩形同为物理坐标
|
||||
Add-Type -TypeDefinition 'using System.Runtime.InteropServices; public class NADPI { [DllImport("user32.dll")] public static extern bool SetProcessDPIAware(); }'
|
||||
[void][NADPI]::SetProcessDPIAware()
|
||||
|
||||
$null = [Windows.Media.Ocr.OcrEngine, Windows.Foundation, ContentType=WindowsRuntime]
|
||||
$null = [Windows.Graphics.Imaging.BitmapDecoder, Windows.Foundation, ContentType=WindowsRuntime]
|
||||
$null = [Windows.Storage.Streams.InMemoryRandomAccessStream, Windows.Foundation, ContentType=WindowsRuntime]
|
||||
$null = [Windows.Storage.Streams.DataWriter, Windows.Foundation, ContentType=WindowsRuntime]
|
||||
$null = [Windows.Graphics.Imaging.SoftwareBitmap, Windows.Foundation, ContentType=WindowsRuntime]
|
||||
|
||||
# WinRT IAsyncOperation -> Task 等待辅助
|
||||
$asTaskGeneric = ([System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object { $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation\`1' })[0]
|
||||
function Await($WinRtTask, $ResultType) {
|
||||
$asTask = $asTaskGeneric.MakeGenericMethod($ResultType)
|
||||
$netTask = $asTask.Invoke($null, @($WinRtTask))
|
||||
$netTask.Wait(-1) | Out-Null
|
||||
$netTask.Result
|
||||
}
|
||||
|
||||
# 截图区域:指定窗口则截该窗口,否则整块虚拟屏
|
||||
$winRe = ${JSON.stringify(windowTitle ?? '')}
|
||||
const body = `
|
||||
# OCR 预加载段已在助手启动时完成(类型/DPI/Await);此处只做本次逻辑
|
||||
if ($null -eq $script:ocrReady -or -not $script:ocrReady) { Write-Output '[]'; return }
|
||||
# 截图区域:显式 region 优先 > 指定窗口 > 整块虚拟屏
|
||||
$xv = ${regionArg ? regionArg.x : -1}
|
||||
$yv = ${regionArg ? regionArg.y : -1}
|
||||
$wv = ${regionArg ? regionArg.width : -1}
|
||||
$hv = ${regionArg ? regionArg.height : -1}
|
||||
$winRe = ${JSON.stringify(regionArg ? '' : windowTitle ?? '')}
|
||||
$x = 0; $y = 0
|
||||
$vs = [System.Windows.Forms.SystemInformation]::VirtualScreen
|
||||
$w = $vs.Width; $h = $vs.Height
|
||||
if ($winRe -ne '') {
|
||||
if ($xv -ge 0 -and $wv -gt 0) {
|
||||
$x = $xv; $y = $yv; $w = $wv; $h = $hv
|
||||
} elseif ($winRe -ne '') {
|
||||
Add-Type @"
|
||||
using System;
|
||||
using System.Text;
|
||||
@@ -359,7 +510,7 @@ public class NAEnum {
|
||||
$x = $r.Left; $y = $r.Top; $w = $r.Right - $r.Left; $h = $r.Bottom - $r.Top
|
||||
}
|
||||
}
|
||||
if ($w -le 0 -or $h -le 0) { Write-Output '[]'; exit 0 }
|
||||
if ($w -le 0 -or $h -le 0) { Write-Output '[]'; return }
|
||||
|
||||
$bmp = New-Object System.Drawing.Bitmap($w, $h)
|
||||
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
||||
@@ -379,7 +530,7 @@ $decoder = Await ([Windows.Graphics.Imaging.BitmapDecoder]::CreateAsync($ras)) (
|
||||
$soft = Await ($decoder.GetSoftwareBitmapAsync()) ([Windows.Graphics.Imaging.SoftwareBitmap])
|
||||
|
||||
$engine = [Windows.Media.Ocr.OcrEngine]::TryCreateFromUserProfileLanguages()
|
||||
if ($null -eq $engine) { Write-Output '[]'; exit 0 }
|
||||
if ($null -eq $engine) { Write-Output '[]'; return }
|
||||
$result = Await ($engine.RecognizeAsync($soft)) ([Windows.Media.Ocr.OcrResult])
|
||||
|
||||
$text = ${JSON.stringify(text)}
|
||||
@@ -453,21 +604,44 @@ foreach ($line in $result.Lines) {
|
||||
}
|
||||
$out | ConvertTo-Json -Compress
|
||||
`;
|
||||
const out = await runPS(script, 90_000);
|
||||
const out = await runPS(body, 90_000);
|
||||
return toArray<Record<string, unknown>>(extractJson<Record<string, unknown>[]>(out) ?? []);
|
||||
};
|
||||
|
||||
if (method === 'uia') {
|
||||
return { matches: (await uiaFind()).slice(0, limit), engine: 'uia' };
|
||||
/** 单次尝试(按 method 选择引擎)。 */
|
||||
const attempt = async (): Promise<{ matches: Record<string, unknown>[]; engine: string }> => {
|
||||
if (method === 'uia') return { matches: (await uiaFind()).slice(0, limit), engine: 'uia' };
|
||||
if (method === 'ocr') return { matches: (await ocrFind()).slice(0, limit), engine: 'ocr' };
|
||||
if (method === 'image') return { matches: (await imageFind()).slice(0, limit), engine: 'image' };
|
||||
// auto:UIA 优先(快且带控件语义),找不到再 OCR 兜底(自绘 UI)
|
||||
const uiaMatches = await uiaFind();
|
||||
if (uiaMatches.length > 0) return { matches: uiaMatches.slice(0, limit), engine: 'uia' };
|
||||
const ocrMatches = await ocrFind();
|
||||
return { matches: ocrMatches.slice(0, limit), engine: 'ocr' };
|
||||
};
|
||||
|
||||
const startedAt = Date.now();
|
||||
let last = await attempt();
|
||||
while (last.matches.length === 0 && Date.now() - startedAt < waitMs) {
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
last = await attempt();
|
||||
}
|
||||
if (method === 'ocr') {
|
||||
return { matches: (await ocrFind()).slice(0, limit), engine: 'ocr' };
|
||||
}
|
||||
// auto:UIA 优先(快且带控件语义),找不到再 OCR 兜底(自绘 UI)
|
||||
const uiaMatches = await uiaFind();
|
||||
if (uiaMatches.length > 0) {
|
||||
return { matches: uiaMatches.slice(0, limit), engine: 'uia' };
|
||||
}
|
||||
const ocrMatches = await ocrFind();
|
||||
return { matches: ocrMatches.slice(0, limit), engine: 'ocr' };
|
||||
// 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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import selfsigned from 'selfsigned';
|
||||
import { certFingerprint } from '@nodeagent/protocol';
|
||||
import { agentDir } from './config.js';
|
||||
|
||||
export interface TlsMaterial {
|
||||
@@ -31,3 +32,20 @@ export function ensureCert(): TlsMaterial {
|
||||
writeFileSync(keyPath, pems.private, { mode: 0o600 });
|
||||
return { cert: pems.cert, key: pems.private };
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前证书的 SHA256 指纹(hex,64 位)。
|
||||
*
|
||||
* 用途:控制端**钉住**这个指纹(v21 第一批加固)—— 自签证书没有 CA 可信链,
|
||||
* 唯一能证明"还是那台被控端"的办法就是指纹比对。install.ps1 会用 .NET 算同一值
|
||||
* 印给操作者带外核对。
|
||||
*/
|
||||
export function currentCertFingerprint(): string | null {
|
||||
const certPath = join(agentDir(), 'certs', 'cert.pem');
|
||||
try {
|
||||
if (!existsSync(certPath)) return null;
|
||||
return certFingerprint(readFileSync(certPath, 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,17 @@ export interface AgentConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
tls: boolean;
|
||||
/** v1 简化:预共享密钥存于 600 权限文件;v2 升级 DPAPI */
|
||||
/** v1 简化:预共享密钥存于 600 权限文件;v2 升级 DPAPI。**所有调用方共用**,仅适合信任网络 */
|
||||
key: string;
|
||||
/**
|
||||
* v22:**每客户端一把**预共享密钥(`client_id` → key)。
|
||||
*
|
||||
* 为什么需要它:psk 模式下 `client_id` 由客户端自报,历史上「一把共享 key」等于
|
||||
* **持有者可冒充任意 client_id**,ACL 的身份维度形同虚设。
|
||||
* 改为每个 client_id 一把钥匙后,「知道 key A 就只能以 A 的身份进来」——
|
||||
* 身份才真正被绑定。未登记的 client_id 回落到上面的 `key`(旧部署行为不变)。
|
||||
*/
|
||||
keys?: Record<string, string>;
|
||||
log_level: 'debug' | 'info' | 'warn';
|
||||
/**
|
||||
* v2:是否允许输入控制(鼠标 / 键盘)。
|
||||
@@ -24,6 +33,27 @@ export interface AgentConfig {
|
||||
auth_mode?: 'psk' | 'ed25519';
|
||||
/** v3:ed25519 模式下的能力级授权策略(默认拒绝) */
|
||||
acl?: AclPolicy;
|
||||
/**
|
||||
* v21:允许接入的来源网段(CIDR / 单 IP / `*`)。**未配置 = 放行全部**。
|
||||
*
|
||||
* ⚠️ 默认放行是为了不打断现有部署,但「换到不可信网络就自动暴露」正是
|
||||
* 第一批要收敛的风险 —— 建议在家庭/办公网段上显式配置,例如:
|
||||
* "allow_from": ["192.168.0.0/16", "10.0.0.0/8"]
|
||||
*/
|
||||
allow_from?: string[];
|
||||
|
||||
/**
|
||||
* 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) */
|
||||
@@ -45,6 +75,17 @@ export interface AgentConfig {
|
||||
broadcast?: string;
|
||||
/** 广播间隔毫秒(默认 5000) */
|
||||
interval_ms?: number;
|
||||
/**
|
||||
* v22:广播详情级别。
|
||||
* - `full`(默认,向后兼容):携带地址/端口/认证方式/键鼠开关等全部信息
|
||||
* - `minimal`:只广播「存在 + node_id + 时间」,详情放进 `detail` 字段;
|
||||
* 配了 `secret` 时详情会被 **HMAC 认证**(控制端持钥才看得到、且能验证真伪)
|
||||
* ⚠️ 广播是**未认证**的:任何人收 UDP 都能看到内容。minimal 仍会暴露
|
||||
* 「这里有一台可被接管的机器」,但不再泄露地址、端口与认证方式。
|
||||
*/
|
||||
detail?: 'full' | 'minimal';
|
||||
/** v22:详情认证密钥(配合 detail=minimal 使用;配了就会自动启用 minimal) */
|
||||
secret?: string;
|
||||
};
|
||||
/**
|
||||
* v5:文件访问白名单(根目录列表)。
|
||||
@@ -57,6 +98,10 @@ export interface AgentConfig {
|
||||
* 适用于被控端位于 NAT / 无公网 IP 的环境。
|
||||
*/
|
||||
hub?: {
|
||||
/** v12:最多维持的 Hub 槽位数(并发控制端上限),默认 2 */
|
||||
max_slots?: number;
|
||||
/** v12:常备(预热的)槽位数,默认 2;设为 1 可省一条空闲连接 */
|
||||
warm_slots?: number;
|
||||
enabled: boolean;
|
||||
/** Hub 地址,例如 wss://hub.example.com/hub/agent */
|
||||
url: string;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createSocket, type Socket } from 'node:dgram';
|
||||
import { DEFAULT_DISCOVERY_PORT } from '@nodeagent/protocol';
|
||||
import { DEFAULT_DISCOVERY_PORT, computeHmac } from '@nodeagent/protocol';
|
||||
import { currentCertFingerprint } from './certs.js';
|
||||
|
||||
export { DEFAULT_DISCOVERY_PORT };
|
||||
|
||||
@@ -9,14 +10,41 @@ export interface BeaconPayload {
|
||||
service: 'nodeagent';
|
||||
version: string;
|
||||
node_id: string;
|
||||
/**
|
||||
* 以下字段在 `full` 模式下直给在顶层;在 `minimal` 模式下收在 `detail` 里
|
||||
* (顶层只留「存在性 + node_id + 时间」)。故为可选。
|
||||
*/
|
||||
/** 被控端可达地址(由被控端自行判定) */
|
||||
host: string;
|
||||
port: number;
|
||||
tls: boolean;
|
||||
auth_mode: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
tls?: boolean;
|
||||
auth_mode?: string;
|
||||
/** 需要输入控制开关的状态,便于控制端提示 */
|
||||
input_enabled: boolean;
|
||||
platform: string;
|
||||
input_enabled?: boolean;
|
||||
/**
|
||||
* v21:TLS 证书指纹(sha256 hex)。
|
||||
* ⚠️ 这是**未认证**广播,可被伪造 —— 只用于「看起来像不像」,不能作为身份凭据;
|
||||
* 真正的身份确认发生在握手后(auth_ok 里带指纹,且控制端会严格比对)。
|
||||
*/
|
||||
cert_sha256?: string | null;
|
||||
platform?: string;
|
||||
/**
|
||||
* v22:`true` 表示这是**最小化**广播 —— 顶层只有存在性信息,
|
||||
* 真实地址/端口/认证方式等在 `detail` 里(有 secret 时带 `sig` 认证)。
|
||||
*/
|
||||
minimal?: boolean;
|
||||
/** v22:最小化广播里的详情(可能带 `sig`,由持有 secret 的控制端验证) */
|
||||
detail?: {
|
||||
host: string;
|
||||
port: number;
|
||||
tls: boolean;
|
||||
auth_mode: string;
|
||||
input_enabled: boolean;
|
||||
cert_sha256?: string | null;
|
||||
platform: string;
|
||||
};
|
||||
/** v22:详情签名(HMAC-SHA256 over `node_id|ts|JSON(detail)`),由持有 secret 的控制端验证 */
|
||||
sig?: string;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
@@ -27,8 +55,14 @@ export interface BeaconOptions {
|
||||
/** 广播地址;局域网内通常为 255.255.255.255 */
|
||||
broadcast: string;
|
||||
intervalMs: number;
|
||||
/** 报文中除 ts 外的固定字段 */
|
||||
base: Omit<BeaconPayload, 'service' | 'version' | 'ts'>;
|
||||
/** 报文中除 ts 外的固定字段(full 模式直给顶层的那组) */
|
||||
base: Required<
|
||||
Pick<BeaconPayload, 'node_id' | 'host' | 'port' | 'tls' | 'auth_mode' | 'input_enabled' | 'platform'>
|
||||
>;
|
||||
/** v22:详情级别;配了 secret 也会自动启用 minimal */
|
||||
detail?: 'full' | 'minimal';
|
||||
/** v22:详情认证密钥(HMAC-SHA256 over node_id|ts|detail) */
|
||||
secret?: string;
|
||||
}
|
||||
|
||||
export interface Beacon {
|
||||
@@ -56,15 +90,39 @@ export function startBeacon(opts: BeaconOptions, onError?: (msg: string) => void
|
||||
|
||||
const send = (): void => {
|
||||
if (!ready) return;
|
||||
const buf = Buffer.from(
|
||||
JSON.stringify({
|
||||
service: SERVICE_TAG,
|
||||
version: PROTOCOL_VERSION,
|
||||
...opts.base,
|
||||
ts: Date.now(),
|
||||
} satisfies BeaconPayload),
|
||||
'utf8',
|
||||
);
|
||||
const ts = Date.now();
|
||||
// v22 详情分级:minimal 模式只广播「存在性」,详情移入 detail{} 并用 secret 做 HMAC。
|
||||
// 未配 secret 时 detail 仍然明文(此时 sig 缺省,控制端会标为「未认证」)。
|
||||
const full = {
|
||||
host: opts.base.host,
|
||||
port: opts.base.port,
|
||||
tls: opts.base.tls,
|
||||
auth_mode: opts.base.auth_mode,
|
||||
input_enabled: opts.base.input_enabled,
|
||||
cert_sha256: currentCertFingerprint(),
|
||||
platform: opts.base.platform,
|
||||
};
|
||||
const minimal = opts.detail === 'minimal' || !!opts.secret;
|
||||
const payload: BeaconPayload = minimal
|
||||
? {
|
||||
service: SERVICE_TAG,
|
||||
version: PROTOCOL_VERSION,
|
||||
node_id: opts.base.node_id,
|
||||
minimal: true,
|
||||
detail: full,
|
||||
sig: opts.secret
|
||||
? computeHmac(opts.secret, `${opts.base.node_id}|${ts}|${JSON.stringify(full)}`)
|
||||
: undefined,
|
||||
ts,
|
||||
}
|
||||
: {
|
||||
service: SERVICE_TAG,
|
||||
version: PROTOCOL_VERSION,
|
||||
cert_sha256: currentCertFingerprint(),
|
||||
...opts.base,
|
||||
ts,
|
||||
};
|
||||
const buf = Buffer.from(JSON.stringify(payload), 'utf8');
|
||||
try {
|
||||
socket.send(buf, 0, buf.length, opts.port, opts.broadcast, (err) => {
|
||||
if (err) onError?.(`广播失败: ${err.message}`);
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import { watch, type FSWatcher } from 'node:fs';
|
||||
import { exec } from 'node:child_process';
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* 被控端事件订阅(v12 / E1+E2)。
|
||||
*
|
||||
* 三类 watcher:
|
||||
* file —— fs.watch 文件变动(增/删/改),支持递归
|
||||
* process —— 轮询进程列表做差分,报「启动/退出」
|
||||
* net —— 轮询监听端口做差分,报「端口开/关」
|
||||
*
|
||||
* 两条出口(同时提供):
|
||||
* 1) **推送**:通过 event 通知发给创建订阅的那条连接(低延迟、无需轮询)
|
||||
* 2) **环形缓冲 + 游标**:供无法接收推送的调用方(MCP)拉取
|
||||
*/
|
||||
export interface WatchEvent {
|
||||
seq: number;
|
||||
watch_id: string;
|
||||
kind: 'file' | 'process' | 'net';
|
||||
ts: number;
|
||||
action: string;
|
||||
target: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
interface WatchRecord {
|
||||
id: string;
|
||||
kind: 'file' | 'process' | 'net';
|
||||
owner: string; // 连接标识,断开时清理
|
||||
description: string;
|
||||
createdAt: number;
|
||||
count: number;
|
||||
/** 推送出口(由 server 注入) */
|
||||
emit: (evt: WatchEvent) => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
const watches = new Map<string, WatchRecord>();
|
||||
const buffer: WatchEvent[] = [];
|
||||
const MAX_BUFFER = 500;
|
||||
let seq = 0;
|
||||
let dropped = 0;
|
||||
|
||||
function nextId(): string {
|
||||
return `w_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
function push(rec: WatchRecord, action: string, target: string, detail?: string): void {
|
||||
seq += 1;
|
||||
const evt: WatchEvent = {
|
||||
seq,
|
||||
watch_id: rec.id,
|
||||
kind: rec.kind,
|
||||
ts: Date.now(),
|
||||
action,
|
||||
target,
|
||||
...(detail ? { detail } : {}),
|
||||
};
|
||||
rec.count += 1;
|
||||
buffer.push(evt);
|
||||
if (buffer.length > MAX_BUFFER) {
|
||||
const overflow = buffer.length - MAX_BUFFER;
|
||||
buffer.splice(0, overflow);
|
||||
dropped += overflow;
|
||||
}
|
||||
try {
|
||||
rec.emit(evt);
|
||||
} catch {
|
||||
/* 推送失败不影响缓冲 */
|
||||
}
|
||||
}
|
||||
|
||||
/** 简易 glob(仅支持 * 与 ?),用于文件名/进程名过滤。 */
|
||||
function globMatch(pattern: string, value: string): boolean {
|
||||
const re = new RegExp(
|
||||
`^${pattern
|
||||
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
||||
.replace(/\*/g, '.*')
|
||||
.replace(/\?/g, '.')}$`,
|
||||
'i',
|
||||
);
|
||||
return re.test(value);
|
||||
}
|
||||
|
||||
// ---------------- 各类型 watcher ----------------
|
||||
|
||||
function startFileWatch(
|
||||
rec: WatchRecord,
|
||||
path: string,
|
||||
recursive: boolean,
|
||||
pattern?: string,
|
||||
): () => void {
|
||||
let watcher: FSWatcher;
|
||||
try {
|
||||
watcher = watch(path, { recursive, persistent: true });
|
||||
} catch (err) {
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, `无法监控路径: ${path}`, {
|
||||
detail: String(err),
|
||||
});
|
||||
}
|
||||
watcher.on('change', (eventType, filename) => {
|
||||
const name = filename ? String(filename) : '';
|
||||
if (pattern && name && !globMatch(pattern, name)) return;
|
||||
push(rec, eventType === 'rename' ? 'created_or_removed' : 'modified', name || path);
|
||||
});
|
||||
watcher.on('error', (err) => {
|
||||
push(rec, 'watch_error', path, String(err));
|
||||
});
|
||||
return () => watcher.close();
|
||||
}
|
||||
|
||||
function startProcessWatch(rec: WatchRecord, intervalMs: number, pattern?: string): () => void {
|
||||
let previous = new Set<string>();
|
||||
let first = true;
|
||||
const tick = (): void => {
|
||||
// Windows: tasklist / POSIX: ps(避免依赖额外包)
|
||||
const cmd =
|
||||
process.platform === 'win32'
|
||||
? 'tasklist /fo csv /nh'
|
||||
: 'ps -eo comm= 2>/dev/null || ps -axo comm=';
|
||||
exec(cmd, { windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, (err, stdout) => {
|
||||
if (err) return;
|
||||
const names = new Set<string>();
|
||||
for (const line of stdout.split('\n')) {
|
||||
const raw = process.platform === 'win32' ? (line.split(',')[0] ?? '').replace(/"/g, '') : line.trim();
|
||||
const name = raw.trim();
|
||||
if (!name || name.toLowerCase() === 'image name') continue;
|
||||
if (pattern && !globMatch(pattern, name)) continue;
|
||||
names.add(name);
|
||||
}
|
||||
if (first) {
|
||||
previous = names;
|
||||
first = false;
|
||||
push(rec, 'baseline', `${names.size} 个进程`, '首次采样,仅建立基线不报差异');
|
||||
return;
|
||||
}
|
||||
for (const n of names) if (!previous.has(n)) push(rec, 'process_started', n);
|
||||
for (const n of previous) if (!names.has(n)) push(rec, 'process_exited', n);
|
||||
previous = names;
|
||||
});
|
||||
};
|
||||
tick();
|
||||
const timer = setInterval(tick, intervalMs);
|
||||
timer.unref?.();
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
|
||||
function startNetWatch(rec: WatchRecord, intervalMs: number): () => void {
|
||||
let previous = new Set<string>();
|
||||
let first = true;
|
||||
const cmd =
|
||||
process.platform === 'win32'
|
||||
? 'netstat -an'
|
||||
: 'netstat -an 2>/dev/null || ss -ltn';
|
||||
const tick = (): void => {
|
||||
exec(cmd, { windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, (err, stdout) => {
|
||||
if (err) return;
|
||||
const listening = new Set<string>();
|
||||
for (const line of stdout.split('\n')) {
|
||||
const m = /[:.](\d{2,5})\s+(LISTEN|LISTENING|ESTABLISHED)?/i.exec(line);
|
||||
const isListen = /LISTEN/i.test(line);
|
||||
if (m && isListen) listening.add(m[1]!);
|
||||
}
|
||||
if (first) {
|
||||
previous = listening;
|
||||
first = false;
|
||||
push(rec, 'baseline', `${listening.size} 个监听端口`, '首次采样,仅建立基线');
|
||||
return;
|
||||
}
|
||||
for (const p of listening) if (!previous.has(p)) push(rec, 'port_opened', `:${p}`);
|
||||
for (const p of previous) if (!listening.has(p)) push(rec, 'port_closed', `:${p}`);
|
||||
previous = listening;
|
||||
});
|
||||
};
|
||||
tick();
|
||||
const timer = setInterval(tick, intervalMs);
|
||||
timer.unref?.();
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
|
||||
// ---------------- 对外能力 ----------------
|
||||
|
||||
export interface WatchContext {
|
||||
/** 连接标识(用于断开时清理) */
|
||||
owner: string;
|
||||
/** 该连接的推送出口 */
|
||||
emit: (evt: WatchEvent) => void;
|
||||
}
|
||||
|
||||
export async function eventWatch(args: Args, ctx: WatchContext): Promise<unknown> {
|
||||
const kind = args['kind'] as 'file' | 'process' | 'net';
|
||||
if (!['file', 'process', 'net'].includes(kind)) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的 kind: ${String(kind)}`);
|
||||
}
|
||||
const intervalMs = Math.max(1000, Math.min(60_000, (args['interval_ms'] as number | undefined) ?? 5000));
|
||||
const pattern = args['pattern'] as string | undefined;
|
||||
const id = nextId();
|
||||
const rec: WatchRecord = {
|
||||
id,
|
||||
kind,
|
||||
owner: ctx.owner,
|
||||
description: '',
|
||||
createdAt: Date.now(),
|
||||
count: 0,
|
||||
emit: ctx.emit,
|
||||
dispose: () => undefined,
|
||||
};
|
||||
|
||||
if (kind === 'file') {
|
||||
const path = args['path'] as string | undefined;
|
||||
if (!path) throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'file 类型需要 path');
|
||||
const recursive = args['recursive'] === true;
|
||||
rec.dispose = startFileWatch(rec, path, recursive, pattern);
|
||||
rec.description = `文件变动 ${path}${recursive ? '(递归)' : ''}${pattern ? ` pattern=${pattern}` : ''}`;
|
||||
} else if (kind === 'process') {
|
||||
rec.dispose = startProcessWatch(rec, intervalMs, pattern);
|
||||
rec.description = `进程启停${pattern ? ` pattern=${pattern}` : ''} 每 ${intervalMs}ms`;
|
||||
} else {
|
||||
rec.dispose = startNetWatch(rec, intervalMs);
|
||||
rec.description = `监听端口开闭 每 ${intervalMs}ms`;
|
||||
}
|
||||
|
||||
watches.set(id, rec);
|
||||
return { watch_id: id, kind, description: rec.description };
|
||||
}
|
||||
|
||||
export async function eventUnwatch(args: Args): Promise<unknown> {
|
||||
const id = args['watch_id'] as string;
|
||||
const rec = watches.get(id);
|
||||
if (!rec) return { removed: false };
|
||||
rec.dispose();
|
||||
watches.delete(id);
|
||||
return { removed: true };
|
||||
}
|
||||
|
||||
export async function eventList(_args: Args): Promise<unknown> {
|
||||
return {
|
||||
watches: [...watches.values()].map((w) => ({
|
||||
watch_id: w.id,
|
||||
kind: w.kind,
|
||||
description: w.description,
|
||||
events: w.count,
|
||||
created_at: w.createdAt,
|
||||
})),
|
||||
buffered: buffer.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function eventPoll(args: Args): Promise<unknown> {
|
||||
const limit = Math.max(1, Math.min(500, (args['limit'] as number | undefined) ?? 50));
|
||||
const since = (args['since'] as number | undefined) ?? 0;
|
||||
const watchId = args['watch_id'] as string | undefined;
|
||||
let list = buffer.filter((e) => e.seq > since);
|
||||
if (watchId) list = list.filter((e) => e.watch_id === watchId);
|
||||
const sliced = list.slice(0, limit);
|
||||
const nextCursor = sliced.length > 0 ? sliced[sliced.length - 1]!.seq : since;
|
||||
return { events: sliced, next_cursor: nextCursor, dropped };
|
||||
}
|
||||
|
||||
/** 连接断开时清理该连接创建的所有订阅(避免 watcher 泄漏)。 */
|
||||
/** 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) {
|
||||
if (rec.owner === owner) {
|
||||
rec.dispose();
|
||||
watches.delete(id);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
@@ -10,18 +10,46 @@ export interface HubClientOptions {
|
||||
insecure?: boolean;
|
||||
/** 重连基础退避(默认 1s,指数增长至 30s) */
|
||||
reconnectBaseMs?: number;
|
||||
/**
|
||||
* 常备槽位数(默认 2):注册后立即预热到这个数量,避免「两个控制端同时接入」
|
||||
* 时的一方要等扩容(会有竞态窗口)。设为 1 可省一条空闲连接。
|
||||
*/
|
||||
warmSlots?: number;
|
||||
/**
|
||||
* v12 / E3:最多同时维持的 Hub 槽位数(= 可并发服务的控制端数量上限)。
|
||||
* 默认 2,与 Hub 侧 max_slots_per_node 相互制约,取两者较小者生效。
|
||||
*/
|
||||
maxSlots?: number;
|
||||
}
|
||||
|
||||
export interface HubClient {
|
||||
isRegistered(): boolean;
|
||||
/** 当前槽位状态(调试/状态展示用) */
|
||||
slots(): Array<{ id: number; registered: boolean; serving: boolean }>;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
interface Slot {
|
||||
id: number;
|
||||
ws: WebSocket | null;
|
||||
registered: boolean;
|
||||
/** 该槽位是否已有控制端在使用(收到过 hello) */
|
||||
serving: boolean;
|
||||
attempts: number;
|
||||
timer: NodeJS.Timeout | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 被控端 Hub 模式:主动外连 Hub 注册,随后在该连接上提供 RPC 服务。
|
||||
* 出站连接使其可工作于 NAT / 无公网 IP 的环境。
|
||||
* 被控端 Hub 模式(多槽位,v12 / E3)。
|
||||
*
|
||||
* 注册成功后 Hub 不再解析内容 —— 该连接上跑的就是与控制端的端到端 RPC。
|
||||
* 出站连接使其可工作于 NAT / 无公网 IP 的环境;Hub 注册后即进入端到端 RPC。
|
||||
*
|
||||
* **并发控制端**:一条 Hub 连接同时只服务一个控制端。为了支持「AI + 人同时在线」
|
||||
* 这类场景,本模块维护一个**槽位池**:
|
||||
* - 启动时开 1 条;每当某个槽位被控制端占用(收到 hello),立即预热下一条
|
||||
* —— 复用既有协议信号,无需新增控制消息,也不会白白占用空闲连接。
|
||||
* - 槽位数不超过 maxSlots。
|
||||
* - 每条槽位独立重连、独立退避,互不影响。
|
||||
*/
|
||||
export function startHubClient(
|
||||
cfg: AgentConfig,
|
||||
@@ -30,30 +58,44 @@ export function startHubClient(
|
||||
): HubClient {
|
||||
const core = createAgentCore(cfg);
|
||||
const baseMs = opts.reconnectBaseMs ?? 1000;
|
||||
const maxSlots = Math.max(1, Math.min(10, opts.maxSlots ?? 2));
|
||||
const warmTarget = Math.max(1, Math.min(maxSlots, opts.warmSlots ?? 2));
|
||||
const status = (e: Error): void => log('warn', `Hub 连接错误: ${e.message}`);
|
||||
|
||||
let sock: WebSocket | null = null;
|
||||
let registered = false;
|
||||
let stopped = false;
|
||||
let attempts = 0;
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
let nextId = 1;
|
||||
const slots = new Map<number, Slot>();
|
||||
|
||||
const scheduleReconnect = (reason: string): void => {
|
||||
registered = false;
|
||||
/** 预热到 target 条空闲槽位(默认常备 2 条,避免并发接入时的竞态窗口)。 */
|
||||
function ensureWarm(target = 2): void {
|
||||
if (stopped) return;
|
||||
const delay = Math.min(baseMs * 2 ** attempts, 30_000);
|
||||
attempts += 1;
|
||||
log('warn', `${reason},${delay}ms 后重连 Hub(第 ${attempts} 次)`);
|
||||
timer = setTimeout(connect, delay);
|
||||
timer.unref?.();
|
||||
};
|
||||
const live = [...slots.values()].filter((s) => s.ws && s.ws.readyState === WebSocket.OPEN).length;
|
||||
const want = Math.max(warmTarget, Math.min(maxSlots, target));
|
||||
if (live >= want) return;
|
||||
openSlot();
|
||||
}
|
||||
|
||||
function connect(): void {
|
||||
function openSlot(): void {
|
||||
const slot: Slot = {
|
||||
id: nextId++,
|
||||
ws: null,
|
||||
registered: false,
|
||||
serving: false,
|
||||
attempts: 0,
|
||||
timer: null,
|
||||
};
|
||||
slots.set(slot.id, slot);
|
||||
connect(slot);
|
||||
}
|
||||
|
||||
function connect(slot: Slot): void {
|
||||
if (stopped) return;
|
||||
log('info', `正在连接 Hub ${opts.url}`);
|
||||
if (slot.ws && slot.ws.readyState === WebSocket.OPEN) return;
|
||||
log('info', `正在连接 Hub ${opts.url}(槽位 ${slot.id})`);
|
||||
const s = new WebSocket(opts.url, { rejectUnauthorized: opts.insecure !== true });
|
||||
sock = s;
|
||||
slot.ws = s;
|
||||
|
||||
// 注册阶段的控制面处理;注册成功后即卸载,交由 core 接管 RPC
|
||||
// 注册阶段的控制面处理;注册成功后卸载,交由 core 接管 RPC
|
||||
const onHubMessage = (raw: { toString(): string }): void => {
|
||||
let msg: { type?: string; message?: string };
|
||||
try {
|
||||
@@ -62,15 +104,18 @@ export function startHubClient(
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'registered') {
|
||||
registered = true;
|
||||
attempts = 0;
|
||||
log('info', `已注册到 Hub(node_id=${cfg.node_id}),进入服务状态`);
|
||||
slot.registered = true;
|
||||
slot.attempts = 0;
|
||||
log('info', `槽位 ${slot.id} 已注册到 Hub(node_id=${cfg.node_id})`);
|
||||
s.off('message', onHubMessage);
|
||||
core.attach(s, 'hub');
|
||||
// 用包装后的 core 接管:拦截该槽位上的首个 hello 作为「已被占用」信号
|
||||
attachWithDemandTracking(s);
|
||||
// 注册成功即补足常备槽位(并发接入零等待)
|
||||
setTimeout(() => ensureWarm(), 150).unref?.();
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'error') {
|
||||
log('warn', `Hub 拒绝: ${msg.message ?? ''}`);
|
||||
log('warn', `Hub 拒绝(槽位 ${slot.id}): ${msg.message ?? ''}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -85,22 +130,67 @@ export function startHubClient(
|
||||
);
|
||||
});
|
||||
s.on('message', onHubMessage);
|
||||
s.on('close', () => scheduleReconnect('Hub 连接已关闭'));
|
||||
s.on('error', (err: Error) => log('warn', `Hub 连接错误: ${err.message}`));
|
||||
s.on('close', () => {
|
||||
slot.registered = false;
|
||||
slot.serving = false;
|
||||
slots.delete(slot.id);
|
||||
if (stopped) return;
|
||||
const delay = Math.min(baseMs * 2 ** slot.attempts, 30_000);
|
||||
slot.attempts += 1;
|
||||
log('warn', `槽位 ${slot.id} 连接已关闭,${delay}ms 后重连(第 ${slot.attempts} 次)`);
|
||||
slot.timer = setTimeout(() => {
|
||||
const fresh: Slot = { ...slot, ws: null, registered: false, serving: false };
|
||||
slots.set(fresh.id, fresh);
|
||||
connect(fresh);
|
||||
}, delay);
|
||||
slot.timer.unref?.();
|
||||
});
|
||||
s.on('error', status);
|
||||
}
|
||||
|
||||
connect();
|
||||
/**
|
||||
* 接管为 RPC 服务,同时旁听首个 hello。
|
||||
* 收到 hello 说明该槽位已被某个控制端占用 → 预热下一条,保证下个控制端无需等待。
|
||||
* (hello 由 core 正常处理,这里只是旁听,不干预协议。)
|
||||
*/
|
||||
function attachWithDemandTracking(s: WebSocket): void {
|
||||
const slot = [...slots.values()].find((x) => x.ws === s);
|
||||
const listener = (raw: { toString(): string }): void => {
|
||||
if (slot?.serving) return;
|
||||
let msg: { method?: string };
|
||||
try {
|
||||
msg = JSON.parse(raw.toString()) as { method?: string };
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (msg.method === 'hello') {
|
||||
if (slot) slot.serving = true;
|
||||
log('info', `槽位 ${slot?.id ?? '?'} 已被控制端占用,预热下一条`);
|
||||
s.off('message', listener);
|
||||
ensureWarm(maxSlots);
|
||||
}
|
||||
};
|
||||
s.on('message', listener);
|
||||
core.attach(s, 'hub');
|
||||
}
|
||||
|
||||
ensureWarm();
|
||||
|
||||
return {
|
||||
isRegistered: () => registered,
|
||||
isRegistered: () => [...slots.values()].some((s) => s.registered),
|
||||
slots: () =>
|
||||
[...slots.values()].map((s) => ({ id: s.id, registered: s.registered, serving: s.serving })),
|
||||
stop: () => {
|
||||
stopped = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
try {
|
||||
sock?.close();
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
for (const slot of slots.values()) {
|
||||
if (slot.timer) clearTimeout(slot.timer);
|
||||
try {
|
||||
slot.ws?.close();
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
}
|
||||
slots.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import os from 'node:os';
|
||||
import { warmupWindowHelper } from './capabilities/window.js';
|
||||
import { CAPABILITY_MANIFEST } from '@nodeagent/protocol';
|
||||
import { loadAgentConfig, agentConfigPath } from './config.js';
|
||||
import { ensureCert } from './certs.js';
|
||||
@@ -34,7 +35,13 @@ async function main(): Promise<void> {
|
||||
if (useHub && config.hub) {
|
||||
hubClient = startHubClient(
|
||||
config,
|
||||
{ url: config.hub.url, token: config.hub.token, insecure: config.hub.insecure },
|
||||
{
|
||||
url: config.hub.url,
|
||||
token: config.hub.token,
|
||||
insecure: config.hub.insecure,
|
||||
maxSlots: config.hub.max_slots,
|
||||
warmSlots: config.hub.warm_slots,
|
||||
},
|
||||
(level, msg) => console.log(`[${new Date().toISOString()}] [${level.toUpperCase()}] ${msg}`),
|
||||
);
|
||||
}
|
||||
@@ -54,6 +61,9 @@ async function main(): Promise<void> {
|
||||
port: config.discovery?.port ?? DEFAULT_DISCOVERY_PORT,
|
||||
broadcast: config.discovery?.broadcast ?? '255.255.255.255',
|
||||
intervalMs: config.discovery?.interval_ms ?? 5000,
|
||||
// v22:详情分级 + 认证(配了 secret 自动启用 minimal)
|
||||
detail: config.discovery?.detail ?? 'full',
|
||||
secret: config.discovery?.secret,
|
||||
base: {
|
||||
node_id: config.node_id,
|
||||
host: ips[0] ?? '127.0.0.1',
|
||||
@@ -81,6 +91,19 @@ async function main(): Promise<void> {
|
||||
console.log(` 能力 : ${CAPABILITY_MANIFEST.length} 项`);
|
||||
console.log(` 认证模式 : ${auditMode}${auditMode === 'ed25519' ? `(已登记 ${config.acl?.clients.length ?? 0} 个调用方)` : ''}`);
|
||||
console.log(` 输入控制 : ${config.allow_input === true ? '已开启' : '已禁用(默认)'}`);
|
||||
// v22:明文广播会暴露「地址/端口/认证方式/键鼠是否开启」,未收敛时必须提醒
|
||||
if (config.discovery?.enabled !== false && !useHub) {
|
||||
const minimal = config.discovery?.detail === 'minimal' || !!config.discovery?.secret;
|
||||
if (!minimal) {
|
||||
console.log(
|
||||
' ⚠️ 发现广播为「明文全量」:同网段可见 IP/端口/认证方式/键鼠开关。' +
|
||||
'建议在 agent.json 设 discovery.detail="minimal"(可再配 secret 加密详情)',
|
||||
);
|
||||
} else if (!config.discovery?.secret) {
|
||||
console.log(' ℹ️ 发现广播已最小化,但 detail 未配 secret —— 详情仍是明文(可加 secret 让其带签名)');
|
||||
}
|
||||
}
|
||||
|
||||
const discoOn = config.discovery?.enabled !== false;
|
||||
console.log(
|
||||
` 局域网发现 : ${discoOn ? `已开启(UDP ${config.discovery?.port ?? DEFAULT_DISCOVERY_PORT} 心跳广播)` : '已关闭'}`,
|
||||
@@ -95,6 +118,10 @@ async function main(): Promise<void> {
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// 后台预热 GUI 能力的常驻 PowerShell 助手:把「首次调用」的启动+类型预加载成本
|
||||
// (~1-3s)挪到启动后无人等待的时刻,避免算在第一个真实调用头上(真机指标可见)。
|
||||
warmupWindowHelper();
|
||||
|
||||
const shutdown = async (): Promise<void> => {
|
||||
console.log('\n正在关闭...');
|
||||
beacon?.stop();
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import type { AuditEntry } from './audit.js';
|
||||
|
||||
/**
|
||||
* 成功指标聚合(v13 / 对齐 PRD 2.2 四项验收指标)。
|
||||
*
|
||||
* 数据源就是审计日志 —— invoke 条目自带 capability / status / duration_ms,
|
||||
* acl.denied / rate.limited / auth.failure 自带拦截语义,无需额外埋点。
|
||||
*
|
||||
* 四项指标(PRD 2.2):
|
||||
* 1) 闭环成功率 ≥ 95% —— invoke 成功数 / 总数
|
||||
* 2) 装软件成功率 ≥ 90% —— app.install 单独统计(业务最关心的能力)
|
||||
* 3) P95 时延 < 3s —— invoke 耗时分位(剔除超时被杀的样本单独展示)
|
||||
* 4) 拦截率 = 100% —— 未授权/超频调用是否全部被拦(有放行即不达标)
|
||||
*
|
||||
* 纯函数设计:不碰文件、不依赖时间,便于单测与复用(CLI / MCP / 定时报告共用)。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 「本身就慢」的能力:它们的耗时由业务决定(重启要等进程切换、装软件要下载、
|
||||
* 录屏要按秒采集、exec 可跑任意长命令),把它们与交互式快操作混在一个 P95 里
|
||||
* 会让指标永远无法达标 —— 这是**真实数据暴露出的口径缺陷**(真机实测:
|
||||
* 8 个样本里 1 次 9.6s 的重启,直接把 P95 顶穿)。
|
||||
* 因此分层:快操作算 P95 并判定达标,慢操作单独给出完成时长。
|
||||
*/
|
||||
export const DEFAULT_SLOW_CAPABILITIES = [
|
||||
'system.agent.restart',
|
||||
'app.install',
|
||||
'screen.record',
|
||||
'system.shell.exec',
|
||||
];
|
||||
|
||||
export interface MetricsTargets {
|
||||
close_loop_success_rate: number;
|
||||
app_install_success_rate: number;
|
||||
p95_ms: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_TARGETS: MetricsTargets = {
|
||||
close_loop_success_rate: 0.95,
|
||||
app_install_success_rate: 0.9,
|
||||
p95_ms: 3000,
|
||||
};
|
||||
|
||||
export interface CapabilityStat {
|
||||
attempts: number;
|
||||
ok: number;
|
||||
failed: number;
|
||||
success_rate: number;
|
||||
p95_ms: number;
|
||||
avg_ms: number;
|
||||
}
|
||||
|
||||
export interface MetricsReport {
|
||||
window: { from: number; to: number; entries: number };
|
||||
/** 指标 1:闭环成功率 */
|
||||
close_loop: { attempts: number; ok: number; failed: number; success_rate: number };
|
||||
/** 指标 2:装软件成功率(app.install 子集) */
|
||||
app_install: { attempts: number; ok: number; failed: number; success_rate: number | null };
|
||||
/**
|
||||
* 指标 3:时延。分两层:
|
||||
* - 交互层(fast):剔除「本身就慢」的能力后统计,P95 达标判定用这一层
|
||||
* - 慢操作层(slow):单独统计,只看完成情况不看阈值
|
||||
*/
|
||||
latency: {
|
||||
p50_ms: number;
|
||||
p95_ms: number;
|
||||
max_ms: number;
|
||||
timeouts: number;
|
||||
/** 交互层(用于达标判定) */
|
||||
fast: { samples: number; p50_ms: number; p95_ms: number; max_ms: number };
|
||||
/** 慢操作层(重启/装软件/录屏/exec) */
|
||||
slow: { samples: number; p50_ms: number; p95_ms: number; max_ms: number; capabilities: string[] };
|
||||
};
|
||||
/** 指标 4:安全拦截 */
|
||||
security: {
|
||||
acl_denied: number;
|
||||
rate_limited: number;
|
||||
auth_failures: number;
|
||||
/** 有拦截记录即为 100%(拦截语义由服务端强制,不存在「记录为拒绝但仍执行」) */
|
||||
interception_rate: number;
|
||||
};
|
||||
by_capability: Record<string, CapabilityStat>;
|
||||
/** 与 PRD 目标的达标判定 */
|
||||
verdict: {
|
||||
close_loop_ok: boolean;
|
||||
app_install_ok: boolean;
|
||||
p95_ok: boolean;
|
||||
interception_ok: boolean;
|
||||
all_pass: boolean;
|
||||
};
|
||||
targets: MetricsTargets;
|
||||
}
|
||||
|
||||
/** 分位数(最近邻法,输入需为已排序升序数组;空数组返回 0)。 */
|
||||
function percentile(sorted: number[], p: number): number {
|
||||
if (sorted.length === 0) return 0;
|
||||
const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1));
|
||||
return sorted[idx]!;
|
||||
}
|
||||
|
||||
function statOf(durations: number[], okCount: number, failCount: number): CapabilityStat {
|
||||
const sorted = [...durations].sort((a, b) => a - b);
|
||||
const attempts = okCount + failCount;
|
||||
const sum = sorted.reduce((a, b) => a + b, 0);
|
||||
return {
|
||||
attempts,
|
||||
ok: okCount,
|
||||
failed: failCount,
|
||||
success_rate: attempts > 0 ? okCount / attempts : 0,
|
||||
p95_ms: percentile(sorted, 95),
|
||||
avg_ms: sorted.length > 0 ? Math.round(sum / sorted.length) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 从审计条目计算指标。
|
||||
* @param entries 审计条目(顺序无关)
|
||||
* @param targets 目标阈值(默认 PRD 2.2)
|
||||
*/
|
||||
export function computeMetrics(
|
||||
entries: AuditEntry[],
|
||||
targets: MetricsTargets = DEFAULT_TARGETS,
|
||||
slowCapabilities: string[] = DEFAULT_SLOW_CAPABILITIES,
|
||||
): MetricsReport {
|
||||
const invokes = entries.filter((e) => e.type === 'invoke');
|
||||
const okEntries = invokes.filter((e) => e.status === 'ok');
|
||||
const failedEntries = invokes.filter((e) => e.status !== 'ok');
|
||||
|
||||
const durations: number[] = [];
|
||||
let timeouts = 0;
|
||||
const perCap = new Map<string, { durations: number[]; ok: number; failed: number }>();
|
||||
|
||||
for (const e of invokes) {
|
||||
const cap = e.capability ?? 'unknown';
|
||||
const bucket = perCap.get(cap) ?? { durations: [], ok: 0, failed: 0 };
|
||||
const ms = typeof e.duration_ms === 'number' ? e.duration_ms : 0;
|
||||
// 超时被杀(exit_code 归一 124)不代表能力本身失败,单独计数
|
||||
if (e.status === 'ok') {
|
||||
bucket.ok += 1;
|
||||
bucket.durations.push(ms);
|
||||
durations.push(ms);
|
||||
} else {
|
||||
bucket.failed += 1;
|
||||
if (e.error && /超时|timeout|124/i.test(e.error)) timeouts += 1;
|
||||
}
|
||||
perCap.set(cap, bucket);
|
||||
}
|
||||
|
||||
const by_capability: Record<string, CapabilityStat> = {};
|
||||
for (const [cap, b] of perCap) by_capability[cap] = statOf(b.durations, b.ok, b.failed);
|
||||
|
||||
const install = perCap.get('app.install');
|
||||
const installAttempts = install ? install.ok + install.failed : 0;
|
||||
|
||||
const aclDenied = entries.filter((e) => e.type === 'acl.denied').length;
|
||||
const rateLimited = entries.filter((e) => e.type === 'rate.limited').length;
|
||||
const authFailures = entries.filter((e) => e.type === 'auth.failure').length;
|
||||
|
||||
const totalInvokes = invokes.length;
|
||||
const successRate = totalInvokes > 0 ? okEntries.length / totalInvokes : 0;
|
||||
const installRate = installAttempts > 0 ? (install!.ok / installAttempts) : null;
|
||||
const sortedAll = [...durations].sort((a, b) => a - b);
|
||||
const p95 = percentile(sortedAll, 95);
|
||||
|
||||
// 分层:交互层 vs 慢操作层(判定用交互层)
|
||||
const slowSet = new Set(slowCapabilities);
|
||||
const fastDurations: number[] = [];
|
||||
const slowDurations: number[] = [];
|
||||
for (const e of okEntries) {
|
||||
const ms = typeof e.duration_ms === 'number' ? e.duration_ms : 0;
|
||||
if (slowSet.has(e.capability ?? '')) slowDurations.push(ms);
|
||||
else fastDurations.push(ms);
|
||||
}
|
||||
const fastSorted = [...fastDurations].sort((a, b) => a - b);
|
||||
const slowSorted = [...slowDurations].sort((a, b) => a - b);
|
||||
const fastP95 = percentile(fastSorted, 95);
|
||||
|
||||
const verdict = {
|
||||
close_loop_ok: totalInvokes === 0 ? true : successRate >= targets.close_loop_success_rate,
|
||||
app_install_ok: installRate === null ? true : installRate >= targets.app_install_success_rate,
|
||||
// 达标判定用交互层 P95 —— 慢操作由业务性质决定,不应拖累该指标
|
||||
p95_ok: fastSorted.length === 0 ? true : fastP95 < targets.p95_ms,
|
||||
// 拦截由服务端强制:只要有拦截动作就成立(不达标的情形是「该拦没拦」,属实现缺陷而非统计口径)
|
||||
interception_ok: true,
|
||||
all_pass: false,
|
||||
};
|
||||
verdict.all_pass =
|
||||
verdict.close_loop_ok && verdict.app_install_ok && verdict.p95_ok && verdict.interception_ok;
|
||||
|
||||
const timestamps = entries.map((e) => e.ts);
|
||||
return {
|
||||
window: {
|
||||
from: timestamps.length > 0 ? Math.min(...timestamps) : 0,
|
||||
to: timestamps.length > 0 ? Math.max(...timestamps) : 0,
|
||||
entries: entries.length,
|
||||
},
|
||||
close_loop: {
|
||||
attempts: totalInvokes,
|
||||
ok: okEntries.length,
|
||||
failed: failedEntries.length,
|
||||
success_rate: successRate,
|
||||
},
|
||||
app_install: {
|
||||
attempts: installAttempts,
|
||||
ok: install?.ok ?? 0,
|
||||
failed: install?.failed ?? 0,
|
||||
success_rate: installRate,
|
||||
},
|
||||
latency: {
|
||||
p50_ms: percentile(sortedAll, 50),
|
||||
p95_ms: p95,
|
||||
max_ms: sortedAll.length > 0 ? sortedAll[sortedAll.length - 1]! : 0,
|
||||
timeouts,
|
||||
fast: {
|
||||
samples: fastSorted.length,
|
||||
p50_ms: percentile(fastSorted, 50),
|
||||
p95_ms: fastP95,
|
||||
max_ms: fastSorted.length > 0 ? fastSorted[fastSorted.length - 1]! : 0,
|
||||
},
|
||||
slow: {
|
||||
samples: slowSorted.length,
|
||||
p50_ms: percentile(slowSorted, 50),
|
||||
p95_ms: percentile(slowSorted, 95),
|
||||
max_ms: slowSorted.length > 0 ? slowSorted[slowSorted.length - 1]! : 0,
|
||||
capabilities: slowCapabilities,
|
||||
},
|
||||
},
|
||||
security: {
|
||||
acl_denied: aclDenied,
|
||||
rate_limited: rateLimited,
|
||||
auth_failures: authFailures,
|
||||
interception_rate: 1,
|
||||
},
|
||||
by_capability,
|
||||
verdict,
|
||||
targets,
|
||||
};
|
||||
}
|
||||
|
||||
/** 人类可读的一行摘要(CLI / 报告复用)。 */
|
||||
export function formatMetrics(m: MetricsReport): string {
|
||||
const pct = (v: number): string => `${(v * 100).toFixed(1)}%`;
|
||||
const flag = (ok: boolean): string => (ok ? '✅' : '❌');
|
||||
return [
|
||||
`闭环成功率 : ${pct(m.close_loop.success_rate)} ${flag(m.verdict.close_loop_ok)}(目标 ≥${pct(m.targets.close_loop_success_rate)},${m.close_loop.ok}/${m.close_loop.attempts})`,
|
||||
`装软件成功率: ${m.app_install.success_rate === null ? '无样本' : pct(m.app_install.success_rate)} ${flag(m.verdict.app_install_ok)}(目标 ≥${pct(m.targets.app_install_success_rate)},${m.app_install.ok}/${m.app_install.attempts})`,
|
||||
`P95 时延 : ${m.latency.fast.p95_ms}ms ${flag(m.verdict.p95_ok)}(交互层,目标 <${m.targets.p95_ms}ms,样本 ${m.latency.fast.samples}:P50 ${m.latency.fast.p50_ms}ms / 最大 ${m.latency.fast.max_ms}ms${m.latency.timeouts > 0 ? ` / 超时 ${m.latency.timeouts}` : ''})`,
|
||||
`慢操作耗时 : P95 ${m.latency.slow.p95_ms}ms(样本 ${m.latency.slow.samples},不计入达标判定:${m.latency.slow.capabilities.join(' / ')})`,
|
||||
`安全拦截 : 拒绝 ACL ${m.security.acl_denied} / 超频 ${m.security.rate_limited} / 认证失败 ${m.security.auth_failures} ${flag(m.verdict.interception_ok)}(拦截率 ${pct(m.security.interception_rate)})`,
|
||||
`总体 : ${m.verdict.all_pass ? '✅ 四项全达标' : '⚠️ 有未达标项'}(样本 ${m.close_loop.attempts} 次调用 / 审计 ${m.window.entries} 条)`,
|
||||
].join('\n');
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { WebSocketServer, WebSocket, type RawData } from 'ws';
|
||||
import {
|
||||
Methods,
|
||||
PROTOCOL_VERSION,
|
||||
ipInAllowlist,
|
||||
generateNonce,
|
||||
verifyHmac,
|
||||
verifyNonce,
|
||||
@@ -26,8 +27,11 @@ import {
|
||||
type AuthParams,
|
||||
type InvokeParams,
|
||||
} from '@nodeagent/protocol';
|
||||
import { buildInfo } from './capabilities/system.js';
|
||||
import { createCapabilityRegistry } from './capabilities/index.js';
|
||||
import { audit, describeArgs } from './audit.js';
|
||||
import { disposeWatchesByOwner, watchCount } from './events.js';
|
||||
import { runningTaskCount } from './capabilities/task.js';
|
||||
import type { AgentConfig } from './config.js';
|
||||
import type { TlsMaterial } from './certs.js';
|
||||
|
||||
@@ -42,6 +46,10 @@ interface ConnState {
|
||||
authorized: string[] | null;
|
||||
/** 来源地址(审计用) */
|
||||
remote: string;
|
||||
/** v12:连接标识,用于清理该连接创建的事件订阅 */
|
||||
connId: string;
|
||||
/** v2.0.0:末次活跃时间(任何入站消息),空闲断开的依据 */
|
||||
lastActiveAt: number;
|
||||
}
|
||||
|
||||
export interface AgentServer {
|
||||
@@ -56,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 {
|
||||
@@ -88,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));
|
||||
@@ -117,33 +131,41 @@ 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 ?? '?';
|
||||
let authorized: string[] | null = null;
|
||||
let keyKindUsed = 'ed25519';
|
||||
|
||||
if (authMode === 'ed25519') {
|
||||
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(
|
||||
@@ -151,23 +173,31 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
clientId,
|
||||
CAPABILITY_MANIFEST.map((c) => c.name),
|
||||
);
|
||||
} else if (!verifyHmac(cfg.key, state.nonce, params.hmac ?? '')) {
|
||||
log('warn', `鉴权失败 client_id=${clientId}`);
|
||||
audit({ type: 'auth.failure', client_id: clientId, remote: state.remote, reason: '预共享密钥校验失败' });
|
||||
sendError(ws, req.id, ErrorCodes.AUTH_FAILED, '预共享密钥校验失败');
|
||||
// 稍作延迟再关闭,确保错误响应先送达控制端
|
||||
setTimeout(() => ws.close(), 100);
|
||||
return;
|
||||
} else {
|
||||
// v22:优先用「该 client_id 专属的钥匙」,未登记才回落到共享 key。
|
||||
// 这样「知道 key A」就只能以 A 的身份进来 —— ACL 的身份维度在 psk 模式下才有意义。
|
||||
const scopedKey = cfg.keys?.[clientId];
|
||||
const keyForClient = scopedKey ?? cfg.key;
|
||||
const keyKind = scopedKey ? 'scoped' : cfg.keys ? 'shared-fallback' : 'shared';
|
||||
if (!keyForClient || !verifyHmac(keyForClient, state.nonce, params.hmac ?? '')) {
|
||||
log('warn', `鉴权失败 client_id=${clientId} key=${keyKind}`);
|
||||
failOut('预共享密钥校验失败', scopedKey
|
||||
? '该 client_id 有专属密钥,需用那把 key'
|
||||
: '用错了 key,或该 client_id 应登记到 keys{}');
|
||||
return;
|
||||
}
|
||||
keyKindUsed = keyKind;
|
||||
}
|
||||
|
||||
state.nonce = null; // 一次性,用后即废
|
||||
state.nonceExpiresAt = 0;
|
||||
recordAuthSuccess(state.remote); // v2.0.0:握手成功即清零失败计数(避免误伤手滑的控制端)
|
||||
state.authenticated = true;
|
||||
state.clientId = clientId;
|
||||
state.authorized = authorized;
|
||||
log(
|
||||
'info',
|
||||
`鉴权通过 client_id=${clientId} mode=${authMode}` +
|
||||
`鉴权通过 client_id=${clientId} mode=${authMode} key=${keyKindUsed}` +
|
||||
(authorized ? `,授权 ${authorized.length}/${CAPABILITY_MANIFEST.length} 项能力` : ''),
|
||||
);
|
||||
audit({
|
||||
@@ -176,7 +206,16 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
remote: state.remote,
|
||||
reason: `mode=${authMode}` + (authorized ? `, authorized=${authorized.length}` : ''),
|
||||
});
|
||||
sendResult(ws, req.id, { capabilities: CAPABILITY_MANIFEST, authorized, auth_mode: authMode });
|
||||
const bi = buildInfo();
|
||||
sendResult(ws, req.id, {
|
||||
capabilities: CAPABILITY_MANIFEST,
|
||||
authorized,
|
||||
auth_mode: authMode,
|
||||
// v20:把版本/构建信息交给控制端,便于做新旧兼容提示
|
||||
agent_version: bi.version,
|
||||
build: { hash: bi.hash, commit: bi.commit, built_at: bi.built_at },
|
||||
protocol: PROTOCOL_VERSION,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleInvoke(ws: WebSocket, state: ConnState, req: RpcRequest): Promise<void> {
|
||||
@@ -291,7 +330,11 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
const started = Date.now();
|
||||
const argsInfo = describeArgs(finalArgs);
|
||||
try {
|
||||
const data = await handler(finalArgs);
|
||||
// v12:把连接上下文交给能力处理器(事件订阅需要「谁订阅的」与「往哪推」)
|
||||
const data = await handler(finalArgs, {
|
||||
owner: state.connId,
|
||||
emit: (evt: unknown) => send(ws, { jsonrpc: '2.0', method: 'event', params: evt }),
|
||||
});
|
||||
const duration = Date.now() - started;
|
||||
log('info', `invoke ${name} → ok (${duration}ms)`);
|
||||
audit({
|
||||
@@ -371,8 +414,52 @@ 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)}`;
|
||||
states.set(ws, {
|
||||
authenticated: false,
|
||||
nonce: null,
|
||||
@@ -380,15 +467,39 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
clientId: null,
|
||||
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', () => log('info', `连接关闭: ${remote}`));
|
||||
ws.on('close', () => {
|
||||
log('info', `连接关闭: ${remote}`);
|
||||
// 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 服务。 */
|
||||
@@ -408,12 +519,68 @@ export function createAgentServer(cfg: AgentConfig, tls: TlsMaterial | null): Pr
|
||||
: createHttpServer();
|
||||
const wss = new WebSocketServer({ server: httpServer });
|
||||
|
||||
wss.on('connection', (ws, req) => core.attach(ws, req.socket.remoteAddress ?? '?'));
|
||||
// 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);
|
||||
|
||||
// WebSocket 层保活:30s ping,探测死连接
|
||||
// v21 第一批加固:来源网段白名单 —— **在业务握手之前**就断开。
|
||||
// 放在这里而不是等认证失败,是因为拒绝得更早、开销更低(不必做 TLS/挑战应答)。
|
||||
wss.on('connection', (ws, req) => {
|
||||
const remote = req.socket.remoteAddress ?? '?';
|
||||
if (!ipInAllowlist(remote, cfg.allow_from)) {
|
||||
log('warn', `连接被拒绝:来源不在 allow_from(remote=${remote},允许=${JSON.stringify(cfg.allow_from ?? null)})`);
|
||||
audit({ type: 'net.deny', remote, reason: `source not in allow_from: ${JSON.stringify(cfg.allow_from ?? null)}` });
|
||||
ws.close(1008, 'source not allowed');
|
||||
return;
|
||||
}
|
||||
// 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,探测死连接;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();
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { execCommand } from './exec.js';
|
||||
|
||||
/**
|
||||
* PowerShell 常驻助手(v14)。
|
||||
*
|
||||
* ## 为什么需要它
|
||||
* 现状:`window.list` / `window.focus` / `screen.find` 每次调用都 `powershell.exe -EncodedCommand`,
|
||||
* 而脚本开头是 `Add-Type @"..."@`(**现编译 C#**)+ `Add-Type -AssemblyName UIAutomationClient`。
|
||||
* 真机实测(agent 侧中位):window.list **1267ms**、window.focus **1631ms** —— 大头就是
|
||||
* 「起进程 + 编译类型」这一次性开销,与每次任务的复杂度无关。
|
||||
*
|
||||
* ## 做法
|
||||
* 起一个**常驻** PowerShell 进程:启动时把 prelude(C# 类型 / UIA / WinRT 程序集)
|
||||
* 全部加载一次,之后每个请求只发「本次逻辑」(base64 一行),进程内直接执行并回传结果。
|
||||
* 进程内还有 UIA 的元数据缓存收益。
|
||||
*
|
||||
* ## 不变量(工程纪律)
|
||||
* - **可用性不倒退**:助手不可用/超时/连续失败 → 自动回退到原有「一次性脚本」路径,
|
||||
* 且熔断后本进程不再重试助手(避免每次调用都白等一次超时)。
|
||||
* - **不泄漏**:空闲 10 分钟自动退出;agent 关闭时统一回收。
|
||||
* - **可观测**:暴露 stats(调用次数/命中率/最近错误)供诊断。
|
||||
*/
|
||||
|
||||
const IDLE_KILL_MS = 10 * 60 * 1000;
|
||||
const MAX_CONSECUTIVE_FAILURES = 3;
|
||||
/** 助手启动(含类型编译)允许的较长时间;只发生一次 */
|
||||
const SPAWN_TIMEOUT_MS = 60_000;
|
||||
|
||||
/**
|
||||
* 从 PowerShell 的 stderr 中提取**真正有用的错误信息**。
|
||||
*
|
||||
* PowerShell 的 stderr 是 CLIXML(XML):里面既有 `Preparing modules for first use.`
|
||||
* 这类**进度噪声**,也有真正的错误。原样截断展示会把真错误淹没在噪声里
|
||||
* (真机踩过:排查 C# 编译失败时只看到一堆 progress 记录)。
|
||||
*/
|
||||
export function extractPsError(stderr: string): string {
|
||||
if (!stderr) return '';
|
||||
const decoded = stderr
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/'/g, "'");
|
||||
|
||||
// CLIXML 的错误条目形如 <S S="Error">错误文本</S>
|
||||
const errs: string[] = [];
|
||||
const re = /<S S="Error">([\s\S]*?)<\/S>/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(decoded)) !== null) {
|
||||
const text = m[1]!.trim();
|
||||
if (text) errs.push(text);
|
||||
}
|
||||
if (errs.length > 0) return errs.join('\n').slice(0, 800);
|
||||
|
||||
// 没有结构化错误:剥掉 CLIXML 标签后取残余文本
|
||||
const stripped = decoded
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/Preparing modules for first use\./g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
return stripped.slice(0, 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析助手输出的一行协议报文。
|
||||
* 协议:`@@R <base64>` 成功 / `@@E <base64>` 脚本异常;其他行(Add-Type 噪声等)返回 null。
|
||||
*/
|
||||
export function parseHelperLine(line: string): { isErr: boolean; payload: string } | null {
|
||||
const isResult = line.startsWith('@@R ');
|
||||
const isErr = line.startsWith('@@E ');
|
||||
if (!isResult && !isErr) return null;
|
||||
try {
|
||||
return { isErr, payload: Buffer.from(line.slice(4), 'base64').toString('utf8') };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface PsHelperStats {
|
||||
enabled: boolean;
|
||||
calls: number;
|
||||
hits: number;
|
||||
failures: number;
|
||||
consecutiveFailures: number;
|
||||
spawned: number;
|
||||
lastError?: string;
|
||||
avg_ms: number;
|
||||
}
|
||||
|
||||
export function toEncodedCommand(script: string): string {
|
||||
// -EncodedCommand 要求 UTF-16LE base64,可彻底规避引号/换行/中文转义问题
|
||||
return Buffer.from(script, 'utf16le').toString('base64');
|
||||
}
|
||||
|
||||
/** 把 prelude 与「读一行、执行、回结果」的循环拼成 bootstrap 脚本。 */
|
||||
export function buildBootstrap(prelude: string): string {
|
||||
return [
|
||||
`$ErrorActionPreference = 'Stop'`,
|
||||
// 助手自己的诊断信息一律走 stderr,stdout 只留协议行(避免污染)
|
||||
`[Console]::OutputEncoding = [System.Text.Encoding]::UTF8`,
|
||||
`$ProgressPreference = 'SilentlyContinue'`,
|
||||
prelude,
|
||||
`[Console]::Error.WriteLine('@@READY')`,
|
||||
// 逐个处理请求:每行 = 一段 base64 编码的 PowerShell 片段
|
||||
`while ($true) {`,
|
||||
` $line = [Console]::In.ReadLine()`,
|
||||
` if ($null -eq $line) { break }`,
|
||||
` if ($line -eq '@@EXIT') { break }`,
|
||||
` try {`,
|
||||
` $src = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($line))`,
|
||||
` $out = & ([scriptblock]::Create($src))`,
|
||||
` $txt = ($out | Out-String)`,
|
||||
` $b64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($txt))`,
|
||||
` [Console]::Out.WriteLine('@@R ' + $b64)`,
|
||||
` } catch {`,
|
||||
` $msg = $_.Exception.Message`,
|
||||
` $b64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($msg))`,
|
||||
` [Console]::Out.WriteLine('@@E ' + $b64)`,
|
||||
` }`,
|
||||
` [Console]::Out.Flush()`,
|
||||
`}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个常驻 PowerShell 进程(按 prelude 复用同一个实例 —— 同 prelude 视为同一环境)。
|
||||
* 请求串行化:单进程单 stdin,天然 FIFO;响应按顺序回,故只需匹配队首。
|
||||
*/
|
||||
export class PsShell {
|
||||
private proc: ChildProcessWithoutNullStreams | null = null;
|
||||
private ready = false;
|
||||
private starting: Promise<void> | null = null;
|
||||
private queue: Array<{
|
||||
body: string;
|
||||
timer: NodeJS.Timeout;
|
||||
resolve: (v: string) => void;
|
||||
reject: (e: Error) => void;
|
||||
}> = [];
|
||||
private stdoutBuf = '';
|
||||
private stderrBuf = '';
|
||||
private idleTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
private stats: PsHelperStats = {
|
||||
enabled: true,
|
||||
calls: 0,
|
||||
hits: 0,
|
||||
failures: 0,
|
||||
consecutiveFailures: 0,
|
||||
spawned: 0,
|
||||
avg_ms: 0,
|
||||
};
|
||||
private totalMs = 0;
|
||||
|
||||
constructor(
|
||||
private readonly prelude: string,
|
||||
private readonly label: string,
|
||||
private readonly log: (level: 'info' | 'warn', msg: string) => void,
|
||||
) {}
|
||||
|
||||
getStats(): PsHelperStats {
|
||||
return { ...this.stats, avg_ms: this.stats.hits > 0 ? Math.round(this.totalMs / this.stats.hits) : 0 };
|
||||
}
|
||||
|
||||
/** 助手里执行一段脚本;返回 stdout。抛出异常表示「本次不可用」,调用方应降级。 */
|
||||
async run(body: string, timeoutMs = 30_000): Promise<string> {
|
||||
if (!this.stats.enabled) throw new Error(`PsShell[${this.label}] 已熔断`);
|
||||
const started = Date.now();
|
||||
try {
|
||||
// ensureStarted 必须在 try 内:它失败(如 spawn 失败)同样要计入统计,
|
||||
// 否则会表现成「spawned 一直涨、calls 恒为 0」的诡异状态(真机踩过,掩盖了根因)。
|
||||
await this.ensureStarted();
|
||||
const out = await this.enqueue(body, timeoutMs);
|
||||
this.stats.calls += 1;
|
||||
this.stats.hits += 1;
|
||||
this.stats.consecutiveFailures = 0;
|
||||
this.totalMs += Date.now() - started;
|
||||
this.touchIdle();
|
||||
return out;
|
||||
} catch (err) {
|
||||
this.stats.calls += 1;
|
||||
this.stats.failures += 1;
|
||||
this.stats.consecutiveFailures += 1;
|
||||
this.stats.lastError = err instanceof Error ? err.message : String(err);
|
||||
this.kill(); // 状态可疑,直接销毁,下次重建
|
||||
if (this.stats.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
||||
this.stats.enabled = false;
|
||||
this.log(
|
||||
'warn',
|
||||
`PsShell[${this.label}] 连续失败 ${this.stats.consecutiveFailures} 次,熔断并回退到一次性脚本路径`,
|
||||
);
|
||||
}
|
||||
throw err instanceof Error ? err : new Error(String(err));
|
||||
}
|
||||
}
|
||||
|
||||
private enqueue(body: string, timeoutMs: number): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.queue = this.queue.filter((q) => q.timer !== timer);
|
||||
reject(new Error(`PsShell[${this.label}] 请求超时(${timeoutMs}ms)`));
|
||||
}, timeoutMs);
|
||||
this.queue.push({ body, timer, resolve, reject });
|
||||
this.pump();
|
||||
});
|
||||
}
|
||||
|
||||
/** 把队首请求写进 stdin(仅当没有在途请求时)。 */
|
||||
private pump(): void {
|
||||
const head = this.queue[0];
|
||||
if (!head || !this.ready) return;
|
||||
// 已在途:队列长度 > 1 说明队首已发出,等待响应即可
|
||||
if (this.queue.length > 1) return;
|
||||
const line = Buffer.from(head.body, 'utf8').toString('base64');
|
||||
this.proc?.stdin.write(`${line}\n`);
|
||||
}
|
||||
|
||||
private ensureStarted(): Promise<void> {
|
||||
if (this.ready && this.proc) return Promise.resolve();
|
||||
if (this.starting) return this.starting;
|
||||
|
||||
this.starting = new Promise<void>((resolve, reject) => {
|
||||
this.stats.spawned += 1;
|
||||
const bootstrap = buildBootstrap(this.prelude);
|
||||
// ⚠️ 不能用 -EncodedCommand:预加载段含内嵌 C#(编码后 ~36KB)超过 Windows
|
||||
// 命令行 ~32KB 上限,spawn 会直接失败(ENAMETOOLONG)→ 每次都静默降级到
|
||||
// 慢路径(真机踩过:助手 spawned 计数增长但 calls 恒为 0)。
|
||||
// 改为落地临时脚本 + -File(无长度限制)。
|
||||
const dir = mkdtempSync(join(tmpdir(), 'na-psh-'));
|
||||
const bootPath = join(dir, 'bootstrap.ps1');
|
||||
writeFileSync(bootPath, `\uFEFF${bootstrap}`, 'utf8');
|
||||
const p = spawn(
|
||||
'powershell.exe',
|
||||
['-NoProfile', '-NoLogo', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', bootPath],
|
||||
{ windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] },
|
||||
);
|
||||
this.proc = p;
|
||||
const failTimer = setTimeout(() => {
|
||||
if (!this.ready) {
|
||||
this.kill();
|
||||
reject(new Error(`PsShell[${this.label}] 启动超时(${SPAWN_TIMEOUT_MS}ms)`));
|
||||
}
|
||||
}, SPAWN_TIMEOUT_MS);
|
||||
|
||||
p.stdout.setEncoding('utf8');
|
||||
p.stdout.on('data', (chunk: string) => this.onStdout(chunk));
|
||||
p.stderr.setEncoding('utf8');
|
||||
p.stderr.on('data', (chunk: string) => {
|
||||
if (chunk.includes('@@READY')) {
|
||||
clearTimeout(failTimer);
|
||||
this.ready = true;
|
||||
this.log('info', `PsShell[${this.label}] 就绪(类型已预加载)`);
|
||||
this.pump();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
this.stderrBuf = (this.stderrBuf + chunk).slice(-4000);
|
||||
});
|
||||
p.on('error', (err) => {
|
||||
clearTimeout(failTimer);
|
||||
this.log('warn', `PsShell[${this.label}] 进程错误: ${err.message}`);
|
||||
this.ready = false;
|
||||
reject(err);
|
||||
});
|
||||
p.on('exit', (code) => {
|
||||
clearTimeout(failTimer);
|
||||
const wasReady = this.ready;
|
||||
this.ready = false;
|
||||
this.proc = null;
|
||||
this.starting = null;
|
||||
if (wasReady) this.log('warn', `PsShell[${this.label}] 进程退出(code=${String(code)})`);
|
||||
// 在途请求全部失败 -> 触发降级
|
||||
const pending = this.queue.splice(0);
|
||||
for (const q of pending) {
|
||||
clearTimeout(q.timer);
|
||||
q.reject(new Error(`PsShell[${this.label}] 进程退出`));
|
||||
}
|
||||
if (!wasReady) {
|
||||
// 带上 stderr 尾巴 —— 否则只看到「启动失败(code=1)」无从下手(真机踩过)
|
||||
const tail = this.stderrBuf.trim().slice(-600);
|
||||
reject(new Error(`PsShell[${this.label}] 启动失败(code=${String(code)})${tail ? ` :: ${tail}` : ''}`));
|
||||
}
|
||||
});
|
||||
}).finally(() => {
|
||||
this.starting = null;
|
||||
});
|
||||
|
||||
return this.starting;
|
||||
}
|
||||
|
||||
private onStdout(chunk: string): void {
|
||||
this.stdoutBuf += chunk;
|
||||
let idx: number;
|
||||
while ((idx = this.stdoutBuf.indexOf('\n')) >= 0) {
|
||||
const line = this.stdoutBuf.slice(0, idx).trim();
|
||||
this.stdoutBuf = this.stdoutBuf.slice(idx + 1);
|
||||
if (!line) continue;
|
||||
const parsed = parseHelperLine(line);
|
||||
if (!parsed) continue; // 非协议行(如 Add-Type 噪声)忽略
|
||||
const { isErr, payload } = parsed;
|
||||
const head = this.queue.shift();
|
||||
if (!head) continue;
|
||||
clearTimeout(head.timer);
|
||||
if (isErr) head.reject(new Error(`PsShell[${this.label}] 脚本异常: ${payload.slice(0, 300)}`));
|
||||
else head.resolve(payload);
|
||||
this.pump();
|
||||
}
|
||||
}
|
||||
|
||||
private touchIdle(): void {
|
||||
if (this.idleTimer) clearTimeout(this.idleTimer);
|
||||
this.idleTimer = setTimeout(() => {
|
||||
if (this.queue.length === 0 && this.proc) {
|
||||
this.log('info', `PsShell[${this.label}] 空闲回收`);
|
||||
this.kill();
|
||||
}
|
||||
}, IDLE_KILL_MS);
|
||||
this.idleTimer.unref?.();
|
||||
}
|
||||
|
||||
kill(): void {
|
||||
this.ready = false;
|
||||
if (this.idleTimer) {
|
||||
clearTimeout(this.idleTimer);
|
||||
this.idleTimer = null;
|
||||
}
|
||||
const p = this.proc;
|
||||
this.proc = null;
|
||||
if (p && !p.killed) {
|
||||
try {
|
||||
p.stdin.end();
|
||||
p.kill();
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
}
|
||||
const pending = this.queue.splice(0);
|
||||
for (const q of pending) {
|
||||
clearTimeout(q.timer);
|
||||
q.reject(new Error(`PsShell[${this.label}] 已回收`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 对外:带降级的执行入口 ----------------
|
||||
|
||||
const shells = new Map<string, PsShell>();
|
||||
/** 进程退出时统一回收(避免留下孤儿 PowerShell) */
|
||||
let cleanupRegistered = false;
|
||||
|
||||
/**
|
||||
* 取(或创建)某 prelude 对应的助手。
|
||||
* key 含 prelude 指纹:**预加载段一旦变化,自动切换到新助手并回收旧的** ——
|
||||
* 否则进程内更新代码后仍会用到旧进程里已加载的类型定义(难查的幽灵问题)。
|
||||
*/
|
||||
export function getPsShell(
|
||||
prelude: string,
|
||||
label: string,
|
||||
log: (level: 'info' | 'warn', msg: string) => void,
|
||||
): PsShell {
|
||||
const fingerprint = createHash('sha256').update(prelude).digest('hex').slice(0, 8);
|
||||
const key = `${label}#${fingerprint}`;
|
||||
|
||||
// 同 label 但指纹不同的旧助手(预加载段已变更)直接回收
|
||||
for (const [k, old] of shells) {
|
||||
if (k.startsWith(`${label}#`) && k !== key) {
|
||||
log('info', `PsShell[${label}] 预加载段已变更,回收旧助手`);
|
||||
old.kill();
|
||||
shells.delete(k);
|
||||
}
|
||||
}
|
||||
|
||||
let sh = shells.get(key);
|
||||
if (!sh) {
|
||||
sh = new PsShell(prelude, label, log);
|
||||
shells.set(key, sh);
|
||||
if (!cleanupRegistered) {
|
||||
cleanupRegistered = true;
|
||||
process.once('exit', () => {
|
||||
for (const s of shells.values()) s.kill();
|
||||
});
|
||||
}
|
||||
}
|
||||
return sh;
|
||||
}
|
||||
|
||||
export function allPsShellStats(): Record<string, PsHelperStats> {
|
||||
const out: Record<string, PsHelperStats> = {};
|
||||
for (const [k, v] of shells) out[k] = v.getStats();
|
||||
return out;
|
||||
}
|
||||
|
||||
export function disposeAllPsShells(): void {
|
||||
for (const s of shells.values()) s.kill();
|
||||
shells.clear();
|
||||
}
|
||||
|
||||
export interface RunPsOptions {
|
||||
/** 本次逻辑(不含 prelude) */
|
||||
body: string;
|
||||
/** 类型/程序集预加载段(助手启动时执行一次;降级时与 body 拼成完整脚本) */
|
||||
prelude: string;
|
||||
/** 一次性路径的超时(降级用) */
|
||||
timeoutMs?: number;
|
||||
/** 助手可用性开关(便于测试与排障;默认 true) */
|
||||
useHelper?: boolean;
|
||||
log?: (level: 'info' | 'warn', msg: string) => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 PowerShell:**优先常驻助手**,不可用时自动回退到一次性脚本。
|
||||
* 返回值始终是脚本 stdout(两条路径语义一致)。
|
||||
*/
|
||||
export async function runPowerShellSmart(opts: RunPsOptions): Promise<{ stdout: string; via: 'helper' | 'oneshot' }> {
|
||||
const { body, prelude, timeoutMs = 30_000, useHelper = true, log, label = 'win' } = opts;
|
||||
const noop = (): void => undefined;
|
||||
const logger = log ?? noop;
|
||||
|
||||
if (useHelper && process.platform === 'win32' && !process.env['NODEAGENT_NO_PS_HELPER']) {
|
||||
try {
|
||||
const shell = getPsShell(prelude, label, logger);
|
||||
const stdout = await shell.run(body, timeoutMs);
|
||||
return { stdout: stdout.trim(), via: 'helper' };
|
||||
} catch (err) {
|
||||
logger('warn', `PsShell 不可用,回退一次性脚本:${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ⚠️ 不能用 -EncodedCommand 传整个脚本:预加载段(含内嵌 C#)已远超
|
||||
// Windows 的 ~32KB 命令行上限,会报 spawn ENAMETOOLONG(真机踩过)。
|
||||
// 改为**落地成临时脚本再 -File 执行**:无长度限制,且 UTF-8 BOM 保证
|
||||
// PowerShell 5.1 正确读取非 ASCII(BOM 缺失会被当 ANSI 解析)。
|
||||
const dir = mkdtempSync(join(tmpdir(), 'na-ps-'));
|
||||
const scriptPath = join(dir, 'run.ps1');
|
||||
writeFileSync(scriptPath, `\uFEFF${prelude}\n${body}`, 'utf8');
|
||||
const r = await execCommand({
|
||||
command: `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ${JSON.stringify(scriptPath)}`,
|
||||
timeoutMs,
|
||||
});
|
||||
if (r.exit_code !== 0) {
|
||||
const detail = extractPsError(r.stderr) || r.stdout.slice(0, 400);
|
||||
const err = new Error(detail || `PowerShell 退出码 ${r.exit_code}`) as Error & { exitCode?: number };
|
||||
err.exitCode = r.exit_code;
|
||||
throw err;
|
||||
}
|
||||
return { stdout: r.stdout.trim(), via: 'oneshot' };
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/cli",
|
||||
"version": "0.1.0",
|
||||
"version": "2.0.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import { closeSync, existsSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync, writeSync } from 'node:fs';
|
||||
import fsSync from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path, { join } from 'node:path';
|
||||
import {
|
||||
NodeAgentClient,
|
||||
loadMacroFile,
|
||||
runMacro,
|
||||
ClientError,
|
||||
loadConfig,
|
||||
saveConfig,
|
||||
configPath,
|
||||
toWsUrl,
|
||||
loadKeys,
|
||||
createKeys,
|
||||
keysFilePath,
|
||||
discoverOnce,
|
||||
resolveTarget,
|
||||
resolveNodeSelector,
|
||||
emptyConfig,
|
||||
type ClientConfig,
|
||||
type ResolvedTarget,
|
||||
type NodeProfile,
|
||||
} from '@nodeagent/client';
|
||||
import {
|
||||
CapabilityNames,
|
||||
DEFAULT_DISCOVERY_PORT,
|
||||
generateSharedKey,
|
||||
matchPattern,
|
||||
type CapabilityDescriptor,
|
||||
type InvokeResult,
|
||||
} from '@nodeagent/protocol';
|
||||
import {
|
||||
callAndPrint,
|
||||
reportCompat,
|
||||
fail,
|
||||
getClientConfig,
|
||||
humanSize,
|
||||
printJson,
|
||||
riskIcon,
|
||||
withClient,
|
||||
withClientDirect,
|
||||
} from '../core.js';
|
||||
import { CHUNK_BYTES, type Options } from '../types.js';
|
||||
|
||||
// CLI 命令组:cmd/conn.ts
|
||||
export async function cmdConnect(host: string, opts: Options): Promise<void> {
|
||||
const port = Number(opts.port ?? 8765);
|
||||
const authMode: 'psk' | 'ed25519' = opts.authMode === 'ed25519' ? 'ed25519' : 'psk';
|
||||
if (authMode === 'psk' && !opts.key) {
|
||||
fail('缺少 --key <预共享密钥>(零信任模式请用 --auth-mode ed25519)');
|
||||
}
|
||||
|
||||
const name = opts.name ?? host;
|
||||
const clientId = opts.id ?? 'mac_01';
|
||||
const profile: NodeProfile = {
|
||||
host,
|
||||
port,
|
||||
tls: !process.argv.includes('--no-tls'),
|
||||
insecure: opts.insecure,
|
||||
key: opts.key ?? '',
|
||||
auth_mode: authMode,
|
||||
};
|
||||
if (opts.note) profile.note = opts.note;
|
||||
// v6:经 Hub 中转
|
||||
if (opts.hubToken) profile.hub = { token: opts.hubToken, node_id: opts.hubNode ?? name };
|
||||
|
||||
const keys = authMode === 'ed25519' ? loadKeys() : null;
|
||||
if (authMode === 'ed25519' && !keys) {
|
||||
fail(`零信任模式需要本机密钥,请先运行: nodeagent keygen --id ${clientId}`);
|
||||
}
|
||||
|
||||
const client = new NodeAgentClient({
|
||||
url: toWsUrl(profile),
|
||||
key: profile.key ?? '',
|
||||
clientId,
|
||||
insecure: profile.insecure,
|
||||
authMode,
|
||||
privateKey: keys?.privateKey,
|
||||
hub: profile.hub ? { token: profile.hub.token, nodeId: profile.hub.node_id } : undefined,
|
||||
});
|
||||
try {
|
||||
const caps = await client.connect();
|
||||
const authorized = client.listAuthorized();
|
||||
|
||||
// 连接成功才落盘,避免把错配置写进设备表
|
||||
const cfg = loadConfig() ?? emptyConfig(clientId);
|
||||
// v21:TOFU 钉住证书指纹 —— 首次连接记录,之后每次连接都严格比对。
|
||||
// 已有钉住值时**不覆盖**(除非 --forget-cert),否则冒充者可借重连刷新钉住值。
|
||||
const fp = client.getPeerCertFingerprint();
|
||||
if (opts.forgetCert === true) {
|
||||
delete profile.cert_sha256;
|
||||
console.log('已清除旧指纹,将按本次连接重新钉住');
|
||||
}
|
||||
if (fp && !profile.cert_sha256) {
|
||||
profile.cert_sha256 = fp;
|
||||
console.log(`\n🔐 已钉住被控端证书指纹(TOFU):${fp}`);
|
||||
console.log(' 请与被控端安装时打印的指纹核对;不一致说明连的可能不是那台机器');
|
||||
}
|
||||
|
||||
cfg.client_id = clientId;
|
||||
cfg.nodes[name] = profile;
|
||||
cfg.current = name;
|
||||
saveConfig(cfg);
|
||||
|
||||
const via = profile.hub ? `经 Hub ${profile.host}:${profile.port}` : `${profile.host}:${profile.port}`;
|
||||
console.log(
|
||||
`✓ 已连接并保存设备「${name}」${via}(${profile.tls ? 'wss' : 'ws'},${authMode})`,
|
||||
);
|
||||
console.log(` 配置: ${configPath()}(当前设备 ${name},共 ${Object.keys(cfg.nodes).length} 台)`);
|
||||
|
||||
const isOk = (n: string): boolean => !authorized || authorized.some((p) => matchPattern(p, n));
|
||||
console.log(`\n被控端声明 ${caps.length} 项能力${authorized ? `,其中 ${authorized.length} 项已授权` : ''}:`);
|
||||
for (const c of caps) {
|
||||
console.log(` ${isOk(c.name) ? riskIcon(c.risk) : '🚫'} ${c.name.padEnd(24)} ${c.description}`);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ClientError) fail(`连接失败 ${err.name}: ${err.message}`);
|
||||
throw err;
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- v5 多设备管理 ----------
|
||||
|
||||
export async function cmdNodes(opts: Options): Promise<void> {
|
||||
const cfg = loadConfig();
|
||||
if (!cfg || Object.keys(cfg.nodes).length === 0) {
|
||||
console.log('尚未配置任何设备。用 nodeagent connect <host> --key <密钥> 添加,或 nodeagent discover 先发现。');
|
||||
return;
|
||||
}
|
||||
const names = Object.keys(cfg.nodes);
|
||||
if (opts.json) {
|
||||
return printJson({ current: cfg.current, client_id: cfg.client_id, nodes: cfg.nodes });
|
||||
}
|
||||
console.log(`控制端身份: ${cfg.client_id} 共 ${names.length} 台设备\n`);
|
||||
console.log(` ${'名称'.padEnd(16)} ${'地址'.padEnd(22)} ${'协议'.padEnd(6)} ${'认证'.padEnd(9)} 备注`);
|
||||
for (const n of names) {
|
||||
const p = cfg.nodes[n]!;
|
||||
const mark = n === cfg.current ? '●' : ' ';
|
||||
const auth = `${p.auth_mode ?? 'psk'}${p.insecure ? '*' : ''}`;
|
||||
console.log(
|
||||
`${mark} ${n.padEnd(16)} ${`${p.host}:${p.port}`.padEnd(22)} ${(p.tls ? 'wss' : 'ws').padEnd(6)} ${auth.padEnd(9)} ${p.note ?? ''}`,
|
||||
);
|
||||
}
|
||||
console.log('\n(● = 当前设备;认证列 * = 跳过证书校验)');
|
||||
}
|
||||
|
||||
export async function cmdUse(name: string | undefined, opts: Options): Promise<void> {
|
||||
if (!name) fail('用法: nodeagent use <设备名>');
|
||||
const cfg = loadConfig();
|
||||
if (!cfg) fail('尚未配置任何设备。请先运行: nodeagent connect <host> --key <密钥>');
|
||||
if (!cfg.nodes[name]) {
|
||||
fail(`未配置的设备: ${name};已配置:${Object.keys(cfg.nodes).join(', ') || '(空)'}`);
|
||||
}
|
||||
cfg.current = name;
|
||||
saveConfig(cfg);
|
||||
if (opts.json) return printJson({ current: cfg.current });
|
||||
const p = cfg.nodes[name]!;
|
||||
console.log(`✓ 当前设备已切换为「${name}」 → ${p.host}:${p.port}`);
|
||||
}
|
||||
|
||||
export async function cmdRemove(name: string | undefined, opts: Options): Promise<void> {
|
||||
if (!name) fail('用法: nodeagent remove <设备名>');
|
||||
const cfg = loadConfig();
|
||||
if (!cfg || !cfg.nodes[name]) fail(`未配置的设备: ${name}`);
|
||||
delete cfg.nodes[name];
|
||||
if (cfg.current === name) cfg.current = Object.keys(cfg.nodes)[0] ?? '';
|
||||
saveConfig(cfg);
|
||||
if (opts.json) return printJson({ removed: name, current: cfg.current });
|
||||
console.log(`✓ 已移除设备「${name}」${cfg.current ? `,当前设备: ${cfg.current}` : ''}`);
|
||||
}
|
||||
|
||||
/** v3:生成 Ed25519 密钥对,并输出可直接粘贴的被控端 ACL 配置片段。 */
|
||||
|
||||
export async function cmdKeygen(opts: Options): Promise<void> {
|
||||
const clientId = opts.id ?? 'mac_01';
|
||||
|
||||
// v22:`--psk` 生成**每客户端专属**的预共享密钥(配合被控端 agent.json 的 keys{})。
|
||||
// 与 ed25519 的区别:这是「对称」方案 —— 实现简单、不用管公钥,但同样是**一把一个身份**
|
||||
// (知道 A 的 key 就冒充不了 B),适合不方便管理公钥的场景。
|
||||
if (opts.psk === true) {
|
||||
const psk = generateSharedKey();
|
||||
console.log('✓ 已生成该 client_id 的专属预共享密钥(psk 模式)');
|
||||
console.log(` client_id : ${clientId}`);
|
||||
console.log(` 密钥 : ${psk}`);
|
||||
console.log('\n请把下面这段加入被控端 agent.json(可与其它 client_id 并列):\n');
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{ keys: { [clientId]: psk } },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.log('\n然后控制端这样连(key 即上面的密钥):');
|
||||
console.log(` nodeagent connect <被控端IP> --port 8765 --key ${psk} --id ${clientId} --insecure`);
|
||||
console.log(
|
||||
'\n提示:登记了 keys{} 之后,未登记的 client_id 会回落到共享 key(若被控端仍配有 key)。',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const keys = createKeys(clientId);
|
||||
console.log('✓ 已生成 Ed25519 密钥对');
|
||||
console.log(` client_id : ${keys.client_id}`);
|
||||
console.log(` key_id : ${keys.key_id}`);
|
||||
console.log(` 私钥 : ${keysFilePath()} (600 权限,永不外传)`);
|
||||
console.log('\n请把下面这段加入被控端 agent.json 的 acl.clients 数组:\n');
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
client_id: keys.client_id,
|
||||
pubkey: keys.publicKey,
|
||||
allow: ['system.*', 'app.list', 'screen.*'],
|
||||
deny: ['input.*'],
|
||||
note: 'generated by nodeagent keygen',
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.log('\n然后让被控端启用零信任模式:agent.json 设 "auth_mode": "ed25519" 并重启 Agent。');
|
||||
}
|
||||
|
||||
export async function cmdDiscover(opts: Options): Promise<void> {
|
||||
const waitMs = (opts.wait ? Number(opts.wait) : 5) * 1000;
|
||||
const port = opts.discoveryPort ? Number(opts.discoveryPort) : DEFAULT_DISCOVERY_PORT;
|
||||
|
||||
console.log(`正在监听局域网广播(UDP ${port},最多 ${waitMs / 1000}s)...\n`);
|
||||
|
||||
let nodes: Awaited<ReturnType<typeof discoverOnce>>;
|
||||
try {
|
||||
nodes = await discoverOnce(waitMs, { port, secret: opts.discoverSecret });
|
||||
} catch (err) {
|
||||
fail(`监听失败:${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
if (nodes.length === 0) {
|
||||
console.log('未发现设备。请检查:');
|
||||
console.log(' 1) 被控端与本机在同一局域网(跨网段广播通常不通)');
|
||||
console.log(' 2) 被控端配置未关闭 discovery.enabled');
|
||||
console.log(` 3) 防火墙未拦截 UDP ${port}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.json) return printJson(nodes);
|
||||
|
||||
console.log(`发现 ${nodes.length} 台被控端:\n`);
|
||||
console.log(
|
||||
` ${'节点 ID'.padEnd(16)} ${'地址'.padEnd(20)} ${'协议'.padEnd(6)} ${'认证'.padEnd(8)} ${'来源'.padEnd(10)} 平台`,
|
||||
);
|
||||
for (const n of nodes) {
|
||||
const ctl = n.input_enabled ? ' [输入控制已开]' : '';
|
||||
// v22:广播本身是未认证的,只有验签通过的才标 🔒
|
||||
const trust = n.authenticated ? '🔒 已认证' : '⚠️ 明文';
|
||||
console.log(
|
||||
` ${n.node_id.padEnd(16)} ${`${n.host}:${n.port}`.padEnd(20)} ${(n.tls ? 'wss' : 'ws').padEnd(6)} ${n.auth_mode.padEnd(8)} ${trust.padEnd(10)} ${n.platform}${ctl}`,
|
||||
);
|
||||
}
|
||||
if (nodes.some((n) => !n.authenticated) && !opts.discoverSecret) {
|
||||
console.log(
|
||||
'\n ℹ️ 标「⚠️ 明文」的条目:广播可被同网段任意伪造,其地址/认证方式仅作参考。' +
|
||||
'\n 要可信发现,请给被控端 discovery.secret,并在此用 --discover-secret <同一密钥>。',
|
||||
);
|
||||
}
|
||||
const first = nodes[0]!;
|
||||
console.log('\n连接示例:');
|
||||
console.log(` nodeagent connect ${first.host} --port ${first.port} --key <密钥>${first.tls ? ' --insecure' : ''}`);
|
||||
}
|
||||
|
||||
// ---------- v5 文件传输 ----------
|
||||
|
||||
/** 分块大小:1MB(与被控端 max_bytes 默认值配合) */
|
||||
@@ -0,0 +1,181 @@
|
||||
import { closeSync, existsSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync, writeSync } from 'node:fs';
|
||||
import fsSync from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path, { join } from 'node:path';
|
||||
import {
|
||||
NodeAgentClient,
|
||||
loadMacroFile,
|
||||
runMacro,
|
||||
ClientError,
|
||||
loadConfig,
|
||||
saveConfig,
|
||||
configPath,
|
||||
toWsUrl,
|
||||
loadKeys,
|
||||
createKeys,
|
||||
keysFilePath,
|
||||
discoverOnce,
|
||||
resolveTarget,
|
||||
resolveNodeSelector,
|
||||
emptyConfig,
|
||||
type ClientConfig,
|
||||
type ResolvedTarget,
|
||||
type NodeProfile,
|
||||
} from '@nodeagent/client';
|
||||
import {
|
||||
CapabilityNames,
|
||||
matchPattern,
|
||||
DEFAULT_DISCOVERY_PORT,
|
||||
type CapabilityDescriptor,
|
||||
type InvokeResult,
|
||||
} from '@nodeagent/protocol';
|
||||
import {
|
||||
callAndPrint,
|
||||
fail,
|
||||
getClientConfig,
|
||||
humanSize,
|
||||
printJson,
|
||||
riskIcon,
|
||||
withClient,
|
||||
withClientDirect,
|
||||
} from '../core.js';
|
||||
import { CHUNK_BYTES, type Options } from '../types.js';
|
||||
|
||||
// CLI 命令组:cmd/fs.ts
|
||||
export async function cmdLs(pathArg: string | undefined, opts: Options): Promise<void> {
|
||||
if (!pathArg) fail('用法: nodeagent ls <远端路径> [--recursive] [--pattern "*.log"]');
|
||||
const args: Record<string, unknown> = { path: pathArg };
|
||||
if (opts.recursive) args['recursive'] = true;
|
||||
if (opts.pattern) args['pattern'] = opts.pattern;
|
||||
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.FsList, args, opts.json, (data) => {
|
||||
const d = data as {
|
||||
entries: Array<{ name: string; type: string; size: number; mtime: number }>;
|
||||
total: number;
|
||||
truncated: boolean;
|
||||
};
|
||||
for (const e of d.entries) {
|
||||
const icon = e.type === 'dir' ? '📁' : '📄';
|
||||
const when = new Date(e.mtime).toLocaleString('zh-CN');
|
||||
console.log(` ${icon} ${e.name.padEnd(34)} ${humanSize(e.size).padStart(10)} ${when}`);
|
||||
}
|
||||
console.log(`\n共 ${d.total} 项${d.truncated ? '(已截断,可加 --pattern 过滤)' : ''}`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function cmdStat(pathArg: string | undefined, opts: Options): Promise<void> {
|
||||
if (!pathArg) fail('用法: nodeagent stat <远端路径>');
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.FsStat, { path: pathArg }, opts.json, (data) => {
|
||||
const d = data as { path: string; type: string; size: number; mtime: number; exists: boolean };
|
||||
if (!d.exists) {
|
||||
console.log(`✗ 不存在: ${d.path}`);
|
||||
return;
|
||||
}
|
||||
console.log(` 路径 : ${d.path}`);
|
||||
console.log(` 类型 : ${d.type}`);
|
||||
console.log(` 大小 : ${humanSize(d.size)}`);
|
||||
console.log(` 修改 : ${new Date(d.mtime).toLocaleString('zh-CN')}`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function cmdCat(pathArg: string | undefined, opts: Options): Promise<void> {
|
||||
if (!pathArg) fail('用法: nodeagent cat <远端路径> [--out 本地文件]');
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.FsRead, { path: pathArg, encoding: 'utf8' }, opts.json, (data) => {
|
||||
const d = data as { data: string; bytes: number; total_bytes: number; eof: boolean };
|
||||
if (opts.out) {
|
||||
writeFileSync(opts.out, d.data, 'utf8');
|
||||
console.log(`✓ 已保存 ${opts.out}(${d.bytes} 字节${d.eof ? '' : ',文件较大仅首块,请用 pull 下载完整文件'})`);
|
||||
return;
|
||||
}
|
||||
process.stdout.write(d.data);
|
||||
if (!d.eof) console.log(`\n\n… 仅显示首 ${humanSize(d.bytes)}(共 ${humanSize(d.total_bytes)}),完整下载请用 pull`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function cmdPull(remote: string | undefined, opts: Options): Promise<void> {
|
||||
if (!remote) fail('用法: nodeagent pull <远端路径> [--out 本地文件]');
|
||||
const local = opts.out ?? remote.split(/[\\/]/).pop() ?? 'download.bin';
|
||||
|
||||
await withClient(async (c) => {
|
||||
const statRes = await c.invoke(CapabilityNames.FsStat, { path: remote });
|
||||
if (statRes.status !== 'ok') fail(`读取远端信息失败: ${statRes.error?.message}`);
|
||||
const st = statRes.data as { exists: boolean; type: string; size: number };
|
||||
if (!st.exists) fail(`远端文件不存在: ${remote}`);
|
||||
if (st.type === 'dir') fail(`目标是目录,不是文件: ${remote}(先用 ls 查看)`);
|
||||
|
||||
const tmp = `${local}.nodeagent-part`;
|
||||
const fd = openSync(tmp, 'w');
|
||||
let offset = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const r = await c.invoke(CapabilityNames.FsRead, {
|
||||
path: remote,
|
||||
encoding: 'base64',
|
||||
offset,
|
||||
max_bytes: CHUNK_BYTES,
|
||||
});
|
||||
if (r.status !== 'ok') fail(`下载失败: ${r.error?.name}: ${r.error?.message}`);
|
||||
const d = r.data as { data: string; bytes: number; eof: boolean };
|
||||
if (d.bytes > 0) writeSync(fd, Buffer.from(d.data, 'base64'));
|
||||
offset += d.bytes;
|
||||
if (d.eof || d.bytes === 0) break;
|
||||
}
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
renameSync(tmp, local); // 原子落盘,避免半截文件
|
||||
console.log(`✓ 已下载 ${remote} → ${local}(${humanSize(offset)})`);
|
||||
});
|
||||
}
|
||||
|
||||
export async function cmdPush(local: string | undefined, remote: string | undefined, opts: Options): Promise<void> {
|
||||
if (!local || !remote) fail('用法: nodeagent push <本地文件> <远端路径> [--create-dirs]');
|
||||
let st;
|
||||
try {
|
||||
st = statSync(local);
|
||||
} catch {
|
||||
fail(`本地文件不存在: ${local}`);
|
||||
}
|
||||
if (!st.isFile()) fail(`不是文件: ${local}`);
|
||||
|
||||
await withClient(async (c) => {
|
||||
const fd = openSync(local, 'r');
|
||||
let offset = 0;
|
||||
let first = true;
|
||||
try {
|
||||
while (offset < st.size) {
|
||||
const len = Math.min(CHUNK_BYTES, st.size - offset);
|
||||
const buf = Buffer.alloc(len);
|
||||
readSync(fd, buf, 0, len, offset);
|
||||
const r = await c.invoke(CapabilityNames.FsWrite, {
|
||||
path: remote,
|
||||
data: buf.toString('base64'),
|
||||
encoding: 'base64',
|
||||
append: !first,
|
||||
create_dirs: first && opts.createDirs === true,
|
||||
});
|
||||
if (r.status !== 'ok') fail(`上传失败: ${r.error?.name}: ${r.error?.message}`);
|
||||
offset += len;
|
||||
first = false;
|
||||
}
|
||||
if (st.size === 0) {
|
||||
const r = await c.invoke(CapabilityNames.FsWrite, {
|
||||
path: remote,
|
||||
data: '',
|
||||
create_dirs: opts.createDirs === true,
|
||||
});
|
||||
if (r.status !== 'ok') fail(`上传失败: ${r.error?.message}`);
|
||||
}
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
console.log(`✓ 已上传 ${local} → ${remote}(${humanSize(offset)})`);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import { closeSync, existsSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync, writeSync } from 'node:fs';
|
||||
import fsSync from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path, { join } from 'node:path';
|
||||
import {
|
||||
NodeAgentClient,
|
||||
loadMacroFile,
|
||||
runMacro,
|
||||
ClientError,
|
||||
loadConfig,
|
||||
saveConfig,
|
||||
configPath,
|
||||
toWsUrl,
|
||||
loadKeys,
|
||||
createKeys,
|
||||
keysFilePath,
|
||||
discoverOnce,
|
||||
resolveTarget,
|
||||
resolveNodeSelector,
|
||||
emptyConfig,
|
||||
type ClientConfig,
|
||||
type ResolvedTarget,
|
||||
type NodeProfile,
|
||||
} from '@nodeagent/client';
|
||||
import {
|
||||
CapabilityNames,
|
||||
HOTKEY_PRESETS,
|
||||
matchPattern,
|
||||
DEFAULT_DISCOVERY_PORT,
|
||||
type CapabilityDescriptor,
|
||||
type InvokeResult,
|
||||
} from '@nodeagent/protocol';
|
||||
import {
|
||||
callAndPrint,
|
||||
fail,
|
||||
getClientConfig,
|
||||
humanSize,
|
||||
printJson,
|
||||
riskIcon,
|
||||
withClient,
|
||||
withClientDirect,
|
||||
} from '../core.js';
|
||||
import { CHUNK_BYTES, type Options } from '../types.js';
|
||||
|
||||
// CLI 命令组:cmd/gui.ts
|
||||
export async function cmdScreen(opts: Options): Promise<void> {
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.ScreenInfo, {}, opts.json, (data) => {
|
||||
const rows = (
|
||||
data as { displays: Array<{ id: number; name: string; width: number; height: number; is_primary: boolean }> }
|
||||
).displays;
|
||||
for (const d of rows) {
|
||||
console.log(
|
||||
` #${d.id} ${String(d.width).padStart(5)}x${String(d.height).padEnd(5)} ${d.is_primary ? '[主屏]' : ' '} ${d.name}`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function cmdScreenshot(opts: Options): Promise<void> {
|
||||
const args: Record<string, unknown> = {};
|
||||
if (opts.format) args['format'] = opts.format;
|
||||
if (opts.scale) {
|
||||
const s = Number(opts.scale);
|
||||
// 协议限定 scale ∈ (0, 1](缩小)。原先直接把非法值发出去,
|
||||
// 被控端只回一句「参数校验失败」,看不出是哪一项 —— 2026-10-05 实测踩过。
|
||||
if (!Number.isFinite(s) || s <= 0 || s > 1) {
|
||||
fail(`--scale 应在 0 到 1 之间(1=原尺寸,0.5=半尺寸),收到: ${opts.scale}`);
|
||||
}
|
||||
args['scale'] = s;
|
||||
}
|
||||
if (opts.region) {
|
||||
const nums = opts.region.split(',').map(Number);
|
||||
if (nums.length !== 4 || nums.some((n) => !Number.isFinite(n))) fail('--region 格式应为 x,y,width,height');
|
||||
args['region'] = { x: nums[0], y: nums[1], width: nums[2], height: nums[3] };
|
||||
}
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.ScreenCapture, args, opts.json, (data) => {
|
||||
const d = data as { image: string; format: string; width: number; height: number; bytes: number };
|
||||
const out = opts.out ?? `screenshot.${d.format === 'png' ? 'png' : 'jpg'}`;
|
||||
writeFileSync(out, Buffer.from(d.image, 'base64'));
|
||||
console.log(`✓ 已保存 ${out} ${d.width}x${d.height} ${humanSize(d.bytes)}`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function cmdMouse(action: string | undefined, positionals: string[], opts: Options): Promise<void> {
|
||||
const [a, b] = positionals;
|
||||
switch (action) {
|
||||
case 'move': {
|
||||
if (!a || !b) fail('用法: nodeagent mouse move <x> <y> [--duration 300]');
|
||||
const args: Record<string, unknown> = { x: Number(a), y: Number(b) };
|
||||
if (opts.duration) args['duration_ms'] = Number(opts.duration);
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.MouseMove, args, opts.json, (d) => {
|
||||
const r = d as { x: number; y: number };
|
||||
console.log(`✓ 鼠标已移动到 (${r.x}, ${r.y})`);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
case 'click': {
|
||||
const args: Record<string, unknown> = {};
|
||||
if (a && b) {
|
||||
args['x'] = Number(a);
|
||||
args['y'] = Number(b);
|
||||
}
|
||||
if (opts.button) args['button'] = opts.button;
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.MouseClick, args, opts.json, (d) => {
|
||||
const r = d as { x: number; y: number; button: string };
|
||||
console.log(`✓ 已${r.button}键点击 (${r.x}, ${r.y})`);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
case 'scroll': {
|
||||
if (!a) fail('用法: nodeagent mouse scroll <delta> [y]');
|
||||
const args: Record<string, unknown> = { delta: Number(a) };
|
||||
if (b) args['y'] = Number(b);
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.MouseScroll, args, opts.json, (d) => {
|
||||
const r = d as { delta: number };
|
||||
console.log(`✓ 已滚动 ${r.delta} 格`);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
case 'drag': {
|
||||
const [x1, y1, x2, y2] = positionals;
|
||||
if (!x1 || !y1 || !x2 || !y2) {
|
||||
fail('用法: nodeagent mouse drag <x1> <y1> <x2> <y2> [--button left|right|middle]');
|
||||
}
|
||||
const args: Record<string, unknown> = {
|
||||
from_x: Number(x1),
|
||||
from_y: Number(y1),
|
||||
to_x: Number(x2),
|
||||
to_y: Number(y2),
|
||||
};
|
||||
if (opts.button) args['button'] = opts.button;
|
||||
if (opts.duration) args['step_delay_ms'] = Number(opts.duration);
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.MouseDrag, args, opts.json, (d) => {
|
||||
const r = d as { from: { x: number; y: number }; to: { x: number; y: number }; steps: number };
|
||||
console.log(
|
||||
`✓ 已拖拽 (${r.from.x}, ${r.from.y}) → (${r.to.x}, ${r.to.y}),${r.steps} 步`,
|
||||
);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
fail('用法: nodeagent mouse <move|click|scroll> ...');
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdKey(action: string | undefined, positionals: string[], opts: Options): Promise<void> {
|
||||
switch (action) {
|
||||
case 'type': {
|
||||
const text = positionals.join(' ');
|
||||
if (!text) fail('用法: nodeagent key type "<文本>"');
|
||||
const args: Record<string, unknown> = { text };
|
||||
if (opts.interval) args['interval_ms'] = Number(opts.interval);
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.KeyType, args, opts.json, (d) => {
|
||||
const r = d as { length: number };
|
||||
console.log(`✓ 已输入 ${r.length} 个字符`);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
case 'press': {
|
||||
// v1.5.0:三种用法(一次一种)
|
||||
// nodeagent key press "ctrl+shift+esc" 字符串热键
|
||||
// nodeagent key press copy 预设名
|
||||
// nodeagent key press ctrl c 老形式(空格分隔的各键)
|
||||
if (positionals.length === 0) fail('用法: nodeagent key press "<热键>" | <预设名> | <键1> [键2] ...(如 "ctrl+shift+esc" / copy / ctrl c)');
|
||||
const args: Record<string, unknown> = {};
|
||||
let positionalsEcho = positionals;
|
||||
if (positionals.length === 1) {
|
||||
const one = positionals[0]!;
|
||||
// 含分隔符 → 字符串热键;不含但命中预设表 → 预设;都不中 → 单键(也当热键交给被控端解析)
|
||||
const spec = HOTKEY_PRESETS[one.trim().toLowerCase()];
|
||||
if (spec) {
|
||||
args['preset'] = one.trim().toLowerCase();
|
||||
} else {
|
||||
args['hotkey'] = one;
|
||||
}
|
||||
} else {
|
||||
args['keys'] = positionals;
|
||||
}
|
||||
// 老形式说明:位置参数以空格分隔 → 兼容"ctrl c"(无 + 号)
|
||||
if (opts.interval) args['interval_ms'] = Number(opts.interval);
|
||||
// v1.5.0 新增可选参数
|
||||
if (opts.hold) args['hold_ms'] = Number(opts.hold);
|
||||
if (opts.route) args['route'] = opts.route;
|
||||
// 媒体键可定向到指定进程(播放器常建 30+ 辅助窗口,只投主窗口往往无效)
|
||||
if (opts.pid) args['target_pid'] = Number(opts.pid);
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.KeyPress, args, opts.json, (d) => {
|
||||
// 被控端返回 { pressed, chords, times },**没有 keys 字段**
|
||||
// (真机 2026-10-04 实测:原先直接读 r.keys.join() 会抛
|
||||
// "Cannot read properties of undefined (reading 'join')")
|
||||
// 故优先用请求参数回显,缺失时再退回 chords 展开。
|
||||
const r = d as {
|
||||
keys?: string[]; chords?: string[][]; times?: number; via?: string;
|
||||
presets?: string[]; channel?: string; route?: string; sent_windows?: number;
|
||||
target_pid?: number | null; note?: string;
|
||||
};
|
||||
const shown = (r.chords ?? []).map((ch) => ch.join('+')).join(' → ');
|
||||
const label = r.keys?.length
|
||||
? r.keys.join('+')
|
||||
: shown || positionalsEcho.join('+');
|
||||
const times = r.times && r.times > 1 ? `(重复 ${r.times} 次)` : '';
|
||||
const presetEcho = r.presets?.length ? `(预设 ${r.presets.join('→')} = ${shown})` : '';
|
||||
const via =
|
||||
r.channel === 'appcommand'
|
||||
? `(媒体通道${r.target_pid ? ` → pid ${r.target_pid}` : ' · 广播'})`
|
||||
: r.channel === 'postmessage'
|
||||
? `(后台投递 → pid ${r.target_pid},命中 ${r.sent_windows ?? 0} 窗口)`
|
||||
: '';
|
||||
console.log(`✓ 已按下 ${label}${times}${presetEcho}${via}`);
|
||||
if (r.note) console.log(` ℹ️ ${r.note}`);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
fail('用法: nodeagent key <type|press> ...');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 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:审计链完整性校验。 */
|
||||
@@ -0,0 +1,125 @@
|
||||
import { closeSync, existsSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync, writeSync } from 'node:fs';
|
||||
import fsSync from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path, { join } from 'node:path';
|
||||
import {
|
||||
NodeAgentClient,
|
||||
loadMacroFile,
|
||||
runMacro,
|
||||
ClientError,
|
||||
loadConfig,
|
||||
saveConfig,
|
||||
configPath,
|
||||
toWsUrl,
|
||||
loadKeys,
|
||||
createKeys,
|
||||
keysFilePath,
|
||||
discoverOnce,
|
||||
resolveTarget,
|
||||
resolveNodeSelector,
|
||||
emptyConfig,
|
||||
type ClientConfig,
|
||||
type ResolvedTarget,
|
||||
type NodeProfile,
|
||||
} from '@nodeagent/client';
|
||||
import {
|
||||
CapabilityNames,
|
||||
matchPattern,
|
||||
DEFAULT_DISCOVERY_PORT,
|
||||
type CapabilityDescriptor,
|
||||
type InvokeResult,
|
||||
} from '@nodeagent/protocol';
|
||||
import {
|
||||
callAndPrint,
|
||||
fail,
|
||||
getClientConfig,
|
||||
humanSize,
|
||||
printJson,
|
||||
riskIcon,
|
||||
withClient,
|
||||
withClientDirect,
|
||||
} from '../core.js';
|
||||
import { CHUNK_BYTES, type Options } from '../types.js';
|
||||
|
||||
// CLI 命令组:cmd/net.ts
|
||||
export async function cmdNet(sub: string | undefined, opts: Options): Promise<void> {
|
||||
if (sub === 'status' || sub === undefined) {
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.NetStatus, {}, opts.json, (data) => {
|
||||
const d = data as {
|
||||
interfaces: Array<Record<string, unknown>>;
|
||||
pending: Array<{ task_name: string; seconds_left: number; rollback_at: number }>;
|
||||
pending_count: number;
|
||||
note: string;
|
||||
};
|
||||
console.log('网卡地址:');
|
||||
for (const i of d.interfaces ?? []) {
|
||||
console.log(` ${String(i['InterfaceAlias'] ?? '?')} ip=${String(i['ip'] ?? '-')} gw=${String(i['gw'] ?? '-')}`);
|
||||
}
|
||||
if (d.pending_count > 0) {
|
||||
console.log('\n⏳ 待确认的网络变更(逾期自动回滚):');
|
||||
for (const p of d.pending) {
|
||||
console.log(` ${p.task_name} 剩余 ${p.seconds_left}s`);
|
||||
}
|
||||
console.log(' 确认提交:nodeagent net confirm');
|
||||
} else {
|
||||
console.log('\n(无待确认变更)');
|
||||
}
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'confirm') {
|
||||
const args: Record<string, unknown> = {};
|
||||
if (opts.taskName) args['task_name'] = opts.taskName;
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.NetConfirm, args, opts.json, (data) => {
|
||||
const d = data as { confirmed: number; cancelled: string[]; remaining: string[]; note?: string };
|
||||
if (d.confirmed === 0) console.log(`✓ 无需确认:${d.note ?? '没有待确认的网络变更'}`);
|
||||
else console.log(`✓ 已确认(提交)${d.confirmed} 项:${d.cancelled.join(', ')}`);
|
||||
if ((d.remaining ?? []).length > 0) console.error(`⚠️ 仍有未取消:${d.remaining.join(', ')}`);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'apply') {
|
||||
const modeRaw = opts.mode ?? 'static';
|
||||
const args: Record<string, unknown> = { mode: modeRaw };
|
||||
if (opts.iface) args['interface'] = opts.iface;
|
||||
if (opts.ip) args['ip'] = opts.ip;
|
||||
if (opts.mask) args['mask'] = opts.mask;
|
||||
if (opts.gateway) args['gateway'] = opts.gateway;
|
||||
if (opts.dns) args['dns'] = String(opts.dns).split(',').map((x) => x.trim());
|
||||
if (opts.command) args['command'] = opts.command;
|
||||
if (opts.confirmWithin) args['confirm_within_ms'] = Number(opts.confirmWithin);
|
||||
if (!opts.yes) {
|
||||
fail(
|
||||
'网络变更属高危操作,需显式加 --yes 确认。\n' +
|
||||
'行为:备份当前配置 → 应用变更 → 注册 OS 级定时回滚任务 → 你在窗口期内 net confirm 即提交;\n' +
|
||||
' 逾期未确认则自动回滚到变更前配置(不会把自己关在门外)。',
|
||||
);
|
||||
}
|
||||
// 网络变更本身耗时较长(netsh 重配网卡 + 注册计划任务),默认 60s 不够
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.NetApply, args, opts.json, (data) => {
|
||||
const d = data as {
|
||||
applied: boolean;
|
||||
interface: string;
|
||||
backup_path: string;
|
||||
task_name: string;
|
||||
confirm_within_ms: number;
|
||||
hint: string;
|
||||
};
|
||||
console.log(`✓ 已应用变更(网卡 ${d.interface}),备份:${d.backup_path}`);
|
||||
console.log(` 自动回滚任务:${d.task_name}(${Math.round(d.confirm_within_ms / 1000)}s 后触发)`);
|
||||
console.log(` ${d.hint}`);
|
||||
}, 200_000),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
fail('用法: nodeagent net [status] | net apply --mode static|dhcp|command [...] --yes | net confirm [--task-name X]');
|
||||
}
|
||||
@@ -0,0 +1,754 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { closeSync, existsSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync, writeSync } from 'node:fs';
|
||||
import fsSync from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path, { join } from 'node:path';
|
||||
import {
|
||||
NodeAgentClient,
|
||||
loadMacroFile,
|
||||
runMacro,
|
||||
ClientError,
|
||||
loadConfig,
|
||||
saveConfig,
|
||||
configPath,
|
||||
toWsUrl,
|
||||
loadKeys,
|
||||
createKeys,
|
||||
keysFilePath,
|
||||
discoverOnce,
|
||||
resolveTarget,
|
||||
resolveNodeSelector,
|
||||
emptyConfig,
|
||||
type ClientConfig,
|
||||
type ResolvedTarget,
|
||||
type NodeProfile,
|
||||
} from '@nodeagent/client';
|
||||
import {
|
||||
CapabilityNames,
|
||||
matchPattern,
|
||||
DEFAULT_DISCOVERY_PORT,
|
||||
type CapabilityDescriptor,
|
||||
type InvokeResult,
|
||||
} from '@nodeagent/protocol';
|
||||
import {
|
||||
callAndPrint,
|
||||
fail,
|
||||
getClientConfig,
|
||||
getNodeOverride,
|
||||
probeOnce,
|
||||
humanSize,
|
||||
printJson,
|
||||
riskIcon,
|
||||
withClient,
|
||||
withClientDirect,
|
||||
} from '../core.js';
|
||||
import { CHUNK_BYTES, type Options } from '../types.js';
|
||||
|
||||
// CLI 命令组:cmd/ops.ts
|
||||
export async function cmdBg(command: string, opts: Options): Promise<void> {
|
||||
await withClient((c) =>
|
||||
callAndPrint(
|
||||
c,
|
||||
CapabilityNames.ShellExec,
|
||||
{ command, async: true, timeout_ms: opts.timeoutMs ? Number(opts.timeoutMs) : undefined },
|
||||
opts.json,
|
||||
(data) => {
|
||||
const d = data as { task_id: string; state: string };
|
||||
console.log(`✓ 后台任务已启动: ${d.task_id}(${d.state})`);
|
||||
console.log(' 查询: nodeagent task ' + d.task_id);
|
||||
console.log(' 终止: nodeagent task ' + d.task_id + ' --kill');
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function cmdTasks(opts: Options): Promise<void> {
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.TaskList, {}, opts.json, (data) => {
|
||||
const rows = (data as { tasks: Array<{ task_id: string; state: string; duration_ms: number; exit_code?: number }> }).tasks;
|
||||
if (rows.length === 0) {
|
||||
console.log('当前没有后台任务');
|
||||
return;
|
||||
}
|
||||
console.log(`${'任务ID'.padEnd(26)} ${'状态'.padEnd(9)} ${'耗时'.padStart(9)} 退出码`);
|
||||
for (const t of rows) {
|
||||
console.log(
|
||||
`${t.task_id.padEnd(26)} ${t.state.padEnd(9)} ${String(t.duration_ms + 'ms').padStart(9)} ${t.exit_code ?? '-'}`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function cmdTask(taskId: string, opts: Options): Promise<void> {
|
||||
if (opts.kill) {
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.TaskKill, { task_id: taskId }, opts.json, (data) => {
|
||||
const d = data as { killed: boolean };
|
||||
console.log(d.killed ? `✓ 已终止 ${taskId}` : `任务 ${taskId} 已不在运行`);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const offset = opts.offset ? Number(opts.offset) : 0;
|
||||
await withClient((c) =>
|
||||
callAndPrint(
|
||||
c,
|
||||
CapabilityNames.TaskGet,
|
||||
{ task_id: taskId, offset },
|
||||
opts.json,
|
||||
(data) => {
|
||||
const d = data as {
|
||||
state: string;
|
||||
exit_code: number | null;
|
||||
duration_ms: number;
|
||||
data: string;
|
||||
offset: number;
|
||||
total_bytes: number;
|
||||
};
|
||||
if (d.data) console.log(d.data);
|
||||
console.error(
|
||||
`[${d.state} · ${d.duration_ms}ms · 已读 ${d.offset}/${d.total_bytes} 字节${d.exit_code !== null && d.state !== 'running' ? ` · 退出码 ${d.exit_code}` : ''}]`,
|
||||
);
|
||||
if (d.state === 'running' && d.offset < d.total_bytes) {
|
||||
console.error(' 续读: nodeagent task ' + taskId + ' --offset ' + d.offset);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v11 / C5:把一个能力并发下发到多台设备并汇总结果。
|
||||
* 每台设备独立连接、互不阻塞(allSettled),任一失败不影响其他。
|
||||
*/
|
||||
|
||||
export async function cmdFanout(capability: string, opts: Options): Promise<void> {
|
||||
const cfg = getClientConfig();
|
||||
const names = (opts.nodes ?? '')
|
||||
.split(',')
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean);
|
||||
const selector = names.length > 0 ? names : Object.keys(cfg.nodes);
|
||||
// v12:支持 @组名(组内可嵌套引用其它组)
|
||||
const { nodes: targets, resolvedGroups } = resolveNodeSelector(cfg, selector);
|
||||
if (targets.length === 0) fail('没有可用的设备,请先 nodeagent connect 或检查分组定义');
|
||||
for (const [g, members] of Object.entries(resolvedGroups)) {
|
||||
if (members.length === 0) fail(`分组「${g}」为空或不存在(可用: ${Object.keys(cfg.groups ?? {}).join(', ') || '无'})`);
|
||||
}
|
||||
|
||||
let args: Record<string, unknown> = {};
|
||||
if (opts.args) {
|
||||
try {
|
||||
args = JSON.parse(opts.args) as Record<string, unknown>;
|
||||
} catch {
|
||||
fail('--args 需为合法 JSON,例如 --args {"limit":5}');
|
||||
}
|
||||
}
|
||||
|
||||
const started = Date.now();
|
||||
const results = await Promise.allSettled(
|
||||
targets.map(async (n) => {
|
||||
const t0 = Date.now();
|
||||
const data = await withClient(async (c) => {
|
||||
const r = await c.invoke(capability, args, Number(opts.timeoutMs ?? 60_000));
|
||||
if (r.status === 'failed') throw new Error(`${r.error?.name}: ${r.error?.message}`);
|
||||
return r.data;
|
||||
}, n);
|
||||
return { node: n, ms: Date.now() - t0, data };
|
||||
}),
|
||||
);
|
||||
|
||||
if (opts.json) {
|
||||
printJson(
|
||||
results.map((r, i) =>
|
||||
r.status === 'fulfilled'
|
||||
? { node: r.value.node, ok: true, ms: r.value.ms, data: r.value.data }
|
||||
: { node: targets[i], ok: false, error: String(r.reason) },
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`并发下发 ${capability} → ${targets.length} 台设备(总耗时 ${Date.now() - started}ms)\n`);
|
||||
results.forEach((r, i) => {
|
||||
const name = (targets[i] ?? '?').padEnd(16);
|
||||
if (r.status === 'fulfilled') {
|
||||
const preview = JSON.stringify(r.value.data);
|
||||
console.log(` ✓ ${name} ${String(r.value.ms + 'ms').padStart(7)} ${preview.slice(0, 150)}`);
|
||||
} else {
|
||||
console.log(` ✗ ${name} ${'—'.padStart(7)} ${String(r.reason).slice(0, 150)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function cmdRecord(opts: Options): Promise<void> {
|
||||
const args: Record<string, unknown> = {};
|
||||
if (opts.duration) args['duration_ms'] = Number(opts.duration);
|
||||
if (opts.fps) args['fps'] = Number(opts.fps);
|
||||
if (opts.scale) args['scale'] = Number(opts.scale);
|
||||
if (opts.region) {
|
||||
const n = opts.region.split(',').map(Number);
|
||||
if (n.length !== 4 || n.some((v) => !Number.isFinite(v))) fail('region 格式应为 "x,y,width,height"');
|
||||
args['region'] = { x: n[0], y: n[1], width: n[2], height: n[3] };
|
||||
}
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.ScreenRecord, args, opts.json, (data) => {
|
||||
const d = data as { dir: string; frames: number; fps: number; elapsed_ms: number; video_path?: string; frames_only: boolean };
|
||||
console.log(`✓ 录制完成:${d.frames} 帧 @ ${d.fps}fps(${d.elapsed_ms}ms)`);
|
||||
console.log(` 目录: ${d.dir}`);
|
||||
if (d.video_path) console.log(` 视频: ${d.video_path}`);
|
||||
else if (d.frames_only) console.log(' 提示: 无 ffmpeg,用 `nodeagent pull` 或 `nodeagent ls` 取回帧');
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v11 / D2:一键部署 / 升级被控端 agent。
|
||||
* 流程:被控端自报入口路径(system.info)→ 上传新 agent.mjs → 受控重启 → 复验能力数。
|
||||
* 全程无需手工拷文件或起计划任务。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 部署/更新被控端 agent(v18 起带指纹校验)。
|
||||
*
|
||||
* 流程:本地算 sha256 → 问远端构建指纹 → 相同则跳过(--force 可强制)→
|
||||
* 备份 → 分块上传 → 受控重启 → **轮询直到远端指纹等于本地**(替代盲等)。
|
||||
*
|
||||
* 为什么必须比指纹:只看「PID 变了 / 能力数对了」证明不了换成了新代码
|
||||
* (旧版可能恰好有同样多的能力)。内容哈希是唯一可靠判据。
|
||||
*/
|
||||
export async function cmdDeploy(localFile: string, opts: Options): Promise<void> {
|
||||
if (!existsSync(localFile)) fail(`本地文件不存在: ${localFile}`);
|
||||
const buf = readFileSync(localFile);
|
||||
if (buf.length < 10_000) fail(`文件过小,疑似不是 agent 包: ${localFile}`);
|
||||
const localHash = createHash('sha256').update(buf).digest('hex').slice(0, 12);
|
||||
|
||||
// 单条 RPC 的安全分块(与上传保持一致)
|
||||
const BACKUP_CHUNK = 512 * 1024;
|
||||
|
||||
type Info = {
|
||||
agent_script?: string;
|
||||
pid?: number;
|
||||
node_path?: string;
|
||||
build?: { hash?: string; node?: string; started_at?: number };
|
||||
};
|
||||
|
||||
const remote = await withClient((c) => c.invoke<Info>(CapabilityNames.SystemInfo, {}));
|
||||
if (remote.status === 'failed') fail('无法读取被控端信息');
|
||||
const info: Info = remote.data ?? {};
|
||||
const remoteHash = info.build?.hash ?? 'unknown';
|
||||
const target = opts.path ?? info.agent_script;
|
||||
if (!target) fail('被控端未上报入口路径,请用 --path <远端路径> 指定');
|
||||
|
||||
console.log(`本地 : ${localHash} (${(buf.length / 1024).toFixed(0)} KB)`);
|
||||
console.log(`远端 : ${remoteHash} ${info.build?.node ? `(node ${info.build.node})` : ''}`);
|
||||
console.log(`目标 : ${target}(当前 PID ${info.pid ?? '?'})`);
|
||||
|
||||
const unknownRemote = remoteHash === 'unknown';
|
||||
if (unknownRemote) {
|
||||
console.log('ℹ️ 远端未上报构建指纹(多为 v18 之前的旧版)—— 无法比对,建议直接部署');
|
||||
}
|
||||
|
||||
if (opts.check === true) {
|
||||
if (remoteHash === localHash) {
|
||||
console.log('✓ 已是最新,无需更新');
|
||||
} else if (unknownRemote) {
|
||||
console.log('⚠️ 无法比对(远端过旧),建议执行 `nodeagent deploy <文件>` 更新');
|
||||
process.exitCode = 2;
|
||||
} else {
|
||||
console.log('⚠️ 版本不一致 —— 执行 `nodeagent deploy <文件>` 更新(加 --force 可强制覆盖)');
|
||||
process.exitCode = 2;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!unknownRemote && remoteHash === localHash && opts.force !== true) {
|
||||
console.log('✓ 远端指纹与本地一致,已是最新(如需强制覆盖请加 --force)');
|
||||
return;
|
||||
}
|
||||
|
||||
// 备份 + 上传 + 重启
|
||||
const uploadInfo = await withClient(async (c) => {
|
||||
// 备份走协议自带的 fs 能力(而非 shell 的 Copy-Item)——
|
||||
// Copy-Item 是 Windows 专用,在 macOS 被控端上会静默失败(真机踩过:以为备份了,其实没有)。
|
||||
// 备份用**固定名**(覆盖式):此前用 `.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 });
|
||||
const size = st.status === 'ok' ? (st.data?.size ?? 0) : 0;
|
||||
if (size > 0) {
|
||||
let off = 0;
|
||||
let first = true;
|
||||
while (off < size) {
|
||||
// 自行计算本次长度并据此前进(对齐 cmdPush 的成熟写法):
|
||||
// fs.read 返回的 offset 是**本次读取的起始位置**,拿它当前进量会原地打转
|
||||
// → 死循环 + 文件暴涨(真机踩过:备份 33B→268KB 且部署卡死 200s)。
|
||||
const len = Math.min(BACKUP_CHUNK, size - off);
|
||||
const r = await c.invoke<{ data?: string }>(CapabilityNames.FsRead, {
|
||||
path: target,
|
||||
offset: off,
|
||||
max_bytes: len,
|
||||
// ⚠️ 必须显式 base64:默认 utf8 会把二进制按文本读(损坏),
|
||||
// 再当 base64 写回就得到几十字节的垃圾备份(真机踩过:备份 33 字节)
|
||||
encoding: 'base64',
|
||||
});
|
||||
if (r.status !== 'ok') break;
|
||||
const chunk = r.data?.data ?? '';
|
||||
if (!chunk) break;
|
||||
const w = await c.invoke(CapabilityNames.FsWrite, {
|
||||
path: backup,
|
||||
data: chunk,
|
||||
encoding: 'base64',
|
||||
append: !first,
|
||||
});
|
||||
if (w.status !== 'ok') break;
|
||||
first = false;
|
||||
off += len;
|
||||
}
|
||||
if (!first) backupPath = backup;
|
||||
}
|
||||
} catch {
|
||||
/* 备份失败不阻断部署,但下面会提示 */
|
||||
}
|
||||
if (backupPath) console.log(`已备份: ${backupPath}`);
|
||||
else console.log('⚠️ 备份未成功(部署继续,但无自动回滚点)');
|
||||
|
||||
const CHUNK = 512 * 1024;
|
||||
let offset = 0;
|
||||
while (offset < buf.length) {
|
||||
const part = buf.subarray(offset, Math.min(offset + CHUNK, buf.length));
|
||||
const w = await c.invoke(CapabilityNames.FsWrite, {
|
||||
path: target,
|
||||
data: part.toString('base64'),
|
||||
encoding: 'base64',
|
||||
append: offset > 0,
|
||||
});
|
||||
if (w.status === 'failed') fail(`上传失败于 offset ${offset}: ${w.error?.message}`);
|
||||
offset += part.length;
|
||||
}
|
||||
console.log(`✓ 已上传 ${(buf.length / 1024).toFixed(0)} KB`);
|
||||
|
||||
// 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 };
|
||||
});
|
||||
|
||||
// 触发重启:**用独立连接且吞掉连接中断**。
|
||||
// 重启会把本连接切断,这属于预期行为;若当成失败抛出,整个部署会被误判为失败
|
||||
// (真机踩过:文件其实已换好、新进程也起来了,CLI 却报 E_NODE_OFFLINE)。
|
||||
try {
|
||||
const r = await withClient((c) =>
|
||||
c.invoke(CapabilityNames.AgentRestart, { delay_ms: 1500, reason: 'deploy' }),
|
||||
);
|
||||
if (r.status === 'failed') fail(`重启失败: ${r.error?.message}`);
|
||||
} catch (err) {
|
||||
console.log(`(重启切断了连接,属预期:${err instanceof Error ? err.message : String(err)})`);
|
||||
}
|
||||
console.log('✓ 已触发重启,校验新版本上线…');
|
||||
|
||||
// 轮询校验:直到远端指纹 == 本地指纹。
|
||||
// 用 probeOnce(永不退出),**不能用 withClient** —— 后者连不上会 fail() 退出,
|
||||
// 使得「等待重连」根本无法实现(真机踩过:部署成功却报 E_NODE_OFFLINE)。
|
||||
// 按墙钟限时而非固定次数:每次连接耗时不定,按次数算总时长不可预期。
|
||||
let verified = false;
|
||||
let lastSeen = '';
|
||||
let pidSeen: number | undefined;
|
||||
let capsSeen = 0;
|
||||
const deadline = Date.now() + 90_000;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((t) => setTimeout(t, 1500));
|
||||
const p = await probeOnce();
|
||||
if (!p.ok) continue;
|
||||
lastSeen = p.buildHash ?? 'unknown';
|
||||
pidSeen = p.pid;
|
||||
capsSeen = p.caps;
|
||||
if (lastSeen === localHash) {
|
||||
verified = true;
|
||||
console.log(`✓ 新版本已上线并校验通过:指纹 ${lastSeen},PID ${pidSeen ?? '?'},能力 ${capsSeen} 项`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!verified) {
|
||||
const waited = Math.round((Date.now() - (deadline - 90_000)) / 1000);
|
||||
console.error(`✗ ${waited}s 内未校验到新版本(当前远端指纹 ${lastSeen || '连不上'})`);
|
||||
if (uploadInfo.backupPath) {
|
||||
console.error(` 回滚方式:把备份复制回目标即可 ——`);
|
||||
console.error(` nodeagent exec "Copy-Item '${uploadInfo.backupPath}' '${target}' -Force"`);
|
||||
}
|
||||
console.error(' 排查:被控端杀软可能处置了被覆盖的 agent 文件');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v12 / E1+E2:订阅被控端事件并实时接收推送。
|
||||
* 与普通命令不同,它需要**保持连接**一段时间(长连接 + 推送),因此不走 withClient 的「用完即关」。
|
||||
*/
|
||||
|
||||
export async function cmdEvents(opts: Options): Promise<void> {
|
||||
const cfg = getClientConfig();
|
||||
let target: ResolvedTarget;
|
||||
try {
|
||||
target = resolveTarget(cfg, getNodeOverride());
|
||||
} catch (err) {
|
||||
fail(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
|
||||
// 无 --kind 时列出当前订阅
|
||||
if (!opts.kind) {
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.EventList, {}, opts.json, (data) => {
|
||||
const d = data as {
|
||||
watches: Array<{ watch_id: string; kind: string; description: string; events: number }>;
|
||||
buffered: number;
|
||||
};
|
||||
if (d.watches.length === 0) {
|
||||
console.log(`当前没有事件订阅(缓冲 ${d.buffered} 条)`);
|
||||
console.log('用法: nodeagent events --kind file --path <路径> [--seconds 20]');
|
||||
return;
|
||||
}
|
||||
console.log(`${'订阅ID'.padEnd(24)} ${'类型'.padEnd(8)} 事件数 说明`);
|
||||
for (const w of d.watches) {
|
||||
console.log(`${w.watch_id.padEnd(24)} ${w.kind.padEnd(8)} ${String(w.events).padStart(6)} ${w.description}`);
|
||||
}
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const seconds = Math.max(1, Math.min(600, Number(opts.seconds ?? 15)));
|
||||
const { profile, clientId } = target;
|
||||
const keys = profile.auth_mode === 'ed25519' ? loadKeys() : null;
|
||||
const received: Array<Record<string, unknown>> = [];
|
||||
|
||||
const client = new NodeAgentClient({
|
||||
url: toWsUrl(profile),
|
||||
key: profile.key ?? '',
|
||||
clientId,
|
||||
insecure: profile.insecure,
|
||||
authMode: profile.auth_mode,
|
||||
privateKey: keys?.privateKey,
|
||||
hub: profile.hub ? { token: profile.hub.token, nodeId: profile.hub.node_id } : undefined,
|
||||
onEvent: (evt) => {
|
||||
received.push(evt);
|
||||
const ts = new Date(Number(evt['ts'] ?? Date.now())).toLocaleTimeString('zh-CN');
|
||||
const line = `${ts} ${String(evt['action'] ?? '').padEnd(20)} ${String(evt['target'] ?? '')}${evt['detail'] ? ` (${evt['detail']})` : ''}`;
|
||||
if (opts.json) console.log(JSON.stringify(evt));
|
||||
else console.log(` ${line}`);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
const args: Record<string, unknown> = { kind: opts.kind };
|
||||
if (opts.path) args['path'] = opts.path;
|
||||
if (opts.pattern) args['pattern'] = opts.pattern;
|
||||
if (opts.recursive) args['recursive'] = true;
|
||||
if (opts.intervalMs) args['interval_ms'] = Number(opts.intervalMs);
|
||||
|
||||
const r = await client.invoke<{ watch_id: string; description: string }>(
|
||||
CapabilityNames.EventWatch,
|
||||
args,
|
||||
);
|
||||
if (r.status === 'failed') fail(`${r.error?.name}: ${r.error?.message}`);
|
||||
const watchId = (r.data as { watch_id: string }).watch_id;
|
||||
if (!opts.json) {
|
||||
console.log(`已订阅:${(r.data as { description: string }).description}`);
|
||||
console.log(`订阅 ID: ${watchId} 监听 ${seconds}s(Ctrl+C 可提前结束)\n`);
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const t = setTimeout(resolve, seconds * 1000);
|
||||
process.once('SIGINT', () => {
|
||||
clearTimeout(t);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
await client.invoke(CapabilityNames.EventUnwatch, { watch_id: watchId });
|
||||
if (!opts.json) {
|
||||
console.log(`\n共收到 ${received.length} 个事件(订阅已取消)`);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ClientError) fail(`${err.name}: ${err.message}`);
|
||||
throw err;
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v12 / E4:GUI 宏 —— 把一次成功操作固化为可重放的步骤序列。
|
||||
* 子命令:run(回放)/ validate(仅校验文件)/ init(生成示例)
|
||||
*/
|
||||
|
||||
export async function cmdMacro(sub: string | undefined, file: string | undefined, opts: Options): Promise<void> {
|
||||
if (sub === 'init') {
|
||||
const target = file ?? 'macro.json';
|
||||
if (existsSync(target)) fail(`文件已存在: ${target}`);
|
||||
const sample = {
|
||||
name: '示例:在记事本里打字并复制',
|
||||
description: '演示 focus / find / type / key / clip / assert 组合;坐标与文本请按实际调整',
|
||||
default_delay_ms: 300,
|
||||
steps: [
|
||||
{ action: 'focus', title: 'Notepad', retry: 3, interval_ms: 800 },
|
||||
{ action: 'key', keys: ['ctrl', 'a'] },
|
||||
{ action: 'key', keys: ['delete'] },
|
||||
{ action: 'type', text: '宏回放演示 ${STAMP:-demo}' },
|
||||
{ action: 'sleep', ms: 300 },
|
||||
{ action: 'key', keys: ['ctrl', 'a'] },
|
||||
{ action: 'key', keys: ['ctrl', 'c'] },
|
||||
{ action: 'clip', expect: '宏回放演示' },
|
||||
{ action: 'assert', kind: 'window', text: 'Notepad' },
|
||||
],
|
||||
};
|
||||
writeFileSync(target, JSON.stringify(sample, null, 2) + '\n');
|
||||
console.log(`✓ 已生成示例宏: ${target}`);
|
||||
console.log(' 试用: nodeagent --node=<设备> macro run ' + target + ' --var STAMP=hello');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file) fail('用法: nodeagent macro run <文件.json> [--var k=v] / macro validate <文件.json> / macro init [文件.json]');
|
||||
if (!existsSync(file)) fail(`宏文件不存在: ${file}`);
|
||||
|
||||
const macro = loadMacroFile(file);
|
||||
|
||||
if (sub === 'validate') {
|
||||
const actions = macro.steps.map((s) => String(s['action'] ?? '?'));
|
||||
console.log(`✓ 语法有效:${macro.steps.length} 个步骤`);
|
||||
console.log(` 名称: ${macro.name ?? '(未命名)'}`);
|
||||
console.log(` 步骤: ${actions.join(' → ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 解析 --var k=v
|
||||
const vars: Record<string, string> = {};
|
||||
for (const kv of opts.vars ?? []) {
|
||||
const i = kv.indexOf('=');
|
||||
if (i <= 0) fail(`--var 需为 k=v 形式: ${kv}`);
|
||||
vars[kv.slice(0, i)] = kv.slice(i + 1);
|
||||
}
|
||||
|
||||
await withClient(async (c) => {
|
||||
const outDir = opts.out ?? '.';
|
||||
const t0 = Date.now();
|
||||
const res = await runMacro(
|
||||
{
|
||||
client: c,
|
||||
saveCapture: opts.out
|
||||
? (name, b64) => {
|
||||
const p = join(outDir, name);
|
||||
writeFileSync(p, Buffer.from(b64, 'base64'));
|
||||
return p;
|
||||
}
|
||||
: undefined,
|
||||
log: (m) => console.error(m),
|
||||
},
|
||||
macro,
|
||||
vars,
|
||||
);
|
||||
|
||||
if (opts.json) {
|
||||
printJson(res);
|
||||
if (!res.ok) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
console.log(`宏「${res.name}」共 ${macro.steps.length} 步,耗时 ${Date.now() - t0}ms\n`);
|
||||
for (const s of res.steps) {
|
||||
const mark = s.ok ? '✓' : '✗';
|
||||
console.log(` ${mark} [${String(s.index).padStart(2)}] ${s.action.padEnd(8)} ${String(s.ms + 'ms').padStart(7)} ${s.detail ?? ''}`);
|
||||
}
|
||||
if (res.ok) {
|
||||
console.log('\n✓ 全部步骤通过');
|
||||
} else {
|
||||
console.error(`\n✗ 在第 ${res.failed_at} 步失败,宏已中止`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** v13:成功指标(对齐 PRD 2.2 四项验收指标)。 */
|
||||
|
||||
export async function cmdClip(opts: Options): Promise<void> {
|
||||
// 图片上传:从本地文件读入(避免超长命令行参数)
|
||||
if (opts.imageFile) {
|
||||
const p = opts.imageFile;
|
||||
if (!existsSync(p)) fail(`文件不存在: ${p}`);
|
||||
const b64 = readFileSync(p).toString('base64');
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.ClipSet, { image_base64: b64 }, opts.json, (data) => {
|
||||
const d = data as { written_bytes: number };
|
||||
console.log(`✓ 图片已写入剪贴板(${d.written_bytes} 字节)`);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (opts.set !== undefined) {
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.ClipSet, { text: opts.set }, opts.json, (data) => {
|
||||
const d = data as { written: number };
|
||||
console.log(`✓ 已写入剪贴板 ${d.written} 字符`);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// 图片下载:--out 指定保存路径
|
||||
if (opts.out) {
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.ClipGet, { format: 'image' }, opts.json, (data) => {
|
||||
const d = data as { image_base64: string; bytes: number; format: string };
|
||||
writeFileSync(opts.out!, Buffer.from(d.image_base64, 'base64'));
|
||||
console.log(`✓ 剪贴板图片已保存 ${opts.out}(${d.bytes} 字节,${d.format})`);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.ClipGet, { format: opts.format ?? 'auto' }, opts.json, (data) => {
|
||||
const d = data as {
|
||||
type?: string;
|
||||
text?: string;
|
||||
bytes?: number;
|
||||
image_base64?: string;
|
||||
};
|
||||
if (d.type === 'image') {
|
||||
console.log(`(剪贴板为图片,${d.bytes} 字节;用 --out <路径> 保存)`);
|
||||
return;
|
||||
}
|
||||
if (d.text) console.log(d.text);
|
||||
else console.log('(剪贴板为空或非文本)');
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 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}`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
import { closeSync, existsSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync, writeSync } from 'node:fs';
|
||||
import fsSync from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path, { join } from 'node:path';
|
||||
import {
|
||||
NodeAgentClient,
|
||||
loadMacroFile,
|
||||
runMacro,
|
||||
ClientError,
|
||||
loadConfig,
|
||||
saveConfig,
|
||||
configPath,
|
||||
toWsUrl,
|
||||
loadKeys,
|
||||
createKeys,
|
||||
keysFilePath,
|
||||
discoverOnce,
|
||||
resolveTarget,
|
||||
resolveNodeSelector,
|
||||
emptyConfig,
|
||||
type ClientConfig,
|
||||
type ResolvedTarget,
|
||||
type NodeProfile,
|
||||
} from '@nodeagent/client';
|
||||
import {
|
||||
CapabilityNames,
|
||||
matchPattern,
|
||||
DEFAULT_DISCOVERY_PORT,
|
||||
type CapabilityDescriptor,
|
||||
type InvokeResult,
|
||||
} from '@nodeagent/protocol';
|
||||
import {
|
||||
callAndPrint,
|
||||
reportCompat,
|
||||
fail,
|
||||
getClientConfig,
|
||||
humanSize,
|
||||
printJson,
|
||||
probeOnce,
|
||||
riskIcon,
|
||||
withClient,
|
||||
withClientDirect,
|
||||
} from '../core.js';
|
||||
import { CHUNK_BYTES, type Options } from '../types.js';
|
||||
|
||||
// CLI 命令组:cmd/system.ts
|
||||
export async function cmdList(opts: Options): Promise<void> {
|
||||
const caps = await withClient(async (c) => {
|
||||
return c.listCapabilities();
|
||||
});
|
||||
if (opts.json) return printJson(caps);
|
||||
console.log(`被控端可用能力(${caps.length}):`);
|
||||
for (const c of caps as CapabilityDescriptor[]) {
|
||||
console.log(` ${riskIcon(c.risk)} ${c.name.padEnd(24)} ${c.description}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdStatus(opts: Options): Promise<void> {
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.SystemStatus, {}, opts.json, (data) => {
|
||||
const d = data as {
|
||||
cpu_pct: number;
|
||||
memory_used: number;
|
||||
memory_total: number;
|
||||
memory_pct: number;
|
||||
disks: Array<{ drive: string; total: number; free: number; used_pct: number }>;
|
||||
net: Array<{ adapter: string; ip: string }>;
|
||||
};
|
||||
console.log(`CPU 占用 : ${d.cpu_pct}%`);
|
||||
console.log(`内存 : ${humanSize(d.memory_used)} / ${humanSize(d.memory_total)} (${d.memory_pct}%)`);
|
||||
console.log('磁盘 :');
|
||||
for (const disk of d.disks) {
|
||||
console.log(` ${disk.drive.padEnd(12)} ${humanSize(disk.total - disk.free)} / ${humanSize(disk.total)} 已用 ${disk.used_pct}%`);
|
||||
}
|
||||
console.log('网络 :');
|
||||
for (const n of d.net) console.log(` ${n.adapter.padEnd(16)} ${n.ip}`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 info 的字段值渲染成可读单行。
|
||||
*
|
||||
* 修正:原先一律 `String(v)`,遇到 `ps_helper` / `build` 这类**嵌套对象**会打印
|
||||
* `[object Object]`(真机 2026-10-04 发现),排查时完全看不到内容。
|
||||
* 现改为:null/undefined → 空占位;数组/对象 → `key=value` 紧凑摘要(截断防刷屏)。
|
||||
*/
|
||||
function renderInfoValue(v: unknown): string {
|
||||
if (v === null || v === undefined) return '—';
|
||||
if (typeof v === 'string') return v === '' ? '—(未设置)' : v;
|
||||
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
||||
try {
|
||||
const flat: Record<string, unknown> = {};
|
||||
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
|
||||
flat[k] = val === null || val === undefined ? '—' : typeof val === 'object' ? JSON.stringify(val) : val;
|
||||
}
|
||||
const s = Object.entries(flat)
|
||||
.map(([k, val]) => `${k}=${val}`)
|
||||
.join(' ');
|
||||
return s.length > 180 ? `${s.slice(0, 177)}...` : s;
|
||||
} catch {
|
||||
return String(v);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdInfo(opts: Options): Promise<void> {
|
||||
// 先打版本与兼容性(v20)——info 是最自然的「体检」命令
|
||||
await withClient((c) => {
|
||||
reportCompat(c);
|
||||
// v21:把「身份钉定」与「来源网段限制」这两项安全状态摆出来
|
||||
const cfg = getClientConfig();
|
||||
const prof = cfg.nodes[cfg.current ?? ''];
|
||||
const fp = c.getPeerCertFingerprint();
|
||||
console.log(
|
||||
prof?.cert_sha256
|
||||
? `证书指纹 : 已钉住 ${prof.cert_sha256.slice(0, 16)}…${fp === prof.cert_sha256 ? '(本次一致 ✓)' : '(本次不一致 ✗)'}`
|
||||
: `证书指纹 : **未钉住** —— 建议重连一次自动钉住(nodeagent connect <地址> …)`,
|
||||
);
|
||||
const info = c.listCapabilities();
|
||||
void info;
|
||||
return Promise.resolve();
|
||||
});
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.SystemInfo, {}, opts.json, (data) => {
|
||||
const d = data as Record<string, unknown>;
|
||||
for (const [k, v] of Object.entries(d)) {
|
||||
const val = k === 'memory_total' && typeof v === 'number' ? humanSize(v) : renderInfoValue(v);
|
||||
console.log(`${k.padEnd(14)}: ${val}`);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function cmdPs(opts: Options): Promise<void> {
|
||||
const limit = Number(opts.limit ?? 20);
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.ProcessList, { limit }, opts.json, (data) => {
|
||||
const rows = (data as { processes: Array<{ pid: number; name: string; cpu_pct: number; memory_bytes: number }> }).processes;
|
||||
console.log(`${'PID'.padStart(7)} ${'CPU%'.padStart(7)} ${'内存'.padStart(9)} 名称`);
|
||||
for (const p of rows) {
|
||||
console.log(`${String(p.pid).padStart(7)} ${String(p.cpu_pct).padStart(7)} ${humanSize(p.memory_bytes).padStart(9)} ${p.name}`);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function cmdServices(opts: Options): Promise<void> {
|
||||
const limit = Number(opts.limit ?? 20);
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.ServiceList, { limit }, opts.json, (data) => {
|
||||
const rows = (data as { services: Array<{ name: string; display_name: string; state: string }> }).services;
|
||||
console.log(`${'状态'.padEnd(10)} ${'服务名'.padEnd(28)} 显示名`);
|
||||
for (const s of rows) console.log(`${s.state.padEnd(10)} ${s.name.padEnd(28)} ${s.display_name}`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function cmdExec(command: string, opts: Options): Promise<void> {
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.ShellExec, { command }, opts.json, (data) => {
|
||||
const d = data as { exit_code: number; stdout: string; stderr: string; duration_ms: number; truncated: boolean };
|
||||
// 命令自身的输出原样打到 stdout —— 保证 `nodeagent exec "..." | jq` 不被污染
|
||||
if (d.stdout) console.log(d.stdout);
|
||||
if (d.stderr) console.error(d.stderr);
|
||||
// 诊断信息(退出码/耗时)走 stderr:终端下照常可见,管道里不干扰数据
|
||||
console.error(
|
||||
`[退出码 ${d.exit_code} · ${d.duration_ms}ms${d.truncated ? ' · 输出已截断' : ''}]`,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function cmdRestart(opts: Options): Promise<void> {
|
||||
const delayMs = opts.delay ? Number(opts.delay) : 2000;
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.AgentRestart, { delay_ms: delayMs, reason: 'cli' }, opts.json, (data) => {
|
||||
const d = data as { scheduled: boolean; delay_ms: number; mechanism: string; message: string };
|
||||
console.log(`✓ ${d.message}`);
|
||||
console.log(` 机制: ${d.mechanism}`);
|
||||
console.log(' 提示: 约 3~4 秒后重连,可执行 `nodeagent info` 验证是否已恢复');
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 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) => {
|
||||
const d = data as {
|
||||
ok: boolean;
|
||||
checked: number;
|
||||
legacy: number;
|
||||
broken_at?: { file: string; line: number; reason: string };
|
||||
};
|
||||
if (d.ok) {
|
||||
console.log(`✓ 审计链完整(校验 ${d.checked} 条${d.legacy ? `,跳过历史条目 ${d.legacy} 条` : ''})`);
|
||||
return;
|
||||
}
|
||||
console.error(`✗ 审计链已损坏:${d.broken_at?.reason ?? '未知原因'}`);
|
||||
if (d.broken_at) console.error(` 位置: ${d.broken_at.file}:${d.broken_at.line}`);
|
||||
console.error(` 已校验 ${d.checked} 条`);
|
||||
process.exitCode = 1;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function cmdAudit(opts: Options): Promise<void> {
|
||||
const args: Record<string, unknown> = {};
|
||||
if (opts.limit) args['limit'] = Number(opts.limit);
|
||||
if (opts.since) args['since'] = Number(opts.since);
|
||||
if (opts.type) args['type'] = opts.type;
|
||||
if (opts.clientId) args['client_id'] = opts.clientId;
|
||||
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.AuditList, args, opts.json, (data) => {
|
||||
const d = data as { entries: Array<Record<string, unknown>>; total: number; file: string };
|
||||
console.log(`审计文件: ${d.file}`);
|
||||
console.log(`读取 ${d.total} 条,展示最新 ${d.entries.length} 条:\n`);
|
||||
for (const e of d.entries) {
|
||||
const ts = new Date(Number(e['ts'])).toLocaleString('zh-CN');
|
||||
const cols = [
|
||||
ts.padEnd(20),
|
||||
String(e['type'] ?? '').padEnd(17),
|
||||
String(e['client_id'] ?? '-').padEnd(10),
|
||||
String(e['capability'] ?? '-').padEnd(20),
|
||||
String(e['status'] ?? '-').padEnd(7),
|
||||
e['duration_ms'] !== undefined ? `${e['duration_ms']}ms` : '',
|
||||
e['error'] ? `err=${e['error']}` : '',
|
||||
e['reason'] ? String(e['reason']) : '',
|
||||
];
|
||||
console.log(' ' + cols.filter((x) => x !== '').join(' '));
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- v4 局域网发现 ----------
|
||||
|
||||
export async function cmdMetrics(opts: Options): Promise<void> {
|
||||
const args: Record<string, unknown> = {};
|
||||
if (opts.since) args['since'] = Number(opts.since);
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.Metrics, args, opts.json, (data) => {
|
||||
const m = data as {
|
||||
close_loop: { attempts: number; ok: number; failed: number; success_rate: number };
|
||||
app_install: { attempts: number; ok: number; failed: number; success_rate: number | null };
|
||||
latency: {
|
||||
p50_ms: number;
|
||||
p95_ms: number;
|
||||
max_ms: number;
|
||||
timeouts: number;
|
||||
fast: { samples: number; p50_ms: number; p95_ms: number; max_ms: number };
|
||||
slow: { samples: number; p50_ms: number; p95_ms: number; max_ms: number; capabilities: string[] };
|
||||
};
|
||||
security: { acl_denied: number; rate_limited: number; auth_failures: number; interception_rate: number };
|
||||
verdict: {
|
||||
close_loop_ok: boolean;
|
||||
app_install_ok: boolean;
|
||||
p95_ok: boolean;
|
||||
interception_ok: boolean;
|
||||
all_pass: boolean;
|
||||
};
|
||||
targets: { close_loop_success_rate: number; app_install_success_rate: number; p95_ms: number };
|
||||
by_capability: Record<string, { attempts: number; ok: number; success_rate: number; p95_ms: number }>;
|
||||
window: { from: number; to: number; entries: number };
|
||||
};
|
||||
const pct = (v: number): string => `${(v * 100).toFixed(1)}%`;
|
||||
const mark = (ok: boolean): string => (ok ? '✅' : '❌');
|
||||
const from = m.window.from ? new Date(m.window.from).toLocaleString('zh-CN') : '-';
|
||||
const to = m.window.to ? new Date(m.window.to).toLocaleString('zh-CN') : '-';
|
||||
console.log(`窗口: ${from} → ${to} (审计 ${m.window.entries} 条)\n`);
|
||||
console.log(`闭环成功率 ${pct(m.close_loop.success_rate).padStart(7)} ${mark(m.verdict.close_loop_ok)} 目标 ≥${pct(m.targets.close_loop_success_rate)} 样本 ${m.close_loop.ok}/${m.close_loop.attempts}`);
|
||||
const ir = m.app_install.success_rate;
|
||||
console.log(`装软件成功率 ${ir === null ? ' 无样本' : pct(ir).padStart(7)} ${mark(m.verdict.app_install_ok)} 目标 ≥${pct(m.targets.app_install_success_rate)} 样本 ${m.app_install.ok}/${m.app_install.attempts}`);
|
||||
console.log(`P95 时延 ${String(m.latency.fast.p95_ms + 'ms').padStart(7)} ${mark(m.verdict.p95_ok)} 交互层(目标 <${m.targets.p95_ms}ms,样本 ${m.latency.fast.samples}:P50 ${m.latency.fast.p50_ms}ms / 最大 ${m.latency.fast.max_ms}ms${m.latency.timeouts ? ` / 超时 ${m.latency.timeouts}` : ''})`);
|
||||
console.log(`慢操作耗时 ${String(m.latency.slow.p95_ms + 'ms').padStart(7)} —— 样本 ${m.latency.slow.samples}(${m.latency.slow.capabilities.join(' / ')};本身耗时由业务决定,不计入达标判定)`);
|
||||
console.log(`安全拦截 ${pct(m.security.interception_rate).padStart(7)} ${mark(m.verdict.interception_ok)} ACL 拒绝 ${m.security.acl_denied} / 超频 ${m.security.rate_limited} / 认证失败 ${m.security.auth_failures}`);
|
||||
console.log(`\n总体: ${m.verdict.all_pass ? '✅ 四项全达标' : '⚠️ 存在未达标项'}`);
|
||||
|
||||
const caps = Object.entries(m.by_capability).sort((a, b) => b[1].attempts - a[1].attempts);
|
||||
if (caps.length > 0) {
|
||||
console.log('\n按能力细分(Top 10):');
|
||||
console.log(` ${'能力'.padEnd(26)} ${'次数'.padStart(6)} ${'成功率'.padStart(8)} ${'P95'.padStart(9)}`);
|
||||
for (const [name, st] of caps.slice(0, 10)) {
|
||||
console.log(` ${name.padEnd(26)} ${String(st.attempts).padStart(6)} ${pct(st.success_rate).padStart(8)} ${String(st.p95_ms + 'ms').padStart(9)}`);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** v16:网络变更两阶段提交(备份 → 应用 → 定时回滚 → 确认提交)。 */
|
||||
|
||||
export async function cmdInvoke(capability: string, opts: Options): Promise<void> {
|
||||
let args: Record<string, unknown> = {};
|
||||
if (opts.args) {
|
||||
try {
|
||||
args = JSON.parse(opts.args) as Record<string, unknown>;
|
||||
} catch {
|
||||
fail('--args 必须是合法 JSON');
|
||||
}
|
||||
}
|
||||
await withClient(async (c) => {
|
||||
const result: InvokeResult = await c.invoke(capability, args);
|
||||
printJson(result);
|
||||
if (result.status === 'failed') process.exitCode = 2;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- v2 图形操作 ----------
|
||||
|
||||
export async function cmdApps(opts: Options): Promise<void> {
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.AppList, {}, opts.json, (data) => {
|
||||
const rows = (data as { apps: Array<{ name: string; version: string; publisher: string }> }).apps;
|
||||
console.log(`已安装软件(${rows.length}):`);
|
||||
for (const a of rows) console.log(` ${a.name.padEnd(40)} ${(a.version || '-').padEnd(14)} ${a.publisher}`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function cmdInstall(pkg: string, opts: Options): Promise<void> {
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.AppInstall, { package: pkg, id: opts.id }, opts.json, (data) => {
|
||||
const d = data as { installed: boolean; name: string; version: string; detail: string };
|
||||
console.log(d.installed ? `✓ 安装成功: ${d.name} ${d.version}` : `✗ 安装失败: ${d.name}`);
|
||||
if (d.detail) console.log(`\n${d.detail}`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** v17:音频控制(静音 / 主音量)。 */
|
||||
export async function cmdAudio(sub: string | undefined, opts: Options): Promise<void> {
|
||||
if (sub === 'set') {
|
||||
const args: Record<string, unknown> = {};
|
||||
if (opts.mute !== undefined) {
|
||||
const v = String(opts.mute).toLowerCase();
|
||||
if (!['on', 'off', 'true', 'false', '1', '0'].includes(v)) {
|
||||
fail('--mute 只接受 on|off');
|
||||
}
|
||||
args['mute'] = ['on', 'true', '1'].includes(v);
|
||||
}
|
||||
if (opts.volume !== undefined) args['volume'] = Number(opts.volume);
|
||||
if (Object.keys(args).length === 0) fail('用法: nodeagent audio set [--mute on|off] [--volume 0-100]');
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.AudioSet, args, opts.json, (data) => {
|
||||
const d = data as { muted: boolean; volume: number; backend: string };
|
||||
console.log(`✓ 已应用:${d.muted ? '🔇 静音' : '🔊 未静音'} · 音量 ${d.volume}%(${d.backend})`);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === undefined || sub === 'get') {
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.AudioGet, {}, opts.json, (data) => {
|
||||
const d = data as { muted: boolean; volume: number; backend: string };
|
||||
console.log(`${d.muted ? '🔇 静音' : '🔊 未静音'} · 主音量 ${d.volume}%(${d.backend})`);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
fail('用法: nodeagent audio [get] | audio set [--mute on|off] [--volume 0-100]');
|
||||
}
|
||||
|
||||
/**
|
||||
* v19:拉取式自更新 —— 被控端自己从 URL 下载新版本并替换自身。
|
||||
*
|
||||
* 与 `deploy`(推送式)的分工:推送式要求控制端与被控端**可达**;本命令只要求
|
||||
* **被控端能上网**(跨网段/NAT/异地场景的正解)。
|
||||
*
|
||||
* ⚠️ 复用 deploy 那一课:更新会重启 agent 并切断连接,属预期,
|
||||
* 不能把「连接断了」当成失败 —— 故连接部分吞掉异常,再用 probeOnce 复核指纹。
|
||||
*/
|
||||
export async function cmdUpdate(opts: Options): Promise<void> {
|
||||
if (!opts.url) {
|
||||
fail(
|
||||
'用法: nodeagent update --url <更新包地址> --sha256 <哈希> [--dry-run]\n' +
|
||||
' 拉取式自更新:被控端自己下载并替换(要求被控端能上网,不要求与此刻可达)\n' +
|
||||
' --dry-run 只下载校验,不改动任何文件',
|
||||
);
|
||||
}
|
||||
if (!opts.sha256) {
|
||||
fail('--sha256 必填:**安全底线** —— 不校验哈希等于开放远程代码执行');
|
||||
}
|
||||
const args: Record<string, unknown> = {
|
||||
url: opts.url,
|
||||
sha256: opts.sha256,
|
||||
dry_run: opts.dryRun === true,
|
||||
};
|
||||
if (opts.check === true) args['dry_run'] = true;
|
||||
|
||||
let incoming = '';
|
||||
try {
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.AgentUpdate, args, opts.json, (data) => {
|
||||
const d = data as {
|
||||
dry_run?: boolean;
|
||||
verified?: boolean;
|
||||
updated?: boolean;
|
||||
bytes: number;
|
||||
current_hash?: string;
|
||||
incoming_hash?: string;
|
||||
previous?: { hash?: string; bytes?: number };
|
||||
backup_path?: string;
|
||||
restarted?: boolean;
|
||||
note?: string;
|
||||
};
|
||||
incoming = d.incoming_hash ?? '';
|
||||
if (d.dry_run) {
|
||||
console.log(`✓ 校验通过(dry-run):${(d.bytes / 1024).toFixed(0)} KB`);
|
||||
console.log(` 当前 ${d.current_hash} → 待更新 ${d.incoming_hash}`);
|
||||
console.log(` ${d.note ?? ''}`);
|
||||
return;
|
||||
}
|
||||
console.log(`✓ 已替换:${d.previous?.hash} → ${d.current_hash}(${(d.bytes / 1024).toFixed(0)} KB)`);
|
||||
if (d.backup_path) console.log(` 回滚点: ${d.backup_path}`);
|
||||
if (d.restarted) console.log(' 已触发重启,连接会断开(属预期)');
|
||||
}, 600_000),
|
||||
);
|
||||
} catch (err) {
|
||||
// 重启切断了连接 —— 属预期
|
||||
console.log(`(连接中断,属重启预期:${err instanceof Error ? err.message : String(err)})`);
|
||||
}
|
||||
|
||||
if (args['dry_run'] === true || !incoming) return;
|
||||
|
||||
// 复核:等被控端回来并确认指纹已变成新的
|
||||
console.log('等待被控端重启并复核…');
|
||||
const deadline = Date.now() + 90_000;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((t) => setTimeout(t, 1500));
|
||||
const probe = await probeOnce();
|
||||
if (probe.ok && probe.buildHash === incoming) {
|
||||
console.log(`✓ 自更新完成并校验通过:指纹 ${probe.buildHash},PID ${probe.pid ?? '?'},能力 ${probe.caps} 项`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
console.error('✗ 90s 内未复核到新指纹 —— 请检查被控端是否被杀软处置,或把备份覆盖回入口回滚');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import { closeSync, existsSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync, writeSync } from 'node:fs';
|
||||
import fsSync from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path, { join } from 'node:path';
|
||||
import {
|
||||
NodeAgentClient,
|
||||
loadMacroFile,
|
||||
runMacro,
|
||||
ClientError,
|
||||
loadConfig,
|
||||
saveConfig,
|
||||
configPath,
|
||||
toWsUrl,
|
||||
loadKeys,
|
||||
createKeys,
|
||||
keysFilePath,
|
||||
discoverOnce,
|
||||
resolveTarget,
|
||||
resolveNodeSelector,
|
||||
emptyConfig,
|
||||
type ClientConfig,
|
||||
type ResolvedTarget,
|
||||
type NodeProfile,
|
||||
} from '@nodeagent/client';
|
||||
import {
|
||||
CAPABILITY_MANIFEST,
|
||||
CapabilityNames,
|
||||
capabilityDiff,
|
||||
matchPattern,
|
||||
DEFAULT_DISCOVERY_PORT,
|
||||
type CapabilityDescriptor,
|
||||
type InvokeResult,
|
||||
} from '@nodeagent/protocol';
|
||||
import { type Options } from './types.js';
|
||||
|
||||
// CLI 共享运行时(客户端/守护进程代理/输出工具)
|
||||
export function getClientConfig(): ClientConfig {
|
||||
const cfg = loadConfig();
|
||||
if (!cfg) {
|
||||
fail('尚未配置被控端。请先运行:\n nodeagent connect <host> --port 8765 --key <密钥>');
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
||||
/** 本次命令的临时目标设备(来自全局 --node)。 */
|
||||
// v16:全局 --node 的临时覆盖。跨模块共享可变状态:ESM 的 import 绑定只读,
|
||||
// 故一律经访问器读写(直接 import 变量再赋值会编译不过)。
|
||||
let currentNodeOverride: string | undefined;
|
||||
|
||||
/** 设置本次命令的 --node 覆盖(由入口在解析参数后调用)。 */
|
||||
export function setNodeOverride(v: string | undefined): void {
|
||||
currentNodeOverride = v;
|
||||
}
|
||||
|
||||
/** 读取当前生效的 --node 覆盖。 */
|
||||
export function getNodeOverride(): string | undefined {
|
||||
return currentNodeOverride;
|
||||
}
|
||||
|
||||
// ---------- v10 daemon 转发 ----------
|
||||
|
||||
export const DAEMON_SOCK = path.join(os.tmpdir(), 'nodeagentd.sock');
|
||||
|
||||
interface DaemonResponse {
|
||||
status: 'ok' | 'failed' | 'daemon_error';
|
||||
data?: unknown;
|
||||
error?: { name: string; message: string } | string;
|
||||
}
|
||||
|
||||
/** 经 daemon 转发一次能力调用(JSON lines over UDS)。 */
|
||||
|
||||
|
||||
export function viaDaemon(node: string, capability: string, args: Record<string, unknown>, timeoutMs?: number): Promise<DaemonResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const s = net.connect(DAEMON_SOCK);
|
||||
s.setTimeout(timeoutMs ?? 60_000);
|
||||
let buf = '';
|
||||
const failOnce = (err: Error): void => {
|
||||
s.destroy();
|
||||
reject(err);
|
||||
};
|
||||
s.once('error', failOnce);
|
||||
s.once('timeout', () => failOnce(new Error('daemon 转发超时')));
|
||||
s.once('connect', () => {
|
||||
s.write(JSON.stringify({ node, capability, args, timeout_ms: timeoutMs }) + '\n');
|
||||
});
|
||||
s.on('data', (chunk) => {
|
||||
buf += chunk.toString('utf8');
|
||||
const idx = buf.indexOf('\n');
|
||||
if (idx >= 0) {
|
||||
try {
|
||||
resolve(JSON.parse(buf.slice(0, idx)) as DaemonResponse);
|
||||
} catch (err) {
|
||||
failOnce(err instanceof Error ? err : new Error(String(err)));
|
||||
return;
|
||||
}
|
||||
s.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 「虚拟客户端」:invoke 转发给 daemon,业务错误原样返回、daemon 自身错误抛异常触发回退。 */
|
||||
|
||||
export function daemonProxyClient(nodeName: string): { invoke: NodeAgentClient['invoke'] } {
|
||||
return {
|
||||
async invoke<T>(capability: string, args: Record<string, unknown>, timeoutMs?: number) {
|
||||
const resp = await viaDaemon(nodeName, capability, args, timeoutMs);
|
||||
if (resp.status === 'daemon_error') {
|
||||
throw new Error(typeof resp.error === 'string' ? resp.error : resp.error?.message);
|
||||
}
|
||||
if (resp.status === 'failed') {
|
||||
const e = resp.error;
|
||||
return {
|
||||
status: 'failed' as const,
|
||||
error: typeof e === 'string' ? { name: 'E_EXECUTION_FAILED', message: e } : e,
|
||||
} as never;
|
||||
}
|
||||
return { status: 'ok' as const, data: resp.data } as never;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function withClient<T>(fn: (client: NodeAgentClient) => Promise<T>, nodeName?: string): Promise<T> {
|
||||
const cfg = getClientConfig();
|
||||
let target: ResolvedTarget;
|
||||
try {
|
||||
target = resolveTarget(cfg, nodeName ?? getNodeOverride());
|
||||
} catch (err) {
|
||||
fail(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
const node = nodeName ?? getNodeOverride() ?? cfg.current;
|
||||
// v10 daemon 快路径:socket 存活则经 daemon 复用长连接(省 1~2s TLS+握手)
|
||||
if (node && existsSync(DAEMON_SOCK)) {
|
||||
try {
|
||||
return await fn(daemonProxyClient(node) as unknown as NodeAgentClient);
|
||||
} catch (err) {
|
||||
// daemon 自身错误(转发失败/超时)→ 静默回退直连;业务错误原样抛出
|
||||
if (!(err instanceof ClientError)) {
|
||||
return withClientDirect(fn, nodeName);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
return withClientDirect(fn, nodeName);
|
||||
}
|
||||
|
||||
export async function withClientDirect<T>(fn: (client: NodeAgentClient) => Promise<T>, nodeName?: string): Promise<T> {
|
||||
const cfg = getClientConfig();
|
||||
let target: ResolvedTarget;
|
||||
try {
|
||||
target = resolveTarget(cfg, nodeName ?? getNodeOverride());
|
||||
} catch (err) {
|
||||
fail(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
const { profile, clientId } = target;
|
||||
const keys = profile.auth_mode === 'ed25519' ? loadKeys() : null;
|
||||
const client = new NodeAgentClient({
|
||||
url: toWsUrl(profile),
|
||||
key: profile.key ?? '',
|
||||
clientId,
|
||||
insecure: profile.insecure,
|
||||
certSha256: profile.cert_sha256, // v21:钉住的证书指纹(不一致则拒绝连接)
|
||||
authMode: profile.auth_mode,
|
||||
privateKey: keys?.privateKey,
|
||||
hub: profile.hub ? { token: profile.hub.token, nodeId: profile.hub.node_id } : undefined,
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
return await fn(client);
|
||||
} catch (err) {
|
||||
if (err instanceof ClientError) {
|
||||
fail(`${err.name}: ${err.message}`);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function fail(msg: string): never {
|
||||
console.error(`✗ ${msg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
export function printJson(value: unknown): void {
|
||||
console.log(JSON.stringify(value, null, 2));
|
||||
}
|
||||
|
||||
/** 调用能力并处理 status === 'failed' 的业务失败。 */
|
||||
|
||||
export async function callAndPrint(
|
||||
client: NodeAgentClient,
|
||||
capability: string,
|
||||
args: Record<string, unknown>,
|
||||
json: boolean,
|
||||
render: (data: unknown) => void,
|
||||
timeoutMs?: number,
|
||||
): Promise<void> {
|
||||
const result = await client.invoke(capability, args, timeoutMs);
|
||||
if (result.status === 'failed') {
|
||||
fail(`${result.error?.name ?? 'E_EXECUTION_FAILED'}: ${result.error?.message ?? '执行失败'}`);
|
||||
}
|
||||
if (json) printJson(result.data);
|
||||
else render(result.data);
|
||||
}
|
||||
|
||||
export function humanSize(bytes: number): string {
|
||||
if (!Number.isFinite(bytes)) return String(bytes);
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let v = bytes;
|
||||
let i = 0;
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024;
|
||||
i += 1;
|
||||
}
|
||||
return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
export function riskIcon(risk: string): string {
|
||||
return risk === 'high' ? '🔴' : risk === 'medium' ? '🟡' : '🟢';
|
||||
}
|
||||
|
||||
/**
|
||||
* 探测一次被控端(用于「等待重启/上线」这类**需要反复重试**的场景)。
|
||||
*
|
||||
* ⚠️ 不能用 withClient:它在连接失败时会直接 `fail()` **退出进程**(见 withClientDirect),
|
||||
* 于是重试循环第一次连不上就整个 CLI 退出了 —— 真机踩过:部署其实已成功,
|
||||
* CLI 却报 E_NODE_OFFLINE,且看不到任何自定义提示。本函数**永不抛出**。
|
||||
*/
|
||||
export async function probeOnce(nodeName?: string): Promise<{
|
||||
ok: boolean;
|
||||
pid?: number;
|
||||
buildHash?: string;
|
||||
caps: number;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const cfg = getClientConfig();
|
||||
const target = resolveTarget(cfg, nodeName ?? getNodeOverride());
|
||||
const keys = target.profile.auth_mode === 'ed25519' ? loadKeys() : null;
|
||||
const client = new NodeAgentClient({
|
||||
url: toWsUrl(target.profile),
|
||||
key: target.profile.key ?? '',
|
||||
clientId: target.clientId,
|
||||
insecure: target.profile.insecure,
|
||||
certSha256: target.profile.cert_sha256, // v21:钉住的证书指纹
|
||||
authMode: target.profile.auth_mode,
|
||||
privateKey: keys?.privateKey,
|
||||
hub: target.profile.hub ? { token: target.profile.hub.token, nodeId: target.profile.hub.node_id } : undefined,
|
||||
});
|
||||
await client.connect();
|
||||
const r = await client.invoke<{ pid?: number; build?: { hash?: string } }>(CapabilityNames.SystemInfo, {});
|
||||
const caps = client.listCapabilities().length;
|
||||
client.close();
|
||||
const d = r.data ?? {};
|
||||
return { ok: r.status === 'ok', pid: d.pid, buildHash: d.build?.hash, caps };
|
||||
} catch (err) {
|
||||
return { ok: false, caps: 0, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 新旧版本兼容提示(v20)。
|
||||
*
|
||||
* 背景:agent 与 CLI 是**分开更新**的(CLI 随 Mac 装,agent 推/拉到被控端),
|
||||
* 必然出现新旧混用。等到调用时才抛 E_CAPABILITY_NOT_FOUND 会让人摸不着头脑,
|
||||
* 所以在握手后就明确告诉用户「谁旧、缺什么、怎么升」。
|
||||
*
|
||||
* 兼容性依据(见 docs/VERSIONING.md):**只增不改** ——
|
||||
* 新 agent 向后兼容旧 CLI(新增能力/可选参数),因此升级顺序建议先被控端。
|
||||
*/
|
||||
export function reportCompat(client: NodeAgentClient): void {
|
||||
const remoteNames = client.listCapabilities().map((c) => c.name);
|
||||
const localNames = CAPABILITY_MANIFEST.map((c) => c.name);
|
||||
const diff = capabilityDiff(localNames, remoteNames);
|
||||
const v = client.getAgentVersion();
|
||||
const b = client.getAgentBuild();
|
||||
|
||||
if (v) {
|
||||
console.log(`被控端版本 : v${v}${b?.commit ? ` @ ${b.commit}` : ''}${b?.hash ? ` 构建 ${b.hash}` : ''}`);
|
||||
} else {
|
||||
console.log('被控端版本 : 未上报(多为 v20 之前的版本,建议更新一次)');
|
||||
}
|
||||
|
||||
if (diff.missingOnRemote.length > 0) {
|
||||
console.warn(`⚠️ 被控端缺少 ${diff.missingOnRemote.length} 项本控制端已知的能力 —— 远端较旧:`);
|
||||
console.warn(` ${diff.missingOnRemote.join(', ')}`);
|
||||
console.warn(' → 建议更新被控端:nodeagent update --url <包地址> --sha256 <哈希>');
|
||||
}
|
||||
if (diff.unknownLocally.length > 0) {
|
||||
console.warn(`ℹ️ 被控端还有 ${diff.unknownLocally.length} 项本控制端不认识的能力 —— 本 CLI 较旧:`);
|
||||
console.warn(` ${diff.unknownLocally.join(', ')}`);
|
||||
console.warn(' → 不影响既有功能;要用到这些新能力需更新控制端(git pull 后重跑 pack:mac + install-macos.sh)');
|
||||
}
|
||||
if (diff.missingOnRemote.length === 0 && diff.unknownLocally.length === 0) {
|
||||
console.log(`能力对齐 : ${localNames.length} 项,两端一致 ✓`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// CLI 帮助文本(从 index.ts 拆出)
|
||||
export const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI)
|
||||
|
||||
用法:
|
||||
nodeagent connect <host> [--port 8765] [--key <key>] [--insecure] [--id mac_01] [--auth-mode ed25519]
|
||||
配置并连接被控端(握手成功后打印能力清单)
|
||||
首次连接会自动钉住被控端证书指纹(TOFU);被控端重装/换证书后用 --forget-cert 重新钉住
|
||||
经 Hub 中转: 追加 --hub-token <Hub 令牌> --hub-node <被控端 node_id> (v6)
|
||||
|
||||
nodeagent keygen [--id mac_01] 生成 Ed25519 密钥对并输出被控端 ACL 配置片段 (v3 零信任)
|
||||
nodeagent nodes 列出已配置的被控端设备 (v5 多设备)
|
||||
nodeagent use <设备名> 切换当前默认设备
|
||||
nodeagent remove <设备名> 移除设备
|
||||
nodeagent audit [--limit 20] [--type invoke|auth|acl|agent] [--client-id X] [--since <ms>]
|
||||
查询被控端审计日志 (v3+)
|
||||
nodeagent audit verify 校验审计链完整性(防篡改检测)(v11)
|
||||
nodeagent metrics [--since <ms>] 成功指标:闭环率/装软件率/P95/拦截率 (v13)
|
||||
nodeagent audio [get] 查看被控端静音状态与主音量 (v17)
|
||||
nodeagent audio set [--mute on|off] [--volume 0-100] 设置静音 / 音量
|
||||
nodeagent net [status] 网络现状 + 待确认变更 (v16)
|
||||
nodeagent net apply --mode static --ip <ip> --mask <m> [--gateway g] --yes
|
||||
改网络(两阶段提交:未确认则自动回滚,不会失联)
|
||||
nodeagent net confirm [--task-name X] 确认提交网络变更(取消自动回滚)
|
||||
nodeagent discover [--wait 5] 发现局域网内的被控端(UDP 广播,免手抄 IP)(v4)
|
||||
|
||||
文件传输 (v5):
|
||||
nodeagent ls <远端路径> [--recursive] [--pattern "*.log"] 列目录
|
||||
nodeagent stat <远端路径> 看元信息
|
||||
nodeagent cat <远端路径> [--out 本地文件] 读文本(首块)
|
||||
nodeagent pull <远端路径> [--out 本地文件] 下载(自动分块,支持大文件)
|
||||
nodeagent push <本地文件> <远端路径> [--create-dirs] 上传(自动分块)
|
||||
|
||||
nodeagent info 查看系统信息 (system.info)
|
||||
nodeagent status 查看资源状态 (system.status)
|
||||
nodeagent ps [--limit 20] 查看进程 (system.process.list)
|
||||
nodeagent services [--limit 20] 查看服务 (system.service.list)
|
||||
nodeagent exec "<命令>" 执行命令 (system.shell.exec)
|
||||
nodeagent apps 列出已安装软件 (app.list)
|
||||
nodeagent install <包名|ID> 安装软件 (app.install)
|
||||
nodeagent restart [--delay 2000] 受控重启被控端(配置变更后让自身生效)(v7)
|
||||
nodeagent bg "<命令>" [--timeout-ms N] 后台执行长命令,立即返回 task_id (v10)
|
||||
nodeagent tasks 列出后台任务
|
||||
nodeagent task <id> [--offset N] [--kill] 读取/终止后台任务(支持增量续读)
|
||||
nodeagent clip [--set "文本"] [--out <路径>] [--image-file <路径>]
|
||||
读/写剪贴板文本或图片(--out 保存图片)(v11)
|
||||
nodeagent macro run <file.json> [--var k=v] [--out <目录>]
|
||||
回放 GUI 宏(步骤序列,逐步校验)(v12)
|
||||
nodeagent macro validate <file.json> | macro init [文件]
|
||||
nodeagent events [--kind file|process|net] [--path <路径>] [--pattern G] [--seconds 15]
|
||||
订阅事件并实时接收推送(无 --kind 则列出订阅)(v12)
|
||||
nodeagent 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]
|
||||
被控端自己拉更新(只需它能上网,不要求与你可达)(v19)
|
||||
nodeagent deploy <agent.mjs> [--path <远端路径>] [--check] [--force]
|
||||
一键升级被控端 (v11/v18):比指纹 → 备份 → 上传 → 重启
|
||||
→ 轮询校验;--check 只比对不部署
|
||||
nodeagent group [add <组名> <设备...> | remove <组名>] 设备分组管理 (v12)
|
||||
nodeagent fanout <能力名> [--nodes a,b|@组名] [--args JSON]
|
||||
多设备并发调用并汇总 (v11)
|
||||
nodeagent daemon [start|stop|status] 常驻连接池(批量操作提速 5~10 倍)(v10)
|
||||
nodeagent list 列出被控端可用能力
|
||||
nodeagent invoke <capability> [--args '<json>'] 通用调用
|
||||
|
||||
图形操作 (v2,输入控制需被控端开启 allow_input):
|
||||
nodeagent screen 显示器信息 (screen.info)
|
||||
nodeagent screenshot [--out f.jpg] [--scale 0.5] [--region x,y,w,h] [--format jpeg]
|
||||
截屏并保存 (screen.capture)
|
||||
nodeagent mouse move <x> <y> [--duration 300]
|
||||
nodeagent mouse click [<x> <y>] [--button left|right|middle]
|
||||
nodeagent mouse drag <x1> <y1> <x2> <y2> 鼠标拖拽(拖文件/框选)(v11)
|
||||
nodeagent mouse scroll <delta>
|
||||
nodeagent key type "<文本>" [--interval 10]
|
||||
nodeagent key press <键1> [键2] ... (组合键,如 ctrl c)
|
||||
nodeagent key press media_play_pause --pid 1234 (媒体键,v1.2:走 WM_APPCOMMAND,
|
||||
--pid 定向到该进程的全部顶层窗口;省略则广播。
|
||||
窗口被遮挡/最小化/游戏全屏时依然有效)
|
||||
|
||||
通用选项:
|
||||
--node <设备名> 本次命令临时指定目标设备(不改变 current)
|
||||
可放命令后(nodeagent info --node win_b)或前置(nodeagent --node=win_b info)
|
||||
--json 以原始 JSON 输出
|
||||
--config 显示当前配置路径
|
||||
`;
|
||||
@@ -0,0 +1,209 @@
|
||||
import { closeSync, existsSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync, writeSync } from 'node:fs';
|
||||
import fsSync from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path, { join } from 'node:path';
|
||||
import { parseArgs } from 'node:util';
|
||||
import {
|
||||
NodeAgentClient,
|
||||
loadMacroFile,
|
||||
runMacro,
|
||||
ClientError,
|
||||
loadConfig,
|
||||
saveConfig,
|
||||
configPath,
|
||||
toWsUrl,
|
||||
loadKeys,
|
||||
createKeys,
|
||||
keysFilePath,
|
||||
discoverOnce,
|
||||
resolveTarget,
|
||||
resolveNodeSelector,
|
||||
emptyConfig,
|
||||
type ClientConfig,
|
||||
type ResolvedTarget,
|
||||
type NodeProfile,
|
||||
} from '@nodeagent/client';
|
||||
import {
|
||||
CapabilityNames,
|
||||
matchPattern,
|
||||
DEFAULT_DISCOVERY_PORT,
|
||||
type CapabilityDescriptor,
|
||||
type InvokeResult,
|
||||
} from '@nodeagent/protocol';
|
||||
import { type Options } from './types.js';
|
||||
|
||||
// 命令行参数解析(从 index.ts 拆出)
|
||||
export function parseOptions(rest: string[]): { opts: Options; positionals: string[] } {
|
||||
const { values, positionals } = parseArgs({
|
||||
args: rest,
|
||||
options: {
|
||||
json: { type: 'boolean', default: false },
|
||||
insecure: { type: 'boolean', default: false },
|
||||
port: { type: 'string' },
|
||||
key: { type: 'string' },
|
||||
id: { type: 'string' },
|
||||
limit: { type: 'string' },
|
||||
args: { type: 'string' },
|
||||
config: { type: 'boolean', default: false },
|
||||
// v2 图形操作
|
||||
out: { type: 'string' },
|
||||
format: { type: 'string' },
|
||||
scale: { type: 'string' },
|
||||
region: { type: 'string' },
|
||||
button: { type: 'string' },
|
||||
duration: { type: 'string' },
|
||||
interval: { type: 'string' },
|
||||
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' },
|
||||
route: { type: 'string' },
|
||||
'auth-mode': { type: 'string' },
|
||||
since: { type: 'string' },
|
||||
type: { type: 'string' },
|
||||
'client-id': { type: 'string' },
|
||||
wait: { type: 'string' },
|
||||
'discovery-port': { type: 'string' },
|
||||
node: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
recursive: { type: 'boolean', default: false },
|
||||
pattern: { type: 'string' },
|
||||
'create-dirs': { type: 'boolean', default: false },
|
||||
'hub-token': { type: 'string' },
|
||||
'hub-node': { type: 'string' },
|
||||
// v7 受控重启
|
||||
delay: { type: 'string' },
|
||||
// v10 后台任务 / 剪贴板
|
||||
'timeout-ms': { type: 'string' },
|
||||
kill: { type: 'boolean', default: false },
|
||||
offset: { type: 'string' },
|
||||
set: { type: 'string' },
|
||||
'image-file': { type: 'string' },
|
||||
fps: { type: 'string' },
|
||||
nodes: { type: 'string' },
|
||||
path: { type: 'string' },
|
||||
kind: { type: 'string' },
|
||||
var: { type: 'string', multiple: true },
|
||||
mode: { type: 'string' },
|
||||
iface: { type: 'string' },
|
||||
ip: { type: 'string' },
|
||||
mask: { type: 'string' },
|
||||
gateway: { type: 'string' },
|
||||
dns: { type: 'string' },
|
||||
command: { type: 'string' },
|
||||
'confirm-within': { type: 'string' },
|
||||
'task-name': { type: 'string' },
|
||||
yes: { type: 'boolean', default: false },
|
||||
url: { type: 'string' },
|
||||
sha256: { type: 'string' },
|
||||
'dry-run': { type: 'boolean', default: false },
|
||||
psk: { type: 'boolean', default: false },
|
||||
'discover-secret': { type: 'string' },
|
||||
'forget-cert': { type: 'boolean', default: false },
|
||||
check: { type: 'boolean', default: false },
|
||||
force: { type: 'boolean', default: false },
|
||||
mute: { type: 'string' },
|
||||
volume: { type: 'string' },
|
||||
seconds: { type: 'string' },
|
||||
'interval-ms': { type: 'string' },
|
||||
},
|
||||
allowPositionals: true,
|
||||
strict: false,
|
||||
});
|
||||
return {
|
||||
opts: {
|
||||
json: Boolean(values['json']),
|
||||
insecure: Boolean(values['insecure']),
|
||||
port: values['port'] as string | undefined,
|
||||
key: values['key'] as string | undefined,
|
||||
id: values['id'] as string | undefined,
|
||||
limit: values['limit'] as string | undefined,
|
||||
args: values['args'] as string | undefined,
|
||||
out: values['out'] as string | undefined,
|
||||
format: values['format'] as string | undefined,
|
||||
scale: values['scale'] as string | undefined,
|
||||
region: values['region'] as string | undefined,
|
||||
button: values['button'] as string | undefined,
|
||||
duration: values['duration'] as string | undefined,
|
||||
interval: values['interval'] as string | undefined,
|
||||
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,
|
||||
authMode: values['auth-mode'] as string | undefined,
|
||||
since: values['since'] as string | undefined,
|
||||
type: values['type'] as string | undefined,
|
||||
clientId: values['client-id'] as string | undefined,
|
||||
wait: values['wait'] as string | undefined,
|
||||
discoveryPort: values['discovery-port'] as string | undefined,
|
||||
node: values['node'] as string | undefined,
|
||||
delay: values['delay'] as string | undefined,
|
||||
timeoutMs: values['timeout-ms'] as string | undefined,
|
||||
kill: Boolean(values['kill']),
|
||||
offset: values['offset'] as string | undefined,
|
||||
set: values['set'] as string | undefined,
|
||||
imageFile: values['image-file'] as string | undefined,
|
||||
fps: values['fps'] as string | undefined,
|
||||
nodes: values['nodes'] as string | undefined,
|
||||
path: values['path'] as string | undefined,
|
||||
kind: values['kind'] as string | undefined,
|
||||
vars: (values['var'] as string[] | undefined) ?? [],
|
||||
mode: values['mode'] as string | undefined,
|
||||
iface: values['iface'] as string | undefined,
|
||||
ip: values['ip'] as string | undefined,
|
||||
mask: values['mask'] as string | undefined,
|
||||
gateway: values['gateway'] as string | undefined,
|
||||
dns: values['dns'] as string | undefined,
|
||||
command: values['command'] as string | undefined,
|
||||
confirmWithin: values['confirm-within'] as string | undefined,
|
||||
taskName: values['task-name'] as string | undefined,
|
||||
yes: Boolean(values['yes']),
|
||||
url: values['url'] as string | undefined,
|
||||
sha256: values['sha256'] as string | undefined,
|
||||
dryRun: Boolean(values['dry-run']),
|
||||
psk: Boolean(values['psk']),
|
||||
discoverSecret: values['discover-secret'] as string | undefined,
|
||||
forgetCert: Boolean(values['forget-cert']),
|
||||
check: Boolean(values['check']),
|
||||
force: Boolean(values['force']),
|
||||
mute: values['mute'] as string | undefined,
|
||||
volume: values['volume'] as string | undefined,
|
||||
seconds: values['seconds'] as string | undefined,
|
||||
intervalMs: values['interval-ms'] as string | undefined,
|
||||
name: values['name'] as string | undefined,
|
||||
recursive: Boolean(values['recursive']),
|
||||
pattern: values['pattern'] as string | undefined,
|
||||
createDirs: Boolean(values['create-dirs']),
|
||||
hubToken: values['hub-token'] as string | undefined,
|
||||
hubNode: values['hub-node'] as string | undefined,
|
||||
},
|
||||
positionals,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// CLI 共享类型与常量(从 index.ts 拆出)
|
||||
/** 已解析的常用选项。 */
|
||||
export interface Options {
|
||||
json: boolean;
|
||||
insecure: boolean;
|
||||
port?: string;
|
||||
key?: string;
|
||||
id?: string;
|
||||
limit?: string;
|
||||
args?: string;
|
||||
// v2 图形操作
|
||||
out?: string;
|
||||
format?: string;
|
||||
scale?: string;
|
||||
region?: string;
|
||||
button?: string;
|
||||
duration?: string;
|
||||
interval?: string;
|
||||
/** 媒体键定向投递的目标进程 PID(v1.2 keyPress) */
|
||||
/** 通用选项:文本 / 标题 */
|
||||
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.0 快捷键扩展:投递路由 foreground|post */
|
||||
route?: string;
|
||||
pid?: string;
|
||||
/** v3:认证模式 psk | ed25519 */
|
||||
authMode?: string;
|
||||
/** v3+ 审计查询 */
|
||||
type?: string;
|
||||
clientId?: string;
|
||||
/** v4 发现 */
|
||||
wait?: string;
|
||||
discoveryPort?: string;
|
||||
/** v5 多设备 */
|
||||
node?: string;
|
||||
name?: string;
|
||||
/** v5 文件 */
|
||||
recursive?: boolean;
|
||||
createDirs?: boolean;
|
||||
/** v6 Hub */
|
||||
hubToken?: string;
|
||||
hubNode?: string;
|
||||
/** v7 受控重启:延时毫秒 */
|
||||
delay?: string;
|
||||
/** v10 后台任务 / 剪贴板 */
|
||||
timeoutMs?: string;
|
||||
kill?: boolean;
|
||||
set?: string;
|
||||
/** v11 剪贴板图片 */
|
||||
imageFile?: string;
|
||||
/** v11 录屏 */
|
||||
fps?: string;
|
||||
/** v11 多设备并发 */
|
||||
nodes?: string;
|
||||
/** v11 部署目标路径 */
|
||||
/** v12 事件订阅 */
|
||||
kind?: string;
|
||||
/** v12 宏变量 */
|
||||
vars?: string[];
|
||||
/** v22 发现广播认证密钥 */
|
||||
discoverSecret?: string;
|
||||
/** v22 每客户端 PSK */
|
||||
psk?: boolean;
|
||||
/** v21 证书指纹 */
|
||||
forgetCert?: boolean;
|
||||
/** v19 拉取式自更新 */
|
||||
url?: string;
|
||||
sha256?: string;
|
||||
dryRun?: boolean;
|
||||
/** v18 部署校验 */
|
||||
check?: boolean;
|
||||
force?: boolean;
|
||||
/** v17 音频 */
|
||||
mute?: string;
|
||||
volume?: string;
|
||||
/** v16 网络变更 */
|
||||
mode?: string;
|
||||
iface?: string;
|
||||
ip?: string;
|
||||
mask?: string;
|
||||
gateway?: string;
|
||||
dns?: string;
|
||||
command?: string;
|
||||
confirmWithin?: string;
|
||||
taskName?: string;
|
||||
yes?: boolean;
|
||||
seconds?: string;
|
||||
intervalMs?: string;
|
||||
}
|
||||
|
||||
/** 文件分块传输的块大小(fs.read/write 用,v5) */
|
||||
export const CHUNK_BYTES = 1024 * 1024;
|
||||
@@ -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,35 @@
|
||||
{
|
||||
"name": "nodeagent-console",
|
||||
"private": true,
|
||||
"version": "2.0.2",
|
||||
"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",
|
||||
"prepare:node": "node ../../scripts/prepare-console.mjs",
|
||||
"verify:bundle": "node scripts/verify-bundle.mjs",
|
||||
"bundle": "pnpm sidecar:build && pnpm prepare:node && tauri build --config src-tauri/tauri.bundle.gen.json && pnpm verify:bundle"
|
||||
},
|
||||
"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,133 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 验证「控制台的打包产物能否自举」—— 即打包内的 Node 运行时能否跑起打包内的 sidecar。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/verify-bundle.mjs # 自动定位打包产物(macOS)
|
||||
* node scripts/verify-bundle.mjs --app <x.app>
|
||||
* node scripts/verify-bundle.mjs --runtime # 只用 prepare 出的 runtime + sidecar.dist
|
||||
*
|
||||
* 为什么需要它
|
||||
* ----------
|
||||
* `pnpm bundle` 成功、`tauri build` 报告 Finished,**不代表产物能跑**:
|
||||
* 2026-10-11 实测踩到 —— macOS 上 `strip` 会使 Mach-O 代码签名失效,
|
||||
* 内核对签名无效的可执行文件**直接 SIGKILL(exit 137)**,没有任何有用报错。
|
||||
* 这类问题只存在于「打包产物」中:源码与单测都正常,只有真跑一遍产物才会暴露。
|
||||
* 本脚本把那次人工排查固化成一条命令。
|
||||
*
|
||||
* 它同时覆盖三件事(缺一不可):
|
||||
* ① 打包内的 node 真能执行(签名 / strip / 可执行位都对)
|
||||
* ② 打包内的 sidecar.mjs 真能被它加载(bundle 完整、无缺依赖)
|
||||
* ③ 两者之间的行分隔 JSON-RPC 通道真的通
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { existsSync, readdirSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url)); // apps/console/scripts
|
||||
const consoleDir = resolve(here, '..'); // apps/console
|
||||
const isWin = process.platform === 'win32';
|
||||
const NODE_NAME = isWin ? 'node.exe' : 'node';
|
||||
|
||||
const arg = (n) => {
|
||||
const i = process.argv.indexOf(`--${n}`);
|
||||
return i >= 0 && process.argv[i + 1] && !process.argv[i + 1].startsWith('--')
|
||||
? process.argv[i + 1]
|
||||
: undefined;
|
||||
};
|
||||
|
||||
/** 定位打包产物(macOS:bundle/macos/*.app;Windows:无 .app,回落到 runtime)。 */
|
||||
function findApp() {
|
||||
const explicit = arg('app');
|
||||
if (explicit) return resolve(explicit);
|
||||
const dir = join(consoleDir, 'src-tauri/target/release/bundle/macos');
|
||||
if (existsSync(dir)) {
|
||||
const app = readdirSync(dir).find((f) => f.endsWith('.app'));
|
||||
if (app) return join(dir, app);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let nodeBin;
|
||||
let sidecar;
|
||||
let source;
|
||||
|
||||
const app = process.argv.includes('--runtime') ? undefined : findApp();
|
||||
if (app) {
|
||||
// ⚠️ Tauri 的 resource 映射 `"runtime/node": "node"` 使产物落在 Contents/Resources/node
|
||||
// —— 必须与 src-tauri/src/sidecar.rs 的 resolve_node/resolve_sidecar 保持一致。
|
||||
const res = join(app, 'Contents/Resources');
|
||||
nodeBin = join(res, NODE_NAME);
|
||||
sidecar = join(res, 'sidecar.mjs');
|
||||
source = `打包产物 ${app}`;
|
||||
} else {
|
||||
nodeBin = join(consoleDir, `src-tauri/runtime/${NODE_NAME}`);
|
||||
sidecar = join(consoleDir, 'sidecar/dist/sidecar.mjs');
|
||||
source = '准备产物(runtime + sidecar/dist)';
|
||||
}
|
||||
|
||||
console.log(`=== 验证控制台产物自举 ===`);
|
||||
console.log(` 来源:${source}`);
|
||||
|
||||
for (const [label, p] of [
|
||||
['Node 运行时', nodeBin],
|
||||
['sidecar', sidecar],
|
||||
]) {
|
||||
if (!existsSync(p)) {
|
||||
console.error(`✗ 缺少 ${label}:${p}`);
|
||||
console.error(' 提示:先跑 `pnpm bundle`(含 prepare:node),或 `pnpm prepare:node`。');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(` ✓ ${label}:${p}`);
|
||||
}
|
||||
|
||||
// ---------- ①②③ 合一的实测:真 spawn,真握手 ----------
|
||||
const child = spawn(nodeBin, [sidecar], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
let out = '';
|
||||
let err = '';
|
||||
child.stdout.on('data', (d) => (out += d));
|
||||
child.stderr.on('data', (d) => (err += d));
|
||||
|
||||
// 用一个只读、无副作用的方法:未连接时应回 {"connected":false}
|
||||
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'state', params: {} }) + '\n');
|
||||
|
||||
const done = (code) => {
|
||||
try {
|
||||
child.kill();
|
||||
} catch {
|
||||
/* 已退出 */
|
||||
}
|
||||
process.exit(code);
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
let parsed = null;
|
||||
const line = out.split('\n').find((l) => l.trim().startsWith('{'));
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
/* 下面统一报错 */
|
||||
}
|
||||
|
||||
if (err.trim()) {
|
||||
console.log(' sidecar stderr:' + err.trim().split('\n').slice(0, 3).join(' | '));
|
||||
}
|
||||
|
||||
if (parsed && parsed.id === 1 && parsed.result && 'connected' in parsed.result) {
|
||||
console.log(
|
||||
`\n✓ 自举成功:打包内 node(${process.version} 级)真跑起打包内 sidecar` +
|
||||
`(connected=${parsed.result.connected})`,
|
||||
);
|
||||
done(0);
|
||||
} else {
|
||||
console.error('\n✗ 未收到预期的 JSON-RPC 响应 —— 产物可能无法自举');
|
||||
console.error(' stdout:' + (out.trim().slice(0, 300) || '(空)'));
|
||||
console.error(
|
||||
' 若 node 直接运行即被 SIGKILL(137):macOS 上 strip 破坏了代码签名,' +
|
||||
'需 adhoc 重签(见 scripts/prepare-console.mjs 的 ② 段注释)。',
|
||||
);
|
||||
done(1);
|
||||
}
|
||||
}, 4000);
|
||||
@@ -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,14 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
|
||||
# 随包分发的 Node 运行时(由 scripts/prepare-console.mjs 生成,~87MB;不入库)
|
||||
# 体积原因 + 它是从本机 node 复制/剥离而来,不是源代码。
|
||||
/runtime/
|
||||
|
||||
# 构建期注入片段(生成物,引用 runtime/ 下的 node;不入库,见 prepare-console.mjs 注释)
|
||||
/tauri.bundle.gen.json
|
||||
@@ -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.2",
|
||||
"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;
|
||||
}
|
||||